@erclx/canon 4.116.0 → 4.117.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/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/review-pr/REQUIREMENT.md +2 -0
- package/claude/skills/review-pr/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/install-and-sync.md +1 -1
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/skills-audit.md +8 -1
- package/docs/workflow/ai-workflow.md +9 -9
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/markdown.ts +63 -2
- package/src/context/audit.ts +6 -8
- package/src/gate/measures.ts +60 -0
- package/src/gate/stages.ts +12 -2
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
|
@@ -60,7 +60,8 @@ check_capability "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
|
60
60
|
"$PROJECT_ROOT"/tooling/claude/seeds/.claude/hooks/*
|
|
61
61
|
|
|
62
62
|
check_capability "Workflows" "$PROJECT_ROOT/.github/workflows" \
|
|
63
|
-
"$PROJECT_ROOT"/tooling/*/configs/.github/workflows/*
|
|
63
|
+
"$PROJECT_ROOT"/tooling/*/configs/.github/workflows/* \
|
|
64
|
+
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows/*
|
|
64
65
|
|
|
65
66
|
check_capability "Husky" "$PROJECT_ROOT/.husky" \
|
|
66
67
|
"$PROJECT_ROOT"/tooling/base/configs/.husky/*
|
|
@@ -90,7 +91,8 @@ check_orphans() {
|
|
|
90
91
|
check_orphans "Hooks" "$PROJECT_ROOT/.claude/hooks" \
|
|
91
92
|
"$PROJECT_ROOT/tooling/claude/seeds/.claude/hooks"
|
|
92
93
|
|
|
93
|
-
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows
|
|
94
|
+
for dir in "$PROJECT_ROOT"/tooling/*/configs/.github/workflows \
|
|
95
|
+
"$PROJECT_ROOT"/tooling/*/seeds/.github/workflows; do
|
|
94
96
|
check_orphans "Workflows" "$PROJECT_ROOT/.github/workflows" "$dir"
|
|
95
97
|
done
|
|
96
98
|
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
36
36
|
"We're about to hit the context limit, write the handoff before we lose state.",
|
|
37
37
|
expect: 'session-compact',
|
|
38
38
|
},
|
|
39
|
+
{
|
|
40
|
+
prompt:
|
|
41
|
+
"I'm moving this session to another machine, write the handoff before I pack it up.",
|
|
42
|
+
expect: 'session-compact',
|
|
43
|
+
},
|
|
39
44
|
{
|
|
40
45
|
prompt:
|
|
41
46
|
'I am the orchestrating session and about to compact, so write the board handoff as a session map on the task board.',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
4
|
+
import { bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
4
5
|
|
|
5
6
|
/** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
|
|
6
7
|
export const EXIT_MISSING_REQUIREMENT = 2
|
|
@@ -33,6 +34,14 @@ const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
|
33
34
|
|
|
34
35
|
const HEADING = /^#{1,6}\s+(.+?)\s*$/
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An ISO date, read as provenance wherever a reader is told it. The context
|
|
39
|
+
* audit's detector clears a date after "measured" or "verified", and a skill
|
|
40
|
+
* body keeps no such carve-out, since the standard sends every incident and its
|
|
41
|
+
* date to the requirement's Gap or to git.
|
|
42
|
+
*/
|
|
43
|
+
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
|
|
44
|
+
|
|
36
45
|
export interface SkillFinding {
|
|
37
46
|
readonly rel: string
|
|
38
47
|
readonly detail: string
|
|
@@ -59,6 +68,7 @@ export interface SkillsAudit {
|
|
|
59
68
|
readonly nameMismatch: readonly SkillFinding[]
|
|
60
69
|
readonly longDescription: readonly SkillFinding[]
|
|
61
70
|
readonly requirementSections: readonly SkillFinding[]
|
|
71
|
+
readonly datedProvenance: readonly SkillFinding[]
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
interface SkillSource {
|
|
@@ -69,6 +79,17 @@ interface SkillSource {
|
|
|
69
79
|
readonly description: string | undefined
|
|
70
80
|
/** Undefined when the folder carries no `REQUIREMENT.md` at all. */
|
|
71
81
|
readonly requirementHeadings: readonly string[] | undefined
|
|
82
|
+
/**
|
|
83
|
+
* `SKILL.md` and every reference below the folder, keyed by the path a
|
|
84
|
+
* finding names. `REQUIREMENT.md` and `EVAL.md` are left out, since the
|
|
85
|
+
* requirement is where the standard sends a date.
|
|
86
|
+
*/
|
|
87
|
+
readonly bodies: readonly SkillText[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface SkillText {
|
|
91
|
+
readonly rel: string
|
|
92
|
+
readonly text: string
|
|
72
93
|
}
|
|
73
94
|
|
|
74
95
|
/**
|
|
@@ -110,6 +131,9 @@ export async function auditSkills(root: string): Promise<SkillsAudit> {
|
|
|
110
131
|
nameMismatch: sources.flatMap(nameFindings),
|
|
111
132
|
longDescription: sources.flatMap(lengthFindings),
|
|
112
133
|
requirementSections: sources.flatMap(sectionFindings),
|
|
134
|
+
datedProvenance: sources.flatMap((source) =>
|
|
135
|
+
source.bodies.flatMap(dateFindings),
|
|
136
|
+
),
|
|
113
137
|
}
|
|
114
138
|
}
|
|
115
139
|
|
|
@@ -138,23 +162,41 @@ async function readSkill(
|
|
|
138
162
|
const skillDir = join(dir, folder)
|
|
139
163
|
const requirementPath = join(skillDir, 'REQUIREMENT.md')
|
|
140
164
|
|
|
141
|
-
const
|
|
165
|
+
const references = [
|
|
166
|
+
...new Bun.Glob('references/**/*.md').scanSync({
|
|
167
|
+
cwd: skillDir,
|
|
168
|
+
onlyFiles: true,
|
|
169
|
+
}),
|
|
170
|
+
].sort()
|
|
171
|
+
|
|
172
|
+
const [body, requirement, referenceTexts] = await Promise.all([
|
|
142
173
|
Bun.file(join(skillDir, 'SKILL.md')).text(),
|
|
143
174
|
existsSync(requirementPath)
|
|
144
175
|
? Bun.file(requirementPath).text()
|
|
145
176
|
: Promise.resolve(undefined),
|
|
177
|
+
Promise.all(
|
|
178
|
+
references.map((path) => Bun.file(join(skillDir, path)).text()),
|
|
179
|
+
),
|
|
146
180
|
])
|
|
147
181
|
|
|
148
182
|
const fields = parseFrontmatter(body)
|
|
183
|
+
const skillRel = join(rel, folder)
|
|
149
184
|
|
|
150
185
|
return {
|
|
151
|
-
rel:
|
|
186
|
+
rel: skillRel,
|
|
152
187
|
folder,
|
|
153
188
|
hasReadme: existsSync(join(skillDir, 'README.md')),
|
|
154
189
|
name: declared(readField(fields, 'name')),
|
|
155
190
|
description: declared(readField(fields, 'description')),
|
|
156
191
|
requirementHeadings:
|
|
157
192
|
requirement === undefined ? undefined : headings(requirement),
|
|
193
|
+
bodies: [
|
|
194
|
+
{ rel: join(skillRel, 'SKILL.md'), text: body },
|
|
195
|
+
...references.map((path, index) => ({
|
|
196
|
+
rel: join(skillRel, path),
|
|
197
|
+
text: referenceTexts[index] ?? '',
|
|
198
|
+
})),
|
|
199
|
+
],
|
|
158
200
|
}
|
|
159
201
|
}
|
|
160
202
|
|
|
@@ -209,3 +251,18 @@ function sectionFindings(source: SkillSource): SkillFinding[] {
|
|
|
209
251
|
},
|
|
210
252
|
]
|
|
211
253
|
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* A date inside a fence or a code span is example data a reader is shown, such
|
|
257
|
+
* as a sample frontmatter block, so only prose outside both is read.
|
|
258
|
+
*/
|
|
259
|
+
function dateFindings(source: SkillText): SkillFinding[] {
|
|
260
|
+
return bodyLines(source.text)
|
|
261
|
+
.filter((line) => !line.fenced)
|
|
262
|
+
.flatMap((line) =>
|
|
263
|
+
[...maskDisplayed(line.text).matchAll(ISO_DATE)].map((match) => ({
|
|
264
|
+
rel: source.rel,
|
|
265
|
+
detail: `line ${line.number}: ${match[0]}`,
|
|
266
|
+
})),
|
|
267
|
+
)
|
|
268
|
+
}
|
package/src/commands/claude.ts
CHANGED
|
@@ -1397,6 +1397,7 @@ async function runSkillsAudit(
|
|
|
1397
1397
|
reportFrontmatter(report)
|
|
1398
1398
|
reportFolder(report)
|
|
1399
1399
|
reportRequirementShape(report)
|
|
1400
|
+
reportProvenance(report)
|
|
1400
1401
|
reportUnmeasured()
|
|
1401
1402
|
outro()
|
|
1402
1403
|
}
|
|
@@ -1418,6 +1419,7 @@ async function runSkillsAudit(
|
|
|
1418
1419
|
readme: report.readme,
|
|
1419
1420
|
folderName: report.folderName,
|
|
1420
1421
|
requirementSections: report.requirementSections,
|
|
1422
|
+
datedProvenance: report.datedProvenance,
|
|
1421
1423
|
},
|
|
1422
1424
|
checkpoints: {
|
|
1423
1425
|
descriptionLimit: DESCRIPTION_LIMIT,
|
|
@@ -1588,6 +1590,31 @@ function reportRequirementShape(report: SkillsAudit): void {
|
|
|
1588
1590
|
reportFindings(report.requirementSections)
|
|
1589
1591
|
}
|
|
1590
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* Names where the date belongs, since the finding is a date to move rather
|
|
1595
|
+
* than one to delete, and reports without failing the run for the same reason
|
|
1596
|
+
* the frontmatter measures do.
|
|
1597
|
+
*/
|
|
1598
|
+
function reportProvenance(report: SkillsAudit): void {
|
|
1599
|
+
logStep('Provenance')
|
|
1600
|
+
logInfo(
|
|
1601
|
+
'A SKILL.md and its references state the current rule, with no ISO date outside a fence or code span.',
|
|
1602
|
+
)
|
|
1603
|
+
logInfo(
|
|
1604
|
+
'The incident and date that earned a rule go in REQUIREMENT.md under Gap, or stay in git.',
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1607
|
+
if (report.datedProvenance.length === 0) {
|
|
1608
|
+
logInfo('No body or reference carries a date.')
|
|
1609
|
+
return
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
logWarn(
|
|
1613
|
+
`${plural(report.datedProvenance.length, 'date')} in a body or reference`,
|
|
1614
|
+
)
|
|
1615
|
+
reportFindings(report.datedProvenance)
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1591
1618
|
/**
|
|
1592
1619
|
* Stated on every run, including the run where everything above passed. A
|
|
1593
1620
|
* report that lists only what it measured reads as a verdict on the standard
|
|
@@ -1604,4 +1631,7 @@ function reportUnmeasured(): void {
|
|
|
1604
1631
|
logInfo(
|
|
1605
1632
|
'The 150-line body checkpoint is mechanical and still absent here, and it would print a count rather than a defect.',
|
|
1606
1633
|
)
|
|
1634
|
+
logInfo(
|
|
1635
|
+
'Undated provenance, such as "a pull request dropped...", and a date written in words or without its day are not read.',
|
|
1636
|
+
)
|
|
1607
1637
|
}
|
package/src/commands/markdown.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
3
3
|
import { dirname, relative, resolve } from 'node:path'
|
|
4
4
|
import type { Command } from 'commander'
|
|
5
5
|
import { BAN_SETS, emptyBanSets } from '@/markdown/bans'
|
|
6
|
+
import { lengthExemption } from '@/markdown/ceiling'
|
|
6
7
|
import { type MarkdownAuditRefusal, resolveMarkdown } from '@/markdown/files'
|
|
7
8
|
import { isGating } from '@/markdown/gate'
|
|
8
9
|
import { decodePath, findBrokenLinks, type LinkFinding } from '@/markdown/links'
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
BASELINE,
|
|
17
18
|
CHECKPOINTS,
|
|
18
19
|
type Checkpoints,
|
|
20
|
+
documentHeight,
|
|
19
21
|
measureStructure,
|
|
20
22
|
type StructureReport,
|
|
21
23
|
} from '@/markdown/structure'
|
|
@@ -51,6 +53,9 @@ interface FileReport {
|
|
|
51
53
|
readonly bans: readonly BanFinding[]
|
|
52
54
|
readonly links: readonly LinkFinding[]
|
|
53
55
|
readonly structure: StructureReport
|
|
56
|
+
readonly renderedLines: number
|
|
57
|
+
/** The stated reason, or null where the document is not exempt. */
|
|
58
|
+
readonly exempt: string | null
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export function register(program: Command): void {
|
|
@@ -62,7 +67,7 @@ export function register(program: Command): void {
|
|
|
62
67
|
markdown
|
|
63
68
|
.command('audit')
|
|
64
69
|
.description(
|
|
65
|
-
'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, and
|
|
70
|
+
'Fail on a banned character, word, or spelling, and report bullet, paragraph, cadence, depth, and length',
|
|
66
71
|
)
|
|
67
72
|
.argument(
|
|
68
73
|
'[path...]',
|
|
@@ -85,6 +90,11 @@ export function register(program: Command): void {
|
|
|
85
90
|
'Bullet, paragraph, and depth weight are judgments a reader settles,',
|
|
86
91
|
'so all three report and none of them fails a run.',
|
|
87
92
|
'',
|
|
93
|
+
'Length reports a whole document past the ceiling and never exits 2.',
|
|
94
|
+
'The Document ceiling gate stage owns that verdict, since this audit also',
|
|
95
|
+
'runs on a record kept out of version control, where a long plan is not',
|
|
96
|
+
'a defect.',
|
|
97
|
+
'',
|
|
88
98
|
'Cadence reports the same way and carries one more caveat. Its range is',
|
|
89
99
|
'drawn from prose a person reads, so terse reference prose sits below',
|
|
90
100
|
'it correctly and a flat paragraph there is not a defect.',
|
|
@@ -148,7 +158,8 @@ async function runAudit(
|
|
|
148
158
|
const reports: FileReport[] = await Promise.all(
|
|
149
159
|
scope.files.map(async (rel) => {
|
|
150
160
|
const abs = resolve(root, rel)
|
|
151
|
-
const
|
|
161
|
+
const source = await readFile(abs, 'utf8')
|
|
162
|
+
const lines = bodyLines(source)
|
|
152
163
|
return {
|
|
153
164
|
rel,
|
|
154
165
|
bans: scanBans(lines, bans),
|
|
@@ -156,6 +167,8 @@ async function runAudit(
|
|
|
156
167
|
existsSync(resolve(dirname(abs), path)),
|
|
157
168
|
),
|
|
158
169
|
structure: measureStructure(rel, lines, checkpoints),
|
|
170
|
+
renderedLines: documentHeight(source, checkpoints.renderWidth),
|
|
171
|
+
exempt: lengthExemption(rel, source),
|
|
159
172
|
}
|
|
160
173
|
}),
|
|
161
174
|
)
|
|
@@ -168,6 +181,7 @@ async function runAudit(
|
|
|
168
181
|
reportParagraphs(reports, checkpoints)
|
|
169
182
|
reportCadence(reports, checkpoints)
|
|
170
183
|
reportDepth(reports, checkpoints)
|
|
184
|
+
reportLength(reports, checkpoints)
|
|
171
185
|
outro()
|
|
172
186
|
|
|
173
187
|
if (opts.json) {
|
|
@@ -192,6 +206,7 @@ async function runAudit(
|
|
|
192
206
|
cadence: checkpoints.cadence,
|
|
193
207
|
spread: checkpoints.spread,
|
|
194
208
|
opener: checkpoints.opener,
|
|
209
|
+
ceiling: checkpoints.ceiling,
|
|
195
210
|
},
|
|
196
211
|
entries: reports.map((report) => ({
|
|
197
212
|
path: report.rel,
|
|
@@ -202,6 +217,8 @@ async function runAudit(
|
|
|
202
217
|
heavyBullets: report.structure.heavyBullets,
|
|
203
218
|
heavyParagraphs: report.structure.heavyParagraphs,
|
|
204
219
|
cadence: report.structure.cadence,
|
|
220
|
+
renderedLines: report.renderedLines,
|
|
221
|
+
exempt: report.exempt,
|
|
205
222
|
})),
|
|
206
223
|
})}\n`,
|
|
207
224
|
)
|
|
@@ -606,3 +623,47 @@ function reportDepth(
|
|
|
606
623
|
.join('\n'),
|
|
607
624
|
)
|
|
608
625
|
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Lists every document past the ceiling and counts the exempt ones rather than
|
|
629
|
+
* naming them, since an exemption is settled where its marker sits.
|
|
630
|
+
*/
|
|
631
|
+
function reportLength(
|
|
632
|
+
reports: readonly FileReport[],
|
|
633
|
+
checkpoints: Checkpoints,
|
|
634
|
+
): void {
|
|
635
|
+
logStep('Length')
|
|
636
|
+
logInfo(
|
|
637
|
+
`Whole documents measure rendered lines at ${checkpoints.renderWidth} columns, frontmatter and fenced blocks included.`,
|
|
638
|
+
)
|
|
639
|
+
logInfo(
|
|
640
|
+
'A changelog is exempt by name, and so is a document carrying a canon-length-exempt marker that names a reason.',
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
const past = reports.filter(
|
|
644
|
+
(report) => report.renderedLines > checkpoints.ceiling,
|
|
645
|
+
)
|
|
646
|
+
const over = past
|
|
647
|
+
.filter((report) => report.exempt === null)
|
|
648
|
+
.sort((a, b) => b.renderedLines - a.renderedLines)
|
|
649
|
+
const exempt = past.length - over.length
|
|
650
|
+
|
|
651
|
+
if (over.length === 0) {
|
|
652
|
+
logInfo(`No document past the ${checkpoints.ceiling}-line ceiling.`)
|
|
653
|
+
} else {
|
|
654
|
+
logWarn(
|
|
655
|
+
`${plural(over.length, 'document')} past the ${checkpoints.ceiling}-line ceiling`,
|
|
656
|
+
)
|
|
657
|
+
pipeOutput(
|
|
658
|
+
over
|
|
659
|
+
.map(
|
|
660
|
+
(report) => `${report.rel} ${report.renderedLines} rendered lines`,
|
|
661
|
+
)
|
|
662
|
+
.join('\n'),
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (exempt > 0) {
|
|
667
|
+
logInfo(`${plural(exempt, 'exempt document')} past the ceiling.`)
|
|
668
|
+
}
|
|
669
|
+
}
|
package/src/context/audit.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { BARE_NAME, IGNORE_MARKER } from '@/context/citations'
|
|
|
4
4
|
import type { AuditedFolder } from '@/context/folders'
|
|
5
5
|
import type { NarrationTerms } from '@/context/narration'
|
|
6
6
|
import { type BodyLine, bodyLines, maskDisplayed } from '@/markdown/scan'
|
|
7
|
-
import {
|
|
7
|
+
import { documentHeight } from '@/markdown/structure'
|
|
8
8
|
import { isStubSeed } from '@/seed-marker'
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -214,9 +214,10 @@ export interface EntryReport {
|
|
|
214
214
|
readonly rel: string
|
|
215
215
|
/**
|
|
216
216
|
* Rendered lines across the whole file, counting frontmatter and fenced
|
|
217
|
-
* blocks. It
|
|
218
|
-
* `
|
|
219
|
-
*
|
|
217
|
+
* blocks. It counts through `documentHeight` in `src/markdown/structure.ts`,
|
|
218
|
+
* which shares `renderedHeight` with the depth checkpoint and with the
|
|
219
|
+
* document ceiling, since all three sit beside each other in the standard
|
|
220
|
+
* and a reader compares them.
|
|
220
221
|
*
|
|
221
222
|
* Fences are counted here and excluded there. The depth measure skips one so
|
|
222
223
|
* an example cannot break the run around it, and a file measure has no run to
|
|
@@ -646,10 +647,7 @@ export function measureEntry(
|
|
|
646
647
|
|
|
647
648
|
return {
|
|
648
649
|
rel,
|
|
649
|
-
lines: source
|
|
650
|
-
.replace(/\n$/, '')
|
|
651
|
-
.split('\n')
|
|
652
|
-
.reduce((sum, text) => sum + renderedHeight(text), 0),
|
|
650
|
+
lines: documentHeight(source),
|
|
653
651
|
catalogTables: catalogTables(lines),
|
|
654
652
|
provenance: governsContent ? provenance(lines) : [],
|
|
655
653
|
narration: governsContent && terms ? narration(lines, terms) : [],
|
package/src/gate/measures.ts
CHANGED
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
measureArchitecture,
|
|
14
14
|
} from '@/context/architecture'
|
|
15
15
|
import { listRepositoryFiles } from '@/git-files'
|
|
16
|
+
import { ceilingFindings } from '@/markdown/ceiling'
|
|
17
|
+
import { resolveMarkdown } from '@/markdown/files'
|
|
18
|
+
import { CHECKPOINTS } from '@/markdown/structure'
|
|
16
19
|
import {
|
|
17
20
|
isShippedCorpus,
|
|
18
21
|
REFERENCE_MARKER,
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
type ShippedReference,
|
|
22
25
|
} from '@/shipped/references'
|
|
23
26
|
import { surfaceDir } from '@/surface-root'
|
|
27
|
+
import { plural } from '@/ui'
|
|
24
28
|
import {
|
|
25
29
|
README_PARAPHRASE_MARKER,
|
|
26
30
|
readmeCitationsIn,
|
|
@@ -273,6 +277,62 @@ export const architectureRecord: Measure = async (ctx) => {
|
|
|
273
277
|
}
|
|
274
278
|
}
|
|
275
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Every tracked markdown document against the whole-document ceiling.
|
|
282
|
+
*
|
|
283
|
+
* Report-only for now: a document past the ceiling warns and the stage still
|
|
284
|
+
* passes, since the corpus carried dozens past it when the stage landed. The
|
|
285
|
+
* flip to a failure is a separate change once the count reads zero, and it
|
|
286
|
+
* replaces the warn path rather than sitting beside it.
|
|
287
|
+
*
|
|
288
|
+
* Read in-process rather than through `canon markdown audit`, whose exit code
|
|
289
|
+
* stays bans and dead links only. The plan and groundwork skills run that audit
|
|
290
|
+
* on a gitignored record, and a gating length there would fail every long plan.
|
|
291
|
+
*/
|
|
292
|
+
export const documentCeiling: Measure = async (ctx) => {
|
|
293
|
+
const scope = await resolveMarkdown(ctx.root, [])
|
|
294
|
+
if (scope.kind === 'unavailable') {
|
|
295
|
+
return {
|
|
296
|
+
emissions: [],
|
|
297
|
+
unmeasured: 'git could not list the tree, so no document was measured.',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const documents = scope.files.flatMap((rel) => {
|
|
302
|
+
try {
|
|
303
|
+
return [{ rel, source: readFileSync(join(ctx.root, rel), 'utf8') }]
|
|
304
|
+
} catch {
|
|
305
|
+
return []
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
const findings = ceilingFindings(documents)
|
|
309
|
+
const over = findings.filter((finding) => finding.exempt === null)
|
|
310
|
+
const exempt = findings.length - over.length
|
|
311
|
+
const ceiling = CHECKPOINTS.ceiling
|
|
312
|
+
|
|
313
|
+
if (over.length === 0) {
|
|
314
|
+
const exemptNote = exempt > 0 ? `, ${exempt} exempt` : ''
|
|
315
|
+
return {
|
|
316
|
+
emissions: [
|
|
317
|
+
info(
|
|
318
|
+
`No document past the ${ceiling}-line ceiling across ${plural(documents.length, 'markdown file')}${exemptNote}`,
|
|
319
|
+
),
|
|
320
|
+
],
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
emissions: [
|
|
326
|
+
...over.map((finding) =>
|
|
327
|
+
warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
|
|
328
|
+
),
|
|
329
|
+
info(
|
|
330
|
+
`${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
|
|
331
|
+
),
|
|
332
|
+
],
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
276
336
|
/**
|
|
277
337
|
* A second run of the records move should rewrite nothing, and the count is
|
|
278
338
|
* only knowable once the folders themselves have landed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
auditSet,
|
|
4
4
|
captureStamps,
|
|
5
5
|
clientCommandCitations,
|
|
6
|
+
documentCeiling,
|
|
6
7
|
markdownBans,
|
|
7
8
|
type Measure,
|
|
8
9
|
pluginManifests,
|
|
@@ -333,9 +334,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
333
334
|
checks: [{ kind: 'measure', measure: unreferencedRules }],
|
|
334
335
|
},
|
|
335
336
|
{
|
|
336
|
-
// Only the citation half of the audit gates.
|
|
337
|
+
// Only the citation half of the audit gates. Its length, depth, table, and
|
|
337
338
|
// index findings are judgment thresholds, and failing a push on one would
|
|
338
|
-
// make the stage something to route around.
|
|
339
|
+
// make the stage something to route around. The one length reading that
|
|
340
|
+
// will gate is the whole-document ceiling, which the Document ceiling stage
|
|
341
|
+
// below owns rather than this audit.
|
|
339
342
|
id: 'context-citations',
|
|
340
343
|
label: 'Context citations',
|
|
341
344
|
checks: [
|
|
@@ -355,6 +358,13 @@ export const STAGES: readonly Stage[] = [
|
|
|
355
358
|
label: 'Architecture record',
|
|
356
359
|
checks: [{ kind: 'measure', measure: architectureRecord }],
|
|
357
360
|
},
|
|
361
|
+
{
|
|
362
|
+
// Unscoped, since any branch can grow any document. It warns and passes
|
|
363
|
+
// until the corpus sits under the ceiling, then flips to a failure.
|
|
364
|
+
id: 'document-ceiling',
|
|
365
|
+
label: 'Document ceiling',
|
|
366
|
+
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
367
|
+
},
|
|
358
368
|
{
|
|
359
369
|
// A rule citing a file that moved fails silently. The consumed-copy drift
|
|
360
370
|
// stage passes an authored rule and its copy that are wrong together, and
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { bodyLines } from '@/markdown/scan'
|
|
3
|
+
import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The marker exempting one document from the ceiling, written as a whole line
|
|
7
|
+
* outside a fence and naming a reason.
|
|
8
|
+
*
|
|
9
|
+
* The whole line rather than anywhere on it, so a standard quoting the marker
|
|
10
|
+
* inside a code span to document it does not exempt itself. A marker with no
|
|
11
|
+
* reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
|
|
12
|
+
* gives: a bare token reads as a line that meant to say something and did not.
|
|
13
|
+
*/
|
|
14
|
+
const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A changelog is exempt by name because the release tool rewrites it and would
|
|
18
|
+
* not keep a marker in place. A committed list of exempt paths was the
|
|
19
|
+
* alternative, and it turns every rename into a second edit with nothing to
|
|
20
|
+
* check the list against the tree.
|
|
21
|
+
*/
|
|
22
|
+
const CHANGELOG = 'CHANGELOG.md'
|
|
23
|
+
const CHANGELOG_REASON = 'CHANGELOG.md is written by the release tool'
|
|
24
|
+
|
|
25
|
+
export interface CeilingDocument {
|
|
26
|
+
readonly rel: string
|
|
27
|
+
readonly source: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CeilingFinding {
|
|
31
|
+
readonly rel: string
|
|
32
|
+
readonly renderedLines: number
|
|
33
|
+
/** The stated reason, or null where the document is not exempt. */
|
|
34
|
+
readonly exempt: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The reason a document is exempt from the ceiling, or null where it is not. */
|
|
38
|
+
export function lengthExemption(rel: string, source: string): string | null {
|
|
39
|
+
if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
|
|
40
|
+
|
|
41
|
+
for (const line of bodyLines(source)) {
|
|
42
|
+
if (line.fenced) continue
|
|
43
|
+
const match = EXEMPT_MARKER.exec(line.text.trim())
|
|
44
|
+
if (match) return match[1]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every document past the ceiling, longest first, exempt ones included with
|
|
52
|
+
* their reason so a report can count them rather than hide them.
|
|
53
|
+
*/
|
|
54
|
+
export function ceilingFindings(
|
|
55
|
+
documents: readonly CeilingDocument[],
|
|
56
|
+
): CeilingFinding[] {
|
|
57
|
+
return documents
|
|
58
|
+
.map(({ rel, source }) => ({
|
|
59
|
+
rel,
|
|
60
|
+
renderedLines: documentHeight(source),
|
|
61
|
+
source,
|
|
62
|
+
}))
|
|
63
|
+
.filter(({ renderedLines }) => renderedLines > CHECKPOINTS.ceiling)
|
|
64
|
+
.map(({ rel, renderedLines, source }) => ({
|
|
65
|
+
rel,
|
|
66
|
+
renderedLines,
|
|
67
|
+
exempt: lengthExemption(rel, source),
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => b.renderedLines - a.renderedLines)
|
|
70
|
+
}
|
|
@@ -116,6 +116,7 @@ export const CHECKPOINTS = {
|
|
|
116
116
|
cadence: 3,
|
|
117
117
|
spread: 5,
|
|
118
118
|
opener: 2,
|
|
119
|
+
ceiling: 300,
|
|
119
120
|
} as const
|
|
120
121
|
|
|
121
122
|
/**
|
|
@@ -167,6 +168,11 @@ export interface Checkpoints {
|
|
|
167
168
|
readonly spread: number
|
|
168
169
|
/** Times one opening word may open a sentence in a paragraph before it is a pattern. */
|
|
169
170
|
readonly opener: number
|
|
171
|
+
/**
|
|
172
|
+
* Rendered lines a whole document may reach, read by the Document ceiling
|
|
173
|
+
* gate stage rather than by any exit code of the audit.
|
|
174
|
+
*/
|
|
175
|
+
readonly ceiling: number
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
export interface BulletFinding {
|
|
@@ -247,6 +253,22 @@ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
|
|
|
247
253
|
return Math.max(1, Math.ceil(visibleText(text).length / width))
|
|
248
254
|
}
|
|
249
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Height a whole source occupies once wrapped, frontmatter and fenced blocks
|
|
258
|
+
* included.
|
|
259
|
+
*
|
|
260
|
+
* Nothing is excluded because a session pays for every line it loads, which is
|
|
261
|
+
* what separates this from the depth measure. The context audit's length
|
|
262
|
+
* reading and the document ceiling both count through here, so the two report
|
|
263
|
+
* one unit.
|
|
264
|
+
*/
|
|
265
|
+
export function documentHeight(source: string, width = RENDER_WIDTH): number {
|
|
266
|
+
return source
|
|
267
|
+
.replace(/\n$/, '')
|
|
268
|
+
.split('\n')
|
|
269
|
+
.reduce((sum, text) => sum + renderedHeight(text, width), 0)
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
/**
|
|
251
273
|
* Reports whether a run is the peer list the standard exempts.
|
|
252
274
|
*
|
package/src/tooling/inject.ts
CHANGED
|
@@ -6,7 +6,12 @@ import { copyPreservingMode } from '@/copy'
|
|
|
6
6
|
import { rewritesOnInstall, stripSeedMarker } from '@/seed-marker'
|
|
7
7
|
import { resolveSurfacePath } from '@/surface-root'
|
|
8
8
|
import { mergeSections, pruneSections } from '@/tooling/gitignore'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
ancestorsFirst,
|
|
11
|
+
configPaths,
|
|
12
|
+
listFiles,
|
|
13
|
+
type Manifest,
|
|
14
|
+
} from '@/tooling/manifest'
|
|
10
15
|
import {
|
|
11
16
|
applyScripts,
|
|
12
17
|
collectDeps,
|
|
@@ -97,9 +102,14 @@ export async function injectSeeds(
|
|
|
97
102
|
target: string,
|
|
98
103
|
): Promise<string[]> {
|
|
99
104
|
const applied: string[] = []
|
|
105
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
106
|
+
const shadowed = configPaths(chain)
|
|
100
107
|
|
|
101
108
|
for (const manifest of ancestorsFirst(chain)) {
|
|
102
|
-
const files = listFiles(manifest.seedsDir)
|
|
109
|
+
const files = listFiles(manifest.seedsDir).filter(
|
|
110
|
+
(rel) =>
|
|
111
|
+
!shadowed.has(rel) && !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
112
|
+
)
|
|
103
113
|
if (files.length === 0) continue
|
|
104
114
|
|
|
105
115
|
logStep(`Applying ${manifest.name} seeds`)
|
|
@@ -111,9 +121,7 @@ export async function injectSeeds(
|
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
const spellConfig =
|
|
115
|
-
? subfolderSpellConfig(chain)
|
|
116
|
-
: undefined
|
|
124
|
+
const spellConfig = isSubfolder ? subfolderSpellConfig(chain) : undefined
|
|
117
125
|
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
126
|
logStep('Applying subfolder spell config')
|
|
119
127
|
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -44,6 +44,15 @@ export function listFiles(dir: string): string[] {
|
|
|
44
44
|
].sort()
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every path any stack in the chain ships as a config. A seed at one of these
|
|
49
|
+
* paths is shadowed, since the config overwrites it on every sync. `scan` and
|
|
50
|
+
* `injectSeeds` both read this, so the report and the write cannot disagree.
|
|
51
|
+
*/
|
|
52
|
+
export function configPaths(chain: readonly Manifest[]): Set<string> {
|
|
53
|
+
return new Set(chain.flatMap((manifest) => listFiles(manifest.configsDir)))
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
/**
|
|
48
57
|
* Reverses a chain so the furthest ancestor is applied first and nearer
|
|
49
58
|
* stacks overwrite it.
|