trackfw 7.3.0 → 7.5.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.
@@ -3,7 +3,20 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const config = require('../config')
5
5
  const { localDateISO } = require('./date')
6
- const { resolveReqFiles } = require('../validator/index.js')
6
+ const { resolveReqFiles, resolveAgentNamespaces } = require('../validator/index.js')
7
+ const { normalizeRefSeparator: pathfmtNormalizeRefSeparator } = require('../lib/pathfmt')
8
+ const { normalizeCRLF } = require('../integrations/render')
9
+
10
+ // STATUS_LEGEND teaches the vocabulary the `barrier` parser accepts for "**Status:**"
11
+ // (AC11, ADR decision 5): the canonical form the template now writes (⬜ Pendente) plus the
12
+ // other three states. Placed once, right before the first wave, so it is close to the first
13
+ // place a "**Status:**" line appears — not repeated per-ML (would clutter) and not left for
14
+ // the end (nobody reads that far). Byte-identical across the 3 CLIs
15
+ // (gate: scripts/check-artifact-parity.sh).
16
+ const STATUS_LEGEND = `## Status Legend
17
+ ⬜ Pendente · 🔄 Em andamento · ✅ Concluído · ❌ Bloqueado
18
+
19
+ `
7
20
 
8
21
  // wave0Block — "## Wave 0 — Threat Model" section prepended to every generated roadmap, before
9
22
  // the first implementation wave (AC1, AC12). Byte-identical to internal/generators/roadmap.go's
@@ -17,11 +30,11 @@ const { resolveReqFiles } = require('../validator/index.js')
17
30
  //
18
31
  // The ML is always ML-0A, never ML-1A — newRoadmapFromReq labels MLs derived from REQ acceptance
19
32
  // criteria "ML-1A", "ML-1B", ... starting at the first criterion.
20
- const WAVE0_BLOCK = `## Wave 0 — Threat Model
33
+ const WAVE0_BLOCK = STATUS_LEGEND + `## Wave 0 — Threat Model
21
34
  > Dependencies: none. Blocks all implementation.
22
35
 
23
36
  ### ML-0A — Threat model for this roadmap
24
- **Status:** pending
37
+ **Status:** ⬜ Pendente
25
38
  **Files affected:**
26
39
  **Actions:**
27
40
  1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
