@erclx/aitk 0.63.2 → 0.64.1
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/docs/agents/commands.md +3 -1
- package/docs/agents/context-audit-checks.md +9 -11
- package/docs/agents/context-audit.md +3 -1
- package/docs/agents/index.md +2 -1
- package/docs/agents/markdown-audit.md +80 -0
- package/package.json +1 -1
- package/src/cli.ts +4 -0
- package/src/commands/context.ts +8 -104
- package/src/commands/markdown.ts +383 -0
- package/src/context/audit.ts +19 -296
- package/src/context/citations.ts +2 -30
- package/src/git-files.ts +31 -0
- package/src/markdown/bans.ts +241 -0
- package/src/markdown/files.ts +92 -0
- package/src/markdown/scan.ts +259 -0
- package/src/markdown/structure.ts +416 -0
- package/src/records/validate.ts +1 -37
- package/standards/markdown.md +2 -1
package/src/context/audit.ts
CHANGED
|
@@ -1,50 +1,19 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { relative } from 'node:path'
|
|
3
3
|
import type { AuditedFolder } from '@/context/folders'
|
|
4
|
+
import { type BodyLine, bodyLines } from '@/markdown/scan'
|
|
5
|
+
import { renderedHeight } from '@/markdown/structure'
|
|
4
6
|
import { isStubSeed } from '@/seed-marker'
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
9
|
+
* Checkpoint quoted from the standard stating it. It is not a cap.
|
|
8
10
|
*
|
|
9
11
|
* Entry length rests on one entry per domain, which is a domain fact, so
|
|
10
|
-
* `standards/context.md` keeps it.
|
|
11
|
-
* file, so
|
|
12
|
+
* `standards/context.md` keeps it. Depth and bullet weight read the same over
|
|
13
|
+
* any markdown file, so they are stated at the attribute tier and measured by
|
|
14
|
+
* `aitk markdown audit` rather than here.
|
|
12
15
|
*/
|
|
13
16
|
export const LENGTH_CHECKPOINT = 150
|
|
14
|
-
export const RUN_CHECKPOINT = 40
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Columns a source line wraps at when rendered.
|
|
18
|
-
*
|
|
19
|
-
* Nothing in this repository sets a line width and entries are authored one
|
|
20
|
-
* line per bullet, so the rendered width is the viewer's rather than the file's.
|
|
21
|
-
* The common terminal and diff width is the reproducible choice, and the report
|
|
22
|
-
* legend states it so a reader can arrive at the same number by hand.
|
|
23
|
-
*/
|
|
24
|
-
export const RENDER_WIDTH = 80
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Characters a bullet averages before its list stops reading as a set of peers.
|
|
28
|
-
*
|
|
29
|
-
* The exemption below covers a flat catalog of one-liners and a stack of
|
|
30
|
-
* paragraphs equally, and only the first is navigable. Measured across this
|
|
31
|
-
* corpus the two shapes separate with nothing between roughly 100 and 170
|
|
32
|
-
* characters a bullet, so the midpoint splits the population rather than a
|
|
33
|
-
* continuum. It is a checkpoint like the two above, not a cap.
|
|
34
|
-
*/
|
|
35
|
-
export const PEER_BULLET_CHECKPOINT = 130
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Characters a top-level bullet carries before its overflow belongs in prose.
|
|
39
|
-
*
|
|
40
|
-
* Unlike the peer-list checkpoint above, this corpus has no gap behind the
|
|
41
|
-
* number. Bullet weight decays smoothly from a median near 170 with the
|
|
42
|
-
* steepest relative fall across this boundary and nothing resembling two
|
|
43
|
-
* populations, so the number is a judgment where that one was a measurement.
|
|
44
|
-
* A bullet that reads well past it means the number is wrong rather than the
|
|
45
|
-
* rule, which is why this reports and never gates.
|
|
46
|
-
*/
|
|
47
|
-
export const BULLET_CHECKPOINT = 400
|
|
48
17
|
|
|
49
18
|
/**
|
|
50
19
|
* A table this size or larger whose first column mostly names artifacts reads
|
|
@@ -72,11 +41,7 @@ const CATALOG_NAMED_RATIO = 0.6
|
|
|
72
41
|
*/
|
|
73
42
|
export const REQUIRED_SECTIONS: readonly string[] = ['Overview', 'Layout']
|
|
74
43
|
|
|
75
|
-
const FRONTMATTER = /^---\n[\s\S]*?\n---\n?/
|
|
76
|
-
const FENCE = /^\s*(```|~~~)/
|
|
77
|
-
const HEADING = /^#{1,6}\s/
|
|
78
44
|
const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
|
|
79
|
-
const LIST_ITEM = /^(\s*)([-*+]|\d+\.)\s+/
|
|
80
45
|
const TABLE_ROW = /^\s*\|/
|
|
81
46
|
const TABLE_SEPARATOR = /^\s*\|[\s:|-]+\|\s*$/
|
|
82
47
|
const NAMED_CELL = /`[^`]+`|\[[^\]]+\]\([^)]+\)/
|
|
@@ -126,12 +91,6 @@ export interface TableFinding {
|
|
|
126
91
|
readonly rows: number
|
|
127
92
|
}
|
|
128
93
|
|
|
129
|
-
export interface BulletFinding {
|
|
130
|
-
readonly line: number
|
|
131
|
-
/** Weight as folded, so a report says how far past the checkpoint it sits. */
|
|
132
|
-
readonly characters: number
|
|
133
|
-
}
|
|
134
|
-
|
|
135
94
|
export interface ProvenanceFinding {
|
|
136
95
|
readonly line: number
|
|
137
96
|
readonly kind: ProvenanceKind
|
|
@@ -143,26 +102,20 @@ export interface EntryReport {
|
|
|
143
102
|
readonly rel: string
|
|
144
103
|
/**
|
|
145
104
|
* Rendered lines across the whole file, counting frontmatter and fenced
|
|
146
|
-
* blocks.
|
|
147
|
-
*
|
|
148
|
-
* compares them.
|
|
105
|
+
* blocks. It shares `renderedHeight` with the depth checkpoint in
|
|
106
|
+
* `src/markdown/structure.ts`, since the two sit in one section of the
|
|
107
|
+
* standard and a reader compares them.
|
|
149
108
|
*
|
|
150
|
-
*
|
|
151
|
-
* cannot break the run around it, and a file measure has no run to
|
|
152
|
-
* Excluding
|
|
153
|
-
* not reach the case that motivates it: the most fenced entry in the
|
|
154
|
-
* runs 20 percent fenced and sits past the checkpoint either way.
|
|
109
|
+
* Fences are counted here and excluded there. The depth measure skips one so
|
|
110
|
+
* an example cannot break the run around it, and a file measure has no run to
|
|
111
|
+
* protect. Excluding them here would change which entries report by one and
|
|
112
|
+
* would not reach the case that motivates it: the most fenced entry in the
|
|
113
|
+
* corpus runs 20 percent fenced and sits past the checkpoint either way.
|
|
155
114
|
*/
|
|
156
115
|
readonly lines: number
|
|
157
|
-
/** Rendered lines at `RENDER_WIDTH`, not source lines. */
|
|
158
|
-
readonly longestRun: number
|
|
159
|
-
/** First line of the longest run, or 0 when the entry has no run at all. */
|
|
160
|
-
readonly longestRunLine: number
|
|
161
116
|
readonly catalogTables: readonly TableFinding[]
|
|
162
117
|
/** Empty for an entry no standard bans a change narrative in. */
|
|
163
118
|
readonly provenance: readonly ProvenanceFinding[]
|
|
164
|
-
/** Measured in every audited folder, since an attribute standard states it. */
|
|
165
|
-
readonly heavyBullets: readonly BulletFinding[]
|
|
166
119
|
/**
|
|
167
120
|
* Required sections this entry declares, in the standard's order, and empty
|
|
168
121
|
* outside the folder whose standard names them. What the folder is short of
|
|
@@ -188,163 +141,6 @@ export interface SectionFinding {
|
|
|
188
141
|
readonly missing: readonly string[]
|
|
189
142
|
}
|
|
190
143
|
|
|
191
|
-
interface BodyLine {
|
|
192
|
-
readonly number: number
|
|
193
|
-
readonly text: string
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Drops the frontmatter while keeping every surviving line's original number,
|
|
198
|
-
* so a finding points at the line an editor opens rather than at an offset into
|
|
199
|
-
* the body.
|
|
200
|
-
*/
|
|
201
|
-
function bodyLines(source: string): BodyLine[] {
|
|
202
|
-
const match = source.match(FRONTMATTER)
|
|
203
|
-
const offset = match ? match[0].split('\n').length - 1 : 0
|
|
204
|
-
|
|
205
|
-
return source
|
|
206
|
-
.slice(match ? match[0].length : 0)
|
|
207
|
-
.replace(/\n$/, '')
|
|
208
|
-
.split('\n')
|
|
209
|
-
.map((text, index) => ({ number: offset + index + 1, text }))
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Reports whether a run is the peer list the standard exempts.
|
|
214
|
-
*
|
|
215
|
-
* Every non-blank line has to be a list item at one indent. Prose mixed into
|
|
216
|
-
* the run or a nested level inside it ends the exemption, because either one
|
|
217
|
-
* means the block is no longer a flat set a reader can skim. Bullet count says
|
|
218
|
-
* nothing on its own, since a catalog of one-liners and a wall of paragraphs
|
|
219
|
-
* reach the same count and read nothing alike, so the average bullet is what
|
|
220
|
-
* decides whether the set is still skimmable.
|
|
221
|
-
*/
|
|
222
|
-
function isScannablePeerList(run: readonly BodyLine[]): boolean {
|
|
223
|
-
const indents = new Set<number>()
|
|
224
|
-
let items = 0
|
|
225
|
-
let characters = 0
|
|
226
|
-
|
|
227
|
-
for (const line of run) {
|
|
228
|
-
const text = line.text.trim()
|
|
229
|
-
if (text === '') continue
|
|
230
|
-
|
|
231
|
-
const match = line.text.match(LIST_ITEM)
|
|
232
|
-
if (!match) return false
|
|
233
|
-
indents.add(match[1].length)
|
|
234
|
-
items++
|
|
235
|
-
characters += text.length
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (indents.size !== 1) return false
|
|
239
|
-
|
|
240
|
-
return characters / items < PEER_BULLET_CHECKPOINT
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Reports whether a run is a table, the second shape the checkpoint cannot fix.
|
|
245
|
-
*
|
|
246
|
-
* The peer list above is exempt because it is already navigable. A table is
|
|
247
|
-
* exempt for the other reason: the remedy does not exist. A heading dropped
|
|
248
|
-
* inside one splits the table into two tables rather than breaking the run, so
|
|
249
|
-
* a catalog renders as an unbroken stretch by construction and no edit short of
|
|
250
|
-
* rewriting it as a list clears the report.
|
|
251
|
-
*
|
|
252
|
-
* Every non-blank line has to be a row. A run holding a table between
|
|
253
|
-
* paragraphs is genuinely mixed, and a heading breaks it at a seam either side,
|
|
254
|
-
* so testing whether the run holds a table would hide the case the checkpoint
|
|
255
|
-
* exists for.
|
|
256
|
-
*
|
|
257
|
-
* A delimiter is required rather than assumed, matching the table scan below. A
|
|
258
|
-
* stack of lines opening with a pipe and no delimiter renders as paragraph text
|
|
259
|
-
* and would otherwise earn the exemption on its punctuation.
|
|
260
|
-
*/
|
|
261
|
-
function isTableRun(run: readonly BodyLine[]): boolean {
|
|
262
|
-
let separators = 0
|
|
263
|
-
|
|
264
|
-
for (const line of run) {
|
|
265
|
-
if (line.text.trim() === '') continue
|
|
266
|
-
if (!TABLE_ROW.test(line.text)) return false
|
|
267
|
-
if (TABLE_SEPARATOR.test(line.text)) separators++
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return separators > 0
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Height a source line occupies once wrapped.
|
|
275
|
-
*
|
|
276
|
-
* A blank line renders as the gap it is rather than as nothing, which keeps it
|
|
277
|
-
* the distance the source measure already counted it as.
|
|
278
|
-
*/
|
|
279
|
-
function renderedHeight(text: string): number {
|
|
280
|
-
return Math.max(1, Math.ceil(text.length / RENDER_WIDTH))
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* Measures the longest run of lines no heading breaks, in rendered lines.
|
|
285
|
-
*
|
|
286
|
-
* Fenced blocks are skipped rather than treated as breaks, per the standard:
|
|
287
|
-
* they leave the count without ending the run, so prose either side of an
|
|
288
|
-
* example still measures as the one stretch a reader scrolls through. Blank
|
|
289
|
-
* lines do count, since the checkpoint is about how far a reader travels
|
|
290
|
-
* between signposts and a blank line is distance like any other. A hand reader
|
|
291
|
-
* measuring without them lands one or two lines lower, which the report legend
|
|
292
|
-
* states.
|
|
293
|
-
*
|
|
294
|
-
* Height is what a reader travels, and source lines only stand in for it while
|
|
295
|
-
* lines stay short. An entry authored one line per bullet puts a paragraph on
|
|
296
|
-
* each, so a block of fifteen bullets measures as fifteen and renders past
|
|
297
|
-
* sixty. Wrapping every line at a stated width is what closes that gap.
|
|
298
|
-
*/
|
|
299
|
-
function longestRun(lines: readonly BodyLine[]): {
|
|
300
|
-
length: number
|
|
301
|
-
line: number
|
|
302
|
-
} {
|
|
303
|
-
let longest = 0
|
|
304
|
-
let longestLine = 0
|
|
305
|
-
let run: BodyLine[] = []
|
|
306
|
-
let fenced = false
|
|
307
|
-
|
|
308
|
-
const close = (): void => {
|
|
309
|
-
// The reported line is the run's first non-blank one, since that is what an
|
|
310
|
-
// editor should open. A run of nothing but blank lines is the gap between
|
|
311
|
-
// two headings rather than a stretch a reader travels, so it never counts.
|
|
312
|
-
const first = run.find((line) => line.text.trim() !== '')
|
|
313
|
-
|
|
314
|
-
if (first && !isScannablePeerList(run) && !isTableRun(run)) {
|
|
315
|
-
const height = run.reduce(
|
|
316
|
-
(sum, line) => sum + renderedHeight(line.text),
|
|
317
|
-
0,
|
|
318
|
-
)
|
|
319
|
-
|
|
320
|
-
if (height > longest) {
|
|
321
|
-
longest = height
|
|
322
|
-
longestLine = first.number
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
run = []
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
for (const line of lines) {
|
|
329
|
-
if (FENCE.test(line.text)) {
|
|
330
|
-
fenced = !fenced
|
|
331
|
-
continue
|
|
332
|
-
}
|
|
333
|
-
if (fenced) continue
|
|
334
|
-
|
|
335
|
-
if (HEADING.test(line.text)) {
|
|
336
|
-
close()
|
|
337
|
-
continue
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
run.push(line)
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
close()
|
|
344
|
-
|
|
345
|
-
return { length: longest, line: longestLine }
|
|
346
|
-
}
|
|
347
|
-
|
|
348
144
|
function firstCell(row: string): string {
|
|
349
145
|
return row.split('|').slice(1)[0] ?? ''
|
|
350
146
|
}
|
|
@@ -357,20 +153,15 @@ function firstCell(row: string): string {
|
|
|
357
153
|
* catalog that gains a row per artifact, and a first column carrying a path,
|
|
358
154
|
* command, or link is what separates the two without reading the prose.
|
|
359
155
|
*/
|
|
360
|
-
function catalogTables(
|
|
156
|
+
function catalogTables(entry: readonly BodyLine[]): TableFinding[] {
|
|
361
157
|
const findings: TableFinding[] = []
|
|
362
|
-
|
|
158
|
+
const lines = entry.filter((line) => !line.fenced)
|
|
363
159
|
let index = 0
|
|
364
160
|
|
|
365
161
|
while (index < lines.length) {
|
|
366
162
|
const line = lines[index]
|
|
367
163
|
|
|
368
|
-
if (
|
|
369
|
-
fenced = !fenced
|
|
370
|
-
index++
|
|
371
|
-
continue
|
|
372
|
-
}
|
|
373
|
-
if (fenced || !TABLE_ROW.test(line.text)) {
|
|
164
|
+
if (!TABLE_ROW.test(line.text)) {
|
|
374
165
|
index++
|
|
375
166
|
continue
|
|
376
167
|
}
|
|
@@ -402,60 +193,6 @@ function catalogTables(lines: readonly BodyLine[]): TableFinding[] {
|
|
|
402
193
|
return findings
|
|
403
194
|
}
|
|
404
195
|
|
|
405
|
-
/**
|
|
406
|
-
* Finds the top-level bullets carrying more than a decision.
|
|
407
|
-
*
|
|
408
|
-
* A nested item is left out rather than folded into its parent, since the
|
|
409
|
-
* checkpoint asks what one bullet carries and a child carries its own. Lines
|
|
410
|
-
* continuing a bullet do fold in, so a heavy bullet cannot fall under the
|
|
411
|
-
* checkpoint by being wrapped across two source lines. Fenced blocks are
|
|
412
|
-
* skipped for the reason the scans above skip them: a sample an entry displays
|
|
413
|
-
* is not a claim it makes.
|
|
414
|
-
*/
|
|
415
|
-
function heavyBullets(lines: readonly BodyLine[]): BulletFinding[] {
|
|
416
|
-
const findings: BulletFinding[] = []
|
|
417
|
-
let open: BulletFinding | null = null
|
|
418
|
-
let fenced = false
|
|
419
|
-
|
|
420
|
-
const close = (): void => {
|
|
421
|
-
if (open && open.characters > BULLET_CHECKPOINT) findings.push(open)
|
|
422
|
-
open = null
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
for (const line of lines) {
|
|
426
|
-
if (FENCE.test(line.text)) {
|
|
427
|
-
fenced = !fenced
|
|
428
|
-
close()
|
|
429
|
-
continue
|
|
430
|
-
}
|
|
431
|
-
if (fenced) continue
|
|
432
|
-
|
|
433
|
-
const item = line.text.match(LIST_ITEM)
|
|
434
|
-
const text = line.text.trim()
|
|
435
|
-
|
|
436
|
-
if (item) {
|
|
437
|
-
close()
|
|
438
|
-
if (item[1].length === 0) {
|
|
439
|
-
open = { line: line.number, characters: text.length }
|
|
440
|
-
}
|
|
441
|
-
continue
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
if (text === '' || HEADING.test(line.text) || TABLE_ROW.test(line.text)) {
|
|
445
|
-
close()
|
|
446
|
-
continue
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// The joining space a wrapped line would have carried, so folding two
|
|
450
|
-
// source lines measures what one unwrapped line would have.
|
|
451
|
-
if (open) open = { ...open, characters: open.characters + text.length + 1 }
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
close()
|
|
455
|
-
|
|
456
|
-
return findings
|
|
457
|
-
}
|
|
458
|
-
|
|
459
196
|
/**
|
|
460
197
|
* Finds the markers narrating a change rather than describing the domain.
|
|
461
198
|
*
|
|
@@ -470,14 +207,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
470
207
|
// holding a date and two change numbers reports them in an order the reader
|
|
471
208
|
// cannot find by scanning left to right.
|
|
472
209
|
const found: { finding: ProvenanceFinding; column: number }[] = []
|
|
473
|
-
let fenced = false
|
|
474
210
|
|
|
475
211
|
for (const line of lines) {
|
|
476
|
-
if (
|
|
477
|
-
fenced = !fenced
|
|
478
|
-
continue
|
|
479
|
-
}
|
|
480
|
-
if (fenced) continue
|
|
212
|
+
if (line.fenced) continue
|
|
481
213
|
|
|
482
214
|
for (const { kind, pattern } of PROVENANCE) {
|
|
483
215
|
for (const match of line.text.matchAll(pattern)) {
|
|
@@ -510,14 +242,9 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
510
242
|
*/
|
|
511
243
|
function declaredSections(lines: readonly BodyLine[]): string[] {
|
|
512
244
|
const found = new Set<string>()
|
|
513
|
-
let fenced = false
|
|
514
245
|
|
|
515
246
|
for (const line of lines) {
|
|
516
|
-
if (
|
|
517
|
-
fenced = !fenced
|
|
518
|
-
continue
|
|
519
|
-
}
|
|
520
|
-
if (fenced) continue
|
|
247
|
+
if (line.fenced) continue
|
|
521
248
|
|
|
522
249
|
const match = line.text.match(HEADING_TEXT)
|
|
523
250
|
if (match && REQUIRED_SECTIONS.includes(match[1])) found.add(match[1])
|
|
@@ -539,7 +266,6 @@ export function measureEntry(
|
|
|
539
266
|
governsContent = true,
|
|
540
267
|
): EntryReport {
|
|
541
268
|
const lines = bodyLines(source)
|
|
542
|
-
const run = longestRun(lines)
|
|
543
269
|
|
|
544
270
|
return {
|
|
545
271
|
rel,
|
|
@@ -547,11 +273,8 @@ export function measureEntry(
|
|
|
547
273
|
.replace(/\n$/, '')
|
|
548
274
|
.split('\n')
|
|
549
275
|
.reduce((sum, text) => sum + renderedHeight(text), 0),
|
|
550
|
-
longestRun: run.length,
|
|
551
|
-
longestRunLine: run.line,
|
|
552
276
|
catalogTables: catalogTables(lines),
|
|
553
277
|
provenance: governsContent ? provenance(lines) : [],
|
|
554
|
-
heavyBullets: heavyBullets(lines),
|
|
555
278
|
sections: governsContent ? declaredSections(lines) : [],
|
|
556
279
|
stub: isStubSeed(source),
|
|
557
280
|
}
|
package/src/context/citations.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
-
import {
|
|
5
|
-
import { gitEnv } from '@/git-env'
|
|
4
|
+
import { listRepositoryFiles } from '@/git-files'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Suppresses citation checking for the source line carrying it.
|
|
@@ -81,33 +80,6 @@ export function collectCitations(
|
|
|
81
80
|
return found
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
/**
|
|
85
|
-
* Lists the files a citation can live in: tracked, plus untracked files git
|
|
86
|
-
* does not ignore. The untracked half is what keeps a new entry's references
|
|
87
|
-
* checked on the branch that adds it rather than one push later.
|
|
88
|
-
*
|
|
89
|
-
* Returns undefined when git cannot answer, which the caller reports rather
|
|
90
|
-
* than smoothing into an empty list. An empty list resolves every one of its
|
|
91
|
-
* zero citations, so a degraded git would otherwise turn the push gate into an
|
|
92
|
-
* unconditional pass with output indistinguishable from a clean tree.
|
|
93
|
-
*/
|
|
94
|
-
async function listFiles(root: string): Promise<string[] | undefined> {
|
|
95
|
-
const run = async (args: string[]): Promise<string[] | undefined> => {
|
|
96
|
-
const result = await $`git -C ${root} ${args}`
|
|
97
|
-
.env(gitEnv())
|
|
98
|
-
.quiet()
|
|
99
|
-
.nothrow()
|
|
100
|
-
if (result.exitCode !== 0) return undefined
|
|
101
|
-
return result.text().split('\n').filter(Boolean)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const tracked = await run(['ls-files'])
|
|
105
|
-
const untracked = await run(['ls-files', '--others', '--exclude-standard'])
|
|
106
|
-
if (!tracked || !untracked) return undefined
|
|
107
|
-
|
|
108
|
-
return [...new Set([...tracked, ...untracked])].sort()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
83
|
/**
|
|
112
84
|
* `unavailable` is a distinct state from a clean scan.
|
|
113
85
|
*
|
|
@@ -136,7 +108,7 @@ export async function auditCitations(
|
|
|
136
108
|
folders: readonly string[],
|
|
137
109
|
): Promise<CitationReport> {
|
|
138
110
|
const pattern = citationPattern(folders)
|
|
139
|
-
const listed = await
|
|
111
|
+
const listed = await listRepositoryFiles(root)
|
|
140
112
|
if (!listed) return { kind: 'unavailable' }
|
|
141
113
|
|
|
142
114
|
const candidates = listed.filter((rel) => !isFixture(rel))
|
package/src/git-files.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lists the files under `root`: tracked, plus untracked files git does not
|
|
6
|
+
* ignore. The untracked half is what keeps a file added on this branch in scope
|
|
7
|
+
* rather than one push later.
|
|
8
|
+
*
|
|
9
|
+
* Returns undefined when git cannot answer, which every caller reports rather
|
|
10
|
+
* than smoothing into an empty list. An empty list passes each of its zero
|
|
11
|
+
* files, so a degraded git would otherwise be indistinguishable from a clean
|
|
12
|
+
* tree in the output.
|
|
13
|
+
*/
|
|
14
|
+
export async function listRepositoryFiles(
|
|
15
|
+
root: string,
|
|
16
|
+
): Promise<string[] | undefined> {
|
|
17
|
+
const run = async (args: string[]): Promise<string[] | undefined> => {
|
|
18
|
+
const result = await $`git -C ${root} ${args}`
|
|
19
|
+
.env(gitEnv())
|
|
20
|
+
.quiet()
|
|
21
|
+
.nothrow()
|
|
22
|
+
if (result.exitCode !== 0) return undefined
|
|
23
|
+
return result.text().split('\n').filter(Boolean)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tracked = await run(['ls-files'])
|
|
27
|
+
const untracked = await run(['ls-files', '--others', '--exclude-standard'])
|
|
28
|
+
if (!tracked || !untracked) return undefined
|
|
29
|
+
|
|
30
|
+
return [...new Set([...tracked, ...untracked])].sort()
|
|
31
|
+
}
|