@erclx/aitk 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "0.56.0",
4
+ "version": "0.58.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -7,7 +7,7 @@ description: Opens and runs a numbered groundwork folder under `.claude/groundwo
7
7
 
8
8
  Groundwork gathers and weighs. A plan commits. A groundwork folder costs nothing to throw away, which is what makes it the right container for a question nobody has answered yet.
9
9
 
10
- Read `${CLAUDE_SKILL_DIR}/references/folder-format.md` before writing any file in the folder. It holds the reserved numbers, what each required file carries, the conventions, and the anti-patterns.
10
+ Read `.claude/standards/groundwork.md` from the project root before writing any file in the folder, or `${CLAUDE_SKILL_DIR}/../../standards/groundwork.md` when the project does not have it. It holds the reserved numbers, the frontmatter and dating rules, what each required file carries, the conventions, and the anti-patterns. Do not work them from memory.
11
11
 
12
12
  ## Guards
13
13
 
@@ -65,19 +65,9 @@ Then read only what a live question needs. Do not read entire directories specul
65
65
 
66
66
  Do not dispatch subagents. A groundwork track is a conversation, and fanning out loses the reasoning that makes the folder worth keeping. A search too large to run inline is a finding that the question is too broad.
67
67
 
68
- ## Open question format
68
+ ## Open questions in chat
69
69
 
70
- Every open question carries a lean, wherever it appears: inside a topic file, in `00-scope.md`, and in the chat output. A bare numbered list hands the reader a quiz and defers the judgment the track exists to inform.
71
-
72
- ```markdown
73
- 1. <question>
74
- - Leaning: <where the evidence currently points>
75
- - Overturned by: <the finding that would change it>
76
- ```
77
-
78
- - `Leaning:` is weaker than a plan's `- Suggested:`. It records the current read on a question still open by definition, not a decision to accept by default at execution time.
79
- - Pair every lean with what would overturn it. A lean with no falsifier is an opinion.
80
- - On a measurement rather than a judgment, write `- Leaning: none, needs measuring` and drop the overturn line. A guess at a number is worse than an admission.
70
+ The standard sets the open question format and requires it inside a topic file and in `00-scope.md`. Carry the same shape into the chat output, which sits outside the folder the standard governs. A bare numbered list hands the reader a quiz and defers the judgment the track exists to inform.
81
71
 
82
72
  ## Open mode
83
73
 
@@ -85,7 +75,7 @@ Every open question carries a lean, wherever it appears: inside a topic file, in
85
75
  2. Write `README.md` first. Writing it first forces the question of what the track is for.
86
76
  3. Write `01-current-state.md` by measuring now. Never carry a figure from a previous session or from recall without re-measuring it. Measure only what an open question in the folder needs. A number with no question attached is how groundwork turns into the work.
87
77
  4. Write `00-scope.md` when the track is large enough to run away. Skip it on a small track.
88
- 5. Add topic files at `02` through `05` as the subject demands. Close each one with its open questions in the format above.
78
+ 5. Add topic files at `02` through `05` as the subject demands. Close each one with its open questions in the standard's format.
89
79
  6. Keep going. Revise, reframe, and take correction as the questions move. The folder is meant to be rewritten.
90
80
 
91
81
  ## Resume mode
