@erclx/aitk 3.0.0 → 3.2.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,4 +1,6 @@
1
+ import type { ReachRefusal } from '@/claude/skills-reach'
1
2
  import type { AuditRefusal } from '@/deps/audit'
3
+ import type { LabelAuditRefusal } from '@/labels/audit'
2
4
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
3
5
  import type { ScanRefusal } from '@/secrets/scan'
4
6
  import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
@@ -286,6 +288,21 @@ function skillCounts(record: unknown): Record<string, number> | undefined {
286
288
  )
287
289
  }
288
290
 
291
+ /**
292
+ * Reads the unqualified citations alone, leaving the qualified ones out.
293
+ *
294
+ * A qualified citation is a repair that already landed, so folding the two
295
+ * together would report a corpus getting worse every time one is fixed. The
296
+ * key is still read rather than assumed present, since a record carrying
297
+ * neither array is a shape that moved rather than a catalog with nothing in it.
298
+ */
299
+ function reachCounts(record: unknown): Record<string, number> | undefined {
300
+ const root = asObject(record)
301
+ if (root === undefined || !Array.isArray(root.qualified)) return undefined
302
+
303
+ return allOf({ unqualifiedCitations: lengthOf(root.unqualified) })
304
+ }
305
+
289
306
  function boardCounts(record: unknown): Record<string, number> | undefined {
290
307
  const root = asObject(record)
291
308
  if (root === undefined) return undefined
@@ -318,6 +335,27 @@ function advisoryCounts(record: unknown): Record<string, number> | undefined {
318
335
  return counts
319
336
  }
320
337
 
338
+ /**
339
+ * Reads the uncovered paths alone, which is the half that is a finding.
340
+ *
341
+ * The declined paths are deliberately not counted. They are whichever declined
342
+ * rows this branch happened to touch rather than a measure of the map, so a
343
+ * clean trunk reads zero while the map declares eight, and the number would
344
+ * describe the branch rather than the decision.
345
+ *
346
+ * Retaining it also broke the verdict. `classify` reads a clean run as quiet
347
+ * only when every count is zero, so a branch touching one declined path exited
348
+ * 0 and still reported as carrying findings.
349
+ */
350
+ function labelCoverageCounts(
351
+ record: unknown,
352
+ ): Record<string, number> | undefined {
353
+ const root = asObject(record)
354
+ if (root === undefined) return undefined
355
+
356
+ return allOf({ uncovered: lengthOf(root.uncovered) })
357
+ }
358
+
321
359
  function findingsOnly(record: unknown): Record<string, number> | undefined {
322
360
  const root = asObject(record)
323
361
  if (root === undefined) return undefined
@@ -403,6 +441,26 @@ export const AUDITS: readonly AuditSpec[] = [
403
441
  corpus: 'tracked',
404
442
  counts: skillCounts,
405
443
  },
444
+ {
445
+ id: 'skills-reach',
446
+ label: 'Shipped citation reach',
447
+ argv: ['claude', 'skills', 'reach', '--json'],
448
+ // Reports rather than gates, on the split this file already draws. A body
449
+ // naming a toolkit path is sometimes correct, since the instruction may be
450
+ // meant for a session in this repository, so the verdict is a judgment and
451
+ // a push failing on one teaches a contributor to route around the stage.
452
+ gatingExits: [],
453
+ corpus: 'tracked',
454
+ // The one reason this verb refuses for, and it is an absence rather than a
455
+ // break. A tracked corpus normally allows nothing, since a tree that ships
456
+ // to targets and cannot be found is a broken checkout, and this is the
457
+ // second exception on the same test the secret scan takes: no target holds
458
+ // `claude/skills/`, so without the allowance every project installing this
459
+ // CLI reports the verb unmeasured on every run and never changes, which is
460
+ // the permanent signal the per-machine allowance exists against.
461
+ absentReasons: ['no-skills'] satisfies ReachRefusal[],
462
+ counts: reachCounts,
463
+ },
406
464
  {
407
465
  id: 'tasks',
408
466
  label: 'Task board',
@@ -463,6 +521,33 @@ export const AUDITS: readonly AuditSpec[] = [
463
521
  ] satisfies ScanRefusal[],
464
522
  counts: findingsOnly,
465
523
  },
524
+ {
525
+ id: 'labels',
526
+ label: 'Pull request label coverage',
527
+ argv: ['labels', 'audit', '--json'],
528
+ // Reports rather than gates, on the split this file already draws. Whether
529
+ // an uncovered surface deserves a label is a judgment, and a push failing
530
+ // on one would ask a contributor to answer a question only the person who
531
+ // owns the surface can.
532
+ gatingExits: [],
533
+ // The map is committed, so every clone reads the same rows and a delta is
534
+ // shared. The changed set the rows are read against is the branch's, which
535
+ // is what makes a clean trunk report zero rather than nothing.
536
+ corpus: 'tracked',
537
+ // Only `uncovered` is retained, for the reason its extractor states.
538
+ //
539
+ // The one reason that means this project declares no surfaces to cover,
540
+ // which is the recorded decision that a project without a map is labelled
541
+ // silently. A tracked corpus normally allows nothing absent, and this is
542
+ // the second exception beside the secret scan rather than a default.
543
+ //
544
+ // The other three are deliberately left out. A map that will not parse, a
545
+ // map with no usable row, and a range git could not answer are each a
546
+ // corpus that exists and went unread, so calling any of them an absence
547
+ // would report a pass over a branch nobody measured.
548
+ absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
549
+ counts: labelCoverageCounts,
550
+ },
466
551
  {
467
552
  id: 'deps',
468
553
  label: 'Dependency advisories',
@@ -0,0 +1,193 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * The tree that installs into a target. The internal skills under `.claude/`
6
+ * never leave this repository, so a citation there is read by a session that
7
+ * already has the file and cannot be a reach defect.
8
+ */
9
+ const SHIPPED_SKILLS = join('claude', 'skills')
10
+
11
+ /**
12
+ * The authoring roots this repository owns and no install channel delivers.
13
+ *
14
+ * Every entry is a folder a target never holds under that spelling. Standards
15
+ * install nowhere and are reached through the plugin corpus, rules install
16
+ * under `.claude/rules/`, snippets under `.claude/snippets/`, and the rest are
17
+ * this repository's own source, docs, and catalogs.
18
+ *
19
+ * `src/`, `scripts/`, and bare `docs/` are deliberately absent. A body naming
20
+ * one of those is describing the reader's own tree, so listing them would
21
+ * report a correct citation on every run and bury the defect this measures.
22
+ * `docs/agents/` is the exception, being the CLI contract pages that exist
23
+ * here alone.
24
+ */
25
+ const AUTHORING_ROOTS = [
26
+ '.claude/context/',
27
+ 'claude/',
28
+ 'docs/agents/',
29
+ 'governance/',
30
+ 'internal/',
31
+ 'snippets/',
32
+ 'standards/',
33
+ 'tooling/',
34
+ 'wiki/',
35
+ ] as const
36
+
37
+ /**
38
+ * What marks a citation as deliberately naming this repository's own copy.
39
+ *
40
+ * The word rather than a notation, matching the three bodies that already
41
+ * spell it and the repair the plan settled on. A parser-visible syntax was the
42
+ * alternative and it invents a spelling for a handful of lines while leaving
43
+ * the shipped precedent unreadable.
44
+ */
45
+ const QUALIFIER = /toolkit/i
46
+
47
+ /** A backticked token, which is how every body spells a path it cites. */
48
+ const TOKEN = /`([^`\s]+)`/g
49
+
50
+ /**
51
+ * A path a reader could open, which is the only kind worth measuring.
52
+ *
53
+ * Requires an extension and a separator, and admits no `<`, `$`, or `*`. A
54
+ * body writes `.claude/context/<domain>.md` to name a shape rather than a
55
+ * file, and `${CLAUDE_SKILL_DIR}/../../standards/markdown.md` to resolve
56
+ * against the plugin root, which is self-contained by construction.
57
+ */
58
+ const CONCRETE = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*\.[a-z]{1,4}$/
59
+
60
+ export interface Citation {
61
+ readonly file: string
62
+ /** One-based, matching the `file:line` form a reader clicks. */
63
+ readonly line: number
64
+ readonly path: string
65
+ readonly qualified: boolean
66
+ }
67
+
68
+ /** Why a scan produced no corpus, which is never the same as a clean one. */
69
+ export type ReachRefusal = 'no-skills'
70
+
71
+ export type ReachReport =
72
+ | {
73
+ readonly kind: 'measured'
74
+ /** Files opened, so a report can state what the verdict covers. */
75
+ readonly bodies: number
76
+ readonly qualified: readonly Citation[]
77
+ readonly unqualified: readonly Citation[]
78
+ }
79
+ | { readonly kind: 'refused'; readonly reason: ReachRefusal }
80
+
81
+ export function isQualified(line: string): boolean {
82
+ return QUALIFIER.test(line)
83
+ }
84
+
85
+ /**
86
+ * Every path a seed lands on in a target, spelled the way a body would cite it.
87
+ *
88
+ * Read off the seed tree rather than listed, so a seed added to any stack
89
+ * clears its own citations without this module being edited. Dotfiles are in
90
+ * scope because the whole seeded context corpus sits under `.claude/`.
91
+ */
92
+ export function readReceivedPaths(root: string): Set<string> {
93
+ const toolingRoot = join(root, 'tooling')
94
+ if (!existsSync(toolingRoot)) return new Set()
95
+
96
+ const received = new Set<string>()
97
+ for (const path of new Bun.Glob('*/seeds/**/*').scanSync({
98
+ cwd: toolingRoot,
99
+ onlyFiles: true,
100
+ dot: true,
101
+ })) {
102
+ const posix = path.replaceAll('\\', '/')
103
+ received.add(posix.replace(/^[^/]+\/seeds\//, ''))
104
+ }
105
+ return received
106
+ }
107
+
108
+ /**
109
+ * Whether a cited path is this repository's own rather than the reader's.
110
+ *
111
+ * A seeded path is disowned twice over: under its own name, and under the
112
+ * folder spelling it takes once a target splits the entry. A domain that
113
+ * outgrows one file becomes `<domain>/`, which is still the entry the seed
114
+ * delivered, so reporting the split form would fail a target for growing.
115
+ */
116
+ export function isToolkitOwned(path: string, received: Set<string>): boolean {
117
+ if (received.has(path)) return false
118
+
119
+ for (const seeded of received) {
120
+ const stem = seeded.replace(/\.md$/, '')
121
+ if (stem !== seeded && path.startsWith(`${stem}/`)) return false
122
+ }
123
+
124
+ return AUTHORING_ROOTS.some((prefix) => path.startsWith(prefix))
125
+ }
126
+
127
+ /**
128
+ * Every toolkit-owned path one shipped file cites, with the line's verdict.
129
+ *
130
+ * Existence is not checked here. A body may name a path this repository once
131
+ * held, and separating the shape test from the disk read is what lets the
132
+ * shape be tested without a tree on disk.
133
+ */
134
+ export function citationsIn(
135
+ file: string,
136
+ text: string,
137
+ received: Set<string>,
138
+ ): Citation[] {
139
+ const citations: Citation[] = []
140
+
141
+ for (const [index, line] of text.split('\n').entries()) {
142
+ const qualified = isQualified(line)
143
+
144
+ for (const match of line.matchAll(TOKEN)) {
145
+ const path = match[1]
146
+ if (!CONCRETE.test(path) || !path.includes('/')) continue
147
+ if (!isToolkitOwned(path, received)) continue
148
+
149
+ citations.push({ file, line: index + 1, path, qualified })
150
+ }
151
+ }
152
+
153
+ return citations
154
+ }
155
+
156
+ /**
157
+ * Reads every shipped body for a path its reader cannot open.
158
+ *
159
+ * A citation of a path this repository does not hold is dropped rather than
160
+ * reported. The measure asks whether a claim true here is false in a target,
161
+ * and a path true in neither is a different defect that `aitk context audit`
162
+ * already reports against its own corpus.
163
+ */
164
+ export function scanReach(root: string): ReachReport {
165
+ const skillsRoot = join(root, SHIPPED_SKILLS)
166
+ if (!existsSync(skillsRoot)) return { kind: 'refused', reason: 'no-skills' }
167
+
168
+ const received = readReceivedPaths(root)
169
+ const files = [
170
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: skillsRoot, onlyFiles: true }),
171
+ ].sort()
172
+
173
+ const qualified: Citation[] = []
174
+ const unqualified: Citation[] = []
175
+
176
+ for (const file of files) {
177
+ const posix = file.replaceAll('\\', '/')
178
+ const text = readFileSync(join(skillsRoot, file), 'utf8')
179
+
180
+ for (const citation of citationsIn(
181
+ `${SHIPPED_SKILLS.replaceAll('\\', '/')}/${posix}`,
182
+ text,
183
+ received,
184
+ )) {
185
+ if (!existsSync(join(root, citation.path))) continue
186
+
187
+ if (citation.qualified) qualified.push(citation)
188
+ else unqualified.push(citation)
189
+ }
190
+ }
191
+
192
+ return { kind: 'measured', bodies: files.length, qualified, unqualified }
193
+ }
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ import { register as sessions } from '@/commands/sessions'
28
28
  import { register as audits } from '@/commands/audits'
29
29
  import { register as secrets } from '@/commands/secrets'
30
30
  import { register as deps } from '@/commands/deps'
31
+ import { register as labels } from '@/commands/labels'
31
32
  import { register as upgrade } from '@/commands/upgrade'
32
33
  import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
33
34
  import { palette } from '@/ui'
@@ -67,6 +68,7 @@ function showHelp(): void {
67
68
  `${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
68
69
  `${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
69
70
  `${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
71
+ `${GREY}│${NC} labels [cmd] ${GREY}# Read a changed set against the pull request label map (audit)${NC}`,
70
72
  `${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
71
73
  `${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
72
74
  `${GREY}│${NC}`,
@@ -110,6 +112,7 @@ function showHelp(): void {
110
112
  `${GREY}│${NC} aitk sessions list --json`,
111
113
  `${GREY}│${NC} aitk secrets scan --json`,
112
114
  `${GREY}│${NC} aitk deps audit --json`,
115
+ `${GREY}│${NC} aitk labels audit --json`,
113
116
  `${GREY}│${NC} aitk audits run --json`,
114
117
  `${GREY}│${NC} aitk upgrade --json`,
115
118
  `${GREY}└${NC}`,
@@ -156,6 +159,7 @@ records(program)
156
159
  sessions(program)
157
160
  secrets(program)
158
161
  deps(program)
162
+ labels(program)
159
163
  audits(program)
160
164
  upgrade(program)
161
165
 
@@ -23,6 +23,11 @@ import {
23
23
  } from '@/claude/skills-audit'
24
24
  import { type DriftReport, readDrift } from '@/claude/skills-drift'
25
25
  import { listSkills } from '@/claude/skills-list'
26
+ import {
27
+ type ReachRefusal,
28
+ type ReachReport,
29
+ scanReach,
30
+ } from '@/claude/skills-reach'
26
31
  import {
27
32
  planSettings,
28
33
  readSettings,
@@ -70,6 +75,10 @@ interface SkillsDriftOptions {
70
75
  readonly json?: boolean
71
76
  }
72
77
 
78
+ interface SkillsReachOptions {
79
+ readonly json?: boolean
80
+ }
81
+
73
82
  const SEEDED_FILES: readonly string[] = [
74
83
  'ARCHITECTURE.md',
75
84
  'REQUIREMENTS.md',
@@ -160,15 +169,15 @@ export function register(program: Command): void {
160
169
 
161
170
  const skills = claude
162
171
  .command('skills')
163
- .description('Plugin skill catalog (list, audit, drift)')
164
- .argument('[subcommand]', "One of 'list', 'audit', or 'drift'")
172
+ .description('Plugin skill catalog (list, audit, drift, reach)')
173
+ .argument('[subcommand]', "One of 'list', 'audit', 'drift', or 'reach'")
165
174
  .helpOption('-h, --help', 'Show this help message')
166
175
  .action((subcommand: string | undefined) => {
167
176
  intro('aitk claude')
168
177
  logError(
169
178
  subcommand === undefined
170
- ? "Missing subcommand. Use 'list', 'audit', or 'drift'."
171
- : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', or 'drift'.`,
179
+ ? "Missing subcommand. Use 'list', 'audit', 'drift', or 'reach'."
180
+ : `Unknown subcommand: ${subcommand}. Use 'list', 'audit', 'drift', or 'reach'.`,
172
181
  )
173
182
  outro()
174
183
  process.exitCode = 1
@@ -260,6 +269,42 @@ export function register(program: Command): void {
260
269
  .action(async (ref: string, opts: SkillsDriftOptions) => {
261
270
  process.exitCode = await runSkillsDrift(ref, opts)
262
271
  })
272
+
273
+ skills
274
+ .command('reach')
275
+ .description('Report shipped bodies citing a path no target receives')
276
+ .argument('[path]', 'Repository root, defaulting to the current directory')
277
+ .helpOption('-h, --help', 'Show this help message')
278
+ .option('--json', 'Add a machine-readable record on stdout')
279
+ .addHelpText(
280
+ 'after',
281
+ [
282
+ '',
283
+ 'Scope:',
284
+ ' Every markdown file under claude/skills/, which is the tree that',
285
+ ' installs into a target. A cited path counts when it sits under an',
286
+ ' authoring root no install channel delivers and this repository',
287
+ ' holds it. A path under src/, scripts/, or bare docs/ names the',
288
+ " reader's own tree and is not measured.",
289
+ '',
290
+ 'Exit codes:',
291
+ ' 0 every citation names the toolkit as the owner',
292
+ ' 1 refused, with the reason on stderr',
293
+ ' 2 at least one citation is unqualified',
294
+ '',
295
+ 'Reports rather than gates. A toolkit-scoped instruction is sometimes',
296
+ 'meant for a session in this repository, so the verdict is a reading',
297
+ 'and the repair is to name the owner in the sentence.',
298
+ '',
299
+ 'Examples:',
300
+ ' aitk claude skills reach',
301
+ ' aitk claude skills reach --json',
302
+ '',
303
+ ].join('\n'),
304
+ )
305
+ .action((path: string | undefined, opts: SkillsReachOptions) => {
306
+ process.exitCode = runSkillsReach(path, opts)
307
+ })
263
308
  }
264
309
 
265
310
  function succeed(message: string): number {
@@ -567,6 +612,80 @@ function reportSkew(skew: SkewReport): void {
567
612
  else logInfo(describeSkew(skew))
568
613
  }
569
614
 
615
+ /** What a reader does about the one way the corpus fails to build. */
616
+ const REACH_REFUSALS: Record<ReachRefusal, string> = {
617
+ 'no-skills':
618
+ 'No claude/skills/ here, so this tree ships no plugin body to measure.',
619
+ }
620
+
621
+ /**
622
+ * Measures the cwd rather than the toolkit root, matching the audit and drift
623
+ * verbs, so a linked worktree reads its own branch instead of `main`.
624
+ */
625
+ function runSkillsReach(
626
+ path: string | undefined,
627
+ opts: SkillsReachOptions,
628
+ ): number {
629
+ const root = resolve(path ?? process.cwd())
630
+ const report = scanReach(root)
631
+
632
+ if (report.kind === 'refused') {
633
+ frameError(REACH_REFUSALS[report.reason])
634
+ if (opts.json) {
635
+ process.stdout.write(
636
+ `${JSON.stringify({
637
+ root,
638
+ reason: report.reason,
639
+ message: REACH_REFUSALS[report.reason],
640
+ })}\n`,
641
+ )
642
+ }
643
+ return 1
644
+ }
645
+
646
+ intro('aitk claude skills reach')
647
+ reportReach(report)
648
+ outro()
649
+
650
+ if (opts.json) {
651
+ process.stdout.write(
652
+ `${JSON.stringify({
653
+ root,
654
+ bodies: report.bodies,
655
+ qualified: report.qualified,
656
+ unqualified: report.unqualified,
657
+ })}\n`,
658
+ )
659
+ }
660
+
661
+ return report.unqualified.length === 0 ? 0 : 2
662
+ }
663
+
664
+ /**
665
+ * States the corpus on every run, including the clean one. A count of what
666
+ * failed reads as a verdict on the catalog unless the run also says how many
667
+ * bodies it opened and how many citations it already accepted.
668
+ */
669
+ function reportReach(report: Extract<ReachReport, { kind: 'measured' }>): void {
670
+ logStep('Corpus')
671
+ logInfo(
672
+ `${plural(report.bodies, 'shipped file')} read, ${plural(report.qualified.length, 'citation')} already naming the toolkit as owner`,
673
+ )
674
+
675
+ logStep('Unqualified citations')
676
+ if (report.unqualified.length === 0) {
677
+ logInfo('Every toolkit-owned path a shipped body cites names its owner.')
678
+ return
679
+ }
680
+
681
+ logWarn(plural(report.unqualified.length, 'citation'))
682
+ pipeOutput(
683
+ report.unqualified
684
+ .map((citation) => `${citation.file}:${citation.line} ${citation.path}`)
685
+ .join('\n'),
686
+ )
687
+ }
688
+
570
689
  /**
571
690
  * States the bound on every run, including the run that names nothing. A report
572
691
  * listing only what moved reads as a verdict on what a session holds, and the
@@ -0,0 +1,166 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { type LabelAuditRefusal, auditLabels } from '@/labels/audit'
4
+ import { MAP_REL } from '@/labels/map'
5
+ import { intro, logInfo, logStep, logWarn, outro, plural } from '@/ui'
6
+
7
+ interface AuditOptions {
8
+ readonly base?: string
9
+ readonly root?: string
10
+ readonly json?: boolean
11
+ }
12
+
13
+ /** What a reader does about each way the audit produced no reading. */
14
+ const REFUSALS: Record<LabelAuditRefusal, string> = {
15
+ // An answer rather than a fault. A project declaring no map is labelled
16
+ // silently by design, so a refusal reading as a break would make the map
17
+ // mandatory for every target.
18
+ 'no-map': `No ${MAP_REL} here, so this project labels nothing and declares no surfaces to cover.`,
19
+ 'unreadable-map': `${MAP_REL} is not valid TOML, so no row could be read.`,
20
+ 'no-domains': `${MAP_REL} carries no usable row under [domains], so every path would read as uncovered.`,
21
+ 'no-base': 'No base resolves against the trunk. Fetch origin or pass --base.',
22
+ 'bad-base':
23
+ 'The ref passed to --base resolves to no commit here. Pass one this tree carries.',
24
+ 'unreadable-changes':
25
+ 'git could not list what this branch changed, so the set is unknown.',
26
+ }
27
+
28
+ export function register(program: Command): void {
29
+ const labels = program
30
+ .command('labels')
31
+ .description('Resolve a changed set against the pull request label map')
32
+ .helpOption('-h, --help', 'Show this help message')
33
+
34
+ labels
35
+ .command('audit')
36
+ .description(
37
+ 'Report the labels a changed set earns and the paths no row reaches',
38
+ )
39
+ .argument(
40
+ '[paths...]',
41
+ 'Changed set to read, defaulting to the branch range',
42
+ )
43
+ .helpOption('-h, --help', 'Show this help message')
44
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
45
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
46
+ .option('--json', 'Add a machine-readable record on stdout')
47
+ .addHelpText(
48
+ 'after',
49
+ [
50
+ '',
51
+ `Reads ${MAP_REL} and matches it prefix-anchored, which is the rule the`,
52
+ "map's own census was measured against. It reports and never gates,",
53
+ 'because whether an uncovered surface deserves a label is a judgment.',
54
+ '',
55
+ 'What it separates:',
56
+ ' uncovered a surface no row reaches, which is a gap wanting a row',
57
+ ' declined a path a [declined] row names, which is a decision already taken',
58
+ '',
59
+ 'What it does not measure:',
60
+ ' a prefix reaching no path, which is the map going stale from the other',
61
+ ' side and a second measure rather than this one',
62
+ '',
63
+ 'Exit codes:',
64
+ ' 0 every changed path is labelled or declined',
65
+ ' 1 refused, with the reason on stderr or in the JSON record',
66
+ ' 2 at least one changed path is reached by no row',
67
+ '',
68
+ 'Examples:',
69
+ ' aitk labels audit',
70
+ ' aitk labels audit --json',
71
+ ' aitk labels audit --base origin/main',
72
+ ' aitk labels audit src/cli.ts docs/index.md --json',
73
+ '',
74
+ ].join('\n'),
75
+ )
76
+ .action(async (paths: string[], opts: AuditOptions) => {
77
+ process.exitCode = await runAudit(paths, opts)
78
+ })
79
+ }
80
+
81
+ async function runAudit(paths: string[], opts: AuditOptions): Promise<number> {
82
+ const root = resolve(opts.root ?? process.cwd())
83
+ const emitJson = opts.json ?? false
84
+
85
+ const report = await auditLabels(root, {
86
+ base: opts.base,
87
+ ...(paths.length > 0 && { paths }),
88
+ })
89
+
90
+ intro('aitk labels audit')
91
+
92
+ // The frame renders on stderr in both modes and the record goes to stdout
93
+ // alone, so an operator reading the terminal sees the refusal rather than a
94
+ // command that appeared to do nothing.
95
+ if (report.kind === 'refused') {
96
+ logStep('Refused')
97
+ logWarn(REFUSALS[report.reason])
98
+ outro()
99
+
100
+ if (emitJson) {
101
+ process.stdout.write(
102
+ `${JSON.stringify({
103
+ root,
104
+ reason: report.reason,
105
+ message: REFUSALS[report.reason],
106
+ })}\n`,
107
+ )
108
+ }
109
+ return 1
110
+ }
111
+
112
+ const { coverage } = report
113
+
114
+ logStep('Scope')
115
+ logInfo(
116
+ report.base === undefined
117
+ ? `${plural(report.changed.length, 'path')} supplied by the caller`
118
+ : `${plural(report.changed.length, 'path')} changed since ${report.base.slice(0, 8)}`,
119
+ )
120
+
121
+ logStep('Labels')
122
+ logInfo(
123
+ coverage.labels.length === 0
124
+ ? 'this set earns no label'
125
+ : coverage.labels.join(', '),
126
+ )
127
+
128
+ // Named rather than counted into the uncovered line. A path somebody decided
129
+ // against wants nothing done, and folding it in would ask for a row that was
130
+ // already refused.
131
+ logStep('Declined')
132
+ if (coverage.declined.length === 0) {
133
+ logInfo('no changed path is deliberately unlabelled')
134
+ } else {
135
+ for (const entry of coverage.declined) {
136
+ logInfo(`${entry.path}: ${entry.reason}`)
137
+ }
138
+ }
139
+
140
+ logStep(coverage.uncovered.length === 0 ? 'Covered' : 'Uncovered')
141
+ if (coverage.uncovered.length === 0) {
142
+ logInfo('every changed path is reached by a row')
143
+ } else {
144
+ logWarn(
145
+ `${plural(coverage.uncovered.length, 'path')} reached by no row. Give each a prefix on the row that owns its subject, or a [declined] row with the reason it earns none.`,
146
+ )
147
+ for (const path of coverage.uncovered) logWarn(path)
148
+ }
149
+
150
+ outro()
151
+
152
+ if (emitJson) {
153
+ process.stdout.write(
154
+ `${JSON.stringify({
155
+ root,
156
+ ...(report.base !== undefined && { base: report.base }),
157
+ changed: report.changed,
158
+ labels: coverage.labels,
159
+ declined: coverage.declined,
160
+ uncovered: coverage.uncovered,
161
+ })}\n`,
162
+ )
163
+ }
164
+
165
+ return coverage.uncovered.length === 0 ? 0 : 2
166
+ }
@@ -162,6 +162,19 @@ function renderCheck(report: CheckReport): void {
162
162
  for (const name of report.newSkills) logInfo(name)
163
163
  }
164
164
 
165
+ // Warned where `newSkills` is noted, because a new skill loads live and needs
166
+ // nothing run while a rule reaches the target only when someone installs it.
167
+ // No sync closes any of this, so the remedy names the install command.
168
+ if (report.newRules.length > 0) {
169
+ logStep('New rules, never installed')
170
+ for (const name of report.newRules) logWarn(name)
171
+ // Names the stack as the reader's to supply, since no target records one
172
+ // and `--add` layers onto a resolved stack rather than standing in for it.
173
+ logInfo(
174
+ 'Run `aitk gov install <stack>`, naming the stack yourself since no target records it. Add `--add <rule>` to take one.',
175
+ )
176
+ }
177
+
165
178
  renderUnclaimed(report)
166
179
  renderMigrations(report)
167
180