@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/scripting.md +21 -9
- package/governance/stacks/base.toml +3 -1
- package/package.json +1 -1
- package/scripts/core/verify.sh +55 -0
- package/src/commands/gov.ts +79 -4
- package/src/gov/install.ts +32 -15
- package/src/gov/list.ts +106 -0
- package/src/gov/stacks.ts +55 -6
- package/standards/rule.md +9 -0
- package/scripts/gov/list.sh +0 -234
package/docs/agents/scripting.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
package/scripts/core/verify.sh
CHANGED
|
@@ -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
|
package/src/commands/gov.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/gov/install.ts
CHANGED
|
@@ -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 =
|
|
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[] = []
|
package/src/gov/list.ts
ADDED
|
@@ -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
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
package/scripts/gov/list.sh
DELETED
|
@@ -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 "$@"
|