diff --git a/.dev/features/strict-frontmatter-fence/GRILL.md b/.dev/features/strict-frontmatter-fence/GRILL.md new file mode 100644 index 0000000..d7394f7 --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/GRILL.md @@ -0,0 +1,29 @@ +# GRILL — strict-frontmatter-fence + +Plan: `.dev/features/strict-frontmatter-fence/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: important + file: '.dev/features/strict-frontmatter-fence/PLAN.md:5' + problem: 'Keep line-ending acceptance exactly as today (LF fence only). Silently widening to CRLF would change which upstream files install, beyond this finding.' + evidence: '`the first line is exactly ---`' +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/features/strict-frontmatter-fence/PLAN.md:7' + problem: 'Count duplicates by the same anchored key match that reads the value (`^role:`), so an indented or prefixed look-alike (`roles:`, ` role:`) is neither a duplicate nor a value — unchanged from today.' + evidence: '`a role or applies key that appears more than once`' +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/strict-frontmatter-fence/PLAN.md:30' + problem: 'The empty-frontmatter eval must put a VALID role/applies in the body, or it passes on the base source for the wrong reason (missing field).' + evidence: '`empty frontmatter → unknown (not a body read)`' +``` + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — all folded into the build. diff --git a/.dev/features/strict-frontmatter-fence/PLAN.md b/.dev/features/strict-frontmatter-fence/PLAN.md new file mode 100644 index 0000000..d7bff1a --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/PLAN.md @@ -0,0 +1,51 @@ +# PLAN — strict-frontmatter-fence (PHARN-14: the capability frontmatter reader must not disagree with upstream's validator) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `parseCapabilityIndex` reads the frontmatter FENCE line by line (the first line is exactly + `---`, the block ends at the next line that is exactly `---`), so an empty frontmatter (`---\n---`) + yields an empty block instead of reading `role`/`applies` out of the document body; and a + `role` or `applies` key that appears more than once is a `ManifestValidationError` (the capability + becomes `unknown` — named, never installed) instead of silently taking the FIRST occurrence while + upstream's validator takes the LAST. +- layer(s): the CLI itself (`src/lib/capability-index.ts`, the untrusted-frontmatter fetch boundary) +- constitution_refs: [P1, P2, P5] + +## Discovery — verified this run (P6) + +`extractFrontmatter` uses `/^---\n([\s\S]*?)\n---/`: for `---\n---\nrole: lens\n---` the lazy capture +starts at the second `---` and runs to the third, so body lines are read as frontmatter. +`readField` uses a multiline `^field:` regex whose first match wins; pharn-oss's floor validator keeps +the last. Same file, two different `applies` → a capability can install with an applicability the +upstream gate never checked. Callers: only `role` and `applies` are read (`capability-index.ts:132-139`). + +## Files + +- `src/lib/capability-index.ts` — line-based fence; `readField` rejects a duplicated key — layer CLI/lib +- `tests/capability-index.test.ts` — empty frontmatter → unknown (not a body read); duplicated `role` / + `applies` → unknown naming the field; unterminated fence → unknown; the valid fixtures still parse + +## Contracts satisfied + +- CLAUDE.md "strict field reader" at the fetch boundary — now unambiguous: one value or refusal. + +## Evals to write (P1) + +- listed above; the empty-frontmatter and duplicate cases fail on the base source. + +## Guarantee audit (P0) + +- "a capability's role/applies is the single value in its fenced block" → floor: exact-line fence + match + occurrence count == 1. + +## Trust audit (P2) + +- Input is untrusted clone content; the change only narrows what is accepted. A refused capability + lands in `unknown` (named warning; an installed one is KEPT, see PHARN-13). + +## Determinism audit (P5) + +- Pure string processing; no ordering dependence. + +## Open questions (HALT) + +- none diff --git a/.dev/features/strict-frontmatter-fence/REGRESSION.md b/.dev/features/strict-frontmatter-fence/REGRESSION.md new file mode 100644 index 0000000..6e9c9f7 --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/REGRESSION.md @@ -0,0 +1,30 @@ +# REGRESSION — strict-frontmatter-fence + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `aec6d3e7e8c49bf18f2e7e7bca66a01596076367` (`origin/main` at build time; the build is an uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `src/lib/capability-index.ts`, `tests/capability-index.test.ts`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and this + feature's own stage artifacts are not build output. +- **outside gates:** the stdlib `*.test.mjs` / `*.test.cjs` files + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / `lint:md` are absent from both maps. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` is +owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the comparison, never the increment. diff --git a/.dev/features/strict-frontmatter-fence/REVIEW.md b/.dev/features/strict-frontmatter-fence/REVIEW.md new file mode 100644 index 0000000..f57ce89 --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/REVIEW.md @@ -0,0 +1,37 @@ +# REVIEW — strict-frontmatter-fence + +Floor first: `node .dev/floor/validate.mjs .` → exit 0 (GREEN). Everything below is **advisory**. + +## Floor-gate findings (blocking) + +None. + +- **L-floor (P0):** a value is read only when its anchored key occurs exactly once inside a fence whose + first line is exactly `---` and whose end is the next `---` line; otherwise `ManifestValidationError`. +- **L-eval (P1):** the empty-block and both duplicate cases fail on the base source; the empty-block body + carries a VALID role/applies so it cannot pass for the wrong reason (grill #3). A positive case pins + trailing blanks on the close and that look-alike keys (`roles:`, indented `applies:`) stay ignored (grill #2). +- **L-trust (P2):** only narrows what the untrusted clone may declare; every refusal lands in `unknown`, + named, never installed (an installed one is KEPT and re-checked, PHARN-13). +- **L-axis (P3):** contained in `capability-index.ts`. + +## Advisory findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: minor + file: 'src/lib/capability-index.ts:233' + problem: 'The close is now an exact `---` line (plus trailing blanks); the old regex also closed on `----` or `---x`. No upstream file uses those shapes today, but a future one would be reported unknown rather than parsed.' + evidence: "const close = lines.findIndex((line, i) => i > 0 && /^---[ \\t]*$/.test(line));" +- type: FINDING + rule_id: 'P4' + severity: minor + file: 'CHANGELOG.md:8' + problem: "No CHANGELOG `[Unreleased]` entry (not in the plan's `## Files`)." + evidence: '## [Unreleased]' +``` + +## Verdict + +**GREEN** — 0 floor-gate findings, 2 advisory findings. No lesson proposed for canon. diff --git a/.dev/features/strict-frontmatter-fence/SHIP.md b/.dev/features/strict-frontmatter-fence/SHIP.md new file mode 100644 index 0000000..1100da9 --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/SHIP.md @@ -0,0 +1,17 @@ +# SHIP — strict-frontmatter-fence + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: **Approve as written**) → `/pharn-dev-grill` → +`/pharn-dev-build` → `/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- Run ended at **GATE 2**. The human's standing instruction for this batch: open a PR and merge it only if + its CI checks are green. + +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. diff --git a/.dev/features/strict-frontmatter-fence/VERIFY.md b/.dev/features/strict-frontmatter-fence/VERIFY.md new file mode 100644 index 0000000..d36d457 --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/VERIFY.md @@ -0,0 +1,27 @@ +# VERIFY — strict-frontmatter-fence + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, as a non-root user on node 22 with the session +proxy variables unset (as root with the proxy set, 5 pre-existing tests in `init.test.ts` / +`update.test.ts` fail for environmental reasons, identically at the baseline). + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — floor gates only. + +Residual (P0/P7): "verified" means the named gates passed — not that the feature is correct in any sense +the suite does not encode. diff --git a/.dev/features/strict-frontmatter-fence/regression-report.json b/.dev/features/strict-frontmatter-fence/regression-report.json new file mode 100644 index 0000000..1d91c2e --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/regression-report.json @@ -0,0 +1,20 @@ +{ + "base": "aec6d3e7e8c49bf18f2e7e7bca66a01596076367", + "inside": [ + "src/lib/capability-index.ts", + "tests/capability-index.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/strict-frontmatter-fence/verify-report.json b/.dev/features/strict-frontmatter-fence/verify-report.json new file mode 100644 index 0000000..d36b2cc --- /dev/null +++ b/.dev/features/strict-frontmatter-fence/verify-report.json @@ -0,0 +1,17 @@ +{ + "feature": "strict-frontmatter-fence", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index e7162a7..b769d55 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/frozen-capability-recheck/SHIP.md" + ".dev/features/strict-frontmatter-fence/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T09:52:10.070Z" + "set_at": "2026-09-24T09:55:43.509Z" } diff --git a/src/lib/capability-index.ts b/src/lib/capability-index.ts index 8e66557..0981076 100644 --- a/src/lib/capability-index.ts +++ b/src/lib/capability-index.ts @@ -223,31 +223,45 @@ function readCapabilityMarkdown( * file. Only this block is parsed — a field-looking line in the prose body is * never read. Hard-fails (naming the capability) when no frontmatter fence is * present (P5). + * + * Line-based, not a lazy regex: the block opens on a first line that is exactly + * `---` and ends at the NEXT line that is `---` (trailing blanks allowed). A + * regex capture (`^---\n([\s\S]*?)\n---`) skips an empty block (`---\n---`) + * and reads the document BODY as frontmatter, up to the next `---`. */ function extractFrontmatter(content: string, name: string): string { - const match = /^---\n([\s\S]*?)\n---/.exec(content); - if (!match) { + const lines = content.split('\n'); + const close = lines.findIndex((line, i) => i > 0 && /^---[ \t]*$/.test(line)); + if (lines[0] !== '---' || close === -1) { throw new ManifestValidationError( `Capability "${name}" is missing a "---"-fenced frontmatter block.`, ); } - return match[1]!; + return lines.slice(1, close).join('\n'); } /** * Read a single required scalar field from the frontmatter block, returning its * raw (quote-stripped) value. Hard-fails (naming the capability + field) when - * the field is absent (P5). A strict per-field regex — not a YAML parser. + * the field is absent OR appears more than once (P5): pharn-oss's validator + * keeps the LAST occurrence while a first-match reader keeps the FIRST, so a + * duplicate is the one shape where the two could install different values. A + * strict per-field regex — not a YAML parser. */ function readField(frontmatter: string, field: string, name: string): string { - const re = new RegExp(`^${field}:[ \\t]*(.+?)[ \\t]*$`, 'm'); - const match = re.exec(frontmatter); - if (!match) { + const re = new RegExp(`^${field}:[ \\t]*(.+?)[ \\t]*$`, 'gm'); + const matches = [...frontmatter.matchAll(re)]; + if (matches.length === 0) { throw new ManifestValidationError( `Capability "${name}" is missing the "${field}" frontmatter field.`, ); } - return stripQuotes(match[1]!); + if (matches.length > 1) { + throw new ManifestValidationError( + `Capability "${name}" declares the "${field}" frontmatter field ${matches.length} times.`, + ); + } + return stripQuotes(matches[0]![1]!); } function stripQuotes(value: string): string { diff --git a/tests/capability-index.test.ts b/tests/capability-index.test.ts index 1e4dbc0..02bbb65 100644 --- a/tests/capability-index.test.ts +++ b/tests/capability-index.test.ts @@ -143,6 +143,22 @@ describe('parseCapabilityIndex', () => { expect(entry!.applies).toEqual(['ssr']); }); + it('accepts a closing fence with trailing blanks, and ignores look-alike keys', () => { + const repo = tmp.path(); + scaffold(repo); + writeCap( + repo, + GRILLERS, + 'spaced', + '---\nrole: griller\nroles: lens\n applies: ["ssr"]\napplies: ["universal"]\n--- \t\n# x\n', + ); + const index = parseCapabilityIndex(repo); + expect(index.unknown).toEqual([]); + expect(index.capabilities).toEqual([ + { name: 'spaced', role: 'griller', applies: 'universal' }, + ]); + }); + it('reports an empty unknown list for a fully-parseable clone (P5: zero noise)', () => { const repo = tmp.path(); scaffold(repo); @@ -209,6 +225,30 @@ describe('parseCapabilityIndex', () => { label: 'a missing role field', body: '---\nname: x\napplies: ["universal"]\n---\n# x\n', reason: /missing the "role"/, + }, // PHARN-14: a lazy-regex fence read an EMPTY block's following BODY as + // frontmatter. The body carries a VALID role/applies on purpose, so the base + // source installs it (the wrong reason cannot make this pass). + { + label: 'an empty frontmatter block (fields only in the body)', + body: '---\n---\nrole: griller\napplies: ["universal"]\n---\n# x\n', + reason: /missing the "role"/, + }, + { + label: 'an unterminated frontmatter fence', + body: '---\nrole: griller\napplies: ["universal"]\n# x\n', + reason: /frontmatter block/, + }, + // PHARN-14: upstream's validator keeps the LAST duplicate, a first-match + // reader the FIRST — so a duplicate is refused rather than guessed. + { + label: 'a duplicated applies field', + body: '---\nrole: griller\napplies: ["ssr"]\napplies: ["universal"]\n---\n# x\n', + reason: /"applies" frontmatter field 2 times/, + }, + { + label: 'a duplicated role field', + body: '---\nrole: griller\nrole: griller\napplies: ["universal"]\n---\n# x\n', + reason: /"role" frontmatter field 2 times/, }, ];