@erclx/aitk 3.1.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,7 +1,7 @@
1
1
  {
2
2
  "name": "aitk",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
@@ -84,9 +84,23 @@ A detached HEAD gives `git branch --show-current` an empty result, which would r
84
84
 
85
85
  ### Labels
86
86
 
87
- Read `.claude/aitk/pr-labels.toml` from the project root. A project that has not declared a map gets no labels and no warning, since a label set this skill supplied would be a guess about that project's surfaces.
87
+ Ask the CLI first:
88
88
 
89
- When the file resolves, match it against the name-only diff per `${CLAUDE_SKILL_DIR}/references/labels.md` and write the comma-separated result into `pr_labels` below. Leave `pr_labels` empty when no map resolves or no prefix matches, which skips the labelling command rather than running it against nothing.
89
+ ```bash
90
+ aitk labels audit --base <base> --json
91
+ ```
92
+
93
+ The record carries `labels`, the set this branch earns, and `uncovered`, the changed paths no row of the map reaches. Join `labels` with commas into `pr_labels` below. Report each `uncovered` path beside the result line, naming the map so the reader knows where a row would go, since a surface nobody covered merges bare and nothing else says so.
94
+
95
+ Branch on the record rather than on the exit. An operator's shell profile may wrap `aitk` in a function whose status comes from a trailing command, and the binary exits 1 for an unknown subcommand and 1 for an ordinary refusal alike.
96
+
97
+ A `reason` of `no-map` is the answer that the project declared no map, which earns no labels and no warning: a label set this skill supplied would be a guess about that project's surfaces. Stop there and label nothing.
98
+
99
+ Every other `reason` is a map or a range the verb could not read, which is `unreadable-map`, `no-domains`, `no-base`, and `unreadable-changes`, plus `bad-base` for a ref this skill resolved wrongly. Take the fallback below and warn beside the result line, naming the reason. A map with a typo in it still has rows a prefix match can reach, and reading the refusal as an absence would open the pull request with no labels and nothing said, which is the surface merging bare that the verb exists to name.
100
+
101
+ The fallback is reading `.claude/aitk/pr-labels.toml` and matching it against the name-only diff per `${CLAUDE_SKILL_DIR}/references/labels.md`. It also covers no record coming back at all, which is an installed `aitk` predating the verb, since a skill reaches a target the moment it merges while the CLI reaches one only when a release publishes. The fallback labels correctly and reports no uncovered path, which is the half only the verb carries.
102
+
103
+ Leave `pr_labels` empty when no map resolves or no prefix matches, which skips the labelling command rather than running it against nothing.
90
104
 
91
105
  ### Final command
92
106
 
@@ -29,11 +29,35 @@ A label takes more than one prefix when two folders read as one surface. Two lab
29
29
 
30
30
  Matching is prefix-anchored, so a row written for an authoring root reaches nothing under the copy a project consumes. A surface living only under a dotted folder carries its own prefix on the row that owns its subject, and a folder holding several subjects rather than one splits across the rows that own them.
31
31
 
32
- The map is authored by hand and nothing detects a directory it fails to cover, so a surface added after the map was written labels nothing until someone adds a row. A map that has been censused against its own history says in its comment where that check is owned, so a reader meeting the gap is sent somewhere rather than left with the prediction.
32
+ The map is authored by hand, so a surface added after it was written labels nothing until someone adds a row. `aitk labels audit` is what names that surface before the branch merges.
33
33
 
34
34
  ## Paths a map declines to label
35
35
 
36
- A path that moves only when a release or a generator rewrites it earns no row. Release automation applies its own label, and a domain label on a generated file gives a mechanical edit a subject it does not have. Neither is covered, so both are recorded in the map's comment with the reason, which is what separates a path nobody has gotten to from one somebody decided against.
36
+ A path that moves only when a release or a generator rewrites it earns no row. Release automation applies its own label, and a domain label on a generated file gives a mechanical edit a subject it does not have.
37
+
38
+ Both go under a `[declined]` table keyed by the reason, in the same file and matched by the same prefix rule:
39
+
40
+ ```toml
41
+ [declined]
42
+ release-managed = ["CHANGELOG.md", "package.json"]
43
+ generated = ["build/manifest.json"]
44
+ ```
45
+
46
+ A table rather than a comment because the check reads it. A path here is a decision already taken and a path under neither table is a gap nobody has gotten to, and a report that could not tell those apart would be useful about neither.
47
+
48
+ A path claimed by a `[domains]` row and a `[declined]` row takes the label. It already has a subject, so reporting it as deliberately unlabelled would contradict the label the same run applies.
49
+
50
+ ## Reporting a surface no row reaches
51
+
52
+ ```bash
53
+ aitk labels audit --base <base> --json
54
+ ```
55
+
56
+ The record carries `labels`, `declined`, and `uncovered`. It exits 2 when `uncovered` has anything in it and 1 when it refuses, with `reason` naming the cause: `no-map` for a project that declared none, and a parse or range failure otherwise.
57
+
58
+ It reports and never gates. Whether an uncovered surface deserves a label is a judgment only the person who owns that surface can make, and a push failing on one teaches a contributor to route around the check while nothing about the surface has changed.
59
+
60
+ What it leaves unmeasured is a prefix reaching no path, so a row left behind by a deleted folder stays in the map. That is the map going stale from the other side and a second measure rather than this one.
37
61
 
38
62
  ## Matching
39
63
 
@@ -22,11 +22,11 @@ aitk audits list --json
22
22
 
23
23
  ## What it runs
24
24
 
25
- Fifteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
25
+ Sixteen verbs, listed by `aitk audits list`. Each runs once in its fullest form, and the aggregate reads that verb's own record rather than imposing a shared envelope on it. Every one of those records already has consumers naming its keys, so a common shape would be a breaking change bought for tidiness.
26
26
 
27
27
  The verbs walk separate trees and share no state, so they run together. Measured on the authoring machine at twelve verbs, a run finished in 0.8 seconds of wall clock against 4.4 seconds of processor, which is under every other stage in `bun run check`. `aitk deps audit` is the one that changes that reading, since it reaches a network rather than a tree and its latency is the index's rather than this machine's.
28
28
 
29
- Thirteen of the fifteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction.
29
+ Thirteen of the sixteen read a tree on this disk. The two added by `state-scoped-risk.md` read committed state rather than an arriving change, which is the gap every review surface here leaves by construction, and the one added by `label-coverage.md` reads a branch range against a map the project declares.
30
30
 
31
31
  Each is invoked as the CLI the caller is running rather than as a global `aitk`. A globally installed binary resolves to the main checkout no matter which worktree is executing, so the aggregate would measure a tree the branch never touched and report a pass over it.
32
32
 
@@ -50,6 +50,7 @@ Full help: `aitk <command> --help`. Behavior notes for the install and sync verb
50
50
  | `aitk gov test-order` | Report where an implementation reached history ahead of the test covering it (`--json`) |
51
51
  | `aitk secrets scan` | Report credential-shaped values in the tree the package ships, keyed on issued values rather than on words (`--json`) |
52
52
  | `aitk deps audit` | Report published advisories against the resolved dependency set, refusing rather than reporting clean when the index is unreachable (`--json`) |
53
+ | `aitk labels audit` | Report the labels a changed set earns from the pull request label map and the paths no row reaches (`--json`) |
53
54
  | `aitk audits run` | Run every audit as one set, report per check under one verdict, and compare each count to the recorded baseline (`--json`, `--record`) |
54
55
  | `aitk audits list` | List every audit the set runs, with the corpus each reads and whether it gates (`--json`) |
55
56
  | `aitk capture [source]` | Render HTML capture sources to PNG, toolkit-only and absent from an installed package |
@@ -77,6 +78,7 @@ Each domain exposes a consistent shape where applicable: `list`, `install`, `syn
77
78
  | `markdown` | `audit` |
78
79
  | `secrets` | `scan` |
79
80
  | `deps` | `audit` |
81
+ | `labels` | `audit` |
80
82
  | `audits` | `run`, `list` |
81
83
 
82
84
  Common patterns:
@@ -18,6 +18,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
18
18
  - [Indexes](indexes.md): Flags, exit codes, and JSON shape for aitk indexes regen, plus when it auto-stages what it rewrote
19
19
  - [Install and sync](install-and-sync.md): What each install and sync verb writes, refuses, or leaves alone, and how drift is attributed in a target project
20
20
  - [Intake](intake.md): Reading intake folder counts and items, the three read states an item can be in, landing a batch of selections in one cluster, the refusal reasons, and why a call is scoped to one file
21
+ - [Label coverage](label-coverage.md): Reading a changed set against the pull request label map, the two tables it matches, how a gap is separated from a decision, and why an absent map is an answer
21
22
  - [Markdown audit](markdown-audit.md): Running the audit over any markdown path, where its bans and checkpoints are read from, what each check reports, and why the ban half gates while the structural half reports
22
23
  - [Output shape](output-shape.md): Two framed shapes every command renders into, how JSON and --names modes keep stdout clean, and the exit discipline that lets piped output drain
23
24
  - [Overview](overview.md): What this folder covers, the invocation rules every command inherits, and where domain behavior is documented instead
@@ -138,11 +138,14 @@ edit and a job counting those stays red with no remedy.
138
138
 
139
139
  ### Surfaces reported beside the domains
140
140
 
141
- Four sections sit outside the per-domain scan, because each names something
142
- that walk cannot see. None of them produces a change, and no sync command
143
- applies any of them.
144
-
145
- All four report only against a toolkit-managed target, which is one carrying a
141
+ Six sections sit outside the per-domain scan, because each names something
142
+ that walk cannot see: `seeds`, `superseded`, `unmigrated`, `newSkills`,
143
+ `newRules`, and `reverse`. None of them produces a change, and no sync command
144
+ applies any of them. The headings below group the first three together and give
145
+ `newSkills` its description under `newRules`, which answers the same question
146
+ about a different corpus.
147
+
148
+ All six report only against a toolkit-managed target, which is one carrying a
146
149
  `.claude/` directory, a `CLAUDE.md`, or a domain still at the root layout. The
147
150
  report says so through `managed` in the JSON and routes an unmanaged directory to
148
151
  `aitk init`. Seeds are why the gate exists, since they enumerate from the toolkit
@@ -198,6 +201,66 @@ the toolkit ships, so a root folder holding the project's own documents beside
198
201
  the installed ones reports the installed subset, where a listing reports every
199
202
  file and proposes relocating the lot.
200
203
 
204
+ #### Rules the target never received
205
+
206
+ `newRules` names a rule the toolkit authored after this target last synced
207
+ governance. A sync refreshes the files a target already holds and adds none, so
208
+ without this section a project's rule set freezes at its install date while every
209
+ file it does hold reports as current. That is the report's most confident wrong
210
+ answer, since a clean result reads as a target holding everything the toolkit
211
+ publishes.
212
+
213
+ The list rides beside `newSkills`, which asks the same question about the plugin
214
+ catalog. Both are names rather than paths, and neither queues a change, because
215
+ the two remedies differ: a skill loads live from the plugin directory and needs
216
+ nothing run, while a rule reaches a target only when someone runs
217
+ `aitk gov install`.
218
+
219
+ Read a clean section as reporting rather than as delivering. Nothing here
220
+ installs, nothing counts toward `--exit-code`, and a target can read the list and
221
+ act on none of it. The value stops at an operator reading it, which is the same
222
+ contract the skills list already sets.
223
+
224
+ The measurement anchors on governance's own stamp rather than on the oldest
225
+ anchor across domains, since rules are domain-scoped and a shared anchor would
226
+ let a snippets sync move the revision rules are measured from. A target carrying
227
+ no governance anchor reports nothing at all: it has no date to measure against,
228
+ and diffing from the start of history would read the whole catalog as new.
229
+
230
+ An anchor this toolkit cannot resolve reports nothing by the same route, and that
231
+ one is not visible. A stamp naming a revision the running clone has never seen,
232
+ which is what a registry install without history or a shallow clone produces,
233
+ fails the read and yields an empty list rather than a stated absence. It looks
234
+ identical to a target holding every rule the toolkit publishes. `newSkills`
235
+ behaves the same way, and neither carries the `historyUnavailable` flag the
236
+ per-domain scan uses to separate the two. Treat an empty section on a toolkit
237
+ that is not a full clone as unmeasured rather than clean.
238
+
239
+ Entitlement is filtered, because a stack does not receive every rule. The base
240
+ stack takes the `core` and `claude` folders whole and every other stack extends
241
+ it with individually named rules, so an unfiltered list would tell a base
242
+ consumer about rules it can never receive and train the reader to skip the
243
+ section.
244
+
245
+ The filter accepts a band on either of two grounds. A folder the base stack takes
246
+ whole is entitled to every target, read from the stack file so a folder added to
247
+ base later needs no code change. Every other band is read off the folders the
248
+ target already carries, since `aitk gov install` records file hashes and never
249
+ the stack it resolved, which leaves the installed tree as the only evidence of
250
+ what a chain reached. One band can be reached by more than one stack, so the test
251
+ over-reports inside a folder the target holds, which costs a line where
252
+ under-reporting would cost the section its point.
253
+
254
+ A rule the target already holds is dropped by name. That is what keeps a rule the
255
+ toolkit moved between band folders out of the list, since a rename reaches this
256
+ read as an addition and only the name tells the two apart.
257
+
258
+ This closes the dangling-citation case as a side effect rather than checking for
259
+ one. The live instance is a rule citing a sibling authored after the target's
260
+ install, and the section names the sibling as new rather than naming the citation
261
+ as broken. That is enough for an operator to act on, and it is not a citation
262
+ check.
263
+
201
264
  #### The reverse walk
202
265
 
203
266
  `reverse` is the one section built by walking the target rather than the
@@ -249,6 +312,11 @@ documented and unreachable from any report.
249
312
  is: only the user can move content they wrote, so failing a job on it leaves the
250
313
  job red with no mechanical remedy.
251
314
 
315
+ `newRules` is excluded on a different ground, since a command does close it. What
316
+ excludes it is that installing a rule changes what a project is governed by, so
317
+ gating on the count would pressure a target into adopting rules nobody chose.
318
+ `newSkills` is excluded because it needs no command at all.
319
+
252
320
  The whole `reverse` section is excluded on the same grounds, and more strongly.
253
321
  Every entry in it is a judgment about a file the project may own, and one of its
254
322
  three verdicts is a labelled unknown by design. The unmigrated detection shipped
@@ -0,0 +1,73 @@
1
+ ---
2
+ title: Label coverage
3
+ description: Reading a changed set against the pull request label map, the two tables it matches, how a gap is separated from a decision, and why an absent map is an answer
4
+ ---
5
+
6
+ # Label coverage
7
+
8
+ `aitk labels audit` resolves the paths a branch changed against the label map a project declares, reports the labels the set earns, and names every path no row reaches. It closes a gap the map's own comment had predicted since the map shipped: nothing detected a surface added after the rows were written, so such a branch merged carrying no label and nobody heard about it.
9
+
10
+ ```bash
11
+ aitk labels audit
12
+ aitk labels audit --json
13
+ aitk labels audit --base origin/main
14
+ aitk labels audit src/cli.ts docs/index.md --json
15
+ ```
16
+
17
+ | Option | Behavior |
18
+ | --------------- | ----------------------------------------------------------------- |
19
+ | `--base <ref>` | Far side of the range, defaulting to the merge base against trunk |
20
+ | `--root <path>` | Repository to read, defaulting to the current directory |
21
+ | `--json` | Add a machine-readable record on stdout, keeping the frame |
22
+
23
+ Positional paths replace the range entirely. A caller holding a changed set already passes it rather than paying for a second git read, and the record then omits `base` to say the range was never resolved.
24
+
25
+ Under `--json` the record holds stdout alone and the frame still renders on stderr, refusals included, which is the split `output-shape.md` fixes for every mode.
26
+
27
+ ## The map it reads
28
+
29
+ The map sits at `.claude/aitk/pr-labels.toml` and carries two tables. `[domains]` keys a label name to the path prefixes that earn it, and `[declined]` keys a reason to the prefixes that earn no label on purpose.
30
+
31
+ ```toml
32
+ [domains]
33
+ api = ["services/api/"]
34
+ web = ["apps/web/", "packages/ui/"]
35
+
36
+ [declined]
37
+ release-managed = ["CHANGELOG.md", "package.json"]
38
+ generated = ["build/manifest.json"]
39
+ ```
40
+
41
+ Matching is prefix-anchored, so a row written for an authoring root reaches nothing under the copy a project consumes. A path claimed by rows in both tables takes the label, since it already has a subject and reporting it as deliberately unlabelled would contradict the label the same run applies.
42
+
43
+ ## A gap and a decision are different findings
44
+
45
+ The verb sorts every changed path into three buckets and keeps two of them apart:
46
+
47
+ - **Labelled.** At least one `[domains]` row reaches the path. It contributes its label and nothing else.
48
+ - **Declined.** A `[declined]` row reaches it. Somebody already decided the path earns no label, so the run names it and moves no exit code.
49
+ - **Uncovered.** Neither table reaches it. This is the finding, and it wants either a prefix on the row that owns its subject or a `[declined]` row carrying the reason.
50
+
51
+ Folding the last two together was the shape this replaced. A report that cannot tell a surface nobody has gotten to from one somebody refused is useful about neither, which is why the declined half moved out of the map's trailing comment and became data the command reads.
52
+
53
+ ## The range it reads
54
+
55
+ The default range is the branch against the trunk, resolved as the merge base against `origin/main` and then local `main`. The changed set is that base diffed against the working tree, plus untracked files git does not ignore, rather than against `HEAD`.
56
+
57
+ Reading the working tree is what lets the check run before the branch commits, which is the moment a session adds the surface nobody covered. The set is a superset of `base..HEAD`, so a caller running after the commits still sees the whole branch.
58
+
59
+ ## Exit codes and refusals
60
+
61
+ | Code | Meaning |
62
+ | ---- | ---------------------------------------------- |
63
+ | `0` | every changed path is labelled or declined |
64
+ | `1` | refused, with `reason` naming the cause |
65
+ | `2` | at least one changed path is reached by no row |
66
+
67
+ It reports and never gates. Whether an uncovered surface deserves a label is a judgment only whoever owns that surface can make, and a push failing on one teaches a contributor to route around the check while nothing about the surface has changed.
68
+
69
+ A project declaring no map refuses with `no-map`, which is an answer rather than a fault. Such a project is labelled silently by design, and treating the absence as a break would make the map mandatory for every target. `aitk audits run` reads that one reason as an expected absence and every other refusal as a measure that did not run.
70
+
71
+ ## What it does not measure
72
+
73
+ A prefix reaching no path is invisible here. A row left behind by a deleted folder stays in the map forever, and this verb has nothing to say about it. That is the map going stale from the other side and a second measure rather than this one.
@@ -130,13 +130,25 @@ Each domain holds its own toolkit commit, so syncing governance today does not m
130
130
 
131
131
  A project that has never synced under a toolkit new enough to write a stamp falls back to the toolkit's own git history. Installed content matching any version that history published proves the file untouched, so it reports `stale` naming the commit it came from, and content matching no published version stays `drifted`. That fallback needs the toolkit as a git checkout. Installed from the registry it ships source without history, and the report says attribution was unavailable rather than reading every file as a local edit.
132
132
 
133
- Four further causes sit outside the per-domain scan, each naming something that walk cannot see. A seed the project edited is reported under `seeds` and reconciled with `aitk:claude-seed-sync`, since no sync command touches a seed. A file a newer seed folder replaced is reported under `superseded`, such as `.claude/TASKS.md` against the `.claude/tasks/` that now ships, and nothing moves it because the content is the project's own. A domain sitting at the root layout with nothing under `.claude/` is reported under `unmigrated` and handed to `aitk:migration-standards`.
133
+ Further causes sit outside the per-domain scan, each naming something that walk cannot see. A seed the project edited is reported under `seeds` and reconciled with `aitk:claude-seed-sync`, since no sync command touches a seed. A file a newer seed folder replaced is reported under `superseded`, such as `.claude/TASKS.md` against the `.claude/tasks/` that now ships, and nothing moves it because the content is the project's own. A domain sitting at the root layout with nothing under `.claude/` is reported under `unmigrated` and handed to `aitk:migration-standards`.
134
134
 
135
135
  That third one matters most on an older project. Before it existed, a target holding `snippets/` at its root reported zero entries for that domain, so a project that had never migrated was indistinguishable from one that was fully current. It covers snippets alone, since a root `standards/` folder in a target is the project's own authoring surface and nothing proposes moving it.
136
136
 
137
+ #### Rules you never received
138
+
139
+ A sync refreshes the files you already hold and adds none, so your rule set is frozen at the date you installed governance while every file in it reports as current. `newRules` names the rules the toolkit has authored since then, which is the one section that reads your absence rather than your contents.
140
+
141
+ Take a clean section as reporting rather than as delivering. Nothing here installs, and it counts toward nothing, so pick the rules up with `aitk gov install <stack>` or take one with `--add <rule>`. That separation is deliberate: adopting a rule changes what your project is governed by, which is a choice a sync has no business making for you.
142
+
143
+ The list is filtered to what your stack can receive, read off the rule folders you already carry plus the folders the base stack takes whole. A rule under `lang/` or `ui/` belongs to some stacks and not others, so an unfiltered list would name rules you can never install. A target that has never stamped governance reports nothing at all, since there is no date to measure against.
144
+
145
+ An empty section is not proof either way when the toolkit running the check is not a full git clone. The read needs history to reach your anchor, and an install from the registry ships none, so the section goes quiet rather than saying it could not measure. Run the check from a clone before reading a clean result as a complete one.
146
+
147
+ This also closes the case where a rule arrives citing a sibling you do not have. A sync refreshing a rule can land a version pointing at a file it never installs, and the section names the missing sibling rather than the broken citation, which is enough to act on.
148
+
137
149
  #### What the toolkit stopped shipping
138
150
 
139
- The fourth cause runs backwards. Every one above starts from what the toolkit ships and asks whether the target matches, so a folder the toolkit dropped appears in none of them. `reverse` walks the target instead and reports a folder sitting at a top-level path the toolkit once shipped and has since deleted.
151
+ The last cause runs backwards. Every one above starts from what the toolkit ships and asks whether the target matches, so a folder the toolkit dropped appears in none of them. `reverse` walks the target instead and reports a folder sitting at a top-level path the toolkit once shipped and has since deleted.
140
152
 
141
153
  Each entry carries a verdict, since a dropped folder and one the project wrote are the same bytes at the same path.
142
154
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/aitk",
3
3
  "type": "module",
4
- "version": "3.1.0",
4
+ "version": "3.2.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -1,5 +1,6 @@
1
1
  import type { ReachRefusal } from '@/claude/skills-reach'
2
2
  import type { AuditRefusal } from '@/deps/audit'
3
+ import type { LabelAuditRefusal } from '@/labels/audit'
3
4
  import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
4
5
  import type { ScanRefusal } from '@/secrets/scan'
5
6
  import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
@@ -334,6 +335,27 @@ function advisoryCounts(record: unknown): Record<string, number> | undefined {
334
335
  return counts
335
336
  }
336
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
+
337
359
  function findingsOnly(record: unknown): Record<string, number> | undefined {
338
360
  const root = asObject(record)
339
361
  if (root === undefined) return undefined
@@ -499,6 +521,33 @@ export const AUDITS: readonly AuditSpec[] = [
499
521
  ] satisfies ScanRefusal[],
500
522
  counts: findingsOnly,
501
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
+ },
502
551
  {
503
552
  id: 'deps',
504
553
  label: 'Dependency advisories',
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
 
@@ -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
 
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