trackfw 7.2.0 → 7.3.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.
@@ -123,6 +123,10 @@ function generateValidateScript(cfg, cwd) {
123
123
  const script = buildValidateScript(cfg)
124
124
  const scriptPath = path.join(root, 'scripts', 'trackfw-validate.sh')
125
125
  fs.writeFileSync(scriptPath, script, { encoding: 'utf8', mode: 0o755 })
126
+ // AC9 (REQ-2026-08-28): writeFileSync's {mode} option applies only on O_CREAT;
127
+ // for an existing file the mode is not changed. fs.chmodSync restores 0o755
128
+ // unconditionally, matching Python's os.chmod behavior (which was already correct).
129
+ fs.chmodSync(scriptPath, 0o755)
126
130
  console.log(` ✓ ${path.join('scripts', 'trackfw-validate.sh')}`)
127
131
  }
128
132
 
@@ -201,10 +205,13 @@ function generateCIWorkflow(cfg) {
201
205
  }
202
206
  }
203
207
 
204
- function generateGitHubActionsWorkflow() {
205
- fs.mkdirSync('.github/workflows', { recursive: true })
206
-
207
- const content = `name: trackfw-gate
208
+ // buildGitHubActionsWorkflowContent returns the byte-identical content the GitHub Actions
209
+ // workflow generator would write. Exported for scaffold doctor (ADR-2026-08-27): the
210
+ // comparison uses the same function the write path uses, so drift is structurally impossible.
211
+ // The cfg parameter is accepted for API symmetry with Go's buildGitHubActionsWorkflowContent
212
+ // (which also ignores cfg for now — the template is not cfg-dependent).
213
+ function buildGitHubActionsWorkflowContent(_cfg) {
214
+ return `name: trackfw-gate
208
215
  on:
209
216
  pull_request:
210
217
  branches: [main]
@@ -222,14 +229,11 @@ jobs:
222
229
  - name: Governance gate
223
230
  run: trackfw validate
224
231
  `
225
-
226
- const filePath = '.github/workflows/trackfw-gate.yml'
227
- fs.writeFileSync(filePath, content, 'utf8')
228
- console.log(` ✓ ${filePath}`)
229
232
  }
230
233
 
231
- function generateGitLabCIWorkflow() {
232
- const content = `# trackfw governance gate
234
+ // buildGitLabCIWorkflowContent mirrors buildGitHubActionsWorkflowContent for GitLab CI.
235
+ function buildGitLabCIWorkflowContent(_cfg) {
236
+ return `# trackfw governance gate
233
237
  trackfw-gate:
234
238
  stage: test
235
239
  image: alpine:latest
@@ -241,8 +245,17 @@ trackfw-gate:
241
245
  only:
242
246
  - merge_requests
243
247
  `
248
+ }
249
+
250
+ function generateGitHubActionsWorkflow() {
251
+ fs.mkdirSync('.github/workflows', { recursive: true })
252
+ const filePath = '.github/workflows/trackfw-gate.yml'
253
+ fs.writeFileSync(filePath, buildGitHubActionsWorkflowContent(null), 'utf8')
254
+ console.log(` ✓ ${filePath}`)
255
+ }
244
256
 
245
- fs.writeFileSync('.gitlab-ci-trackfw.yml', content, 'utf8')
257
+ function generateGitLabCIWorkflow() {
258
+ fs.writeFileSync('.gitlab-ci-trackfw.yml', buildGitLabCIWorkflowContent(null), 'utf8')
246
259
  console.log(' ✓ .gitlab-ci-trackfw.yml')
247
260
  }
248
261
 
@@ -452,7 +465,7 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
452
465
  - **Docker + .env from day 1:** containerize early; all config via env vars
453
466
  - **2-layer validation:** frontend (UX) + backend (security) — never only one
454
467
  - **API-first:** define OpenAPI contract before coding frontend/backend integration
455
- - **Security wave:** include a red-team review wave in every feature roadmap
468
+ - **Threat model waves:** every feature roadmap opens with a Wave 0 threat model (before implementation) and closes with a red-team review wave (before release)
456
469
  - **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
457
470
  - Use \`/trackfw:architect\` to define stack before the first REQ
458
471
  ` + conventionsSection + `
@@ -855,7 +868,7 @@ trackfw não está instalado. Instale com:
855
868
  3. **Gerar o roadmap**
856
869
  Com base no conteúdo da REQ, gere um roadmap seguindo **estritamente** este formato:
857
870
 
858
- \`\`\`markdown
871
+ \`\`\`\`markdown
859
872
  ---
860
873
  status: backlog
861
874
  date: <YYYY-MM-DD>
@@ -870,6 +883,28 @@ trackfw não está instalado. Instale com:
870
883
  ## Diagnóstico / Contexto
871
884
  <resumo do problema, motivação e escopo extraídos da REQ>
872
885
 
886
+ ## Wave 0 — Threat Model
887
+ > Dependencies: none. Blocks all implementation.
888
+
889
+ ### ML-0A — Threat model for this roadmap
890
+ **Status:** pending
891
+ **Files affected:**
892
+ **Actions:**
893
+ 1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
894
+ 2. Threat model — who empties this Wave 0 without breaking any written rule, and how?
895
+ 3. Falsification targets in both directions — for each surface, what breaks when the behavior regresses, and what breaks when it regresses the opposite way?
896
+ 4. Declared residual — what this design accepts not covering.
897
+ **Acceptance criteria:**
898
+ - [ ] The four sections above answered with evidence, not a one-line assertion
899
+ - [ ] No implementation line written for this ML
900
+
901
+ **Gates da wave:**
902
+ \`\`\`bash
903
+ # Wave 0 gate — replace this placeholder with a project-specific check before
904
+ # marking ML-0A done. Do not remove the gate; replace its command (AC13).
905
+ exit 1 # placeholder gate fails closed until ML-0A replaces it — see docs/cli-parity.md
906
+ \`\`\`
907
+
873
908
  ## Wave 1 — <nome descritivo> (<N> MLs em paralelo)
874
909
  > Dependências: Independente
875
910
 
@@ -890,7 +925,7 @@ trackfw não está instalado. Instale com:
890
925
  ## Wave 2 — <nome> (depende de Wave 1)
891
926
  > Dependências: Wave 1 completa
892
927
  ...
893
- \`\`\`
928
+ \`\`\`\`
894
929
 
895
930
  **Princípios obrigatórios:**
896
931
  - MLs dentro da mesma Wave são **independentes** (arquivos distintos, sem conflito)
@@ -1032,9 +1067,19 @@ Próximo passo: abrir PR com gh pr create
1032
1067
 
1033
1068
  Execute primeiro:
1034
1069
  \`\`\`bash
1035
- trackfw barrier <roadmap> --wave <n> --json
1070
+ trackfw barrier <roadmap> --wave <n> --trust-local-gates --json
1036
1071
  \`\`\`
1037
1072
 
1073
+ \`--trust-local-gates\` é obrigatório aqui: roadmaps WIP (modificados localmente, ainda não commitados em
1074
+ \`origin/main\`) são marcados como não confiáveis pela CLI direta por padrão, como proteção contra a
1075
+ execução de gates de roadmaps chegados por PR de terceiro. O slash command aplica esse flag porque
1076
+ ele representa o fluxo legítimo do arquiteto operando no próprio repositório — não porque os gates
1077
+ são inspecionados previamente (o diff ainda é responsabilidade do checklist abaixo).
1078
+
1079
+ ⚠️ **Não use \`--trust-local-gates\` ao revisar um roadmap chegado por PR de terceiro** — use a CLI
1080
+ direta sem o flag (\`trackfw barrier <roadmap> --wave <n> --json\`) para que os gates sejam marcados
1081
+ como \`not_evaluated\` e não executados.
1082
+
1038
1083
  Este comando é **necessário mas não suficiente**. Ele verifica MLs concluídos, evidências e \`trackfw validate\`, mas não substitui as inspeções especializadas nem a auditoria de diff abaixo — nenhuma delas é avaliada pelo binário. Consulte a seção \`trackfw barrier\` em \`docs/cli-parity.md\` para o contrato completo (estados, exit codes, saída JSON).
1039
1084
 
1040
1085
  Se o comando retornar exit code não-zero (\`blocked\` ou erro de resolução): pare, reporte a falha ao usuário e não prossiga no checklist até que a wave passe.
@@ -1258,8 +1303,11 @@ async function installAmazonQ(cwd = process.cwd()) {
1258
1303
  */
1259
1304
  async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project', { onSkip } = {}) {
1260
1305
  const { execute, buildPlans } = require('../integrations')
1306
+ const { resolveAgentModels } = require('../config')
1307
+ const { models: agentModels, warning: agentModelsWarning } = resolveAgentModels(scope, os.homedir(), cwd)
1308
+ if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
1261
1309
  const roots = { projectRoot: cwd }
1262
- const options = { targets: [target], scope, onSkip }
1310
+ const options = { targets: [target], scope, onSkip, agentModels }
1263
1311
  // D5 — transparency: print resolved destinations before writing anything.
1264
1312
  // buildPlans has no side effects, so it is safe to call here purely to
1265
1313
  // enumerate destinations; the actual write happens below via execute().
@@ -1383,4 +1431,12 @@ module.exports = {
1383
1431
  injectHooksDetected,
1384
1432
  trackfwRulesBlock,
1385
1433
  printArchitectNextSteps,
1434
+ // Template constants and builders — exported for scaffold doctor (ADR-2026-08-27).
1435
+ // Same single-source-of-truth principle as hooks.js exports: comparing against the
1436
+ // same object the write path uses makes drift structurally impossible.
1437
+ CLAUDE_COMMANDS,
1438
+ buildValidateScript,
1439
+ // CI workflow content builders (inline content from generateGitHub/GitLabCIWorkflow)
1440
+ buildGitHubActionsWorkflowContent,
1441
+ buildGitLabCIWorkflowContent,
1386
1442
  }
@@ -5,6 +5,42 @@ const config = require('../config')
5
5
  const { localDateISO } = require('./date')
6
6
  const { resolveReqFiles } = require('../validator/index.js')
7
7
 
8
+ // wave0Block — "## Wave 0 — Threat Model" section prepended to every generated roadmap, before
9
+ // the first implementation wave (AC1, AC12). Byte-identical to internal/generators/roadmap.go's
10
+ // wave0Block/wave0GateFence and to pypi/trackfw/generators/roadmap.py's WAVE0_BLOCK (gate:
11
+ // scripts/check-artifact-parity.sh).
12
+ //
13
+ // The gate command (`exit 1`) is fixed, literal and never interpolated with any REQ title, slug
14
+ // or user-controlled string — see the Go generator's comment for the full rationale (AC13,
15
+ // docs/cli-parity.md § "trackfw barrier"). It intentionally fails closed until ML-0A replaces it
16
+ // with a real, project-specific check.
17
+ //
18
+ // The ML is always ML-0A, never ML-1A — newRoadmapFromReq labels MLs derived from REQ acceptance
19
+ // criteria "ML-1A", "ML-1B", ... starting at the first criterion.
20
+ const WAVE0_BLOCK = `## Wave 0 — Threat Model
21
+ > Dependencies: none. Blocks all implementation.
22
+
23
+ ### ML-0A — Threat model for this roadmap
24
+ **Status:** pending
25
+ **Files affected:**
26
+ **Actions:**
27
+ 1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
28
+ 2. Threat model — who empties this Wave 0 without breaking any written rule, and how?
29
+ 3. Falsification targets in both directions — for each surface, what breaks when the behavior regresses, and what breaks when it regresses the opposite way?
30
+ 4. Declared residual — what this design accepts not covering.
31
+ **Acceptance criteria:**
32
+ - [ ] The four sections above answered with evidence, not a one-line assertion
33
+ - [ ] No implementation line written for this ML
34
+
35
+ **Gates da wave:**
36
+ \`\`\`bash
37
+ # Wave 0 gate — replace this placeholder with a project-specific check before
38
+ # marking ML-0A done. Do not remove the gate; replace its command (AC13).
39
+ exit 1 # placeholder gate fails closed until ML-0A replaces it — see docs/cli-parity.md
40
+ \`\`\`
41
+
42
+ `
43
+
8
44
  const VALID_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
9
45
  const STATE_ORDER = ['analyzing', 'wip', 'backlog', 'blocked', 'done', 'abandoned']
10
46
  const VALID_STATES_MESSAGE = VALID_STATES.join(', ')
@@ -400,6 +436,14 @@ function syncReqReferences(movedBasename, newRoadmapPath, cfg) {
400
436
  * Em modo by_agent, usa o primeiro agente configurado.
401
437
  */
402
438
  function newRoadmap(title, reqPath) {
439
+ // AC1/AC2: o título é dado de uma linha — newline e CR são entrada malformada.
440
+ // Mensagem byte-idêntica nos 3 CLIs (docs/cli-parity.md).
441
+ if (/[\n\r]/.test(title)) {
442
+ console.error('Error: roadmap title must be a single line: newline and carriage return are not allowed')
443
+ process.exitCode = 1
444
+ return
445
+ }
446
+
403
447
  const cfg = config.load()
404
448
  const date = localDateISO()
405
449
  const slug = toSlug(title)
@@ -441,7 +485,7 @@ REQ: ${reqPath || ''}
441
485
  - [ ]
442
486
  - [ ]
443
487
 
444
- ## Wave 1 — <name> (parallel MLs)
488
+ ${WAVE0_BLOCK}## Wave 1 — <name> (parallel MLs)
445
489
  > Dependencies: none
446
490
 
447
491
  ### ML-1A — ${title}
@@ -476,6 +520,13 @@ function newRoadmapFromReq(reqPath) {
476
520
  const basename = path.basename(reqPath)
477
521
  const title = parsedTitle || basename.replace(/\.md$/, '').replace(/^REQ-/, '')
478
522
 
523
+ // AC1: o título lido da REQ também pode conter newline forjado — rejeitar cedo.
524
+ if (/[\n\r]/.test(title)) {
525
+ console.error('Error: roadmap title must be a single line: newline and carriage return are not allowed')
526
+ process.exitCode = 1
527
+ return
528
+ }
529
+
479
530
  const cfg = config.load()
480
531
  const date = localDateISO()
481
532
  const slug = toSlug(title)
@@ -509,7 +560,7 @@ function newRoadmapFromReq(reqPath) {
509
560
  mlLines.push('- [ ] build passes')
510
561
  mlLines.push('- [ ] tests green')
511
562
  }
512
- const mlSection = mlLines.join('\n')
563
+ const mlSection = WAVE0_BLOCK + mlLines.join('\n')
513
564
 
514
565
  const adrRef = linkedADR ? `\nADR: ${linkedADR}` : ''
515
566
 
@@ -24,16 +24,18 @@ Append an entry to `docs/agents-working-context.md` when you start and when you
24
24
  Before investigating a bug or unexpected behavior, read `vault/notes/index.md` when it exists and open the related notes. After reaching a non-obvious root cause, write a note and link it in the index. Rule of thumb: if another agent would lose more than ten minutes tomorrow without the note, the note must exist.
25
25
 
26
26
  ## Git authority
27
- `trackfw_architect` is the **only** role with Git authority in this project. No other agent creates branches, commits, pushes, checks out, merges or rebases — every specialist hands its work back to `trackfw_architect` uncommitted. This role: creates the branch with `trackfw branch new <type>/<slug>`, which validates a matching roadmap already sits in `wip/` or `done/` before running `git checkout -b` and blocks with governance orientation if none is found; falls back to a raw `git checkout -b` only if `trackfw branch new` is an unknown command (binary predating v6.4.0 or missing from PATH) or fails for a reason other than the expected missing-roadmap block, while still requiring REQ + roadmap in `wip` first; audits the full diff produced by specialists against the assigned scope; performs every commit, including orchestration artifacts (ADRs, REQs, roadmaps, vault notes, the working context file) and the product code produced by specialists once audited; pushes to the working branch; and suggests opening a pull request, opening it only when the user explicitly asks. Never merge.
27
+ `trackfw_architect` is the **only** role with Git authority in this project. No other agent creates branches, commits, pushes, checks out, merges or rebases — every specialist hands its work back to `trackfw_architect` uncommitted. This role: creates the branch with `trackfw branch new <type>/<slug>`, which validates a matching roadmap already sits in `wip/` or `done/` before running `git checkout -b` and blocks with governance orientation if none is found; falls back to a raw `git checkout -b` only if `trackfw branch new` is an unknown command (binary predating v6.4.0 or missing from PATH) or fails for a reason other than the expected missing-roadmap block, while still requiring REQ + roadmap in `wip` first; audits the full diff produced by specialists against the assigned scope; performs every commit, including orchestration artifacts (ADRs, REQs, roadmaps, vault notes, the working context file) and the product code produced by specialists once audited; pushes commits already created to the working branch with `trackfw push`; and suggests opening a pull request, opening it only when the user explicitly asks. Never merge.
28
+
29
+ Three distinct commands, never interchangeable: `trackfw commit` commits staged changes on the working branch; `trackfw push` pushes commits already created — use it instead of a raw `git push`, which the guard intercepts and redirects here; `trackfw ship` composes commit + push + PR into one governed step. Prefer the raw `git commit`/`git push` only when `trackfw commit`/`trackfw push` are unknown commands (binary predating the version that introduced them) or fail for a reason unrelated to governance.
28
30
 
29
31
  ## Barrier protocol
30
- Before releasing the next wave: invoke the `code-quality` and `security` roles whenever the change warrants their review (new code paths, dependencies, permissions, secrets, parsers, or attack surface); block the next wave on any failed check and dispatch a corrective microbatch to the owning role instead of proceeding; audit the diff and re-run the wave's gates before performing any commit. A green `trackfw barrier` result from the CLI is necessary but not sufficient — it does not replace the specialized code-quality/security review or the manual diff audit.
32
+ Before releasing the next wave: confirm Wave 0 (threat model) of the current roadmap is audited — every MB in it, before dispatching any implementation wave; invoke the `code-quality` and `security` roles whenever the change warrants their review (new code paths, dependencies, permissions, secrets, parsers, or attack surface); block the next wave on any failed check and dispatch a corrective microbatch to the owning role instead of proceeding; audit the diff and re-run the wave's gates before performing any commit. A green `trackfw barrier` result from the CLI is necessary but not sufficient — it does not replace the specialized code-quality/security review or the manual diff audit.
31
33
 
32
34
  ## Parallelization
33
35
  Analyze real dependencies between microbatches before assigning work. Microbatches touching disjoint files run in parallel; microbatches sharing any file — including generated trees, build outputs and the git index — become sequential, and the reason is documented. Put an explicit barrier between waves. Every handoff prompt must be self-contained: exact files, exact values, exact commands. Never let two agents edit the same file at the same time.
34
36
 
35
37
  ## Workflow
36
- Analyze the codebase and requirements; record material decisions in an ADR; create the REQ with an explicit negative scope; produce a roadmap of waves and microbatches with measurable acceptance criteria; create the branch; commit the governance artifacts before any handoff; dispatch the wave; audit each microbatch against its acceptance criteria; update the roadmap; open the pull request only on request.
38
+ Analyze the codebase and requirements; record material decisions in an ADR; create the REQ with an explicit negative scope; produce a roadmap of waves and microbatches with measurable acceptance criteria, starting with a Wave 0 threat model (`security` role, `trackfw barrier <roadmap> --wave 0`) that MUST be audited before any implementation wave is dispatched; create the branch; commit the governance artifacts before any handoff; dispatch Wave 0 first, then the implementation waves; audit each microbatch against its acceptance criteria; update the roadmap; open the pull request only on request.
37
39
 
38
40
  ## Dispatch contract
39
41
  Naming a specialist in prose or in a roadmap's `squad:` field is documentation, not delegation — it does not route the Agent tool call by itself. Every dispatch to a specialist MUST pass the Agent tool's `subagent_type` parameter explicitly; omitting it silently falls back to the generic `general-purpose` agent, which has none of the intended specialist's domain instructions. The correct `subagent_type` value is the `name:` from the frontmatter of that role's installed agent file — always `<slug>-tf`, where `<slug>` depends on the identity the user configured (Greek, Norse, custom, or otherwise); never assume a fixed name. If the exact value is not already known, read the installed agent file for that role before dispatching instead of guessing from the name used in prose. Confirm `subagent_type` is present and correct before every dispatch call.
@@ -42,6 +42,9 @@ This role never executes Git operations — no `branch`, `commit`, `push`, `chec
42
42
  ## Definition of done
43
43
  Green build and tests do not close a microbatch. It is done when the roadmap reflects the new status and the governance artifacts sit in the correct state folder. Leaving an artifact in the wrong folder is the failure the gate exists to catch.
44
44
 
45
+ ## Wave 0 deliverable
46
+ When dispatched for a roadmap's `## Wave 0 — Threat Model`, produce a written threat model with exactly four sections, before any implementation line is written: **enumeration completeness** (is the list of surfaces in the roadmap complete? name what is missing, or show the list is closed); **threat model** (who empties this Wave 0 without breaking any written rule, and how — the adversary here is the rushed implementer and the optimistic architect, not an external attacker); **falsification targets in both directions** (for each surface: where the sabotage enters, which gate should catch it, in which direction — false negative and false positive); **declared residual** (what this design accepts not covering, said plainly). Mark each of the four sections done with evidence, not a one-line assertion — `trackfw barrier <roadmap> --wave 0` checks that the ML is `✅` and its acceptance criteria are `[x]`, never the quality of the analysis; a shallow Wave 0 can still pass those two checks, so the `trackfw_architect` audit of the artifact's content is what actually catches it.
47
+
45
48
  ## Mission
46
49
  Perform evidence-backed threat analysis. Prioritize concrete exploit paths and mitigations, preserve authentication and least privilege, and never expose secrets or weaken controls to make a test pass.
47
50
 
@@ -0,0 +1,366 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * scaffold_doctor.js — scaffold artifact coverage for `trackfw doctor`
5
+ * (ADR-2026-08-27-doctor-cobre-artefatos-de-scaffold-por-comparacao-com-o-template-
6
+ * com-propriedade-dada-pelo-caminho.md).
7
+ *
8
+ * Mirrors internal/generators/scaffold_doctor.go (Go, canonical source of truth).
9
+ * Same design decisions apply — see that file's doc comment for the full rationale.
10
+ *
11
+ * Key decisions:
12
+ * - Property by path, not manifest (AC3): scaffold artifacts are identified by
13
+ * well-known namespace paths. No manifest entry is written or read.
14
+ * - Sibling classifier (AC15): scaffold artifacts are never in the manifest, so
15
+ * routing them through classifyDoctor would produce wrong remedies. The finding
16
+ * kinds SCAFFOLD_DIVERGENT / SCAFFOLD_MISSING are separate, with zero claim and
17
+ * a trackfw-update remedy.
18
+ * - Config-rendered templates (AC12): buildValidateScript(cfg) varies with
19
+ * cfg.backend/cfg.frontend — rendered from the project's own trackfw.yaml.
20
+ * - Eligibility for slash commands (AC14): only checked when
21
+ * .claude/commands/trackfw/ directory already exists.
22
+ * - Conditional artifacts (AC13): CI workflow only when cfg.ci declares it.
23
+ * - Neutral blame message (AC16): binary version stated; direction ambiguous.
24
+ * - Execute-bit checking (REQ-2026-08-28, AC2–AC5, AC10, AC11):
25
+ * The five scripts written with mode 0o755 are additionally checked for the
26
+ * owner-execute bit. The check uses (stat.mode & 0o100) !== 0 (not === 0o755)
27
+ * so umask-narrowed modes (0o750, 0o700) are accepted (AC10). Non-executable
28
+ * artifacts (slash commands, CI workflows) carry execBit=false and are never
29
+ * accused of a missing execute bit (AC4/AC11). Content divergence takes
30
+ * precedence over mode (at most one finding per artifact). Content correct +
31
+ * bit missing → SCAFFOLD_WRONG_MODE (AC3 distinct state). On Windows
32
+ * (process.platform === 'win32') the execute bit is not representable on NTFS,
33
+ * so the mode check is suppressed entirely — AC5.
34
+ *
35
+ * _platform is a module-level constant seeded from process.platform so that unit
36
+ * tests can override it via the exported _setPlatformForTest helper (AC5 testability).
37
+ */
38
+
39
+ const fs = require('fs')
40
+ const path = require('path')
41
+ const yaml = require('yaml')
42
+
43
+ const { SIGNAL_SCRIPT, CLEANUP_SCRIPT, CREDENTIAL_GUARD_SCRIPT, GIT_BRANCH_GUARD_SCRIPT } =
44
+ require('../generators/hooks')
45
+ const { CLAUDE_COMMANDS, buildValidateScript, buildGitHubActionsWorkflowContent, buildGitLabCIWorkflowContent } =
46
+ require('../generators/init')
47
+
48
+ // Finding kind constants — mirrors Go's DoctorScaffoldDivergent / DoctorScaffoldMissing /
49
+ // DoctorScaffoldWrongMode.
50
+ const SCAFFOLD_DIVERGENT = 'scaffold-divergent'
51
+ const SCAFFOLD_MISSING = 'scaffold-missing'
52
+ // SCAFFOLD_WRONG_MODE: content correct but owner-execute bit missing (AC3).
53
+ // Only emitted for artifacts with execBit=true; never on Windows (AC5).
54
+ const SCAFFOLD_WRONG_MODE = 'scaffold-wrong-mode'
55
+
56
+ // PYTHON_VALIDATE_SCRIPT_FORM is the byte-exact content Python's `trackfw init` and
57
+ // `trackfw update` (validate-script target) write to scripts/trackfw-validate.sh.
58
+ // Accepted by the set-membership check in checkValidateScriptArtifact so that a
59
+ // project initialized by the Python runtime does not produce a false-positive
60
+ // scaffold-divergent finding in the Node.js doctor.
61
+ //
62
+ // Scope of the exception: ONLY scripts/trackfw-validate.sh uses set-membership.
63
+ // All other scaffold artifacts use single-template equality.
64
+ // See docs/cli-parity.md, "validate.sh — pertencimento a conjunto (set-membership, escopado)".
65
+ const PYTHON_VALIDATE_SCRIPT_FORM = '#!/usr/bin/env bash\nset -euo pipefail\ntrackfw validate\n'
66
+
67
+ // Paths used by the scaffold doctor (mirrors Go's exported constants).
68
+ const CLAUDE_COMMANDS_DIR_PATH = '.claude/commands/trackfw'
69
+ const GITHUB_ACTIONS_WORKFLOW_PATH = '.github/workflows/trackfw-gate.yml'
70
+ const GITLAB_CI_WORKFLOW_PATH = '.gitlab-ci-trackfw.yml'
71
+
72
+ // _platform is seeded from process.platform at module load time.
73
+ // Tests override it via _setPlatformForTest to exercise the Windows guard (AC5).
74
+ let _platform = process.platform
75
+
76
+ /**
77
+ * _setPlatformForTest overrides the platform string used by mode checks.
78
+ * ONLY for use in unit tests. Returns a restore function.
79
+ *
80
+ * @param {string} plat - e.g. 'win32' or 'darwin'
81
+ * @returns {function} call to restore the original value
82
+ */
83
+ function _setPlatformForTest(plat) {
84
+ const prev = _platform
85
+ _platform = plat
86
+ return () => { _platform = prev }
87
+ }
88
+
89
+ /**
90
+ * execBitPresent returns true when the file at absPath has the owner-execute bit set
91
+ * (stat.mode & 0o100 !== 0). Returns false on any stat error.
92
+ *
93
+ * Uses bit mask rather than equality to 0o755 so that umask-narrowed modes like
94
+ * 0o750 or 0o700 are also accepted — AC10.
95
+ *
96
+ * @param {string} absPath - absolute path to the file
97
+ * @returns {boolean}
98
+ */
99
+ function execBitPresent(absPath) {
100
+ try {
101
+ const stat = fs.statSync(absPath)
102
+ return (stat.mode & 0o100) !== 0
103
+ } catch (_) {
104
+ return false
105
+ }
106
+ }
107
+
108
+ /**
109
+ * scaffoldRemedy returns a ready-to-copy remedy command for a scaffold finding.
110
+ * The message is neutral about blame direction (AC16).
111
+ */
112
+ function scaffoldRemedy(action, relPath) {
113
+ // Lazy-require version to avoid a circular dependency at module load time.
114
+ let ver = 'unknown'
115
+ try {
116
+ const pkg = require('../../package.json')
117
+ ver = pkg.version || 'unknown'
118
+ } catch (_) {}
119
+ return `trackfw update # ${action} ${relPath}: content differs from the template trackfw v${ver} generates; if this project was initialized with a newer binary, update the binary instead`
120
+ }
121
+
122
+ /**
123
+ * scaffoldWrongModeRemedy returns a remedy command for the scaffold-wrong-mode finding.
124
+ * Names the missing execute bit explicitly to distinguish it from content divergence (AC3).
125
+ * The message is runtime-neutral so the parity check can diff Go, Node, and Python outputs
126
+ * byte-for-byte on this finding kind.
127
+ */
128
+ function scaffoldWrongModeRemedy(relPath) {
129
+ return `trackfw update # restore execute bit on ${relPath}: content is correct but the owner-execute bit is missing (mode 0755 required); trackfw update now restores the mode unconditionally on existing files`
130
+ }
131
+
132
+ /**
133
+ * loadProjectConfig reads trackfw.yaml from projectRoot and returns a cfg object
134
+ * with the same keys that buildValidateScript / buildCIWorkflowContent expect.
135
+ * Missing keys default to undefined/null — identical to Go's loadUpdateConfig().
136
+ */
137
+ function loadProjectConfig(projectRoot) {
138
+ try {
139
+ const raw = fs.readFileSync(path.join(projectRoot, 'trackfw.yaml'), 'utf8')
140
+ const parsed = yaml.parse(raw) || {}
141
+ return {
142
+ backend: parsed.backend || null,
143
+ frontend: parsed.frontend || null,
144
+ pkgManager: parsed.pkg_manager || null,
145
+ ci: parsed.ci || null,
146
+ }
147
+ } catch (_) {
148
+ return { backend: null, frontend: null, pkgManager: null, ci: null }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * checkValidateScriptArtifact checks scripts/trackfw-validate.sh using set-membership:
154
+ * accepted if the file content matches EITHER Go/Node's cfg-rendered form OR Python's
155
+ * fixed form (PYTHON_VALIDATE_SCRIPT_FORM). A file matching NO known form is accused.
156
+ * All other scaffold artifacts use checkScaffoldArtifact (single-template equality).
157
+ *
158
+ * After content membership passes, the execute bit is checked unless on Windows (AC5).
159
+ * Content divergence always takes precedence over mode (at most one finding per artifact).
160
+ *
161
+ * @param {string} absPath - absolute path to the file
162
+ * @param {string} relPath - relative path (used as destination in findings)
163
+ * @param {object} cfg - project config from trackfw.yaml
164
+ * @returns {object|null} finding or null
165
+ */
166
+ function checkValidateScriptArtifact(absPath, relPath, cfg) {
167
+ let actual
168
+ try {
169
+ actual = fs.readFileSync(absPath, 'utf8')
170
+ } catch (err) {
171
+ if (err.code === 'ENOENT') {
172
+ return {
173
+ finding: SCAFFOLD_MISSING,
174
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
175
+ destination: relPath,
176
+ remedy: scaffoldRemedy('restore', relPath),
177
+ }
178
+ }
179
+ return {
180
+ finding: SCAFFOLD_DIVERGENT,
181
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
182
+ destination: relPath,
183
+ remedy: scaffoldRemedy('resync', relPath),
184
+ }
185
+ }
186
+ const goNodeForm = buildValidateScript(cfg)
187
+ if (actual !== goNodeForm && actual !== PYTHON_VALIDATE_SCRIPT_FORM) {
188
+ // Content diverges — scaffold-divergent takes precedence over any mode issue.
189
+ return {
190
+ finding: SCAFFOLD_DIVERGENT,
191
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
192
+ destination: relPath,
193
+ remedy: scaffoldRemedy('resync', relPath),
194
+ }
195
+ }
196
+ // Content accepted. Check the execute bit (AC2/AC3).
197
+ // Suppressed on Windows where the bit is not representable (AC5).
198
+ if (_platform !== 'win32' && !execBitPresent(absPath)) {
199
+ return {
200
+ finding: SCAFFOLD_WRONG_MODE,
201
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
202
+ destination: relPath,
203
+ remedy: scaffoldWrongModeRemedy(relPath),
204
+ }
205
+ }
206
+ return null
207
+ }
208
+
209
+ /**
210
+ * checkScaffoldArtifact compares the on-disk content at absPath against expected.
211
+ * Returns a finding if the file is divergent or (when reportMissing=true) absent.
212
+ * relPath is used as destination in the finding.
213
+ *
214
+ * @param {string} absPath - absolute path to the file
215
+ * @param {string} relPath - relative path (destination in findings)
216
+ * @param {string} expected - expected file content
217
+ * @param {boolean} reportMissing - if true, absence is a finding
218
+ * @param {boolean} execBit - if true, check the owner-execute bit after content passes
219
+ * true → artifact was written with 0o755; bit absence → SCAFFOLD_WRONG_MODE (AC2/AC3)
220
+ * false → artifact is 0o644 (slash commands, CI workflows); never mode-checked (AC4/AC11)
221
+ * @returns {object|null} finding or null
222
+ */
223
+ function checkScaffoldArtifact(absPath, relPath, expected, reportMissing, execBit) {
224
+ let actual
225
+ try {
226
+ actual = fs.readFileSync(absPath, 'utf8')
227
+ } catch (err) {
228
+ if (err.code === 'ENOENT') {
229
+ if (!reportMissing) return null
230
+ return {
231
+ finding: SCAFFOLD_MISSING,
232
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
233
+ destination: relPath,
234
+ remedy: scaffoldRemedy('restore', relPath),
235
+ }
236
+ }
237
+ // Unreadable artifact: report as divergent so the user is informed.
238
+ return {
239
+ finding: SCAFFOLD_DIVERGENT,
240
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
241
+ destination: relPath,
242
+ remedy: scaffoldRemedy('resync', relPath),
243
+ }
244
+ }
245
+ if (actual !== expected) {
246
+ // Content diverges — takes precedence over any mode issue (at most one finding
247
+ // per artifact; update fixes both content and mode anyway).
248
+ return {
249
+ finding: SCAFFOLD_DIVERGENT,
250
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
251
+ destination: relPath,
252
+ remedy: scaffoldRemedy('resync', relPath),
253
+ }
254
+ }
255
+ // Content matches. Check the execute bit when required (AC2/AC3).
256
+ // Suppressed on Windows where the bit is not representable (AC5).
257
+ if (execBit && _platform !== 'win32' && !execBitPresent(absPath)) {
258
+ return {
259
+ finding: SCAFFOLD_WRONG_MODE,
260
+ claim: { kind: '', item: '', target: '', surface: '', scope: '' },
261
+ destination: relPath,
262
+ remedy: scaffoldWrongModeRemedy(relPath),
263
+ }
264
+ }
265
+ return null
266
+ }
267
+
268
+ /**
269
+ * runScaffoldDoctor compares scaffold artifacts on disk against the templates the
270
+ * currently installed binary would generate (given the project's own trackfw.yaml),
271
+ * and returns findings for any artifact that is divergent or missing.
272
+ *
273
+ * @param {string} projectRoot - absolute path to the project root
274
+ * @returns {Array} findings
275
+ */
276
+ function runScaffoldDoctor(projectRoot) {
277
+ // Eligibility: trackfw.yaml must exist.
278
+ try {
279
+ fs.statSync(path.join(projectRoot, 'trackfw.yaml'))
280
+ } catch (_) {
281
+ return []
282
+ }
283
+
284
+ const cfg = loadProjectConfig(projectRoot)
285
+ const findings = []
286
+
287
+ // --- Scripts (always in scope when trackfw.yaml is present) ---
288
+ //
289
+ // scripts/trackfw-validate.sh uses set-membership (Go/Node form OR Python form).
290
+ // The four remaining scripts use single-template equality via checkScaffoldArtifact.
291
+ // All five have execBit=true: the generator writes them with mode 0o755 (AC11).
292
+ const validateF = checkValidateScriptArtifact(
293
+ path.join(projectRoot, 'scripts/trackfw-validate.sh'),
294
+ 'scripts/trackfw-validate.sh',
295
+ cfg,
296
+ )
297
+ if (validateF) findings.push(validateF)
298
+
299
+ const staticScripts = [
300
+ { relPath: 'scripts/trackfw-attention-signal.sh', expected: SIGNAL_SCRIPT, execBit: true },
301
+ { relPath: 'scripts/trackfw-attention-cleanup.sh', expected: CLEANUP_SCRIPT, execBit: true },
302
+ { relPath: 'scripts/trackfw-credential-guard.sh', expected: CREDENTIAL_GUARD_SCRIPT, execBit: true },
303
+ { relPath: 'scripts/trackfw-git-branch-guard.sh', expected: GIT_BRANCH_GUARD_SCRIPT, execBit: true },
304
+ ]
305
+ for (const { relPath, expected, execBit } of staticScripts) {
306
+ const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, expected, true, execBit)
307
+ if (f) findings.push(f)
308
+ }
309
+
310
+ // --- Slash commands (AC14: only when the directory already exists) ---
311
+ //
312
+ // Slash commands are markdown files written with mode 0644 — execBit=false (AC11).
313
+ const claudeDir = path.join(projectRoot, CLAUDE_COMMANDS_DIR_PATH)
314
+ let claudeDirExists = false
315
+ try {
316
+ fs.statSync(claudeDir)
317
+ claudeDirExists = true
318
+ } catch (_) {}
319
+ if (claudeDirExists) {
320
+ for (const [filename, content] of Object.entries(CLAUDE_COMMANDS)) {
321
+ const relPath = `${CLAUDE_COMMANDS_DIR_PATH}/${filename}`
322
+ const f = checkScaffoldArtifact(path.join(projectRoot, relPath), relPath, content, true, false)
323
+ if (f) findings.push(f)
324
+ }
325
+ }
326
+
327
+ // --- CI workflow (AC13: conditional on ci: in trackfw.yaml) ---
328
+ //
329
+ // CI workflow YAML files are written with mode 0644 — execBit=false (AC11).
330
+ if (cfg.ci === 'github-actions') {
331
+ const relPath = GITHUB_ACTIONS_WORKFLOW_PATH
332
+ const f = checkScaffoldArtifact(
333
+ path.join(projectRoot, relPath),
334
+ relPath,
335
+ buildGitHubActionsWorkflowContent(cfg),
336
+ true,
337
+ false,
338
+ )
339
+ if (f) findings.push(f)
340
+ } else if (cfg.ci === 'gitlab-ci') {
341
+ const relPath = GITLAB_CI_WORKFLOW_PATH
342
+ const f = checkScaffoldArtifact(
343
+ path.join(projectRoot, relPath),
344
+ relPath,
345
+ buildGitLabCIWorkflowContent(cfg),
346
+ true,
347
+ false,
348
+ )
349
+ if (f) findings.push(f)
350
+ }
351
+
352
+ // Deterministic output (AC7): sort by destination.
353
+ findings.sort((a, b) => (a.destination < b.destination ? -1 : a.destination > b.destination ? 1 : 0))
354
+
355
+ return findings
356
+ }
357
+
358
+ module.exports = {
359
+ runScaffoldDoctor,
360
+ checkValidateScriptArtifact,
361
+ SCAFFOLD_DIVERGENT,
362
+ SCAFFOLD_MISSING,
363
+ SCAFFOLD_WRONG_MODE,
364
+ PYTHON_VALIDATE_SCRIPT_FORM,
365
+ _setPlatformForTest,
366
+ }