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.
- 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 +152 -15
- 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 +43 -20
- package/src/serve/api_metrics.js +2 -8
- package/src/validator/index.js +914 -177
package/src/validator/index.js
CHANGED
|
@@ -8,6 +8,28 @@ const { gitOutput } = require('./git-exec')
|
|
|
8
8
|
const config = require('../config')
|
|
9
9
|
const { checkTraceIds } = require('./traceid')
|
|
10
10
|
const { loadProvenance } = require('../thirdparty/provenance')
|
|
11
|
+
const { homedir } = require('../homedir')
|
|
12
|
+
const { normalizeRefSeparator } = require('../lib/pathfmt')
|
|
13
|
+
const { normalizeCRLF } = require('../integrations/render')
|
|
14
|
+
|
|
15
|
+
// _platform is seeded from process.platform at module load time. Tests override
|
|
16
|
+
// it via _setPlatformForTest to exercise the Windows guard on any host.
|
|
17
|
+
//
|
|
18
|
+
// Why the mode checks need it: on Windows the POSIX execute bit is not
|
|
19
|
+
// representable on NTFS. fs.statSync().mode & 0o111 is 0 for every regular file,
|
|
20
|
+
// even immediately after fs.chmodSync(path, 0o755). A check written as "the
|
|
21
|
+
// script is not executable" is therefore ALWAYS true there, and no action the
|
|
22
|
+
// user takes can make it false.
|
|
23
|
+
//
|
|
24
|
+
// Mirrors internal/validator/goos.go (Go, canonical) and
|
|
25
|
+
// pypi/trackfw/validator.py (Python).
|
|
26
|
+
let _platform = process.platform
|
|
27
|
+
|
|
28
|
+
function _setPlatformForTest(plat) {
|
|
29
|
+
const prev = _platform
|
|
30
|
+
_platform = plat
|
|
31
|
+
return () => { _platform = prev }
|
|
32
|
+
}
|
|
11
33
|
|
|
12
34
|
const STALE_WIP_DAYS = 7
|
|
13
35
|
let staleWipNowMs = () => Date.now()
|
|
@@ -60,13 +82,62 @@ function listDirForRule(rule, dir, messages) {
|
|
|
60
82
|
|
|
61
83
|
function readFileForRule(rule, filePath, messages) {
|
|
62
84
|
try {
|
|
63
|
-
return
|
|
85
|
+
return readRegularFileSync(filePath).toString('utf8')
|
|
64
86
|
} catch (err) {
|
|
65
87
|
messages.push(inspectionDiagnostic(rule, filePath, err))
|
|
66
88
|
return null
|
|
67
89
|
}
|
|
68
90
|
}
|
|
69
91
|
|
|
92
|
+
// readRegularFileSync is the fail-safe replacement for fs.readFileSync used by every guard
|
|
93
|
+
// config/script read in this file: validateGuardHookResolvable / validateGuardGlobalHookResolvable,
|
|
94
|
+
// and the *_script_integrity family (validateCredentialGuardScriptIntegrity,
|
|
95
|
+
// validateGitBranchGuardScriptIntegrity, validateGuardGlobalScriptIntegrity).
|
|
96
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C. Port
|
|
97
|
+
// of Go's readRegularFile (internal/validator/regularfile.go) -- see that file's doc comment for
|
|
98
|
+
// the full design rationale.
|
|
99
|
+
//
|
|
100
|
+
// Why: hades-tf's ML-1B barrier found that a FIFO in place of any of these files makes
|
|
101
|
+
// fs.readFileSync block INDEFINITELY (`mkfifo .claude/settings.json` -- `trackfw validate --json`
|
|
102
|
+
// never returns), reproduced live in all 3 runtimes, pre-existing (not introduced by ML-1B).
|
|
103
|
+
//
|
|
104
|
+
// Opens with O_NONBLOCK (where the platform defines the flag -- POSIX only; fs.constants.O_NONBLOCK
|
|
105
|
+
// is undefined on win32, where this degrades to a plain open, matching Go's windows fallback and
|
|
106
|
+
// its documented "reduction, not elimination" caveat for that platform) so a FIFO with no writer on
|
|
107
|
+
// the other end never blocks the open() call itself, then fstats the resulting FILE DESCRIPTOR --
|
|
108
|
+
// not the path -- to confirm what was actually opened is a regular file.
|
|
109
|
+
//
|
|
110
|
+
// Fstat-on-fd is what makes the type check immune to TOCTOU on POSIX: a file descriptor is bound to
|
|
111
|
+
// the inode (or pipe, or device) it was opened against, not to the path string -- whatever happens
|
|
112
|
+
// to the PATH after fs.openSync returns cannot change what fd refers to.
|
|
113
|
+
//
|
|
114
|
+
// Checked via st.isFile(), a POSITIVE allowlist -- every special type (FIFO, socket, char/block
|
|
115
|
+
// device) is excluded by construction, not enumerated one by one.
|
|
116
|
+
//
|
|
117
|
+
// Throws the raw error from fs.openSync unchanged for ordinary open failures (e.g. ENOENT, EACCES)
|
|
118
|
+
// -- callers' existing `e.code === 'ENOENT'` branches keep working exactly as before. Throws a
|
|
119
|
+
// synthetic Error with code 'ENOTFILE' (never 'ENOENT') when the opened fd is not a regular file --
|
|
120
|
+
// this flows into the SAME "not ENOENT" accusing branch every caller already has, no new branch
|
|
121
|
+
// needed anywhere.
|
|
122
|
+
function readRegularFileSync(filePath) {
|
|
123
|
+
const nonblock = typeof fs.constants.O_NONBLOCK === 'number' ? fs.constants.O_NONBLOCK : 0
|
|
124
|
+
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | nonblock)
|
|
125
|
+
try {
|
|
126
|
+
const st = fs.fstatSync(fd)
|
|
127
|
+
if (!st.isFile()) {
|
|
128
|
+
const err = new Error(`${filePath} is not a regular file`)
|
|
129
|
+
err.code = 'ENOTFILE'
|
|
130
|
+
throw err
|
|
131
|
+
}
|
|
132
|
+
// raw-read-allowed: this IS the fail-safe primitive (readRegularFileSync) — reads from the
|
|
133
|
+
// already-fstat-confirmed-regular file descriptor `fd`, not from a path.
|
|
134
|
+
// raw-read-allowed: fd-based, not path-based — the FIFO-hang/TOCTOU class this file exists to close does not apply here.
|
|
135
|
+
return fs.readFileSync(fd)
|
|
136
|
+
} finally {
|
|
137
|
+
fs.closeSync(fd)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
70
141
|
// isInsideDir retorna true se childPath estiver contido ou for igual a parentDir.
|
|
71
142
|
function isInsideDir(parentDir, childPath) {
|
|
72
143
|
if (!parentDir || !childPath) return false
|
|
@@ -147,41 +218,288 @@ function gitLastModifiedTime(filePath) {
|
|
|
147
218
|
return null
|
|
148
219
|
}
|
|
149
220
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
221
|
+
// resolveAgentNamespaces é o resolvedor canônico de namespaces em modo by_agent — o ÚNICO lugar do
|
|
222
|
+
// módulo onde a lista `agents:` do trackfw.yaml é lida ao lado do disco. Devolve a UNIÃO entre
|
|
223
|
+
// cfg.agents (na ordem declarada, deduplicada) e os subdiretórios de primeiro nível encontrados em
|
|
224
|
+
// dir (ordenados). Todo outro ponto do módulo que precisar enumerar agentes DEVE chamar esta função
|
|
225
|
+
// — nunca reimplementar "if (!agents.length) { ler disco }": esse padrão SUBSTITUÍA o disco em vez
|
|
226
|
+
// de complementá-lo, deixando invisível qualquer namespace em disco não declarado em agents:
|
|
227
|
+
// (REQ-2026-08-29). O padrão `!agents.length`/`agents.length === 0` só pode existir aqui dentro.
|
|
228
|
+
//
|
|
229
|
+
// Segurança — NÃO segue symlink (AC12/AC13, bloqueante): usa fs.readdirSync(dir, {withFileTypes:
|
|
230
|
+
// true}) + dirent.isDirectory(), que reflete o tipo da própria entrada do diretório, não o alvo do
|
|
231
|
+
// link — um namespace que é symlink para fora do projeto nunca é tratado como diretório aqui. NÃO
|
|
232
|
+
// trocar por fs.statSync (que SEGUE symlink e reproduziu ao vivo escrita fora do projeto via
|
|
233
|
+
// `roadmap move` — ver ADR-2026-08-29, decisão 5, e
|
|
234
|
+
// vault/notes/update-segue-symlink-e-escreve-fora-do-projeto-2026-08-28.md).
|
|
235
|
+
function resolveAgentNamespaces(cfg, dir) {
|
|
236
|
+
const declared = cfg.agents || []
|
|
237
|
+
const seen = new Set()
|
|
238
|
+
const ordered = []
|
|
239
|
+
for (const a of declared) {
|
|
240
|
+
if (!a || seen.has(a)) continue
|
|
241
|
+
seen.add(a)
|
|
242
|
+
ordered.push(a)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let entries = []
|
|
246
|
+
try {
|
|
247
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
248
|
+
} catch (_) {
|
|
249
|
+
return ordered
|
|
250
|
+
}
|
|
251
|
+
const fromDisk = entries
|
|
252
|
+
.filter(e => e.isDirectory()) // symlinks retornam false aqui — nunca seguidos (AC12/AC13)
|
|
253
|
+
.map(e => e.name)
|
|
254
|
+
.filter(name => !isInfraDirName(name)) // ML-2A: nunca vira namespace, ver comentário na função
|
|
255
|
+
.sort()
|
|
256
|
+
for (const name of fromDisk) {
|
|
257
|
+
if (seen.has(name)) continue
|
|
258
|
+
seen.add(name)
|
|
259
|
+
ordered.push(name)
|
|
260
|
+
}
|
|
261
|
+
return ordered
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// isInfraDirName decide, no ponto único de leitura de disco do resolvedor, se uma entrada é
|
|
265
|
+
// COMPROVADAMENTE infraestrutura e nunca um namespace de agente — filtrada da união (decisão 1 do
|
|
266
|
+
// ADR) e portanto invisível a todo consumidor.
|
|
267
|
+
//
|
|
268
|
+
// CORREÇÃO (ML-4A, achado 1 do parecer hades-tf 2026-08-30, REPROVA original — espelha
|
|
269
|
+
// internal/validator/validator.go, ver o comentário lá para a justificativa completa): esta lista já
|
|
270
|
+
// incluiu "qualquer nome iniciando com '.'", reabrindo a invisibilidade que a REQ existe para fechar
|
|
271
|
+
// (um namespace real ".ghost" desaparecia de union, status, wip limit e `move` sem sinal algum — um
|
|
272
|
+
// canal de ocultação deliberada). A lista fechada agora tem UMA entrada:
|
|
273
|
+
// - "node_modules": artefato de tooling JS. Nenhum operador digita isto como nome de agente por
|
|
274
|
+
// acidente ou por design — ruído inequívoco, sem a ambiguidade de um nome iniciado por ponto.
|
|
275
|
+
// Nomes iniciados por "." NÃO são mais filtrados aqui — continuam entrando na união (nunca
|
|
276
|
+
// invisíveis) — mas o sinal de "não declarado" é rebaixado de violação para aviso de baixo ruído
|
|
277
|
+
// (ver isDotPrefixedName / hiddenNamespaceWarnings), nunca zero sinal.
|
|
278
|
+
function isInfraDirName(name) {
|
|
279
|
+
return name === 'node_modules'
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// isDotPrefixedName reporta se name começa com "." — sinal ambíguo (pode ser um namespace legítimo,
|
|
283
|
+
// ou tooling que nunca deveria estar dentro de roadmapDir/reqDir), não mais um filtro de
|
|
284
|
+
// invisibilidade (ver isInfraDirName). Só rebaixa "não declarado em agents:" de violação para aviso.
|
|
285
|
+
function isDotPrefixedName(name) {
|
|
286
|
+
return name.startsWith('.')
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// AGENT_NAMESPACE_STATE_NAMES replica, só para esta regra, os 6 nomes de estado reservados de
|
|
290
|
+
// roadmap/REQ. Um diretório com um desses nomes no topo de roadmap_dir/req_dir é, na prática, resto
|
|
291
|
+
// de migração incompleta flat→by_agent (ex.: "wip" órfão) — não um agente. A união (decisão 1 do
|
|
292
|
+
// ADR) continua enumerando esses diretórios normalmente — nada fica invisível —, mas eles NÃO
|
|
293
|
+
// disparam validateAgentNamespaceUndeclared: pedir para declarar "wip" como agente em agents: seria
|
|
294
|
+
// ruído confuso, não uma correção real (ML-0A, seção 3, item 3; recomendação adotada sem alteração).
|
|
295
|
+
// Esta exclusão vive só aqui, não no resolvedor — a colisão de nome não é "comprovadamente
|
|
296
|
+
// infraestrutura" como isInfraDirName, é uma inferência sobre o significado do nome, então só afeta
|
|
297
|
+
// a violação, nunca a união/enumeração.
|
|
298
|
+
const AGENT_NAMESPACE_STATE_NAMES = new Set(['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned'])
|
|
299
|
+
|
|
300
|
+
// undeclaredNamespacesOnDisk devolve, a partir do resolvedor canônico (que já filtra infra e não
|
|
301
|
+
// segue symlink), os nomes de namespace presentes em dir e ausentes de agents:, excluindo colisões
|
|
302
|
+
// com nome de estado reservado (AGENT_NAMESPACE_STATE_NAMES).
|
|
303
|
+
function undeclaredNamespacesOnDisk(cfg, dir, declared) {
|
|
304
|
+
const out = []
|
|
305
|
+
for (const name of resolveAgentNamespaces(cfg, dir)) {
|
|
306
|
+
if (declared.has(name) || AGENT_NAMESPACE_STATE_NAMES.has(name) || isDotPrefixedName(name)) continue
|
|
307
|
+
out.push(name)
|
|
308
|
+
}
|
|
309
|
+
return out
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// dotPrefixedUndeclaredOnDisk é o espelho de undeclaredNamespacesOnDisk para o caso ambíguo (nome
|
|
313
|
+
// iniciado por "."): mesmo resolvedor canônico, mesma exclusão de nomes já declarados, mas mantendo
|
|
314
|
+
// exatamente os nomes que undeclaredNamespacesOnDisk descarta por causa do ponto.
|
|
315
|
+
function dotPrefixedUndeclaredOnDisk(cfg, dir, declared) {
|
|
316
|
+
const out = []
|
|
317
|
+
for (const name of resolveAgentNamespaces(cfg, dir)) {
|
|
318
|
+
if (declared.has(name) || !isDotPrefixedName(name)) continue
|
|
319
|
+
out.push(name)
|
|
320
|
+
}
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// validateAgentNamespaceUndeclared implementa a regra "agent_namespace_undeclared"
|
|
325
|
+
// (ADR-2026-08-29, decisão 2 / REQ AC4, AC5, AC9): em modo by_agent, um namespace presente em disco
|
|
326
|
+
// (roadmapDir e/ou reqDir — AC2 estende a união às duas árvores, e esta violação segue) e ausente de
|
|
327
|
+
// agents: é VIOLAÇÃO, não aviso — usa o mesmo default 'error' de toda regra sem entrada em
|
|
328
|
+
// RULE_DEFAULTS (diskRuleSeverity).
|
|
329
|
+
//
|
|
330
|
+
// A união já garante (Wave 1) que o namespace continua sendo ENUMERADO por todo consumidor mesmo com
|
|
331
|
+
// esta violação ativa — esta função só ADICIONA o sinal de configuração incompleta, nunca CONDICIONA
|
|
332
|
+
// a enumeração a ele (AC5-b).
|
|
333
|
+
//
|
|
334
|
+
// Deduplicação por namespace, não por árvore: o caso motivador (cmdb, "zeus" ausente de agents: e em
|
|
335
|
+
// disco em roadmapDir E reqDir ao mesmo tempo) produziria duas violações quase-idênticas se o laço
|
|
336
|
+
// fosse por árvore — ruído no caso comum, não no caso raro. Uma violação por nome, nomeando todas as
|
|
337
|
+
// árvores onde ele foi encontrado.
|
|
338
|
+
function validateAgentNamespaceUndeclared(cfg) {
|
|
339
|
+
if (cfg.roadmapNamespacing !== config.NAMESPACING_BY_AGENT) return []
|
|
340
|
+
const declared = new Set(cfg.agents || [])
|
|
341
|
+
|
|
342
|
+
const roadmapNames = undeclaredNamespacesOnDisk(cfg, cfg.roadmapDir, declared)
|
|
343
|
+
const reqNames = undeclaredNamespacesOnDisk(cfg, cfg.reqDir || cfg.req_dir || '', declared)
|
|
344
|
+
|
|
345
|
+
const inRoadmap = new Set(roadmapNames)
|
|
346
|
+
const inReq = new Set(reqNames)
|
|
347
|
+
|
|
348
|
+
const names = []
|
|
349
|
+
const seen = new Set()
|
|
350
|
+
for (const n of [...roadmapNames, ...reqNames]) {
|
|
351
|
+
if (seen.has(n)) continue
|
|
352
|
+
seen.add(n)
|
|
353
|
+
names.push(n)
|
|
354
|
+
}
|
|
355
|
+
names.sort()
|
|
356
|
+
|
|
357
|
+
const msgs = []
|
|
358
|
+
for (const name of names) {
|
|
359
|
+
const trees = []
|
|
360
|
+
if (inRoadmap.has(name)) trees.push('roadmap_dir')
|
|
361
|
+
if (inReq.has(name)) trees.push('req_dir')
|
|
362
|
+
msgs.push(`agent namespace "${name}" exists in ${trees.join(', ')} but is not declared in agents: — add it to trackfw.yaml`)
|
|
363
|
+
}
|
|
364
|
+
return msgs
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// hiddenNamespaceWarnings implementa a regra "agent_namespace_hidden" — o contraponto de baixo ruído
|
|
368
|
+
// de validateAgentNamespaceUndeclared para nomes iniciados por "." (ML-4A, achado 1 do parecer
|
|
369
|
+
// hades-tf 2026-08-30). Um diretório oculto/ambíguo em disco (roadmapDir e/ou reqDir), ausente de
|
|
370
|
+
// agents:, NÃO é filtrado da união (resolveAgentNamespaces mantém) e NÃO dispara a violação plena —
|
|
371
|
+
// mas também não é silêncio total: esta função emite um aviso nomeando explicitamente o diretório.
|
|
372
|
+
function hiddenNamespaceWarnings(cfg) {
|
|
373
|
+
if (cfg.roadmapNamespacing !== config.NAMESPACING_BY_AGENT) return []
|
|
374
|
+
const declared = new Set(cfg.agents || [])
|
|
375
|
+
|
|
376
|
+
const roadmapNames = dotPrefixedUndeclaredOnDisk(cfg, cfg.roadmapDir, declared)
|
|
377
|
+
const reqNames = dotPrefixedUndeclaredOnDisk(cfg, cfg.reqDir || cfg.req_dir || '', declared)
|
|
378
|
+
|
|
379
|
+
const inRoadmap = new Set(roadmapNames)
|
|
380
|
+
const inReq = new Set(reqNames)
|
|
381
|
+
|
|
382
|
+
const names = []
|
|
383
|
+
const seen = new Set()
|
|
384
|
+
for (const n of [...roadmapNames, ...reqNames]) {
|
|
385
|
+
if (seen.has(n)) continue
|
|
386
|
+
seen.add(n)
|
|
387
|
+
names.push(n)
|
|
388
|
+
}
|
|
389
|
+
names.sort()
|
|
390
|
+
|
|
391
|
+
const msgs = []
|
|
392
|
+
for (const name of names) {
|
|
393
|
+
const trees = []
|
|
394
|
+
if (inRoadmap.has(name)) trees.push('roadmap_dir')
|
|
395
|
+
if (inReq.has(name)) trees.push('req_dir')
|
|
396
|
+
msgs.push(`dot-prefixed directory "${name}" found in ${trees.join(', ')} is treated as an agent namespace (fully enumerated, not declared in agents:) — declare it in trackfw.yaml if intentional, or remove it if it is leftover tooling`)
|
|
397
|
+
}
|
|
398
|
+
return msgs
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// REQ_LAYOUT_STATES é a lista fechada de nomes de pasta de ESTADO reconhecidos nos layouts LEGADOS
|
|
402
|
+
// de REQ. Existe só para o leitor tolerar árvores antigas: pelo invariante D1 da ADR-2026-09-03,
|
|
403
|
+
// REQ NÃO tem dimensão de estado. Nada aqui decide onde ESCREVER uma REQ.
|
|
404
|
+
const REQ_LAYOUT_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
|
|
405
|
+
|
|
406
|
+
// listReqMdFiles lista os .md diretamente dentro de dir (sem recursão, sem seguir para subpastas).
|
|
407
|
+
// Não usa glob: nomes de agente vêm do disco e metacaracteres ("*", "[") corromperiam a contagem.
|
|
408
|
+
function listReqMdFiles(dir) {
|
|
409
|
+
try {
|
|
410
|
+
return fs.readdirSync(dir)
|
|
411
|
+
.filter(n => n.endsWith('.md'))
|
|
412
|
+
.filter(n => { try { return !fs.statSync(path.join(dir, n)).isDirectory() } catch (_) { return false } })
|
|
413
|
+
.map(n => path.join(dir, n))
|
|
414
|
+
} catch (_) { return [] }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// reqWriteDir é o PONTO ÚNICO que decide ONDE uma REQ nova é gravada (ADR-2026-09-03, D2/D4):
|
|
418
|
+
// flat → req_dir/
|
|
419
|
+
// by_agent → req_dir/<agente>/ (primeiro de agents:, ou "default" se a lista é vazia)
|
|
420
|
+
// Consumido pelo gerador (newREQ); a união de leitura abaixo contém este diretório por construção.
|
|
421
|
+
function reqWriteDir(cfg) {
|
|
422
|
+
const reqDir = cfg.reqDir || cfg.req_dir || ''
|
|
423
|
+
if (!reqDir) return ''
|
|
424
|
+
const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
|
|
425
|
+
if (namespacing === 'by_agent') {
|
|
426
|
+
const agents = (cfg.agents || []).filter(a => a)
|
|
427
|
+
const agent = agents.length > 0 ? agents[0] : 'default'
|
|
428
|
+
return path.join(reqDir, agent)
|
|
429
|
+
}
|
|
430
|
+
return reqDir
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// resolveReqFiles é o PONTO ÚNICO de LEITURA de REQ (ADR-2026-09-03, D3/D4): devolve os paths de
|
|
434
|
+
// todos os .md de REQ como UNIÃO dos 4 layouts suportados, nunca como escolha exclusiva:
|
|
435
|
+
// req_dir/*.md flat legado
|
|
436
|
+
// req_dir/<estado>/*.md por-estado legado (apesar de D1)
|
|
437
|
+
// req_dir/<agente>/*.md CANÔNICO em by_agent
|
|
438
|
+
// req_dir/<agente>/<estado>/*.md legado
|
|
439
|
+
// Os dois últimos só valem em by_agent — fora dele não há noção de namespace de agente.
|
|
440
|
+
//
|
|
441
|
+
// Deduplicação é OBRIGATÓRIA: resolveAgentNamespaces devolve agents: ∪ disco, então um
|
|
442
|
+
// req_dir/backlog/ real entra na lista de agentes e o caso <agente>/*.md emite os mesmos paths do
|
|
443
|
+
// caso <estado>/*.md — sem o Set, toda REQ em layout por-estado seria contada (e violada) em dobro.
|
|
444
|
+
// Ordenação final: determinismo igual ao Go/Python (readdirSync não é ordenado por contrato).
|
|
152
445
|
function resolveReqFiles(cfg) {
|
|
153
446
|
const reqDir = cfg.reqDir || cfg.req_dir || ''
|
|
154
447
|
if (!reqDir) return []
|
|
155
448
|
const namespacing = cfg.roadmapNamespacing || cfg.roadmap_namespacing || ''
|
|
449
|
+
const seen = new Set()
|
|
450
|
+
const files = []
|
|
451
|
+
const add = (paths) => {
|
|
452
|
+
for (const p of paths) {
|
|
453
|
+
const clean = path.normalize(p)
|
|
454
|
+
if (seen.has(clean)) continue
|
|
455
|
+
seen.add(clean)
|
|
456
|
+
files.push(clean)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 🔴 §4 (hades-tf 2026-09-03): a dedup por STRING não vê req_dir/Backlog ≡ req_dir/backlog em
|
|
461
|
+
// filesystem case-INSENSITIVE (APFS, NTFS) — "Backlog" entra na lista de agentes pelo disco e
|
|
462
|
+
// emite req_dir/Backlog/*.md, enquanto o laço de estados emite req_dir/backlog/*.md hardcoded em
|
|
463
|
+
// minúscula. Mesmo diretório, strings diferentes: toda REQ contada em DOBRO (medido em APFS:
|
|
464
|
+
// 2 REQs e 4 violações para 1 arquivo real). Verde no CI Linux, vermelho na máquina do dev.
|
|
465
|
+
//
|
|
466
|
+
// MECANISMO: só enumeramos um candidato de subdiretório se o nome existir VERBATIM na listagem do
|
|
467
|
+
// pai — a grafia do disco é a autoridade, medimos o disco em vez de presumir a propriedade do FS.
|
|
468
|
+
// - NÃO troca dupla contagem por SUPRESSÃO: em FS case-SENSITIVE o readdir lista "Backlog" E
|
|
469
|
+
// "backlog" (dois diretórios reais distintos) e ambos seguem enumerados. Lowercase colapsaria.
|
|
470
|
+
// - NÃO usa ino/dev: fs.statSync().ino não tem contrato medido em NTFS, e ino colidente
|
|
471
|
+
// colapsaria arquivos distintos — supressão, a direção proibida.
|
|
472
|
+
// - Cobre também NFC/NFD, que case-folding não cobre.
|
|
473
|
+
// - FALLBACK É JOIN CEGO, NUNCA LISTA VAZIA: pai ilegível → não filtra nada (dupla contagem
|
|
474
|
+
// benigna). Devolver vazio seria supressão.
|
|
475
|
+
// - Não filtra por TIPO: um <estado> que seja symlink segue enumerado como antes (§3 é outro
|
|
476
|
+
// escopo).
|
|
477
|
+
// Paridade: mesma lógica em internal/validator/validator.go e pypi/trackfw/validator.py.
|
|
478
|
+
const childCache = new Map()
|
|
479
|
+
const hasChildVerbatim = (parent, name) => {
|
|
480
|
+
if (!childCache.has(parent)) {
|
|
481
|
+
try { childCache.set(parent, new Set(fs.readdirSync(parent))) } catch (_) { childCache.set(parent, null) }
|
|
482
|
+
}
|
|
483
|
+
const children = childCache.get(parent)
|
|
484
|
+
return children === null ? true : children.has(name)
|
|
485
|
+
}
|
|
486
|
+
const addChild = (parent, name) => {
|
|
487
|
+
if (!hasChildVerbatim(parent, name)) return
|
|
488
|
+
add(listReqMdFiles(path.join(parent, name)))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
add(listReqMdFiles(reqDir))
|
|
492
|
+
for (const state of REQ_LAYOUT_STATES) addChild(reqDir, state)
|
|
493
|
+
|
|
156
494
|
if (namespacing === 'by_agent') {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
agents = fs.readdirSync(reqDir).filter(e => {
|
|
162
|
-
try { return fs.statSync(path.join(reqDir, e)).isDirectory() } catch (_) { return false }
|
|
163
|
-
})
|
|
164
|
-
} catch (_) { return [] }
|
|
165
|
-
}
|
|
166
|
-
const files = []
|
|
167
|
-
for (const agent of agents) {
|
|
168
|
-
for (const state of STATES) {
|
|
169
|
-
const dir = path.join(reqDir, agent, state)
|
|
170
|
-
try {
|
|
171
|
-
for (const name of fs.readdirSync(dir)) {
|
|
172
|
-
if (name.endsWith('.md')) files.push(path.join(dir, name))
|
|
173
|
-
}
|
|
174
|
-
} catch (_) {}
|
|
175
|
-
}
|
|
495
|
+
for (const agent of resolveAgentNamespaces(cfg, reqDir)) {
|
|
496
|
+
addChild(reqDir, agent)
|
|
497
|
+
for (const state of REQ_LAYOUT_STATES) addChild(path.join(reqDir, agent), state)
|
|
176
498
|
}
|
|
177
|
-
return files
|
|
178
499
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
.filter(n => n.endsWith('.md') && !fs.statSync(path.join(reqDir, n)).isDirectory())
|
|
183
|
-
.map(n => path.join(reqDir, n))
|
|
184
|
-
} catch (_) { return [] }
|
|
500
|
+
|
|
501
|
+
files.sort()
|
|
502
|
+
return files
|
|
185
503
|
}
|
|
186
504
|
|
|
187
505
|
// resolveStateDirs retorna todos os diretórios de um estado (ex: 'wip', 'done') conforme o modo de
|
|
@@ -190,14 +508,7 @@ function resolveReqFiles(cfg) {
|
|
|
190
508
|
// anteriores (roadmap_dir divergente entre runtimes).
|
|
191
509
|
function resolveStateDirs(cfg, state) {
|
|
192
510
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
193
|
-
|
|
194
|
-
if (agents.length === 0) {
|
|
195
|
-
try {
|
|
196
|
-
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
197
|
-
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
198
|
-
})
|
|
199
|
-
} catch (_) { agents = [] }
|
|
200
|
-
}
|
|
511
|
+
const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
|
|
201
512
|
return agents.map(agent => cfg.roadmapDir + '/' + agent + '/' + state)
|
|
202
513
|
}
|
|
203
514
|
return [cfg.roadmapDir + '/' + state]
|
|
@@ -214,10 +525,19 @@ function resolveDoneDirs(cfg) {
|
|
|
214
525
|
}
|
|
215
526
|
|
|
216
527
|
// parseBlockedADRs extrai basenames de ADRs da seção "## Blocked by ADRs" de um arquivo REQ.
|
|
528
|
+
//
|
|
529
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1H:
|
|
530
|
+
// content is folded through normalizeCRLF (npm/src/integrations/render.js) before the exact
|
|
531
|
+
// `line === '## Blocked by ADRs'` comparison below -- same fix, same reason, as Go's
|
|
532
|
+
// parseBlockedADRs (internal/validator/validator.go) and Python's _parse_blocked_adrs
|
|
533
|
+
// (pypi/trackfw/validator.py): a REQ with CRLF (Windows author, or git checkout with
|
|
534
|
+
// core.autocrlf=true -- ADR-2026-09-04 D1's exact scenario) leaves a trailing "\r" on this line
|
|
535
|
+
// after content.split('\n'), and the exact-equality match silently never fires without this.
|
|
536
|
+
// Measured first in the Python port on Windows ARM64; closed identically in all 3 runtimes here.
|
|
217
537
|
function parseBlockedADRs(filePath) {
|
|
218
538
|
let content
|
|
219
539
|
try {
|
|
220
|
-
content =
|
|
540
|
+
content = normalizeCRLF(readRegularFileSync(filePath).toString('utf8'))
|
|
221
541
|
} catch (_) {
|
|
222
542
|
return []
|
|
223
543
|
}
|
|
@@ -245,6 +565,9 @@ function parseBlockedADRs(filePath) {
|
|
|
245
565
|
|
|
246
566
|
// contentHasMarker retorna true se o conteúdo contém algum dos markers sem espaço em branco após.
|
|
247
567
|
// P3: verifica tanto "\n" quanto "\r\n" para detectar campos vazios em arquivos CRLF.
|
|
568
|
+
//
|
|
569
|
+
// Usada apenas para markers de "existência de bloco" (ex: acceptanceMarkers, um heading de
|
|
570
|
+
// seção) — não para markers de link (REQ:/ADR:/Roadmap:), que usam contentHasMarkerValue.
|
|
248
571
|
function contentHasMarker(content, markers) {
|
|
249
572
|
for (const marker of markers) {
|
|
250
573
|
if (content.includes(marker) && !content.includes(marker + ' \n') && !content.includes(marker + ' \r\n')) {
|
|
@@ -254,6 +577,65 @@ function contentHasMarker(content, markers) {
|
|
|
254
577
|
return false
|
|
255
578
|
}
|
|
256
579
|
|
|
580
|
+
// contentHasMarkerValue retorna true se, em alguma LINHA de content, um dos markers aparece
|
|
581
|
+
// logo após espaços/tabs de indentação (nunca no meio de uma frase) e é seguido de um valor
|
|
582
|
+
// substantivo na mesma linha — não vazio e não um comentário HTML disfarçado de valor.
|
|
583
|
+
//
|
|
584
|
+
// issue #278 (achado A2 da auditoria externa, 2026-09-05): a versão anterior buscava o marker
|
|
585
|
+
// como SUBSTRING em qualquer lugar do conteúdo. Isso produzia dois defeitos medidos no acervo
|
|
586
|
+
// mergeado:
|
|
587
|
+
// 1. prosa que menciona o marker no meio de uma frase contava como vínculo real
|
|
588
|
+
// ("veja a secao ADR: mais abaixo" → true, sem nenhum vínculo).
|
|
589
|
+
// 2. um comentário HTML usado como placeholder contava como valor
|
|
590
|
+
// ("ADR: <!-- preencher depois -->" → true, campo de fato vazio).
|
|
591
|
+
//
|
|
592
|
+
// Ambos se resolvem tratando a detecção por LINHA em vez de por substring do documento inteiro:
|
|
593
|
+
// o marker só conta quando é a primeira coisa não-branca da linha (tolera espaços/tabs à
|
|
594
|
+
// esquerda, não tolera qualquer outra decoração — "> ", "- ", "# ", "**" não contam como início).
|
|
595
|
+
// Medido antes de decidir (não assumido): nas quatro pastas realmente varridas por estas 4
|
|
596
|
+
// regras (REQ files para ADR:/Roadmap:; roadmaps de wip/blocked para REQ:), a contagem de linhas
|
|
597
|
+
// com decoração diferente de espaço em branco antes do marker é ZERO — os três geradores
|
|
598
|
+
// canônicos sempre emitem o marker no início da linha. Restringir a espaço/tab não introduz
|
|
599
|
+
// falso-positivo novo neste acervo.
|
|
600
|
+
//
|
|
601
|
+
// NÃO resolve (fora de escopo desta ML): "REQ (**reaberta**):" — decoração ENTRE o nome do
|
|
602
|
+
// campo e ":", não ANTES do marker. A literal "REQ:" não aparece nessa linha nem por
|
|
603
|
+
// substring nem por prefixo; ancorar por linha não causa nem cura esse caso. A forma que
|
|
604
|
+
// funciona hoje é "REQ: docs/req/x.md (reaberta)" — o marker intacto, a anotação depois.
|
|
605
|
+
//
|
|
606
|
+
// "none", "TBD", "N/A", "-" continuam sendo tratados como VALOR (não como placeholder). Nenhum
|
|
607
|
+
// dos quatro casos exigidos por esta ML os menciona, e "none" é usado deliberadamente em
|
|
608
|
+
// scripts/check-gates-falsify.sh como placeholder inofensivo — para dizer "sem vínculo real,
|
|
609
|
+
// de propósito" nos fixtures que testam OUTRA regra — sem ficar vazio nem virar comentário
|
|
610
|
+
// HTML. Bloqueá-los moveria a contagem do acervo por um motivo alheio ao achado A2 e quebraria
|
|
611
|
+
// cenários de falsificação que dependem desse contrato.
|
|
612
|
+
//
|
|
613
|
+
// issue #278 (ML-1B): 5 de 7 grafias naturais de campo vazio escapavam da checagem anterior
|
|
614
|
+
// por literal — esta função decide por VALOR (trim do resto da linha), o que já cobre as 7
|
|
615
|
+
// grafias medidas na triagem original.
|
|
616
|
+
function contentHasMarkerValue(content, markers) {
|
|
617
|
+
for (const rawLine of content.split('\n')) {
|
|
618
|
+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
|
|
619
|
+
const leading = line.replace(/^[ \t]+/, '')
|
|
620
|
+
for (const marker of markers) {
|
|
621
|
+
if (!leading.startsWith(marker)) continue
|
|
622
|
+
const rest = leading.slice(marker.length).trim()
|
|
623
|
+
if (rest === '') continue
|
|
624
|
+
if (isHTMLCommentOnlyValue(rest)) continue
|
|
625
|
+
return true
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return false
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// isHTMLCommentOnlyValue retorna true se value (já sem espaços nas duas pontas) é inteiramente
|
|
632
|
+
// um comentário HTML — "<!-- ... -->" do início ao fim, sem nada fora dele. É a forma medida no
|
|
633
|
+
// achado A2: "ADR: <!-- preencher depois -->" é sintaticamente um valor, mas semanticamente um
|
|
634
|
+
// placeholder de "ainda não preenchido" — mesma intenção de um campo vazio, só que disfarçado.
|
|
635
|
+
function isHTMLCommentOnlyValue(value) {
|
|
636
|
+
return value.startsWith('<!--') && value.endsWith('-->')
|
|
637
|
+
}
|
|
638
|
+
|
|
257
639
|
// extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
|
|
258
640
|
// ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
|
|
259
641
|
// documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
|
|
@@ -323,7 +705,7 @@ function adrNotAcceptedStatusForRule(rule, basename, messages) {
|
|
|
323
705
|
const p = findAdrFile(basename)
|
|
324
706
|
if (!p) return { notAccepted: false, status: '', inspected: true }
|
|
325
707
|
try {
|
|
326
|
-
const content =
|
|
708
|
+
const content = readRegularFileSync(p).toString('utf8')
|
|
327
709
|
const status = resolveAdrStatus(content)
|
|
328
710
|
const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
|
|
329
711
|
return { notAccepted, status, inspected: true }
|
|
@@ -356,8 +738,8 @@ function validateWIPHasREQ() {
|
|
|
356
738
|
for (const name of entries) {
|
|
357
739
|
const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
|
|
358
740
|
if (content === null) continue
|
|
359
|
-
if (!
|
|
360
|
-
violations.push(`roadmap "${name}" is in wip but has no linked REQ`)
|
|
741
|
+
if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
|
|
742
|
+
violations.push(`roadmap "${name}" is in wip but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
|
|
361
743
|
}
|
|
362
744
|
}
|
|
363
745
|
}
|
|
@@ -370,13 +752,10 @@ function validateREQsHaveADR() {
|
|
|
370
752
|
const files = resolveReqFiles(cfg)
|
|
371
753
|
const violations = []
|
|
372
754
|
for (const filePath of files) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
} catch (_) {
|
|
379
|
-
// ignorar
|
|
755
|
+
const content = readFileForRule('req_has_adr', filePath, violations)
|
|
756
|
+
if (content === null) continue
|
|
757
|
+
if (!contentHasMarkerValue(content, cfg.linkFields.adr)) {
|
|
758
|
+
violations.push(`req "${path.basename(filePath)}" has no linked ADR (marker must start the line with a real, non-placeholder value)`)
|
|
380
759
|
}
|
|
381
760
|
}
|
|
382
761
|
return violations
|
|
@@ -391,8 +770,8 @@ function validateBlockedHasREQ() {
|
|
|
391
770
|
for (const name of entries) {
|
|
392
771
|
const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
|
|
393
772
|
if (content === null) continue
|
|
394
|
-
if (!
|
|
395
|
-
violations.push(`roadmap "${name}" is in blocked but has no linked REQ`)
|
|
773
|
+
if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
|
|
774
|
+
violations.push(`roadmap "${name}" is in blocked but has no linked REQ (marker must start the line with a real, non-placeholder value)`)
|
|
396
775
|
}
|
|
397
776
|
}
|
|
398
777
|
}
|
|
@@ -405,13 +784,10 @@ function validateREQsHaveRoadmap() {
|
|
|
405
784
|
const files = resolveReqFiles(cfg)
|
|
406
785
|
const violations = []
|
|
407
786
|
for (const filePath of files) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
}
|
|
413
|
-
} catch (_) {
|
|
414
|
-
// ignorar
|
|
787
|
+
const content = readFileForRule('req_has_roadmap', filePath, violations)
|
|
788
|
+
if (content === null) continue
|
|
789
|
+
if (!contentHasMarkerValue(content, cfg.linkFields.roadmap)) {
|
|
790
|
+
violations.push(`req "${path.basename(filePath)}" has no linked Roadmap (marker must start the line with a real, non-placeholder value)`)
|
|
415
791
|
}
|
|
416
792
|
}
|
|
417
793
|
return violations
|
|
@@ -505,7 +881,7 @@ function wipConfigFrom(cfg) {
|
|
|
505
881
|
function parseSquadFromFrontmatter(filePath) {
|
|
506
882
|
let content
|
|
507
883
|
try {
|
|
508
|
-
content =
|
|
884
|
+
content = readRegularFileSync(filePath).toString('utf8')
|
|
509
885
|
} catch (_) {
|
|
510
886
|
return ''
|
|
511
887
|
}
|
|
@@ -526,14 +902,7 @@ function validateWIPLimit() {
|
|
|
526
902
|
const warnings = []
|
|
527
903
|
|
|
528
904
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
529
|
-
|
|
530
|
-
if (agents.length === 0) {
|
|
531
|
-
try {
|
|
532
|
-
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
533
|
-
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
534
|
-
})
|
|
535
|
-
} catch (_) { agents = [] }
|
|
536
|
-
}
|
|
905
|
+
const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
|
|
537
906
|
const limit = cfg.wipLimit > 0 ? cfg.wipLimit : 1
|
|
538
907
|
for (const agent of agents) {
|
|
539
908
|
const entries = listDir(cfg.roadmapDir + '/' + agent + '/wip')
|
|
@@ -606,7 +975,7 @@ function latestWipTransitionTime(cfg, filePath) {
|
|
|
606
975
|
let content
|
|
607
976
|
const logPath = path.join(cfg.roadmapDir, '.trackfw-log')
|
|
608
977
|
try {
|
|
609
|
-
content =
|
|
978
|
+
content = readRegularFileSync(logPath).toString('utf8')
|
|
610
979
|
} catch (err) {
|
|
611
980
|
return { time: null, diagnostics: err && err.code === 'ENOENT' ? [] : [inspectionDiagnostic('stale_wip', logPath, err)] }
|
|
612
981
|
}
|
|
@@ -743,7 +1112,7 @@ function blockedREQs() {
|
|
|
743
1112
|
for (const filePath of files) {
|
|
744
1113
|
let content
|
|
745
1114
|
try {
|
|
746
|
-
content =
|
|
1115
|
+
content = readRegularFileSync(filePath).toString('utf8')
|
|
747
1116
|
} catch (_) {
|
|
748
1117
|
continue
|
|
749
1118
|
}
|
|
@@ -796,23 +1165,21 @@ function validateFrontmatterPresence() {
|
|
|
796
1165
|
for (const f of walkDirMd(adrDir)) {
|
|
797
1166
|
const fullPath = findAdrFile(f)
|
|
798
1167
|
if (!fullPath) continue
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
} catch (_) {}
|
|
1168
|
+
const content = readFileForRule('frontmatter_presence', fullPath, violations)
|
|
1169
|
+
if (content === null) continue
|
|
1170
|
+
if (!content.startsWith('---')) {
|
|
1171
|
+
violations.push(`adr "${f}" has no frontmatter block`)
|
|
1172
|
+
}
|
|
805
1173
|
}
|
|
806
1174
|
}
|
|
807
1175
|
|
|
808
1176
|
const reqFilePaths = resolveReqFiles(cfg)
|
|
809
1177
|
for (const filePath of reqFilePaths) {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
} catch (_) {}
|
|
1178
|
+
const content = readFileForRule('frontmatter_presence', filePath, violations)
|
|
1179
|
+
if (content === null) continue
|
|
1180
|
+
if (!content.startsWith('---')) {
|
|
1181
|
+
violations.push(`req "${path.basename(filePath)}" has no frontmatter block`)
|
|
1182
|
+
}
|
|
816
1183
|
}
|
|
817
1184
|
|
|
818
1185
|
return violations
|
|
@@ -862,8 +1229,22 @@ function validateRefTargetsExist() {
|
|
|
862
1229
|
warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
|
|
863
1230
|
}
|
|
864
1231
|
const roadmapRef = extractRefPath(content, 'Roadmap')
|
|
865
|
-
if (roadmapRef
|
|
866
|
-
|
|
1232
|
+
if (roadmapRef) {
|
|
1233
|
+
const { resolved, stale } = resolveRoadmapRefStatus(cfg, roadmapRef)
|
|
1234
|
+
if (resolved.length === 0) {
|
|
1235
|
+
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" which does not exist`)
|
|
1236
|
+
} else if (resolved.length === 1) {
|
|
1237
|
+
if (stale) {
|
|
1238
|
+
// literal falhou, resolvido só pelo fallback de basename num outro estado: o
|
|
1239
|
+
// vínculo aponta para um caminho de estado velho — o serve casa edge.To pelo
|
|
1240
|
+
// literal e desenharia aresta órfã para o mesmo vínculo se calássemos aqui.
|
|
1241
|
+
const actualState = path.basename(path.dirname(resolved[0]))
|
|
1242
|
+
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" but the file is now in ${actualState}/ (stale state path)`)
|
|
1243
|
+
}
|
|
1244
|
+
// senão: resolvido pelo caminho literal — nenhum aviso.
|
|
1245
|
+
} else {
|
|
1246
|
+
warnings.push(`req "${name}" links to Roadmap "${roadmapRef}" is ambiguous: found in multiple states: ${resolved.join(', ')}`)
|
|
1247
|
+
}
|
|
867
1248
|
}
|
|
868
1249
|
}
|
|
869
1250
|
|
|
@@ -876,21 +1257,90 @@ function referenceExists(ref) {
|
|
|
876
1257
|
return false
|
|
877
1258
|
}
|
|
878
1259
|
|
|
1260
|
+
// isStaleRoadmapStateRef reporta se `ref` (um valor de campo Roadmap: já extraído) parece ter sido
|
|
1261
|
+
// gravado com uma pasta de estado que pode ter ficado velha — condição de entrada do fallback por
|
|
1262
|
+
// basename (resolveRoadmapRefByBasename). Ver o comentário completo em
|
|
1263
|
+
// internal/validator/validator.go, isStaleRoadmapStateRef (fonte canônica, ROADMAP-2026-09-05, ML-3B).
|
|
1264
|
+
function isStaleRoadmapStateRef(ref) {
|
|
1265
|
+
const parent = path.basename(path.dirname(ref.replace(/\\/g, '/')))
|
|
1266
|
+
return AGENT_NAMESPACE_STATE_NAMES.has(parent)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// resolveRoadmapRefByBasename procura, nos diretórios de ESTADO do roadmap_dir (backlog/analyzing/
|
|
1270
|
+
// wip/blocked/done/abandoned, honrando roadmap_namespacing), um arquivo cujo basename case com
|
|
1271
|
+
// path.basename(ref). Escopo restrito ao campo Roadmap: — ver a justificativa completa (REQ sem
|
|
1272
|
+
// dimensão de estado / ADR de REQ, árvore de ADR flat) no comentário gêmeo em
|
|
1273
|
+
// internal/validator/validator.go, resolveRoadmapRefByBasename.
|
|
1274
|
+
//
|
|
1275
|
+
// Retorna a lista de caminhos absolutos encontrados: 0 (sem match), 1 (resolvido) ou mais de 1
|
|
1276
|
+
// (ambíguo — decisão do chamador).
|
|
1277
|
+
function resolveRoadmapRefByBasename(cfg, ref) {
|
|
1278
|
+
const base = path.basename(ref)
|
|
1279
|
+
if (base === '' || base === '.' || base === path.sep) return []
|
|
1280
|
+
const found = []
|
|
1281
|
+
const seen = new Set()
|
|
1282
|
+
for (const state of Array.from(AGENT_NAMESPACE_STATE_NAMES).sort()) {
|
|
1283
|
+
for (const stateDir of resolveStateDirs(cfg, state)) {
|
|
1284
|
+
let entries
|
|
1285
|
+
try {
|
|
1286
|
+
entries = fs.readdirSync(stateDir, { withFileTypes: true })
|
|
1287
|
+
} catch {
|
|
1288
|
+
continue
|
|
1289
|
+
}
|
|
1290
|
+
for (const entry of entries) {
|
|
1291
|
+
if (entry.name !== base || entry.isDirectory()) continue
|
|
1292
|
+
const full = path.normalize(path.join(stateDir, entry.name))
|
|
1293
|
+
if (!seen.has(full)) {
|
|
1294
|
+
seen.add(full)
|
|
1295
|
+
found.push(full)
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
found.sort()
|
|
1301
|
+
return found
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// resolveRoadmapRef resolve um valor de campo Roadmap: já extraído: caminho literal primeiro
|
|
1305
|
+
// (idêntico a referenceExists — preserva o branch de diretório existente hoje), e só cai para o
|
|
1306
|
+
// fallback por basename quando o literal falha e isStaleRoadmapStateRef(ref) indica que vale a
|
|
1307
|
+
// pena tentar. Retorna 0 (quebrado), 1 (resolvido) ou >1 (ambíguo) caminhos.
|
|
1308
|
+
function resolveRoadmapRef(cfg, ref) {
|
|
1309
|
+
return resolveRoadmapRefStatus(cfg, ref).resolved
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// resolveRoadmapRefStatus é resolveRoadmapRef com um flag adicional: stale=true quando a
|
|
1313
|
+
// resolução só teve sucesso via resolveRoadmapRefByBasename (o caminho literal falhou e o
|
|
1314
|
+
// arquivo foi encontrado em outra pasta de estado). Espelha
|
|
1315
|
+
// internal/validator/validator.go:resolveRoadmapRefStatus (fonte canônica, ROADMAP-2026-09-05, ML-3B).
|
|
1316
|
+
function resolveRoadmapRefStatus(cfg, ref) {
|
|
1317
|
+
const expandedRef = config.expandPath ? config.expandPath(ref) : ref
|
|
1318
|
+
if (fs.existsSync(expandedRef)) return { resolved: [expandedRef], stale: false }
|
|
1319
|
+
if (!isStaleRoadmapStateRef(ref)) return { resolved: [], stale: false }
|
|
1320
|
+
const found = resolveRoadmapRefByBasename(cfg, ref)
|
|
1321
|
+
return { resolved: found, stale: found.length > 0 }
|
|
1322
|
+
}
|
|
1323
|
+
|
|
879
1324
|
function validateREQRoadmapLifecycle() {
|
|
880
1325
|
const cfg = config.load()
|
|
881
1326
|
const warnings = []
|
|
882
1327
|
for (const filePath of resolveReqFiles(cfg)) {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}
|
|
1328
|
+
const content = readFileForRule('req_roadmap_lifecycle', filePath, warnings)
|
|
1329
|
+
if (content === null) continue
|
|
1330
|
+
if (!reqStatusIsOpen(content)) continue
|
|
1331
|
+
const ref = extractRefPath(content, 'Roadmap')
|
|
1332
|
+
if (!ref) continue
|
|
1333
|
+
const resolved = resolveRoadmapRef(cfg, ref)
|
|
1334
|
+
if (resolved.length !== 1) {
|
|
1335
|
+
// 0 = vínculo quebrado, >1 = ambíguo — ambos já reportados por validateRefTargetsExist.
|
|
1336
|
+
// Esta regra só decide estado quando há UM arquivo real de onde derivá-lo.
|
|
1337
|
+
continue
|
|
1338
|
+
}
|
|
1339
|
+
const expandedRef = resolved[0]
|
|
1340
|
+
if (!fs.existsSync(expandedRef)) continue
|
|
1341
|
+
if (path.basename(path.dirname(expandedRef)) === 'done') {
|
|
1342
|
+
warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
|
|
1343
|
+
}
|
|
894
1344
|
}
|
|
895
1345
|
return warnings
|
|
896
1346
|
}
|
|
@@ -932,12 +1382,7 @@ function validateFolderStatusCoherence() {
|
|
|
932
1382
|
|
|
933
1383
|
let dirs = []
|
|
934
1384
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
935
|
-
|
|
936
|
-
if (agents.length === 0) {
|
|
937
|
-
try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
938
|
-
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
939
|
-
}) } catch (_) { agents = [] }
|
|
940
|
-
}
|
|
1385
|
+
const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
|
|
941
1386
|
for (const agent of agents) {
|
|
942
1387
|
for (const state of states) {
|
|
943
1388
|
dirs.push({ dir: path.join(cfg.roadmapDir, agent, state), state })
|
|
@@ -957,28 +1402,27 @@ function validateFolderStatusCoherence() {
|
|
|
957
1402
|
continue
|
|
958
1403
|
}
|
|
959
1404
|
for (const name of entries.filter(f => f.endsWith('.md'))) {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
}
|
|
1405
|
+
const content = readFileForRule('folder_status', path.join(dir, name), warnings)
|
|
1406
|
+
if (content === null) continue
|
|
1407
|
+
// Extrair status do frontmatter
|
|
1408
|
+
let declared = ''
|
|
1409
|
+
if (content.startsWith('---')) {
|
|
1410
|
+
const end = content.indexOf('\n---', 3)
|
|
1411
|
+
if (end > 0) {
|
|
1412
|
+
for (const line of content.slice(3, end).split('\n')) {
|
|
1413
|
+
const t = line.trim()
|
|
1414
|
+
if (t.startsWith('status:')) {
|
|
1415
|
+
declared = t.slice('status:'.length).trim().replace(/['"]/g, '')
|
|
1416
|
+
break
|
|
973
1417
|
}
|
|
974
1418
|
}
|
|
975
1419
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
}
|
|
1420
|
+
}
|
|
1421
|
+
if (!declared) continue
|
|
1422
|
+
const expected = FOLDER_TO_STATUS[state] || []
|
|
1423
|
+
if (!expected.some(e => e.toLowerCase() === declared.toLowerCase())) {
|
|
1424
|
+
warnings.push(`roadmap "${name}": folder is "${state}" but status declares "${declared}"`)
|
|
1425
|
+
}
|
|
982
1426
|
}
|
|
983
1427
|
}
|
|
984
1428
|
return warnings
|
|
@@ -992,12 +1436,7 @@ function validateFilenameUniqueness() {
|
|
|
992
1436
|
|
|
993
1437
|
const listErrors = []
|
|
994
1438
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
995
|
-
|
|
996
|
-
if (agents.length === 0) {
|
|
997
|
-
try { agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
998
|
-
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
999
|
-
}) } catch (_) { agents = [] }
|
|
1000
|
-
}
|
|
1439
|
+
const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
|
|
1001
1440
|
for (const agent of agents) {
|
|
1002
1441
|
for (const state of states) {
|
|
1003
1442
|
const dir = path.join(cfg.roadmapDir, agent, state)
|
|
@@ -1132,8 +1571,15 @@ function validateNoteOrphan(cwd) {
|
|
|
1132
1571
|
|
|
1133
1572
|
const indexPath = path.join(vaultDir, 'index.md')
|
|
1134
1573
|
let indexContent = ''
|
|
1135
|
-
|
|
1136
|
-
indexContent =
|
|
1574
|
+
try {
|
|
1575
|
+
indexContent = readRegularFileSync(indexPath).toString('utf8')
|
|
1576
|
+
} catch (err) {
|
|
1577
|
+
if (err.code !== 'ENOENT') {
|
|
1578
|
+
// index.md existe mas nao pode ser lido (permissao, FIFO...) -- diagnostica em vez de
|
|
1579
|
+
// tratar como indice vazio, que produziria um "not referenced" falso para toda nota
|
|
1580
|
+
// (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
|
|
1581
|
+
return [inspectionDiagnostic('note_orphan', indexPath, err)]
|
|
1582
|
+
}
|
|
1137
1583
|
}
|
|
1138
1584
|
|
|
1139
1585
|
const notes = fs.readdirSync(vaultDir).filter((f) => f.endsWith('.md') && f !== 'index.md')
|
|
@@ -1205,6 +1651,61 @@ const CREDENTIAL_GUARD_HOOK_FILES = [
|
|
|
1205
1651
|
// Qualquer valor que não bata em nenhuma das 3 formas retorna null — o chamador NÃO deve tratar
|
|
1206
1652
|
// isso como violação. Não é função desta regra adivinhar wiring próprio do usuário fora dos
|
|
1207
1653
|
// formatos que o trackfw gera.
|
|
1654
|
+
// pathIsAnchoredForHookConfig reporta se raw é um caminho ANCORADO para os fins de um comando de
|
|
1655
|
+
// hook lido de config de CLI de agente e executado por bash — não se é absoluto para o SO host.
|
|
1656
|
+
// Decisão: ADR-2026-09-04-caminho-posix-ancorado-num-config-lido-por-cli-de-agente-e-absoluto-
|
|
1657
|
+
// independente-do-so-host. Quem interpreta esse caminho é o bash (ou o próprio CLI de agente),
|
|
1658
|
+
// nunca o process.platform do runtime Node — por isso este predicado é a UNIÃO de duas formas,
|
|
1659
|
+
// nunca a definição de "absoluto" de um único SO:
|
|
1660
|
+
// - POSIX: prefixo "/" — ancorado em qualquer host, inclusive rodando em Windows via Git Bash.
|
|
1661
|
+
// - Windows: letra de unidade ("C:\..." / "C:/...") ou UNC ("\\servidor\share") — ancorado em
|
|
1662
|
+
// qualquer host, inclusive medido a partir de macOS/Linux (D1 da ADR: uma letra de unidade
|
|
1663
|
+
// também é ancorada, independente de onde o validator roda).
|
|
1664
|
+
//
|
|
1665
|
+
// 🔴 Requisito duro da ADR: ZERO chamada dependente de SO aqui — nada de path.isAbsolute,
|
|
1666
|
+
// path.sep, ou qualquer predicado do módulo "path" que responda com base no process.platform.
|
|
1667
|
+
// `path.isAbsolute("/opt/foo")` é `false` no Windows — essa é exatamente a lacuna que motivou
|
|
1668
|
+
// esta função. Invariante por construção, verificável por grep.
|
|
1669
|
+
//
|
|
1670
|
+
// D2 da ADR: este predicado NÃO substitui path.isAbsolute nos sítios de travessia real de
|
|
1671
|
+
// sistema de arquivos (ex.: linha 95 e npm/src/integrations/manager.js) — ali o SO É a
|
|
1672
|
+
// autoridade certa. Uso restrito aos sítios de classificação de config de hook.
|
|
1673
|
+
function pathIsAnchoredForHookConfig(raw) {
|
|
1674
|
+
if (!raw) return false
|
|
1675
|
+
if (raw[0] === '/') return true
|
|
1676
|
+
// UNC: \\servidor\share\... — exige um segmento de SERVIDOR não vazio e diferente de "." ou
|
|
1677
|
+
// ".." (não são hostname válido), seguido de um separador, seguido de um segmento de SHARE não
|
|
1678
|
+
// vazio que não comece com outra barra invertida (evita componente vazio quando há barra dupla
|
|
1679
|
+
// no meio). "\\" e "\\x" sozinhos (sem separador de share), "\\.\x" / "\\..\evil" (server "."
|
|
1680
|
+
// ou ".."), e "\\..\\evil" (barra dupla no meio produz share vazio) NÃO são UNC válido — são
|
|
1681
|
+
// POSIX cwd-dependent (barra invertida não é separador em POSIX), e aceitá-los como ancorado
|
|
1682
|
+
// seria o afrouxamento inverso que este predicado existe para evitar (ressalva do parecer
|
|
1683
|
+
// hades-tf de 2026-09-04 sobre o ML-3A; a notação exata do exemplo "\\..\\evil" no parecer é
|
|
1684
|
+
// ambígua entre 1 e 2 barras antes de "evil" — a checagem de server !== "."/".." fecha as duas
|
|
1685
|
+
// leituras, não só uma). A forma POSIX equivalente, "//servidor/share", já é coberta pelo braço
|
|
1686
|
+
// raw[0]==='/' acima — este braço cobre só a forma com barra invertida.
|
|
1687
|
+
if (raw.length >= 2 && raw[0] === '\\' && raw[1] === '\\') {
|
|
1688
|
+
const rest = raw.slice(2)
|
|
1689
|
+
const sepIdx = rest.indexOf('\\')
|
|
1690
|
+
if (sepIdx > 0) {
|
|
1691
|
+
const server = rest.slice(0, sepIdx)
|
|
1692
|
+
const share = rest.slice(sepIdx + 1)
|
|
1693
|
+
if (server !== '.' && server !== '..' && share.length > 0 && share[0] !== '\\') return true
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
// Letra de unidade: C:\... ou C:/...
|
|
1697
|
+
if (raw.length >= 3 && isASCIIDriveLetter(raw[0]) && raw[1] === ':' && (raw[2] === '\\' || raw[2] === '/')) {
|
|
1698
|
+
return true
|
|
1699
|
+
}
|
|
1700
|
+
return false
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// isASCIIDriveLetter reporta se ch é uma letra ASCII (a-z, A-Z) — reconhecimento de letra de
|
|
1704
|
+
// unidade do Windows em pathIsAnchoredForHookConfig.
|
|
1705
|
+
function isASCIIDriveLetter(ch) {
|
|
1706
|
+
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1208
1709
|
function resolveCredentialGuardHookPath(raw, root) {
|
|
1209
1710
|
const claudePrefix = '$CLAUDE_PROJECT_DIR/'
|
|
1210
1711
|
const geminiPrefix = '$GEMINI_PROJECT_DIR/'
|
|
@@ -1220,7 +1721,7 @@ function resolveCredentialGuardHookPath(raw, root) {
|
|
|
1220
1721
|
const inner = raw.slice(codexPrefix.length, raw.length - 1)
|
|
1221
1722
|
return path.join(root, inner)
|
|
1222
1723
|
}
|
|
1223
|
-
if (!raw.startsWith('$') && !raw.startsWith('"') && !
|
|
1724
|
+
if (!raw.startsWith('$') && !raw.startsWith('"') && !pathIsAnchoredForHookConfig(raw) && !raw.startsWith('~/')) {
|
|
1224
1725
|
// Caminho relativo puro — Cursor (beforeShellExecution/preToolUse), GitHub Copilot CLI
|
|
1225
1726
|
// (campo "bash"), Kiro (action.command).
|
|
1226
1727
|
// ~/… é excluído: é classe 1 (tilde expande para $HOME — ancorado) mas o validator não expande
|
|
@@ -1263,7 +1764,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
|
|
|
1263
1764
|
rawStripped.startsWith('$CLAUDE_PROJECT_DIR/') ||
|
|
1264
1765
|
rawStripped.startsWith('$GEMINI_PROJECT_DIR/') ||
|
|
1265
1766
|
rawStripped.startsWith('$(git rev-parse --show-toplevel)/') ||
|
|
1266
|
-
|
|
1767
|
+
pathIsAnchoredForHookConfig(rawStripped)
|
|
1267
1768
|
) {
|
|
1268
1769
|
return HOOK_ANCHORAGE_CLASS_ANCHORED
|
|
1269
1770
|
}
|
|
@@ -1279,7 +1780,7 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
|
|
|
1279
1780
|
rawStripped.startsWith('${PWD}/') ||
|
|
1280
1781
|
rawStripped.startsWith('./') ||
|
|
1281
1782
|
rawStripped.startsWith('../') ||
|
|
1282
|
-
(!rawStripped.startsWith('$') && !
|
|
1783
|
+
(!rawStripped.startsWith('$') && !pathIsAnchoredForHookConfig(rawStripped))
|
|
1283
1784
|
) {
|
|
1284
1785
|
return HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT
|
|
1285
1786
|
}
|
|
@@ -1288,13 +1789,28 @@ function classifyHookAnchorage(rawStripped, wasQuoted) {
|
|
|
1288
1789
|
}
|
|
1289
1790
|
|
|
1290
1791
|
// cwdDependentReason retorna o sufixo de mensagem específico por forma para violações de
|
|
1291
|
-
// classe 2, iniciando em "with a …".
|
|
1292
|
-
// posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD;
|
|
1293
|
-
// "
|
|
1792
|
+
// classe 2, iniciando em "with a …". Formatos: formas contendo $PWD ou ${PWD} (em qualquer
|
|
1793
|
+
// posição, inclusive em wrappers sh -c / env) recebem a mensagem do $PWD; "~/…" (D4 da
|
|
1794
|
+
// ADR-2026-09-04) recebe "quoted tilde path"; "~usuario/…" recebe "named-user tilde path";
|
|
1795
|
+
// demais recebem "bare relative path". Usa includes (não startsWith) para cobrir
|
|
1796
|
+
// sh -c "$PWD/…" e env FOO=x $PWD/….
|
|
1797
|
+
//
|
|
1798
|
+
// 🔴 D4 da ADR-2026-09-04: achado do ML-2B — "~usuario/" e "\"~/\"" caíam no catch-all e
|
|
1799
|
+
// recebiam "bare relative path", que não é o motivo real: ~user/ EXPANDE em POSIX (indecidível
|
|
1800
|
+
// sem executar shell, não relativo), e "~/" só falha por causa das ASPAS. O invariante que
|
|
1801
|
+
// permite reconhecer "~/" aqui sem receber wasQuoted como parâmetro: classifyHookAnchorage já
|
|
1802
|
+
// classifica "~/…" SEM aspas como classe 1 (ancorado) — logo todo "~/…" que chega aqui (classe
|
|
1803
|
+
// 2) só pode ter vindo de um valor originalmente citado; ver stripOuterQuotesForClassify.
|
|
1294
1804
|
function cwdDependentReason(rawStripped) {
|
|
1295
1805
|
if (rawStripped.includes('$PWD') || rawStripped.includes('${PWD}')) {
|
|
1296
1806
|
return 'with a $PWD path — $PWD expands to the current working directory, not the project root; run `trackfw update` to fix it'
|
|
1297
1807
|
}
|
|
1808
|
+
if (rawStripped.startsWith('~/')) {
|
|
1809
|
+
return 'with a quoted tilde path — double quotes prevent shell tilde expansion, so this never resolves to $HOME; run `trackfw update` to fix it'
|
|
1810
|
+
}
|
|
1811
|
+
if (rawStripped.startsWith('~')) {
|
|
1812
|
+
return "with a named-user tilde path — ~user/ expands to that user's home directory, not the project root, and this validator cannot resolve it without running a shell; run `trackfw update` to fix it"
|
|
1813
|
+
}
|
|
1298
1814
|
return "with a bare relative path — this command only resolves from the project root and will silently fail when the agent's cwd is a subdirectory; run `trackfw update` to fix it"
|
|
1299
1815
|
}
|
|
1300
1816
|
|
|
@@ -1364,31 +1880,96 @@ function collectCredentialGuardCommands(value, out) {
|
|
|
1364
1880
|
// - A regra só avalia entradas que EXISTEM. Ausência de entrada de guard é estado legítimo
|
|
1365
1881
|
// (guard global instalado via `trackfw update harness`) — nunca é violação por si só.
|
|
1366
1882
|
// - Arquivo de hook ausente é pulado em silêncio.
|
|
1367
|
-
// -
|
|
1368
|
-
// JSON
|
|
1883
|
+
// - ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1A:
|
|
1884
|
+
// arquivo de hook presente mas com JSON inválido deixou de ser pulado em silêncio — ver o
|
|
1885
|
+
// bloco catch do JSON.parse abaixo para a decisão e a medição que a sustentam.
|
|
1369
1886
|
//
|
|
1370
1887
|
// process.cwd() no Node já retorna o caminho FÍSICO (resolve symlinks via getcwd(3) diretamente),
|
|
1371
1888
|
// diferente de os.Getwd() do Go — ver o comentário sobre EvalSymlinks em
|
|
1372
1889
|
// internal/validator/validator_git_branch_guard.go's validateGuardHookResolvable equivalente
|
|
1373
1890
|
// (validator_credential_guard.go). Nenhuma resolução extra de symlink é necessária aqui.
|
|
1891
|
+
// isValidUtf8Buffer reports whether buf decodes losslessly as UTF-8, using a fatal TextDecoder
|
|
1892
|
+
// (throws instead of substituting U+FFFD). Used purely to CLASSIFY which message to emit when
|
|
1893
|
+
// JSON.parse goes on to fail below — never to gate whether parsing is attempted. buf.toString(
|
|
1894
|
+
// 'utf8') (the actual parse input) stays lossy on purpose, mirroring the coercion Go's
|
|
1895
|
+
// json.Unmarshal already does on raw bytes — ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-
|
|
1896
|
+
// silencio, ML-1B; see validateGuardHookResolvable's JSON.parse catch block for why.
|
|
1897
|
+
function isValidUtf8Buffer(buf) {
|
|
1898
|
+
try {
|
|
1899
|
+
new TextDecoder('utf-8', { fatal: true }).decode(buf)
|
|
1900
|
+
return true
|
|
1901
|
+
} catch (_) {
|
|
1902
|
+
return false
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1374
1906
|
function validateGuardHookResolvable(scriptMarker, cwd) {
|
|
1375
1907
|
const root = cwd || process.cwd()
|
|
1376
1908
|
const msgs = []
|
|
1377
1909
|
|
|
1378
1910
|
for (const hf of CREDENTIAL_GUARD_HOOK_FILES) {
|
|
1379
1911
|
const fullPath = path.join(root, hf.relPath)
|
|
1380
|
-
let
|
|
1912
|
+
let raw
|
|
1381
1913
|
try {
|
|
1382
|
-
|
|
1914
|
+
raw = readRegularFileSync(fullPath)
|
|
1383
1915
|
} catch (e) {
|
|
1384
1916
|
if (e.code === 'ENOENT') continue
|
|
1917
|
+
// ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: readRegularFileSync
|
|
1918
|
+
// replaces the bare fs.readFileSync — a FIFO at fullPath made fs.readFileSync block
|
|
1919
|
+
// INDEFINITELY (hades-tf's ML-1B barrier, `mkfifo .claude/settings.json`), which this branch
|
|
1920
|
+
// could never reach because the call never returned. readRegularFileSync's synthetic
|
|
1921
|
+
// 'ENOTFILE' error (never 'ENOENT') falls into this SAME accusing branch below.
|
|
1922
|
+
//
|
|
1923
|
+
// ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: was an unconditional
|
|
1924
|
+
// `continue` regardless of e.code — a dead `if (e.code === 'ENOENT') continue` above it,
|
|
1925
|
+
// then `continue` again for EVERY other error (EACCES, EISDIR, ELOOP...). hades-tf's ML-1A
|
|
1926
|
+
// barrier reproduced this live (chmod 000, and a directory in place of the file): `trackfw
|
|
1927
|
+
// validate --json` exited 0, no violation, no mention of the file — success reported about
|
|
1928
|
+
// wiring the rule never actually inspected, the exact defect this ML exists to close, for a
|
|
1929
|
+
// different `continue` than the JSON-parse one ML-1A already fixed. Same remedy as the
|
|
1930
|
+
// invalid-JSON branch below.
|
|
1931
|
+
msgs.push(
|
|
1932
|
+
`${hf.relPath} (${hf.cli}) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
1933
|
+
`wired here, and ${hf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
|
|
1934
|
+
'`trackfw update` to regenerate it'
|
|
1935
|
+
)
|
|
1385
1936
|
continue
|
|
1386
1937
|
}
|
|
1387
1938
|
|
|
1939
|
+
// ML-1B: content is the SAME lossy decode this function has always used (invalid bytes become
|
|
1940
|
+
// U+FFFD, never throws) — wasValidUtf8 only classifies which message to emit if JSON.parse
|
|
1941
|
+
// fails below; it never changes whether parsing is attempted.
|
|
1942
|
+
const wasValidUtf8 = isValidUtf8Buffer(raw)
|
|
1943
|
+
const content = raw.toString('utf8')
|
|
1944
|
+
|
|
1388
1945
|
let parsed
|
|
1389
1946
|
try {
|
|
1390
1947
|
parsed = JSON.parse(content)
|
|
1391
1948
|
} catch (_) {
|
|
1949
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
|
|
1950
|
+
// ML-1A: was a silent `continue` (fail-open) — port of Go's validateGuardHookResolvable
|
|
1951
|
+
// (internal/validator/validator_credential_guard.go) — see that function's json.Unmarshal
|
|
1952
|
+
// error branch for the full decision and measurement. CREDENTIAL_GUARD_HOOK_FILES is a
|
|
1953
|
+
// closed, enumerable list (6 entries) trackfw itself owns/merges into; invalid JSON in any
|
|
1954
|
+
// of them is never a legitimate state, since hf.cli itself requires strict JSON to load its
|
|
1955
|
+
// own hooks from that file.
|
|
1956
|
+
//
|
|
1957
|
+
// ML-1B: not-valid-UTF-8 gets its own message (D4 of ADR-2026-09-04 — classify right,
|
|
1958
|
+
// explain right, or the reader investigates the wrong thing; e.g. a file saved as UTF-16
|
|
1959
|
+
// looks fine in the user's editor).
|
|
1960
|
+
if (!wasValidUtf8) {
|
|
1961
|
+
msgs.push(
|
|
1962
|
+
`${hf.relPath} (${hf.cli}) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
1963
|
+
`wired here, and ${hf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
|
|
1964
|
+
'encoding, or run `trackfw update` to regenerate it'
|
|
1965
|
+
)
|
|
1966
|
+
continue
|
|
1967
|
+
}
|
|
1968
|
+
msgs.push(
|
|
1969
|
+
`${hf.relPath} (${hf.cli}) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
1970
|
+
`wired here, and ${hf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
|
|
1971
|
+
'`trackfw update` to regenerate it'
|
|
1972
|
+
)
|
|
1392
1973
|
continue
|
|
1393
1974
|
}
|
|
1394
1975
|
|
|
@@ -1435,7 +2016,7 @@ function validateGuardHookResolvable(scriptMarker, cwd) {
|
|
|
1435
2016
|
|
|
1436
2017
|
if (!stat) {
|
|
1437
2018
|
msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script does not exist — run \`trackfw update\` to regenerate it`)
|
|
1438
|
-
} else if ((stat.mode & 0o111) === 0) {
|
|
2019
|
+
} else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
|
|
1439
2020
|
msgs.push(`${hf.relPath} (${hf.cli}) references ${scriptMarker} resolved to "${resolved}", but the script is not executable — run \`trackfw update\` to regenerate it`)
|
|
1440
2021
|
}
|
|
1441
2022
|
}
|
|
@@ -1644,10 +2225,29 @@ function validateCredentialGuardScriptIntegrity(cwd) {
|
|
|
1644
2225
|
const relPath = 'scripts/trackfw-credential-guard.sh'
|
|
1645
2226
|
let content
|
|
1646
2227
|
try {
|
|
1647
|
-
|
|
2228
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
2229
|
+
// readRegularFileSync (defined above) replaces the bare fs.readFileSync — a FIFO at this path
|
|
2230
|
+
// made fs.readFileSync block INDEFINITELY (hades-tf's ML-1B barrier), a hang this catch block
|
|
2231
|
+
// could never reach because the read never returned. The non-ENOENT accusing branch below
|
|
2232
|
+
// (already correct pre-ML-1C, unlike its Go/Python siblings) needed no change for this fix.
|
|
2233
|
+
content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
|
|
1648
2234
|
} catch (e) {
|
|
1649
2235
|
if (e.code === 'ENOENT') return []
|
|
1650
|
-
|
|
2236
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
2237
|
+
// was `[inspectionDiagnostic(...)]` — correct in SUBSTANCE before this ML (already a
|
|
2238
|
+
// violation, not a silence, unlike Go which aborted and Python which silenced), but its
|
|
2239
|
+
// 'rule: could not inspect "target": cause' shape put this runtime's message text out of
|
|
2240
|
+
// parity with Go/Python's "could not be read" phrasing FOR THIS SPECIFIC RULE FAMILY once all
|
|
2241
|
+
// 3 runtimes actually produce a message here — a divergence that had no observable effect
|
|
2242
|
+
// before ML-1C (Go/Python never reached this branch at all), but would become a real 3-CLI
|
|
2243
|
+
// text mismatch the moment all 3 runtimes accuse on the same fixture. Unified to the same
|
|
2244
|
+
// fixed text validateGuardHookResolvable / validateGuardGlobalScriptIntegrity already use,
|
|
2245
|
+
// matching Go's identical fix in validateCredentialGuardScriptIntegrity
|
|
2246
|
+
// (validator_credential_guard_integrity.go).
|
|
2247
|
+
return [
|
|
2248
|
+
`${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
|
|
2249
|
+
'template it should; fix the file, or run `trackfw update` to regenerate it',
|
|
2250
|
+
]
|
|
1651
2251
|
}
|
|
1652
2252
|
|
|
1653
2253
|
if (content === CREDENTIAL_GUARD_SCRIPT_REFERENCE) return []
|
|
@@ -1716,12 +2316,31 @@ set -f
|
|
|
1716
2316
|
# --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
|
|
1717
2317
|
# global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
|
|
1718
2318
|
# quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
|
|
1719
|
-
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing.
|
|
1720
|
-
#
|
|
1721
|
-
#
|
|
1722
|
-
#
|
|
2319
|
+
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
|
|
2320
|
+
# roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
|
|
2321
|
+
#
|
|
2322
|
+
# ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
|
|
2323
|
+
# clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
|
|
2324
|
+
# "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
|
|
2325
|
+
# (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
|
|
2326
|
+
# limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
|
|
2327
|
+
# guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
|
|
2328
|
+
# "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
|
|
2329
|
+
# idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
|
|
2330
|
+
# do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
|
|
2331
|
+
# existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
|
|
2332
|
+
# nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
|
|
2333
|
+
# FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
|
|
2334
|
+
# timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
|
|
2335
|
+
# para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
|
|
2336
|
+
# ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
|
|
2337
|
+
# o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
|
|
2338
|
+
# posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
|
|
2339
|
+
# continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
|
|
2340
|
+
# total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
|
|
2341
|
+
# elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
|
|
1723
2342
|
_TRACKFW_STDIN=""
|
|
1724
|
-
|
|
2343
|
+
IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
|
|
1725
2344
|
|
|
1726
2345
|
# --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
|
|
1727
2346
|
# projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
|
|
@@ -2251,7 +2870,7 @@ case "$SUBCOMMAND" in
|
|
|
2251
2870
|
;;
|
|
2252
2871
|
esac
|
|
2253
2872
|
|
|
2254
|
-
printf '{"
|
|
2873
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
|
|
2255
2874
|
echo "$REASON" >&2
|
|
2256
2875
|
exit 2
|
|
2257
2876
|
`
|
|
@@ -2265,10 +2884,19 @@ function validateGitBranchGuardScriptIntegrity(cwd) {
|
|
|
2265
2884
|
const relPath = 'scripts/trackfw-git-branch-guard.sh'
|
|
2266
2885
|
let content
|
|
2267
2886
|
try {
|
|
2268
|
-
|
|
2887
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
2888
|
+
// same readRegularFileSync substitution as validateCredentialGuardScriptIntegrity's identical
|
|
2889
|
+
// comment above — closes the FIFO hang; the accusing branch below needed no change.
|
|
2890
|
+
content = readRegularFileSync(path.join(root, relPath)).toString('utf8')
|
|
2269
2891
|
} catch (e) {
|
|
2270
2892
|
if (e.code === 'ENOENT') return []
|
|
2271
|
-
|
|
2893
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
2894
|
+
// same message unification as validateCredentialGuardScriptIntegrity's identical comment
|
|
2895
|
+
// above — text parity with Go/Python for this rule family.
|
|
2896
|
+
return [
|
|
2897
|
+
`${relPath} could not be read — trackfw cannot tell whether this script matches the ` +
|
|
2898
|
+
'template it should; fix the file, or run `trackfw update` to regenerate it',
|
|
2899
|
+
]
|
|
2272
2900
|
}
|
|
2273
2901
|
|
|
2274
2902
|
if (content === GIT_BRANCH_GUARD_SCRIPT_REFERENCE) return []
|
|
@@ -2486,28 +3114,68 @@ function globalGuardConfigPath(gf, scriptMarker) {
|
|
|
2486
3114
|
// prefix-stripping is needed here: any matched command that is NOT already an absolute path is
|
|
2487
3115
|
// skipped (never treated as a violation).
|
|
2488
3116
|
//
|
|
2489
|
-
// Fail-open: unresolvable $HOME
|
|
2490
|
-
//
|
|
3117
|
+
// Fail-open: unresolvable $HOME and unreadable file skip that file in silence — legitimate states.
|
|
3118
|
+
// Invalid JSON no longer does — see ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-
|
|
3119
|
+
// deixa-de-ser-silencio, ML-1A, and the JSON.parse catch block below for the decision and measurement.
|
|
2491
3120
|
function validateGuardGlobalHookResolvable(scriptMarker) {
|
|
2492
|
-
const home =
|
|
3121
|
+
const home = homedir()
|
|
2493
3122
|
if (!home) return []
|
|
2494
3123
|
|
|
2495
3124
|
const msgs = []
|
|
2496
3125
|
for (const gf of GLOBAL_GUARD_CONFIG_FILES) {
|
|
2497
3126
|
const relPath = globalGuardConfigPath(gf, scriptMarker)
|
|
2498
3127
|
const fullPath = path.join(home, relPath)
|
|
2499
|
-
let
|
|
3128
|
+
let raw
|
|
2500
3129
|
try {
|
|
2501
|
-
|
|
3130
|
+
raw = readRegularFileSync(fullPath)
|
|
2502
3131
|
} catch (e) {
|
|
2503
3132
|
if (e.code === 'ENOENT') continue
|
|
3133
|
+
// ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1C: same
|
|
3134
|
+
// readRegularFileSync substitution as the project-scope sibling — see that function's
|
|
3135
|
+
// identical comment for the FIFO hang this closes.
|
|
3136
|
+
//
|
|
3137
|
+
// ROADMAP-2026-09-06-...-config-ilegivel-deixa-de-ser-silencio, ML-1B: same decision as the
|
|
3138
|
+
// project-scope sibling (validateGuardHookResolvable, above) — was an unconditional
|
|
3139
|
+
// `continue` for every non-ENOENT read error. See that function's read-error catch branch
|
|
3140
|
+
// for the full measurement (hades-tf ML-1A barrier, reproduced live).
|
|
3141
|
+
msgs.push(
|
|
3142
|
+
`~/${relPath} (${gf.cli}, global scope) could not be read — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
3143
|
+
`wired here, and ${gf.cli} cannot load any hook from a file it cannot read; fix the file, or run ` +
|
|
3144
|
+
'`trackfw update harness` to regenerate it'
|
|
3145
|
+
)
|
|
2504
3146
|
continue
|
|
2505
3147
|
}
|
|
2506
3148
|
|
|
3149
|
+
// ML-1B: see validateGuardHookResolvable's identical comment above for why decoding, not just
|
|
3150
|
+
// parsing, must be classified separately here.
|
|
3151
|
+
const wasValidUtf8 = isValidUtf8Buffer(raw)
|
|
3152
|
+
const content = raw.toString('utf8')
|
|
3153
|
+
|
|
2507
3154
|
let parsed
|
|
2508
3155
|
try {
|
|
2509
3156
|
parsed = JSON.parse(content)
|
|
2510
3157
|
} catch (_) {
|
|
3158
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio,
|
|
3159
|
+
// ML-1A: same decision as the project-scope sibling (validateGuardHookResolvable, above) —
|
|
3160
|
+
// see that function's json.parse error branch for the full measurement. Applies identically
|
|
3161
|
+
// here: GLOBAL_GUARD_CONFIG_FILES is the same kind of closed, enumerable list (6 entries),
|
|
3162
|
+
// each owned/consumed by gf.cli itself, so invalid JSON is never a legitimate state.
|
|
3163
|
+
//
|
|
3164
|
+
// ML-1B: not-valid-UTF-8 gets its own message — see validateGuardHookResolvable's identical
|
|
3165
|
+
// branch above for the reasoning.
|
|
3166
|
+
if (!wasValidUtf8) {
|
|
3167
|
+
msgs.push(
|
|
3168
|
+
`~/${relPath} (${gf.cli}, global scope) is not valid UTF-8 — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
3169
|
+
`wired here, and ${gf.cli} cannot load any hook from a file it cannot decode; fix the file's ` +
|
|
3170
|
+
'encoding, or run `trackfw update harness` to regenerate it'
|
|
3171
|
+
)
|
|
3172
|
+
continue
|
|
3173
|
+
}
|
|
3174
|
+
msgs.push(
|
|
3175
|
+
`~/${relPath} (${gf.cli}, global scope) is not valid JSON — trackfw cannot tell whether ${scriptMarker} is ` +
|
|
3176
|
+
`wired here, and ${gf.cli} cannot load any hook from a file it cannot parse; fix the file, or run ` +
|
|
3177
|
+
'`trackfw update harness` to regenerate it'
|
|
3178
|
+
)
|
|
2511
3179
|
continue
|
|
2512
3180
|
}
|
|
2513
3181
|
|
|
@@ -2520,7 +3188,10 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
|
|
|
2520
3188
|
if (seen.has(seenKey)) continue
|
|
2521
3189
|
seen.add(seenKey)
|
|
2522
3190
|
|
|
2523
|
-
|
|
3191
|
+
// ADR-2026-09-04-caminho-posix-ancorado-...: pathIsAnchoredForHookConfig classifica por
|
|
3192
|
+
// ancoragem (POSIX "/", letra de unidade, UNC), NÃO por path.isAbsolute — que no Windows
|
|
3193
|
+
// devolve false para "/opt/foo/guard.sh" e faria este `continue` pular a entrada inteira.
|
|
3194
|
+
if (!pathIsAnchoredForHookConfig(m.raw)) continue
|
|
2524
3195
|
|
|
2525
3196
|
// ROADMAP-2026-08-17 ML-4B: reproduced by hades-tf (ML-4A barrier) — a global config entry
|
|
2526
3197
|
// with the correct absolute command but missing/wrong "type" (hand-edited, an older trackfw
|
|
@@ -2541,7 +3212,7 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
|
|
|
2541
3212
|
|
|
2542
3213
|
if (!stat) {
|
|
2543
3214
|
msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script does not exist — run \`trackfw update harness\` to regenerate it`)
|
|
2544
|
-
} else if ((stat.mode & 0o111) === 0) {
|
|
3215
|
+
} else if (_platform !== 'win32' && (stat.mode & 0o111) === 0) {
|
|
2545
3216
|
msgs.push(`~/${relPath} (${gf.cli}, global scope) references ${scriptMarker} resolved to "${m.raw}", but the script is not executable — run \`trackfw update harness\` to regenerate it`)
|
|
2546
3217
|
}
|
|
2547
3218
|
}
|
|
@@ -2574,17 +3245,33 @@ function validateGuardGlobalHookResolvable(scriptMarker) {
|
|
|
2574
3245
|
// scriptMarker doubles as the script's own filename (trackfw-credential-guard.sh /
|
|
2575
3246
|
// trackfw-git-branch-guard.sh) in both call sites below — same equivalence the Go port relies on.
|
|
2576
3247
|
function validateGuardGlobalScriptIntegrity(scriptFileName, referenceContent) {
|
|
2577
|
-
const home =
|
|
3248
|
+
const home = homedir()
|
|
2578
3249
|
if (!home) return []
|
|
2579
3250
|
|
|
2580
3251
|
const scriptPath = path.join(home, '.trackfw', 'scripts', scriptFileName)
|
|
2581
3252
|
let content
|
|
2582
3253
|
try {
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
3254
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
3255
|
+
// readRegularFileSync also closes the FIFO hang for this path (see its doc comment above).
|
|
3256
|
+
content = readRegularFileSync(scriptPath).toString('utf8')
|
|
3257
|
+
} catch (e) {
|
|
3258
|
+
if (e.code === 'ENOENT') {
|
|
3259
|
+
// Not installed is not a violation — same contract as every other *_script_integrity check
|
|
3260
|
+
// (project-scope and global) in this file.
|
|
3261
|
+
return []
|
|
3262
|
+
}
|
|
3263
|
+
// ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1C:
|
|
3264
|
+
// was an UNCONDITIONAL `return []` on ANY read error (bare `catch (_)`) — hades-tf's ML-1B
|
|
3265
|
+
// barrier found this silenced EACCES/EISDIR/ELOOP exactly like the pre-ML-1B config-file
|
|
3266
|
+
// `continue` did (fail-open: validate reports health about an artifact it never actually
|
|
3267
|
+
// inspected). Distinguished from absence by e.code, mirroring every other read-error branch in
|
|
3268
|
+
// this file. Message shape mirrors Go's validateGuardGlobalScriptIntegrity — not
|
|
3269
|
+
// inspectionDiagnostic's shape, since this function serves both credential-guard and
|
|
3270
|
+
// git-branch-guard callers and has no rule name of its own to embed.
|
|
3271
|
+
return [
|
|
3272
|
+
`${scriptPath} (global scope) could not be read — trackfw cannot tell whether this script ` +
|
|
3273
|
+
'matches the template it should; fix the file, or run `trackfw update harness` to regenerate it',
|
|
3274
|
+
]
|
|
2588
3275
|
}
|
|
2589
3276
|
|
|
2590
3277
|
if (content === referenceContent) return []
|
|
@@ -2686,10 +3373,15 @@ function validateCredentialGuardModeDowngrade(cwd) {
|
|
|
2686
3373
|
|
|
2687
3374
|
let diskContent
|
|
2688
3375
|
try {
|
|
2689
|
-
diskContent =
|
|
3376
|
+
diskContent = readRegularFileSync(path.join(root, 'trackfw.yaml')).toString('utf8')
|
|
2690
3377
|
} catch (e) {
|
|
2691
3378
|
if (e.code === 'ENOENT') return [credentialGuardModeDowngradeMessage()]
|
|
2692
|
-
|
|
3379
|
+
// Fixed-text message, NOT inspectionDiagnostic — e.message (e.g. "EACCES: permission denied,
|
|
3380
|
+
// open 'trackfw.yaml'") diverges from Go's %v and Python's str(e) for the exact same failure;
|
|
3381
|
+
// every sibling "could not be read" message in this guard family avoids raw OS error text for
|
|
3382
|
+
// the same reason (ROADMAP-2026-09-06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-
|
|
3383
|
+
// silencio, ML-1G).
|
|
3384
|
+
return [credentialGuardModeDowngradeReadFailureMessage()]
|
|
2693
3385
|
}
|
|
2694
3386
|
|
|
2695
3387
|
const diskMode = extractCredentialGuardMode(diskContent)
|
|
@@ -2704,6 +3396,13 @@ function credentialGuardModeDowngradeMessage() {
|
|
|
2704
3396
|
'otherwise investigate before treating the credential guard as active'
|
|
2705
3397
|
}
|
|
2706
3398
|
|
|
3399
|
+
// Fixed-text remedy for a non-ENOENT read failure on trackfw.yaml when HEAD had mode: block — see
|
|
3400
|
+
// its call site for why this avoids inspectionDiagnostic's raw OS error text.
|
|
3401
|
+
function credentialGuardModeDowngradeReadFailureMessage() {
|
|
3402
|
+
return 'trackfw.yaml could not be read — trackfw cannot tell whether credential_guard.mode ' +
|
|
3403
|
+
'is still block; fix the file, or run `trackfw update` to regenerate it'
|
|
3404
|
+
}
|
|
3405
|
+
|
|
2707
3406
|
// _itemMeta: mapa de message → { rule, file } para enriquecer saída JSON.
|
|
2708
3407
|
// Populado em applyRule e nos pushs diretos do validateUnfiltered.
|
|
2709
3408
|
// Permanece em memória apenas durante a execução de uma chamada validate*.
|
|
@@ -2740,6 +3439,11 @@ const RULE_DEFAULTS = {
|
|
|
2740
3439
|
// git_branch_guard_hook_resolvable is deliberately absent from this map (falls through to
|
|
2741
3440
|
// 'error'), mirroring credential_guard_hook_resolvable.
|
|
2742
3441
|
git_branch_guard_script_integrity: 'warning',
|
|
3442
|
+
// ML-4A (REQ-2026-08-29, achado 1 do parecer hades-tf 2026-08-30): namespace oculto/ambíguo (nome
|
|
3443
|
+
// iniciado por ".") é sinal de baixo ruído por natureza, não um defeito de configuração como
|
|
3444
|
+
// agent_namespace_undeclared ('error'). Nunca 'off' por default — silêncio total é o defeito que
|
|
3445
|
+
// esta REQ existe para fechar.
|
|
3446
|
+
agent_namespace_hidden: 'warning',
|
|
2743
3447
|
}
|
|
2744
3448
|
|
|
2745
3449
|
// ROADMAP-2026-08-12-ancorar-rules-no-head-para-as-regras-de-credential-guard, ML-1A.
|
|
@@ -2841,7 +3545,7 @@ const BASELINE_FILE = '.trackfw-baseline.json'
|
|
|
2841
3545
|
// Retorna null se o arquivo não existir.
|
|
2842
3546
|
function loadBaseline() {
|
|
2843
3547
|
try {
|
|
2844
|
-
const data =
|
|
3548
|
+
const data = readRegularFileSync(BASELINE_FILE).toString('utf8')
|
|
2845
3549
|
return JSON.parse(data)
|
|
2846
3550
|
} catch (e) {
|
|
2847
3551
|
if (e.code === 'ENOENT') return null
|
|
@@ -2898,11 +3602,16 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
|
|
|
2898
3602
|
const manifestPath = path.join(root, '.trackfw', 'integrations-manifest.json')
|
|
2899
3603
|
let manifest
|
|
2900
3604
|
try {
|
|
2901
|
-
const raw =
|
|
3605
|
+
const raw = readRegularFileSync(manifestPath).toString('utf8')
|
|
2902
3606
|
manifest = JSON.parse(raw)
|
|
2903
3607
|
} catch (err) {
|
|
2904
3608
|
if (err.code === 'ENOENT') return []
|
|
2905
|
-
|
|
3609
|
+
// Used to `throw` here — an unreadable/corrupt manifest crashed the whole `trackfw validate`
|
|
3610
|
+
// process instead of reporting a diagnostic for this one rule and letting every other rule
|
|
3611
|
+
// still run. Same defect class ML-1C fixed for *_script_integrity, found here by measurement
|
|
3612
|
+
// outside the guard family, in parity with the Go fix at the same call site (ROADMAP-2026-09-
|
|
3613
|
+
// 06-fecha-o-fail-open-do-guard-config-ilegivel-deixa-de-ser-silencio, ML-1G).
|
|
3614
|
+
return [inspectionDiagnostic('thirdparty_artifact_has_provenance', manifestPath, err)]
|
|
2906
3615
|
}
|
|
2907
3616
|
|
|
2908
3617
|
const destinations = []
|
|
@@ -2933,7 +3642,18 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
|
|
|
2933
3642
|
// instead, which this rule intentionally never reads). path.relative
|
|
2934
3643
|
// inverts resolve()'s path.resolve(root, relative) exactly. Mirrors
|
|
2935
3644
|
// internal/validator/validator_thirdparty_provenance.go.
|
|
2936
|
-
|
|
3645
|
+
//
|
|
3646
|
+
// ML-2A (ADR-2026-09-04, D1 categoria 2 — "chave de dicionario ou identificador"):
|
|
3647
|
+
// path.relative devolve separador NATIVO, e a chave gravada em
|
|
3648
|
+
// thirdparty-provenance.json e sempre "/" (resolveThirdPartySkillDestination monta
|
|
3649
|
+
// o destino por concatenacao explicita com "/", nos 3 runtimes). Sem normalizar, no
|
|
3650
|
+
// Windows a chave nunca casa e TODO artefato de terceiro e reportado como sem
|
|
3651
|
+
// entrada — falso positivo em massa. Go (validator_thirdparty_provenance.go:160) e
|
|
3652
|
+
// Python (validator.py:3530) ja normalizavam; o Node era a divergencia (D4).
|
|
3653
|
+
//
|
|
3654
|
+
// 🔴 So a CHAVE DERIVADA e normalizada. `destination` continua cru e e o que aparece
|
|
3655
|
+
// nas mensagens e no manifest; nada aqui altera caminho passado a syscall (ADR D2).
|
|
3656
|
+
const provenanceKey = normalizeRefSeparator(path.relative(root, destination))
|
|
2937
3657
|
const entry = (prov.entries || {})[provenanceKey]
|
|
2938
3658
|
if (!entry) {
|
|
2939
3659
|
msgs.push(
|
|
@@ -2946,7 +3666,7 @@ function validateThirdPartyArtifactHasProvenance(cwd) {
|
|
|
2946
3666
|
|
|
2947
3667
|
let installed
|
|
2948
3668
|
try {
|
|
2949
|
-
installed =
|
|
3669
|
+
installed = readRegularFileSync(destination)
|
|
2950
3670
|
} catch (err) {
|
|
2951
3671
|
msgs.push(
|
|
2952
3672
|
`thirdparty_artifact_has_provenance: "${destination}" is claimed as a third-party artifact with an ` +
|
|
@@ -3027,6 +3747,15 @@ async function validateUnfiltered() {
|
|
|
3027
3747
|
|
|
3028
3748
|
// Verificação bidirecional de trace ID (somente se traceIdField configurado)
|
|
3029
3749
|
const cfg = config.load()
|
|
3750
|
+
|
|
3751
|
+
// ML-2A (REQ-2026-08-29): namespace de agente em disco e não declarado em agents: — violação, não
|
|
3752
|
+
// aviso (ver comentário em validateAgentNamespaceUndeclared).
|
|
3753
|
+
applyRule('agent_namespace_undeclared', validateAgentNamespaceUndeclared(cfg), violations, warnings)
|
|
3754
|
+
|
|
3755
|
+
// ML-4A (achado 1, hades-tf 2026-08-30): contraponto de baixo ruído para nomes ocultos/ambíguos
|
|
3756
|
+
// (iniciados por ".") — aviso, nunca silêncio total, nunca erro (RULE_DEFAULTS abaixo).
|
|
3757
|
+
applyRule('agent_namespace_hidden', hiddenNamespaceWarnings(cfg), violations, warnings)
|
|
3758
|
+
|
|
3030
3759
|
if (cfg.traceIdField) {
|
|
3031
3760
|
for (const msg of checkTraceIds(cfg.reqDir, cfg.roadmapDir, cfg.traceIdField)) {
|
|
3032
3761
|
// O prefixo da mensagem traceid já carrega o nome da regra (ex: "traceid_orphan_roadmap: ...")
|
|
@@ -3090,16 +3819,18 @@ function buildInventorySection(cfg) {
|
|
|
3090
3819
|
let reqOpen = 0
|
|
3091
3820
|
let reqDone = 0
|
|
3092
3821
|
let reqClosed = 0
|
|
3822
|
+
let reqOther = 0
|
|
3093
3823
|
for (const filePath of reqFiles) {
|
|
3094
3824
|
let content
|
|
3095
3825
|
try {
|
|
3096
|
-
content =
|
|
3826
|
+
content = readRegularFileSync(filePath).toString('utf8')
|
|
3097
3827
|
} catch (_) {
|
|
3098
3828
|
continue
|
|
3099
3829
|
}
|
|
3100
3830
|
if (reqStatusEquals(content, 'open')) reqOpen++
|
|
3101
3831
|
else if (reqStatusEquals(content, 'done')) reqDone++
|
|
3102
3832
|
else if (reqStatusEquals(content, 'closed')) reqClosed++
|
|
3833
|
+
else reqOther++
|
|
3103
3834
|
}
|
|
3104
3835
|
|
|
3105
3836
|
const roadmapCounts = {}
|
|
@@ -3115,7 +3846,14 @@ function buildInventorySection(cfg) {
|
|
|
3115
3846
|
|
|
3116
3847
|
let section = '\n📊 Inventory\n'
|
|
3117
3848
|
section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
|
|
3118
|
-
|
|
3849
|
+
// Toda grafia fora de open/done/closed cai em reqOther. Sem este bucket o total
|
|
3850
|
+
// bate e a quebra some com a diferenca EM SILENCIO: um acervo com
|
|
3851
|
+
// approved/backlog/abandoned mostra "53 (8 Open · 36 Done · 0 Closed)" sem
|
|
3852
|
+
// indicar que 9 existem e nao estao em lugar nenhum da conta.
|
|
3853
|
+
// O Python ja fazia isto (pypi/trackfw/commands/status.py:58,199).
|
|
3854
|
+
let reqDetail = `${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed`
|
|
3855
|
+
if (reqOther > 0) reqDetail += ` · ${reqOther} Other`
|
|
3856
|
+
section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqDetail})\n`
|
|
3119
3857
|
section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
|
|
3120
3858
|
section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
|
|
3121
3859
|
section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
|
|
@@ -3129,14 +3867,7 @@ async function getStatus() {
|
|
|
3129
3867
|
out += buildInventorySection(cfg)
|
|
3130
3868
|
|
|
3131
3869
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
3132
|
-
|
|
3133
|
-
if (agents.length === 0) {
|
|
3134
|
-
try {
|
|
3135
|
-
agents = fs.readdirSync(cfg.roadmapDir).filter(f => {
|
|
3136
|
-
try { return fs.statSync(path.join(cfg.roadmapDir, f)).isDirectory() } catch (_) { return false }
|
|
3137
|
-
})
|
|
3138
|
-
} catch (_) { agents = [] }
|
|
3139
|
-
}
|
|
3870
|
+
const agents = resolveAgentNamespaces(cfg, cfg.roadmapDir)
|
|
3140
3871
|
out += '\n⚙ WIP by Agent\n'
|
|
3141
3872
|
for (const agent of agents) {
|
|
3142
3873
|
const wip = listDir(cfg.roadmapDir + '/' + agent + '/wip')
|
|
@@ -3205,6 +3936,7 @@ async function getStatus() {
|
|
|
3205
3936
|
}
|
|
3206
3937
|
|
|
3207
3938
|
module.exports = {
|
|
3939
|
+
_setPlatformForTest,
|
|
3208
3940
|
validate,
|
|
3209
3941
|
validateUnfiltered,
|
|
3210
3942
|
loadBaseline,
|
|
@@ -3229,9 +3961,11 @@ module.exports = {
|
|
|
3229
3961
|
listDir,
|
|
3230
3962
|
tryListDir,
|
|
3231
3963
|
resolveReqFiles,
|
|
3964
|
+
reqWriteDir,
|
|
3232
3965
|
resolveStateDirs,
|
|
3233
3966
|
resolveWIPDirs,
|
|
3234
3967
|
resolveDoneDirs,
|
|
3968
|
+
resolveAgentNamespaces,
|
|
3235
3969
|
parseSquadFromFrontmatter,
|
|
3236
3970
|
validateFrontmatterPresence,
|
|
3237
3971
|
// novas funções ML-1B
|
|
@@ -3251,6 +3985,7 @@ module.exports = {
|
|
|
3251
3985
|
normalizeBranchSlug,
|
|
3252
3986
|
// novas funções ML-2B
|
|
3253
3987
|
contentHasMarker,
|
|
3988
|
+
contentHasMarkerValue,
|
|
3254
3989
|
ruleSeverity,
|
|
3255
3990
|
applyRule,
|
|
3256
3991
|
// novas funções ML-1B (v2.5.1)
|
|
@@ -3270,6 +4005,8 @@ module.exports = {
|
|
|
3270
4005
|
resolveAdrStatus,
|
|
3271
4006
|
// ROADMAP-2026-08-12-mitigacao-do-fail-open-do-credential-guard, ML-1A
|
|
3272
4007
|
resolveCredentialGuardHookPath,
|
|
4008
|
+
// ADR-2026-09-04 (ML-3A): predicado de ancoragem invariante por GOOS
|
|
4009
|
+
pathIsAnchoredForHookConfig,
|
|
3273
4010
|
// ADR-2026-08-22 (ML-1A): classificação por ancoragem
|
|
3274
4011
|
HOOK_ANCHORAGE_CLASS_ANCHORED,
|
|
3275
4012
|
HOOK_ANCHORAGE_CLASS_CWD_DEPENDENT,
|