@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.
package/src/git-files.ts CHANGED
@@ -1,6 +1,80 @@
1
1
  import { $ } from 'bun'
2
2
  import { gitEnv } from '@/git-env'
3
3
 
4
+ /**
5
+ * Preferred first, matching `src/gov/test-order.ts` and `src/tasks/trunk.ts`. A
6
+ * local `main` trailing the remote pulls other people's merged commits into the
7
+ * range, so a check reading it decides against files the branch never touched.
8
+ */
9
+ const TRUNK_REFS = ['origin/main', 'main'] as const
10
+
11
+ /** Runs git under `root` with the resolution variables a hook exports stripped. */
12
+ async function git(
13
+ root: string,
14
+ args: readonly string[],
15
+ ): Promise<string | undefined> {
16
+ const result = await $`git -C ${root} ${args}`.env(gitEnv()).quiet().nothrow()
17
+ return result.exitCode === 0 ? result.text().trimEnd() : undefined
18
+ }
19
+
20
+ /**
21
+ * The far side of a branch range: the ref a caller named, or the merge base
22
+ * against the first trunk this repository carries.
23
+ *
24
+ * A named ref that resolves to nothing refuses rather than falling back, since
25
+ * measuring the trunk range instead would answer a question nobody asked.
26
+ */
27
+ export async function resolveBaseRef(
28
+ root: string,
29
+ ref?: string,
30
+ ): Promise<string | undefined> {
31
+ if (ref !== undefined) {
32
+ const resolved = await git(root, [
33
+ 'rev-parse',
34
+ '--verify',
35
+ '--quiet',
36
+ `${ref}^{commit}`,
37
+ ])
38
+ return resolved === undefined || resolved === '' ? undefined : resolved
39
+ }
40
+
41
+ for (const trunk of TRUNK_REFS) {
42
+ const merged = await git(root, ['merge-base', 'HEAD', trunk])
43
+ if (merged !== undefined && merged !== '') return merged
44
+ }
45
+
46
+ return undefined
47
+ }
48
+
49
+ /**
50
+ * Every path a branch has touched since `base`: the diff against the working
51
+ * tree, plus untracked files git does not ignore.
52
+ *
53
+ * The working tree rather than `HEAD` on purpose. A check that runs before the
54
+ * branch is committed has to see the surface a session just added, and reading
55
+ * `HEAD` there returns a set the working tree has already moved past. Since the
56
+ * range is a superset of `base..HEAD`, a caller running after the commits still
57
+ * gets the whole branch.
58
+ *
59
+ * Returns undefined when git cannot answer, for the reason
60
+ * `listRepositoryFiles` states: an empty list reads as a clean branch.
61
+ */
62
+ export async function listChangedFiles(
63
+ root: string,
64
+ base: string,
65
+ ): Promise<string[] | undefined> {
66
+ const changed = await git(root, ['diff', '--name-only', base])
67
+ const untracked = await git(root, [
68
+ 'ls-files',
69
+ '--others',
70
+ '--exclude-standard',
71
+ ])
72
+ if (changed === undefined || untracked === undefined) return undefined
73
+
74
+ const paths = [...changed.split('\n'), ...untracked.split('\n')]
75
+ return [...new Set(paths.filter(Boolean))].sort()
76
+ }
77
+
4
78
  /**
5
79
  * Lists the files under `root`: tracked, plus untracked files git does not
6
80
  * ignore. The untracked half is what keeps a file added on this branch in scope
@@ -0,0 +1,82 @@
1
+ import { listChangedFiles, resolveBaseRef } from '@/git-files'
2
+ import { type Coverage, resolveCoverage } from '@/labels/coverage'
3
+ import { type MapRefusal, readLabelMap } from '@/labels/map'
4
+
5
+ /**
6
+ * Why an audit produced no reading.
7
+ *
8
+ * `no-map` travels through from the map reader and stays an answer rather than
9
+ * a fault. The git reasons are the opposite: a range this check asked for and
10
+ * could not get, which is a broken invocation rather than a project that
11
+ * declared nothing.
12
+ *
13
+ * A named ref that will not resolve is its own reason. Folding it into
14
+ * `no-base` sends the caller who already passed `--base` a message telling
15
+ * them to pass `--base`.
16
+ */
17
+ export type LabelAuditRefusal =
18
+ | MapRefusal
19
+ | 'no-base'
20
+ | 'bad-base'
21
+ | 'unreadable-changes'
22
+
23
+ export type LabelAudit =
24
+ | {
25
+ readonly kind: 'measured'
26
+ /** Absent when the caller supplied the changed set rather than a range. */
27
+ readonly base?: string
28
+ readonly changed: readonly string[]
29
+ readonly coverage: Coverage
30
+ }
31
+ | { readonly kind: 'refused'; readonly reason: LabelAuditRefusal }
32
+
33
+ export interface LabelAuditOptions {
34
+ /** Far side of the range, defaulting to the merge base against the trunk. */
35
+ readonly base?: string
36
+ /** A changed set the caller already holds, which skips git entirely. */
37
+ readonly paths?: readonly string[]
38
+ }
39
+
40
+ /**
41
+ * Resolves a changed set against the map a project declares, and reports both
42
+ * what it earns and what it leaves uncovered.
43
+ *
44
+ * One verb for two readers by design. `git-pr` reads the labels at open time
45
+ * and the audit aggregate reads the uncovered count, and a verb shaped for the
46
+ * first alone returns nothing the second can retain.
47
+ */
48
+ export async function auditLabels(
49
+ root: string,
50
+ options: LabelAuditOptions = {},
51
+ ): Promise<LabelAudit> {
52
+ const map = readLabelMap(root)
53
+ if (map.kind === 'refused') return { kind: 'refused', reason: map.reason }
54
+
55
+ if (options.paths !== undefined) {
56
+ return {
57
+ kind: 'measured',
58
+ changed: [...options.paths],
59
+ coverage: resolveCoverage(map, options.paths),
60
+ }
61
+ }
62
+
63
+ const base = await resolveBaseRef(root, options.base)
64
+ if (base === undefined) {
65
+ return {
66
+ kind: 'refused',
67
+ reason: options.base === undefined ? 'no-base' : 'bad-base',
68
+ }
69
+ }
70
+
71
+ const changed = await listChangedFiles(root, base)
72
+ if (changed === undefined) {
73
+ return { kind: 'refused', reason: 'unreadable-changes' }
74
+ }
75
+
76
+ return {
77
+ kind: 'measured',
78
+ base,
79
+ changed,
80
+ coverage: resolveCoverage(map, changed),
81
+ }
82
+ }
@@ -0,0 +1,79 @@
1
+ import type { LabelMap } from '@/labels/map'
2
+
3
+ /** A path a row leaves unlabelled on purpose, carrying the reason it gives. */
4
+ export interface DeclinedPath {
5
+ readonly path: string
6
+ readonly reason: string
7
+ }
8
+
9
+ export interface Coverage {
10
+ /** Distinct labels the whole set earns, ordered as the map declares them. */
11
+ readonly labels: readonly string[]
12
+ /**
13
+ * Paths a `[declined]` row covers, which are a decision rather than a gap.
14
+ *
15
+ * Kept apart from `uncovered` because the response to the two differs. A
16
+ * surface nobody has gotten to wants a row, and one somebody decided against
17
+ * wants nothing, so a report folding them together is useful about neither.
18
+ */
19
+ readonly declined: readonly DeclinedPath[]
20
+ /** Paths no row of either table reaches, which is the finding. */
21
+ readonly uncovered: readonly string[]
22
+ }
23
+
24
+ /**
25
+ * Prefix-anchored, matching what the map's own comment describes and what the
26
+ * census behind its 41 prefixes was measured against.
27
+ *
28
+ * A glob would reach every existing prefix and invalidate that measurement, so
29
+ * the rule stays a `startsWith` even where a glob would read more naturally.
30
+ */
31
+ function matches(path: string, prefixes: readonly string[]): boolean {
32
+ return prefixes.some((prefix) => path.startsWith(prefix))
33
+ }
34
+
35
+ /**
36
+ * Resolves what a changed set earns from a map, and what it leaves behind.
37
+ *
38
+ * One pass answers both readers. `git-pr` wants the labels to apply, and the
39
+ * audit wants the paths that earned none, and a function shaped for the first
40
+ * alone returns nothing the second can count.
41
+ */
42
+ export function resolveCoverage(
43
+ map: Extract<LabelMap, { kind: 'map' }>,
44
+ paths: readonly string[],
45
+ ): Coverage {
46
+ const earned = new Set<string>()
47
+ const declined: DeclinedPath[] = []
48
+ const uncovered: string[] = []
49
+
50
+ for (const path of paths) {
51
+ const labels = map.domains.filter((row) => matches(path, row.prefixes))
52
+
53
+ // A label wins over a decline. A path both tables claim already carries a
54
+ // subject, so reporting it as deliberately unlabelled would contradict the
55
+ // label the same run is about to apply.
56
+ if (labels.length > 0) {
57
+ for (const row of labels) earned.add(row.label)
58
+ continue
59
+ }
60
+
61
+ const row = map.declined.find((entry) => matches(path, entry.prefixes))
62
+ if (row !== undefined) {
63
+ declined.push({ path, reason: row.reason })
64
+ continue
65
+ }
66
+
67
+ uncovered.push(path)
68
+ }
69
+
70
+ return {
71
+ // Read back off the map rather than out of the set, so two runs over one
72
+ // branch produce one order and one string.
73
+ labels: map.domains
74
+ .map((row) => row.label)
75
+ .filter((label) => earned.has(label)),
76
+ declined,
77
+ uncovered,
78
+ }
79
+ }
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Where a project declares its pull request label map, spelled once.
6
+ *
7
+ * The file has already moved once, and that relocation rewrote every mention
8
+ * across four surfaces. Nothing in code spells it anywhere else, so the next
9
+ * move is one edit here rather than a sweep.
10
+ */
11
+ export const MAP_REL = join('.claude', 'aitk', 'pr-labels.toml')
12
+
13
+ /** A label name and the path prefixes that earn it, in the map's own order. */
14
+ export interface DomainRow {
15
+ readonly label: string
16
+ readonly prefixes: readonly string[]
17
+ }
18
+
19
+ /** A reason a path is deliberately unlabelled, and the prefixes it covers. */
20
+ export interface DeclinedRow {
21
+ readonly reason: string
22
+ readonly prefixes: readonly string[]
23
+ }
24
+
25
+ /**
26
+ * Why a map could not be read, which is never the same as a map with no rows.
27
+ *
28
+ * `no-map` is an answer rather than a fault. A project declaring no map is
29
+ * labelled silently by design, so a refusal read as a break would make the map
30
+ * mandatory for every target, which that decision declined. The other two are
31
+ * a file that exists and cannot be used, which is a defect in the map itself.
32
+ */
33
+ export type MapRefusal = 'no-map' | 'unreadable-map' | 'no-domains'
34
+
35
+ export type LabelMap =
36
+ | {
37
+ readonly kind: 'map'
38
+ readonly domains: readonly DomainRow[]
39
+ readonly declined: readonly DeclinedRow[]
40
+ }
41
+ | { readonly kind: 'refused'; readonly reason: MapRefusal }
42
+
43
+ /**
44
+ * Reads a TOML table of string arrays into rows, dropping any key whose value
45
+ * carries no usable prefix.
46
+ *
47
+ * A malformed row is skipped rather than refused, because both tables are
48
+ * authored by hand and one bad entry should not blind the check to the other
49
+ * forty. What it costs is that a typo reads as a row nobody wrote, which the
50
+ * uncovered report surfaces from the other side.
51
+ */
52
+ function readRows(table: unknown): { key: string; prefixes: string[] }[] {
53
+ if (typeof table !== 'object' || table === null || Array.isArray(table)) {
54
+ return []
55
+ }
56
+
57
+ const rows: { key: string; prefixes: string[] }[] = []
58
+ for (const [key, value] of Object.entries(table)) {
59
+ if (!Array.isArray(value)) continue
60
+ const prefixes = value.filter(
61
+ (entry): entry is string => typeof entry === 'string' && entry !== '',
62
+ )
63
+ if (prefixes.length > 0) rows.push({ key, prefixes })
64
+ }
65
+
66
+ return rows
67
+ }
68
+
69
+ /** Parses map text, so a caller holding the bytes skips the filesystem. */
70
+ export function parseLabelMap(source: string): LabelMap {
71
+ let parsed: Record<string, unknown>
72
+ try {
73
+ parsed = Bun.TOML.parse(source) as Record<string, unknown>
74
+ } catch {
75
+ return { kind: 'refused', reason: 'unreadable-map' }
76
+ }
77
+
78
+ const domains = readRows(parsed.domains)
79
+ if (domains.length === 0) return { kind: 'refused', reason: 'no-domains' }
80
+
81
+ return {
82
+ kind: 'map',
83
+ domains: domains.map(({ key, prefixes }) => ({ label: key, prefixes })),
84
+ declined: readRows(parsed.declined).map(({ key, prefixes }) => ({
85
+ reason: key,
86
+ prefixes,
87
+ })),
88
+ }
89
+ }
90
+
91
+ /** Reads the map a project declares at `root`, or says why it could not. */
92
+ export function readLabelMap(root: string): LabelMap {
93
+ let source: string
94
+ try {
95
+ source = readFileSync(join(root, MAP_REL), 'utf8')
96
+ } catch {
97
+ return { kind: 'refused', reason: 'no-map' }
98
+ }
99
+
100
+ return parseLabelMap(source)
101
+ }
package/src/sync/check.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { basename, join, sep } from 'node:path'
3
3
  import { execa } from 'execa'
