trackfw 7.6.0 → 8.0.0-rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +28 -11
  2. package/bin/trackfw.js +81 -0
  3. package/package.json +14 -8
  4. package/bin/trackfw +0 -41
  5. package/src/branch/prune.js +0 -450
  6. package/src/branch/runner.js +0 -153
  7. package/src/changelog/index.js +0 -114
  8. package/src/commands/adr.js +0 -56
  9. package/src/commands/agents.js +0 -3
  10. package/src/commands/audit-surface.js +0 -520
  11. package/src/commands/barrier.js +0 -822
  12. package/src/commands/baseline.js +0 -13
  13. package/src/commands/branch.js +0 -98
  14. package/src/commands/changelog.js +0 -29
  15. package/src/commands/commit.js +0 -66
  16. package/src/commands/configure.js +0 -106
  17. package/src/commands/context.js +0 -215
  18. package/src/commands/discover.js +0 -628
  19. package/src/commands/doctor.js +0 -66
  20. package/src/commands/help.js +0 -358
  21. package/src/commands/identity-wizard.js +0 -258
  22. package/src/commands/index.js +0 -68
  23. package/src/commands/init.js +0 -296
  24. package/src/commands/integrations.js +0 -305
  25. package/src/commands/log.js +0 -31
  26. package/src/commands/metrics.js +0 -242
  27. package/src/commands/note.js +0 -24
  28. package/src/commands/push.js +0 -51
  29. package/src/commands/release.js +0 -47
  30. package/src/commands/req.js +0 -98
  31. package/src/commands/roadmap.js +0 -44
  32. package/src/commands/serve.js +0 -345
  33. package/src/commands/ship.js +0 -58
  34. package/src/commands/skills.js +0 -3
  35. package/src/commands/status.js +0 -12
  36. package/src/commands/sync.js +0 -348
  37. package/src/commands/thirdparty.js +0 -390
  38. package/src/commands/update-harness.js +0 -1002
  39. package/src/commands/update.js +0 -500
  40. package/src/commands/validate.js +0 -57
  41. package/src/commands/version.js +0 -10
  42. package/src/commit/runner.js +0 -352
  43. package/src/config/index.js +0 -457
  44. package/src/forge/adapter.js +0 -189
  45. package/src/forge/resolve.js +0 -191
  46. package/src/generators/adr.js +0 -198
  47. package/src/generators/codex.js +0 -79
  48. package/src/generators/date.js +0 -23
  49. package/src/generators/hooks.js +0 -2414
  50. package/src/generators/init.js +0 -1563
  51. package/src/generators/note.js +0 -154
  52. package/src/generators/req.js +0 -436
  53. package/src/generators/roadmap.js +0 -841
  54. package/src/homedir.js +0 -30
  55. package/src/i18n/index.js +0 -53
  56. package/src/i18n/locales/en-US.json +0 -124
  57. package/src/i18n/locales/es-ES.json +0 -124
  58. package/src/i18n/locales/pt-BR.json +0 -124
  59. package/src/identity/config.js +0 -134
  60. package/src/identity/index.js +0 -19
  61. package/src/identity/preset.js +0 -219
  62. package/src/identity/slug.js +0 -62
  63. package/src/integrations/assets/agents/architect.md +0 -61
  64. package/src/integrations/assets/agents/backend.md +0 -41
  65. package/src/integrations/assets/agents/code-quality.md +0 -48
  66. package/src/integrations/assets/agents/data.md +0 -41
  67. package/src/integrations/assets/agents/dba.md +0 -41
  68. package/src/integrations/assets/agents/frontend.md +0 -41
  69. package/src/integrations/assets/agents/iac.md +0 -46
  70. package/src/integrations/assets/agents/infra.md +0 -43
  71. package/src/integrations/assets/agents/qa.md +0 -41
  72. package/src/integrations/assets/agents/security.md +0 -51
  73. package/src/integrations/assets/agents/tooling.md +0 -47
  74. package/src/integrations/assets/agents/ux.md +0 -48
  75. package/src/integrations/assets/catalog.json +0 -48
  76. package/src/integrations/assets/skills/architecture.md +0 -66
  77. package/src/integrations/assets/skills/backend.md +0 -67
  78. package/src/integrations/assets/skills/code-quality.md +0 -78
  79. package/src/integrations/assets/skills/data.md +0 -75
  80. package/src/integrations/assets/skills/dba.md +0 -77
  81. package/src/integrations/assets/skills/frontend.md +0 -70
  82. package/src/integrations/assets/skills/governance.md +0 -6
  83. package/src/integrations/assets/skills/iac.md +0 -90
  84. package/src/integrations/assets/skills/implement.md +0 -6
  85. package/src/integrations/assets/skills/infra.md +0 -62
  86. package/src/integrations/assets/skills/plan.md +0 -6
  87. package/src/integrations/assets/skills/qa.md +0 -63
  88. package/src/integrations/assets/skills/release.md +0 -6
  89. package/src/integrations/assets/skills/review.md +0 -6
  90. package/src/integrations/assets/skills/security.md +0 -74
  91. package/src/integrations/assets/skills/tooling.md +0 -80
  92. package/src/integrations/assets/skills/ux.md +0 -70
  93. package/src/integrations/catalog.js +0 -58
  94. package/src/integrations/doctor.js +0 -0
  95. package/src/integrations/doctor_remote.js +0 -224
  96. package/src/integrations/index.js +0 -171
  97. package/src/integrations/legacy.js +0 -35
  98. package/src/integrations/manager.js +0 -438
  99. package/src/integrations/register-agent.js +0 -106
  100. package/src/integrations/render.js +0 -563
  101. package/src/integrations/scaffold_doctor.js +0 -395
  102. package/src/lib/fatal-error.js +0 -64
  103. package/src/lib/pathfmt.js +0 -43
  104. package/src/lib/unknown-command.js +0 -82
  105. package/src/lib/update-engine.js +0 -242
  106. package/src/push/runner.js +0 -226
  107. package/src/release/runner.js +0 -474
  108. package/src/serve/api_attention.js +0 -24
  109. package/src/serve/api_board.js +0 -105
  110. package/src/serve/api_chain.js +0 -246
  111. package/src/serve/api_file.js +0 -117
  112. package/src/serve/api_metrics.js +0 -139
  113. package/src/serve/static/app.js +0 -1151
  114. package/src/serve/static/index.html +0 -338
  115. package/src/serve/static/style.css +0 -297
  116. package/src/ship/runner.js +0 -775
  117. package/src/thirdparty/fetch.js +0 -140
  118. package/src/thirdparty/markers.js +0 -207
  119. package/src/thirdparty/provenance.js +0 -114
  120. package/src/thirdparty/quarantine.js +0 -113
  121. package/src/thirdparty/references.js +0 -194
  122. package/src/validator/git-exec.js +0 -72
  123. package/src/validator/index.js +0 -4086
  124. package/src/validator/traceid.js +0 -155
