trackfw 7.3.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/barrier.js +293 -26
- package/src/commands/context.js +42 -36
- package/src/commands/discover.js +72 -21
- package/src/commands/doctor.js +23 -3
- package/src/commands/init.js +2 -1
- package/src/commands/integrations.js +5 -4
- package/src/commands/req.js +2 -1
- package/src/commands/thirdparty.js +2 -1
- package/src/commands/update-harness.js +3 -2
- package/src/commands/update.js +127 -9
- package/src/config/index.js +4 -3
- package/src/generators/hooks.js +121 -6
- package/src/generators/init.js +103 -7
- package/src/generators/req.js +15 -53
- package/src/generators/roadmap.js +81 -28
- package/src/homedir.js +30 -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 +29 -0
- package/src/lib/pathfmt.js +43 -0
- package/src/lib/update-engine.js +15 -2
- 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
|
@@ -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:**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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:**
|
|
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
|
-
|
|
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 ||
|
|
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 =
|
|
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 =
|
|
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 }
|
|
@@ -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
|
|