Orientation for agents and new contributors working on @clerk/break-check. The
README is the user-facing surface; this file covers what you
need to know to change the code.
A CLI that snapshots TypeScript public API surfaces using Microsoft API Extractor, diffs them between a baseline and the current build, and reports breaking vs. non-breaking changes. It is consumed three ways:
- As a local CLI (
break-check snapshot,break-check detect). - As a GitHub composite Action (see
action.yml) that runs the CLI on PRs. - Programmatically via
src/index.tsexports (currently thin).
The package is published as @clerk/break-check. Versioning is managed by
Changesets.
src/
cli.ts Commander entrypoint. Wires init / snapshot / detect.
config.ts Loads + validates break-check.config.json via zod.
index.ts Public programmatic exports.
types.ts Shared types (Snapshot, ApiChange, Severity, etc.).
core/
api-extractor.ts Wraps @microsoft/api-extractor. Discovers package
entrypoints, including subpath exports, and produces
raw .d.ts rollups.
detector.ts Top-level orchestration for `break-check detect`. Loads
snapshots, runs the rule-based diff, optionally
invokes the AI reviewer, then renders the report.
analyzers/
api-diff.ts Rule-based structural diff. Source of truth for
classification (breaking / non-breaking / addition).
ai-analyzer.ts Optional Claude-based reviewer. Confirms/overrides
rule-based verdicts and scans for misses. Fail-soft.
version.ts Inspects package.json version bumps relative to the
baseline; flags insufficient bumps when enabled.
reporters/
markdown.ts Renders the change report. JSON output is produced
directly from the change objects, not via a reporter.
utils/ Small shared helpers.
test/ Node's built-in test runner (`node --test`). Tests
run against the built dist/, not src/. See `test`
script in package.json.
action.yml Composite GitHub Action. Snapshots the base ref in a
worktree, builds the PR, runs detect, comments on PR.
.github/workflows/ CI for this repo (build/test/release/api-check).
.changeset/ Pending changesets for the next release.
Package manager is pnpm (pnpm-lock.yaml). Node >=22.13 (repo pins
24 via .nvmrc).
pnpm install
pnpm build # tsc -> dist/
pnpm test # builds then runs node --test test/*.test.mjs
pnpm typecheck # tsc --noEmit
pnpm check # format + typecheck + test + pnpm pack --dry-run
pnpm format # prettier --writeTests import the built dist/ output, so pnpm test always builds first.
If you're iterating tightly, run pnpm dev (tsc --watch) in one shell and
node --test test/<file>.test.mjs in another.
Before declaring work done: pnpm check must pass, and git diff main --stat should show only intended files.
- New CLI flag: add it in
src/cli.ts, thread it through to whichever module consumes it. Most options flow intodetector.tsorapi-extractor.ts. - New config field: extend the zod schema in
src/config.tsand theBreakCheckConfigtype. Document it inREADME.mdand bump via a changeset. - Change how a diff is classified: edit
src/analyzers/api-diff.ts. The "Change Detection" table inREADME.mdis the contract; if you shift a classification, update the README and add a test intest/api-diff.test.mjs. - Change AI reviewer behavior:
src/analyzers/ai-analyzer.ts. Keep it fail-soft. Any new failure mode must fall back to the rule-based result rather than crashingdetect. - Change Action behavior:
action.ymlis a composite Action, pure shell. The Action's "first PR introducing break-check" branch copies the PR config into the base checkout (see README for context); be careful not to regress that.
- TypeScript: strict, ESM (
"type": "module"),NodeNextmodule resolution. Imports of local files use.jsextensions even though source is.ts. - Errors: surface diagnostics through the CLI's existing error
paths (non-zero exit, message on stderr). Don't
process.exitfrom deep modules; letcli.tsdecide the exit code. - JSON output: when
--format jsonwrites to stdout, every other log line must go to stderr so stdout stays parseable.detector.tsenforces this; preserve it. - No em-dashes in code comments, commit messages, PR descriptions, or docs. Use commas, semicolons, or periods.
- Changesets: every PR that touches published code needs a
changeset (
pnpm changeset). For Action-only or tooling-only changes that don't affect the published package, commit an empty changeset (frontmatter with no packages). - Commits/PRs: conventional commit prefixes (
feat:,fix:,chore:,docs:) follow the existing log. PR descriptions stay short and point reviewers at the load-bearing parts of the diff. - Worktrees: keep them under
.worktrees/(gitignored).
- Subpath exports:
api-extractor.tswalks every entrypoint exposed throughpackage.json#exports, not just.. A package withexports["./foo"]produces a separate snapshot file. Seetest/subpath.test.mjsfor the contract. - Hashed bundler chunks under
./*are filtered. When a wildcard export globs into a bundler output dir, the shared content-hashed chunks (index-Dq-_K2VH,url-CcPzUbGM) are not public API but their hash flips every build, so naive expansion reports phantom remove+add subpaths.isHashedChunkSubpath(inutils/api-extractor.ts) drops wildcard matches whose basename ends in a high-entropy-<8 base64url chars>suffix; it is on by default and toggled via theignoreHashedChunksconfig field. The filter is applied symmetrically, both indiscoverEntries(current build) and on the baseline read indetector.ts#analyzePackage, so an older baseline that recorded chunk subpaths reconciles without aDISCOVERY_VERSIONbump.ignoreSubpathsis glob-aware and optionally package-scoped (makeScopedSubpathMatcher): a bare entry (anything not shapedpkg#subpath, including glob forms like**) applies to every configured package, while@clerk/astro#./envpins one package using theacknowledgedChanges#separator. Globs work on both sides here, which goes beyondacknowledgedChanges(its package part is exact-match). It is the explicit escape hatch for anything the heuristic misses, applied at the same two symmetric sites as the chunk filter; skip-reason guidance emits the exact scoped entry to copy.makeSubpathMatcher(unscoped) remains forresolvableSpecifiers. - Type variance is intentionally pessimistic: any type change is flagged as breaking, even when the new type is strictly wider. The AI reviewer is currently the only thing that can downgrade those. This is documented in the README; don't "fix" it silently.
- Union/intersection member order is canonicalized at compare time. TS
emits inferred union members in an order keyed off an unstable internal
type-id table, so an unrelated edit rotates the order and the raw string
compare reads a pure reorder as a breaking
Return type changed(issue #85).canonicalizeType(utils/canonicalize-type.ts) sorts top-level union/intersection members (recursing into brackets) before comparison. It is the final step ofapi-diff.ts#normalizeType, so every structural compare (returnType, param, property, enum initializer, opaque signature) and the snippet fallback inherit it; the AI missed-breaks audit applies it too (ai-analyzer.ts#extractSurface,normalizeExcerpt) so its own surface diff can't re-flag a reorder. It is reorder + exact-dedup only, never semantic normalization (preserving the pessimistic stance above), and fail-closed: a function type (=>), a conditional (extends ? :), or any malformed string is returned unchanged, so a bug can at worst leave a phantom break, never hide a real one. It is compare-time only and applied symmetrically to both reads, so it needs no snapshot/schemaVersion/DISCOVERY_VERSIONbump and an old baseline that recorded the other order reconciles without regeneration. - API Extractor major bumps are break-check major bumps.
@microsoft/api-extractoris pinned to an exact version inpackage.json(no^). Each per-package metadata file records the producingbreakCheckVersion,apiExtractorVersion, anddiscoveryVersion(snapshotschemaVersion: 4). Onbreak-check detect, a baseline whose recorded AE major differs from the running one is refused with a structured error, since the hand-rolledparseApiJsonreader is not guaranteed to be forward/backward compatible across AE majors. Pre-stamp baselines (v1/v2) load with a warning. When you bump AE, expect to issue a break-check major and document that committed baselines must be regenerated. - Discovery-version gate.
DISCOVERY_VERSIONinutils/api-extractor.tstracks break-check's entry-point discovery semantics; bump it whenever a change alters which entry points are enumerated (e.g. wildcard subpath expansion did).detectrefuses a baseline whose recordeddiscoveryVersionis older than the running one, and refuses a producer-stamped baseline (schema >= 3) that predates the field, because the two snapshots no longer cover the same surface and newly enumerated subpaths would otherwise read as phantom additions. As a backstop, a current subpath that has no baseline entry in an already-baselined package is collapsed to a single "new subpath" addition (buildSubpathAdditionChangeincore/detector.ts) rather than one addition per exported member. - AI reviewer is opt-in: it runs iff
BREAK_CHECK_ANTHROPIC_API_KEYis set, unlessai.enabledis explicitlyfalse. Model resolution priority is--ai-model>BREAK_CHECK_AI_MODEL>ai.modelconfig >claude-sonnet-4-6. Preserve that priority order when editing. - The verdict call ships a focused context, not the whole surface.
buildFocusedSurfaceBlockresolves, per change, the type definitions its signature references (transitively, via API ExtractorcanonicalReferencetokens, capped atMAX_FOCUSED_SYMBOLS), including a referenced type's baseline definition where it changed. The changed members are not re-emitted; their before/after signatures ride inline in the compact-JSON review list. Unresolvable references are dropped, and system-prompt rule 8 tells the model to keep "breaking" when it cannot resolve a type, so a thin context fails safe.submit_reviewasks for one-sentence rationales, and the surface only takes a prompt-cache breakpoint when more than one chunk will read it. The missed-breaks audit is the exception: to find a break the rule pass didn't flag at all it must diff old against new itself, so it sends both the baseline and current full surfaces (buildAuditSurfaceBlock), not the focused set. The focused context also carries a "Usage sites" block: for each changed named type, the signatures that reference it (collectUsageSites), so the model can judge input vs output direction (adding a required field to a read-only output type is non-breaking; system-prompt rule 11). Referrers are gathered across the package's OTHER subpath surfaces too, threaded in viaAiPackageContext.siblingCurrentApiJsonPathsfromdetector.ts(a changed type and the function returning it frequently live in different subpath rollups, and the analyzer otherwise sees one subpath at a time). Matching is bycanonicalReference, which is stable across rollups; an unresolved/diverging ref just yields fewer usage sites, which fails safe (rule 11 keeps "breaking" when no usage sites are shown). Usage sites are collected BEFORE the empty-forward-refs early return, since atype R = {...}with no references can still have usage sites.walkSurfaceis memoized by path+mtime (walkSurfaceCached) so the per-subpath calls don't re-parse the same sibling.api.jsonrepeatedly. NotewalkSurfaceindexes every member under BOTH its full-chain name and api-diff's immediate-parent name (Inner.aas well asOuter.Inner.a), because the rule-based differ names a change by its immediate parent only; without that alias a namespace-nested change would seed an empty closure. Keep both keys. It also keepsallNodes(a flat list) socollectUsageSitescan scan referrers including members without acanonicalReference. - Two orthogonal opt-ins, both default off; the default cannot clear a
break.
applyDowngradesdecides whether abreaking -> non-breakingverdict (the only one that can hide a break) is acted on or recorded as anai-suggested-downgrade(change stays breaking, report points the user at--ai-apply-downgrades).scanForMissedruns the audit and reviews additions-only diffs. Both are resolved indetector.ts(resolveAiFlag: option > env > config) and threaded into the analyzer. Keep them separate: one relaxes verdicts (lenient, risky), the other hunts for more breaks (paranoid, safe), so a single flag for both is wrong. Escalations (-> breaking) and confirmations always apply. An additions-only diff makes zero API calls unlessscanForMissedis on. acknowledgedChangesis a config-level override, not an AI knob.makeAcknowledgedMatcher(utils/acknowledged.ts) compiles the config patterns ("<name>"or"<packageName>#<name>",*glob in the name part, reusingglobToRegExpSource).detector.ts#analyzePackageapplies it as a final pass over the assembledallChanges, flipping any matchedbreakingchange tonon-breaking(recordingruleBasedType, settingacknowledged: true). It runs whether or not the AI is on, after the AI, so the maintainer's override always wins; it is unconditional (not gated behind--ai-apply-downgrades). Counts,hasBreakingChanges, and the recommended bump all key offtype, so the flip is sufficient. The markdown reporter tags acknowledged changes and suppresses the "re-run with --ai-apply-downgrades" nudge for them.- The unresolvable-reference guard is a deterministic, AI-proof escalation
in the opposite direction. When a change's new signature references a
dependency subpath consumers can't resolve (export-blocked or an internal
bundler chunk, e.g.
@clerk/shared/_chunks/index-DcO1-lARunder"./_chunks/*": null), the change is breaking regardless of structural shape: downstream it errors (TS2307) or degrades toany(skipLibCheck). This is the false-negative from issue #60. Noteapi-diff.ts#canonicalTypestrips the subpath from a resolved reference and an unresolvable one carries nocanonicalReferenceat all, so the signal survives ONLY in the rawafterSnippettext, never the canonical comparison type.utils/exports-resolution.tsextracts the inlineimport("...")specifiers a signature newly introduces (present inafterSnippet, absent inbeforeSnippet) and classifies each:isSubpathExportedresolves it against the dependency'spackage.jsonexports(exact key, single-*wildcard longest-prefix-wins,null= blocked), located by walking upnode_modulesfrompackageInfo.path; when the dependency can't be located it falls back tolooksLikeInternalChunk(a/_chunks/segment orisHashedChunkSubpathbasename), reported as a non-deterministic hit.detector.ts#flagUnresolvableReferencesruns BEFORE the AI over every non-addition change: a change the rule pass already flaggedbreakingis markedunresolvableReference(either a deterministic block or the heuristic qualifies, since marking an already-breaking change only prevents a relaxation, never invents a break); anon-breakingmodification is escalated to breaking ONLY on a deterministicexportsblock (e.g. a newly-added optional param whose type lives in a blocked subpath), never on the heuristic, so a chunk-shaped name can't manufacture a break. Theai-analyzer.tsdowngrade branch then refuses to apply a downgrade for a flagged change even whenapplyDowngradesis on (it records the model's opinion as a non-applied suggestion); system-prompt rule 12 also tells the model not to downgrade such refs. The reporter shows a⛔callout naming the specifier and suppresses the--ai-apply-downgradesnudge for it.acknowledgedChangesstill wins (it runs after and can clear it);resolvableSpecifiers(glob-aware, viamakeSubpathMatcher) is the per-specifier escape hatch. KeepunresolvableReferenceOUT ofgenerateChangeId. SCOPE: the guard inspects emitted breaking / non-breaking changes, not brand-new exports (an addition referencing a blocked subpath is reported as an addition; a new unusable export is not a "breaking" change and shouldn't force a major). The reported issue #60 transition (resolvable subpath -> blocked chunk) is fully caught even when the exported symbol name is preserved: a blocked reference carries nocanonicalReference, socanonicalTypeleaves its raw chunk path in the comparison string and the diff fires. The only casecanonicalTypecollapses to nothing is a resolvable-chunk -> resolvable-chunk move, which is benign (a resolvable chunk is importable by consumers). - The repair downgrade is the guard's deterministic inverse (issue #98).
When a breaking modification's only diff is swapping unconsumable specifiers
for exported ones,
detector.ts#applyReferenceRepairs(running right afterflagUnresolvableReferences, before the AI) flips it to non-breaking and recordsrepairedReference: { from, to }. The gate isfindRepairedReference(utils/exports-resolution.ts): every removed specifier must classifyblockeddeterministically, orunknown+ chunk-shaped +packageNotFound(a LOCATED dependency without anexportsmap never qualifies: legacy resolution serves every file, so the chunk may genuinely have resolved), and must not matchresolvableSpecifiers; every introduced specifier must be a bare specifier classifiedexportedDETERMINISTICALLY against the dependency's actualexportsmap (the downgrade clears a break, so nothing else may vouch for the after side; noteclassifyReferencecalls a relative/absolute/malformed specifier "exported" for the guard's fail-safe direction, which is why the repair pass usesclassifyTransition's richer verdicts, notclassifyReference); and the snippets must be identical after masking each swappedimport("spec").Nameunit (the alias name may change with the specifier, bundlers minify chunk-internal names; only the first member access is masked, deeper chains must still match). Anything else fails the masked compare and stays breaking, fail-closed. A change the unresolvable guard flagged is never downgraded;downgradeRepairedReferences: falseis the config opt-out. The AI cannot escalate a repaired change: the analyzer records the refused verdict asai-suggested-escalation, mirroring the downgrade refusal forunresolvableReference. The same pass attachesreferenceResolutions(per-specifier exports-map verdicts, both sides) to any change whose specifier sets differ, regardless of repair outcome or the toggle; the per-change review JSON ships them and system-prompt rules 12/13 tell the model to trust those verdicts over path shapes. KeeprepairedReferenceandreferenceResolutionsOUT ofgenerateChangeId. - The absorbing-arm downgrade clears suggestion-only union changes (issue
#114). A union carrying
string & {}/string & Record<never, never>(or thenumberequivalents) accepts every value of that primitive; its literal/template-literal arms only drive editor autocomplete (theAutocomplete/LiteralUnionidiom), and the AI tends to CONFIRM the rule pass's breaking verdict for them, which--ai-apply-downgradescannot relax.detector.ts#applyAbsorbingArmDowngrades(right afterapplyReferenceRepairs, before the AI) downgrades a breakingcategory: "type"modification whenfindAbsorbingArmEquivalence(utils/union-absorption.ts) proves both sides are unions with an IDENTICAL absorbing arm and every changed arm is a subtype of the primitive (literals, template literals, unions/intersections/ conditionals thereof, or same-report alias references, depth-capped). The changed alias's RHS is usually an unexpanded application (Autocomplete<X>) of UNEXPORTED aliases, which the.api.jsondoc model omits entirely, so the resolver parses each side's.api.mdAPI report (forgotten exports appear there verbatim); a legacy baseline without a stored report never downgrades. Each side resolves against its own report, so a changedAutocompletedefinition diverges the expansions and fails the match. Everything is fail-closed: reserved-name shadowing (a surface importing or declaringRecord, a type param namedRecord), substitution into arms with unquoted:/=>/braces, bindings with depth-0|/&spliced into non-bare arms, and unchanged arms that neither prove subtype on BOTH sides nor are reference-free keywords all keep the change breaking (byte-identity of a named reference proves nothing; the name could re-bind between versions).unresolvableReferencewins over it; the AI records but cannot apply an escalation (mirroringrepairedReference); system-prompt rule 14 teaches the idiom and the marker;acknowledgedChangesstill applies. KeepabsorbingArmUnionOUT ofgenerateChangeId. Opt out withdowngradeAbsorbingArmUnions: false. - Action depends on the published package: the composite Action's
npxstep fetches@clerk/break-checkfrom npm at runtime, so consumers pin the repo's movingv1tag (clerk/break-check@v1). Keep the README's Actions section in sync if this changes.
- Land PRs with changesets.
- The release workflow (see
.github/workflows/) opens a "Version Packages" PR. Merging it tags and publishes viapnpm release(which runschangeset publish). - After publishing, the release workflow force-moves the
v1tag to the release commit soclerk/break-check@v1tracks the latest.v1names the Action's INTERFACE major, decoupled from the npm version; if action.yml's inputs/outputs ever change incompatibly, freeze the tag step at the last compatible commit and pushv2instead.