Skip to content

feat(api): regenerate types for the delivery union, and narrow on it - #229

Merged
thecodedrift merged 1 commit into
mainfrom
openspec/alignment-slice-6
Sep 1, 2026
Merged

feat(api): regenerate types for the delivery union, and narrow on it#229
thecodedrift merged 1 commit into
mainfrom
openspec/alignment-slice-6

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Task 5.8 of generator-payload-alignment. The generator's file-set tier is live (their #122 deployed), so src/generated/api.d.ts is regenerated from the published schema.

This is the bottom of a two-PR stack; slice 6 (the restore client) stacks on top.

What the regeneration brought

rules is now a discriminated union — single-content, plus one file-set variant per engine — where it used to be one shape with optional fields. The runtime variant states signature as required:

/** REQUIRED. Execution is gated on this signature, so a runtime rule without one could never run. */
signature: string;

That is the property we asked the generator for, now expressed in our types rather than as an assumption on both sides. A runtime rule executes only against a blessed signature, so an unsigned one gets written, verifies clean, and never runs.

Why the diff is not just the generated file

The union broke six sites reading .content and .tests off a rule without asking which variant they had. They are narrowed, not cast: isFileSetRule / isSingleContentRule in api/rules.ts.

Both key on files rather than on engine. engine is what the file-set variants have in common; files is what separates them from the single-content one. Narrowing on the wrong field reads as equivalent and silently admits a shape the branch cannot handle.

The carries both files and content check stays, even though the union now makes that unrepresentable. The type states what the service promises; the check defends against it breaking that promise, which is the only reason a client validates a payload at all.

One live bug closed

A payload carrying neither files nor content fell through to the single-content branch and handed yaml.stringify an undefined. That returns the string "undefined" rather than throwing, so the rule file was created and its contents were that word — a malformed rule on disk, discovered two steps from the cause.

Refused now, before the directory is created. Checked on content itself rather than on "not a file set", because a payload with neither is not a file set either, so the negative admits it. My first attempt got exactly that wrong and the new test caught it.

Verification

1087/1087 tests, typecheck, lint, and pnpm cli check clean.

The changeset is extended rather than duplicated: this is the same change that shipped slices 1 to 5, and one change gets one release note.

Task 5.8. The generator's file-set tier is live, so `src/generated/api.d.ts`
is regenerated from the published schema.

`rules` is now a discriminated union — single-content, plus one file-set
variant per engine — where it used to be one shape with optional fields.
The runtime variant states `signature` as REQUIRED, which is the change
we asked for: a runtime rule executes only against a blessed signature,
and an unsigned one is a rule that will be written, verify clean, and
never run.

That union broke six sites reading `.content` and `.tests` off a rule
without asking which variant it was. They are narrowed rather than cast:
`isFileSetRule` and `isSingleContentRule` key on `files`, because
`engine` is what the file-set variants have in common and `files` is what
separates them from the single-content one.

The "carries both `files` and `content`" check stays, though the union
now makes that unrepresentable. The type states what the service
promises; the check defends against it breaking that promise, which is
the only reason a client validates a payload at all.

Fixes one case that reached the filesystem. A payload carrying NEITHER
`files` nor `content` fell through to the single-content branch and
handed `yaml.stringify` an `undefined`, which returns the string
"undefined" rather than throwing — so the rule file was written and what
it contained was that word. Refused now, before the directory is
created, checked on `content` itself rather than on "not a file set",
since a payload with neither is not a file set either.
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 50s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no prior review threads, reviews, or top-level comments (only the @claude /review trigger itself), so this is the first review of this PR — the whole diff was assessed fresh, nothing to reconcile against.

  • Read .prior-review.json (no prior findings to reconcile)
  • Read the diff (gh pr diff) and the full contents of every touched file, plus deliver.ts for cross-checking
  • Reviewed correctness, security, performance, test adequacy, clarity
  • Posted inline findings
  • Posted summary

Findings

[New] packages/cli/src/rules/files.ts:77 — the completeness guard misses content: null, reproducing the exact bug class this PR closes. The guard checks rule.content === undefined, but the sibling mutual-exclusion check three lines up (files.ts:51) already treats null as "present" (null !== undefined). A payload like { id, content: null } slips past this guard and reaches stringify(rule.content, ...), which renders the YAML scalar null instead of throwing — the same "malformed rule silently written to disk" failure mode the changeset says this PR fixes for undefined, just triggered by null. No test covers it. Posted inline with a suggested fix.

[New] Same line — a dead disjunct. !isSingleContentRule(rule) in that same condition can never be true at that point in the function, since isFileSetRule/deliveredFiles both key off the identical rule.files !== undefined check and the "present"/"malformed" cases already returned above. Not a bug, but simplifying the guard (e.g. to rule.content == null) would fix both issues together. Posted inline.

[New] packages/cli/src/commands/rules.ts:249 (and the mirrored :489 in improveCommand) — minor symmetry gap. isSingleContentRule(rule) && rule.tests silently drops tests for any non-single-content rule, with no guard analogous to the content+files mutual-exclusion check for a hypothetical rule that carries both files and a stray tests. Current schema makes that combination unrepresentable, so this isn't reachable today — flagged only because the rest of the PR is careful to fail loudly on broken-contract payloads rather than silently drop data. Posted inline, not blocking.

