trackfw 7.2.0 → 7.3.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/package.json +1 -1
- package/src/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +107 -8
- package/src/commands/doctor.js +16 -3
- package/src/commands/index.js +1 -0
- package/src/commands/integrations.js +19 -2
- package/src/commands/thirdparty.js +7 -2
- package/src/commands/update-harness.js +3 -1
- package/src/commands/update.js +20 -1
- package/src/config/index.js +79 -0
- package/src/generators/hooks.js +18 -0
- package/src/generators/init.js +72 -16
- package/src/generators/roadmap.js +53 -2
- package/src/integrations/assets/agents/architect.md +5 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/scaffold_doctor.js +366 -0
- package/src/lib/update-engine.js +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.3.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// audit-surface.js — `trackfw audit-surface` command (Node.js CLI).
|
|
4
|
+
//
|
|
5
|
+
// AC16 invariant (no false positives, by construction): this module NEVER reads
|
|
6
|
+
// file content looking for hook-path strings. It ONLY opens the 8 exact wiring-file
|
|
7
|
+
// paths defined in RUNTIME_WIRING_PATH. Files like docs/cli-parity.md and
|
|
8
|
+
// internal/generators/agentfiles.go happen to mention those paths as strings but are
|
|
9
|
+
// never opened here — they live at paths not in RUNTIME_WIRING_PATH.
|
|
10
|
+
|
|
11
|
+
const { Command } = require('commander')
|
|
12
|
+
const { execFileSync, spawnSync } = require('child_process')
|
|
13
|
+
const crypto = require('crypto')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
|
|
16
|
+
// Runtime names in canonical order (matches check-agent-hooks-parity.sh CLIS).
|
|
17
|
+
const CANONICAL_RUNTIMES = ['claude', 'codex', 'gemini', 'copilot', 'cursor', 'kiro', 'windsurf', 'amazonq']
|
|
18
|
+
|
|
19
|
+
const RUNTIME_WIRING_PATH = {
|
|
20
|
+
claude: '.claude/settings.json',
|
|
21
|
+
codex: '.codex/hooks.json',
|
|
22
|
+
gemini: '.gemini/settings.json',
|
|
23
|
+
copilot: '.github/hooks/trackfw-attention.json',
|
|
24
|
+
cursor: '.cursor/hooks.json',
|
|
25
|
+
kiro: '.kiro/hooks/trackfw-attention.json',
|
|
26
|
+
windsurf: '.windsurf/hooks.json',
|
|
27
|
+
amazonq: '.amazonq/cli-agents/q_cli_default.json',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const INSTRUCTION_FILE_PATHS = [
|
|
31
|
+
'CLAUDE.md',
|
|
32
|
+
'AGENTS.md',
|
|
33
|
+
'GEMINI.md',
|
|
34
|
+
'.windsurfrules',
|
|
35
|
+
'.github/copilot-instructions.md',
|
|
36
|
+
'.amazonq/developer/guidelines.md',
|
|
37
|
+
'.cursor/rules/trackfw.mdc',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
// gitShow reads a file at a given ref via `git show <ref>:<path>`.
|
|
41
|
+
// Returns Buffer on success, null if not found.
|
|
42
|
+
function gitShow(ref, filePath, gitRoot) {
|
|
43
|
+
const result = spawnSync('git', ['show', `${ref}:${filePath}`], { cwd: gitRoot, maxBuffer: 10 * 1024 * 1024 })
|
|
44
|
+
if (result.status !== 0) return null
|
|
45
|
+
return result.stdout
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// gitLsTree lists files under a directory at a given ref.
|
|
49
|
+
function gitLsTree(ref, dir, gitRoot) {
|
|
50
|
+
const result = spawnSync('git', ['ls-tree', '-r', '--name-only', ref, '--', dir], { cwd: gitRoot })
|
|
51
|
+
if (result.status !== 0) return []
|
|
52
|
+
const lines = result.stdout.toString().replace(/\n$/, '').split('\n').filter(Boolean)
|
|
53
|
+
return lines.sort()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// findGitRoot returns the git repository root.
|
|
57
|
+
function findGitRoot(cwd) {
|
|
58
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: cwd || process.cwd() })
|
|
59
|
+
if (result.status !== 0) return null
|
|
60
|
+
return result.stdout.toString().trim()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// normalizeCommand strips known project-root env-var prefixes and outer quotes
|
|
64
|
+
// to produce a repo-relative script path. Returns '' if genuinely unresolvable
|
|
65
|
+
// (inline pipeline, -c string, builtin, etc.).
|
|
66
|
+
//
|
|
67
|
+
// Resolved forms:
|
|
68
|
+
// bare path: "scripts/hook.sh"
|
|
69
|
+
// path with args: "scripts/hook.sh --strict" → "scripts/hook.sh"
|
|
70
|
+
// interpreter prefix: "bash scripts/hook.sh" → "scripts/hook.sh"
|
|
71
|
+
// project-root prefix:"$CLAUDE_PROJECT_DIR/scripts/hook.sh" → "scripts/hook.sh"
|
|
72
|
+
//
|
|
73
|
+
// Recognised script extensions: .sh .bash .zsh .py .js .rb .pl .fish
|
|
74
|
+
function normalizeCommand(rawCmd) {
|
|
75
|
+
let cmd = rawCmd.trim()
|
|
76
|
+
// Strip surrounding double-quotes (Codex format).
|
|
77
|
+
if (cmd.startsWith('"') && cmd.endsWith('"')) {
|
|
78
|
+
cmd = cmd.slice(1, -1)
|
|
79
|
+
}
|
|
80
|
+
const prefixes = [
|
|
81
|
+
'$CLAUDE_PROJECT_DIR/',
|
|
82
|
+
'$GEMINI_PROJECT_DIR/',
|
|
83
|
+
'$(git rev-parse --show-toplevel)/',
|
|
84
|
+
]
|
|
85
|
+
const stripPrefix = s => {
|
|
86
|
+
for (const p of prefixes) {
|
|
87
|
+
if (s.startsWith(p)) return s.slice(p.length)
|
|
88
|
+
}
|
|
89
|
+
return s
|
|
90
|
+
}
|
|
91
|
+
cmd = stripPrefix(cmd)
|
|
92
|
+
|
|
93
|
+
const scriptExts = ['.sh', '.bash', '.zsh', '.py', '.js', '.rb', '.pl', '.fish']
|
|
94
|
+
const hasScriptExt = s => scriptExts.some(ext => s.endsWith(ext))
|
|
95
|
+
|
|
96
|
+
// No spaces: bare path — accept if it has a recognised script extension.
|
|
97
|
+
if (!cmd.includes(' ')) {
|
|
98
|
+
return hasScriptExt(cmd) ? cmd : ''
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Command has spaces: interpreter prefix or path-with-arguments.
|
|
102
|
+
const interpreters = new Set(['bash', 'sh', 'dash', 'zsh', 'python', 'python3', 'python2', 'node', 'nodejs', 'ruby', 'perl'])
|
|
103
|
+
const tokens = cmd.trim().split(/\s+/)
|
|
104
|
+
let candidate = ''
|
|
105
|
+
|
|
106
|
+
if (interpreters.has(tokens[0])) {
|
|
107
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
108
|
+
if (tokens[i] === '-c') return '' // inline script string, not a file path
|
|
109
|
+
if (tokens[i].startsWith('-')) continue // flags: -e, -u, -x, etc.
|
|
110
|
+
candidate = tokens[i]
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// No interpreter: first token is the script path, rest are arguments.
|
|
115
|
+
candidate = tokens[0]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!candidate) return ''
|
|
119
|
+
|
|
120
|
+
// Strip project-root prefixes from candidate too.
|
|
121
|
+
candidate = stripPrefix(candidate)
|
|
122
|
+
|
|
123
|
+
return hasScriptExt(candidate) ? candidate : ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// getSymlinkTarget checks whether path at ref is a git symlink (mode 120000).
|
|
127
|
+
// Returns { target, isLink } — isLink is true when it's a symlink.
|
|
128
|
+
function getSymlinkTarget(ref, scriptPath, gitRoot) {
|
|
129
|
+
const result = spawnSync('git', ['ls-tree', ref, '--', scriptPath], { cwd: gitRoot })
|
|
130
|
+
if (result.status !== 0 || !result.stdout || result.stdout.length === 0) {
|
|
131
|
+
return { target: '', isLink: false }
|
|
132
|
+
}
|
|
133
|
+
const line = result.stdout.toString()
|
|
134
|
+
const tabIdx = line.indexOf('\t')
|
|
135
|
+
if (tabIdx < 0) return { target: '', isLink: false }
|
|
136
|
+
const fields = line.slice(0, tabIdx).trim().split(/\s+/)
|
|
137
|
+
if (!fields.length || fields[0] !== '120000') return { target: '', isLink: false }
|
|
138
|
+
// Read the symlink target string.
|
|
139
|
+
const targetBuf = gitShow(ref, scriptPath, gitRoot)
|
|
140
|
+
if (!targetBuf) return { target: '', isLink: false }
|
|
141
|
+
return { target: targetBuf.toString().trim(), isLink: true }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// resolveScriptDigest follows the symlink chain (if any) at scriptPath with
|
|
145
|
+
// cycle detection and a depth limit, then returns the digest string.
|
|
146
|
+
//
|
|
147
|
+
// Marker format: "symlink-><first_target>|<outcome>" for symlink chains;
|
|
148
|
+
// plain "sha256:<hex>" or "not-found" for regular files.
|
|
149
|
+
//
|
|
150
|
+
// Outcomes for symlink chains:
|
|
151
|
+
// sha256:<hex> — resolved to real content
|
|
152
|
+
// not-found — final target absent at this ref
|
|
153
|
+
// not-supported — absolute symlink target
|
|
154
|
+
// circular-not-supported — cycle detected
|
|
155
|
+
// chain-not-supported — depth limit (8 hops) exceeded
|
|
156
|
+
function resolveScriptDigest(ref, scriptPath, gitRoot) {
|
|
157
|
+
const maxHops = 8
|
|
158
|
+
const visited = new Set([scriptPath])
|
|
159
|
+
let current = scriptPath
|
|
160
|
+
let firstTarget = '' // first symlink target, for the "symlink->" prefix
|
|
161
|
+
|
|
162
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
163
|
+
const { target, isLink } = getSymlinkTarget(ref, current, gitRoot)
|
|
164
|
+
if (!isLink) {
|
|
165
|
+
// current is a regular file (or absent).
|
|
166
|
+
if (firstTarget === '') {
|
|
167
|
+
// No symlink in the chain: plain digest.
|
|
168
|
+
const buf = gitShow(ref, current, gitRoot)
|
|
169
|
+
return buf ? sha256hex(buf) : 'not-found'
|
|
170
|
+
}
|
|
171
|
+
// End of symlink chain.
|
|
172
|
+
const buf = gitShow(ref, current, gitRoot)
|
|
173
|
+
return buf
|
|
174
|
+
? `symlink->${firstTarget}|` + sha256hex(buf)
|
|
175
|
+
: `symlink->${firstTarget}|not-found`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (firstTarget === '') firstTarget = target
|
|
179
|
+
|
|
180
|
+
// Absolute target — cannot follow without checkout.
|
|
181
|
+
if (target.startsWith('/')) {
|
|
182
|
+
return `symlink->${firstTarget}|not-supported`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Resolve relative to current's directory.
|
|
186
|
+
const linkDir = path.posix.dirname(current)
|
|
187
|
+
const resolved = path.posix.join(linkDir, target)
|
|
188
|
+
|
|
189
|
+
// Cycle detection.
|
|
190
|
+
if (visited.has(resolved)) {
|
|
191
|
+
return `symlink->${firstTarget}|circular-not-supported`
|
|
192
|
+
}
|
|
193
|
+
visited.add(resolved)
|
|
194
|
+
current = resolved
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Depth limit exceeded.
|
|
198
|
+
return `symlink->${firstTarget}|chain-not-supported`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// sha256hex computes SHA-256 of buffer content.
|
|
202
|
+
function sha256hex(buf) {
|
|
203
|
+
return 'sha256:' + crypto.createHash('sha256').update(buf).digest('hex')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// extractTuples parses wiring-file JSON and returns hook tuples per runtime schema.
|
|
207
|
+
function extractTuples(runtime, content) {
|
|
208
|
+
let root
|
|
209
|
+
try {
|
|
210
|
+
root = JSON.parse(content.toString())
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return [{ event: 'parse-error', matcher: '', raw_command: e.message, script_path: '', script_digest: 'unresolvable' }]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
switch (runtime) {
|
|
216
|
+
case 'claude':
|
|
217
|
+
case 'codex':
|
|
218
|
+
case 'amazonq':
|
|
219
|
+
case 'gemini':
|
|
220
|
+
return extractClaudeSchema(root)
|
|
221
|
+
case 'kiro':
|
|
222
|
+
return extractKiroSchema(root)
|
|
223
|
+
case 'copilot':
|
|
224
|
+
return extractCopilotSchema(root)
|
|
225
|
+
case 'cursor':
|
|
226
|
+
return extractCursorSchema(root)
|
|
227
|
+
case 'windsurf':
|
|
228
|
+
return extractWindsurfSchema(root)
|
|
229
|
+
default:
|
|
230
|
+
return []
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// extractClaudeSchema: {"hooks": {"EVENT": [{"matcher":"...","hooks":[{"command":"...","type":"command"}]}]}}
|
|
235
|
+
function extractClaudeSchema(root) {
|
|
236
|
+
const hooks = root.hooks || {}
|
|
237
|
+
const events = Object.keys(hooks).sort()
|
|
238
|
+
const tuples = []
|
|
239
|
+
for (const event of events) {
|
|
240
|
+
const entries = (hooks[event] || []).slice().sort((a, b) => (a.matcher || '') < (b.matcher || '') ? -1 : (a.matcher || '') > (b.matcher || '') ? 1 : 0)
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
for (const h of (entry.hooks || [])) {
|
|
243
|
+
if (h.type !== 'command') continue
|
|
244
|
+
tuples.push({
|
|
245
|
+
event,
|
|
246
|
+
matcher: entry.matcher || '',
|
|
247
|
+
raw_command: h.command || '',
|
|
248
|
+
script_path: normalizeCommand(h.command || ''),
|
|
249
|
+
script_digest: '',
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return tuples
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// extractKiroSchema: {"version":"v1","hooks":[{"trigger":"...","matcher":"...","action":{"type":"command","command":"..."}}]}
|
|
258
|
+
function extractKiroSchema(root) {
|
|
259
|
+
const hooks = (root.hooks || []).slice().sort((a, b) => {
|
|
260
|
+
if (a.trigger !== b.trigger) return a.trigger < b.trigger ? -1 : 1
|
|
261
|
+
return (a.matcher || '') < (b.matcher || '') ? -1 : (a.matcher || '') > (b.matcher || '') ? 1 : 0
|
|
262
|
+
})
|
|
263
|
+
return hooks
|
|
264
|
+
.filter(h => h.action && h.action.type === 'command')
|
|
265
|
+
.map(h => ({
|
|
266
|
+
event: h.trigger,
|
|
267
|
+
matcher: h.matcher || '',
|
|
268
|
+
raw_command: h.action.command || '',
|
|
269
|
+
script_path: normalizeCommand(h.action.command || ''),
|
|
270
|
+
script_digest: '',
|
|
271
|
+
}))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// extractCopilotSchema: {"version":1,"hooks":{"preToolUse":[{"type":"command","bash":"...","matcher":"..."}],...}}
|
|
275
|
+
function extractCopilotSchema(root) {
|
|
276
|
+
const hooks = root.hooks || {}
|
|
277
|
+
const events = Object.keys(hooks).sort()
|
|
278
|
+
const tuples = []
|
|
279
|
+
for (const event of events) {
|
|
280
|
+
const entries = (hooks[event] || []).slice().sort((a, b) => {
|
|
281
|
+
const ma = a.matcher || '', mb = b.matcher || ''
|
|
282
|
+
if (ma !== mb) return ma < mb ? -1 : 1
|
|
283
|
+
return (a.bash || '') < (b.bash || '') ? -1 : (a.bash || '') > (b.bash || '') ? 1 : 0
|
|
284
|
+
})
|
|
285
|
+
for (const entry of entries) {
|
|
286
|
+
if (entry.type !== 'command') continue
|
|
287
|
+
tuples.push({
|
|
288
|
+
event,
|
|
289
|
+
matcher: entry.matcher || '',
|
|
290
|
+
raw_command: entry.bash || '',
|
|
291
|
+
script_path: normalizeCommand(entry.bash || ''),
|
|
292
|
+
script_digest: '',
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return tuples
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// extractCursorSchema: {"hooks":{"preToolUse":[{"command":"...","matcher":"..."}],...}}
|
|
300
|
+
function extractCursorSchema(root) {
|
|
301
|
+
const hooks = root.hooks || {}
|
|
302
|
+
const events = Object.keys(hooks).sort()
|
|
303
|
+
const tuples = []
|
|
304
|
+
for (const event of events) {
|
|
305
|
+
const entries = (hooks[event] || []).slice().sort((a, b) => (a.command || '') < (b.command || '') ? -1 : 1)
|
|
306
|
+
for (const entry of entries) {
|
|
307
|
+
if (!entry.command) continue
|
|
308
|
+
tuples.push({
|
|
309
|
+
event,
|
|
310
|
+
matcher: entry.matcher || '',
|
|
311
|
+
raw_command: entry.command,
|
|
312
|
+
script_path: normalizeCommand(entry.command),
|
|
313
|
+
script_digest: '',
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return tuples
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// extractWindsurfSchema: {"hooks":{"pre_run_command":[{"command":"...","show_output":true}]}}
|
|
321
|
+
function extractWindsurfSchema(root) {
|
|
322
|
+
const hooks = root.hooks || {}
|
|
323
|
+
const events = Object.keys(hooks).sort()
|
|
324
|
+
const tuples = []
|
|
325
|
+
for (const event of events) {
|
|
326
|
+
const entries = (hooks[event] || []).slice().sort((a, b) => (a.command || '') < (b.command || '') ? -1 : 1)
|
|
327
|
+
for (const entry of entries) {
|
|
328
|
+
if (!entry.command) continue
|
|
329
|
+
tuples.push({
|
|
330
|
+
event,
|
|
331
|
+
matcher: '*',
|
|
332
|
+
raw_command: entry.command,
|
|
333
|
+
script_path: normalizeCommand(entry.command),
|
|
334
|
+
script_digest: '',
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return tuples
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// auditLifecycleHooks checks npm lifecycle hooks, .husky/pre-commit, and .vscode/tasks.json.
|
|
342
|
+
function auditLifecycleHooks(ref, gitRoot) {
|
|
343
|
+
const hooks = []
|
|
344
|
+
|
|
345
|
+
// npm lifecycle hooks: discover package.json by trying candidate paths in order.
|
|
346
|
+
const npmCandidates = ['package.json', 'npm/package.json']
|
|
347
|
+
let npmFound = null
|
|
348
|
+
let npmContent = null
|
|
349
|
+
for (const c of npmCandidates) {
|
|
350
|
+
const content = gitShow(ref, c, gitRoot)
|
|
351
|
+
if (content) { npmFound = c; npmContent = content; break }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (npmFound) {
|
|
355
|
+
let pkg = {}
|
|
356
|
+
try { pkg = JSON.parse(npmContent.toString()) } catch (_) {}
|
|
357
|
+
const scripts = pkg.scripts || {}
|
|
358
|
+
for (const key of ['preinstall', 'postinstall', 'prepare']) {
|
|
359
|
+
if (key in scripts) {
|
|
360
|
+
hooks.push({ file: npmFound, key, command: scripts[key], present: true })
|
|
361
|
+
} else {
|
|
362
|
+
hooks.push({ file: npmFound, key, present: false })
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
for (const key of ['preinstall', 'postinstall', 'prepare']) {
|
|
367
|
+
hooks.push({ file: 'package.json', key, present: false })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const huskyContent = gitShow(ref, '.husky/pre-commit', gitRoot)
|
|
372
|
+
if (huskyContent) {
|
|
373
|
+
const cmd = extractHuskyCommand(huskyContent.toString())
|
|
374
|
+
hooks.push({ file: '.husky/pre-commit', key: 'pre-commit', command: cmd, present: true })
|
|
375
|
+
} else {
|
|
376
|
+
hooks.push({ file: '.husky/pre-commit', key: 'pre-commit', present: false })
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// .vscode/tasks.json — presence/absence only (AC13 pattern: absence is information).
|
|
380
|
+
const vsContent = gitShow(ref, '.vscode/tasks.json', gitRoot)
|
|
381
|
+
hooks.push({ file: '.vscode/tasks.json', key: 'tasks', present: vsContent !== null })
|
|
382
|
+
return hooks
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function extractHuskyCommand(content) {
|
|
386
|
+
for (const line of content.split('\n')) {
|
|
387
|
+
const t = line.trim()
|
|
388
|
+
if (!t || t.startsWith('#')) continue
|
|
389
|
+
return t
|
|
390
|
+
}
|
|
391
|
+
return ''
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// runAuditSurface performs the full audit and returns a report object.
|
|
395
|
+
function runAuditSurface(ref, base, gitRoot) {
|
|
396
|
+
const report = { ref, hook_wiring: [], instruction_files: [], lifecycle_hooks: [] }
|
|
397
|
+
if (base) report.base = base
|
|
398
|
+
|
|
399
|
+
// 1. Hook wiring — 8 runtimes in canonical order.
|
|
400
|
+
for (const runtime of CANONICAL_RUNTIMES) {
|
|
401
|
+
const wiringFile = RUNTIME_WIRING_PATH[runtime]
|
|
402
|
+
const content = gitShow(ref, wiringFile, gitRoot)
|
|
403
|
+
if (!content) {
|
|
404
|
+
report.hook_wiring.push({ runtime, wiring_file: wiringFile, present: false, tuples: [] })
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
const tuples = extractTuples(runtime, content)
|
|
408
|
+
// Compute digests for each tuple's script, following symlink chains.
|
|
409
|
+
for (const t of tuples) {
|
|
410
|
+
t.script_digest = t.script_path
|
|
411
|
+
? resolveScriptDigest(ref, t.script_path, gitRoot)
|
|
412
|
+
: 'unresolvable'
|
|
413
|
+
}
|
|
414
|
+
report.hook_wiring.push({ runtime, wiring_file: wiringFile, present: true, tuples })
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 2. Instruction files (agent-config kind).
|
|
418
|
+
for (const p of INSTRUCTION_FILE_PATHS) {
|
|
419
|
+
const content = gitShow(ref, p, gitRoot)
|
|
420
|
+
report.instruction_files.push({ path: p, kind: 'agent-config', present: content !== null })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 3. Slash commands (.claude/commands/**/*.md).
|
|
424
|
+
const slashFiles = gitLsTree(ref, '.claude/commands', gitRoot).filter(f => f.endsWith('.md'))
|
|
425
|
+
for (const f of slashFiles) {
|
|
426
|
+
report.instruction_files.push({ path: f, kind: 'slash-command', present: true })
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 4. Lifecycle hooks.
|
|
430
|
+
report.lifecycle_hooks = auditLifecycleHooks(ref, gitRoot)
|
|
431
|
+
|
|
432
|
+
return report
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// tupleCount returns the total number of hook tuples across all runtimes.
|
|
436
|
+
function tupleCount(report) {
|
|
437
|
+
return report.hook_wiring.reduce((n, rr) => n + rr.tuples.length, 0)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// formatText renders the human-readable report.
|
|
441
|
+
// Format is byte-identical to Go and Python implementations.
|
|
442
|
+
function formatText(report) {
|
|
443
|
+
const n = tupleCount(report)
|
|
444
|
+
const lines = [`trackfw audit-surface: ${n} hook tuple(s) at ${report.ref}`, '']
|
|
445
|
+
|
|
446
|
+
for (const rr of report.hook_wiring) {
|
|
447
|
+
if (!rr.present) {
|
|
448
|
+
lines.push(`absent [${rr.runtime}] ${rr.wiring_file}`)
|
|
449
|
+
continue
|
|
450
|
+
}
|
|
451
|
+
if (rr.tuples.length === 0) {
|
|
452
|
+
lines.push(`no_hooks [${rr.runtime}] ${rr.wiring_file}`)
|
|
453
|
+
continue
|
|
454
|
+
}
|
|
455
|
+
for (const t of rr.tuples) {
|
|
456
|
+
const matcher = t.matcher || '*'
|
|
457
|
+
lines.push(`hook [${rr.runtime}] ${rr.wiring_file} ${t.event}/${matcher} ${t.raw_command} ${t.script_digest}`)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
for (const f of report.instruction_files) {
|
|
462
|
+
if (f.kind === 'slash-command') {
|
|
463
|
+
if (f.present) lines.push(`slash-command ${f.path}`)
|
|
464
|
+
} else {
|
|
465
|
+
lines.push(`instruction [${f.present ? 'present' : 'absent'}] ${f.path}`)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const lh of report.lifecycle_hooks) {
|
|
470
|
+
if (lh.present) {
|
|
471
|
+
let line = `lifecycle [present] ${lh.file} ${lh.key}`
|
|
472
|
+
if (lh.command) line += ` ${lh.command}`
|
|
473
|
+
lines.push(line)
|
|
474
|
+
} else {
|
|
475
|
+
lines.push(`lifecycle [absent] ${lh.file} ${lh.key}`)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Remove the trailing blank line we added if body has entries after the blank.
|
|
480
|
+
// If there are no body entries, remove the blank line too (match Go behavior).
|
|
481
|
+
const bodyStart = lines.indexOf('') + 1
|
|
482
|
+
if (bodyStart >= lines.length) {
|
|
483
|
+
// Nothing after the blank — remove it.
|
|
484
|
+
lines.splice(1, 1)
|
|
485
|
+
return lines.join('\n') + '\n'
|
|
486
|
+
}
|
|
487
|
+
return lines.join('\n') + '\n'
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const cmd = new Command('audit-surface')
|
|
491
|
+
cmd.description('Report the executable surface of a git ref without checking it out')
|
|
492
|
+
cmd.argument('<ref>', 'git ref to audit (e.g. FETCH_HEAD, a commit hash, a branch name)')
|
|
493
|
+
cmd.option('--json', 'Emit report as JSON instead of text')
|
|
494
|
+
cmd.option('--base <base>', 'Base ref for Makefile/CI diff (optional; e.g. HEAD, main)')
|
|
495
|
+
|
|
496
|
+
cmd.action((ref, options) => {
|
|
497
|
+
const gitRoot = findGitRoot(process.cwd())
|
|
498
|
+
if (!gitRoot) {
|
|
499
|
+
process.stderr.write('audit-surface: not inside a git repository\n')
|
|
500
|
+
process.exit(1)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Validate ref resolves to a commit object in this repository (F3 fix: ^{commit} rejects
|
|
504
|
+
// a 40-hex SHA from another repo that git would otherwise accept without ^{commit}).
|
|
505
|
+
const validateResult = spawnSync('git', ['rev-parse', '--verify', ref + '^{commit}'], { cwd: gitRoot })
|
|
506
|
+
if (validateResult.status !== 0) {
|
|
507
|
+
process.stderr.write(`audit-surface: ref "${ref}" does not resolve\n`)
|
|
508
|
+
process.exit(1)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const report = runAuditSurface(ref, options.base || '', gitRoot)
|
|
512
|
+
|
|
513
|
+
if (options.json) {
|
|
514
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
process.stdout.write(formatText(report))
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
module.exports = cmd
|