sinapse-ai 1.20.1 → 1.21.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/.sinapse-ai/core/orchestration/brownfield-progress.js +219 -0
- package/.sinapse-ai/core/orchestration/doc-first-resolver.js +3 -0
- package/.sinapse-ai/core/orchestration/greenfield-handler.js +97 -0
- package/.sinapse-ai/core/orchestration/workflow-executor.js +17 -1
- package/.sinapse-ai/data/entity-registry.yaml +39 -14
- package/.sinapse-ai/development/tasks/execute-epic-plan.md +3 -0
- package/.sinapse-ai/development/workflows/brownfield-discovery.yaml +16 -0
- package/.sinapse-ai/development/workflows/epic-orchestration.yaml +11 -0
- package/.sinapse-ai/install-manifest.yaml +20 -16
- package/CHANGELOG.md +29 -14
- package/package.json +3 -1
- package/scripts/eval-e2e.js +149 -0
- package/scripts/validate-all.js +3 -0
- package/scripts/validate-article-iv.js +289 -0
- package/scripts/validate-evals.js +9 -2
- package/scripts/wave-gate.js +207 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
## [1.
|
|
1
|
+
## [1.21.0](https://github.com/caioimori/sinapse-ai/compare/1.20.1...1.21.0) (2026-07-04)
|
|
2
2
|
|
|
3
|
-
###
|
|
4
|
-
|
|
5
|
-
* **security:** Onda A da triagem AF-20260703 — 4 fixes cirúrgicos ([#337](https://github.com/caioimori/sinapse-ai/issues/337)) ([39f3338](https://github.com/caioimori/sinapse-ai/commit/39f33384aea83df4f609c91eec1ef25acce30d29)), closes [#1](https://github.com/caioimori/sinapse-ai/issues/1) [524-#528](https://github.com/caioimori/524-/issues/528) [#332](https://github.com/caioimori/sinapse-ai/issues/332) [#511](https://github.com/caioimori/sinapse-ai/issues/511) [#32](https://github.com/caioimori/sinapse-ai/issues/32)
|
|
6
|
-
* **security:** Onda B1 — TOCTOU na cadeia de confiança do instalador + git sem shell ([#340](https://github.com/caioimori/sinapse-ai/issues/340)) ([6f0a72b](https://github.com/caioimori/sinapse-ai/commit/6f0a72b89f8a72df741d5e7c94e89c2d3bd7bfd8)), closes [19/#20](https://github.com/19/sinapse-ai/issues/20) [15-#18](https://github.com/caioimori/15-/issues/18) [#22](https://github.com/caioimori/sinapse-ai/issues/22) [#507](https://github.com/caioimori/sinapse-ai/issues/507)
|
|
7
|
-
* **security:** Onda B2 — escrita atômica em todo o caminho de instalação ([#341](https://github.com/caioimori/sinapse-ai/issues/341)) ([0c90216](https://github.com/caioimori/sinapse-ai/commit/0c9021628526ff7cf5ef3dcf41efae3dc629e20a))
|
|
8
|
-
|
|
9
|
-
### Documentation
|
|
3
|
+
### Features
|
|
10
4
|
|
|
11
|
-
* **
|
|
12
|
-
* **
|
|
5
|
+
* **constitution:** Artigo IV ganha dente determinístico — traceability arquivo→AC ([#346](https://github.com/caioimori/sinapse-ai/issues/346)) ([9c0f991](https://github.com/caioimori/sinapse-ai/commit/9c0f991fda8e456d85e9338c904575fda13a907b))
|
|
6
|
+
* **evals:** golden set comportamental do gate de épicos + eval:e2e no gate de merge [Story onda3-s4-behavioral-eval-regression] ([#349](https://github.com/caioimori/sinapse-ai/issues/349)) ([4c22695](https://github.com/caioimori/sinapse-ai/commit/4c226958bfa830f83c813f1ccf28bdf46f161c35))
|
|
7
|
+
* **orchestration:** brownfield discovery ganha progresso determinístico + gate QA em código [Story onda3-s3-brownfield-progress-gate] ([#348](https://github.com/caioimori/sinapse-ai/issues/348)) ([8b3930e](https://github.com/caioimori/sinapse-ai/commit/8b3930e0b27a4431b6d21ad60832759bbd7ce74d))
|
|
8
|
+
* **orchestration:** gates determinísticos de artefato na Phase 1 do greenfield ([#347](https://github.com/caioimori/sinapse-ai/issues/347)) ([ca61c0f](https://github.com/caioimori/sinapse-ai/commit/ca61c0f8c65d89a5cd8e2ff0c381330f25ee3c6f)), closes [#317](https://github.com/caioimori/sinapse-ai/issues/317)
|
|
9
|
+
* **orchestration:** wave-gate executável + piloto medido de epic waves — REPROVADO pelo critério pré-registrado [Story onda3-s5-epic-waves-wrapper-pilot] ([#350](https://github.com/caioimori/sinapse-ai/issues/350)) ([172cd86](https://github.com/caioimori/sinapse-ai/commit/172cd86004adfb796c8e4eea18d992940c42aca3))
|
|
13
10
|
|
|
14
11
|
### Maintenance
|
|
15
12
|
|
|
16
|
-
*
|
|
17
|
-
* **
|
|
18
|
-
* **release:** sync da main pós-release v1.20.0 ([#336](https://github.com/caioimori/sinapse-ai/issues/336)) ([b3973f0](https://github.com/caioimori/sinapse-ai/commit/b3973f0223173ce570b9ae49878f2d278dc257f8)), closes [#277](https://github.com/caioimori/sinapse-ai/issues/277)
|
|
19
|
-
* **security:** Onda C — higiene do code scanning + 5 fixes de qualidade em produção ([#339](https://github.com/caioimori/sinapse-ai/issues/339)) ([13ae8dd](https://github.com/caioimori/sinapse-ai/commit/13ae8ddd314d2ab923ab019b4e0994be4e1627a1)), closes [#515](https://github.com/caioimori/sinapse-ai/issues/515) [#2](https://github.com/caioimori/sinapse-ai/issues/2) [#409](https://github.com/caioimori/sinapse-ai/issues/409) [#226](https://github.com/caioimori/sinapse-ai/issues/226) [401/#402](https://github.com/401/sinapse-ai/issues/402) [#228](https://github.com/caioimori/sinapse-ai/issues/228)
|
|
13
|
+
* regenera manifests pós-consolidação da Onda 3 (S1-S5 na main) [Story onda3-s5-epic-waves-wrapper-pilot] ([#351](https://github.com/caioimori/sinapse-ai/issues/351)) ([302e99f](https://github.com/caioimori/sinapse-ai/commit/302e99fdcbca44bf8ec56064632e01ffd66fd40e)), closes [346-#350](https://github.com/caioimori/346-/issues/350)
|
|
14
|
+
* **release:** sync da main pós-release 1.20.1 ([#345](https://github.com/caioimori/sinapse-ai/issues/345)) ([4bd61b4](https://github.com/caioimori/sinapse-ai/commit/4bd61b4c7cbdc06b76b0dec6b300a8fb3be5f06a)), closes [#277](https://github.com/caioimori/sinapse-ai/issues/277)
|
|
20
15
|
|
|
21
16
|
# Changelog
|
|
22
17
|
|
|
@@ -27,6 +22,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
27
22
|
|
|
28
23
|
## [Unreleased]
|
|
29
24
|
|
|
25
|
+
## [1.20.1] — 2026-07-03 — 🛡️ Segurança em 3 ondas + ativação enxuta + corte do cluster órfão (DEC-03)
|
|
26
|
+
|
|
27
|
+
> Patch. Atualização segura via `npx sinapse-ai update`. Fecha a execução da mesa de decisões da otimização: PRs #337–#344.
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
- **security:** Onda A da triagem AF-20260703 — 4 fixes cirúrgicos: CodeQL volta a analisar `actions`, escape de HTML na fonte geradora do atlas, âncoras agrupadas no secret-scan (equivalência validada com 20.030 casos), sanitização de log no updater (#337)
|
|
32
|
+
- **security:** Onda B1 — TOCTOU eliminado na cadeia de confiança do instalador (manifest-signature/file-hasher/post-install-validator em file descriptor único) + git sem shell no gate do Artigo XI (#340)
|
|
33
|
+
- **security:** Onda B2 — escrita atômica (tmp+rename) em ~30 pontos/21 arquivos de todo o caminho de instalação (#341)
|
|
34
|
+
|
|
35
|
+
### Maintenance
|
|
36
|
+
|
|
37
|
+
- **security:** Onda C — higiene do code scanning (`paths-ignore` de testes fecha ~366 alertas de teste na análise da main) + 2 falso-positivos dismissados com evidência + os 5 únicos fixes de qualidade fora de testes (#339)
|
|
38
|
+
- **agents:** rollout da ativação enxuta pós-piloto — 22 arquivos (9 núcleo + 8 mastery + 4 locais + template), −717/+374 linhas, zero coerção restante (#342)
|
|
39
|
+
- **cleanup:** DEC-03 — remoção do cluster multi-story órfão (~4,4k linhas de código morto shipped: wave-executor, parallel-monitor, context-injector, semantic-merge-engine, parallel-executor de execution/, wave-analyzer + task `*waves`) com split DEC-02 preservando suggestion-engine/learning; registries e manifests regenerados (#344)
|
|
40
|
+
|
|
41
|
+
### Documentation
|
|
42
|
+
|
|
43
|
+
- **decisions:** mesa de decisões registra Ondas A/B/C de segurança, rollout da ativação enxuta e execução de DEC-02/DEC-03 (#338, #343)
|
|
44
|
+
|
|
30
45
|
## [1.20.0] — 2026-07-03 — 🧠 Ciclo Fable 5: dieta de contexto, spec/plan de 1ª classe, era de modelo sincronizada
|
|
31
46
|
|
|
32
47
|
> Minor. Atualização segura via `npx sinapse-ai update`. Consolida o ciclo de upgrade Fable 5 (auditoria AF-20260702 → Onda 1 → Onda 2 → mesa de decisões): 15 PRs (#321–#335).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sinapse-ai",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.21.0",
|
|
4
4
|
"description": "SINAPSE AI: Framework de orquestracao de IA — 17 squads, 172 agentes especializados",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sinapse": "bin/sinapse.js",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"test:coverage": "jest --coverage",
|
|
87
87
|
"test:health-check": "jest tests/health-check",
|
|
88
88
|
"eval": "node scripts/eval-runner.js",
|
|
89
|
+
"eval:e2e": "node scripts/eval-e2e.js",
|
|
89
90
|
"validate:evals": "node scripts/validate-evals.js",
|
|
90
91
|
"validate:schemas": "node scripts/validate-schemas.js",
|
|
91
92
|
"lint": "eslint . --cache --cache-location .eslintcache",
|
|
@@ -97,6 +98,7 @@
|
|
|
97
98
|
"validate:article-vii": "node scripts/validate-article-vii.js",
|
|
98
99
|
"validate:article-viii": "node scripts/validate-article-viii.js",
|
|
99
100
|
"validate:article-xi": "node scripts/validate-article-xi.js",
|
|
101
|
+
"validate:article-iv": "node scripts/validate-article-iv.js",
|
|
100
102
|
"reconcile:squads": "node scripts/reconcile-squad-manifests.js",
|
|
101
103
|
"apply:persona-disclaimer": "node scripts/apply-persona-disclaimer.js",
|
|
102
104
|
"validate:manifest": "node scripts/validate-manifest.js",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* eval-e2e.js — behavioral regression eval for the epic GateEvaluator.
|
|
5
|
+
*
|
|
6
|
+
* Story onda3-s4-behavioral-eval-regression (AF-20260702 item 3.1).
|
|
7
|
+
*
|
|
8
|
+
* Two layers:
|
|
9
|
+
*
|
|
10
|
+
* DETERMINISTIC (always runs, merge gate): replays the versioned golden set
|
|
11
|
+
* (tests/evals/epic-gates/cases.json) against the REAL orchestration
|
|
12
|
+
* GateEvaluator with the DEFAULT gate config pinned (repo-local overrides in
|
|
13
|
+
* core-config.yaml never bend the baseline). Every case is a measured bug
|
|
14
|
+
* from the 2026-06-30 checkpoints — or a control case. Any mismatch = exit 1.
|
|
15
|
+
*
|
|
16
|
+
* LLM ARM PREFLIGHT (opt-in: --llm or SINAPSE_EVAL_LLM=1): verifies that a
|
|
17
|
+
* `claude` binary is spawnable and prints the exact 2-arm measurement
|
|
18
|
+
* protocol commands (tests/evals/epic-gates/PROTOCOL.md). When the spawn is
|
|
19
|
+
* unavailable (known Windows nested-spawn limitation, KNOWN-LIMITATIONS of
|
|
20
|
+
* epic-orchestration-consolidation) it reports EVAL_LLM_SKIPPED with the
|
|
21
|
+
* reason — an explicit skip, never a fake pass.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* npm run eval:e2e
|
|
25
|
+
* node scripts/eval-e2e.js [--llm]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { execFileSync } = require('child_process');
|
|
33
|
+
|
|
34
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
35
|
+
const CASES_PATH = path.join(ROOT, 'tests', 'evals', 'epic-gates', 'cases.json');
|
|
36
|
+
const PROTOCOL_PATH = path.join('tests', 'evals', 'epic-gates', 'PROTOCOL.md');
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
GateEvaluator,
|
|
40
|
+
DEFAULT_GATE_CONFIG,
|
|
41
|
+
} = require(path.join(ROOT, '.sinapse-ai', 'core', 'orchestration', 'gate-evaluator'));
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Assert one golden case against a live gate result.
|
|
45
|
+
* @returns {string[]} List of mismatch descriptions (empty = pass)
|
|
46
|
+
*/
|
|
47
|
+
function assertCase(caseObj, result) {
|
|
48
|
+
const failures = [];
|
|
49
|
+
const expect = caseObj.expect || {};
|
|
50
|
+
|
|
51
|
+
if (expect.verdict && result.verdict !== expect.verdict) {
|
|
52
|
+
failures.push(`verdict: expected "${expect.verdict}", got "${result.verdict}"`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (typeof expect.scoreEquals === 'number' && Math.abs(result.score - expect.scoreEquals) > 0.01) {
|
|
56
|
+
failures.push(`score: expected ${expect.scoreEquals}, got ${result.score}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const name of expect.failedChecks || []) {
|
|
60
|
+
const check = result.checks.find((c) => c.name === name);
|
|
61
|
+
if (!check) failures.push(`failed check "${name}": not evaluated at all`);
|
|
62
|
+
else if (check.passed) failures.push(`failed check "${name}": expected FAIL, got PASS`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const name of expect.passedChecks || []) {
|
|
66
|
+
const check = result.checks.find((c) => c.name === name);
|
|
67
|
+
if (!check) failures.push(`passed check "${name}": not evaluated at all`);
|
|
68
|
+
else if (!check.passed) failures.push(`passed check "${name}": expected PASS, got FAIL (${check.message})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const name of expect.issues || []) {
|
|
72
|
+
if (!result.issues.some((i) => i.check === name)) {
|
|
73
|
+
failures.push(`issue "${name}": expected present, not found`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return failures;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runDeterministicLayer() {
|
|
81
|
+
const suite = JSON.parse(fs.readFileSync(CASES_PATH, 'utf8'));
|
|
82
|
+
console.log(`=== eval:e2e — ${suite.suite} (${suite.cases.length} cases) ===`);
|
|
83
|
+
|
|
84
|
+
let failedCases = 0;
|
|
85
|
+
for (const caseObj of suite.cases) {
|
|
86
|
+
// Fresh evaluator per case; DEFAULT config pinned so repo-local
|
|
87
|
+
// core-config.yaml overrides can never bend the behavioral baseline.
|
|
88
|
+
const evaluator = new GateEvaluator({
|
|
89
|
+
projectRoot: ROOT,
|
|
90
|
+
strictMode: Boolean(caseObj.strictMode),
|
|
91
|
+
gateConfig: DEFAULT_GATE_CONFIG,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await evaluator.evaluate(caseObj.gate.from, caseObj.gate.to, caseObj.epicResult);
|
|
95
|
+
const failures = assertCase(caseObj, result);
|
|
96
|
+
|
|
97
|
+
if (failures.length === 0) {
|
|
98
|
+
console.log(` PASS ${caseObj.id}`);
|
|
99
|
+
} else {
|
|
100
|
+
failedCases += 1;
|
|
101
|
+
console.log(` FAIL ${caseObj.id}`);
|
|
102
|
+
for (const f of failures) console.log(` - ${f}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('');
|
|
107
|
+
if (failedCases > 0) {
|
|
108
|
+
console.log(`eval:e2e: FAIL — ${failedCases}/${suite.cases.length} behavioral case(s) regressed.`);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
console.log(`eval:e2e: OK — all ${suite.cases.length} behavioral cases passed.`);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function runLlmArmPreflight() {
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log('=== eval:e2e — LLM arm preflight ===');
|
|
118
|
+
try {
|
|
119
|
+
const version = execFileSync('claude', ['--version'], {
|
|
120
|
+
encoding: 'utf8',
|
|
121
|
+
timeout: 30000,
|
|
122
|
+
shell: process.platform === 'win32',
|
|
123
|
+
}).trim();
|
|
124
|
+
console.log(`claude spawnable: ${version}`);
|
|
125
|
+
console.log(`Protocolo de medição 2 braços: ${PROTOCOL_PATH}`);
|
|
126
|
+
console.log('Execute os braços conforme o protocolo e registre o scoreboard no checkpoint.');
|
|
127
|
+
return true;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.log(`EVAL_LLM_SKIPPED: claude não spawnável daqui (${error.message.split('\n')[0]}).`);
|
|
130
|
+
console.log('Motivo conhecido no Windows: spawn aninhado falha (KNOWN-LIMITATIONS,');
|
|
131
|
+
console.log('epic-orchestration-consolidation). Rode o braço LLM via harness ou em');
|
|
132
|
+
console.log(`ambiente com spawn direto, seguindo ${PROTOCOL_PATH}.`);
|
|
133
|
+
return true; // skip explícito não é falha — mas nunca é reportado como PASS do braço
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function main() {
|
|
138
|
+
const wantLlm = process.argv.includes('--llm') || process.env.SINAPSE_EVAL_LLM === '1';
|
|
139
|
+
|
|
140
|
+
const deterministicOk = await runDeterministicLayer();
|
|
141
|
+
if (wantLlm) runLlmArmPreflight();
|
|
142
|
+
|
|
143
|
+
process.exit(deterministicOk ? 0 : 1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
main().catch((error) => {
|
|
147
|
+
console.error(`eval:e2e crashed: ${error.message}`);
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
package/scripts/validate-all.js
CHANGED
|
@@ -55,6 +55,9 @@ const GUARDS = [
|
|
|
55
55
|
{ name: 'squad-yaml', script: 'validate:squad-yaml' },
|
|
56
56
|
{ name: 'squad-orqx', script: 'validate:squad-orqx' },
|
|
57
57
|
{ name: 'agent-codenames', script: 'validate:agent-codenames' },
|
|
58
|
+
// Article IV traceability — WARN-only during calibration: the script itself
|
|
59
|
+
// exits 0 on orphans unless ARTICLE_IV_STRICT=1 (see validate-article-iv.js).
|
|
60
|
+
{ name: 'article-iv', script: 'validate:article-iv' },
|
|
58
61
|
];
|
|
59
62
|
|
|
60
63
|
/**
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* validate-article-iv.js — Constitution Article IV (No Invention) traceability check.
|
|
5
|
+
*
|
|
6
|
+
* Story onda3-s1-article-iv-traceability (AF-20260702 item 3.2).
|
|
7
|
+
*
|
|
8
|
+
* What it does (deterministic, no LLM):
|
|
9
|
+
* 1. Resolves the push range: merge-base(HEAD, origin/main)..HEAD.
|
|
10
|
+
* 2. Extracts story refs ("[Story xyz]") from commit messages in the range.
|
|
11
|
+
* 3. Locates each referenced story under docs/stories/ and extracts its
|
|
12
|
+
* acceptance criteria ("- [ ] ACn:") and its "## File List" section.
|
|
13
|
+
* 4. Compares changed PRODUCT files against the union of the stories'
|
|
14
|
+
* File Lists. A changed product file mapped by no story is an ORPHAN.
|
|
15
|
+
*
|
|
16
|
+
* Calibration mode (default): orphans are WARNINGS, exit 0. Pass --strict
|
|
17
|
+
* (or ARTICLE_IV_STRICT=1) to turn orphans into a failure (exit 1). The
|
|
18
|
+
* warning phase exists to calibrate the matcher before it gets teeth —
|
|
19
|
+
* mirrors the rollout the audit prescribed ("órfão começa como warning").
|
|
20
|
+
*
|
|
21
|
+
* Why this runs at pre-push and NOT in GitHub CI: docs/stories/ is local-only
|
|
22
|
+
* (gitignored by design — stories are working artifacts). CI has no stories
|
|
23
|
+
* to trace against, so the only honest home for this check is the developer
|
|
24
|
+
* machine, where the stories exist. When stories are unavailable the check
|
|
25
|
+
* reports ARTICLE_IV_SKIPPED and exits 0 — an explicit skip, never a fake pass.
|
|
26
|
+
*
|
|
27
|
+
* Usage:
|
|
28
|
+
* npm run validate:article-iv
|
|
29
|
+
* node scripts/validate-article-iv.js [--strict] [--base <ref>]
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
'use strict';
|
|
33
|
+
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
35
|
+
const fs = require('fs');
|
|
36
|
+
const path = require('path');
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
39
|
+
|
|
40
|
+
// Prefixes that count as product surface for traceability purposes.
|
|
41
|
+
// Aligned with enforce-story-gate.cjs CODE_PATHS plus the framework tree
|
|
42
|
+
// (.sinapse-ai) and scripts/, which are product in this repo.
|
|
43
|
+
const PRODUCT_PREFIXES = [
|
|
44
|
+
'packages/',
|
|
45
|
+
'src/',
|
|
46
|
+
'app/',
|
|
47
|
+
'lib/',
|
|
48
|
+
'bin/',
|
|
49
|
+
'components/',
|
|
50
|
+
'pages/',
|
|
51
|
+
'api/',
|
|
52
|
+
'services/',
|
|
53
|
+
'scripts/',
|
|
54
|
+
'.sinapse-ai/',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// Never traced: docs, tests, mirrors, CI plumbing and generated artifacts —
|
|
58
|
+
// they either have their own governance or are regenerated mechanically.
|
|
59
|
+
const EXCLUDED_PREFIXES = [
|
|
60
|
+
'docs/',
|
|
61
|
+
'tests/',
|
|
62
|
+
'.claude/',
|
|
63
|
+
'.codex/',
|
|
64
|
+
'.github/',
|
|
65
|
+
'audits/',
|
|
66
|
+
'governance/',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const GENERATED_FILES = [
|
|
70
|
+
'.sinapse-ai/install-manifest.yaml',
|
|
71
|
+
'.sinapse-ai/data/entity-registry.yaml',
|
|
72
|
+
'.sinapse-ai/manifests/agents.csv',
|
|
73
|
+
'.sinapse-ai/manifests/workers.csv',
|
|
74
|
+
'.sinapse-ai/manifests/tasks.csv',
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const STORY_REF_RE = /\[story\s+([^\]]+)\]/gi;
|
|
78
|
+
const AC_RE = /^- \[( |x)\] (AC\d+):\s*(.*)$/gim;
|
|
79
|
+
|
|
80
|
+
/** Extract unique story refs ("[Story 2.1]" → "2.1") from free text. */
|
|
81
|
+
function extractStoryRefs(text) {
|
|
82
|
+
const refs = new Set();
|
|
83
|
+
let m;
|
|
84
|
+
STORY_REF_RE.lastIndex = 0;
|
|
85
|
+
while ((m = STORY_REF_RE.exec(text)) !== null) {
|
|
86
|
+
const ref = m[1].trim();
|
|
87
|
+
if (ref) refs.add(ref);
|
|
88
|
+
}
|
|
89
|
+
return [...refs];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extract acceptance criteria from story markdown. */
|
|
93
|
+
function extractAcceptanceCriteria(storyContent) {
|
|
94
|
+
const acs = [];
|
|
95
|
+
let m;
|
|
96
|
+
AC_RE.lastIndex = 0;
|
|
97
|
+
while ((m = AC_RE.exec(storyContent)) !== null) {
|
|
98
|
+
acs.push({ id: m[2].toUpperCase(), checked: m[1] === 'x', text: m[3].trim() });
|
|
99
|
+
}
|
|
100
|
+
return acs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract path-ish entries from the story's "## File List" section.
|
|
105
|
+
* Accepts list items like "- path/to/file.js (novo)" — the first
|
|
106
|
+
* whitespace-delimited token of each bullet is taken as the path.
|
|
107
|
+
*/
|
|
108
|
+
function extractFileList(storyContent) {
|
|
109
|
+
const lines = storyContent.split(/\r?\n/);
|
|
110
|
+
const entries = [];
|
|
111
|
+
let inSection = false;
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (/^##\s+file list\s*$/i.test(line.trim())) {
|
|
114
|
+
inSection = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (inSection && /^##\s+/.test(line.trim())) break;
|
|
118
|
+
if (!inSection) continue;
|
|
119
|
+
const bullet = line.trim().match(/^[-*]\s+(.*)$/);
|
|
120
|
+
if (!bullet) continue;
|
|
121
|
+
const token = bullet[1].trim().split(/\s+/)[0];
|
|
122
|
+
if (token) entries.push(token.replace(/\\/g, '/').replace(/[,;]$/, ''));
|
|
123
|
+
}
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when a changed path counts as traceable product surface. */
|
|
128
|
+
function isProductFile(rel) {
|
|
129
|
+
const norm = rel.replace(/\\/g, '/');
|
|
130
|
+
if (GENERATED_FILES.includes(norm)) return false;
|
|
131
|
+
if (EXCLUDED_PREFIXES.some((p) => norm.startsWith(p))) return false;
|
|
132
|
+
return PRODUCT_PREFIXES.some((p) => norm.startsWith(p));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Match changed product files against File List entries.
|
|
137
|
+
* A file is mapped when some entry equals it, is a prefix directory of it,
|
|
138
|
+
* or the file path ends with the entry (tolerates entries written relative).
|
|
139
|
+
*/
|
|
140
|
+
function matchFilesToStory(changedProductFiles, fileListEntries) {
|
|
141
|
+
const norm = (s) => s.replace(/\\/g, '/').toLowerCase();
|
|
142
|
+
const entries = fileListEntries.map(norm);
|
|
143
|
+
const mapped = [];
|
|
144
|
+
const orphans = [];
|
|
145
|
+
for (const file of changedProductFiles) {
|
|
146
|
+
const f = norm(file);
|
|
147
|
+
const hit = entries.some(
|
|
148
|
+
(e) => e === f || f.startsWith(e.endsWith('/') ? e : `${e}/`) || f.endsWith(e),
|
|
149
|
+
);
|
|
150
|
+
(hit ? mapped : orphans).push(file);
|
|
151
|
+
}
|
|
152
|
+
return { mapped, orphans };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Recursively collect .md files under a directory. */
|
|
156
|
+
function walkMarkdown(dir) {
|
|
157
|
+
const out = [];
|
|
158
|
+
let items = [];
|
|
159
|
+
try {
|
|
160
|
+
items = fs.readdirSync(dir, { withFileTypes: true });
|
|
161
|
+
} catch {
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
for (const item of items) {
|
|
165
|
+
const full = path.join(dir, item.name);
|
|
166
|
+
if (item.isDirectory()) out.push(...walkMarkdown(full));
|
|
167
|
+
else if (item.name.endsWith('.md')) out.push(full);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Find the story file for a ref: filename containing the slugged ref, or
|
|
174
|
+
* story content containing the literal "[Story <ref>]".
|
|
175
|
+
*/
|
|
176
|
+
function findStoryFile(storiesDir, ref) {
|
|
177
|
+
const slug = ref.toLowerCase().replace(/\s+/g, '-');
|
|
178
|
+
const files = walkMarkdown(storiesDir);
|
|
179
|
+
for (const file of files) {
|
|
180
|
+
const base = path.basename(file, '.md').toLowerCase();
|
|
181
|
+
if (base.includes(slug)) return file;
|
|
182
|
+
}
|
|
183
|
+
const needle = `[story ${ref.toLowerCase()}]`;
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
try {
|
|
186
|
+
if (fs.readFileSync(file, 'utf8').toLowerCase().includes(needle)) return file;
|
|
187
|
+
} catch {
|
|
188
|
+
/* unreadable file — keep scanning */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function git(args) {
|
|
195
|
+
return execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function main() {
|
|
199
|
+
const argv = process.argv.slice(2);
|
|
200
|
+
const strict = argv.includes('--strict') || process.env.ARTICLE_IV_STRICT === '1';
|
|
201
|
+
const baseIdx = argv.indexOf('--base');
|
|
202
|
+
const baseRef = baseIdx !== -1 ? argv[baseIdx + 1] : process.env.ARTICLE_IV_BASE || 'origin/main';
|
|
203
|
+
|
|
204
|
+
let mergeBase;
|
|
205
|
+
try {
|
|
206
|
+
mergeBase = git(['merge-base', 'HEAD', baseRef]).trim();
|
|
207
|
+
} catch {
|
|
208
|
+
console.log(`ARTICLE_IV_SKIPPED: cannot resolve merge-base with ${baseRef} (no remote?).`);
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const changed = git(['diff', '--name-only', `${mergeBase}..HEAD`])
|
|
213
|
+
.split(/\r?\n/)
|
|
214
|
+
.filter(Boolean);
|
|
215
|
+
const commitText = git(['log', '--format=%B', `${mergeBase}..HEAD`]);
|
|
216
|
+
|
|
217
|
+
const refs = extractStoryRefs(commitText);
|
|
218
|
+
if (refs.length === 0) {
|
|
219
|
+
console.log('article-iv: no story refs in push range — nothing to trace.');
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const storiesDir = path.join(ROOT, 'docs', 'stories');
|
|
224
|
+
if (!fs.existsSync(storiesDir)) {
|
|
225
|
+
console.log('ARTICLE_IV_SKIPPED: docs/stories unavailable (local-only artifact).');
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const fileListUnion = [];
|
|
230
|
+
const foundStories = [];
|
|
231
|
+
const missingRefs = [];
|
|
232
|
+
for (const ref of refs) {
|
|
233
|
+
const storyFile = findStoryFile(storiesDir, ref);
|
|
234
|
+
if (!storyFile) {
|
|
235
|
+
missingRefs.push(ref);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const content = fs.readFileSync(storyFile, 'utf8');
|
|
239
|
+
foundStories.push({ ref, file: path.relative(ROOT, storyFile), acs: extractAcceptanceCriteria(content).length });
|
|
240
|
+
fileListUnion.push(...extractFileList(content));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (foundStories.length === 0) {
|
|
244
|
+
console.log(
|
|
245
|
+
`ARTICLE_IV_SKIPPED: no referenced story found locally (refs: ${refs.join(', ')}).`,
|
|
246
|
+
);
|
|
247
|
+
process.exit(0);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const productChanged = changed.filter(isProductFile);
|
|
251
|
+
const { mapped, orphans } = matchFilesToStory(productChanged, fileListUnion);
|
|
252
|
+
|
|
253
|
+
console.log('=== article-iv traceability ===');
|
|
254
|
+
for (const s of foundStories) {
|
|
255
|
+
console.log(`story [${s.ref}] -> ${s.file} (${s.acs} ACs)`);
|
|
256
|
+
}
|
|
257
|
+
if (missingRefs.length) {
|
|
258
|
+
console.log(`refs without local story (skipped): ${missingRefs.join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
console.log(`product files changed: ${productChanged.length} | mapped: ${mapped.length} | orphans: ${orphans.length}`);
|
|
261
|
+
|
|
262
|
+
if (orphans.length > 0) {
|
|
263
|
+
console.log('');
|
|
264
|
+
console.log('ORPHAN product files (changed but absent from every referenced File List):');
|
|
265
|
+
for (const f of orphans) console.log(` WARN ${f}`);
|
|
266
|
+
console.log('');
|
|
267
|
+
console.log('Fix: add the file to the story\'s "## File List", or move the change to its own story.');
|
|
268
|
+
if (strict) {
|
|
269
|
+
console.log('article-iv: FAIL (strict mode).');
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
console.log('article-iv: WARN-only (calibration mode) — not blocking.');
|
|
273
|
+
process.exit(0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
console.log('article-iv: OK — full traceability.');
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
extractStoryRefs,
|
|
282
|
+
extractAcceptanceCriteria,
|
|
283
|
+
extractFileList,
|
|
284
|
+
isProductFile,
|
|
285
|
+
matchFilesToStory,
|
|
286
|
+
findStoryFile,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
if (require.main === module) main();
|
|
@@ -146,7 +146,14 @@ function isPlainObject(v) {
|
|
|
146
146
|
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Suites that follow their OWN contract (not the IDS evals.json/triggers.json
|
|
151
|
+
* one) and have their own runner + jest wrapper. Onda3-S4: epic-gates uses
|
|
152
|
+
* cases.json validated by scripts/eval-e2e.js.
|
|
153
|
+
*/
|
|
154
|
+
const NON_IDS_SUITES = new Set(['epic-gates']);
|
|
155
|
+
|
|
156
|
+
/** Discover gate folders under tests/evals/ (every dir except _lib / _* / non-IDS suites). */
|
|
150
157
|
function discoverGateDirs() {
|
|
151
158
|
let entries;
|
|
152
159
|
try {
|
|
@@ -156,7 +163,7 @@ function discoverGateDirs() {
|
|
|
156
163
|
process.exit(2);
|
|
157
164
|
}
|
|
158
165
|
return entries
|
|
159
|
-
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
166
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_') && !NON_IDS_SUITES.has(d.name))
|
|
160
167
|
.map((d) => path.join(EVALS_ROOT, d.name))
|
|
161
168
|
.sort();
|
|
162
169
|
}
|