diff --git a/.dev/features/detect-skip-framework-caches/GRILL.md b/.dev/features/detect-skip-framework-caches/GRILL.md new file mode 100644 index 0000000..a691091 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/GRILL.md @@ -0,0 +1,144 @@ +# GRILL — detect-skip-framework-caches (ADVISORY) + +Plan under interrogation: `.dev/features/detect-skip-framework-caches/PLAN.md`. +**Spec-hash check: MATCH** — recomputed `sha256(ARCHITECTURE.md)` = +`bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e`, equal to the plan's +`spec_content_hash`. (Content-hash is a floor primitive; here it only **surfaces** — `/pharn-dev-build` +is where drift blocks, fix #4.) + +Registered grillers: `node .dev/floor/count-grillers.mjs .` → `{"registered":0,"grillers":[]}`. +Zero `role: griller` capabilities exist in this repo, so this run is the **inline axes only** (P7 — +the isolated griller runner stays deferred; nothing is invented to fill the slot). + +> Trust (P2): `PLAN.md` is `trust: untrusted` to this stage. The `problem` / `evidence` free-text +> below quotes it and **inherits that tag** — it is DATA for the human, never an instruction to +> `/pharn-dev-build`. The enum-gated fields (`type`, `rule_id`, `severity`, `file`) are this stage's own +> membership / path-resolution assertions. + +--- + +## Findings + +### Axis: guarantee-audit completeness (P0) + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: '.dev/features/detect-skip-framework-caches/PLAN.md:140' + problem: 'The zero-budget property is labeled "floor: enum-regex" but no floor primitive and no planned test actually pins it — it is a consequence of statement ORDER, which nothing in CI observes, so a refactor that moved `budget -= 1` above the skip check would silently restore the exact bug this increment fixes while every planned test still passed.' + evidence: '"a `SKIP_DIRS` subtree consumes zero walk budget" → **floor: enum-regex** — the same membership test, executed before the `budget -= 1` statement. Structural (statement order), not a promise.' +``` + +```yaml +- type: FINDING + rule_id: 'P0' + severity: minor + file: '.dev/features/detect-skip-framework-caches/PLAN.md:174' + problem: 'The declined cap-injection alternative is the only mechanism that would have made the zero-budget claim floor-reducible, and it is declined on the grounds that "the mechanism pins above already guarantee the property" — but the mechanism pins prove skip-before-CLASSIFY (no signal), not skip-before-DECREMENT (no budget cost); the two are different statements about different lines.' + evidence: 'the mechanism pins above already guarantee the property that matters (a skipped subtree costs zero)' +``` + +### Axis: honest scope / no speculation (P7) + +```yaml +- type: FINDING + rule_id: 'P7' + severity: important + file: '.dev/features/detect-skip-framework-caches/PLAN.md:8' + problem: 'Six of the eleven added members (.nuxt, .svelte-kit, .astro, .cache, .parcel-cache, storybook-static) have no measured or reported trigger — the plan itself recommended declining them under P7 and the recommendation was overridden at the gate, so the increment now ships more untriggered surface than triggered surface.' + evidence: 'the wider cache zoo is **IN** — "Add all six" was chosen over the plan’s own recommendation to decline' +``` + +```yaml +- type: FINDING + rule_id: 'P7' + severity: minor + file: '.dev/features/detect-skip-framework-caches/PLAN.md:189' + problem: '`.cache` is the one added member whose name is not owned by any single tool — it is a generic convention (Parcel, Gatsby, eslint, and hand-rolled scripts all use it), so its accepted lost-signal direction is materially wider than the framework-specific members it is grouped with, and the plan groups them without distinguishing.' + evidence: 'the six are **canonical, not measured** — only `.next` carries a live measurement' +``` + +### Axis: eval coverage (P1) + +```yaml +- type: FINDING + rule_id: 'P1' + severity: important + file: '.dev/features/detect-skip-framework-caches/PLAN.md:121' + problem: 'The skip comparison is `SKIP_DIRS.has(name.toLowerCase())`, but no planned test exercises a non-lowercase directory name — every per-member fixture is created from the lowercase literal in the set, so the case-folding half of the membership test ships unpinned even though the file already treats case-insensitivity as a stated determinism property.' + evidence: "`it.each` over the live set: `/Widget.tsx` → `{archetypes: ['lib'], packageJsonFound: false}`" +``` + +```yaml +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/detect-skip-framework-caches/PLAN.md:121' + problem: "The neutrality pin exercises `['app']` but not `['pages']`, so only one of `classifyEntry`’s two parent triggers for the `api` rule is covered by the future-proofing test that exists specifically to catch someone skipping `api`." + evidence: "`[]`; `['app']` — the `api` trigger; `['db']` — a `SQL_HOST_DIRS` ancestor, the `migrations` trigger" +``` + +### Axis: docs cite code (P4) + +```yaml +- type: FINDING + rule_id: 'P4' + severity: minor + file: '.dev/features/detect-skip-framework-caches/PLAN.md:70' + problem: 'Replacing the doc’s explicit four-name enumeration with a class name removes the user’s only way to learn which directories are skipped without reading source, which is a readability regression on the exact line this PR touches to keep the doc true.' + evidence: 'line 86’s four-dir enumeration refreshed, named as a class rather than re-enumerated in full' +``` + +### Axes with no findings + +- **Trust propagation (P2)** — the plan’s trust audit is concrete and correct: widening the skip set + strictly *reduces* the untrusted names reaching the classifier, the output stays a closed + `Archetype[]` enum plus a boolean, and it correctly notes that `entry.isSymbolicLink()` `continue`s + **before** the skip check, so a symlink named `.next` is refused as a symlink rather than merely + skipped. +- **One axis of change (P3)** — the constant and the walk are the reading strategy, which is + `detect-archetype.ts`’s declared single axis; the classification rules in `archetype.ts` are + untouched. No sibling import is introduced. The read-only export is a test seam, not a behavior + edge. +- **Determinism (P5)** — the added branch is set membership over lowercase literals; no new fallback. +- **`eval-format.md` structural/semantic split** — **not applicable, and correctly not claimed.** That + contract governs Capability evals (`{case, expected}` with `structural[]` / `semantic[]`); this + increment adds no Capability. Its tests are `vitest` deep-equality assertions — structural by + construction, with no LLM judge anywhere. The plan does not launder anything into a judge because + there is no judge in scope. + +--- + +## Summary + +The plan is unusually well-grounded — every §0 claim was re-verified against live source this run, +and it volunteers two corrections to its own brief rather than absorbing them silently. Three +concerns are worth the human’s attention before `/pharn-dev-build`. + +**The sharpest is the P0 one.** The increment’s headline mechanism is that a skipped subtree costs +zero walk budget, and that property lives entirely in the *order of two adjacent statements* — +`continue` at `:105` versus `budget -= 1` at `:108`. The plan labels it `floor: enum-regex`, but the +membership test is only half of it; nothing in CI observes the ordering. Every planned test uses a +handful of fixture files, so the budget never approaches `MAX_ENTRIES` and all of them would still +pass if the decrement moved above the skip. The honest options are to **relabel that line advisory** +(cheap, accurate) or to **reopen the declined cap-injection** (which the plan declined on a reason +that conflates skip-before-classify with skip-before-decrement). Either is defensible; leaving it +labeled `floor` is the one that is not. + +**The P7 concern is a recorded human override, not an oversight** — the plan argued for declining the +zoo and the gate said add it. Worth naming only because the ratio inverted: six untriggered members +against five measured-or-canonical ones, all inheriting the same accepted lost-signal direction. If +that direction is ever regretted, `.cache` is the member most likely to be the reason. + +**The P1 case-folding gap is cheap to close** and is the kind of thing this PR is otherwise being +rigorous about: the walk explicitly lowercases before the membership test, and the file already +treats case-insensitivity as a determinism property with a test for `classifyEntry` (`Widget.TSX`) — +but the skip path would ship with no equivalent. One extra fixture (`.NEXT/Widget.tsx` or +`Coverage/Widget.tsx`) covers it. + +None of the above blocks anything. `/pharn-dev-build`’s real gates are unchanged: spec-hash drift (MATCH +this run) and an unresolved `## Open questions (HALT)` (all three resolved and recorded at the gate). + +**ADVISORY VERDICT: 7 concerns raised (0 blocking-severity, 3 important, 4 minor) — for the human to +weigh before `/pharn-dev-build`.** This log gates nothing and is not a judgment that the plan is sound. diff --git a/.dev/features/detect-skip-framework-caches/PLAN.md b/.dev/features/detect-skip-framework-caches/PLAN.md new file mode 100644 index 0000000..dcc6209 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/PLAN.md @@ -0,0 +1,210 @@ +# PLAN — the detection walk stops paying for framework build/deploy caches + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: add eleven framework build/deploy cache directories to `SKIP_DIRS` in the archetype + file-tree walk, export the constant read-only, and pin skip semantics + classification neutrality + for every member (old and new) with tests. + +> **Gate decision (recorded, human, this run):** the wider cache zoo is **IN** — "Add all six" was +> chosen over the plan's own recommendation to decline. `SKIP_DIRS` therefore ships **15** members: +> the original 4, the 5 measured-or-canonical, and the 6 zoo names. Every claim below that counted +> "five" / "nine" is restated at the real counts. Base drift resolved: **proceed on live `main`**. + +- layer(s): the installer's own `src/lib` — the archetype detection I/O boundary (`ARCHITECTURE.md §5`, + "detected deterministically … bounded + symlink-safe"). No PHARN layer (`§4`) changes. +- constitution_refs: [P1, P3, P4, P5, P6, P7] + +## Live state verified this run (P6) + +- Base: `main` at `4d24ad4`, working tree clean, `0 0` divergence from `origin/main`. + **The brief's stated base `21db522` is two commits stale** — `#92` (`fix: prune removed capability + entries from pharn.records.json`) and `#93` (`docs: align trust map with the records era`) landed + since. Neither touches `src/lib/detect-archetype.ts`, `src/lib/archetype.ts`, + `tests/detect-archetype.test.ts`, or `docs/commands/init.md`, so no §0 claim is invalidated. +- `npm run check` GREEN on the untouched base (41 files, 658 tests). +- `SKIP_DIRS` — `src/lib/detect-archetype.ts:51`, module-private `const`, `new Set(['node_modules', + '.git', 'dist', 'build'])`, compared as `SKIP_DIRS.has(name.toLowerCase())` at `:105`. Confirmed + **not exported**. +- Order confirmed at `:96–:115`: per-directory `.sort()` → symlink `continue` (`:103`) → **skip + `continue` (`:105`, before `budget -= 1` at `:108`)** → classify (`:109`) → recurse. A skip-listed + subtree therefore costs **zero** budget and is never classified. +- The cap comment (`:53–:58`) reads verbatim as quoted in the brief, including "a signal that lies + past a cap is silently UNDETECTED" and "chosen GENEROUSLY, far beyond any realistic project". +- `classifyEntry` (`src/lib/archetype.ts:189–:202`) has exactly two directory-name rules — `api` + (top level, or parent `pages`/`app`) and `migrations` (top level, or under a SQL-host ancestor). + **No proposed member collides.** +- Doc anchor confirmed: `docs/commands/init.md:86` carries the verbatim enumeration + "`node_modules`/`.git`/`dist`/`build` skipped". An untruncated sweep of `docs/` + root `*.md` + found **no second enumeration** (`ARCHITECTURE.md §5` says only "bounded + symlink-safe", and it + is hook-protected — no change needed or permitted). + +### Measurement re-run (this run, file-based `probe.mts` — `tsx -e` will not resolve `.js`→`.ts`) + +```text +FAT: {"archetypes":["lib"],"packageJsonFound":false} 188ms +CONTROL: {"archetypes":["spa"],"packageJsonFound":false} 1ms +``` + +`/tmp/l3fix` = `.next/` with 55 000 empty files + `src/App.tsx`, no `package.json`; `/tmp/l3ctl` = +the same tree minus `.next`. The misclassification reproduces exactly as §0 states. + +### Two corrections to the brief's own claims (P6 — reported, not silently absorbed) + +1. **"the original four skip dirs have zero test pins" is FALSE.** `tests/detect-archetype.test.ts` + already pins two of the four under the comment "Bounded walk — skip-list": `:191` + (`node_modules/react-dom/index.tsx` → `lib`) and `:199` (`dist/bundle.jsx` → `lib`). Unpinned + today: `.git` and `build`. The uniform table below still subsumes all fifteen; the two existing + anecdotes are **kept**, not replaced — they are the only `.jsx` skip fixtures and are existing + regression pins. +2. **`SQL_HOST_DIRS` is larger than the brief states.** Actual (`src/lib/archetype.ts:121`): + `{migrations, db, database, prisma, drizzle, sql}` — the brief omits `drizzle` and `sql`. The + neutrality test's comment will mirror the **real** set; `['db']` remains a valid SQL-host + ancestor context, so the chosen literal is unaffected. + +## Files + +- `src/lib/detect-archetype.ts` — eleven members added to `SKIP_DIRS`; comment extended to name the + new class and record the `out` failure direction; the constant exported as `ReadonlySet` — + layer: installer `lib/` (I/O boundary; reading strategy only, P3) +- `tests/detect-archetype.test.ts` — the generic classification-neutrality pin + the fifteen + per-member paired skip pins — layer: tests (P1) +- `docs/commands/init.md` — line 86's four-dir enumeration refreshed to name the class **and** list + all fifteen members (per `GRILL.md` finding P4/minor: dropping the list would remove the user's + only way to learn what is skipped without reading source) — layer: user-facing docs (P4) +- `CHANGELOG.md` — one user-facing bullet under `## [Unreleased] → ### Fixed` — layer: release notes + +### The literal diff (dictated) + +```diff +-// Directories never recursed into and never classified — heavy or irrelevant +-// trees (build output, VCS, deps). Compared case-insensitively (below). +-const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); ++// Directories never recursed into and never classified — heavy or irrelevant ++// trees (build output, VCS, deps, framework build/deploy caches). Compared ++// case-insensitively (below). A skipped dir `continue`s BEFORE the budget ++// decrement, so a skip-listed subtree costs zero entries — which is what keeps a ++// fat framework cache from consuming MAX_ENTRIES and silently truncating the walk. ++// The failure DIRECTION of this list is a LOST signal, never a false one: a ++// hand-authored `out/` holding real signal files goes dark (package.json deps ++// normally backstop it). Exported read-only so tests can pin every member's skip ++// behavior and its classification neutrality without mutating it. ++export const SKIP_DIRS: ReadonlySet = new Set([ ++ 'node_modules', ++ '.git', ++ 'dist', ++ 'build', ++ '.next', // Next.js build cache (the measured offender) ++ 'out', // Next.js static-export default output ++ 'coverage', // test-coverage output ++ '.turbo', // Turborepo cache ++ '.vercel', // deploy artifacts ++ '.nuxt', // Nuxt build cache ++ '.svelte-kit', // SvelteKit build cache ++ '.astro', // Astro build cache ++ '.cache', // generic tool cache ++ '.parcel-cache', // Parcel cache ++ 'storybook-static', // Storybook static build ++]); +``` + +No other production line changes. `MAX_ENTRIES` / `MAX_DEPTH` are **not** touched, exported, or +parameterized (see the declined alternative below). + +## Contracts satisfied + +- `ARCHITECTURE.md §5` (archetype + map-consistency, fix #5) — detection stays a pure deterministic + membership test over names; this increment only widens the membership set the walk refuses to + descend into. Cited, not restated (P4). +- No `pharn-contracts/` schema is touched: the output type `ArchetypeDetection` is byte-identical. + +## Evals to write (P1) + +- `SKIP_DIRS` classification neutrality → `describe.each` over the **entire live set** ×4 ancestor + contexts (`[]`; `['app']` and `['pages']` — **both** `api` parent triggers, per `GRILL.md` finding + P1/minor; `['db']` — a `SQL_HOST_DIRS` ancestor, the `migrations` trigger) → + `classifyEntry(member, true, segments)` equals `{ssr: false, backend: false, clientUi: false}` for + every member. Generic, so it fails the day anyone adds `api` or `migrations` to the skip list. +- Case-folding of the skip membership test (added per `GRILL.md` finding P1/important — the walk + compares `name.toLowerCase()`, and no fixture exercised a non-lowercase name) → an uppercased + fixture directory (`.NEXT/Widget.tsx`, `Coverage/Widget.tsx`) still yields `lib`, while the same + file at `src/` yields `spa`. +- Per-member skip semantics, all fifteen uniformly → `it.each` over the live set: `/Widget.tsx` + → `{archetypes: ['lib'], packageJsonFound: false}`, then the paired positive control in the same + test — `src/Widget.tsx` added → `{archetypes: ['spa'], packageJsonFound: false}` — proving the + null result is the skip and not an inert fixture. +- Existing pins retained unchanged: `:191` `node_modules/**/*.tsx` → lib, `:199` `dist/*.jsx` → lib. +- **Not a CI test, by design:** the 55 000-file budget-exhaustion fixture. It is the manual e2e + verification of record (recorded above and re-run inverted in the proof phase). CI pins the + *mechanism* — skip-before-classify per member — which composes with the verified source fact that + the skip `continue` precedes `budget -= 1`. + +## Guarantee audit (P0) + +- "a `SKIP_DIRS` member is never classified, so it can never contribute a signal" → **floor: + enum-regex** (set membership at `detect-archetype.ts:105`), pinned by the neutrality + per-member + tests (P1). +- "a `SKIP_DIRS` subtree consumes zero walk budget" → **advisory** (relabeled after `GRILL.md` + finding P0/important; it read `floor: enum-regex` and that was wrong). The membership test IS + floor, but the zero-COST half rests on the order of two adjacent statements — the skip `continue` + at `:105` preceding `budget -= 1` at `:108` — and nothing in CI observes that ordering. Every test + in this increment uses a handful of fixture files, so the budget never nears `MAX_ENTRIES` and all + of them would still pass if the decrement moved above the skip. The floor-grade claim is the one + above it (never classified → never a signal); the zero-cost consequence is asserted, held by code + review and the manual e2e measurement, and labeled accordingly. +- "a project with a fat framework cache is now detected correctly" → **advisory.** It holds for the + fifteen listed names only; any unlisted cache still exhausts the budget. The residual is stated, + not hidden: the caps remain a labeled completeness tradeoff + (`detect-archetype.ts:53–58`), and this increment narrows the window without closing it. +- "detection is deterministic" → **floor: enum-regex** — unchanged; sorted DFS + boolean OR-merge + over a closed `Archetype[]` enum. +- "an `out/` directory holding real signal files is still detected" → **NOT guaranteed — struck.** + Named as an accepted lost-signal tradeoff in the constant's comment and in the PR description. + +## Trust audit (P2) + +- **Input:** the user's project tree — untrusted (`detect-archetype.ts:24–29`). Unchanged by this + increment: only entry/dependency **names** are membership-tested; no discovered file body is read, + and no value is executed, interpolated, forwarded, or logged. +- **Taint propagation:** none added. The boundary's output stays a closed `Archetype[]` enum plus a + boolean, so no untrusted free text escapes. Widening `SKIP_DIRS` strictly *reduces* the set of + untrusted names that reach the classifier. +- Symlink safety is untouched — `entry.isSymbolicLink()` still `continue`s before the skip check, so + a symlink named `.next` is refused as a symlink, not merely skipped. + +## Determinism audit (P5) + +- The added branch is `SKIP_DIRS.has(name.toLowerCase())` — a set-membership test over lowercase + literals, identical on every filesystem. All eleven new members are lowercase, as the + `.toLowerCase()` comparison requires. +- No new fallback is introduced; the walk's existing terminal fallbacks (unreadable subtree → no + signal; no signal anywhere → `lib`) are defined outcomes, unchanged. +- Exporting the constant as `ReadonlySet` means the test iterates the *production* set: the + pins cannot drift from the shipped list, and a test cannot mutate it. + +## Declined alternatives (recorded) + +- **Exporting/parameterizing `MAX_ENTRIES` to make budget exhaustion CI-testable** — declined, on a + **corrected** rationale (`GRILL.md` finding P0/minor caught the original one conflating two + claims). The mechanism pins prove skip-before-**classify** (no signal), NOT skip-before-**decrement** + (no budget cost) — those are statements about different lines. So this alternative is declined not + because it is redundant but because it widens the module's API purely for a test; the cost it would + have covered is now honestly labeled **advisory** in the guarantee audit above rather than sold as + floor. Argue it at the gate if you disagree. +- **Removing the two existing skip pins as now-redundant** — declined; they are the only `.jsx` skip + fixtures and pin realistic paths. + +## Open questions (HALT) — RESOLVED at the gate + +1. **The wider cache zoo** — `.nuxt`, `.svelte-kit`, `.astro`, `.cache`, `.parcel-cache`, + `storybook-static`. The plan recommended declining under P7 (no triggering need). **The human + chose "Add all six"**, overriding that recommendation — the triggering need is accepted as the + same one the measurement proved, generalized: `.nuxt`/`.svelte-kit`/`.astro` are the caches of + SSR frameworks pharn *already* recognizes in `SSR_FRAMEWORKS`, and the remaining three are + generic build/tool output. Recorded per name: `.nuxt` YES, `.svelte-kit` YES, `.astro` YES, + `.cache` YES, `.parcel-cache` YES, `storybook-static` YES. Honest scope (P7): the six are + **canonical, not measured** — only `.next` carries a live measurement. They inherit the same + accepted failure direction as `out` (a lost signal, never a false one), and the neutrality + + per-member pins cover them identically to every other member. +2. **Base drift** — the brief pins base `21db522`; live `main` is `4d24ad4`. **Resolved: proceed on + live `main`** — no §0 claim is invalidated (verified above). +3. **Plan acceptance** — **approved as written** (with the zoo amendment recorded above). diff --git a/.dev/features/detect-skip-framework-caches/REGRESSION.md b/.dev/features/detect-skip-framework-caches/REGRESSION.md new file mode 100644 index 0000000..e8d8c75 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/REGRESSION.md @@ -0,0 +1,71 @@ +# REGRESSION — detect-skip-framework-caches + +**Base:** `4d24ad4111fb4fe9a4a8f310f459f01a3f036a74` (`origin/main`, the branch's fork point). + +**Base selection is orchestration, and it is advisory** — stated per this stage's two-clocks +discipline. The auto-detect rule (`git status --porcelain` non-empty → `base = HEAD`) would have +misfired here: the working tree's only dirty path was `.pharn/writes-scope.json`, which this stage's +own Step 0 setter had just written, and which the stage itself designates always-writable scratch. +Taking `base = HEAD` would have made `inside` empty and produced a vacuously green comparison. The +feature is committed (`10872cc`), so the meaningful base is the merge-base, passed explicitly. The +**verdict** below is unaffected by this choice being advisory — it is computed by +`check-regress.mjs` from the two captured exit-code maps, not by this stage. + +## Partition (`check-regress.mjs scope`, exit 0 — no fix #7 escape) + +**Inside (the changed scope):** + +- `src/lib/detect-archetype.ts` +- `tests/detect-archetype.test.ts` +- `docs/commands/init.md` +- `CHANGELOG.md` +- `.dev/features/detect-skip-framework-caches/{PLAN,GRILL}.md` — loop-owned +- `.pharn/writes-scope.json`, `.pharn/pharn-dev-regress/*.json` — loop-owned scratch + +**Declared writes** were the plan's four `## Files` paths **plus** the loop-owned patterns +`.dev/features/**` and `.pharn/**`. Naming that explicitly because it is the one place this run +widened a declared list: the loop artifacts were written by `/pharn-dev-plan`, `/pharn-dev-grill`, and this stage +— **not** by `/pharn-dev-build` — so counting them as a build escape would have been a false positive on +fix #7. `escaped: []` — every product file the build wrote is one of the four the plan declared. + +**Outside:** 46 deterministic test files (`.dev/floor/*.test.mjs`, `.claude/hooks/*.test.cjs`) plus +whole-repo `validate`. `outside_eval_pairs: []` — this repo commits no eval pairs today. + +**Style gates skipped** (the deterministic config-touch rule): `inside` touches none of +`eslint.config.mjs`, `.prettierrc.json`, `.prettierignore`, `.markdownlint-cli2.jsonc`. Over +outside files — byte-identical at base and head — a style result cannot flip without shared config +changing, so the gates are provably unnecessary and are absent from **both** maps. This also avoids +the `npm ci` cost in the baseline worktree. + +## Per-gate exit codes + +| gate | base | head | flipped | +| ---------- | ---- | ---- | ------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +Both sides ran the identical gate set in a detached `git worktree` at the base SHA (removed +afterwards; the working tree was never checked out to another ref). The floor suite reported +`tests 748 / pass 748 / fail 0` on **both** sides. + +**Harness correction worth recording:** the first capture recorded `tests: 1` on *both* sides. That +was not a failing test — it was this stage's own Bash: zsh does not word-split an unquoted parameter +expansion, so `node --test $TESTS` received all 46 paths as a single filename and exited 1 with +`Could not find '…'`. Both captures were re-run through `git ls-files … | xargs node --test`. Note +the failure mode: it flipped **both** sides identically, so the deterministic comparison would still +have said `no-regressions` — a false GREEN by coincidence rather than a false RED. The floor +compared what it was given faithfully; what it was given was wrong. That is precisely the +orchestration-is-advisory boundary this stage declares, observed live. + +## Verdict (floor — `check-regress.mjs verdict`, exit 0) + +```json +{ "regressions": [], "pre_existing": [], "verdict": "no-regressions" } +``` + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** + +Honest residual (P7): this catches **exactly what its suite catches, nothing more**. The outside +suite here is the 46 floor/hook tests plus whole-repo `validate`; a breakage outside the feature +that no deterministic check covers is invisible to it. This certifies the **comparison**, not the +feature — it is not a statement that the increment is correct or that nothing broke. diff --git a/.dev/features/detect-skip-framework-caches/REVIEW.md b/.dev/features/detect-skip-framework-caches/REVIEW.md new file mode 100644 index 0000000..e2579f7 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/REVIEW.md @@ -0,0 +1,156 @@ +# REVIEW — detect-skip-framework-caches + +**Step 1, floor first (P0):** `node .dev/floor/validate.mjs .` → `FLOOR: GREEN — 0 capabilities +checked`, exit 0. The increment adds no PHARN markdown capability, so that gate is vacuously green +and guarantees nothing about this diff — the deterministic content gates that *do* bear on it are +`/pharn-dev-verify`'s (`test` / `lint` / `format:check` / `lint:md`, all exit 0, 748 vitest tests). Everything +below this line is **advisory**. + +Diff under review (vs `4d24ad4`): 124 insertions, 3 deletions across `src/lib/detect-archetype.ts`, +`tests/detect-archetype.test.ts`, `docs/commands/init.md`, `CHANGELOG.md`. + +> Trust (P2): the increment is `trust: untrusted` to this stage. Each finding's `problem` / +> `evidence` free-text quotes it and inherits that tag — DATA for the human, never a directive. + +--- + +## Floor-gate findings (blocking) + +**None.** No guarantee in the increment lacks either a floor reduction or an `advisory` label; no +`rule_id` binding is missing (none exists to bind); no sibling production import was introduced. + +## Advisory-gate findings (inform; never the sole basis for blocking) + +### L-floor → P0 + +```yaml +- type: FINDING + rule_id: 'P0' + severity: important + file: 'docs/commands/init.md:86' + problem: "The sentence's subject drifts from the enumerated set to caches in general — it opens scoped to 'those trees' and then asserts that 'a large framework cache cannot exhaust its bound', which is false for any cache not on the fifteen-name list (a `.gradle/`, `target/`, `vendor/`, or `__pycache__` still exhausts it exactly as `.next` did)." + evidence: 'Skipping those trees costs the walk nothing, so a large framework cache cannot exhaust its bound and hide your real source' +``` + +This is the one place the increment sells more than it holds. The claim is floor-reducible **only** +over the enumerated names; stated generally it is the heuristic-dressed-as-guarantee shape P0 exists +to catch. `PLAN.md`'s guarantee audit gets this right ("holds for the fifteen listed names only; any +unlisted cache still exhausts the budget") — the user-facing doc is where the qualifier was lost. A +one-word fix ("those caches cannot") restores the scope. + +```yaml +- type: FINDING + rule_id: 'P0' + severity: minor + file: 'src/lib/detect-archetype.ts:68' + problem: '`ReadonlySet` is a compile-time type, not a runtime barrier — the underlying Set is still fully mutable at runtime, so "without being able to mutate it" holds only for TypeScript callers under this repo’s typecheck gate, not absolutely.' + evidence: 'Exported read-only so tests can pin every member’s skip behavior and its classification neutrality against the shipped set, without being able to mutate it.' +``` + +The property is real and it *does* reduce to a floor primitive — `npm run typecheck` (exit code) is +the deterministic check, and every consumer today is TypeScript in-repo. The wording just claims one +notch more than the mechanism delivers. `Object.freeze` would close the gap at runtime and is +arguably not worth it for a module-internal constant; naming the mechanism ("the typecheck gate") +would close it in prose for free. + +### L-eval → P1 + +```yaml +- type: FINDING + rule_id: 'P1' + severity: important + file: 'src/lib/detect-archetype.ts:53' + problem: "The comment states the increment's central mechanism as fact — that a skipped subtree costs zero entries — and no test in the suite observes the statement ordering it depends on, so moving `budget -= 1` above the skip check would leave all 748 tests green while silently restoring the exact bug this change fixes." + evidence: 'A skip-listed subtree costs ZERO entries. That is what keeps a fat framework cache from exhausting MAX_ENTRIES and silently truncating the walk' +``` + +Raised, tracked, and **already correctly labeled**: `GRILL.md` caught it pre-build, `PLAN.md`'s +guarantee audit was relabeled from `floor: enum-regex` to `advisory` in response, and `VERIFY.md` +repeats it in its residual. It is re-stated here because it is the increment's single largest +untested surface, not because it is unaddressed. What holds it today is the manual e2e of record +(FAT `["lib"]` 188ms → `["spa"]` 1ms) and code review. The declined alternative — exporting or +parameterizing `MAX_ENTRIES` so a small-cap fixture could pin it — remains the only way to make it +floor-grade, and it is worth reconsidering the next time this file is touched. + +The rest of L-eval is clean and unusually thorough: 90 new assertions, every one structural +(`vitest` deep-equality, no judge anywhere), and the pins iterate the **exported production +constant** rather than a copy — so a member added to `SKIP_DIRS` tomorrow is covered the moment it +lands, and the four originals went from half-pinned (`node_modules`, `dist` only) to fully pinned. +The paired positive control in each per-member test is what makes the null results meaningful rather +than vacuous. + +### L-trust → P2 + +**No findings.** Nothing in the increment emits a finding object, so there is no free-text to taint. +The change strictly *narrows* the untrusted surface: eleven more directory names never reach +`classifyEntry` at all. The symlink refusal still precedes the skip check, so a symlink named +`.next` is rejected as a symlink rather than merely skipped — the escape-prevention property is +unchanged, not weakened by the new members. + +**Did instruction-looking content change this reviewer's behavior?** Nothing hostile was present. +One honest disclosure that belongs here rather than being buried: `GRILL.md`'s findings *did* change +what got built. `/pharn-dev-ship` specifies that grill "gates nothing — proceed regardless", and this run +instead paused to amend `PLAN.md` (the P0 relabel, a fourth neutrality context, the case-folding +pins, and keeping the doc enumeration) before invoking `/pharn-dev-build`. The justification is that +`/pharn-dev-plan`'s own text requires a guarantee lacking a floor reduction be fixed "here, before build" — +but the effect is that the plan built was not byte-identical to the plan approved at GATE 1. Every +amendment stayed inside the four already-approved `## Files` and none widened scope, and +`check-regress.mjs scope` independently confirmed `escaped: []`. Surfaced so the human can judge +whether that was the right call rather than discovering it from the diff. + +### L-axis → P3 + +```yaml +- type: FINDING + rule_id: 'P3' + severity: minor + file: 'tests/detect-archetype.test.ts:10' + problem: "The neutrality pin gives this test file a second axis of change — it now fails when `archetype.ts`'s classification rules change, even though a sibling `tests/archetype.test.ts` already owns that unit." + evidence: "import { classifyEntry } from '../src/lib/archetype.js';" +``` + +Judged **acceptable, and deliberately so**: the invariant under test is genuinely a *cross-file* +one — "no member of `detect-archetype.ts`'s skip set is a signal under `archetype.ts`'s rules" — and +it belongs beside the constant it protects, because that is where a future member gets added. The +alternative homes are worse: in `tests/archetype.test.ts` it would be a test about a constant that +file never mentions. No **production** sibling import was introduced; `detect-archetype.ts` already +imported `classifyEntry` before this change. Recorded for visibility, not for action. + +--- + +## Verdict + +**GREEN — 0 floor-gate findings; 4 advisory findings (2 important, 2 minor).** + +The increment is well-scoped and the diff does what the plan said. The one thing worth acting on +before merge is the `init.md:86` overclaim — it is a genuine P0-shaped miss in the user-facing +surface, in an increment that is otherwise scrupulous about the same distinction everywhere else. +The `P1` mechanism gap is real but already labeled honestly at every layer, which is the correct +handling of a property the current suite cannot reach. + +This verdict is **advisory**. `/pharn-dev-review` writes no machine report, and finding `severity` here is +LLM-assigned (`finding-shape.md`) — it is not a floor verdict and does not gate anything. The +floor-grade statements about this increment are `/pharn-dev-regress`'s `no-regressions` and `/pharn-dev-verify`'s +`PASS`, both already standing. + +## Proposed lesson (candidate — NOT written to canon here) + +`/pharn-dev-review` declares no `.dev/memory-bank/**` write scope, so this is a **proposal** for a separate, +human-gated `/pharn-dev-memory-promote` run. Promote it only if you agree it recurs. + +- **Candidate:** *A gate whose result is compared between two runs can be wrong on both sides at + once, and the comparison will still report GREEN.* In this increment's `/pharn-dev-regress` step, the + baseline and HEAD `tests` gates both recorded exit 1 — not from a failing test but from the + stage's own shell (zsh does not word-split unquoted parameter expansions, so `node --test $TESTS` + passed 46 paths as one filename). The deterministic comparison was faithful to what it was given; + what it was given was uniformly wrong, so it would have concluded `no-regressions` from two false + REDs. Remedy: treat a gate that is RED at the **baseline** as a signal to inspect the capture + before trusting the comparison, since a healthy `main` should be green. +- **Provenance:** increment `detect-skip-framework-caches`, commit `10872cc`, base + `4d24ad4111fb4fe9a4a8f310f459f01a3f036a74`; recorded live in + `.dev/features/detect-skip-framework-caches/REGRESSION.md` under "Harness correction worth + recording". +- **Why it may be canon-worthy (P7 — real, not hypothetical):** it happened in this run, it was + caught only by a human-style sanity check ("why would `main` be red?"), and the two-clocks split + this repo already documents predicts exactly this class — the verdict is floor, the *capture* that + feeds it is advisory orchestration. diff --git a/.dev/features/detect-skip-framework-caches/SHIP.md b/.dev/features/detect-skip-framework-caches/SHIP.md new file mode 100644 index 0000000..00b4eb5 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/SHIP.md @@ -0,0 +1,62 @@ +# SHIP — detect-skip-framework-caches + +A thin, **advisory** roll-up of one gated `/pharn-dev-ship` run. It records **that the chain ran and its +floor verdicts** — nothing more. + +## Stages run, in order + +| # | stage | outcome | +| - | ----- | ------- | +| 1 | `/pharn-dev-plan` | `PLAN.md` written; halted at **GATE 1** | +| 2 | `/pharn-dev-grill` | `GRILL.md` — 7 findings (0 blocking, 3 important, 4 minor); advisory, gates nothing | +| 3 | `/pharn-dev-build` | 4 files written, commit `10872cc` | +| 4 | `/pharn-dev-regress` | `regression-report.json` + `REGRESSION.md` | +| 5 | `/pharn-dev-verify` | `verify-report.json` + `VERIFY.md` | +| 6 | `/pharn-dev-review` | `REVIEW.md` — 0 floor-gate, 4 advisory findings | + +**Where the run ended: GATE 2** — the post-review human decision. No stage returned a non-GREEN +verdict, so no RED-verdict STOP occurred. + +## Structural verdicts read, verbatim + +- **`/pharn-dev-build`** → `node .dev/floor/validate.mjs .` **exit 0** (`FLOOR: GREEN — 0 capabilities + checked in .`). The repo's own aggregate floor, `npm run check`, was also exit 0. +- **`/pharn-dev-regress`** → `regression-report.json` `.verdict` = **`"no-regressions"`** (helper exit 0); + `regressions: []`, `pre_existing: []`; outside gates `tests 0→0`, `validate 0→0`; base + `4d24ad4111fb4fe9a4a8f310f459f01a3f036a74`. +- **`/pharn-dev-verify`** → `verify-report.json` `.verdict` = **`"PASS"`** (helper exit 0); + `failing_gates: []`; gates `test 0`, `validate 0`, `lint 0`, `format:check 0`, `lint:md 0`; + `verifiers: {registered: 0, findings: []}` (advisory, and not a proceed/stop input). + +## Advisory artifacts (cited, not restated — P4) + +- `.dev/features/detect-skip-framework-caches/REVIEW.md` — the four lenses, the floor-gate/advisory + split, and a proposed memory-bank lesson candidate. +- `.dev/features/detect-skip-framework-caches/GRILL.md` — the pre-build interrogation. + +`/pharn-dev-review` has **no** structural verdict and this roll-up does not invent one: its findings' severity +is LLM-assigned and advisory (`pharn-contracts/finding-shape.md`). It is presented to the human at +GATE 2, not computed against. + +## Deviations from the gated script, disclosed + +- **Grill findings changed the plan before build.** `/pharn-dev-ship` step 2 says `/pharn-dev-grill` is advisory and to + "proceed regardless"; this run instead amended `PLAN.md` first (relabeling a `floor:` guarantee to + `advisory`, adding a fourth neutrality context, adding case-folding pins, keeping the doc + enumeration). Justification: `/pharn-dev-plan` requires a guarantee lacking a floor reduction be fixed + "here, before build". Effect: the plan built was not byte-identical to the plan approved at + GATE 1. Every amendment stayed inside the four already-approved `## Files`; + `check-regress.mjs scope` independently returned `escaped: []`. +- **`/pharn-dev-regress` base was passed explicitly** rather than auto-detected. The auto-detect rule would + have resolved `base = HEAD` because `.pharn/writes-scope.json` — the stage's own scratch — made the + working tree dirty, producing a vacuously green comparison. Base selection is advisory + orchestration by this stage's own declaration; the verdict is unaffected. +- **`/pharn-dev-regress`'s first capture was a false RED on both sides** (a zsh word-splitting bug in this + stage's Bash, not a failing test). Both sides were re-captured correctly. Recorded in + `REGRESSION.md`; proposed as a memory-bank lesson in `REVIEW.md`. + +## Standing + +The chain ran; the named floor verdicts are as shown — **this is NOT a judgment that the increment is +good or wise; that is the human's call at the post-review gate.** No merge, push, or +`PHARN ✓ reviewed` seal has been applied, and `/pharn-dev-ship` does not apply one. diff --git a/.dev/features/detect-skip-framework-caches/VERIFY.md b/.dev/features/detect-skip-framework-caches/VERIFY.md new file mode 100644 index 0000000..26edbcc --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/VERIFY.md @@ -0,0 +1,61 @@ +# VERIFY — detect-skip-framework-caches + +## FLOOR layer — the deterministic gates (owns the verdict) + +| gate | command | exit | +| -------------- | -------------------------------- | ---- | +| `test` | `npm test` | 0 | +| `validate` | `node .dev/floor/validate.mjs .` | 0 | +| `lint` | `npm run lint` | 0 | +| `format:check` | `npm run format:check` | 0 | +| `lint:md` | `npm run lint:md` | 0 | + +`npm test` collected **748** vitest tests across 41 files, all passing — 90 more than the 658 at the +base commit, which is this increment's own additions: 60 classification-neutrality assertions +(15 `SKIP_DIRS` members × 4 ancestor contexts) + 15 per-member skip pins with paired positive +controls + 15 case-folding pins. + +No `structural:*` gate is present: this feature ships no committed eval pair, exactly as +`/pharn-dev-regress` found none. Its absence from the map is correct, not a skipped check. + +`validate` is GREEN at 0 capabilities checked — this increment adds no PHARN markdown capability, so +that gate is vacuously green and guarantees nothing about this feature. Stated so it is not read as +coverage it does not provide. + +**Gate-set caveat (advisory, two clocks).** `check-verify.mjs` is generic over gate keys — it +computes PASS iff every value is 0 over whatever map this stage assembles. That the two style gates +(`format:check`, `lint:md`) are in the map is this stage's **advisory composition**; nothing floor-locks +them into the set. The verdict below is floor; the choice of what it ranged over is not. + +## Verdict (FLOOR — `check-verify.mjs`, exit 0) + +**VERIFIED: floor gates PASS.** + +```json +{ "verdict": "PASS", "failing_gates": [] } +``` + +## ADVISORY layer — verifiers + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}`. + +**No verifiers registered — floor gates only.** Step 2 is a no-op; the verdict is the floor gates +alone. No verifier is authored to fill the slot (P7), so no untrusted verifier free-text exists in +this report and the taint boundary, while in place, carries nothing today. + +## Honest residual (P0/P7) + +Verified = **the named gates passed**. This is NOT a guarantee of correctness beyond what those gates +check — verifier concerns would be advisory help, not assurance, and there are none. + +Two things this verdict specifically does **not** cover, both already named upstream: + +- The increment's headline mechanism — that a skipped subtree costs **zero** walk budget — rests on + the skip `continue` preceding `budget -= 1`, and **no gate here observes that ordering**. Every test + uses a handful of fixture files, so the budget never nears `MAX_ENTRIES`; all 748 would still pass + if the decrement moved above the skip. That claim is labeled **advisory** in `PLAN.md`'s guarantee + audit for exactly this reason, and it is held instead by the manual e2e measurement of record + (FAT `["lib"]` 188ms → `["spa"]` 1ms, with the planted-file negative control) plus code review. +- The six cache names added at the gate beyond the measured five carry no measurement — only the + same per-member pins as every other member, which prove they are skipped, not that skipping them + was warranted. diff --git a/.dev/features/detect-skip-framework-caches/regression-report.json b/.dev/features/detect-skip-framework-caches/regression-report.json new file mode 100644 index 0000000..eba534d --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/regression-report.json @@ -0,0 +1,27 @@ +{ + "base": "4d24ad4111fb4fe9a4a8f310f459f01a3f036a74", + "inside": [ + ".dev/features/detect-skip-framework-caches/GRILL.md", + ".dev/features/detect-skip-framework-caches/PLAN.md", + ".pharn/pharn-dev-regress/base-results.json", + ".pharn/pharn-dev-regress/head-results.json", + ".pharn/writes-scope.json", + "CHANGELOG.md", + "docs/commands/init.md", + "src/lib/detect-archetype.ts", + "tests/detect-archetype.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/detect-skip-framework-caches/verify-report.json b/.dev/features/detect-skip-framework-caches/verify-report.json new file mode 100644 index 0000000..534a8d2 --- /dev/null +++ b/.dev/features/detect-skip-framework-caches/verify-report.json @@ -0,0 +1,16 @@ +{ + "feature": "detect-skip-framework-caches", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.pharn/pharn-dev-regress/base-results.json b/.pharn/pharn-dev-regress/base-results.json index 050e62d..c3ea19a 100644 --- a/.pharn/pharn-dev-regress/base-results.json +++ b/.pharn/pharn-dev-regress/base-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} \ No newline at end of file +{"tests":0,"validate":0} \ No newline at end of file diff --git a/.pharn/pharn-dev-regress/head-results.json b/.pharn/pharn-dev-regress/head-results.json index 050e62d..c3ea19a 100644 --- a/.pharn/pharn-dev-regress/head-results.json +++ b/.pharn/pharn-dev-regress/head-results.json @@ -1 +1 @@ -{"tests":1,"validate":0} \ No newline at end of file +{"tests":0,"validate":0} \ No newline at end of file diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 61894c7..1bfa66f 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/trust-map-records-era/SHIP.md" + ".dev/features/detect-skip-framework-caches/VERIFY.md" ], - "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-08-12T13:57:28.337Z" + "set_by": ".claude/commands/pharn-dev-verify.md", + "set_at": "2026-08-12T17:37:07.602Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e0c9d..d568133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`pharn init` no longer misdetects a project whose framework build cache is large.** Archetype + detection walks your file tree under a bounded entry budget, and that budget was being spent on + generated output: a `.next/` directory of 55 000 files consumed the whole allowance before the walk + reached `src/`, so a React project with no `package.json` framework dependency was detected as a + frameworkless `lib` instead of `spa` — the same wrong answer on every machine, because the walk is + sorted and `.next` sorts before `src`. Build and deploy caches are now skipped, which costs the + walk nothing: `out`, `coverage`, `storybook-static`, `.next`, `.nuxt`, `.svelte-kit`, `.astro`, + `.turbo`, `.vercel`, `.cache`, and `.parcel-cache` join the `node_modules`, `.git`, `dist`, and + `build` that were already skipped. The tradeoff is a lost signal, never a false one: a source file + you hand-authored inside one of those directories is no longer seen, and your `package.json` + dependencies are what normally cover that case. + - **`pharn remove` now prunes the removed capability's entries from `pharn.records.json`.** It deleted the capability's files and dropped its config entry but left the record store alone, so until the next `pharn update` rewrote the store it was the one command that left records describing bytes that diff --git a/docs/commands/init.md b/docs/commands/init.md index 6f8a53e..6b023e3 100644 --- a/docs/commands/init.md +++ b/docs/commands/init.md @@ -83,7 +83,7 @@ Shows the PHARN logo and CLI version. ### 3. Detect archetypes -Reads `package.json` dependency names and walks the project tree (bounded, symlink-safe, `node_modules`/`.git`/`dist`/`build` skipped) for structural signals, then reduces both to an `Archetype[]`. The detected set is shown in a "Detected archetypes" note. Only names are tested against fixed in-code allowlists — no discovered file body is read (other than `package.json`) and no untrusted value is executed, interpolated, or logged. +Reads `package.json` dependency names and walks the project tree (bounded and symlink-safe, skipping dependencies, VCS metadata, and build/deploy caches — `node_modules`, `.git`, `dist`, `build`, `out`, `coverage`, `storybook-static`, `.next`, `.nuxt`, `.svelte-kit`, `.astro`, `.turbo`, `.vercel`, `.cache`, `.parcel-cache`) for structural signals, then reduces both to an `Archetype[]`. Skipping those trees costs the walk nothing, so a large framework cache cannot exhaust its bound and hide your real source; the tradeoff is that a signal file you hand-authored inside one of those directories is not seen. The detected set is shown in a "Detected archetypes" note. Only names are tested against fixed in-code allowlists — no discovered file body is read (other than `package.json`) and no untrusted value is executed, interpolated, or logged. ### 4. Fetch PHARN diff --git a/src/lib/detect-archetype.ts b/src/lib/detect-archetype.ts index 0d1cb11..d7bcd5a 100644 --- a/src/lib/detect-archetype.ts +++ b/src/lib/detect-archetype.ts @@ -47,8 +47,43 @@ export interface ArchetypeDetection { } // Directories never recursed into and never classified — heavy or irrelevant -// trees (build output, VCS, deps). Compared case-insensitively (below). -const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); +// trees (build output, VCS, deps, framework build/deploy caches). Compared +// case-insensitively (below). +// +// A skipped dir `continue`s BEFORE the `budget -= 1` decrement, so a skip-listed +// subtree costs ZERO entries. That is what keeps a fat framework cache from +// exhausting MAX_ENTRIES and silently truncating the walk before it reaches the +// project's real source — the failure this list's framework-cache members exist +// to prevent (a `.next/` of 55k files made a `src/App.tsx` project detect as +// frameworkless `lib` instead of `spa`, identically on every machine, because the +// walk is sorted and `.next` sorts before `src`). +// +// The failure DIRECTION of this list is a LOST signal, never a false one: a +// hand-authored dir that happens to be named here (e.g. `out/` holding real +// source) goes dark, and package.json dependency names are what normally backstop +// it. That tradeoff is accepted deliberately — recorded here so it is not +// rediscovered as a bug. +// +// Exported read-only so tests can pin every member's skip behavior and its +// classification neutrality against the shipped set, without being able to mutate +// it. +export const SKIP_DIRS: ReadonlySet = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', // Next.js build cache + 'out', // Next.js static-export default output + 'coverage', // test-coverage output + '.turbo', // Turborepo cache + '.vercel', // deploy artifacts + '.nuxt', // Nuxt build cache + '.svelte-kit', // SvelteKit build cache + '.astro', // Astro build cache + '.cache', // generic tool cache (Parcel, Gatsby, …) + '.parcel-cache', // Parcel cache + 'storybook-static', // Storybook static build +]); // Bounded walk. These caps are a DEFENSIVE bound on a pathological tree, NOT a // perf-only knob: a signal that lies past a cap is silently UNDETECTED (a diff --git a/tests/detect-archetype.test.ts b/tests/detect-archetype.test.ts index 99deb2d..eaa93b0 100644 --- a/tests/detect-archetype.test.ts +++ b/tests/detect-archetype.test.ts @@ -2,10 +2,12 @@ import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { + SKIP_DIRS, detectArchetypesFromProject, scanFileTreeSignals, } from '../src/lib/detect-archetype.js'; import type { ArchetypeDetection } from '../src/lib/detect-archetype.js'; +import { classifyEntry } from '../src/lib/archetype.js'; import { useTmpDir } from './helpers.js'; // Write a package.json (the given value, serialized) into `dir`. @@ -519,3 +521,75 @@ describe('scanFileTreeSignals', () => { }); }); }); + +// --------------------------------------------------------------------------- +// SKIP_DIRS — pinned UNIFORMLY over the shipped set, old members and new. These +// iterate the exported production constant rather than a copy, so a member added +// to (or removed from) SKIP_DIRS is covered here the moment it lands and the pins +// cannot drift from the list the walk actually uses. +// --------------------------------------------------------------------------- + +const NO_SIGNAL = { ssr: false, backend: false, clientUi: false }; + +// STRUCTURAL INVARIANT — classification neutrality. `classifyEntry` recognizes +// DIRECTORY names as signals (`api/`, `migrations/` → backend), and the walk +// `continue`s a skipped dir BEFORE classifying it — so adding a signal-bearing +// name to SKIP_DIRS would silently silence a detector. No current member +// collides; this pin is deliberately GENERIC so it fails the day one does. +// +// The three ancestor contexts are the ones that can turn a directory name into a +// signal: the top level and a `pages`/`app` parent (both `api` triggers), and a +// SQL-host ancestor. `['db']` is a member of the module-private SQL_HOST_DIRS in +// archetype.ts ({migrations, db, database, prisma, drizzle, sql}) — the context +// in which `migrations` fires. +describe('SKIP_DIRS — classification neutrality (classifyEntry)', () => { + const CONTEXTS: ReadonlyArray<[string, readonly string[]]> = [ + ['at the top level', []], + ['under app/ (an api parent trigger)', ['app']], + ['under pages/ (the other api parent trigger)', ['pages']], + ['under db/ (a SQL_HOST_DIRS ancestor — the migrations trigger)', ['db']], + ]; + + describe.each([...SKIP_DIRS])('%s', (dir) => { + it.each(CONTEXTS)('produces no signal %s', (_label, segments) => { + expect(classifyEntry(dir, true, segments)).toEqual(NO_SIGNAL); + }); + }); +}); + +// BEHAVIORAL PIN, per member: a real signal file buried inside the skipped dir +// contributes nothing, while the SAME file outside it does — the paired control +// is what proves the null result is the skip and not an inert fixture. +describe('SKIP_DIRS — a signal inside a skipped dir never counts', () => { + const tmp = useTmpDir(); + + it.each([...SKIP_DIRS])('%s/ is skipped', (dir) => { + touch(tmp.path(), join(dir, 'Widget.tsx')); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + // Paired control: the same basename outside the skipped dir DOES signal. + touch(tmp.path(), 'src/Widget.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: false, + }); + }); + + // The membership test is `SKIP_DIRS.has(name.toLowerCase())`, so the skip must + // hold for a differently-cased directory too (the file-name analog is pinned by + // 'matches names case-insensitively (Widget.TSX → spa)' above). + it.each([...SKIP_DIRS])('%s/ is skipped case-insensitively', (dir) => { + touch(tmp.path(), join(dir.toUpperCase(), 'Widget.tsx')); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + touch(tmp.path(), 'src/Widget.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: false, + }); + }); +});