feat(fabrika): implement the six glossary verbs and register the group (#5322) - #5357
Conversation
…oup (#5322) The `/glossary` skill's every step opens with a `fabrika glossary …` fence and none of those verbs existed, so the skill was inert on `main` — a model routed to it ran commands that exit 127. This builds all six against the derived contract. - `packages/fabrika-cli/src/glossary/` — `init`, `drift`, `lookup`, `sections`, `add` and `check`, each a pure verb with a thin `*-verb.ts` entry and a unit test beside it, over shared parsing (`register.ts`), edit mechanics (`edit.ts`), defect predicates (`findings.ts`), drift extraction (`candidates.ts`) and the git range (`history.ts`). - `glossary/codes.ts` imports every shared meaning from `report/codes.ts` rather than restating a numeral, holds `5` as `DELIBERATE_GAP` and `6` by prose alone, and adds `12`-`15` on its own account. - Registration: `registry.ts`, `ALIGNED_GROUPS` + `GLOSSARY_SEATS` in `exit-code-alignment.ts` and the `TABLES` map in its unit test, and a README section — every one a pure insertion. - `STOPWORDS` is exported from `adr/sweep.ts` so the two term-extraction surfaces cannot drift apart; that module's ASCII-only `tokenize` is deliberately not imported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a90458e to
3095e5e
Compare
No preview deploy
|
…e pair CodeQL's incomplete-sanitization finding on `escapeCell` is a real one, not a nit: escaping `|` without escaping the escape character leaves the encoding ambiguous. A cell whose own text ends in a backslash rendered as `…\ |`, and that trailing backslash then read as escaping the delimiter after it — a value able to smuggle in a column separator the escape believed it had neutralized. `escapeCell` now escapes `\` first, `splitCells` unescapes both arms, and a round-trip table covers the plain, embedded-pipe, trailing-backslash, pre-escaped-pipe and double-backslash cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
review-code: PASS @ 986d610 — merge-ready Verified PR #5357 against the acceptance criteria of #5322, one at a time. The contract was read first-party from Required namespaces for this diff ( §CP, both axes. Acceptance criteria
The six things graded specifically1 — the CodeQL escaper fix is genuinely complete, not half-fixed. I re-derived the inverse property rather than reading it. Fixing it here rather than filing was the right call, and I would grade a wave-through as the weaker branch: the flagged line is authored by this PR in a module this PR introduces, filing it would land a knowingly ambiguous escaper on 2 — the unreachable contract example, reproduced by execution. Running the shipped Implementing the stated algorithm, pinning both directions in 3 — the exit table holds. Seven shared meanings are aliased imports ( The three-way split is structural rather than documentary: 4 — the one deletion in the whole diff. Across the entire 33-file diff against merge base 5 — the mid-flight rebase dropped nothing from Confirmed positively at all three conflict points as well. 6 — the ten silences, graded one at a time. Every one is a place the contract is silent or self-inconsistent, every one took the conservative branch, and none of them should have blocked. Four are surfaced on #5322 and six more in the PR
Recorded UNKNOWN — not invented, not graded away
Sub-gates
Run-evidence bundle: PRESENT for head Read the PR head (§HEAD): every file under review was sourced from One note, no action asked: the PR body Tests section still says 3843 tests, while the head actually runs 3848 — the five round-trip rows the fix commit added. The Amendment section and the #5322 progress comment both carry the current number. Stale prose, not a code defect. All criteria pass. This PR is merge-ready. review-code does not merge — Verdict-written: 2026-08-10T19:21:01Z |
… pair (#5364) CodeQL's incomplete-escaping finding on `cellText` is a real defect, not a nit. Escaping `|` without escaping the escape character leaves the encoding ambiguous: a cell carrying a backslash-pipe sequence emitted an unescaped pipe, so a three-cell row rendered as four. This ports the implementation proven on the sibling module in PR #5357 (`escapeCell` / `splitCells` in `src/glossary/register.ts`) rather than deriving a second one. `cellText` escapes `\\` first and `|` second; `rowCells` consumes both arms in a single left-to-right pass, which is what makes it the exact inverse. The old splitter's `(?<!\\)` lookbehind mirrored the escaper's bug, so the pair round-tripped itself while a renderer disagreed — which is why the round-trip table is asserted against composed rows and not against the splitter alone. Twelve adversarial cells cover trailing backslash, lone backslash, doubled backslash, escaped-pipe-then-backslash, escaped-backslash-then-pipe, pre-escaped \\|, pipe-only, trailing bare pipe and the empty cell. The share-vs-duplicate question these two escapers raise stays open on #5364. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The
/glossaryskill landed with a 933-line derived contract and none of the six verbs it callsexisted, so every step of the skill ran a command that exits
127. This builds all six —init,drift,lookup,sections,add,check— registers the group, and pins thecontract's worked examples against the committed fixtures so the document and the code cannot
drift apart quietly.
Fixes #5322
What changed
New group at
packages/fabrika-cli/src/glossary/: a pure core plus a thin*-verb.tsentry perverb, a
*.unit.test.tsbeside each, and onecodes.tsevery verb allocates from.register.tsnormalizeKey, whole-word overlap, cell escapingedit.ts--create-sectionblockfindings.tscheckdefect kinds and their fixeddetailtextcandidates.tsdrift's Unicode-classed extraction, filter and equality-only suppressionhistory.tsFailureand never a factguards.ts--registerenum,--dirresolution, and the absent / unreadable / malformed splitRegistration, each a pure insertion (see the proof below):
registry.ts,GLOSSARY_SEATS+ALIGNED_GROUPSinexit-code-alignment.ts, theTABLESmap in its unit test, and a READMEsection. No wire format is registered — the contract calls for none, and the group emits no verdict
two skills meet through.
One shipped module changes as the contract orders:
STOPWORDSinpackages/fabrika-cli/src/adr/sweep.tsbecomes exported so the two term-extraction surfaces cannot drift apart. That module's
tokenizeisnot imported — it splits on
[^a-z0-9]+, which is the ASCII-only defectdriftexists to avoid.The exit table
Shared meanings are imported from
report/codes.tsunder an alias; no shared numeral is restatedanywhere in the group.
3EMPTY_STDINEMPTY_STDIN)4BAD_SECTIONSBAD_SECTIONS)5DELIBERATE_GAP6DELIBERATE_GAP_27ZERO_SCOPENO_TARGET)8WRITE_UNKNOWNWRITE_UNKNOWN)9READBACK_MISMATCHREADBACK_MISMATCH)10OFF_VOCABULARYCLASSIFIED)11PRECONDITION_UNKNOWNPRECONDITION_UNKNOWN)12TERM_COLLISION13SECTION_ABSENT14ROW_SHAPE_INVALID15EDIT_BEYOND_ROWGLOSSARY_SEATSisBUILD_SEATSminusLEAKED_PATH/BARE_AT_PATH, derived from the shipped maprather than retyped. No seat was re-seated:
5and6are the base's and stay unclaimed here.The three-way split holds:
1/127is the call never deciding,11is a precondition read thatfailed with nothing written,
8is a write attempted whose outcome is UNKNOWN — and every provenverdict at
3+ leaves stdout empty (refuse()hardcodes that).Insertion-only proof for the shared files
git diff --numstatagainst the merge base, added/deleted per file:Deletions summed over the four registration files: 0. The README section is added whole; no
existing prose is reflowed or rewritten. The single deletion in the whole diff is
adr/sweep.ts'sconst STOPWORDS =line becomingexport const STOPWORDS =— the one-line change to a shippedmodule the contract explicitly requires, called out here rather than hidden inside the total.
Tests
pnpm typecheckin-package (tsgo -p tsconfig.jsonfrompackages/fabrika-cli/) is clean, and thewhole package suite passes — 269 files, 3843 tests. Coverage is three tiers:
fakeFs/fakeShell: one*.unit.test.tsper verb plus the four puremodules, covering every refusal each verb can produce.
contract-examples.test.ts, in process over the real filesystem throughNodeServices.layer:the contract's worked examples reproduced byte for byte against the committed fixtures under
claude-plugins/fabrika/skills/glossary/evals/fixtures/, includingcheck's two planted defectsand
add's derived line 18 (against a temp copy, so the committed corpus does not move).glossary.cli.test.ts, three subprocess spawns for the facts only a process boundary proves:reachability by registration alone, the stdout/stderr split, and a refusal leaving stdout empty.
Deviations
Nine, plus one disclosed scan hit. Every one is a place the contract is silent or self-inconsistent;
none is an invention over a clause that decided.
1 — the contract's
de-po→depoexample is UNREACHABLE, proven by execution. Normalizationstep 3 says "replace every run of
-,_or whitespace with a single space", and the #4481grounding gives the reason: a declared
front-doormust suppressfront door. Under that rulede-ponormalizes tode po, which is notdepo— so thelookupexample that printsdeclared terms Products (domains) depocannot happen. The conservative branch was taken: thestated algorithm is implemented (the one #4481 fixes), and both directions are pinned in
lookup-verb.unit.test.ts—front-door↔front doorresolve as one key, andde-poanswersabsent. Surfaced on #5322 rather than patched into the contract.2 — exit
4's second stated trigger is unreachable in this parser. The contract names two: "aheader row without a separator" and "rows whose cell count the parser cannot resolve". Splitting on
unescaped
|resolves a count for every line, so a short row is arow-shapefinding at exit0, not a refusal — which is also what the
row-shapedetail text (expected 3 cells, found <n>)requires.
4therefore fires only on the header-without-separator case. Pinned inregister.unit.test.ts.3 —
initseats a failed existence probe on11, which its per-verb table does not list. Thetable gives
0/8/9/10/12. A probe that could not be performed is neither "already exists" (12,a lie), nor a failed write (
8, nothing was attempted).11is the group's own seat for exactlythis and is not a new number. Answering "absent" instead would license a write over a register the
process never managed to look at.
4 —
init's read-back arm covers a read that failed, not only one that differs. Both mean thewrite landed and cannot be proven correct, which is
9's meaning; the pinned message is unchanged.5 —
drift --register bothis under-determined and takes the recall-biased branch. The contractbounds the range by "the resolved register file", singular, while
--registeradmitsboth. Herethe declared set is the union of the present registers' keys and the range starts at the
older of their last-change commits, so nothing that moved since either register changed falls
outside a list the skill already treats as recall-biased.
bootstrapfires only when no selectedregister contributes rows.
6 —
citations-unverifiedis emitted once per run, with-in the register/section/termcolumns. The contract fixes the kind and its
detailbut not which row it is reported against; itis a fact about the corpus, not about any row. An individual record that exists and cannot be read
lands here too, with the file name in the reason.
7 —
lookup --jsonusesnullwhere the line grammar prints-. The line grammar's-ispinned and is emitted verbatim; the JSON shape's
register/sectionfor anabsentterm are not,and
nullis what a JSON consumer can branch on.matchedis[], matching the declared arrayshape.
8 —
duplicate-keyis scoped within a register. The contract says "two rows in the selectedregisters" and then gives
cross-registerits own kind for the same key in TERMS and LANGUAGE.Scoping
duplicate-keyper register is what stops one defect being reported twice under two names.9 — two
addorderings the precedence list does not reach.--replacerewrites the row whereit actually lives and reports that row's section (
--sectionis still validated first, so abad one is
13). And where a register's table structure is unresolvable globally, its sectionscannot be enumerated at all, so
4precedes13in that one case; wherever the register parses,the precedence is exactly as ordered.
Disclosed scan hit —
dev-tier-m.shclass 5, one line, a known false positive. The scan's barexit(alternative matches insideprocess.exit(outcome.code)insrc/glossary/command.ts's emitadapter. That line is byte-identical to the same adapter in ~19 sibling verb groups; it is not a
skipped test and there is nothing to suppress. Class 6 (removed assertions) is zero — this diff
removes no test line at all.
Two codes not reachable from a test, stated rather than left to be noticed.
15cannot beproduced by the shipped composer, which only ever splices the lines it named — so
edit.unit.test.tsproves the assertion itself catches a re-sort and a truncation, which is the property that makes the
guard load-bearing rather than dead.
9onaddneeds a filesystem that returns bytes other thanthe ones written, which the in-memory fake cannot express; the arm is a straight comparison against
the composed row.
Amendment — the CodeQL thread on
register.tsis fixed, not waved throughCodeQL flagged
escapeCellas an incomplete sanitization, and it was right. Escaping|withoutescaping the escape character left the encoding ambiguous: a cell whose own text ends in a backslash
rendered as
…\ |, and that trailing backslash then read as escaping the delimiter after it — avalue able to smuggle in the column separator the escape believed it had neutralized.
The second commit makes the pair an exact inverse:
escapeCellescapes\first,splitCellsunescapes both arms, and a round-trip table covers the plain, embedded-pipe, trailing-backslash,
pre-escaped-pipe and double-backslash cases. Real corpora are unaffected — a lone backslash before a
non-delimiter still reads as itself.