refactor(scripts): generate audit-area surfaces from one registry

scripts/audit-areas.json is now the single source for /axiom:audit
areas. Three surfaces are generated from it into marker-delimited
regions, leaving the surrounding hand-authored prose untouched:

  commands/audit.md   argument: list   (hash markers, inside frontmatter)
  commands/audit.md   body table       (model-facing "Detects" text)
  docs/commands/utility/audit.md       (human-facing prose)

The registry keeps `detects` and `docsDetects` as separate fields
rather than reconciling them. They differ for 22 of 32 areas because
one is routing text the model reads and the other is prose a person
reads — the docs-are-human / source-is-for-LLM split, not drift.

The sidebar is deliberately NOT generated: each of its groups
interleaves audit entries with unrelated commands (Build holds
audit-build beside fix-build, optimize-build, resolve-deps), so the
audit rows are not a spliceable region and generating the groups would
delete the others. It stays hand-maintained and is now checked directly
against the registry, which is what the old docs-vs-sidebar grouped
parity was approximating.

Retires validateParity and validateGroupedParity: with three surfaces
generated from one source they cannot disagree by construction, so the
set-parity and grouped-parity comparisons are replaced by one staleness
check — the same generate-and-diff pattern already used for the inlined
auditors (12d-bis) and the Codex variant (12f). validateRegistry gates
the source itself (duplicate areas, unknown groups, empty fields, a `|`
that would break a table cell, a docPath the sidebar parser could not
read the area back out of).

Also collapses the three arbitrary orderings that had accumulated
across the surfaces into one: group order, then alphabetical within
group. Row order in a for-LLM table carries no meaning, and docs and
sidebar already used exactly this order.