@@ -77,14 +90,7 @@ function listRoadmaps() {
77
90
  let found = false
78
91
 
79
92
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
80
- let agents = cfg.agents || []
81
- if (agents.length === 0) {
82
- try {
83
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
84
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
85
- })
86
- } catch (_) { agents = [] }
87
- }
93
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
88
94
  for (const agent of agents) {
89
95
  for (const state of STATE_ORDER) {
90
96
  const dir = cfg.roadmapDir + '/' + agent + '/' + state
@@ -166,9 +172,14 @@ function showRoadmap(name) {
166
172
  * antes do primeiro "## " heading é atualizada.
167
173
  *
168
174
  * Retorna { content: string, changed: boolean }.
175
+ *
176
+ * D1/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5B):
177
+ * normaliza CRLF -> LF via normalizeCRLF (npm/src/integrations/render.js) na própria entrada,
178
+ * antes de qualquer casamento de "---\n" — reaproveita a única implementação Node, sem criar
179
+ * uma segunda cópia.
169
180
  */
170
181
  function rewriteRoadmapStatus(source, state) {
171
- const s = String(source)
182
+ const s = normalizeCRLF(String(source))
172
183
  if (!s.startsWith('---\n')) return { content: s, changed: false }
173
184
  const end = s.indexOf('\n---', 4)
174
185
  if (end < 0) return { content: s, changed: false }
@@ -289,8 +300,29 @@ function moveRoadmap(name, state) {
289
300
  appendTransitionLog(logBasename, fromState, state)
290
301
  console.log(`✓ moved ${basename} → ${targetDir}`)
291
302
 
292
- // Sincroniza o roadmap: das REQs que apontem para este roadmap.
293
- syncReqReferences(basename, dst, cfg)
303
+ // Sincroniza o roadmap: das REQs que apontem para este roadmap. O valor escrito no
304
+ // frontmatter da REQ pareada é dado portável, nunca separador nativo — normaliza antes de
305
+ // sincronizar (dst continua nativo acima, para rename/read/write no filesystem).
306
+ syncReqReferences(basename, normalizeRefSeparator(dst), cfg)
307
+ }
308
+
309
+ /**
310
+ * normalizeRefSeparator — normaliza um valor já extraído (não o buffer inteiro do arquivo)
311
+ * para o separador portável (/) antes de ele ser escrito ou comparado como referência dentro
312
+ * de conteúdo versionado. Substituição incondicional: em POSIX, path.join nunca produz "\",
313
+ * então isto só atua sobre um valor herdado de um commit feito no Windows — exatamente o
314
+ * defeito que esta função existe para curar
315
+ * (docs/seguranca/2026-09-01-modelo-de-ameaca-do-separador-em-artefato.md).
316
+ * @param {string} p
317
+ * @returns {string}
318
+ */
319
+ function normalizeRefSeparator(p) {
320
+ // Delegação ao ponto único do runtime (ADR-2026-09-04, D3). A função continua
321
+ // exportada daqui porque consumidores e testes já a importam por este caminho;
322
+ // a IMPLEMENTAÇÃO agora é uma só, em npm/src/lib/pathfmt.js, para que o CLI Node
323
+ // não tenha duas noções de formato — a origem documentada das ocorrências de
324
+ // gerador e verificador discordando.
325
+ return pathfmtNormalizeRefSeparator(p)
294
326
  }
295
327
 
296
328
  /**
@@ -342,11 +374,30 @@ function extractFrontmatterRoadmap(content) {
342
374
  * rewriteReqRoadmapRef — reescreve o campo `roadmap:` no frontmatter e a linha `Roadmap:` no
343
375
  * corpo de uma REQ, substituindo oldRef por newRef.
344
376
  * Preserva toda formatação existente (aspas, backticks) via substituição literal de string.
377
+ *
378
+ * D2/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5D):
379
+ * normaliza CRLF -> LF via normalizeCRLF na própria entrada, antes de qualquer split por "\n"
380
+ * — mesmo ponto único Node reaproveitado por rewriteRoadmapStatus/rewriteREQStatus. Sem isto,
381
+ * um REQ gravado com CRLF (Windows) tinha as linhas RESCRITAS saindo em LF (via .replace) e as
382
+ * linhas NÃO tocadas saindo com o "\r" original ainda embutido no elemento que o split
383
+ * preservava — join produzia um arquivo com terminador MISTO. Medido com o binário real
384
+ * (ML-5C): 10 bytes "\r" vazados na REQ reescrita por `roadmap move`.
385
+ *
386
+ * 🔴 Se nenhuma linha REALMENTE muda (comparando o texto produzido com a linha original, não
387
+ * apenas "houve match de oldRef"), devolve o `content` ORIGINAL (não normalizado), não o
388
+ * resultado da normalização. Isto importa porque o chamador da guarda de idempotência
389
+ * (syncReqReferences) invoca esta função com oldRef === newRef — um "replace" sem-efeito, que
390
+ * bate no ramo de match mas não deve contar como mudança. O único chamador detecta "nada a
391
+ * escrever" comparando `updated === content` por igualdade de string, não por um bool de
392
+ * "changed" explícito — normalizar incondicionalmente quebraria a cardinalidade "já correta →
393
+ * nenhuma escrita" (contrato pinado em docs/cli-parity.md) para qualquer REQ com CRLF em outro
394
+ * trecho do arquivo, mesmo quando o campo roadmap: já está correto.
345
395
  */
346
396
  function rewriteReqRoadmapRef(content, oldRef, newRef) {
347
- const lines = content.split('\n')
397
+ const lines = normalizeCRLF(String(content)).split('\n')
348
398
  let inFm = false
349
399
  let fmDone = false
400
+ let changed = false
350
401
  const result = []
351
402
 
352
403
  for (let i = 0; i < lines.length; i++) {
@@ -356,7 +407,9 @@ function rewriteReqRoadmapRef(content, oldRef, newRef) {
356
407
  if (inFm) {
357
408
  const idx = line.indexOf(':')
358
409
  if (idx !== -1 && line.slice(0, idx).trim() === 'roadmap' && line.includes(oldRef)) {
359
- result.push(line.replace(oldRef, newRef))
410
+ const newLine = line.replace(oldRef, newRef)
411
+ if (newLine !== line) changed = true
412
+ result.push(newLine)
360
413
  continue
361
414
  }
362
415
  result.push(line)
@@ -365,13 +418,16 @@ function rewriteReqRoadmapRef(content, oldRef, newRef) {
365
418
  if (fmDone) {
366
419
  // No corpo: reescreve linhas `Roadmap:` que contenham oldRef
367
420
  if (line.trim().toLowerCase().startsWith('roadmap:') && line.includes(oldRef)) {
368
- result.push(line.replace(oldRef, newRef))
421
+ const newLine = line.replace(oldRef, newRef)
422
+ if (newLine !== line) changed = true
423
+ result.push(newLine)
369
424
  continue
370
425
  }
371
426
  }
372
427
  result.push(line)
373
428
  }
374
429
 
430
+ if (!changed) return String(content)
375
431
  return result.join('\n')
376
432
  }
377
433
 
@@ -410,7 +466,10 @@ function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
410
466
 
411
467
  const currentRef = extractFrontmatterRoadmap(content)
412
468
  if (!currentRef) continue
413
- if (path.basename(currentRef) !== movedBasename) continue // aponta para outro roadmap
469
+ // path.basename sobre um valor sujo com "\" (gravado no Windows antes do fix de escrita)
470
+ // não separa nada em POSIX — normaliza antes de comparar, senão uma REQ já suja nunca é
471
+ // curada por um roadmap move subsequente.
472
+ if (path.basename(normalizeRefSeparator(currentRef)) !== movedBasename) continue // aponta para outro roadmap
414
473
  if (currentRef === newRoadmapPath) {
415
474
  // Guarda rápida: frontmatter já correto — confirma idempotência com reescrita estrutural
416
475
  const updated = rewriteReqRoadmapRef(content, currentRef, newRoadmapPath)
@@ -489,7 +548,7 @@ ${WAVE0_BLOCK}## Wave 1 — <name> (parallel MLs)
489
548
  > Dependencies: none
490
549
 
491
550
  ### ML-1A — ${title}
492
- **Status:** pending
551
+ **Status:** ⬜ Pendente
493
552
  **Files affected:**
494
553
  **Actions:**
495
554
  **Acceptance criteria:**
@@ -552,7 +611,7 @@ function newRoadmapFromReq(reqPath) {
552
611
  const mlLabel = `ML-1${String.fromCharCode(65 + i)}`
553
612
  const crit = criteria[i]
554
613
  mlLines.push(`\n### ${mlLabel} — ${crit}`)
555
- mlLines.push('**Status:** pending')
614
+ mlLines.push('**Status:** ⬜ Pendente')
556
615
  mlLines.push('**Files affected:**')
557
616
  mlLines.push('**Actions:**')
558
617
  mlLines.push('**Acceptance criteria:**')
@@ -655,14 +714,7 @@ function findRoadmapMatches(name) {
655
714
  const nameLower = name.toLowerCase()
656
715
 
657
716
  if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
658
- let agents = cfg.agents || []
659
- if (agents.length === 0) {
660
- try {
661
- agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
662
- try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
663
- })
664
- } catch (_) { agents = ['default'] }
665
- }
717
+ const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
666
718
  for (const agent of agents) {
667
719
  for (const state of STATE_ORDER) {
668
720
  const dir = cfg.roadmapDir + '/' + agent + '/' + state
@@ -712,6 +764,7 @@ module.exports = {
712
764
  extractFrontmatterRoadmap,
713
765
  rewriteReqRoadmapRef,
714
766
  syncReqReferences,
767
+ normalizeRefSeparator,
715
768
  newRoadmap,
716
769
  newRoadmapFromReq,
717
770
  stateDir,
package/src/homedir.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * homedir.js — resolves the user's home directory consistently across platforms.
5
+ *
6
+ * Mirrors internal/homedir/homedir.go (Go, canonical source of truth) and
7
+ * pypi/trackfw/homedir.py (Python). See that file's doc comment for the full
8
+ * rationale.
9
+ *
10
+ * Why it exists: os.homedir() reads $HOME on Linux and macOS, but %USERPROFILE%
11
+ * on Windows. Tests and gates isolate the home directory with HOME=<tempdir>,
12
+ * which on Windows isolates nothing — the process keeps reading and writing the
13
+ * developer's real home.
14
+ *
15
+ * homedir() makes Windows behave like the other platforms: $HOME first,
16
+ * os.homedir() as the fallback. Where $HOME is unset nothing changes.
17
+ *
18
+ * The empty string does NOT count as set: HOME="" would resolve to "" and every
19
+ * derived path would silently become relative.
20
+ */
21
+
22
+ const os = require('os')
23
+
24
+ function homedir() {
25
+ const fromEnv = process.env.HOME
26
+ if (fromEnv) return fromEnv
27
+ return os.homedir()
28
+ }
29
+
30
+ module.exports = { homedir }
Binary file
@@ -0,0 +1,224 @@
1
+ 'use strict'
2
+
3
+ // Mirrors internal/commands/doctor_remote.go (Go, canonical source of truth for wording/
4
+ // semantics) — see that file's doc comments for the full rationale. Implements the --remote
5
+ // modality of `trackfw doctor` (ADR-2026-09-02, ML-3A): GitHub branch protection
6
+ // (required_status_checks, enforce_admins) plus local core.hooksPath neutralization. Never
7
+ // runs unless explicitly requested via --remote.
8
+
9
+ const { spawnSync } = require('child_process')
10
+ const {
11
+ REQUIRED_STATUS_CHECKS_MISSING,
12
+ ENFORCE_ADMINS_DISABLED,
13
+ HOOKS_PATH_NEUTRALIZED,
14
+ NOT_EVALUATED,
15
+ } = require('./doctor')
16
+ const { resolve: resolveForge } = require('../forge/resolve')
17
+ const { forgeAdapter } = require('../forge/adapter')
18
+
19
+ // defaultExecGit runs a git command and returns { stdout, error } — stdout trimmed, error null
20
+ // on success. Mirrors release/runner.js's defaultExecGit.
21
+ function defaultExecGit(args) {
22
+ const result = spawnSync('git', args, { encoding: 'utf8' })
23
+ if (result.status !== 0) {
24
+ const msg = (result.stderr || '').trim() || `git ${args.join(' ')} exited with ${result.status}`
25
+ return { stdout: '', error: new Error(msg) }
26
+ }
27
+ return { stdout: (result.stdout || '').trim(), error: null }
28
+ }
29
+
30
+ // defaultExecForgeAPI runs a forge CLI command (gh api ...) and returns { stdout, error }.
31
+ // Mirrors release/runner.js's defaultExecForgeAPI.
32
+ function defaultExecForgeAPI(name, args, stdin) {
33
+ const result = spawnSync(name, args, { input: stdin || '', encoding: 'utf8' })
34
+ if (result.status !== 0) {
35
+ const msg = (result.stderr || '').trim() || `${name} ${args.join(' ')} exited with ${result.status}`
36
+ return { stdout: (result.stdout || '').trim(), error: new Error(msg) }
37
+ }
38
+ return { stdout: (result.stdout || '').trim(), error: null }
39
+ }
40
+
41
+ // Values that discard every git hook invocation on each OS git supports as a hooksPath target.
42
+ // Anything else (including unset) is left alone — a custom husky/lefthook directory is
43
+ // legitimate and must never be flagged.
44
+ const NEUTRALIZED_HOOKS_PATH_VALUES = new Set(['/dev/null', 'NUL'])
45
+
46
+ /**
47
+ * runDoctorRemote implements the --remote modality: every branch either produces a genuine
48
+ * finding (evaluated, and wrong) or a NOT_EVALUATED finding (could not evaluate) — never
49
+ * silence, which would read as "ok" to a report that treats an empty finding list as a clean
50
+ * bill of health.
51
+ *
52
+ * @param {object} deps
53
+ * @param {function} [deps.execGit] - (args: string[]) => { stdout, error }
54
+ * @param {function} [deps.execForgeAPI] - (name, args, stdin) => { stdout, error }
55
+ * @param {function} [deps.availFn] - (name: string) => boolean
56
+ * @param {string} [deps.configForge] - forge: field from trackfw.yaml
57
+ * @param {string} [deps.repoDir] - repo root, for CI file detection during forge resolution
58
+ * @returns {Array<object>} findings
59
+ */
60
+ function runDoctorRemote(deps = {}) {
61
+ const execGit = deps.execGit || defaultExecGit
62
+ const execForgeAPI = deps.execForgeAPI || defaultExecForgeAPI
63
+ const availFn = deps.availFn
64
+ const configForge = deps.configForge || ''
65
+ const repoDir = deps.repoDir !== undefined ? deps.repoDir : ''
66
+
67
+ const findings = []
68
+
69
+ // ── Local check: core.hooksPath neutralized (no network needed) ──────────────────────
70
+ const hooksPathResp = execGit(['config', '--get', 'core.hooksPath'])
71
+ if (!hooksPathResp.error && NEUTRALIZED_HOOKS_PATH_VALUES.has(hooksPathResp.stdout.trim())) {
72
+ findings.push({
73
+ finding: HOOKS_PATH_NEUTRALIZED,
74
+ destination: 'git:core.hooksPath',
75
+ remedy: `git config --unset core.hooksPath # currently "${hooksPathResp.stdout.trim()}" discards every hook invocation; unset to restore .git/hooks, or point it at your real hooks directory`,
76
+ })
77
+ }
78
+
79
+ // ── Forge resolution: only GitHub is evaluated; every other forge is not applicable ──
80
+ const remoteURLResp = execGit(['remote', 'get-url', 'origin'])
81
+ const remoteURL = remoteURLResp.error ? '' : remoteURLResp.stdout.trim()
82
+ let resolution
83
+ try {
84
+ resolution = resolveForge({ configForge, remoteURL, repoDir })
85
+ } catch (e) {
86
+ findings.push({
87
+ finding: NOT_EVALUATED,
88
+ destination: 'branch-protection',
89
+ remedy: `not applicable: branch protection is checked only on GitHub, and this repository's forge resolved to "unknown". Not a failure — no action needed unless this repository is actually hosted on GitHub, in which case set forge: github in trackfw.yaml or add a github.com origin remote.`,
90
+ })
91
+ return findings
92
+ }
93
+ if (resolution.forge !== 'github') {
94
+ findings.push({
95
+ finding: NOT_EVALUATED,
96
+ destination: 'branch-protection',
97
+ remedy: `not applicable: branch protection is checked only on GitHub, and this repository's forge resolved to "${resolution.forge}". Not a failure — no action needed unless this repository is actually hosted on GitHub, in which case set forge: github in trackfw.yaml or add a github.com origin remote.`,
98
+ })
99
+ return findings
100
+ }
101
+
102
+ // ── gh CLI availability ───────────────────────────────────────────────────────────────
103
+ const adapter = forgeAdapter('github', availFn)
104
+ if (!adapter.available) {
105
+ findings.push({
106
+ finding: NOT_EVALUATED,
107
+ destination: 'branch-protection',
108
+ remedy: 'install the GitHub CLI (gh) to evaluate branch protection remotely: https://cli.github.com, then retry with --remote',
109
+ })
110
+ return findings
111
+ }
112
+
113
+ // ── Credential presence: distinct from credential SCOPE below (ADR-2026-09-02) ────────
114
+ const authResp = execForgeAPI('gh', ['auth', 'status'], '')
115
+ if (authResp.error) {
116
+ findings.push({
117
+ finding: NOT_EVALUATED,
118
+ destination: 'branch-protection',
119
+ remedy: 'GitHub CLI has no credential — authenticate first: gh auth login (or set GITHUB_TOKEN/GH_TOKEN), then retry with --remote',
120
+ })
121
+ return findings
122
+ }
123
+
124
+ // ── Repository info: default branch + whether this credential has admin access ────────
125
+ const repoInfoResp = execForgeAPI('gh', ['api', 'repos/{owner}/{repo}'], '')
126
+ if (repoInfoResp.error) {
127
+ findings.push({
128
+ finding: NOT_EVALUATED,
129
+ destination: 'branch-protection',
130
+ remedy: `could not reach the GitHub API to resolve this repository: ${repoInfoResp.error.message}. Check network connectivity and retry with --remote`,
131
+ })
132
+ return findings
133
+ }
134
+ let repoInfo
135
+ try {
136
+ repoInfo = JSON.parse(repoInfoResp.stdout)
137
+ } catch (e) {
138
+ repoInfo = null
139
+ }
140
+ if (!repoInfo || !repoInfo.default_branch) {
141
+ findings.push({
142
+ finding: NOT_EVALUATED,
143
+ destination: 'branch-protection',
144
+ remedy: `could not parse the repository response from the GitHub API: ${repoInfoResp.stdout}. Retry with --remote`,
145
+ })
146
+ return findings
147
+ }
148
+
149
+ // Credential SCOPE: reading branch protection requires admin access to the repository.
150
+ // Distinct remedy from "no credential" above — one is fixed by authenticating, this one by
151
+ // being granted admin access (or using a token for a repo you administer).
152
+ const isAdmin = Boolean(repoInfo.permissions && repoInfo.permissions.admin)
153
+ if (!isAdmin) {
154
+ findings.push({
155
+ finding: NOT_EVALUATED,
156
+ destination: 'branch-protection',
157
+ remedy: 'the authenticated GitHub credential lacks admin access to this repository — reading branch protection requires it. Ask a repository admin to grant access, or authenticate as an account that has it, then retry with --remote',
158
+ })
159
+ return findings
160
+ }
161
+
162
+ // ── Branch protection itself ────────────────────────────────────────────────────────────
163
+ const defaultBranch = repoInfo.default_branch
164
+ const protectionResp = execForgeAPI('gh', ['api', `repos/{owner}/{repo}/branches/${defaultBranch}/protection`], '')
165
+ if (protectionResp.error) {
166
+ if (protectionResp.error.message.includes('(HTTP 404)')) {
167
+ // Evaluated (admin confirmed above): the branch genuinely has no protection at all,
168
+ // which means both checks fail — GitHub does not return the two settings separately
169
+ // when there is no rule to read them from.
170
+ findings.push({
171
+ finding: REQUIRED_STATUS_CHECKS_MISSING,
172
+ destination: `branch-protection:${defaultBranch}:required_status_checks`,
173
+ remedy: `configure required status checks: GitHub repo Settings > Branches > Branch protection rules > ${defaultBranch} > Require status checks to pass before merging`,
174
+ })
175
+ findings.push({
176
+ finding: ENFORCE_ADMINS_DISABLED,
177
+ destination: `branch-protection:${defaultBranch}:enforce_admins`,
178
+ remedy: `gh api repos/{owner}/{repo}/branches/${defaultBranch}/protection/enforce_admins --method POST`,
179
+ })
180
+ return findings
181
+ }
182
+ findings.push({
183
+ finding: NOT_EVALUATED,
184
+ destination: `branch-protection:${defaultBranch}`,
185
+ remedy: `could not read branch protection from the GitHub API: ${protectionResp.error.message}. This may be transient (rate limit, network) — retry with --remote`,
186
+ })
187
+ return findings
188
+ }
189
+
190
+ let protection
191
+ try {
192
+ protection = JSON.parse(protectionResp.stdout)
193
+ } catch (e) {
194
+ findings.push({
195
+ finding: NOT_EVALUATED,
196
+ destination: `branch-protection:${defaultBranch}`,
197
+ remedy: `could not parse the branch protection response from the GitHub API: ${protectionResp.stdout}. Retry with --remote`,
198
+ })
199
+ return findings
200
+ }
201
+
202
+ const rsc = protection.required_status_checks
203
+ const contexts = (rsc && rsc.contexts) || []
204
+ const checks = (rsc && rsc.checks) || []
205
+ if (!rsc || (contexts.length === 0 && checks.length === 0)) {
206
+ findings.push({
207
+ finding: REQUIRED_STATUS_CHECKS_MISSING,
208
+ destination: `branch-protection:${defaultBranch}:required_status_checks`,
209
+ remedy: `configure required status checks: GitHub repo Settings > Branches > Branch protection rules > ${defaultBranch} > Require status checks to pass before merging`,
210
+ })
211
+ }
212
+ const enforceAdmins = protection.enforce_admins
213
+ if (!enforceAdmins || !enforceAdmins.enabled) {
214
+ findings.push({
215
+ finding: ENFORCE_ADMINS_DISABLED,
216
+ destination: `branch-protection:${defaultBranch}:enforce_admins`,
217
+ remedy: `gh api repos/{owner}/{repo}/branches/${defaultBranch}/protection/enforce_admins --method POST`,
218
+ })
219
+ }
220
+
221
+ return findings
222
+ }
223
+
224
+ module.exports = { runDoctorRemote, defaultExecGit, defaultExecForgeAPI }
@@ -9,6 +9,7 @@ const { legacyHashes } = require('./legacy')
9
9
  const identityStore = require('../identity')
10
10
  const { injectRulesForTool } = require('../generators/init')
11
11
  const { applyThirdPartyReferences } = require('../thirdparty/references')
12
+ const { homedir } = require('../homedir')
12
13
 
13
14
  function parseSurfaces(values = []) {
14
15
  const result = {}
@@ -109,7 +110,7 @@ function execute(kind, operation, options = {}, roots = {}) {
109
110
  // reverteria silenciosamente os nomes customizados dos agentes para os
110
111
  // defaults neutros (espelha internal/commands/integrations_flags.go:
111
112
  // executeIntegrationMutation/executeIntegrationList).
112
- const homeRoot = roots.homeRoot || os.homedir()
113
+ const homeRoot = roots.homeRoot || homedir()
113
114
  const identityConfig = options.identity !== undefined ? options.identity : identityStore.load(homeRoot)
114
115
  const manager = new IntegrationManager(roots, { onSkip: options.onSkip })
115
116
  // D5/D9: manager.roots.project lets buildPlans reproduce any persisted
@@ -7,6 +7,8 @@ const path = require('node:path')
7
7
 
8
8
  const { frontmatterName } = require('./render')
9
9
  const { tildeify } = require('../lib/update-engine')
10
+ const { normalizeRefSeparator } = require('../lib/pathfmt')
11
+ const { homedir } = require('../homedir')
10
12
 
11
13
  const SCHEMA_VERSION = 1
12
14
  const sha256 = content => crypto.createHash('sha256').update(content).digest('hex')
@@ -27,7 +29,7 @@ const cleanClaim = claim => {
27
29
  }
28
30
 
29
31
  class IntegrationManager {
30
- constructor({ projectRoot = process.cwd(), homeRoot = os.homedir() } = {}, { onSkip } = {}) {
32
+ constructor({ projectRoot = process.cwd(), homeRoot = homedir() } = {}, { onSkip } = {}) {
31
33
  this.roots = { project: path.resolve(projectRoot), global: path.resolve(homeRoot) }
32
34
  this.onSkip = onSkip
33
35
  // _afterManifestPersist, when set, runs immediately after manifests are
@@ -411,9 +413,15 @@ class IntegrationManager {
411
413
  // - scope global: substitui homeRoot por '~' (via tildeify, com salvaguarda
412
414
  // de barra dupla corrigida no ML-6H).
413
415
  // - scope de projeto: caminho relativo ao projectRoot, sem './' prefixo.
416
+ // ML-2A: o ramo global ja sai normalizado (tildeify normaliza os 3 retornos);
417
+ // o ramo de projeto precisa da normalizacao aqui, porque path.relative devolve
418
+ // separador nativo. Os dois sao EMISSAO — o valor vai so para a mensagem de
419
+ // warning entregue a onSkip, que o escreve verbatim em stderr (ADR-2026-09-04,
420
+ // D1 categoria 1). O par Python (integrations/manager.py) ja emitia "/" via
421
+ // .as_posix(); Node e Go eram a divergencia (D4).
414
422
  tildeAbbrev(file, scope) {
415
423
  if (scope === 'global') return tildeify(this.roots.global, file)
416
- return path.relative(this.roots.project, file)
424
+ return normalizeRefSeparator(path.relative(this.roots.project, file))
417
425
  }
418
426
 
419
427
  cleanEmpty(directory, root) {
@@ -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 }
@@ -44,6 +44,10 @@ const { SIGNAL_SCRIPT, CLEANUP_SCRIPT, CREDENTIAL_GUARD_SCRIPT, GIT_BRANCH_GUARD
44
44
  require('../generators/hooks')
45
45
  const { CLAUDE_COMMANDS, buildValidateScript, buildGitHubActionsWorkflowContent, buildGitLabCIWorkflowContent } =
46
46
  require('../generators/init')
47
+ const {
48
+ buildDiscoverGitHubActionsWorkflowContent,
49
+ DISCOVER_GITHUB_ACTIONS_WORKFLOW_PATH,
50
+ } = require('../commands/discover')
47
51
 
48
52
  // Finding kind constants — mirrors Go's DoctorScaffoldDivergent / DoctorScaffoldMissing /
49
53
  // DoctorScaffoldWrongMode.
@@ -349,6 +353,31 @@ function runScaffoldDoctor(projectRoot) {
349
353
  if (f) findings.push(f)
350
354
  }
351
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
+
352
381
  // Deterministic output (AC7): sort by destination.
353
382
  findings.sort((a, b) => (a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0))
354
383