@erclx/canon 5.45.0 → 5.46.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/api-design/REQUIREMENT.md +15 -31
- package/claude/skills/auto-ship/REQUIREMENT.md +14 -25
- package/claude/skills/backlog-triage/REQUIREMENT.md +12 -23
- package/claude/skills/build-in-slices/REQUIREMENT.md +15 -25
- package/claude/skills/canon-cli/REQUIREMENT.md +15 -27
- package/claude/skills/canon-feedback/REQUIREMENT.md +10 -18
- package/claude/skills/canon-feedback-triage/REQUIREMENT.md +10 -15
- package/claude/skills/canvas-board/REQUIREMENT.md +14 -28
- package/claude/skills/ci-workflow/REQUIREMENT.md +12 -21
- package/claude/skills/code-craft/REQUIREMENT.md +14 -28
- package/claude/skills/codebase-layout/REQUIREMENT.md +14 -31
- package/claude/skills/context-fold/REQUIREMENT.md +15 -39
- package/claude/skills/context-fold/references/context.md +3 -3
- package/claude/skills/create-rule/REQUIREMENT.md +10 -24
- package/claude/skills/create-rule/SKILL.md +3 -0
- package/claude/skills/create-skill/REQUIREMENT.md +10 -21
- package/claude/skills/decision-escalate/REQUIREMENT.md +12 -24
- package/claude/skills/deploy-app/REQUIREMENT.md +12 -22
- package/claude/skills/deprecation-migration/REQUIREMENT.md +14 -42
- package/claude/skills/design-identity/REQUIREMENT.md +14 -20
- package/claude/skills/design-taste/REQUIREMENT.md +16 -57
- package/claude/skills/docs-sync/REQUIREMENT.md +14 -23
- package/claude/skills/document-health/REQUIREMENT.md +15 -29
- package/claude/skills/document-health/SKILL.md +2 -2
- package/claude/skills/document-repair/REQUIREMENT.md +32 -0
- package/claude/skills/document-repair/SKILL.md +76 -0
- package/claude/skills/document-repair/references/batches.md +44 -0
- package/claude/skills/draft-and-pick/REQUIREMENT.md +4 -3
- package/claude/skills/draft-doc/REQUIREMENT.md +4 -3
- package/claude/skills/draft-figure/REQUIREMENT.md +4 -3
- package/claude/skills/draft-identity/REQUIREMENT.md +4 -3
- package/claude/skills/draft-ready/REQUIREMENT.md +4 -3
- package/claude/skills/draft-screencast/REQUIREMENT.md +4 -3
- package/claude/skills/draft-slides/REQUIREMENT.md +4 -3
- package/claude/skills/draw-figure/REQUIREMENT.md +15 -39
- package/claude/skills/git-branch/REQUIREMENT.md +12 -26
- package/claude/skills/git-commit/REQUIREMENT.md +11 -15
- package/claude/skills/git-followup/REQUIREMENT.md +11 -22
- package/claude/skills/git-issue/REQUIREMENT.md +9 -15
- package/claude/skills/git-pr/REQUIREMENT.md +13 -32
- package/claude/skills/git-pr/SKILL.md +10 -2
- package/claude/skills/git-pr/references/closing-issues.md +47 -0
- package/claude/skills/git-pr/references/pr.md +4 -0
- package/claude/skills/git-ship/REQUIREMENT.md +13 -26
- package/claude/skills/git-split/REQUIREMENT.md +10 -17
- package/claude/skills/git-stage/REQUIREMENT.md +12 -18
- package/claude/skills/git-worktree/REQUIREMENT.md +15 -19
- package/claude/skills/make-deck/REQUIREMENT.md +13 -21
- package/claude/skills/markdown-craft/REQUIREMENT.md +9 -3
- package/claude/skills/plan-feature/REQUIREMENT.md +15 -24
- package/claude/skills/plan-groundwork/REQUIREMENT.md +12 -28
- package/claude/skills/plan-groundwork/SKILL.md +5 -2
- package/claude/skills/plan-groundwork/references/output.md +47 -1
- package/claude/skills/plan-intake/REQUIREMENT.md +11 -26
- package/claude/skills/plan-intake/references/intake.md +2 -2
- package/claude/skills/plan-intake-answer/REQUIREMENT.md +14 -29
- package/claude/skills/plan-intake-answer/SKILL.md +7 -1
- package/claude/skills/plan-ready/REQUIREMENT.md +17 -36
- package/claude/skills/pr-evidence/REQUIREMENT.md +11 -17
- package/claude/skills/read-frames/REQUIREMENT.md +3 -8
- package/claude/skills/record-screencast/REQUIREMENT.md +14 -17
- package/claude/skills/review-address/REQUIREMENT.md +14 -38
- package/claude/skills/review-craft/REQUIREMENT.md +14 -32
- package/claude/skills/review-pr/REQUIREMENT.md +15 -34
- package/claude/skills/review-ui/REQUIREMENT.md +14 -39
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +5 -0
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +9 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +15 -65
- package/claude/skills/role-reviewer/SKILL.md +5 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +2 -0
- package/claude/skills/script-screencast/REQUIREMENT.md +10 -16
- package/claude/skills/search-craft/REQUIREMENT.md +12 -36
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/claude/skills/search-craft/references/adopted.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +13 -23
- package/claude/skills/session-map/REQUIREMENT.md +13 -29
- package/claude/skills/session-relay/REQUIREMENT.md +14 -58
- package/claude/skills/session-relay/SKILL.md +6 -0
- package/claude/skills/session-resume/REQUIREMENT.md +13 -27
- package/claude/skills/session-worktree/REQUIREMENT.md +14 -43
- package/claude/skills/systematic-debugging/REQUIREMENT.md +13 -34
- package/claude/skills/target-check/REQUIREMENT.md +16 -23
- package/claude/skills/target-setup/REQUIREMENT.md +11 -34
- package/claude/skills/task-board/REQUIREMENT.md +13 -22
- package/claude/skills/task-board/SKILL.md +7 -0
- package/claude/skills/task-board/references/tasks.md +1 -1
- package/claude/skills/task-complete/REQUIREMENT.md +11 -24
- package/claude/skills/task-complete/references/archive.md +1 -0
- package/claude/skills/teach-workspace/REQUIREMENT.md +14 -45
- package/claude/skills/test-craft/REQUIREMENT.md +15 -46
- package/claude/skills/test-first/REQUIREMENT.md +14 -20
- package/claude/skills/ui-checklist/REQUIREMENT.md +14 -29
- package/claude/skills/ux-audit/REQUIREMENT.md +3 -7
- package/claude/skills/ux-measure/REQUIREMENT.md +14 -28
- package/claude/skills/video-craft/REQUIREMENT.md +14 -27
- package/claude/skills/write-doc/REQUIREMENT.md +14 -24
- package/claude/skills/write-human/REQUIREMENT.md +15 -27
- package/claude/skills/youtube-transcripts/REQUIREMENT.md +9 -15
- package/docs/agents/context-audit.md +19 -12
- package/docs/agents/markdown-audit.md +15 -10
- package/docs/target/projects.md +2 -0
- package/docs/workflow/skill-map.md +6 -5
- package/governance/renames.toml +0 -2
- package/governance/rules/claude/output.md +5 -1
- package/governance/rules/code/routing.md +5 -1
- package/governance/stacks/base.toml +0 -1
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +12 -0
- package/src/commands/context.ts +57 -3
- package/src/commands/labels.ts +83 -3
- package/src/commands/markdown.ts +188 -21
- package/src/commands/sessions.ts +36 -3
- package/src/commands/tasks.ts +180 -0
- package/src/commands/worktrees.ts +2 -0
- package/src/context/coverage.ts +210 -0
- package/src/context/gate.ts +12 -3
- package/src/gate/stages.ts +1 -1
- package/src/init/steps.ts +45 -0
- package/src/intake/items.ts +14 -0
- package/src/labels/closing.ts +46 -0
- package/src/markdown/gated.ts +250 -0
- package/src/markdown/scope.ts +115 -2
- package/src/migrate/rename.ts +6 -0
- package/src/sessions/claim.ts +7 -0
- package/src/sessions/live.ts +53 -6
- package/src/sessions/registry.ts +11 -0
- package/src/sessions/resolve.ts +32 -5
- package/src/tasks/closes.ts +136 -0
- package/src/tasks/record.ts +1 -1
- package/src/worktrees/reclaim.ts +16 -1
- package/tooling/base/configs/scripts/verify.ts +1 -1
- package/tooling/claude/seeds/.claude/hooks/board-validate.sh +1 -1
- package/tooling/claude/seeds/CLAUDE.md +2 -2
- package/governance/rules/canon/task-scope.md +0 -12
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative } from 'node:path'
|
|
3
|
+
import type { AuditedFolder } from '@/context/folders'
|
|
4
|
+
|
|
5
|
+
/** Where a project declares the code roots its context entries must cover. */
|
|
6
|
+
export const CONTEXT_CONFIG_REL = join('canon', 'config', 'context.toml')
|
|
7
|
+
|
|
8
|
+
/** The root assumed when a project declares none and carries the folder. */
|
|
9
|
+
export const DEFAULT_ROOT = 'src'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The roots a project asked the coverage check to measure.
|
|
13
|
+
*
|
|
14
|
+
* `none` says the project carries no declaration and no `src/`, which reports
|
|
15
|
+
* and never gates so a seed tree audited at its own root keeps passing.
|
|
16
|
+
* `opted-out` is an explicit `roots = []`. `invalid` is a declaration that
|
|
17
|
+
* exists and cannot be used, which gates rather than falling back to `src`,
|
|
18
|
+
* since a project that wrote one meant it.
|
|
19
|
+
*/
|
|
20
|
+
export type RootDeclaration =
|
|
21
|
+
| { readonly kind: 'roots'; readonly patterns: readonly string[] }
|
|
22
|
+
| { readonly kind: 'opted-out' }
|
|
23
|
+
| { readonly kind: 'none' }
|
|
24
|
+
| { readonly kind: 'invalid'; readonly reason: string }
|
|
25
|
+
|
|
26
|
+
export interface DoubleClaim {
|
|
27
|
+
readonly folder: string
|
|
28
|
+
readonly rels: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CoverageFinding {
|
|
32
|
+
/** Folders under a declared root that no entry's Layout names. */
|
|
33
|
+
readonly unnamed: readonly string[]
|
|
34
|
+
/** Folders taking a Layout H3 in more than one entry. */
|
|
35
|
+
readonly doubled: readonly DoubleClaim[]
|
|
36
|
+
/** Declared roots or globs that matched no folder on disk. */
|
|
37
|
+
readonly missingRoots: readonly string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CoverageReport =
|
|
41
|
+
| {
|
|
42
|
+
readonly kind: 'measured'
|
|
43
|
+
readonly roots: readonly string[]
|
|
44
|
+
readonly findings: CoverageFinding
|
|
45
|
+
}
|
|
46
|
+
| { readonly kind: 'opted-out' }
|
|
47
|
+
| { readonly kind: 'no-root' }
|
|
48
|
+
| { readonly kind: 'invalid'; readonly reason: string }
|
|
49
|
+
|
|
50
|
+
/** Reads the declaration, falling back to `src` when that folder exists. */
|
|
51
|
+
export function readRoots(root: string): RootDeclaration {
|
|
52
|
+
const path = join(root, CONTEXT_CONFIG_REL)
|
|
53
|
+
|
|
54
|
+
if (existsSync(path)) {
|
|
55
|
+
let parsed: Record<string, unknown>
|
|
56
|
+
try {
|
|
57
|
+
parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
58
|
+
string,
|
|
59
|
+
unknown
|
|
60
|
+
>
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
kind: 'invalid',
|
|
64
|
+
reason: `${CONTEXT_CONFIG_REL} is not valid TOML`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const declared = parsed.roots
|
|
69
|
+
if (
|
|
70
|
+
!Array.isArray(declared) ||
|
|
71
|
+
!declared.every((entry) => typeof entry === 'string' && entry !== '')
|
|
72
|
+
) {
|
|
73
|
+
return {
|
|
74
|
+
kind: 'invalid',
|
|
75
|
+
reason: `${CONTEXT_CONFIG_REL} needs roots as a list of folder or glob strings`,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return declared.length === 0
|
|
80
|
+
? { kind: 'opted-out' }
|
|
81
|
+
: { kind: 'roots', patterns: declared as string[] }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return isDirectory(join(root, DEFAULT_ROOT))
|
|
85
|
+
? { kind: 'roots', patterns: [DEFAULT_ROOT] }
|
|
86
|
+
: { kind: 'none' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isDirectory(path: string): boolean {
|
|
90
|
+
try {
|
|
91
|
+
return statSync(path).isDirectory()
|
|
92
|
+
} catch {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Expands each pattern to the folders it matches, relative to `root`. */
|
|
98
|
+
function expandRoots(root: string, patterns: readonly string[]) {
|
|
99
|
+
const roots: string[] = []
|
|
100
|
+
const missing: string[] = []
|
|
101
|
+
|
|
102
|
+
for (const pattern of patterns) {
|
|
103
|
+
const matched = [
|
|
104
|
+
...new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: false }),
|
|
105
|
+
]
|
|
106
|
+
.map((path) => path.replace(/\/$/, ''))
|
|
107
|
+
.filter((path) => isDirectory(join(root, path)))
|
|
108
|
+
.sort()
|
|
109
|
+
|
|
110
|
+
if (matched.length === 0) missing.push(pattern)
|
|
111
|
+
roots.push(...matched)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { roots: [...new Set(roots)], missing }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lists folders directly under a root, skipping hidden ones and `node_modules`. */
|
|
118
|
+
function listFolders(root: string, rel: string): string[] {
|
|
119
|
+
return readdirSync(join(root, rel), { withFileTypes: true })
|
|
120
|
+
.filter(
|
|
121
|
+
(entry) =>
|
|
122
|
+
entry.isDirectory() &&
|
|
123
|
+
!entry.name.startsWith('.') &&
|
|
124
|
+
entry.name !== 'node_modules',
|
|
125
|
+
)
|
|
126
|
+
.map((entry) => `${rel}/${entry.name}`)
|
|
127
|
+
.sort()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function layoutSection(source: string): string {
|
|
131
|
+
const lines = source.split('\n')
|
|
132
|
+
const start = lines.findIndex((line) => /^##\s+Layout\s*$/.test(line))
|
|
133
|
+
if (start === -1) return ''
|
|
134
|
+
|
|
135
|
+
const end = lines.findIndex(
|
|
136
|
+
(line, index) => index > start && /^##\s/.test(line),
|
|
137
|
+
)
|
|
138
|
+
return lines.slice(start + 1, end === -1 ? undefined : end).join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function backtickedPaths(text: string): string[] {
|
|
142
|
+
return [...text.matchAll(/`([^`\s]+)`/g)].map((match) =>
|
|
143
|
+
match[1].replace(/\/$/, ''),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Measures the folders under the declared roots against the Layout sections.
|
|
149
|
+
*
|
|
150
|
+
* A folder is named when any Layout text cites it in backticks, a bullet
|
|
151
|
+
* included, and claimed when it is an H3 title. Only the H3 counts toward
|
|
152
|
+
* uniqueness, since a bullet legitimately cites another domain's folder and a
|
|
153
|
+
* grep cannot tell a citation from a claim.
|
|
154
|
+
*/
|
|
155
|
+
export function measureCoverage(
|
|
156
|
+
root: string,
|
|
157
|
+
declaration: RootDeclaration,
|
|
158
|
+
folders: readonly AuditedFolder[],
|
|
159
|
+
): CoverageReport {
|
|
160
|
+
if (declaration.kind === 'opted-out') return { kind: 'opted-out' }
|
|
161
|
+
if (declaration.kind === 'none') return { kind: 'no-root' }
|
|
162
|
+
if (declaration.kind === 'invalid') {
|
|
163
|
+
return { kind: 'invalid', reason: declaration.reason }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const named = new Set<string>()
|
|
167
|
+
const claims = new Map<string, Set<string>>()
|
|
168
|
+
|
|
169
|
+
for (const folder of folders) {
|
|
170
|
+
for (const entry of folder.entries) {
|
|
171
|
+
const rel = relative(root, entry)
|
|
172
|
+
const layout = layoutSection(readFileSync(entry, 'utf8'))
|
|
173
|
+
|
|
174
|
+
for (const path of backtickedPaths(layout)) named.add(path)
|
|
175
|
+
|
|
176
|
+
for (const line of layout.split('\n')) {
|
|
177
|
+
const heading = line.match(/^###\s+`([^`\s]+)`\s*$/)
|
|
178
|
+
if (!heading) continue
|
|
179
|
+
const path = heading[1].replace(/\/$/, '')
|
|
180
|
+
claims.set(path, (claims.get(path) ?? new Set()).add(rel))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { roots, missing } = expandRoots(root, declaration.patterns)
|
|
186
|
+
const onDisk = roots.flatMap((rel) => listFolders(root, rel))
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
kind: 'measured',
|
|
190
|
+
roots,
|
|
191
|
+
findings: {
|
|
192
|
+
unnamed: onDisk.filter((path) => !named.has(path)),
|
|
193
|
+
doubled: onDisk
|
|
194
|
+
.filter((path) => (claims.get(path)?.size ?? 0) > 1)
|
|
195
|
+
.map((folder) => ({
|
|
196
|
+
folder,
|
|
197
|
+
rels: [...(claims.get(folder) ?? [])].sort(),
|
|
198
|
+
})),
|
|
199
|
+
missingRoots: missing,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Whether the report holds a finding that should fail a widened gate. */
|
|
205
|
+
export function hasCoverageFindings(report: CoverageReport): boolean {
|
|
206
|
+
if (report.kind === 'invalid') return true
|
|
207
|
+
if (report.kind !== 'measured') return false
|
|
208
|
+
const { unnamed, doubled, missingRoots } = report.findings
|
|
209
|
+
return unnamed.length > 0 || doubled.length > 0 || missingRoots.length > 0
|
|
210
|
+
}
|
package/src/context/gate.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
SectionFinding,
|
|
4
4
|
ShapeFinding,
|
|
5
5
|
} from '@/context/audit'
|
|
6
|
+
import { type CoverageReport, hasCoverageFindings } from '@/context/coverage'
|
|
6
7
|
import type { FolderDrift } from '@/context/index-drift'
|
|
7
8
|
|
|
8
9
|
export interface GateInput {
|
|
@@ -25,6 +26,12 @@ export interface GateInput {
|
|
|
25
26
|
*/
|
|
26
27
|
readonly shape: readonly ShapeFinding[]
|
|
27
28
|
readonly drift: readonly FolderDrift[]
|
|
29
|
+
/**
|
|
30
|
+
* Folders under the declared code roots that no Layout names or two entries
|
|
31
|
+
* claim, gating under `--gate` alone. A project with no code root reads as
|
|
32
|
+
* not measured and never gates.
|
|
33
|
+
*/
|
|
34
|
+
readonly coverage: CoverageReport
|
|
28
35
|
/**
|
|
29
36
|
* Whether the caller asked for the widened gate. False leaves a missing
|
|
30
37
|
* section and a drifted index advisory, which is what the project-root stage
|
|
@@ -48,8 +55,8 @@ export function hasDrift(drift: readonly FolderDrift[]): boolean {
|
|
|
48
55
|
* for itself, which makes it a fact rather than a threshold a reader weighs.
|
|
49
56
|
* The findings `--gate` adds are the ones answerable from the file itself: a
|
|
50
57
|
* required section it does not declare, an H2 outside the closed set, a shape
|
|
51
|
-
* break in a folder rebuilt to the shape,
|
|
52
|
-
* folder. Entry length, depth, bullet, table, provenance, and the record's
|
|
58
|
+
* break in a folder rebuilt to the shape, an index disagreeing with its
|
|
59
|
+
* folder, and a code folder no entry names or two entries claim. Entry length, depth, bullet, table, provenance, and the record's
|
|
53
60
|
* claim coverage are judgments, so they stay out under both modes.
|
|
54
61
|
*/
|
|
55
62
|
export function isGating({
|
|
@@ -59,6 +66,7 @@ export function isGating({
|
|
|
59
66
|
extraSections,
|
|
60
67
|
shape,
|
|
61
68
|
drift,
|
|
69
|
+
coverage,
|
|
62
70
|
widened,
|
|
63
71
|
}: GateInput): boolean {
|
|
64
72
|
if (unresolvedCitations > 0) return true
|
|
@@ -69,6 +77,7 @@ export function isGating({
|
|
|
69
77
|
sections.length > 0 ||
|
|
70
78
|
extraSections.length > 0 ||
|
|
71
79
|
shape.some((finding) => finding.gated) ||
|
|
72
|
-
hasDrift(drift)
|
|
80
|
+
hasDrift(drift) ||
|
|
81
|
+
hasCoverageFindings(coverage)
|
|
73
82
|
)
|
|
74
83
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -334,7 +334,7 @@ export const STAGES: readonly Stage[] = [
|
|
|
334
334
|
kind: 'cli',
|
|
335
335
|
argv: ['context', 'audit', '--gate'],
|
|
336
336
|
failure:
|
|
337
|
-
'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape,
|
|
337
|
+
'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, and Gotchas, an entry under a shape-gated folder breaks the section shape, an index disagrees with its folder, or a code folder is named in no Layout or claimed by two entries. Run bun src/cli.ts context audit --gate.',
|
|
338
338
|
},
|
|
339
339
|
],
|
|
340
340
|
success: 'Context citations resolve',
|
package/src/init/steps.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { logInfo, logWarn, plural } from '@/cli/ui'
|
|
1
2
|
import { type InitFlags, resolveStack } from '@/init/plan'
|
|
2
3
|
import type { DomainStep } from '@/init/run'
|
|
4
|
+
import { GATED_CONFIG_REL, gateScopeRoots } from '@/markdown/gated'
|
|
3
5
|
|
|
4
6
|
/** Builds the child-process invocation for one domain. */
|
|
5
7
|
export type RunFactory = (args: readonly string[]) => () => Promise<boolean>
|
|
@@ -55,6 +57,16 @@ export function buildSteps(
|
|
|
55
57
|
})
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
// In-process rather than a child verb, since the audit reads the working
|
|
61
|
+
// directory and every child here is handed the target as an argument. It
|
|
62
|
+
// runs after every seeding domain so the roots it measures hold what they
|
|
63
|
+
// will ship with.
|
|
64
|
+
steps.push({
|
|
65
|
+
kind: 'run',
|
|
66
|
+
label: 'Markdown gating',
|
|
67
|
+
run: () => gateMarkdown(resolved),
|
|
68
|
+
})
|
|
69
|
+
|
|
58
70
|
if (!flags.skip.skipped.has('records')) {
|
|
59
71
|
// `skip` here means there is nothing to run non-interactively, not that
|
|
60
72
|
// the caller opted out, unlike every other push of this kind above.
|
|
@@ -68,6 +80,39 @@ export function buildSteps(
|
|
|
68
80
|
return steps
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Starts a new project with every density root gated that passes now. A root
|
|
85
|
+
* with a finding is named and left out, since gating it would fail the first
|
|
86
|
+
* push on prose a scaffolder wrote.
|
|
87
|
+
*/
|
|
88
|
+
async function gateMarkdown(root: string): Promise<boolean> {
|
|
89
|
+
const result = await gateScopeRoots(root)
|
|
90
|
+
|
|
91
|
+
if (result.kind === 'exists') {
|
|
92
|
+
logInfo(`${GATED_CONFIG_REL} already exists, so its list is kept.`)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A scaffold such as `npm create vite` makes no repository, and nothing else
|
|
97
|
+
// in init needs one, so this is a skip rather than a failed domain.
|
|
98
|
+
if (result.kind === 'unavailable') {
|
|
99
|
+
logWarn(
|
|
100
|
+
"Skipped: not a git repository, so no root was measured or gated. Run 'canon markdown gated add <prefix>' once it is one.",
|
|
101
|
+
)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
logInfo(
|
|
106
|
+
`Gated ${result.gated.length === 0 ? 'nothing' : result.gated.join(', ')} in ${GATED_CONFIG_REL}.`,
|
|
107
|
+
)
|
|
108
|
+
for (const { prefix, findings } of result.left) {
|
|
109
|
+
logWarn(
|
|
110
|
+
`Left ${prefix} ungated with ${plural(findings, 'finding')}. Repair it, then run 'canon markdown gated add ${prefix}'.`,
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* Builds the `gov install` argv. The run and the recovery command a skip prints
|
|
73
118
|
* come from here both, so the command a caller is told to paste installs what
|
package/src/intake/items.ts
CHANGED
|
@@ -44,6 +44,10 @@ export interface IntakeItem {
|
|
|
44
44
|
readonly answerLine: number | undefined
|
|
45
45
|
/** Text in the slot, absent when the slot is empty and the item is unread. */
|
|
46
46
|
readonly answer: string | undefined
|
|
47
|
+
/** What is wrong today, absent when the item carries no `Problem:` line. */
|
|
48
|
+
readonly problem: string | undefined
|
|
49
|
+
/** The one change proposed, absent when the item carries no `Fix:` line. */
|
|
50
|
+
readonly fix: string | undefined
|
|
47
51
|
readonly open: string | undefined
|
|
48
52
|
readonly suggested: string | undefined
|
|
49
53
|
readonly worth: string | undefined
|
|
@@ -55,6 +59,8 @@ interface Draft {
|
|
|
55
59
|
line: number
|
|
56
60
|
answerLine: number | undefined
|
|
57
61
|
answer: string | undefined
|
|
62
|
+
problem: string | undefined
|
|
63
|
+
fix: string | undefined
|
|
58
64
|
open: string | undefined
|
|
59
65
|
suggested: string | undefined
|
|
60
66
|
worth: string | undefined
|
|
@@ -89,6 +95,8 @@ export function readItems(text: string): IntakeItem[] {
|
|
|
89
95
|
line: line.number,
|
|
90
96
|
answerLine: undefined,
|
|
91
97
|
answer: undefined,
|
|
98
|
+
problem: undefined,
|
|
99
|
+
fix: undefined,
|
|
92
100
|
open: undefined,
|
|
93
101
|
suggested: undefined,
|
|
94
102
|
worth: undefined,
|
|
@@ -114,6 +122,12 @@ export function readItems(text: string): IntakeItem[] {
|
|
|
114
122
|
draft.answerLine = line.number
|
|
115
123
|
draft.answer = value === '' ? undefined : value
|
|
116
124
|
break
|
|
125
|
+
case 'problem':
|
|
126
|
+
draft.problem = value
|
|
127
|
+
break
|
|
128
|
+
case 'fix':
|
|
129
|
+
draft.fix = value
|
|
130
|
+
break
|
|
117
131
|
case 'open':
|
|
118
132
|
draft.open = value
|
|
119
133
|
break
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { linesOutsideFences, maskCodeSpans } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keywords GitHub reads as closing an issue on merge, each with its
|
|
5
|
+
* tenses. The keyword must start a word, so `disclosed #12` closes nothing,
|
|
6
|
+
* and GitHub reads only the one number that follows it, so `Closes #12, #13`
|
|
7
|
+
* closes #12 alone.
|
|
8
|
+
*/
|
|
9
|
+
const CLOSING =
|
|
10
|
+
/(?<!\w)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+#(\d+)/gi
|
|
11
|
+
|
|
12
|
+
export interface ClosingCheck {
|
|
13
|
+
/** Expected issues the body carries no closing reference for. */
|
|
14
|
+
readonly missing: readonly number[]
|
|
15
|
+
/** Issues the body closes that the expectation did not name. */
|
|
16
|
+
readonly stray: readonly number[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reads the issue numbers a body closes, in order and deduped. A code span and
|
|
21
|
+
* a fence quote a keyword rather than use it, and GitHub ignores both.
|
|
22
|
+
*/
|
|
23
|
+
export function readClosingReferences(body: string): readonly number[] {
|
|
24
|
+
const numbers = new Set<number>()
|
|
25
|
+
|
|
26
|
+
for (const line of linesOutsideFences(body)) {
|
|
27
|
+
for (const match of maskCodeSpans(line).matchAll(CLOSING)) {
|
|
28
|
+
numbers.add(Number(match[1]))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return [...numbers]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Compares the issues a body closes to the issues it should. */
|
|
36
|
+
export function checkClosing(
|
|
37
|
+
body: string,
|
|
38
|
+
expected: readonly number[],
|
|
39
|
+
): ClosingCheck {
|
|
40
|
+
const found = readClosingReferences(body)
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
missing: expected.filter((number) => !found.includes(number)),
|
|
44
|
+
stray: found.filter((number) => !expected.includes(number)),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { readFile } from 'node:fs/promises'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
12
|
+
import { densityFindings, type DensityKind } from '@/markdown/density'
|
|
13
|
+
import { referenceShapeGates } from '@/markdown/gate'
|
|
14
|
+
import {
|
|
15
|
+
type ReferenceShapeKind,
|
|
16
|
+
referenceShapeFindings,
|
|
17
|
+
} from '@/markdown/reference-shape'
|
|
18
|
+
import { bodyLines } from '@/markdown/scan'
|
|
19
|
+
import {
|
|
20
|
+
densityGates,
|
|
21
|
+
GATED_PATHS,
|
|
22
|
+
GATED_SCOPE_ROOTS,
|
|
23
|
+
inDensityScope,
|
|
24
|
+
} from '@/markdown/scope'
|
|
25
|
+
import { isOwnCheckout } from '@/roots/project'
|
|
26
|
+
|
|
27
|
+
/** Where a target keeps the prefixes its density findings gate under. */
|
|
28
|
+
export const GATED_CONFIG_REL = join('canon', 'config', 'markdown.toml')
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An absent file is today's target default and gates nothing. A malformed one
|
|
32
|
+
* refuses rather than reading as empty, since an empty list would silently
|
|
33
|
+
* drop every gate a project had turned on.
|
|
34
|
+
*/
|
|
35
|
+
export type GatedConfig =
|
|
36
|
+
| { readonly kind: 'absent' }
|
|
37
|
+
| { readonly kind: 'listed'; readonly prefixes: readonly string[] }
|
|
38
|
+
| { readonly kind: 'malformed'; readonly message: string }
|
|
39
|
+
|
|
40
|
+
export function readGatedConfig(root: string): GatedConfig {
|
|
41
|
+
const path = join(root, GATED_CONFIG_REL)
|
|
42
|
+
if (!existsSync(path)) return { kind: 'absent' }
|
|
43
|
+
|
|
44
|
+
let parsed: Record<string, unknown>
|
|
45
|
+
try {
|
|
46
|
+
parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
47
|
+
string,
|
|
48
|
+
unknown
|
|
49
|
+
>
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
kind: 'malformed',
|
|
53
|
+
message: `${GATED_CONFIG_REL} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const table = parsed.markdown
|
|
58
|
+
if (table === undefined) return { kind: 'listed', prefixes: [] }
|
|
59
|
+
if (typeof table !== 'object' || table === null || Array.isArray(table)) {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'malformed',
|
|
62
|
+
message: `${GATED_CONFIG_REL}: [markdown] is not a table`,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const gated = (table as Record<string, unknown>).gated
|
|
67
|
+
if (gated === undefined) return { kind: 'listed', prefixes: [] }
|
|
68
|
+
if (
|
|
69
|
+
!Array.isArray(gated) ||
|
|
70
|
+
!gated.every((each): each is string => typeof each === 'string')
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
kind: 'malformed',
|
|
74
|
+
message: `${GATED_CONFIG_REL}: markdown.gated is not a list of strings`,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { kind: 'listed', prefixes: gated }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether `prefix` can be listed. A folder ends in a slash, since a bare
|
|
83
|
+
* `docs` would also gate `docs-old/`. A path without one has to name an
|
|
84
|
+
* existing file, which is how a requirement gates by its own path.
|
|
85
|
+
*/
|
|
86
|
+
export function prefixShapeError(
|
|
87
|
+
root: string,
|
|
88
|
+
prefix: string,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
if (prefix === '' || prefix.startsWith('/') || prefix.includes('..')) {
|
|
91
|
+
return `${prefix || '(empty)'} is not a repository-relative path`
|
|
92
|
+
}
|
|
93
|
+
// A listed prefix is a `startsWith` match against repository paths, so either
|
|
94
|
+
// shape below matches no file and would gate nothing while reading as clean.
|
|
95
|
+
if (prefix.includes('//')) {
|
|
96
|
+
return `${prefix} carries a doubled slash, so it matches no file`
|
|
97
|
+
}
|
|
98
|
+
const path = join(root, prefix.replace(/\/$/, ''))
|
|
99
|
+
const isFile = existsSync(path) && !statSync(path).isDirectory()
|
|
100
|
+
if (prefix.endsWith('/')) {
|
|
101
|
+
return isFile
|
|
102
|
+
? `${prefix} names a file, so drop the trailing slash`
|
|
103
|
+
: undefined
|
|
104
|
+
}
|
|
105
|
+
if (isFile) return undefined
|
|
106
|
+
return `${prefix} names no file. End a folder prefix with a slash.`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Appends `prefix` to the target's list, creating the file when absent. A
|
|
111
|
+
* prefix already listed writes nothing. Preserves an existing file's mode.
|
|
112
|
+
*/
|
|
113
|
+
export function appendGatedPrefix(
|
|
114
|
+
root: string,
|
|
115
|
+
current: readonly string[],
|
|
116
|
+
prefix: string,
|
|
117
|
+
): 'appended' | 'present' {
|
|
118
|
+
if (current.includes(prefix)) return 'present'
|
|
119
|
+
writeGatedList(root, [...current, prefix])
|
|
120
|
+
return 'appended'
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Written by hand rather than serialized, the way `classifier.toml` is. */
|
|
124
|
+
function writeGatedList(root: string, prefixes: readonly string[]): void {
|
|
125
|
+
const path = join(root, GATED_CONFIG_REL)
|
|
126
|
+
const mode = existsSync(path) ? statSync(path).mode : undefined
|
|
127
|
+
const lines = prefixes.map((each) => ` ${JSON.stringify(each)},`)
|
|
128
|
+
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
130
|
+
writeFileSync(path, `[markdown]\ngated = [\n${lines.join('\n')}\n]\n`)
|
|
131
|
+
if (mode !== undefined) chmodSync(path, mode)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The prefixes a root gates under, and whether a bullet run gates unlisted. */
|
|
135
|
+
export interface GatedList {
|
|
136
|
+
readonly prefixes: readonly string[]
|
|
137
|
+
readonly alwaysGated: boolean
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type GatedResolution =
|
|
141
|
+
| {
|
|
142
|
+
readonly kind: 'resolved'
|
|
143
|
+
readonly source: 'canon' | 'config' | 'absent'
|
|
144
|
+
readonly list: GatedList
|
|
145
|
+
}
|
|
146
|
+
| { readonly kind: 'malformed'; readonly message: string }
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The list a root gates under: `GATED_PATHS` in canon's own checkout, which
|
|
150
|
+
* tracks its rewrite progress, and the target's config everywhere else.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveGatedList(
|
|
153
|
+
root: string,
|
|
154
|
+
own: boolean = isOwnCheckout(root),
|
|
155
|
+
): GatedResolution {
|
|
156
|
+
if (own) {
|
|
157
|
+
return {
|
|
158
|
+
kind: 'resolved',
|
|
159
|
+
source: 'canon',
|
|
160
|
+
list: { prefixes: GATED_PATHS, alwaysGated: true },
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const config = readGatedConfig(root)
|
|
165
|
+
if (config.kind === 'malformed') return config
|
|
166
|
+
return {
|
|
167
|
+
kind: 'resolved',
|
|
168
|
+
source: config.kind === 'absent' ? 'absent' : 'config',
|
|
169
|
+
list: {
|
|
170
|
+
prefixes: config.kind === 'listed' ? config.prefixes : [],
|
|
171
|
+
alwaysGated: false,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface BlockingFinding {
|
|
177
|
+
readonly path: string
|
|
178
|
+
readonly kind: DensityKind | ReferenceShapeKind
|
|
179
|
+
readonly line: number
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Every density and reference shape finding that would gate once `prefix` is
|
|
184
|
+
* listed, read from the files the audit reads. An absent folder carries none.
|
|
185
|
+
* Undefined when the tree cannot be listed, since an empty answer there would
|
|
186
|
+
* gate a corpus nobody measured.
|
|
187
|
+
*/
|
|
188
|
+
export async function findingsUnder(
|
|
189
|
+
root: string,
|
|
190
|
+
prefix: string,
|
|
191
|
+
): Promise<BlockingFinding[] | undefined> {
|
|
192
|
+
const listed = await listRepositoryFiles(root)
|
|
193
|
+
if (!listed) return undefined
|
|
194
|
+
|
|
195
|
+
const under = listed.filter(
|
|
196
|
+
(rel) => rel.endsWith('.md') && rel.startsWith(prefix),
|
|
197
|
+
)
|
|
198
|
+
const found = await Promise.all(
|
|
199
|
+
under.map(async (rel) => {
|
|
200
|
+
const source = await readFile(join(root, rel), 'utf8')
|
|
201
|
+
const density = inDensityScope(rel)
|
|
202
|
+
? densityFindings(bodyLines(source)).filter((finding) =>
|
|
203
|
+
densityGates(rel, finding.kind, [prefix], false),
|
|
204
|
+
)
|
|
205
|
+
: []
|
|
206
|
+
const shape = referenceShapeGates(rel, [prefix])
|
|
207
|
+
? referenceShapeFindings(source)
|
|
208
|
+
: []
|
|
209
|
+
return [...density, ...shape].map((finding) => ({
|
|
210
|
+
path: rel,
|
|
211
|
+
kind: finding.kind,
|
|
212
|
+
line: finding.line,
|
|
213
|
+
}))
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return found.flat()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type ScopeRootsResult =
|
|
221
|
+
| { readonly kind: 'exists' }
|
|
222
|
+
| { readonly kind: 'unavailable' }
|
|
223
|
+
| {
|
|
224
|
+
readonly kind: 'gated'
|
|
225
|
+
readonly gated: readonly string[]
|
|
226
|
+
readonly left: readonly { prefix: string; findings: number }[]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Gates each of `GATED_SCOPE_ROOTS` that carries no finding, for a new
|
|
231
|
+
* project. A root with one is left out and named, since gating it would fail
|
|
232
|
+
* the first push on prose a scaffolder wrote. An existing config is a list
|
|
233
|
+
* someone chose, so it is never touched.
|
|
234
|
+
*/
|
|
235
|
+
export async function gateScopeRoots(root: string): Promise<ScopeRootsResult> {
|
|
236
|
+
if (existsSync(join(root, GATED_CONFIG_REL))) return { kind: 'exists' }
|
|
237
|
+
|
|
238
|
+
const gated: string[] = []
|
|
239
|
+
const left: { prefix: string; findings: number }[] = []
|
|
240
|
+
|
|
241
|
+
for (const prefix of GATED_SCOPE_ROOTS) {
|
|
242
|
+
const findings = await findingsUnder(root, prefix)
|
|
243
|
+
if (!findings) return { kind: 'unavailable' }
|
|
244
|
+
if (findings.length === 0) gated.push(prefix)
|
|
245
|
+
else left.push({ prefix, findings: findings.length })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
writeGatedList(root, gated)
|
|
249
|
+
return { kind: 'gated', gated, left }
|
|
250
|
+
}
|