Verified no data was lost: every body-table row and every docs
description is byte-identical to the previous committed content
compared order-independently; only ordering changed. Mutation-tested by
adding an area to the registry alone — all three generated regions plus
the sidebar check reported it. 126 unit tests pass.
This commit is contained in:
Charles Wiltgen
2026-07-25 11:09:25 -07:00
parent 83ded47539
commit 21990414b2
10 changed files with 1023 additions and 54 deletions
+29 -25
View File
@@ -1,10 +1,12 @@
---
description: Smart audit selector - analyzes your project and suggests relevant audits
argument-hint: "[area]"
# Not a Claude Code field — Axiom's own canonical audit-area registry,
# validated against the body table, docs page, and sidebar by
# scripts/audit-parity.ts. `argument-hint` above is the real user-facing hint.
argument: "area (optional) - Which audit to run: all, memory, concurrency, accessibility, energy, swiftui-performance, swiftui-architecture, swiftui-nav, swiftui-layout, swift-performance, swift-simplify, core-data, swiftdata, database-schema, grdb-performance, networking, codable, icloud, storage, liquid-glass, textkit, testing, test-failures, build, spritekit, security, modernization, camera, foundation-models, iap, screenshots, ux-flow, resize"
# Not a Claude Code field — Axiom's own audit-area registry. GENERATED from
# scripts/audit-areas.json by scripts/build-audit-areas.ts; do not hand-edit.
# `argument-hint` above is the real user-facing hint.
# AXIOM_AUDIT_ARGUMENT_BEGIN
argument: "area (optional) - Which audit to run: all, build, codable, core-data, energy, memory, modernization, swift-performance, swift-simplify, test-failures, testing, concurrency, liquid-glass, resize, swiftui-architecture, swiftui-layout, swiftui-nav, swiftui-performance, textkit, ux-flow, camera, foundation-models, iap, networking, database-schema, grdb-performance, icloud, storage, swiftdata, accessibility, spritekit, screenshots, security"
# AXIOM_AUDIT_ARGUMENT_END
disable-model-invocation: true
---
@@ -17,40 +19,42 @@ If no area specified → analyze project and suggest relevant audits
## Available Audits
<!-- AXIOM_AUDIT_TABLE_BEGIN — generated from scripts/audit-areas.json -->
| Area | Agent | Detects |
|------|-------|---------|
| accessibility | accessibility-auditor | VoiceOver labels, Dynamic Type, color contrast, WCAG compliance |
| concurrency | concurrency-auditor | Swift 6 data races, unsafe Task captures, actor isolation |
| build | build-optimizer | Build time optimization opportunities |
| codable | codable-auditor | JSON serialization issues, Sendable violations |
| core-data | core-data-auditor | Thread safety, schema migrations, N+1 queries |
| energy | energy-auditor | Timer abuse, polling patterns, continuous location, animation leaks, background mode misuse |
| memory | memory-auditor | Retain cycles, leaks, Timer/observer patterns |
| swiftui-performance | swiftui-performance-analyzer | Expensive body, formatters, whole-collection dependencies, missing lazy |
| swiftui-architecture | swiftui-architecture-auditor | Logic in view, MVVM/TCA patterns, boundary violations |
| swiftui-nav | swiftui-nav-auditor | NavigationStack issues, path management, deep linking |
| modernization | modernization-helper | ObservableObject→@Observable, @StateObject@State, deprecated APIs |
| swift-performance | swift-performance-analyzer | ARC issues, allocation patterns, generic specialization |
| swift-simplify | swift-simplifier | Behavior-preserving Swift simplifications — guard/optional cleanups, if/switch expressions, collection idioms, redundant boilerplate, dead availability guards |
| core-data | core-data-auditor | Thread safety, schema migrations, N+1 queries |
| networking | networking-auditor | Deprecated APIs (SCNetworkReachability), anti-patterns |
| codable | codable-auditor | JSON serialization issues, Sendable violations |
| icloud | icloud-auditor | iCloud integration issues, entitlements |
| storage | storage-auditor | File protection, storage strategies, data management |
| liquid-glass | liquid-glass-auditor | iOS 26 adoption opportunities, toolbar improvements |
| textkit | textkit-auditor | TextKit issues, text rendering problems |
| testing | testing-auditor | Flaky tests, slow tests, Swift Testing migration, test quality |
| test-failures | test-failure-analyzer | Root-cause diagnosis for a failing or intermittent test — missing await confirmation, @MainActor gaps, shared suite state, missing .serialized |
| build | build-optimizer | Build time optimization opportunities |
| spritekit | spritekit-auditor | Physics bitmask issues, draw call waste, node accumulation, action leaks |
| security | security-privacy-scanner | API keys in code, insecure storage, Privacy Manifests, ATS violations |
| modernization | modernization-helper | ObservableObject→@Observable, @StateObject@State, deprecated APIs |
| testing | testing-auditor | Flaky tests, slow tests, Swift Testing migration, test quality |
| concurrency | concurrency-auditor | Swift 6 data races, unsafe Task captures, actor isolation |
| liquid-glass | liquid-glass-auditor | iOS 26 adoption opportunities, toolbar improvements |
| resize | resize-auditor | Scene-lifecycle gaps, UIScreen.main, UIRequiresFullScreen, orientation-derived layout, fixed-canvas rendering, Mirroring input |
| swiftui-architecture | swiftui-architecture-auditor | Logic in view, MVVM/TCA patterns, boundary violations |
| swiftui-layout | swiftui-layout-auditor | GeometryReader misuse, deprecated screen APIs, hardcoded breakpoints, identity loss |
| swiftui-nav | swiftui-nav-auditor | NavigationStack issues, path management, deep linking |
| swiftui-performance | swiftui-performance-analyzer | Expensive body, formatters, whole-collection dependencies, missing lazy |
| textkit | textkit-auditor | TextKit issues, text rendering problems |
| ux-flow | ux-flow-auditor | Dead-end views, dismiss traps, buried CTAs, missing empty/loading/error states, accessibility dead ends |
| camera | camera-auditor | Deprecated camera APIs, missing interruption handlers, threading violations |
| swiftdata | swiftdata-auditor | @Model struct, missing VersionedSchema models, relationship defaults, migration timing, N+1 |
| foundation-models | foundation-models-auditor | Missing availability checks, main thread blocking, manual JSON parsing, guardrail handling |
| iap | iap-auditor | Missing transaction.finish(), weak receipt validation, missing restore, subscription status tracking, StoreKit test config gaps |
| swiftui-layout | swiftui-layout-auditor | GeometryReader misuse, deprecated screen APIs, hardcoded breakpoints, identity loss |
| resize | resize-auditor | Scene-lifecycle gaps, UIScreen.main, UIRequiresFullScreen, orientation-derived layout, fixed-canvas rendering, Mirroring input |
| networking | networking-auditor | Deprecated APIs (SCNetworkReachability), anti-patterns |
| database-schema | database-schema-auditor | Unsafe ALTER TABLE, DROP operations, missing idempotency, FK misuse, transaction safety |
| grdb-performance | grdb-performance-auditor | Raw SQL string interpolation, missing FK indexes, missing PRAGMA optimize, app-group WAL and suspension defense, INSERT OR REPLACE misused as upsert, observation on WITHOUT ROWID tables |
| icloud | icloud-auditor | iCloud integration issues, entitlements |
| storage | storage-auditor | File protection, storage strategies, data management |
| swiftdata | swiftdata-auditor | @Model struct, missing VersionedSchema models, relationship defaults, migration timing, N+1 |
| accessibility | accessibility-auditor | VoiceOver labels, Dynamic Type, color contrast, WCAG compliance |
| spritekit | spritekit-auditor | Physics bitmask issues, draw call waste, node accumulation, action leaks |
| screenshots | screenshot-validator | Placeholder text, wrong dimensions, debug indicators, broken UI, competitor references |
| ux-flow | ux-flow-auditor | Dead-end views, dismiss traps, buried CTAs, missing empty/loading/error states, accessibility dead ends |
| security | security-privacy-scanner | API keys in code, insecure storage, Privacy Manifests, ATS violations |
<!-- AXIOM_AUDIT_TABLE_END -->
## Direct Dispatch
+3 -3
View File
@@ -1,12 +1,12 @@
{
"totalBytes": 12663907,
"totalBytes": 12664017,
"skills": {
"count": 296,
"bytes": 6916351
},
"commands": {
"count": 15,
"bytes": 41000
"bytes": 41110
},
"agents": {
"count": 42,
@@ -15,5 +15,5 @@
"searchIndex": {
"bytes": 5055648
},
"generatedAt": "2026-07-25T17:18:03.321Z"
"generatedAt": "2026-07-25T17:51:46.409Z"
}
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -30,6 +30,7 @@ When run without arguments, analyzes your project and recommends relevant audits
Grouped to mirror the sidebar exactly — same group names, same group order, same items in the same order within each group.
<!-- AXIOM_AUDIT_DOCS_BEGIN — generated from scripts/audit-areas.json -->
### Build
| Area | What It Checks |
|------|----------------|
@@ -101,7 +102,7 @@ Grouped to mirror the sidebar exactly — same group names, same group order, sa
|------|----------------|
| `screenshots` | Placeholder text, wrong dimensions, debug indicators |
| `security` | API keys in code, insecure storage, Privacy Manifests, ATS violations |
<!-- AXIOM_AUDIT_DOCS_END -->
## Priority Levels
1. **CRITICAL** `core-data`, `swiftdata`, `database-schema`, `storage`, `icloud` (data corruption/loss risk)
+2 -1
View File
@@ -12,7 +12,8 @@
]
},
"scripts": {
"build": "npm run build:auditors && npm run build:codex && npm run build:mcp && npm run build:docs",
"build": "npm run build:audit-areas && npm run build:auditors && npm run build:codex && npm run build:mcp && npm run build:docs",
"build:audit-areas": "node scripts/build-audit-areas.ts",
"build:auditors": "node scripts/build-inlined-auditors.ts",
"build:codex": "node scripts/build-codex.ts",
"build:mcp": "cd axiom-mcp && pnpm run build:bundle",
+273
View File
@@ -0,0 +1,273 @@
{
"//": "Canonical registry of /axiom:audit areas. Edit here, then run `npm run build:audit-areas`. Four surfaces are generated from this file: the argument list and body table in commands/audit.md (for-LLM), the docs page table (for-humans), and the VitePress sidebar. `detects` is model-facing routing text; `docsDetects` is human prose — they differ on purpose per the docs-are-human / source-is-for-LLM split.",
"groupOrder": [
"Build",
"Debugging",
"Testing",
"Concurrency",
"UI & Design",
"Integration",
"Storage",
"Accessibility",
"Games",
"Shipping"
],
"areas": [
{
"area": "build",
"agent": "build-optimizer",
"group": "Build",
"detects": "Build time optimization opportunities",
"docsDetects": "Build time optimization opportunities",
"docPath": "/commands/build/audit-build"
},
{
"area": "codable",
"agent": "codable-auditor",
"group": "Debugging",
"detects": "JSON serialization issues, Sendable violations",
"docsDetects": "Manual JSON building, error swallowing, Sendable violations",
"docPath": "/commands/debugging/audit-codable"
},
{
"area": "core-data",
"agent": "core-data-auditor",
"group": "Debugging",
"detects": "Thread safety, schema migrations, N+1 queries",
"docsDetects": "Thread safety, schema migrations, N+1 queries",
"docPath": "/commands/debugging/audit-core-data"
},
{
"area": "energy",
"agent": "energy-auditor",
"group": "Debugging",
"detects": "Timer abuse, polling patterns, continuous location, animation leaks, background mode misuse",
"docsDetects": "Timer abuse, polling patterns, continuous location, animation leaks",
"docPath": "/commands/debugging/audit-energy"
},
{
"area": "memory",
"agent": "memory-auditor",
"group": "Debugging",
"detects": "Retain cycles, leaks, Timer/observer patterns",
"docsDetects": "Retain cycles, Timer/observer leaks, closure captures",
"docPath": "/commands/debugging/audit-memory"
},
{
"area": "modernization",
"agent": "modernization-helper",
"group": "Debugging",
"detects": "ObservableObject→@Observable, @StateObject→@State, deprecated APIs",
"docsDetects": "ObservableObject→@Observable, @StateObject→@State, deprecated APIs",
"docPath": "/commands/debugging/audit-modernization"
},
{
"area": "swift-performance",
"agent": "swift-performance-analyzer",
"group": "Debugging",
"detects": "ARC issues, allocation patterns, generic specialization",
"docsDetects": "ARC issues, allocation patterns, generic specialization",
"docPath": "/commands/debugging/audit-swift-performance"
},
{
"area": "swift-simplify",
"agent": "swift-simplifier",
"group": "Debugging",
"detects": "Behavior-preserving Swift simplifications — guard/optional cleanups, if/switch expressions, collection idioms, redundant boilerplate, dead availability guards",
"docsDetects": "Behavior-preserving Swift clarity — guard/optional cleanups, if/switch expressions, collection idioms, boilerplate",
"docPath": "/commands/debugging/audit-swift-simplify"
},
{
"area": "test-failures",
"agent": "test-failure-analyzer",
"group": "Testing",
"detects": "Root-cause diagnosis for a failing or intermittent test — missing await confirmation, @MainActor gaps, shared suite state, missing .serialized",
"docsDetects": "Why a specific test fails or flakes — missing `await confirmation`, `@MainActor` gaps, shared suite state",
"docPath": "/commands/testing/audit-test-failures"
},
{
"area": "testing",
"agent": "testing-auditor",
"group": "Testing",
"detects": "Flaky tests, slow tests, Swift Testing migration, test quality",
"docsDetects": "Flaky tests, slow tests, Swift Testing migration",
"docPath": "/commands/testing/audit-testing"
},
{
"area": "concurrency",
"agent": "concurrency-auditor",
"group": "Concurrency",
"detects": "Swift 6 data races, unsafe Task captures, actor isolation",
"docsDetects": "Swift 6 data races, unsafe Task captures, actor isolation",
"docPath": "/commands/concurrency/audit-concurrency"
},
{
"area": "liquid-glass",
"agent": "liquid-glass-auditor",
"group": "UI & Design",
"detects": "iOS 26 adoption opportunities, toolbar improvements",
"docsDetects": "iOS 26 adoption opportunities, toolbar improvements",
"docPath": "/commands/ui-design/audit-liquid-glass"
},
{
"area": "resize",
"agent": "resize-auditor",
"group": "UI & Design",
"detects": "Scene-lifecycle gaps, UIScreen.main, UIRequiresFullScreen, orientation-derived layout, fixed-canvas rendering, Mirroring input",
"docsDetects": "Scene-lifecycle gaps, UIScreen.main, fixed-canvas rendering, iPhone Mirroring input",
"docPath": "/commands/ui-design/audit-resize"
},
{
"area": "swiftui-architecture",
"agent": "swiftui-architecture-auditor",
"group": "UI & Design",
"detects": "Logic in view, MVVM/TCA patterns, boundary violations",
"docsDetects": "Logic in views, MVVM/TCA boundary violations",
"docPath": "/commands/ui-design/audit-swiftui-architecture"
},
{
"area": "swiftui-layout",
"agent": "swiftui-layout-auditor",
"group": "UI & Design",
"detects": "GeometryReader misuse, deprecated screen APIs, hardcoded breakpoints, identity loss",
"docsDetects": "GeometryReader misuse, deprecated screen APIs, hardcoded breakpoints",
"docPath": "/commands/ui-design/audit-swiftui-layout"
},
{
"area": "swiftui-nav",
"agent": "swiftui-nav-auditor",
"group": "UI & Design",
"detects": "NavigationStack issues, path management, deep linking",
"docsDetects": "NavigationStack issues, path management, deep linking",
"docPath": "/commands/ui-design/audit-swiftui-nav"
},
{
"area": "swiftui-performance",
"agent": "swiftui-performance-analyzer",
"group": "UI & Design",
"detects": "Expensive body, formatters, whole-collection dependencies, missing lazy",
"docsDetects": "Expensive body, formatters, missing lazy containers",
"docPath": "/commands/ui-design/audit-swiftui-performance"
},
{
"area": "textkit",
"agent": "textkit-auditor",
"group": "UI & Design",
"detects": "TextKit issues, text rendering problems",
"docsDetects": "TextKit issues, text rendering problems",
"docPath": "/commands/ui-design/audit-textkit"
},
{
"area": "ux-flow",
"agent": "ux-flow-auditor",
"group": "UI & Design",
"detects": "Dead-end views, dismiss traps, buried CTAs, missing empty/loading/error states, accessibility dead ends",
"docsDetects": "Dead-end views, dismiss traps, missing empty/loading/error states",
"docPath": "/commands/ui-design/audit-ux-flow"
},
{
"area": "camera",
"agent": "camera-auditor",
"group": "Integration",
"detects": "Deprecated camera APIs, missing interruption handlers, threading violations",
"docsDetects": "Deprecated camera APIs, missing interruption handlers",
"docPath": "/commands/integration/audit-camera"
},
{
"area": "foundation-models",
"agent": "foundation-models-auditor",
"group": "Integration",
"detects": "Missing availability checks, main thread blocking, manual JSON parsing, guardrail handling",
"docsDetects": "Availability checks, main-thread blocking, guardrail handling",
"docPath": "/commands/integration/audit-foundation-models"
},
{
"area": "iap",
"agent": "iap-auditor",
"group": "Integration",
"detects": "Missing transaction.finish(), weak receipt validation, missing restore, subscription status tracking, StoreKit test config gaps",
"docsDetects": "Missing `transaction.finish()`, weak receipt validation, missing restore, subscription tracking",
"docPath": "/commands/integration/audit-iap"
},
{
"area": "networking",
"agent": "networking-auditor",
"group": "Integration",
"detects": "Deprecated APIs (SCNetworkReachability), anti-patterns",
"docsDetects": "Deprecated APIs (SCNetworkReachability), anti-patterns",
"docPath": "/commands/integration/audit-networking"
},
{
"area": "database-schema",
"agent": "database-schema-auditor",
"group": "Storage",
"detects": "Unsafe ALTER TABLE, DROP operations, missing idempotency, FK misuse, transaction safety",
"docsDetects": "Unsafe ALTER TABLE, DROP operations, FK integrity",
"docPath": "/commands/storage/audit-database-schema"
},
{
"area": "grdb-performance",
"agent": "grdb-performance-auditor",
"group": "Storage",
"detects": "Raw SQL string interpolation, missing FK indexes, missing PRAGMA optimize, app-group WAL and suspension defense, INSERT OR REPLACE misused as upsert, observation on WITHOUT ROWID tables",
"docsDetects": "SQL string interpolation, missing FK indexes, `PRAGMA optimize`, app-group WAL and suspension defense, silent observation staleness",
"docPath": "/commands/storage/audit-grdb-performance"
},
{
"area": "icloud",
"agent": "icloud-auditor",
"group": "Storage",
"detects": "iCloud integration issues, entitlements",
"docsDetects": "iCloud entitlements, file coordination, CloudKit errors",
"docPath": "/commands/storage/audit-icloud"
},
{
"area": "storage",
"agent": "storage-auditor",
"group": "Storage",
"detects": "File protection, storage strategies, data management",
"docsDetects": "File protection, backup exclusions, storage strategies",
"docPath": "/commands/storage/audit-storage"
},
{
"area": "swiftdata",
"agent": "swiftdata-auditor",
"group": "Storage",
"detects": "@Model struct, missing VersionedSchema models, relationship defaults, migration timing, N+1",
"docsDetects": "@Model correctness, VersionedSchema, relationship defaults",
"docPath": "/commands/storage/audit-swiftdata"
},
{
"area": "accessibility",
"agent": "accessibility-auditor",
"group": "Accessibility",
"detects": "VoiceOver labels, Dynamic Type, color contrast, WCAG compliance",
"docsDetects": "VoiceOver, Dynamic Type, WCAG compliance",
"docPath": "/commands/accessibility/audit-accessibility"
},
{
"area": "spritekit",
"agent": "spritekit-auditor",
"group": "Games",
"detects": "Physics bitmask issues, draw call waste, node accumulation, action leaks",
"docsDetects": "Physics bitmask issues, draw call waste, action leaks",
"docPath": "/commands/games/audit-spritekit"
},
{
"area": "screenshots",
"agent": "screenshot-validator",
"group": "Shipping",
"detects": "Placeholder text, wrong dimensions, debug indicators, broken UI, competitor references",
"docsDetects": "Placeholder text, wrong dimensions, debug indicators",
"docPath": "/commands/shipping/audit-screenshots"
},
{
"area": "security",
"agent": "security-privacy-scanner",
"group": "Shipping",
"detects": "API keys in code, insecure storage, Privacy Manifests, ATS violations",
"docsDetects": "API keys in code, insecure storage, Privacy Manifests, ATS violations",
"docPath": "/commands/shipping/audit-security"
}
]
}
+271
View File
@@ -0,0 +1,271 @@
/**
* Tests for scripts/audit-areas.ts.
*
* Run via `node --test scripts/audit-areas.test.ts`. Wired into predeploy
* so every release gates on these passing.
*
* Hermetic — synthetic registries only, never the real audit-areas.json.
* The real file is exercised by the round-trip check in
* build-audit-areas.ts --check, which predeploy also runs.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
orderAreas,
validateRegistry,
renderArgumentList,
renderBodyTable,
renderDocsTables,
expectedSidebarGroups,
validateSidebarAgainstRegistry,
spliceRegion,
type AuditRegistry,
} from "./audit-areas.ts";
const registry: AuditRegistry = {
groupOrder: ["Build", "Storage"],
areas: [
{
area: "swiftdata",
agent: "swiftdata-auditor",
group: "Storage",
detects: "@Model struct, VersionedSchema, relationship defaults",
docsDetects: "@Model correctness, VersionedSchema, relationship defaults",
docPath: "/commands/storage/audit-swiftdata",
},
{
area: "build",
agent: "build-optimizer",
group: "Build",
detects: "Build time optimization opportunities",
docsDetects: "Build time optimization opportunities",
docPath: "/commands/build/audit-build",
},
{
area: "icloud",
agent: "icloud-auditor",
group: "Storage",
detects: "iCloud integration issues, entitlements",
docsDetects: "iCloud entitlements, file coordination, CloudKit errors",
docPath: "/commands/storage/audit-icloud",
},
],
};
describe("orderAreas", () => {
it("sorts by group order, then alphabetically within group", () => {
assert.deepEqual(orderAreas(registry).map((a) => a.area), [
"build",
"icloud",
"swiftdata",
]);
});
it("does not mutate the registry", () => {
const before = registry.areas.map((a) => a.area);
orderAreas(registry);
assert.deepEqual(registry.areas.map((a) => a.area), before);
});
});
describe("validateRegistry", () => {
it("accepts a well-formed registry", () => {
assert.deepEqual(validateRegistry(registry), []);
});
it("rejects an area whose group is not in groupOrder", () => {
const bad = { ...registry, areas: [{ ...registry.areas[0], group: "Ghost" }] };
const errs = validateRegistry(bad);
assert.equal(errs.filter((e) => /not in groupOrder/.test(e)).length, 1);
});
it("rejects a duplicate area", () => {
const bad = { ...registry, areas: [...registry.areas, registry.areas[0]] };
assert.ok(validateRegistry(bad).some((e) => /duplicate area 'swiftdata'/.test(e)));
});
it("rejects an empty required field", () => {
const bad = { ...registry, areas: [{ ...registry.areas[0], detects: " " }] };
assert.ok(validateRegistry(bad).some((e) => /empty 'detects'/.test(e)));
});
it("rejects a pipe in description text, which would break the table cell", () => {
const bad = {
...registry,
areas: [{ ...registry.areas[0], docsDetects: "a | b" }],
};
assert.ok(validateRegistry(bad).some((e) => /breaks table rendering/.test(e)));
});
it("rejects a docPath the sidebar parser could not derive the area from", () => {
// parseSidebarAreas reads the area back out of /audit-<area>; a
// mismatch would make the generated sidebar disagree with itself.
const bad = {
...registry,
areas: [{ ...registry.areas[0], docPath: "/commands/storage/audit-swift-data" }],
};
assert.ok(validateRegistry(bad).some((e) => /must end with '\/audit-swiftdata'/.test(e)));
});
it("rejects 'all', which is the meta-target rather than an area", () => {
const bad = {
...registry,
areas: [{ ...registry.areas[0], area: "all", docPath: "/commands/storage/audit-all" }],
};
assert.ok(validateRegistry(bad).some((e) => /meta-target/.test(e)));
});
it("rejects a group declared in groupOrder but used by no area", () => {
const bad = { ...registry, groupOrder: [...registry.groupOrder, "Games"] };
assert.ok(validateRegistry(bad).some((e) => /group 'Games'.*no areas/.test(e)));
});
});
describe("renderArgumentList", () => {
it("renders the meta-target first, then areas in canonical order", () => {
assert.equal(
renderArgumentList(registry),
'argument: "area (optional) - Which audit to run: all, build, icloud, swiftdata"',
);
});
});
describe("renderBodyTable", () => {
it("renders the model-facing detects text in canonical order", () => {
assert.equal(
renderBodyTable(registry),
[
"| Area | Agent | Detects |",
"|------|-------|---------|",
"| build | build-optimizer | Build time optimization opportunities |",
"| icloud | icloud-auditor | iCloud integration issues, entitlements |",
"| swiftdata | swiftdata-auditor | @Model struct, VersionedSchema, relationship defaults |",
].join("\n"),
);
});
});
describe("renderDocsTables", () => {
it("renders one table per group, using the human-facing text", () => {
assert.equal(
renderDocsTables(registry),
[
"### Build",
"| Area | What It Checks |",
"|------|----------------|",
"| `build` | Build time optimization opportunities |",
"",
"### Storage",
"| Area | What It Checks |",
"|------|----------------|",
"| `icloud` | iCloud entitlements, file coordination, CloudKit errors |",
"| `swiftdata` | @Model correctness, VersionedSchema, relationship defaults |",
].join("\n"),
);
});
it("uses docsDetects, not detects — the two differ on purpose", () => {
const out = renderDocsTables(registry);
assert.match(out, /iCloud entitlements, file coordination, CloudKit errors/);
assert.doesNotMatch(out, /iCloud integration issues, entitlements/);
});
});
describe("expectedSidebarGroups", () => {
it("returns audit areas per group, in registry order", () => {
assert.deepEqual(expectedSidebarGroups(registry), [
{ group: "Build", areas: ["build"] },
{ group: "Storage", areas: ["icloud", "swiftdata"] },
]);
});
it("omits groups that have no areas", () => {
const withEmpty = { ...registry, groupOrder: ["Build", "Games", "Storage"] };
assert.deepEqual(expectedSidebarGroups(withEmpty).map((g) => g.group), ["Build", "Storage"]);
});
});
describe("validateSidebarAgainstRegistry", () => {
it("accepts a sidebar matching the registry", () => {
assert.deepEqual(
validateSidebarAgainstRegistry(registry, expectedSidebarGroups(registry)),
[],
);
});
it("flags an area present in the registry but missing from the sidebar", () => {
const actual = [
{ group: "Build", areas: ["build"] },
{ group: "Storage", areas: ["icloud"] },
];
const errs = validateSidebarAgainstRegistry(registry, actual);
assert.equal(errs.length, 1);
assert.match(errs[0], /Storage/);
assert.match(errs[0], /swiftdata/);
});
it("flags within-group order drift", () => {
const actual = [
{ group: "Build", areas: ["build"] },
{ group: "Storage", areas: ["swiftdata", "icloud"] },
];
assert.equal(validateSidebarAgainstRegistry(registry, actual).length, 1);
});
it("reports group misalignment once, not per item", () => {
const actual = [{ group: "Storage", areas: ["icloud", "swiftdata"] }];
const errs = validateSidebarAgainstRegistry(registry, actual);
assert.equal(errs.length, 1);
assert.match(errs[0], /groups differ/);
});
});
describe("spliceRegion", () => {
const doc = [
"before",
"<!-- X_BEGIN — generated -->",
"old content",
"still old",
"<!-- X_END -->",
"after",
].join("\n");
it("replaces only the content between the markers", () => {
assert.equal(
spliceRegion(doc, "X", "NEW"),
["before", "<!-- X_BEGIN — generated -->", "NEW", "<!-- X_END -->", "after"].join("\n"),
);
});
it("is idempotent — splicing the same content twice is a fixed point", () => {
const once = spliceRegion(doc, "X", "NEW");
assert.equal(spliceRegion(once!, "X", "NEW"), once);
});
it("supports line-comment markers for TypeScript config files", () => {
const ts = ["a", "// Y_BEGIN", "old", "// Y_END", "b"].join("\n");
assert.equal(
spliceRegion(ts, "Y", "new", "line"),
["a", "// Y_BEGIN", "new", "// Y_END", "b"].join("\n"),
);
});
it("supports hash markers for YAML frontmatter, where HTML comments are not comments", () => {
const yaml = ["description: x", "# Z_BEGIN", "old: y", "# Z_END", "other: z"].join("\n");
assert.equal(
spliceRegion(yaml, "Z", "new: y", "hash"),
["description: x", "# Z_BEGIN", "new: y", "# Z_END", "other: z"].join("\n"),
);
});
it("returns null when a marker is missing, rather than writing garbage", () => {
assert.equal(spliceRegion("no markers here", "X", "NEW"), null);
assert.equal(spliceRegion("<!-- X_BEGIN -->\nonly open", "X", "NEW"), null);
});
it("returns null when the markers are inverted", () => {
const inverted = ["<!-- X_END -->", "<!-- X_BEGIN -->"].join("\n");
assert.equal(spliceRegion(inverted, "X", "NEW"), null);
});
});
+228
View File
@@ -0,0 +1,228 @@
/**
* Audit-area registry rendering — pure, I/O free.
*
* `scripts/audit-areas.json` is the single source for the /axiom:audit
* area list. Four surfaces are rendered from it:
*
* 1. the `argument:` list in commands/audit.md (Axiom's own registry)
* 2. the `## Available Audits` body table there (for-LLM routing text)
* 3. the grouped tables in docs/commands/utility/audit.md (human prose)
* 4. the audit links in the docs sidebar (navigation)
*
* Surfaces 1-2 and 3-4 deliberately carry DIFFERENT description text:
* `detects` is what the model reads to pick an agent, `docsDetects` is
* what a person reads on the docs site. That split is the project's
* docs-are-human / source-is-for-LLM convention, not drift — so the
* registry holds both fields rather than reconciling them.
*
* Callers (scripts/build-audit-areas.ts) do the file I/O and the
* marker-region splicing. Tests in audit-areas.test.ts.
*/
export interface AuditArea {
area: string;
agent: string;
group: string;
/** Model-facing routing text — the body table's "Detects" column. */
detects: string;
/** Human-facing prose — the docs page's "What It Checks" column. */
docsDetects: string;
/** Sidebar link target, e.g. `/commands/storage/audit-swiftdata`. */
docPath: string;
}
export interface AuditRegistry {
groupOrder: string[];
areas: AuditArea[];
}
/** Begin/end markers delimiting each generated region. */
export const MARKERS = {
argument: "AXIOM_AUDIT_ARGUMENT",
bodyTable: "AXIOM_AUDIT_TABLE",
docsTable: "AXIOM_AUDIT_DOCS",
sidebar: "AXIOM_AUDIT_SIDEBAR",
} as const;
/**
* Order areas canonically: by group order, then alphabetically within
* each group. This is the order docs and sidebar already used; applying
* it to audit.md too collapses the three arbitrary orderings that had
* accumulated across the four surfaces into one.
*/
export function orderAreas(registry: AuditRegistry): AuditArea[] {
const rank = new Map(registry.groupOrder.map((g, i) => [g, i]));
return [...registry.areas].sort(
(a, b) =>
(rank.get(a.group) ?? Infinity) - (rank.get(b.group) ?? Infinity) ||
a.area.localeCompare(b.area),
);
}
/**
* Validate the registry before anything is rendered from it. A bad
* registry would otherwise propagate silently into all four surfaces.
*/
export function validateRegistry(registry: AuditRegistry): string[] {
const errors: string[] = [];
const groups = new Set(registry.groupOrder);
const seenAreas = new Set<string>();
const seenPaths = new Set<string>();
if (registry.groupOrder.length === 0) errors.push("groupOrder is empty");
if (registry.areas.length === 0) errors.push("areas is empty");
for (const a of registry.areas) {
for (const field of [
"area",
"agent",
"group",
"detects",
"docsDetects",
"docPath",
] as const) {
if (!a[field] || a[field].trim() === "") {
errors.push(`area '${a.area || "(unnamed)"}' has empty '${field}'`);
}
}
if (!groups.has(a.group)) {
errors.push(`area '${a.area}' has group '${a.group}' which is not in groupOrder`);
}
if (seenAreas.has(a.area)) errors.push(`duplicate area '${a.area}'`);
seenAreas.add(a.area);
if (seenPaths.has(a.docPath)) errors.push(`duplicate docPath '${a.docPath}'`);
seenPaths.add(a.docPath);
// A `|` would break out of the markdown table cell it is rendered into.
if (a.detects.includes("|") || a.docsDetects.includes("|")) {
errors.push(`area '${a.area}' has a '|' in description text — breaks table rendering`);
}
if (!a.docPath.endsWith(`/audit-${a.area}`)) {
errors.push(
`area '${a.area}' has docPath '${a.docPath}' — must end with '/audit-${a.area}' so parseSidebarAreas can derive the area back`,
);
}
}
// 'all' is the meta-target dispatching to health-check; it is never an area.
if (seenAreas.has("all")) errors.push("'all' is the meta-target and must not be an area");
const unused = registry.groupOrder.filter(
(g) => !registry.areas.some((a) => a.group === g),
);
for (const g of unused) errors.push(`group '${g}' in groupOrder has no areas`);
return errors;
}
/** Surface 1 — the comma-separated list in the `argument:` frontmatter line. */
export function renderArgumentList(registry: AuditRegistry): string {
const areas = orderAreas(registry).map((a) => a.area);
return `argument: "area (optional) - Which audit to run: all, ${areas.join(", ")}"`;
}
/** Surface 2 — the `## Available Audits` body table (model-facing text). */
export function renderBodyTable(registry: AuditRegistry): string {
const lines = ["| Area | Agent | Detects |", "|------|-------|---------|"];
for (const a of orderAreas(registry)) {
lines.push(`| ${a.area} | ${a.agent} | ${a.detects} |`);
}
return lines.join("\n");
}
/** Surface 3 — the grouped docs tables (human-facing prose). */
export function renderDocsTables(registry: AuditRegistry): string {
const ordered = orderAreas(registry);
const out: string[] = [];
for (const group of registry.groupOrder) {
const inGroup = ordered.filter((a) => a.group === group);
if (inGroup.length === 0) continue;
out.push(`### ${group}`);
out.push("| Area | What It Checks |");
out.push("|------|----------------|");
for (const a of inGroup) out.push(`| \`${a.area}\` | ${a.docsDetects} |`);
out.push("");
}
// Trailing blank line is supplied by the marker splice.
return out.join("\n").replace(/\n$/, "");
}
/**
* Surface 4 — the docs sidebar. NOT generated, and deliberately so: each
* sidebar group interleaves audit entries with unrelated commands
* (`Build` holds audit-build alongside fix-build, optimize-build, and
* resolve-deps), so the audit rows are not a whole region that can be
* spliced. Generating the groups would delete the non-audit entries.
*
* Instead the sidebar stays hand-maintained and is CHECKED against the
* registry. That still collapses the old four hand-maintained copies to
* one source plus one checked projection.
*
* Returns the audit groups the sidebar should contain, in order, for
* comparison against `parseSidebarGroups` from audit-parity.ts.
*/
export function expectedSidebarGroups(
registry: AuditRegistry,
): Array<{ group: string; areas: string[] }> {
const ordered = orderAreas(registry);
return registry.groupOrder
.map((group) => ({
group,
areas: ordered.filter((a) => a.group === group).map((a) => a.area),
}))
.filter((g) => g.areas.length > 0);
}
/**
* Compare the sidebar as parsed from config.ts against what the registry
* says it should hold. Reports group-level and item-level divergence.
*/
export function validateSidebarAgainstRegistry(
registry: AuditRegistry,
actual: Array<{ group: string; areas: string[] }>,
): string[] {
const expected = expectedSidebarGroups(registry);
const errors: string[] = [];
const eNames = expected.map((g) => g.group);
const aNames = actual.map((g) => g.group);
if (JSON.stringify(eNames) !== JSON.stringify(aNames)) {
errors.push(
`sidebar audit groups differ from the registry: expected [${eNames.join(", ")}], found [${aNames.join(", ")}]`,
);
return errors; // Item comparison is noise once groups are misaligned.
}
for (let i = 0; i < expected.length; i++) {
if (JSON.stringify(expected[i].areas) !== JSON.stringify(actual[i].areas)) {
errors.push(
`sidebar group '${expected[i].group}': expected [${expected[i].areas.join(", ")}], found [${actual[i].areas.join(", ")}]`,
);
}
}
return errors;
}
/**
* Replace the content between `<!-- BEGIN name -->` / `<!-- END name -->`
* style markers. Returns null when the markers are absent or malformed,
* so the caller can report a precise error instead of writing garbage.
*/
export function spliceRegion(
content: string,
marker: string,
replacement: string,
commentStyle: "html" | "line" | "hash" = "html",
): string | null {
// `hash` is for YAML frontmatter, where an HTML comment is not a comment.
const prefix = { html: "<!-- ", line: "// ", hash: "# " }[commentStyle];
const [open, close] = [`${prefix}${marker}_BEGIN`, `${prefix}${marker}_END`];
const beginIdx = content.indexOf(open);
const endIdx = content.indexOf(close);
if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) return null;
const afterBegin = content.indexOf("\n", beginIdx);
if (afterBegin === -1 || afterBegin > endIdx) return null;
return content.slice(0, afterBegin + 1) + replacement + "\n" + content.slice(endIdx);
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Generate the /axiom:audit area surfaces from scripts/audit-areas.json.
*
* npm run build:audit-areas # write
* npm run build:audit-areas -- --check # verify only, non-zero on drift
*
* Three regions, each delimited by BEGIN/END markers so the surrounding
* hand-authored prose is untouched:
*
* commands/audit.md `argument:` line (hash markers, in frontmatter)
* commands/audit.md body table (html markers)
* docs/commands/utility/audit.md grouped tables (html markers)
*
* The docs sidebar is NOT generated: each of its groups interleaves audit
* entries with unrelated commands, so the audit rows are not a spliceable
* region. It stays hand-maintained and is checked against the registry by
* validateSidebarAgainstRegistry in pre-deploy.
*
* Generated output is COMMITTED — same convention as axiom-codex/ and the
* inlined auditors. pre-deploy.ts runs this in --check mode and fails the
* build if a committed file has drifted from the registry.
*
* Rendering logic lives in scripts/audit-areas.ts (I/O free, unit tested).
* This file is the disk layer.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
MARKERS,
renderArgumentList,
renderBodyTable,
renderDocsTables,
spliceRegion,
validateRegistry,
type AuditRegistry,
} from "./audit-areas.ts";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PLUGIN = path.join(root, ".claude-plugin/plugins/axiom");
const check = process.argv.includes("--check");
const registryPath = path.join(root, "scripts/audit-areas.json");
const registry: AuditRegistry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
const registryErrors = validateRegistry(registry);
if (registryErrors.length > 0) {
console.error("audit-areas.json is invalid:");
for (const e of registryErrors) console.error(`${e}`);
process.exit(1);
}
interface Target {
file: string;
marker: string;
style: "html" | "line" | "hash";
render: () => string;
label: string;
}
const targets: Target[] = [
{
file: path.join(PLUGIN, "commands/audit.md"),
marker: MARKERS.argument,
style: "hash",
render: () => renderArgumentList(registry),
label: "commands/audit.md argument: line",
},
{
file: path.join(PLUGIN, "commands/audit.md"),
marker: MARKERS.bodyTable,
style: "html",
render: () => renderBodyTable(registry),
label: "commands/audit.md body table",
},
{
file: path.join(root, "docs/commands/utility/audit.md"),
marker: MARKERS.docsTable,
style: "html",
render: () => renderDocsTables(registry),
label: "docs/commands/utility/audit.md grouped tables",
},
];
let written = 0;
let unchanged = 0;
const drift: string[] = [];
const errors: string[] = [];
// Group by file so two regions in one file compose rather than clobber.
const byFile = new Map<string, Target[]>();
for (const t of targets) {
const list = byFile.get(t.file) ?? [];
list.push(t);
byFile.set(t.file, list);
}
for (const [file, fileTargets] of byFile) {
if (!fs.existsSync(file)) {
errors.push(`${path.relative(root, file)} not found`);
continue;
}
const original = fs.readFileSync(file, "utf8");
let content = original;
for (const t of fileTargets) {
const next = spliceRegion(content, t.marker, t.render(), t.style);
if (next === null) {
errors.push(
`${t.label}: markers ${t.marker}_BEGIN / ${t.marker}_END not found or malformed`,
);
continue;
}
content = next;
}
if (content === original) {
unchanged += fileTargets.length;
continue;
}
if (check) {
drift.push(path.relative(root, file));
} else {
fs.writeFileSync(file, content);
written += fileTargets.length;
}
}
if (errors.length > 0) {
for (const e of errors) console.error(`${e}`);
process.exit(1);
}
if (check) {
if (drift.length > 0) {
console.error(
`audit-area surfaces are stale relative to scripts/audit-areas.json: ${drift.join(", ")}`,
);
console.error(" Run: npm run build:audit-areas");
process.exit(1);
}
console.log(
`audit areas: ${registry.areas.length} areas across ${registry.groupOrder.length} groups; all 3 surfaces up to date`,
);
} else {
console.log(
`audit areas: ${registry.areas.length} areas across ${registry.groupOrder.length} groups; ${written} region(s) written, ${unchanged} unchanged`,
);
}
+62 -22
View File
@@ -36,12 +36,7 @@ import { MCP_TOOL_BINARIES } from "../axiom-mcp/src/tools/binaries.ts";
import {
parseFrontmatterAreas,
parseBodyTable,
parseDocAreas,
parseSidebarAreas,
parseSidebarGroups,
parseDocGroups,
validateParity,
validateGroupedParity,
parseInlineAuditReferences,
validateInlineReferences,
validateAgentDescriptionParity,
@@ -50,6 +45,16 @@ import {
validateAdvertisedCommands,
AGENT_FRONTMATTER_KEYS,
} from "./audit-parity.ts";
import {
MARKERS,
renderArgumentList,
renderBodyTable,
renderDocsTables,
spliceRegion,
validateRegistry,
validateSidebarAgainstRegistry,
type AuditRegistry,
} from "./audit-areas.ts";
import {
checkSkillInvocations,
findSkillNameCollisions,
@@ -1037,27 +1042,61 @@ if (!fs.existsSync(auditCmdPath)) {
const docContent = fs.readFileSync(auditDocPath, "utf8");
const cfgContent = fs.readFileSync(sidebarConfigPath, "utf8");
// frontmatter is still parsed (not derived from the registry) so the
// advertised-area check below validates against what the file actually
// ships, not against what it was supposed to ship.
const frontmatter = parseFrontmatterAreas(cmdContent);
const bodyRows = parseBodyTable(cmdContent);
const body = bodyRows.map((r) => r.area);
const docAreas = parseDocAreas(docContent);
const sidebar = parseSidebarAreas(cfgContent);
const parityErrors = validateParity({
frontmatter,
body,
docs: docAreas,
sidebar,
});
// The frontmatter list, the body table, and the docs page are now
// GENERATED from scripts/audit-areas.json, so they cannot disagree with
// each other by construction — the old A↔B↔C set-parity and grouped-parity
// checks are replaced by a single staleness check against the registry.
// Same "generate in memory, diff against committed" pattern as the
// inlined auditors (12d-bis) and the Codex variant (12f).
const registryPath = path.join(root, "scripts/audit-areas.json");
let auditRegistry: AuditRegistry | undefined;
const parityErrors: string[] = [];
try {
auditRegistry = JSON.parse(fs.readFileSync(registryPath, "utf8"));
} catch (e: unknown) {
parityErrors.push(`scripts/audit-areas.json unreadable: ${(e as Error).message}`);
}
if (auditRegistry) {
for (const msg of validateRegistry(auditRegistry)) {
parityErrors.push(`audit-areas.json: ${msg}`);
}
const generated: Array<[string, string, string, "html" | "hash", string]> = [
[auditCmdPath, cmdContent, MARKERS.argument, "hash", renderArgumentList(auditRegistry)],
[auditCmdPath, cmdContent, MARKERS.bodyTable, "html", renderBodyTable(auditRegistry)],
[auditDocPath, docContent, MARKERS.docsTable, "html", renderDocsTables(auditRegistry)],
];
for (const [file, content, marker, style, rendered] of generated) {
const spliced = spliceRegion(content, marker, rendered, style);
const rel = path.relative(root, file);
if (spliced === null) {
parityErrors.push(`${rel}: ${marker} markers missing or malformed`);
} else if (spliced !== content) {
parityErrors.push(
`${rel} region ${marker} is stale relative to scripts/audit-areas.json — run \`npm run build:audit-areas\``,
);
}
}
}
for (const msg of parityErrors) error("audit-parity", msg);
// Grouped parity: enforce same group names, same group order, same
// items per group, same item order. Catches drifts that set parity
// doesn't (e.g. axiom-imz: 27=27 set parity but 5-vs-8 group counts).
// The sidebar is NOT generated — each of its groups interleaves audit
// entries with unrelated commands, so the audit rows are not a
// spliceable region. It stays hand-maintained and is checked against
// the registry directly, which is what the old docs↔sidebar grouped
// parity was approximating.
const sidebarGroups = parseSidebarGroups(cfgContent);
const docGroups = parseDocGroups(docContent);
const groupedErrors = validateGroupedParity(sidebarGroups, docGroups);
for (const msg of groupedErrors) error("audit-parity", `(grouped) ${msg}`);
const groupedErrors = auditRegistry
? validateSidebarAgainstRegistry(auditRegistry, sidebarGroups)
: [];
for (const msg of groupedErrors) error("audit-parity", `(sidebar) ${msg}`);
// E: agent file existence — needs filesystem access so it stays here.
// Read agent file contents into a map so the description-parity check
@@ -1190,8 +1229,9 @@ if (!fs.existsSync(auditCmdPath)) {
advertisedErrors.length === 0
) {
console.log(
`${frontmatter.length} audit areas in sync across frontmatter, body table, docs page, sidebar ` +
`(${sidebarGroups.length} groups, same order; ${bodyRows.length} agent refs resolve; ` +
`${frontmatter.length} audit areas generated from scripts/audit-areas.json into ` +
`frontmatter + body table + docs page; sidebar checked against it ` +
`(${sidebarGroups.length} groups; ${bodyRows.length} agent refs resolve; ` +
`${inlineSections.length} prose sections + ${bodyRows.length} agent descriptions verified; ` +
`${advertisingAgents} agents advertise a command, ${advertisedExempt.length} exempt)`,
);