@erclx/canon 5.18.0 → 5.20.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/record-screencast/SKILL.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -3
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/task-board/REQUIREMENT.md +1 -0
- package/claude/skills/task-board/SKILL.md +6 -0
- package/claude/skills/task-board/references/decline.md +2 -0
- package/claude/skills/video-craft/REQUIREMENT.md +48 -0
- package/claude/skills/video-craft/SKILL.md +80 -0
- package/claude/skills/video-craft/references/adopted.md +66 -0
- package/docs/agents/canvas.md +4 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/demo.md +1 -1
- package/docs/agents/install-and-sync.md +17 -3
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/sandbox.md +8 -7
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/operating-model.md +9 -9
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/renames.toml +24 -0
- package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
- package/governance/rules/standards/context.md +15 -0
- package/governance/rules/standards/design.md +13 -0
- package/governance/rules/standards/diagrams.md +17 -0
- package/governance/rules/standards/groundwork.md +13 -0
- package/governance/rules/standards/intake.md +12 -0
- package/governance/rules/standards/markdown.md +15 -0
- package/governance/rules/standards/memory.md +12 -0
- package/governance/rules/standards/mermaid.md +15 -0
- package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
- package/governance/rules/standards/readme.md +12 -0
- package/governance/rules/standards/ready.md +13 -0
- package/governance/rules/standards/requirements.md +13 -0
- package/governance/rules/standards/rule.md +13 -0
- package/governance/rules/standards/session.md +13 -0
- package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
- package/governance/rules/standards/standard.md +13 -0
- package/governance/rules/standards/tasks.md +15 -0
- package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
- package/governance/rules/standards/wiki.md +13 -0
- package/governance/rules/standards/wireframes.md +14 -0
- package/governance/rules/writing/800-prose.md +1 -1
- package/governance/stacks/base.toml +4 -2
- package/package.json +2 -1
- package/src/canvas/capture.ts +228 -15
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/routing.ts +1 -1
- package/src/commands/canvas.ts +76 -2
- package/src/commands/gov.ts +19 -4
- package/src/commands/sandbox.ts +7 -0
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/gate/stages.ts +7 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/standard-rules.ts +179 -0
- package/src/sandbox/equivalence.ts +43 -14
- package/src/sandbox/stub-remote.ts +140 -0
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +5 -0
- package/standards/context.md +6 -0
- package/standards/design.md +3 -0
- package/standards/diagrams.md +8 -0
- package/standards/groundwork-spikes.md +66 -0
- package/standards/groundwork.md +5 -25
- package/standards/index.md +6 -2
- package/standards/intake.md +2 -0
- package/standards/markdown.md +6 -0
- package/standards/memory.md +2 -0
- package/standards/mermaid.md +6 -0
- package/standards/plan-lifecycle.md +36 -0
- package/standards/plan.md +13 -11
- package/standards/readme.md +2 -0
- package/standards/ready.md +4 -0
- package/standards/requirements.md +3 -0
- package/standards/rule.md +6 -1
- package/standards/session.md +4 -0
- package/standards/skill-paths.md +50 -0
- package/standards/skill.md +15 -23
- package/standards/standard.md +7 -1
- package/standards/tasks-decline.md +38 -0
- package/standards/tasks.md +8 -13
- package/standards/teach.md +5 -0
- package/standards/wiki.md +4 -0
- package/standards/wireframes.md +5 -0
- package/governance/rules/canon/610-context.md +0 -17
- package/governance/rules/canon/620-wireframes.md +0 -13
- package/governance/rules/canon/630-requirements.md +0 -12
- package/governance/rules/canon/650-design.md +0 -12
- package/governance/rules/canon/655-tasks.md +0 -13
- package/governance/rules/canon/656-groundwork.md +0 -11
- package/governance/rules/canon/657-intake.md +0 -11
- package/governance/rules/canon/659-memory.md +0 -11
- package/governance/rules/canon/660-diagrams.md +0 -19
- package/governance/rules/canon/662-session.md +0 -15
- package/governance/rules/canon/663-ready.md +0 -15
- package/governance/rules/claude/590-rule-authoring.md +0 -12
- package/governance/rules/writing/801-markdown.md +0 -14
- package/governance/rules/writing/802-mermaid.md +0 -14
- package/governance/rules/writing/880-readme.md +0 -11
- package/governance/rules/writing/891-standard-authoring.md +0 -12
package/src/commands/teach.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
2
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { LiveStream } from '@/serve/live'
|
|
3
5
|
import {
|
|
4
6
|
logServing,
|
|
5
7
|
parsePort,
|
|
6
8
|
serveFields,
|
|
7
9
|
waitForInterrupt,
|
|
8
10
|
} from '@/serve/report'
|
|
9
|
-
import { DEFAULT_PORT, startServer } from '@/serve/static'
|
|
11
|
+
import { DEFAULT_PORT, LIVE_EVENTS_PATH, startServer } from '@/serve/static'
|
|
12
|
+
import { compileScript } from '@/teach/browser/compile'
|
|
13
|
+
import { reload } from '@/teach/browser/reload'
|
|
10
14
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
11
15
|
import { type NavGenerated, type NavOutcome, generateNav } from '@/teach/nav'
|
|
12
16
|
import type { RenderOutcome } from '@/teach/render'
|
|
@@ -942,8 +946,17 @@ async function runUp(
|
|
|
942
946
|
? 'index.html'
|
|
943
947
|
: relative(dir, resolve(root, contents)).split(sep).join('/')
|
|
944
948
|
|
|
945
|
-
const
|
|
949
|
+
const stream = refreshOnChange(root, dir, topic, emitJson)
|
|
950
|
+
const served = startServer(dir, {
|
|
951
|
+
port,
|
|
952
|
+
entry,
|
|
953
|
+
live: {
|
|
954
|
+
stream,
|
|
955
|
+
script: compileScript(reload, [LIVE_EVENTS_PATH]),
|
|
956
|
+
},
|
|
957
|
+
})
|
|
946
958
|
if (!served.ok) {
|
|
959
|
+
stream.close()
|
|
947
960
|
// A serve refusal carries a reason the teach refusals do not list, so it
|
|
948
961
|
// is written here in the teach record's shape rather than through one.
|
|
949
962
|
if (emitJson) {
|
|
@@ -970,10 +983,105 @@ async function runUp(
|
|
|
970
983
|
logServing(served)
|
|
971
984
|
}
|
|
972
985
|
|
|
973
|
-
await waitForInterrupt(
|
|
986
|
+
await waitForInterrupt(async () => {
|
|
987
|
+
stream.close()
|
|
988
|
+
await served.stop()
|
|
989
|
+
})
|
|
974
990
|
return 0
|
|
975
991
|
}
|
|
976
992
|
|
|
993
|
+
/**
|
|
994
|
+
* An editor's swap or backup file, or anything under a dot folder. It changes
|
|
995
|
+
* nothing a page shows, so it neither starts a pass nor counts as served.
|
|
996
|
+
*/
|
|
997
|
+
function isScratch(path: string): boolean {
|
|
998
|
+
return path
|
|
999
|
+
.split(/[\\/]/)
|
|
1000
|
+
.some((segment) => segment.startsWith('.') || segment.endsWith('~'))
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* What the served folder holds, as each file's path, size, and mtime. A pass
|
|
1005
|
+
* whose `nav` wrote nothing leaves it unchanged, which is how the echo of a
|
|
1006
|
+
* pass's own writes settles without a second reload.
|
|
1007
|
+
*/
|
|
1008
|
+
function servedState(dir: string): string {
|
|
1009
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
1010
|
+
.filter((entry) => entry.isFile())
|
|
1011
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)))
|
|
1012
|
+
.filter((path) => !isScratch(path))
|
|
1013
|
+
.sort()
|
|
1014
|
+
.map((path) => {
|
|
1015
|
+
const stat = statSync(join(dir, path), { throwIfNoEntry: false })
|
|
1016
|
+
return `${path}:${stat?.size}:${stat?.mtimeMs}`
|
|
1017
|
+
})
|
|
1018
|
+
.join('\n')
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Re-runs `nav` on each settled change under the teach folder and tells every
|
|
1023
|
+
* open page to reload once a pass changed what is served. Passes run one at a
|
|
1024
|
+
* time, since `nav` is not reentrant over the files it rewrites, and a change
|
|
1025
|
+
* arriving mid-pass is coalesced into exactly one more. A refused or failed
|
|
1026
|
+
* pass sends no reload, so a page never reloads onto a half-written state.
|
|
1027
|
+
*/
|
|
1028
|
+
function refreshOnChange(
|
|
1029
|
+
root: string,
|
|
1030
|
+
dir: string,
|
|
1031
|
+
topic: string | undefined,
|
|
1032
|
+
emitJson: boolean,
|
|
1033
|
+
): LiveStream<string> {
|
|
1034
|
+
let isRunning = false
|
|
1035
|
+
let isDirty = false
|
|
1036
|
+
let state = servedState(dir)
|
|
1037
|
+
|
|
1038
|
+
const report = (message: string, isWarning: boolean) => {
|
|
1039
|
+
if (emitJson) process.stderr.write(`${message}\n`)
|
|
1040
|
+
else if (isWarning) logWarn(message)
|
|
1041
|
+
else logInfo(message)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const pass = async (): Promise<void> => {
|
|
1045
|
+
const nav = await generateNav(root, topic)
|
|
1046
|
+
if (!nav.ok) {
|
|
1047
|
+
report(`Refresh refused: ${nav.message}`, true)
|
|
1048
|
+
return
|
|
1049
|
+
}
|
|
1050
|
+
const next = servedState(dir)
|
|
1051
|
+
if (next === state) return
|
|
1052
|
+
state = next
|
|
1053
|
+
stream.send({ reload: true })
|
|
1054
|
+
report(`Refreshed ${nav.lessons} lessons, reloading open pages`, false)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const schedule = async (): Promise<void> => {
|
|
1058
|
+
if (isRunning) {
|
|
1059
|
+
isDirty = true
|
|
1060
|
+
return
|
|
1061
|
+
}
|
|
1062
|
+
isRunning = true
|
|
1063
|
+
do {
|
|
1064
|
+
isDirty = false
|
|
1065
|
+
// A throw, such as the teach folder removed mid-pass, is reported and
|
|
1066
|
+
// the loop carries on, since a rejection left here would end the
|
|
1067
|
+
// process and a stuck flag would end every later refresh.
|
|
1068
|
+
try {
|
|
1069
|
+
await pass()
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
const code = (error as NodeJS.ErrnoException).code ?? String(error)
|
|
1072
|
+
report(`Refresh failed: ${code}`, true)
|
|
1073
|
+
}
|
|
1074
|
+
} while (isDirty)
|
|
1075
|
+
isRunning = false
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const stream = new LiveStream<string>(dir, {
|
|
1079
|
+
map: (path) => (isScratch(path) ? undefined : path),
|
|
1080
|
+
onChange: () => void schedule(),
|
|
1081
|
+
})
|
|
1082
|
+
return stream
|
|
1083
|
+
}
|
|
1084
|
+
|
|
977
1085
|
function reportRefusal(
|
|
978
1086
|
title: string,
|
|
979
1087
|
refused: TeachRefused,
|
package/src/gate/stages.ts
CHANGED
|
@@ -192,11 +192,17 @@ export const STAGES: readonly Stage[] = [
|
|
|
192
192
|
argv: ['gov', 'regen'],
|
|
193
193
|
failure: 'Consumed-copy regen failed',
|
|
194
194
|
},
|
|
195
|
+
{
|
|
196
|
+
kind: 'drift',
|
|
197
|
+
pathspec: 'governance/rules/standards',
|
|
198
|
+
failure:
|
|
199
|
+
'Standard-pointer rules drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
|
|
200
|
+
},
|
|
195
201
|
{
|
|
196
202
|
kind: 'drift',
|
|
197
203
|
pathspec: '.claude/rules',
|
|
198
204
|
failure:
|
|
199
|
-
'Consumed copies drifted. Run bun run check and commit .claude/rules.',
|
|
205
|
+
'Consumed copies drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
|
|
200
206
|
},
|
|
201
207
|
],
|
|
202
208
|
success: 'Consumed copies clean',
|
package/src/gov/citations.ts
CHANGED
|
@@ -266,7 +266,7 @@ export function collectCitations(text: string): RawCitation[] {
|
|
|
266
266
|
*
|
|
267
267
|
* A rule spelling a whole path there is declaring that exact artifact, and a
|
|
268
268
|
* body line naming it again is naming what the rule governs rather than
|
|
269
|
-
* pointing a reader somewhere. `governance/rules/
|
|
269
|
+
* pointing a reader somewhere. `governance/rules/standards/diagrams.md` tells
|
|
270
270
|
* its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
|
|
271
271
|
* is correctly absent from this tree and correctly named in the rule.
|
|
272
272
|
*/
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, join, relative } from 'node:path'
|
|
4
|
+
import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
|
|
5
|
+
import { parseFrontmatter } from '@/indexes/frontmatter'
|
|
6
|
+
|
|
7
|
+
const STANDARDS_REL = 'standards'
|
|
8
|
+
|
|
9
|
+
/** The band the generated rules ship in, named for what they point at. */
|
|
10
|
+
export const STANDARD_RULES_BAND = 'standards'
|
|
11
|
+
|
|
12
|
+
export type StandardRulesResult =
|
|
13
|
+
| { readonly ok: true; readonly written: readonly string[] }
|
|
14
|
+
| { readonly ok: false; readonly reason: string }
|
|
15
|
+
|
|
16
|
+
interface StandardRule {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly sourceRel: string
|
|
19
|
+
readonly title: string
|
|
20
|
+
readonly description: string
|
|
21
|
+
readonly paths: readonly string[]
|
|
22
|
+
readonly bullets: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function standardRulesDir(root: string): string {
|
|
26
|
+
return join(rulesSourceDir(root), STANDARD_RULES_BAND)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generates one path-scoped rule per standard whose frontmatter carries
|
|
31
|
+
* `paths:`. The standard owns the glob and the operative bullets, so the rule
|
|
32
|
+
* is output, committed beside the authored bands so every reader of
|
|
33
|
+
* `governance/rules/` sees it as an ordinary file.
|
|
34
|
+
*
|
|
35
|
+
* Every standard is read and checked before anything is written, so a refusal
|
|
36
|
+
* leaves the previous output whole rather than half cleared.
|
|
37
|
+
*/
|
|
38
|
+
export async function regenStandardRules(
|
|
39
|
+
root: string,
|
|
40
|
+
): Promise<StandardRulesResult> {
|
|
41
|
+
const standardsDir = join(root, STANDARDS_REL)
|
|
42
|
+
if (!existsSync(standardsDir)) return { ok: true, written: [] }
|
|
43
|
+
|
|
44
|
+
const reserved = reservedNames(root)
|
|
45
|
+
const rules: StandardRule[] = []
|
|
46
|
+
|
|
47
|
+
for (const file of readdirSync(standardsDir).sort()) {
|
|
48
|
+
if (!file.endsWith('.md')) continue
|
|
49
|
+
const read = readStandard(root, file, reserved)
|
|
50
|
+
if (typeof read === 'string') return { ok: false, reason: read }
|
|
51
|
+
if (read !== undefined) rules.push(read)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dir = standardRulesDir(root)
|
|
55
|
+
await rm(dir, { recursive: true, force: true })
|
|
56
|
+
if (rules.length > 0) await mkdir(dir, { recursive: true })
|
|
57
|
+
|
|
58
|
+
const written: string[] = []
|
|
59
|
+
for (const rule of rules) {
|
|
60
|
+
const dest = join(dir, `${rule.name}.md`)
|
|
61
|
+
await writeFile(dest, renderStandardRule(rule))
|
|
62
|
+
written.push(relative(root, dest))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ok: true, written }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Band folders and authored rule basenames. A bare-word rule name equal to a
|
|
70
|
+
* band would install the whole band through `--add`, and one equal to an
|
|
71
|
+
* authored rule would shadow it in the name index.
|
|
72
|
+
*/
|
|
73
|
+
function reservedNames(root: string): {
|
|
74
|
+
readonly bands: ReadonlySet<string>
|
|
75
|
+
readonly rules: ReadonlySet<string>
|
|
76
|
+
} {
|
|
77
|
+
const bands = new Set<string>([STANDARD_RULES_BAND])
|
|
78
|
+
const rules = new Set<string>()
|
|
79
|
+
|
|
80
|
+
for (const rel of listRuleSourcePaths(root)) {
|
|
81
|
+
const segments = rel.split('/')
|
|
82
|
+
if (segments.length > 1) bands.add(segments[0])
|
|
83
|
+
if (segments[0] === STANDARD_RULES_BAND) continue
|
|
84
|
+
rules.add(basename(rel, '.md'))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { bands, rules }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A rule to render, `undefined` for a standard with no `paths:`, or a reason. */
|
|
91
|
+
function readStandard(
|
|
92
|
+
root: string,
|
|
93
|
+
file: string,
|
|
94
|
+
reserved: ReturnType<typeof reservedNames>,
|
|
95
|
+
): StandardRule | string | undefined {
|
|
96
|
+
const sourceRel = `${STANDARDS_REL}/${file}`
|
|
97
|
+
const fields = parseFrontmatter(
|
|
98
|
+
readFileSync(join(root, STANDARDS_REL, file), 'utf8'),
|
|
99
|
+
)?.fields
|
|
100
|
+
if (fields === undefined || fields.paths === undefined) return undefined
|
|
101
|
+
|
|
102
|
+
const name = basename(file, '.md')
|
|
103
|
+
if (!isStringList(fields.paths)) {
|
|
104
|
+
return `${sourceRel}: paths must be a list of strings`
|
|
105
|
+
}
|
|
106
|
+
if (fields.rule !== undefined && !isStringList(fields.rule)) {
|
|
107
|
+
return `${sourceRel}: rule must be a list of strings`
|
|
108
|
+
}
|
|
109
|
+
if (reserved.bands.has(name)) {
|
|
110
|
+
return `${sourceRel}: name collides with a rule band folder`
|
|
111
|
+
}
|
|
112
|
+
if (reserved.rules.has(name)) {
|
|
113
|
+
return `${sourceRel}: name collides with an authored rule`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name,
|
|
118
|
+
sourceRel,
|
|
119
|
+
title: typeof fields.title === 'string' ? fields.title : name,
|
|
120
|
+
description:
|
|
121
|
+
typeof fields.description === 'string' ? fields.description : '',
|
|
122
|
+
paths: fields.paths,
|
|
123
|
+
bullets: fields.rule ?? [],
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isStringList(value: unknown): value is string[] {
|
|
128
|
+
return (
|
|
129
|
+
Array.isArray(value) && value.every((entry) => typeof entry === 'string')
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function renderStandardRule(rule: StandardRule): string {
|
|
134
|
+
const scope = rule.description.replace(/\.$/, '')
|
|
135
|
+
const pointer = [
|
|
136
|
+
`Follow the ${rule.name} standard for ${lowerFirst(scope)}.`,
|
|
137
|
+
'It is the single source.',
|
|
138
|
+
`Read it with \`canon standards ${rule.name}\`.`,
|
|
139
|
+
].join(' ')
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
'---',
|
|
143
|
+
`# Generated by \`canon gov regen\` from ${rule.sourceRel}. Edit that frontmatter, not this file.`,
|
|
144
|
+
`description: ${yamlScalar(rule.description)}`,
|
|
145
|
+
'paths:',
|
|
146
|
+
...rule.paths.map((glob) => ` - ${yamlQuoted(glob)}`),
|
|
147
|
+
'---',
|
|
148
|
+
'',
|
|
149
|
+
`# ${rule.title.replace(/\breference$/, 'standards')}`,
|
|
150
|
+
'',
|
|
151
|
+
'## Authority',
|
|
152
|
+
'',
|
|
153
|
+
...[pointer, ...rule.bullets].map((bullet) => `- ${bullet}`),
|
|
154
|
+
'',
|
|
155
|
+
].join('\n')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function lowerFirst(text: string): string {
|
|
159
|
+
return text.charAt(0).toLowerCase() + text.slice(1)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Plain when YAML would read it back unchanged, single-quoted otherwise. */
|
|
163
|
+
function yamlScalar(text: string): string {
|
|
164
|
+
const isPlain =
|
|
165
|
+
text !== '' &&
|
|
166
|
+
!/^[-?:,[\]{}#&*!|>'"%@`\s]/.test(text) &&
|
|
167
|
+
!/: |\s#|:$|\s$/.test(text)
|
|
168
|
+
return isPlain ? text : yamlQuoted(text)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Quoted the way Prettier's `singleQuote` leaves YAML, so the drift check
|
|
173
|
+
* never fights the formatter: single quotes unless the text holds one and
|
|
174
|
+
* double quotes would need no escape.
|
|
175
|
+
*/
|
|
176
|
+
function yamlQuoted(text: string): string {
|
|
177
|
+
if (text.includes("'") && !/["\\]/.test(text)) return `"${text}"`
|
|
178
|
+
return `'${text.replaceAll("'", "''")}'`
|
|
179
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
import { tmpdir } from 'node:os'
|
|
18
18
|
import { join } from 'node:path'
|
|
19
19
|
import { collectCoverage, DEFAULT_ARM } from '@/sandbox/coverage'
|
|
20
|
+
import { createStubRemote, readStubManifest } from '@/sandbox/stub-remote'
|
|
20
21
|
|
|
21
22
|
/** Holds fixture content rather than scenarios. Twin of the filter in `sandbox.ts`. */
|
|
22
23
|
const FIXTURES_DIR = 'fixtures'
|
|
@@ -136,6 +137,8 @@ export interface EquivalenceOptions {
|
|
|
136
137
|
readonly targets: readonly string[]
|
|
137
138
|
readonly base: string
|
|
138
139
|
readonly includeAnchor: boolean
|
|
140
|
+
/** Runs anchor arms against a local bare repository and a stub `gh`, never the shared anchor. */
|
|
141
|
+
readonly stubRemote?: boolean
|
|
139
142
|
readonly useMasks: boolean
|
|
140
143
|
readonly out?: string
|
|
141
144
|
}
|
|
@@ -516,7 +519,15 @@ function configDirFor(dir: string): string {
|
|
|
516
519
|
return `${dir}.config`
|
|
517
520
|
}
|
|
518
521
|
|
|
519
|
-
function
|
|
522
|
+
function stubDirFor(dir: string): string {
|
|
523
|
+
return `${dir}.stub`
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function provisionEnv(
|
|
527
|
+
arm: Arm,
|
|
528
|
+
dir: string,
|
|
529
|
+
stubbed = false,
|
|
530
|
+
): NodeJS.ProcessEnv {
|
|
520
531
|
const env: NodeJS.ProcessEnv = {
|
|
521
532
|
...process.env,
|
|
522
533
|
CANON_NON_INTERACTIVE: '1',
|
|
@@ -524,6 +535,7 @@ function provisionEnv(arm: Arm, dir: string): NodeJS.ProcessEnv {
|
|
|
524
535
|
CANON_SANDBOX_RUN_ID: PINNED_RUN_ID,
|
|
525
536
|
CLAUDE_CONFIG_DIR: configDirFor(dir),
|
|
526
537
|
}
|
|
538
|
+
if (stubbed) Object.assign(env, createStubRemote(stubDirFor(dir)))
|
|
527
539
|
if (arm.arm === undefined) delete env.SANDBOX_SCENARIO
|
|
528
540
|
else env.SANDBOX_SCENARIO = arm.arm
|
|
529
541
|
|
|
@@ -541,13 +553,14 @@ async function provision(
|
|
|
541
553
|
arm: Arm,
|
|
542
554
|
dir: string,
|
|
543
555
|
logPath: string,
|
|
556
|
+
stubbed: boolean,
|
|
544
557
|
): Promise<Provisioned> {
|
|
545
558
|
rmSync(dir, { recursive: true, force: true })
|
|
546
559
|
rmSync(configDirFor(dir), { recursive: true, force: true })
|
|
547
560
|
const exit = await runProcess(
|
|
548
561
|
'bun',
|
|
549
562
|
[join(root, 'src', 'cli.ts'), 'sandbox', `${arm.category}:${arm.command}`],
|
|
550
|
-
{ cwd: root, env: provisionEnv(arm, dir), logPath },
|
|
563
|
+
{ cwd: root, env: provisionEnv(arm, dir, stubbed), logPath },
|
|
551
564
|
)
|
|
552
565
|
const log = readFileSync(logPath, 'utf8').split(root).join(ROOT_PLACEHOLDER)
|
|
553
566
|
|
|
@@ -592,8 +605,12 @@ async function checkHead(
|
|
|
592
605
|
}
|
|
593
606
|
}
|
|
594
607
|
|
|
595
|
-
function withExit(
|
|
596
|
-
|
|
608
|
+
function withExit(
|
|
609
|
+
manifest: Manifest,
|
|
610
|
+
run: Provisioned,
|
|
611
|
+
stub?: Manifest,
|
|
612
|
+
): Manifest {
|
|
613
|
+
const next = new Map([...manifest, ...(stub ?? [])])
|
|
597
614
|
next.set('exit', String(run.exit))
|
|
598
615
|
next.set('log', run.log)
|
|
599
616
|
|
|
@@ -608,16 +625,20 @@ async function runArm(
|
|
|
608
625
|
headRoot: string,
|
|
609
626
|
masks: readonly Mask[],
|
|
610
627
|
declared: boolean,
|
|
628
|
+
stubbed: boolean,
|
|
611
629
|
): Promise<ArmRecord> {
|
|
612
630
|
const key = armKey(arm)
|
|
613
631
|
const dir = join(workDir, 'arms', `arm-${index}`)
|
|
614
632
|
const logPath = join(workDir, 'logs', `arm-${index}.log`)
|
|
615
633
|
|
|
616
|
-
const
|
|
617
|
-
|
|
634
|
+
const stubManifest = (): Manifest | undefined =>
|
|
635
|
+
stubbed ? readStubManifest(stubDirFor(dir)) : undefined
|
|
618
636
|
|
|
619
|
-
const
|
|
620
|
-
const
|
|
637
|
+
const base = await provision(baseRoot, arm, dir, logPath, stubbed)
|
|
638
|
+
const baseManifest = withExit(buildManifest(dir), base, stubManifest())
|
|
639
|
+
|
|
640
|
+
const head = await provision(headRoot, arm, dir, logPath, stubbed)
|
|
641
|
+
const headManifest = withExit(buildManifest(dir), head, stubManifest())
|
|
621
642
|
|
|
622
643
|
// Kept beside the logs, so a reader of an `--out` folder can see what a
|
|
623
644
|
// difference was rather than only that one exists.
|
|
@@ -643,6 +664,7 @@ async function runArm(
|
|
|
643
664
|
: undefined
|
|
644
665
|
|
|
645
666
|
rmSync(configDirFor(dir), { recursive: true, force: true })
|
|
667
|
+
rmSync(stubDirFor(dir), { recursive: true, force: true })
|
|
646
668
|
|
|
647
669
|
return {
|
|
648
670
|
arm: key,
|
|
@@ -770,8 +792,13 @@ export async function runEquivalence(
|
|
|
770
792
|
try {
|
|
771
793
|
const baseRoot = join(workDir, 'base')
|
|
772
794
|
const baseCommit = git(options.root, ['rev-parse', options.base])
|
|
773
|
-
const
|
|
774
|
-
const
|
|
795
|
+
const stubRemote = options.stubRemote === true
|
|
796
|
+
const runnable = arms.filter(
|
|
797
|
+
(a) => !a.anchor || options.includeAnchor || stubRemote,
|
|
798
|
+
)
|
|
799
|
+
const skipped = arms.filter(
|
|
800
|
+
(a) => a.anchor && !options.includeAnchor && !stubRemote,
|
|
801
|
+
)
|
|
775
802
|
|
|
776
803
|
const indexed = new Map(arms.map((a, i) => [armKey(a), i]))
|
|
777
804
|
const run = (arm: Arm): Promise<ArmRecord> =>
|
|
@@ -783,18 +810,20 @@ export async function runEquivalence(
|
|
|
783
810
|
options.root,
|
|
784
811
|
masks,
|
|
785
812
|
declaredKeys.has(armKey(arm)),
|
|
813
|
+
stubRemote && arm.anchor,
|
|
786
814
|
)
|
|
787
815
|
|
|
788
816
|
// Anchor arms force-push `main` and recreate a pull request on one shared
|
|
789
|
-
// remote, so they run strictly one at a time.
|
|
817
|
+
// remote, so they run strictly one at a time. A stub remote is private to its
|
|
818
|
+
// arm, so those arms join the pool.
|
|
819
|
+
const shared = (a: Arm): boolean => a.anchor && !stubRemote
|
|
790
820
|
const offline = await pool(
|
|
791
|
-
runnable.filter((a) => !a
|
|
821
|
+
runnable.filter((a) => !shared(a)),
|
|
792
822
|
CONCURRENCY,
|
|
793
823
|
(arm) => run(arm),
|
|
794
824
|
)
|
|
795
825
|
const anchored: ArmRecord[] = []
|
|
796
|
-
for (const arm of runnable.filter(
|
|
797
|
-
anchored.push(await run(arm))
|
|
826
|
+
for (const arm of runnable.filter(shared)) anchored.push(await run(arm))
|
|
798
827
|
|
|
799
828
|
const records: ArmRecord[] = [
|
|
800
829
|
...offline,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
const STUB_ORG = 'stub-org'
|
|
14
|
+
const ANCHOR_REPO = 'canon-sandbox'
|
|
15
|
+
|
|
16
|
+
/** The URL `sandbox_anchor_url` builds once `GITHUB_ORG` names the stub organization. */
|
|
17
|
+
export const STUB_ANCHOR_URL = `https://github.com/${STUB_ORG}/${ANCHOR_REPO}.git`
|
|
18
|
+
|
|
19
|
+
/** Port 9 is the discard service, which nothing listens on, so a real HTTPS attempt fails. */
|
|
20
|
+
const DEAD_PROXY = 'http://127.0.0.1:9'
|
|
21
|
+
|
|
22
|
+
/** Pinned so a commit id depends on its tree alone and two provisions of one tree agree. */
|
|
23
|
+
const PINNED_DATE = '2026-01-01T00:00:00Z'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Answers the `gh` calls the git scenarios make and fails on any other, so a new
|
|
27
|
+
* call reaches a reader as a red arm rather than a silent pass. Every call is
|
|
28
|
+
* logged as one line, quoted so an argument holding a space stays one argument.
|
|
29
|
+
*/
|
|
30
|
+
const STUB_GH = `#!/usr/bin/env bash
|
|
31
|
+
# stub gh
|
|
32
|
+
dir="$STUB_GH_DIR"
|
|
33
|
+
line="$(printf '%q ' "$@")"
|
|
34
|
+
line="\${line% }"
|
|
35
|
+
printf '%s\\n' "$line" >>"$dir/gh.log"
|
|
36
|
+
|
|
37
|
+
case "$1" in
|
|
38
|
+
api) exit 0 ;;
|
|
39
|
+
pr)
|
|
40
|
+
case "$2" in
|
|
41
|
+
create)
|
|
42
|
+
count="$(cat "$dir/pr-count" 2>/dev/null || echo 0)"
|
|
43
|
+
count=$((count + 1))
|
|
44
|
+
echo "$count" >"$dir/pr-count"
|
|
45
|
+
echo "https://github.com/${STUB_ORG}/${ANCHOR_REPO}/pull/$count"
|
|
46
|
+
exit 0
|
|
47
|
+
;;
|
|
48
|
+
close | list) exit 0 ;;
|
|
49
|
+
esac
|
|
50
|
+
;;
|
|
51
|
+
esac
|
|
52
|
+
|
|
53
|
+
printf '%s\\n' "$line" >>"$dir/gh-unknown.log"
|
|
54
|
+
echo "stub gh: unknown call: $line" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Builds a local bare repository and a stub `gh` under `stubDir` and returns the
|
|
60
|
+
* environment that sends a provision's pushes and `gh` calls there. Nothing in
|
|
61
|
+
* it reaches the shared anchor: the URL rewrite turns every push into a file
|
|
62
|
+
* path, and the stub heads `PATH` ahead of any real `gh`.
|
|
63
|
+
*/
|
|
64
|
+
export function createStubRemote(stubDir: string): Record<string, string> {
|
|
65
|
+
rmSync(stubDir, { recursive: true, force: true })
|
|
66
|
+
const bin = join(stubDir, 'bin')
|
|
67
|
+
const remotes = join(stubDir, 'remotes')
|
|
68
|
+
mkdirSync(bin, { recursive: true })
|
|
69
|
+
mkdirSync(remotes, { recursive: true })
|
|
70
|
+
|
|
71
|
+
execFileSync(
|
|
72
|
+
'git',
|
|
73
|
+
['init', '-q', '--bare', '-b', 'main', join(remotes, `${ANCHOR_REPO}.git`)],
|
|
74
|
+
{ stdio: 'ignore' },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const gh = join(bin, 'gh')
|
|
78
|
+
writeFileSync(gh, STUB_GH)
|
|
79
|
+
chmodSync(gh, 0o755)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
PATH: `${bin}:${process.env.PATH ?? ''}`,
|
|
83
|
+
STUB_GH_DIR: stubDir,
|
|
84
|
+
GITHUB_ORG: STUB_ORG,
|
|
85
|
+
GIT_CONFIG_COUNT: '1',
|
|
86
|
+
GIT_CONFIG_KEY_0: `url.${remotes}/.insteadOf`,
|
|
87
|
+
GIT_CONFIG_VALUE_0: `https://github.com/${STUB_ORG}/`,
|
|
88
|
+
GIT_AUTHOR_DATE: PINNED_DATE,
|
|
89
|
+
GIT_COMMITTER_DATE: PINNED_DATE,
|
|
90
|
+
HTTPS_PROXY: DEAD_PROXY,
|
|
91
|
+
https_proxy: DEAD_PROXY,
|
|
92
|
+
ALL_PROXY: DEAD_PROXY,
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function git(cwd: string, args: string[]): string {
|
|
97
|
+
return execFileSync('git', args, {
|
|
98
|
+
cwd,
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
101
|
+
}).trimEnd()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function readLog(path: string): string {
|
|
105
|
+
return existsSync(path) ? readFileSync(path, 'utf8').trimEnd() : ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Reads what a provision left in the stub: the `gh` calls, the calls it did not
|
|
110
|
+
* recognize, and for each bare repository every ref with its tip tree and the
|
|
111
|
+
* commit subjects beneath it. A tree id carries no timestamp, so it compares
|
|
112
|
+
* across two runs where a commit id would not.
|
|
113
|
+
*/
|
|
114
|
+
export function readStubManifest(stubDir: string): Map<string, string> {
|
|
115
|
+
const manifest = new Map<string, string>()
|
|
116
|
+
manifest.set('stub:gh', readLog(join(stubDir, 'gh.log')))
|
|
117
|
+
manifest.set('stub:gh-unknown', readLog(join(stubDir, 'gh-unknown.log')))
|
|
118
|
+
|
|
119
|
+
const remotes = join(stubDir, 'remotes')
|
|
120
|
+
if (!existsSync(remotes)) return manifest
|
|
121
|
+
|
|
122
|
+
for (const entry of readdirSync(remotes).sort()) {
|
|
123
|
+
if (!entry.endsWith('.git')) continue
|
|
124
|
+
const repo = join(remotes, entry)
|
|
125
|
+
const name = entry.slice(0, -'.git'.length)
|
|
126
|
+
|
|
127
|
+
const refs = git(repo, ['for-each-ref', '--format=%(refname) %(tree)'])
|
|
128
|
+
.split('\n')
|
|
129
|
+
.filter((line) => line !== '')
|
|
130
|
+
.map((line) => {
|
|
131
|
+
const ref = line.split(' ')[0] ?? ''
|
|
132
|
+
const subjects = git(repo, ['log', '--format= %s', '--reverse', ref])
|
|
133
|
+
|
|
134
|
+
return `${line}\n${subjects}`
|
|
135
|
+
})
|
|
136
|
+
manifest.set(`stub:${name}:refs`, refs.join('\n'))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return manifest
|
|
140
|
+
}
|