@erclx/canon 5.33.0 → 5.35.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 +3 -5
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +2 -3
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +10 -11
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +10 -28
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/canvas-board/SKILL.md +4 -1
- package/claude/skills/canvas-board/references/canvas.md +26 -3
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/create-rule/SKILL.md +1 -1
- package/claude/skills/create-rule/references/rule.md +3 -1
- package/claude/skills/create-skill/SKILL.md +2 -1
- package/claude/skills/create-skill/references/skill-paths.md +12 -15
- package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
- package/claude/skills/create-skill/references/skill.md +10 -4
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/git-commit/references/versioning.md +13 -13
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/git-pr/SKILL.md +1 -1
- package/claude/skills/git-pr/references/publish.md +1 -1
- package/claude/skills/review-address/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +2 -2
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-pr/SKILL.md +3 -3
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +8 -6
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +52 -16
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
- package/claude/skills/role-orchestrator/SKILL.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
- package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
- package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
- package/claude/skills/role-reviewer/SKILL.md +17 -10
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +6 -6
- package/claude/skills/session-compact/references/handoff-note.md +1 -1
- package/claude/skills/target-setup/SKILL.md +12 -6
- package/claude/skills/target-setup/references/detect.md +22 -5
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/audits.md +1 -1
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +3 -5
- package/docs/agents/counts.md +5 -6
- package/docs/agents/drift-surfaces.md +15 -19
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +6 -4
- package/docs/agents/install-and-sync.md +11 -31
- package/docs/agents/markdown-audit.md +10 -12
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/records-stale.md +1 -1
- package/docs/agents/records-validate.md +11 -23
- package/docs/agents/records.md +1 -1
- package/docs/agents/routing.md +1 -1
- package/docs/agents/rule-citations.md +11 -14
- package/docs/agents/scripting.md +4 -21
- package/docs/agents/state-scoped-risk.md +1 -1
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/teach.md +1 -1
- package/docs/target/index.md +1 -0
- package/docs/target/migrations.md +4 -0
- package/docs/target/projects.md +11 -11
- package/docs/target/scenarios.md +5 -6
- package/docs/target/standards-moved.md +32 -0
- package/docs/target/sync.md +5 -5
- package/docs/workflow/operating-model.md +16 -18
- package/docs/workflow/skill-map.md +1 -1
- package/governance/detect.toml +197 -0
- package/governance/renames.toml +0 -1
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/governance/stacks/base.toml +15 -8
- package/governance/stacks/go.toml +2 -0
- package/governance/stacks/node.toml +1 -1
- package/governance/stacks/php.toml +2 -0
- package/governance/stacks/python.toml +1 -1
- package/package.json +1 -4
- package/scripts/core/install-check.sh +2 -3
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -9
- package/src/audits/catalog.ts +0 -9
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/claude/routing.ts +1 -1
- package/src/claude/skills-reach.ts +4 -6
- package/src/cli/help.ts +3 -4
- package/src/cli.ts +0 -2
- package/src/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +149 -10
- package/src/commands/init.ts +1 -1
- package/src/commands/labels.ts +4 -44
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +376 -1
- package/src/commands/records.ts +4 -20
- package/src/commands/tooling.ts +2 -4
- package/src/counts/catalogs.ts +0 -6
- package/src/feedback/body.ts +26 -5
- package/src/gate/boundaries.ts +2 -2
- package/src/gate/measures.ts +64 -72
- package/src/gate/sequencer.ts +51 -2
- package/src/gate/stages.ts +3 -22
- package/src/gov/citations.ts +11 -43
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- package/src/markdown/bans.ts +1 -1
- package/src/migrate/scratch-evidence.ts +2 -2
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/records/validate.ts +6 -194
- package/src/sandbox/equivalence.ts +14 -7
- package/src/sandbox/provision.ts +0 -5
- package/src/secrets/shipped.ts +1 -2
- package/src/serve/static.ts +1 -2
- package/src/shipped/references.ts +29 -80
- package/src/sync/check.ts +18 -14
- package/src/tooling/inject.ts +6 -37
- package/src/tooling/read.ts +2 -3
- package/src/tooling/scan.ts +4 -17
- package/src/tooling/subfolder.ts +2 -66
- package/tooling/base/configs/scripts/verify.json +1 -1
- package/tooling/base/configs/scripts/verify.ts +1 -12
- package/tooling/base/manifest.toml +1 -2
- package/tooling/base/reference.md +10 -16
- package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
- package/tooling/base/seeds/.lintstagedrc +1 -2
- package/tooling/base/seeds/canon/context/ci.md +0 -1
- package/tooling/base/seeds/canon/context/development.md +1 -1
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
- package/tooling/go/reference.md +1 -1
- package/tooling/php/reference.md +1 -1
- package/tooling/python/reference.md +2 -2
- package/tooling/web/configs/.github/workflows/verify.yml +0 -3
- package/tooling/web/manifest.toml +0 -1
- package/tooling/web/reference.md +2 -4
- package/docs/agents/standards-audit.md +0 -40
- package/governance/rules/tooling/765-spelling.md +0 -19
- package/governance/stacks/astro.toml +0 -2
- package/governance/stacks/nextjs.toml +0 -2
- package/governance/stacks/node-server.toml +0 -2
- package/governance/stacks/python-fastapi.toml +0 -2
- package/governance/stacks/react.toml +0 -2
- package/src/commands/standards.ts +0 -278
- package/src/labels/spelling.ts +0 -120
- package/src/standards/audit.ts +0 -146
- package/src/standards/list.ts +0 -33
- package/src/standards/read.ts +0 -177
- package/src/standards/scope.ts +0 -56
- package/standards/index.md +0 -10
- package/tooling/base/configs/.vscode/extensions.json +0 -9
- package/tooling/base/configs/.vscode/settings.json +0 -3
- package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
- package/tooling/base/seeds/cspell.json +0 -20
- package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
- package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
- package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
- package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
- package/tooling/web/configs/.vscode/extensions.json +0 -13
- package/tooling/web/configs/.vscode/settings.json +0 -10
- package/tooling/web/seeds/.cspell/tech-stack.txt +0 -21
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
4
|
+
import { indexRuleSources } from '@/gov/install'
|
|
5
|
+
import { resolveRules } from '@/gov/stacks'
|
|
6
|
+
|
|
7
|
+
/** One table of `governance/detect.toml`. */
|
|
8
|
+
export interface DetectEntry {
|
|
9
|
+
readonly rule: string
|
|
10
|
+
readonly deps: readonly string[]
|
|
11
|
+
readonly files: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What matched for one proposal. `path` is the manifest a dependency was read
|
|
16
|
+
* from, or the path a file glob matched, so a reader can open the evidence.
|
|
17
|
+
*/
|
|
18
|
+
export interface Evidence {
|
|
19
|
+
readonly kind: 'dep' | 'file'
|
|
20
|
+
readonly match: string
|
|
21
|
+
readonly path: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Proposal {
|
|
25
|
+
readonly rule: string
|
|
26
|
+
readonly evidence: readonly Evidence[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DetectResult =
|
|
30
|
+
| { readonly ok: true; readonly proposals: readonly Proposal[] }
|
|
31
|
+
| { readonly ok: false; readonly missingStack: string }
|
|
32
|
+
|
|
33
|
+
/** How deep detection reads, matching `target-setup`'s marker walk. */
|
|
34
|
+
const MAX_DIRS = 2
|
|
35
|
+
|
|
36
|
+
/** Trees an outside-git walk skips, since each carries manifests of its own. */
|
|
37
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv'])
|
|
38
|
+
|
|
39
|
+
export function detectFilePath(root: string): string {
|
|
40
|
+
return join(root, 'governance', 'detect.toml')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringList(value: unknown): string[] {
|
|
44
|
+
return Array.isArray(value)
|
|
45
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
46
|
+
: []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reads every table in file order. An absent file reads as no entries. */
|
|
50
|
+
export function loadDetectEntries(root: string): DetectEntry[] {
|
|
51
|
+
const path = detectFilePath(root)
|
|
52
|
+
if (!existsSync(path)) return []
|
|
53
|
+
|
|
54
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
55
|
+
string,
|
|
56
|
+
unknown
|
|
57
|
+
>
|
|
58
|
+
|
|
59
|
+
return Object.entries(parsed).flatMap(([rule, table]) => {
|
|
60
|
+
if (typeof table !== 'object' || table === null) return []
|
|
61
|
+
const fields = table as Record<string, unknown>
|
|
62
|
+
return [
|
|
63
|
+
{ rule, deps: stringList(fields.deps), files: stringList(fields.files) },
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keys naming no rule under `governance/rules/`, sorted. */
|
|
69
|
+
export function staleDetectKeys(root: string): string[] {
|
|
70
|
+
const live = indexRuleSources(root)
|
|
71
|
+
return loadDetectEntries(root)
|
|
72
|
+
.map((entry) => entry.rule)
|
|
73
|
+
.filter((rule) => !live.has(rule))
|
|
74
|
+
.sort()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function depth(path: string): number {
|
|
78
|
+
return path.split('/').length - 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Files to depth two when the target is not a git checkout. */
|
|
82
|
+
function walkFiles(target: string): string[] {
|
|
83
|
+
const files: string[] = []
|
|
84
|
+
|
|
85
|
+
const visit = (rel: string): void => {
|
|
86
|
+
const abs = rel === '' ? target : join(target, rel)
|
|
87
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
88
|
+
const child = rel === '' ? entry.name : `${rel}/${entry.name}`
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
if (SKIPPED_DIRS.has(entry.name)) continue
|
|
91
|
+
if (depth(child) < MAX_DIRS + 1) visit(child)
|
|
92
|
+
} else if (entry.isFile()) {
|
|
93
|
+
files.push(child)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
visit('')
|
|
99
|
+
return files.sort()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The paths a file glob is matched against: every file to depth two, plus
|
|
104
|
+
* every folder on the way to any listed file, so an evidence folder such as
|
|
105
|
+
* `.github/workflows` matches without naming the file inside it.
|
|
106
|
+
*/
|
|
107
|
+
async function candidatePaths(target: string): Promise<string[]> {
|
|
108
|
+
const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
|
|
109
|
+
const paths = new Set<string>()
|
|
110
|
+
|
|
111
|
+
for (const file of listed) {
|
|
112
|
+
let dir = dirname(file)
|
|
113
|
+
while (dir !== '.') {
|
|
114
|
+
if (depth(dir) <= MAX_DIRS) paths.add(dir)
|
|
115
|
+
dir = dirname(dir)
|
|
116
|
+
}
|
|
117
|
+
if (depth(file) <= MAX_DIRS) paths.add(file)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [...paths].sort()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A PEP 508 requirement's distribution name, normalized per PEP 503. */
|
|
124
|
+
function pythonName(requirement: string): string {
|
|
125
|
+
const name = requirement.trim().split(/[\s<>=!~;[(@,]/)[0] ?? ''
|
|
126
|
+
return name.toLowerCase().replace(/[_.]+/g, '-')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function objectKeys(value: unknown): string[] {
|
|
130
|
+
return typeof value === 'object' && value !== null ? Object.keys(value) : []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function packageJsonDeps(text: string): string[] {
|
|
134
|
+
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
135
|
+
return [
|
|
136
|
+
...objectKeys(parsed.dependencies),
|
|
137
|
+
...objectKeys(parsed.devDependencies),
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function composerDeps(text: string): string[] {
|
|
142
|
+
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
143
|
+
return [...objectKeys(parsed.require), ...objectKeys(parsed['require-dev'])]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pyprojectDeps(text: string): string[] {
|
|
147
|
+
const parsed = Bun.TOML.parse(text) as Record<string, unknown>
|
|
148
|
+
const project = (parsed.project ?? {}) as Record<string, unknown>
|
|
149
|
+
const requirements = [
|
|
150
|
+
...stringList(project.dependencies),
|
|
151
|
+
...Object.values(
|
|
152
|
+
(project['optional-dependencies'] ?? {}) as Record<string, unknown>,
|
|
153
|
+
).flatMap(stringList),
|
|
154
|
+
...Object.values(
|
|
155
|
+
(parsed['dependency-groups'] ?? {}) as Record<string, unknown>,
|
|
156
|
+
).flatMap(stringList),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
const poetry = ((parsed.tool ?? {}) as Record<string, unknown>).poetry as
|
|
160
|
+
| Record<string, unknown>
|
|
161
|
+
| undefined
|
|
162
|
+
const groups = (poetry?.group ?? {}) as Record<string, unknown>
|
|
163
|
+
const poetryNames = [
|
|
164
|
+
...objectKeys(poetry?.dependencies),
|
|
165
|
+
...objectKeys(poetry?.['dev-dependencies']),
|
|
166
|
+
...Object.values(groups).flatMap((group) =>
|
|
167
|
+
objectKeys((group as Record<string, unknown>)?.dependencies),
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
return [...requirements, ...poetryNames].map(pythonName)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function goModDeps(text: string): string[] {
|
|
175
|
+
const names: string[] = []
|
|
176
|
+
let inBlock = false
|
|
177
|
+
|
|
178
|
+
for (const raw of text.split('\n')) {
|
|
179
|
+
const line = raw.replace(/\/\/.*$/, '').trim()
|
|
180
|
+
if (inBlock) {
|
|
181
|
+
if (line === ')') inBlock = false
|
|
182
|
+
else if (line !== '') names.push(line.split(/\s+/)[0] ?? '')
|
|
183
|
+
} else if (/^require\s*\($/.test(line)) {
|
|
184
|
+
inBlock = true
|
|
185
|
+
} else if (line.startsWith('require ')) {
|
|
186
|
+
names.push(line.split(/\s+/)[1] ?? '')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return names.filter(Boolean)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Python names are normalized on read, so `detect.toml` lists them lowercase. */
|
|
194
|
+
const READERS: Readonly<Record<string, (text: string) => string[]>> = {
|
|
195
|
+
'package.json': packageJsonDeps,
|
|
196
|
+
'composer.json': composerDeps,
|
|
197
|
+
'pyproject.toml': pyprojectDeps,
|
|
198
|
+
'go.mod': goModDeps,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every dependency name a manifest to depth two declares, mapped to the
|
|
203
|
+
* manifests declaring it. A manifest that does not parse contributes nothing
|
|
204
|
+
* rather than failing the run, since one broken fixture folder should not cost
|
|
205
|
+
* a project every other proposal.
|
|
206
|
+
*/
|
|
207
|
+
function readDependencies(
|
|
208
|
+
target: string,
|
|
209
|
+
paths: readonly string[],
|
|
210
|
+
): Map<string, string[]> {
|
|
211
|
+
const deps = new Map<string, string[]>()
|
|
212
|
+
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
const reader = READERS[basename(path)]
|
|
215
|
+
if (reader === undefined) continue
|
|
216
|
+
|
|
217
|
+
let declared: string[]
|
|
218
|
+
try {
|
|
219
|
+
declared = reader(readFileSync(join(target, path), 'utf8'))
|
|
220
|
+
} catch {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const dep of declared) {
|
|
225
|
+
const holders = deps.get(dep) ?? []
|
|
226
|
+
if (!holders.includes(path)) holders.push(path)
|
|
227
|
+
deps.set(dep, holders)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return deps
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A glob carrying no wildcard names one path rather than a pattern. */
|
|
235
|
+
function isLiteral(pattern: string): boolean {
|
|
236
|
+
return !/[*?[\]{}]/.test(pattern)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A literal pattern is also tested on disk, since evidence such as
|
|
241
|
+
* `.canon/canvas` sits in a folder a target's own `.gitignore` excludes, and
|
|
242
|
+
* the git listing never carries it.
|
|
243
|
+
*/
|
|
244
|
+
function matchEntry(
|
|
245
|
+
entry: DetectEntry,
|
|
246
|
+
target: string,
|
|
247
|
+
deps: ReadonlyMap<string, readonly string[]>,
|
|
248
|
+
paths: readonly string[],
|
|
249
|
+
): Evidence[] {
|
|
250
|
+
const evidence: Evidence[] = []
|
|
251
|
+
|
|
252
|
+
for (const dep of entry.deps) {
|
|
253
|
+
const holders = deps.get(dep) ?? []
|
|
254
|
+
for (const path of holders) evidence.push({ kind: 'dep', match: dep, path })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const pattern of entry.files) {
|
|
258
|
+
const glob = new Bun.Glob(pattern)
|
|
259
|
+
const hit =
|
|
260
|
+
paths.find((path) => glob.match(path)) ??
|
|
261
|
+
(isLiteral(pattern) && existsSync(join(target, pattern))
|
|
262
|
+
? pattern
|
|
263
|
+
: undefined)
|
|
264
|
+
if (hit !== undefined)
|
|
265
|
+
evidence.push({ kind: 'file', match: pattern, path: hit })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return evidence
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Proposes each opt-in rule whose evidence the target holds. A rule `stack`
|
|
273
|
+
* already resolves is left out, since proposing it would install nothing, and
|
|
274
|
+
* a key naming no live rule is skipped rather than proposed, which the
|
|
275
|
+
* `unreferenced-rules` gate stage reports on the toolkit side.
|
|
276
|
+
*/
|
|
277
|
+
export async function detectRules(
|
|
278
|
+
root: string,
|
|
279
|
+
target: string,
|
|
280
|
+
stack?: string,
|
|
281
|
+
): Promise<DetectResult> {
|
|
282
|
+
let covered = new Set<string>()
|
|
283
|
+
if (stack !== undefined) {
|
|
284
|
+
const resolution = resolveRules(root, stack)
|
|
285
|
+
if (!resolution.ok) return resolution
|
|
286
|
+
covered = new Set(resolution.rules)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const live = indexRuleSources(root)
|
|
290
|
+
const paths = await candidatePaths(target)
|
|
291
|
+
const deps = readDependencies(target, paths)
|
|
292
|
+
|
|
293
|
+
const proposals = loadDetectEntries(root)
|
|
294
|
+
.filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
|
|
295
|
+
.flatMap((entry) => {
|
|
296
|
+
const evidence = matchEntry(entry, target, deps, paths)
|
|
297
|
+
return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
return { ok: true, proposals }
|
|
301
|
+
}
|
package/src/gov/list.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
|
+
import { loadDetectEntries, staleDetectKeys } from '@/gov/detect'
|
|
3
4
|
import { listRuleSourcePaths, rulesSourceDir } from '@/gov/install'
|
|
4
5
|
import {
|
|
5
6
|
expandStackEntry,
|
|
@@ -15,17 +16,26 @@ export interface StackEntry {
|
|
|
15
16
|
readonly rules: string[]
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** The evidence `governance/detect.toml` records for one opt-in rule. */
|
|
20
|
+
export interface RuleDetect {
|
|
21
|
+
readonly deps: readonly string[]
|
|
22
|
+
readonly files: readonly string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
export interface RuleEntry {
|
|
19
26
|
readonly name: string
|
|
20
27
|
readonly domain: string
|
|
21
28
|
readonly description: string
|
|
22
29
|
readonly paths: string[] | null
|
|
30
|
+
readonly detect: RuleDetect | null
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export interface GovCatalog {
|
|
26
34
|
readonly stacks: StackEntry[]
|
|
27
35
|
readonly rules: RuleEntry[]
|
|
28
36
|
readonly unreferenced: string[]
|
|
37
|
+
/** `detect.toml` keys naming no live rule, which the gate fails on. */
|
|
38
|
+
readonly staleDetect: string[]
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/**
|
|
@@ -64,24 +74,34 @@ export function buildStackEntries(root: string): StackEntry[] {
|
|
|
64
74
|
|
|
65
75
|
/**
|
|
66
76
|
* Reads the catalog straight off the source tree. A rule's domain is the
|
|
67
|
-
* subdirectory it sits in, which is the band grouping install preserves
|
|
77
|
+
* subdirectory it sits in, which is the band grouping install preserves, and
|
|
78
|
+
* its `detect` entry is the evidence that proposes it, null for a rule
|
|
79
|
+
* `detect.toml` does not name.
|
|
68
80
|
*/
|
|
69
81
|
export function buildRuleEntries(root: string): RuleEntry[] {
|
|
70
82
|
const rulesRoot = rulesSourceDir(root)
|
|
83
|
+
const detect = new Map(
|
|
84
|
+
loadDetectEntries(root).map((entry) => [
|
|
85
|
+
entry.rule,
|
|
86
|
+
{ deps: entry.deps, files: entry.files },
|
|
87
|
+
]),
|
|
88
|
+
)
|
|
71
89
|
|
|
72
90
|
return listRuleSourcePaths(root).map((rel) => {
|
|
73
91
|
const frontmatter = parseFrontmatter(
|
|
74
92
|
readFileSync(join(rulesRoot, rel), 'utf8'),
|
|
75
93
|
)
|
|
76
94
|
const paths = frontmatter?.fields.paths
|
|
95
|
+
const name = basename(rel, '.md')
|
|
77
96
|
|
|
78
97
|
return {
|
|
79
|
-
name
|
|
98
|
+
name,
|
|
80
99
|
domain: rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '',
|
|
81
100
|
description: readField(frontmatter, 'description') ?? '',
|
|
82
101
|
paths: Array.isArray(paths)
|
|
83
102
|
? paths.filter((entry): entry is string => typeof entry === 'string')
|
|
84
103
|
: null,
|
|
104
|
+
detect: detect.get(name) ?? null,
|
|
85
105
|
}
|
|
86
106
|
})
|
|
87
107
|
}
|
|
@@ -91,6 +111,7 @@ export function buildGovCatalog(root: string): GovCatalog {
|
|
|
91
111
|
stacks: buildStackEntries(root),
|
|
92
112
|
rules: buildRuleEntries(root),
|
|
93
113
|
unreferenced: unreferencedRules(root),
|
|
114
|
+
staleDetect: staleDetectKeys(root),
|
|
94
115
|
}
|
|
95
116
|
}
|
|
96
117
|
|
package/src/gov/stacks.ts
CHANGED
|
@@ -207,3 +207,12 @@ export function mergeExtraRules(
|
|
|
207
207
|
|
|
208
208
|
return merged
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolves `--add` names with no stack under them. It runs the same merge as
|
|
213
|
+
* the stack path against an empty list, so an add with no stack and a layered one
|
|
214
|
+
* split and dedupe their names identically.
|
|
215
|
+
*/
|
|
216
|
+
export function resolveExtraRules(add: string): string[] {
|
|
217
|
+
return mergeExtraRules([], add)
|
|
218
|
+
}
|
package/src/markdown/bans.ts
CHANGED
|
@@ -16,7 +16,7 @@ const CHARACTERS = ['—', ';'] as const
|
|
|
16
16
|
* Parsing the prose was the alternative and it put a parser contract on a
|
|
17
17
|
* document authored for people. The set is closed rather than extensible,
|
|
18
18
|
* since a character is the one class a literal match settles. A word is left
|
|
19
|
-
* to `write-human` as guidance and a spelling to
|
|
19
|
+
* to `write-human` as guidance and a spelling to no check at all, because a word ban
|
|
20
20
|
* catches the token and misses the habit behind it.
|
|
21
21
|
*/
|
|
22
22
|
export const BAN_SETS: BanSets = {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* durable record naming a folder under it as its evidence is a citation into
|
|
7
7
|
* something the backup never covers. The nine promoted here are every folder
|
|
8
8
|
* under scratch that a live or archived record cites and no source file under
|
|
9
|
-
* `claude/`, `scripts/`, `src/`,
|
|
9
|
+
* `claude/`, `scripts/`, `src/`, or `governance/` names by path,
|
|
10
10
|
* which is what separates them from a folder a script still writes into on its
|
|
11
11
|
* own schedule.
|
|
12
12
|
*
|
|
@@ -42,7 +42,7 @@ import { recordDir, SCRATCH } from '@/roots/record'
|
|
|
42
42
|
* Derived by the two-clause test `canon migrate scratch-evidence` exists to
|
|
43
43
|
* apply mechanically: a durable record under a `BACKED_FOLDERS` entry, live
|
|
44
44
|
* or archived, names the folder as its evidence, and no file under `claude/`,
|
|
45
|
-
* `scripts/`, `src/`,
|
|
45
|
+
* `scripts/`, `src/`, or `governance/` names that path. Measured
|
|
46
46
|
* 2026-09-06 against nine folders holding thirteen files.
|
|
47
47
|
*
|
|
48
48
|
* `verify-astro` and `verify-vite-react` pass the same test and are excluded
|