@@ -7,7 +7,7 @@ description: Files a raw brain dump into a numbered intake folder under `.claude
7
7
 
8
8
  Intake dispositions many findings in breadth. A dump goes in, an inventory comes out, and every item carries a problem measured against the tree, one proposed fix, and a verdict. The item that turns out to be already settled is the highest-value output, and it is the one thing neither a plan nor a groundwork track has anywhere to put.
9
9
 
10
- Read `${CLAUDE_SKILL_DIR}/references/folder-format.md` before writing any file in the folder. It holds the numbering, the file map, the frontmatter, and the item template.
10
+ Read `.claude/standards/intake.md` from the project root before writing any file in the folder, or `${CLAUDE_SKILL_DIR}/../../standards/intake.md` when the project does not have it. It holds the numbering, the file map, the frontmatter and dating rules, the item format, and the answer contract. Do not work them from memory.
11
11
 
12
12
  ## Routing
13
13
 
@@ -34,7 +34,7 @@ Using the wrong one fails in two shapes. Intake on a question that needs measuri
34
34
  - Reading is unrestricted inside the project. Measuring is the work.
35
35
  - Treat the folder as gitignored and unbacked. No check reaches its contents, so every rule stated here holds only while a session reads it.
36
36
 
37
- Nothing outside this body carries the floor, and no path-scoped rule can. The item format and the answer contract are heading for a standard covering this folder and a groundwork track together, which is queued rather than written.
37
+ Nothing outside this body carries the write-scope floor, and no path-scoped rule can. A misrouted write lands on a path the folder's glob never matches, so the rule that ships beside the intake standard carries the item format and the answer contract instead, for a session editing the folder with this skill unloaded.
38
38
 
39
39
  ## Step 1: detect open or resume
40
40
 
@@ -71,7 +71,9 @@ Two heading levels is the right depth. A third means the cluster should have bee
71
71
 
72
72
  ## Step 5: disposition each item
73
73
 
74
- Write every item in the format below, in the cluster file its fix belongs to. Close each item with a verdict and an empty operator slot.
74
+ Write every item in the standard's item format, in the cluster file its fix belongs to. Close each item with a verdict and an empty operator slot, and never fill that slot or read an empty one as agreement.
75
+
76
+ Where an item's call is the operator's, pair it with a suggestion rather than a bare question. The toolkit's `decision-help` snippet writes the same shape for chat use, and the standard's item format is the whole spec.
75
77
 
76
78
  ## Step 6: write the index
77
79
 
@@ -81,40 +83,6 @@ Each open question in the index is a labeled markdown link to its owning item's
81
83
 
82
84
  Add `99-next-session.md` only where the pass ends holding context no cluster file carries, such as a dump half filed or a measurement that has to be redone. Write it self-contained, since the folder is unbacked and the conversation behind it compacts away.
83
85
 
84
- ## Item format
85
-
86
- ```markdown
87
- ### N. Short title stating the defect
88
-
89
- - **Problem:** what is wrong today, stated against the tree and carrying a number or a file path
90
- - **Fix:** the one change proposed
91
- - **Worth it:** yes, later, or no, with the reason
92
- - **Open:** only where the call is the operator's
93
- - **Suggested:** the pick in one sentence, then the reason and the main tradeoff in one or two
94
- - **Overlaps:** the live board task that already owns this item
95
- - **You:**
96
- ```
97
-
98
- - `Problem:`, `Fix:`, `Worth it:`, and the empty `You:` slot ship on every item. The other two are conditional.
99
- - `Suggested:` is required whenever `Open:` is present. A bare question invites a bare answer, and `ok` against two defensible options carries no information. Where the answer is the operator's preference rather than a technical call, say so in that form rather than inventing a default. The toolkit's `decision-help` snippet writes the same four-line shape for chat use, and the four lines above are the whole spec.
100
- - `Overlaps:` never replaces `Worth it:`. The items where a live board task might be the thing that is wrong are exactly the ones whose verdict matters most.
101
-
102
- ## The answer contract
103
-
104
- `You:` belongs to the operator and ships empty on every item.
105
-
106
- Empty means unread. It never means agreement. Accepting a verdict is typed as one token, `- **You:** ok`.
107
-
108
- That inverts the plan file's contract, where a blank `- Answer:` means accept the suggestion, and the inversion is deliberate. A plan is read in one sitting with every question already surfaced in chat. An intake folder is read over weeks, so an empty slot is ambiguous between accepting the verdict and never having reached the item, and the second reading is far more likely. Acting on silence as consent ships a change nobody approved.
109
-
110
- Never fill a `You:` slot, and never infer a disposition from an empty one. On a resume pass, report unread items by count rather than deciding them.
111
-
112
- ## Numbering
113
-
114
- Numbers are read order and nothing else. Reserve `00` for the index and carry the domain in every other filename, so `07-tooling.md` says what it holds before anyone opens it.
115
-
116
- Do not reserve mid-range numbers. Clusters differ per dump, so a contract over `06` would force every future intake into one dump's shape. `claude-groundwork` reserves its numbers because its shape is fixed, and that half of the convention does not transfer.
117
-
118
86
  ## Output
119
87
 
120
88
  Emit the full relative path from the project root for every file written, and name the heading and the act beside it. A path alone cannot distinguish three new items from one reworded sentence in a file that holds a dozen items and lives for weeks, so a bare path sends the reader to diff it against memory. This overrides the paths-only reporting the project states generally, which stays right wherever the reader is about to see a diff.
@@ -11,15 +11,15 @@ What a skill or script reads to discover names at runtime, and how each domain i
11
11
 
12
12
  Use these to discover what's available instead of hardcoding names.
13
13
 
14
- | Command | Returns |
15
- | -------------------------------- | --------------------------------------------- |
16
- | `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
17
- | `aitk snippets list --json` | Presets and categories with their slugs |
18
- | `aitk standards list --json` | Standards docs and the paths each governs |
19
- | `aitk gov list --json` | Governance stacks and rule sets |
20
- | `aitk claude seeds list --json` | Seed doc sources with content |
21
- | `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag |
22
- | `aitk docs list --json` | Consumer docs plus per-domain context |
14
+ | Command | Returns |
15
+ | -------------------------------- | ---------------------------------------------------- |
16
+ | `aitk tooling list --json` | Stacks, extends chain, dep and script counts |
17
+ | `aitk snippets list --json` | Presets and categories with their slugs |
18
+ | `aitk standards list --json` | Standards docs and the paths each governs |
19
+ | `aitk gov list --json` | Governance stacks, rule sets, and unreferenced rules |
20
+ | `aitk claude seeds list --json` | Seed doc sources with content |
21
+ | `aitk claude skills list --json` | Plugin skills, descriptions, requirement flag |
22
+ | `aitk docs list --json` | Consumer docs plus per-domain context |
23
23
 
24
24
  ### Catalog fields
25
25
 
@@ -40,6 +40,18 @@ listing and the install cannot disagree. It now reports
40
40
  `.claude/context/index.md`, which `init` has always installed and the listing
41
41
  never named, and it emits the project-level `CLAUDE.md` last rather than first.
42
42
 
43
+ `aitk gov list` reports each stack's rules already expanded. A stack entry names
44
+ either a rule or a whole rule folder under `governance/rules/`, and the folder
45
+ form reaches this listing as the rules it stands for rather than as the folder
46
+ name. A consumer deduping `--add` extras against a stack therefore reads the same
47
+ set the install writes, whichever form the stack file uses.
48
+
49
+ It also carries `unreferenced`, the rules no stack reaches, on every invocation
50
+ rather than behind a flag. These install only through `--add`, so the key answers
51
+ what a stack leaves out without a caller resolving every stack itself. The key is
52
+ present alongside `stacks` and `rules` and survives `--stacks` or `--rules`
53
+ narrowing the other two.
54
+
43
55
  ### The skills catalog
44
56
 
45
57
  `aitk claude skills list` reads `claude/skills/*/SKILL.md` and reports the folder
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Route .claude/groundwork/ edits to the groundwork standard for numbering, frontmatter, and required file contents
3
+ paths:
4
+ - '.claude/groundwork/**'
5
+ ---
6
+
7
+ # Groundwork standards
8
+
9
+ ## Measuring and closing
10
+
11
+ - Never carry a figure from an earlier session without re-measuring it. Mark an inference as an inference.
12
+ - Never close a track while an open question quietly fails an outcome. Resolve it, or record it as knowingly accepted.
13
+
14
+ ## Authority
15
+
16
+ - Follow `.claude/standards/groundwork.md` for reserved numbering, frontmatter and dating, what each required file holds, and the conventions a track keeps. It is the single source.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Route .claude/intake/ edits to the intake standard for the item format, the answer contract, and numbering
3
+ paths:
4
+ - '.claude/intake/**'
5
+ ---
6
+
7
+ # Intake standards
8
+
9
+ ## The answer contract
10
+
11
+ - Never fill a `You:` slot and never infer a disposition from an empty one. Empty means unread, never agreement.
12
+ - Report unread items by count on a resume pass. Do not decide one.
13
+
14
+ ## Authority
15
+
16
+ - Follow `.claude/standards/intake.md` for the folder layout, frontmatter and dating, the item format, and retrieval. It is the single source.
@@ -1,2 +1,4 @@
1
1
  extends = ""
2
- rules = ["000-constitution", "010-testing", "020-concurrency", "030-error-handling", "040-performance", "050-logging", "060-naming", "070-planning", "080-config-comments", "090-code-comments", "500-prose", "501-markdown", "510-context", "520-wireframes", "530-requirements", "540-architecture", "550-design", "555-tasks", "560-diagrams", "570-skill", "580-readme", "590-rule-authoring", "591-standard-authoring"]
2
+ # Both folders whole. Every rule authored under them ships to a base consumer,
3
+ # so adding one is a single edit rather than a file and a name here.
4
+ rules = ["core", "claude"]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "0.56.0",
4
+ "version": "0.58.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -45,14 +45,34 @@ for payload in "$SKILLS_JSON" "$GOV_JSON" "$STANDARDS_JSON" "$SNIPPETS_JSON" "$T
45
45
  fi
46
46
  done
47
47
 
48
- export SKILLS_JSON GOV_JSON STANDARDS_JSON SNIPPETS_JSON TOOLING_JSON
48
+ # The catalogs reach the eval as files rather than as environment entries. Linux
49
+ # caps a single env string at 128KB, and the standards payload crossed it, which
50
+ # fails the exec with E2BIG before any stage can report a stale count. A file
51
+ # path is bounded whatever the catalogs grow to.
52
+ PAYLOAD_DIR="$(mktemp -d)"
53
+ trap 'rm -rf "$PAYLOAD_DIR"' EXIT
54
+
55
+ printf '%s' "$SKILLS_JSON" >"$PAYLOAD_DIR/skills.json"
56
+ printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
57
+ printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
58
+ printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
59
+ printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
60
+
61
+ export PAYLOAD_DIR
49
62
  export TEMPLATE OUTPUT LISTED PROJECT_ROOT
50
63
 
51
64
  bun --eval '
52
- const {
53
- SKILLS_JSON, GOV_JSON, STANDARDS_JSON, SNIPPETS_JSON, TOOLING_JSON,
54
- TEMPLATE, OUTPUT, LISTED, PROJECT_ROOT,
55
- } = process.env
65
+ const { readFileSync } = require("node:fs")
66
+
67
+ const { PAYLOAD_DIR, TEMPLATE, OUTPUT, LISTED, PROJECT_ROOT } = process.env
68
+
69
+ const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
70
+
71
+ const SKILLS_JSON = payload("skills")
72
+ const GOV_JSON = payload("gov")
73
+ const STANDARDS_JSON = payload("standards")
74
+ const SNIPPETS_JSON = payload("snippets")
75
+ const TOOLING_JSON = payload("tooling")
56
76
 
57
77
  const listed = Number(LISTED)
58
78
  const skills = JSON.parse(SKILLS_JSON).skills.map((entry) => entry.name)
@@ -18,6 +18,12 @@ CHANGED_FILES=""
18
18
  # unarmed and why.
19
19
  SANDBOX_UNDECLARED_CEILING=47
20
20
 
21
+ # Rules no stack reaches, space separated and sorted the way `aitk gov list`
22
+ # emits them. Both are opt-in libraries a project may not want, so they are
23
+ # recorded here rather than in a config file: the list is what a reader compares
24
+ # a new arrival against, and a config file would absorb the arrival silently.
25
+ GOV_EXPECTED_UNREFERENCED="260-shadcn 320-tanstack-query"
26
+
21
27
  check_dependencies() {
22
28
  command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
23
29
  }
@@ -208,6 +214,55 @@ main() {
208
214
  run_check "bash $PROJECT_ROOT/scripts/core/check-plugin-boundary.sh" "Plugin ships toolkit-internal content."
209
215
  log_info "Plugin boundary clean"
210
216
 
217
+ # A stack entry naming a rule folder takes every rule in it, which is what
218
+ # stops a new rule from needing a second edit to reach a target. The failure
219
+ # it leaves open is a rule authored into a folder no stack names, which
220
+ # installs for nobody and reports nothing on its own.
221
+ #
222
+ # This reports and never fails. Both standing findings are opt-in libraries
223
+ # this repository ships on purpose, so gating would fail every push over the
224
+ # deliberate case and teach a reader to route around the stage. Revisit when a
225
+ # third appears and the pattern is either a library set or an accident.
226
+ log_step "Unreferenced rules"
227
+ local gov_json gov_status=0 unreferenced
228
+ gov_json=$(cd "$PROJECT_ROOT" && bun src/cli.ts gov list --json 2>/dev/null) || gov_status=$?
229
+ if [ "$gov_status" -ne 0 ] || [ -z "$gov_json" ]; then
230
+ log_warn "Skipped, the governance catalog did not report"
231
+ else
232
+ # `bun --eval` rather than a grep, because the key holds an array of names
233
+ # and the numeric matches the stages above use reach a scalar alone.
234
+ #
235
+ # The `ok:` sentinel carries success rather than the exit code, because
236
+ # `bun --eval` reading piped stdin exits 0 even when the script throws.
237
+ # Measured on Bun 1.3.14: the same throw exits 1 with no pipe attached. A
238
+ # payload that parses as text but not as JSON would therefore print nothing
239
+ # and exit clean, and empty already means every rule is reached, so reading
240
+ # the exit code would report a broken catalog as a clean sweep. A missing
241
+ # or non-array key takes the same branch, since a stage that cannot measure
242
+ # should say so rather than claim the sweep found nothing.
243
+ local reported
244
+ reported=$(printf '%s' "$gov_json" | bun --eval '
245
+ try {
246
+ const data = JSON.parse(require("node:fs").readFileSync(0, "utf8"))
247
+ if (!Array.isArray(data.unreferenced)) throw new Error("no field")
248
+ console.log("ok:" + data.unreferenced.join(" "))
249
+ } catch {
250
+ console.log("unreadable:")
251
+ }
252
+ ')
253
+ unreferenced="${reported#ok:}"
254
+ if [ "${reported%%:*}" != "ok" ]; then
255
+ log_warn "Skipped, the governance catalog carried no readable unreferenced list"
256
+ elif [ -z "$unreferenced" ]; then
257
+ log_info "Every rule is reached by a stack"
258
+ elif [ "$unreferenced" = "$GOV_EXPECTED_UNREFERENCED" ]; then
259
+ log_info "Reached by no stack: $unreferenced (opt-in, install with --add)"
260
+ else
261
+ log_warn "Reached by no stack: $unreferenced"
262
+ log_warn "Expected: $GOV_EXPECTED_UNREFERENCED. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in this script and say why it ships opt-in."
263
+ fi
264
+ fi
265
+
211
266
  # Only the citation half of the audit gates. Length, depth, table, and index
212
267
  # findings are judgment thresholds, and failing a push on one would make the
213
268
  # stage something to route around. `bun src/cli.ts` rather than `aitk`, since a
@@ -2,11 +2,11 @@ import { existsSync } from 'node:fs'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
3
  import { basename, dirname, join, resolve } from 'node:path'
4
4
  import type { Command } from 'commander'
5
- import { registerPassThroughVerbs } from '@/commands/pass-through'
6
5
  import { PROJECT_ROOT } from '@/exec'
7
6
  import { createGovAdapter } from '@/gov/adapter'
8
7
  import { regenConsumedRules } from '@/gov/consumed'
9
8
  import { hasStandards, installRules, lookupRules } from '@/gov/install'
9
+ import { buildGovCatalog, describeRule, describeStack } from '@/gov/list'
10
10
  import { buildRulesPayload, listRuleFiles } from '@/gov/payload'
11
11
  import {
12
12
  govStackExists,
@@ -34,8 +34,6 @@ const NC = '\x1b[0m'
34
34
  const PAYLOAD_REL = join('.claude', '.tmp', 'gov', 'rules.md')
35
35
  const RULES_REL = join('.claude', 'rules')
36
36
 
37
- const PASS_THROUGH_VERBS = ['list'] as const
38
-
39
37
  interface InstallOptions {
40
38
  readonly add?: string
41
39
  }
@@ -44,6 +42,12 @@ interface RegenOptions {
44
42
  readonly root?: string
45
43
  }
46
44
 
45
+ interface ListOptions {
46
+ readonly stacks?: boolean
47
+ readonly rules?: boolean
48
+ readonly json?: boolean
49
+ }
50
+
47
51
  export function register(program: Command): void {
48
52
  const gov = program
49
53
  .command('gov')
@@ -119,7 +123,78 @@ export function register(program: Command): void {
119
123
  process.exitCode = await runRegen(opts)
120
124
  })
121
125
 
122
- registerPassThroughVerbs(gov, 'gov', PASS_THROUGH_VERBS)
126
+ gov
127
+ .command('list')
128
+ .description('Emit the catalog of stacks and rules')
129
+ .helpOption('-h, --help', 'Show this help message')
130
+ .option('--stacks', 'Only list stacks')
131
+ .option('--rules', 'Only list rules')
132
+ .option('--json', 'Emit machine-readable JSON')
133
+ .action((opts: ListOptions) => {
134
+ process.exitCode = runList(opts)
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Both selectors absent means both sections, which is the bash default. Naming
140
+ * both is the same as naming neither rather than an error, since the two flags
141
+ * read as filters and a caller passing both is asking for everything.
142
+ */
143
+ function selectedSections(opts: ListOptions): {
144
+ stacks: boolean
145
+ rules: boolean
146
+ } {
147
+ const stacks = opts.stacks === true
148
+ const rules = opts.rules === true
149
+ if (stacks === rules) return { stacks: true, rules: true }
150
+ return { stacks, rules }
151
+ }
152
+
153
+ /**
154
+ * `JSON.stringify` replaces a `printf` that interpolated a description into a
155
+ * JSON string literal through a hand-rolled escaper, so a rule carrying a
156
+ * character that escaper missed emitted output a consuming skill could not
157
+ * parse.
158
+ *
159
+ * `unreferenced` rides the same payload rather than taking a flag of its own.
160
+ * The verify stage and a skill asking what a stack leaves out read one call,
161
+ * and the key is additive, so a consumer reading `stacks` or `rules` is
162
+ * untouched by it.
163
+ */
164
+ function runList(opts: ListOptions): number {
165
+ const catalog = buildGovCatalog(PROJECT_ROOT)
166
+ const sections = selectedSections(opts)
167
+
168
+ if (opts.json) {
169
+ process.stdout.write(
170
+ `${JSON.stringify({
171
+ ...(sections.stacks ? { stacks: catalog.stacks } : {}),
172
+ ...(sections.rules ? { rules: catalog.rules } : {}),
173
+ unreferenced: catalog.unreferenced,
174
+ })}\n`,
175
+ )
176
+ return 0
177
+ }
178
+
179
+ intro('aitk gov list')
180
+
181
+ if (sections.stacks) {
182
+ logStep('Stacks')
183
+ for (const entry of catalog.stacks) logInfo(describeStack(entry))
184
+ }
185
+
186
+ if (sections.rules) {
187
+ logStep('Rules')
188
+ for (const entry of catalog.rules) logInfo(describeRule(entry))
189
+ }
190
+
191
+ if (catalog.unreferenced.length > 0) {
192
+ logStep('Reached by no stack')
193
+ for (const rule of catalog.unreferenced) logInfo(rule)
194
+ }
195
+
196
+ outro()
197
+ return 0
123
198
  }
