@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.
Files changed (190) hide show
  1. package/README.md +3 -5
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/api-design/REQUIREMENT.md +2 -3
  4. package/claude/skills/api-design/SKILL.md +2 -3
  5. package/claude/skills/api-design/references/adopted.md +1 -1
  6. package/claude/skills/auto-ship/references/draft-mark.md +1 -1
  7. package/claude/skills/canon-cli/SKILL.md +10 -11
  8. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  9. package/claude/skills/canon-feedback/SKILL.md +10 -28
  10. package/claude/skills/canon-feedback/references/template.md +53 -0
  11. package/claude/skills/canvas-board/SKILL.md +4 -1
  12. package/claude/skills/canvas-board/references/canvas.md +26 -3
  13. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  14. package/claude/skills/code-craft/SKILL.md +0 -1
  15. package/claude/skills/create-rule/SKILL.md +1 -1
  16. package/claude/skills/create-rule/references/rule.md +3 -1
  17. package/claude/skills/create-skill/SKILL.md +2 -1
  18. package/claude/skills/create-skill/references/skill-paths.md +12 -15
  19. package/{standards/standard.md → claude/skills/create-skill/references/skill-reference.md} +50 -45
  20. package/claude/skills/create-skill/references/skill.md +10 -4
  21. package/claude/skills/draft-and-pick/SKILL.md +1 -1
  22. package/claude/skills/git-commit/references/versioning.md +13 -13
  23. package/claude/skills/git-issue/references/issue.md +4 -1
  24. package/claude/skills/git-pr/SKILL.md +1 -1
  25. package/claude/skills/git-pr/references/publish.md +1 -1
  26. package/claude/skills/review-address/REQUIREMENT.md +1 -1
  27. package/claude/skills/review-address/SKILL.md +2 -2
  28. package/claude/skills/review-craft/references/ui.md +1 -1
  29. package/claude/skills/review-pr/SKILL.md +3 -3
  30. package/claude/skills/review-pr/references/post.md +1 -1
  31. package/claude/skills/review-ui/REQUIREMENT.md +1 -1
  32. package/claude/skills/review-ui/SKILL.md +8 -6
  33. package/claude/skills/review-ui/references/driving.md +8 -8
  34. package/claude/skills/review-ui/references/post.md +52 -16
  35. package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -2
  36. package/claude/skills/role-orchestrator/SKILL.md +11 -12
  37. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +3 -1
  38. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -8
  39. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +10 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +11 -2
  41. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +11 -12
  42. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +12 -15
  43. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +6 -4
  44. package/claude/skills/role-orchestrator/scripts/poll.ts +50 -1
  45. package/claude/skills/role-reviewer/REQUIREMENT.md +17 -7
  46. package/claude/skills/role-reviewer/SKILL.md +17 -10
  47. package/claude/skills/role-worker/REQUIREMENT.md +1 -1
  48. package/claude/skills/role-worker/SKILL.md +6 -6
  49. package/claude/skills/session-compact/references/handoff-note.md +1 -1
  50. package/claude/skills/target-setup/SKILL.md +12 -6
  51. package/claude/skills/target-setup/references/detect.md +22 -5
  52. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  53. package/claude/skills/test-craft/SKILL.md +15 -3
  54. package/docs/agents/audits.md +1 -1
  55. package/docs/agents/canvas.md +5 -1
  56. package/docs/agents/commands.md +3 -5
  57. package/docs/agents/counts.md +5 -6
  58. package/docs/agents/drift-surfaces.md +15 -19
  59. package/docs/agents/gov-detect.md +47 -0
  60. package/docs/agents/index.md +6 -4
  61. package/docs/agents/install-and-sync.md +11 -31
  62. package/docs/agents/markdown-audit.md +10 -12
  63. package/docs/agents/migrate.md +1 -1
  64. package/docs/agents/pr-evidence-layout.md +70 -0
  65. package/docs/agents/pr-evidence.md +8 -9
  66. package/docs/agents/pr-frames.md +7 -2
  67. package/docs/agents/pr-lift.md +132 -0
  68. package/docs/agents/records-stale.md +1 -1
  69. package/docs/agents/records-validate.md +11 -23
  70. package/docs/agents/records.md +1 -1
  71. package/docs/agents/routing.md +1 -1
  72. package/docs/agents/rule-citations.md +11 -14
  73. package/docs/agents/scripting.md +4 -21
  74. package/docs/agents/state-scoped-risk.md +1 -1
  75. package/docs/agents/tasks-validate.md +1 -1
  76. package/docs/agents/teach.md +1 -1
  77. package/docs/target/index.md +1 -0
  78. package/docs/target/migrations.md +4 -0
  79. package/docs/target/projects.md +11 -11
  80. package/docs/target/scenarios.md +5 -6
  81. package/docs/target/standards-moved.md +32 -0
  82. package/docs/target/sync.md +5 -5
  83. package/docs/workflow/operating-model.md +16 -18
  84. package/docs/workflow/skill-map.md +1 -1
  85. package/governance/detect.toml +197 -0
  86. package/governance/renames.toml +0 -1
  87. package/governance/rules/code/000-code.md +1 -13
  88. package/governance/rules/code/010-testing.md +2 -30
  89. package/governance/rules/ui/400-ui.md +1 -1
  90. package/governance/rules/ui/480-canvas.md +13 -0
  91. package/governance/stacks/base.toml +15 -8
  92. package/governance/stacks/go.toml +2 -0
  93. package/governance/stacks/node.toml +1 -1
  94. package/governance/stacks/php.toml +2 -0
  95. package/governance/stacks/python.toml +1 -1
  96. package/package.json +1 -4
  97. package/scripts/core/install-check.sh +2 -3
  98. package/scripts/core/regen-hero.ts +0 -6
  99. package/scripts/lib/ui.sh +0 -9
  100. package/src/audits/catalog.ts +0 -9
  101. package/src/canvas/arrange.ts +66 -0
  102. package/src/canvas/capture.ts +20 -6
  103. package/src/canvas/content.ts +39 -0
  104. package/src/canvas/fit.ts +238 -0
  105. package/src/canvas/tokens.ts +28 -10
  106. package/src/capture/frame-values.ts +2 -32
  107. package/src/claude/routing.ts +1 -1
  108. package/src/claude/skills-reach.ts +4 -6
  109. package/src/cli/help.ts +3 -4
  110. package/src/cli.ts +0 -2
  111. package/src/commands/canvas.ts +179 -1
  112. package/src/commands/gov.ts +149 -10
  113. package/src/commands/init.ts +1 -1
  114. package/src/commands/labels.ts +4 -44
  115. package/src/commands/migrate.ts +1 -1
  116. package/src/commands/pr.ts +376 -1
  117. package/src/commands/records.ts +4 -20
  118. package/src/commands/tooling.ts +2 -4
  119. package/src/counts/catalogs.ts +0 -6
  120. package/src/feedback/body.ts +26 -5
  121. package/src/gate/boundaries.ts +2 -2
  122. package/src/gate/measures.ts +64 -72
  123. package/src/gate/sequencer.ts +51 -2
  124. package/src/gate/stages.ts +3 -22
  125. package/src/gov/citations.ts +11 -43
  126. package/src/gov/detect.ts +301 -0
  127. package/src/gov/list.ts +23 -2
  128. package/src/gov/stacks.ts +9 -0
  129. package/src/markdown/bans.ts +1 -1
  130. package/src/migrate/scratch-evidence.ts +2 -2
  131. package/src/pr/evidence.ts +218 -45
  132. package/src/pr/frames.ts +23 -3
  133. package/src/pr/lift.ts +418 -0
  134. package/src/pr/rest.ts +3 -0
  135. package/src/pr/review-scope.ts +76 -0
  136. package/src/records/validate.ts +6 -194
  137. package/src/sandbox/equivalence.ts +14 -7
  138. package/src/sandbox/provision.ts +0 -5
  139. package/src/secrets/shipped.ts +1 -2
  140. package/src/serve/static.ts +1 -2
  141. package/src/shipped/references.ts +29 -80
  142. package/src/sync/check.ts +18 -14
  143. package/src/tooling/inject.ts +6 -37
  144. package/src/tooling/read.ts +2 -3
  145. package/src/tooling/scan.ts +4 -17
  146. package/src/tooling/subfolder.ts +2 -66
  147. package/tooling/base/configs/scripts/verify.json +1 -1
  148. package/tooling/base/configs/scripts/verify.ts +1 -12
  149. package/tooling/base/manifest.toml +1 -2
  150. package/tooling/base/reference.md +10 -16
  151. package/tooling/base/seeds/.github/workflows/verify.yml +0 -3
  152. package/tooling/base/seeds/.lintstagedrc +1 -2
  153. package/tooling/base/seeds/canon/context/ci.md +0 -1
  154. package/tooling/base/seeds/canon/context/development.md +1 -1
  155. package/tooling/claude/reference.md +1 -1
  156. package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +13 -1
  157. package/tooling/go/reference.md +1 -1
  158. package/tooling/php/reference.md +1 -1
  159. package/tooling/python/reference.md +2 -2
  160. package/tooling/web/configs/.github/workflows/verify.yml +0 -3
  161. package/tooling/web/manifest.toml +0 -1
  162. package/tooling/web/reference.md +2 -4
  163. package/docs/agents/standards-audit.md +0 -40
  164. package/governance/rules/tooling/765-spelling.md +0 -19
  165. package/governance/stacks/astro.toml +0 -2
  166. package/governance/stacks/nextjs.toml +0 -2
  167. package/governance/stacks/node-server.toml +0 -2
  168. package/governance/stacks/python-fastapi.toml +0 -2
  169. package/governance/stacks/react.toml +0 -2
  170. package/src/commands/standards.ts +0 -278
  171. package/src/labels/spelling.ts +0 -120
  172. package/src/standards/audit.ts +0 -146
  173. package/src/standards/list.ts +0 -33
  174. package/src/standards/read.ts +0 -177
  175. package/src/standards/scope.ts +0 -56
  176. package/standards/index.md +0 -10
  177. package/tooling/base/configs/.vscode/extensions.json +0 -9
  178. package/tooling/base/configs/.vscode/settings.json +0 -3
  179. package/tooling/base/seeds/.cspell/project-terms.txt +0 -0
  180. package/tooling/base/seeds/.cspell/tech-stack.txt +0 -25
  181. package/tooling/base/seeds/cspell.json +0 -20
  182. package/tooling/go/seeds/.cspell/tech-stack.txt +0 -16
  183. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +0 -3
  184. package/tooling/php/seeds/.cspell/tech-stack.txt +0 -5
  185. package/tooling/python/seeds/.cspell/tech-stack.txt +0 -18
  186. package/tooling/vite-react/seeds/.cspell/project-terms.txt +0 -1
  187. package/tooling/vite-react/seeds/.cspell/tech-stack.txt +0 -2
  188. package/tooling/web/configs/.vscode/extensions.json +0 -13
  189. package/tooling/web/configs/.vscode/settings.json +0 -10
  190. 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: basename(rel, '.md'),
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
+ }
@@ -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 cspell, because a word ban
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/`, `governance/`, or `standards/` names by path,
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/`, `governance/`, or `standards/` names that path. Measured
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