@erclx/canon 5.33.0 → 5.34.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 (64) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/api-design/REQUIREMENT.md +1 -2
  3. package/claude/skills/api-design/SKILL.md +2 -3
  4. package/claude/skills/api-design/references/adopted.md +1 -1
  5. package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
  6. package/claude/skills/canon-feedback/SKILL.md +10 -28
  7. package/claude/skills/canon-feedback/references/template.md +53 -0
  8. package/claude/skills/canvas-board/SKILL.md +4 -1
  9. package/claude/skills/canvas-board/references/canvas.md +26 -3
  10. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  11. package/claude/skills/code-craft/SKILL.md +0 -1
  12. package/claude/skills/create-rule/SKILL.md +1 -1
  13. package/claude/skills/create-rule/references/rule.md +3 -1
  14. package/claude/skills/create-skill/references/skill.md +1 -1
  15. package/claude/skills/git-issue/references/issue.md +4 -1
  16. package/claude/skills/review-craft/references/ui.md +1 -1
  17. package/claude/skills/review-ui/SKILL.md +7 -5
  18. package/claude/skills/review-ui/references/driving.md +8 -8
  19. package/claude/skills/review-ui/references/post.md +52 -16
  20. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +9 -0
  21. package/claude/skills/target-setup/SKILL.md +10 -4
  22. package/claude/skills/target-setup/references/detect.md +19 -2
  23. package/claude/skills/test-craft/REQUIREMENT.md +2 -3
  24. package/claude/skills/test-craft/SKILL.md +15 -3
  25. package/docs/agents/canvas.md +5 -1
  26. package/docs/agents/commands.md +1 -1
  27. package/docs/agents/gov-detect.md +47 -0
  28. package/docs/agents/index.md +4 -1
  29. package/docs/agents/install-and-sync.md +4 -0
  30. package/docs/agents/pr-evidence-layout.md +70 -0
  31. package/docs/agents/pr-evidence.md +8 -9
  32. package/docs/agents/pr-frames.md +7 -2
  33. package/docs/agents/pr-lift.md +132 -0
  34. package/docs/agents/scripting.md +2 -1
  35. package/docs/target/projects.md +3 -3
  36. package/governance/detect.toml +201 -0
  37. package/governance/rules/code/000-code.md +1 -13
  38. package/governance/rules/code/010-testing.md +2 -30
  39. package/governance/rules/ui/400-ui.md +1 -1
  40. package/governance/rules/ui/480-canvas.md +13 -0
  41. package/package.json +1 -1
  42. package/scripts/core/regen-hero.ts +0 -6
  43. package/scripts/lib/ui.sh +0 -9
  44. package/src/canvas/arrange.ts +66 -0
  45. package/src/canvas/capture.ts +20 -6
  46. package/src/canvas/content.ts +39 -0
  47. package/src/canvas/fit.ts +238 -0
  48. package/src/canvas/tokens.ts +28 -10
  49. package/src/capture/frame-values.ts +2 -32
  50. package/src/commands/canvas.ts +179 -1
  51. package/src/commands/gov.ts +144 -1
  52. package/src/commands/pr.ts +376 -1
  53. package/src/feedback/body.ts +26 -5
  54. package/src/gate/measures.ts +58 -44
  55. package/src/gate/stages.ts +3 -4
  56. package/src/gov/detect.ts +301 -0
  57. package/src/gov/list.ts +23 -2
  58. package/src/gov/stacks.ts +9 -0
  59. package/src/pr/evidence.ts +218 -45
  60. package/src/pr/frames.ts +23 -3
  61. package/src/pr/lift.ts +418 -0
  62. package/src/pr/rest.ts +3 -0
  63. package/src/pr/review-scope.ts +76 -0
  64. package/src/sandbox/equivalence.ts +14 -7
@@ -18,13 +18,18 @@ export const REQUIRED_FIELDS = ['Surface', 'Observed', 'Proposed fix'] as const
18
18
  * retire, and the validator would then enforce nothing.
19
19
  */
20
20
  const FIELD_HEADING = /^###\s+(.+?)\s*$/
21
- const FENCE = /^\s*(```|~~~)/
21
+ const FENCE = /^\s*(`{3,}|~{3,})(.*)$/
22
+
23
+ interface OpenFence {
24
+ readonly marker: string
25
+ readonly length: number
26
+ }
22
27
 
