@erclx/aitk 0.57.0 → 0.59.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.
@@ -0,0 +1,159 @@
1
+ import { $ } from 'bun'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ type Finding,
5
+ isRecordKind,
6
+ RECORD_KINDS,
7
+ type ValidateOutcome,
8
+ validateRecords,
9
+ } from '@/records/validate'
10
+ import { intro, logError, logInfo, logStep, logWarn, outro } from '@/ui'
11
+
12
+ /** Returned when a record carries a finding, which is the gating result. */
13
+ const EXIT_FINDINGS = 2
14
+
15
+ interface ValidateCommandOptions {
16
+ readonly json?: boolean
17
+ readonly root?: string
18
+ }
19
+
20
+ /**
21
+ * The session-record folders are shared scratch at the main worktree root, and
22
+ * `git worktree list` puts that root first. Trusting the working directory would
23
+ * validate a linked worktree's empty folder and report it clean.
24
+ */
25
+ async function mainWorktreeRoot(): Promise<string> {
26
+ const result = await $`git worktree list --porcelain`.quiet().nothrow()
27
+ if (result.exitCode !== 0) return process.cwd()
28
+
29
+ const line = result.stdout
30
+ .toString()
31
+ .split('\n')
32
+ .find((entry) => entry.startsWith('worktree '))
33
+
34
+ return line ? line.slice('worktree '.length).trim() : process.cwd()
35
+ }
36
+
37
+ export function register(program: Command): void {
38
+ const records = program
39
+ .command('records')
40
+ .description('Check the gitignored session records under .claude/')
41
+ .helpOption('-h, --help', 'Show this help message')
42
+
43
+ records
44
+ .command('validate')
45
+ .description('Report where a record and the standard governing it disagree')
46
+ .argument('<kind>', `Record folder: ${RECORD_KINDS.join(', ')}`)
47
+ .helpOption('-h, --help', 'Show this help message')
48
+ .option('--json', 'Add a machine-readable record on stdout')
49
+ .option('--root <path>', 'Project root, defaulting to the main worktree')
50
+ .addHelpText(
51
+ 'after',
52
+ [
53
+ '',
54
+ 'Checks:',
55
+ ' plans filename, required sections, and the suggested-and-answer contract',
56
+ ' groundwork README and current-state files, numbering, dating, and a half-closed track',
57
+ ' intake overview file, numbering, dating, and the four bullets every item carries',
58
+ '',
59
+ 'Exit codes:',
60
+ ' 0 every check passed',
61
+ ' 1 refused, with the reason on stderr or in the JSON record',
62
+ ' 2 at least one record carries a finding',
63
+ '',
64
+ 'It reports and never writes. Each folder is per-machine scratch with no',
65
+ 'history behind it, so a session fixes the record the report names.',
66
+ '',
67
+ 'Examples:',
68
+ ' aitk records validate plans',
69
+ ' aitk records validate intake --json',
70
+ '',
71
+ ].join('\n'),
72
+ )
73
+ .action(async (kind: string, opts: ValidateCommandOptions) => {
74
+ process.exitCode = await runValidate(kind, opts)
75
+ })
76
+ }
77
+
78
+ async function runValidate(
79
+ kind: string,
80
+ opts: ValidateCommandOptions,
81
+ ): Promise<number> {
82
+ const emitJson = opts.json ?? false
83
+
84
+ if (!isRecordKind(kind)) {
85
+ return report(
86
+ {
87
+ ok: false,
88
+ reason: 'unknown-kind',
89
+ message: `Not a record kind: ${kind}. Expected one of: ${RECORD_KINDS.join(', ')}.`,
90
+ },
91
+ emitJson,
92
+ process.cwd(),
93
+ )
94
+ }
95
+
96
+ const root = opts.root ?? (await mainWorktreeRoot())
97
+
98
+ return report(await validateRecords(root, kind), emitJson, root)
99
+ }
100
+
101
+ function report(
102
+ outcome: ValidateOutcome,
103
+ emitJson: boolean,
104
+ root: string,
105
+ ): number {
106
+ if (!outcome.ok) {
107
+ // The framed branch below already reaches stderr through logError, so the
108
+ // bare write is what keeps the JSON mode from reporting the reason on
109
+ // stdout alone.
110
+ if (emitJson) {
111
+ process.stderr.write(`${outcome.message}\n`)
112
+ process.stdout.write(
113
+ `${JSON.stringify({
114
+ ok: false,
115
+ reason: outcome.reason,
116
+ message: outcome.message,
117
+ })}\n`,
118
+ )
119
+ return 1
120
+ }
121
+
122
+ intro('aitk records validate')
123
+ logStep('Refused')
124
+ logError(outcome.message)
125
+ outro()
126
+ return 1
127
+ }
128
+
129
+ if (emitJson) {
130
+ process.stdout.write(
131
+ `${JSON.stringify({
132
+ ok: true,
133
+ root,
134
+ kind: outcome.kind,
135
+ records: outcome.records,
136
+ findings: outcome.findings,
137
+ })}\n`,
138
+ )
139
+ } else {
140
+ intro('aitk records validate')
141
+ logStep(outcome.kind)
142
+ logInfo(`${outcome.records} record(s) read`)
143
+
144
+ logStep(outcome.findings.length === 0 ? 'Clean' : 'Findings')
145
+ if (outcome.findings.length === 0) {
146
+ logInfo('every record matches the shape its standard fixes')
147
+ } else {
148
+ for (const found of outcome.findings) logWarn(describe(found))
149
+ }
150
+ outro()
151
+ }
152
+
153
+ return outcome.findings.length > 0 ? EXIT_FINDINGS : 0
154
+ }
155
+
156
+ function describe(found: Finding): string {
157
+ const scope = found.record === found.subject ? '' : `${found.record}: `
158
+ return `${scope}${found.subject} ${found.message}`
159
+ }
@@ -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