@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/SKILL.md +16 -2
- package/claude/skills/git-pr/references/labels.md +26 -2
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +2 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/install-and-sync.md +73 -5
- package/docs/agents/label-coverage.md +73 -0
- package/docs/target-projects.md +14 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +49 -0
- package/src/cli.ts +4 -0
- package/src/commands/labels.ts +166 -0
- package/src/commands/sync.ts +13 -0
- package/src/git-files.ts +74 -0
- package/src/labels/audit.ts +82 -0
- package/src/labels/coverage.ts +79 -0
- package/src/labels/map.ts +101 -0
- package/src/sync/check.ts +167 -4
|
@@ -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
|
-
|
|
87
|
+
Ask the CLI first:
|
|
88
88
|
|
|
89
|
-
|
|
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
|
|
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.
|
|
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
|
|
package/docs/agents/audits.md
CHANGED
|
@@ -22,11 +22,11 @@ aitk audits list --json
|
|
|
22
22
|
|
|
23
23
|
## What it runs
|
|
24
24
|
|
|
25
|
-
|
|
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
|
|
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
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -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:
|
package/docs/agents/index.md
CHANGED
|
@@ -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
|
-
|
|
142
|
-
that walk cannot see
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
package/docs/target-projects.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
package/src/audits/catalog.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -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 {
|
|
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], {
|
|
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
|
|