23
28
  export function parseSections(body: string): Map<string, string> {
24
29
  const sections = new Map<string, string>()
25
30
  let heading: string | undefined
26
31
  let lines: string[] = []
27
- let fenced = false
32
+ let fence: OpenFence | undefined
28
33
 
29
34
  const flush = (): void => {
30
35
  if (heading === undefined) return
@@ -34,9 +39,25 @@ export function parseSections(body: string): Map<string, string> {
34
39
  for (const line of body.split('\n')) {
35
40
  // A Repro field routinely carries a fenced block, and a `###` comment
36
41
  // inside one is shell rather than the next field. Reading it as a heading
37
- // splits the section and drops everything under it.
38
- if (FENCE.test(line)) fenced = !fenced
39
- const match = fenced ? null : line.match(FIELD_HEADING)
42
+ // splits the section and drops everything under it. A Suggested shape
43
+ // wraps a template that holds its own fence in a longer one, so a fence
44
+ // closes only on the same marker at least as long, per CommonMark.
45
+ const fenceMatch = line.match(FENCE)
46
+ const run = fenceMatch?.[1]
47
+ if (run !== undefined) {
48
+ const marker = run.charAt(0)
49
+ const isBare = (fenceMatch?.[2] ?? '').trim() === ''
50
+ if (fence === undefined) {
51
+ fence = { marker, length: run.length }
52
+ } else if (
53
+ isBare &&
54
+ marker === fence.marker &&
55
+ run.length >= fence.length
56
+ ) {
57
+ fence = undefined
58
+ }
59
+ }
60
+ const match = fence ? null : line.match(FIELD_HEADING)
40
61
  if (match?.[1] === undefined) {
41
62
  lines.push(line)
42
63
  continue
@@ -104,28 +104,6 @@ const info = (text: string): Emission => ({ kind: 'info', text })
104
104
  const warn = (text: string): Emission => ({ kind: 'warn', text })
105
105
  const output = (text: string): Emission => ({ kind: 'output', text })
106
106
 
107
- /**
108
- * Rules no stack reaches, sorted the way `canon gov list` emits them.
109
- * `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
110
- * want.
111
- *
112
- * `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
113
- * `--add` until a Go or PHP stack ships, which `target-setup` reaches by
114
- * matching a detected language against each rule's description.
115
- *
116
- * All are recorded here rather than in a config file: the list is what a
117
- * reader compares a new arrival against, and a config file would absorb the
118
- * arrival silently.
119
- */
120
- export const GOV_EXPECTED_UNREFERENCED = [
121
- '130-go',
122
- '140-php',
123
- '260-shadcn',
124
- '320-tanstack-query',
125
- '335-testing-go',
126
- '336-testing-php',
127
- ]
128
-
129
107
  /**
130
108
  * Scenarios declaring no expectation, taken from `canon sandbox coverage`
131
109
  * against a clean tree. Raising it is a deliberate edit that says which
@@ -170,16 +148,19 @@ function parseJson(payload: string): unknown {
170
148
  }
171
149
 
172
150
  /**
173
- * A rule no stack names is a report rather than a gate. All three standing
174
- * findings ship that way on purpose, so failing would fail every push over the
175
- * deliberate case and teach a reader to route around the stage.
151
+ * A rule no stack reaches arrives at a project only through `canon gov
152
+ * detect`, so each one needs a `governance/detect.toml` entry or nothing ever
153
+ * proposes it. A key naming a retired rule fails too, since it would hide from
154
+ * the rename sweep that keys the file by basename.
155
+ *
156
+ * The check replaced a fixed list of expected unreferenced rules. Once the
157
+ * stacks are thin, that list is most of the catalog and would be rewritten on
158
+ * every new rule, which is a list nobody reads.
176
159
  *
177
- * The catalog is parsed here rather than piped through `bun --eval`, which the
178
- * script this replaces had to do and had to guard: that interpreter exits 0
179
- * when its script throws while stdin is a pipe, so a payload that is not JSON
180
- * printed nothing and exited clean, and empty already means every rule is
181
- * reached. A sentinel string carried success past that. Parsing in process
182
- * leaves the two states distinguishable with nothing to carry.
160
+ * The catalog is parsed here rather than piped through `bun --eval`, whose
161
+ * interpreter exits 0 when its script throws while stdin is a pipe, so a
162
+ * payload that is not JSON would print nothing and read as every rule
163
+ * reached. Parsing in process keeps the two states distinguishable.
183
164
  */
184
165
  export const unreferencedRules: Measure = async (ctx) => {
185
166
  const { exitCode, stdout } = await ctx.cli(['gov', 'list', '--json'])
@@ -190,34 +171,67 @@ export const unreferencedRules: Measure = async (ctx) => {
190
171
  }
191
172
  }
192
173
 
193
- const record = parseJson(stdout) as { unreferenced?: unknown } | undefined
194
- if (!Array.isArray(record?.unreferenced)) {
174
+ const record = parseJson(stdout) as
175
+ | { unreferenced?: unknown; rules?: unknown; staleDetect?: unknown }
176
+ | undefined
177
+ if (
178
+ !Array.isArray(record?.unreferenced) ||
179
+ !Array.isArray(record?.rules) ||
180
+ !Array.isArray(record?.staleDetect)
181
+ ) {
195
182
  return {
196
183
  emissions: [],
197
184
  unmeasured:
198
- 'The governance catalog carried no readable unreferenced list.',
185
+ 'The governance catalog carried no readable unreferenced, rules, or staleDetect list.',
199
186
  }
200
187
  }
201
188
 
189
+ const detected = new Set(
190
+ record.rules
191
+ .filter(
192
+ (rule): rule is { name: string; detect: unknown } =>
193
+ typeof rule === 'object' &&
194
+ rule !== null &&
195
+ typeof (rule as { name?: unknown }).name === 'string',
196
+ )
197
+ .filter((rule) => rule.detect !== null && rule.detect !== undefined)
198
+ .map((rule) => rule.name),
199
+ )
202
200
  const unreferenced = record.unreferenced.map(String)
203
- if (unreferenced.length === 0) {
204
- return { emissions: [info('Every rule is reached by a stack')] }
201
+ const undetected = unreferenced.filter((rule) => !detected.has(rule))
202
+ const stale = record.staleDetect.map(String)
203
+
204
+ const emissions: Emission[] = []
205
+ if (undetected.length > 0) {
206
+ emissions.push(
207
+ warn(
208
+ `Reached by no stack and proposed by nothing: ${undetected.join(' ')}`,
209
+ ),
210
+ warn(
211
+ 'Name each in a stack, or add a table to governance/detect.toml with the deps or files that mark a project as wanting it.',
212
+ ),
213
+ )
214
+ }
215
+ if (stale.length > 0) {
216
+ emissions.push(
217
+ warn(`governance/detect.toml names no live rule: ${stale.join(' ')}`),
218
+ warn('Rename the key to the rule it became, or delete the table.'),
219
+ )
205
220
  }
206
221
 
207
- const listed = unreferenced.join(' ')
208
- if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
222
+ if (emissions.length > 0) {
209
223
  return {
210
- emissions: [
211
- info(`Reached by no stack: ${listed} (each recorded above with why)`),
212
- ],
224
+ emissions,
225
+ failure: 'An opt-in rule has no route to a project',
213
226
  }
214
227
  }
215
228
 
216
229
  return {
217
230
  emissions: [
218
- warn(`Reached by no stack: ${listed}`),
219
- warn(
220
- `Expected: ${GOV_EXPECTED_UNREFERENCED.join(' ')}. Name the new rule in a stack, or update GOV_EXPECTED_UNREFERENCED in src/gate/measures.ts and say why it reaches no stack.`,
231
+ info(
232
+ unreferenced.length === 0
233
+ ? 'Every rule is reached by a stack'
234
+ : `${plural(unreferenced.length, 'rule')} reached by no stack, each proposed by governance/detect.toml`,
221
235
  ),
222
236
  ],
223
237
  }
@@ -328,10 +328,9 @@ export const STAGES: readonly Stage[] = [
328
328
  success: 'Capability seeding clean',
329
329
  },
330
330
  {
331
- // A stack entry naming a rule folder takes every rule in it, which is what
332
- // stops a new rule from needing a second edit to reach a target. The
333
- // failure it leaves open is a rule authored into a folder no stack names,
334
- // which `canon gov install` never reaches on its own.
331
+ // A rule no stack names reaches a target only when `canon gov detect`
332
+ // proposes it, so every such rule needs a governance/detect.toml entry,
333
+ // and every key there has to name a live rule.
335
334
  id: 'unreferenced-rules',
336
335
  label: 'Unreferenced rules',
337
336
  checks: [{ kind: 'measure', measure: unreferencedRules }],
@@ -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
+ }