trackfw 6.0.0 → 6.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.
- package/README.md +26 -0
- package/package.json +3 -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/roadmap.js +14 -2
- package/src/serve/static/app.js +295 -18
- package/src/serve/static/index.html +106 -6
- package/src/serve/static/style.css +79 -0
- package/src/validator/index.js +222 -68
package/README.md
CHANGED
|
@@ -248,6 +248,32 @@ governance_mode: strict # CI fails on any violation
|
|
|
248
248
|
# governance_mode: lenient # CI passes with warnings only
|
|
249
249
|
```
|
|
250
250
|
|
|
251
|
+
### `update` and `sync` configuration fields
|
|
252
|
+
|
|
253
|
+
`trackfw update` and `trackfw sync` read the following keys, flat at the root of `trackfw.yaml`, through
|
|
254
|
+
the same loader used by every other command. All default to an empty string when absent.
|
|
255
|
+
|
|
256
|
+
```yaml
|
|
257
|
+
# trackfw.yaml — consumed by `trackfw update`
|
|
258
|
+
hooks: husky # husky | lefthook | native | "" (no hooks regenerated)
|
|
259
|
+
ci: github # github | gitlab | "" (no CI workflow regenerated)
|
|
260
|
+
backend: node # backend stack — informs CLAUDE.md/agent stack sections and hook commands
|
|
261
|
+
frontend: react # frontend stack — same as backend
|
|
262
|
+
pkg_manager: npm # npm | yarn | pnpm | ... — composes build/test commands in generated hooks
|
|
263
|
+
|
|
264
|
+
# trackfw.yaml — consumed by `trackfw sync` (checked before the matching env var, same order in all 3 CLIs)
|
|
265
|
+
linear_api_key: ""
|
|
266
|
+
linear_team_id: ""
|
|
267
|
+
jira_base_url: ""
|
|
268
|
+
jira_email: ""
|
|
269
|
+
jira_token: ""
|
|
270
|
+
jira_project: ""
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Full contract, including per-field consumers and the intentional exception for generated Git hooks
|
|
274
|
+
(which read `roadmap_dir` with their own `grep`/`sed`, since they run without the `trackfw` binary
|
|
275
|
+
present): `docs/cli-parity.md` → `## trackfw update vs trackfw update harness`.
|
|
276
|
+
|
|
251
277
|
---
|
|
252
278
|
|
|
253
279
|
## REQ-driven ADR discovery
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, 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/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
|
+
};
|
|
@@ -419,10 +419,12 @@ function newRoadmap(title, reqPath) {
|
|
|
419
419
|
const filename = `${backlogDir}/ROADMAP-${date}-${slug}.md`
|
|
420
420
|
fs.mkdirSync(backlogDir, { recursive: true })
|
|
421
421
|
|
|
422
|
+
const reqField = reqPath ? `"${reqPath}"` : '""'
|
|
423
|
+
|
|
422
424
|
const body = `---
|
|
423
425
|
status: backlog
|
|
424
426
|
date: ${date}
|
|
425
|
-
req:
|
|
427
|
+
req: ${reqField}
|
|
426
428
|
squad: ""
|
|
427
429
|
---
|
|
428
430
|
|
|
@@ -434,6 +436,11 @@ squad: ""
|
|
|
434
436
|
<!-- What problem does this roadmap solve? Link the REQ. -->
|
|
435
437
|
REQ: ${reqPath || ''}
|
|
436
438
|
|
|
439
|
+
## Acceptance Criteria
|
|
440
|
+
<!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
|
|
441
|
+
- [ ]
|
|
442
|
+
- [ ]
|
|
443
|
+
|
|
437
444
|
## Wave 1 — <name> (parallel MLs)
|
|
438
445
|
> Dependencies: none
|
|
439
446
|
|
|
@@ -509,7 +516,7 @@ function newRoadmapFromReq(reqPath) {
|
|
|
509
516
|
const body = `---
|
|
510
517
|
status: backlog
|
|
511
518
|
date: ${date}
|
|
512
|
-
req: "${
|
|
519
|
+
req: "${reqPath}"
|
|
513
520
|
squad: ""
|
|
514
521
|
---
|
|
515
522
|
|
|
@@ -521,6 +528,11 @@ squad: ""
|
|
|
521
528
|
<!-- Derived from REQ: ${basename} -->
|
|
522
529
|
REQ: ${reqPath}${adrRef}
|
|
523
530
|
|
|
531
|
+
## Acceptance Criteria
|
|
532
|
+
<!-- Consolidated criteria for this roadmap. Detail per ML in the waves below. -->
|
|
533
|
+
- [ ]
|
|
534
|
+
- [ ]
|
|
535
|
+
|
|
524
536
|
${mlSection}
|
|
525
537
|
`
|
|
526
538
|
|