@erclx/aitk 3.48.1 → 3.50.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.
@@ -30,6 +30,14 @@ import {
30
30
  SEED_REL,
31
31
  SHIPPED_SKILLS_REL,
32
32
  } from '@/gov/restated'
33
+ import {
34
+ CITATION_MARKER,
35
+ type CitationReport,
36
+ GLOB_CORPUS,
37
+ readCitations,
38
+ RULE_DIRS,
39
+ type RuleCitation,
40
+ } from '@/gov/citations'
33
41
  import {
34
42
  readSuperseded,
35
43
  SUPERSEDED_MARKER,
@@ -85,6 +93,11 @@ interface SupersededOptions {
85
93
  readonly json?: boolean
86
94
  }
87
95
 
96
+ interface CitationsOptions {
97
+ readonly root?: string
98
+ readonly json?: boolean
99
+ }
100
+
88
101
  interface RestatedOptions {
89
102
  readonly root?: string
90
103
  readonly json?: boolean
@@ -348,6 +361,86 @@ export function register(program: Command): void {
348
361
  },
349
362
  )
350
363
 
364
+ gov
365
+ .command('citations')
366
+ .description(
367
+ 'Resolve every path a rule cites and every internal frontmatter glob, naming the ones reaching nothing',
368
+ )
369
+ .helpOption('-h, --help', 'Show this help message')
370
+ .option('--root <path>', 'Tree to read, defaulting to the cwd')
371
+ .option('--json', 'Add a machine-readable record on stdout')
372
+ .addHelpText(
373
+ 'after',
374
+ [
375
+ '',
376
+ `Reads bodies across ${RULE_DIRS.join(' and ')}, and frontmatter globs`,
377
+ `under ${GLOB_CORPUS} alone. A rule citing a file that moved fails`,
378
+ 'silently: nothing resolves the path until a session opens it, and the',
379
+ 'drift check beside this one passes an authored rule and its consumed',
380
+ 'copy that are wrong together. A rule whose glob names a directory that',
381
+ 'moved fails the same way, by never firing again.',
382
+ '',
383
+ 'This gates, unlike the superseded sweep beside it. A path resolving to',
384
+ 'nothing carries no judgment, and the classes where absence is correct',
385
+ 'are separated before the verdict rather than left for a reader.',
386
+ '',
387
+ `Frontmatter globs read under ${GLOB_CORPUS} and nowhere else. A rule`,
388
+ `under ${RULE_DIRS[0]} installs into a target and its paths: entries`,
389
+ "name that project's shape, so src/pages/** in the Astro rule cannot be",
390
+ 'told by pattern from a path here. Measured over that corpus, 32 of 72',
391
+ 'globs match nothing in this tree and every one of them is correct, so',
392
+ 'gating them would ship an exemption list the length of the corpus. The',
393
+ 'internal corpus ships nowhere, which makes the tree it governs the tree',
394
+ 'present and the question answerable.',
395
+ '',
396
+ 'Forms read, with where each resolves:',
397
+ ' standard aitk standards <name> , against standards/ alone, which',
398
+ ' is where the verb itself looks. internal/standards/ is not',
399
+ ' tried, since a name resolving only there refuses for the',
400
+ ' session that opens the citation.',
401
+ ' path a backticked path carrying a directory segment and a file',
402
+ ' extension, against the root',
403
+ " sibling a bare <nnn>-<slug>.md , against the citing rule's folder",
404
+ '',
405
+ 'Not read, each a shape the corpus writes and none of them a citation:',
406
+ ' a placeholder or glob segment, which describes a shape rather than',
407
+ ' naming a file, such as .claude/context/<domain>.md or app/**/route.ts',
408
+ ' a bare filename naming a convention, such as route.ts or manifest.toml',
409
+ ' a span carrying no file extension, which is a folder or a module',
410
+ ' specifier, such as src/pages/ , next/font , or claude/standards',
411
+ ' a fenced block, which displays a path rather than pointing at one',
412
+ '',
413
+ 'Exempt, reported by name rather than dropped:',
414
+ ' governed the citing rule spells the path in its own frontmatter, so',
415
+ ' it names a target artifact rather than a file here. A glob',
416
+ ' never exempts, since a typo inside one is the defect.',
417
+ ' ignored git ignores the path, which is session scratch a clone is',
418
+ ' not expected to hold',
419
+ ` exempt the line carries a \`${CITATION_MARKER}: <reason>\` marker`,
420
+ ' on itself or the one above',
421
+ '',
422
+ 'Blind spots: a citation that resolves and points at the wrong file, a',
423
+ 'path written without backticks, a folder or a path carrying no extension,',
424
+ 'which this declines rather than guesses at, and a glob that matches real',
425
+ 'files while reaching none of the work it was scoped at, which is a',
426
+ 'reading rather than a resolution.',
427
+ '',
428
+ 'Exit codes:',
429
+ ' 0 every citation resolves or is exempt, and every glob read matches',
430
+ ' 1 refused, with the reason on stderr or in the JSON record',
431
+ ' 2 at least one cited path resolves to nothing, or one glob matches',
432
+ ' nothing in this tree',
433
+ '',
434
+ 'Examples:',
435
+ ' aitk gov citations',
436
+ ' aitk gov citations --json',
437
+ '',
438
+ ].join('\n'),
439
+ )
440
+ .action(async (opts: CitationsOptions) => {
441
+ process.exitCode = await runCitations(opts)
442
+ })
443
+
351
444
  gov
352
445
  .command('restated')
353
446
  .description(
@@ -529,6 +622,160 @@ async function runSuperseded(
529
622
  return report.findings.length > 0 ? 2 : 0
530
623
  }
531
624
 
625
+ /**
626
+ * Gates, unlike `superseded` and `test-order` above. The classes where a path
627
+ * reaching nothing is correct are separated inside the sweep, which is what
628
+ * leaves the remainder a defect with no judgment in it, so a finding fails the
629
+ * push rather than asking a reader to weigh it.
630
+ */
631
+ async function runCitations(opts: CitationsOptions): Promise<number> {
632
+ const root = resolve(opts.root ?? process.cwd())
633
+ const report = await readCitations(root)
634
+ const emitJson = opts.json ?? false
635
+
636
+ if (report.kind === 'unreadable') {
637
+ intro('aitk gov citations')
638
+ logStep('Refused')
639
+ logError(report.reason)
640
+ outro()
641
+
642
+ if (emitJson) {
643
+ process.stdout.write(
644
+ `${JSON.stringify({ ok: false, reason: report.reason })}\n`,
645
+ )
646
+ }
647
+
648
+ return 1
649
+ }
650
+
651
+ const dead = report.citations.filter((citation) => citation.status === 'dead')
652
+ const unmatched = report.globs.filter((glob) => !glob.matched)
653
+ reportCitations(report, root)
654
+
655
+ if (emitJson) {
656
+ process.stdout.write(`${JSON.stringify({ ok: true, root, ...report })}\n`)
657
+ }
658
+
659
+ return dead.length + unmatched.length > 0 ? 2 : 0
660
+ }
661
+
662
+ function describeCitation(citation: RuleCitation): string {
663
+ const tried = citation.candidates.join(', ')
664
+ return `[${citation.form}] ${citation.file}:${citation.line}: ${citation.cited} reaches nothing at ${tried}`
665
+ }
666
+
667
+ /**
668
+ * Named rather than counted, matching the exempt section of the sweep beside
669
+ * this one. An exemption is a judgment the tree recorded, and a reader weighing
670
+ * this report has to be able to reach the line that carries it.
671
+ */
672
+ function reportExcused(
673
+ citations: readonly RuleCitation[],
674
+ status: RuleCitation['status'],
675
+ label: string,
676
+ empty: string,
677
+ ): void {
678
+ const excused = citations.filter((citation) => citation.status === status)
679
+
680
+ logStep(label)
681
+ if (excused.length === 0) {
682
+ logInfo(empty)
683
+ return
684
+ }
685
+
686
+ for (const citation of excused) {
687
+ logInfo(`${citation.file}:${citation.line}: ${citation.cited}`)
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Names the corpus on every run, clean or not.
693
+ *
694
+ * A section reporting that every glob resolves, over a corpus the reader
695
+ * assumes is both of them, says the shipped rules were checked and they were
696
+ * not. The scope is the finding here as much as the count is.
697
+ */
698
+ function reportGlobs(
699
+ report: Extract<CitationReport, { kind: 'measured' }>,
700
+ ): void {
701
+ const unmatched = report.globs.filter((glob) => !glob.matched)
702
+
703
+ logStep('Frontmatter globs')
704
+ logInfo(`${report.globs.length} globs read, under ${GLOB_CORPUS} alone`)
705
+
706
+ if (unmatched.length === 0) {
707
+ logInfo('every glob matches a file in this tree')
708
+ } else {
709
+ for (const glob of unmatched) {
710
+ logError(
711
+ `${glob.file}:${glob.line}: ${glob.glob} matches nothing, so the rule never fires`,
712
+ )
713
+ }
714
+ }
715
+
716
+ logInfo(
717
+ `not read: a glob under ${RULE_DIRS[0]}, which names the shape a target holds rather than this tree, and a glob matching real files while reaching none of the work it was scoped at`,
718
+ )
719
+ }
720
+
721
+ function reportCitations(
722
+ report: Extract<CitationReport, { kind: 'measured' }>,
723
+ root: string,
724
+ ): void {
725
+ intro('aitk gov citations')
726
+
727
+ logStep('Sweep')
728
+ logInfo(`${report.rules} rules under ${RULE_DIRS.join(' and ')} in ${root}`)
729
+
730
+ const counted = new Map<RuleCitation['form'], number>()
731
+ for (const citation of report.citations) {
732
+ counted.set(citation.form, (counted.get(citation.form) ?? 0) + 1)
733
+ }
734
+ logInfo(
735
+ report.citations.length === 0
736
+ ? 'no citation in either corpus'
737
+ : `${report.citations.length} citations: ${[...counted]
738
+ .map(([form, count]) => `${count} ${form}`)
739
+ .join(', ')}`,
740
+ )
741
+
742
+ const dead = report.citations.filter((citation) => citation.status === 'dead')
743
+
744
+ logStep('Unresolved')
745
+ if (dead.length === 0) {
746
+ logInfo('every cited path resolves')
747
+ } else {
748
+ for (const citation of dead) logError(describeCitation(citation))
749
+ }
750
+
751
+ reportGlobs(report)
752
+
753
+ reportExcused(
754
+ report.citations,
755
+ 'governed',
756
+ 'Governed',
757
+ 'no rule cites a path it declares in its own frontmatter',
758
+ )
759
+ reportExcused(
760
+ report.citations,
761
+ 'ignored',
762
+ 'Ignored',
763
+ 'no rule cites a path git ignores',
764
+ )
765
+ reportExcused(
766
+ report.citations,
767
+ 'exempt',
768
+ 'Exempt',
769
+ `no line carries a ${CITATION_MARKER} marker`,
770
+ )
771
+
772
+ logInfo(
773
+ 'not read: a citation that resolves and points at the wrong file, a path written without backticks, and a folder or a path carrying no extension',
774
+ )
775
+
776
+ outro()
777
+ }
778
+
532
779
  function describeHit(hit: SupersededHit): string {
533
780
  const note = hit.carriesReplacement
534
781
  ? ' (the replacement is on this line)'