sinapse-ai 1.20.0 → 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/atlas/render-html.js +10 -8
- 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/core/registry/service-registry.json +2 -32
- package/.sinapse-ai/data/entity-registry.yaml +473 -203
- package/.sinapse-ai/development/agents/analyst.md +18 -34
- package/.sinapse-ai/development/agents/architect.md +18 -34
- package/.sinapse-ai/development/agents/data-engineer.md +15 -28
- package/.sinapse-ai/development/agents/developer.md +0 -6
- package/.sinapse-ai/development/agents/devops.md +15 -28
- package/.sinapse-ai/development/agents/product-lead.md +18 -34
- package/.sinapse-ai/development/agents/project-lead.md +18 -34
- package/.sinapse-ai/development/agents/quality-gate.md +18 -34
- package/.sinapse-ai/development/agents/squad-creator.md +15 -27
- package/.sinapse-ai/development/agents/ux-design-expert.md +15 -27
- package/.sinapse-ai/development/scripts/populate-entity-registry.js +3 -0
- package/.sinapse-ai/development/tasks/execute-epic-plan.md +3 -4
- package/.sinapse-ai/development/workflows/brownfield-discovery.yaml +16 -0
- package/.sinapse-ai/development/workflows/epic-orchestration.yaml +11 -1
- package/.sinapse-ai/git-hooks/lib/staged-secret-scan.js +3 -1
- package/.sinapse-ai/install-manifest.yaml +52 -80
- package/.sinapse-ai/product/templates/personalized-agent-template.md +3 -5
- package/.sinapse-ai/workflow-intelligence/index.js +3 -21
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +56 -22
- package/bin/commands/install.js +5 -4
- package/bin/lib/command-generator.js +4 -4
- package/bin/lib/fs-utils.js +18 -0
- package/bin/lib/setup-statusline.js +3 -1
- package/bin/modules/chrome-brain-installer.js +5 -3
- package/bin/modules/mcp-installer.js +3 -6
- package/bin/postinstall.js +2 -1
- package/bin/sinapse-init.js +8 -9
- package/bin/sinapse.js +3 -1
- package/bin/utils/staged-secret-scan.js +3 -1
- package/docs/framework/atlas/OPERATING-ATLAS.md +1 -1
- package/docs/framework/atlas/atlas-data.json +22 -22
- package/docs/framework/atlas/atlas.html +12 -10
- package/docs/framework/source-tree.md +1 -2
- package/package.json +3 -1
- package/packages/installer/src/config/configure-environment.js +8 -6
- package/packages/installer/src/installer/brownfield-upgrader.js +5 -3
- package/packages/installer/src/installer/file-hasher.js +29 -21
- package/packages/installer/src/installer/manifest-signature.js +61 -49
- package/packages/installer/src/installer/post-install-validator.js +24 -17
- package/packages/installer/src/merger/parsers/markdown-section-parser.js +3 -2
- package/packages/installer/src/updater/index.js +7 -1
- package/packages/installer/src/utils/atomic-write.js +35 -0
- package/packages/installer/src/wizard/index.js +5 -3
- package/packages/sinapse-install/src/capabilities/chrome-brain.js +5 -3
- package/packages/sinapse-install/src/installer.js +3 -1
- package/packages/sinapse-install/src/utils/atomic-write.js +67 -0
- package/scripts/apply-persona-disclaimer.js +2 -1
- package/scripts/eval-e2e.js +149 -0
- package/scripts/sinapse-patch.js +2 -1
- package/scripts/sync-squad-yaml-components.js +2 -1
- package/scripts/validate-agents-md.js +2 -1
- package/scripts/validate-all.js +3 -0
- package/scripts/validate-article-iv.js +289 -0
- package/scripts/validate-article-xi.js +10 -8
- package/scripts/validate-evals.js +9 -2
- package/scripts/validate-no-external-refs.js +14 -8
- package/scripts/wave-gate.js +207 -0
- package/squads/claude-code-mastery/agents/claude-mastery-chief.md +3 -8
- package/squads/claude-code-mastery/agents/config-engineer.md +5 -12
- package/squads/claude-code-mastery/agents/hooks-architect.md +5 -11
- package/squads/claude-code-mastery/agents/mcp-integrator.md +5 -12
- package/squads/claude-code-mastery/agents/project-integrator.md +5 -12
- package/squads/claude-code-mastery/agents/roadmap-sentinel.md +5 -12
- package/squads/claude-code-mastery/agents/skill-craftsman.md +5 -11
- package/squads/claude-code-mastery/agents/swarm-orqx.md +15 -28
- package/.sinapse-ai/core/execution/context-injector.js +0 -539
- package/.sinapse-ai/core/execution/parallel-executor.js +0 -301
- package/.sinapse-ai/core/execution/parallel-monitor.js +0 -432
- package/.sinapse-ai/core/execution/semantic-merge-engine.js +0 -1750
- package/.sinapse-ai/core/execution/wave-executor.js +0 -403
- package/.sinapse-ai/development/tasks/waves.md +0 -205
- package/.sinapse-ai/workflow-intelligence/engine/wave-analyzer.js +0 -692
|
@@ -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/sinapse-patch.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const { atomicWriteSync } = require('../.sinapse-ai/core/synapse/utils/atomic-write');
|
|
15
16
|
const os = require('os');
|
|
16
17
|
|
|
17
18
|
// ─────────────────────────────────────────────
|
|
@@ -228,7 +229,7 @@ if (dkyStart !== -1) {
|
|
|
228
229
|
// ═══════════════════════════════════════════════════
|
|
229
230
|
// SALVAR
|
|
230
231
|
// ═══════════════════════════════════════════════════
|
|
231
|
-
|
|
232
|
+
atomicWriteSync(CLI_PATH, content, 'utf8');
|
|
232
233
|
|
|
233
234
|
console.log(`\n=== Patch aplicado com sucesso: ${changes} alteracoes ===`);
|
|
234
235
|
console.log('');
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
const fs = require('fs');
|
|
35
35
|
const path = require('path');
|
|
36
|
+
const { atomicWriteSync } = require('../.sinapse-ai/core/synapse/utils/atomic-write');
|
|
36
37
|
|
|
37
38
|
const ROOT = path.resolve(__dirname, '..');
|
|
38
39
|
const SQUADS_DIR = path.join(ROOT, 'squads');
|
|
@@ -168,7 +169,7 @@ function processSquad(squadName) {
|
|
|
168
169
|
}
|
|
169
170
|
const block = buildComponentsBlock(agents);
|
|
170
171
|
const newContent = content.trimEnd() + '\n' + block;
|
|
171
|
-
|
|
172
|
+
atomicWriteSync(yamlPath, newContent, 'utf8');
|
|
172
173
|
return { squadName, status: 'updated', changes: agents.length };
|
|
173
174
|
}
|
|
174
175
|
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
const fs = require('fs');
|
|
29
29
|
const path = require('path');
|
|
30
|
+
const { atomicWriteSync } = require('../.sinapse-ai/core/synapse/utils/atomic-write');
|
|
30
31
|
const { execSync } = require('child_process');
|
|
31
32
|
|
|
32
33
|
const ROOT = path.resolve(__dirname, '..');
|
|
@@ -113,7 +114,7 @@ function main() {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
if (FIX) {
|
|
116
|
-
|
|
117
|
+
atomicWriteSync(AGENTS_MD, fixed, 'utf8');
|
|
117
118
|
console.log('AGENTS.md count(s) rewritten to match disk:');
|
|
118
119
|
for (const mm of mismatches) console.log(` - ${mm}`);
|
|
119
120
|
process.exit(0);
|
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();
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
'use strict';
|
|
32
32
|
|
|
33
33
|
const path = require('path');
|
|
34
|
-
const {
|
|
34
|
+
const { execFileSync } = require('child_process');
|
|
35
35
|
|
|
36
36
|
const ROOT = path.resolve(__dirname, '..');
|
|
37
37
|
|
|
@@ -61,9 +61,11 @@ function getBaseRef(args) {
|
|
|
61
61
|
return 'main';
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// SECURITY: args go straight to git without a shell — baseRef comes from CLI
|
|
65
|
+
// or GITHUB_BASE_REF and must never be interpolated into a shell string.
|
|
66
|
+
function gitExec(args) {
|
|
65
67
|
try {
|
|
66
|
-
return
|
|
68
|
+
return execFileSync('git', args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
67
69
|
} catch (_err) {
|
|
68
70
|
return null;
|
|
69
71
|
}
|
|
@@ -78,14 +80,14 @@ function gitExec(cmd) {
|
|
|
78
80
|
*/
|
|
79
81
|
function diffAgainstBase(baseRef) {
|
|
80
82
|
// Try fetching first (CI may need it). Silent if it fails.
|
|
81
|
-
gitExec(
|
|
83
|
+
gitExec(['fetch', 'origin', baseRef, '--quiet']);
|
|
82
84
|
|
|
83
85
|
// Use raw diff with --name-status -M (rename detection).
|
|
84
86
|
const tripleDot = `origin/${baseRef}...HEAD`;
|
|
85
|
-
let raw = gitExec(
|
|
87
|
+
let raw = gitExec(['diff', '--name-status', '-M', tripleDot]);
|
|
86
88
|
if (raw === null) {
|
|
87
89
|
// Fallback: local base ref
|
|
88
|
-
raw = gitExec(
|
|
90
|
+
raw = gitExec(['diff', '--name-status', '-M', `${baseRef}...HEAD`]);
|
|
89
91
|
}
|
|
90
92
|
if (raw === null) {
|
|
91
93
|
return { error: `nao foi possivel comparar com ${baseRef}`, deletions: [], renames: [] };
|
|
@@ -110,8 +112,8 @@ function diffAgainstBase(baseRef) {
|
|
|
110
112
|
|
|
111
113
|
function getOverrideText(baseRef) {
|
|
112
114
|
// Aggregate commit messages between base..HEAD + PR body.
|
|
113
|
-
const commitMsgs = gitExec(
|
|
114
|
-
|| gitExec(
|
|
115
|
+
const commitMsgs = gitExec(['log', '--format=%B', `origin/${baseRef}..HEAD`])
|
|
116
|
+
|| gitExec(['log', '--format=%B', `${baseRef}..HEAD`])
|
|
115
117
|
|| '';
|
|
116
118
|
const prBody = process.env.GITHUB_PR_BODY || '';
|
|
117
119
|
const forcedOverride = process.env.ARTICLE_XI_OVERRIDE || '';
|
|
@@ -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
|
}
|
|
@@ -240,21 +240,27 @@ function isBinaryFile(absPath) {
|
|
|
240
240
|
function scanFile(rootDir, relPath) {
|
|
241
241
|
if (isAllowListed(relPath)) return [];
|
|
242
242
|
const absPath = path.join(rootDir, relPath);
|
|
243
|
-
|
|
243
|
+
|
|
244
|
+
// Size cap and read share one fd, so the scanned bytes are the sized bytes.
|
|
245
|
+
let content;
|
|
246
|
+
let fd;
|
|
244
247
|
try {
|
|
245
|
-
|
|
248
|
+
fd = fs.openSync(absPath, 'r');
|
|
246
249
|
} catch {
|
|
247
250
|
return [];
|
|
248
251
|
}
|
|
249
|
-
if (!stat.isFile()) return [];
|
|
250
|
-
if (stat.size > MAX_SCAN_BYTES) return [];
|
|
251
|
-
if (isBinaryFile(absPath)) return [];
|
|
252
|
-
|
|
253
|
-
let content;
|
|
254
252
|
try {
|
|
255
|
-
|
|
253
|
+
const stat = fs.fstatSync(fd);
|
|
254
|
+
if (!stat.isFile()) return [];
|
|
255
|
+
if (stat.size > MAX_SCAN_BYTES) return [];
|
|
256
|
+
if (isBinaryFile(absPath)) return [];
|
|
257
|
+
const buf = Buffer.alloc(stat.size);
|
|
258
|
+
fs.readSync(fd, buf, 0, stat.size, 0);
|
|
259
|
+
content = buf.toString('utf8');
|
|
256
260
|
} catch {
|
|
257
261
|
return [];
|
|
262
|
+
} finally {
|
|
263
|
+
fs.closeSync(fd);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
266
|
const lines = content.split(/\r?\n/);
|