Skip to content

fix(tooling): the ADR-0087 prescription detector reads real prescriptions, not the FROM/TO placeholder (#6419) - #6432

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6419-adr0087-never-declared-category
Aug 7, 2026
Merged

fix(tooling): the ADR-0087 prescription detector reads real prescriptions, not the FROM/TO placeholder (#6419)#6432
os-project-manager merged 1 commit into
mainfrom
claude/issue-6419-adr0087-never-declared-category

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6419

Scope: Ruling 2 only. The CATEGORIES vocabulary stays three entries, byte-identical — Ruling 1 (a fourth never-declared category) was withdrawn on the thread and is not touched here. The ledger / registered path is likewise byte-identical (verified: the only diff line matching registered is a new test string).

The defect

hasMigrationPrescription had an English branch and a Chinese branch, and both required the literal uppercase words FROM and TO:

written in the changeset old detector
迁移:FROM → TO (the template placeholder) caught
迁移:`aggregate:` → `aggregations:` (a real prescription) missed

So it detected the shape of one historical changeset (#6048's), not the presence of a prescription — and because a useful prescription names real identifiers, the better an author wrote one, the more invisible it became. Since not-required (no-migration-prescription) is a self-contradiction check that rests entirely on this detector, the category was trivially abusable.

Premise re-verified against origin/main (e8dc61e) before implementing: the regex is exactly as the issue describes, the Chinese branch 迁移[^\n]{0,12}FROM\s*(?:→|->)\s*TO requires the same two uppercase words, and the detector is consulted at exactly one site — the no-migration-prescription arm of scan().

The fix

A union of two branches.

  1. The label convention — the old pattern, kept verbatim. This makes the new detector a strict superset: nothing that used to be refused can now slip through, and the exemption can only have got harder to claim. Corpus-attested as a real house convention, not one changeset's quirk: ## FROM → TO x16, FROM → TO: x15, **FROM → TO** x11, Migration (FROM → TO): x4.
  2. A framing-anchored rewrite. X → Y where both sides are code-ish (a backticked span, or a dotted/slashed identifier path), appearing either on a line carrying migration framing or anywhere under a heading that carries it. The framing vocabulary is derived from the stock, not invented — Chinese 迁移 / 改写 / 改名 / 升级 (from ## 迁移, **迁移面**:, ## 破坏性变更 · 迁移, ## 升级说明, 应改写为), English migrat* / rename* / rewrit* / upgrade*, word-anchored.

Word-anchoring the English alternatives is load-bearing: without \b the token matches inside sys_migration_journal, renameConfigKey, migrations/registry.ts, onUpgrade, all of which appear in prose next to arrows. Measured, it removed one false positive and no true positive.

findMigrationPrescription() now returns the evidence line and which branch matched, and the refusal message quotes it. An author who believes their changeset has no prescription can now see exactly what the gate read — the thing nobody could see when #6419 was filed.

Measurement (old detector vs new, both run over the real corpus)

Both versions were imported as modules (CLI trailer stripped, functions byte-identical to the shipping ones) and run over the current .changeset stock, parsed with the gate's own parseChangeset / breakingDeclaration.

CORPUS                 1342 changesets  (declared-breaking: 235)
old detector hits      113  (declared-breaking: 87)
new detector hits      122  (declared-breaking: 92)

DIFFERENCES: +9 newly flagged, -0 no longer flagged

-0 is the superset property, measured rather than argued.

Corpus reach: the current tree's .changeset/*.md set — the whole v17 train's accumulated stock, 1342 files. Historical changesets consumed by past releases are not included: they are deleted at version time, so reaching them means replaying git log over deleted paths across the whole history, which is not cheap and would measure a corpus written under older conventions. The v17 stock is 1342 files against the gate's own design sample of 400 first-parent commits, so it is already the larger measurement.

Hand-checked classification of all 9 differences

changeset breaking? evidence line the new branch matched verdict
client-delete-result-success.md yes `r.deleted` → `r.success`. That is the whole migration. caught FN — the changeset says this is the migration
close-out-sweep-inert-keys.md yes the protocol-17 `topics`→`sources` rename is absorbed caught FN — 14 keys retired with retiredKey prescriptions and os migrate meta
dual-source-cross-form-convergence.md yes **Renamed — `./contracts` `ShareRecipientType` → `RecordShareRecipientType`:** caught FN — export rename, consumers rewrite imports
http-method-defkey-collision.md yes 改名 `HttpMethodSchema` → `HttpMethodSubsetSchema` caught FN — the Chinese-framed rename the old regex was blind to
sharing-rule-recipient-reconcile.md yes rename `group` → `team` caught FN — enum member wire-rename
hook-condition-previous-binding.md no (minor) migration table maps `OLD.x` → `previous.x` caught FN — a literal migration table
object-parse-path-strict.md no (minor) the semantic renames … (`capabilities` / `features` → `enable`) caught FN — names the rewrite that survives the strict flip
strict-unknown-key-history-last.md no (patch) [ Did you mean `k1` → `canonical`? ] fix, channel 1 (renames) FALSE POSITIVE — a diagram of an error-message template, inside a fenced block, in a patch changeset
widget-contract-theme-token-vocabulary.md no (no bump) `colors` 出门时的改名(`surface` → `--card`… FALSE POSITIVE — describes the theme engine's internal CSS-variable mapping; docs-only, "releases nothing"

False positives: 2, both individually justified. Neither changeset declares a breaking change, so breakingDeclaration() filters both out before the detector is ever consulted — their flag is visible only in the --list audit column. Verdict impact on the current tree: zero.

Caution the drivers lane raised on the thread, and it is right: this corpus was itself written under the broken detector, so "historical zero false positives" would prove nothing about safety. That is why the classification above is per-changeset by hand, and why the two false positives are named rather than netted out.

Residual blind spot, measured and documented in the script

Framing-anchored means an unframed rename table is still missed. Measured over the same stock: 128 changesets carry a code-to-code rewrite this detector does not flag, 21 of them declared-breaking — typically the unknown-key-strictness-* and adr-0112-* batches, which list oldnew pairs under a plain heading with no migration word near them. The numbers and the class are written into the script's header so the next person to widen it starts from them.

Two wider rules were measured and rejected:

candidate hits declared-breaking why rejected
shipped (label + framing-anchored) 122 92
also fire on any line with >=2 rewrites 157 102 +35 changesets, a clear minority of them prescriptions
fire on any code-to-code arrow 250 113 a different check — flags behaviour tables and worked examples

The asymmetry that decides it: a false positive here has no honest escape. The author is refused an exemption they are entitled to and the closed vocabulary offers them nothing else — which is the #6419 shape itself, one category over. So the honestly-scoped detector wins, with its blind spot stated rather than hidden.

Tests

The gate's pin tests live in the script's own --self-test (real temp git repositories driven through the shipping scan()), per the #6342 template. 46 -> 67 assertions.

New scan-level cases (real repos, real scan()):

New unit pins: P9-P13 (real prescriptions in both languages, inline and under a heading), P14-P17 (the prose shapes that must still be refused — all real corpus text), P18-P21 (the evidence branch each shape reports).

Reverse verification — direction predicted first

Prediction (recorded before running): ablate branch 2 (delete the framing-anchored limb, leaving the placeholder-only detector) and expect red — P9-P13 and P18-P19 fail on the unit side, R10/R11 report "expected RED, got green" plus their message assertions; and expect P1-P5, P14-P17, P21, G7, R2, G5 to stay green, since branch 1 is unchanged and the false-positive floor is independent of the fix.

Result — exactly as predicted, 14 failures:

R10 the #6419 shape (a real Chinese prescription under the catch-all): expected RED, got green
R10 ... message must match /contradicts the changeset's own body/   (actual: empty)
R10 ... message must match /Evidence \(framed-line\)/               (actual: empty)
R10 ... message must match /aggregations/                           (actual: empty)
R11 a prescription under a migration HEADING: expected RED, got green
R11 ... message must match /contradicts the changeset's own body/   (actual: empty)
R11 ... message must match /Evidence \(framed-section\)/            (actual: empty)
P9:  a real Chinese prescription with backticked identifiers must match
P10: a 改写 prescription must match
P11: an English inline Migration prescription must match
P12: a rewrite under a migration HEADING must match
P13: the same, in English
P18: an inline framed rewrite reports the framed-line branch
P19: a heading-framed rewrite reports the framed-section branch

Nothing else moved — which is the second half of the proof: the label branch and the false-positive floor are green under both versions.

Observable behaviour on the current tree

Requirement: no changeset in the tree may currently claim no-migration-prescription while carrying a real prescription. Measured:

changesets in the current tree claiming `no-migration-prescription`: 0
changesets in the current tree carrying ANY adr-0087 marker:         0

The gate landed today (#6148 / PR #6342) and no changeset in stock carries a marker yet, so no verdict anywhere in the tree changes. There is no live self-contradiction to report and nothing to file. The only observable difference on stock is the --list audit column (prescription=yes/no) for the 9 changesets above.

Gates run

Enumerated from .github/workflows/lint.yml and .github/workflows/pr-automation.yml, not from memory. Every check:* step in both, one by one — all PASS.

ESLint job (30 steps): pnpm lint, slot-lookup, query-options-erasure, nul-bytes, doc-authoring, docs-audit-scope, role-word, quick-reference-counts, adr-anchors, org-identifier, authz-resolver, service-providers, route-envelope, error-code-casing, wildcard-fallthrough, meta-type-normalized, init-service-contract, durability-log-level, startup-registry-verdict, objectui-changeset, release-notes, release-body, node-version, workflow-status-functions, shard-attestation, published-files, engine-double-contract, resume-authority-declared, merge-driver, spec-parsed-alias.

TypeScript Type Check job: type-check-coverage, driver-conformance, stall-guard, spec tsc --noEmit, spec check:generated --reconcile-only, skill-docs, spec-changes, upgrade-guide, authorable-surface, docs, skill-refs, skill-frame-sync, skill-compatibility, react-blocks, type-check-debt, api-surface, exported-any, dual-source-exports, skill-examples, lint doc-formula-expressions, i18n, i18n-coverage.

Check Changeset job: check-empty-changeset.mjs (self-test + base), check-adr-0087-registration.mjs (self-test + base), check-changeset-no-major.mjs.

Two of these needed a build before they said anything, and both reported the prerequisite honestly rather than passing: check:i18n / check:i18n-coverage ("PREREQUISITE NOT MET — the workspace CLI is not built" / "COULD NOT MEASURE"), and check:type-check-debt, which reported @objectstack/spec-monorepo drifting 80 -> 84. That last one was checked rather than assumed: 84 with this change and 84 with it reverted, so it is not this diff — it was an unbuilt-tree artifact, and after turbo run build --filter='./packages/*' --filter='./packages/*/*' the gate passes. (tsc --showConfig also confirms the root program has allowJs off and does not include scripts/*.mjs at all, so no TypeScript program reads this file.)

Why skip-changeset

One file changed, scripts/check-adr-0087-registration.mjs — a zero-dependency CI gate script that no workspace package imports and that ships in no published tarball. It releases nothing, so it writes no changeset and needs the skip-changeset label.


Generated by Claude Code

…ions, not the FROM/TO placeholder (#6419)

`hasMigrationPrescription` had an English branch and a Chinese branch and BOTH
required the literal uppercase words `FROM` and `TO`. So it detected the shape
of one historical changeset (#6048's `迁移:FROM → TO` template) rather than the
presence of a prescription -- and because a useful prescription names real
identifiers, the better an author wrote one, the more invisible it became.

The `no-migration-prescription` exemption is a self-contradiction check that
rests entirely on this detector, so a detector blind to real prescriptions made
that category trivially abusable.

Widened to a union of two branches. Branch 1 is the old pattern, verbatim, so
the detector is a strict SUPERSET -- measured, not asserted. Branch 2 fires on a
framing-anchored rewrite: `X → Y` with code-ish operands on both sides, on a
line carrying migration framing or under a heading that carries it, in both
languages.

Measured over the 1342-changeset stock (235 declared-breaking): old 113 hits /
87 breaking, new 122 / 92, +9 / -0. Seven of the nine are real prescriptions the
old pattern missed; two are false positives on changesets that declare no
breaking change and are therefore never judged. Zero changesets in the tree
claim the category, so no verdict changes.

Reverse-verified with the direction predicted first: ablate branch 2 and the
five real-prescription pins plus both scan-level cases go red (14 failures),
while the label branch and the false-positive floor stay green.

Self-test 46 -> 67 assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 7, 2026 6:29pm

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants