trackfw 7.2.0 → 7.4.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/README.md +67 -1
- package/package.json +1 -1
- package/src/commands/adr.js +2 -1
- package/src/commands/audit-surface.js +520 -0
- package/src/commands/barrier.js +400 -34
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +37 -4
- package/src/commands/index.js +1 -0
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +22 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +8 -2
- package/src/commands/update-harness.js +6 -3
- package/src/commands/update.js +147 -10
- package/src/config/index.js +82 -2
- package/src/generators/hooks.js +139 -6
- package/src/generators/init.js +173 -21
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +132 -28
- package/src/homedir.js +30 -0
- package/src/integrations/assets/agents/architect.md +5 -3
- package/src/integrations/assets/agents/security.md +3 -0
- package/src/integrations/doctor.js +0 -0
- package/src/integrations/doctor_remote.js +224 -0
- package/src/integrations/index.js +2 -1
- package/src/integrations/manager.js +10 -2
- package/src/integrations/render.js +39 -8
- package/src/integrations/scaffold_doctor.js +395 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +28 -3
- package/src/serve/api_board.js +11 -10
- package/src/serve/api_chain.js +13 -17
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +768 -167
|
@@ -34,8 +34,39 @@ function normalize(content) {
|
|
|
34
34
|
return `${content.trim()}\n`
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// normalizeCRLF converte "\r\n" em "\n" na fronteira em que o conteúdo cru de
|
|
38
|
+
// um agent-md entra num parser ou reescritor de frontmatter (ADR
|
|
39
|
+
// ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada,
|
|
40
|
+
// D1/D3). Um source CRLF é entrada válida, não erro: `String.prototype.trim`
|
|
41
|
+
// só remove whitespace das pontas, então um "---\r\n" interno sobrevive ao
|
|
42
|
+
// `.trim()` e falsifica todo `startsWith('---\n')` deste arquivo — é
|
|
43
|
+
// exatamente o defeito medido (frontmatter inteiro cai no corpo).
|
|
44
|
+
//
|
|
45
|
+
// Única implementação desta normalização neste runtime (D3): cada função de
|
|
46
|
+
// fronteira de frontmatter deste arquivo (markdownParts,
|
|
47
|
+
// rewriteFrontmatterModelLine, removeFrontmatterModelLine, insertBodyPrefix,
|
|
48
|
+
// rewriteSignatureLine, rewriteFrontmatterFields, frontmatterName) chama esta
|
|
49
|
+
// função na própria entrada, porque este módulo não tem uma única leitura de
|
|
50
|
+
// I/O compartilhada por todas elas — render(), manager.js e o consumidor de
|
|
51
|
+
// AgentTier entram por pontos distintos. Espelha
|
|
52
|
+
// internal/integrations/render.go:normalizeCRLF.
|
|
53
|
+
//
|
|
54
|
+
// D2: só a leitura é afetada. Toda reescrita deste arquivo reconstrói a
|
|
55
|
+
// saída com `.join('\n')`, então normalizar a entrada para LF aqui não
|
|
56
|
+
// autoriza emitir CRLF — a saída permanece LF independente do terminador de
|
|
57
|
+
// linha da entrada.
|
|
58
|
+
//
|
|
59
|
+
// D4/escopo: só "\r\n" vira "\n". Um "\r" solto (Mac clássico, sem "\n") é
|
|
60
|
+
// deixado intocado de propósito, conforme o não-objetivo declarado na ADR.
|
|
61
|
+
//
|
|
62
|
+
// Reaproveitada por npm/src/generators/roadmap.js e npm/src/generators/req.js (ML-5B,
|
|
63
|
+
// rewriteRoadmapStatus/rewriteREQStatus) — mesma implementação Node, não uma segunda cópia.
|
|
64
|
+
function normalizeCRLF(content) {
|
|
65
|
+
return String(content).replace(/\r\n/g, '\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
37
68
|
function markdownParts(content) {
|
|
38
|
-
const text = content.trim()
|
|
69
|
+
const text = normalizeCRLF(content).trim()
|
|
39
70
|
let name = 'trackfw-agent'
|
|
40
71
|
let description = 'trackfw specialist'
|
|
41
72
|
let model = ''
|
|
@@ -126,7 +157,7 @@ function rewriteFrontmatterModelLine(source, value) {
|
|
|
126
157
|
if (containsControlChar(value)) {
|
|
127
158
|
throw new Error(`model value contains control character and was rejected: model IDs never require newlines or other control characters (got ${JSON.stringify(value)})`)
|
|
128
159
|
}
|
|
129
|
-
const trimmed =
|
|
160
|
+
const trimmed = normalizeCRLF(source).trim()
|
|
130
161
|
if (!trimmed.startsWith('---\n')) return trimmed
|
|
131
162
|
const end = trimmed.indexOf('\n---', 4)
|
|
132
163
|
if (end < 0) return trimmed
|
|
@@ -156,7 +187,7 @@ function rewriteFrontmatterModelLine(source, value) {
|
|
|
156
187
|
// reconhecível, source é retornado sem alteração (trimado). Espelha
|
|
157
188
|
// internal/integrations/render.go:removeFrontmatterModelLine.
|
|
158
189
|
function removeFrontmatterModelLine(source) {
|
|
159
|
-
const trimmed =
|
|
190
|
+
const trimmed = normalizeCRLF(source).trim()
|
|
160
191
|
if (!trimmed.startsWith('---\n')) return trimmed
|
|
161
192
|
const end = trimmed.indexOf('\n---', 4)
|
|
162
193
|
if (end < 0) return trimmed
|
|
@@ -197,7 +228,7 @@ function greetingLine(displayName, nickname) {
|
|
|
197
228
|
// markdownParts, para que Rota A e Rota B concordem sobre onde o corpo
|
|
198
229
|
// começa. Espelha internal/integrations/render.go:insertBodyPrefix.
|
|
199
230
|
function insertBodyPrefix(source, prefix) {
|
|
200
|
-
const trimmed =
|
|
231
|
+
const trimmed = normalizeCRLF(source).trim()
|
|
201
232
|
if (!prefix) return trimmed
|
|
202
233
|
if (!trimmed.startsWith('---\n')) return `${prefix}\n\n${trimmed}`
|
|
203
234
|
const end = trimmed.indexOf('\n---', 4)
|
|
@@ -226,7 +257,7 @@ function insertBodyPrefix(source, prefix) {
|
|
|
226
257
|
// Espelha internal/integrations/render.go:rewriteSignatureLine.
|
|
227
258
|
function rewriteSignatureLine(source, displayName) {
|
|
228
259
|
if (!displayName) return source
|
|
229
|
-
const trimmed =
|
|
260
|
+
const trimmed = normalizeCRLF(source).trim()
|
|
230
261
|
|
|
231
262
|
// Localiza o início do corpo — espelha a detecção de fronteira de
|
|
232
263
|
// rewriteFrontmatterFields para que o escopo de ambas as funções coincida.
|
|
@@ -269,7 +300,7 @@ function rewriteSignatureLine(source, displayName) {
|
|
|
269
300
|
// frontmatter reconhecível, source é retornado sem alteração (trimado).
|
|
270
301
|
// Espelha internal/integrations/render.go:rewriteFrontmatterFields.
|
|
271
302
|
function rewriteFrontmatterFields(source, name, description) {
|
|
272
|
-
const trimmed =
|
|
303
|
+
const trimmed = normalizeCRLF(source).trim()
|
|
273
304
|
if (!trimmed.startsWith('---\n')) return trimmed
|
|
274
305
|
const end = trimmed.indexOf('\n---', 4)
|
|
275
306
|
if (end < 0) return trimmed
|
|
@@ -298,7 +329,7 @@ function rewriteFrontmatterFields(source, name, description) {
|
|
|
298
329
|
// declara "name". Usado pela detecção de colisão em manager.js. Espelha
|
|
299
330
|
// internal/integrations/render.go:frontmatterName.
|
|
300
331
|
function frontmatterName(content) {
|
|
301
|
-
const text =
|
|
332
|
+
const text = normalizeCRLF(content).trim()
|
|
302
333
|
if (!text.startsWith('---\n')) return undefined
|
|
303
334
|
const end = text.indexOf('\n---', 4)
|
|
304
335
|
if (end < 0) return undefined
|
|
@@ -529,4 +560,4 @@ function looksLikeSuspectModelValue(v) {
|
|
|
529
560
|
return containsControlChar(v) || (!isVersionString(v) && !v.startsWith('claude-'))
|
|
530
561
|
}
|
|
531
562
|
|
|
532
|
-
module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields, rewriteFrontmatterModelLine, rewriteSignatureLine, isVersionString, composeClaudeModelID, resolveAgentModel, looksLikeSuspectModelValue }
|
|
563
|
+
module.exports = { render, markdownParts, frontmatterName, greetingLine, insertBodyPrefix, rewriteFrontmatterFields, rewriteFrontmatterModelLine, rewriteSignatureLine, isVersionString, composeClaudeModelID, resolveAgentModel, looksLikeSuspectModelValue, normalizeCRLF }
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scaffold_doctor.js — scaffold artifact coverage for `trackfw doctor`
|
|
5
|
+
* (ADR-2026-08-27-doctor-cobre-artefatos-de-scaffold-por-comparacao-com-o-template-
|
|
6
|
+
* com-propriedade-dada-pelo-caminho.md).
|
|
7
|
+
*
|
|
8
|
+
* Mirrors internal/generators/scaffold_doctor.go (Go, canonical source of truth).
|
|
9
|
+
* Same design decisions apply — see that file's doc comment for the full rationale.
|
|
10
|
+
*
|
|
11
|
+
* Key decisions:
|
|
12
|
+
* - Property by path, not manifest (AC3): scaffold artifacts are identified by
|
|
13
|
+
* well-known namespace paths. No manifest entry is written or read.
|
|
14
|
+
* - Sibling classifier (AC15): scaffold artifacts are never in the manifest, so
|
|
15
|
+
* routing them through classifyDoctor would produce wrong remedies. The finding
|
|
16
|
+
* kinds SCAFFOLD_DIVERGENT / SCAFFOLD_MISSING are separate, with zero claim and
|
|
17
|
+
* a trackfw-update remedy.
|
|
18
|
+
* - Config-rendered templates (AC12): buildValidateScript(cfg) varies with
|
|
19
|
+
* cfg.backend/cfg.frontend — rendered from the project's own trackfw.yaml.
|
|
20
|
+
* - Eligibility for slash commands (AC14): only checked when
|
|
21
|
+
* .claude/commands/trackfw/ directory already exists.
|
|
22
|
+
* - Conditional artifacts (AC13): CI workflow only when cfg.ci declares it.
|
|
23
|
+
* - Neutral blame message (AC16): binary version stated; direction ambiguous.
|
|
24
|
+
* - Execute-bit checking (REQ-2026-08-28, AC2–AC5, AC10, AC11):
|
|
25
|
+
* The five scripts written with mode 0o755 are additionally checked for the
|
|
26
|
+
* owner-execute bit. The check uses (stat.mode & 0o100) !== 0 (not === 0o755)
|
|
27
|
+
* so umask-narrowed modes (0o750, 0o700) are accepted (AC10). Non-executable
|
|
28
|
+
* artifacts (slash commands, CI workflows) carry execBit=false and are never
|
|
29
|
+
* accused of a missing execute bit (AC4/AC11). Content divergence takes
|
|
30
|
+
* precedence over mode (at most one finding per artifact). Content correct +
|
|
31
|
+
* bit missing → SCAFFOLD_WRONG_MODE (AC3 distinct state). On Windows
|
|
32
|
+
* (process.platform === 'win32') the execute bit is not representable on NTFS,
|
|
33
|
+
* so the mode check is suppressed entirely — AC5.
|
|
34
|
+
*
|
|
35
|
+
* _platform is a module-level constant seeded from process.platform so that unit
|
|
36
|
+
* tests can override it via the exported _setPlatformForTest helper (AC5 testability).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require('fs')
|
|
40
|
+
const path = require('path')
|
|
41
|
+
const yaml = require('yaml')
|
|
42
|
+
|
|
43
|
+
const { SIGNAL_SCRIPT, CLEANUP_SCRIPT, CREDENTIAL_GUARD_SCRIPT, GIT_BRANCH_GUARD_SCRIPT } =
|
|
44
|
+
require('../generators/hooks')
|
|
45
|
+
const { CLAUDE_COMMANDS, buildValidateScript, buildGitHubActionsWorkflowContent, buildGitLabCIWorkflowContent } =
|
|
46
|
+
require('../generators/init')
|
|
47
|
+
const {
|
|
48
|
+
buildDiscoverGitHubActionsWorkflowContent,
|
|
49
|
+
DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH,
|
|
50
|
+
} = require('../commands/discover')
|
|
51
|
+
|
|
52
|
+
// Finding kind constants — mirrors Go's DoctorScaffoldDivergent / DoctorScaffoldMissing /
|
|
53
|
+
// DoctorScaffoldWrongMode.
|
|
54
|
+
const SCAFFOLD_DIVERGENT = 'scaffold-divergent'
|
|
55
|
+
const SCAFFOLD_MISSING = 'scaffold-missing'
|
|
56
|
+
// SCAFFOLD_WRONG_MODE: content correct but owner-execute bit missing (AC3).
|
|
57
|
+
// Only emitted for artifacts with execBit=true; never on Windows (AC5).
|
|
58
|
+
const SCAFFOLD_WRONG_MODE = 'scaffold-wrong-mode'
|
|
59
|
+
|
|
60
|
+
// PYTHON_VALIDATE_SCRIPT_FORM is the byte-exact content Python's `trackfw init` and
|
|
61
|
+
// `trackfw update` (validate-script target) write to scripts/trackfw-validate.sh.
|
|
62
|
+
// Accepted by the set-membership check in checkValidateScriptArtifact so that a
|
|
63
|
+
// project initialized by the Python runtime does not produce a false-positive
|
|
64
|
+
// scaffold-divergent finding in the Node.js doctor.
|
|
65
|
+
//
|
|
66
|
+
// Scope of the exception: ONLY scripts/trackfw-validate.sh uses set-membership.
|
|
67
|
+
// All other scaffold artifacts use single-template equality.
|
|
68
|
+
// See docs/cli-parity.md, "validate.sh — pertencimento a conjunto (set-membership, escopado)".
|
|
69
|
+
const PYTHON_VALIDATE_SCRIPT_FORM = '#!/usr/bin/env bash\nset -euo pipefail\ntrackfw validate\n'
|
|
70
|
+
|
|
71
|
+
// Paths used by the scaffold doctor (mirrors Go's exported constants).
|
|
72
|
+
const CLAUDE_COMMANDS_DIR_PATH = '.claude/commands/trackfw'
|
|
73
|
+
const GITHUB_ACTIONS_WORKFLOW_PATH = '.github/workflows/trackfw-gate.yml'
|
|
74
|
+
const GITLAB_CI_WORKFLOW_PATH = '.gitlab-ci-trackfw.yml'
|
|
75
|
+
|
|
76
|
+
// _platform is seeded from process.platform at module load time.
|
|
77
|
+
// Tests override it via _setPlatformForTest to exercise the Windows guard (AC5).
|
|
78
|
+
let _platform = process.platform
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* _setPlatformForTest overrides the platform string used by mode checks.
|
|
82
|
+
* ONLY for use in unit tests. Returns a restore function.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} plat - e.g. 'win32' or 'darwin'
|
|
85
|
+
* @returns {function} call to restore the original value
|
|
86
|
+
*/
|
|
87
|
+
function _setPlatformForTest(plat) {
|
|
88
|
+
const prev = _platform
|
|
89
|
+
_platform = plat
|
|
90
|
+
return () => { _platform = prev }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* execBitPresent returns true when the file at absPath has the owner-execute bit set
|
|
95
|
+
* (stat.mode & 0o100 !== 0). Returns false on any stat error.
|
|
96
|
+
*
|
|
97
|
+
* Uses bit mask rather than equality to 0o755 so that umask-narrowed modes like
|
|
98
|
+
* 0o750 or 0o700 are also accepted — AC10.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} absPath - absolute path to the file
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
function execBitPresent(absPath) {
|
|
104
|
+
try {
|
|
105
|
+
const stat = fs.statSync(absPath)
|
|
106
|
+
return (stat.mode & 0o100) !== 0
|
|
107
|
+
} catch (_) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* scaffoldRemedy returns a ready-to-copy remedy command for a scaffold finding.
|
|
114
|
+
* The message is neutral about blame direction (AC16).
|
|
115
|
+
*/
|
|
116
|
+
function scaffoldRemedy(action, relPath) {
|
|
117
|
+
// Lazy-require version to avoid a circular dependency at module load time.
|
|
118
|
+
let ver = 'unknown'
|
|
119
|
+
try {
|
|
120
|
+
const pkg = require('../../package.json')
|
|
121
|
+
ver = pkg.version || 'unknown'
|
|
122
|
+
} catch (_) {}
|
|
123
|
+
return `trackfw update # ${action} ${relPath}: content differs from the template trackfw v${ver} generates; if this project was initialized with a newer binary, update the binary instead`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* scaffoldWrongModeRemedy returns a remedy command for the scaffold-wrong-mode finding.
|
|
128
|
+
* Names the missing execute bit explicitly to distinguish it from content divergence (AC3).
|
|
129
|
+
* The message is runtime-neutral so the parity check can diff Go, Node, and Python outputs
|
|
130
|
+
* byte-for-byte on this finding kind.
|
|
131
|
+
*/
|
|
132
|
+
function scaffoldWrongModeRemedy(relPath) {
|
|
133
|
+
return `trackfw update # restore execute bit on ${relPath}: content is correct but the owner-execute bit is missing (mode 0755 required); trackfw update now restores the mode unconditionally on existing files`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* loadProjectConfig reads trackfw.yaml from projectRoot and returns a cfg object
|
|
138
|
+
* with the same keys that buildValidateScript / buildCIWorkflowContent expect.
|
|
139
|
+
* Missing keys default to undefined/null — identical to Go's loadUpdateConfig().
|
|
140
|
+
*/
|
|
141
|
+
function loadProjectConfig(projectRoot) {
|
|
142
|
+
try {
|
|
143
|
+
const raw = fs.readFileSync(path.join(projectRoot, 'trackfw.yaml'), 'utf8')
|
|
144
|
+
const parsed = yaml.parse(raw) || {}
|
|
145
|
+
return {
|
|
146
|
+
backend: parsed.backend || null,
|
|
147
|
+
frontend: parsed.frontend || null,
|
|
148
|
+
pkgManager: parsed.pkg_manager || null,
|
|
149
|
+
ci: parsed.ci || null,
|
|
150
|
+
}
|
|
151
|
+
} catch (_) {
|
|
152
|
+
return { backend: null, frontend: null, pkgManager: null, ci: null }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* checkValidateScriptArtifact checks scripts/trackfw-validate.sh using set-membership:
|
|
158
|
+
* accepted if the file content matches EITHER Go/Node's cfg-rendered form OR Python's
|
|
159
|
+
* fixed form (PYTHON_VALIDATE_SCRIPT_FORM). A file matching NO known form is accused.
|
|
160
|
+
* All other scaffold artifacts use checkScaffoldArtifact (single-template equality).
|
|
161
|
+
*
|
|
162
|
+
* After content membership passes, the execute bit is checked unless on Windows (AC5).
|
|
163
|
+
* Content divergence always takes precedence over mode (at most one finding per artifact).
|
|
164
|
+
*
|
|
165
|
+
* @param {string} absPath - absolute path to the file
|
|
166
|
+
* @param {string} relPath - relative path (used as destination in findings)
|
|
167
|
+
* @param {object} cfg - project config from trackfw.yaml
|
|
168
|
+
* @returns {object|null} finding or null
|
|
169
|
+
*/
|
|
170
|
+
function checkValidateScriptArtifact(absPath, relPath, cfg) {
|
|
171
|
+
let actual
|
|
172
|
+
try {
|
|
173
|
+
actual = fs.readFileSync(absPath, 'utf8')
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.code === 'ENOENT') {
|
|
176
|
+
return {
|
|
177
|
+
finding: SCAFFOLD_MISSING,
|
|
178
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
179
|
+
destination: relPath,
|
|
180
|
+
remedy: scaffoldRemedy('restore', relPath),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
185
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
186
|
+
destination: relPath,
|
|
187
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const goNodeForm = buildValidateScript(cfg)
|
|
191
|
+
if (actual !== goNodeForm && actual !== PYTHON_VALIDATE_SCRIPT_FORM) {
|
|
192
|
+
// Content diverges — scaffold-divergent takes precedence over any mode issue.
|
|
193
|
+
return {
|
|
194
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
195
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
196
|
+
destination: relPath,
|
|
197
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Content accepted. Check the execute bit (AC2/AC3).
|
|
201
|
+
// Suppressed on Windows where the bit is not representable (AC5).
|
|
202
|
+
if (_platform !== 'win32' && !execBitPresent(absPath)) {
|
|
203
|
+
return {
|
|
204
|
+
finding: SCAFFOLD_WRONG_MODE,
|
|
205
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
206
|
+
destination: relPath,
|
|
207
|
+
remedy: scaffoldWrongModeRemedy(relPath),
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* checkScaffoldArtifact compares the on-disk content at absPath against expected.
|
|
215
|
+
* Returns a finding if the file is divergent or (when reportMissing=true) absent.
|
|
216
|
+
* relPath is used as destination in the finding.
|
|
217
|
+
*
|
|
218
|
+
* @param {string} absPath - absolute path to the file
|
|
219
|
+
* @param {string} relPath - relative path (destination in findings)
|
|
220
|
+
* @param {string} expected - expected file content
|
|
221
|
+
* @param {boolean} reportMissing - if true, absence is a finding
|
|
222
|
+
* @param {boolean} execBit - if true, check the owner-execute bit after content passes
|
|
223
|
+
* true → artifact was written with 0o755; bit absence → SCAFFOLD_WRONG_MODE (AC2/AC3)
|
|
224
|
+
* false → artifact is 0o644 (slash commands, CI workflows); never mode-checked (AC4/AC11)
|
|
225
|
+
* @returns {object|null} finding or null
|
|
226
|
+
*/
|
|
227
|
+
function checkScaffoldArtifact(absPath, relPath, expected, reportMissing, execBit) {
|
|
228
|
+
let actual
|
|
229
|
+
try {
|
|
230
|
+
actual = fs.readFileSync(absPath, 'utf8')
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err.code === 'ENOENT') {
|
|
233
|
+
if (!reportMissing) return null
|
|
234
|
+
return {
|
|
235
|
+
finding: SCAFFOLD_MISSING,
|
|
236
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
237
|
+
destination: relPath,
|
|
238
|
+
remedy: scaffoldRemedy('restore', relPath),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
// Unreadable artifact: report as divergent so the user is informed.
|
|
242
|
+
return {
|
|
243
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
244
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
245
|
+
destination: relPath,
|
|
246
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (actual !== expected) {
|
|
250
|
+
// Content diverges — takes precedence over any mode issue (at most one finding
|
|
251
|
+
// per artifact; update fixes both content and mode anyway).
|
|
252
|
+
return {
|
|
253
|
+
finding: SCAFFOLD_DIVERGENT,
|
|
254
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
255
|
+
destination: relPath,
|
|
256
|
+
remedy: scaffoldRemedy('resync', relPath),
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Content matches. Check the execute bit when required (AC2/AC3).
|
|
260
|
+
// Suppressed on Windows where the bit is not representable (AC5).
|
|
261
|
+
if (execBit && _platform !== 'win32' && !execBitPresent(absPath)) {
|
|
262
|
+
return {
|
|
263
|
+
finding: SCAFFOLD_WRONG_MODE,
|
|
264
|
+
claim: { kind: '', item: '', target: '', surface: '', scope: '' },
|
|
265
|
+
destination: relPath,
|
|
266
|
+
remedy: scaffoldWrongModeRemedy(relPath),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* runScaffoldDoctor compares scaffold artifacts on disk against the templates the
|
|
274
|
+
* currently installed binary would generate (given the project's own trackfw.yaml),
|
|
275
|
+
* and returns findings for any artifact that is divergent or missing.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} projectRoot - absolute path to the project root
|
|
278
|
+
* @returns {Array} findings
|
|
279
|
+
*/
|
|
280
|
+
function runScaffoldDoctor(projectRoot) {
|
|
281
|
+
// Eligibility: trackfw.yaml must exist.
|
|
282
|
+
try {
|
|
283
|
+
fs.statSync(path.join(projectRoot, 'trackfw.yaml'))
|
|
284
|
+
} catch (_) {
|
|
285
|
+
return []
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const cfg = loadProjectConfig(projectRoot)
|
|
289
|
+
const findings = []
|
|
290
|
+
|
|
291
|
+
// --- Scripts (always in scope when trackfw.yaml is present) ---
|
|
292
|
+
//
|
|
293
|
+
// scripts/trackfw-validate.sh uses set-membership (Go/Node form OR Python form).
|
|
294
|
+
// The four remaining scripts use single-template equality via checkScaffoldArtifact.
|
|
295
|
+
// All five have execBit=true: the generator writes them with mode 0o755 (AC11).
|
|
296
|
+
const validateF = checkValidateScriptArtifact(
|
|
297
|
+
path.join(projectRoot, 'scripts/trackfw-validate.sh'),
|
|
298
|
+
'scripts/trackfw-validate.sh',
|
|
299
|
+
cfg,
|
|
300
|
+
)
|
|
301
|
+
if (validateF) findings.push(validateF)
|
|
302
|
+
|
|
303
|
+
const staticScripts = [
|
|
304
|
+
{ relPath: 'scripts/trackfw-attention-signal.sh', expected: SIGNAL_SCRIPT, execBit: true },
|
|
305
|
+
{ relPath: 'scripts/trackfw-attention-cleanup.sh', expected: CLEANUP_SCRIPT, execBit: true },
|
|
306
|
+
{ relPath: 'scripts/trackfw-credential-guard.sh', expected: CREDENTIAL_GUARD_SCRIPT, execBit: true },
|
|
307
|
+
{ relPath: 'scripts/trackfw-git-branch-guard.sh', expected: GIT_BRANCH_GUARD_SCRIPT, execBit: true },
|
|
308
|
+
]
|
|
309
|
+
for (const { relPath, expected, execBit } of staticScripts) {
|
|
310
|
+
const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, expected, true, execBit)
|
|
311
|
+
if (f) findings.push(f)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- Slash commands (AC14: only when the directory already exists) ---
|
|
315
|
+
//
|
|
316
|
+
// Slash commands are markdown files written with mode 0644 — execBit=false (AC11).
|
|
317
|
+
const claudeDir = path.join(projectRoot, CLAUDE_COMMANDS_DIR_PATH)
|
|
318
|
+
let claudeDirExists = false
|
|
319
|
+
try {
|
|
320
|
+
fs.statSync(claudeDir)
|
|
321
|
+
claudeDirExists = true
|
|
322
|
+
} catch (_) {}
|
|
323
|
+
if (claudeDirExists) {
|
|
324
|
+
for (const [filename, content] of Object.entries(CLAUDE_COMMANDS)) {
|
|
325
|
+
const relPath = `${CLAUDE_COMMANDS_DIR_PATH}/${filename}`
|
|
326
|
+
const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, content, true, false)
|
|
327
|
+
if (f) findings.push(f)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- CI workflow (AC13: conditional on ci: in trackfw.yaml) ---
|
|
332
|
+
//
|
|
333
|
+
// CI workflow YAML files are written with mode 0644 — execBit=false (AC11).
|
|
334
|
+
if (cfg.ci === 'github-actions') {
|
|
335
|
+
const relPath = GITHUB_ACTIONS_WORKFLOW_PATH
|
|
336
|
+
const f = checkScaffoldArtifact(
|
|
337
|
+
path.join(projectRoot, relPath),
|
|
338
|
+
relPath,
|
|
339
|
+
buildGitHubActionsWorkflowContent(cfg),
|
|
340
|
+
true,
|
|
341
|
+
false,
|
|
342
|
+
)
|
|
343
|
+
if (f) findings.push(f)
|
|
344
|
+
} else if (cfg.ci === 'gitlab-ci') {
|
|
345
|
+
const relPath = GITLAB_CI_WORKFLOW_PATH
|
|
346
|
+
const f = checkScaffoldArtifact(
|
|
347
|
+
path.join(projectRoot, relPath),
|
|
348
|
+
relPath,
|
|
349
|
+
buildGitLabCIWorkflowContent(cfg),
|
|
350
|
+
true,
|
|
351
|
+
false,
|
|
352
|
+
)
|
|
353
|
+
if (f) findings.push(f)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// --- Discover CI workflow (second, independent install mechanism) ---
|
|
357
|
+
//
|
|
358
|
+
// trackfw-validate.yml (written by `trackfw discover --init`, installGates) is a
|
|
359
|
+
// separate artifact from trackfw-gate.yml above — both can coexist in the same
|
|
360
|
+
// project (ADR-2026-08-28). Only checked when the file is already present, mirroring
|
|
361
|
+
// the "conditional artifact" treatment of trackfw-gate.yml above but using
|
|
362
|
+
// presence-on-disk instead of cfg.ci, because installGates decides on its own
|
|
363
|
+
// discovery signal (github-actions detection), not on trackfw.yaml's `ci:` key — a
|
|
364
|
+
// project can have discover's workflow without cfg.ci ever being set.
|
|
365
|
+
let discoverWorkflowExists = false
|
|
366
|
+
try {
|
|
367
|
+
fs.statSync(path.join(projectRoot, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH))
|
|
368
|
+
discoverWorkflowExists = true
|
|
369
|
+
} catch (_) {}
|
|
370
|
+
if (discoverWorkflowExists) {
|
|
371
|
+
const f = checkScaffoldArtifact(
|
|
372
|
+
path.join(projectRoot, DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH),
|
|
373
|
+
DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH,
|
|
374
|
+
buildDiscoverGitHubActionsWorkflowContent(),
|
|
375
|
+
true,
|
|
376
|
+
false,
|
|
377
|
+
)
|
|
378
|
+
if (f) findings.push(f)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Deterministic output (AC7): sort by destination.
|
|
382
|
+
findings.sort((a, b) => (a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0))
|
|
383
|
+
|
|
384
|
+
return findings
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = {
|
|
388
|
+
runScaffoldDoctor,
|
|
389
|
+
checkValidateScriptArtifact,
|
|
390
|
+
SCAFFOLD_DIVERGENT,
|
|
391
|
+
SCAFFOLD_MISSING,
|
|
392
|
+
SCAFFOLD_WRONG_MODE,
|
|
393
|
+
PYTHON_VALIDATE_SCRIPT_FORM,
|
|
394
|
+
_setPlatformForTest,
|
|
395
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* pathfmt — ponto único de normalização de separador do CLI Node
|
|
5
|
+
* (ADR-2026-09-04 "Separador POSIX nos artefatos autorados cujo consumidor não é o
|
|
6
|
+
* sistema de arquivos", D3: "Não espalhar `strings.ReplaceAll` pelos chamadores. Uma
|
|
7
|
+
* função por runtime, aplicada onde o valor SAI").
|
|
8
|
+
*
|
|
9
|
+
* 🔴 ESCOPO NEGATIVO (D2 da mesma ADR) — esta função é de EMISSÃO, nunca de travessia.
|
|
10
|
+
* Não aplicar a um caminho antes de `fs.readFileSync`/`fs.statSync`/`fs.writeFileSync`:
|
|
11
|
+
* normalizar cegamente quebra UNC (`\\server\share`) e o prefixo de caminho longo
|
|
12
|
+
* (`\\?\`), que exige backslash EXCLUSIVAMENTE — nem o Windows converte. O modo de falha
|
|
13
|
+
* seria intermitente, que é o pior. `path.join`/`path.relative` continuam sendo o certo
|
|
14
|
+
* para caminho que vai a uma syscall.
|
|
15
|
+
*
|
|
16
|
+
* Aplicar apenas às três categorias que a D1 nomeia, cujo consumidor NÃO é o filesystem:
|
|
17
|
+
* 1. texto de relatório / saída para humano (displayPath, mensagens)
|
|
18
|
+
* 2. chave de dicionário ou identificador (provenanceKey, node ID, edge.to)
|
|
19
|
+
* 3. string de comando interpretada por shell (command de hook, gate de wave)
|
|
20
|
+
*
|
|
21
|
+
* Substituição INCONDICIONAL de "\" por "/", não `path.posix.normalize` nem uma troca de
|
|
22
|
+
* `path.sep`: em POSIX `path.sep` é "/" e uma troca por separador seria no-op estrutural,
|
|
23
|
+
* o que não normalizaria um valor sujo herdado de um commit feito no Windows — exatamente
|
|
24
|
+
* o defeito que esta função existe para curar
|
|
25
|
+
* (docs/seguranca/2026-09-01-modelo-de-ameaca-do-separador-em-artefato.md). É a mesma
|
|
26
|
+
* semântica já pinada em Go (`internal/validator/validator.go:normalizeRefSeparator`) e
|
|
27
|
+
* Python (`pypi/trackfw/pathfmt.py:normalize_ref_separator`); D4 exige que os 3 sejam
|
|
28
|
+
* byte-idênticos, então esta escolha não é livre — é herdada.
|
|
29
|
+
*
|
|
30
|
+
* Custo aceito e nomeado: em POSIX um "\" literal dentro de um nome de arquivo é DADO, e
|
|
31
|
+
* esta função o converteria. Vale só para o texto emitido (display/chave), nunca para o
|
|
32
|
+
* caminho realmente aberto, que não passa por aqui.
|
|
33
|
+
*
|
|
34
|
+
* NÃO aplicar ao buffer inteiro de um arquivo — só ao valor já extraído/derivado.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} p valor já extraído ou derivado (nunca o buffer de um arquivo)
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeRefSeparator(p) {
|
|
40
|
+
return p.replace(/\\/g, '/')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { normalizeRefSeparator }
|
package/src/lib/update-engine.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const { normalizeRefSeparator } = require('./pathfmt')
|
|
4
|
+
|
|
3
5
|
// update-engine.js — shared state machine for `trackfw update` and
|
|
4
6
|
// `trackfw update harness` (ML-6C of ROADMAP-2026-07-29-barrier-governanca-
|
|
5
7
|
// e-autoridade-do-orquestrador). Both commands report one of exactly four
|
|
@@ -94,7 +96,18 @@ function silenceConsole(fn) {
|
|
|
94
96
|
* - `missing` (nothing present under any of `relPaths`) never triggers
|
|
95
97
|
* `apply` unless `installMissing` is true.
|
|
96
98
|
*/
|
|
97
|
-
|
|
99
|
+
// runFileTarget — computes updated/skipped/missing/failed for a target whose
|
|
100
|
+
// only observable effect is writing under a fixed set of paths (files or
|
|
101
|
+
// directories) relative to `root`, by diffing content hashes before/after
|
|
102
|
+
// invoking `apply(root)`.
|
|
103
|
+
//
|
|
104
|
+
// `seeds` (optional) — extra paths copied into the dry-run sandbox but NOT
|
|
105
|
+
// included in the hash comparison. Used for:
|
|
106
|
+
// - trackfw.yaml: agent-rules reads it for agent_conventions (Gap E)
|
|
107
|
+
// - detection signals for agent-hooks: .windsurfrules, .github/copilot-
|
|
108
|
+
// instructions.md, etc. — InjectHooksDetected checks these to decide
|
|
109
|
+
// which hooks to write; absent → hooks silently omitted (Gap C)
|
|
110
|
+
function runFileTarget({ id, path: displayPath, root, relPaths, seeds = [], apply, dryRun, installMissing }) {
|
|
98
111
|
const before = relPaths.map(rel => hashPath(path.join(root, rel)))
|
|
99
112
|
const allMissingBefore = before.every(h => h === null)
|
|
100
113
|
|
|
@@ -108,6 +121,7 @@ function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, i
|
|
|
108
121
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trackfw-update-'))
|
|
109
122
|
try {
|
|
110
123
|
for (const rel of relPaths) copyPath(path.join(root, rel), path.join(tmp, rel))
|
|
124
|
+
for (const rel of seeds) copyPath(path.join(root, rel), path.join(tmp, rel))
|
|
111
125
|
silenceConsole(() => apply(tmp))
|
|
112
126
|
after = relPaths.map(rel => hashPath(path.join(tmp, rel)))
|
|
113
127
|
} finally {
|
|
@@ -157,6 +171,17 @@ function summarize(targets) {
|
|
|
157
171
|
// fell through to the absolute-path branch even though homeRoot had
|
|
158
172
|
// already been normalized. Stripping a trailing separator from
|
|
159
173
|
// normalizedHome (root "/" excepted) before the prefix check closes this.
|
|
174
|
+
// ML-2A (ADR-2026-09-04, D1 categoria 1 — "texto de relatorio / saida para humano"):
|
|
175
|
+
// os TRES retornos de caminho passam por normalizeRefSeparator. A variavel que recebe
|
|
176
|
+
// esta funcao chama-se literalmente displayPath (npm/src/commands/update-harness.js) e
|
|
177
|
+
// vai para o campo `path` do documento JSON do relatorio — o consumidor nunca e o
|
|
178
|
+
// sistema de arquivos. E o "~" que ela emite ja e POSIX-ismo puro (nenhum shell do
|
|
179
|
+
// Windows expande til), entao devolver "~\\.claude\\settings.json" era incoerente com a
|
|
180
|
+
// decisao ja tomada ao escolher o til.
|
|
181
|
+
//
|
|
182
|
+
// 🔴 A normalizacao e do VALOR DE SAIDA. path.normalize acima continua operando no
|
|
183
|
+
// separador nativo, e `absPath` (o caminho que o chamador realmente abre) nao e
|
|
184
|
+
// alterado por esta funcao — ela nao muda como o produto abre arquivo (ADR D2).
|
|
160
185
|
function tildeify(homeRoot, absPath) {
|
|
161
186
|
let normalizedHome = path.normalize(homeRoot)
|
|
162
187
|
if (normalizedHome.length > path.sep.length && normalizedHome.endsWith(path.sep)) {
|
|
@@ -164,8 +189,8 @@ function tildeify(homeRoot, absPath) {
|
|
|
164
189
|
}
|
|
165
190
|
const normalizedPath = path.normalize(absPath)
|
|
166
191
|
if (normalizedPath === normalizedHome) return '~'
|
|
167
|
-
if (normalizedPath.startsWith(normalizedHome + path.sep)) return '~' + normalizedPath.slice(normalizedHome.length)
|
|
168
|
-
return normalizedPath
|
|
192
|
+
if (normalizedPath.startsWith(normalizedHome + path.sep)) return normalizeRefSeparator('~' + normalizedPath.slice(normalizedHome.length))
|
|
193
|
+
return normalizeRefSeparator(normalizedPath)
|
|
169
194
|
}
|
|
170
195
|
|
|
171
196
|
// validateTargets — throws (usage error) when the caller asked for an id
|
package/src/serve/api_board.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
|
+
const { resolveAgentNamespaces } = require('../validator')
|
|
6
|
+
const { normalizeRefSeparator } = require('../lib/pathfmt')
|
|
5
7
|
|
|
6
8
|
const STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
7
9
|
|
|
@@ -45,9 +47,15 @@ function scanState(dir, state, agent, roadmapDir) {
|
|
|
45
47
|
let content = ''
|
|
46
48
|
try { content = fs.readFileSync(path.join(dir, file), 'utf8') } catch (_) {}
|
|
47
49
|
const title = extractTitle(content, file)
|
|
48
|
-
|
|
50
|
+
// IDENTIFICADOR emitido em JSON, nao caminho de travessia (ADR-2026-09-04, D1
|
|
51
|
+
// categoria 2): o frontend devolve este valor verbatim em GET /api/file?path=...,
|
|
52
|
+
// onde o servidor refaz path.join — no Windows o join reconverte "/" para o
|
|
53
|
+
// separador nativo, entao o round-trip fecha. O path.join da linha 47 acima, que
|
|
54
|
+
// e o caminho realmente lido por fs.readFileSync, permanece intocado: as duas
|
|
55
|
+
// expressoes sao independentes sobre os mesmos operandos (ADR D2).
|
|
56
|
+
const relPath = normalizeRefSeparator(agent
|
|
49
57
|
? path.join(roadmapDir, agent, state, file)
|
|
50
|
-
: path.join(roadmapDir, state, file)
|
|
58
|
+
: path.join(roadmapDir, state, file))
|
|
51
59
|
|
|
52
60
|
items.push({ file, title, state, agent: agent || '', path: relPath })
|
|
53
61
|
}
|
|
@@ -68,14 +76,7 @@ function handleBoard(cfg, req, res) {
|
|
|
68
76
|
const agentSet = new Set()
|
|
69
77
|
|
|
70
78
|
if (namespacing === 'by_agent') {
|
|
71
|
-
|
|
72
|
-
if (!agents.length) {
|
|
73
|
-
try {
|
|
74
|
-
agents = fs.readdirSync(roadmapDir).filter(f => {
|
|
75
|
-
try { return fs.statSync(path.join(roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
76
|
-
})
|
|
77
|
-
} catch (_) { agents = [] }
|
|
78
|
-
}
|
|
79
|
+
const agents = resolveAgentNamespaces(cfg, roadmapDir)
|
|
79
80
|
for (const agent of agents) {
|
|
80
81
|
agentSet.add(agent)
|
|
81
82
|
for (const state of STATES) {
|