@erclx/aitk 3.48.1 → 3.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/claude-autoship/SKILL.md +21 -1
- package/docs/agents/commands.md +4 -1
- package/docs/agents/index.md +2 -0
- package/docs/agents/review-classification.md +77 -0
- package/docs/agents/rule-citations.md +98 -0
- package/docs/ai-workflow.md +4 -0
- package/package.json +1 -1
- package/scripts/core/verify.sh +16 -0
- package/src/autoship/classify.ts +75 -0
- package/src/autoship/paths.ts +51 -0
- package/src/cli.ts +3 -0
- package/src/commands/autoship.ts +129 -0
- package/src/commands/gov.ts +247 -0
- package/src/gov/citations.ts +514 -0
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { $ } from 'bun'
|
|
5
|
+
import { isMarked } from '@/exempt-marker'
|
|
6
|
+
import { gitEnv } from '@/git-env'
|
|
7
|
+
import { listRuleFiles } from '@/gov/payload'
|
|
8
|
+
import { parseFrontmatter } from '@/indexes/frontmatter'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The inline token exempting one line from this sweep, shaped on the
|
|
12
|
+
* `aitk-allow-superseded` precedent and read by the same two-line rule.
|
|
13
|
+
*
|
|
14
|
+
* Everything the classifier below can separate mechanically is separated
|
|
15
|
+
* there. This is for the residue: a line whose path is written as a reference
|
|
16
|
+
* and is correct in naming something absent, for a reason a later reader has
|
|
17
|
+
* to be able to weigh. A bare token names no reason, so it mutes nothing.
|
|
18
|
+
*/
|
|
19
|
+
export const CITATION_MARKER = 'aitk-allow-citation'
|
|
20
|
+
|
|
21
|
+
/** The two rule corpora, authored here and read from the repository root. */
|
|
22
|
+
export const RULE_DIRS: readonly string[] = [
|
|
23
|
+
join('governance', 'rules'),
|
|
24
|
+
join('internal', 'rules'),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The corpus whose frontmatter globs resolve against this tree.
|
|
29
|
+
*
|
|
30
|
+
* Bodies are read across both corpora and globs across this one alone. A rule
|
|
31
|
+
* under `governance/rules/` installs into a target and its `paths:` entries
|
|
32
|
+
* name that project's shape, which is why 32 of the 72 globs there match
|
|
33
|
+
* nothing here and every one of them is correct: `src/pages/**` in the Astro
|
|
34
|
+
* rule is indistinguishable by pattern from a path this repository might hold.
|
|
35
|
+
* Gating on them would ship a permanent exemption list the length of the
|
|
36
|
+
* corpus. The internal corpus ships nowhere, so the tree it governs is the
|
|
37
|
+
* tree present and a glob matching nothing there is a rule that stopped
|
|
38
|
+
* firing.
|
|
39
|
+
*/
|
|
40
|
+
export const GLOB_CORPUS = join('internal', 'rules')
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How the citation was written, kept on the finding because the three resolve
|
|
44
|
+
* against different roots and a reader repairing one needs to know which.
|
|
45
|
+
*
|
|
46
|
+
* `standard` is the live form, carried by 20 rules. `path` is a backticked
|
|
47
|
+
* repository path. `sibling` is a rule naming another rule by filename alone,
|
|
48
|
+
* which resolves inside the folder the citing rule sits in.
|
|
49
|
+
*/
|
|
50
|
+
export type CitationForm = 'path' | 'standard' | 'sibling'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What the sweep decided about one citation.
|
|
54
|
+
*
|
|
55
|
+
* `governed` and `ignored` are the two classes where a path reaching nothing is
|
|
56
|
+
* correct rather than stale, and they are named rather than dropped so the
|
|
57
|
+
* report states what it declined to judge. `exempt` is the marker.
|
|
58
|
+
*/
|
|
59
|
+
export type CitationStatus =
|
|
60
|
+
| 'resolved'
|
|
61
|
+
| 'governed'
|
|
62
|
+
| 'ignored'
|
|
63
|
+
| 'exempt'
|
|
64
|
+
| 'dead'
|
|
65
|
+
|
|
66
|
+
export interface RuleCitation {
|
|
67
|
+
/** The citing rule, relative to the root that was swept. */
|
|
68
|
+
readonly file: string
|
|
69
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
70
|
+
readonly line: number
|
|
71
|
+
readonly form: CitationForm
|
|
72
|
+
/** The citation exactly as the rule wrote it. */
|
|
73
|
+
readonly cited: string
|
|
74
|
+
/** Repository-relative paths tried, in order, so a finding names its net. */
|
|
75
|
+
readonly candidates: readonly string[]
|
|
76
|
+
/** Which candidate answered, absent when none did. */
|
|
77
|
+
readonly resolved: string | undefined
|
|
78
|
+
readonly status: CitationStatus
|
|
79
|
+
readonly preview: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One `paths:` entry from a rule's frontmatter, resolved against the tree.
|
|
84
|
+
*
|
|
85
|
+
* Only the internal corpus is read. A rule shipping to a target declares the
|
|
86
|
+
* shape that target holds, so its globs answer about a tree that is not this
|
|
87
|
+
* one, which `GLOB_CORPUS` states and the report repeats on every run.
|
|
88
|
+
*/
|
|
89
|
+
export interface RuleGlob {
|
|
90
|
+
readonly file: string
|
|
91
|
+
readonly line: number
|
|
92
|
+
readonly glob: string
|
|
93
|
+
readonly matched: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CitationReport =
|
|
97
|
+
| {
|
|
98
|
+
readonly kind: 'measured'
|
|
99
|
+
/** Rule files opened, which is what the verdict covers. */
|
|
100
|
+
readonly rules: number
|
|
101
|
+
readonly citations: readonly RuleCitation[]
|
|
102
|
+
/** Frontmatter globs read, from `GLOB_CORPUS` alone. */
|
|
103
|
+
readonly globs: readonly RuleGlob[]
|
|
104
|
+
}
|
|
105
|
+
| { readonly kind: 'unreadable'; readonly reason: string }
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The longest preview a finding carries, matching the superseded sweep beside
|
|
109
|
+
* it. A rule bullet runs long and the report prints one line per citation.
|
|
110
|
+
*/
|
|
111
|
+
const PREVIEW_LIMIT = 200
|
|
112
|
+
|
|
113
|
+
const FENCE = /^\s*(?:```|~~~)/
|
|
114
|
+
|
|
115
|
+
const FRONTMATTER_DELIMITER = /^---\s*$/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A backticked span, which is the only carrier a rule writes a citation in. No
|
|
119
|
+
* rule in either corpus uses a markdown link, and matching running prose would
|
|
120
|
+
* report every sentence that happens to name a file.
|
|
121
|
+
*/
|
|
122
|
+
const BACKTICKED = /`([^`\n]+)`/g
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The verb form, with the name captured. A leading letter or digit is required,
|
|
126
|
+
* which is what leaves `aitk standards <name>` unmatched: that line teaches the
|
|
127
|
+
* form rather than citing a standard, and it is the only one in either corpus.
|
|
128
|
+
*/
|
|
129
|
+
const STANDARD_CALL = /aitk standards ([A-Za-z0-9][A-Za-z0-9._-]*)/g
|
|
130
|
+
|
|
131
|
+
/** A rule filename, which is how a rule names a sibling with no folder around it. */
|
|
132
|
+
const SIBLING_RULE = /^\d{3}-[a-z0-9-]+\.md$/
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A character that puts the span outside this repository, or outside paths
|
|
136
|
+
* altogether.
|
|
137
|
+
*
|
|
138
|
+
* `<` and `$` are the placeholder forms, `*` is a glob, and both describe a
|
|
139
|
+
* shape rather than name a file. The rest are anchors nothing here resolves
|
|
140
|
+
* against: an absolute path, a home path, a module alias, a URL scheme.
|
|
141
|
+
*/
|
|
142
|
+
function isNotRepositoryPath(span: string): boolean {
|
|
143
|
+
if (/[\s<>$*|]/.test(span)) return true
|
|
144
|
+
if (span.includes('://')) return true
|
|
145
|
+
return /^[/~@#!]/.test(span)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether the span's last segment carries a file extension.
|
|
150
|
+
*
|
|
151
|
+
* This is what separates a citation from the folder and module conventions the
|
|
152
|
+
* corpus is full of. `next/font`, `try/except`, `react-hooks/set-state-in-effect`,
|
|
153
|
+
* and `oven-sh/setup-bun@v2` all carry a slash and name no file, and a trailing
|
|
154
|
+
* slash is a folder rather than a document. The cost is that `claude/standards`
|
|
155
|
+
* is a real path this declines to check, which is the bound the report states.
|
|
156
|
+
*/
|
|
157
|
+
function namesAFile(span: string): boolean {
|
|
158
|
+
if (span.endsWith('/')) return false
|
|
159
|
+
const segment = span.slice(span.lastIndexOf('/') + 1)
|
|
160
|
+
return /\.[A-Za-z0-9]+$/.test(segment)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Which form the span is written in, or nothing when it names no file this
|
|
165
|
+
* sweep can resolve.
|
|
166
|
+
*
|
|
167
|
+
* The whole span is classified rather than a trailing pattern inside it.
|
|
168
|
+
* Extracting `standards/tooling-reference.md` out of
|
|
169
|
+
* `internal/standards/tooling-reference.md` and resolving that against the
|
|
170
|
+
* standards root manufactures a dead citation out of a file that exists, which
|
|
171
|
+
* a session measuring this corpus did before the check was written.
|
|
172
|
+
*/
|
|
173
|
+
export function classifySpan(span: string): CitationForm | undefined {
|
|
174
|
+
if (isNotRepositoryPath(span)) return undefined
|
|
175
|
+
if (!namesAFile(span)) return undefined
|
|
176
|
+
if (span.includes('/')) return 'path'
|
|
177
|
+
return SIBLING_RULE.test(span) ? 'sibling' : undefined
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Where a citation could answer, which is wherever the reader's own tools look
|
|
182
|
+
* and nowhere else.
|
|
183
|
+
*
|
|
184
|
+
* A standard name takes the authoring root alone, matching `standardRoots` in
|
|
185
|
+
* `@/standards/read`, which reads `standards/` at the working root and then the
|
|
186
|
+
* package corpus. This verb refuses a tree holding no rule corpus, so it runs
|
|
187
|
+
* only where those two roots are one directory. `internal/standards/` is
|
|
188
|
+
* deliberately absent: `aitk standards <name>` never reaches it, so admitting it
|
|
189
|
+
* here would pass a citation that refuses for the session opening it, which is a
|
|
190
|
+
* gate failing open.
|
|
191
|
+
*
|
|
192
|
+
* A sibling resolves inside the folder the citing rule sits in, since that is
|
|
193
|
+
* the only place a bare rule filename means anything.
|
|
194
|
+
*/
|
|
195
|
+
function candidatesFor(
|
|
196
|
+
form: CitationForm,
|
|
197
|
+
cited: string,
|
|
198
|
+
ruleFile: string,
|
|
199
|
+
): string[] {
|
|
200
|
+
if (form === 'standard') return [join('standards', `${cited}.md`)]
|
|
201
|
+
if (form === 'sibling') return [join(dirname(ruleFile), cited)]
|
|
202
|
+
return [cited]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface RawCitation {
|
|
206
|
+
readonly line: number
|
|
207
|
+
readonly form: CitationForm
|
|
208
|
+
readonly cited: string
|
|
209
|
+
readonly preview: string
|
|
210
|
+
readonly marked: boolean
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Every citation one rule body carries.
|
|
215
|
+
*
|
|
216
|
+
* The frontmatter block is skipped, since a `paths:` glob declares what the
|
|
217
|
+
* rule governs rather than what it points a reader at, and the two questions
|
|
218
|
+
* resolve against different trees. Fenced blocks are skipped for the reason the
|
|
219
|
+
* marker exists: a fenced example displays a path rather than citing one. No
|
|
220
|
+
* rule in either corpus opens a fence today, so this is a floor rather than a
|
|
221
|
+
* filter over anything present.
|
|
222
|
+
*/
|
|
223
|
+
export function collectCitations(text: string): RawCitation[] {
|
|
224
|
+
const lines = text.split('\n')
|
|
225
|
+
const found: RawCitation[] = []
|
|
226
|
+
let fenced = false
|
|
227
|
+
let inFrontmatter = FRONTMATTER_DELIMITER.test(lines[0] ?? '')
|
|
228
|
+
|
|
229
|
+
for (const [index, line] of lines.entries()) {
|
|
230
|
+
if (inFrontmatter) {
|
|
231
|
+
if (index > 0 && FRONTMATTER_DELIMITER.test(line)) inFrontmatter = false
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (FENCE.test(line)) {
|
|
235
|
+
fenced = !fenced
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (fenced) continue
|
|
239
|
+
|
|
240
|
+
const trimmed = line.trim()
|
|
241
|
+
const preview =
|
|
242
|
+
trimmed.length > PREVIEW_LIMIT
|
|
243
|
+
? `${trimmed.slice(0, PREVIEW_LIMIT)}…`
|
|
244
|
+
: trimmed
|
|
245
|
+
const marked = isMarked(lines, index, CITATION_MARKER)
|
|
246
|
+
|
|
247
|
+
for (const match of line.matchAll(STANDARD_CALL)) {
|
|
248
|
+
found.push({
|
|
249
|
+
line: index + 1,
|
|
250
|
+
form: 'standard',
|
|
251
|
+
cited: match[1] ?? '',
|
|
252
|
+
preview,
|
|
253
|
+
marked,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const match of line.matchAll(BACKTICKED)) {
|
|
258
|
+
const span = match[1] ?? ''
|
|
259
|
+
const form = classifySpan(span)
|
|
260
|
+
if (form === undefined) continue
|
|
261
|
+
found.push({ line: index + 1, form, cited: span, preview, marked })
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return found
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The paths one rule declares in its own frontmatter.
|
|
270
|
+
*
|
|
271
|
+
* A rule spelling a whole path there is declaring that exact artifact, and a
|
|
272
|
+
* body line naming it again is naming what the rule governs rather than
|
|
273
|
+
* pointing a reader somewhere. `governance/rules/claude/560-diagrams.md` tells
|
|
274
|
+
* its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
|
|
275
|
+
* is correctly absent from this tree and correctly named in the rule.
|
|
276
|
+
*/
|
|
277
|
+
function governedPaths(text: string): string[] {
|
|
278
|
+
const parsed = parseFrontmatter(text)
|
|
279
|
+
const paths = parsed?.fields.paths
|
|
280
|
+
if (!Array.isArray(paths)) return []
|
|
281
|
+
return paths.filter((entry): entry is string => typeof entry === 'string')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Exact declarations only, never a glob match against one.
|
|
286
|
+
*
|
|
287
|
+
* A glob declares a shape rather than an artifact, so a body path sitting
|
|
288
|
+
* inside one is still a citation and a stale one is still a defect. Matching
|
|
289
|
+
* the glob would exempt a rule scoped at `docs/**` citing a
|
|
290
|
+
* `docs/agents/renamed.md` that moved, which is the class this check exists
|
|
291
|
+
* to catch.
|
|
292
|
+
*/
|
|
293
|
+
function isGoverned(cited: string, declared: readonly string[]): boolean {
|
|
294
|
+
return declared.includes(cited)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* A `paths:` list entry, with the glob captured and its quoting dropped.
|
|
299
|
+
*
|
|
300
|
+
* Anchored on the entry shape rather than searched for as a substring. A bare
|
|
301
|
+
* scan for the glob text finds it in the `description:` line first wherever a
|
|
302
|
+
* rule names what it governs in prose, which is how `596-claude-md.md` reported
|
|
303
|
+
* its `CLAUDE.md` glob against line 2 instead of line 4.
|
|
304
|
+
*/
|
|
305
|
+
const LIST_ENTRY = /^\s*-\s*(?:'([^']*)'|"([^"]*)"|(\S.*?))\s*$/
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Where each declared glob sits, so a finding names a line a reader can click.
|
|
309
|
+
*
|
|
310
|
+
* The values come from the YAML parse and the line numbers from a scan of the
|
|
311
|
+
* frontmatter block alone, rather than from a second parse of the list syntax.
|
|
312
|
+
* A quoted entry, a bare one, and a flow sequence all reach the parse
|
|
313
|
+
* identically, and only the first two are what this corpus writes, so a flow
|
|
314
|
+
* sequence resolves its value and reports no line.
|
|
315
|
+
*/
|
|
316
|
+
function locateGlobs(text: string): { line: number; glob: string }[] {
|
|
317
|
+
const declared = governedPaths(text)
|
|
318
|
+
if (declared.length === 0) return []
|
|
319
|
+
|
|
320
|
+
const lines = text.split('\n')
|
|
321
|
+
const close = lines.findIndex(
|
|
322
|
+
(line, index) => index > 0 && FRONTMATTER_DELIMITER.test(line),
|
|
323
|
+
)
|
|
324
|
+
const block = close === -1 ? lines : lines.slice(0, close)
|
|
325
|
+
const taken = new Set<number>()
|
|
326
|
+
|
|
327
|
+
return declared.map((glob) => {
|
|
328
|
+
const at = block.findIndex((line, index) => {
|
|
329
|
+
if (taken.has(index)) return false
|
|
330
|
+
const entry = line.match(LIST_ENTRY)
|
|
331
|
+
return entry !== null && (entry[1] ?? entry[2] ?? entry[3]) === glob
|
|
332
|
+
})
|
|
333
|
+
if (at !== -1) taken.add(at)
|
|
334
|
+
return { line: at + 1, glob }
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Whether the glob matches a file present in this tree.
|
|
340
|
+
*
|
|
341
|
+
* Read only for `internal/rules/`. A shipped rule's glob names the shape a
|
|
342
|
+
* target holds, so `src/pages/**` in the Astro rule is indistinguishable by
|
|
343
|
+
* pattern from a path here and resolving it would report 32 of 72 correct
|
|
344
|
+
* globs as defects. The internal corpus ships nowhere, which makes the tree it
|
|
345
|
+
* governs the tree present and the question answerable.
|
|
346
|
+
*/
|
|
347
|
+
function globMatches(root: string, glob: string): boolean {
|
|
348
|
+
const scan = new Bun.Glob(glob).scanSync({
|
|
349
|
+
cwd: root,
|
|
350
|
+
onlyFiles: true,
|
|
351
|
+
dot: true,
|
|
352
|
+
})
|
|
353
|
+
for (const _ of scan) return true
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Which of `paths` git ignores, or nothing when git could not answer.
|
|
359
|
+
*
|
|
360
|
+
* Session scratch is the class this reaches. `.claude/tasks/index.md` is real
|
|
361
|
+
* in a live project, absent from a fresh clone and from every linked worktree,
|
|
362
|
+
* and a rule naming it is right either way. Resolving against the filesystem
|
|
363
|
+
* alone would make the verdict depend on which tree the check ran in.
|
|
364
|
+
*
|
|
365
|
+
* `git check-ignore` exits 1 when nothing matches, which is a clean answer
|
|
366
|
+
* rather than a failure, so only a higher code is read as one.
|
|
367
|
+
*/
|
|
368
|
+
async function readIgnored(
|
|
369
|
+
root: string,
|
|
370
|
+
paths: readonly string[],
|
|
371
|
+
): Promise<Set<string> | undefined> {
|
|
372
|
+
if (paths.length === 0) return new Set()
|
|
373
|
+
|
|
374
|
+
const input = Buffer.from(`${paths.join('\n')}\n`)
|
|
375
|
+
const result = await $`git -C ${root} check-ignore --stdin < ${input}`
|
|
376
|
+
.env(gitEnv())
|
|
377
|
+
.quiet()
|
|
378
|
+
.nothrow()
|
|
379
|
+
|
|
380
|
+
if (result.exitCode > 1) return undefined
|
|
381
|
+
return new Set(result.text().split('\n').filter(Boolean))
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resolves every path the two rule corpora cite and names the ones reaching
|
|
386
|
+
* nothing, plus every frontmatter glob under `GLOB_CORPUS`.
|
|
387
|
+
*
|
|
388
|
+
* Two questions rather than one, because they fail the same way. A citation
|
|
389
|
+
* broken by a move sends a reader to an absence, and a glob broken by a move
|
|
390
|
+
* stops the rule firing at all, and neither says anything when it happens. One
|
|
391
|
+
* stage reads both rather than two reading one file each.
|
|
392
|
+
*
|
|
393
|
+
* This gates rather than reports, unlike the superseded sweep it sits beside. A
|
|
394
|
+
* path resolving to nothing carries no judgment: either the file is there or the
|
|
395
|
+
* citation is stale, and the classes where absence is correct are separated
|
|
396
|
+
* before the verdict rather than left for a reader to settle.
|
|
397
|
+
*
|
|
398
|
+
* What it cannot see is a citation that resolves and points somewhere wrong,
|
|
399
|
+
* a path written without backticks, a folder or module specifier carrying no
|
|
400
|
+
* extension, which `namesAFile` declines rather than guessing at, and a glob
|
|
401
|
+
* that matches real files while reaching none of the work it was scoped at.
|
|
402
|
+
*/
|
|
403
|
+
export async function readCitations(root: string): Promise<CitationReport> {
|
|
404
|
+
const dirs = RULE_DIRS.map((rel) => ({
|
|
405
|
+
rel,
|
|
406
|
+
abs: resolve(root, rel),
|
|
407
|
+
})).filter((dir) => existsSync(dir.abs))
|
|
408
|
+
|
|
409
|
+
if (dirs.length === 0) {
|
|
410
|
+
return {
|
|
411
|
+
kind: 'unreadable',
|
|
412
|
+
reason: `No rule corpus under ${root}. A tree holding neither ${RULE_DIRS.join(' nor ')} passes each of its zero rules, so it refuses rather than reporting clean.`,
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const citations: RuleCitation[] = []
|
|
417
|
+
const globs: RuleGlob[] = []
|
|
418
|
+
let rules = 0
|
|
419
|
+
|
|
420
|
+
for (const dir of dirs) {
|
|
421
|
+
for (const abs of listRuleFiles(dir.abs)) {
|
|
422
|
+
const file = join(dir.rel, abs.slice(dir.abs.length + 1))
|
|
423
|
+
|
|
424
|
+
let text: string
|
|
425
|
+
try {
|
|
426
|
+
text = await readFile(abs, 'utf8')
|
|
427
|
+
} catch {
|
|
428
|
+
// A rule git listed and the filesystem will not open is a file removed
|
|
429
|
+
// since the glob answered. Skipping it under-reports rather than
|
|
430
|
+
// failing a push on a race.
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
rules += 1
|
|
435
|
+
const declared = governedPaths(text)
|
|
436
|
+
|
|
437
|
+
if (dir.rel === GLOB_CORPUS) {
|
|
438
|
+
for (const { line, glob } of locateGlobs(text)) {
|
|
439
|
+
globs.push({ file, line, glob, matched: globMatches(root, glob) })
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const raw of collectCitations(text)) {
|
|
444
|
+
const candidates = candidatesFor(raw.form, raw.cited, file)
|
|
445
|
+
const resolved = candidates.find((path) =>
|
|
446
|
+
existsSync(resolve(root, path)),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
citations.push({
|
|
450
|
+
file,
|
|
451
|
+
line: raw.line,
|
|
452
|
+
form: raw.form,
|
|
453
|
+
cited: raw.cited,
|
|
454
|
+
candidates,
|
|
455
|
+
resolved,
|
|
456
|
+
status: classifyStatus(raw, resolved, declared),
|
|
457
|
+
preview: raw.preview,
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return applyIgnored(root, { kind: 'measured', rules, citations, globs })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function classifyStatus(
|
|
467
|
+
raw: RawCitation,
|
|
468
|
+
resolved: string | undefined,
|
|
469
|
+
declared: readonly string[],
|
|
470
|
+
): CitationStatus {
|
|
471
|
+
if (resolved !== undefined) return 'resolved'
|
|
472
|
+
if (raw.marked) return 'exempt'
|
|
473
|
+
if (raw.form === 'path' && isGoverned(raw.cited, declared)) return 'governed'
|
|
474
|
+
return 'dead'
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Moves the unresolved paths git ignores out of the dead set.
|
|
479
|
+
*
|
|
480
|
+
* Batched into one call over the whole sweep rather than one per citation, and
|
|
481
|
+
* skipped outright when nothing is unresolved, so a clean corpus spawns no git
|
|
482
|
+
* at all. A read git cannot answer refuses, since treating it as "nothing is
|
|
483
|
+
* ignored" would fail a push over session scratch that was never in the tree.
|
|
484
|
+
*/
|
|
485
|
+
async function applyIgnored(
|
|
486
|
+
root: string,
|
|
487
|
+
measured: Extract<CitationReport, { kind: 'measured' }>,
|
|
488
|
+
): Promise<CitationReport> {
|
|
489
|
+
const pending = measured.citations.filter(
|
|
490
|
+
(citation) => citation.status === 'dead' && citation.form === 'path',
|
|
491
|
+
)
|
|
492
|
+
if (pending.length === 0) return measured
|
|
493
|
+
|
|
494
|
+
const ignored = await readIgnored(
|
|
495
|
+
root,
|
|
496
|
+
pending.map((citation) => citation.cited),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
if (ignored === undefined) {
|
|
500
|
+
return {
|
|
501
|
+
kind: 'unreadable',
|
|
502
|
+
reason: `Git could not say which of ${pending.length} unresolved paths it ignores under ${root}. Session scratch is absent from a fresh clone and correctly cited anyway, so an unreadable answer refuses rather than reporting those paths dead.`,
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
...measured,
|
|
508
|
+
citations: measured.citations.map((citation) =>
|
|
509
|
+
citation.status === 'dead' && ignored.has(citation.cited)
|
|
510
|
+
? { ...citation, status: 'ignored' as const }
|
|
511
|
+
: citation,
|
|
512
|
+
),
|
|
513
|
+
}
|
|
514
|
+
}
|