@erclx/aitk 3.7.0 → 3.9.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,5 +1,6 @@
1
1
  import type { ReachRefusal } from '@/claude/skills-reach'
2
2
  import type { AuditRefusal } from '@/deps/audit'
3
+ import type { RestatedRefusal } from '@/gov/restated'
3
4
  import type { LabelAuditRefusal } from '@/labels/audit'
4
5
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
5
6
  import type { ScanRefusal } from '@/secrets/scan'
@@ -356,6 +357,33 @@ function labelCoverageCounts(
356
357
  return allOf({ uncovered: lengthOf(root.uncovered) })
357
358
  }
358
359
 
360
+ /**
361
+ * Reads the two classes that are findings, leaving the declared mirrors out.
362
+ *
363
+ * A mirror is an authoring root and its consumed copy, which duplicate on
364
+ * purpose, so folding them in would report a corpus getting worse every time a
365
+ * seed is kept in step with the file it is authored from. Same reasoning the
366
+ * label audit drops its declined rows on.
367
+ *
368
+ * The reach count is left out for a different reason. It counts how many
369
+ * surfaces an instruction reached rather than whether anything is wrong, and it
370
+ * moves in lockstep with the two retained here, so a baseline carrying it would
371
+ * report one movement twice.
372
+ */
373
+ function restatedCounts(record: unknown): Record<string, number> | undefined {
374
+ const counts = asObject(asObject(record)?.counts)
375
+ if (counts === undefined) return undefined
376
+
377
+ return allOf({
378
+ contradictions:
379
+ typeof counts.contradictions === 'number'
380
+ ? counts.contradictions
381
+ : undefined,
382
+ repetitions:
383
+ typeof counts.repetitions === 'number' ? counts.repetitions : undefined,
384
+ })
385
+ }
386
+
359
387
  function findingsOnly(record: unknown): Record<string, number> | undefined {
360
388
  const root = asObject(record)
361
389
  if (root === undefined) return undefined
@@ -548,6 +576,27 @@ export const AUDITS: readonly AuditSpec[] = [
548
576
  absentReasons: ['no-map'] satisfies LabelAuditRefusal[],
549
577
  counts: labelCoverageCounts,
550
578
  },
579
+ {
580
+ id: 'restated',
581
+ label: 'Restated instructions',
582
+ argv: ['gov', 'restated', '--json'],
583
+ // Reports rather than gates, on the split this file already draws. Whether
584
+ // a rule stated on two surfaces should be stated on one is a judgment the
585
+ // person owning the surface takes, and most restatements here are correct,
586
+ // so a push failing on one would fail on the ordinary case.
587
+ gatingExits: [],
588
+ corpus: 'tracked',
589
+ // Both reasons the sweep refuses for, and each is an absence rather than a
590
+ // break. A target holds neither the seed nor a shipped skills tree, so
591
+ // without the allowance every project installing this CLI reports the verb
592
+ // unmeasured on every run and never changes, which is the permanent signal
593
+ // the per-machine allowance exists against. Same shape as the reach verb.
594
+ absentReasons: [
595
+ 'no-instructions',
596
+ 'no-surfaces',
597
+ ] satisfies RestatedRefusal[],
598
+ counts: restatedCounts,
599
+ },
551
600
  {
552
601
  id: 'deps',
553
602
  label: 'Dependency advisories',
@@ -0,0 +1,283 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ const CLAUDE_MD = 'CLAUDE.md'
5
+ const RULES_DIR = join('.claude', 'rules')
6
+
7
+ /** A backticked token, which is how the always-loaded file spells a path. */
8
+ const TOKEN = /`([^`\s]+)`/g
9
+
10
+ /**
11
+ * A path a reader could open. Admits no `$`, `*`, or `=`, so an assignment
12
+ * (`AITK_NON_INTERACTIVE=1`) and a variable are both excluded.
13
+ */
14
+ const PATH_TOKEN = /^[.A-Za-z0-9_][A-Za-z0-9._/-]*$/
15
+
16
+ /** A placeholder segment, which a shape carries and a real path does not. */
17
+ const SHAPE = /<[^>]*>/
18
+
19
+ /** An alphabetic extension, which separates `cspell.json` from `3.6.0`. */
20
+ const EXTENSION = /\.[a-z]{2,5}$/
21
+
22
+ /** Extensions a folder is probed with, so a glob narrowed by type still hits. */
23
+ const PROBES = ['probe.md', 'probe.ts', 'probe.tsx', 'probe.sh', 'probe.json']
24
+
25
+ export interface RuleGlobs {
26
+ /** Path under `.claude/rules/`, which is how a reader cites a rule. */
27
+ readonly rule: string
28
+ readonly globs: readonly string[]
29
+ }
30
+
31
+ export interface Section {
32
+ /** `Behavior` for an H2, `Behavior / Scope` for the H3 beneath it. */
33
+ readonly heading: string
34
+ /** The top-level bullet lines the heading owns, in reading order. */
35
+ readonly lines: readonly string[]
36
+ }
37
+
38
+ export interface SectionReport {
39
+ readonly heading: string
40
+ readonly bullets: number
41
+ /** Bullets naming at least one path, which is the reading this measures. */
42
+ readonly pathScoped: number
43
+ /** Path-scoped bullets whose named path some rule glob already reaches. */
44
+ readonly covered: number
45
+ /** Distinct named paths no rule glob reaches, in reading order. */
46
+ readonly uncovered: readonly string[]
47
+ }
48
+
49
+ /** Why a scan produced no reading, which is never the same as a clean one. */
50
+ export type RoutingRefusal = 'no-claude-md' | 'no-rules'
51
+
52
+ export type RoutingReport =
53
+ | {
54
+ readonly kind: 'measured'
55
+ readonly rules: number
56
+ readonly sections: readonly SectionReport[]
57
+ }
58
+ | { readonly kind: 'refused'; readonly reason: RoutingRefusal }
59
+
60
+ /**
61
+ * Every distinct path one bullet names, in reading order.
62
+ *
63
+ * Naming a path is what this measures, not firing only on it. A bullet can
64
+ * name a folder and still apply every session, and one can fire on a path it
65
+ * never spells, so the count is a reading a person still has to judge.
66
+ */
67
+ export function namedPaths(line: string): string[] {
68
+ const paths: string[] = []
69
+
70
+ for (const match of line.matchAll(TOKEN)) {
71
+ const token = concretePrefix(match[1])
72
+ if (token === undefined) continue
73
+ if (!PATH_TOKEN.test(token)) continue
74
+ if (!token.includes('/') && !EXTENSION.test(token)) continue
75
+ if (!paths.includes(token)) paths.push(token)
76
+ }
77
+
78
+ return paths
79
+ }
80
+
81
+ /**
82
+ * The openable part of a token, which for a shape is the folder above the
83
+ * placeholder.
84
+ *
85
+ * `.claude/context/<domain>.md` names `.claude/context/` and nothing narrower,
86
+ * so dropping the whole token would report the section that carries it as
87
+ * naming no path at all. A token whose placeholder sits in the first segment
88
+ * has no openable prefix and is dropped.
89
+ */
90
+ function concretePrefix(token: string): string | undefined {
91
+ if (!SHAPE.test(token)) return token
92
+
93
+ const prefix = token.slice(0, token.indexOf('<'))
94
+ return prefix.endsWith('/') ? prefix : undefined
95
+ }
96
+
97
+ /**
98
+ * Splits the file into the sections a reader sees, counting top-level bullets.
99
+ *
100
+ * A nested bullet belongs to the one above it rather than to the section, and
101
+ * a fenced block holds example text rather than instruction, so neither is
102
+ * counted. A heading carrying no bullet is dropped, since the report answers
103
+ * how many bullets are path-scoped and a section with none answers nothing.
104
+ */
105
+ export function splitSections(text: string): Section[] {
106
+ const sections: Section[] = []
107
+ let parent = ''
108
+ let heading: string | undefined
109
+ let lines: string[] = []
110
+ let fenced = false
111
+
112
+ const flush = (): void => {
113
+ if (heading !== undefined && lines.length > 0)
114
+ sections.push({ heading, lines })
115
+ }
116
+
117
+ const open = (next: string): void => {
118
+ flush()
119
+ heading = next
120
+ lines = []
121
+ }
122
+
123
+ for (const line of text.split('\n')) {
124
+ if (line.startsWith('```')) {
125
+ fenced = !fenced
126
+ continue
127
+ }
128
+ if (fenced) continue
129
+
130
+ const h2 = line.match(/^## (.+)$/)
131
+ if (h2) {
132
+ parent = h2[1]
133
+ open(parent)
134
+ continue
135
+ }
136
+
137
+ const h3 = line.match(/^### (.+)$/)
138
+ if (h3) {
139
+ open(parent === '' ? h3[1] : `${parent} / ${h3[1]}`)
140
+ continue
141
+ }
142
+
143
+ if (line.startsWith('- ')) lines.push(line)
144
+ }
145
+
146
+ flush()
147
+ return sections
148
+ }
149
+
150
+ /**
151
+ * Every path-scoped rule the tree installs, with the globs it declares.
152
+ *
153
+ * An always-on rule declares no `paths` and applies at the same priority as
154
+ * the always-loaded file, so it covers no path in particular and is skipped.
155
+ */
156
+ export function readRuleGlobs(root: string): RuleGlobs[] {
157
+ const rulesRoot = join(root, RULES_DIR)
158
+ if (!existsSync(rulesRoot)) return []
159
+
160
+ const rules: RuleGlobs[] = []
161
+ const files = [
162
+ ...new Bun.Glob('**/*.md').scanSync({ cwd: rulesRoot, onlyFiles: true }),
163
+ ].sort()
164
+
165
+ for (const file of files) {
166
+ const text = readFileSync(join(rulesRoot, file), 'utf8')
167
+ const globs = [...frontmatter(text).matchAll(/^\s*-\s*'([^']+)'\s*$/gm)]
168
+ .map((match) => match[1])
169
+ .filter((glob) => glob.includes('*') || glob.includes('.'))
170
+
171
+ if (globs.length === 0) continue
172
+ rules.push({ rule: file.replaceAll('\\', '/'), globs })
173
+ }
174
+
175
+ return rules
176
+ }
177
+
178
+ /**
179
+ * The block between the opening and closing `---`, or nothing for a rule
180
+ * carrying no frontmatter.
181
+ *
182
+ * Bounding the read is what keeps a body bullet out of the glob list. A rule
183
+ * quoting a path in prose would otherwise register it as a scope the rule
184
+ * never declared, and the resulting coverage would be wrong with nothing
185
+ * reporting it.
186
+ */
187
+ function frontmatter(text: string): string {
188
+ if (!text.startsWith('---\n')) return ''
189
+
190
+ const end = text.indexOf('\n---', 3)
191
+ return end === -1 ? '' : text.slice(4, end)
192
+ }
193
+
194
+ /**
195
+ * Whether a glob scopes itself to a location rather than to a file type.
196
+ *
197
+ * A glob opening `**` reaches every folder in the tree, so it answers that a
198
+ * file type is governed and never that a named path is. Counting one would
199
+ * report every markdown path covered by `501-markdown` and leave the column
200
+ * saying nothing a reader could act on.
201
+ */
202
+ function isAnchored(glob: string): boolean {
203
+ return !glob.startsWith('**/')
204
+ }
205
+
206
+ /**
207
+ * The first rule scoping itself to a named path, or undefined for none.
208
+ *
209
+ * A folder is probed with a handful of extensions rather than matched as a
210
+ * literal, because a glob narrowed by file type reaches under the folder
211
+ * without ever matching the folder's own name.
212
+ */
213
+ export function coveringRule(
214
+ path: string,
215
+ rules: readonly RuleGlobs[],
216
+ ): string | undefined {
217
+ const bare = path.replace(/\/+$/, '')
218
+ const candidates = [bare, ...PROBES.map((probe) => `${bare}/${probe}`)]
219
+
220
+ for (const { rule, globs } of rules) {
221
+ for (const glob of globs) {
222
+ if (!isAnchored(glob)) continue
223
+ const matcher = new Bun.Glob(glob)
224
+ if (candidates.some((candidate) => matcher.match(candidate))) return rule
225
+ }
226
+ }
227
+
228
+ return undefined
229
+ }
230
+
231
+ /**
232
+ * Reads the always-loaded file against the rules installed beside it.
233
+ *
234
+ * Measures the tree it is pointed at rather than the toolkit root, so a linked
235
+ * worktree reads its own branch and a target reads its own file.
236
+ */
237
+ export function scanRouting(root: string): RoutingReport {
238
+ const file = join(root, CLAUDE_MD)
239
+ if (!existsSync(file)) return { kind: 'refused', reason: 'no-claude-md' }
240
+
241
+ const rules = readRuleGlobs(root)
242
+ if (rules.length === 0) return { kind: 'refused', reason: 'no-rules' }
243
+
244
+ const sections = splitSections(readFileSync(file, 'utf8')).map((section) =>
245
+ classify(section, rules),
246
+ )
247
+
248
+ return { kind: 'measured', rules: rules.length, sections }
249
+ }
250
+
251
+ /** Counts one section's bullets against the rules installed beside the file. */
252
+ function classify(
253
+ section: Section,
254
+ rules: readonly RuleGlobs[],
255
+ ): SectionReport {
256
+ let pathScoped = 0
257
+ let covered = 0
258
+ const uncovered: string[] = []
259
+
260
+ for (const line of section.lines) {
261
+ const paths = namedPaths(line)
262
+ if (paths.length === 0) continue
263
+ pathScoped += 1
264
+
265
+ const reached = paths.filter(
266
+ (path) => coveringRule(path, rules) !== undefined,
267
+ )
268
+ if (reached.length > 0) covered += 1
269
+
270
+ for (const path of paths) {
271
+ if (reached.includes(path) || uncovered.includes(path)) continue
272
+ uncovered.push(path)
273
+ }
274
+ }
275
+
276
+ return {
277
+ heading: section.heading,
278
+ bullets: section.lines.length,
279
+ pathScoped,
280
+ covered,
281
+ uncovered,
282
+ }
283
+ }
package/src/cli.ts CHANGED
@@ -85,6 +85,7 @@ function showHelp(): void {
85
85
  `${GREY}│${NC} aitk sandbox git:commit`,
86
86
  `${GREY}│${NC} aitk gov install react`,
87
87
  `${GREY}│${NC} aitk gov sync ../my-app`,
88
+ `${GREY}│${NC} aitk gov restated --json`,
88
89
  `${GREY}│${NC} aitk standards markdown`,
89
90
  `${GREY}│${NC} aitk snippets install base ../my-app`,
90
91
  `${GREY}│${NC} aitk snippets sync ../my-app`,
@@ -21,6 +21,11 @@ import {
21
21
  type SkillFinding,
22
22
  type SkillsAudit,
23
23
  } from '@/claude/skills-audit'
24
+ import {
25
+ type RoutingRefusal,
26
+ type RoutingReport,
27
+ scanRouting,
28
+ } from '@/claude/routing'
24
29
  import { type DriftReport, readDrift } from '@/claude/skills-drift'
25
30
  import { listSkills } from '@/claude/skills-list'
26
31
  import {
@@ -79,6 +84,10 @@ interface SkillsReachOptions {
79
84
  readonly json?: boolean
80
85
  }
81
86
 
87
+ interface RoutingOptions {
88
+ readonly json?: boolean
89
+ }
90
+
82
91
  const SEEDED_FILES: readonly string[] = [
83
92
  'ARCHITECTURE.md',
84
93
  'REQUIREMENTS.md',
@@ -91,7 +100,7 @@ const STATUSLINE = 'statusline-command.sh'
91
100
  export function register(program: Command): void {
92
101
  const claude = program
93
102
  .command('claude')
94
- .description('Claude workflow (init, seeds, sync, setup)')
103
+ .description('Claude workflow (init, seeds, sync, setup, routing)')
95
104
  .helpOption('-h, --help', 'Show this help message')
96
105
  .addHelpText(
97
106
  'after',
@@ -167,6 +176,42 @@ export function register(program: Command): void {
167
176
  process.exitCode = await runSeedsList(opts)
168
177
  })
169
178
 
179
+ claude
180
+ .command('routing')
181
+ .description('Report per CLAUDE.md section how many bullets name a path')
182
+ .argument('[path]', 'Repository root, defaulting to the current directory')
183
+ .helpOption('-h, --help', 'Show this help message')
184
+ .option('--json', 'Add a machine-readable record on stdout')
185
+ .addHelpText(
186
+ 'after',
187
+ [
188
+ '',
189
+ 'Scope:',
190
+ ' Every H2 and H3 in CLAUDE.md that owns at least one top-level',
191
+ ' bullet, counted against the path-scoped rules under .claude/rules/.',
192
+ ' A bullet is path-scoped here when it names a path, which is not the',
193
+ " same as firing only on one. A rule's glob covers a named folder",
194
+ ' only when the glob is anchored to a location rather than to a file',
195
+ ' type, so **/*.md covers README.md and no folder at all.',
196
+ '',
197
+ 'Exit codes:',
198
+ ' 0 the file was read',
199
+ ' 1 refused, with the reason on stderr',
200
+ '',
201
+ 'Reports rather than gates. Whether a bullet belongs in a rule is the',
202
+ 'judgment 592-claude-md states, and naming a path is evidence for it',
203
+ 'rather than the answer.',
204
+ '',
205
+ 'Examples:',
206
+ ' aitk claude routing',
207
+ ' aitk claude routing --json',
208
+ '',
209
+ ].join('\n'),
210
+ )
211
+ .action((path: string | undefined, opts: RoutingOptions) => {
212
+ process.exitCode = runRouting(path, opts)
213
+ })
214
+
170
215
  const skills = claude
171
216
  .command('skills')
172
217
  .description('Plugin skill catalog (list, audit, drift, reach)')
@@ -612,6 +657,90 @@ function reportSkew(skew: SkewReport): void {
612
657
  else logInfo(describeSkew(skew))
613
658
  }
614
659
 
660
+ /** What a reader does about each way the reading cannot be taken. */
661
+ const ROUTING_REFUSALS: Record<RoutingRefusal, string> = {
662
+ 'no-claude-md': 'No CLAUDE.md here, so this tree has no always-loaded file.',
663
+ 'no-rules':
664
+ 'No path-scoped rules under .claude/rules/, so nothing covers a path yet.',
665
+ }
666
+
667
+ /**
668
+ * Measures the cwd rather than the toolkit root, matching the reach and drift
669
+ * verbs, so a linked worktree reads its own branch instead of `main`.
670
+ */
671
+ function runRouting(path: string | undefined, opts: RoutingOptions): number {
672
+ const root = resolve(path ?? process.cwd())
673
+ const report = scanRouting(root)
674
+
675
+ if (report.kind === 'refused') {
676
+ frameError(ROUTING_REFUSALS[report.reason])
677
+ if (opts.json) {
678
+ process.stdout.write(
679
+ `${JSON.stringify({
680
+ root,
681
+ reason: report.reason,
682
+ message: ROUTING_REFUSALS[report.reason],
683
+ })}\n`,
684
+ )
685
+ }
686
+ return 1
687
+ }
688
+
689
+ intro('aitk claude routing')
690
+ reportRouting(report)
691
+ outro()
692
+
693
+ if (opts.json) {
694
+ process.stdout.write(
695
+ `${JSON.stringify({
696
+ root,
697
+ rules: report.rules,
698
+ sections: report.sections,
699
+ })}\n`,
700
+ )
701
+ }
702
+
703
+ return 0
704
+ }
705
+
706
+ /**
707
+ * States the corpus on every run, so a section naming no path reads as
708
+ * measured rather than as skipped. A reader deciding what to cut needs the
709
+ * sections that stay as much as the ones that move.
710
+ */
711
+ function reportRouting(
712
+ report: Extract<RoutingReport, { kind: 'measured' }>,
713
+ ): void {
714
+ const bullets = report.sections.reduce(
715
+ (total, section) => total + section.bullets,
716
+ 0,
717
+ )
718
+ const pathScoped = report.sections.reduce(
719
+ (total, section) => total + section.pathScoped,
720
+ 0,
721
+ )
722
+
723
+ logStep('Corpus')
724
+ logInfo(
725
+ `${plural(report.sections.length, 'section')} carrying ${plural(bullets, 'bullet')}, read against ${plural(report.rules, 'path-scoped rule')}`,
726
+ )
727
+
728
+ logStep('Sections')
729
+ logInfo(`${pathScoped} of ${bullets} bullets name a path`)
730
+ pipeOutput(
731
+ report.sections
732
+ .map(
733
+ (section) =>
734
+ `${section.pathScoped}/${section.bullets} path-scoped, ${section.covered} covered ${section.heading}${
735
+ section.uncovered.length === 0
736
+ ? ''
737
+ : ` [uncovered: ${section.uncovered.join(', ')}]`
738
+ }`,
739
+ )
740
+ .join('\n'),
741
+ )
742
+ }
743
+
615
744
  /** What a reader does about the one way the corpus fails to build. */
616
745
  const REACH_REFUSALS: Record<ReachRefusal, string> = {
617
746
  'no-skills':
@@ -14,6 +14,15 @@ import {
14
14
  mergeExtraRules,
15
15
  resolveRules,
16
16
  } from '@/gov/stacks'
17
+ import {
18
+ INSTRUCTIONS_REL,
19
+ type RestatedEntry,
20
+ type RestatedRefusal,
21
+ type RestatedReport,
22
+ readRestated,
23
+ SEED_REL,
24
+ SHIPPED_SKILLS_REL,
25
+ } from '@/gov/restated'
17
26
  import {
18
27
  readSuperseded,
19
28
  SUPERSEDED_MARKER,
@@ -68,6 +77,17 @@ interface SupersededOptions {
68
77
  readonly json?: boolean
69
78
  }
70
79
 
80
+ interface RestatedOptions {
81
+ readonly root?: string
82
+ readonly json?: boolean
83
+ }
84
+
85
+ /** What a reader does about each way the sweep produced no reading. */
86
+ const RESTATED_REFUSALS: Record<RestatedRefusal, string> = {
87
+ 'no-instructions': `No ${INSTRUCTIONS_REL} here, or it carries no bullet, so there is no instruction corpus to sweep.`,
88
+ 'no-surfaces': `Neither ${SEED_REL} nor ${SHIPPED_SKILLS_REL}/ is here, so no second surface exists to match against.`,
89
+ }
90
+
71
91
  export function register(program: Command): void {
72
92
  const gov = program
73
93
  .command('gov')
@@ -245,6 +265,146 @@ export function register(program: Command): void {
245
265
  process.exitCode = await runSuperseded(superseded, replacement, opts)
246
266
  },
247
267
  )
268
+
269
+ gov
270
+ .command('restated')
271
+ .description(
272
+ 'Report every instruction the always-loaded file states that a second surface states too',
273
+ )
274
+ .helpOption('-h, --help', 'Show this help message')
275
+ .option('--root <path>', 'Tree to read, defaulting to the cwd')
276
+ .option('--json', 'Add a machine-readable record on stdout')
277
+ .addHelpText(
278
+ 'after',
279
+ [
280
+ '',
281
+ `Matches every bullet in ${INSTRUCTIONS_REL} against ${SEED_REL}`,
282
+ `and every ${SHIPPED_SKILLS_REL}/*/SKILL.md body. Matching is recall-first,`,
283
+ 'keyed on distinctive tokens two statements share rather than on a phrase',
284
+ 'they spell the same way, because the case this exists for was one rule',
285
+ 'written three different ways.',
286
+ '',
287
+ 'What it separates:',
288
+ ' mirror a declared authoring-to-consumed pair, where repeating is the design',
289
+ ' repetition two surfaces state one rule and neither is declared a copy',
290
+ ' contradiction the prohibition falls on one surface alone, on a strong match',
291
+ '',
292
+ 'The contradiction class is a polarity reading rather than a judgment',
293
+ 'about meaning, so weigh each against the surfaces it names.',
294
+ '',
295
+ 'What it does not measure:',
296
+ ' a rule stated in two skill bodies and never in the always-loaded file,',
297
+ ' since the bullets there are the subjects and the bodies are searched',
298
+ '',
299
+ 'Exit codes:',
300
+ ' 0 no instruction is restated outside a declared mirror',
301
+ ' 1 refused, with the reason on stderr or in the JSON record',
302
+ ' 2 at least one instruction is restated outside a declared mirror',
303
+ '',
304
+ 'Examples:',
305
+ ' aitk gov restated',
306
+ ' aitk gov restated --json',
307
+ '',
308
+ ].join('\n'),
309
+ )
310
+ .action((opts: RestatedOptions) => {
311
+ process.exitCode = runRestated(opts)
312
+ })
313
+ }
314
+
315
+ /**
316
+ * Reports and never gates, matching the two sweeps above. A restatement is
317
+ * legitimate more often than not, so failing a push on one would fail on the
318
+ * ordinary case and teach contributors to route around the stage.
319
+ */
320
+ function runRestated(opts: RestatedOptions): number {
321
+ const root = resolve(opts.root ?? process.cwd())
322
+ const report = readRestated(root)
323
+ const emitJson = opts.json ?? false
324
+
325
+ if (report.kind === 'unreadable') {
326
+ intro('aitk gov restated')
327
+ logStep('Refused')
328
+ logWarn(RESTATED_REFUSALS[report.reason])
329
+ outro()
330
+
331
+ if (emitJson) {
332
+ process.stdout.write(
333
+ `${JSON.stringify({
334
+ root,
335
+ reason: report.reason,
336
+ message: RESTATED_REFUSALS[report.reason],
337
+ })}\n`,
338
+ )
339
+ }
340
+
341
+ return 1
342
+ }
343
+
344
+ reportRestated(report, root)
345
+
346
+ if (emitJson) {
347
+ process.stdout.write(`${JSON.stringify({ root, ...report })}\n`)
348
+ }
349
+
350
+ const findings = report.counts.contradictions + report.counts.repetitions
351
+ return findings > 0 ? 2 : 0
352
+ }
353
+
354
+ function describeEntry(entry: RestatedEntry): string[] {
355
+ const lines = [`${entry.subject.file}:${entry.subject.line}`]
356
+
357
+ for (const surface of entry.surfaces) {
358
+ lines.push(
359
+ ` [${surface.restatement}] ${surface.file}:${surface.line} via ${surface.anchors.join(', ')}`,
360
+ )
361
+ }
362
+
363
+ return lines
364
+ }
365
+
366
+ function reportRestated(
367
+ report: Extract<RestatedReport, { kind: 'measured' }>,
368
+ root: string,
369
+ ): void {
370
+ intro('aitk gov restated')
371
+
372
+ // A count of what matched reads as a verdict on the repository unless the run
373
+ // also says how wide the corpus behind it was.
374
+ logStep('Corpus')
375
+ logInfo(
376
+ `${report.corpus.instructions} instruction(s) against ${report.corpus.candidates} statement(s) from the seed and ${report.corpus.bodies} shipped body/bodies in ${root}`,
377
+ )
378
+ logInfo(
379
+ `matched on ${report.matcher.anchors} weighted anchor(s), dropping any token in more than ${report.matcher.common} statements`,
380
+ )
381
+
382
+ // Named rather than counted. A polarity split is the only class claiming a
383
+ // defect, and a reader weighing one has to reach both surfaces.
384
+ logStep(
385
+ report.counts.contradictions === 0 ? 'No contradiction' : 'Contradictions',
386
+ )
387
+ if (report.counts.contradictions === 0) {
388
+ logInfo('no restatement puts a prohibition on one surface alone')
389
+ } else {
390
+ for (const entry of report.restatements) {
391
+ const carries = entry.surfaces.some(
392
+ (surface) => surface.restatement === 'contradiction',
393
+ )
394
+ if (!carries) continue
395
+ for (const line of describeEntry(entry)) logWarn(line)
396
+ }
397
+ }
398
+
399
+ logStep('Restated')
400
+ logInfo(
401
+ `${report.counts.repetitions} repetition(s) outside a declared mirror, and ${report.counts.mirrors} on one`,
402
+ )
403
+ logInfo(
404
+ `${report.counts.threeSurface} instruction(s) reach three surfaces or more`,
405
+ )
406
+
407
+ outro()
248
408
  }
249
409
 
250
410
  /**