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