124
199
 
125
200
  /**
@@ -31,6 +31,37 @@ export function ruleSubdir(src: string, rulesRoot: string): string {
31
31
  return subdir === '.' ? '' : subdir
32
32
  }
33
33
 
34
+ /**
35
+ * Lists every rule source path under `governance/rules/`, relative to it and
36
+ * sorted, so a caller walking the tree and a caller resolving one name read the
37
+ * same order.
38
+ */
39
+ export function listRuleSourcePaths(root: string): string[] {
40
+ const rulesRoot = rulesSourceDir(root)
41
+ if (!existsSync(rulesRoot)) return []
42
+
43
+ return [
44
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
45
+ ].sort()
46
+ }
47
+
48
+ /**
49
+ * Maps each rule name to its source file. First path wins, so a name appearing
50
+ * in two subdirectories resolves deterministically rather than by whichever
51
+ * entry the filesystem yielded first.
52
+ */
53
+ function indexRuleSources(root: string): Map<string, string> {
54
+ const rulesRoot = rulesSourceDir(root)
55
+ const byName = new Map<string, string>()
56
+
57
+ for (const rel of listRuleSourcePaths(root)) {
58
+ const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
59
+ if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
60
+ }
61
+
62
+ return byName
63
+ }
64
+
34
65
  /**
35
66
  * Finds each rule's source file by name across the `governance/rules/`
36
67
  * subfolders. A rule with no source is reported rather than dropped, matching
@@ -41,21 +72,7 @@ export function lookupRules(
41
72
  rules: readonly string[],
42
73
  ): RuleLookup {
43
74
  const rulesRoot = rulesSourceDir(root)
44
- const byName = new Map<string, string>()
45
-
46
- const relPaths = existsSync(rulesRoot)
47
- ? [
48
- ...new Bun.Glob('**/*.md').scanSync({
49
- cwd: rulesRoot,
50
- onlyFiles: true,
51
- }),
52
- ].sort()
53
- : []
54
-
55
- for (const rel of relPaths) {
56
- const name = rel.slice(rel.lastIndexOf('/') + 1, -'.md'.length)
57
- if (!byName.has(name)) byName.set(name, join(rulesRoot, rel))
58
- }
75
+ const byName = indexRuleSources(root)
59
76
 
