Track the rule registry as a generated artifact (#728)

* Track the rule registry as a generated artifact

`cargo xtask bundle` already wrote the registry to `dist/antipatterns.json`
and into `extension/detector/`, but neither is tracked, so a consumer
reading this repo from a source checkout or a tarball had no way to get the
rule list without a Rust toolchain. The Rust swap made that concrete:
impeccable.style imported `cli/engine/registry/antipatterns.mjs` for its
rule count and its Slop catalog, and that file is gone.

Write the same JSON to `crates/live/assets/antipatterns.json`, next to the
in-page bundle and tracked like it, and extend `cargo xtask bundle --check`
to fail when either asset is stale. The build's rule-count check now reads
the tracked copy first and falls back to the extension copy, so a fresh
checkout validates counts instead of skipping the check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Registry: wire the staleness gate into CI, harden the count read

Two review findings on the tracked-registry change.

`cargo xtask bundle --check` was never run by CI, so a rule whose name,
category, or description changed without changing the rule count could
ship a stale `crates/live/assets/antipatterns.json`. The extension job
already runs `bun run build:extension` (and so `cargo xtask bundle`) and
then asserts a clean tree; adding that file to the path list covers it
with the gate that is already there. The bundle beside it stays out: its
bytes carry a wasm module built by whatever wasm-pack and wasm-opt the
runner installed, so diffing it would fail on toolchain drift rather than
on a real change.

`readDetectionRuleCount` counted `new Set(rules.map(r => r.id))`, so a
shape change would collapse to a set of one `undefined` and read as a
one-rule registry, flagging every count claim as stale. Count only
non-empty string ids, and say "no readable antipatterns.json" when the
file is present but unparseable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Registry gate: make the trigger honest, name the real count condition

The tracked-registry diff check ran on every PR, but the step that
regenerates the registry (`bun run build:extension`, which is `cargo xtask
bundle`) only runs when the detector trigger fires, and that trigger did
not list `crates/bundle`. A PR that changed how the registry is
serialized therefore never rebuilt it, and the check compared the
committed file against an untouched tree and passed on stale bytes.

Two changes. The detector trigger now covers every input the bundle
reads: `crates/(bundle|core|foundation|wasm|xtask)/` plus
`crates/live/assets/` so a hand-edit of a tracked artifact is regenerated
over. And the registry check moved into its own step carrying the same
condition as the build it validates, so it no longer claims to check
something that was never regenerated; the provider-output check stays
unconditional, because `bun run build` runs on every PR.

Separately, `readDetectionRuleCount` returns the reason it found no
count. "no antipatterns.json" covered three different conditions, and a
registry that is present but unparseable sends anyone debugging a count
failure to the wrong place. It now reports the paths it looked at, or
names the file that is not readable as JSON, or names the file that
carries no rule ids.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-09-04 12:43:53 -07:00
committed by GitHub
co-authored by Claude Code
parent 3d17a8e40f
commit 87d8f6d686
9 changed files with 500 additions and 45 deletions
+47 -14
View File
@@ -61,12 +61,12 @@ function generateCounts(rootDir, skills, buildDir) {
commandCount = activeCommands.length;
}
// Count detection rules from the engine's rule registry as vendored by
// build:extension (extension/detector/antipatterns.json). The registry
// lives in the engine repo now, so when that file is absent (fresh
// checkout, no extension build) the detection-count check is skipped
// rather than guessed.
const detectionCount = readDetectionRuleCount(rootDir);
// Count detection rules from the rule registry as `cargo xtask bundle`
// emits it. crates/live/assets/antipatterns.json is tracked, so a fresh
// checkout has it; extension/detector/antipatterns.json is the gitignored
// extension copy and only stands in for an older tree. With neither, the
// detection-count check is skipped rather than guessed.
const { count: detectionCount, reason: detectionReason } = readDetectionRuleCount(rootDir);
// Validate counts in key files
const filesToCheck = [
@@ -118,19 +118,52 @@ function generateCounts(rootDir, skills, buildDir) {
console.error(`\n${errors} stale count reference(s) found. Update them to match source of truth.`);
}
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount == null ? 'detection rules unchecked (no extension/detector/antipatterns.json)' : `${detectionCount} detection rules`}`);
console.log(`✓ Generated counts: ${commandCount} commands, ${detectionCount == null ? `detection rules unchecked: ${detectionReason}` : `${detectionCount} detection rules`}`);
return errors;
}
const RULE_REGISTRY_PATHS = [
['crates', 'live', 'assets', 'antipatterns.json'],
['extension', 'detector', 'antipatterns.json'],
];
/**
* The number of distinct rule ids in the registry, or `{ count: null, reason }`
* when no location yields one. The reason names the actual condition and the
* path it applies to: a registry that is present but unparseable reads very
* differently from one that was never generated, and "no antipatterns.json"
* for both sends anyone debugging a count failure to the wrong place.
*/
function readDetectionRuleCount(rootDir) {
const registry = path.join(rootDir, 'extension', 'detector', 'antipatterns.json');
if (!fs.existsSync(registry)) return null;
try {
const rules = JSON.parse(fs.readFileSync(registry, 'utf-8'));
return new Set((Array.isArray(rules) ? rules : []).map(rule => rule.id)).size || null;
} catch {
return null;
const problems = [];
for (const parts of RULE_REGISTRY_PATHS) {
const rel = parts.join('/');
const registry = path.join(rootDir, ...parts);
if (!fs.existsSync(registry)) continue;
let rules;
try {
rules = JSON.parse(fs.readFileSync(registry, 'utf-8'));
} catch (err) {
problems.push(`${rel} is not readable as JSON (${err.message})`);
continue;
}
// Only string ids count. A shape change (a wrapper object, a row without
// an id) would otherwise collapse to a Set of one `undefined` and read as
// a one-rule registry, which validates every count claim as stale.
const ids = (Array.isArray(rules) ? rules : [])
.map(rule => rule?.id)
.filter(id => typeof id === 'string' && id.length > 0);
if (ids.length === 0) {
problems.push(`${rel} carries no rule ids`);
continue;
}
return { count: new Set(ids).size };
}
const where = RULE_REGISTRY_PATHS.map(parts => parts.join('/')).join(' or ');
return {
count: null,
reason: problems.length > 0 ? problems.join('; ') : `no antipatterns.json at ${where}`,
};
}
/**