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.
@@ -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 = String(source).trim()
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 = String(source).trim()
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 = String(source).trim()
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 = String(source).trim()
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 = String(source).trim()
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 = String(content).trim()
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 }
@@ -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
- function runFileTarget({ id, path: displayPath, root, relPaths, apply, dryRun, installMissing }) {
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
@@ -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
- const relPath = agent
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
- let agents = cfg.agents || []
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) {