60
77
  const found: RuleSource[] = []
61
78
  const missing: string[] = []
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
4
+ import {
5
+ expandStackEntry,
6
+ listGovStacks,
7
+ loadGovStack,
8
+ unreferencedRules,
9
+ } from '@/gov/stacks'
10
+ import { parseFrontmatter, readField } from '@/indexes/frontmatter'
11
+
12
+ export interface StackEntry {
13
+ readonly name: string
14
+ readonly extends: string | null
15
+ readonly rules: string[]
16
+ }
17
+
18
+ export interface RuleEntry {
19
+ readonly name: string
20
+ readonly domain: string
21
+ readonly description: string
22
+ readonly paths: string[] | null
23
+ }
24
+
25
+ export interface GovCatalog {
26
+ readonly stacks: StackEntry[]
27
+ readonly rules: RuleEntry[]
28
+ readonly unreferenced: string[]
29
+ }
30
+
31
+ /**
32
+ * Reports each stack's own entries expanded, so a folder entry reaches a
33
+ * consumer as the rules it stands for. `setup-gov` dedupes `--add` extras
34
+ * against this list, and a stack answering `core` there would re-add every
35
+ * rule that folder already carries.
36
+ *
37
+ * The `extends` chain is deliberately not resolved, which is what the bash
38
+ * did. The list shows what each stack contributes beside the parent it
39
+ * inherits from, rather than repeating the ancestors under every descendant.
40
+ */
41
+ export function buildStackEntries(root: string): StackEntry[] {
42
+ const entries: StackEntry[] = []
43
+
44
+ for (const name of listGovStacks(root)) {
45
+ const stack = loadGovStack(root, name)
46
+ if (!stack) continue
47
+
48
+ const seen = new Set<string>()
49
+ const rules: string[] = []
50
+
51
+ for (const entry of stack.rules) {
52
+ for (const rule of expandStackEntry(root, entry)) {
53
+ if (seen.has(rule)) continue
54
+ seen.add(rule)
55
+ rules.push(rule)
56
+ }
57
+ }
58
+
59
+ entries.push({ name, extends: stack.parent ?? null, rules })
60
+ }
61
+
62
+ return entries
63
+ }
64
+
65
+ /**
66
+ * Reads the catalog straight off the source tree. A rule's domain is the
67
+ * subdirectory it sits in, which is the band grouping install preserves.
68
+ */
69
+ export function buildRuleEntries(root: string): RuleEntry[] {
70
+ const rulesRoot = rulesSourceDir(root)
71
+
72
+ return listRuleSourcePaths(root).map((rel) => {
73
+ const frontmatter = parseFrontmatter(
74
+ readFileSync(join(rulesRoot, rel), 'utf8'),
75
+ )
76
+ const paths = frontmatter?.fields.paths
77
+
78
+ return {
79
+ name: basename(rel, '.md'),
80
+ domain: rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '',
81
+ description: readField(frontmatter, 'description') ?? '',
82
+ paths: Array.isArray(paths)
83
+ ? paths.filter((entry): entry is string => typeof entry === 'string')
84
+ : null,
85
+ }
86
+ })
87
+ }
88
+
89
+ export function buildGovCatalog(root: string): GovCatalog {
90
+ return {
91
+ stacks: buildStackEntries(root),
92
+ rules: buildRuleEntries(root),
93
+ unreferenced: unreferencedRules(root),
94
+ }
95
+ }
96
+
97
+ export function describeStack(entry: StackEntry): string {
98
+ const count = `${entry.rules.length} rules`
99
+ return entry.extends === null
100
+ ? `${entry.name} (${count})`
101
+ : `${entry.name} (extends: ${entry.extends}, ${count})`
102
+ }
103
+
104
+ export function describeRule(entry: RuleEntry): string {
105
+ return `${entry.name} [${entry.domain}] ${entry.description}`
106
+ }