kiro-spec-engine 1.45.13 → 1.46.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 +14 -0
- package/README.md +15 -4
- package/README.zh.md +15 -3
- package/bin/kiro-spec-engine.js +84 -0
- package/docs/adoption-guide.md +3 -2
- package/docs/command-reference.md +30 -12
- package/docs/cross-tool-guide.md +2 -1
- package/docs/document-governance.md +2 -1
- package/docs/faq.md +14 -13
- package/docs/manual-workflows-guide.md +2 -1
- package/docs/quick-start-with-ai-tools.md +4 -3
- package/docs/quick-start.md +21 -7
- package/docs/spec-workflow.md +3 -2
- package/docs/tools/claude-guide.md +3 -2
- package/docs/tools/cursor-guide.md +3 -2
- package/docs/tools/generic-guide.md +3 -2
- package/docs/tools/vscode-guide.md +3 -2
- package/docs/tools/windsurf-guide.md +3 -2
- package/docs/troubleshooting.md +10 -9
- package/docs/zh/quick-start.md +30 -14
- package/docs/zh/tools/claude-guide.md +2 -1
- package/docs/zh/tools/cursor-guide.md +2 -1
- package/docs/zh/tools/generic-guide.md +8 -7
- package/docs/zh/tools/vscode-guide.md +2 -1
- package/docs/zh/tools/windsurf-guide.md +2 -1
- package/lib/commands/orchestrate.js +123 -95
- package/lib/commands/spec-bootstrap.js +147 -0
- package/lib/commands/spec-gate.js +157 -0
- package/lib/commands/spec-pipeline.js +205 -0
- package/lib/spec/bootstrap/context-collector.js +48 -0
- package/lib/spec/bootstrap/draft-generator.js +158 -0
- package/lib/spec/bootstrap/questionnaire-engine.js +70 -0
- package/lib/spec/bootstrap/trace-emitter.js +59 -0
- package/lib/spec/multi-spec-orchestrate.js +93 -0
- package/lib/spec/pipeline/constants.js +6 -0
- package/lib/spec/pipeline/stage-adapters.js +118 -0
- package/lib/spec/pipeline/stage-runner.js +146 -0
- package/lib/spec/pipeline/state-store.js +119 -0
- package/lib/spec-gate/engine/gate-engine.js +165 -0
- package/lib/spec-gate/policy/default-policy.js +22 -0
- package/lib/spec-gate/policy/policy-loader.js +103 -0
- package/lib/spec-gate/result-emitter.js +81 -0
- package/lib/spec-gate/rules/default-rules.js +156 -0
- package/lib/spec-gate/rules/rule-registry.js +51 -0
- package/package.json +2 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse --spec / --specs options into a de-duplicated list.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @returns {string[]}
|
|
10
|
+
*/
|
|
11
|
+
function parseSpecTargets(options = {}) {
|
|
12
|
+
const fromSpec = (options.spec || '').trim();
|
|
13
|
+
const fromSpecs = `${options.specs || ''}`
|
|
14
|
+
.split(',')
|
|
15
|
+
.map(item => item.trim())
|
|
16
|
+
.filter(Boolean);
|
|
17
|
+
|
|
18
|
+
const merged = [];
|
|
19
|
+
if (fromSpec) {
|
|
20
|
+
merged.push(fromSpec);
|
|
21
|
+
}
|
|
22
|
+
merged.push(...fromSpecs);
|
|
23
|
+
|
|
24
|
+
return Array.from(new Set(merged));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Execute multi-spec work through orchestrate mode.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {string[]} options.specTargets
|
|
32
|
+
* @param {string} options.projectPath
|
|
33
|
+
* @param {object} options.commandOptions
|
|
34
|
+
* @param {Function} options.runOrchestration
|
|
35
|
+
* @param {string} options.commandLabel
|
|
36
|
+
* @param {string} options.nextActionLabel
|
|
37
|
+
* @returns {Promise<object>}
|
|
38
|
+
*/
|
|
39
|
+
async function runMultiSpecViaOrchestrate(options = {}) {
|
|
40
|
+
const {
|
|
41
|
+
specTargets,
|
|
42
|
+
projectPath,
|
|
43
|
+
commandOptions,
|
|
44
|
+
runOrchestration,
|
|
45
|
+
commandLabel,
|
|
46
|
+
nextActionLabel
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
const orchestrationResult = await runOrchestration({
|
|
50
|
+
specs: specTargets.join(','),
|
|
51
|
+
maxParallel: commandOptions.maxParallel,
|
|
52
|
+
json: false,
|
|
53
|
+
silent: true
|
|
54
|
+
}, {
|
|
55
|
+
workspaceRoot: projectPath
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const result = {
|
|
59
|
+
mode: 'orchestrate',
|
|
60
|
+
spec_ids: specTargets,
|
|
61
|
+
status: orchestrationResult.status,
|
|
62
|
+
orchestrate_result: orchestrationResult,
|
|
63
|
+
next_actions: [
|
|
64
|
+
nextActionLabel,
|
|
65
|
+
'Use kse orchestrate status to inspect live orchestration state.'
|
|
66
|
+
]
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (commandOptions.out) {
|
|
70
|
+
const outPath = path.isAbsolute(commandOptions.out)
|
|
71
|
+
? commandOptions.out
|
|
72
|
+
: path.join(projectPath, commandOptions.out);
|
|
73
|
+
await fs.ensureDir(path.dirname(outPath));
|
|
74
|
+
await fs.writeJson(outPath, result, { spaces: 2 });
|
|
75
|
+
result.output_file = outPath;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (commandOptions.json) {
|
|
79
|
+
console.log(JSON.stringify(result, null, 2));
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.blue('🚀') + ` ${commandLabel} defaulted to orchestrate mode for ${specTargets.length} specs.`);
|
|
82
|
+
console.log(chalk.gray(` Specs: ${specTargets.join(', ')}`));
|
|
83
|
+
console.log(chalk.gray(` Status: ${orchestrationResult.status}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
parseSpecTargets,
|
|
91
|
+
runMultiSpecViaOrchestrate
|
|
92
|
+
};
|
|
93
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { runSpecBootstrap } = require('../../commands/spec-bootstrap');
|
|
4
|
+
const { runSpecGate } = require('../../commands/spec-gate');
|
|
5
|
+
|
|
6
|
+
function createDefaultStageAdapters(projectPath = process.cwd()) {
|
|
7
|
+
return {
|
|
8
|
+
requirements: async context => {
|
|
9
|
+
const specDir = path.join(projectPath, '.kiro', 'specs', context.specId);
|
|
10
|
+
const requirementsPath = path.join(specDir, 'requirements.md');
|
|
11
|
+
|
|
12
|
+
if (!await fs.pathExists(requirementsPath)) {
|
|
13
|
+
await runSpecBootstrap({
|
|
14
|
+
name: context.specId,
|
|
15
|
+
nonInteractive: true,
|
|
16
|
+
profile: 'pipeline',
|
|
17
|
+
dryRun: false,
|
|
18
|
+
json: false
|
|
19
|
+
}, {
|
|
20
|
+
projectPath
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
success: true,
|
|
25
|
+
details: {
|
|
26
|
+
action: 'bootstrap-generated'
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
success: true,
|
|
33
|
+
details: {
|
|
34
|
+
action: 'requirements-existing'
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
design: async context => {
|
|
40
|
+
const designPath = path.join(projectPath, '.kiro', 'specs', context.specId, 'design.md');
|
|
41
|
+
const exists = await fs.pathExists(designPath);
|
|
42
|
+
|
|
43
|
+
if (!exists) {
|
|
44
|
+
await runSpecBootstrap({
|
|
45
|
+
name: context.specId,
|
|
46
|
+
nonInteractive: true,
|
|
47
|
+
profile: 'pipeline',
|
|
48
|
+
dryRun: false,
|
|
49
|
+
json: false
|
|
50
|
+
}, {
|
|
51
|
+
projectPath
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
warnings: exists ? [] : ['design.md was created from bootstrap defaults'],
|
|
58
|
+
details: {
|
|
59
|
+
action: exists ? 'design-existing' : 'design-generated'
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
tasks: async context => {
|
|
65
|
+
const tasksPath = path.join(projectPath, '.kiro', 'specs', context.specId, 'tasks.md');
|
|
66
|
+
const exists = await fs.pathExists(tasksPath);
|
|
67
|
+
|
|
68
|
+
if (!exists) {
|
|
69
|
+
await runSpecBootstrap({
|
|
70
|
+
name: context.specId,
|
|
71
|
+
nonInteractive: true,
|
|
72
|
+
profile: 'pipeline',
|
|
73
|
+
dryRun: false,
|
|
74
|
+
json: false
|
|
75
|
+
}, {
|
|
76
|
+
projectPath
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
success: true,
|
|
82
|
+
warnings: exists ? [] : ['tasks.md was created from bootstrap defaults'],
|
|
83
|
+
details: {
|
|
84
|
+
action: exists ? 'tasks-existing' : 'tasks-generated'
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
gate: async context => {
|
|
90
|
+
const gateResult = await runSpecGate({
|
|
91
|
+
spec: context.specId,
|
|
92
|
+
json: false,
|
|
93
|
+
strict: !!context.strict,
|
|
94
|
+
out: context.gateOut,
|
|
95
|
+
silent: true
|
|
96
|
+
}, {
|
|
97
|
+
projectPath
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const success = gateResult.decision === 'go' || gateResult.decision === 'conditional-go';
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
success,
|
|
104
|
+
warnings: gateResult.warnings ? gateResult.warnings.map(item => item.message) : [],
|
|
105
|
+
details: {
|
|
106
|
+
decision: gateResult.decision,
|
|
107
|
+
score: gateResult.score,
|
|
108
|
+
failed_checks: gateResult.failed_checks || []
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = {
|
|
116
|
+
createDefaultStageAdapters
|
|
117
|
+
};
|
|
118
|
+
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const { STAGE_ORDER } = require('./constants');
|
|
2
|
+
|
|
3
|
+
class StageRunner {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.stateStore = options.stateStore;
|
|
6
|
+
this.adapters = options.adapters || {};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async run(context) {
|
|
10
|
+
const selectedStages = this._selectStages({
|
|
11
|
+
fromStage: context.fromStage,
|
|
12
|
+
toStage: context.toStage
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const stageResults = [];
|
|
16
|
+
|
|
17
|
+
for (const stageName of selectedStages) {
|
|
18
|
+
if (context.resume && this._isCompleted(context.state, stageName)) {
|
|
19
|
+
stageResults.push({
|
|
20
|
+
name: stageName,
|
|
21
|
+
status: 'skipped',
|
|
22
|
+
reason: 'already-completed'
|
|
23
|
+
});
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const adapter = this.adapters[stageName];
|
|
28
|
+
if (typeof adapter !== 'function') {
|
|
29
|
+
const error = `No adapter registered for stage: ${stageName}`;
|
|
30
|
+
await this.stateStore.markStageResult(context.state, stageName, {
|
|
31
|
+
status: 'failed',
|
|
32
|
+
error
|
|
33
|
+
});
|
|
34
|
+
stageResults.push({ name: stageName, status: 'failed', error });
|
|
35
|
+
return {
|
|
36
|
+
status: 'failed',
|
|
37
|
+
stageResults,
|
|
38
|
+
failure: { stage: stageName, error }
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await this.stateStore.markStageStart(context.state, stageName);
|
|
43
|
+
|
|
44
|
+
let adapterResult;
|
|
45
|
+
try {
|
|
46
|
+
adapterResult = context.dryRun
|
|
47
|
+
? { success: true, details: { dry_run: true }, warnings: [] }
|
|
48
|
+
: await adapter(context);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
adapterResult = {
|
|
51
|
+
success: false,
|
|
52
|
+
error: error.message,
|
|
53
|
+
warnings: []
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const normalized = this._normalizeResult(adapterResult);
|
|
58
|
+
const status = normalized.success ? (normalized.warnings.length > 0 ? 'warning' : 'completed') : 'failed';
|
|
59
|
+
|
|
60
|
+
await this.stateStore.markStageResult(context.state, stageName, {
|
|
61
|
+
status,
|
|
62
|
+
warnings: normalized.warnings,
|
|
63
|
+
error: normalized.error,
|
|
64
|
+
output: normalized.details
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
stageResults.push({
|
|
68
|
+
name: stageName,
|
|
69
|
+
status,
|
|
70
|
+
warnings: normalized.warnings,
|
|
71
|
+
error: normalized.error,
|
|
72
|
+
output: normalized.details
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (status === 'failed' && context.failFast) {
|
|
76
|
+
return {
|
|
77
|
+
status: 'failed',
|
|
78
|
+
stageResults,
|
|
79
|
+
failure: {
|
|
80
|
+
stage: stageName,
|
|
81
|
+
error: normalized.error || 'stage failed'
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (status === 'warning' && !context.continueOnWarning) {
|
|
87
|
+
return {
|
|
88
|
+
status: 'failed',
|
|
89
|
+
stageResults,
|
|
90
|
+
failure: {
|
|
91
|
+
stage: stageName,
|
|
92
|
+
error: 'warning encountered with continue-on-warning disabled'
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
status: 'completed',
|
|
100
|
+
stageResults,
|
|
101
|
+
failure: null
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_selectStages(range = {}) {
|
|
106
|
+
const fromStage = range.fromStage || STAGE_ORDER[0];
|
|
107
|
+
const toStage = range.toStage || STAGE_ORDER[STAGE_ORDER.length - 1];
|
|
108
|
+
|
|
109
|
+
const fromIndex = STAGE_ORDER.indexOf(fromStage);
|
|
110
|
+
const toIndex = STAGE_ORDER.indexOf(toStage);
|
|
111
|
+
|
|
112
|
+
if (fromIndex < 0 || toIndex < 0 || fromIndex > toIndex) {
|
|
113
|
+
throw new Error(`Invalid stage range: from=${fromStage}, to=${toStage}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return STAGE_ORDER.slice(fromIndex, toIndex + 1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_normalizeResult(result) {
|
|
120
|
+
if (!result || typeof result !== 'object') {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
warnings: [],
|
|
124
|
+
error: 'Invalid stage result',
|
|
125
|
+
details: null
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
success: !!result.success,
|
|
131
|
+
warnings: Array.isArray(result.warnings) ? result.warnings : [],
|
|
132
|
+
error: result.error || null,
|
|
133
|
+
details: result.details || null
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_isCompleted(state, stageName) {
|
|
138
|
+
const stage = (state.stages || []).find(item => item.name === stageName);
|
|
139
|
+
return !!stage && stage.status === 'completed';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
StageRunner
|
|
145
|
+
};
|
|
146
|
+
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { randomUUID } = require('crypto');
|
|
4
|
+
|
|
5
|
+
class PipelineStateStore {
|
|
6
|
+
constructor(projectPath = process.cwd()) {
|
|
7
|
+
this.projectPath = projectPath;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
getStateDir(specId) {
|
|
11
|
+
return path.join(this.projectPath, '.kiro', 'state', 'spec-pipeline', specId);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getRunPath(specId, runId) {
|
|
15
|
+
return path.join(this.getStateDir(specId), `${runId}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
getLatestPath(specId) {
|
|
19
|
+
return path.join(this.getStateDir(specId), 'latest.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async create(specId, options = {}) {
|
|
23
|
+
const runId = options.runId || randomUUID();
|
|
24
|
+
const state = {
|
|
25
|
+
spec_id: specId,
|
|
26
|
+
run_id: runId,
|
|
27
|
+
status: 'running',
|
|
28
|
+
strategy: {
|
|
29
|
+
fail_fast: options.failFast !== false,
|
|
30
|
+
continue_on_warning: !!options.continueOnWarning
|
|
31
|
+
},
|
|
32
|
+
stages: [],
|
|
33
|
+
started_at: new Date().toISOString(),
|
|
34
|
+
ended_at: null
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
await this.save(state);
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async save(state) {
|
|
42
|
+
const runPath = this.getRunPath(state.spec_id, state.run_id);
|
|
43
|
+
const latestPath = this.getLatestPath(state.spec_id);
|
|
44
|
+
|
|
45
|
+
await fs.ensureDir(path.dirname(runPath));
|
|
46
|
+
await fs.writeJson(runPath, state, { spaces: 2 });
|
|
47
|
+
await fs.writeJson(latestPath, {
|
|
48
|
+
spec_id: state.spec_id,
|
|
49
|
+
run_id: state.run_id,
|
|
50
|
+
updated_at: new Date().toISOString()
|
|
51
|
+
}, { spaces: 2 });
|
|
52
|
+
|
|
53
|
+
return runPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async loadLatest(specId) {
|
|
57
|
+
const latestPath = this.getLatestPath(specId);
|
|
58
|
+
if (!await fs.pathExists(latestPath)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const latest = await fs.readJson(latestPath);
|
|
63
|
+
const runPath = this.getRunPath(specId, latest.run_id);
|
|
64
|
+
if (!await fs.pathExists(runPath)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return fs.readJson(runPath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async markStageStart(state, stageName) {
|
|
72
|
+
const stage = this._getOrCreateStage(state, stageName);
|
|
73
|
+
stage.status = 'running';
|
|
74
|
+
stage.started_at = new Date().toISOString();
|
|
75
|
+
stage.ended_at = null;
|
|
76
|
+
stage.error = null;
|
|
77
|
+
stage.warnings = [];
|
|
78
|
+
|
|
79
|
+
await this.save(state);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async markStageResult(state, stageName, result) {
|
|
83
|
+
const stage = this._getOrCreateStage(state, stageName);
|
|
84
|
+
stage.status = result.status;
|
|
85
|
+
stage.ended_at = new Date().toISOString();
|
|
86
|
+
stage.error = result.error || null;
|
|
87
|
+
stage.warnings = result.warnings || [];
|
|
88
|
+
stage.output = result.output || null;
|
|
89
|
+
|
|
90
|
+
await this.save(state);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async markFinished(state, status) {
|
|
94
|
+
state.status = status;
|
|
95
|
+
state.ended_at = new Date().toISOString();
|
|
96
|
+
await this.save(state);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_getOrCreateStage(state, stageName) {
|
|
100
|
+
let stage = state.stages.find(item => item.name === stageName);
|
|
101
|
+
if (!stage) {
|
|
102
|
+
stage = {
|
|
103
|
+
name: stageName,
|
|
104
|
+
status: 'pending',
|
|
105
|
+
started_at: null,
|
|
106
|
+
ended_at: null,
|
|
107
|
+
warnings: []
|
|
108
|
+
};
|
|
109
|
+
state.stages.push(stage);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return stage;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
PipelineStateStore
|
|
118
|
+
};
|
|
119
|
+
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const { randomUUID } = require('crypto');
|
|
2
|
+
|
|
3
|
+
class GateEngine {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.registry = options.registry;
|
|
6
|
+
this.policy = options.policy;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async evaluate(input = {}) {
|
|
10
|
+
const specId = input.specId;
|
|
11
|
+
const runId = input.runId || randomUUID();
|
|
12
|
+
const startedAt = new Date().toISOString();
|
|
13
|
+
|
|
14
|
+
const policyRules = (this.policy && this.policy.rules) || {};
|
|
15
|
+
const enabledRules = this.registry.listEnabled(policyRules);
|
|
16
|
+
|
|
17
|
+
const rules = [];
|
|
18
|
+
const failedChecks = [];
|
|
19
|
+
const warnings = [];
|
|
20
|
+
|
|
21
|
+
let weightedScore = 0;
|
|
22
|
+
let totalWeight = 0;
|
|
23
|
+
let hardFailTriggered = false;
|
|
24
|
+
|
|
25
|
+
for (const rule of enabledRules) {
|
|
26
|
+
const policyEntry = policyRules[rule.id] || {};
|
|
27
|
+
const weight = Number(policyEntry.weight || 0);
|
|
28
|
+
const hardFail = !!policyEntry.hard_fail;
|
|
29
|
+
|
|
30
|
+
totalWeight += weight;
|
|
31
|
+
const execution = await rule.execute({
|
|
32
|
+
specId,
|
|
33
|
+
runId,
|
|
34
|
+
policy: this.policy
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const passed = !!execution.passed;
|
|
38
|
+
const ratio = this._normalizeRatio(execution.ratio);
|
|
39
|
+
const ruleScore = passed ? weight : Number((weight * ratio).toFixed(2));
|
|
40
|
+
|
|
41
|
+
if (!passed) {
|
|
42
|
+
failedChecks.push({
|
|
43
|
+
id: rule.id,
|
|
44
|
+
hard_fail: hardFail,
|
|
45
|
+
details: execution.details || {}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (hardFail && !passed) {
|
|
50
|
+
hardFailTriggered = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(execution.warnings) && execution.warnings.length > 0) {
|
|
54
|
+
warnings.push(...execution.warnings.map(message => ({ id: rule.id, message })));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
weightedScore += ruleScore;
|
|
58
|
+
|
|
59
|
+
rules.push({
|
|
60
|
+
id: rule.id,
|
|
61
|
+
passed,
|
|
62
|
+
score: ruleScore,
|
|
63
|
+
max_score: weight,
|
|
64
|
+
hard_fail: hardFail,
|
|
65
|
+
warnings: execution.warnings || [],
|
|
66
|
+
details: execution.details || {}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const score = totalWeight > 0
|
|
71
|
+
? Math.round((weightedScore / totalWeight) * 100)
|
|
72
|
+
: 0;
|
|
73
|
+
|
|
74
|
+
const decision = this._decide({
|
|
75
|
+
score,
|
|
76
|
+
hardFailTriggered,
|
|
77
|
+
warnings
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const nextActions = this._buildNextActions(decision, failedChecks, warnings);
|
|
81
|
+
const endedAt = new Date().toISOString();
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
spec_id: specId,
|
|
85
|
+
run_id: runId,
|
|
86
|
+
decision,
|
|
87
|
+
score,
|
|
88
|
+
rules,
|
|
89
|
+
failed_checks: failedChecks,
|
|
90
|
+
warnings,
|
|
91
|
+
next_actions: nextActions,
|
|
92
|
+
policy_snapshot: {
|
|
93
|
+
thresholds: this.policy.thresholds,
|
|
94
|
+
strict_mode: this.policy.strict_mode
|
|
95
|
+
},
|
|
96
|
+
started_at: startedAt,
|
|
97
|
+
ended_at: endedAt
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_normalizeRatio(value) {
|
|
102
|
+
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (value < 0) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (value > 1) {
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
_decide(context) {
|
|
118
|
+
const thresholds = this.policy.thresholds || {};
|
|
119
|
+
const goThreshold = Number(thresholds.go || 90);
|
|
120
|
+
const conditionalThreshold = Number(thresholds.conditional_go || 70);
|
|
121
|
+
const warningAsFailure = !!(this.policy.strict_mode && this.policy.strict_mode.warning_as_failure);
|
|
122
|
+
|
|
123
|
+
if (context.hardFailTriggered) {
|
|
124
|
+
return 'no-go';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (warningAsFailure && context.warnings.length > 0) {
|
|
128
|
+
return 'no-go';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (context.score >= goThreshold) {
|
|
132
|
+
return 'go';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (context.score >= conditionalThreshold) {
|
|
136
|
+
return 'conditional-go';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return 'no-go';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_buildNextActions(decision, failedChecks, warnings) {
|
|
143
|
+
if (decision === 'go') {
|
|
144
|
+
return ['Proceed to implementation and keep evidence attached to Spec artifacts.'];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const actions = [];
|
|
148
|
+
|
|
149
|
+
if (failedChecks.length > 0) {
|
|
150
|
+
actions.push(`Fix failed checks: ${failedChecks.map(item => item.id).join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (warnings.length > 0) {
|
|
154
|
+
actions.push('Review warnings and decide whether to re-run with relaxed strict mode.');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
actions.push('Re-run gate after remediation and attach latest gate report.');
|
|
158
|
+
return actions;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
GateEngine
|
|
164
|
+
};
|
|
165
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const DEFAULT_GATE_POLICY = {
|
|
2
|
+
version: 1,
|
|
3
|
+
thresholds: {
|
|
4
|
+
go: 90,
|
|
5
|
+
conditional_go: 70
|
|
6
|
+
},
|
|
7
|
+
strict_mode: {
|
|
8
|
+
warning_as_failure: true
|
|
9
|
+
},
|
|
10
|
+
rules: {
|
|
11
|
+
mandatory: { enabled: true, weight: 30, hard_fail: true },
|
|
12
|
+
tests: { enabled: true, weight: 25, hard_fail: true },
|
|
13
|
+
docs: { enabled: true, weight: 15, hard_fail: false },
|
|
14
|
+
config_consistency: { enabled: true, weight: 15, hard_fail: false },
|
|
15
|
+
traceability: { enabled: true, weight: 15, hard_fail: false }
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
DEFAULT_GATE_POLICY
|
|
21
|
+
};
|
|
22
|
+
|