From b7dc5b6a6bb1df9aa43cd141a342264a722b638a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Thu, 9 Jul 2026 15:12:56 +0200 Subject: [PATCH 1/4] feat: scope archetype file-tree signals to path context Prevent false positives from basename-only matching (e.g. src/api/, seed .sql, email templates, test fixtures) and accept bare YAML tokens in capability applies frontmatter. Co-authored-by: Cursor --- .dev/features/archetype-path-context/GRILL.md | 98 +++++++++ .dev/features/archetype-path-context/PLAN.md | 185 ++++++++++++++++ .pharn/writes-scope.json | 32 +-- src/lib/capability-index.ts | 16 +- src/lib/detect-archetype.ts | 130 ++++++++--- tests/capability-index.test.ts | 35 +++ tests/detect-archetype.test.ts | 203 +++++++++++++++++- 7 files changed, 638 insertions(+), 61 deletions(-) create mode 100644 .dev/features/archetype-path-context/GRILL.md create mode 100644 .dev/features/archetype-path-context/PLAN.md diff --git a/.dev/features/archetype-path-context/GRILL.md b/.dev/features/archetype-path-context/GRILL.md new file mode 100644 index 0000000..5eb0597 --- /dev/null +++ b/.dev/features/archetype-path-context/GRILL.md @@ -0,0 +1,98 @@ +# GRILL — archetype-path-context (ADVISORY) + +Interrogates `.dev/features/archetype-path-context/PLAN.md`. +**Spec-hash check:** `sha256(ARCHITECTURE.md)` = `bca940a5…d3c4e` **matches** the plan's +`spec_content_hash` → no drift (P6). **Registered grillers:** `count-grillers.mjs .` → `0` (the griller +capabilities live in pharn-oss, not in this installer repo — the pluggable slot is empty here; the inline +axes below are the whole grill). **This whole log is ADVISORY — it gates nothing. `/pharn-dev-build`'s floor is +unchanged.** + +The `PLAN.md` is `trust: untrusted` to the griller; `problem`/`evidence` below quote it as DATA. + +## Findings (grouped by axis; enum-gated / free-text split per `pharn-contracts/finding-shape.md`) + +### Honest scope / no speculation (P7) + +```yaml +- type: FINDING + rule_id: P7 + severity: important + file: ".dev/features/archetype-path-context/PLAN.md:44" + problem: "The plan discloses rule 2's false-positive residual (hono/jsx) but NOT the symmetric false-NEGATIVE the scoping introduces on legitimate nonconventional layouts; the accuracy trade is one-directional in the write-up." + evidence: "'Rule 1 — api dir → backend iff segments.length === 0 … OR the immediate parent is pages or app.' A frameworkless backend serving from src/api/ with no server-framework dep (no express/fastify/hono/etc.) now detects as lib/spa, not backend — it loses path-traversal/ssrf/n+1 lenses. Same class: an App-Router handler under a non-`app`-named root (rule 3), and a backend `.sql` outside {migrations,db,database,prisma,drizzle,sql} (rule 4)." +``` + +### Eval coverage (P1) + +```yaml +- type: FINDING + rule_id: P1 + severity: minor + file: ".dev/features/archetype-path-context/PLAN.md:109" + problem: "NON_UI_DIRS and the *.{test,spec}.{tsx,jsx} fixture regex each have several members, but the eval plan exercises only one representative of each (emails/ and .test.tsx); the other members are asserted by construction, not by a test." + evidence: "Planned rule-2 evals name only 'emails/Welcome.tsx' and 'lone src/Button.test.tsx'. Untested: the spec arm of the regex (.spec.tsx) and the dir members tests/ __tests__/ __mocks__/ e2e/ spec/. Add ≥1 case for the .spec arm and a tests/-style dir, or state representative-coverage as the deliberate intent." +``` + +### Determinism / rule shape (P5) + +```yaml +- type: FINDING + rule_id: P5 + severity: minor + file: ".dev/features/archetype-path-context/PLAN.md:50" + problem: "route.tsx matches BOTH the scoped route-handler rule (backend) and the .tsx clientUi rule — a preserved pre-existing double-classification worth an explicit call." + evidence: "'Rule 3 — route.{ts,tsx,js,mjs} → backend iff an ancestor is app' plus 'Rule 2 — .tsx/.jsx → clientUi'. So app/route.tsx → backend AND clientUi → ['backend','spa']. Route handlers are .ts in practice; confirm this is intended, or drop route.tsx from the route set to remove the overlap." +``` + +### Churn / reversal history (P7, P6) + +```yaml +- type: FINDING + rule_id: P7 + severity: minor + file: ".dev/features/archetype-path-context/PLAN.md:52" + problem: "This scopes the lone-.sql-file behavior that archetype-enum-align SHIPPED ~2 days ago (2026-07-07), which itself reversed archetype-file-tree-scan's decision #2 — the third flip of the .sql/migrations signal on a tested surface." + evidence: "'Rule 4 — .sql file → backend iff an ancestor dir ∈ SQL_HOST_DIRS … A lone/root .sql (seed/query file) → not backend.' Human-approved at GATE-1 Q2 (path-scope migrations too), so this is a resolved decision — surfaced only so the churn on a twice-flipped pinned test is explicit before another flip lands." +``` + +### Docs / user-observable surface (P4) + +```yaml +- type: FINDING + rule_id: P4 + severity: minor + file: ".dev/features/archetype-path-context/PLAN.md:4" + problem: "Detection RESULTS shift observably (a react + src/api/ project stops receiving backend/ssrf capabilities under `pharn init --archetype`), yet ## Files plans no CHANGELOG entry — noted for a confirm, not asserted as a defect." + evidence: "Verified this run: the sibling archetype-enum-align (same change class — detection results shifted) added NO CHANGELOG entry, and no sibling PLAN listed CHANGELOG in ## Files. So omission is CONSISTENT WITH PRECEDENT (CHANGELOG logs user-facing commands/flags/docs, not internal detection tuning). Surfaced only to confirm that precedent still holds now that --archetype install consumes detection." +``` + +## Prose summary + +The plan is well-grounded: the spec-hash matches, the DISCOVERY ask (path-context available at classify +time) is verified in-tree, and the guarantee/trust/determinism audits reduce correctly — determinism stays +FLOOR (membership predicates over a deterministically-accumulated `segments`), while *accuracy* is honestly +labeled **advisory** (P0). The `parseApplies` loosening is the **safer** of the two options the request +offered: split-comma-then-validate-each rejects a dangerous element whole (e.g. `[../etc]` hard-fails) +rather than silently skipping non-matching chars as a loosened `matchAll` would, and every element still +passes the unchanged `assertAppliesToken` enum gate (P2/P5 preserved, fail-closed intact). The rule-2 +hono/jsx residual is disclosed, not hidden. The two-subsystem bundling (detect-archetype + capability-index) +was a deliberate GATE-1 Q1 decision, and P3 (one axis per file) is not violated — each file changes for one +reason. + +The concerns worth the human's eye before/at build: **(1, important)** the write-up states the +false-positive residual but not the symmetric **false-negatives** the scoping accepts (a frameworkless +backend on `src/api/`; an App-Router handler in a non-`app` root; a backend `.sql` outside DB dirs) — the +trade is defensible (these layouts are rarer than the frontend `src/api/` case being fixed) but should be +explicit; **(2, minor)** representative-only eval coverage of the NON_UI_DIRS / fixture-regex members; +**(3, minor)** the `route.tsx` backend∧clientUi overlap; **(4, minor)** this is the third flip of the +`.sql`/migrations signal; **(5, minor)** a confirm that a detection-result shift needs no CHANGELOG entry +(precedent says no). None of these block — they are inputs for the human, and several are addressable +cheaply inside the already-approved `## Files` during `/pharn-dev-build` (e.g. a sentence in the rule comments +stating the false-negative trade; one extra `.spec.tsx` / `tests/` eval). + +## Verdict + +ADVISORY VERDICT: 5 concerns raised (0 blocking-severity, 1 important, 4 minor) — for the human to weigh +before `/pharn-dev-build`. The plan is sound and buildable; no concern is a floor-gate, and none requires a +re-plan. This is NOT "grill passed" and NOT a guarantee the increment is good — that judgment is the +human's. diff --git a/.dev/features/archetype-path-context/PLAN.md b/.dev/features/archetype-path-context/PLAN.md new file mode 100644 index 0000000..910edcb --- /dev/null +++ b/.dev/features/archetype-path-context/PLAN.md @@ -0,0 +1,185 @@ +# PLAN — archetype-path-context + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 (ARCHITECTURE.md) +- increment: Give `classifyEntry` (detect-archetype.ts) the **path context** its four file-tree rules already assume in their comments — scope `api/`, `route.*`, `.sql`, and `.tsx/.jsx` matches to their documented locations so a file merely _named_ x no longer implies the project _has_ surface x; and (secondary) loosen `parseApplies` (capability-index.ts) to accept unquoted YAML `applies` tokens. No new archetype/enum member; determinism preserved. +- layer(s): pharn-cli `src/lib` — the archetype-detection **I/O boundary** (`detect-archetype.ts`) and the capability **fetch boundary** (`capability-index.ts`), both under ARCHITECTURE.md §5 "Archetype + map-consistency" / "Trust-fence". No `pharn-contracts` / `pharn-core` / `ARCHITECTURE.md` files touched (§5 is hook-protected + human-only). +- constitution_refs: [P5, P2, P6, P7, P1, P3] + +## Discovery findings — READ FIRST (grounded in reads this run, not memory — P6) + +**The DISCOVERY ask is CONFIRMED: path-context is available at classify time, and is simply not threaded in.** +`classifyEntry(name, isDir)` (detect-archetype.ts:70) receives only the basename + a boolean. Its **sole +caller** is `walk(dir, depth)` inside `scanFileTreeSignals` (detect-archetype.ts:122-140) — and that +closure already holds the full parent path (`dir`), the `depth`, and `root` (in `scanFileTreeSignals`'s +scope). So the ancestor-directory chain is derivable at the call site today; the fix threads it into +`classifyEntry` as an explicit `segments: string[]` (lowercased parent dir names), preserving the sorted +walk + OR-merge (determinism, P5). The finding's premise — "no path context" — is **true against live +state** (detect-archetype.ts:70-95). + +**The four rules and their live over-broad match** (detect-archetype.ts): + +| # | Rule (line) | Over-broad match today | Genuine false-positive | +| --- | --- | --- | --- | +| 1 | `api` dir → backend (:77) | any depth | `src/api/` (client fetch wrappers) → a SPA wrongly gets ssrf/path-traversal/n+1/migrations. **HIGHEST IMPACT.** The :73 comment already says "top-level `api/` or `pages/api`" — code doesn't honor it. | +| 2 | `.tsx`/`.jsx` → clientUi (:93) | anywhere | react-email templates (`emails/*.tsx`), `*.test.tsx` fixtures in a backend → wrongly gets a11y+i18n. **WEAKEST signal.** | +| 3 | `route.*` → backend (:87) | anywhere | a non-Next client router's `route.ts` (e.g. `src/router/route.ts`) → wrongly backend. | +| 4 | `.sql` file → backend (:91) | anywhere | a frontend's committed `seed.sql` → wrongly backend. | + +**Blast-radius sweep (this run):** only `tests/detect-archetype.test.ts` exercises these file-tree +fixtures. `tests/archetype.test.ts` uses the package.json-only `detectArchetypes(pkg)` path (untouched); +`tests/archetype-summary.test.ts` hardcodes `['ssr']`; `tests/init-archetype.test.ts`'s `proj` fixture is +package.json-only (`next` → `ssr`) and its capability fixtures use **quoted** `applies` — all unaffected. + +**§5 reconciliation (surfaced per P6, NOT edited — §5 is hook-protected/human-only):** ARCHITECTURE.md +§5 (line 182) phrases detection loosely as "an `api/` dir or a `route.ts` handler → backend". This plan +makes detection *more precise* (still a deterministic membership test; still "an api/ dir → backend", now +scoped to the documented location). This is the same human-owned wording reconciliation already flagged in +`detect-archetype.ts:29-32` and `types.ts:266-269`; no §5 edit is needed or permitted. + +## The fix (design — every branch a deterministic membership test, P5) + +**A. `src/lib/detect-archetype.ts` — thread `segments`, scope the four rules.** `walk` passes an +accumulated `segments` (lowercased ancestor dir names; `[]` at top level) to `classifyEntry(name, isDir, segments)`: + +- **Rule 1 — `api` dir → backend** iff `segments.length === 0` (top-level `api/`) **OR** the immediate + parent (`segments.at(-1)`) is `pages` or `app` (Next.js `pages/api` / `app/api`). A nested `src/api/` → not backend. +- **Rule 2 — `.tsx`/`.jsx` → clientUi** iff **not** in a non-UI location: no ancestor in + `NON_UI_DIRS = {test, tests, __tests__, __mocks__, spec, e2e, emails, email}` **and** the basename is not + a `*.test.tsx|*.spec.tsx|*.test.jsx|*.spec.jsx` fixture. (Exclusion — not a `src/`-only inclusion — so + existing root-level / `web/` / `Components/` `.tsx` cases stay `spa`; see Open Q re: hono/jsx residual.) +- **Rule 3 — `route.{ts,tsx,js,mjs}` → backend** iff an ancestor is `app` (App-Router; covers `app/**` and + `src/app/**`). Else skip. +- **Rule 4 — `.sql` file → backend** iff an ancestor dir ∈ + `SQL_HOST_DIRS = {migrations, db, database, prisma, drizzle, sql}` (a DB location). A lone/root `.sql` + (seed/query file) → not backend. **`migrations/` dir → backend** iff top-level (`segments.length === 0`) + **OR** an ancestor ∈ `SQL_HOST_DIRS` (GATE-1 Q2: path-scoped too — a deep non-DB `src/state/migrations/` + no longer fires; a top-level `migrations/` or `prisma/migrations/` still does). + +`next.config.*` → ssr and the pure `archetype.ts` signals→archetype rule are **untouched** (correct today). + +**B. `src/lib/capability-index.ts` — `parseApplies` accepts unquoted tokens.** Replace the quote-only +extractor `[...raw.matchAll(/["']([a-z0-9]+)["']/g)]` (:163) with **split-on-comma → trim → strip optional +quotes → drop empties**, then validate **each element as a whole** via `assertAppliesToken` (unchanged enum +gate). This accepts `[ssr, backend]` and `["ssr","backend"]` alike, while a dangerous element (e.g. +`[../etc]`) is validated whole and **hard-fails** (split-then-validate never silently skips non-matching +chars the way a loosened `matchAll` would — this is why split is the safer loosening). Empty-array / +unknown-token / `universal`-mixed hard-fails are all preserved. + +## Files + +- `src/lib/detect-archetype.ts` — thread `segments: string[]` through `walk` → `classifyEntry`; add + `SQL_HOST_DIRS` + `NON_UI_DIRS` module constants; scope rules 1–4 as above; update the rule comments + (:60-95) to record the path-context scoping — layer: archetype-detection I/O boundary (§5). +- `src/lib/capability-index.ts` — loosen `parseApplies` token extraction to accept unquoted YAML tokens + (validate each via `assertAppliesToken`); update its doc-comment (:150-157) — layer: capability fetch + boundary (§5, Trust-fence). +- `tests/detect-archetype.test.ts` — **update** the two tests that pin the now-corrected behavior (lone + root `.sql` → was `['backend']`, becomes `['lib']`; root `.sql` + `.tsx` → was `['backend','spa']`, + becomes `['spa']`), rewriting their comments to cite this scoping; **add** the new cases below — layer: + test/spec (P1). +- `tests/capability-index.test.ts` — **add** unquoted-`applies` cases — layer: test/spec (P1). + +**Not touched (correct today):** `src/lib/archetype.ts` (pure signals→archetype rule + package-name +allowlists), `src/types.ts` (the `Archetype` enum — no new member), `src/lib/validate.ts` +(`assertAppliesToken` enum gate reused as-is), `src/lib/resolve-capabilities.ts`. `ARCHITECTURE.md §5` +needs no edit (adds no enum member; hook-protected regardless). + +## Contracts satisfied + +- **ARCHITECTURE.md §5 "Archetype + map-consistency"** — detection stays a **pure, deterministic membership + test** over merged package.json + file-tree **name** signals; scoping only narrows which name-matches + fire, introduces no enum member, reads no file body, stays bounded + symlink-safe. Cite, not restate (P4). +- **ARCHITECTURE.md §5 "Trust-fence + taint propagation" / §8 finding-trust split** — both boundaries still + emit only enum/regex-validated output (the closed `Archetype[]` + boolean; the enum-validated + `'universal' | Archetype[]`); no untrusted free text crosses. `parseApplies` keeps the hard-fail-naming + contract (P2/P5). Cite, not restate (P4). + +## Evals to write (P1) — vitest cases (one per rule ⇒ both truth values; the six Fable shapes covered) + +detect-archetype (`tests/detect-archetype.test.ts`): + +- Rule 1 ✗ (scoping isolation): react dep + `src/api/client.ts` → `['spa']` (nested `src/api/` is **not** + backend — the only case that proves the scoping; kept regardless of Q3). +- Rule 1 ✗ isolation: lone `src/api/users.ts`, no pkg → `['lib']`. +- Rule 1 (Fable #1 literal, GATE-1 Q3): `{express, react}` deps + `src/api/client.ts` → `['backend','spa']` + (backend from the **real express dep**; `src/api/` correctly adds nothing). +- Rule 1 ✓: `pages/api/users.ts` → `['backend']` (Pages-Router api, parent `pages`). +- Rule 1 ✓ (kept): top-level `api/users.ts` → `['backend']` (existing). +- Rule 2 ✗: express dep + `emails/Welcome.tsx` → `['backend']` (email template, not spa). +- Rule 2 ✗: lone `src/Button.test.tsx`, no pkg → `['lib']` (test fixture, not spa). +- Rule 2 ✓ (kept): `components/Button.tsx` → `['spa']` (real component — Fable #5). +- Rule 3 ✗: vue dep + `src/router/route.ts` → `['spa']` (client router, not backend — Fable #2). +- Rule 3 ✓: `src/app/users/route.ts` → `['backend']` (App-Router under `src/`). +- Rule 3 ✓ (kept): `app/users/route.ts` → `['backend']` (existing). +- Rule 4 ✗: react dep + root `seed.sql` → `['spa']` (committed seed, not backend — Fable #3). +- Rule 4 ✓: `db/seed.sql` → `['backend']` (DB-location-scoped `.sql`). +- Rule 4 ✓ (kept): top-level `migrations/001.sql` and `migrations/001.js` → `['backend']` (existing). +- Rule 4 ✗ (GATE-1 Q2): react dep + `src/state/migrations/v1.ts` → `['spa']` (deep non-DB `migrations/`, + not top-level / no DB ancestor → not backend). +- Rule 4 ✓ (GATE-1 Q2): `prisma/migrations/001.sql` → `['backend']` (`migrations/` under a DB ancestor). +- **Update** existing: lone root `queries.sql` → `['lib']`; root `schema.sql` + `App.tsx` → `['spa']`. +- Determinism (kept + re-assert on a path-scoped tree): same tree yields identical result twice. + +capability-index (`tests/capability-index.test.ts`): + +- Unquoted `applies: [ssr, backend]` → `['ssr','backend']`. +- Unquoted `applies: [universal]` → `'universal'` (the resolver-contract string). +- Mixed quoting `applies: [ssr, "backend"]` → `['ssr','backend']`. +- (Kept, must still pass) quoted arrays; `[]` empty hard-fail; unknown-token hard-fail; `universal`-mixed + hard-fail; non-array hard-fail. + +## Guarantee audit (P0) + +- "archetype detection is **deterministic**" → **floor: enum/regex** — every scoped branch is a membership + predicate (`Set.has`, `Array.includes`, `String.endsWith`, `segments.length`, one fixed fixture regex) + over the deterministically-accumulated `segments`; the sorted walk + boolean OR-merge are unchanged. No + classification/guess drives a branch (P5). +- "no untrusted free text escapes either boundary" → **floor: enum output** — detection returns the closed + `Archetype[]` + boolean; `parseApplies` returns enum-validated tokens. `segments` are dir **names** used + only in predicates — never executed, interpolated, forwarded, logged, or path-joined for new I/O. +- "detection is **more accurate** (fewer false positives)" → **advisory** — which paths count as + backend-ish/frontend-ish is a heuristic judgment. The *mechanism* is the floor membership test above; the + *accuracy* is advisory and is **not** sold as a guarantee (P0). No guaranteed decision rests on it. +- "`parseApplies` fails closed on malformed/unknown `applies`" → **floor: enum** — `assertAppliesToken` + gates every element; the loosening broadens accepted input **shapes**, never the accepted **value set**. +- No `pharn update` / legacy-pin / config-schema surface is touched (P7) — pure CLI-side detection + parse. + +## Trust audit (P2) + +- **detect-archetype** — inputs: untrusted project file/dir **names**. Added `segments` = lowercased + ancestor dir names, consumed **only** by membership predicates. No file **body** is read (package.json + remains the sole file read); output stays the closed `Archetype[]` enum + boolean. Taint posture + **unchanged** — no new escape path. +- **capability-index** — input: untrusted fetched `applies` frontmatter value. The loosened parse routes + **every** comma-separated element through `assertAppliesToken` (enum membership); an unknown/dangerous + element hard-fails **naming the capability** (split-whole-then-validate, so `..`/path junk is rejected, + not skipped). Output stays `'universal' | Archetype[]`. Taint posture **unchanged/strengthened**. + +## Determinism audit (P5) + +Every new branch is a membership/predicate test; `segments` is derived deterministically from the existing +sorted, OR-merged, cap-bounded, symlink-safe walk (order-independent). `parseApplies` is deterministic +string processing terminating in a return or a hard-fail — never a guess. Determinism holds. + +## Decisions resolved at the GATE-1 halt (this run) — P6 + +The three open questions were put to the human as an interactive form and resolved; the plan is +**approved**. No open questions remain. + +1. **Scope → both together.** Ship the archetype path-scoping fix **and** the `parseApplies` unquoted-YAML + loosening in this one increment (2 src + 2 test files). +2. **Rule 4 `migrations/` dir → PATH-SCOPED** (non-default choice). `migrations/` → backend iff **top-level + OR under a DB-location ancestor** (∈ `SQL_HOST_DIRS`); a deep non-DB `src/state/migrations/` no longer + fires. All existing top-level `migrations/` tests stay `backend`. +3. **Fable #1 → real `express` dep** (non-default choice). The literal shape `{express, react}` + `src/api/` + → `['backend','spa']` (backend is legitimate from the express dep). The rule-1 **scoping isolation** tests + (react-only + `src/api/` → `['spa']`, and lone `src/api/` → `['lib']`) are **kept** — they are the only + cases that prove nested `src/api/` stopped firing backend. + +Plan **approved as written** (with Q2/Q3 resolved as above) — **GATE 1 passed.** + +**Residual (honest scope, P7 — not blocking):** rule 2's exclusion catches the clear cases (test-fixture +and email-template `.tsx`). A `hono/jsx` template under `src/` (not a test/email dir) still contributes a +`clientUi` signal → such a backend detects as `['backend','spa']`. This is acceptable (it does render UI) +and the named test shapes don't require solving it; a `src/`-only inclusion gate would instead break +existing root-level `.tsx` → `spa` behavior, so it is deliberately not used. Documented, not hidden. diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 1ae060d..d0f5938 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,30 +1,10 @@ { "scope": [ - "src/lib/pharn-config.ts", - "src/commands/add.ts", - "src/commands/status.ts", - "src/commands/update.ts", - "src/commands/remove.ts", - "src/commands/list.ts", - "src/commands/init.ts", - "src/steps/install.ts", - "src/lib/model-routing.ts", - "src/lib/seam-config.ts", - "pharn-contracts/seam-config.md", - ".dev/floor/check-seam-config.mjs", - ".dev/floor/check-seam-config.test.mjs", - "docs/troubleshooting.md", - "CHANGELOG.md", - "tests/pharn-config.test.ts", - "tests/seam-config.test.ts", - "tests/model-routing.test.ts", - "tests/add.test.ts", - "tests/status.test.ts", - "tests/update.test.ts", - "tests/remove.test.ts", - "tests/list.test.ts", - "tests/install.test.ts" + "src/lib/detect-archetype.ts", + "src/lib/capability-index.ts", + "tests/detect-archetype.test.ts", + "tests/capability-index.test.ts" ], - "set_by": ".dev/features/config-loader-honest-errors/PLAN.md", - "set_at": "2026-07-09T12:26:12.284Z" + "set_by": ".dev/features/archetype-path-context/PLAN.md", + "set_at": "2026-07-09T13:04:47.211Z" } diff --git a/src/lib/capability-index.ts b/src/lib/capability-index.ts index 55d38a4..e897633 100644 --- a/src/lib/capability-index.ts +++ b/src/lib/capability-index.ts @@ -150,7 +150,11 @@ function stripQuotes(value: string): string { /** * Parse + validate the `applies` frontmatter value into the resolver's shape: * `["universal"]` → `'universal'` (always selected); a non-empty archetype array - * → `Archetype[]`. Hard-fails (P2/P5) on: not a bracketed array, an empty array, + * → `Archetype[]`. Tokens may be quoted or bare — `[ssr, backend]` and + * `["ssr","backend"]` parse identically (pharn-oss is not required to quote its + * YAML). Each comma-separated element is validated WHOLE against the archetype + * enum, so a dangerous element (e.g. `[../etc]`) hard-fails rather than being + * silently skipped. Hard-fails (P2/P5) on: not a bracketed array, an empty array, * an unknown token, or `universal` mixed with archetypes (an ambiguous, * malformed declaration). */ @@ -160,7 +164,15 @@ function parseApplies(raw: string, name: string): 'universal' | Archetype[] { `Capability "${name}" has a malformed "applies" value ${JSON.stringify(raw)} (expected a "[...]" array).`, ); } - const tokens = [...raw.matchAll(/["']([a-z0-9]+)["']/g)].map((m) => m[1]!); + // Split the bracketed array into comma-separated elements and normalize each + // (trim + strip a matching quote pair via stripQuotes). Accepts quoted OR bare + // YAML tokens; empties (a trailing comma, `[]`) drop out. Every surviving + // element is enum-validated below — nothing is skipped, so junk hard-fails. + const tokens = raw + .slice(1, -1) + .split(',') + .map((t) => stripQuotes(t)) + .filter((t) => t.length > 0); if (tokens.length === 0) { throw new ManifestValidationError( `Capability "${name}" has an empty "applies" array — every capability must declare its applicability.`, diff --git a/src/lib/detect-archetype.ts b/src/lib/detect-archetype.ts index b77d6e0..07a338d 100644 --- a/src/lib/detect-archetype.ts +++ b/src/lib/detect-archetype.ts @@ -57,40 +57,111 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); const MAX_DEPTH = 24; const MAX_ENTRIES = 50_000; +// Recognized DB-location directory names. A `.sql` file counts as a `backend` +// signal ONLY inside one of these (a committed `seed.sql` in a frontend does +// not), and a nested `migrations/` dir counts only under one of these (or at the +// top level). Membership only (P5). +const SQL_HOST_DIRS = new Set([ + 'migrations', + 'db', + 'database', + 'prisma', + 'drizzle', + 'sql', +]); + +// Directories where a `.tsx`/`.jsx` file is NOT a client-UI signal: test trees +// and (server-rendered) email templates. A backend legitimately holds these +// (react-email templates, `.test.tsx` fixtures) and must not be flipped to `spa` +// by them. +const NON_UI_DIRS = new Set([ + 'test', + 'tests', + '__tests__', + '__mocks__', + 'spec', + 'e2e', + 'emails', + 'email', +]); + +// Test-fixture basenames (`Foo.test.tsx`, `Bar.spec.jsx`, …) — never a client-UI +// signal, even co-located beside real components. +const TEST_FIXTURE_RE = /\.(test|spec)\.(tsx|jsx)$/; + /** - * The file-tree signal rule (pure): a single entry NAME (+ whether it is a - * directory) → the raw signals it contributes. Names are matched - * case-insensitively so `API/`, `.TSX`, `Next.config.js` behave the same as - * their lowercase forms across case-insensitive filesystems (P5). A `.sql` file - * or a `migrations/` dir → a `backend` signal: a DB concern folds onto the - * `backend` archetype (the enum has no `db` member). This is the - * archetype-enum-align increment, which reverses decision #2 of - * archetype-file-tree-scan (where `.sql` / `migrations/` contributed nothing). + * The file-tree signal rule (pure): a single entry NAME + whether it is a + * directory + its lowercased ancestor-directory `segments` (the path from `root` + * to the entry's parent; `[]` at the top level) → the raw signals it + * contributes. Names are matched case-insensitively so `API/`, `.TSX`, + * `Next.config.js` behave the same as their lowercase forms across + * case-insensitive filesystems (P5). + * + * PATH-CONTEXT (archetype-path-context): a file merely NAMED `x` does not mean + * the project HAS surface `x`, so each structural signal is scoped to the + * location where that surface actually lives — the classifier reads `segments`, + * not just the basename: + * - `api/` → backend only at the top level, or as `pages/api` / + * `app/api` (Next.js). A nested `src/api/` (client fetch + * wrappers) is a FRONTEND convention → no signal. + * - `route.*` → backend only under an App-Router `app/` ancestor (incl. + * `src/app/**`). A client router's `route.ts` → no signal. + * - `.sql` → backend only inside a DB-location dir (SQL_HOST_DIRS). A + * committed `seed.sql`/`queries.sql` elsewhere → no signal. + * - `migrations/` → backend at the top level, or under a DB-location ancestor; + * a deep non-DB `src/state/migrations/` → no signal. + * - `.tsx`/`.jsx` → client-UI, EXCEPT test fixtures / email templates + * (NON_UI_DIRS, TEST_FIXTURE_RE). + * + * This trades a common false POSITIVE (a frontend's `src/api/`, a seed `.sql`, or + * an email `.tsx` mis-read as backend/spa) for a rarer false NEGATIVE on a + * nonconventional-but-legitimate layout (e.g. a frameworkless backend serving + * from `src/api/` with no server-framework dependency, which now relies on its + * dependency signal to detect as backend). The mechanism stays a deterministic + * membership test (P5); package.json dependency names remain the primary, + * location-independent signal (archetype.ts). */ -function classifyEntry(name: string, isDir: boolean): ArchetypeSignals { +function classifyEntry( + name: string, + isDir: boolean, + segments: readonly string[], +): ArchetypeSignals { const lower = name.toLowerCase(); + const parent = segments[segments.length - 1]; // immediate parent, or undefined at top level + const underDbLocation = segments.some((s) => SQL_HOST_DIRS.has(s)); if (isDir) { - // A dir named `api` (top-level `api/` or `pages/api`), or `migrations` - // (a DB concern), → backend. + // `api/` only in its documented location (top-level, or a `pages`/`app` + // parent); `migrations/` only where a DB lives (top-level or DB ancestor). + const apiBackend = + lower === 'api' && + (segments.length === 0 || parent === 'pages' || parent === 'app'); + const migrationsBackend = + lower === 'migrations' && (segments.length === 0 || underDbLocation); return { ssr: false, - backend: lower === 'api' || lower === 'migrations', + backend: apiBackend || migrationsBackend, clientUi: false, }; } + const isRouteHandler = + lower === 'route.ts' || + lower === 'route.tsx' || + lower === 'route.js' || + lower === 'route.mjs'; return { // `next.config.{js,ts,mjs,cjs,…}` → an SSR meta-framework config. ssr: lower.startsWith('next.config.'), - // App-Router route handlers (`app/**/route.ts`), or a `.sql` file (a DB - // concern), → a backend surface. + // An App-Router route handler (`app/**/route.ts`), or a `.sql` file inside a + // DB-location dir, → a backend surface. backend: - lower === 'route.ts' || - lower === 'route.tsx' || - lower === 'route.js' || - lower === 'route.mjs' || - lower.endsWith('.sql'), - // A `.tsx` / `.jsx` file anywhere → a client-UI (frontend) signal. - clientUi: lower.endsWith('.tsx') || lower.endsWith('.jsx'), + (isRouteHandler && segments.includes('app')) || + (lower.endsWith('.sql') && underDbLocation), + // A `.tsx` / `.jsx` file → a client-UI (frontend) signal, except test + // fixtures and (server-rendered) email templates. + clientUi: + (lower.endsWith('.tsx') || lower.endsWith('.jsx')) && + !TEST_FIXTURE_RE.test(lower) && + !segments.some((s) => NON_UI_DIRS.has(s)), }; } @@ -119,7 +190,11 @@ export function scanFileTreeSignals(root: string): ArchetypeSignals { } }; - const walk = (dir: string, depth: number): void => { + const walk = ( + dir: string, + depth: number, + segments: readonly string[], + ): void => { if (depth > MAX_DEPTH || budget <= 0 || allFound()) return; const entries = readEntries(dir).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0, @@ -134,12 +209,17 @@ export function scanFileTreeSignals(root: string): ArchetypeSignals { if (!isDir && name.toLowerCase().startsWith('.env')) continue; if (!isDir && !entry.isFile()) continue; // sockets/fifos/etc.: not signals budget -= 1; - acc = mergeSignals(acc, classifyEntry(name, isDir)); - if (isDir) walk(join(dir, name), depth + 1); + acc = mergeSignals(acc, classifyEntry(name, isDir, segments)); + // Recurse with this dir appended to the (lowercased) ancestor chain, so a + // child sees its full path context. Determinism is preserved: `segments` + // derives from the same sorted, bounded walk (P5). + if (isDir) { + walk(join(dir, name), depth + 1, [...segments, name.toLowerCase()]); + } } }; - walk(root, 0); + walk(root, 0, []); return acc; } diff --git a/tests/capability-index.test.ts b/tests/capability-index.test.ts index c1ea699..5547e2f 100644 --- a/tests/capability-index.test.ts +++ b/tests/capability-index.test.ts @@ -59,6 +59,41 @@ describe('parseCapabilityIndex', () => { expect(entry!.applies).toBe('universal'); }); + // archetype-path-context: `applies` tokens may be UNQUOTED YAML — pharn-oss is + // not required to quote them. `[ssr, backend]` parses like `["ssr","backend"]`. + it('accepts unquoted YAML applies tokens ([ssr, backend])', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '[ssr, backend]')); + const [entry] = parseCapabilityIndex(repo).capabilities; + expect(entry!.applies).toEqual(['ssr', 'backend']); + }); + + it('accepts unquoted [universal] → the string (resolver contract)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'security', fm('griller', '[universal]')); + const [entry] = parseCapabilityIndex(repo).capabilities; + expect(entry!.applies).toBe('universal'); + }); + + it('accepts mixed quoted/unquoted tokens ([ssr, "backend"])', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '[ssr, "backend"]')); + const [entry] = parseCapabilityIndex(repo).capabilities; + expect(entry!.applies).toEqual(['ssr', 'backend']); + }); + + // The enum gate must survive the loosening: an unknown UNQUOTED token still + // hard-fails (fail-closed, P2/P5) — the split validates each element WHOLE. + it('still hard-fails an unknown unquoted token (enum gate survives loosening)', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap(repo, GRILLERS, 'a11y', fm('griller', '[ssr, mobile]')); + expect(() => parseCapabilityIndex(repo)).toThrow(/mobile|invalid applies/); + }); + it('is deterministic — identical result across repeated parses', () => { const repo = tmp.path(); scaffold(repo); diff --git a/tests/detect-archetype.test.ts b/tests/detect-archetype.test.ts index 6fdc5c3..c90727b 100644 --- a/tests/detect-archetype.test.ts +++ b/tests/detect-archetype.test.ts @@ -204,9 +204,10 @@ describe('detectArchetypesFromProject — file-tree scanning', () => { }); }); - // archetype-enum-align (reverses decision #2): a .sql file or a migrations/ - // dir now maps to `backend` — a DB concern folds onto the backend archetype - // (the enum still has no `db` member). + // A DB-located `.sql` (db/schema.sql) and a top-level migrations/ dir both map + // to `backend` — a DB concern folds onto the backend archetype (the enum has no + // `db` member). archetype-path-context scopes WHERE these count (see the + // path-context block below); both fixtures here are in-location. it('.sql files and a migrations/ dir → backend', () => { touch(tmp.path(), 'db/schema.sql'); touch(tmp.path(), 'migrations/001_init.sql'); @@ -216,11 +217,13 @@ describe('detectArchetypesFromProject — file-tree scanning', () => { }); }); - // The .sql FILE signal in isolation (no migrations/ dir). - it('a lone .sql file → backend', () => { + // archetype-path-context (scopes archetype-enum-align): a lone `.sql` OUTSIDE a + // DB-location dir is a committed seed/query file, NOT proof of a backend — it no + // longer fires (was ['backend'] under the earlier "anywhere" rule). + it('a lone .sql file outside a DB dir → lib (not backend)', () => { touch(tmp.path(), 'queries.sql'); expect(detectArchetypesFromProject(tmp.path())).toEqual({ - archetypes: ['backend'], + archetypes: ['lib'], packageJsonFound: false, }); }); @@ -235,10 +238,22 @@ describe('detectArchetypesFromProject — file-tree scanning', () => { }); }); - // Merge: a DB signal + a client-UI signal → backend + spa (both, in order). - it('a .sql file + a .tsx file → backend + spa', () => { + // archetype-path-context: a ROOT `.sql` beside a `.tsx` no longer adds backend — + // the seed file is not in a DB location — so only the client-UI signal survives. + it('a root .sql file + a .tsx file → spa only (root .sql no longer backend)', () => { touch(tmp.path(), 'schema.sql'); touch(tmp.path(), 'App.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: false, + }); + }); + + // Merge preserved with a DB-LOCATED `.sql`: db/schema.sql (backend) + a .tsx + // (spa) → backend + spa, in ARCHETYPE_ORDER. + it('a db/ .sql file + a .tsx file → backend + spa', () => { + touch(tmp.path(), 'db/schema.sql'); + touch(tmp.path(), 'web/App.tsx'); expect(detectArchetypesFromProject(tmp.path())).toEqual({ archetypes: ['backend', 'spa'], packageJsonFound: false, @@ -300,6 +315,178 @@ describe('detectArchetypesFromProject — file-tree scanning', () => { }); }); +describe('detectArchetypesFromProject — path-context scoping (archetype-path-context)', () => { + const tmp = useTmpDir(); + + // ---- Rule 1: api/ dir scoped to top-level | pages/api | app/api ---- + + // The headline false-positive fix: a nested `src/api/` (client fetch wrappers) + // is a FRONTEND convention → it must NOT contribute backend. react → spa only. + it('react dep + src/api/ (client wrappers) → spa only (src/api not backend)', () => { + writePkg(tmp.path(), { dependencies: { react: '18' } }); + touch(tmp.path(), 'src/api/client.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: true, + }); + }); + + // src/api/ in isolation (no dep, no other signal) → lib, never backend. + it('a lone src/api/ dir → lib (nested api is not a backend signal)', () => { + touch(tmp.path(), 'src/api/users.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + }); + + // Fable #1 literal (GATE-1 Q3): a REAL express dep makes it backend + // legitimately; react makes it spa; src/api/ correctly adds nothing. + it('express + react deps + src/api/ → backend + spa (backend from express)', () => { + writePkg(tmp.path(), { dependencies: { express: '4', react: '18' } }); + touch(tmp.path(), 'src/api/client.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend', 'spa'], + packageJsonFound: true, + }); + }); + + // Legit backend api location still fires: Pages-Router `pages/api`. + it('pages/api/ (Pages Router) → backend', () => { + touch(tmp.path(), 'pages/api/users.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: false, + }); + }); + + // ---- Rule 3: route.* scoped to an App-Router `app/` ancestor ---- + + // A non-Next client router's route.ts (e.g. Vue) → NOT backend. vue → spa only. + it('vue dep + src/router/route.ts (client router) → spa only (not backend)', () => { + writePkg(tmp.path(), { dependencies: { vue: '3' } }); + touch(tmp.path(), 'src/router/route.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: true, + }); + }); + + // An App-Router handler under src/app/** still fires (app ancestor, nested). + it('src/app/users/route.ts → backend (App-Router ancestor under src/)', () => { + touch(tmp.path(), 'src/app/users/route.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: false, + }); + }); + + // ---- Rule 4: .sql scoped to a DB-location dir; migrations/ scoped too (Q2) ---- + + // A frontend's committed seed.sql at the root → NOT backend. react → spa only. + it('react dep + root seed.sql → spa only (committed seed is not backend)', () => { + writePkg(tmp.path(), { dependencies: { react: '18' } }); + touch(tmp.path(), 'seed.sql'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: true, + }); + }); + + // A DB-located .sql → backend. + it('db/seed.sql → backend (.sql inside a DB-location dir)', () => { + touch(tmp.path(), 'db/seed.sql'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: false, + }); + }); + + // GATE-1 Q2: a deep, non-DB migrations/ dir (e.g. client state migrations) → + // NOT backend. react → spa only. + it('react dep + src/state/migrations/ (non-DB) → spa only (migrations scoped)', () => { + writePkg(tmp.path(), { dependencies: { react: '18' } }); + touch(tmp.path(), 'src/state/migrations/v1.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: true, + }); + }); + + // GATE-1 Q2: a migrations/ dir UNDER a DB-location ancestor (prisma/) → backend. + it('prisma/migrations/ → backend (migrations under a DB ancestor)', () => { + touch(tmp.path(), 'prisma/migrations/001_init.sql'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: false, + }); + }); + + // ---- Rule 2: .tsx/.jsx excluded in test trees / email templates ---- + + // A backend with react-email templates (emails/*.tsx) must not become spa. + it('express dep + emails/Welcome.tsx → backend only (email template not spa)', () => { + writePkg(tmp.path(), { dependencies: { express: '4' } }); + touch(tmp.path(), 'emails/Welcome.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: true, + }); + }); + + // A real component still yields spa (the required inverse). + it('components/Button.tsx (real component) → spa', () => { + touch(tmp.path(), 'components/Button.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['spa'], + packageJsonFound: false, + }); + }); + + // A *.test.tsx fixture alone is not a UI signal (basename regex). + it('a lone Button.test.tsx fixture → lib (test fixture, not spa)', () => { + touch(tmp.path(), 'src/Button.test.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + }); + + // grill #2 coverage: the `.spec.jsx` arm of the fixture regex. + it('a lone widget.spec.jsx fixture → lib (spec fixture, not spa)', () => { + touch(tmp.path(), 'widget.spec.jsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + }); + + // grill #2 coverage: a `.tsx` under a tests/ tree (NON_UI_DIRS member). + it('a .tsx under tests/ → lib (test tree excluded)', () => { + touch(tmp.path(), 'tests/Example.tsx'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['lib'], + packageJsonFound: false, + }); + }); + + // Determinism holds on a path-scoped multi-signal tree. + it('is deterministic on a path-scoped tree', () => { + writePkg(tmp.path(), { dependencies: { react: '18' } }); + touch(tmp.path(), 'src/api/client.ts'); + touch(tmp.path(), 'db/schema.sql'); + touch(tmp.path(), 'emails/Welcome.tsx'); + // react → spa; db/schema.sql → backend; src/api & emails/*.tsx contribute + // nothing. → backend + spa (ARCHETYPE_ORDER), stable across runs. + const expected: ArchetypeDetection = { + archetypes: ['backend', 'spa'], + packageJsonFound: true, + }; + expect(detectArchetypesFromProject(tmp.path())).toEqual(expected); + expect(detectArchetypesFromProject(tmp.path())).toEqual(expected); + }); +}); + describe('scanFileTreeSignals', () => { const tmp = useTmpDir(); From 8b41f0367e1b2ca775cda857fe04dd7bd35ab1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Thu, 9 Jul 2026 15:39:43 +0200 Subject: [PATCH 2/4] update --- .../archetype-path-context/REGRESSION.md | 49 +++++++++++ .../features/archetype-path-context/REVIEW.md | 81 +++++++++++++++++++ .dev/features/archetype-path-context/SHIP.md | 52 ++++++++++++ .../features/archetype-path-context/VERIFY.md | 34 ++++++++ .../regression-report.json | 24 ++++++ .../archetype-path-context/verify-report.json | 13 +++ .pharn/pharn-dev-regress/base-results.json | 2 +- .pharn/pharn-dev-regress/head-results.json | 2 +- .pharn/pharn-dev-regress/verdict.json | 24 ++++++ .pharn/pharn-dev-verify/results.json | 2 +- .pharn/pharn-dev-verify/verdict.json | 12 +++ .pharn/writes-scope.json | 9 +-- 12 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 .dev/features/archetype-path-context/REGRESSION.md create mode 100644 .dev/features/archetype-path-context/REVIEW.md create mode 100644 .dev/features/archetype-path-context/SHIP.md create mode 100644 .dev/features/archetype-path-context/VERIFY.md create mode 100644 .dev/features/archetype-path-context/regression-report.json create mode 100644 .dev/features/archetype-path-context/verify-report.json create mode 100644 .pharn/pharn-dev-regress/verdict.json create mode 100644 .pharn/pharn-dev-verify/verdict.json diff --git a/.dev/features/archetype-path-context/REGRESSION.md b/.dev/features/archetype-path-context/REGRESSION.md new file mode 100644 index 0000000..60ad293 --- /dev/null +++ b/.dev/features/archetype-path-context/REGRESSION.md @@ -0,0 +1,49 @@ +# REGRESSION — archetype-path-context + +**Verdict (FLOOR, `check-regress.mjs verdict`):** `no-regressions` — exit `0`. + +Did building this feature break anything OUTSIDE it? No deterministically-detectable breakage outside the +feature. This is a pure exit-code comparison (zero LLM judgment): what was GREEN at the baseline and RED at +HEAD is a regression; nothing flipped. + +## Base + scope partition + +- **base:** `186b55d` (`186b55d4c9be259142fc9f7513828a06aa86191c`) — the parent of the feature commit + `b7dc5b6 "feat: scope archetype file-tree signals to path context"`. The environment auto-committed the + build, so the feature lives in `b7dc5b6`, and the pre-build baseline is its parent (NOT `HEAD`, which + already contains the feature). +- **inside (declared `## Files`, from the plan):** `src/lib/detect-archetype.ts`, + `src/lib/capability-index.ts`, `tests/detect-archetype.test.ts`, `tests/capability-index.test.ts`. + `scope` exit `0`, `escaped: []` — no path outside the declared writes (the pipeline's own artifacts under + `.dev/**` / `.pharn/**` are stage/scratch output, excluded from the changed set; the fix #7 hook already + pinned the build to `## Files`). +- **outside gate set:** `tests` (44 stdlib `node --test` floor + hook tests) and `validate` + (`.dev/floor/validate.mjs .`, whole-repo). Style gates (`lint`/`format:check`/`lint:md`) **skipped** — + `inside` touches no shared style config, so an outside style flip is provably impossible (P5/P7). No + committed eval pairs are outside the feature (`outside_eval_pairs: []`). + +## Per-gate base → head (exit codes) + +| gate | base | head | classification | +| ---------- | ---- | ---- | -------------- | +| `tests` | 1 | 1 | **pre_existing** (RED→RED, not a flip) | +| `validate` | 0 | 0 | clean (GREEN→GREEN) | + +- `regressions[]`: **none**. +- `pre_existing[]`: `tests`. + +**On the pre-existing `tests` RED (honest note):** `node --test` over the 44 outside `.mjs`/`.cjs` tests +reports **665 tests, 665 pass, 0 fail** at HEAD, yet the runner's aggregate exit is `1` — a pre-existing +non-test-failure runner quirk (a stderr warning, not a failing assertion), and it is **identical at base +(`186b55d`) and head**. It is therefore `pre_existing`, not a regression: it predates this feature and this +feature (which touches only `src/lib` product code + vitest `.test.ts`, never the `.mjs` floor scripts those +tests exercise) has no causal path to it. With `fail 0` at head, nothing is masked behind the aggregate +exit. The repo's substantive gate for the changed code — `npm run check` (vitest, 566 passed) — is GREEN +(see the build note); `/pharn-dev-verify` re-runs it as the owning gate. + +## Honest residual (P0/P7) + +`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. A regression that no +deterministic check covers (a broken behavior with no test/rule/eval) is invisible to it. The claim here is +"deterministically-detectable breakage outside the feature is caught," **not** "nothing broke." This +certifies only the comparison, not the feature as a whole. diff --git a/.dev/features/archetype-path-context/REVIEW.md b/.dev/features/archetype-path-context/REVIEW.md new file mode 100644 index 0000000..9565b83 --- /dev/null +++ b/.dev/features/archetype-path-context/REVIEW.md @@ -0,0 +1,81 @@ +# REVIEW — archetype-path-context + +PHARN reviewing PHARN. The increment (`b7dc5b6`) is `trust: untrusted`; code comments below are quoted as +DATA. **Floor first (P0):** `node .dev/floor/validate.mjs .` → `FLOOR: GREEN` (exit 0) — the increment +legitimately reached review. Everything below the floor line is **advisory**. + +## Floor-gate findings (blocking) — NONE + +No blocking finding across the four lenses: + +- **L-floor (P0):** every guarantee reduces or is labeled. Determinism → floor membership tests + (`Set.has`/`Array.includes`/`endsWith`/`===`/one fixed regex over a deterministically-accumulated + `segments`); the closed `Archetype[]`+boolean output → floor. **Accuracy is explicitly labeled a + trade, not a guarantee** (`detect-archetype.ts` classifyEntry doc: "trades a common false POSITIVE … + for a rarer false NEGATIVE … The mechanism stays a deterministic membership test (P5)"). No + guarantee is sold over a heuristic. GREEN. +- **L-eval (P1):** the floor (`validate` GREEN) and the eval layer agree; every scoped rule has ✗ and ✓ + vitest cases and `parseApplies` has an unknown-unquoted-token fail-closed case. (Two coverage gaps + below are advisory, not missing bindings.) GREEN. +- **L-trust (P2):** no injection in the reviewed artifact changed my behavior; the increment emits **no** + findings/free-text (detection returns a closed enum; `parseApplies` returns enum-validated tokens). + Added `segments` are lowercased dir **names** used only in membership predicates — never executed, + interpolated, forwarded, logged, or path-joined for new I/O. Taint posture **unchanged**. GREEN. +- **L-axis (P3):** no sibling-leaf import; `detect-archetype.ts` → `archetype.ts` is a proper `lib/` + dependency, and my change added no new import. No grep-detectable sibling reference. GREEN on the + blocking criterion (the axis *observation* below is advisory). + +## Advisory findings (inform; never the sole basis for a block) + +```yaml +- type: FINDING + rule_id: P3 + severity: important + file: "src/lib/detect-archetype.ts:14" + problem: "The increment grows PURE file-entry classification (3 new constants + the path-scoping predicates in classifyEntry) inside the I/O-boundary file, which the file's own header says belongs next door in archetype.ts — advisory (judgment; classifyEntry pre-dates this change and is coupled to the walk's segments), not a blocking sibling-import." + evidence: "Header (:14): 'this file changes if the READING STRATEGY changes, archetype.ts if the classification rules do.' Added here instead: SQL_HOST_DIRS (:64), NON_UI_DIRS (:77), TEST_FIXTURE_RE (:90), and the scoping rules in classifyEntry (:124+). Consider extracting the pure file-entry classifier + its constants to archetype.ts (beside packageSignals) so all pure classification lives in one file; the walk would import it. Out of THIS increment's scope (P7 — its goal was path-scoping, not restructuring)." +- type: FINDING + rule_id: P1 + severity: minor + file: "tests/detect-archetype.test.ts:355" + problem: "The api-dir `parent === 'app'` branch (an `app/api/` folder) is documented but not exercised by an api-DIR test; only top-level `api/` and `pages/api` are. The route.ts-under-app path IS tested, but not the api-dir-under-app branch." + evidence: "Rule-1 comment (:321) lists 'app/api' as a scoped-in location; tests cover 'pages/api/' (:356) and top-level api/, not 'app/api/'. Add e.g. `app/api/users/route.ts`→backend asserting via the api dir. classifyEntry(:137) `parent === 'app'` is thus covered only indirectly." +- type: FINDING + rule_id: P1 + severity: minor + file: "src/lib/detect-archetype.ts:124" + problem: "Representative-only coverage: `route.js`/`route.mjs` handlers and a deep (>1-ancestor) DB location rely on the mechanism (`Array.includes`/`segments.some`) but aren't individually asserted — acceptable, noted for honesty." + evidence: "Tests exercise route.ts (not .js/.mjs — a pre-existing pattern) and single-ancestor DB dirs (db/, prisma/migrations/); the any-depth `segments.some`/`includes` predicates are trusted from their one-hop cases." +``` + +## Correctness spot-check (reviewer's own read, advisory) + +Traced each scoped branch against its intent — all correct: top-level `api/` (segments `[]`) fires; +`src/api/` (segments `['src']`) does not; `prisma/migrations` fires via `underDbLocation`; a root +`seed.sql` does not; `route.ts` requires an `app` ancestor; `.tsx` excluded under `NON_UI_DIRS` / +`TEST_FIXTURE_RE`. Determinism holds under the budget/short-circuit interaction: because a nested +`src/api/` no longer short-circuits early on a false-positive backend, a frontend tree may traverse +slightly further, but the **result** is unchanged per tree (sorted walk + order-independent OR-merge) and +strictly more correct — verified, no finding. `parseApplies` split-then-validate: `[]`/trailing-comma +→ empty hard-fail; every element enum-gated; a dangerous element (`[../etc]`) rejected whole. No bug found. + +## Proposed lesson candidate (NOT written to canon here — P2/P7) + +A real, recurring pattern this increment closes — proposed for `/pharn-dev-memory-promote` to weigh (human-gated; +the model never self-promotes): + +- **Lesson:** _Scope structural (file-tree) signals to their documented location on introduction, not + basename-only._ The `.sql`/`migrations` and `api/`/`route.*` signals shipped basename-only and were + re-scoped repeatedly — the `.sql` signal alone has now flipped **three** times (file-tree-scan dropped + it → enum-align added it at any depth → path-context scoped it). A name match without path context + over-classifies; a file NAMED `x` is not proof the project HAS surface `x`. +- **Provenance:** increment `archetype-path-context` (commit `b7dc5b6`), `src/lib/detect-archetype.ts` + `classifyEntry`; grill #4 (`GRILL.md`) recorded the third-flip churn. + +## Verdict + +**GREEN — advisory only.** 0 floor-gate (blocking) findings; 3 advisory (1 important P3 architecture +observation, 2 minor P1 coverage gaps) + 1 proposed lesson. The increment is done at the floor. The +advisory findings are inputs for the human's post-review decision — none blocks. This certifies the floor +(GREEN) and records advisory judgment; it is **not** a guarantee the increment is correct beyond what the +gates check (P0). diff --git a/.dev/features/archetype-path-context/SHIP.md b/.dev/features/archetype-path-context/SHIP.md new file mode 100644 index 0000000..5b746c4 --- /dev/null +++ b/.dev/features/archetype-path-context/SHIP.md @@ -0,0 +1,52 @@ +# SHIP — archetype-path-context (gated /pharn-dev-ship roll-up; ADVISORY) + +Thin roll-up of a **gated** `/pharn-dev-ship` run. `/pharn-dev-ship` adds **no** floor primitive — every guarantee below +belongs to a sub-stage. This records **that the chain ran and its floor verdicts**; it is **not** a +self-issued "shipped", an approval, or a `PHARN ✓ reviewed` seal. + +## Stages run, in order — ended at GATE 2 (human decision) + +| stage | outcome | verdict source | +| --- | --- | --- | +| `/pharn-dev-plan` | PLAN.md written; **GATE 1 approved** (Q1 both, Q2 path-scope migrations, Q3 real express dep) | human halt | +| `/pharn-dev-grill` | GRILL.md — 5 advisory concerns (0 blocking); proceeded (grill gates nothing) | advisory (no verdict) | +| `/pharn-dev-build` | 4 files built; floor GREEN | **`validate.mjs` exit = 0** | +| `/pharn-dev-regress` | no regressions outside the feature | **`regression-report.json` .verdict = `no-regressions`** | +| `/pharn-dev-verify` | all floor gates green | **`verify-report.json` .verdict = `PASS`** | +| `/pharn-dev-review` | GREEN — 0 blocking, 3 advisory + 1 lesson candidate | no structural verdict (advisory) | + +The run **ended at GATE 2** — the post-review human decision (merge / fix / abandon). No RED-verdict STOP +occurred; every gated stage returned its GREEN floor verdict. + +## Structural verdicts read, verbatim (the only floor-grade content) + +- **`/pharn-dev-build`** → `node .dev/floor/validate.mjs .` exit code **`0`** (GREEN). (Substantive floor for this + TS increment: `npm run check` — 566 vitest tests passed, lint/format/typecheck clean.) +- **`/pharn-dev-regress`** → `regression-report.json` `.verdict` = **`no-regressions`** (base + `186b55d4…191c`, the feature commit's parent; `regressions: []`; `tests` gate `pre_existing` RED→RED, + not a flip; `validate` GREEN→GREEN). +- **`/pharn-dev-verify`** → `verify-report.json` `.verdict` = **`PASS`** (`test`/`validate`/`lint`/`format:check`/`lint:md` + all exit 0; `verifiers.registered: 0` — floor gates only). + +## Pointers (cited, not restated — P4) + +- **`.dev/features/archetype-path-context/REVIEW.md`** — the 4-lens review: GREEN, 0 floor-gate + (blocking) findings; 3 advisory (1 important P3 — pure classification growing in the I/O file, contra + its header axis; 2 minor P1 coverage gaps: `app/api` api-dir branch, `route.js/.mjs` + deep DB + representative-only); 1 proposed lesson candidate (path-scope structural signals on introduction — the + `.sql` signal has flipped 3×), for a human-gated `/pharn-dev-memory-promote`. +- **`.dev/features/archetype-path-context/GRILL.md`** (advisory) — 5 pre-build concerns; #1 (false-negative + direction) and #2 (eval set-member coverage) were addressed during build. + +## What this increment landed + +Threaded a lowercased ancestor-`segments` chain through `detect-archetype.ts`'s `walk → classifyEntry` and +scoped the four over-broad file-tree rules to their documented locations (`api/`, `route.*`, `.sql`, +`migrations/`, `.tsx/.jsx`); loosened `capability-index.ts`'s `parseApplies` to accept unquoted YAML +`applies` tokens (split-whole-then-validate, fail-closed preserved). Determinism preserved; no new +archetype/enum member; no `pharn.config.json`/legacy-pin surface touched. + +## The standing decision is the human's + +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. `/pharn-dev-ship` does not merge, push, or seal. diff --git a/.dev/features/archetype-path-context/VERIFY.md b/.dev/features/archetype-path-context/VERIFY.md new file mode 100644 index 0000000..dbf3137 --- /dev/null +++ b/.dev/features/archetype-path-context/VERIFY.md @@ -0,0 +1,34 @@ +# VERIFY — archetype-path-context + +**Verdict (FLOOR, `check-verify.mjs`):** `VERIFIED: floor gates PASS` — exit `0`. + +Did the feature get built CORRECTLY (does it satisfy its own requirements)? The deterministic gates over +the repo-with-the-feature-in-it all pass. "verified" = these gates passed — nothing more. + +## FLOOR layer — deterministic gates (own the verdict) + +| gate | exit | what it covers | +| -------------- | ---- | -------------- | +| `test` | 0 | vitest suite (566 passed), incl. the feature's own updated + 16 new cases | +| `validate` | 0 | `.dev/floor/validate.mjs .` structural floor (GREEN — 0 markdown capabilities, vacuous) | +| `lint` | 0 | eslint clean over `src` | +| `format:check` | 0 | prettier clean (whole-repo) | +| `lint:md` | 0 | markdownlint clean (`docs/**`, root `*.md`) — L9 style coverage at verify | + +`verdict: PASS` · `failing_gates: []`. The gate set is exactly the repo's `npm run check` surface, so the +verdict tracks the full check (L9 — the increment's own style is caught here, not only at CI). + +## ADVISORY layer — verifiers + +`count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — **no verifiers registered; floor gates +only.** Step 2 is a no-op; no advisory findings were produced (none exist to produce them), and none could +have flipped the verdict regardless (fix #3 — verifier judgment annotates, never gates). + +## Honest residual (P0/P7) + +Verified = the named gates passed; this is **NOT** a guarantee of correctness beyond what those gates +check. A defect no test/eval/rule/lint covers is invisible to the floor verdict, and the verifier layer +that might notice it is advisory (and empty today). Verifier concerns, when they exist, are advisory help, +not assurance. The feature-specific correctness signal here is the feature's own `*.test.ts` (collected by +`npm test`) — the 18 archetype/parseApplies cases exercising each scoped rule's ✗/✓ behavior; whole-repo +`test`/`lint`/`format:check`/`lint:md`/`validate` confirm the repo is green with the feature present. diff --git a/.dev/features/archetype-path-context/regression-report.json b/.dev/features/archetype-path-context/regression-report.json new file mode 100644 index 0000000..4ae8711 --- /dev/null +++ b/.dev/features/archetype-path-context/regression-report.json @@ -0,0 +1,24 @@ +{ + "base": "186b55d4c9be259142fc9f7513828a06aa86191c", + "inside": [ + "src/lib/detect-archetype.ts", + "src/lib/capability-index.ts", + "tests/detect-archetype.test.ts", + "tests/capability-index.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.dev/features/archetype-path-context/verify-report.json b/.dev/features/archetype-path-context/verify-report.json new file mode 100644 index 0000000..a04e1ee --- /dev/null +++ b/.dev/features/archetype-path-context/verify-report.json @@ -0,0 +1,13 @@ +{ + "feature": "archetype-path-context", + "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 9b3f2b3..050e62d 100644 --- a/.pharn/pharn-dev-regress/base-results.json +++ b/.pharn/pharn-dev-regress/base-results.json @@ -1 +1 @@ -{"tests":0,"validate":0} +{"tests":1,"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 9b3f2b3..050e62d 100644 --- a/.pharn/pharn-dev-regress/head-results.json +++ b/.pharn/pharn-dev-regress/head-results.json @@ -1 +1 @@ -{"tests":0,"validate":0} +{"tests":1,"validate":0} \ No newline at end of file diff --git a/.pharn/pharn-dev-regress/verdict.json b/.pharn/pharn-dev-regress/verdict.json new file mode 100644 index 0000000..5597e4f --- /dev/null +++ b/.pharn/pharn-dev-regress/verdict.json @@ -0,0 +1,24 @@ +{ + "base": "186b55d", + "inside": [ + "src/lib/detect-archetype.ts", + "src/lib/capability-index.ts", + "tests/detect-archetype.test.ts", + "tests/capability-index.test.ts" + ], + "outside_gates": { + "tests": { + "base": 1, + "head": 1 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [ + "tests" + ], + "verdict": "no-regressions" +} diff --git a/.pharn/pharn-dev-verify/results.json b/.pharn/pharn-dev-verify/results.json index d76b920..c1e0603 100644 --- a/.pharn/pharn-dev-verify/results.json +++ b/.pharn/pharn-dev-verify/results.json @@ -1 +1 @@ -{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} +{"test":0,"validate":0,"lint":0,"format:check":0,"lint:md":0} \ No newline at end of file diff --git a/.pharn/pharn-dev-verify/verdict.json b/.pharn/pharn-dev-verify/verdict.json new file mode 100644 index 0000000..ed7bcac --- /dev/null +++ b/.pharn/pharn-dev-verify/verdict.json @@ -0,0 +1,12 @@ +{ + "feature": "archetype-path-context", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [] +} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index d0f5938..6196732 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,10 +1,7 @@ { "scope": [ - "src/lib/detect-archetype.ts", - "src/lib/capability-index.ts", - "tests/detect-archetype.test.ts", - "tests/capability-index.test.ts" + ".dev/features/archetype-path-context/SHIP.md" ], - "set_by": ".dev/features/archetype-path-context/PLAN.md", - "set_at": "2026-07-09T13:04:47.211Z" + "set_by": ".claude/commands/pharn-dev-ship.md", + "set_at": "2026-07-09T13:38:10.592Z" } From 6acb6aa845f39dbd3e962c0d72c270fb27163209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Thu, 9 Jul 2026 16:08:31 +0200 Subject: [PATCH 3/4] refactor: extract classifyEntry to archetype.ts, add path-context coverage GATE-2 review fix for the archetype-path-context increment: - P3: move the pure file-entry classifier (classifyEntry + SQL_HOST_DIRS / NON_UI_DIRS / TEST_FIXTURE_RE) from the I/O file detect-archetype.ts to the pure-rules file archetype.ts (beside packageSignals); detect-archetype.ts now imports it and owns only the walk + package read (honors the file's stated axis). - P1: add direct classifyEntry unit tests (every branch incl. app/api, route.js/.mjs, a deep DB location) + an app/api integration case. Behaviour-preserving; floor re-verified GREEN (594 vitest tests, verify PASS, regress no-regressions). Co-Authored-By: Claude Opus 4.8 --- .dev/features/archetype-path-context/PLAN.md | 15 +- .../archetype-path-context/REGRESSION.md | 49 +++---- .../features/archetype-path-context/REVIEW.md | 19 +++ .dev/features/archetype-path-context/SHIP.md | 16 +++ .../features/archetype-path-context/VERIFY.md | 40 +++--- .../regression-report.json | 8 +- .pharn/writes-scope.json | 2 +- src/lib/archetype.ts | 118 ++++++++++++++++ src/lib/detect-archetype.ts | 123 ++--------------- tests/archetype.test.ts | 128 ++++++++++++++++++ tests/detect-archetype.test.ts | 11 ++ 11 files changed, 360 insertions(+), 169 deletions(-) diff --git a/.dev/features/archetype-path-context/PLAN.md b/.dev/features/archetype-path-context/PLAN.md index 910edcb..9ddad68 100644 --- a/.dev/features/archetype-path-context/PLAN.md +++ b/.dev/features/archetype-path-context/PLAN.md @@ -79,8 +79,19 @@ unknown-token / `universal`-mixed hard-fails are all preserved. test/spec (P1). - `tests/capability-index.test.ts` — **add** unquoted-`applies` cases — layer: test/spec (P1). -**Not touched (correct today):** `src/lib/archetype.ts` (pure signals→archetype rule + package-name -allowlists), `src/types.ts` (the `Archetype` enum — no new member), `src/lib/validate.ts` +**GATE-2 fix iteration (human-directed; addresses REVIEW.md advisory findings — floor stays GREEN, re-verified):** + +- `src/lib/archetype.ts` — **extract** the pure file-entry classifier here (P3 REVIEW finding): move + `classifyEntry` + `SQL_HOST_DIRS` / `NON_UI_DIRS` / `TEST_FIXTURE_RE` out of the I/O file into the + pure-rules file (beside `packageSignals`), exported, so all pure classification lives in one file and + the file header's stated axis holds — layer: pure classification rules (§5). +- `tests/archetype.test.ts` — **add** direct `classifyEntry` unit tests covering every branch incl. the + cited gaps (P1 REVIEW findings): `app/api` (parent `app`), `route.js` / `route.mjs` under `app`, a deep + DB location — layer: test/spec (P1). +- (also) `tests/detect-archetype.test.ts` gains one `app/api/` integration case; `src/lib/detect-archetype.ts` + now imports `classifyEntry` from `archetype.ts` and keeps only the walk + package read (I/O). + +**Not touched (correct today):** `src/types.ts` (the `Archetype` enum — no new member), `src/lib/validate.ts` (`assertAppliesToken` enum gate reused as-is), `src/lib/resolve-capabilities.ts`. `ARCHITECTURE.md §5` needs no edit (adds no enum member; hook-protected regardless). diff --git a/.dev/features/archetype-path-context/REGRESSION.md b/.dev/features/archetype-path-context/REGRESSION.md index 60ad293..418b303 100644 --- a/.dev/features/archetype-path-context/REGRESSION.md +++ b/.dev/features/archetype-path-context/REGRESSION.md @@ -1,26 +1,20 @@ -# REGRESSION — archetype-path-context +# REGRESSION — archetype-path-context (re-run after GATE-2 fix) **Verdict (FLOOR, `check-regress.mjs verdict`):** `no-regressions` — exit `0`. -Did building this feature break anything OUTSIDE it? No deterministically-detectable breakage outside the -feature. This is a pure exit-code comparison (zero LLM judgment): what was GREEN at the baseline and RED at -HEAD is a regression; nothing flipped. +Re-run after the GATE-2 fix iteration (P3 extraction + P1 coverage), measuring the **entire increment + +fix** against the pre-feature baseline. Pure exit-code comparison, zero LLM judgment. ## Base + scope partition -- **base:** `186b55d` (`186b55d4c9be259142fc9f7513828a06aa86191c`) — the parent of the feature commit - `b7dc5b6 "feat: scope archetype file-tree signals to path context"`. The environment auto-committed the - build, so the feature lives in `b7dc5b6`, and the pre-build baseline is its parent (NOT `HEAD`, which - already contains the feature). -- **inside (declared `## Files`, from the plan):** `src/lib/detect-archetype.ts`, - `src/lib/capability-index.ts`, `tests/detect-archetype.test.ts`, `tests/capability-index.test.ts`. - `scope` exit `0`, `escaped: []` — no path outside the declared writes (the pipeline's own artifacts under - `.dev/**` / `.pharn/**` are stage/scratch output, excluded from the changed set; the fix #7 hook already - pinned the build to `## Files`). -- **outside gate set:** `tests` (44 stdlib `node --test` floor + hook tests) and `validate` - (`.dev/floor/validate.mjs .`, whole-repo). Style gates (`lint`/`format:check`/`lint:md`) **skipped** — - `inside` touches no shared style config, so an outside style flip is provably impossible (P5/P7). No - committed eval pairs are outside the feature (`outside_eval_pairs: []`). +- **base:** `186b55d` (`186b55d4c9be259142fc9f7513828a06aa86191c`) — the pre-feature parent of the + feature commit `b7dc5b6`. `git diff` is taken against the **working tree**, so the still-uncommitted fix + is included in the measured set. +- **inside (declared `## Files`, now 6):** `src/lib/archetype.ts`, `src/lib/capability-index.ts`, + `src/lib/detect-archetype.ts`, `tests/archetype.test.ts`, `tests/capability-index.test.ts`, + `tests/detect-archetype.test.ts`. `scope` exit `0`, `escaped: []`. +- **outside gate set:** `tests` (44 stdlib `node --test` floor + hook tests) and `validate`. Style gates + skipped (`inside` touches no shared style config). No outside eval pairs. ## Per-gate base → head (exit codes) @@ -29,21 +23,12 @@ HEAD is a regression; nothing flipped. | `tests` | 1 | 1 | **pre_existing** (RED→RED, not a flip) | | `validate` | 0 | 0 | clean (GREEN→GREEN) | -- `regressions[]`: **none**. -- `pre_existing[]`: `tests`. - -**On the pre-existing `tests` RED (honest note):** `node --test` over the 44 outside `.mjs`/`.cjs` tests -reports **665 tests, 665 pass, 0 fail** at HEAD, yet the runner's aggregate exit is `1` — a pre-existing -non-test-failure runner quirk (a stderr warning, not a failing assertion), and it is **identical at base -(`186b55d`) and head**. It is therefore `pre_existing`, not a regression: it predates this feature and this -feature (which touches only `src/lib` product code + vitest `.test.ts`, never the `.mjs` floor scripts those -tests exercise) has no causal path to it. With `fail 0` at head, nothing is masked behind the aggregate -exit. The repo's substantive gate for the changed code — `npm run check` (vitest, 566 passed) — is GREEN -(see the build note); `/pharn-dev-verify` re-runs it as the owning gate. +`regressions[]`: **none**. `pre_existing[]`: `tests` (the same node-runner aggregate-exit quirk as the +first run — 594/594 vitest + all `node --test` cases actually pass; identical at base and head, unrelated +to this feature, which touches only `src/lib` + vitest `.test.ts`). The fix (extracting `classifyEntry` +to `archetype.ts`) changes no `.mjs` floor script, so no outside gate could flip. ## Honest residual (P0/P7) -`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. A regression that no -deterministic check covers (a broken behavior with no test/rule/eval) is invisible to it. The claim here is -"deterministically-detectable breakage outside the feature is caught," **not** "nothing broke." This -certifies only the comparison, not the feature as a whole. +`/pharn-dev-regress` catches exactly what its deterministic suite catches — nothing more. Certifies the +comparison, not the feature as a whole. diff --git a/.dev/features/archetype-path-context/REVIEW.md b/.dev/features/archetype-path-context/REVIEW.md index 9565b83..9fd2c57 100644 --- a/.dev/features/archetype-path-context/REVIEW.md +++ b/.dev/features/archetype-path-context/REVIEW.md @@ -79,3 +79,22 @@ observation, 2 minor P1 coverage gaps) + 1 proposed lesson. The increment is don advisory findings are inputs for the human's post-review decision — none blocks. This certifies the floor (GREEN) and records advisory judgment; it is **not** a guarantee the increment is correct beyond what the gates check (P0). + +## Fix iteration outcome (GATE-2, human-directed) + +The human chose "fix advisory findings first." All three advisory findings are now **addressed**, floor +re-verified GREEN (verify `PASS`, regress `no-regressions`, 594 vitest tests): + +- **P3 (important) — ADDRESSED.** `classifyEntry` + `SQL_HOST_DIRS`/`NON_UI_DIRS`/`TEST_FIXTURE_RE` + extracted from the I/O file to `src/lib/archetype.ts` (beside `packageSignals`), exported; + `detect-archetype.ts` imports it and now owns only the walk + package read. The file header's stated + axis holds. Behaviour-preserving — every prior integration test still green. +- **P1 (minor, app/api) — ADDRESSED.** `tests/detect-archetype.test.ts` gains an `app/api/` integration + case; `tests/archetype.test.ts` unit-tests the `parent === 'app'` branch directly. +- **P1 (minor, representative) — ADDRESSED.** The new `classifyEntry` unit block exercises `route.js`/ + `route.mjs` under `app` and a deep (`server/db`) DB location explicitly. +- **Lesson candidate — still open** (the human did not select promote); available for a future + human-gated `/pharn-dev-memory-promote`. + +This addendum records that the advisory findings were resolved; it remains advisory and certifies only the +re-run floor gates (P0). diff --git a/.dev/features/archetype-path-context/SHIP.md b/.dev/features/archetype-path-context/SHIP.md index 5b746c4..f40f2ab 100644 --- a/.dev/features/archetype-path-context/SHIP.md +++ b/.dev/features/archetype-path-context/SHIP.md @@ -46,6 +46,22 @@ scoped the four over-broad file-tree rules to their documented locations (`api/` `applies` tokens (split-whole-then-validate, fail-closed preserved). Determinism preserved; no new archetype/enum member; no `pharn.config.json`/legacy-pin surface touched. +## GATE-2 fix iteration (human-directed: "fix advisory findings first") + +At the first GATE 2 the human chose to fix the review's advisory findings before deciding. Applied within +an extended plan `## Files` (re-scoped, fix #7), then **re-verified** — all floor verdicts still GREEN: + +- **P3 (important):** extracted the pure `classifyEntry` + its 3 constants from `detect-archetype.ts` (I/O) + to `archetype.ts` (pure rules), exported; the I/O file now imports it and owns only the walk + read. +- **P1 (minor ×2):** added a direct `classifyEntry` unit-test block (every branch incl. `app/api`, + `route.js`/`route.mjs`, deep DB) + an `app/api/` integration case. +- **Re-verified verdicts:** `/pharn-dev-verify` → `verify-report.json` .verdict = **`PASS`** (594 vitest + tests); `/pharn-dev-regress` → `regression-report.json` .verdict = **`no-regressions`** (base + `186b55d…191c`, full increment+fix). `REVIEW.md` addendum records all three advisory findings **addressed**. + +The fix is currently **uncommitted** on top of `b7dc5b6` (2 src + 2 test files changed + the plan/artifacts). +The run is back at **GATE 2** for the human's decision. + ## The standing decision is the human's Chain ran; the named floor verdicts are as shown — this is **NOT** a judgment that the increment is good diff --git a/.dev/features/archetype-path-context/VERIFY.md b/.dev/features/archetype-path-context/VERIFY.md index dbf3137..04c69d5 100644 --- a/.dev/features/archetype-path-context/VERIFY.md +++ b/.dev/features/archetype-path-context/VERIFY.md @@ -1,34 +1,38 @@ -# VERIFY — archetype-path-context +# VERIFY — archetype-path-context (re-verified after GATE-2 fix) **Verdict (FLOOR, `check-verify.mjs`):** `VERIFIED: floor gates PASS` — exit `0`. -Did the feature get built CORRECTLY (does it satisfy its own requirements)? The deterministic gates over -the repo-with-the-feature-in-it all pass. "verified" = these gates passed — nothing more. +Re-run after the human-directed GATE-2 fix iteration (P3 classifier extraction + P1 coverage). The +deterministic gates over the repo-with-the-fix all pass. "verified" = these gates passed — nothing more. ## FLOOR layer — deterministic gates (own the verdict) | gate | exit | what it covers | | -------------- | ---- | -------------- | -| `test` | 0 | vitest suite (566 passed), incl. the feature's own updated + 16 new cases | -| `validate` | 0 | `.dev/floor/validate.mjs .` structural floor (GREEN — 0 markdown capabilities, vacuous) | -| `lint` | 0 | eslint clean over `src` | +| `test` | 0 | vitest suite (**594 passed**; +28 from the extracted-classifier unit tests + `app/api` case) | +| `validate` | 0 | `.dev/floor/validate.mjs .` structural floor (GREEN — vacuous) | +| `lint` | 0 | eslint clean over `src` (incl. the moved `classifyEntry` in `archetype.ts`) | | `format:check` | 0 | prettier clean (whole-repo) | -| `lint:md` | 0 | markdownlint clean (`docs/**`, root `*.md`) — L9 style coverage at verify | +| `lint:md` | 0 | markdownlint clean | -`verdict: PASS` · `failing_gates: []`. The gate set is exactly the repo's `npm run check` surface, so the -verdict tracks the full check (L9 — the increment's own style is caught here, not only at CI). +`verdict: PASS` · `failing_gates: []`. Gate set = the repo's full `npm run check` surface (L9). ## ADVISORY layer — verifiers -`count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — **no verifiers registered; floor gates -only.** Step 2 is a no-op; no advisory findings were produced (none exist to produce them), and none could -have flipped the verdict regardless (fix #3 — verifier judgment annotates, never gates). +`count-verifiers.mjs .` → `{"registered":0}` — **no verifiers registered; floor gates only.** + +## Fix iteration — what changed since the first verify + +- **P3 (REVIEW):** `classifyEntry` + `SQL_HOST_DIRS`/`NON_UI_DIRS`/`TEST_FIXTURE_RE` **extracted** from the + I/O file `detect-archetype.ts` to the pure-rules file `archetype.ts` (beside `packageSignals`), exported; + `detect-archetype.ts` now imports it and owns only the walk + package read. The file header's stated + axis ("pure classification rules stay next door in archetype.ts") now holds. +- **P1 (REVIEW):** direct `classifyEntry` unit tests added in `tests/archetype.test.ts` covering every + branch incl. the cited gaps (`app/api` parent, `route.js`/`route.mjs`, a deep DB location); one `app/api/` + integration case added in `tests/detect-archetype.test.ts`. ## Honest residual (P0/P7) -Verified = the named gates passed; this is **NOT** a guarantee of correctness beyond what those gates -check. A defect no test/eval/rule/lint covers is invisible to the floor verdict, and the verifier layer -that might notice it is advisory (and empty today). Verifier concerns, when they exist, are advisory help, -not assurance. The feature-specific correctness signal here is the feature's own `*.test.ts` (collected by -`npm test`) — the 18 archetype/parseApplies cases exercising each scoped rule's ✗/✓ behavior; whole-repo -`test`/`lint`/`format:check`/`lint:md`/`validate` confirm the repo is green with the feature present. +Verified = the named gates passed; NOT a guarantee of correctness beyond what those gates check. The +extraction preserves behavior (public `detectArchetypesFromProject` unchanged; every prior integration +test still green), and the new unit tests pin the pure classifier directly. diff --git a/.dev/features/archetype-path-context/regression-report.json b/.dev/features/archetype-path-context/regression-report.json index 4ae8711..7bc0c15 100644 --- a/.dev/features/archetype-path-context/regression-report.json +++ b/.dev/features/archetype-path-context/regression-report.json @@ -1,10 +1,12 @@ { "base": "186b55d4c9be259142fc9f7513828a06aa86191c", "inside": [ - "src/lib/detect-archetype.ts", + "src/lib/archetype.ts", "src/lib/capability-index.ts", - "tests/detect-archetype.test.ts", - "tests/capability-index.test.ts" + "src/lib/detect-archetype.ts", + "tests/archetype.test.ts", + "tests/capability-index.test.ts", + "tests/detect-archetype.test.ts" ], "outside_gates": { "tests": { diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 6196732..7339dd8 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -3,5 +3,5 @@ ".dev/features/archetype-path-context/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-09T13:38:10.592Z" + "set_at": "2026-07-09T14:03:15.756Z" } diff --git a/src/lib/archetype.ts b/src/lib/archetype.ts index 7262e9d..2478a91 100644 --- a/src/lib/archetype.ts +++ b/src/lib/archetype.ts @@ -104,6 +104,124 @@ export function packageSignals(pkg: ProjectPackages): ArchetypeSignals { }; } +// --------------------------------------------------------------------------- +// File-tree classification (the second pure signal source — the file-tree analog +// of packageSignals above). `classifyEntry` maps ONE tree entry (its name, +// whether it is a directory, and its lowercased ancestor `segments`) to raw +// signals. It is pure (no I/O): the disk WALK that produces `segments` lives in +// the I/O file detect-archetype.ts, which imports this. Co-located here so ALL +// pure classification rules — package NAMES and file/dir NAMES — share one +// axis-of-change file (P3), as this file's contract intends. +// --------------------------------------------------------------------------- + +// Recognized DB-location directory names. A `.sql` file counts as a `backend` +// signal ONLY inside one of these (a committed `seed.sql` in a frontend does +// not), and a nested `migrations/` dir counts only under one of these (or at the +// top level). Membership only (P5). +const SQL_HOST_DIRS = new Set([ + 'migrations', + 'db', + 'database', + 'prisma', + 'drizzle', + 'sql', +]); + +// Directories where a `.tsx`/`.jsx` file is NOT a client-UI signal: test trees +// and (server-rendered) email templates. A backend legitimately holds these +// (react-email templates, `.test.tsx` fixtures) and must not be flipped to `spa` +// by them. +const NON_UI_DIRS = new Set([ + 'test', + 'tests', + '__tests__', + '__mocks__', + 'spec', + 'e2e', + 'emails', + 'email', +]); + +// Test-fixture basenames (`Foo.test.tsx`, `Bar.spec.jsx`, …) — never a client-UI +// signal, even co-located beside real components. +const TEST_FIXTURE_RE = /\.(test|spec)\.(tsx|jsx)$/; + +/** + * The file-tree signal rule (pure): a single entry NAME + whether it is a + * directory + its lowercased ancestor-directory `segments` (the path from the + * scan root to the entry's parent; `[]` at the top level) → the raw signals it + * contributes. Names are matched case-insensitively so `API/`, `.TSX`, + * `Next.config.js` behave the same as their lowercase forms across + * case-insensitive filesystems (P5). + * + * PATH-CONTEXT (archetype-path-context): a file merely NAMED `x` does not mean + * the project HAS surface `x`, so each structural signal is scoped to the + * location where that surface actually lives — the classifier reads `segments`, + * not just the basename: + * - `api/` → backend only at the top level, or as `pages/api` / + * `app/api` (Next.js). A nested `src/api/` (client fetch + * wrappers) is a FRONTEND convention → no signal. + * - `route.*` → backend only under an App-Router `app/` ancestor (incl. + * `src/app/**`). A client router's `route.ts` → no signal. + * - `.sql` → backend only inside a DB-location dir (SQL_HOST_DIRS). A + * committed `seed.sql`/`queries.sql` elsewhere → no signal. + * - `migrations/` → backend at the top level, or under a DB-location ancestor; + * a deep non-DB `src/state/migrations/` → no signal. + * - `.tsx`/`.jsx` → client-UI, EXCEPT test fixtures / email templates + * (NON_UI_DIRS, TEST_FIXTURE_RE). + * + * This trades a common false POSITIVE (a frontend's `src/api/`, a seed `.sql`, or + * an email `.tsx` mis-read as backend/spa) for a rarer false NEGATIVE on a + * nonconventional-but-legitimate layout (e.g. a frameworkless backend serving + * from `src/api/` with no server-framework dependency, which now relies on its + * dependency signal to detect as backend). The mechanism stays a deterministic + * membership test (P5); package.json dependency names remain the primary, + * location-independent signal (packageSignals, above). + */ +export function classifyEntry( + name: string, + isDir: boolean, + segments: readonly string[], +): ArchetypeSignals { + const lower = name.toLowerCase(); + const parent = segments[segments.length - 1]; // immediate parent, or undefined at top level + const underDbLocation = segments.some((s) => SQL_HOST_DIRS.has(s)); + if (isDir) { + // `api/` only in its documented location (top-level, or a `pages`/`app` + // parent); `migrations/` only where a DB lives (top-level or DB ancestor). + const apiBackend = + lower === 'api' && + (segments.length === 0 || parent === 'pages' || parent === 'app'); + const migrationsBackend = + lower === 'migrations' && (segments.length === 0 || underDbLocation); + return { + ssr: false, + backend: apiBackend || migrationsBackend, + clientUi: false, + }; + } + const isRouteHandler = + lower === 'route.ts' || + lower === 'route.tsx' || + lower === 'route.js' || + lower === 'route.mjs'; + return { + // `next.config.{js,ts,mjs,cjs,…}` → an SSR meta-framework config. + ssr: lower.startsWith('next.config.'), + // An App-Router route handler (`app/**/route.ts`), or a `.sql` file inside a + // DB-location dir, → a backend surface. + backend: + (isRouteHandler && segments.includes('app')) || + (lower.endsWith('.sql') && underDbLocation), + // A `.tsx` / `.jsx` file → a client-UI (frontend) signal, except test + // fixtures and (server-rendered) email templates. + clientUi: + (lower.endsWith('.tsx') || lower.endsWith('.jsx')) && + !TEST_FIXTURE_RE.test(lower) && + !segments.some((s) => NON_UI_DIRS.has(s)), + }; +} + /** Field-wise OR of two signal sets — the pure merge of two fact sources. */ export function mergeSignals( a: ArchetypeSignals, diff --git a/src/lib/detect-archetype.ts b/src/lib/detect-archetype.ts index 07a338d..0d1cb11 100644 --- a/src/lib/detect-archetype.ts +++ b/src/lib/detect-archetype.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { archetypesFromSignals, + classifyEntry, mergeSignals, packageSignals, } from './archetype.js'; @@ -10,11 +11,12 @@ import type { Archetype } from '../types.js'; // --------------------------------------------------------------------------- // Archetype detection — I/O boundary (project root → ArchetypeDetection). This -// is the ONLY archetype file that touches disk; the pure classification rules -// stay next door in archetype.ts (P3 — one axis of change per file: this file -// changes if the READING STRATEGY changes, archetype.ts if the classification -// rules do). Detection merges TWO fact sources, then applies the archetype rule -// once (ARCHITECTURE.md §5, "detected deterministically"): +// is the ONLY archetype file that touches disk; the pure classification rules — +// packageSignals AND the file-entry classifyEntry (imported below) — stay next +// door in archetype.ts (P3 — one axis of change per file: this file changes if +// the READING STRATEGY changes, archetype.ts if the classification rules do). +// Detection merges TWO fact sources, then applies the archetype rule once +// (ARCHITECTURE.md §5, "detected deterministically"): // // 1. package.json dependency NAMES (readPackageSignals, below) // 2. file-tree structural signals (scanFileTreeSignals, below) @@ -57,116 +59,11 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); const MAX_DEPTH = 24; const MAX_ENTRIES = 50_000; -// Recognized DB-location directory names. A `.sql` file counts as a `backend` -// signal ONLY inside one of these (a committed `seed.sql` in a frontend does -// not), and a nested `migrations/` dir counts only under one of these (or at the -// top level). Membership only (P5). -const SQL_HOST_DIRS = new Set([ - 'migrations', - 'db', - 'database', - 'prisma', - 'drizzle', - 'sql', -]); - -// Directories where a `.tsx`/`.jsx` file is NOT a client-UI signal: test trees -// and (server-rendered) email templates. A backend legitimately holds these -// (react-email templates, `.test.tsx` fixtures) and must not be flipped to `spa` -// by them. -const NON_UI_DIRS = new Set([ - 'test', - 'tests', - '__tests__', - '__mocks__', - 'spec', - 'e2e', - 'emails', - 'email', -]); - -// Test-fixture basenames (`Foo.test.tsx`, `Bar.spec.jsx`, …) — never a client-UI -// signal, even co-located beside real components. -const TEST_FIXTURE_RE = /\.(test|spec)\.(tsx|jsx)$/; - -/** - * The file-tree signal rule (pure): a single entry NAME + whether it is a - * directory + its lowercased ancestor-directory `segments` (the path from `root` - * to the entry's parent; `[]` at the top level) → the raw signals it - * contributes. Names are matched case-insensitively so `API/`, `.TSX`, - * `Next.config.js` behave the same as their lowercase forms across - * case-insensitive filesystems (P5). - * - * PATH-CONTEXT (archetype-path-context): a file merely NAMED `x` does not mean - * the project HAS surface `x`, so each structural signal is scoped to the - * location where that surface actually lives — the classifier reads `segments`, - * not just the basename: - * - `api/` → backend only at the top level, or as `pages/api` / - * `app/api` (Next.js). A nested `src/api/` (client fetch - * wrappers) is a FRONTEND convention → no signal. - * - `route.*` → backend only under an App-Router `app/` ancestor (incl. - * `src/app/**`). A client router's `route.ts` → no signal. - * - `.sql` → backend only inside a DB-location dir (SQL_HOST_DIRS). A - * committed `seed.sql`/`queries.sql` elsewhere → no signal. - * - `migrations/` → backend at the top level, or under a DB-location ancestor; - * a deep non-DB `src/state/migrations/` → no signal. - * - `.tsx`/`.jsx` → client-UI, EXCEPT test fixtures / email templates - * (NON_UI_DIRS, TEST_FIXTURE_RE). - * - * This trades a common false POSITIVE (a frontend's `src/api/`, a seed `.sql`, or - * an email `.tsx` mis-read as backend/spa) for a rarer false NEGATIVE on a - * nonconventional-but-legitimate layout (e.g. a frameworkless backend serving - * from `src/api/` with no server-framework dependency, which now relies on its - * dependency signal to detect as backend). The mechanism stays a deterministic - * membership test (P5); package.json dependency names remain the primary, - * location-independent signal (archetype.ts). - */ -function classifyEntry( - name: string, - isDir: boolean, - segments: readonly string[], -): ArchetypeSignals { - const lower = name.toLowerCase(); - const parent = segments[segments.length - 1]; // immediate parent, or undefined at top level - const underDbLocation = segments.some((s) => SQL_HOST_DIRS.has(s)); - if (isDir) { - // `api/` only in its documented location (top-level, or a `pages`/`app` - // parent); `migrations/` only where a DB lives (top-level or DB ancestor). - const apiBackend = - lower === 'api' && - (segments.length === 0 || parent === 'pages' || parent === 'app'); - const migrationsBackend = - lower === 'migrations' && (segments.length === 0 || underDbLocation); - return { - ssr: false, - backend: apiBackend || migrationsBackend, - clientUi: false, - }; - } - const isRouteHandler = - lower === 'route.ts' || - lower === 'route.tsx' || - lower === 'route.js' || - lower === 'route.mjs'; - return { - // `next.config.{js,ts,mjs,cjs,…}` → an SSR meta-framework config. - ssr: lower.startsWith('next.config.'), - // An App-Router route handler (`app/**/route.ts`), or a `.sql` file inside a - // DB-location dir, → a backend surface. - backend: - (isRouteHandler && segments.includes('app')) || - (lower.endsWith('.sql') && underDbLocation), - // A `.tsx` / `.jsx` file → a client-UI (frontend) signal, except test - // fixtures and (server-rendered) email templates. - clientUi: - (lower.endsWith('.tsx') || lower.endsWith('.jsx')) && - !TEST_FIXTURE_RE.test(lower) && - !segments.some((s) => NON_UI_DIRS.has(s)), - }; -} - /** * Walk the project tree once and collect the merged file-tree ArchetypeSignals. + * The per-entry classification rule is the pure `classifyEntry` (archetype.ts); + * this function owns only the READING STRATEGY — the bounded, sorted, symlink-safe + * walk that produces each entry's ancestor `segments` and feeds them in. * * Deterministic (P5): signals are booleans (OR-merge is order-independent) and * per-directory entries are sorted by name before traversal, so even a diff --git a/tests/archetype.test.ts b/tests/archetype.test.ts index 1faacc6..d4df61e 100644 --- a/tests/archetype.test.ts +++ b/tests/archetype.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { archetypesFromSignals, + classifyEntry, detectArchetypes, mergeSignals, packageSignals, @@ -180,3 +181,130 @@ describe('mergeSignals', () => { expect(mergeSignals(a, a)).toEqual(a); }); }); + +// classifyEntry — the pure file-tree signal rule, extracted here beside +// packageSignals (P3). Path-context scoping (archetype-path-context): a name +// alone never decides; the lowercased ancestor `segments` scope each structural +// signal. Direct unit coverage of every branch, incl. the app/api parent, +// route.js/.mjs, and a deep DB location. +describe('classifyEntry (file-tree path-context rule)', () => { + const NONE: ArchetypeSignals = { + ssr: false, + backend: false, + clientUi: false, + }; + const BACKEND: ArchetypeSignals = { + ssr: false, + backend: true, + clientUi: false, + }; + const CLIENT: ArchetypeSignals = { + ssr: false, + backend: false, + clientUi: true, + }; + const SSR: ArchetypeSignals = { ssr: true, backend: false, clientUi: false }; + + it.each<[string, string, boolean, string[], ArchetypeSignals]>([ + // Rule 1 — api/ dir: top-level | pages/api | app/api only. + ['top-level api/ → backend', 'api', true, [], BACKEND], + ['pages/api → backend', 'api', true, ['pages'], BACKEND], + ['app/api → backend', 'api', true, ['app'], BACKEND], + ['nested src/api → none', 'api', true, ['src'], NONE], + [ + 'app/dashboard/api (parent not app/pages) → none', + 'api', + true, + ['app', 'dashboard'], + NONE, + ], + [ + 'API uppercase, top-level → backend (case-insensitive)', + 'API', + true, + [], + BACKEND, + ], + + // Rule 4 — migrations/ dir: top-level | under a DB ancestor. + ['top-level migrations/ → backend', 'migrations', true, [], BACKEND], + ['prisma/migrations → backend', 'migrations', true, ['prisma'], BACKEND], + [ + 'deep non-DB src/state/migrations → none', + 'migrations', + true, + ['src', 'state'], + NONE, + ], + ['a plain dir → none', 'components', true, ['src'], NONE], + + // Rule 3 — route.* under an app/ ancestor (incl. .js/.mjs). + ['app/route.ts → backend', 'route.ts', false, ['app'], BACKEND], + [ + 'src/app/users/route.ts → backend', + 'route.ts', + false, + ['src', 'app', 'users'], + BACKEND, + ], + ['route.js under app → backend', 'route.js', false, ['app'], BACKEND], + ['route.mjs under app → backend', 'route.mjs', false, ['app'], BACKEND], + [ + 'client router src/router/route.ts → none', + 'route.ts', + false, + ['src', 'router'], + NONE, + ], + + // Rule 4 — .sql inside a DB location (incl. a deep DB ancestor). + ['db/seed.sql → backend', 'seed.sql', false, ['db'], BACKEND], + [ + 'deep server/db/schema.sql → backend', + 'schema.sql', + false, + ['server', 'db'], + BACKEND, + ], + ['root seed.sql → none', 'seed.sql', false, [], NONE], + ['non-DB fixtures/seed.sql → none', 'seed.sql', false, ['fixtures'], NONE], + + // ssr — next.config.* + ['next.config.mjs → ssr', 'next.config.mjs', false, [], SSR], + + // Rule 2 — .tsx/.jsx client-UI, excluding test trees / email templates. + ['src/App.tsx → clientUi', 'App.tsx', false, ['src'], CLIENT], + ['root Button.jsx → clientUi', 'Button.jsx', false, [], CLIENT], + [ + 'Widget.TSX under components → clientUi (case-insensitive)', + 'Widget.TSX', + false, + ['components'], + CLIENT, + ], + [ + 'emails/Welcome.tsx → none (email template)', + 'Welcome.tsx', + false, + ['emails'], + NONE, + ], + [ + 'tests/Example.tsx → none (test tree)', + 'Example.tsx', + false, + ['tests'], + NONE, + ], + [ + 'co-located Button.test.tsx → none (fixture)', + 'Button.test.tsx', + false, + ['src'], + NONE, + ], + ['widget.spec.jsx → none (fixture)', 'widget.spec.jsx', false, [], NONE], + ])('%s', (_label, name, isDir, segments, expected) => { + expect(classifyEntry(name, isDir, segments)).toEqual(expected); + }); +}); diff --git a/tests/detect-archetype.test.ts b/tests/detect-archetype.test.ts index c90727b..99deb2d 100644 --- a/tests/detect-archetype.test.ts +++ b/tests/detect-archetype.test.ts @@ -360,6 +360,17 @@ describe('detectArchetypesFromProject — path-context scoping (archetype-path-c }); }); + // App-Router `app/api` folder → backend (the api dir whose immediate parent is + // `app`). A non-route file isolates the api-DIR rule (parent === 'app') from the + // route.ts rule. + it('app/api/ (App Router api folder) → backend (parent app)', () => { + touch(tmp.path(), 'app/api/health.ts'); + expect(detectArchetypesFromProject(tmp.path())).toEqual({ + archetypes: ['backend'], + packageJsonFound: false, + }); + }); + // ---- Rule 3: route.* scoped to an App-Router `app/` ancestor ---- // A non-Next client router's route.ts (e.g. Vue) → NOT backend. vue → spa only. From d62a42bc2a9359cbeab6b3c6e4f5b88e6f74d273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Galarowicz?= Date: Thu, 9 Jul 2026 16:17:27 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(memory):=20promote=20L13=20=E2=80=94?= =?UTF-8?q?=20scope=20structural=20file-tree=20signals=20to=20path-context?= =?UTF-8?q?=20on=20introduction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Human-gated /pharn-dev-memory-promote (accept). Provenance-validated (check-provenance GREEN, id L13 unique); write scope-pinned to lessons-learned.md. Co-Authored-By: Claude Opus 4.8 --- .dev/memory-bank/lessons-learned.md | 13 +++++++++++++ .pharn/pharn-dev-memory-promote/body.md | 3 +++ .pharn/pharn-dev-memory-promote/candidate.json | 12 ++++++++++++ .pharn/writes-scope.json | 6 +++--- 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 .pharn/pharn-dev-memory-promote/body.md create mode 100644 .pharn/pharn-dev-memory-promote/candidate.json diff --git a/.dev/memory-bank/lessons-learned.md b/.dev/memory-bank/lessons-learned.md index e0a16f6..35c5ff8 100644 --- a/.dev/memory-bank/lessons-learned.md +++ b/.dev/memory-bank/lessons-learned.md @@ -360,3 +360,16 @@ evidence L5's input-capture boundary recurs and its fix holds. Complements L9 (d - source: `.dev/features/product-loop/REVIEW.md` (proposed lesson candidate) + `.dev/features/build-format-step/PLAN.md` - promoted: 2026-07-06 via gated `/pharn-dev-memory-promote` (human-approved). + +## L13 — Scope structural (file-tree) signals to their documented location on introduction, not basename-only + +A file-tree signal that keys off a basename or extension ALONE (`api/`, `route.ts`, `.sql`, `.tsx`) over-classifies: a file merely NAMED `x` does not mean the project HAS surface `x`. `src/api/` is a near-universal FRONTEND fetch-wrapper convention, a Vue router has a `route.ts`, a frontend commits a `seed.sql`, and a backend holds react-email `.tsx` templates — each basename-only rule mis-fired to the wrong archetype (a wrong [backend, spa]). The remedy is to thread ANCESTOR PATH-CONTEXT (the lowercased parent-dir `segments`) into the classifier and gate each signal on the location where that surface actually lives: `api/` → top-level or a `pages`/`app` parent; `route.*` → an `app/` ancestor; `.sql` → a DB-location dir; `.tsx`/`.jsx` → excluding test/email dirs. Determinism is preserved (every branch stays a membership test); the trade is a common false POSITIVE for a rarer, DOCUMENTED false NEGATIVE on nonconventional layouts. + +**Why it matters.** Basename-only breadth is not a one-off bug — it RECURS and CHURNS. The `.sql` signal alone flipped THREE times across increments: `archetype-file-tree-scan` dropped it → `archetype-enum-align` added it at ANY depth → `archetype-path-context` scoped it to DB-location dirs; Fable independently found the same over-broadness in `api/`, `route.*`, and `.tsx`/`.jsx`. Each flip re-litigated a pinned test. Introducing a structural signal WITH its path scope from the start (check the path, not just the name) avoids the churn and the wrong archetype capabilities it produces — a SPA wrongly receiving ssrf / path-traversal / migrations lenses, a backend wrongly receiving a11y / i18n. The scope stays deterministic membership (P5), so precision costs no guarantee; only the accuracy improves, and accuracy is an advisory trade that must be LABELED as such (P0), never sold as guaranteed-correct detection. + +**Provenance.** + +- feature: `archetype-path-context` +- commit: `6acb6aa845f39dbd3e962c0d72c270fb27163209` +- source: `.dev/features/archetype-path-context/REVIEW.md` (proposed lesson candidate) + `GRILL.md` F4 (third-flip churn) +- promoted: 2026-07-09 via gated `/pharn-dev-memory-promote` (human-approved). diff --git a/.pharn/pharn-dev-memory-promote/body.md b/.pharn/pharn-dev-memory-promote/body.md new file mode 100644 index 0000000..2693b0f --- /dev/null +++ b/.pharn/pharn-dev-memory-promote/body.md @@ -0,0 +1,3 @@ +A file-tree signal that keys off a basename or extension ALONE (`api/`, `route.ts`, `.sql`, `.tsx`) over-classifies: a file merely NAMED `x` does not mean the project HAS surface `x`. `src/api/` is a near-universal FRONTEND fetch-wrapper convention, a Vue router has a `route.ts`, a frontend commits a `seed.sql`, and a backend holds react-email `.tsx` templates — each basename-only rule mis-fired to the wrong archetype (a wrong [backend, spa]). The remedy is to thread ANCESTOR PATH-CONTEXT (the lowercased parent-dir `segments`) into the classifier and gate each signal on the location where that surface actually lives: `api/` → top-level or a `pages`/`app` parent; `route.*` → an `app/` ancestor; `.sql` → a DB-location dir; `.tsx`/`.jsx` → excluding test/email dirs. Determinism is preserved (every branch stays a membership test); the trade is a common false POSITIVE for a rarer, DOCUMENTED false NEGATIVE on nonconventional layouts. + +**Why it matters.** Basename-only breadth is not a one-off bug — it RECURS and CHURNS. The `.sql` signal alone flipped THREE times across increments: `archetype-file-tree-scan` dropped it → `archetype-enum-align` added it at ANY depth → `archetype-path-context` scoped it to DB-location dirs; Fable independently found the same over-broadness in `api/`, `route.*`, and `.tsx`/`.jsx`. Each flip re-litigated a pinned test. Introducing a structural signal WITH its path scope from the start (check the path, not just the name) avoids the churn and the wrong archetype capabilities it produces — a SPA wrongly receiving ssrf / path-traversal / migrations lenses, a backend wrongly receiving a11y / i18n. The scope stays deterministic membership (P5), so precision costs no guarantee; only the accuracy improves, and accuracy is an advisory trade that must be LABELED as such (P0), never sold as guaranteed-correct detection. diff --git a/.pharn/pharn-dev-memory-promote/candidate.json b/.pharn/pharn-dev-memory-promote/candidate.json new file mode 100644 index 0000000..c8ce3db --- /dev/null +++ b/.pharn/pharn-dev-memory-promote/candidate.json @@ -0,0 +1,12 @@ +{ + "target": ".dev/memory-bank/lessons-learned.md", + "id": "L13", + "provenance": { + "feature": "archetype-path-context", + "commit": "6acb6aa845f39dbd3e962c0d72c270fb27163209", + "source": ".dev/features/archetype-path-context/REVIEW.md (proposed lesson candidate) + GRILL.md F4 (third-flip churn)", + "date": "2026-07-09" + }, + "title": "Scope structural (file-tree) signals to their documented location on introduction, not basename-only", + "body": "A file-tree signal that keys off a basename or extension ALONE (`api/`, `route.ts`, `.sql`, `.tsx`) over-classifies: a file merely NAMED `x` does not mean the project HAS surface `x`. `src/api/` is a near-universal FRONTEND fetch-wrapper convention, a Vue router has a `route.ts`, a frontend commits a `seed.sql`, and a backend holds react-email `.tsx` templates — each basename-only rule mis-fired to the wrong archetype (a wrong [backend, spa]). The remedy is to thread ANCESTOR PATH-CONTEXT (the lowercased parent-dir `segments`) into the classifier and gate each signal on the location where that surface actually lives: `api/` → top-level or a `pages`/`app` parent; `route.*` → an `app/` ancestor; `.sql` → a DB-location dir; `.tsx`/`.jsx` → excluding test/email dirs. Determinism is preserved (every branch stays a membership test); the trade is a common false POSITIVE for a rarer, DOCUMENTED false NEGATIVE on nonconventional layouts.\n\n**Why it matters.** Basename-only breadth is not a one-off bug — it RECURS and CHURNS. The `.sql` signal alone flipped THREE times across increments: `archetype-file-tree-scan` dropped it → `archetype-enum-align` added it at ANY depth → `archetype-path-context` scoped it to DB-location dirs; Fable independently found the same over-broadness in `api/`, `route.*`, and `.tsx`/`.jsx`. Each flip re-litigated a pinned test. Introducing a structural signal WITH its path scope from the start (check the path, not just the name) avoids the churn and the wrong archetype capabilities it produces — a SPA wrongly receiving ssrf / path-traversal / migrations lenses, a backend wrongly receiving a11y / i18n. The scope stays deterministic membership (P5), so precision costs no guarantee; only the accuracy improves, and accuracy is an advisory trade that must be LABELED as such (P0), never sold as guaranteed-correct detection." +} \ No newline at end of file diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 7339dd8..658e2db 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/archetype-path-context/SHIP.md" + ".dev/memory-bank/lessons-learned.md" ], - "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-07-09T14:03:15.756Z" + "set_by": ".claude/commands/pharn-dev-memory-promote.md", + "set_at": "2026-07-09T14:10:26.121Z" }