@erclx/canon 5.42.0 → 5.43.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/auto-ship/SKILL.md +3 -3
- package/claude/skills/ci-workflow/REQUIREMENT.md +2 -1
- package/claude/skills/ci-workflow/SKILL.md +12 -2
- package/claude/skills/ci-workflow/references/workflows.md +12 -9
- package/claude/skills/create-rule/SKILL.md +1 -0
- package/claude/skills/decision-escalate/SKILL.md +1 -2
- package/claude/skills/document-health/references/axes.md +2 -1
- package/claude/skills/draft-doc/references/wiki-kind.md +1 -1
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/git-commit/references/commit.md +1 -5
- package/claude/skills/git-pr/references/pr.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-feature/references/plan.md +1 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -0
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +3 -3
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +26 -2
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +2 -2
- package/docs/agents/audits.md +1 -0
- package/docs/agents/migrate.md +11 -8
- package/docs/target/migrations.md +11 -0
- package/governance/detect.toml +4 -0
- package/governance/rules/canon/context.md +2 -1
- package/governance/rules/canon/plan.md +3 -5
- package/governance/rules/canon/scratch.md +3 -1
- package/governance/rules/canon/task-scope.md +2 -1
- package/governance/rules/canon/tasks.md +2 -1
- package/governance/rules/canon/worktrees.md +5 -2
- package/governance/rules/claude/output.md +7 -4
- package/governance/rules/claude/skill.md +6 -4
- package/governance/rules/tooling/ci-workflow.md +6 -39
- package/governance/rules/ui/design-reference.md +37 -0
- package/governance/rules/writing/markdown.md +2 -1
- package/package.json +6 -6
- package/src/audits/catalog.ts +42 -0
- package/src/claude/settings.ts +8 -4
- package/src/commands/claude.ts +11 -1
- package/src/commands/gov.ts +198 -0
- package/src/commands/migrate.ts +121 -2
- package/src/commands/pr.ts +4 -77
- package/src/commands/tooling.ts +3 -3
- package/src/commands/worktrees.ts +49 -0
- package/src/gate/stages.ts +15 -0
- package/src/gov/audit.ts +440 -0
- package/src/listeners/belongs.ts +21 -0
- package/src/listeners/machine.ts +93 -0
- package/src/listeners/parse.ts +41 -0
- package/src/migrate/session-defaults.ts +138 -0
- package/src/pr/local.ts +4 -69
- package/src/sync/check.ts +15 -0
- package/src/tooling/stamp.ts +56 -12
- package/src/worktrees/stop.ts +92 -0
- package/tooling/base/configs/.github/workflows/phase-label-gate.yml +6 -2
- package/tooling/base/configs/commitlint.config.js +0 -20
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/workflows/verify.yml +4 -1
- package/tooling/claude/reference.md +13 -7
- package/tooling/claude/seeds/.claude/settings.json +4 -0
- package/tooling/claude/user/settings.template.json +0 -4
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +5 -5
- package/tooling/public/configs/.github/dependabot.yml +12 -0
- package/tooling/public/configs/.github/workflows/release-please.yml +30 -0
- package/tooling/public/manifest.toml +8 -0
- package/tooling/public/reference.md +53 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/bug.yml +48 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/config.yml +5 -0
- package/tooling/public/seeds/.github/ISSUE_TEMPLATE/feature.yml +16 -0
- package/tooling/public/seeds/.release-please-manifest.json +3 -0
- package/tooling/public/seeds/CONTRIBUTING.md +50 -0
- package/tooling/public/seeds/LICENSE +21 -0
- package/tooling/public/seeds/SECURITY.md +13 -0
- package/tooling/public/seeds/release-please-config.json +12 -0
- package/tooling/shell/configs/.github/workflows/shell.yml +4 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +6 -3
- package/tooling/web/configs/.github/workflows/verify.yml +15 -12
package/src/gov/audit.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { listRuleFiles } from '@/gov/payload'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The six shape limits a rule body is held to.
|
|
8
|
+
*
|
|
9
|
+
* `create-rule`'s `references/rule.md` states the same six numbers to the
|
|
10
|
+
* session writing a rule, and nothing compares the two, so a change to one is
|
|
11
|
+
* a change to both in the same commit. The numbers come from groundwork 158's
|
|
12
|
+
* decision rather than from the density limits, which bound prose sections a
|
|
13
|
+
* rule never carries.
|
|
14
|
+
*/
|
|
15
|
+
export const RULE_LIMITS = {
|
|
16
|
+
words: 300,
|
|
17
|
+
bullets: 15,
|
|
18
|
+
h2: 6,
|
|
19
|
+
perH2: 7,
|
|
20
|
+
chars: 200,
|
|
21
|
+
sentences: 2,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type RuleLimit = keyof typeof RULE_LIMITS | 'emphasis'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rule folders whose findings fail `--gate`, as path prefixes from the root.
|
|
28
|
+
*
|
|
29
|
+
* Each folder's rewrite appends its own line once that folder reaches zero, so
|
|
30
|
+
* the slices merge without touching one another's entries. `canon/` waits on
|
|
31
|
+
* `plan.md`, which a later slice rewrites.
|
|
32
|
+
*/
|
|
33
|
+
export const RULE_GATED_FOLDERS: readonly string[] = [
|
|
34
|
+
join('governance', 'rules', 'claude') + sep,
|
|
35
|
+
join('governance', 'rules', 'code') + sep,
|
|
36
|
+
join('governance', 'rules', 'writing') + sep,
|
|
37
|
+
join('internal', 'rules') + sep,
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** The folders whose rules claim outside practice, so each carries a ledger. */
|
|
41
|
+
export const PRACTICE_FOLDERS: readonly string[] = [
|
|
42
|
+
'lang',
|
|
43
|
+
'lib',
|
|
44
|
+
'framework',
|
|
45
|
+
'ui',
|
|
46
|
+
'tooling',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const SHIPPED_RULES = join('governance', 'rules')
|
|
50
|
+
const LEDGERS = join('governance', 'ledger')
|
|
51
|
+
|
|
52
|
+
/** Both corpora authored here, read when the tree is a canon checkout. */
|
|
53
|
+
const CANON_CORPUS: readonly string[] = [
|
|
54
|
+
SHIPPED_RULES,
|
|
55
|
+
join('internal', 'rules'),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What a target authors. `.claude/rules/canon/` is installed from the toolkit
|
|
60
|
+
* and fixed at its source, so a finding there is one the target cannot act on.
|
|
61
|
+
*/
|
|
62
|
+
const TARGET_CORPUS: readonly string[] = [join('.claude', 'rules', 'project')]
|
|
63
|
+
|
|
64
|
+
export interface ShapeFinding {
|
|
65
|
+
readonly limit: RuleLimit
|
|
66
|
+
readonly count: number
|
|
67
|
+
readonly max: number
|
|
68
|
+
/** One-based file line of the bullet, absent for a whole-body limit. */
|
|
69
|
+
readonly line?: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface RuleShape {
|
|
73
|
+
readonly words: number
|
|
74
|
+
readonly bullets: number
|
|
75
|
+
readonly h2: number
|
|
76
|
+
/** The most bullets under one H2, with the text above the first H2 a group. */
|
|
77
|
+
readonly perH2: number
|
|
78
|
+
readonly findings: readonly ShapeFinding[]
|
|
79
|
+
/** Bullets per H2 heading, `null` for the group above the first one. */
|
|
80
|
+
readonly groups: readonly HeadingCount[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HeadingCount {
|
|
84
|
+
readonly heading: string | null
|
|
85
|
+
readonly count: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LedgerMismatch {
|
|
89
|
+
readonly heading: string | null
|
|
90
|
+
readonly bullets: number
|
|
91
|
+
readonly rows: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type LedgerReading =
|
|
95
|
+
| { readonly status: 'not-required' }
|
|
96
|
+
| { readonly status: 'missing'; readonly path: string }
|
|
97
|
+
| {
|
|
98
|
+
readonly status: 'present'
|
|
99
|
+
readonly path: string
|
|
100
|
+
readonly mismatches: readonly LedgerMismatch[]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RuleReading extends Omit<RuleShape, 'groups'> {
|
|
104
|
+
/** Relative to the root that was read. */
|
|
105
|
+
readonly file: string
|
|
106
|
+
readonly ledger: LedgerReading
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type RuleAuditRefusal = 'no-rules' | 'no-path' | 'outside-root'
|
|
110
|
+
|
|
111
|
+
export type RuleAuditReport =
|
|
112
|
+
| {
|
|
113
|
+
readonly kind: 'measured'
|
|
114
|
+
/** `canon` reads both authored corpora and ledgers, `target` neither. */
|
|
115
|
+
readonly scope: 'canon' | 'target'
|
|
116
|
+
readonly corpus: readonly string[]
|
|
117
|
+
readonly rules: readonly RuleReading[]
|
|
118
|
+
}
|
|
119
|
+
| {
|
|
120
|
+
readonly kind: 'unreadable'
|
|
121
|
+
readonly reason: RuleAuditRefusal
|
|
122
|
+
readonly message: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const FRONTMATTER = /^---\n[\s\S]*?\n---\n/
|
|
126
|
+
const FENCE = /^\s*(?:```|~~~)/
|
|
127
|
+
const H2 = /^## /
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Case-sensitive on the capitals, since "Never" opening a directive is plain
|
|
131
|
+
* wording and "NEVER" is the shouting the prompting guide says newer models
|
|
132
|
+
* overtrigger on.
|
|
133
|
+
*/
|
|
134
|
+
const EMPHASIS = /\b(?:CRITICAL|IMPORTANT|MUST|ALWAYS|NEVER)\b|\b[Yy]ou must\b/
|
|
135
|
+
|
|
136
|
+
function stripCodeSpans(text: string): string {
|
|
137
|
+
return text.replace(/`[^`]*`/g, 'X')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Sentence ends in one bullet, never fewer than one.
|
|
142
|
+
*
|
|
143
|
+
* Copied from groundwork 158's `audit.py`, which measured the baseline. A
|
|
144
|
+
* period inside a code span or a short abbreviation ends nothing, and counting
|
|
145
|
+
* either would put most of the corpus past two.
|
|
146
|
+
*/
|
|
147
|
+
export function countSentences(bullet: string): number {
|
|
148
|
+
const text = stripCodeSpans(bullet).replace(/\b(?:e\.g|i\.e|etc|vs)\./g, 'X')
|
|
149
|
+
return text.match(/[.!?](?:\s|$)/g)?.length ?? 1
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface Bullet {
|
|
153
|
+
text: string
|
|
154
|
+
line: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Measures one rule file against `RULE_LIMITS`.
|
|
159
|
+
*
|
|
160
|
+
* A bullet is a top-level `- ` line plus any indented line directly under it,
|
|
161
|
+
* so a wrapped bullet is measured whole. A nested bullet is not counted, which
|
|
162
|
+
* matches the groundwork script the baseline came from. Fenced blocks display
|
|
163
|
+
* a list rather than state one.
|
|
164
|
+
*/
|
|
165
|
+
export function measureRule(text: string): RuleShape {
|
|
166
|
+
const frontmatter = text.match(FRONTMATTER)
|
|
167
|
+
const body = frontmatter ? text.slice(frontmatter[0].length) : text
|
|
168
|
+
const offset = frontmatter ? frontmatter[0].split('\n').length - 1 : 0
|
|
169
|
+
const lines = body.split('\n')
|
|
170
|
+
|
|
171
|
+
const bullets: Bullet[] = []
|
|
172
|
+
const groups: { heading: string | null; count: number }[] = [
|
|
173
|
+
{ heading: null, count: 0 },
|
|
174
|
+
]
|
|
175
|
+
let fenced = false
|
|
176
|
+
let open: Bullet | undefined
|
|
177
|
+
|
|
178
|
+
for (const [index, line] of lines.entries()) {
|
|
179
|
+
if (FENCE.test(line)) {
|
|
180
|
+
fenced = !fenced
|
|
181
|
+
open = undefined
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
if (fenced) continue
|
|
185
|
+
|
|
186
|
+
if (H2.test(line)) {
|
|
187
|
+
groups.push({ heading: line.slice(3).trim(), count: 0 })
|
|
188
|
+
open = undefined
|
|
189
|
+
} else if (line.startsWith('- ')) {
|
|
190
|
+
open = { text: line.slice(2).trim(), line: offset + index + 1 }
|
|
191
|
+
bullets.push(open)
|
|
192
|
+
groups[groups.length - 1].count += 1
|
|
193
|
+
} else if (
|
|
194
|
+
open !== undefined &&
|
|
195
|
+
line.startsWith(' ') &&
|
|
196
|
+
!line.trim().startsWith('- ')
|
|
197
|
+
) {
|
|
198
|
+
open.text += ` ${line.trim()}`
|
|
199
|
+
} else {
|
|
200
|
+
open = undefined
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const words = body.match(/\S+/g)?.length ?? 0
|
|
205
|
+
const h2 = groups.length - 1
|
|
206
|
+
const perH2 = Math.max(...groups.map((group) => group.count))
|
|
207
|
+
const findings: ShapeFinding[] = []
|
|
208
|
+
|
|
209
|
+
const whole: [RuleLimit & keyof typeof RULE_LIMITS, number][] = [
|
|
210
|
+
['words', words],
|
|
211
|
+
['bullets', bullets.length],
|
|
212
|
+
['h2', h2],
|
|
213
|
+
['perH2', perH2],
|
|
214
|
+
]
|
|
215
|
+
for (const [limit, count] of whole) {
|
|
216
|
+
if (count > RULE_LIMITS[limit]) {
|
|
217
|
+
findings.push({ limit, count, max: RULE_LIMITS[limit] })
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const bullet of bullets) {
|
|
222
|
+
const chars = [...bullet.text].length
|
|
223
|
+
if (chars > RULE_LIMITS.chars) {
|
|
224
|
+
findings.push({
|
|
225
|
+
limit: 'chars',
|
|
226
|
+
count: chars,
|
|
227
|
+
max: RULE_LIMITS.chars,
|
|
228
|
+
line: bullet.line,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
const sentences = countSentences(bullet.text)
|
|
232
|
+
if (sentences > RULE_LIMITS.sentences) {
|
|
233
|
+
findings.push({
|
|
234
|
+
limit: 'sentences',
|
|
235
|
+
count: sentences,
|
|
236
|
+
max: RULE_LIMITS.sentences,
|
|
237
|
+
line: bullet.line,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
// A code span quotes a word rather than shouting it.
|
|
241
|
+
if (EMPHASIS.test(stripCodeSpans(bullet.text))) {
|
|
242
|
+
findings.push({ limit: 'emphasis', count: 1, max: 0, line: bullet.line })
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
words,
|
|
248
|
+
bullets: bullets.length,
|
|
249
|
+
h2,
|
|
250
|
+
perH2,
|
|
251
|
+
findings,
|
|
252
|
+
groups: groups.filter((group) => group.count > 0),
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Table rows per H2 in a ledger, `null` for a table above the first H2.
|
|
258
|
+
*
|
|
259
|
+
* A row is a table line after the header and its separator. Rows are matched
|
|
260
|
+
* to bullets by count under a heading of the same name rather than by text,
|
|
261
|
+
* since matching text breaks on every reword and whether a source says what
|
|
262
|
+
* its bullet says is a review judgment either way.
|
|
263
|
+
*/
|
|
264
|
+
export function countLedgerRows(text: string): HeadingCount[] {
|
|
265
|
+
const counts = new Map<string | null, number>()
|
|
266
|
+
let heading: string | null = null
|
|
267
|
+
let tableLines = 0
|
|
268
|
+
let fenced = false
|
|
269
|
+
|
|
270
|
+
for (const line of text.split('\n')) {
|
|
271
|
+
if (FENCE.test(line)) fenced = !fenced
|
|
272
|
+
if (fenced) continue
|
|
273
|
+
|
|
274
|
+
if (line.startsWith('|')) {
|
|
275
|
+
tableLines += 1
|
|
276
|
+
if (tableLines > 2) counts.set(heading, (counts.get(heading) ?? 0) + 1)
|
|
277
|
+
continue
|
|
278
|
+
}
|
|
279
|
+
tableLines = 0
|
|
280
|
+
if (H2.test(line)) heading = line.slice(3).trim()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return [...counts].map(([key, count]) => ({ heading: key, count }))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function compareLedger(
|
|
287
|
+
groups: readonly HeadingCount[],
|
|
288
|
+
rows: readonly HeadingCount[],
|
|
289
|
+
): LedgerMismatch[] {
|
|
290
|
+
const bullets = new Map(groups.map((group) => [group.heading, group.count]))
|
|
291
|
+
const ledger = new Map(rows.map((row) => [row.heading, row.count]))
|
|
292
|
+
const headings = [...new Set([...bullets.keys(), ...ledger.keys()])]
|
|
293
|
+
|
|
294
|
+
return headings
|
|
295
|
+
.map((heading) => ({
|
|
296
|
+
heading,
|
|
297
|
+
bullets: bullets.get(heading) ?? 0,
|
|
298
|
+
rows: ledger.get(heading) ?? 0,
|
|
299
|
+
}))
|
|
300
|
+
.filter((entry) => entry.bullets !== entry.rows)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Where a rule's ledger lives, or nothing when its folder carries none. */
|
|
304
|
+
function ledgerPath(file: string): string | undefined {
|
|
305
|
+
const prefix = `${SHIPPED_RULES}${sep}`
|
|
306
|
+
if (!file.startsWith(prefix)) return undefined
|
|
307
|
+
const rest = file.slice(prefix.length)
|
|
308
|
+
const folder = rest.split(sep)[0]
|
|
309
|
+
if (!PRACTICE_FOLDERS.includes(folder)) return undefined
|
|
310
|
+
return join(LEDGERS, rest)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function readLedger(
|
|
314
|
+
root: string,
|
|
315
|
+
file: string,
|
|
316
|
+
groups: readonly HeadingCount[],
|
|
317
|
+
): Promise<LedgerReading> {
|
|
318
|
+
const path = ledgerPath(file)
|
|
319
|
+
if (path === undefined) return { status: 'not-required' }
|
|
320
|
+
|
|
321
|
+
let text: string
|
|
322
|
+
try {
|
|
323
|
+
text = await readFile(resolve(root, path), 'utf8')
|
|
324
|
+
} catch {
|
|
325
|
+
return { status: 'missing', path }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
status: 'present',
|
|
330
|
+
path,
|
|
331
|
+
mismatches: compareLedger(groups, countLedgerRows(text)),
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Measures every rule this tree authors, or the files named.
|
|
337
|
+
*
|
|
338
|
+
* A tree holding `governance/rules/` is a canon checkout, read across both
|
|
339
|
+
* authored corpora with a ledger asked of each outside-practice rule. Any
|
|
340
|
+
* other tree is a target, read at `.claude/rules/project/` alone with no
|
|
341
|
+
* ledger asked, since ledgers never ship.
|
|
342
|
+
*/
|
|
343
|
+
export async function readRuleAudit(
|
|
344
|
+
root: string,
|
|
345
|
+
paths: readonly string[] = [],
|
|
346
|
+
): Promise<RuleAuditReport> {
|
|
347
|
+
const scope = existsSync(resolve(root, SHIPPED_RULES)) ? 'canon' : 'target'
|
|
348
|
+
const corpus = scope === 'canon' ? CANON_CORPUS : TARGET_CORPUS
|
|
349
|
+
|
|
350
|
+
let files: string[]
|
|
351
|
+
if (paths.length > 0) {
|
|
352
|
+
const missing = paths.filter((path) => !existsSync(resolve(root, path)))
|
|
353
|
+
if (missing.length > 0) {
|
|
354
|
+
return {
|
|
355
|
+
kind: 'unreadable',
|
|
356
|
+
reason: 'no-path',
|
|
357
|
+
message: `No rule at ${missing.join(', ')}.`,
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// A path outside the root takes a `../` file name, which no gated prefix
|
|
361
|
+
// and no ledger path can match, so it would pass `--gate` unread.
|
|
362
|
+
const outside = paths.filter((path) =>
|
|
363
|
+
relative(root, resolve(root, path)).startsWith('..'),
|
|
364
|
+
)
|
|
365
|
+
if (outside.length > 0) {
|
|
366
|
+
return {
|
|
367
|
+
kind: 'unreadable',
|
|
368
|
+
reason: 'outside-root',
|
|
369
|
+
message: `${outside.join(', ')} sits outside ${root}. Pass --root at the tree that holds it.`,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const expanded: string[] = []
|
|
373
|
+
for (const path of paths) {
|
|
374
|
+
const abs = resolve(root, path)
|
|
375
|
+
if (!statSync(abs).isDirectory()) {
|
|
376
|
+
expanded.push(abs)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
const found = listRuleFiles(abs)
|
|
380
|
+
if (found.length === 0) {
|
|
381
|
+
return {
|
|
382
|
+
kind: 'unreadable',
|
|
383
|
+
reason: 'no-rules',
|
|
384
|
+
message: `No rule under ${path}, and a folder with no rules passes each of its zero rules, so it refuses rather than reporting clean.`,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
expanded.push(...found)
|
|
388
|
+
}
|
|
389
|
+
files = expanded
|
|
390
|
+
} else {
|
|
391
|
+
const dirs = corpus
|
|
392
|
+
.map((rel) => resolve(root, rel))
|
|
393
|
+
.filter((dir) => existsSync(dir))
|
|
394
|
+
if (dirs.length === 0) {
|
|
395
|
+
return {
|
|
396
|
+
kind: 'unreadable',
|
|
397
|
+
reason: 'no-rules',
|
|
398
|
+
message: `No rule corpus under ${root}. Looked for ${corpus.join(', ')}, and a tree with no rules passes each of its zero rules, so it refuses rather than reporting clean.`,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
files = dirs.flatMap((dir) => listRuleFiles(dir))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const rules: RuleReading[] = []
|
|
405
|
+
for (const abs of files) {
|
|
406
|
+
const file = relative(root, abs)
|
|
407
|
+
const { groups, ...shape } = measureRule(await readFile(abs, 'utf8'))
|
|
408
|
+
rules.push({ file, ...shape, ledger: await readLedger(root, file, groups) })
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return { kind: 'measured', scope, corpus, rules }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function hasFinding(reading: RuleReading): boolean {
|
|
415
|
+
if (reading.findings.length > 0) return true
|
|
416
|
+
if (reading.ledger.status === 'missing') return true
|
|
417
|
+
return (
|
|
418
|
+
reading.ledger.status === 'present' && reading.ledger.mismatches.length > 0
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Exit code for a measured report.
|
|
424
|
+
*
|
|
425
|
+
* Without `gate`, any finding sets 2, which is what a session fixing its own
|
|
426
|
+
* rule reads. With it, only a finding in a gated folder does, so the push
|
|
427
|
+
* stage reports the whole corpus and fails on the folders already at zero.
|
|
428
|
+
*/
|
|
429
|
+
export function auditExit(
|
|
430
|
+
report: Extract<RuleAuditReport, { kind: 'measured' }>,
|
|
431
|
+
options: { readonly gate: boolean; readonly gated: readonly string[] },
|
|
432
|
+
): number {
|
|
433
|
+
const failing = report.rules.filter(
|
|
434
|
+
(reading) =>
|
|
435
|
+
hasFinding(reading) &&
|
|
436
|
+
(!options.gate ||
|
|
437
|
+
options.gated.some((prefix) => reading.file.startsWith(prefix))),
|
|
438
|
+
)
|
|
439
|
+
return failing.length > 0 ? 2 : 0
|
|
440
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The folder linked worktrees sit under. A listener there belongs to another
|
|
3
|
+
* branch even though its cwd sits inside the main checkout's toplevel.
|
|
4
|
+
*/
|
|
5
|
+
const LINKED_WORKTREES = '.claude/worktrees'
|
|
6
|
+
|
|
7
|
+
function isInside(path: string, root: string): boolean {
|
|
8
|
+
return path === root || path.startsWith(`${root}/`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a process running in `cwd` serves this worktree. A tool that
|
|
13
|
+
* changes into a subfolder before listening still counts, while a sibling
|
|
14
|
+
* worktree nested under the main checkout's `.claude/worktrees/` does not.
|
|
15
|
+
*/
|
|
16
|
+
export function belongsTo(cwd: string, worktreeRoot: string): boolean {
|
|
17
|
+
return (
|
|
18
|
+
isInside(cwd, worktreeRoot) &&
|
|
19
|
+
!isInside(cwd, `${worktreeRoot}/${LINKED_WORKTREES}`)
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readdir, readFile, readlink } from 'node:fs/promises'
|
|
3
|
+
import { $ } from 'bun'
|
|
4
|
+
import {
|
|
5
|
+
type Listener,
|
|
6
|
+
parseLsofListeners,
|
|
7
|
+
parseProcNetTcp,
|
|
8
|
+
} from '@/listeners/parse'
|
|
9
|
+
|
|
10
|
+
/** The machine reads a listener lookup makes, injected so it can be driven without binding a socket. */
|
|
11
|
+
export interface ListenerReader {
|
|
12
|
+
/** Every TCP socket in LISTEN with its owning pid, or undefined when this machine offers no way to read them. */
|
|
13
|
+
listListeners(): Promise<readonly Listener[] | undefined>
|
|
14
|
+
cwdOf(pid: number): Promise<string | undefined>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readText(path: string): Promise<string | undefined> {
|
|
18
|
+
return readFile(path, 'utf8').catch(() => undefined)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every listening socket's owning pid, read from `/proc`. Only a process this
|
|
23
|
+
* user owns exposes its `fd` table, which is also the only process whose
|
|
24
|
+
* server this user's worktree could have started.
|
|
25
|
+
*/
|
|
26
|
+
async function readProcListeners(): Promise<Listener[] | undefined> {
|
|
27
|
+
const tables = await Promise.all(
|
|
28
|
+
['/proc/net/tcp', '/proc/net/tcp6'].map(readText),
|
|
29
|
+
)
|
|
30
|
+
if (tables.every((text) => text === undefined)) return undefined
|
|
31
|
+
const byInode = new Map(
|
|
32
|
+
tables.flatMap((text) =>
|
|
33
|
+
text === undefined
|
|
34
|
+
? []
|
|
35
|
+
: parseProcNetTcp(text).map((s) => [s.inode, s.port] as const),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const processIds = (await readdir('/proc').catch(() => [])).filter((name) =>
|
|
40
|
+
/^\d+$/.test(name),
|
|
41
|
+
)
|
|
42
|
+
const listeners: Listener[] = []
|
|
43
|
+
for (const pid of processIds) {
|
|
44
|
+
const fds = await readdir(`/proc/${pid}/fd`).catch(() => [])
|
|
45
|
+
for (const fd of fds) {
|
|
46
|
+
const target = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => '')
|
|
47
|
+
const inode = /^socket:\[(\d+)\]$/.exec(target)?.[1]
|
|
48
|
+
const port = inode === undefined ? undefined : byInode.get(inode)
|
|
49
|
+
if (port !== undefined) listeners.push({ pid: Number(pid), port })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return listeners
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The machine half of a listener lookup: `/proc` wherever it exists, lsof on a
|
|
57
|
+
* machine without it, and no reader anywhere else.
|
|
58
|
+
*/
|
|
59
|
+
export function machineListenerReader(): ListenerReader {
|
|
60
|
+
const hasLsof = Bun.which('lsof') !== null
|
|
61
|
+
return {
|
|
62
|
+
async listListeners() {
|
|
63
|
+
// lsof 4.95 drops every process whose kernel-truncated name holds an
|
|
64
|
+
// unmatched `(`, which Next's `next-server (vX.Y.Z)` title cuts down to,
|
|
65
|
+
// so `/proc` goes first despite costing several times what lsof does.
|
|
66
|
+
if (existsSync('/proc/net/tcp')) {
|
|
67
|
+
const listeners = await readProcListeners()
|
|
68
|
+
if (listeners !== undefined) return listeners
|
|
69
|
+
}
|
|
70
|
+
if (hasLsof) {
|
|
71
|
+
const result = await $`lsof -nP -iTCP -sTCP:LISTEN -Fpn`
|
|
72
|
+
.quiet()
|
|
73
|
+
.nothrow()
|
|
74
|
+
// lsof exits 1 both when it matched nothing and when it could not
|
|
75
|
+
// read some process, and the rows it did print are real either way.
|
|
76
|
+
if (result.exitCode <= 1) return parseLsofListeners(result.text())
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
},
|
|
80
|
+
async cwdOf(pid) {
|
|
81
|
+
if (existsSync(`/proc/${pid}/cwd`)) {
|
|
82
|
+
return readlink(`/proc/${pid}/cwd`).catch(() => undefined)
|
|
83
|
+
}
|
|
84
|
+
if (!hasLsof) return undefined
|
|
85
|
+
const result = await $`lsof -a -p ${pid} -d cwd -Fn`.quiet().nothrow()
|
|
86
|
+
const line = result
|
|
87
|
+
.text()
|
|
88
|
+
.split('\n')
|
|
89
|
+
.find((row) => row.startsWith('n'))
|
|
90
|
+
return line?.slice(1)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The state `/proc/net/tcp` writes for a socket in LISTEN. */
|
|
2
|
+
const PROC_LISTEN_STATE = '0A'
|
|
3
|
+
|
|
4
|
+
export interface Listener {
|
|
5
|
+
readonly port: number
|
|
6
|
+
readonly pid: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Reads `lsof -nP -iTCP -sTCP:LISTEN -Fpn` output, where a `p` line opens a process and each `n` line names one of its sockets. */
|
|
10
|
+
export function parseLsofListeners(text: string): Listener[] {
|
|
11
|
+
const listeners: Listener[] = []
|
|
12
|
+
let pid: number | undefined
|
|
13
|
+
for (const line of text.split('\n')) {
|
|
14
|
+
if (line.startsWith('p')) pid = Number(line.slice(1))
|
|
15
|
+
if (!line.startsWith('n') || pid === undefined) continue
|
|
16
|
+
const port = Number(line.slice(line.lastIndexOf(':') + 1))
|
|
17
|
+
if (Number.isInteger(port) && port > 0) listeners.push({ pid, port })
|
|
18
|
+
}
|
|
19
|
+
return listeners
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProcSocket {
|
|
23
|
+
readonly port: number
|
|
24
|
+
readonly inode: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Reads `/proc/net/tcp` or `tcp6`, keeping each listening socket's port and inode for matching against `/proc/<pid>/fd`. */
|
|
28
|
+
export function parseProcNetTcp(text: string): ProcSocket[] {
|
|
29
|
+
const sockets: ProcSocket[] = []
|
|
30
|
+
for (const line of text.split('\n').slice(1)) {
|
|
31
|
+
const fields = line.trim().split(/\s+/)
|
|
32
|
+
const local = fields[1]
|
|
33
|
+
const state = fields[3]
|
|
34
|
+
const inode = fields[9]
|
|
35
|
+
if (local === undefined || inode === undefined) continue
|
|
36
|
+
if (state !== PROC_LISTEN_STATE) continue
|
|
37
|
+
const port = Number.parseInt(local.slice(local.lastIndexOf(':') + 1), 16)
|
|
38
|
+
if (Number.isInteger(port) && port > 0) sockets.push({ port, inode })
|
|
39
|
+
}
|
|
40
|
+
return sockets
|
|
41
|
+
}
|