@erclx/canon 4.81.0 → 4.83.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/docs-fold/REQUIREMENT.md +13 -0
- package/claude/skills/docs-fold/SKILL.md +20 -0
- package/claude/skills/docs-fold/references/classify.md +78 -0
- package/claude/skills/draft-slides/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +7 -4
- package/docs/agents/commands.md +8 -4
- package/docs/agents/context-audit-checks.md +22 -2
- package/docs/agents/context-audit.md +13 -3
- package/docs/agents/context-classify.md +99 -0
- package/docs/agents/index.md +1 -0
- package/docs/target-projects.md +15 -0
- package/docs/workflow/ai-workflow.md +4 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/520-wireframes.md +1 -1
- package/governance/rules/claude/545-decisions.md +12 -0
- package/package.json +1 -1
- package/src/claude/seeds.ts +1 -0
- package/src/commands/claude.ts +26 -5
- package/src/commands/context.ts +496 -2
- package/src/context/architecture.ts +73 -0
- package/src/context/audit.ts +18 -2
- package/src/context/classify/extract.ts +450 -0
- package/src/context/classify/ollama.ts +172 -0
- package/src/context/classify/patterns.ts +114 -0
- package/src/context/classify/prompts.ts +73 -0
- package/src/context/classify/run.ts +348 -0
- package/src/context/classify/settings.ts +196 -0
- package/src/context/folders.ts +1 -0
- package/src/context/gate.ts +44 -6
- package/src/context/wireframe-states.ts +238 -0
- package/src/surface-root.ts +1 -0
- package/standards/architecture.md +11 -1
- package/standards/context.md +4 -1
- package/standards/decisions.md +100 -0
- package/standards/design.md +6 -0
- package/standards/index.md +1 -0
- package/standards/requirements.md +2 -1
- package/standards/wireframes.md +45 -29
- package/tooling/claude/reference.md +2 -1
- package/tooling/claude/seeds/CLAUDE.md +2 -1
- package/tooling/claude/seeds/canon/decisions/index.md +8 -0
- package/tooling/claude/seeds/canon/wireframes/index.md +2 -2
|
@@ -57,11 +57,25 @@ export interface DecisionReport {
|
|
|
57
57
|
* claim some check happens to cover without the entry saying so.
|
|
58
58
|
*/
|
|
59
59
|
readonly checks: readonly string[]
|
|
60
|
+
/**
|
|
61
|
+
* The entry's own word count, read alongside the weight judgment the
|
|
62
|
+
* standard asks a session to make by reading the file rather than counting
|
|
63
|
+
* it. Never gates, per `standards/architecture.md`'s `## Length` section.
|
|
64
|
+
*/
|
|
65
|
+
readonly words: number
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
export interface ArchitectureReport {
|
|
63
69
|
readonly rel: string
|
|
64
70
|
readonly lines: number
|
|
71
|
+
/** The whole record's word count, reported for the same reason. */
|
|
72
|
+
readonly words: number
|
|
73
|
+
/**
|
|
74
|
+
* Word count of the `## Risks / open questions` section, absent when the
|
|
75
|
+
* record carries no such heading. The standard asks that section to hold
|
|
76
|
+
* only what is still open, so its weight is read the same way the file's is.
|
|
77
|
+
*/
|
|
78
|
+
readonly risksWords?: number
|
|
65
79
|
/** What the record declared, absent when it states no length rule. */
|
|
66
80
|
readonly allowances?: Allowances
|
|
67
81
|
/** The frame plus the per-decision allowance, absent alongside it. */
|
|
@@ -71,6 +85,8 @@ export interface ArchitectureReport {
|
|
|
71
85
|
|
|
72
86
|
const DECISION_HEADING = /^###\s+(.+?)\s*$/
|
|
73
87
|
const SECTION_HEADING = /^##\s+\S/
|
|
88
|
+
/** The one H2 the standard's `## Length` section asks to be weighed by words. */
|
|
89
|
+
const RISKS_HEADING = /^##\s+Risks\s*\/\s*open questions\s*$/i
|
|
74
90
|
const CODE_SPAN = /`[^`]*`/g
|
|
75
91
|
/** Dropped ahead of the figure scan, since an anchor date is not a claim. */
|
|
76
92
|
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
|
|
@@ -238,6 +254,54 @@ export function splitDecisions(source: string): RawDecision[] {
|
|
|
238
254
|
return decisions
|
|
239
255
|
}
|
|
240
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Counts whitespace-delimited tokens, which is the unit the standard's
|
|
259
|
+
* `## Length` section reads alongside the weight judgment a session makes by
|
|
260
|
+
* reading the file. It is a report figure rather than a gate, so a fenced
|
|
261
|
+
* example or a code span inflating the count costs nothing a reader corrects
|
|
262
|
+
* for by reading, the same trade the line count above it already takes.
|
|
263
|
+
*/
|
|
264
|
+
function wordCount(text: string): number {
|
|
265
|
+
return text.match(/\S+/g)?.length ?? 0
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extracts the `## Risks / open questions` section, or nothing when the
|
|
270
|
+
* record carries no such heading.
|
|
271
|
+
*
|
|
272
|
+
* The section is read the same way `splitDecisions` reads a decision: capture
|
|
273
|
+
* starts at the heading and stops at the next H2, or at the end of the file
|
|
274
|
+
* when the section is last, which is where the standard's template puts it.
|
|
275
|
+
*/
|
|
276
|
+
export function risksSection(source: string): string | undefined {
|
|
277
|
+
const lines = bodyLines(source)
|
|
278
|
+
const body: string[] = []
|
|
279
|
+
let capturing = false
|
|
280
|
+
let found = false
|
|
281
|
+
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
if (line.fenced) {
|
|
284
|
+
if (capturing) body.push(line.text)
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (RISKS_HEADING.test(line.text)) {
|
|
289
|
+
capturing = true
|
|
290
|
+
found = true
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (capturing && SECTION_HEADING.test(line.text)) {
|
|
295
|
+
capturing = false
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (capturing) body.push(line.text)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return found ? body.join('\n') : undefined
|
|
303
|
+
}
|
|
304
|
+
|
|
241
305
|
/** Cardinals a record spells rather than writes, which the corpus does for both. */
|
|
242
306
|
const SPELLED: Record<string, number> = {
|
|
243
307
|
one: 1,
|
|
@@ -332,13 +396,22 @@ export async function measureArchitecture(
|
|
|
332
396
|
figures,
|
|
333
397
|
...(quantified !== undefined && { quantified }),
|
|
334
398
|
checks: await namedChecks(root, entry.body),
|
|
399
|
+
words: wordCount(entry.body),
|
|
335
400
|
}
|
|
336
401
|
}),
|
|
337
402
|
)
|
|
338
403
|
|
|
404
|
+
const risks = risksSection(source)
|
|
405
|
+
|
|
339
406
|
return {
|
|
340
407
|
rel,
|
|
341
408
|
lines: source.replace(/\n$/, '').split('\n').length,
|
|
409
|
+
words: wordCount(
|
|
410
|
+
bodyLines(source)
|
|
411
|
+
.map((line) => line.text)
|
|
412
|
+
.join('\n'),
|
|
413
|
+
),
|
|
414
|
+
...(risks !== undefined && { risksWords: wordCount(risks) }),
|
|
342
415
|
...(allowances !== undefined && {
|
|
343
416
|
allowances,
|
|
344
417
|
ceiling: ceilingFor(allowances, raw.length),
|
package/src/context/audit.ts
CHANGED
|
@@ -77,6 +77,14 @@ const INSIDE_LIST = /^\s+\S/
|
|
|
77
77
|
* and which release labelled it. A marker is a judgment rather than a defect,
|
|
78
78
|
* so this is measured and reported and never gates.
|
|
79
79
|
*
|
|
80
|
+
* The change pattern matches any digit count rather than the three-plus this
|
|
81
|
+
* used to require, since a project young enough to sit in single or double
|
|
82
|
+
* digits, `PR #7`, went entirely unread under the old floor. Widening it reads
|
|
83
|
+
* a change reference wherever a document is scanned as prose, so `provenance`
|
|
84
|
+
* masks a line's displayed spans first, which is what keeps a heading's own
|
|
85
|
+
* auto-derived link destination, `(#7-open-questions)`, from reading as a
|
|
86
|
+
* change number: `#7` there names the heading, not a pull request.
|
|
87
|
+
*
|
|
80
88
|
* The release pattern accepts three segments without a leading `v`, since the
|
|
81
89
|
* standard cuts a release label rather than a spelling of one and `a CLI at
|
|
82
90
|
* 0.83.0` names a release exactly as `v0.83.0` does. Two segments still require
|
|
@@ -88,7 +96,7 @@ const INSIDE_LIST = /^\s+\S/
|
|
|
88
96
|
*/
|
|
89
97
|
const PROVENANCE: readonly { kind: ProvenanceKind; pattern: RegExp }[] = [
|
|
90
98
|
{ kind: 'date', pattern: /\b\d{4}-\d{2}-\d{2}\b/g },
|
|
91
|
-
{ kind: 'change', pattern: /#\d
|
|
99
|
+
{ kind: 'change', pattern: /#\d+\b/g },
|
|
92
100
|
{ kind: 'release', pattern: /\b(?:v\d+\.\d+(?:\.\d+)?|\d+\.\d+\.\d+)\b/g },
|
|
93
101
|
]
|
|
94
102
|
|
|
@@ -349,6 +357,12 @@ function catalogTables(entry: readonly BodyLine[]): TableFinding[] {
|
|
|
349
357
|
* rather than a claim it makes, and a version pinned in an install line is the
|
|
350
358
|
* ordinary shape of one.
|
|
351
359
|
*
|
|
360
|
+
* Displayed spans are masked for the same reason. A code span quoting a hex
|
|
361
|
+
* color, `#000`, and a link destination naming a heading's own anchor,
|
|
362
|
+
* `(#7-open-questions)`, both read as text the entry shows rather than a claim
|
|
363
|
+
* it makes, which is the same distinction the fence skip draws at the block
|
|
364
|
+
* level.
|
|
365
|
+
*
|
|
352
366
|
* A date stamping a measurement is dropped rather than reported under a kind of
|
|
353
367
|
* its own. One list with one meaning is what lets every consumer read it
|
|
354
368
|
* without filtering: the report names what the standard cuts, and the length
|
|
@@ -365,8 +379,10 @@ function provenance(lines: readonly BodyLine[]): ProvenanceFinding[] {
|
|
|
365
379
|
for (const line of lines) {
|
|
366
380
|
if (line.fenced) continue
|
|
367
381
|
|
|
382
|
+
const text = maskDisplayed(line.text)
|
|
383
|
+
|
|
368
384
|
for (const { kind, pattern } of PROVENANCE) {
|
|
369
|
-
for (const match of
|
|
385
|
+
for (const match of text.matchAll(pattern)) {
|
|
370
386
|
if (kind === 'date' && stampsMeasurement(line.text, match.index)) {
|
|
371
387
|
continue
|
|
372
388
|
}
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, relative, resolve } from 'node:path'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import { resolveFolders } from '@/context/folders'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
import { listChangedFiles, resolveBaseRef } from '@/git-files'
|
|
7
|
+
import { surfaceDir } from '@/surface-root'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The five canonical doc types the classifier reaches, matching the file
|
|
11
|
+
* pattern the groundwork spike scoped its fixture to
|
|
12
|
+
* (`scripts/hunks.py`'s `DOC_PATH`) and the file-type catalog both system
|
|
13
|
+
* prompts state.
|
|
14
|
+
*/
|
|
15
|
+
export const CANONICAL_DOC_TYPES = [
|
|
16
|
+
'context',
|
|
17
|
+
'architecture',
|
|
18
|
+
'wireframes',
|
|
19
|
+
'design',
|
|
20
|
+
'requirements',
|
|
21
|
+
] as const
|
|
22
|
+
|
|
23
|
+
export type CanonicalDocType = (typeof CANONICAL_DOC_TYPES)[number]
|
|
24
|
+
|
|
25
|
+
const DOC_PATTERNS: Record<CanonicalDocType, RegExp> = {
|
|
26
|
+
context: /(^|\/)context\/.+\.md$/,
|
|
27
|
+
architecture: /(^|\/)ARCHITECTURE\.md$/,
|
|
28
|
+
wireframes: /(^|\/)wireframes\/.+\.md$/,
|
|
29
|
+
design: /(^|\/)DESIGN\.md$/,
|
|
30
|
+
requirements: /(^|\/)REQUIREMENTS\.md$/,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const INDEX_NAME = 'index.md'
|
|
34
|
+
|
|
35
|
+
/** The two doc types `resolveFolders` discovers, paired with the folder name it takes. */
|
|
36
|
+
const MULTI_DOC_TYPES: readonly { type: CanonicalDocType; name: string }[] = [
|
|
37
|
+
{ type: 'context', name: 'context' },
|
|
38
|
+
{ type: 'wireframes', name: 'wireframes' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** The three doc types that resolve to one file each, paired with `surfaceDir`'s entry name. */
|
|
42
|
+
const SINGLE_DOC_TYPES: readonly { type: CanonicalDocType; entry: string }[] = [
|
|
43
|
+
{ type: 'architecture', entry: 'ARCHITECTURE.md' },
|
|
44
|
+
{ type: 'design', entry: 'DESIGN.md' },
|
|
45
|
+
{ type: 'requirements', entry: 'REQUIREMENTS.md' },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Which canonical doc type a repo-relative path belongs to, regardless of
|
|
50
|
+
* which surface root (`canon/` or `.claude/`) it resolved under.
|
|
51
|
+
*/
|
|
52
|
+
export function docTypeOf(file: string): CanonicalDocType | undefined {
|
|
53
|
+
if (basename(file) === INDEX_NAME) return undefined
|
|
54
|
+
|
|
55
|
+
return CANONICAL_DOC_TYPES.find((type) => DOC_PATTERNS[type].test(file))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A hunk of 25 words or more of added text, the groundwork fixture's own
|
|
60
|
+
* floor (`hunks.py`'s `MIN_ADDED_WORDS`). A trivial edit, a typo fix, a
|
|
61
|
+
* one-line link repair, is not worth a model call or a regex read, and the
|
|
62
|
+
* spike's own labelled set never sampled anything smaller.
|
|
63
|
+
*/
|
|
64
|
+
export const MIN_ADDED_WORDS = 25
|
|
65
|
+
|
|
66
|
+
/** `hunks.py`'s `MAX_SECTION_WORDS`, so a chunk's context never blows the model's context window. */
|
|
67
|
+
const MAX_SECTION_WORDS = 900
|
|
68
|
+
|
|
69
|
+
export interface DiffChunk {
|
|
70
|
+
/** Repo-relative path. */
|
|
71
|
+
readonly file: string
|
|
72
|
+
readonly docType: CanonicalDocType
|
|
73
|
+
/** Empty string when the hunk added text with nothing removed. */
|
|
74
|
+
readonly removed: string
|
|
75
|
+
readonly added: string
|
|
76
|
+
/** The heading-delimited section the hunk's added lines landed in, current content. */
|
|
77
|
+
readonly sectionAfter: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ExtractRefusal = 'bad-range' | 'unreadable-file'
|
|
81
|
+
|
|
82
|
+
export type DiffExtraction =
|
|
83
|
+
| { readonly kind: 'ok'; readonly chunks: readonly DiffChunk[] }
|
|
84
|
+
| {
|
|
85
|
+
readonly kind: 'refused'
|
|
86
|
+
readonly reason: ExtractRefusal
|
|
87
|
+
readonly message: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function gitRaw(
|
|
91
|
+
root: string,
|
|
92
|
+
args: string[],
|
|
93
|
+
): Promise<string | undefined> {
|
|
94
|
+
const result = await $`git -C ${root} ${args}`.env(gitEnv()).quiet().nothrow()
|
|
95
|
+
return result.exitCode === 0 ? result.text() : undefined
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface RawHunk {
|
|
99
|
+
/** 1-based line number in the new (working tree) file where the hunk starts. */
|
|
100
|
+
readonly newStart: number
|
|
101
|
+
readonly added: readonly string[]
|
|
102
|
+
readonly removed: readonly string[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads a single-file, zero-context unified diff into its hunks. Ported from
|
|
109
|
+
* `hunks.py`'s header-and-accumulate loop.
|
|
110
|
+
*/
|
|
111
|
+
function parseHunks(diffText: string): RawHunk[] {
|
|
112
|
+
const hunks: { newStart: number; added: string[]; removed: string[] }[] = []
|
|
113
|
+
|
|
114
|
+
for (const line of diffText.split('\n')) {
|
|
115
|
+
const header = HUNK_HEADER.exec(line)
|
|
116
|
+
if (header) {
|
|
117
|
+
hunks.push({ newStart: Number(header[1]), added: [], removed: [] })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const current = hunks.at(-1)
|
|
122
|
+
if (!current) continue
|
|
123
|
+
|
|
124
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
125
|
+
current.added.push(line.slice(1))
|
|
126
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
127
|
+
current.removed.push(line.slice(1))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return hunks
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function wordCount(text: string): number {
|
|
135
|
+
return text.split(/\s+/).filter(Boolean).length
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const HEADING = /^(#{1,6})\s/
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The heading-delimited section a zero-based line index sits in, in the
|
|
142
|
+
* current file content: the nearest heading at or above the line, through
|
|
143
|
+
* the next heading at the same level or shallower. Ported from `hunks.py`'s
|
|
144
|
+
* `_section_around`.
|
|
145
|
+
*
|
|
146
|
+
* Returns an empty string when the index sits above every heading, which a
|
|
147
|
+
* malformed doc or one carrying no heading at all can produce and which the
|
|
148
|
+
* caller reads as "no section to show," never as a refusal.
|
|
149
|
+
*/
|
|
150
|
+
export function sectionAround(
|
|
151
|
+
lines: readonly string[],
|
|
152
|
+
lineIndex: number,
|
|
153
|
+
): string {
|
|
154
|
+
let headingIndex: number | undefined
|
|
155
|
+
for (let j = Math.min(lineIndex, lines.length - 1); j >= 0; j--) {
|
|
156
|
+
if (HEADING.test(lines[j])) {
|
|
157
|
+
headingIndex = j
|
|
158
|
+
break
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (headingIndex === undefined) return ''
|
|
162
|
+
|
|
163
|
+
const level = HEADING.exec(lines[headingIndex])?.[1].length ?? 1
|
|
164
|
+
let end = lines.length
|
|
165
|
+
for (let k = headingIndex + 1; k < lines.length; k++) {
|
|
166
|
+
const match = HEADING.exec(lines[k])
|
|
167
|
+
if (match && match[1].length <= level) {
|
|
168
|
+
end = k
|
|
169
|
+
break
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const section = lines.slice(headingIndex, end).join('\n')
|
|
174
|
+
const words = section.split(/\s+/).filter(Boolean)
|
|
175
|
+
if (words.length <= MAX_SECTION_WORDS) return section
|
|
176
|
+
|
|
177
|
+
return `${words.slice(0, MAX_SECTION_WORDS).join(' ')} …[section truncated]`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Every diff-mode chunk in the range `ref..working tree`, across the
|
|
182
|
+
* requested canonical doc types.
|
|
183
|
+
*
|
|
184
|
+
* Reads the working tree rather than `HEAD`, matching `listChangedFiles`: a
|
|
185
|
+
* `docs-fold` run checking a session's own edits sees them before they are
|
|
186
|
+
* committed. A file the range deleted contributes nothing, since there is no
|
|
187
|
+
* current section left to read a hunk's chunk against.
|
|
188
|
+
*/
|
|
189
|
+
export async function extractDiffChunks(
|
|
190
|
+
root: string,
|
|
191
|
+
ref: string | undefined,
|
|
192
|
+
docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
|
|
193
|
+
): Promise<DiffExtraction> {
|
|
194
|
+
const base = await resolveBaseRef(root, ref)
|
|
195
|
+
if (base === undefined) {
|
|
196
|
+
return {
|
|
197
|
+
kind: 'refused',
|
|
198
|
+
reason: 'bad-range',
|
|
199
|
+
message: `could not resolve a merge base for ${ref ?? 'the trunk'}`,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const changed = await listChangedFiles(root, base)
|
|
204
|
+
if (changed === undefined) {
|
|
205
|
+
return {
|
|
206
|
+
kind: 'refused',
|
|
207
|
+
reason: 'bad-range',
|
|
208
|
+
message: 'git could not list the files changed since the base',
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const scoped = changed
|
|
213
|
+
.map((file) => ({ file, docType: docTypeOf(file) }))
|
|
214
|
+
.filter(
|
|
215
|
+
(entry): entry is { file: string; docType: CanonicalDocType } =>
|
|
216
|
+
entry.docType !== undefined && docTypes.includes(entry.docType),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const chunks: DiffChunk[] = []
|
|
220
|
+
|
|
221
|
+
for (const { file, docType } of scoped) {
|
|
222
|
+
const absolute = resolve(root, file)
|
|
223
|
+
if (!existsSync(absolute)) continue
|
|
224
|
+
|
|
225
|
+
let content: string
|
|
226
|
+
try {
|
|
227
|
+
content = readFileSync(absolute, 'utf8')
|
|
228
|
+
} catch {
|
|
229
|
+
return {
|
|
230
|
+
kind: 'refused',
|
|
231
|
+
reason: 'unreadable-file',
|
|
232
|
+
message: `could not read ${file}`,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const patch = await gitRaw(root, ['diff', '-U0', base, '--', file])
|
|
237
|
+
if (patch === undefined) continue
|
|
238
|
+
|
|
239
|
+
const lines = content.split('\n')
|
|
240
|
+
for (const hunk of parseHunks(patch)) {
|
|
241
|
+
const added = hunk.added.join('\n')
|
|
242
|
+
if (wordCount(added) < MIN_ADDED_WORDS) continue
|
|
243
|
+
|
|
244
|
+
chunks.push({
|
|
245
|
+
file,
|
|
246
|
+
docType,
|
|
247
|
+
removed: hunk.removed.join('\n'),
|
|
248
|
+
added,
|
|
249
|
+
sectionAfter: sectionAround(lines, hunk.newStart - 1),
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { kind: 'ok', chunks }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface SweepSection {
|
|
258
|
+
/** Repo-relative path. */
|
|
259
|
+
readonly file: string
|
|
260
|
+
readonly docType: CanonicalDocType
|
|
261
|
+
readonly heading: string
|
|
262
|
+
readonly body: string
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Splits a file's headings into sweep sections, at H3 where a document
|
|
267
|
+
* carries H3 headings and at H2 otherwise, per the groundwork decision that
|
|
268
|
+
* a large H2 section splits further. An H2 carrying its own prose ahead of
|
|
269
|
+
* its first child H3 contributes that prose as a section of its own, headed
|
|
270
|
+
* by the H2, since it is content nothing else will sweep.
|
|
271
|
+
*/
|
|
272
|
+
export function sweepSections(
|
|
273
|
+
lines: readonly string[],
|
|
274
|
+
): { heading: string; body: string }[] {
|
|
275
|
+
const headings: { index: number; level: number }[] = []
|
|
276
|
+
for (let i = 0; i < lines.length; i++) {
|
|
277
|
+
const match = HEADING.exec(lines[i])
|
|
278
|
+
if (match && match[1].length >= 2 && match[1].length <= 3) {
|
|
279
|
+
headings.push({ index: i, level: match[1].length })
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const sections: { heading: string; body: string }[] = []
|
|
284
|
+
|
|
285
|
+
// An H3 sitting before the document's first H2, or in a document that
|
|
286
|
+
// carries no H2 at all, has no enclosing section for the main loop below
|
|
287
|
+
// to attribute it to. Sweep it as a section of its own instead of
|
|
288
|
+
// dropping it, which is the shape `canon/context/development/scratch.md`
|
|
289
|
+
// takes: an H1 followed directly by eight H3s and no H2 anywhere.
|
|
290
|
+
const firstH2 = headings.findIndex((h) => h.level === 2)
|
|
291
|
+
const leadingBound = firstH2 === -1 ? headings.length : firstH2
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < leadingBound; i++) {
|
|
294
|
+
const { index, level } = headings[i]
|
|
295
|
+
if (level !== 3) continue
|
|
296
|
+
|
|
297
|
+
const nextBoundary = headings.find((h, k) => k > i && h.level <= 3)
|
|
298
|
+
const end = nextBoundary?.index ?? lines.length
|
|
299
|
+
sections.push(bodyOf(lines, index, end))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (let i = 0; i < headings.length; i++) {
|
|
303
|
+
const { index, level } = headings[i]
|
|
304
|
+
if (level !== 2) continue
|
|
305
|
+
|
|
306
|
+
const nextH2 = headings.findIndex((h, j) => j > i && h.level === 2)
|
|
307
|
+
const sectionEnd = nextH2 === -1 ? lines.length : headings[nextH2].index
|
|
308
|
+
|
|
309
|
+
const firstChildH3 = headings.findIndex(
|
|
310
|
+
(h, j) => j > i && h.level === 3 && h.index < sectionEnd,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if (firstChildH3 === -1) {
|
|
314
|
+
sections.push(bodyOf(lines, index, sectionEnd))
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (
|
|
319
|
+
firstChildH3 > i + 1 ||
|
|
320
|
+
hasContent(lines, index + 1, headings[firstChildH3].index)
|
|
321
|
+
) {
|
|
322
|
+
sections.push(bodyOf(lines, index, headings[firstChildH3].index))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
for (let j = firstChildH3; j < headings.length; j++) {
|
|
326
|
+
const { index: h3Index, level: h3Level } = headings[j]
|
|
327
|
+
if (h3Index >= sectionEnd) break
|
|
328
|
+
if (h3Level !== 3) continue
|
|
329
|
+
|
|
330
|
+
const nextBoundary = headings.find(
|
|
331
|
+
(h, k) => k > j && h.index < sectionEnd && h.level <= 3,
|
|
332
|
+
)
|
|
333
|
+
const end = nextBoundary?.index ?? sectionEnd
|
|
334
|
+
sections.push(bodyOf(lines, h3Index, end))
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return sections
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function hasContent(
|
|
342
|
+
lines: readonly string[],
|
|
343
|
+
start: number,
|
|
344
|
+
end: number,
|
|
345
|
+
): boolean {
|
|
346
|
+
return lines.slice(start, end).some((line) => line.trim() !== '')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function bodyOf(
|
|
350
|
+
lines: readonly string[],
|
|
351
|
+
start: number,
|
|
352
|
+
end: number,
|
|
353
|
+
): { heading: string; body: string } {
|
|
354
|
+
const headingText = lines[start].replace(/^#+\s*/, '').trim()
|
|
355
|
+
return {
|
|
356
|
+
heading: headingText,
|
|
357
|
+
body: lines.slice(start, end).join('\n').trim(),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export type SweepExtraction =
|
|
362
|
+
| { readonly kind: 'ok'; readonly sections: readonly SweepSection[] }
|
|
363
|
+
| {
|
|
364
|
+
readonly kind: 'refused'
|
|
365
|
+
readonly reason: ExtractRefusal
|
|
366
|
+
readonly message: string
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function readSections(
|
|
370
|
+
path: string,
|
|
371
|
+
docType: CanonicalDocType,
|
|
372
|
+
rel: string,
|
|
373
|
+
): SweepSection[] | undefined {
|
|
374
|
+
let content: string
|
|
375
|
+
try {
|
|
376
|
+
content = readFileSync(path, 'utf8')
|
|
377
|
+
} catch {
|
|
378
|
+
return undefined
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return sweepSections(content.split('\n')).map((section) => ({
|
|
382
|
+
file: rel,
|
|
383
|
+
docType,
|
|
384
|
+
...section,
|
|
385
|
+
}))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Every sweep-mode section across the requested canonical doc types.
|
|
390
|
+
*
|
|
391
|
+
* `context` and `wireframes` resolve through `resolveFolders`, the same
|
|
392
|
+
* folder discovery `canon context audit` uses, so a domain split into a
|
|
393
|
+
* folder of its own is swept the same way it is audited. The three
|
|
394
|
+
* single-file types resolve through `surfaceDir`, agreeing with
|
|
395
|
+
* `architectureRel`'s own resolution.
|
|
396
|
+
*/
|
|
397
|
+
export async function extractSweepSections(
|
|
398
|
+
root: string,
|
|
399
|
+
docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
|
|
400
|
+
): Promise<SweepExtraction> {
|
|
401
|
+
const sections: SweepSection[] = []
|
|
402
|
+
|
|
403
|
+
const multi = MULTI_DOC_TYPES.filter((entry) => docTypes.includes(entry.type))
|
|
404
|
+
|
|
405
|
+
if (multi.length > 0) {
|
|
406
|
+
const { folders } = await resolveFolders(
|
|
407
|
+
root,
|
|
408
|
+
multi.map((entry) => entry.name),
|
|
409
|
+
)
|
|
410
|
+
for (const folder of folders) {
|
|
411
|
+
const docType = multi.find((entry) => entry.name === folder.name)?.type
|
|
412
|
+
if (!docType) continue
|
|
413
|
+
|
|
414
|
+
for (const path of folder.entries) {
|
|
415
|
+
const rel = relative(root, path)
|
|
416
|
+
const found = readSections(path, docType, rel)
|
|
417
|
+
if (found === undefined) {
|
|
418
|
+
return {
|
|
419
|
+
kind: 'refused',
|
|
420
|
+
reason: 'unreadable-file',
|
|
421
|
+
message: `could not read ${rel}`,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
sections.push(...found)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const single = SINGLE_DOC_TYPES.filter((candidate) =>
|
|
430
|
+
docTypes.includes(candidate.type),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
for (const { type, entry } of single) {
|
|
434
|
+
const path = surfaceDir(root, entry)
|
|
435
|
+
if (!existsSync(path)) continue
|
|
436
|
+
|
|
437
|
+
const rel = relative(root, path)
|
|
438
|
+
const found = readSections(path, type, rel)
|
|
439
|
+
if (found === undefined) {
|
|
440
|
+
return {
|
|
441
|
+
kind: 'refused',
|
|
442
|
+
reason: 'unreadable-file',
|
|
443
|
+
message: `could not read ${rel}`,
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
sections.push(...found)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return { kind: 'ok', sections }
|
|
450
|
+
}
|