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/CHANGELOG.md CHANGED
@@ -1,22 +1,17 @@
1
- ## [1.20.1](https://github.com/caioimori/sinapse-ai/compare/1.20.0...1.20.1) (2026-07-03)
1
+ ## [1.21.0](https://github.com/caioimori/sinapse-ai/compare/1.20.1...1.21.0) (2026-07-04)
2
2
 
3
- ### Bug Fixes
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
- * **decisions:** registra Onda A de segurança executada (PR [#337](https://github.com/caioimori/sinapse-ai/issues/337)) ([#338](https://github.com/caioimori/sinapse-ai/issues/338)) ([c2dbe72](https://github.com/caioimori/sinapse-ai/commit/c2dbe72e2c686a9f2db01a85f4e02e444d4eea24))
12
- * **decisions:** registra Ondas B/C de segurança + rollout da ativação enxuta executados ([#343](https://github.com/caioimori/sinapse-ai/issues/343)) ([0500b56](https://github.com/caioimori/sinapse-ai/commit/0500b56411e44f25cb1729126a3de828ef3f251d))
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
- * **agents:** rollout da ativação enxuta — pós-piloto aprovado por avaliação ([#342](https://github.com/caioimori/sinapse-ai/issues/342)) ([6815e51](https://github.com/caioimori/sinapse-ai/commit/6815e511e13fb1fa146f0a866d054073075c9212)), closes [#332](https://github.com/caioimori/sinapse-ai/issues/332)
17
- * **cleanup:** DEC-03 remove cluster multi-story órfão + split DEC-02 ([#344](https://github.com/caioimori/sinapse-ai/issues/344)) ([359b2fa](https://github.com/caioimori/sinapse-ai/commit/359b2fa2e905eee9be30ea5cac2d7a8bb305a0bd))
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.20.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
+ });
@@ -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
- /** Discover gate folders under tests/evals/ (every dir except _lib / _*). */
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
  }