@erclx/aitk 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-design-extract/SKILL.md +1 -1
- package/claude/skills/claude-intake-answer/REQUIREMENT.md +1 -1
- package/claude/skills/decision-escalate/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +16 -2
- package/claude/skills/git-pr/references/labels.md +26 -2
- package/claude/skills/setup-indexes/SKILL.md +5 -3
- package/claude/skills/youtube-transcripts/SKILL.md +1 -1
- package/docs/agents/audits.md +2 -2
- package/docs/agents/commands.md +22 -19
- package/docs/agents/index.md +2 -0
- package/docs/agents/install-and-sync.md +73 -5
- package/docs/agents/label-coverage.md +73 -0
- package/docs/agents/skills-reach.md +57 -0
- package/docs/target-projects.md +14 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +85 -0
- package/src/claude/skills-reach.ts +193 -0
- package/src/cli.ts +4 -0
- package/src/commands/claude.ts +123 -4
- 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
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
|
|