4
- import { createGovAdapter } from '@/gov/adapter'
4
+ import { gitEnv } from '@/git-env'
5
+ import { createGovAdapter, rulesSourceDir } from '@/gov/adapter'
6
+ import { loadGovStack } from '@/gov/stacks'
5
7
  import { createSnippetsAdapter } from '@/snippets/adapter'
6
8
  import { planSync, type ScanEntry, type SyncAdapter } from '@/sync/engine'
7
9
  import {
@@ -154,6 +156,13 @@ export interface CheckReport {
154
156
  readonly superseded: readonly SupersededEntry[]
155
157
  readonly unmigrated: readonly UnmigratedDomain[]
156
158
  readonly newSkills: readonly string[]
159
+ /**
160
+ * Rules the toolkit authored after this target's governance anchor, filtered
161
+ * to what the target could receive. It rides beside `newSkills` rather than
162
+ * folding into it because the two answer the same question about different
163
+ * corpora and a reader acting on one runs a different command from the other.
164
+ */
165
+ readonly newRules: readonly string[]
157
166
  /**
158
167
  * The one section built by walking the target rather than the catalog. It
159
168
  * reports beside `superseded`, `unmigrated`, and `newSkills` rather than
@@ -336,6 +345,7 @@ export async function buildCheckReport(
336
345
  superseded: [],
337
346
  unmigrated: [],
338
347
  newSkills: [],
348
+ newRules: [],
339
349
  reverse: emptyReverseReport(),
340
350
  skew: await skewRead,
341
351
  }
@@ -350,6 +360,11 @@ export async function buildCheckReport(
350
360
  superseded: collectSuperseded(target),
351
361
  unmigrated,
352
362
  newSkills: await readNewSkills(toolkitRoot, anchors),
363
+ newRules: await readNewRules(
364
+ toolkitRoot,
365
+ target,
366
+ stampedCommit(stamp, 'governance'),
367
+ ),
353
368
  reverse: buildReverseReport(toolkitRoot, target),
354
369
  skew: await skewRead,
355
370
  }
@@ -411,6 +426,145 @@ export function parseNewSkills(paths: string): string[] {
411
426
  return [...new Set(names)].sort()
412
427
  }
413
428
 
429
+ /**
430
+ * What the target's `.claude/rules/` proves about its entitlement. `held` is
431
+ * every rule name it carries and `bands` every band folder those names sit in.
432
+ *
433
+ * Read off the installed tree rather than off a stack name, because
434
+ * `gov install` records file hashes and never the stack it resolved, so the
435
+ * chain a target consumed survives nowhere else. `installRules` copies each
436
+ * rule into the subdirectory it was authored in, which is what makes the band
437
+ * folders legible as evidence.
438
+ */
439
+ export function readInstalledRules(target: string): {
440
+ held: Set<string>
441
+ bands: Set<string>
442
+ } {
443
+ const dir = join(target, ...INSTALL_MARKERS.governance)
444
+ const held = new Set<string>()
445
+ const bands = new Set<string>()
446
+ if (!isDirectory(dir)) return { held, bands }
447
+
448
+ for (const rel of new Bun.Glob('**/*.md').scanSync({
449
+ cwd: dir,
450
+ onlyFiles: true,
451
+ dot: true,
452
+ })) {
453
+ const posix = rel.split(sep).join('/')
454
+ const boundary = posix.indexOf('/')
455
+
456
+ held.add(basename(posix, '.md'))
457
+ if (boundary > 0) bands.add(posix.slice(0, boundary))
458
+ }
459
+
460
+ return { held, bands }
461
+ }
462
+
463
+ /**
464
+ * Narrows rules added upstream to the ones this target could receive and does
465
+ * not already hold.
466
+ *
467
+ * The band test is the entitlement filter, and `bands` carries two sources: the
468
+ * folders the target already holds, and the folders the base stack takes whole.
469
+ * A rule authored under `lang/` or `ui/` is named by an individual stack, so it
470
+ * belongs to some targets and not others, and listing every added file would
471
+ * tell a base consumer about rules it was never entitled to. The test
472
+ * over-reports inside a band a target already carries, since one folder can be
473
+ * reached by more than one stack, and that costs a line where under-reporting
474
+ * would cost the whole point of the section.
475
+ *
476
+ * The `held` test is what keeps a rule that moved bands upstream out. A rename
477
+ * reaches this diff as an addition, and the target already has the file under
478
+ * its old folder, so matching by name is what tells the two apart.
479
+ */
480
+ export function selectNewRules(
481
+ paths: string,
482
+ held: ReadonlySet<string>,
483
+ bands: ReadonlySet<string>,
484
+ ): string[] {
485
+ const prefix = 'governance/rules/'
486
+ const names = new Set<string>()
487
+
488
+ for (const line of paths.split('\n')) {
489
+ const trimmed = line.trim()
490
+ if (!trimmed.startsWith(prefix) || !trimmed.endsWith('.md')) continue
491
+
492
+ const rel = trimmed.slice(prefix.length)
493
+ const boundary = rel.indexOf('/')
494
+ const band = boundary === -1 ? '' : rel.slice(0, boundary)
495
+ const name = basename(rel, '.md')
496
+
497
+ if (held.has(name)) continue
498
+ if (band !== '' && !bands.has(band)) continue
499
+
500
+ names.add(name)
501
+ }
502
+
503
+ return [...names].sort()
504
+ }
505
+
506
+ /**
507
+ * Band folders the base stack takes whole. Every governance stack extends base,
508
+ * so a rule authored under one of these is entitled to every target.
509
+ *
510
+ * This is what covers a band no target can carry yet. Entitlement is otherwise
511
+ * read off folders the target already holds, and a folder added to base later
512
+ * exists in no installed tree, so without this the rules inside it would reach
513
+ * nobody. Read from the stack file rather than fixed, so that addition needs no
514
+ * code change here.
515
+ *
516
+ * A folder only a leaf stack names is deliberately absent. It is entitled to
517
+ * some targets and not others, which is the distinction the band test makes and
518
+ * the installed tree is the only evidence of.
519
+ */
520
+ export function baseBands(root: string): Set<string> {
521
+ const stack = loadGovStack(root, 'base')
522
+ if (stack === undefined) return new Set()
523
+
524
+ return new Set(
525
+ stack.rules.filter((entry) =>
526
+ isDirectory(join(rulesSourceDir(root), entry)),
527
+ ),
528
+ )
529
+ }
530
+
531
+ /**
532
+ * Rules are domain-scoped, so this measures from governance's own anchor rather
533
+ * than from the oldest anchor across domains the way `readNewSkills` does. A
534
+ * shared anchor would let a snippets sync move the revision rules are measured
535
+ * from and drop a rule out of the read.
536
+ *
537
+ * A target carrying no governance anchor reports nothing. It has no date to
538
+ * measure against, and diffing from the beginning of history would read every
539
+ * rule the toolkit ships as new.
540
+ *
541
+ * An anchor this clone cannot resolve reports nothing by a different route and
542
+ * says so nowhere. `read` yields an empty string on a non-zero exit, so a stamp
543
+ * naming a revision a registry install or a shallow clone has never seen reads
544
+ * as a target holding everything. `readNewSkills` carries the same gap, and
545
+ * neither has the `historyUnavailable` flag the per-domain scan uses to tell an
546
+ * unmeasured result from a clean one.
547
+ */
548
+ export async function readNewRules(
549
+ root: string,
550
+ target: string,
551
+ since: string | undefined,
552
+ ): Promise<string[]> {
553
+ if (since === undefined) return []
554
+
555
+ const paths = await read(root, [
556
+ 'diff',
557
+ '--name-only',
558
+ '--diff-filter=A',
559
+ `${since}..HEAD`,
560
+ '--',
561
+ 'governance/rules/',
562
+ ])
563
+
564
+ const { held, bands } = readInstalledRules(target)
565
+ return selectNewRules(paths, held, bands.union(baseBands(root)))
566
+ }
567
+
414
568
  async function readUpstream(
415
569
  root: string,
416
570
  since: string,
@@ -474,7 +628,7 @@ async function isAncestor(
474
628
  const result = await execa(
475
629
  'git',
476
630
  ['-C', root, 'merge-base', '--is-ancestor', candidate, reference],
477
- { reject: false },
631
+ { reject: false, env: gitEnv(), extendEnv: false },
478
632
  )
479
633
 
480
634
  return result.exitCode === 0
@@ -483,9 +637,18 @@ async function isAncestor(
483
637
  /**
484
638
  * A toolkit outside a git clone, or a stamped revision this clone has never
485
639
  * seen, yields no range. The per-file report still stands on its own.
640
+ *
641
+ * Scrubbed through `gitEnv` because a git hook exports `GIT_DIR` and its
642
+ * siblings into every process it runs and they outrank `-C`. A check invoked
643
+ * from a hook would otherwise diff the hook's repository and report a range for
644
+ * a tree nobody asked about, which reads as an ordinary answer.
486
645
  */
487
646
  async function read(root: string, args: readonly string[]): Promise<string> {
488
- const result = await execa('git', ['-C', root, ...args], { reject: false })
647
+ const result = await execa('git', ['-C', root, ...args], {
648
+ reject: false,
649
+ env: gitEnv(),
650
+ extendEnv: false,
651
+ })
489
652
  return result.exitCode === 0 ? result.stdout : ''
490
653
  }
491
654