Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .dev/features/strict-frontmatter-fence/GRILL.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions .dev/features/strict-frontmatter-fence/PLAN.md
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions .dev/features/strict-frontmatter-fence/REGRESSION.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions .dev/features/strict-frontmatter-fence/REVIEW.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions .dev/features/strict-frontmatter-fence/SHIP.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions .dev/features/strict-frontmatter-fence/VERIFY.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions .dev/features/strict-frontmatter-fence/regression-report.json
Original file line number Diff line number Diff line change
@@ -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"
}
17 changes: 17 additions & 0 deletions .dev/features/strict-frontmatter-fence/verify-report.json
Original file line number Diff line number Diff line change
@@ -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": []
}
}
4 changes: 2 additions & 2 deletions .pharn/writes-scope.json
Original file line number Diff line number Diff line change
@@ -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"
}
30 changes: 22 additions & 8 deletions src/lib/capability-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions tests/capability-index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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/,
},
];

Expand Down
Loading