trackfw 6.1.0 → 6.3.1
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 +51 -1
- package/package.json +3 -2
- package/src/commands/req.js +7 -2
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +16 -30
- package/src/commands/update.js +17 -19
- package/src/config/index.js +194 -131
- package/src/generators/req.js +160 -26
- package/src/validator/index.js +218 -64
package/README.md
CHANGED
|
@@ -144,7 +144,8 @@ trackfw ship -m "feat(auth): add login flow"
|
|
|
144
144
|
| `trackfw adr new "title"` | Create a new Architecture Decision Record |
|
|
145
145
|
| `trackfw adr list` | List all ADRs with status |
|
|
146
146
|
| `trackfw req new "title"` | Create a REQ with guided ADR discovery |
|
|
147
|
-
| `trackfw req list` | List all REQs with status |
|
|
147
|
+
| `trackfw req list` | List all REQs with status, discovered across flat, per-state, and by_agent layouts |
|
|
148
|
+
| `trackfw req move <name> <status>` | Update a REQ's status; physically relocates the file when it already lives in a recognized state subfolder (see [Multi-agent namespacing](#multi-agent-namespacing)) |
|
|
148
149
|
| `trackfw roadmap new "title"` | Create a roadmap in `backlog/` |
|
|
149
150
|
| `trackfw roadmap show <name>` | Print a roadmap with its current state |
|
|
150
151
|
| `trackfw roadmap move <name> <state>` | Move roadmap between states |
|
|
@@ -210,6 +211,29 @@ agents: [claude, gemini, copilot]
|
|
|
210
211
|
|
|
211
212
|
Artifacts are organized by agent: `docs/roadmaps/claude/wip/`, `docs/req/gemini/done/`. `trackfw validate` and `trackfw context` are fully by_agent-aware — no false positives.
|
|
212
213
|
|
|
214
|
+
REQs reuse this same `roadmap_namespacing` setting — there is no separate `req_namespacing` key. When
|
|
215
|
+
`by_agent` is configured, REQs may live under `req_dir/<agent>/<state>/` just like roadmaps do.
|
|
216
|
+
|
|
217
|
+
`trackfw req list` and `trackfw req move` discover REQs across three layouts at once (each is a fixed,
|
|
218
|
+
non-recursive glob — a REQ nested deeper than these patterns is not found), with no extra flag required:
|
|
219
|
+
|
|
220
|
+
- **Flat (legacy):** `req_dir/*.md` — REQs loose directly in the REQ directory.
|
|
221
|
+
- **Per-state:** `req_dir/<state>/*.md` — organized by state, without an agent segment.
|
|
222
|
+
- **By agent:** `req_dir/<agent>/<state>/*.md` — used when `roadmap_namespacing: by_agent` is set.
|
|
223
|
+
|
|
224
|
+
`trackfw req move <name> <status>` behaves conditionally depending on where the REQ currently lives:
|
|
225
|
+
|
|
226
|
+
- If the REQ already sits inside a recognized state subfolder (per-state or by_agent layout above), the
|
|
227
|
+
move **physically relocates the file** to the target state's folder, mirroring `trackfw roadmap move` —
|
|
228
|
+
the folder is the source of truth for state. In this mode `<status>` must be one of the six governance
|
|
229
|
+
states (`backlog`, `analyzing`, `wip`, `blocked`, `done`, `abandoned`) — any other value is rejected
|
|
230
|
+
with `invalid state`.
|
|
231
|
+
- If the REQ is loose in `req_dir/` (flat legacy layout), the move rewrites the `status:` frontmatter
|
|
232
|
+
field **in place** and does not move or create any folder. `<status>` is written verbatim in this
|
|
233
|
+
mode — it accepts the free-form values existing REQs already use (`Open`, `Done`, ...), not just the
|
|
234
|
+
six governance state names. Existing flat REQs are never migrated automatically — you are not forced
|
|
235
|
+
to reorganize a project's existing REQs to adopt this behavior.
|
|
236
|
+
|
|
213
237
|
### Bidirectional traceability (`trace_id_field`)
|
|
214
238
|
|
|
215
239
|
```yaml
|
|
@@ -248,6 +272,32 @@ governance_mode: strict # CI fails on any violation
|
|
|
248
272
|
# governance_mode: lenient # CI passes with warnings only
|
|
249
273
|
```
|
|
250
274
|
|
|
275
|
+
### `update` and `sync` configuration fields
|
|
276
|
+
|
|
277
|
+
`trackfw update` and `trackfw sync` read the following keys, flat at the root of `trackfw.yaml`, through
|
|
278
|
+
the same loader used by every other command. All default to an empty string when absent.
|
|
279
|
+
|
|
280
|
+
```yaml
|
|
281
|
+
# trackfw.yaml — consumed by `trackfw update`
|
|
282
|
+
hooks: husky # husky | lefthook | native | "" (no hooks regenerated)
|
|
283
|
+
ci: github # github | gitlab | "" (no CI workflow regenerated)
|
|
284
|
+
backend: node # backend stack — informs CLAUDE.md/agent stack sections and hook commands
|
|
285
|
+
frontend: react # frontend stack — same as backend
|
|
286
|
+
pkg_manager: npm # npm | yarn | pnpm | ... — composes build/test commands in generated hooks
|
|
287
|
+
|
|
288
|
+
# trackfw.yaml — consumed by `trackfw sync` (checked before the matching env var, same order in all 3 CLIs)
|
|
289
|
+
linear_api_key: ""
|
|
290
|
+
linear_team_id: ""
|
|
291
|
+
jira_base_url: ""
|
|
292
|
+
jira_email: ""
|
|
293
|
+
jira_token: ""
|
|
294
|
+
jira_project: ""
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Full contract, including per-field consumers and the intentional exception for generated Git hooks
|
|
298
|
+
(which read `roadmap_dir` with their own `grep`/`sed`, since they run without the `trackfw` binary
|
|
299
|
+
present): `docs/cli-parity.md` → `## trackfw update vs trackfw update harness`.
|
|
300
|
+
|
|
251
301
|
---
|
|
252
302
|
|
|
253
303
|
## REQ-driven ADR discovery
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.1
|
|
3
|
+
"version": "6.3.1",
|
|
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, and Amazon Q.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"commander": "^12.0.0",
|
|
61
|
-
"@inquirer/prompts": "^5.0.0"
|
|
61
|
+
"@inquirer/prompts": "^5.0.0",
|
|
62
|
+
"yaml": "^2.9.0"
|
|
62
63
|
},
|
|
63
64
|
"engines": {
|
|
64
65
|
"node": ">=18"
|
package/src/commands/req.js
CHANGED
|
@@ -64,13 +64,18 @@ cmd.command('new <title>')
|
|
|
64
64
|
cmd.command('list')
|
|
65
65
|
.description(t('req.list.description'))
|
|
66
66
|
.action(async () => {
|
|
67
|
-
listREQs(require('../config').load()
|
|
67
|
+
listREQs(require('../config').load())
|
|
68
68
|
})
|
|
69
69
|
|
|
70
70
|
cmd.command('move <name> <status>')
|
|
71
71
|
.description('Update a REQ status in place')
|
|
72
72
|
.action(async (name, status) => {
|
|
73
|
-
|
|
73
|
+
try {
|
|
74
|
+
moveREQ(name, status)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(`Error: ${err.message}`)
|
|
77
|
+
process.exitCode = 1
|
|
78
|
+
}
|
|
74
79
|
})
|
|
75
80
|
|
|
76
81
|
module.exports = cmd
|
package/src/commands/status.js
CHANGED
package/src/commands/sync.js
CHANGED
|
@@ -5,38 +5,24 @@ const https = require('https')
|
|
|
5
5
|
const http = require('http')
|
|
6
6
|
const fs = require('fs')
|
|
7
7
|
const path = require('path')
|
|
8
|
+
const projectConfig = require('../config')
|
|
8
9
|
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
10
11
|
// Config helpers
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Lê um campo de
|
|
15
|
-
*
|
|
15
|
+
* Lê um campo de cfg.sync (namespace resolvido pelo carregador único, ../config —
|
|
16
|
+
* ver ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-yaml-com-namespaces-tipados.md),
|
|
17
|
+
* com fallback para variável de ambiente. Substitui o antigo scanner artesanal
|
|
18
|
+
* readConfigField (parse linha a linha de trackfw.yaml).
|
|
19
|
+
* @param {string} syncKey chave camelCase em cfg.sync (ex.: "linearApiKey")
|
|
20
|
+
* @param {string} envVar
|
|
16
21
|
* @returns {string}
|
|
17
22
|
*/
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const prefix = field + ':'
|
|
22
|
-
for (const line of data.split('\n')) {
|
|
23
|
-
const trimmed = line.trimStart()
|
|
24
|
-
if (trimmed.startsWith(prefix)) {
|
|
25
|
-
let value = trimmed.slice(prefix.length).trim()
|
|
26
|
-
if (value.length >= 2 &&
|
|
27
|
-
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
28
|
-
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
29
|
-
value = value.slice(1, -1)
|
|
30
|
-
}
|
|
31
|
-
return value
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
} catch (_) { /* sem arquivo */ }
|
|
35
|
-
return ''
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function getConfig(field, envVar) {
|
|
39
|
-
return readConfigField(field) || process.env[envVar] || ''
|
|
23
|
+
function getConfig(syncKey, envVar) {
|
|
24
|
+
const cfg = projectConfig.load(process.cwd())
|
|
25
|
+
return cfg.sync[syncKey] || process.env[envVar] || ''
|
|
40
26
|
}
|
|
41
27
|
|
|
42
28
|
// ---------------------------------------------------------------------------
|
|
@@ -296,18 +282,18 @@ async function syncToProvider(createFn, issueField) {
|
|
|
296
282
|
}
|
|
297
283
|
|
|
298
284
|
async function syncToLinear() {
|
|
299
|
-
const apiKey = getConfig('
|
|
300
|
-
const teamId = getConfig('
|
|
285
|
+
const apiKey = getConfig('linearApiKey', 'LINEAR_API_KEY')
|
|
286
|
+
const teamId = getConfig('linearTeamId', 'LINEAR_TEAM_ID')
|
|
301
287
|
if (!apiKey) throw new Error('Linear API key not found. Set LINEAR_API_KEY env var or linear_api_key in trackfw.yaml')
|
|
302
288
|
if (!teamId) throw new Error('Linear Team ID not found. Set LINEAR_TEAM_ID env var or linear_team_id in trackfw.yaml')
|
|
303
289
|
return syncToProvider((title, desc) => linearCreateIssue(apiKey, teamId, title, desc), 'linear_issue')
|
|
304
290
|
}
|
|
305
291
|
|
|
306
292
|
async function syncToJira() {
|
|
307
|
-
const baseUrl = getConfig('
|
|
308
|
-
const email = getConfig('
|
|
309
|
-
const token = getConfig('
|
|
310
|
-
const project = getConfig('
|
|
293
|
+
const baseUrl = getConfig('jiraBaseUrl', 'JIRA_BASE_URL')
|
|
294
|
+
const email = getConfig('jiraEmail', 'JIRA_EMAIL')
|
|
295
|
+
const token = getConfig('jiraToken', 'JIRA_TOKEN')
|
|
296
|
+
const project = getConfig('jiraProject', 'JIRA_PROJECT')
|
|
311
297
|
if (!baseUrl) throw new Error('Jira base URL not found. Set JIRA_BASE_URL env var or jira_base_url in trackfw.yaml')
|
|
312
298
|
if (!email) throw new Error('Jira email not found. Set JIRA_EMAIL env var or jira_email in trackfw.yaml')
|
|
313
299
|
if (!token) throw new Error('Jira API token not found. Set JIRA_TOKEN env var or jira_token in trackfw.yaml')
|
package/src/commands/update.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require('fs');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const identityStore = require('../identity');
|
|
8
|
+
const projectConfig = require('../config');
|
|
8
9
|
const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine');
|
|
9
10
|
|
|
10
11
|
// `trackfw update` is project-scoped only — see docs/cli-parity.md,
|
|
@@ -12,23 +13,20 @@ const { runFileTarget, validateTargets, buildDocument, humanReport, silenceConso
|
|
|
12
13
|
// state (~/.claude, ~/.codex, etc.). Global artifacts moved to
|
|
13
14
|
// `trackfw update harness` (update-harness.js).
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cfg[key] = val;
|
|
30
|
-
}
|
|
31
|
-
return cfg;
|
|
16
|
+
// loadUpdateConfig replaces the former artisanal line-by-line scanner (readUpdateConfig) with
|
|
17
|
+
// the single config loader (../config, see ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-
|
|
18
|
+
// yaml-com-namespaces-tipados.md). Returns the same snake_case shape the rest of this file
|
|
19
|
+
// already consumes (cfg.hooks, cfg.ci, cfg.backend, cfg.frontend, cfg.pkg_manager) so downstream
|
|
20
|
+
// code (updateHooksSurgical, buildProjectTargets) needs no further change.
|
|
21
|
+
function loadUpdateConfig(rootDir) {
|
|
22
|
+
const u = projectConfig.load(rootDir).update;
|
|
23
|
+
return {
|
|
24
|
+
hooks: u.hooks,
|
|
25
|
+
ci: u.ci,
|
|
26
|
+
backend: u.backend,
|
|
27
|
+
frontend: u.frontend,
|
|
28
|
+
pkg_manager: u.pkgManager,
|
|
29
|
+
};
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
function updateHooksSurgical(cfg, rootDir) {
|
|
@@ -164,7 +162,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
|
|
|
164
162
|
// discover.js's writeValidateScript, which produces a different
|
|
165
163
|
// (simpler, non-per-backend) script and made every `update` re-run
|
|
166
164
|
// report "updated" against a project actually already current
|
|
167
|
-
// (ML-6H fix).
|
|
165
|
+
// (ML-6H fix). loadUpdateConfig returns raw trackfw.yaml keys
|
|
168
166
|
// (snake_case, e.g. "pkg_manager"); buildValidateScript expects the
|
|
169
167
|
// camelCase shape used by the rest of the init generators.
|
|
170
168
|
apply: (root) => generators.generateValidateScript({
|
|
@@ -252,7 +250,7 @@ cmd.action((mode, options) => {
|
|
|
252
250
|
process.exit(1);
|
|
253
251
|
}
|
|
254
252
|
|
|
255
|
-
const cfg =
|
|
253
|
+
const cfg = loadUpdateConfig(cwd);
|
|
256
254
|
const dryRun = Boolean(options.dryRun);
|
|
257
255
|
const installMissing = Boolean(options.installMissing);
|
|
258
256
|
|
package/src/config/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { parseDocument, isScalar, isAlias, isSeq, isMap } = require('yaml');
|
|
6
7
|
|
|
7
8
|
function expandPath(filePath) {
|
|
8
9
|
if (!filePath || typeof filePath !== 'string') return filePath;
|
|
@@ -38,6 +39,24 @@ function defaults() {
|
|
|
38
39
|
roadmap: ['Roadmap:'],
|
|
39
40
|
},
|
|
40
41
|
acceptanceMarkers: ['## Acceptance Criteria', '## Critérios de Aceite'],
|
|
42
|
+
// ML-1A namespaces — see ADR-2026-08-02-caminho-unico-de-leitura-do-trackfw-yaml-com-
|
|
43
|
+
// namespaces-tipados.md. Keys stay flat at the YAML root; these are memory-only groupings
|
|
44
|
+
// populated by the same single parse() below, not a second read of trackfw.yaml.
|
|
45
|
+
update: {
|
|
46
|
+
hooks: '',
|
|
47
|
+
ci: '',
|
|
48
|
+
backend: '',
|
|
49
|
+
frontend: '',
|
|
50
|
+
pkgManager: '',
|
|
51
|
+
},
|
|
52
|
+
sync: {
|
|
53
|
+
linearApiKey: '',
|
|
54
|
+
linearTeamId: '',
|
|
55
|
+
jiraBaseUrl: '',
|
|
56
|
+
jiraEmail: '',
|
|
57
|
+
jiraToken: '',
|
|
58
|
+
jiraProject: '',
|
|
59
|
+
},
|
|
41
60
|
rules: {
|
|
42
61
|
wip_has_req: 'error',
|
|
43
62
|
wip_acceptance: 'error',
|
|
@@ -48,19 +67,30 @@ function defaults() {
|
|
|
48
67
|
folder_status: 'warning',
|
|
49
68
|
filename_uniqueness: 'error',
|
|
50
69
|
blocked_by_draft_adr: 'error',
|
|
70
|
+
adr_accepted_when_req_done: 'error',
|
|
51
71
|
},
|
|
52
72
|
};
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
let _instance = null;
|
|
56
76
|
|
|
77
|
+
// MALFORMED_CONFIG_MESSAGE is written to stderr, verbatim, when trackfw.yaml exists but fails to
|
|
78
|
+
// parse as YAML. Kept identical, character-for-character, to Go's MalformedConfigMessage and
|
|
79
|
+
// Python's MALFORMED_CONFIG_MESSAGE — see the comment on parse() below for why the text is
|
|
80
|
+
// static rather than built from the underlying library's error.
|
|
81
|
+
const MALFORMED_CONFIG_MESSAGE = 'trackfw: erro ao carregar "trackfw.yaml": YAML malformado. Corrija a sintaxe do arquivo antes de continuar.';
|
|
82
|
+
|
|
57
83
|
function load(cwd) {
|
|
58
84
|
if (_instance) return _instance;
|
|
59
85
|
_instance = defaults();
|
|
60
86
|
const yamlPath = path.join(cwd || process.cwd(), 'trackfw.yaml');
|
|
61
87
|
if (!fs.existsSync(yamlPath)) return _instance;
|
|
62
88
|
const content = fs.readFileSync(yamlPath, 'utf8');
|
|
63
|
-
parse(content, _instance);
|
|
89
|
+
const malformed = parse(content, _instance);
|
|
90
|
+
if (malformed) {
|
|
91
|
+
process.stderr.write(MALFORMED_CONFIG_MESSAGE + '\n');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
64
94
|
return _instance;
|
|
65
95
|
}
|
|
66
96
|
|
|
@@ -68,147 +98,180 @@ function reset() {
|
|
|
68
98
|
_instance = null;
|
|
69
99
|
}
|
|
70
100
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
let
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
let inAcceptanceMarkers = false;
|
|
90
|
-
let acceptanceMarkers = [];
|
|
91
|
-
|
|
92
|
-
let inRules = false;
|
|
93
|
-
let rules = {};
|
|
94
|
-
|
|
95
|
-
function flushBlocks() {
|
|
96
|
-
if (inAdrDirs && adrDirs.length) cfg.adrDirs = adrDirs.map(expandPath);
|
|
97
|
-
if (inAgents && agents.length) cfg.agents = agents;
|
|
98
|
-
if (inLinkFields) {
|
|
99
|
-
if (inLinkFieldsReq && linkFieldsReq.length) cfg.linkFields.req = linkFieldsReq;
|
|
100
|
-
if (inLinkFieldsAdr && linkFieldsAdr.length) cfg.linkFields.adr = linkFieldsAdr;
|
|
101
|
-
if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) cfg.linkFields.roadmap = linkFieldsRoadmap;
|
|
101
|
+
// resolveAlias segue a cadeia de um Alias (b: *x) até o nó âncora. Ler .source direto de um
|
|
102
|
+
// Alias não resolvido devolveria o NOME da âncora, não o valor — risco confirmado no ML-0A.
|
|
103
|
+
//
|
|
104
|
+
// state (opcional) é marcado como { unresolved: true } quando node.resolve(doc) devolve
|
|
105
|
+
// undefined — caso de uma âncora referenciada antes de ser definida (b: *x / a: &x 3), que a
|
|
106
|
+
// spec YAML trata como inválido. gopkg.in/yaml.v3 e PyYAML rejeitam esse arquivo (yaml.v3:
|
|
107
|
+
// "unknown anchor 'x' referenced"; PyYAML: ComposerError "found undefined alias"); a lib `yaml`
|
|
108
|
+
// do Node não popula doc.errors para isso — resolve() simplesmente devolve undefined em tempo
|
|
109
|
+
// de leitura. Sem este sinalizador, o valor viraria string vazia em silêncio (divergência de
|
|
110
|
+
// exit code encontrada na auditoria cruzada do ML-1B); com ele, parse() devolve malformado=true
|
|
111
|
+
// e load() converge com Go/Python.
|
|
112
|
+
function resolveAlias(doc, node, state) {
|
|
113
|
+
let n = node;
|
|
114
|
+
while (isAlias(n)) {
|
|
115
|
+
const resolved = n.resolve(doc);
|
|
116
|
+
if (resolved === undefined) {
|
|
117
|
+
if (state) state.unresolved = true;
|
|
118
|
+
return null;
|
|
102
119
|
}
|
|
103
|
-
|
|
104
|
-
if (inRules && Object.keys(rules).length) Object.assign(cfg.rules, rules);
|
|
105
|
-
// reset
|
|
106
|
-
inAdrDirs = false; adrDirs = [];
|
|
107
|
-
inAgents = false; agents = [];
|
|
108
|
-
inLinkFields = false;
|
|
109
|
-
inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
|
|
110
|
-
linkFieldsReq = []; linkFieldsAdr = []; linkFieldsRoadmap = [];
|
|
111
|
-
inAcceptanceMarkers = false; acceptanceMarkers = [];
|
|
112
|
-
inRules = false; rules = {};
|
|
120
|
+
n = resolved;
|
|
113
121
|
}
|
|
122
|
+
return n;
|
|
123
|
+
}
|
|
114
124
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
// normalizeNode converte um nó da árvore `yaml` (Scalar/Seq/Map/Alias) para uma string
|
|
126
|
+
// (escalar, usando o texto bruto pré-coerção via Scalar.source), um array de strings
|
|
127
|
+
// (sequência) ou um objeto plano (mapeamento) — recursivamente.
|
|
128
|
+
//
|
|
129
|
+
// Scalar.source já devolve o texto correto tanto para escalares "plain" (não processados —
|
|
130
|
+
// preserva "yes", "010", "2026-08-02" como estão no arquivo) quanto para escalares quoted/bloco
|
|
131
|
+
// (já des-escapados, iguais ao .value) — confirmado empiricamente no ML-1A: não há necessidade
|
|
132
|
+
// de tratar quoted e plain de formas diferentes.
|
|
133
|
+
function normalizeNode(doc, node, state) {
|
|
134
|
+
const n = resolveAlias(doc, node, state);
|
|
135
|
+
if (n == null) return '';
|
|
136
|
+
if (isScalar(n)) {
|
|
137
|
+
return n.source != null ? n.source : (n.value == null ? '' : String(n.value));
|
|
138
|
+
}
|
|
139
|
+
if (isSeq(n)) {
|
|
140
|
+
return n.items.map((item) => normalizeNode(doc, item, state));
|
|
141
|
+
}
|
|
142
|
+
if (isMap(n)) {
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const pair of n.items) {
|
|
145
|
+
const key = resolveAlias(doc, pair.key, state);
|
|
146
|
+
const keyStr = isScalar(key) ? (key.source != null ? key.source : String(key.value)) : String(key);
|
|
147
|
+
result[keyStr] = normalizeNode(doc, pair.value, state);
|
|
122
148
|
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
123
153
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
val = val.replace(/^["']|["']$/g, '');
|
|
129
|
-
adrDirs.push(expandPath(val));
|
|
130
|
-
}
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
if (inAgents) {
|
|
134
|
-
if (line.startsWith('- ')) agents.push(line.slice(2).trim());
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
if (inAcceptanceMarkers) {
|
|
138
|
-
if (line.startsWith('- ')) {
|
|
139
|
-
let val = line.slice(2).trim();
|
|
140
|
-
val = val.replace(/^["']|["']$/g, '');
|
|
141
|
-
acceptanceMarkers.push(val);
|
|
142
|
-
}
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
if (inRules) {
|
|
146
|
-
const colonIdx = line.indexOf(':');
|
|
147
|
-
if (colonIdx > 0) {
|
|
148
|
-
const k = line.slice(0, colonIdx).trim();
|
|
149
|
-
const v = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
|
|
150
|
-
if (k) rules[k] = v;
|
|
151
|
-
}
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
if (inLinkFields) {
|
|
155
|
-
if (line.startsWith('- ')) {
|
|
156
|
-
let val = line.slice(2).trim();
|
|
157
|
-
val = val.replace(/^["']|["']$/g, '');
|
|
158
|
-
if (inLinkFieldsReq) linkFieldsReq.push(val);
|
|
159
|
-
else if (inLinkFieldsAdr) linkFieldsAdr.push(val);
|
|
160
|
-
else if (inLinkFieldsRoadmap) linkFieldsRoadmap.push(val);
|
|
161
|
-
} else {
|
|
162
|
-
// sub-chave dentro de link_fields
|
|
163
|
-
const colonIdx = line.indexOf(':');
|
|
164
|
-
const subKey = colonIdx > 0 ? line.slice(0, colonIdx).trim() : line.replace(':', '').trim();
|
|
165
|
-
// flush sub-campo anterior
|
|
166
|
-
if (inLinkFieldsReq && linkFieldsReq.length) { cfg.linkFields.req = linkFieldsReq; linkFieldsReq = []; }
|
|
167
|
-
if (inLinkFieldsAdr && linkFieldsAdr.length) { cfg.linkFields.adr = linkFieldsAdr; linkFieldsAdr = []; }
|
|
168
|
-
if (inLinkFieldsRoadmap && linkFieldsRoadmap.length) { cfg.linkFields.roadmap = linkFieldsRoadmap; linkFieldsRoadmap = []; }
|
|
169
|
-
inLinkFieldsReq = false; inLinkFieldsAdr = false; inLinkFieldsRoadmap = false;
|
|
170
|
-
if (subKey === 'req') inLinkFieldsReq = true;
|
|
171
|
-
else if (subKey === 'adr') inLinkFieldsAdr = true;
|
|
172
|
-
else if (subKey === 'roadmap') inLinkFieldsRoadmap = true;
|
|
173
|
-
}
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
154
|
+
function stringVal(m, key) {
|
|
155
|
+
const v = m[key];
|
|
156
|
+
return typeof v === 'string' ? v : undefined;
|
|
157
|
+
}
|
|
178
158
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
159
|
+
// stringList converte um valor normalizado (array) em array de strings. Uma sequência
|
|
160
|
+
// presente-porém-vazia devolve array vazio (não undefined), distinguindo "presente e vazio" de
|
|
161
|
+
// "ausente" — contrato herdado do fix de lista inline.
|
|
162
|
+
function stringList(v) {
|
|
163
|
+
if (!Array.isArray(v)) return undefined;
|
|
164
|
+
return v.filter((item) => typeof item === 'string');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// NON_FATAL_ERROR_CODES holds `yaml` package error codes that must NOT trigger the fatal path,
|
|
168
|
+
// because gopkg.in/yaml.v3 (decoding into a generic Node, not a struct) and PyYAML's
|
|
169
|
+
// yaml.compose() silently accept the same input — divergence found by ML-1B audit: a
|
|
170
|
+
// "wip_limit: 3\nwip_limit: 4\n" duplicate-key file made Node exit 1 while Go and Python both
|
|
171
|
+
// parsed it fine (both resolve to "last key wins", same value trackfw ends up using in all
|
|
172
|
+
// three once DUPLICATE_KEY is excluded here). Only Node's `yaml` treats duplicate keys as a
|
|
173
|
+
// composer-level error; treating it as fatal here would make the fatal trigger itself diverge
|
|
174
|
+
// across CLIs, which is exactly the defect this ML exists to avoid. Any other doc.errors entry
|
|
175
|
+
// (e.g. BAD_INDENT — an actually-unparseable document) still triggers the fatal path below.
|
|
176
|
+
const NON_FATAL_ERROR_CODES = new Set(['DUPLICATE_KEY']);
|
|
177
|
+
|
|
178
|
+
// parse applies the ~20 known keys from content onto cfg. Returns true when content is
|
|
179
|
+
// malformed YAML (the caller, load(), turns that into a fatal stderr message + exit 1) and
|
|
180
|
+
// false otherwise — including the benign cases of an absent/empty/comments-only document
|
|
181
|
+
// (doc.contents === null with no errors) or a document whose top-level node parses fine but
|
|
182
|
+
// isn't a mapping (valid YAML, unexpected shape): neither of those is a parse failure, so both
|
|
183
|
+
// stay silent no-ops, same as before this function grew an error signal.
|
|
184
|
+
//
|
|
185
|
+
// The `yaml` package doesn't throw on syntax errors — parseDocument() always returns a Document,
|
|
186
|
+
// and populates doc.errors instead (a passive failure channel). The try/catch below is kept as
|
|
187
|
+
// defense-in-depth for library versions/inputs that do throw, but the actual malformed-YAML path
|
|
188
|
+
// exercised by trackfw.yaml goes through the doc.errors.length > 0 check.
|
|
189
|
+
function parse(content, cfg) {
|
|
190
|
+
let doc;
|
|
191
|
+
try {
|
|
192
|
+
doc = parseDocument(content);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (doc && doc.errors && doc.errors.some((e) => !NON_FATAL_ERROR_CODES.has(e.code))) return true;
|
|
197
|
+
if (!doc || !doc.contents) return false;
|
|
198
|
+
if (!isMap(doc.contents)) return false;
|
|
199
|
+
|
|
200
|
+
const state = { unresolved: false };
|
|
201
|
+
const m = normalizeNode(doc, doc.contents, state);
|
|
202
|
+
if (state.unresolved) return true;
|
|
203
|
+
if (typeof m !== 'object' || m === null || Array.isArray(m)) return false;
|
|
204
|
+
|
|
205
|
+
if (m.adr_dirs !== undefined) {
|
|
206
|
+
const items = stringList(m.adr_dirs);
|
|
207
|
+
if (items) cfg.adrDirs = items.map(expandPath);
|
|
208
|
+
}
|
|
209
|
+
if (stringVal(m, 'req_dir') !== undefined) cfg.reqDir = expandPath(m.req_dir);
|
|
210
|
+
if (stringVal(m, 'roadmap_dir') !== undefined) cfg.roadmapDir = expandPath(m.roadmap_dir);
|
|
211
|
+
if (stringVal(m, 'roadmap_namespacing') !== undefined) cfg.roadmapNamespacing = m.roadmap_namespacing;
|
|
212
|
+
if (m.agents !== undefined) {
|
|
213
|
+
const items = stringList(m.agents);
|
|
214
|
+
if (items) cfg.agents = items;
|
|
215
|
+
}
|
|
216
|
+
if (stringVal(m, 'governance_mode') !== undefined) cfg.governanceMode = m.governance_mode;
|
|
217
|
+
if (stringVal(m, 'lenient_until') !== undefined) cfg.lenientUntil = m.lenient_until;
|
|
218
|
+
if (stringVal(m, 'wip_limit') !== undefined) {
|
|
219
|
+
const n = parseInt(m.wip_limit, 10);
|
|
220
|
+
if (n > 0) cfg.wipLimit = n;
|
|
221
|
+
}
|
|
222
|
+
if (stringVal(m, 'wip_by_squad') !== undefined) cfg.wipBySquad = m.wip_by_squad === 'true';
|
|
223
|
+
if (stringVal(m, 'stale_wip_days') !== undefined) {
|
|
224
|
+
const n = parseInt(m.stale_wip_days, 10);
|
|
225
|
+
if (n > 0) cfg.staleWipDays = n;
|
|
226
|
+
}
|
|
227
|
+
if (stringVal(m, 'require_req_in_commit') !== undefined) cfg.requireReqInCommit = m.require_req_in_commit === 'true';
|
|
228
|
+
if (stringVal(m, 'strict_ci_paths') !== undefined) cfg.strictCiPaths = m.strict_ci_paths === 'true';
|
|
229
|
+
if (stringVal(m, 'trace_id_field') !== undefined) cfg.traceIdField = m.trace_id_field;
|
|
230
|
+
if (stringVal(m, 'forge') !== undefined) cfg.forge = m.forge;
|
|
231
|
+
if (m.acceptance_markers !== undefined) {
|
|
232
|
+
const items = stringList(m.acceptance_markers);
|
|
233
|
+
if (items) cfg.acceptanceMarkers = items;
|
|
234
|
+
}
|
|
235
|
+
if (m.link_fields !== undefined && typeof m.link_fields === 'object' && !Array.isArray(m.link_fields)) {
|
|
236
|
+
const lf = m.link_fields;
|
|
237
|
+
const req = stringList(lf.req);
|
|
238
|
+
if (req) cfg.linkFields.req = req;
|
|
239
|
+
const adr = stringList(lf.adr);
|
|
240
|
+
if (adr) cfg.linkFields.adr = adr;
|
|
241
|
+
const roadmap = stringList(lf.roadmap);
|
|
242
|
+
if (roadmap) cfg.linkFields.roadmap = roadmap;
|
|
243
|
+
}
|
|
244
|
+
if (m.rules !== undefined && typeof m.rules === 'object' && !Array.isArray(m.rules)) {
|
|
245
|
+
for (const [k, v] of Object.entries(m.rules)) {
|
|
246
|
+
if (typeof v === 'string') cfg.rules[k] = v;
|
|
204
247
|
}
|
|
205
248
|
}
|
|
206
249
|
|
|
207
|
-
//
|
|
208
|
-
|
|
250
|
+
// ML-1A — update and sync namespaces. Same normalized `m` as above, no second read.
|
|
251
|
+
if (stringVal(m, 'hooks') !== undefined) cfg.update.hooks = m.hooks;
|
|
252
|
+
if (stringVal(m, 'ci') !== undefined) cfg.update.ci = m.ci;
|
|
253
|
+
if (stringVal(m, 'backend') !== undefined) cfg.update.backend = m.backend;
|
|
254
|
+
if (stringVal(m, 'frontend') !== undefined) cfg.update.frontend = m.frontend;
|
|
255
|
+
if (stringVal(m, 'pkg_manager') !== undefined) cfg.update.pkgManager = m.pkg_manager;
|
|
256
|
+
if (stringVal(m, 'linear_api_key') !== undefined) cfg.sync.linearApiKey = m.linear_api_key;
|
|
257
|
+
if (stringVal(m, 'linear_team_id') !== undefined) cfg.sync.linearTeamId = m.linear_team_id;
|
|
258
|
+
if (stringVal(m, 'jira_base_url') !== undefined) cfg.sync.jiraBaseUrl = m.jira_base_url;
|
|
259
|
+
if (stringVal(m, 'jira_email') !== undefined) cfg.sync.jiraEmail = m.jira_email;
|
|
260
|
+
if (stringVal(m, 'jira_token') !== undefined) cfg.sync.jiraToken = m.jira_token;
|
|
261
|
+
if (stringVal(m, 'jira_project') !== undefined) cfg.sync.jiraProject = m.jira_project;
|
|
262
|
+
|
|
263
|
+
return false;
|
|
209
264
|
}
|
|
210
265
|
|
|
211
266
|
const NAMESPACING_FLAT = 'flat';
|
|
212
267
|
const NAMESPACING_BY_AGENT = 'by_agent';
|
|
213
268
|
|
|
214
|
-
module.exports = {
|
|
269
|
+
module.exports = {
|
|
270
|
+
load,
|
|
271
|
+
reset,
|
|
272
|
+
defaults,
|
|
273
|
+
expandPath,
|
|
274
|
+
NAMESPACING_FLAT,
|
|
275
|
+
NAMESPACING_BY_AGENT,
|
|
276
|
+
MALFORMED_CONFIG_MESSAGE,
|
|
277
|
+
};
|
package/src/generators/req.js
CHANGED
|
@@ -2,27 +2,86 @@
|
|
|
2
2
|
const fs = require('fs')
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const { localDateISO } = require('./date')
|
|
5
|
+
const roadmapGen = require('./roadmap')
|
|
6
|
+
const config = require('../config')
|
|
7
|
+
|
|
8
|
+
const VALID_STATES = roadmapGen.VALID_STATES
|
|
9
|
+
const STATE_ORDER = roadmapGen.STATE_ORDER
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
12
|
+
* listREQFiles — descoberta recursiva de REQs nos 3 layouts suportados, mesmo algoritmo usado
|
|
13
|
+
* em Go/Python (ADR-2026-08-04): flat (reqDir/*.md), por-estado (reqDir/<estado>/*.md) e,
|
|
14
|
+
* quando roadmapNamespacing === 'by_agent', by_agent (reqDir/<agente>/<estado>/*.md).
|
|
15
|
+
* Os três conjuntos não são mutuamente exclusivos — concatena todos.
|
|
16
|
+
* @param {object} cfg — config completo (ver npm/src/config)
|
|
17
|
+
* @returns {string[]} paths completos, na ordem flat → por-estado → by_agent
|
|
10
18
|
*/
|
|
11
|
-
function
|
|
12
|
-
|
|
19
|
+
function listREQFiles(cfg) {
|
|
20
|
+
const reqDir = cfg.reqDir
|
|
21
|
+
const files = []
|
|
22
|
+
|
|
23
|
+
// (a) flat legado — reqDir/*.md
|
|
13
24
|
try {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
for (const f of fs.readdirSync(reqDir)) {
|
|
26
|
+
if (!f.endsWith('.md')) continue
|
|
27
|
+
const full = path.join(reqDir, f)
|
|
28
|
+
try {
|
|
29
|
+
if (!fs.statSync(full).isDirectory()) files.push(full)
|
|
30
|
+
} catch (_) { /* ignora */ }
|
|
31
|
+
}
|
|
32
|
+
} catch (_) { /* reqDir não existe */ }
|
|
33
|
+
|
|
34
|
+
// (b) por-estado, sem agente — reqDir/<estado>/*.md
|
|
35
|
+
for (const state of STATE_ORDER) {
|
|
36
|
+
const dir = path.join(reqDir, state)
|
|
37
|
+
try {
|
|
38
|
+
for (const f of fs.readdirSync(dir)) {
|
|
39
|
+
if (f.endsWith('.md')) files.push(path.join(dir, f))
|
|
40
|
+
}
|
|
41
|
+
} catch (_) { /* estado não existe */ }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// (c) by_agent — reqDir/<agente>/<estado>/*.md
|
|
45
|
+
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
46
|
+
let agents = cfg.agents || []
|
|
47
|
+
if (agents.length === 0) {
|
|
48
|
+
try {
|
|
49
|
+
agents = fs.readdirSync(reqDir).filter(f => {
|
|
50
|
+
try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
|
|
51
|
+
})
|
|
52
|
+
} catch (_) { agents = [] }
|
|
53
|
+
}
|
|
54
|
+
for (const agent of agents) {
|
|
55
|
+
for (const state of STATE_ORDER) {
|
|
56
|
+
const dir = path.join(reqDir, agent, state)
|
|
57
|
+
try {
|
|
58
|
+
for (const f of fs.readdirSync(dir)) {
|
|
59
|
+
if (f.endsWith('.md')) files.push(path.join(dir, f))
|
|
60
|
+
}
|
|
61
|
+
} catch (_) { /* agente/estado não existe */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
17
64
|
}
|
|
18
65
|
|
|
66
|
+
return files
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* listREQs — lista REQs (recursivo nos 3 layouts), imprimindo filename e status (coluna 60 chars).
|
|
71
|
+
* Extrai status da linha `> Date: ... | Status: ...`.
|
|
72
|
+
* Se nenhum REQ encontrado: imprime "No REQs found in <reqDir>".
|
|
73
|
+
* @param {object} cfg — config completo (ver npm/src/config)
|
|
74
|
+
*/
|
|
75
|
+
function listREQs(cfg) {
|
|
76
|
+
const files = listREQFiles(cfg)
|
|
77
|
+
|
|
19
78
|
if (files.length === 0) {
|
|
20
|
-
console.log(`No REQs found in ${
|
|
79
|
+
console.log(`No REQs found in ${cfg.reqDir}`)
|
|
21
80
|
return
|
|
22
81
|
}
|
|
23
82
|
|
|
24
|
-
for (const
|
|
25
|
-
const
|
|
83
|
+
for (const filepath of files) {
|
|
84
|
+
const filename = path.basename(filepath)
|
|
26
85
|
const status = parseREQStatus(filepath)
|
|
27
86
|
console.log(`${filename.padEnd(60)} ${status}`)
|
|
28
87
|
}
|
|
@@ -105,30 +164,105 @@ function rewriteREQStatus(source, status) {
|
|
|
105
164
|
return { content: `---\n${lines.join('\n')}${rest}`, changed: true }
|
|
106
165
|
}
|
|
107
166
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
167
|
+
/**
|
|
168
|
+
* findREQ — busca recursiva nos 3 layouts (flat → por-estado → by_agent), retornando o primeiro
|
|
169
|
+
* path cujo basename contém `name` (case-insensitive).
|
|
170
|
+
* @param {string} name
|
|
171
|
+
* @param {object} cfg — config completo (ver npm/src/config)
|
|
172
|
+
* @returns {string} path completo
|
|
173
|
+
*/
|
|
174
|
+
function findREQ(name, cfg) {
|
|
175
|
+
const files = listREQFiles(cfg)
|
|
115
176
|
const lower = name.toLowerCase()
|
|
116
|
-
const found = files.find(f => f.toLowerCase().includes(lower))
|
|
117
|
-
if (!found) throw new Error(`REQ "${name}" not found in ${reqDir}`)
|
|
118
|
-
return
|
|
177
|
+
const found = files.find(f => path.basename(f).toLowerCase().includes(lower))
|
|
178
|
+
if (!found) throw new Error(`REQ "${name}" not found in ${cfg.reqDir}`)
|
|
179
|
+
return found
|
|
119
180
|
}
|
|
120
181
|
|
|
182
|
+
/**
|
|
183
|
+
* appendREQTransitionLog — append em <reqDir>/.trackfw-log, mesmo formato de
|
|
184
|
+
* appendTransitionLog (roadmap.js), em arquivo de log separado (escopo de REQ, não roadmap).
|
|
185
|
+
*/
|
|
186
|
+
function appendREQTransitionLog(cfg, basename, fromState, toState) {
|
|
187
|
+
const now = new Date()
|
|
188
|
+
const yyyy = now.getFullYear()
|
|
189
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0')
|
|
190
|
+
const dd = String(now.getDate()).padStart(2, '0')
|
|
191
|
+
const hh = String(now.getHours()).padStart(2, '0')
|
|
192
|
+
const min = String(now.getMinutes()).padStart(2, '0')
|
|
193
|
+
const timestamp = `${yyyy}-${mm}-${dd} ${hh}:${min}`
|
|
194
|
+
const line = `${timestamp} ${basename.padEnd(50)} ${fromState} → ${toState}\n`
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const lp = path.join(cfg.reqDir, '.trackfw-log')
|
|
198
|
+
fs.mkdirSync(path.dirname(lp), { recursive: true })
|
|
199
|
+
fs.appendFileSync(lp, line, 'utf8')
|
|
200
|
+
} catch (_) { /* best-effort, mesmo padrão do roadmap */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* moveREQ — reescreve status: e, condicionalmente, move fisicamente o arquivo
|
|
205
|
+
* (ADR-2026-08-04, decisão D3):
|
|
206
|
+
* - REQ solta em `reqDir/` → modo in-place (comportamento legado: só reescreve status, sem mover).
|
|
207
|
+
* - REQ já organizada em `reqDir/<estado>/` ou `reqDir/<agente>/<estado>/` → move fisicamente
|
|
208
|
+
* para o novo estado, preservando o layout (por-estado ou by_agent), e loga a transição.
|
|
209
|
+
*/
|
|
121
210
|
function moveREQ(name, status) {
|
|
122
211
|
if (!String(status || '').trim()) throw new Error('status is required')
|
|
123
|
-
const
|
|
124
|
-
const filepath = findREQ(name,
|
|
212
|
+
const cfg = require('../config').load()
|
|
213
|
+
const filepath = findREQ(name, cfg)
|
|
125
214
|
const source = fs.readFileSync(filepath, 'utf8')
|
|
126
215
|
const result = rewriteREQStatus(source, status)
|
|
127
216
|
if (!result.changed) {
|
|
128
217
|
throw new Error(`REQ "${path.basename(filepath)}" has no frontmatter status/header Status to update`)
|
|
129
218
|
}
|
|
130
|
-
|
|
131
|
-
|
|
219
|
+
|
|
220
|
+
const basename = path.basename(filepath)
|
|
221
|
+
const parentDir = path.dirname(filepath)
|
|
222
|
+
const grandparentDir = path.dirname(parentDir)
|
|
223
|
+
const greatGrandparentDir = path.dirname(grandparentDir)
|
|
224
|
+
const reqDirAbs = path.resolve(cfg.reqDir)
|
|
225
|
+
const fromState = path.basename(parentDir)
|
|
226
|
+
|
|
227
|
+
if (path.resolve(parentDir) === reqDirAbs) {
|
|
228
|
+
// modo in-place — REQ solta em reqDir/, comportamento legado (sem mover)
|
|
229
|
+
fs.writeFileSync(filepath, result.content, 'utf8')
|
|
230
|
+
console.log(`✓ updated ${basename} status → ${status}`)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!VALID_STATES.includes(status)) {
|
|
235
|
+
throw new Error(`invalid state "${status}" — valid states: ${VALID_STATES.join(', ')}`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let targetDir = null
|
|
239
|
+
let logBasename = basename
|
|
240
|
+
|
|
241
|
+
if (path.resolve(grandparentDir) === reqDirAbs && VALID_STATES.includes(fromState)) {
|
|
242
|
+
// layout por-estado — reqDir/<estado>/
|
|
243
|
+
targetDir = path.join(cfg.reqDir, status)
|
|
244
|
+
} else if (VALID_STATES.includes(fromState) && path.resolve(greatGrandparentDir) === reqDirAbs) {
|
|
245
|
+
// layout by_agent — reqDir/<agente>/<estado>/
|
|
246
|
+
const agent = path.basename(grandparentDir)
|
|
247
|
+
targetDir = path.join(cfg.reqDir, agent, status)
|
|
248
|
+
logBasename = `${agent}/${basename}`
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!targetDir) {
|
|
252
|
+
// layout não reconhecido — fallback seguro para in-place (não inventa destino)
|
|
253
|
+
fs.writeFileSync(filepath, result.content, 'utf8')
|
|
254
|
+
console.log(`✓ updated ${basename} status → ${status}`)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fs.mkdirSync(targetDir, { recursive: true })
|
|
259
|
+
const dst = path.join(targetDir, basename)
|
|
260
|
+
fs.writeFileSync(dst, result.content, 'utf8')
|
|
261
|
+
if (path.resolve(dst) !== path.resolve(filepath)) {
|
|
262
|
+
fs.unlinkSync(filepath)
|
|
263
|
+
}
|
|
264
|
+
appendREQTransitionLog(cfg, logBasename, fromState, status)
|
|
265
|
+
console.log(`✓ moved ${basename} → ${targetDir}`)
|
|
132
266
|
}
|
|
133
267
|
|
|
134
268
|
/**
|
|
@@ -328,4 +462,4 @@ function detectDomains(intention) {
|
|
|
328
462
|
)
|
|
329
463
|
}
|
|
330
464
|
|
|
331
|
-
module.exports = { listREQs, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
|
|
465
|
+
module.exports = { listREQs, listREQFiles, findREQ, parseREQStatus, rewriteREQStatus, moveREQ, newREQ, PROBES_CATALOG, detectDomains, localDateISO, toSlug }
|
package/src/validator/index.js
CHANGED
|
@@ -254,22 +254,97 @@ function contentHasMarker(content, markers) {
|
|
|
254
254
|
return false
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
// extractAdrHeaderStatus extrai o valor declarado na linha de cabeçalho
|
|
258
|
+
// ("> Date: ... | Status: X") de um ADR. Ancorado à linha — não é substring livre sobre o
|
|
259
|
+
// documento inteiro — para não confundir texto de prosa que mencione "Status: Draft"/"Proposed"
|
|
260
|
+
// (ex: citações de código, exemplos) com o status real do artefato. Retorna '' se a linha não
|
|
261
|
+
// existir.
|
|
262
|
+
function extractAdrHeaderStatus(content) {
|
|
263
|
+
const marker = '| Status: '
|
|
264
|
+
for (const line of content.split('\n')) {
|
|
265
|
+
const trimmed = line.trim()
|
|
266
|
+
const idx = trimmed.indexOf(marker)
|
|
267
|
+
if (idx >= 0) {
|
|
268
|
+
let rest = trimmed.slice(idx + marker.length)
|
|
269
|
+
const pipeIdx = rest.indexOf(' |')
|
|
270
|
+
if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
|
|
271
|
+
return rest.trim()
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return ''
|
|
260
275
|
}
|
|
261
276
|
|
|
262
|
-
|
|
277
|
+
// extractFrontmatterField extrai o valor de um campo do bloco frontmatter YAML
|
|
278
|
+
// (delimitado por "---" ... "---" no início do arquivo). Retorna '' se o frontmatter
|
|
279
|
+
// estiver ausente ou o campo não existir/estiver vazio.
|
|
280
|
+
function extractFrontmatterField(content, field) {
|
|
281
|
+
if (!content.startsWith('---')) return ''
|
|
282
|
+
const rest = content.slice(3)
|
|
283
|
+
const end = rest.indexOf('\n---')
|
|
284
|
+
if (end < 0) return ''
|
|
285
|
+
const block = rest.slice(0, end)
|
|
286
|
+
for (const line of block.split('\n')) {
|
|
287
|
+
const trimmed = line.trim()
|
|
288
|
+
if (trimmed.startsWith(field + ':')) {
|
|
289
|
+
let val = trimmed.slice(field.length + 1).trim()
|
|
290
|
+
val = val.replace(/^["']|["']$/g, '')
|
|
291
|
+
return val
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// resolveAdrStatus extrai o valor bruto do status de um ADR: frontmatter `status:`
|
|
298
|
+
// primeiro — é o campo machine-readable canônico, o mesmo que os geradores (`adr new`,
|
|
299
|
+
// NewADRDraft) escrevem e que a regra folder_status já usa como fonte de verdade — com
|
|
300
|
+
// fallback para a linha de cabeçalho ("> Date: ... | Status: X") somente se o
|
|
301
|
+
// frontmatter estiver ausente ou sem o campo (cobre ADRs legados sem frontmatter). Em um
|
|
302
|
+
// ADR bem formado os dois concordam. ML-1D (2026-08-01): alinha o Node ao Go e ao
|
|
303
|
+
// Python, que já liam frontmatter-first (ADR-2026-08-01-nocao-canonica-de-adr-nao-aceito).
|
|
304
|
+
function resolveAdrStatus(content) {
|
|
305
|
+
const fm = extractFrontmatterField(content, 'status')
|
|
306
|
+
if (fm) return fm
|
|
307
|
+
return extractAdrHeaderStatus(content)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// adrNotAcceptedStatusForRule é o helper canônico de "ADR não aceito": único lugar que conhece o
|
|
311
|
+
// vocabulário Draft/Proposed. Verdadeiro para ADR cujo status seja "Draft" ou "Proposed"; qualquer
|
|
312
|
+
// outro status (Accepted, Superseded, Deprecated, Rejected, ...) conta como aceito por exclusão —
|
|
313
|
+
// não há allowlist fechada de status aceitos.
|
|
314
|
+
//
|
|
315
|
+
// A fonte do status é resolveAdrStatus (frontmatter-first, fallback de cabeçalho — ver acima).
|
|
316
|
+
// Crucial: o fallback de cabeçalho extrai o valor de UMA linha específica
|
|
317
|
+
// (extractAdrHeaderStatus), não faz content.includes('Status: Draft') sobre o documento
|
|
318
|
+
// inteiro — esse era o defeito do código herdado (adrDraftStatusForRule original): um ADR
|
|
319
|
+
// com status real "Accepted" mas cuja prosa cita literalmente a string "Status: Draft"
|
|
320
|
+
// (ex: este próprio ADR, que documenta o bug) seria classificado como não aceito. Ver
|
|
321
|
+
// vault/notes para o caso concreto que expôs isso.
|
|
322
|
+
function adrNotAcceptedStatusForRule(rule, basename, messages) {
|
|
263
323
|
const p = findAdrFile(basename)
|
|
264
|
-
if (!p) return {
|
|
324
|
+
if (!p) return { notAccepted: false, status: '', inspected: true }
|
|
265
325
|
try {
|
|
266
|
-
|
|
326
|
+
const content = fs.readFileSync(p, 'utf8')
|
|
327
|
+
const status = resolveAdrStatus(content)
|
|
328
|
+
const notAccepted = status.toLowerCase() === 'draft' || status.toLowerCase() === 'proposed'
|
|
329
|
+
return { notAccepted, status, inspected: true }
|
|
267
330
|
} catch (err) {
|
|
268
|
-
if (messages) messages.push(inspectionDiagnostic(
|
|
269
|
-
return {
|
|
331
|
+
if (messages) messages.push(inspectionDiagnostic(rule, p, err))
|
|
332
|
+
return { notAccepted: false, status: '', inspected: false }
|
|
270
333
|
}
|
|
271
334
|
}
|
|
272
335
|
|
|
336
|
+
// adrIsDraft verifica se <adrBasename> está em status não aceito (Draft ou Proposed) buscando
|
|
337
|
+
// recursivamente nas adrDirs. Nome mantido por compatibilidade (chamadores existentes); delega no
|
|
338
|
+
// helper canônico adrNotAcceptedStatusForRule.
|
|
339
|
+
function adrIsDraft(basename) {
|
|
340
|
+
return adrDraftStatusForRule(basename, null).draft
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function adrDraftStatusForRule(basename, messages) {
|
|
344
|
+
const result = adrNotAcceptedStatusForRule('blocked_by_draft_adr', basename, messages)
|
|
345
|
+
return { draft: result.notAccepted, inspected: result.inspected }
|
|
346
|
+
}
|
|
347
|
+
|
|
273
348
|
// validateWIPHasREQ — roadmaps em wip/ sem marker REQ no conteúdo → violation
|
|
274
349
|
// Suporta modo by_agent via resolveWIPDirs.
|
|
275
350
|
function validateWIPHasREQ() {
|
|
@@ -419,29 +494,10 @@ function validateWIPHasAcceptanceCriteria() {
|
|
|
419
494
|
return violations
|
|
420
495
|
}
|
|
421
496
|
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
function
|
|
425
|
-
|
|
426
|
-
let content
|
|
427
|
-
try {
|
|
428
|
-
content = fs.readFileSync('trackfw.yaml', 'utf8')
|
|
429
|
-
} catch (_) {
|
|
430
|
-
return cfg
|
|
431
|
-
}
|
|
432
|
-
for (const line of content.split('\n')) {
|
|
433
|
-
const trimmed = line.trim()
|
|
434
|
-
if (trimmed.startsWith('wip_limit:')) {
|
|
435
|
-
const val = trimmed.slice('wip_limit:'.length).trim().split(/\s+/)[0]
|
|
436
|
-
const n = parseInt(val, 10)
|
|
437
|
-
if (!isNaN(n) && n > 0) cfg.limit = n
|
|
438
|
-
}
|
|
439
|
-
if (trimmed.startsWith('wip_by_squad:')) {
|
|
440
|
-
const val = trimmed.slice('wip_by_squad:'.length).trim().split(/\s+/)[0]
|
|
441
|
-
if (val === 'true') cfg.bySquad = true
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
return cfg
|
|
497
|
+
// wipConfigFrom deriva { limit, bySquad } a partir do ProjectConfig já normalizado por
|
|
498
|
+
// config.load() — nenhuma releitura de trackfw.yaml acontece aqui.
|
|
499
|
+
function wipConfigFrom(cfg) {
|
|
500
|
+
return { limit: cfg.wipLimit > 0 ? cfg.wipLimit : 1, bySquad: !!cfg.wipBySquad }
|
|
445
501
|
}
|
|
446
502
|
|
|
447
503
|
// parseSquadFromFrontmatter extrai o valor do campo "squad:" de um arquivo markdown.
|
|
@@ -498,7 +554,7 @@ function validateWIPLimit() {
|
|
|
498
554
|
return { violations, warnings }
|
|
499
555
|
}
|
|
500
556
|
|
|
501
|
-
const wipCfg =
|
|
557
|
+
const wipCfg = wipConfigFrom(cfg)
|
|
502
558
|
|
|
503
559
|
if (!wipCfg.bySquad) {
|
|
504
560
|
if (files.length > wipCfg.limit) {
|
|
@@ -622,13 +678,63 @@ function validateREQsNotBlockedByDraftADRs() {
|
|
|
622
678
|
const blockedADRs = parseBlockedADRs(filePath)
|
|
623
679
|
for (const adrBasename of blockedADRs) {
|
|
624
680
|
if (adrDraftStatusForRule(adrBasename, violations).draft) {
|
|
625
|
-
violations.push(`REQ ${path.basename(filePath)} is blocked by
|
|
681
|
+
violations.push(`REQ ${path.basename(filePath)} is blocked by not-accepted ADR: ${adrBasename}`)
|
|
626
682
|
}
|
|
627
683
|
}
|
|
628
684
|
}
|
|
629
685
|
return violations
|
|
630
686
|
}
|
|
631
687
|
|
|
688
|
+
// reqStatusEquals compara o status de uma REQ (case-insensitive) contra o valor esperado.
|
|
689
|
+
// Casa tanto o frontmatter ("status: X") quanto a linha de cabeçalho ("> Date: ... | Status: X"),
|
|
690
|
+
// mesma lógica de detecção usada por reqStatusIsOpen — duplicada aqui (em vez de generalizar
|
|
691
|
+
// reqStatusIsOpen) para não alterar o comportamento de req_roadmap_lifecycle, que já a consome.
|
|
692
|
+
function reqStatusEquals(content, status) {
|
|
693
|
+
const target = String(status).toLowerCase()
|
|
694
|
+
for (const line of content.split('\n')) {
|
|
695
|
+
const trimmed = line.trim()
|
|
696
|
+
const idx = trimmed.indexOf(':')
|
|
697
|
+
if (idx >= 0 && trimmed.slice(0, idx).trim().toLowerCase() === 'status') {
|
|
698
|
+
return trimmed.slice(idx + 1).trim().replace(/^["']|["']$/g, '').toLowerCase() === target
|
|
699
|
+
}
|
|
700
|
+
const marker = '| Status: '
|
|
701
|
+
const markerIdx = trimmed.indexOf(marker)
|
|
702
|
+
if (markerIdx >= 0) {
|
|
703
|
+
let rest = trimmed.slice(markerIdx + marker.length)
|
|
704
|
+
const pipeIdx = rest.indexOf(' |')
|
|
705
|
+
if (pipeIdx >= 0) rest = rest.slice(0, pipeIdx)
|
|
706
|
+
return rest.trim().toLowerCase() === target
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return false
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// validateADRAcceptedWhenREQDone — REQ Done cujo ADR vinculado (campo "ADR:") não está aceito
|
|
713
|
+
// (Draft ou Proposed, via o helper canônico adrNotAcceptedStatusForRule) → violation. Fecha a
|
|
714
|
+
// lacuna que deixou um ADR Proposed atravessar sete REQs Done sem nenhum gate detectar.
|
|
715
|
+
function validateADRAcceptedWhenREQDone() {
|
|
716
|
+
const cfg = config.load()
|
|
717
|
+
const files = resolveReqFiles(cfg)
|
|
718
|
+
const violations = []
|
|
719
|
+
for (const filePath of files) {
|
|
720
|
+
const content = readFileForRule('adr_accepted_when_req_done', filePath, violations)
|
|
721
|
+
if (content === null) continue
|
|
722
|
+
if (!reqStatusEquals(content, 'done')) continue
|
|
723
|
+
|
|
724
|
+
const adrRef = extractRefPath(content, 'ADR')
|
|
725
|
+
if (!adrRef) continue
|
|
726
|
+
const adrBasename = path.basename(adrRef)
|
|
727
|
+
const status = adrNotAcceptedStatusForRule('adr_accepted_when_req_done', adrBasename, violations)
|
|
728
|
+
if (status.notAccepted) {
|
|
729
|
+
const reqBasename = path.basename(filePath)
|
|
730
|
+
violations.push(
|
|
731
|
+
`REQ "${reqBasename}" is Done but linked ADR "${adrBasename}" is not accepted (status: ${status.status})`
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return violations
|
|
736
|
+
}
|
|
737
|
+
|
|
632
738
|
// blockedREQs retorna mapa de reqBasename → [adrBasenames Draft] para uso em getStatus()
|
|
633
739
|
function blockedREQs() {
|
|
634
740
|
const cfg = config.load()
|
|
@@ -652,37 +758,23 @@ function blockedREQs() {
|
|
|
652
758
|
return result
|
|
653
759
|
}
|
|
654
760
|
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
} catch (_) {
|
|
662
|
-
return { mode: 'strict', lenientUntil: null }
|
|
663
|
-
}
|
|
664
|
-
let mode = 'strict'
|
|
761
|
+
// governanceModeFrom deriva { mode, lenientUntil } a partir do ProjectConfig já normalizado por
|
|
762
|
+
// config.load() — nenhuma releitura de trackfw.yaml acontece aqui. cfg.governanceMode chega como
|
|
763
|
+
// o valor bruto do campo (string vazia se ausente); cfg.lenientUntil chega como a data literal
|
|
764
|
+
// (ex.: "2026-08-02"), convertida aqui para Date.
|
|
765
|
+
function governanceModeFrom(cfg) {
|
|
766
|
+
const mode = cfg.governanceMode ? cfg.governanceMode : 'strict'
|
|
665
767
|
let lenientUntil = null
|
|
666
|
-
|
|
667
|
-
const
|
|
668
|
-
if (
|
|
669
|
-
const val = trimmed.slice('governance_mode:'.length).trim().split(/\s+/)[0]
|
|
670
|
-
if (val) mode = val
|
|
671
|
-
}
|
|
672
|
-
if (trimmed.startsWith('lenient_until:')) {
|
|
673
|
-
const val = trimmed.slice('lenient_until:'.length).trim().split(/\s+/)[0]
|
|
674
|
-
if (val) {
|
|
675
|
-
const d = new Date(val)
|
|
676
|
-
if (!isNaN(d.getTime())) lenientUntil = d
|
|
677
|
-
}
|
|
678
|
-
}
|
|
768
|
+
if (cfg.lenientUntil) {
|
|
769
|
+
const d = new Date(cfg.lenientUntil)
|
|
770
|
+
if (!isNaN(d.getTime())) lenientUntil = d
|
|
679
771
|
}
|
|
680
772
|
return { mode, lenientUntil }
|
|
681
773
|
}
|
|
682
774
|
|
|
683
775
|
// isLenient retorna true se o projeto está em modo lenient e o prazo não expirou.
|
|
684
776
|
function isLenient() {
|
|
685
|
-
const gm =
|
|
777
|
+
const gm = governanceModeFrom(config.load())
|
|
686
778
|
if (gm.mode !== 'lenient') return false
|
|
687
779
|
if (!gm.lenientUntil) return true
|
|
688
780
|
return new Date() < gm.lenientUntil
|
|
@@ -690,7 +782,7 @@ function isLenient() {
|
|
|
690
782
|
|
|
691
783
|
// lenientUntilDate retorna a data de expiração formatada (YYYY-MM-DD) ou ''.
|
|
692
784
|
function lenientUntilDate() {
|
|
693
|
-
const gm =
|
|
785
|
+
const gm = governanceModeFrom(config.load())
|
|
694
786
|
if (gm.mode !== 'lenient' || !gm.lenientUntil) return ''
|
|
695
787
|
return gm.lenientUntil.toISOString().slice(0, 10)
|
|
696
788
|
}
|
|
@@ -735,7 +827,7 @@ function extractRefPath(content, field) {
|
|
|
735
827
|
let val = trimmed.slice(idx + 1).trim()
|
|
736
828
|
if (!val || val === '—' || val === '-' || val === '–') return null
|
|
737
829
|
val = val.split(/\s+/)[0]
|
|
738
|
-
val = val.replace(/^["']|["']$/g, '')
|
|
830
|
+
val = val.replace(/^["'`]|["'`]$/g, '')
|
|
739
831
|
if (val.endsWith('.md')) return val
|
|
740
832
|
}
|
|
741
833
|
}
|
|
@@ -1131,6 +1223,7 @@ async function validateUnfiltered() {
|
|
|
1131
1223
|
applyRule('filename_uniqueness', validateFilenameUniqueness(), violations, warnings)
|
|
1132
1224
|
applyRule('branch_has_wip_roadmap', validateBranchHasWIPRoadmap(), violations, warnings)
|
|
1133
1225
|
applyRule('blocked_by_draft_adr', validateREQsNotBlockedByDraftADRs(), violations, warnings)
|
|
1226
|
+
applyRule('adr_accepted_when_req_done', validateADRAcceptedWhenREQDone(), violations, warnings)
|
|
1134
1227
|
applyRule('note_orphan', validateNoteOrphan(), violations, warnings)
|
|
1135
1228
|
|
|
1136
1229
|
// Regras configuráveis via applyRule (popula _itemMeta automaticamente)
|
|
@@ -1187,10 +1280,60 @@ async function validate() {
|
|
|
1187
1280
|
return { violations, warnings }
|
|
1188
1281
|
}
|
|
1189
1282
|
|
|
1283
|
+
// ROADMAP_STATES enumera os 6 estados de roadmap na ordem exibida pelo bloco Inventory.
|
|
1284
|
+
const ROADMAP_STATES = ['backlog', 'analyzing', 'wip', 'blocked', 'done', 'abandoned']
|
|
1285
|
+
|
|
1286
|
+
// buildInventorySection monta o bloco "📊 Inventory" — contagem agregada de ADRs, REQs
|
|
1287
|
+
// (discriminadas por status real via reqStatusEquals) e roadmaps (pelos 6 estados, incluindo
|
|
1288
|
+
// "analyzing", historicamente omitido). Namespacing-agnóstico: agrega através de
|
|
1289
|
+
// resolveStateDirs()/resolveReqFiles(), que já resolvem flat vs by_agent.
|
|
1290
|
+
function buildInventorySection(cfg) {
|
|
1291
|
+
let adrCount = 0
|
|
1292
|
+
for (const adrDir of cfg.adrDirs || []) {
|
|
1293
|
+
adrCount += walkDirMdWithPathsForRule('status_inventory', adrDir, []).length
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const reqFiles = resolveReqFiles(cfg)
|
|
1297
|
+
let reqOpen = 0
|
|
1298
|
+
let reqDone = 0
|
|
1299
|
+
let reqClosed = 0
|
|
1300
|
+
for (const filePath of reqFiles) {
|
|
1301
|
+
let content
|
|
1302
|
+
try {
|
|
1303
|
+
content = fs.readFileSync(filePath, 'utf8')
|
|
1304
|
+
} catch (_) {
|
|
1305
|
+
continue
|
|
1306
|
+
}
|
|
1307
|
+
if (reqStatusEquals(content, 'open')) reqOpen++
|
|
1308
|
+
else if (reqStatusEquals(content, 'done')) reqDone++
|
|
1309
|
+
else if (reqStatusEquals(content, 'closed')) reqClosed++
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const roadmapCounts = {}
|
|
1313
|
+
let roadmapTotal = 0
|
|
1314
|
+
for (const state of ROADMAP_STATES) {
|
|
1315
|
+
let count = 0
|
|
1316
|
+
for (const dir of resolveStateDirs(cfg, state)) {
|
|
1317
|
+
count += listDir(dir).length
|
|
1318
|
+
}
|
|
1319
|
+
roadmapCounts[state] = count
|
|
1320
|
+
roadmapTotal += count
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
let section = '\n📊 Inventory\n'
|
|
1324
|
+
section += ` ${'ADRs'.padEnd(12)}${adrCount}\n`
|
|
1325
|
+
section += ` ${'REQs'.padEnd(12)}${reqFiles.length} (${reqOpen} Open · ${reqDone} Done · ${reqClosed} Closed)\n`
|
|
1326
|
+
section += ` ${'Roadmaps'.padEnd(12)}${roadmapTotal}\n`
|
|
1327
|
+
section += ` backlog ${roadmapCounts.backlog} · analyzing ${roadmapCounts.analyzing} · wip ${roadmapCounts.wip}\n`
|
|
1328
|
+
section += ` blocked ${roadmapCounts.blocked} · done ${roadmapCounts.done} · abandoned ${roadmapCounts.abandoned}\n`
|
|
1329
|
+
return section
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1190
1332
|
// getStatus retorna string formatada com o status de governança do projeto
|
|
1191
1333
|
async function getStatus() {
|
|
1192
1334
|
const cfg = config.load()
|
|
1193
1335
|
let out = '── trackfw status ──────────────────────\n'
|
|
1336
|
+
out += buildInventorySection(cfg)
|
|
1194
1337
|
|
|
1195
1338
|
if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
|
|
1196
1339
|
let agents = cfg.agents || []
|
|
@@ -1217,7 +1360,7 @@ async function getStatus() {
|
|
|
1217
1360
|
out += `\n🔄 WIP (${wip.length})\n`
|
|
1218
1361
|
for (const f of wip) out += ` ${f}\n`
|
|
1219
1362
|
|
|
1220
|
-
const wipCfg =
|
|
1363
|
+
const wipCfg = wipConfigFrom(cfg)
|
|
1221
1364
|
if (wipCfg.bySquad && wip.length > 0) {
|
|
1222
1365
|
const bySquad = {}
|
|
1223
1366
|
for (const f of wip) {
|
|
@@ -1242,14 +1385,19 @@ async function getStatus() {
|
|
|
1242
1385
|
for (const w of staleWIPs) out += ` ${w}\n`
|
|
1243
1386
|
}
|
|
1244
1387
|
|
|
1388
|
+
// Seção: REQs bloqueadas por ADRs não aceitos (Draft ou Proposed). O status exibido por
|
|
1389
|
+
// ADR é resolvido via adrNotAcceptedStatusForRule (helper canônico) em vez de hardcodar
|
|
1390
|
+
// "Draft" — blockedREQs() cobre ambos os status desde que delega em adrIsDraft/
|
|
1391
|
+
// adrDraftStatusForRule, e um rótulo fixo "(Draft)" mentiria para um ADR Proposed.
|
|
1245
1392
|
const blockedByDraft = blockedREQs()
|
|
1246
1393
|
const blockedKeys = Object.keys(blockedByDraft)
|
|
1247
1394
|
if (blockedKeys.length > 0) {
|
|
1248
|
-
out += `\n⏳ REQs blocked by
|
|
1395
|
+
out += `\n⏳ REQs blocked by not-accepted ADRs (${blockedKeys.length})\n`
|
|
1249
1396
|
for (const reqFile of blockedKeys) {
|
|
1250
1397
|
out += ` ${reqFile}\n`
|
|
1251
1398
|
for (const adr of blockedByDraft[reqFile]) {
|
|
1252
|
-
|
|
1399
|
+
const { status } = adrNotAcceptedStatusForRule('blocked_by_draft_adr', adr, null)
|
|
1400
|
+
out += ` → ${adr} (${status})\n`
|
|
1253
1401
|
}
|
|
1254
1402
|
}
|
|
1255
1403
|
}
|
|
@@ -1291,8 +1439,6 @@ module.exports = {
|
|
|
1291
1439
|
resolveStateDirs,
|
|
1292
1440
|
resolveWIPDirs,
|
|
1293
1441
|
resolveDoneDirs,
|
|
1294
|
-
readGovernanceMode,
|
|
1295
|
-
readWIPConfig,
|
|
1296
1442
|
parseSquadFromFrontmatter,
|
|
1297
1443
|
validateFrontmatterPresence,
|
|
1298
1444
|
// novas funções ML-1B
|
|
@@ -1316,4 +1462,12 @@ module.exports = {
|
|
|
1316
1462
|
isInsideDir,
|
|
1317
1463
|
walkDirMdWithPaths,
|
|
1318
1464
|
validateADRDirsExist,
|
|
1465
|
+
// novas funções ML-1B (2026-08-01 — adr_accepted_when_req_done)
|
|
1466
|
+
extractAdrHeaderStatus,
|
|
1467
|
+
adrNotAcceptedStatusForRule,
|
|
1468
|
+
reqStatusEquals,
|
|
1469
|
+
validateADRAcceptedWhenREQDone,
|
|
1470
|
+
// ML-1D (2026-08-01 — reconciliação de paridade: frontmatter-first)
|
|
1471
|
+
extractFrontmatterField,
|
|
1472
|
+
resolveAdrStatus,
|
|
1319
1473
|
}
|