@erclx/canon 5.63.0 → 5.65.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/CHANGELOG.md +26 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/context-fold/REQUIREMENT.md +2 -3
- package/claude/skills/context-fold/SKILL.md +1 -11
- package/claude/skills/context-fold/references/completion-report.md +8 -9
- package/claude/skills/create-skill/references/skill-practice.md +16 -1
- package/claude/skills/design-taste/references/adopted.md +17 -97
- package/claude/skills/design-taste/references/tells.md +2 -2
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/SKILL.md +3 -14
- package/claude/skills/document-health/references/axes.md +14 -16
- package/claude/skills/test-craft/references/e2e.md +1 -1
- package/docs/agents/audits.md +2 -6
- package/docs/agents/context-audit.md +1 -1
- package/governance/ledger/skills/api-design/SKILL.md +62 -0
- package/governance/ledger/skills/code-craft/SKILL.md +53 -0
- package/governance/ledger/skills/codebase-layout/SKILL.md +64 -0
- package/governance/ledger/skills/codebase-layout/frontend.md +62 -0
- package/governance/ledger/skills/codebase-layout/tests.md +26 -0
- package/governance/ledger/skills/deprecation-migration/SKILL.md +68 -0
- package/governance/ledger/skills/design-taste/SKILL.md +64 -0
- package/governance/ledger/skills/design-taste/craft.md +66 -0
- package/governance/ledger/skills/design-taste/kinds.md +58 -0
- package/governance/ledger/skills/design-taste/systems.md +42 -0
- package/governance/ledger/skills/design-taste/tells.md +103 -0
- package/governance/ledger/skills/review-craft/SKILL.md +61 -0
- package/governance/ledger/skills/review-craft/security.md +34 -0
- package/governance/ledger/skills/teach-craft/SKILL.md +33 -0
- package/governance/ledger/skills/teach-craft/lesson-craft.md +47 -0
- package/governance/ledger/skills/teach-craft/pedagogy.md +77 -0
- package/governance/ledger/skills/test-craft/SKILL.md +67 -0
- package/governance/ledger/skills/test-craft/component.md +49 -0
- package/governance/ledger/skills/test-craft/e2e.md +55 -0
- package/governance/ledger/skills/test-craft/unit.md +54 -0
- package/package.json +3 -3
- package/src/audits/catalog.ts +0 -52
- package/src/claude/skills-audit.ts +124 -41
- package/src/cli/retired.ts +8 -0
- package/src/commands/claude.ts +11 -2
- package/src/commands/context.ts +0 -425
- package/src/commands/sandbox.ts +247 -2
- package/src/context/citations.ts +9 -3
- package/src/gate/measures.ts +4 -1
- package/src/gate/stages.ts +25 -0
- package/src/git/files.ts +8 -3
- package/src/markdown/gated.ts +1 -1
- package/src/markdown/scope.ts +0 -1
- package/src/migrate/records.ts +6 -0
- package/src/sandbox/replay/expected.ts +137 -0
- package/src/sandbox/replay/run.ts +323 -0
- package/src/sandbox/replay/verdict.ts +102 -0
- package/tooling/astro/configs/playwright.config.ts +2 -2
- package/tooling/nextjs/configs/playwright.config.ts +2 -2
- package/tooling/vite-react/configs/playwright.config.ts +2 -2
- package/claude/skills/context-fold/references/classify.md +0 -87
- package/src/context/classify/extract.ts +0 -443
- package/src/context/classify/ollama.ts +0 -172
- package/src/context/classify/patterns.ts +0 -75
- package/src/context/classify/prompts.ts +0 -74
- package/src/context/classify/run.ts +0 -348
- package/src/context/classify/settings.ts +0 -196
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one step of a replay concluded. A finding or a refusal is the verb
|
|
3
|
+
* working, so it reads `ok`. Only a run that broke rather than answered fails:
|
|
4
|
+
* a `crash` threw or died, an `unreadable` run answered in a shape a caller
|
|
5
|
+
* cannot parse, and an `unformatted` tree holds a file sync wrote that the
|
|
6
|
+
* formatter canon ships rejects. A `drifted` read answered a frozen snapshot
|
|
7
|
+
* differently from the answer recorded beside it, which `judgeDrift` decides.
|
|
8
|
+
*/
|
|
9
|
+
export type ReplayVerdict =
|
|
10
|
+
| 'ok'
|
|
11
|
+
| 'crash'
|
|
12
|
+
| 'unreadable'
|
|
13
|
+
| 'unformatted'
|
|
14
|
+
| 'drifted'
|
|
15
|
+
|
|
16
|
+
export interface StepOutput {
|
|
17
|
+
/** Null when the process ended on a signal, a timeout included. */
|
|
18
|
+
readonly status: number | null
|
|
19
|
+
readonly signal: string | null
|
|
20
|
+
readonly stdout: string
|
|
21
|
+
readonly stderr: string
|
|
22
|
+
/** Whether the step asked for `--json`, which makes stdout a contract. */
|
|
23
|
+
readonly json: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Judgement {
|
|
27
|
+
readonly verdict: ReplayVerdict
|
|
28
|
+
readonly detail?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A JavaScript stack frame, `at fn (file:line:col)` or a bare `at file:line:col`. */
|
|
32
|
+
const STACK_FRAME = /^\s+at .*:\d+:\d+\)?$/m
|
|
33
|
+
|
|
34
|
+
/** The line naming what was thrown, `TypeError: ...` or a system code such as `ENOENT: ...`. */
|
|
35
|
+
const THROWN_LINE = /^(?:error: |[A-Z][A-Za-z]*Error: |E[A-Z]+: ).*$/m
|
|
36
|
+
|
|
37
|
+
function crashOf(output: StepOutput): Judgement | undefined {
|
|
38
|
+
if (output.signal !== null)
|
|
39
|
+
return { verdict: 'crash', detail: `killed by ${output.signal}` }
|
|
40
|
+
if (!STACK_FRAME.test(output.stderr)) return undefined
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
verdict: 'crash',
|
|
44
|
+
detail: THROWN_LINE.exec(output.stderr)?.[0] ?? 'stack trace on stderr',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function judgeStep(output: StepOutput): Judgement {
|
|
49
|
+
const crash = crashOf(output)
|
|
50
|
+
if (crash !== undefined) return crash
|
|
51
|
+
if (!output.json) return { verdict: 'ok' }
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
JSON.parse(output.stdout)
|
|
55
|
+
} catch {
|
|
56
|
+
const first = output.stdout.trim().split('\n')[0]
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
verdict: 'unreadable',
|
|
60
|
+
detail:
|
|
61
|
+
first === undefined || first === ''
|
|
62
|
+
? 'empty stdout'
|
|
63
|
+
: `stdout does not parse: ${first.slice(0, 120)}`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { verdict: 'ok' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The warnings a step printed, each line the CLI marks `!` on a stream that is
|
|
72
|
+
* not a terminal. They never fail the replay, and carrying them is what lets a
|
|
73
|
+
* reader see a finding the verdict passed, such as a seed sync calls missing.
|
|
74
|
+
*/
|
|
75
|
+
export function findingsOf(stderr: string): string[] {
|
|
76
|
+
return stderr
|
|
77
|
+
.split('\n')
|
|
78
|
+
.filter((line) => line.startsWith('! '))
|
|
79
|
+
.map((line) => line.slice(2).trim())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** oxfmt lists each file it would change as `<path> (<n>ms)` under `--check`. */
|
|
83
|
+
const OXFMT_FILE = /^(\S.*) \(\d+m?s\)$/
|
|
84
|
+
|
|
85
|
+
export function judgeFormat(output: StepOutput): Judgement {
|
|
86
|
+
const crash = crashOf(output)
|
|
87
|
+
if (crash !== undefined) return crash
|
|
88
|
+
if (output.status === 0) return { verdict: 'ok' }
|
|
89
|
+
|
|
90
|
+
const files = output.stdout
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((line) => OXFMT_FILE.exec(line.trim())?.[1])
|
|
93
|
+
.filter((file): file is string => file !== undefined)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
verdict: 'unformatted',
|
|
97
|
+
detail:
|
|
98
|
+
files.length > 0
|
|
99
|
+
? files.join(', ')
|
|
100
|
+
: output.stderr.trim().split('\n')[0] || `exit ${output.status}`,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -14,8 +14,8 @@ export default defineConfig({
|
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
16
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
-
//
|
|
18
|
-
// `
|
|
17
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
18
|
+
// `contextOptions` is the spelling that works on every version.
|
|
19
19
|
contextOptions: { reducedMotion: 'reduce' },
|
|
20
20
|
},
|
|
21
21
|
projects: [
|
|
@@ -12,8 +12,8 @@ export default defineConfig({
|
|
|
12
12
|
trace: 'on-first-retry',
|
|
13
13
|
baseURL,
|
|
14
14
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
15
|
-
//
|
|
16
|
-
// `
|
|
15
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
16
|
+
// `contextOptions` is the spelling that works on every version.
|
|
17
17
|
contextOptions: { reducedMotion: 'reduce' },
|
|
18
18
|
},
|
|
19
19
|
projects: [
|
|
@@ -14,8 +14,8 @@ export default defineConfig({
|
|
|
14
14
|
trace: 'on-first-retry',
|
|
15
15
|
baseURL,
|
|
16
16
|
// Nothing to wait out unless a test asserts motion. Opt back in per test with page.emulateMedia({ reducedMotion: 'no-preference' }).
|
|
17
|
-
//
|
|
18
|
-
// `
|
|
17
|
+
// A top-level `use.reducedMotion` works from Playwright 1.50.
|
|
18
|
+
// `contextOptions` is the spelling that works on every version.
|
|
19
19
|
contextOptions: { reducedMotion: 'reduce' },
|
|
20
20
|
},
|
|
21
21
|
projects: [
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Classify the fold's diff baseline
|
|
3
|
-
description: The classify diff invocation, the record fields to read, applying a finding, the one-line keep reason, the unreachable and missing-subcommand lines, and the report shape
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Classify the fold's diff baseline
|
|
7
|
-
|
|
8
|
-
Mechanics for the classify half of Step 4 of `context-fold`. The body owns the skip condition and the shared baseline, and this file owns the invocation, what the record carries, and what applying a finding does.
|
|
9
|
-
|
|
10
|
-
## Invocation
|
|
11
|
-
|
|
12
|
-
Call the verb rather than reimplementing its pattern or prompt in this skill:
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
canon context classify diff --base <base> --json
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
Never substitute a different verb for it, such as `canon docs <name>` (a documentation lookup, not a classification).
|
|
19
|
-
|
|
20
|
-
Reuse the base the Diff baseline section already resolved for Steps 2 and 5 rather than resolving a second time. Never pick or configure a backend here. The project setting decides it, and this step reports `modelLayer` as the record returns it.
|
|
21
|
-
|
|
22
|
-
## Reading the record
|
|
23
|
-
|
|
24
|
-
Branch on the record's `decision` field, never on the exit code, which a shell function wrapping `canon` can flatten to zero.
|
|
25
|
-
|
|
26
|
-
- `decision: 'refused'`: a bad range or a file the verb could not read. Report the record's `message` and continue. See "When the verb is absent or refused" below.
|
|
27
|
-
- `decision: 'ok'`: the record carries `modelLayer` (`'off'`, `'ran'`, `'skipped-no-model'`, or `'skipped-unreachable'`) and `findings`.
|
|
28
|
-
|
|
29
|
-
Each finding carries `file`, a `verdict` of `KEEP`, `REPLACE`, `HISTORY`, or `MOVE`, and `decidedBy` (`'regex'` or `'model'`) naming which layer's reading won. Take the `quote` and `reason` from whichever of the finding's `regex` or `model` fields `decidedBy` names. Skip every `KEEP` finding: nothing decided against that text.
|
|
30
|
-
|
|
31
|
-
## Scope
|
|
32
|
-
|
|
33
|
-
Answer every non-`KEEP` finding the verb returns, not only the files Step 3 wrote this run, since a branch carrying a prior session's commits has doc edits the fold is equally responsible for.
|
|
34
|
-
|
|
35
|
-
The verb's own extraction already scopes to canonical doc types and reports nothing when the range carries none, so there is no narrower check to add here.
|
|
36
|
-
|
|
37
|
-
## Applying a finding
|
|
38
|
-
|
|
39
|
-
Locate the finding by its `quote` in the file's current content. The quote is often a narrow fragment rather than the whole clause it sits in, since the regex layer's own match is a short pattern hit, so the edit below targets the sentence or bullet the quote sits in rather than the literal substring alone.
|
|
40
|
-
|
|
41
|
-
- **Found exactly once.** Apply the verdict under its heading below.
|
|
42
|
-
- **Found more than once, or not found at all.** Report it as not located rather than guessing an occurrence or silently rewriting nothing. A model paraphrasing its quote is the usual cause.
|
|
43
|
-
|
|
44
|
-
Run the classifier once per fold. Do not re-run it after applying a finding to check the edit, since a second pass over what this step wrote is the loop the verb's own reference already warns against.
|
|
45
|
-
|
|
46
|
-
### REPLACE
|
|
47
|
-
|
|
48
|
-
Rewrite the sentence or bullet carrying the quote in place with the fact that now stands, per Step 3's rewrite-in-place rule. State what is current and drop what the quote restated, in the one edit.
|
|
49
|
-
|
|
50
|
-
### HISTORY
|
|
51
|
-
|
|
52
|
-
Rewrite the sentence or bullet carrying the quote to drop the narration and keep any current fact the same clause states. The quote narrates how the fact arrived rather than stating the fact, which a canonical doc excludes regardless of what wrote it.
|
|
53
|
-
|
|
54
|
-
A literal cut of the fragment alone would leave the rest of the clause grammatically stranded.
|
|
55
|
-
|
|
56
|
-
### MOVE
|
|
57
|
-
|
|
58
|
-
Do not cut. The verdict means correct content sits on the wrong surface, such as a rule written into a context entry that belongs in a path-scoped rule, and only the model layer returns it.
|
|
59
|
-
|
|
60
|
-
Report the finding instead, naming the surface the `reason` names as where the content belongs, and leave the file unedited. Cutting would delete content a fold with no model configured would never have flagged at all.
|
|
61
|
-
|
|
62
|
-
## Keeping a finding
|
|
63
|
-
|
|
64
|
-
Keep a non-`KEEP` finding without applying it only when the quote is itself the alternative a nearby decision states and lost, or the file already carries the current fact elsewhere. State the reason in one line in the report. A keep costs one line, where an incorrect apply costs a rewrite of prose nobody asked to change.
|
|
65
|
-
|
|
66
|
-
## When the verb is absent or refused
|
|
67
|
-
|
|
68
|
-
The verb ships with the CLI and this skill ships with the plugin, so a target holding an older installed binary carries no `context classify` subcommand. Never read a missing subcommand as a clean pass, which would report a run that never checked anything as one that found nothing.
|
|
69
|
-
|
|
70
|
-
| Case | Report, then continue |
|
|
71
|
-
| ------------------ | ------------------------------------------------------------------------------------ |
|
|
72
|
-
| Subcommand missing | `⚠ Classify: canon context classify not available on the installed binary. Skipped.` |
|
|
73
|
-
| Range refused | `⚠ Classify: <message>`, naming the verb's own message |
|
|
74
|
-
|
|
75
|
-
Neither case stops the fold. This step checks what the fold wrote, and a check that cannot run is not a reason to leave the fold's own output unshipped.
|
|
76
|
-
|
|
77
|
-
## Report
|
|
78
|
-
|
|
79
|
-
Print one line naming what ran, `✅ Classify: <n> findings, regex ran, model <modelLayer>`, then one line per non-`KEEP` finding:
|
|
80
|
-
|
|
81
|
-
- `✏ Rewrote: <file>, "<quote>" (<reason>)`
|
|
82
|
-
- `✂ Cut: <file>, "<quote>" (<reason>)`
|
|
83
|
-
- `➡ Move needed: <file>, "<quote>" (<reason>)`, for a `MOVE` this step reported rather than cut
|
|
84
|
-
- `⏭ Kept: <file>, "<quote>", <reason for keeping>`
|
|
85
|
-
- `⚠ Not located: <file>, "<quote>" not found or found more than once`
|
|
86
|
-
|
|
87
|
-
When every finding reads `KEEP`, output `✅ Classify: <n> findings, all KEEP` and nothing further.
|
|
@@ -1,443 +0,0 @@
|
|
|
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 '@/roots/surface'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The three 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
|
-
'design',
|
|
18
|
-
'requirements',
|
|
19
|
-
] as const
|
|
20
|
-
|
|
21
|
-
export type CanonicalDocType = (typeof CANONICAL_DOC_TYPES)[number]
|
|
22
|
-
|
|
23
|
-
const DOC_PATTERNS: Record<CanonicalDocType, RegExp> = {
|
|
24
|
-
context: /(^|\/)context\/.+\.md$/,
|
|
25
|
-
design: /(^|\/)DESIGN\.md$/,
|
|
26
|
-
requirements: /(^|\/)REQUIREMENTS\.md$/,
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const INDEX_NAME = 'index.md'
|
|
30
|
-
|
|
31
|
-
/** The doc type `resolveFolders` discovers, paired with the folder name it takes. */
|
|
32
|
-
const MULTI_DOC_TYPES: readonly { type: CanonicalDocType; name: string }[] = [
|
|
33
|
-
{ type: 'context', name: 'context' },
|
|
34
|
-
]
|
|
35
|
-
|
|
36
|
-
/** The two doc types that resolve to one file each, paired with `surfaceDir`'s entry name. */
|
|
37
|
-
const SINGLE_DOC_TYPES: readonly { type: CanonicalDocType; entry: string }[] = [
|
|
38
|
-
{ type: 'design', entry: 'DESIGN.md' },
|
|
39
|
-
{ type: 'requirements', entry: 'REQUIREMENTS.md' },
|
|
40
|
-
]
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Which canonical doc type a repo-relative path belongs to, regardless of
|
|
44
|
-
* which surface root (`canon/` or `.claude/`) it resolved under.
|
|
45
|
-
*/
|
|
46
|
-
export function docTypeOf(file: string): CanonicalDocType | undefined {
|
|
47
|
-
if (basename(file) === INDEX_NAME) return undefined
|
|
48
|
-
|
|
49
|
-
return CANONICAL_DOC_TYPES.find((type) => DOC_PATTERNS[type].test(file))
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* A hunk of 25 words or more of added text, the groundwork fixture's own
|
|
54
|
-
* floor (`hunks.py`'s `MIN_ADDED_WORDS`). A trivial edit, a typo fix, a
|
|
55
|
-
* one-line link repair, is not worth a model call or a regex read, and the
|
|
56
|
-
* spike's own labelled set never sampled anything smaller.
|
|
57
|
-
*/
|
|
58
|
-
export const MIN_ADDED_WORDS = 25
|
|
59
|
-
|
|
60
|
-
/** `hunks.py`'s `MAX_SECTION_WORDS`, so a chunk's context never blows the model's context window. */
|
|
61
|
-
const MAX_SECTION_WORDS = 900
|
|
62
|
-
|
|
63
|
-
export interface DiffChunk {
|
|
64
|
-
/** Repo-relative path. */
|
|
65
|
-
readonly file: string
|
|
66
|
-
readonly docType: CanonicalDocType
|
|
67
|
-
/** Empty string when the hunk added text with nothing removed. */
|
|
68
|
-
readonly removed: string
|
|
69
|
-
readonly added: string
|
|
70
|
-
/** The heading-delimited section the hunk's added lines landed in, current content. */
|
|
71
|
-
readonly sectionAfter: string
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export type ExtractRefusal = 'bad-range' | 'unreadable-file'
|
|
75
|
-
|
|
76
|
-
export type DiffExtraction =
|
|
77
|
-
| { readonly kind: 'ok'; readonly chunks: readonly DiffChunk[] }
|
|
78
|
-
| {
|
|
79
|
-
readonly kind: 'refused'
|
|
80
|
-
readonly reason: ExtractRefusal
|
|
81
|
-
readonly message: string
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async function gitRaw(
|
|
85
|
-
root: string,
|
|
86
|
-
args: string[],
|
|
87
|
-
): Promise<string | undefined> {
|
|
88
|
-
const result = await $`git -C ${root} ${args}`.env(gitEnv()).quiet().nothrow()
|
|
89
|
-
return result.exitCode === 0 ? result.text() : undefined
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface RawHunk {
|
|
93
|
-
/** 1-based line number in the new (working tree) file where the hunk starts. */
|
|
94
|
-
readonly newStart: number
|
|
95
|
-
readonly added: readonly string[]
|
|
96
|
-
readonly removed: readonly string[]
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Reads a single-file, zero-context unified diff into its hunks. Ported from
|
|
103
|
-
* `hunks.py`'s header-and-accumulate loop.
|
|
104
|
-
*/
|
|
105
|
-
function parseHunks(diffText: string): RawHunk[] {
|
|
106
|
-
const hunks: { newStart: number; added: string[]; removed: string[] }[] = []
|
|
107
|
-
|
|
108
|
-
for (const line of diffText.split('\n')) {
|
|
109
|
-
const header = HUNK_HEADER.exec(line)
|
|
110
|
-
if (header) {
|
|
111
|
-
hunks.push({ newStart: Number(header[1]), added: [], removed: [] })
|
|
112
|
-
continue
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const current = hunks.at(-1)
|
|
116
|
-
if (!current) continue
|
|
117
|
-
|
|
118
|
-
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
119
|
-
current.added.push(line.slice(1))
|
|
120
|
-
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
121
|
-
current.removed.push(line.slice(1))
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return hunks
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function wordCount(text: string): number {
|
|
129
|
-
return text.split(/\s+/).filter(Boolean).length
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const HEADING = /^(#{1,6})\s/
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The heading-delimited section a zero-based line index sits in, in the
|
|
136
|
-
* current file content: the nearest heading at or above the line, through
|
|
137
|
-
* the next heading at the same level or shallower. Ported from `hunks.py`'s
|
|
138
|
-
* `_section_around`.
|
|
139
|
-
*
|
|
140
|
-
* Returns an empty string when the index sits above every heading, which a
|
|
141
|
-
* malformed doc or one carrying no heading at all can produce and which the
|
|
142
|
-
* caller reads as "no section to show," never as a refusal.
|
|
143
|
-
*/
|
|
144
|
-
export function sectionAround(
|
|
145
|
-
lines: readonly string[],
|
|
146
|
-
lineIndex: number,
|
|
147
|
-
): string {
|
|
148
|
-
let headingIndex: number | undefined
|
|
149
|
-
for (let j = Math.min(lineIndex, lines.length - 1); j >= 0; j--) {
|
|
150
|
-
if (HEADING.test(lines[j])) {
|
|
151
|
-
headingIndex = j
|
|
152
|
-
break
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (headingIndex === undefined) return ''
|
|
156
|
-
|
|
157
|
-
const level = HEADING.exec(lines[headingIndex])?.[1].length ?? 1
|
|
158
|
-
let end = lines.length
|
|
159
|
-
for (let k = headingIndex + 1; k < lines.length; k++) {
|
|
160
|
-
const match = HEADING.exec(lines[k])
|
|
161
|
-
if (match && match[1].length <= level) {
|
|
162
|
-
end = k
|
|
163
|
-
break
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const section = lines.slice(headingIndex, end).join('\n')
|
|
168
|
-
const words = section.split(/\s+/).filter(Boolean)
|
|
169
|
-
if (words.length <= MAX_SECTION_WORDS) return section
|
|
170
|
-
|
|
171
|
-
return `${words.slice(0, MAX_SECTION_WORDS).join(' ')} …[section truncated]`
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Every diff-mode chunk in the range `ref..working tree`, across the
|
|
176
|
-
* requested canonical doc types.
|
|
177
|
-
*
|
|
178
|
-
* Reads the working tree rather than `HEAD`, matching `listChangedFiles`: a
|
|
179
|
-
* `context-fold` run checking a session's own edits sees them before they are
|
|
180
|
-
* committed. A file the range deleted contributes nothing, since there is no
|
|
181
|
-
* current section left to read a hunk's chunk against.
|
|
182
|
-
*/
|
|
183
|
-
export async function extractDiffChunks(
|
|
184
|
-
root: string,
|
|
185
|
-
ref: string | undefined,
|
|
186
|
-
docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
|
|
187
|
-
): Promise<DiffExtraction> {
|
|
188
|
-
const base = await resolveBaseRef(root, ref)
|
|
189
|
-
if (base === undefined) {
|
|
190
|
-
return {
|
|
191
|
-
kind: 'refused',
|
|
192
|
-
reason: 'bad-range',
|
|
193
|
-
message: `could not resolve a merge base for ${ref ?? 'the trunk'}`,
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const changed = await listChangedFiles(root, base)
|
|
198
|
-
if (changed === undefined) {
|
|
199
|
-
return {
|
|
200
|
-
kind: 'refused',
|
|
201
|
-
reason: 'bad-range',
|
|
202
|
-
message: 'git could not list the files changed since the base',
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const scoped = changed
|
|
207
|
-
.map((file) => ({ file, docType: docTypeOf(file) }))
|
|
208
|
-
.filter(
|
|
209
|
-
(entry): entry is { file: string; docType: CanonicalDocType } =>
|
|
210
|
-
entry.docType !== undefined && docTypes.includes(entry.docType),
|
|
211
|
-
)
|
|
212
|
-
|
|
213
|
-
const chunks: DiffChunk[] = []
|
|
214
|
-
|
|
215
|
-
for (const { file, docType } of scoped) {
|
|
216
|
-
const absolute = resolve(root, file)
|
|
217
|
-
if (!existsSync(absolute)) continue
|
|
218
|
-
|
|
219
|
-
let content: string
|
|
220
|
-
try {
|
|
221
|
-
content = readFileSync(absolute, 'utf8')
|
|
222
|
-
} catch {
|
|
223
|
-
return {
|
|
224
|
-
kind: 'refused',
|
|
225
|
-
reason: 'unreadable-file',
|
|
226
|
-
message: `could not read ${file}`,
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const patch = await gitRaw(root, ['diff', '-U0', base, '--', file])
|
|
231
|
-
if (patch === undefined) continue
|
|
232
|
-
|
|
233
|
-
const lines = content.split('\n')
|
|
234
|
-
for (const hunk of parseHunks(patch)) {
|
|
235
|
-
const added = hunk.added.join('\n')
|
|
236
|
-
if (wordCount(added) < MIN_ADDED_WORDS) continue
|
|
237
|
-
|
|
238
|
-
chunks.push({
|
|
239
|
-
file,
|
|
240
|
-
docType,
|
|
241
|
-
removed: hunk.removed.join('\n'),
|
|
242
|
-
added,
|
|
243
|
-
sectionAfter: sectionAround(lines, hunk.newStart - 1),
|
|
244
|
-
})
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return { kind: 'ok', chunks }
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export interface SweepSection {
|
|
252
|
-
/** Repo-relative path. */
|
|
253
|
-
readonly file: string
|
|
254
|
-
readonly docType: CanonicalDocType
|
|
255
|
-
readonly heading: string
|
|
256
|
-
readonly body: string
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Splits a file's headings into sweep sections, at H3 where a document
|
|
261
|
-
* carries H3 headings and at H2 otherwise, per the groundwork decision that
|
|
262
|
-
* a large H2 section splits further. An H2 carrying its own prose ahead of
|
|
263
|
-
* its first child H3 contributes that prose as a section of its own, headed
|
|
264
|
-
* by the H2, since it is content nothing else will sweep.
|
|
265
|
-
*/
|
|
266
|
-
export function sweepSections(
|
|
267
|
-
lines: readonly string[],
|
|
268
|
-
): { heading: string; body: string }[] {
|
|
269
|
-
const headings: { index: number; level: number }[] = []
|
|
270
|
-
for (let i = 0; i < lines.length; i++) {
|
|
271
|
-
const match = HEADING.exec(lines[i])
|
|
272
|
-
if (match && match[1].length >= 2 && match[1].length <= 3) {
|
|
273
|
-
headings.push({ index: i, level: match[1].length })
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
const sections: { heading: string; body: string }[] = []
|
|
278
|
-
|
|
279
|
-
// An H3 sitting before the document's first H2, or in a document that
|
|
280
|
-
// carries no H2 at all, has no enclosing section for the main loop below
|
|
281
|
-
// to attribute it to. Sweep it as a section of its own instead of
|
|
282
|
-
// dropping it, which is the shape of an entry whose H1 is followed
|
|
283
|
-
// directly by H3s with no H2 anywhere.
|
|
284
|
-
const firstH2 = headings.findIndex((h) => h.level === 2)
|
|
285
|
-
const leadingBound = firstH2 === -1 ? headings.length : firstH2
|
|
286
|
-
|
|
287
|
-
for (let i = 0; i < leadingBound; i++) {
|
|
288
|
-
const { index, level } = headings[i]
|
|
289
|
-
if (level !== 3) continue
|
|
290
|
-
|
|
291
|
-
const nextBoundary = headings.find((h, k) => k > i && h.level <= 3)
|
|
292
|
-
const end = nextBoundary?.index ?? lines.length
|
|
293
|
-
sections.push(bodyOf(lines, index, end))
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
for (let i = 0; i < headings.length; i++) {
|
|
297
|
-
const { index, level } = headings[i]
|
|
298
|
-
if (level !== 2) continue
|
|
299
|
-
|
|
300
|
-
const nextH2 = headings.findIndex((h, j) => j > i && h.level === 2)
|
|
301
|
-
const sectionEnd = nextH2 === -1 ? lines.length : headings[nextH2].index
|
|
302
|
-
|
|
303
|
-
const firstChildH3 = headings.findIndex(
|
|
304
|
-
(h, j) => j > i && h.level === 3 && h.index < sectionEnd,
|
|
305
|
-
)
|
|
306
|
-
|
|
307
|
-
if (firstChildH3 === -1) {
|
|
308
|
-
sections.push(bodyOf(lines, index, sectionEnd))
|
|
309
|
-
continue
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
firstChildH3 > i + 1 ||
|
|
314
|
-
hasContent(lines, index + 1, headings[firstChildH3].index)
|
|
315
|
-
) {
|
|
316
|
-
sections.push(bodyOf(lines, index, headings[firstChildH3].index))
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
for (let j = firstChildH3; j < headings.length; j++) {
|
|
320
|
-
const { index: h3Index, level: h3Level } = headings[j]
|
|
321
|
-
if (h3Index >= sectionEnd) break
|
|
322
|
-
if (h3Level !== 3) continue
|
|
323
|
-
|
|
324
|
-
const nextBoundary = headings.find(
|
|
325
|
-
(h, k) => k > j && h.index < sectionEnd && h.level <= 3,
|
|
326
|
-
)
|
|
327
|
-
const end = nextBoundary?.index ?? sectionEnd
|
|
328
|
-
sections.push(bodyOf(lines, h3Index, end))
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return sections
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function hasContent(
|
|
336
|
-
lines: readonly string[],
|
|
337
|
-
start: number,
|
|
338
|
-
end: number,
|
|
339
|
-
): boolean {
|
|
340
|
-
return lines.slice(start, end).some((line) => line.trim() !== '')
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function bodyOf(
|
|
344
|
-
lines: readonly string[],
|
|
345
|
-
start: number,
|
|
346
|
-
end: number,
|
|
347
|
-
): { heading: string; body: string } {
|
|
348
|
-
const headingText = lines[start].replace(/^#+\s*/, '').trim()
|
|
349
|
-
return {
|
|
350
|
-
heading: headingText,
|
|
351
|
-
body: lines.slice(start, end).join('\n').trim(),
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
export type SweepExtraction =
|
|
356
|
-
| { readonly kind: 'ok'; readonly sections: readonly SweepSection[] }
|
|
357
|
-
| {
|
|
358
|
-
readonly kind: 'refused'
|
|
359
|
-
readonly reason: ExtractRefusal
|
|
360
|
-
readonly message: string
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function readSections(
|
|
364
|
-
path: string,
|
|
365
|
-
docType: CanonicalDocType,
|
|
366
|
-
rel: string,
|
|
367
|
-
): SweepSection[] | undefined {
|
|
368
|
-
let content: string
|
|
369
|
-
try {
|
|
370
|
-
content = readFileSync(path, 'utf8')
|
|
371
|
-
} catch {
|
|
372
|
-
return undefined
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return sweepSections(content.split('\n')).map((section) => ({
|
|
376
|
-
file: rel,
|
|
377
|
-
docType,
|
|
378
|
-
...section,
|
|
379
|
-
}))
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Every sweep-mode section across the requested canonical doc types.
|
|
384
|
-
*
|
|
385
|
-
* `context` resolves through `resolveFolders`, the same
|
|
386
|
-
* folder discovery `canon context audit` uses, so a domain split into a
|
|
387
|
-
* folder of its own is swept the same way it is audited. The two
|
|
388
|
-
* single-file types resolve through `surfaceDir`.
|
|
389
|
-
*/
|
|
390
|
-
export async function extractSweepSections(
|
|
391
|
-
root: string,
|
|
392
|
-
docTypes: readonly CanonicalDocType[] = CANONICAL_DOC_TYPES,
|
|
393
|
-
): Promise<SweepExtraction> {
|
|
394
|
-
const sections: SweepSection[] = []
|
|
395
|
-
|
|
396
|
-
const multi = MULTI_DOC_TYPES.filter((entry) => docTypes.includes(entry.type))
|
|
397
|
-
|
|
398
|
-
if (multi.length > 0) {
|
|
399
|
-
const { folders } = await resolveFolders(
|
|
400
|
-
root,
|
|
401
|
-
multi.map((entry) => entry.name),
|
|
402
|
-
)
|
|
403
|
-
for (const folder of folders) {
|
|
404
|
-
const docType = multi.find((entry) => entry.name === folder.name)?.type
|
|
405
|
-
if (!docType) continue
|
|
406
|
-
|
|
407
|
-
for (const path of folder.entries) {
|
|
408
|
-
const rel = relative(root, path)
|
|
409
|
-
const found = readSections(path, docType, rel)
|
|
410
|
-
if (found === undefined) {
|
|
411
|
-
return {
|
|
412
|
-
kind: 'refused',
|
|
413
|
-
reason: 'unreadable-file',
|
|
414
|
-
message: `could not read ${rel}`,
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
sections.push(...found)
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const single = SINGLE_DOC_TYPES.filter((candidate) =>
|
|
423
|
-
docTypes.includes(candidate.type),
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
for (const { type, entry } of single) {
|
|
427
|
-
const path = surfaceDir(root, entry)
|
|
428
|
-
if (!existsSync(path)) continue
|
|
429
|
-
|
|
430
|
-
const rel = relative(root, path)
|
|
431
|
-
const found = readSections(path, type, rel)
|
|
432
|
-
if (found === undefined) {
|
|
433
|
-
return {
|
|
434
|
-
kind: 'refused',
|
|
435
|
-
reason: 'unreadable-file',
|
|
436
|
-
message: `could not read ${rel}`,
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
sections.push(...found)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
return { kind: 'ok', sections }
|
|
443
|
-
}
|