diff --git a/AGENTS.md b/AGENTS.md index 28ce35b..933a868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,8 @@ templates → Record // data, versioned with the binar Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` repos, not from a public template site. The count is how many of them the stack applies to. +**That estate is the first source, not the only one** ([ADR-0009](docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md)). A stack may also ship on the word of the tool that writes the files — its scaffold's own `.gitignore`, or output it documents — which is the precedent `php@v2` and `laravel@v2` set by following the Laravel skeleton. What never qualifies is a public template site's guess. Either way the provenance comment above the template names the source, and the count below stays honest, `0` included. + | Stack | Fingerprint | Repos | | :--------- | :----------------------------------- | ----: | | `core` | always proposed | 27 | @@ -131,11 +133,14 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` | `dotenv` | `.env.example` / `.env` | 7 | | `php` | `composer.json` | 3 | | `go` | `go.mod` | 3 | +| `python` | `pyproject.toml` / `requirements.txt` / `setup.py`, not under `docs/` | 0 | | `turborepo` | `turbo.json` | 3 | | `tofu` | `*.tf` / `*.tofu` / lock file | 1 | | `laravel` | `artisan` | 1 | | `octane` | `config/octane.php` | 3 | +| `helm` | `Chart.yaml` | 0 | | `nuxt` | `nuxt.config.*` | 1 | +| `vite` | `vite.config.*` beside an `index.html` | 1 | | `tauri` | `src-tauri/` | 1 | | `storybook` | `.storybook/` | 1 | | `gradle` | `build.gradle*` / `settings.gradle*` | 1 | @@ -150,6 +155,10 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` Two stacks show **0**, on purpose. `rust` is fingerprinted on a *root* `Cargo.toml` only — the one Tauri app keeps its crate under `src-tauri/`, and that build output already belongs to the `tauri` stack, so matching it there would render a `/target` line pointing at nothing. `playwright` ships because one repo ignores its three output dirs, but that repo has no root config, so nothing in the estate currently triggers it; it is declared by hand until one does. +`python` shows **0** for `playwright`'s reason: `mc-network` ignores `__pycache__/` but keeps no Python manifest within reach of the fingerprint. It was contributed for three `kirch-ostendorf` Ansible repos outside the 28, which it does trigger, and an `audit` across both estates has it absorbing ten lines. + +Two more ship without estate evidence, on the word of the tool that writes the files. `helm` names the tarballs `helm dependency update` vendors into `charts/` — no repo in the estate holds a chart. `vite` carries the two lines the `create-vite` / `create-vue` scaffold adds on top of `node`, in its spelling, so a fresh scaffold is absorbed; the one Vite app in the estate does not ignore them yet. + ### One detector, and a `source` on every signal There is **one** fingerprint table and **one** `detect(dir)` pass. A `.vscode/` directory sits in the repo directory exactly like `package.json` does, so detecting them two different ways would be an invented distinction. @@ -174,14 +183,16 @@ Deliberate omissions, each for a reason worth keeping: - **`composer.lock` is in no template.** A library ignores it, an application commits it — a project decision, so it belongs in the free zone. - **The JVM stack is `gradle`, not `java`.** What a JVM repo ignores follows the build tool — Gradle writes `build/`, Maven `target/` — so a `java` stack would render lines pointing at nothing in half the repos. `*.jar` is left out because the Gradle wrapper jar is committed. A `maven` stack waits until a repo needs one. - **`dist` belongs to `node` alone**, not to `go`. goreleaser writes there too, but a line may live in only one stack (below), and `node` claims it. +- **`python` carries no `dist/` or `build/`** for the same reason: they are `node`'s and `gradle`'s. A pure Python repository that builds wheels keeps `dist/` in its free zone. `*.py[cod]` is left out too — CPython has written bytecode into `__pycache__/` since 3.2. +- **`helm` never names `charts/` itself**, only `**/charts/*.tgz`: a bare directory would hide the charts a chart repository keeps there, the same defect as a bare `.vscode`. `Chart.lock` is committed, like `.terraform.lock.hcl`, and a packaged chart's `*.tgz` is `node`'s line. - **`.terraform.lock.hcl` is not ignored** — it is meant to be committed. - **No `prisma` stack**, even though one repo ignores `src/generated/prisma/`. The path is configurable, so it is a project rule, and the free zone is exactly where it belongs. -**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for. +**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for — it ships only if the tool that writes the files vouches for it instead, and otherwise belongs in a free zone. **A bare `.idea` or `.vscode` beside a managed block is a defect, not a duplicate.** git does not descend into an ignored directory, so `.vscode` sitting in the free zone silently disables every `!.vscode/…` exception the block renders. `reconcile` reports it as `smothered` — a distinct, louder finding than the equivalent-spelling note, because the file is now doing something other than what it reads like. -**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. +**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. A fingerprint can narrow itself further: `skip` keeps `python` out of `docs/`, where Read the Docs, MkDocs and Sphinx keep build requirements in repos with no Python of their own, and `beside` counts `vite.config.*` only next to an `index.html`, so a library or CLI built with Vite — this repo included — is not offered lines that point at nothing. ### Sub-`.gitignore`s: three kinds, three verdicts diff --git a/CLAUDE.md b/CLAUDE.md index 28ce35b..933a868 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,8 @@ templates → Record // data, versioned with the binar Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` repos, not from a public template site. The count is how many of them the stack applies to. +**That estate is the first source, not the only one** ([ADR-0009](docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md)). A stack may also ship on the word of the tool that writes the files — its scaffold's own `.gitignore`, or output it documents — which is the precedent `php@v2` and `laravel@v2` set by following the Laravel skeleton. What never qualifies is a public template site's guess. Either way the provenance comment above the template names the source, and the count below stays honest, `0` included. + | Stack | Fingerprint | Repos | | :--------- | :----------------------------------- | ----: | | `core` | always proposed | 27 | @@ -131,11 +133,14 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` | `dotenv` | `.env.example` / `.env` | 7 | | `php` | `composer.json` | 3 | | `go` | `go.mod` | 3 | +| `python` | `pyproject.toml` / `requirements.txt` / `setup.py`, not under `docs/` | 0 | | `turborepo` | `turbo.json` | 3 | | `tofu` | `*.tf` / `*.tofu` / lock file | 1 | | `laravel` | `artisan` | 1 | | `octane` | `config/octane.php` | 3 | +| `helm` | `Chart.yaml` | 0 | | `nuxt` | `nuxt.config.*` | 1 | +| `vite` | `vite.config.*` beside an `index.html` | 1 | | `tauri` | `src-tauri/` | 1 | | `storybook` | `.storybook/` | 1 | | `gradle` | `build.gradle*` / `settings.gradle*` | 1 | @@ -150,6 +155,10 @@ Every template was derived from the 28 locally cloned `kirchDev` / `TitusKirch` Two stacks show **0**, on purpose. `rust` is fingerprinted on a *root* `Cargo.toml` only — the one Tauri app keeps its crate under `src-tauri/`, and that build output already belongs to the `tauri` stack, so matching it there would render a `/target` line pointing at nothing. `playwright` ships because one repo ignores its three output dirs, but that repo has no root config, so nothing in the estate currently triggers it; it is declared by hand until one does. +`python` shows **0** for `playwright`'s reason: `mc-network` ignores `__pycache__/` but keeps no Python manifest within reach of the fingerprint. It was contributed for three `kirch-ostendorf` Ansible repos outside the 28, which it does trigger, and an `audit` across both estates has it absorbing ten lines. + +Two more ship without estate evidence, on the word of the tool that writes the files. `helm` names the tarballs `helm dependency update` vendors into `charts/` — no repo in the estate holds a chart. `vite` carries the two lines the `create-vite` / `create-vue` scaffold adds on top of `node`, in its spelling, so a fresh scaffold is absorbed; the one Vite app in the estate does not ignore them yet. + ### One detector, and a `source` on every signal There is **one** fingerprint table and **one** `detect(dir)` pass. A `.vscode/` directory sits in the repo directory exactly like `package.json` does, so detecting them two different ways would be an invented distinction. @@ -174,14 +183,16 @@ Deliberate omissions, each for a reason worth keeping: - **`composer.lock` is in no template.** A library ignores it, an application commits it — a project decision, so it belongs in the free zone. - **The JVM stack is `gradle`, not `java`.** What a JVM repo ignores follows the build tool — Gradle writes `build/`, Maven `target/` — so a `java` stack would render lines pointing at nothing in half the repos. `*.jar` is left out because the Gradle wrapper jar is committed. A `maven` stack waits until a repo needs one. - **`dist` belongs to `node` alone**, not to `go`. goreleaser writes there too, but a line may live in only one stack (below), and `node` claims it. +- **`python` carries no `dist/` or `build/`** for the same reason: they are `node`'s and `gradle`'s. A pure Python repository that builds wheels keeps `dist/` in its free zone. `*.py[cod]` is left out too — CPython has written bytecode into `__pycache__/` since 3.2. +- **`helm` never names `charts/` itself**, only `**/charts/*.tgz`: a bare directory would hide the charts a chart repository keeps there, the same defect as a bare `.vscode`. `Chart.lock` is committed, like `.terraform.lock.hcl`, and a packaged chart's `*.tgz` is `node`'s line. - **`.terraform.lock.hcl` is not ignored** — it is meant to be committed. - **No `prisma` stack**, even though one repo ignores `src/generated/prisma/`. The path is configurable, so it is a project rule, and the free zone is exactly where it belongs. -**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for. +**Coverage is measured, not assumed.** `gitignore-sync audit ` runs the real `reconcile` over every named repo with every stack declared, and reports what is left — per file and aggregated, `--json` included. It exists as a shipped command rather than a scratch script precisely so a later agent or skill measures with the tool's own semantics instead of reimplementing them. The numbers that justified the current set: across the 27 files, **86 patterns** survive outside the five files carrying a generated toptal block, and 33 of those are the bare `.idea` / `.vscode` spellings below. Almost everything else is genuinely project-specific — which is the free zone working as intended. Re-run it before adding a stack; a stack that moves the number by one line is a stack the estate did not ask for — it ships only if the tool that writes the files vouches for it instead, and otherwise belongs in a free zone. **A bare `.idea` or `.vscode` beside a managed block is a defect, not a duplicate.** git does not descend into an ignored directory, so `.vscode` sitting in the free zone silently disables every `!.vscode/…` exception the block renders. `reconcile` reports it as `smothered` — a distinct, louder finding than the equivalent-spelling note, because the file is now doing something other than what it reads like. -**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. +**Pattern fingerprints search one level below the root** (`PATTERN_DEPTH` in `src/detect.ts`), because a Terraform repo keeps its stacks in `tofu/`. `examples/` is never descended into: a `.tf` shown as documentation is not a workspace that produces state, and treating it as one makes every provider repo look like an infrastructure repo. A fingerprint can narrow itself further: `skip` keeps `python` out of `docs/`, where Read the Docs, MkDocs and Sphinx keep build requirements in repos with no Python of their own, and `beside` counts `vite.config.*` only next to an `index.html`, so a library or CLI built with Vite — this repo included — is not offered lines that point at nothing. ### Sub-`.gitignore`s: three kinds, three verdicts diff --git a/README.md b/README.md index 01fa003..98875a3 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Everything below the closing `# endregion` is the **free zone**: yours, preserve ## 🧩 Stacks -Twenty-one curated blocks, each derived from what the estate actually ignores rather than from a public template site. +Curated blocks, each derived from what real repositories actually ignore — or from what the tool writing the files puts in its own scaffold — rather than from a public template site. | Stack | What it covers | Proposed by `init` when | | :--------- | :----------------------------------- | :---------------------------- | @@ -113,14 +113,19 @@ Twenty-one curated blocks, each derived from what the estate actually ignores ra | `dotenv` | `.env*`, minus the committed example | `.env.example` or `.env` | | `php` | `/vendor`, PHPUnit caches | `composer.json` | | `laravel` | build output, storage keys, SSR | `artisan` | +| `octane` | the server binaries `octane:install` adds | `config/octane.php` | | `go` | test and build droppings | `go.mod` | +| `python` | `__pycache__/`, `.venv/`, tool caches | `pyproject.toml`, `requirements.txt`, `setup.py` | | `tofu` | state, tfvars, local overrides | `*.tf` / `*.tofu` | +| `helm` | vendored chart tarballs | `Chart.yaml` | | `nuxt` | `.nuxt`, `.output`, `.nitro` | `nuxt.config.*` | +| `vite` | `dist-ssr`, `*.local` | `vite.config.*` beside `index.html` | | `tauri` | `src-tauri/target`, generated schemas| `src-tauri/` | | `rust` | `/target` | a root `Cargo.toml` | | `turborepo`| `.turbo` | `turbo.json` | | `playwright` | test-results, reports | `playwright.config.*` | | `storybook`| `storybook-static` | `.storybook/` | +| `gradle` | `.gradle/`, `.kotlin/`, `build/` | `build.gradle*` / `settings.gradle*` | | `vscode` | `.vscode/*` + the shared files | you have a `.vscode/` | | `intellij` | `.idea/*` | you have a `.idea/` | | `vim` | swap and session files | your `$EDITOR` is vim | diff --git a/docs/1.concepts/3.detection.md b/docs/1.concepts/3.detection.md index fa8b3fb..dfba5af 100644 --- a/docs/1.concepts/3.detection.md +++ b/docs/1.concepts/3.detection.md @@ -38,6 +38,12 @@ repository keeps its stacks in `tofu/` rather than at the top. `examples/`, `fixtures/`, `testdata/` and dependency directories are never descended into: a `.tf` file shown as documentation is not a workspace. +**A fingerprint can narrow itself further.** `python` does not look inside +`docs/`, where Read the Docs, MkDocs and Sphinx keep their build requirements in +repositories with no Python of their own. `vite` counts a `vite.config.*` only +beside an `index.html` — the entry every Vite app has and a library or CLI built +with Vite does not, so those are not offered lines that point at nothing. + ## Why it's built this way **Adding a file must not rewrite someone's `.gitignore`.** If `sync` detected, diff --git a/docs/1.concepts/4.templates.md b/docs/1.concepts/4.templates.md index 98134b8..e996460 100644 --- a/docs/1.concepts/4.templates.md +++ b/docs/1.concepts/4.templates.md @@ -52,10 +52,15 @@ than deleting something it should not. catch-all. Anything tool-specific belongs in a stack someone can drop, which is why agent working files live in `agents` and `node_modules` in `node`. -**The stacks come from real repositories, not a template site.** Each one was +**The stacks come from real repositories, not a template site.** Most were derived from what a set of locally cloned kirchDev and TitusKirch repositories -actually ignore, and a stack ships only when that estate asks for it. Some -choices that follow from this look like omissions and are not: +actually ignore. A stack may also ship on the word of the tool that writes the +files — its scaffold's own `.gitignore` or the output it documents — which is +how `laravel` follows the Laravel skeleton, `vite` the `create-vite` scaffold and +`helm` what `helm dependency update` vendors +([ADR-0009](/adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files)). +A public template site's guess never qualifies. Some choices that follow from +this look like omissions and are not: - `composer.lock` is in no template — a library ignores it, an application commits it, so it is a project decision and belongs in the free zone. @@ -64,9 +69,15 @@ choices that follow from this look like omissions and are not: build tool, and `*.jar` is left out because the Gradle wrapper jar is committed. - `octane` uses the spellings `octane:install` appends (`frankenphp`, not `/frankenphp`), so re-running the installer adds nothing. -- `dist` belongs to `node` alone, because a line may live in only one stack. +- `dist` belongs to `node` alone, because a line may live in only one stack. The + same rule keeps `dist/` and `build/` out of `python`: a pure Python repository + that builds wheels keeps `dist/` in its free zone. +- `helm` names only `**/charts/*.tgz`, never `charts/` itself — a bare directory + would hide the charts a chart repository keeps there. `Chart.lock` is + committed. **Coverage is measured with the tool itself.** `gitignore-sync audit ` declares every stack against each named repository and reports what would still -be left over. A stack that would move that number by one line is a stack nobody -asked for — see [Add a stack](/guides/add-a-stack). +be left over. A stack that would move that number by one line needs the writing +tool's own word instead, or it is a project rule — see +[Add a stack](/guides/add-a-stack). diff --git a/docs/2.guides/3.add-a-stack.md b/docs/2.guides/3.add-a-stack.md index 6b7c706..6e3a02c 100644 --- a/docs/2.guides/3.add-a-stack.md +++ b/docs/2.guides/3.add-a-stack.md @@ -4,8 +4,10 @@ description: Add a new curated stack to gitignore-sync — measured against real icon: lucide:circle-plus --- -A new stack is justified by measurement, not by a template site. Do this when an -`audit` across real repositories shows the same leftover lines recurring. +A new stack is justified by evidence, not by a template site: an `audit` across +real repositories showing the same leftover lines recurring, or the tool that +writes the files listing them itself — its scaffold's own `.gitignore`, or output +it documents. ## Steps @@ -16,14 +18,17 @@ A new stack is justified by measurement, not by a template site. Do this when an gitignore-sync audit ../repo-a ../repo-b --min-files=2 ``` - If the stack would remove one line from one file, it is a project rule and - belongs in that repository's free zone. + If the stack would remove one line from one file, it needs the second kind + of evidence — the writing tool's own scaffold or documentation, in its + spelling — or it is a project rule and belongs in that repository's free + zone. 2. **Add the template** — a new key in the registry in `src/templates/index.ts`, starting at `version: 1`. Its position decides where it lands in the `# stacks:` line: repository stacks before the person-level ones. Write a - comment above it saying where the lines came from and what was deliberately - left out. + comment above it saying where the lines came from — the repositories + measured, or the scaffold or documentation followed — and what was + deliberately left out. 3. **Check the template rules** — no line that another stack already carries, no bare `.vscode/` or `.idea/`, `!` exceptions only for files repositories really @@ -31,8 +36,10 @@ A new stack is justified by measurement, not by a template site. Do this when an 4. **Add the fingerprint** — an entry in `src/detect.ts` with its `source`: `repo` for a committed marker, `machine` for something that describes one - person. A stack with no reliable fingerprint can still ship; it is then - declared by hand. + person. A marker that also turns up where the stack does not apply can be + narrowed with `skip` (directories its patterns never enter) or `beside` (a + file that must sit next to the match). A stack with no reliable fingerprint + can still ship; it is then declared by hand. 5. **Pin the behaviour** — add a fixture pair `tests/fixtures/.in` / `.out`. `tests/fixtures.test.ts` picks it up and asserts idempotency on @@ -46,14 +53,16 @@ A new stack is justified by measurement, not by a template site. Do this when an ``` 7. **Update the stack tables** in the README and in `CLAUDE.md` (then copy it over - `AGENTS.md`), and run `pnpm check`. + `AGENTS.md`) — with an honest repository count, `0` included, and the source + named where it is not the estate — and run `pnpm check`. ## Checklist -- [ ] `audit` shows the stack absorbing lines across more than one repository +- [ ] `audit` shows the stack absorbing lines across more than one repository, or the writing tool's own scaffold or documentation lists them - [ ] The template is a new registry key at `version: 1`, with a provenance comment - [ ] `tests/templates.test.ts` passes: no line shared with another stack - [ ] The fingerprint has the right `source`, or the stack is documented as hand-declared - [ ] A fixture pair covers the stack - [ ] `tests/templates.lock.json` contains the new version and is formatted +- [ ] The README and `CLAUDE.md` / `AGENTS.md` stack tables list it - [ ] `pnpm check` is green diff --git a/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md b/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md new file mode 100644 index 0000000..303b4da --- /dev/null +++ b/docs/99.adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files.md @@ -0,0 +1,66 @@ +--- +title: Accept stacks on the word of the tool that writes the files +description: A stack may ship either because an audit across real repositories shows its lines recurring, or because the tool that writes the files lists them in its own scaffold or documentation; a public template site never qualifies. +status: accepted +date: 2026-10-15 +--- + +# ADR-0009 — Accept stacks on the word of the tool that writes the files + +## Context + +The first stacks were derived from the locally cloned kirchDev and TitusKirch +repositories, and a stack was meant to ship only when an `audit` across that +estate showed it absorbing lines. The rule kept public template sites out: +their blocks are guesses about what some project might produce, and copying +them is what makes a fetched `.gitignore` long. + +The rule had already bent once. `php@v2` and `laravel@v2` followed the Laravel +skeleton's own `.gitignore`, so that a fresh `laravel new` was absorbed rather +than reported as equivalent spellings, before any repository in the estate +carried those lines. + +A contribution then proposed `python`, `helm` and `vite`. Measured across the +estate, `python` absorbed ten lines, while `helm` and `vite` absorbed none: no +repository held a Helm chart, and the one Vite app did not ignore the +scaffold's lines yet. Under the original wording both would have been refused, +although each names exactly what its tool writes — the tarballs +`helm dependency update` vendors into `charts/`, and the two lines the +`create-vite` scaffold adds on top of `node`. + +## Decision + +- A stack ships on one of two kinds of evidence: an `audit` across real + repositories showing its lines recurring, or the tool that writes the files + listing them itself — its scaffold's own `.gitignore`, or output it + documents. +- A public template site's block never counts as evidence of either kind. +- The provenance comment above a template names its source: the repositories + measured, or the scaffold or documentation followed. Lines from a scaffold + keep the scaffold's spelling. +- The stack table in `CLAUDE.md` keeps an honest repository count, `0` + included, and says where a stack's evidence comes from when it is not the + estate. + +## Consequences + +- A repository created from a scaffold is absorbed on its first `sync`, before + the estate has grown a copy of it. +- Stacks can ship with a count of `0`, so the table alone no longer says + whether the estate needs a stack; the provenance note does. +- A stack that follows a scaffold tracks that scaffold. When the scaffold + changes its lines, the stack takes a new version rather than an edit, as + [ADR-0005](/adr/0005-version-templates-append-only-behind-a-lock) requires. +- The template rules are unchanged: one line per stack, no bare editor + directories, measured `!` exceptions. + +## Alternatives considered + +- **Keep the estate as the only source.** Rejected: it refuses stacks whose + lines are known exactly, only because nobody in the estate has used the + tool yet, and it was already not the rule `laravel@v2` had followed. +- **Accept lines from public template sites.** Rejected: that is the + fetch-and-dump approach the tool exists to replace, and the blocks it + produces are what makes a generated `.gitignore` long. +- **Accept a stack on a contributor's say-so alone.** Rejected: the claim is + not checkable by a reviewer, while a scaffold or a measured repository is. diff --git a/docs/99.adr/index.md b/docs/99.adr/index.md index 8371019..3572985 100644 --- a/docs/99.adr/index.md +++ b/docs/99.adr/index.md @@ -21,3 +21,4 @@ to it. | [ADR-0006](/adr/0006-give-each-line-one-stack-and-keep-core-to-ds-store) | Give each line one stack, and keep core to .DS_Store | accepted | 2026-09-01 | | [ADR-0007](/adr/0007-classify-nested-gitignores-before-acting-on-them) | Classify nested .gitignore files before acting on them | accepted | 2026-09-01 | | [ADR-0008](/adr/0008-ship-the-ci-gate-as-a-composite-action) | Ship the CI gate as a composite action | accepted | 2026-09-01 | +| [ADR-0009](/adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files) | Accept stacks on the word of the tool that writes the files | accepted | 2026-10-15 | diff --git a/src/detect.ts b/src/detect.ts index 510e1c3..8108632 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -28,6 +28,10 @@ type Fingerprint = { files?: string[]; /** Or any entry matching one of these, searched to `PATTERN_DEPTH`. */ patterns?: RegExp[]; + /** A pattern match only counts with one of these in the same directory. */ + beside?: string[]; + /** Directories this stack's patterns never descend into, beyond `SKIP`. */ + skip?: string[]; /** Or the platform running the command. */ platform?: string; /** Or `$VISUAL` / `$EDITOR`. */ @@ -83,11 +87,35 @@ const fingerprints: Record = { // Published by `octane:install`, whichever server the repo picks. octane: { source: 'repo', files: ['config/octane.php'] }, go: { source: 'repo', files: ['go.mod'] }, + // A pattern rather than a file list: an Ansible repo keeps its + // `requirements.txt` in `ansible/`, and a monorepo its backend's + // `pyproject.toml` one level down. `docs/` is not: Read the Docs, MkDocs and + // Sphinx keep their build requirements there, in repos with no Python of + // their own. + python: { + source: 'repo', + patterns: [/^(?:pyproject\.toml|requirements\.txt|setup\.py)$/], + skip: ['docs'] + }, tofu: { source: 'repo', patterns: [/\.tf$/, /\.tofu$/, /^\.terraform\.lock\.hcl$/] }, + // Finds a chart at the root or one level down. A chart repository keeping + // each chart in `charts//` sits below `PATTERN_DEPTH` and declares the + // stack by hand — deepening the search for it would start finding the charts + // other people's examples ship. + helm: { source: 'repo', patterns: [/^Chart\.yaml$/] }, nuxt: { source: 'repo', patterns: [/^nuxt\.config\.[cm]?[jt]s$/] }, + // One level down as well: a Go service keeps its SPA in `web/` or + // `frontend/`, which is where the config sits. Only beside an `index.html`, + // the entry every Vite app has and a library or CLI built with Vite does + // not — there `dist-ssr` and `*.local` would point at nothing. + vite: { + source: 'repo', + patterns: [/^vite\.config\.[cm]?[jt]s$/], + beside: ['index.html'] + }, tauri: { source: 'repo', files: ['src-tauri'] }, // A committed `.env.example` is the marker, not `.env` — the latter is the // very file the stack exists to keep out, so it is often absent on a fresh @@ -106,25 +134,27 @@ export const BASE_STACK = 'core'; function matchesPatterns( dir: string, - patterns: RegExp[], + print: Fingerprint, depth: number ): boolean { + const patterns = print.patterns ?? []; let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; } + const beside = + !print.beside || print.beside.some((file) => existsSync(join(dir, file))); for (const entry of entries) { - if (entry.isDirectory()) continue; + if (entry.isDirectory() || !beside) continue; if (patterns.some((pattern) => pattern.test(entry.name))) return true; } if (depth <= 1) return false; for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith('.')) continue; - if (SKIP.has(entry.name)) continue; - if (matchesPatterns(join(dir, entry.name), patterns, depth - 1)) - return true; + if (SKIP.has(entry.name) || print.skip?.includes(entry.name)) continue; + if (matchesPatterns(join(dir, entry.name), print, depth - 1)) return true; } return false; } @@ -137,8 +167,7 @@ function matches(dir: string, print: Fingerprint): boolean { const editor = process.env.VISUAL ?? process.env.EDITOR ?? ''; if (print.editor.test(editor)) return true; } - if (print.patterns) - return matchesPatterns(dir, print.patterns, PATTERN_DEPTH); + if (print.patterns) return matchesPatterns(dir, print, PATTERN_DEPTH); return false; } diff --git a/src/templates/index.ts b/src/templates/index.ts index 7d0207a..aef29d4 100644 --- a/src/templates/index.ts +++ b/src/templates/index.ts @@ -112,6 +112,33 @@ const registry: Record = { lines: ['*.exe', '*.test', '*.out', '*.prof'] } ], + // The caches Python tooling drops beside the source it just read, plus the + // virtualenv. Nothing packaging-related: a wheel is built into `dist/` and a + // test run reports into `coverage`, and both of those lines are `node`'s. + // + // `.venv/` is unanchored because an Ansible repo keeps its control-machine + // virtualenv in `ansible/.venv/` rather than at the root. `.coverage` and + // `htmlcov/` are coverage.py's data file and its HTML report — distinct + // spellings from `coverage`, so neither collides with the `node` block. + // + // `*.py[cod]` is deliberately absent: CPython has written bytecode into + // `__pycache__/` since 3.2 (PEP 3147), so the directory already covers where + // it lands and the glob would only reach leftovers from Python 2. + python: [ + { + stack: 'python', + version: 1, + lines: [ + '__pycache__/', + '.venv/', + '.pytest_cache/', + '.mypy_cache/', + '.ruff_cache/', + '.coverage', + 'htmlcov/' + ] + } + ], // State and local overrides. `.terraform.lock.hcl` is deliberately **not** // ignored — it is meant to be committed. `*.tfvars` usually carries // credentials, so it is ignored with the example carved back out. @@ -134,6 +161,28 @@ const registry: Record = { ] } ], + // `helm dependency update` vendors a chart's dependencies as tarballs into + // its `charts/` directory. `Chart.lock` records what it resolved and is + // meant to be committed, like `.terraform.lock.hcl` above. + // + // Only the tarballs are named, never the directory: git does not descend + // into an ignored directory, so a bare `charts/` would hide the charts + // themselves — the same defect a bare `.vscode` causes. + // + // `**/charts/` rather than `charts/` because a chart repository keeps each + // chart in `charts//`, one level deeper than a repo shipping a single + // chart at its root. + // + // A chart built with `helm package` lands as `-.tgz` in the + // working directory, and that spelling is `node@v1`'s (`npm pack` writes one + // too). A line lives in one stack, so it stays there. + helm: [ + { + stack: 'helm', + version: 1, + lines: ['**/charts/*.tgz'] + } + ], // `composer.lock` is not here on purpose: a library ignores it, an // application commits it. That is a project decision, so it belongs in the // free zone rather than in a block the tool re-renders. @@ -297,6 +346,22 @@ const registry: Record = { lines: ['.nuxt', '.output', '.nitro', '.data'] } ], + // The two lines a `create-vue` scaffold carries that `node` does not already + // have, in the scaffold's own spelling, so a fresh one is absorbed rather + // than reported as equivalent spellings. + // + // Named after the build tool and not Vue: what a repo ignores follows + // whatever writes the files, the same reason the JVM stack is `gradle` and + // not `java`. A Vue repo on another bundler has neither line, and a Vite + // library that never builds for SSR carries them inert — which is still + // cheaper than every app repeating them by hand in the free zone. + vite: [ + { + stack: 'vite', + version: 1, + lines: ['dist-ssr', '*.local'] + } + ], tauri: [ { stack: 'tauri', diff --git a/tests/detect.test.ts b/tests/detect.test.ts index b4c0907..aeb0ef7 100644 --- a/tests/detect.test.ts +++ b/tests/detect.test.ts @@ -50,6 +50,41 @@ describe('detect', () => { expect(names(dir)).toContain('gradle'); }); + it('fingerprints python from a requirements.txt one level down', () => { + const dir = fresh(); + mkdirSync(join(dir, 'ansible')); + writeFileSync(join(dir, 'ansible', 'requirements.txt'), ''); + expect(names(dir)).toContain('python'); + }); + + it('does not take a docs build requirements file for python', () => { + const dir = fresh(); + writeFileSync(join(dir, 'package.json'), '{}'); + mkdirSync(join(dir, 'docs')); + writeFileSync(join(dir, 'docs', 'requirements.txt'), ''); + expect(names(dir)).not.toContain('python'); + }); + + it('fingerprints helm from a chart at the root', () => { + const dir = fresh(); + writeFileSync(join(dir, 'Chart.yaml'), ''); + expect(names(dir)).toContain('helm'); + }); + + it('fingerprints vite from a config in a frontend subdirectory', () => { + const dir = fresh(); + mkdirSync(join(dir, 'web')); + writeFileSync(join(dir, 'web', 'vite.config.ts'), ''); + writeFileSync(join(dir, 'web', 'index.html'), ''); + expect(names(dir)).toContain('vite'); + }); + + it('does not take a library or CLI built with vite for a vite app', () => { + const dir = fresh(); + writeFileSync(join(dir, 'vite.config.ts'), ''); + expect(names(dir)).not.toContain('vite'); + }); + it('fingerprints dotenv from a committed .env.example', () => { const dir = fresh(); writeFileSync(join(dir, '.env.example'), ''); diff --git a/tests/fixtures/helm.in b/tests/fixtures/helm.in new file mode 100644 index 0000000..6d1ee54 --- /dev/null +++ b/tests/fixtures/helm.in @@ -0,0 +1,11 @@ +# region gitignore-sync +# stacks: core, helm +# ───────────────────────────────────────── + +# endregion + +# Vendored chart dependencies +**/charts/*.tgz + +# Packaged charts +*.tgz diff --git a/tests/fixtures/helm.out b/tests/fixtures/helm.out new file mode 100644 index 0000000..596390b --- /dev/null +++ b/tests/fixtures/helm.out @@ -0,0 +1,16 @@ +# region gitignore-sync +# stacks: core, helm +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region helm@v1 +**/charts/*.tgz +# endregion + +# endregion + +# Packaged charts +*.tgz diff --git a/tests/fixtures/python.in b/tests/fixtures/python.in new file mode 100644 index 0000000..27216bf --- /dev/null +++ b/tests/fixtures/python.in @@ -0,0 +1,17 @@ +# region gitignore-sync +# stacks: core, python +# ───────────────────────────────────────── + +# endregion + +# Python +__pycache__/ +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Ansible installs its collections into the control machine's venv +ansible/collections/ diff --git a/tests/fixtures/python.out b/tests/fixtures/python.out new file mode 100644 index 0000000..635c7cc --- /dev/null +++ b/tests/fixtures/python.out @@ -0,0 +1,22 @@ +# region gitignore-sync +# stacks: core, python +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region python@v1 +__pycache__/ +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +# endregion + +# endregion + +# Ansible installs its collections into the control machine's venv +ansible/collections/ diff --git a/tests/fixtures/vite.in b/tests/fixtures/vite.in new file mode 100644 index 0000000..0c0f4ab --- /dev/null +++ b/tests/fixtures/vite.in @@ -0,0 +1,21 @@ +# region gitignore-sync +# stacks: core, node, vite +# ───────────────────────────────────────── + +# endregion + +# Logs +*.log + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Cypress artefacts +/cypress/videos/ +/cypress/screenshots/ + +*.tsbuildinfo diff --git a/tests/fixtures/vite.out b/tests/fixtures/vite.out new file mode 100644 index 0000000..3bcb70c --- /dev/null +++ b/tests/fixtures/vite.out @@ -0,0 +1,30 @@ +# region gitignore-sync +# stacks: core, node, vite +# ───────────────────────────────────────── + +# region core@v1 +.DS_Store +# endregion + +# region node@v1 +node_modules +dist +coverage +/logs +*.log +*.tsbuildinfo +.eslintcache +.npm +*.tgz +# endregion + +# region vite@v1 +dist-ssr +*.local +# endregion + +# endregion + +# Cypress artefacts +/cypress/videos/ +/cypress/screenshots/ diff --git a/tests/templates.lock.json b/tests/templates.lock.json index 94f397f..a4366ac 100644 --- a/tests/templates.lock.json +++ b/tests/templates.lock.json @@ -30,6 +30,15 @@ "*.tgz" ], "go@v1": ["*.exe", "*.test", "*.out", "*.prof"], + "python@v1": [ + "__pycache__/", + ".venv/", + ".pytest_cache/", + ".mypy_cache/", + ".ruff_cache/", + ".coverage", + "htmlcov/" + ], "tofu@v1": [ ".terraform/", "*.tfstate", @@ -43,6 +52,7 @@ "crash.log", "crash.*.log" ], + "helm@v1": ["**/charts/*.tgz"], "php@v1": ["/vendor", "/.phpunit.cache", "/.phpunit.result.cache"], "php@v2": [ "/vendor", @@ -94,6 +104,7 @@ ], "storybook@v1": ["storybook-static"], "nuxt@v1": [".nuxt", ".output", ".nitro", ".data"], + "vite@v1": ["dist-ssr", "*.local"], "tauri@v1": ["src-tauri/target", "src-tauri/gen/schemas"], "dotenv@v1": [ ".env", diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 0dafb3b..9b4ba98 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -52,6 +52,15 @@ describe('templates', () => { } }); + // Same reason as the editor directories above: git does not descend into an + // ignored directory, so a bare `charts/` would hide every chart in a chart + // repository. Only the vendored tarballs may be named. + it('ignores the tarballs inside a chart directory, never the directory', () => { + const helm = currentTemplate('helm')?.lines ?? []; + expect(helm.length).toBeGreaterThan(0); + for (const line of helm) expect(line.endsWith('.tgz')).toBe(true); + }); + it('has no duplicate lines within a block', () => { for (const stack of knownStacks()) { const lines = currentTemplate(stack)?.lines ?? [];