v3.5.5: a seam that accepts what it should refuse #225
No reviewers
Labels
No labels
already-shipped
bug
documentation
duplicate
enhancement
external-review
good first issue
help wanted
in progress
invalid
needs-decision
proposal
question
security
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: crenshawdev/cadence-archived#225
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "cadence/v3.5.5"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the v3.5.5 milestone. Thirteen requirements across five phases, 123 commits.
Readers that accept what they have a rule against, closed by replacing nine hand-rolled argument parsers with one declarative table: 16 scripts, 77 subcommand rows, 145 flag entries, plus a census that spawns the owning binary for all 231 declared refusals. That census is what caught the table being written but not read,
planning.mjswas applying two of its 145 rules.Also in here:
""ingit.protected_brancheshad been unprotectingmainsilently--dir ''read as "use the current directory" at six seams--datedated the release todaydetect-commandsnamed binaries that are not on PATHrisk-check statuscould not be satisfied for a non-numeric worker keyrisk_surfacedetector matched its own source and fixtures## Shippedand## Traceabilitylookups read inside fenced code blocksAudit green: 13/13 traced, 0 broken, 35/35 acceptance criteria covered. 2380 tests pass, self-verify clean. Manifest at 3.5.5; the tag gets cut on
mainafter this merges.References #219, #223, #224.
The three JSON read-backs tested shape BY dereferencing the parsed value, after the try/catch had closed: JSON.parse("null") is a successful parse, so `r.outcomes`, `r[k]` and `r.brackets` threw an uncaught TypeError where the prose promises a named scratch-shape refusal. The shape check WAS the crash, so no site could reach the refusal. Guard the parse result before touching it, and guard the array elements the same way: an outcomes or brackets entry that is not an object is refused rather than dereferenced, and a printed field that is present but null is as absent as a missing one. Without the element guard, [42] answered a blocking re-arm gate with a fabricated 0 and [null] threw. scratch-readback.test.mjs pins all six arms by executing the shipped scripts against the fixtures, so nothing here rests on reading the prose.requireInt took Number.isInteger, so `--total 9007199254740993` answered {ok:true,value:9007199254740992} - a number the caller never typed, arriving past the guard that exists to stop exactly that. It takes Number.isSafeInteger now, a strict tightening of the same predicate. requireCursorNumber cannot take it: its decimal form legitimately reads 2.1 and Number.isSafeInteger(2.1) is false, so the integer predicate would refuse every sub-phase. It bounds the parsed MAGNITUDE instead, after the CURSOR_SHAPE test and the String(n) round trip that both stay. requirePhaseArg inherits through it and gains no check of its own, so 1.10, 08 and 2.1 still read exactly as their existing rows pin them.`get` decided which requested keys were unknown with a bare `SCHEMA[k]`, so every Object.prototype member resolved truthy through the prototype chain and passed the filter as though the schema held it. The value line then read `.default` off Object.prototype and assigned it into `values` at the same name, where `__proto__` ran the object's own setter and stored nothing: `get __proto__`, `get constructor` and `get toString` each answered {"ok":true,"values":{}} at exit 0, and `get stakes __proto__` answered about one key of the two asked for with nothing saying the other had gone missing. Both index reads now carry `Object.hasOwn`, the guard `validate` already carries at :138-142 for the same reason. No new helper and no new reason code - `fail('unknown-key', unknown)` was always the right answer, and the fix is that a prototype member reaches it. The tests walk `Object.getOwnPropertyNames(Object.prototype)` rather than hand-listing three names, so `__defineGetter__` and its siblings are covered too, and they assert the exit status because a refusal that exits 0 is the shape being repaired. They are kept apart from the merge-path block, which says of itself that it pins rather than proves; both new rows go red with either guard reverted.## Traceabilitylocators read fence-aware b8cccc8480## Shippedand the Traceability filter read fence-aware 28b1eb5c56The rules each seam CLI restated - what a flag's value may be, and what happens when it is not that - get one home: lib/arg-contract.mjs declares required-ness, a type and TWO separate dispositions per flag (value and bare-flag), and one pure evaluator classifies a value against that declaration. The disposition vocabulary is exactly three words, because all three are reasoned positions already shipped here: refuse (flagValue's rule), warn (route.mjs's --phase, where a usage refusal would route the phase lower than its own risk baseline) and fallback (issue-check's --timeout-ms, whose seam may never fail a land, and the drop-on-bare spreads in the trace append|close body). Dropping a bare flag IS fallback and gets no fourth word. The type names reach this tree's existing classifiers rather than re-deriving them - requireInt, requireCursorNumber, requirePhaseArg, requirePlanKey - plus a boolean type for the flags whose bare form is the value. The absent/present-with-nothing-usable split is read off flagValue itself, consulted rather than re-spelled, so the helper census still finds one home for it. One flat {ok, value, detail} on every path, never a JSDoc discriminated union: the CI typecheck runs strict:false, where narrowing by a boolean literal does not happen. ok:false refuses, ok:true with a non-empty detail warns, ok:true with an empty one accepted or fell back. It emits nothing, reads no process or environment and touches no filesystem - the caller owns its envelope and its reason string - and it governs VALUE grammar only: flag membership stays self-verify check 2's prose-side job.The tracer bullet for the whole contract - declaration to evaluator to CLI refusal to envelope - and the first adopter is deliberately the file the table just left. The entry block stops calling flagValue by hand and asks the evaluator to apply CONTRACTS['self-verify.mjs']['*']['--root'], so the rule comes from the declaration rather than from a call this file restates. The throwing mechanism and the e.seam catch arm below it stay (D-08): the refusal still emits one stdout line {"ok":false,"reason":"missing-flag-value","detail":"--root"} with exit 1 and nothing on stderr, and without that arm a thrown seam object carrying no message would surface as detail "[object Object]". The reason string is this file's, named at the throw and held in a const - the contract mints none of its own (D-07), and the literal throw body is pinned to lib/seam-input.mjs by the helper census. A genuinely absent --root still falls through to the plugin's own tree. The empty, valueless and flag-shaped spellings refuse as before, and a whitespace-only one now refuses too rather than linting a tree that is not there. The 25 checks are untouched. The existing refusal test gains the three assertions the criterion names - exit 1, zero bytes of stderr, exactly one stdout line - and pins the row itself, so loosening the declaration reddens rather than quietly loosening the CLI.The global --dir now reads through its declared row in lib/arg-contract.mjs instead of off parseArgs' opts. Measured before the fix: `status --dir ''` answered ok:true about ./.planning, a tree the caller never named, and a bare --dir minted the boolean true, reached existsSync(true) and printed a DEP0187 deprecation warning on stderr beside the answer - stdout is the single channel the seam layer parses, and that deprecation is scheduled to become a throw. The refusal keeps this file's ONE vocabulary, fail('bad-args', ...), never the missing-flag-value throw: there is no e.seam catch arm here to render it as anything but internal. A genuinely absent --dir still defaults to .planning.`parseArgs` did `opts[a.slice(2)] = rest[i + 1]` with no flag-shape test, so a valueless flag ate the flag after it and the value after THAT was skipped: `consult --payload --provider openai` returned `bad-provider: "unknown provider: undefined"`, a refusal about a flag the caller did pass, naming the wrong problem. The six declared flags now read through their rows in lib/arg-contract.mjs, so the missing, empty and flag-shaped spellings are one refusal naming the flag. The reason is `bad-args`, already published in this bin's degradation list; references/seams.md is untouched. The value door only - an absent flag still reaches the handler that owns its wording, so an unknown provider is still bad-provider. parseArgs stays pure and keeps its {cmd, opts} shape: the refusal rides back as a third field and main renders it.`optFile` hand-wrote the value rule as `if (!tokens[i + 1])`, which caught the two spellings an interpolated `--file $VAR` produces but read a FLAG-SHAPED token as a path: `config.mjs validate --file --nonsense` returned `{"ok":false,"reason":"read","detail":"cannot read/parse --nonsense: ENOENT"}`, answering about a file the caller never named. All three spellings are one rule now, read through the per-subcommand row in lib/arg-contract.mjs. The refusal keeps this bin's own `usage` and its published wording verbatim, and both locked mechanics stand: --global is tested first and short-circuits before --file is looked at, and the consumed flag and its value are still filtered out of the token list set and get read keys from.`config.mjs get stakes --global` answered ok:true while `CONTRACTS['config.mjs'].get` declared only `--file` and its two siblings each declared `--global`. Self-verify was green solely because no workflow prose spells that pair, so check 2 never reached it - correct prose spelling it would be reported `unknown-flag` against a flag the seam accepts. The `get` row now declares `--global` with the grammar `validate` and `set` carry, and `optFile` reads it through `evaluateFlag` against `CONTRACTS['config.mjs'][cmd]` rather than through a hand-written `tokens.indexOf('--global')` probe. Reading it off the row is what makes the class unrepeatable here: a subcommand accepting a `--global` it does not declare is no longer expressible, because the read needs the row. Both mechanics stay: `--global` is tested FIRST and short-circuits before `--file` is looked at, and the returned tokens still have the consumed flag filtered out. Watched failing: deleting the row stops `get --global` answering `source: "global"`. The table's entry pin moves 144 -> 145.