@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +1 -2
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- 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/references/skill.md +1 -1
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-ui/SKILL.md +7 -5
- 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/references/orchestrator-launch.md +9 -0
- package/claude/skills/target-setup/SKILL.md +10 -4
- package/claude/skills/target-setup/references/detect.md +19 -2
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/docs/agents/canvas.md +5 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +4 -1
- package/docs/agents/install-and-sync.md +4 -0
- 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/scripting.md +2 -1
- package/docs/target/projects.md +3 -3
- package/governance/detect.toml +201 -0
- 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/package.json +1 -1
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +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/commands/canvas.ts +179 -1
- package/src/commands/gov.ts +144 -1
- package/src/commands/pr.ts +376 -1
- package/src/feedback/body.ts +26 -5
- package/src/gate/measures.ts +58 -44
- package/src/gate/stages.ts +3 -4
- package/src/gov/detect.ts +301 -0
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +9 -0
- 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/sandbox/equivalence.ts +14 -7
package/src/feedback/body.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
package/src/gate/measures.ts
CHANGED
|
@@ -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
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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`,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
208
|
-
if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
|
|
222
|
+
if (emissions.length > 0) {
|
|
209
223
|
return {
|
|
210
|
-
emissions
|
|
211
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -328,10 +328,9 @@ export const STAGES: readonly Stage[] = [
|
|
|
328
328
|
success: 'Capability seeding clean',
|
|
329
329
|
},
|
|
330
330
|
{
|
|
331
|
-
// A stack
|
|
332
|
-
//
|
|
333
|
-
//
|
|
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
|
|
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
|
+
}
|