Skip to content

Commit 4c81537

Browse files
authored
feat: report blocked-to-exported reference repairs as non-breaking (#99)
1 parent 519af54 commit 4c81537

13 files changed

Lines changed: 1200 additions & 16 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@clerk/break-check": minor
3+
---
4+
5+
A reference _repair_ is now reported non-breaking (issue #98). When a breaking
6+
modification's only diff is swapping module specifiers consumers could not
7+
resolve (export-blocked, e.g. `@clerk/shared/_chunks/index-Cr_OtBLq` under
8+
`"./_chunks/*": null`, or chunk-shaped with the dependency unlocatable) for
9+
specifiers that provably resolve against the dependency's `exports`, the change
10+
is deterministically downgraded and tagged as a repaired reference in the
11+
report. The old reference errored (TS2307) or degraded to `any` downstream, so
12+
fixing it cannot break anyone. The check is fail-closed: any difference beyond
13+
the specifier/alias swap, or an introduced specifier that does not provably
14+
resolve, keeps the change breaking. Opt out with
15+
`downgradeRepairedReferences: false`.
16+
17+
The AI reviewer now also receives deterministic exports-map verdicts
18+
(`referenceResolutions`) for every specifier a signature drops or introduces,
19+
instead of guessing resolvability from path shapes, and cannot escalate a
20+
deterministically repaired change back to breaking (recorded as
21+
`ai-suggested-escalation`, mirroring the downgrade refusal for unresolvable
22+
references).

‎AGENTS.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,36 @@ Before declaring work done: `pnpm check` must pass, and `git diff main
276276
diff fires. The only case `canonicalType` collapses to nothing is a
277277
resolvable-chunk -> resolvable-chunk move, which is benign (a resolvable chunk
278278
is importable by consumers).
279+
- **The repair downgrade is the guard's deterministic inverse (issue #98).**
280+
When a breaking modification's only diff is swapping unconsumable specifiers
281+
for exported ones, `detector.ts#applyReferenceRepairs` (running right after
282+
`flagUnresolvableReferences`, before the AI) flips it to non-breaking and
283+
records `repairedReference: { from, to }`. The gate is
284+
`findRepairedReference` (`utils/exports-resolution.ts`): every removed
285+
specifier must classify `blocked` deterministically, or `unknown` +
286+
chunk-shaped + `packageNotFound` (a LOCATED dependency without an `exports`
287+
map never qualifies: legacy resolution serves every file, so the chunk may
288+
genuinely have resolved), and must not match `resolvableSpecifiers`; every
289+
introduced specifier must be a bare specifier classified `exported`
290+
DETERMINISTICALLY against the dependency's actual `exports` map (the
291+
downgrade clears a break, so nothing else may vouch for the after side;
292+
note `classifyReference` calls a relative/absolute/malformed specifier
293+
"exported" for the guard's fail-safe direction, which is why the repair
294+
pass uses `classifyTransition`'s richer verdicts, not `classifyReference`);
295+
and the snippets must be identical after masking each swapped
296+
`import("spec").Name` unit (the alias name may change with the specifier,
297+
bundlers minify chunk-internal names; only the first member access is
298+
masked, deeper chains must still match). Anything else fails the masked
299+
compare and stays breaking, fail-closed. A change the unresolvable guard
300+
flagged is never downgraded; `downgradeRepairedReferences: false` is the
301+
config opt-out. The AI cannot escalate a repaired change: the analyzer
302+
records the refused verdict as `ai-suggested-escalation`, mirroring the
303+
downgrade refusal for `unresolvableReference`. The same pass attaches
304+
`referenceResolutions` (per-specifier exports-map verdicts, both sides) to
305+
any change whose specifier sets differ, regardless of repair outcome or the
306+
toggle; the per-change review JSON ships them and system-prompt rules 12/13
307+
tell the model to trust those verdicts over path shapes. Keep
308+
`repairedReference` and `referenceResolutions` OUT of `generateChangeId`.
279309
- **Action depends on the published package**: the composite Action's `npx`
280310
step fetches `@clerk/break-check` from npm at runtime, so consumers pin the
281311
repo's moving `v1` tag (`clerk/break-check@v1`). Keep the README's Actions

‎README.md‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -187,18 +187,19 @@ a machine-readable verdict without running detection (and the AI reviewer) twice
187187
188188
## Configuration
189189
190-
| Option | Type | Default | Description |
191-
| ---------------------- | -------- | ---------------- | ---------------------------------------------------------------------- |
192-
| `packages` | string[] | required | Package paths to analyze |
193-
| `snapshotDir` | string | `.api-snapshots` | Snapshot output directory |
194-
| `mainBranch` | string | `main` | Base branch name for repo-specific workflows |
195-
| `checkVersionBump` | boolean | `true` | Mark insufficient version bumps in reports |
196-
| `outputFormat` | string | `markdown` | Default report format |
197-
| `ignoreSubpaths` | string[] | `[]` | Subpath exports to skip (exact, or glob with `*`/`**`) |
198-
| `ignoreHashedChunks` | boolean | `true` | Drop content-hashed bundler chunks matched by `./*` |
199-
| `acknowledgedChanges` | string[] | `[]` | Breaking changes you've verified safe (downgraded + tagged) |
200-
| `resolvableSpecifiers` | string[] | `[]` | Module-specifier globs to exempt from the unresolvable-reference guard |
201-
| `ai` | object | unset | AI reviewer options (see below) |
190+
| Option | Type | Default | Description |
191+
| ----------------------------- | -------- | ---------------- | -------------------------------------------------------------------------------- |
192+
| `packages` | string[] | required | Package paths to analyze |
193+
| `snapshotDir` | string | `.api-snapshots` | Snapshot output directory |
194+
| `mainBranch` | string | `main` | Base branch name for repo-specific workflows |
195+
| `checkVersionBump` | boolean | `true` | Mark insufficient version bumps in reports |
196+
| `outputFormat` | string | `markdown` | Default report format |
197+
| `ignoreSubpaths` | string[] | `[]` | Subpath exports to skip (exact, or glob with `*`/`**`) |
198+
| `ignoreHashedChunks` | boolean | `true` | Drop content-hashed bundler chunks matched by `./*` |
199+
| `acknowledgedChanges` | string[] | `[]` | Breaking changes you've verified safe (downgraded + tagged) |
200+
| `resolvableSpecifiers` | string[] | `[]` | Module-specifier globs to exempt from the unresolvable-reference guard |
201+
| `downgradeRepairedReferences` | boolean | `true` | Report a blocked-to-exported specifier swap (a reference repair) as non-breaking |
202+
| `ai` | object | unset | AI reviewer options (see below) |
202203

203204
Version-bump validation (`checkVersionBump`) compares the bump between the
204205
baseline and current versions against the severity of the detected changes,
@@ -254,6 +255,27 @@ escalating an otherwise non-breaking modification (say a new optional parameter)
254255
when its type is provably export-blocked. A brand-new export is still reported as
255256
an addition, not a breaking change, even when its type is unresolvable.
256257
258+
The guard's inverse is a _repair_: the PR that fixes such a regression swaps the
259+
blocked specifier back to a public subpath
260+
(`import("@clerk/shared/_chunks/index-Cr_OtBLq").Xm` becomes
261+
`import("@clerk/shared/types/utils").Without`). The raw text differs, so the
262+
differ would flag it breaking, but the old reference was never consumable
263+
downstream; nobody can be broken by fixing it. When every specifier a signature
264+
drops is export-blocked (or chunk-shaped with the dependency unlocatable), every
265+
specifier it introduces provably resolves against the dependency's `exports`,
266+
and the signature is otherwise identical once the swapped `import("...").Name`
267+
units are masked (the imported alias may change with the specifier; bundlers
268+
minify chunk-internal names), the change is downgraded to non-breaking and
269+
tagged as a repaired reference in the report. The downgrade is deterministic,
270+
runs with or without the AI reviewer, and the AI cannot escalate it back.
271+
Anything beyond the swap, an introduced specifier that does not provably
272+
resolve, or a dropped specifier that was actually public fails the check and
273+
the change stays breaking. Set `downgradeRepairedReferences: false` to opt out
274+
(for example when `skipLibCheck` consumers who saw `any` must be treated as a
275+
compatibility surface). Both directions surface their exports-map verdicts to
276+
the AI reviewer, so it judges resolvability from resolved facts rather than
277+
path shapes.
278+
257279
### AI reviewer config
258280
259281
| Field | Type | Default | Description |
@@ -544,7 +566,7 @@ Break Check classifies each diff as one of three types.
544566
| Type | Severity | What it covers |
545567
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
546568
| Breaking | Major | Removed exports or members; required parameter added; optional parameter or property made required; a parameter's rest-ness changed; parameter or property type changed; return type changed; a member's `static`/`protected`/`abstract` modifier changed |
547-
| Non-breaking | Minor | Optional parameter added; rest parameter added; required parameter or property made optional |
569+
| Non-breaking | Minor | Optional parameter added; rest parameter added; required parameter or property made optional; a type change whose only diff is swapping an unresolvable specifier for an exported one (a reference repair, see Configuration) |
548570
| Addition | Minor | New exports, new interface/class members |
549571

550572
The analyzer compares parameters, return types, property types, and enum values

‎src/analyzers/ai-analyzer.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,8 @@ Reasoning rules:
235235
9. Adding a *new optional* property to an object type is non-breaking, for both input and output types: existing consumers neither passed it nor relied on reading it. (This is distinct from rule 4, which is about flipping an *existing* output field to optional.)
236236
10. \`Pick\`, \`Omit\`, \`Partial\`, \`Required\`, and other mapped types preserve each member's optionality from the source type. Resolve the member against the referenced source definition before judging; a property newly included by a \`Pick\` (or surviving an \`Omit\`) is optional in the result whenever the source declares it optional, so do not treat it as required unless the source does.
237237
11. Adding a *required* property to a type is breaking only when consumers construct or assign values of that type (a parameter / input position, or an object literal they author against the type). For a type consumers only read (a return / response / output type, e.g. the resolved value of a \`Promise<T>\` return or a hook result field), adding a property is non-breaking. Determine the direction from the "Usage sites" block: a \`Promise<T>\` result, a function return, or a result field is output. If any usage is an input position, or no usage sites are shown, keep "breaking".
238-
12. A reference whose module specifier is not a public, exported entry point of its package is breaking regardless of structural shape: the consumer cannot resolve the module, so the type degrades to \`any\` (skipLibCheck) or fails to compile (TS2307). Treat a specifier that contains a \`/_chunks/\` segment, ends in a content-hashed bundler chunk basename (a \`-<hash>\` suffix), or names a subpath a package blocks/omits in its \`exports\` as non-resolvable. Do NOT downgrade such a change on structural-equivalence grounds (rule 2 does not apply): "the shape is identical" is true but irrelevant when the consumer never receives the type.
238+
12. A reference whose module specifier is not a public, exported entry point of its package is breaking regardless of structural shape: the consumer cannot resolve the module, so the type degrades to \`any\` (skipLibCheck) or fails to compile (TS2307). Treat a specifier that contains a \`/_chunks/\` segment, ends in a content-hashed bundler chunk basename (a \`-<hash>\` suffix), or names a subpath a package blocks/omits in its \`exports\` as non-resolvable. Do NOT downgrade such a change on structural-equivalence grounds (rule 2 does not apply): "the shape is identical" is true but irrelevant when the consumer never receives the type. When a change carries a \`referenceResolutions\` list, entries with \`"deterministic": true\` were resolved against the dependency's actual \`exports\` map: \`blocked\` means consumers cannot resolve the specifier, \`exported\` means it is a public entry point. Trust those over any guess from the specifier's path shape. An \`"unknown"\` verdict verified nothing (dependency not installed, no \`exports\` map, or a relative/absolute specifier); fall back to the shape heuristics above for those.
239+
13. The inverse swap is a repair, not a break: when a change's only difference is that a non-resolvable specifier (per rule 12) was replaced by one whose \`referenceResolutions\` verdict is \`exported\`, the old type was never consumable (it errored or degraded to \`any\`), so no well-typed consumer can be broken by the swap. Judge it "non-breaking" even though the imported alias name changed with the specifier (bundlers minify chunk-internal names, e.g. \`Xm\` for \`Without\`). A change carrying \`repairedReference\` was already verified this way deterministically; confirm it as non-breaking rather than escalating.
239240
240241
Output protocol:
241242
- Always respond by calling the submit_review tool. Never reply with plain text.
@@ -538,6 +539,28 @@ export class AiChangeAnalyzer {
538539
continue;
539540
}
540541

542+
// The mirror guard for repaired references (issue #98): the detector's
543+
// downgrade is deterministic (every dropped specifier unconsumable, every
544+
// introduced one exported, signature otherwise identical), and the swap
545+
// is the entire diff, so a model escalation cannot be adding information.
546+
// Record the opinion without applying it; the reporter explains why.
547+
const isEscalation =
548+
change.type === ChangeType.NON_BREAKING &&
549+
verdictType === ChangeType.BREAKING;
550+
if (isEscalation && change.repairedReference) {
551+
enriched.push({
552+
...change,
553+
aiAnalysis: {
554+
source: "ai-suggested-escalation",
555+
confidence: clamp01(v.confidence),
556+
rationale: v.rationale,
557+
migration: undefined,
558+
model: this.model,
559+
},
560+
});
561+
continue;
562+
}
563+
541564
const overrode = verdictType !== change.type;
542565
if (overrode) this.overriddenCount += 1;
543566
const aiAnalysis: AiAnalysis = {
@@ -610,6 +633,16 @@ export class AiChangeAnalyzer {
610633
// referenced type definitions ride in the surface block.
611634
beforeSnippet: capSnippet(c.beforeSnippet),
612635
afterSnippet: capSnippet(c.afterSnippet),
636+
// Deterministic exports-map verdicts for dropped/introduced specifiers
637+
// (rules 12/13), so the model never guesses resolvability from path
638+
// shapes. Only present when the specifier sets differ, so the common
639+
// change pays no tokens for them.
640+
...(c.referenceResolutions
641+
? { referenceResolutions: c.referenceResolutions }
642+
: {}),
643+
...(c.repairedReference
644+
? { repairedReference: c.repairedReference }
645+
: {}),
613646
}));
614647

615648
// Compact JSON (no indentation): this block is in the non-cached part of

‎src/config.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,19 @@ export const ConfigSchema = z.object({
128128
*/
129129
resolvableSpecifiers: z.array(z.string()).default([]),
130130

131+
/**
132+
* Downgrade reference *repairs* to non-breaking. A repair is the inverse of
133+
* the unresolvable-reference guard: a breaking modification whose only diff
134+
* is swapping specifiers consumers could not resolve (export-blocked or
135+
* internal bundler chunks) for deterministically exported ones, leaving the
136+
* signature otherwise identical. The before state already errored (TS2307)
137+
* or degraded to `any` downstream, so the swap strictly improves
138+
* resolvability. On by default; set to `false` to keep reporting repairs as
139+
* breaking (e.g. when `skipLibCheck` consumers who saw `any` must be treated
140+
* as a compatibility surface).
141+
*/
142+
downgradeRepairedReferences: z.boolean().default(true),
143+
131144
/** Optional AI analyzer configuration. */
132145
ai: AiConfigSchema.optional(),
133146
});
@@ -156,6 +169,7 @@ export function createDefaultConfig(): BreakCheckConfig {
156169
ignoreHashedChunks: true,
157170
acknowledgedChanges: [],
158171
resolvableSpecifiers: [],
172+
downgradeRepairedReferences: true,
159173
};
160174
}
161175

0 commit comments

Comments
 (0)