trackfw 7.4.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/package.json +1 -1
- package/src/generators/hooks.js +31 -9
- package/src/serve/api_chain.js +31 -4
- package/src/validator/index.js +169 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.5.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
package/src/generators/hooks.js
CHANGED
|
@@ -428,9 +428,12 @@ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL
|
|
|
428
428
|
// Byte-identical port of internal/generators/scaffold.go:gitBranchGuardScript
|
|
429
429
|
// (Go's canonical reference). Blocks raw `git commit`/`git push`/
|
|
430
430
|
// `git checkout -b` by a subagent, regardless of runtime contract: emits BOTH
|
|
431
|
-
// `{"
|
|
432
|
-
//
|
|
433
|
-
//
|
|
431
|
+
// `{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":
|
|
432
|
+
// "deny","permissionDecisionReason":"..."}}` on stdout (Claude/Gemini
|
|
433
|
+
// JSON-stdout style — UPDATED 2026-09-09, see the Go const's doc comment for
|
|
434
|
+
// why the previous `{"decision":"block",...}` shape stopped validating) AND
|
|
435
|
+
// `exit 2` (Codex/Windsurf/Cursor exit-code style) simultaneously, same
|
|
436
|
+
// simplification decision as the Go const's doc comment. Unlike
|
|
434
437
|
// CREDENTIAL_GUARD_SCRIPT, this script is IDENTICAL between project and
|
|
435
438
|
// global scope (no trackfw.yaml dependency, no mode/roadmap_dir resolution)
|
|
436
439
|
// — a single constant is written by both generateGitBranchGuardScript and
|
|
@@ -495,12 +498,31 @@ set -f
|
|
|
495
498
|
# --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
|
|
496
499
|
# global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
|
|
497
500
|
# quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
|
|
498
|
-
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing.
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
#
|
|
501
|
+
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing. O dreno
|
|
502
|
+
# roda incondicionalmente, com orçamento de tempo (ML-3A, ver abaixo). O valor lido é reaproveitado no passo 1 abaixo — nunca há uma segunda leitura.
|
|
503
|
+
#
|
|
504
|
+
# ML-3A (ROADMAP-2026-09-09-guard-emite-hookspecificoutput-e-a-razao-chega-ao-modelo-nos-3-
|
|
505
|
+
# clis.md): o discriminante que existia aqui era "-t 0" -- separa "terminal interativo" de
|
|
506
|
+
# "pipe", não "vai receber EOF" de "não vai". Qualquer chamador não-tty que segure stdin aberta
|
|
507
|
+
# (ex.: um runtime de hook que nunca fecha o descritor) travava o antigo "cat" para sempre, sem
|
|
508
|
+
# limite -- foi o que pendurou "make quality" por 1h05 quando o gate de schema (ML-2A) chamou o
|
|
509
|
+
# guard sem "</dev/null" em 3 sítios. Substituído por dreno com ORÇAMENTO DE TEMPO via
|
|
510
|
+
# "read -t <segundos> -d ''": builtin do próprio bash desde a 3.0 (não do coreutils) -- medido
|
|
511
|
+
# idêntico no bash 3.2 (padrão do macOS) e no bash 5.3 (Linux/Homebrew); o bash do MSYS2/Git-Bash
|
|
512
|
+
# do Windows é bash de verdade e traz o mesmo builtin. Evita depender de "timeout(1)", que NÃO
|
|
513
|
+
# existe no macOS base nem no MSYS2 mínimo. "-d ''" faz o read tentar ler até o EOF real (NUL
|
|
514
|
+
# nunca aparece em payload de hook); "-t" interrompe a espera no orçamento e, medido contra um
|
|
515
|
+
# FIFO nunca fechado (bash 3.2 e 5.3), preserva na variável qualquer prefixo já lido antes do
|
|
516
|
+
# timeout -- sem perda do que chegou, só do que nunca chegou. 2s de orçamento: folga generosa
|
|
517
|
+
# para o payload pequeno de um hook sob scheduler sob carga (CI, "make quality" paralelo), e
|
|
518
|
+
# ainda curto o bastante para o travamento continuar perceptível em vez de indefinido. Estourar
|
|
519
|
+
# o orçamento NÃO libera o bloqueio: o passo 1 abaixo já prefere "$*" quando há argumento
|
|
520
|
+
# posicional, e cai para o que foi lido até o limite quando não há -- "exit 2" e o fail-closed
|
|
521
|
+
# continuam idênticos nos dois casos. O antigo desvio para invocação manual em terminal (skip
|
|
522
|
+
# total quando "-t 0") foi removido de propósito: era o próprio discriminante errado que este ML
|
|
523
|
+
# elimina, não um caso a preservar -- agora toda invocação drena com o mesmo orçamento de 2s.
|
|
502
524
|
_TRACKFW_STDIN=""
|
|
503
|
-
|
|
525
|
+
IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
|
|
504
526
|
|
|
505
527
|
# --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
|
|
506
528
|
# projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
|
|
@@ -1030,7 +1052,7 @@ case "$SUBCOMMAND" in
|
|
|
1030
1052
|
;;
|
|
1031
1053
|
esac
|
|
1032
1054
|
|
|
1033
|
-
printf '{"
|
|
1055
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
|
|
1034
1056
|
echo "$REASON" >&2
|
|
1035
1057
|
exit 2
|
|
1036
1058
|
`
|
package/src/serve/api_chain.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs')
|
|
4
4
|
const path = require('path')
|
|
5
|
-
const { resolveAgentNamespaces } = require('../validator')
|
|
5
|
+
const { resolveAgentNamespaces, extractRefPath } = require('../validator')
|
|
6
6
|
const { normalizeRefSeparator } = require('../lib/pathfmt')
|
|
7
7
|
|
|
8
8
|
const ROADMAP_STATES = ['wip', 'backlog', 'blocked', 'done', 'abandoned']
|
|
@@ -81,7 +81,7 @@ function collectNodes(dir, type, state) {
|
|
|
81
81
|
// mesmos operandos: normalizar esta nao pode afeta-la. O caminho de travessia
|
|
82
82
|
// permanece nativo (ADR D2). Espelha internal/serve/api_chain.go:111.
|
|
83
83
|
const id = normalizeRefSeparator(path.join(dir, file))
|
|
84
|
-
nodes.push({ id, type, title, state, fm })
|
|
84
|
+
nodes.push({ id, type, title, state, fm, content })
|
|
85
85
|
}
|
|
86
86
|
return nodes
|
|
87
87
|
}
|
|
@@ -169,8 +169,21 @@ function handleChain(cfg, req, res) {
|
|
|
169
169
|
|
|
170
170
|
function resolveRef(val) {
|
|
171
171
|
if (!val) return null
|
|
172
|
-
// Tenta pelo nome de arquivo
|
|
173
|
-
|
|
172
|
+
// Tenta pelo nome de arquivo — path.basename(val), não val cru: o formato canônico
|
|
173
|
+
// gravado por `trackfw req new`/`roadmap new` é o CAMINHO COMPLETO (ADR-2026-08-01), não o
|
|
174
|
+
// basename isolado. Sem o basename() aqui, um vínculo real como
|
|
175
|
+
// "docs/roadmaps/wip/ROADMAP-x.md" nunca batia contra a chave "roadmap-x" do fileIndex —
|
|
176
|
+
// achado do ML-3D: nenhuma aresta REQ/ADR/Roadmap gerada pelo CLI jamais resolvia por este
|
|
177
|
+
// caminho, mascarado porque os testes existentes só cobrem valor já-basename.
|
|
178
|
+
//
|
|
179
|
+
// Por que este arquivo NÃO importa validator.resolveRoadmapRef (ao contrário de
|
|
180
|
+
// internal/serve/api_chain.go, ML-3D): fileIndex é indexado por basename ACROSS todos os
|
|
181
|
+
// estados (wip/backlog/blocked/done/abandoned) desde antes desta correção — um vínculo
|
|
182
|
+
// `roadmap:` "wip/X.md" já resolve contra o node real em "done/X.md" sem fallback extra.
|
|
183
|
+
// O mecanismo do ML-3B/3D (caminho literal gravado com pasta de estado velha) não afeta
|
|
184
|
+
// este CLI porque ele nunca comparou por igualdade de caminho completo — só por basename/
|
|
185
|
+
// título. Ver docs/cli-parity.md, seção "serve: /api/chain".
|
|
186
|
+
const base = path.basename(val).replace(/\.md$/, '').toLowerCase().trim()
|
|
174
187
|
if (fileIndex.has(base)) return fileIndex.get(base)
|
|
175
188
|
// Tenta pelo título
|
|
176
189
|
const norm = val.toLowerCase().trim()
|
|
@@ -202,6 +215,20 @@ function handleChain(cfg, req, res) {
|
|
|
202
215
|
if (target && target !== id) addEdge(id, target)
|
|
203
216
|
}
|
|
204
217
|
}
|
|
218
|
+
|
|
219
|
+
// ML-3D: `trackfw req new` (npm/src/generators/req.js) grava `adr: ""` e `roadmap: ""`
|
|
220
|
+
// SEMPRE vazios no frontmatter — o valor real vive no corpo, em "## Linked ADR / ADR:
|
|
221
|
+
// <path>" e "## Linked Roadmap / Roadmap: <path>". O laço de frontmatter acima nunca
|
|
222
|
+
// encontra esse vínculo para uma REQ gerada pelo próprio CLI (não é caso de borda: é o
|
|
223
|
+
// formato canônico inteiro). validator.extractRefPath varre o CONTEÚDO inteiro
|
|
224
|
+
// linha-a-linha (mesma função usada por validateRefTargetsExist) — ponto único de
|
|
225
|
+
// extração, não duplicado aqui.
|
|
226
|
+
for (const field of ['req', 'adr', 'roadmap']) {
|
|
227
|
+
const val = extractRefPath(node.content || '', field)
|
|
228
|
+
if (!val) continue
|
|
229
|
+
const target = resolveRef(val)
|
|
230
|
+
if (target && target !== id) addEdge(id, target)
|
|
231
|
+
}
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
// Montar nodes de saída sem o campo fm interno
|
package/src/validator/index.js
CHANGED
|
@@ -577,33 +577,65 @@ function contentHasMarker(content, markers) {
|
|
|
577
577
|
return false
|
|
578
578
|
}
|
|
579
579
|
|
|
580
|
-
// contentHasMarkerValue retorna true se
|
|
581
|
-
//
|
|
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.
|
|
582
583
|
//
|
|
583
|
-
// issue #278
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
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.
|
|
589
616
|
function contentHasMarkerValue(content, markers) {
|
|
590
|
-
for (const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
if (
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
if (rest.endsWith('\r')) rest = rest.slice(0, -1)
|
|
600
|
-
if (rest.trim() !== '') return true
|
|
601
|
-
start = pos
|
|
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
|
|
602
626
|
}
|
|
603
627
|
}
|
|
604
628
|
return false
|
|
605
629
|
}
|
|
606
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
|
+
|
|
607
639
|
// extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
|
|
608
640
|
// ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
|
|
609
641
|
// documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
|
|
@@ -707,7 +739,7 @@ function validateWIPHasREQ() {
|
|
|
707
739
|
const content = readFileForRule('wip_has_req', path.join(wipDir, name), violations)
|
|
708
740
|
if (content === null) continue
|
|
709
741
|
if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
|
|
710
|
-
violations.push(`roadmap "${name}" is in wip but has no linked 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)`)
|
|
711
743
|
}
|
|
712
744
|
}
|
|
713
745
|
}
|
|
@@ -723,7 +755,7 @@ function validateREQsHaveADR() {
|
|
|
723
755
|
const content = readFileForRule('req_has_adr', filePath, violations)
|
|
724
756
|
if (content === null) continue
|
|
725
757
|
if (!contentHasMarkerValue(content, cfg.linkFields.adr)) {
|
|
726
|
-
violations.push(`req "${path.basename(filePath)}" has no linked ADR`)
|
|
758
|
+
violations.push(`req "${path.basename(filePath)}" has no linked ADR (marker must start the line with a real, non-placeholder value)`)
|
|
727
759
|
}
|
|
728
760
|
}
|
|
729
761
|
return violations
|
|
@@ -739,7 +771,7 @@ function validateBlockedHasREQ() {
|
|
|
739
771
|
const content = readFileForRule('blocked_has_req', path.join(blockedDir, name), violations)
|
|
740
772
|
if (content === null) continue
|
|
741
773
|
if (!contentHasMarkerValue(content, cfg.linkFields.req)) {
|
|
742
|
-
violations.push(`roadmap "${name}" is in blocked but has no linked 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)`)
|
|
743
775
|
}
|
|
744
776
|
}
|
|
745
777
|
}
|
|
@@ -755,7 +787,7 @@ function validateREQsHaveRoadmap() {
|
|
|
755
787
|
const content = readFileForRule('req_has_roadmap', filePath, violations)
|
|
756
788
|
if (content === null) continue
|
|
757
789
|
if (!contentHasMarkerValue(content, cfg.linkFields.roadmap)) {
|
|
758
|
-
violations.push(`req "${path.basename(filePath)}" has no linked Roadmap`)
|
|
790
|
+
violations.push(`req "${path.basename(filePath)}" has no linked Roadmap (marker must start the line with a real, non-placeholder value)`)
|
|
759
791
|
}
|
|
760
792
|
}
|
|
761
793
|
return violations
|
|
@@ -1197,8 +1229,22 @@ function validateRefTargetsExist() {
|
|
|
1197
1229
|
warnings.push(`req "${name}" links to ADR "${adrRef}" which does not exist`)
|
|
1198
1230
|
}
|
|
1199
1231
|
const roadmapRef = extractRefPath(content, 'Roadmap')
|
|
1200
|
-
if (roadmapRef
|
|
1201
|
-
|
|
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
|
+
}
|
|
1202
1248
|
}
|
|
1203
1249
|
}
|
|
1204
1250
|
|
|
@@ -1211,6 +1257,70 @@ function referenceExists(ref) {
|
|
|
1211
1257
|
return false
|
|
1212
1258
|
}
|
|
1213
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
|
+
|
|
1214
1324
|
function validateREQRoadmapLifecycle() {
|
|
1215
1325
|
const cfg = config.load()
|
|
1216
1326
|
const warnings = []
|
|
@@ -1220,7 +1330,13 @@ function validateREQRoadmapLifecycle() {
|
|
|
1220
1330
|
if (!reqStatusIsOpen(content)) continue
|
|
1221
1331
|
const ref = extractRefPath(content, 'Roadmap')
|
|
1222
1332
|
if (!ref) continue
|
|
1223
|
-
const
|
|
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]
|
|
1224
1340
|
if (!fs.existsSync(expandedRef)) continue
|
|
1225
1341
|
if (path.basename(path.dirname(expandedRef)) === 'done') {
|
|
1226
1342
|
warnings.push(`req "${path.basename(filePath)}" is Open but linked Roadmap "${ref}" is in done/`)
|
|
@@ -2200,12 +2316,31 @@ set -f
|
|
|
2200
2316
|
# --- 0. Drena o stdin ANTES de qualquer saída antecipada (ML-1B, ROADMAP-2026-08-17-guard-
|
|
2201
2317
|
# global-cabeado-com-no-op-fora-de-projeto-e-integridade-independente-de-fiacao.md): sem isso,
|
|
2202
2318
|
# quem escreve o payload JSON no pipe recebe EPIPE quando o no-op abaixo sai com 0 antes de ler
|
|
2203
|
-
# — reprodutível em 100% das chamadas fora de projeto trackfw, não é corrida de timing.
|
|
2204
|
-
#
|
|
2205
|
-
#
|
|
2206
|
-
#
|
|
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.
|
|
2207
2342
|
_TRACKFW_STDIN=""
|
|
2208
|
-
|
|
2343
|
+
IFS= read -r -t 2 -d '' _TRACKFW_STDIN || true
|
|
2209
2344
|
|
|
2210
2345
|
# --- 0b. No-op fora de projeto trackfw (ADR-2026-08-17-guard-global-cabeado-com-no-op-fora-de-
|
|
2211
2346
|
# projeto-trackfw.md): sobe diretórios a partir do cwd FÍSICO (pwd -P, resolve symlink) até
|
|
@@ -2735,7 +2870,7 @@ case "$SUBCOMMAND" in
|
|
|
2735
2870
|
;;
|
|
2736
2871
|
esac
|
|
2737
2872
|
|
|
2738
|
-
printf '{"
|
|
2873
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\\n' "$REASON"
|
|
2739
2874
|
echo "$REASON" >&2
|
|
2740
2875
|
exit 2
|
|
2741
2876
|
`
|
|
@@ -3850,6 +3985,7 @@ module.exports = {
|
|
|
3850
3985
|
normalizeBranchSlug,
|
|
3851
3986
|
// novas funções ML-2B
|
|
3852
3987
|
contentHasMarker,
|
|
3988
|
+
contentHasMarkerValue,
|
|
3853
3989
|
ruleSeverity,
|
|
3854
3990
|
applyRule,
|
|
3855
3991
|
// novas funções ML-1B (v2.5.1)
|