[New] Pre-existing stale comment, not part of this diff but directly contradicted by it — packages/cli/src/rules/deliver.ts:8-16. The DeliveredFile doc comment says the file-set tier "is not live [in the generated schema] yet" and that its shape is "read structurally in the meantime." This PR's own regeneration of api.d.ts makes the file-set tier live in the schema (the union at api.d.ts:220-275), so the comment is now inaccurate — worth a follow-up cleanup (not inline-commentable since deliver.ts isn't in this PR's diff).

Not raised as findings (verified and deemed non-issues): isFileSetRule (api/rules.ts) and deliveredFiles (deliver.ts) independently implement the same files !== undefined check — real duplication, but both key off the one field the schema defines this on, and it's low risk of drift; not worth a blocking comment. The commands/rules.ts isSingleContentRule check does not actually depend on call order the way it first appears — writeRuleFile throws before that line is ever reached for a malformed payload, so there's no reachable bad state. No CLAUDE.md/style-guide violations found (import patterns, library-type usage, export requirements, and the "verify build output in the build" rule are all followed correctly).

Everything else in the diff — the discriminated-union narrowing (isFileSetRule/isSingleContentRule), the content+files mutual-exclusion check, the new "neither files nor content" guard and its test, and the changeset extension — reads correctly and is well-covered by the new tests in deliver.test.ts.

Comment thread packages/cli/src/rules/files.ts
Comment thread packages/cli/src/commands/rules.ts
@thecodedrift
thecodedrift merged commit 25531db into main Sep 1, 2026
8 checks passed
@thecodedrift
thecodedrift deleted the openspec/alignment-slice-6 branch September 1, 2026 06:34
thecodedrift added a commit that referenced this pull request Sep 1, 2026
Review feedback on #229, applied here because #229 had already merged.

`content: null` reproduced the exact bug #229 says it closes. The guard
tested `content === undefined`, and `yaml` does not throw on a value it
cannot make a document of, it renders one: `undefined` becomes the
string "undefined" and `null` becomes the string "null". Either way the
rule file is created and its entire contents are that word. A string or
a number does the same.

The test is now "a usable object" rather than "not undefined", and the
three cases are covered. Confirmed by restoring the old guard and
watching all three fail.

Worth naming why two checks in this function disagree about `null`, and
why that is correct. The mutual-exclusion check asks what the payload
CLAIMS, so any present `content` — `null` included — means the service
sent both envelopes. This one asks what can be WRITTEN. Collapsing them
into one predicate would make one of the two wrong.

Also drops a dead disjunct the reviewer spotted: `!isSingleContentRule`
could never be true there, since `files` is provably absent by that
point and the helper is defined as its negation.

And a file set arriving with a stray `tests` now fails loudly rather
than dropping it. The published schema makes that unrepresentable, so
this is the same defence the rest of the path already applies to a
broken promise — a fixture that vanishes silently shows up much later as
a rule that tests nothing.
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 2m 50s … Review complete"
#229 (comment)

This PR had already merged, so both findings are fixed in #230 (fdbad74).

The content: null one was mis-bucketed as low — it reproduces the exact failure this PR claims to close, and nothing covered it. Verified the mechanism rather than the description: yaml.stringify(null) returns the string "null\n", so the rule file is created containing the word null. A string or a number does the same. The guard now tests "a usable object" rather than "not undefined", with tests for all three, confirmed to bite by restoring the old guard.

The observation that the two checks disagreed about null is the useful half, and the disagreement stays — with the reasoning now written down. The mutual-exclusion check asks what the payload claims, so any present content means both envelopes were sent; the completeness check asks what can be written. One predicate for both would make one of them wrong.

The dead disjunct is gone, and a file set carrying a stray tests now throws at both call sites instead of dropping it.

— AI Coding Agent

thecodedrift added a commit that referenced this pull request Sep 1, 2026
Review feedback on #229, applied here because #229 had already merged.

`content: null` reproduced the exact bug #229 says it closes. The guard
tested `content === undefined`, and `yaml` does not throw on a value it
cannot make a document of, it renders one: `undefined` becomes the
string "undefined" and `null` becomes the string "null". Either way the
rule file is created and its entire contents are that word. A string or
a number does the same.

The test is now "a usable object" rather than "not undefined", and the
three cases are covered. Confirmed by restoring the old guard and
watching all three fail.

Worth naming why two checks in this function disagree about `null`, and
why that is correct. The mutual-exclusion check asks what the payload
CLAIMS, so any present `content` — `null` included — means the service
sent both envelopes. This one asks what can be WRITTEN. Collapsing them
into one predicate would make one of the two wrong.

Also drops a dead disjunct the reviewer spotted: `!isSingleContentRule`
could never be true there, since `files` is provably absent by that
point and the helper is defined as its negation.

And a file set arriving with a stray `tests` now fails loudly rather
than dropping it. The published schema makes that unrepresentable, so
this is the same defence the rest of the path already applies to a
broken promise — a fixture that vanishes silently shows up much later as
a rule that tests nothing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant