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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "7.2.0",
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