@erclx/aitk 0.57.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.57.0",
4
+ "version": "0.58.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -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
@@ -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", "556-groundwork", "557-intake", "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.57.0",
4
+ "version": "0.58.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -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
+ }
package/src/gov/stacks.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { existsSync, readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
3
4
 
4
5
  export interface GovStack {
5
6
  readonly name: string
@@ -64,11 +65,33 @@ export function loadGovStack(
64
65
  }
65
66
  }
66
67
 
68
+ /**
69
+ * Expands one stack entry. An entry naming a directory under
70
+ * `governance/rules/` resolves to every rule inside it, sorted, and any other
71
+ * entry resolves to itself, so a folder and a slug reach the caller as one
72
+ * shape rather than two the caller has to tell apart.
73
+ *
74
+ * The directory wins over a rule file of the same name. They cannot collide
75
+ * while `standards/rule.md` requires a numeric prefix on a rule slug, since a
76
+ * band folder carries none.
77
+ */
78
+ export function expandStackEntry(root: string, entry: string): string[] {
79
+ const dir = join(rulesSourceDir(root), entry)
80
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return [entry]
81
+
82
+ return [...new Bun.Glob('**/*.md').scanSync({ cwd: dir, onlyFiles: true })]
83
+ .sort()
84
+ .map((rel) => basename(rel, '.md'))
85
+ }
86
+
67
87
  /**
68
88
  * Walks `extends` ancestors first, then the stack's own rules, deduped by
69
89
  * first appearance. Tooling's `resolveChain` returns full manifests nearest
70
90
  * first and carries `skipStack` truncation, so the two walks stay separate
71
91
  * rather than fitting one shape to both.
92
+ *
93
+ * Dedupe runs on expanded names rather than on the entries, so a stack naming
94
+ * a folder and an ancestor naming a rule inside it yield that rule once.
72
95
  */
73
96
  export function resolveRules(root: string, stack: string): RuleResolution {
74
97
  const rules: string[] = []
@@ -87,10 +110,12 @@ export function resolveRules(root: string, stack: string): RuleResolution {
87
110
  if (missing !== undefined) return missing
88
111
  }
89
112
 
90
- for (const rule of loaded.rules) {
91
- if (seen.has(rule)) continue
92
- seen.add(rule)
93
- rules.push(rule)
113
+ for (const entry of loaded.rules) {
114
+ for (const rule of expandStackEntry(root, entry)) {
115
+ if (seen.has(rule)) continue
116
+ seen.add(rule)
117
+ rules.push(rule)
118
+ }
94
119
  }
95
120
 
96
121
  return undefined
@@ -102,6 +127,30 @@ export function resolveRules(root: string, stack: string): RuleResolution {
102
127
  return { ok: true, rules }
103
128
  }
104
129
 
130
+ /**
131
+ * Names every rule no stack reaches, sorted. A rule outside every stack still
132
+ * installs through `--add`, so this reports an opt-in library and an oversight
133
+ * alike and leaves telling them apart to the reader.
134
+ *
135
+ * A stack whose `extends` does not resolve contributes nothing rather than
136
+ * aborting the sweep, or one broken stack would report the whole catalog as
137
+ * unreferenced.
138
+ */
139
+ export function unreferencedRules(root: string): string[] {
140
+ const reached = new Set<string>()
141
+
142
+ for (const stack of listGovStacks(root)) {
143
+ const resolution = resolveRules(root, stack)
144
+ if (!resolution.ok) continue
145
+ for (const rule of resolution.rules) reached.add(rule)
146
+ }
147
+
148
+ return listRuleSourcePaths(root)
149
+ .map((rel) => basename(rel, '.md'))
150
+ .filter((rule) => !reached.has(rule))
151
+ .sort()
152
+ }
153
+
105
154
  /**
106
155
  * Layers `--add` names on top of a resolved stack. The bash trimmed a single
107
156
  * leading and trailing space per entry; trimming fully is the same result for
package/standards/rule.md CHANGED
@@ -33,6 +33,15 @@ Write both when both apply. A rule stating the directive and a skill stating how
33
33
  - Subdirectories group by domain: `core/`, `lang/`, `framework/`, `lib/`, `ui/`, `claude/`
34
34
  - `<n>` is a number in the subdirectory's band and `<slug>` is a one-to-three-word kebab topic
35
35
  - Scaffold a rule with a number that collides with neither the project's rules nor any installed shared rule set
36
+ - Give every rule a numeric prefix. A bare-word filename reads as a folder name where a stack names its rules, so a rule without one is unreachable from a stack entry.
37
+
38
+ ## Two sources numbering into one folder
39
+
40
+ A shared rule set and a project's own rules land in the same installed folder and draw from the same band, so the two need a division or they collide. Divide the band by source rather than by topic: one source takes the top of each band and the other takes the gaps between the tens. A rule set that ships to targets should take the tens, since it is the source a project cannot renumber.
41
+
42
+ The collision this prevents is silent. Two rules that resolve to the same `<n>-<slug>` path leave one file in the installed folder, and neither the install nor the session that reads it reports which source lost. Nothing checks the division, so it holds only while both sources follow it.
43
+
44
+ State the division where the rule sources are described, not in the rules themselves. A rule states its own topic, and a numbering convention spanning two sources belongs to whatever documents the pair.
36
45
 
37
46
  ## Frontmatter
38
47
 
@@ -1,234 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -e
3
- set -o pipefail
4
-
5
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
- PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$(dirname "$SCRIPT_DIR")")}"
7
-
8
- source "$PROJECT_ROOT/scripts/lib/ui.sh"
9
-
10
- STACKS_DIR="$PROJECT_ROOT/governance/stacks"
11
- RULES_DIR="$PROJECT_ROOT/governance/rules"
12
-
13
- show_help() {
14
- echo -e "${GREY}┌${NC}"
15
- echo -e "${GREY}├${NC} ${WHITE}Usage:${NC} aitk gov list [options]"
16
- echo -e "${GREY}│${NC}"
17
- echo -e "${GREY}│${NC} ${WHITE}Options:${NC}"
18
- echo -e "${GREY}│${NC} --stacks ${GREY}# Only list stacks${NC}"
19
- echo -e "${GREY}│${NC} --rules ${GREY}# Only list rules${NC}"
20
- echo -e "${GREY}│${NC} --json ${GREY}# Emit machine-readable JSON${NC}"
21
- echo -e "${GREY}│${NC} -h, --help ${GREY}# Show this help message${NC}"
22
- echo -e "${GREY}└${NC}"
23
- exit 0
24
- }
25
-
26
- read_frontmatter_field() {
27
- local file="$1"
28
- local field="$2"
29
- awk -v f="$field" '
30
- BEGIN { fm = 0 }
31
- /^---$/ { fm++; if (fm > 1) exit; next }
32
- fm == 1 {
33
- if (match($0, "^" f ":")) {
34
- val = substr($0, RLENGTH + 1)
35
- sub(/^[[:space:]]+/, "", val)
36
- sub(/^[\x27"]/, "", val)
37
- sub(/[\x27"]$/, "", val)
38
- print val
39
- exit
40
- }
41
- }
42
- ' "$file"
43
- }
44
-
45
- read_frontmatter_paths() {
46
- local file="$1"
47
- awk '
48
- BEGIN { fm = 0; in_paths = 0 }
49
- /^---$/ { fm++; if (fm > 1) exit; next }
50
- fm == 1 {
51
- if (in_paths) {
52
- if (match($0, /^[[:space:]]*-[[:space:]]+/)) {
53
- val = substr($0, RLENGTH + 1)
54
- sub(/^[\x27"]/, "", val)
55
- sub(/[\x27"]$/, "", val)
56
- print val
57
- next
58
- }
59
- in_paths = 0
60
- }
61
- if ($0 == "paths:") { in_paths = 1 }
62
- }
63
- ' "$file"
64
- }
65
-
66
- rule_domain() {
67
- local file="$1"
68
- local rel="${file#"$RULES_DIR"/}"
69
- echo "${rel%%/*}"
70
- }
71
-
72
- stack_rules_array() {
73
- local toml="$1"
74
- grep -oE '"[0-9]{3}-[a-z0-9-]+"' "$toml" | sed 's/"//g'
75
- }
76
-
77
- json_escape() {
78
- local s="$1"
79
- s="${s//\\/\\\\}"
80
- s="${s//\"/\\\"}"
81
- printf '%s' "$s"
82
- }
83
-
84
- list_stacks_text() {
85
- log_step "Stacks"
86
- local toml
87
- for toml in "$STACKS_DIR"/*.toml; do
88
- local name
89
- name=$(basename "$toml" .toml)
90
- local extends
91
- extends=$(grep '^extends' "$toml" | cut -d'"' -f2)
92
- local rule_count
93
- rule_count=$(stack_rules_array "$toml" | wc -l)
94
- if [ -n "$extends" ]; then
95
- log_info "$name (extends: $extends, $rule_count rules)"
96
- else
97
- log_info "$name ($rule_count rules)"
98
- fi
99
- done
100
- }
101
-
102
- list_rules_text() {
103
- log_step "Rules"
104
- local file
105
- while IFS= read -r file; do
106
- local name
107
- name=$(basename "$file" .md)
108
- local domain
109
- domain=$(rule_domain "$file")
110
- local desc
111
- desc=$(read_frontmatter_field "$file" "description")
112
- log_info "$name [$domain] $desc"
113
- done < <(find "$RULES_DIR" -type f -name "*.md" | sort)
114
- }
115
-
116
- list_stacks_json() {
117
- local first=1
118
- local toml
119
- printf '['
120
- for toml in "$STACKS_DIR"/*.toml; do
121
- local name
122
- name=$(basename "$toml" .toml)
123
- local extends
124
- extends=$(grep '^extends' "$toml" | cut -d'"' -f2)
125
- local rules_json="["
126
- local first_rule=1
127
- local rule
128
- while IFS= read -r rule; do
129
- [ -z "$rule" ] && continue
130
- if [ "$first_rule" -eq 0 ]; then
131
- rules_json+=","
132
- fi
133
- rules_json+="\"$rule\""
134
- first_rule=0
135
- done < <(stack_rules_array "$toml")
136
- rules_json+="]"
137
- if [ "$first" -eq 0 ]; then
138
- printf ','
139
- fi
140
- if [ -n "$extends" ]; then
141
- printf '{"name":"%s","extends":"%s","rules":%s}' "$name" "$extends" "$rules_json"
142
- else
143
- printf '{"name":"%s","extends":null,"rules":%s}' "$name" "$rules_json"
144
- fi
145
- first=0
146
- done
147
- printf ']'
148
- }
149
-
150
- list_rules_json() {
151
- local first=1
152
- local file
153
- printf '['
154
- while IFS= read -r file; do
155
- local name
156
- name=$(basename "$file" .md)
157
- local domain
158
- domain=$(rule_domain "$file")
159
- local desc
160
- desc=$(read_frontmatter_field "$file" "description")
161
-
162
- local paths_json="null"
163
- local paths_collected=()
164
- while IFS= read -r p; do
165
- [ -n "$p" ] && paths_collected+=("$p")
166
- done < <(read_frontmatter_paths "$file")
167
- if [ "${#paths_collected[@]}" -gt 0 ]; then
168
- paths_json="["
169
- local first_p=1
170
- local p
171
- for p in "${paths_collected[@]}"; do
172
- [ "$first_p" -eq 0 ] && paths_json+=","
173
- paths_json+="\"$(json_escape "$p")\""
174
- first_p=0
175
- done
176
- paths_json+="]"
177
- fi
178
-
179
- if [ "$first" -eq 0 ]; then
180
- printf ','
181
- fi
182
- printf '{"name":"%s","domain":"%s","description":"%s","paths":%s}' \
183
- "$name" "$domain" "$(json_escape "$desc")" "$paths_json"
184
- first=0
185
- done < <(find "$RULES_DIR" -type f -name "*.md" | sort)
186
- printf ']'
187
- }
188
-
189
- main() {
190
- local show_stacks=1
191
- local show_rules=1
192
- local json=0
193
-
194
- while [[ $# -gt 0 ]]; do
195
- case "$1" in
196
- -h | --help) show_help ;;
197
- --stacks)
198
- show_rules=0
199
- shift
200
- ;;
201
- --rules)
202
- show_stacks=0
203
- shift
204
- ;;
205
- --json)
206
- json=1
207
- shift
208
- ;;
209
- *) log_error "Unknown option: $1" ;;
210
- esac
211
- done
212
-
213
- trap close_timeline EXIT
214
-
215
- if [ "$json" -eq 1 ]; then
216
- printf '{'
217
- if [ "$show_stacks" -eq 1 ]; then
218
- printf '"stacks":'
219
- list_stacks_json
220
- [ "$show_rules" -eq 1 ] && printf ','
221
- fi
222
- if [ "$show_rules" -eq 1 ]; then
223
- printf '"rules":'
224
- list_rules_json
225
- fi
226
- printf '}\n'
227
- exit 0
228
- fi
229
-
230
- [ "$show_stacks" -eq 1 ] && list_stacks_text
231
- [ "$show_rules" -eq 1 ] && list_rules_text
232
- }
233
-
234
- main "$@"