@@ -1,114 +0,0 @@
1
- 'use strict'
2
-
3
- // changelog — parsing e extração de seções do CHANGELOG.md no formato
4
- // Keep a Changelog (https://keepachangelog.com/en/1.1.0/).
5
- // Port 1:1 de internal/changelog/changelog.go — manter em sincronia.
6
-
7
- const fs = require('fs')
8
- const path = require('path')
9
-
10
- // sectionHeaderRE casa cabeçalhos de seção no formato "## [x.y.z] - YYYY-MM-DD"
11
- // ou "## [Unreleased]" (sem data).
12
- const sectionHeaderRE = /^## \[([^\]]+)\](?: - (\d{4}-\d{2}-\d{2}))?/
13
-
14
- /**
15
- * parseSections — separa o conteúdo de um CHANGELOG.md em seções, uma por
16
- * cabeçalho "## [...]" encontrado. Texto antes da primeira seção (título do
17
- * arquivo, preâmbulo) é descartado.
18
- * @param {string} content
19
- * @returns {Array<{version: string, date: string, body: string}>}
20
- */
21
- function parseSections(content) {
22
- const lines = content.split('\n')
23
-
24
- const sections = []
25
- let current = null
26
- let bodyLines = []
27
-
28
- const flush = () => {
29
- if (current !== null) {
30
- current.body = bodyLines.join('\n')
31
- sections.push(current)
32
- }
33
- }
34
-
35
- for (const line of lines) {
36
- const m = sectionHeaderRE.exec(line)
37
- if (m !== null) {
38
- flush()
39
- current = { version: m[1], date: m[2] || '', body: '' }
40
- bodyLines = []
41
- continue
42
- }
43
- if (current !== null) {
44
- bodyLines.push(line)
45
- }
46
- }
47
- flush()
48
-
49
- return sections
50
- }
51
-
52
- /**
53
- * firstSection — retorna a primeira seção da lista.
54
- * Erro se a lista vier vazia.
55
- * @param {Array<{version: string, date: string, body: string}>} sections
56
- * @returns {{version: string, date: string, body: string}}
57
- */
58
- function firstSection(sections) {
59
- if (sections.length === 0) {
60
- throw new Error('CHANGELOG.md has no version sections')
61
- }
62
- return sections[0]
63
- }
64
-
65
- /**
66
- * findVersion — busca a seção com version igual ao argumento, normalizando um
67
- * prefixo "v"/"V" opcional no argumento do usuário antes de comparar.
68
- * @param {Array<{version: string, date: string, body: string}>} sections
69
- * @param {string} version
70
- * @returns {{version: string, date: string, body: string}}
71
- */
72
- function findVersion(sections, version) {
73
- let normalized = version
74
- if (normalized.length > 0 && (normalized[0] === 'v' || normalized[0] === 'V')) {
75
- normalized = normalized.slice(1)
76
- }
77
- for (const s of sections) {
78
- if (s.version === normalized || s.version === version) {
79
- return s
80
- }
81
- }
82
- throw new Error(`version "${version}" not found in CHANGELOG.md`)
83
- }
84
-
85
- /**
86
- * formatSection — reconstrói o texto formatado de uma seção, reproduzindo o
87
- * cabeçalho original.
88
- * @param {{version: string, date: string, body: string}} section
89
- * @returns {string}
90
- */
91
- function formatSection(section) {
92
- const dateSuffix = section.date !== '' ? ` - ${section.date}` : ''
93
- const body = section.body.replace(/^\n+/, '')
94
- return `## [${section.version}]${dateSuffix}\n\n${body.replace(/\n+$/, '') + '\n'}`
95
- }
96
-
97
- /**
98
- * read — lê o CHANGELOG.md na raiz informada.
99
- * @param {string} root
100
- * @returns {string}
101
- */
102
- function read(root) {
103
- const filePath = path.join(root, 'CHANGELOG.md')
104
- try {
105
- return fs.readFileSync(filePath, 'utf8')
106
- } catch (err) {
107
- if (err.code === 'ENOENT') {
108
- throw new Error('CHANGELOG.md not found — nothing to show')
109
- }
110
- throw err
111
- }
112
- }
113
-
114
- module.exports = { parseSections, firstSection, findVersion, formatSection, read }
@@ -1,56 +0,0 @@
1
- 'use strict'
2
-
3
- const path = require('path')
4
- const os = require('os')
5
- const { Command } = require('commander')
6
- const { input } = require('@inquirer/prompts')
7
- const generators = require('../generators/adr')
8
- const { t } = require('../i18n')
9
- const { homedir } = require('../homedir')
10
-
11
- const cmd = new Command('adr')
12
- cmd.description(t('adr.description'))
13
-
14
- /**
15
- * Resolve o diretório de ADRs conforme --scope.
16
- * 'project' (default) preserva o comportamento atual (adrDirs[0] do trackfw.yaml).
17
- * 'global' escreve/lista em ~/.trackfw/adr, sem exigir trackfw.yaml no cwd.
18
- * @param {string} scope
19
- * @returns {string}
20
- */
21
- function resolveAdrDir(scope) {
22
- if (scope === 'global') {
23
- return path.join(homedir(), '.trackfw', 'adr')
24
- }
25
- if (scope === 'project') {
26
- return require('../config').load().adrDirs[0]
27
- }
28
- console.error(`Invalid --scope value: "${scope}". Expected "project" or "global".`)
29
- process.exit(1)
30
- }
31
-
32
- cmd.command('new <title>')
33
- .description(t('adr.new.description'))
34
- .option('--scope <scope>', 'ADR scope: project (default) or global (~/.trackfw/adr)', 'project')
35
- .action(async (title, options) => {
36
- const adrDir = resolveAdrDir(options.scope)
37
- const content = { title }
38
- // wizard interativo se TTY
39
- if (process.stdin.isTTY) {
40
- content.context = await input({ message: t('adr.new.prompt.context'), default: '' })
41
- content.decision = await input({ message: t('adr.new.prompt.decision'), default: '' })
42
- content.consequences = await input({ message: t('adr.new.prompt.consequences'), default: '' })
43
- content.alternatives = await input({ message: t('adr.new.prompt.alternatives'), default: '' })
44
- }
45
- await generators.newADR(content, adrDir)
46
- })
47
-
48
- cmd.command('list')
49
- .description(t('adr.list.description'))
50
- .option('--scope <scope>', 'ADR scope: project (default) or global (~/.trackfw/adr)', 'project')
51
- .action(async (options) => {
52
- const adrDir = resolveAdrDir(options.scope)
53
- await generators.listADRs(adrDir)
54
- })
55
-
56
- module.exports = cmd
@@ -1,3 +0,0 @@
1
- 'use strict'
2
-
3
- module.exports = require('./integrations').createLifecycleCommand('agents')
@@ -1,520 +0,0 @@
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