scene-capability-engine 3.3.17 → 3.3.21
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 +48 -0
- package/README.md +19 -10
- package/README.zh.md +21 -11
- package/bin/scene-capability-engine.js +4 -0
- package/docs/command-reference.md +94 -12
- package/docs/release-checklist.md +6 -0
- package/docs/zh/release-checklist.md +6 -0
- package/lib/commands/errorbook.js +1244 -0
- package/lib/commands/spec-bootstrap.js +126 -51
- package/lib/commands/spec-gate.js +92 -25
- package/lib/commands/spec-pipeline.js +86 -7
- package/lib/commands/studio.js +265 -30
- package/lib/runtime/multi-spec-scene-session.js +147 -0
- package/lib/runtime/scene-session-binding.js +109 -0
- package/lib/runtime/session-store.js +475 -3
- package/package.json +4 -2
- package/template/.sce/steering/CORE_PRINCIPLES.md +26 -1
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const Table = require('cli-table3');
|
|
6
|
+
|
|
7
|
+
const ERRORBOOK_INDEX_API_VERSION = 'sce.errorbook.index/v0.1';
|
|
8
|
+
const ERRORBOOK_ENTRY_API_VERSION = 'sce.errorbook.entry/v0.1';
|
|
9
|
+
const ERRORBOOK_STATUSES = Object.freeze(['candidate', 'verified', 'promoted', 'deprecated']);
|
|
10
|
+
const STATUS_RANK = Object.freeze({
|
|
11
|
+
deprecated: 0,
|
|
12
|
+
candidate: 1,
|
|
13
|
+
verified: 2,
|
|
14
|
+
promoted: 3
|
|
15
|
+
});
|
|
16
|
+
const ERRORBOOK_ONTOLOGY_TAGS = Object.freeze([
|
|
17
|
+
'entity',
|
|
18
|
+
'relation',
|
|
19
|
+
'business_rule',
|
|
20
|
+
'decision_policy',
|
|
21
|
+
'execution_flow'
|
|
22
|
+
]);
|
|
23
|
+
const ONTOLOGY_TAG_ALIASES = Object.freeze({
|
|
24
|
+
entities: 'entity',
|
|
25
|
+
relations: 'relation',
|
|
26
|
+
rule: 'business_rule',
|
|
27
|
+
rules: 'business_rule',
|
|
28
|
+
business_rules: 'business_rule',
|
|
29
|
+
decision: 'decision_policy',
|
|
30
|
+
decisions: 'decision_policy',
|
|
31
|
+
policy: 'decision_policy',
|
|
32
|
+
policies: 'decision_policy',
|
|
33
|
+
execution: 'execution_flow',
|
|
34
|
+
flow: 'execution_flow',
|
|
35
|
+
workflow: 'execution_flow',
|
|
36
|
+
workflows: 'execution_flow',
|
|
37
|
+
action_chain: 'execution_flow'
|
|
38
|
+
});
|
|
39
|
+
const DEFAULT_PROMOTE_MIN_QUALITY = 75;
|
|
40
|
+
const ERRORBOOK_RISK_LEVELS = Object.freeze(['low', 'medium', 'high']);
|
|
41
|
+
const HIGH_RISK_SIGNAL_TAGS = Object.freeze([
|
|
42
|
+
'release-blocker',
|
|
43
|
+
'security',
|
|
44
|
+
'auth',
|
|
45
|
+
'payment',
|
|
46
|
+
'data-loss',
|
|
47
|
+
'integrity',
|
|
48
|
+
'compliance',
|
|
49
|
+
'incident'
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function resolveErrorbookPaths(projectPath = process.cwd()) {
|
|
53
|
+
const baseDir = path.join(projectPath, '.sce', 'errorbook');
|
|
54
|
+
return {
|
|
55
|
+
projectPath,
|
|
56
|
+
baseDir,
|
|
57
|
+
entriesDir: path.join(baseDir, 'entries'),
|
|
58
|
+
indexFile: path.join(baseDir, 'index.json')
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nowIso() {
|
|
63
|
+
return new Date().toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeText(value) {
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return value.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeCsv(value) {
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return value.split(',').map((item) => item.trim()).filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeStringList(...rawInputs) {
|
|
87
|
+
const merged = [];
|
|
88
|
+
for (const raw of rawInputs) {
|
|
89
|
+
if (Array.isArray(raw)) {
|
|
90
|
+
for (const item of raw) {
|
|
91
|
+
const normalized = normalizeText(`${item}`);
|
|
92
|
+
if (normalized) {
|
|
93
|
+
merged.push(normalized);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof raw === 'string') {
|
|
100
|
+
for (const item of normalizeCsv(raw)) {
|
|
101
|
+
const normalized = normalizeText(item);
|
|
102
|
+
if (normalized) {
|
|
103
|
+
merged.push(normalized);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return Array.from(new Set(merged));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeOntologyTags(...rawInputs) {
|
|
113
|
+
const normalized = normalizeStringList(...rawInputs).map((item) => item.toLowerCase());
|
|
114
|
+
const mapped = normalized.map((item) => ONTOLOGY_TAG_ALIASES[item] || item);
|
|
115
|
+
const valid = mapped.filter((item) => ERRORBOOK_ONTOLOGY_TAGS.includes(item));
|
|
116
|
+
return Array.from(new Set(valid));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeStatus(input, fallback = 'candidate') {
|
|
120
|
+
const normalized = normalizeText(`${input || ''}`).toLowerCase();
|
|
121
|
+
if (!normalized) {
|
|
122
|
+
return fallback;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!ERRORBOOK_STATUSES.includes(normalized)) {
|
|
126
|
+
throw new Error(`status must be one of: ${ERRORBOOK_STATUSES.join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return normalized;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function selectStatus(...candidates) {
|
|
133
|
+
let selected = 'candidate';
|
|
134
|
+
for (const candidate of candidates) {
|
|
135
|
+
const status = normalizeStatus(candidate, 'candidate');
|
|
136
|
+
if ((STATUS_RANK[status] || 0) > (STATUS_RANK[selected] || 0)) {
|
|
137
|
+
selected = status;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return selected;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createFingerprint(input = {}) {
|
|
144
|
+
const explicit = normalizeText(input.fingerprint);
|
|
145
|
+
if (explicit) {
|
|
146
|
+
return explicit;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const basis = [
|
|
150
|
+
normalizeText(input.title).toLowerCase(),
|
|
151
|
+
normalizeText(input.symptom).toLowerCase(),
|
|
152
|
+
normalizeText(input.root_cause || input.rootCause).toLowerCase()
|
|
153
|
+
].join('|');
|
|
154
|
+
|
|
155
|
+
const digest = crypto.createHash('sha1').update(basis).digest('hex').slice(0, 16);
|
|
156
|
+
return `fp-${digest}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildDefaultIndex() {
|
|
160
|
+
return {
|
|
161
|
+
api_version: ERRORBOOK_INDEX_API_VERSION,
|
|
162
|
+
updated_at: nowIso(),
|
|
163
|
+
total_entries: 0,
|
|
164
|
+
entries: []
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function ensureErrorbookStorage(paths, fileSystem = fs) {
|
|
169
|
+
await fileSystem.ensureDir(paths.entriesDir);
|
|
170
|
+
if (!await fileSystem.pathExists(paths.indexFile)) {
|
|
171
|
+
await fileSystem.writeJson(paths.indexFile, buildDefaultIndex(), { spaces: 2 });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function readErrorbookIndex(paths, fileSystem = fs) {
|
|
176
|
+
await ensureErrorbookStorage(paths, fileSystem);
|
|
177
|
+
const index = await fileSystem.readJson(paths.indexFile);
|
|
178
|
+
if (!index || typeof index !== 'object' || !Array.isArray(index.entries)) {
|
|
179
|
+
return buildDefaultIndex();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
api_version: index.api_version || ERRORBOOK_INDEX_API_VERSION,
|
|
184
|
+
updated_at: index.updated_at || nowIso(),
|
|
185
|
+
total_entries: Number.isInteger(index.total_entries) ? index.total_entries : index.entries.length,
|
|
186
|
+
entries: index.entries
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function writeErrorbookIndex(paths, index, fileSystem = fs) {
|
|
191
|
+
const payload = {
|
|
192
|
+
...index,
|
|
193
|
+
api_version: ERRORBOOK_INDEX_API_VERSION,
|
|
194
|
+
updated_at: nowIso(),
|
|
195
|
+
total_entries: Array.isArray(index.entries) ? index.entries.length : 0
|
|
196
|
+
};
|
|
197
|
+
await fileSystem.ensureDir(path.dirname(paths.indexFile));
|
|
198
|
+
await fileSystem.writeJson(paths.indexFile, payload, { spaces: 2 });
|
|
199
|
+
return payload;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildEntryFilePath(paths, entryId) {
|
|
203
|
+
return path.join(paths.entriesDir, `${entryId}.json`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function readErrorbookEntry(paths, entryId, fileSystem = fs) {
|
|
207
|
+
const entryPath = buildEntryFilePath(paths, entryId);
|
|
208
|
+
if (!await fileSystem.pathExists(entryPath)) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return fileSystem.readJson(entryPath);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function writeErrorbookEntry(paths, entry, fileSystem = fs) {
|
|
215
|
+
const entryPath = buildEntryFilePath(paths, entry.id);
|
|
216
|
+
await fileSystem.ensureDir(path.dirname(entryPath));
|
|
217
|
+
await fileSystem.writeJson(entryPath, entry, { spaces: 2 });
|
|
218
|
+
return entryPath;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scoreQuality(entry = {}) {
|
|
222
|
+
let score = 0;
|
|
223
|
+
|
|
224
|
+
if (normalizeText(entry.title)) {
|
|
225
|
+
score += 10;
|
|
226
|
+
}
|
|
227
|
+
if (normalizeText(entry.symptom)) {
|
|
228
|
+
score += 10;
|
|
229
|
+
}
|
|
230
|
+
if (normalizeText(entry.fingerprint)) {
|
|
231
|
+
score += 10;
|
|
232
|
+
}
|
|
233
|
+
if (normalizeText(entry.root_cause)) {
|
|
234
|
+
score += 20;
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(entry.fix_actions) && entry.fix_actions.length > 0) {
|
|
237
|
+
score += 20;
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(entry.verification_evidence) && entry.verification_evidence.length > 0) {
|
|
240
|
+
score += 20;
|
|
241
|
+
}
|
|
242
|
+
if (Array.isArray(entry.ontology_tags) && entry.ontology_tags.length > 0) {
|
|
243
|
+
score += 5;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(entry.tags) && entry.tags.length > 0) {
|
|
246
|
+
score += 3;
|
|
247
|
+
}
|
|
248
|
+
if (normalizeText(entry.symptom).length >= 24 && normalizeText(entry.root_cause).length >= 24) {
|
|
249
|
+
score += 2;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return Math.max(0, Math.min(100, score));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function validateRecordPayload(payload) {
|
|
256
|
+
if (!normalizeText(payload.title)) {
|
|
257
|
+
throw new Error('--title is required');
|
|
258
|
+
}
|
|
259
|
+
if (!normalizeText(payload.symptom)) {
|
|
260
|
+
throw new Error('--symptom is required');
|
|
261
|
+
}
|
|
262
|
+
if (!normalizeText(payload.root_cause)) {
|
|
263
|
+
throw new Error('--root-cause is required');
|
|
264
|
+
}
|
|
265
|
+
if (!Array.isArray(payload.fix_actions) || payload.fix_actions.length === 0) {
|
|
266
|
+
throw new Error('at least one --fix-action is required');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const status = normalizeStatus(payload.status, 'candidate');
|
|
270
|
+
if (status === 'promoted') {
|
|
271
|
+
throw new Error('record does not accept status=promoted. Use "sce errorbook promote <id>"');
|
|
272
|
+
}
|
|
273
|
+
if (status === 'verified' && (!Array.isArray(payload.verification_evidence) || payload.verification_evidence.length === 0)) {
|
|
274
|
+
throw new Error('status=verified requires at least one --verification evidence');
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function normalizeRecordPayload(options = {}, fromFilePayload = {}) {
|
|
279
|
+
const payload = {
|
|
280
|
+
title: normalizeText(options.title || fromFilePayload.title),
|
|
281
|
+
symptom: normalizeText(options.symptom || fromFilePayload.symptom),
|
|
282
|
+
root_cause: normalizeText(options.rootCause || options.root_cause || fromFilePayload.root_cause || fromFilePayload.rootCause),
|
|
283
|
+
fix_actions: normalizeStringList(fromFilePayload.fix_actions, fromFilePayload.fixActions, options.fixAction, options.fixActions),
|
|
284
|
+
verification_evidence: normalizeStringList(
|
|
285
|
+
fromFilePayload.verification_evidence,
|
|
286
|
+
fromFilePayload.verificationEvidence,
|
|
287
|
+
options.verification,
|
|
288
|
+
options.verificationEvidence
|
|
289
|
+
),
|
|
290
|
+
tags: normalizeStringList(fromFilePayload.tags, options.tags),
|
|
291
|
+
ontology_tags: normalizeOntologyTags(fromFilePayload.ontology_tags, fromFilePayload.ontology, options.ontology),
|
|
292
|
+
status: normalizeStatus(options.status || fromFilePayload.status || 'candidate'),
|
|
293
|
+
source: {
|
|
294
|
+
spec: normalizeText(options.spec || fromFilePayload?.source?.spec),
|
|
295
|
+
files: normalizeStringList(fromFilePayload?.source?.files, options.files),
|
|
296
|
+
tests: normalizeStringList(fromFilePayload?.source?.tests, options.tests)
|
|
297
|
+
},
|
|
298
|
+
notes: normalizeText(options.notes || fromFilePayload.notes),
|
|
299
|
+
fingerprint: createFingerprint({
|
|
300
|
+
fingerprint: options.fingerprint || fromFilePayload.fingerprint,
|
|
301
|
+
title: options.title || fromFilePayload.title,
|
|
302
|
+
symptom: options.symptom || fromFilePayload.symptom,
|
|
303
|
+
root_cause: options.rootCause || options.root_cause || fromFilePayload.root_cause || fromFilePayload.rootCause
|
|
304
|
+
})
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return payload;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function createEntryId() {
|
|
311
|
+
return `eb-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function buildIndexSummary(entry) {
|
|
315
|
+
return {
|
|
316
|
+
id: entry.id,
|
|
317
|
+
fingerprint: entry.fingerprint,
|
|
318
|
+
title: entry.title,
|
|
319
|
+
status: entry.status,
|
|
320
|
+
quality_score: entry.quality_score,
|
|
321
|
+
tags: entry.tags,
|
|
322
|
+
ontology_tags: entry.ontology_tags,
|
|
323
|
+
occurrences: entry.occurrences || 1,
|
|
324
|
+
created_at: entry.created_at,
|
|
325
|
+
updated_at: entry.updated_at
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function findSummaryById(index, id) {
|
|
330
|
+
const normalized = normalizeText(id);
|
|
331
|
+
if (!normalized) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const exact = index.entries.find((item) => item.id === normalized);
|
|
336
|
+
if (exact) {
|
|
337
|
+
return exact;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const startsWith = index.entries.filter((item) => item.id.startsWith(normalized));
|
|
341
|
+
if (startsWith.length === 1) {
|
|
342
|
+
return startsWith[0];
|
|
343
|
+
}
|
|
344
|
+
if (startsWith.length > 1) {
|
|
345
|
+
throw new Error(`entry id prefix "${normalized}" is ambiguous (${startsWith.length} matches)`);
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function mergeEntry(existingEntry, incomingPayload) {
|
|
351
|
+
const merged = {
|
|
352
|
+
...existingEntry,
|
|
353
|
+
title: normalizeText(incomingPayload.title) || existingEntry.title,
|
|
354
|
+
symptom: normalizeText(incomingPayload.symptom) || existingEntry.symptom,
|
|
355
|
+
root_cause: normalizeText(incomingPayload.root_cause) || existingEntry.root_cause,
|
|
356
|
+
fix_actions: normalizeStringList(existingEntry.fix_actions, incomingPayload.fix_actions),
|
|
357
|
+
verification_evidence: normalizeStringList(existingEntry.verification_evidence, incomingPayload.verification_evidence),
|
|
358
|
+
tags: normalizeStringList(existingEntry.tags, incomingPayload.tags),
|
|
359
|
+
ontology_tags: normalizeOntologyTags(existingEntry.ontology_tags, incomingPayload.ontology_tags),
|
|
360
|
+
status: selectStatus(existingEntry.status, incomingPayload.status),
|
|
361
|
+
notes: normalizeText(incomingPayload.notes) || existingEntry.notes || '',
|
|
362
|
+
source: {
|
|
363
|
+
spec: normalizeText(incomingPayload?.source?.spec) || normalizeText(existingEntry?.source?.spec),
|
|
364
|
+
files: normalizeStringList(existingEntry?.source?.files, incomingPayload?.source?.files),
|
|
365
|
+
tests: normalizeStringList(existingEntry?.source?.tests, incomingPayload?.source?.tests)
|
|
366
|
+
},
|
|
367
|
+
occurrences: Number(existingEntry.occurrences || 1) + 1,
|
|
368
|
+
updated_at: nowIso()
|
|
369
|
+
};
|
|
370
|
+
merged.quality_score = scoreQuality(merged);
|
|
371
|
+
return merged;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadRecordPayloadFromFile(projectPath, sourcePath, fileSystem = fs) {
|
|
375
|
+
const normalized = normalizeText(sourcePath);
|
|
376
|
+
if (!normalized) {
|
|
377
|
+
return {};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const absolutePath = path.isAbsolute(normalized)
|
|
381
|
+
? normalized
|
|
382
|
+
: path.join(projectPath, normalized);
|
|
383
|
+
|
|
384
|
+
if (!await fileSystem.pathExists(absolutePath)) {
|
|
385
|
+
throw new Error(`record source file not found: ${sourcePath}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const payload = await fileSystem.readJson(absolutePath);
|
|
390
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
391
|
+
throw new Error('record source JSON must be an object');
|
|
392
|
+
}
|
|
393
|
+
return payload;
|
|
394
|
+
} catch (error) {
|
|
395
|
+
throw new Error(`failed to parse record source file (${sourcePath}): ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function printRecordSummary(result) {
|
|
400
|
+
const action = result.created ? 'Recorded new entry' : 'Updated duplicate fingerprint';
|
|
401
|
+
console.log(chalk.green(`✓ ${action}`));
|
|
402
|
+
console.log(chalk.gray(` id: ${result.entry.id}`));
|
|
403
|
+
console.log(chalk.gray(` status: ${result.entry.status}`));
|
|
404
|
+
console.log(chalk.gray(` quality: ${result.entry.quality_score}`));
|
|
405
|
+
console.log(chalk.gray(` fingerprint: ${result.entry.fingerprint}`));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function printListSummary(payload) {
|
|
409
|
+
if (payload.entries.length === 0) {
|
|
410
|
+
console.log(chalk.gray('No errorbook entries found'));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const table = new Table({
|
|
415
|
+
head: ['ID', 'Status', 'Quality', 'Title', 'Updated', 'Occurrences'].map((item) => chalk.cyan(item)),
|
|
416
|
+
colWidths: [16, 12, 10, 44, 22, 12]
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
payload.entries.forEach((entry) => {
|
|
420
|
+
table.push([
|
|
421
|
+
entry.id,
|
|
422
|
+
entry.status,
|
|
423
|
+
entry.quality_score,
|
|
424
|
+
entry.title.length > 40 ? `${entry.title.slice(0, 40)}...` : entry.title,
|
|
425
|
+
entry.updated_at,
|
|
426
|
+
entry.occurrences || 1
|
|
427
|
+
]);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
console.log(table.toString());
|
|
431
|
+
console.log(chalk.gray(`Total: ${payload.total_results} (stored: ${payload.total_entries})`));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function scoreSearchMatch(entry, queryTokens) {
|
|
435
|
+
const title = normalizeText(entry.title).toLowerCase();
|
|
436
|
+
const symptom = normalizeText(entry.symptom).toLowerCase();
|
|
437
|
+
const rootCause = normalizeText(entry.root_cause).toLowerCase();
|
|
438
|
+
const fingerprint = normalizeText(entry.fingerprint).toLowerCase();
|
|
439
|
+
const tagText = normalizeStringList(entry.tags, entry.ontology_tags).join(' ').toLowerCase();
|
|
440
|
+
const fixText = normalizeStringList(entry.fix_actions).join(' ').toLowerCase();
|
|
441
|
+
|
|
442
|
+
let score = 0;
|
|
443
|
+
for (const token of queryTokens) {
|
|
444
|
+
if (!token) {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (title.includes(token)) {
|
|
448
|
+
score += 8;
|
|
449
|
+
}
|
|
450
|
+
if (symptom.includes(token)) {
|
|
451
|
+
score += 5;
|
|
452
|
+
}
|
|
453
|
+
if (rootCause.includes(token)) {
|
|
454
|
+
score += 5;
|
|
455
|
+
}
|
|
456
|
+
if (fixText.includes(token)) {
|
|
457
|
+
score += 3;
|
|
458
|
+
}
|
|
459
|
+
if (tagText.includes(token)) {
|
|
460
|
+
score += 2;
|
|
461
|
+
}
|
|
462
|
+
if (fingerprint.includes(token)) {
|
|
463
|
+
score += 1;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
score += (Number(entry.quality_score) || 0) / 20;
|
|
468
|
+
score += STATUS_RANK[entry.status] || 0;
|
|
469
|
+
return Number(score.toFixed(3));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function normalizeRiskLevel(value, fallback = 'high') {
|
|
473
|
+
const normalized = normalizeText(`${value || ''}`).toLowerCase();
|
|
474
|
+
if (!normalized) {
|
|
475
|
+
return fallback;
|
|
476
|
+
}
|
|
477
|
+
if (!ERRORBOOK_RISK_LEVELS.includes(normalized)) {
|
|
478
|
+
throw new Error(`risk level must be one of: ${ERRORBOOK_RISK_LEVELS.join(', ')}`);
|
|
479
|
+
}
|
|
480
|
+
return normalized;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function riskRank(level) {
|
|
484
|
+
const normalized = normalizeRiskLevel(level, 'high');
|
|
485
|
+
if (normalized === 'high') {
|
|
486
|
+
return 3;
|
|
487
|
+
}
|
|
488
|
+
if (normalized === 'medium') {
|
|
489
|
+
return 2;
|
|
490
|
+
}
|
|
491
|
+
return 1;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function evaluateEntryRisk(entry = {}) {
|
|
495
|
+
const status = normalizeStatus(entry.status, 'candidate');
|
|
496
|
+
if (status === 'promoted' || status === 'deprecated') {
|
|
497
|
+
return 'low';
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const qualityScore = Number(entry.quality_score || 0);
|
|
501
|
+
const tags = normalizeStringList(entry.tags).map((item) => item.toLowerCase());
|
|
502
|
+
const ontologyTags = normalizeOntologyTags(entry.ontology_tags);
|
|
503
|
+
const hasHighRiskTag = tags.some((tag) => HIGH_RISK_SIGNAL_TAGS.includes(tag));
|
|
504
|
+
|
|
505
|
+
if (hasHighRiskTag) {
|
|
506
|
+
return 'high';
|
|
507
|
+
}
|
|
508
|
+
if (status === 'candidate' && qualityScore >= 85) {
|
|
509
|
+
return 'high';
|
|
510
|
+
}
|
|
511
|
+
if (status === 'candidate' && qualityScore >= 75 && ontologyTags.includes('decision_policy')) {
|
|
512
|
+
return 'high';
|
|
513
|
+
}
|
|
514
|
+
if (status === 'candidate') {
|
|
515
|
+
return 'medium';
|
|
516
|
+
}
|
|
517
|
+
if (qualityScore >= 85 && ontologyTags.includes('decision_policy')) {
|
|
518
|
+
return 'high';
|
|
519
|
+
}
|
|
520
|
+
return 'medium';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function evaluateErrorbookReleaseGate(options = {}, dependencies = {}) {
|
|
524
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
525
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
526
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
527
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
528
|
+
const minRisk = normalizeRiskLevel(options.minRisk || options.min_risk || 'high', 'high');
|
|
529
|
+
const includeVerified = options.includeVerified === true;
|
|
530
|
+
|
|
531
|
+
const inspected = [];
|
|
532
|
+
for (const summary of index.entries) {
|
|
533
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
534
|
+
if (!entry) {
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const status = normalizeStatus(entry.status, 'candidate');
|
|
539
|
+
const unresolved = status === 'candidate' || (includeVerified && status === 'verified');
|
|
540
|
+
if (!unresolved) {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const risk = evaluateEntryRisk(entry);
|
|
545
|
+
inspected.push({
|
|
546
|
+
id: entry.id,
|
|
547
|
+
title: entry.title,
|
|
548
|
+
status,
|
|
549
|
+
risk,
|
|
550
|
+
quality_score: Number(entry.quality_score || 0),
|
|
551
|
+
tags: normalizeStringList(entry.tags),
|
|
552
|
+
updated_at: entry.updated_at
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const blocked = inspected
|
|
557
|
+
.filter((item) => riskRank(item.risk) >= riskRank(minRisk))
|
|
558
|
+
.sort((left, right) => {
|
|
559
|
+
const riskDiff = riskRank(right.risk) - riskRank(left.risk);
|
|
560
|
+
if (riskDiff !== 0) {
|
|
561
|
+
return riskDiff;
|
|
562
|
+
}
|
|
563
|
+
const qualityDiff = Number(right.quality_score || 0) - Number(left.quality_score || 0);
|
|
564
|
+
if (qualityDiff !== 0) {
|
|
565
|
+
return qualityDiff;
|
|
566
|
+
}
|
|
567
|
+
return `${right.updated_at || ''}`.localeCompare(`${left.updated_at || ''}`);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
mode: 'errorbook-release-gate',
|
|
572
|
+
gate: {
|
|
573
|
+
min_risk: minRisk,
|
|
574
|
+
include_verified: includeVerified
|
|
575
|
+
},
|
|
576
|
+
passed: blocked.length === 0,
|
|
577
|
+
inspected_count: inspected.length,
|
|
578
|
+
blocked_count: blocked.length,
|
|
579
|
+
blocked_entries: blocked
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function validatePromoteCandidate(entry, minQuality = DEFAULT_PROMOTE_MIN_QUALITY) {
|
|
584
|
+
const missing = [];
|
|
585
|
+
if (!normalizeText(entry.root_cause)) {
|
|
586
|
+
missing.push('root_cause');
|
|
587
|
+
}
|
|
588
|
+
if (!Array.isArray(entry.fix_actions) || entry.fix_actions.length === 0) {
|
|
589
|
+
missing.push('fix_actions');
|
|
590
|
+
}
|
|
591
|
+
if (!Array.isArray(entry.verification_evidence) || entry.verification_evidence.length === 0) {
|
|
592
|
+
missing.push('verification_evidence');
|
|
593
|
+
}
|
|
594
|
+
if (!Array.isArray(entry.ontology_tags) || entry.ontology_tags.length === 0) {
|
|
595
|
+
missing.push('ontology_tags');
|
|
596
|
+
}
|
|
597
|
+
if ((Number(entry.quality_score) || 0) < minQuality) {
|
|
598
|
+
missing.push(`quality_score>=${minQuality}`);
|
|
599
|
+
}
|
|
600
|
+
if (entry.status === 'deprecated') {
|
|
601
|
+
missing.push('status!=deprecated');
|
|
602
|
+
}
|
|
603
|
+
return missing;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function runErrorbookRecordCommand(options = {}, dependencies = {}) {
|
|
607
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
608
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
609
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
610
|
+
|
|
611
|
+
const fromFilePayload = await loadRecordPayloadFromFile(projectPath, options.from, fileSystem);
|
|
612
|
+
const normalized = normalizeRecordPayload(options, fromFilePayload);
|
|
613
|
+
validateRecordPayload(normalized);
|
|
614
|
+
|
|
615
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
616
|
+
const existingSummary = index.entries.find((entry) => entry.fingerprint === normalized.fingerprint);
|
|
617
|
+
const timestamp = nowIso();
|
|
618
|
+
|
|
619
|
+
let entry;
|
|
620
|
+
let created = false;
|
|
621
|
+
let deduplicated = false;
|
|
622
|
+
|
|
623
|
+
if (existingSummary) {
|
|
624
|
+
const existingEntry = await readErrorbookEntry(paths, existingSummary.id, fileSystem);
|
|
625
|
+
if (!existingEntry) {
|
|
626
|
+
throw new Error(`errorbook index references missing entry: ${existingSummary.id}`);
|
|
627
|
+
}
|
|
628
|
+
entry = mergeEntry(existingEntry, normalized);
|
|
629
|
+
deduplicated = true;
|
|
630
|
+
} else {
|
|
631
|
+
entry = {
|
|
632
|
+
api_version: ERRORBOOK_ENTRY_API_VERSION,
|
|
633
|
+
id: createEntryId(),
|
|
634
|
+
created_at: timestamp,
|
|
635
|
+
updated_at: timestamp,
|
|
636
|
+
fingerprint: normalized.fingerprint,
|
|
637
|
+
title: normalized.title,
|
|
638
|
+
symptom: normalized.symptom,
|
|
639
|
+
root_cause: normalized.root_cause,
|
|
640
|
+
fix_actions: normalized.fix_actions,
|
|
641
|
+
verification_evidence: normalized.verification_evidence,
|
|
642
|
+
tags: normalized.tags,
|
|
643
|
+
ontology_tags: normalized.ontology_tags,
|
|
644
|
+
status: normalized.status,
|
|
645
|
+
source: normalized.source,
|
|
646
|
+
notes: normalized.notes || '',
|
|
647
|
+
occurrences: 1
|
|
648
|
+
};
|
|
649
|
+
entry.quality_score = scoreQuality(entry);
|
|
650
|
+
created = true;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
entry.updated_at = nowIso();
|
|
654
|
+
entry.quality_score = scoreQuality(entry);
|
|
655
|
+
await writeErrorbookEntry(paths, entry, fileSystem);
|
|
656
|
+
|
|
657
|
+
const summary = buildIndexSummary(entry);
|
|
658
|
+
const summaryIndex = index.entries.findIndex((item) => item.id === summary.id);
|
|
659
|
+
if (summaryIndex >= 0) {
|
|
660
|
+
index.entries[summaryIndex] = summary;
|
|
661
|
+
} else {
|
|
662
|
+
index.entries.push(summary);
|
|
663
|
+
}
|
|
664
|
+
index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
|
|
665
|
+
await writeErrorbookIndex(paths, index, fileSystem);
|
|
666
|
+
|
|
667
|
+
const result = {
|
|
668
|
+
mode: 'errorbook-record',
|
|
669
|
+
created,
|
|
670
|
+
deduplicated,
|
|
671
|
+
entry
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
if (options.json) {
|
|
675
|
+
console.log(JSON.stringify(result, null, 2));
|
|
676
|
+
} else if (!options.silent) {
|
|
677
|
+
printRecordSummary(result);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return result;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function runErrorbookListCommand(options = {}, dependencies = {}) {
|
|
684
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
685
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
686
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
687
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
688
|
+
|
|
689
|
+
const requestedStatus = options.status ? normalizeStatus(options.status) : null;
|
|
690
|
+
const requestedTag = normalizeText(options.tag).toLowerCase();
|
|
691
|
+
const requestedOntology = normalizeOntologyTags(options.ontology)[0] || '';
|
|
692
|
+
const minQuality = Number.isFinite(Number(options.minQuality)) ? Number(options.minQuality) : null;
|
|
693
|
+
const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
|
|
694
|
+
? Number(options.limit)
|
|
695
|
+
: 20;
|
|
696
|
+
|
|
697
|
+
let filtered = [...index.entries];
|
|
698
|
+
if (requestedStatus) {
|
|
699
|
+
filtered = filtered.filter((entry) => entry.status === requestedStatus);
|
|
700
|
+
}
|
|
701
|
+
if (requestedTag) {
|
|
702
|
+
filtered = filtered.filter((entry) => normalizeStringList(entry.tags).some((tag) => tag.toLowerCase() === requestedTag));
|
|
703
|
+
}
|
|
704
|
+
if (requestedOntology) {
|
|
705
|
+
filtered = filtered.filter((entry) => normalizeOntologyTags(entry.ontology_tags).includes(requestedOntology));
|
|
706
|
+
}
|
|
707
|
+
if (minQuality !== null) {
|
|
708
|
+
filtered = filtered.filter((entry) => Number(entry.quality_score || 0) >= minQuality);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
filtered.sort((left, right) => {
|
|
712
|
+
const qualityDiff = Number(right.quality_score || 0) - Number(left.quality_score || 0);
|
|
713
|
+
if (qualityDiff !== 0) {
|
|
714
|
+
return qualityDiff;
|
|
715
|
+
}
|
|
716
|
+
return `${right.updated_at}`.localeCompare(`${left.updated_at}`);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const result = {
|
|
720
|
+
mode: 'errorbook-list',
|
|
721
|
+
total_entries: index.entries.length,
|
|
722
|
+
total_results: filtered.length,
|
|
723
|
+
entries: filtered.slice(0, limit).map((entry) => ({
|
|
724
|
+
...entry,
|
|
725
|
+
tags: normalizeStringList(entry.tags),
|
|
726
|
+
ontology_tags: normalizeOntologyTags(entry.ontology_tags)
|
|
727
|
+
}))
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
if (options.json) {
|
|
731
|
+
console.log(JSON.stringify(result, null, 2));
|
|
732
|
+
} else if (!options.silent) {
|
|
733
|
+
printListSummary(result);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function runErrorbookShowCommand(options = {}, dependencies = {}) {
|
|
740
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
741
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
742
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
743
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
744
|
+
|
|
745
|
+
const id = normalizeText(options.id || options.entryId);
|
|
746
|
+
if (!id) {
|
|
747
|
+
throw new Error('entry id is required');
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const summary = findSummaryById(index, id);
|
|
751
|
+
if (!summary) {
|
|
752
|
+
throw new Error(`errorbook entry not found: ${id}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
756
|
+
if (!entry) {
|
|
757
|
+
throw new Error(`errorbook entry file not found: ${summary.id}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const result = {
|
|
761
|
+
mode: 'errorbook-show',
|
|
762
|
+
entry
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
if (options.json) {
|
|
766
|
+
console.log(JSON.stringify(result, null, 2));
|
|
767
|
+
} else if (!options.silent) {
|
|
768
|
+
console.log(chalk.cyan.bold(entry.title));
|
|
769
|
+
console.log(chalk.gray(`id: ${entry.id}`));
|
|
770
|
+
console.log(chalk.gray(`status: ${entry.status}`));
|
|
771
|
+
console.log(chalk.gray(`quality: ${entry.quality_score}`));
|
|
772
|
+
console.log(chalk.gray(`fingerprint: ${entry.fingerprint}`));
|
|
773
|
+
console.log(chalk.gray(`symptom: ${entry.symptom}`));
|
|
774
|
+
console.log(chalk.gray(`root_cause: ${entry.root_cause}`));
|
|
775
|
+
console.log(chalk.gray(`fix_actions: ${entry.fix_actions.join(' | ')}`));
|
|
776
|
+
console.log(chalk.gray(`verification: ${entry.verification_evidence.join(' | ') || '(none)'}`));
|
|
777
|
+
console.log(chalk.gray(`ontology: ${entry.ontology_tags.join(', ') || '(none)'}`));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
return result;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function runErrorbookFindCommand(options = {}, dependencies = {}) {
|
|
784
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
785
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
786
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
787
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
788
|
+
|
|
789
|
+
const query = normalizeText(options.query);
|
|
790
|
+
if (!query) {
|
|
791
|
+
throw new Error('--query is required');
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const requestedStatus = options.status ? normalizeStatus(options.status) : null;
|
|
795
|
+
const limit = Number.isFinite(Number(options.limit)) && Number(options.limit) > 0
|
|
796
|
+
? Number(options.limit)
|
|
797
|
+
: 10;
|
|
798
|
+
const tokens = query.toLowerCase().split(/[\s,;|]+/).map((item) => item.trim()).filter(Boolean);
|
|
799
|
+
|
|
800
|
+
const candidates = [];
|
|
801
|
+
for (const summary of index.entries) {
|
|
802
|
+
if (requestedStatus && summary.status !== requestedStatus) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
806
|
+
if (!entry) {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
const matchScore = scoreSearchMatch(entry, tokens);
|
|
810
|
+
if (matchScore <= 0) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
candidates.push({
|
|
814
|
+
id: entry.id,
|
|
815
|
+
status: entry.status,
|
|
816
|
+
quality_score: entry.quality_score,
|
|
817
|
+
title: entry.title,
|
|
818
|
+
fingerprint: entry.fingerprint,
|
|
819
|
+
tags: normalizeStringList(entry.tags),
|
|
820
|
+
ontology_tags: normalizeOntologyTags(entry.ontology_tags),
|
|
821
|
+
match_score: matchScore,
|
|
822
|
+
updated_at: entry.updated_at
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
candidates.sort((left, right) => {
|
|
827
|
+
const scoreDiff = Number(right.match_score) - Number(left.match_score);
|
|
828
|
+
if (scoreDiff !== 0) {
|
|
829
|
+
return scoreDiff;
|
|
830
|
+
}
|
|
831
|
+
return `${right.updated_at}`.localeCompare(`${left.updated_at}`);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const result = {
|
|
835
|
+
mode: 'errorbook-find',
|
|
836
|
+
query,
|
|
837
|
+
total_results: candidates.length,
|
|
838
|
+
entries: candidates.slice(0, limit)
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
if (options.json) {
|
|
842
|
+
console.log(JSON.stringify(result, null, 2));
|
|
843
|
+
} else if (!options.silent) {
|
|
844
|
+
printListSummary({
|
|
845
|
+
entries: result.entries,
|
|
846
|
+
total_results: result.total_results,
|
|
847
|
+
total_entries: index.entries.length
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
return result;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async function runErrorbookPromoteCommand(options = {}, dependencies = {}) {
|
|
855
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
856
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
857
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
858
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
859
|
+
|
|
860
|
+
const id = normalizeText(options.id || options.entryId);
|
|
861
|
+
if (!id) {
|
|
862
|
+
throw new Error('entry id is required');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const summary = findSummaryById(index, id);
|
|
866
|
+
if (!summary) {
|
|
867
|
+
throw new Error(`errorbook entry not found: ${id}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
871
|
+
if (!entry) {
|
|
872
|
+
throw new Error(`errorbook entry file not found: ${summary.id}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
entry.quality_score = scoreQuality(entry);
|
|
876
|
+
const missing = validatePromoteCandidate(entry, DEFAULT_PROMOTE_MIN_QUALITY);
|
|
877
|
+
if (missing.length > 0) {
|
|
878
|
+
throw new Error(`promote gate failed: ${missing.join(', ')}`);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
entry.status = 'promoted';
|
|
882
|
+
entry.promoted_at = nowIso();
|
|
883
|
+
entry.updated_at = entry.promoted_at;
|
|
884
|
+
await writeErrorbookEntry(paths, entry, fileSystem);
|
|
885
|
+
|
|
886
|
+
const updatedSummary = buildIndexSummary(entry);
|
|
887
|
+
const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
|
|
888
|
+
if (targetIndex >= 0) {
|
|
889
|
+
index.entries[targetIndex] = updatedSummary;
|
|
890
|
+
} else {
|
|
891
|
+
index.entries.push(updatedSummary);
|
|
892
|
+
}
|
|
893
|
+
index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
|
|
894
|
+
await writeErrorbookIndex(paths, index, fileSystem);
|
|
895
|
+
|
|
896
|
+
const result = {
|
|
897
|
+
mode: 'errorbook-promote',
|
|
898
|
+
promoted: true,
|
|
899
|
+
entry
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
if (options.json) {
|
|
903
|
+
console.log(JSON.stringify(result, null, 2));
|
|
904
|
+
} else if (!options.silent) {
|
|
905
|
+
console.log(chalk.green('✓ Promoted errorbook entry'));
|
|
906
|
+
console.log(chalk.gray(` id: ${entry.id}`));
|
|
907
|
+
console.log(chalk.gray(` quality: ${entry.quality_score}`));
|
|
908
|
+
console.log(chalk.gray(` promoted_at: ${entry.promoted_at}`));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function runErrorbookReleaseGateCommand(options = {}, dependencies = {}) {
|
|
915
|
+
const payload = await evaluateErrorbookReleaseGate(options, dependencies);
|
|
916
|
+
|
|
917
|
+
if (options.json) {
|
|
918
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
919
|
+
} else if (!options.silent) {
|
|
920
|
+
if (payload.passed) {
|
|
921
|
+
console.log(chalk.green('✓ Errorbook release gate passed'));
|
|
922
|
+
console.log(chalk.gray(` inspected: ${payload.inspected_count}`));
|
|
923
|
+
return payload;
|
|
924
|
+
}
|
|
925
|
+
console.log(chalk.red('✗ Errorbook release gate blocked'));
|
|
926
|
+
console.log(chalk.gray(` blocked: ${payload.blocked_count}`));
|
|
927
|
+
payload.blocked_entries.slice(0, 10).forEach((item) => {
|
|
928
|
+
console.log(chalk.gray(` - ${item.id} [${item.risk}] ${item.title}`));
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (options.failOnBlock && !payload.passed) {
|
|
933
|
+
throw new Error(
|
|
934
|
+
`errorbook release gate blocked: ${payload.blocked_count} unresolved entries (min-risk=${payload.gate.min_risk})`
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return payload;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function runErrorbookDeprecateCommand(options = {}, dependencies = {}) {
|
|
942
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
943
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
944
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
945
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
946
|
+
|
|
947
|
+
const id = normalizeText(options.id || options.entryId);
|
|
948
|
+
if (!id) {
|
|
949
|
+
throw new Error('entry id is required');
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const summary = findSummaryById(index, id);
|
|
953
|
+
if (!summary) {
|
|
954
|
+
throw new Error(`errorbook entry not found: ${id}`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const reason = normalizeText(options.reason);
|
|
958
|
+
if (!reason) {
|
|
959
|
+
throw new Error('--reason is required for deprecate');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const replacement = normalizeText(options.replacement);
|
|
963
|
+
if (replacement && replacement === summary.id) {
|
|
964
|
+
throw new Error('--replacement cannot reference the same entry id');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
968
|
+
if (!entry) {
|
|
969
|
+
throw new Error(`errorbook entry file not found: ${summary.id}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
entry.status = 'deprecated';
|
|
973
|
+
entry.updated_at = nowIso();
|
|
974
|
+
entry.deprecated_at = entry.updated_at;
|
|
975
|
+
entry.deprecation = {
|
|
976
|
+
reason,
|
|
977
|
+
replacement_id: replacement || null
|
|
978
|
+
};
|
|
979
|
+
entry.quality_score = scoreQuality(entry);
|
|
980
|
+
await writeErrorbookEntry(paths, entry, fileSystem);
|
|
981
|
+
|
|
982
|
+
const updatedSummary = buildIndexSummary(entry);
|
|
983
|
+
const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
|
|
984
|
+
if (targetIndex >= 0) {
|
|
985
|
+
index.entries[targetIndex] = updatedSummary;
|
|
986
|
+
} else {
|
|
987
|
+
index.entries.push(updatedSummary);
|
|
988
|
+
}
|
|
989
|
+
index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
|
|
990
|
+
await writeErrorbookIndex(paths, index, fileSystem);
|
|
991
|
+
|
|
992
|
+
const result = {
|
|
993
|
+
mode: 'errorbook-deprecate',
|
|
994
|
+
deprecated: true,
|
|
995
|
+
entry
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
if (options.json) {
|
|
999
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1000
|
+
} else if (!options.silent) {
|
|
1001
|
+
console.log(chalk.yellow('✓ Deprecated errorbook entry'));
|
|
1002
|
+
console.log(chalk.gray(` id: ${entry.id}`));
|
|
1003
|
+
console.log(chalk.gray(` reason: ${reason}`));
|
|
1004
|
+
if (replacement) {
|
|
1005
|
+
console.log(chalk.gray(` replacement: ${replacement}`));
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return result;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function runErrorbookRequalifyCommand(options = {}, dependencies = {}) {
|
|
1013
|
+
const projectPath = dependencies.projectPath || process.cwd();
|
|
1014
|
+
const fileSystem = dependencies.fileSystem || fs;
|
|
1015
|
+
const paths = resolveErrorbookPaths(projectPath);
|
|
1016
|
+
const index = await readErrorbookIndex(paths, fileSystem);
|
|
1017
|
+
|
|
1018
|
+
const id = normalizeText(options.id || options.entryId);
|
|
1019
|
+
if (!id) {
|
|
1020
|
+
throw new Error('entry id is required');
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const summary = findSummaryById(index, id);
|
|
1024
|
+
if (!summary) {
|
|
1025
|
+
throw new Error(`errorbook entry not found: ${id}`);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const status = normalizeStatus(options.status || 'verified');
|
|
1029
|
+
if (status === 'promoted') {
|
|
1030
|
+
throw new Error('requalify does not accept status=promoted. Use "sce errorbook promote <id>"');
|
|
1031
|
+
}
|
|
1032
|
+
if (status === 'deprecated') {
|
|
1033
|
+
throw new Error('requalify does not accept status=deprecated. Use "sce errorbook deprecate <id>"');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const entry = await readErrorbookEntry(paths, summary.id, fileSystem);
|
|
1037
|
+
if (!entry) {
|
|
1038
|
+
throw new Error(`errorbook entry file not found: ${summary.id}`);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (status === 'verified' && (!Array.isArray(entry.verification_evidence) || entry.verification_evidence.length === 0)) {
|
|
1042
|
+
throw new Error('requalify to verified requires verification_evidence');
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
entry.status = status;
|
|
1046
|
+
entry.updated_at = nowIso();
|
|
1047
|
+
entry.requalified_at = entry.updated_at;
|
|
1048
|
+
if (entry.deprecation) {
|
|
1049
|
+
delete entry.deprecation;
|
|
1050
|
+
}
|
|
1051
|
+
entry.quality_score = scoreQuality(entry);
|
|
1052
|
+
await writeErrorbookEntry(paths, entry, fileSystem);
|
|
1053
|
+
|
|
1054
|
+
const updatedSummary = buildIndexSummary(entry);
|
|
1055
|
+
const targetIndex = index.entries.findIndex((item) => item.id === entry.id);
|
|
1056
|
+
if (targetIndex >= 0) {
|
|
1057
|
+
index.entries[targetIndex] = updatedSummary;
|
|
1058
|
+
} else {
|
|
1059
|
+
index.entries.push(updatedSummary);
|
|
1060
|
+
}
|
|
1061
|
+
index.entries.sort((left, right) => `${right.updated_at}`.localeCompare(`${left.updated_at}`));
|
|
1062
|
+
await writeErrorbookIndex(paths, index, fileSystem);
|
|
1063
|
+
|
|
1064
|
+
const result = {
|
|
1065
|
+
mode: 'errorbook-requalify',
|
|
1066
|
+
requalified: true,
|
|
1067
|
+
entry
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
if (options.json) {
|
|
1071
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1072
|
+
} else if (!options.silent) {
|
|
1073
|
+
console.log(chalk.green('✓ Requalified errorbook entry'));
|
|
1074
|
+
console.log(chalk.gray(` id: ${entry.id}`));
|
|
1075
|
+
console.log(chalk.gray(` status: ${entry.status}`));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return result;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function collectOptionValue(value, previous = []) {
|
|
1082
|
+
const next = Array.isArray(previous) ? previous : [];
|
|
1083
|
+
next.push(value);
|
|
1084
|
+
return next;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function emitCommandError(error, json) {
|
|
1088
|
+
if (json) {
|
|
1089
|
+
console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
|
|
1090
|
+
} else {
|
|
1091
|
+
console.error(chalk.red('Error:'), error.message);
|
|
1092
|
+
}
|
|
1093
|
+
process.exit(1);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function registerErrorbookCommands(program) {
|
|
1097
|
+
const errorbook = program
|
|
1098
|
+
.command('errorbook')
|
|
1099
|
+
.description('Curated failure-remediation knowledge base');
|
|
1100
|
+
|
|
1101
|
+
errorbook
|
|
1102
|
+
.command('record')
|
|
1103
|
+
.description('Record a high-signal failure remediation entry')
|
|
1104
|
+
.option('--title <text>', 'Entry title')
|
|
1105
|
+
.option('--symptom <text>', 'Observed symptom')
|
|
1106
|
+
.option('--root-cause <text>', 'Validated root cause')
|
|
1107
|
+
.option('--fix-action <text>', 'Concrete fix action (repeatable)', collectOptionValue, [])
|
|
1108
|
+
.option('--verification <text>', 'Verification evidence (repeatable)', collectOptionValue, [])
|
|
1109
|
+
.option('--tags <csv>', 'Tags, comma-separated')
|
|
1110
|
+
.option('--ontology <csv>', `Ontology focus tags (${ERRORBOOK_ONTOLOGY_TAGS.join(', ')})`)
|
|
1111
|
+
.option('--status <status>', 'candidate|verified', 'candidate')
|
|
1112
|
+
.option('--fingerprint <text>', 'Custom deduplication fingerprint')
|
|
1113
|
+
.option('--from <path>', 'Load payload from JSON file')
|
|
1114
|
+
.option('--spec <spec>', 'Related spec id/name')
|
|
1115
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1116
|
+
.action(async (options) => {
|
|
1117
|
+
try {
|
|
1118
|
+
await runErrorbookRecordCommand(options);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
emitCommandError(error, options.json);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
errorbook
|
|
1125
|
+
.command('list')
|
|
1126
|
+
.description('List curated errorbook entries')
|
|
1127
|
+
.option('--status <status>', `Filter by status (${ERRORBOOK_STATUSES.join(', ')})`)
|
|
1128
|
+
.option('--tag <tag>', 'Filter by tag')
|
|
1129
|
+
.option('--ontology <tag>', `Filter by ontology tag (${ERRORBOOK_ONTOLOGY_TAGS.join(', ')})`)
|
|
1130
|
+
.option('--min-quality <n>', 'Minimum quality score', parseInt)
|
|
1131
|
+
.option('--limit <n>', 'Maximum entries returned', parseInt, 20)
|
|
1132
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1133
|
+
.action(async (options) => {
|
|
1134
|
+
try {
|
|
1135
|
+
await runErrorbookListCommand(options);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
emitCommandError(error, options.json);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
errorbook
|
|
1142
|
+
.command('show <id>')
|
|
1143
|
+
.description('Show a single errorbook entry')
|
|
1144
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1145
|
+
.action(async (id, options) => {
|
|
1146
|
+
try {
|
|
1147
|
+
await runErrorbookShowCommand({ ...options, id });
|
|
1148
|
+
} catch (error) {
|
|
1149
|
+
emitCommandError(error, options.json);
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
errorbook
|
|
1154
|
+
.command('find')
|
|
1155
|
+
.description('Search curated entries with ranking')
|
|
1156
|
+
.requiredOption('--query <text>', 'Search query')
|
|
1157
|
+
.option('--status <status>', `Filter by status (${ERRORBOOK_STATUSES.join(', ')})`)
|
|
1158
|
+
.option('--limit <n>', 'Maximum entries returned', parseInt, 10)
|
|
1159
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1160
|
+
.action(async (options) => {
|
|
1161
|
+
try {
|
|
1162
|
+
await runErrorbookFindCommand(options);
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
emitCommandError(error, options.json);
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
errorbook
|
|
1169
|
+
.command('promote <id>')
|
|
1170
|
+
.description('Promote entry after strict quality gate')
|
|
1171
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1172
|
+
.action(async (id, options) => {
|
|
1173
|
+
try {
|
|
1174
|
+
await runErrorbookPromoteCommand({ ...options, id });
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
emitCommandError(error, options.json);
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
errorbook
|
|
1181
|
+
.command('release-gate')
|
|
1182
|
+
.description('Block release on unresolved high-risk candidate entries')
|
|
1183
|
+
.option('--min-risk <level>', 'Risk threshold (low|medium|high)', 'high')
|
|
1184
|
+
.option('--include-verified', 'Also inspect verified (non-promoted) entries')
|
|
1185
|
+
.option('--fail-on-block', 'Exit with error when gate is blocked')
|
|
1186
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1187
|
+
.action(async (options) => {
|
|
1188
|
+
try {
|
|
1189
|
+
await runErrorbookReleaseGateCommand(options);
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
emitCommandError(error, options.json);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
errorbook
|
|
1196
|
+
.command('deprecate <id>')
|
|
1197
|
+
.description('Deprecate low-value or obsolete entry')
|
|
1198
|
+
.requiredOption('--reason <text>', 'Deprecation reason')
|
|
1199
|
+
.option('--replacement <id>', 'Replacement entry id')
|
|
1200
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1201
|
+
.action(async (id, options) => {
|
|
1202
|
+
try {
|
|
1203
|
+
await runErrorbookDeprecateCommand({ ...options, id });
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
emitCommandError(error, options.json);
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
errorbook
|
|
1210
|
+
.command('requalify <id>')
|
|
1211
|
+
.description('Requalify deprecated/candidate entry back to candidate|verified')
|
|
1212
|
+
.option('--status <status>', 'candidate|verified', 'verified')
|
|
1213
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
1214
|
+
.action(async (id, options) => {
|
|
1215
|
+
try {
|
|
1216
|
+
await runErrorbookRequalifyCommand({ ...options, id });
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
emitCommandError(error, options.json);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
module.exports = {
|
|
1224
|
+
ERRORBOOK_STATUSES,
|
|
1225
|
+
ERRORBOOK_ONTOLOGY_TAGS,
|
|
1226
|
+
ERRORBOOK_RISK_LEVELS,
|
|
1227
|
+
HIGH_RISK_SIGNAL_TAGS,
|
|
1228
|
+
DEFAULT_PROMOTE_MIN_QUALITY,
|
|
1229
|
+
resolveErrorbookPaths,
|
|
1230
|
+
normalizeOntologyTags,
|
|
1231
|
+
normalizeRecordPayload,
|
|
1232
|
+
scoreQuality,
|
|
1233
|
+
evaluateEntryRisk,
|
|
1234
|
+
evaluateErrorbookReleaseGate,
|
|
1235
|
+
runErrorbookRecordCommand,
|
|
1236
|
+
runErrorbookListCommand,
|
|
1237
|
+
runErrorbookShowCommand,
|
|
1238
|
+
runErrorbookFindCommand,
|
|
1239
|
+
runErrorbookPromoteCommand,
|
|
1240
|
+
runErrorbookReleaseGateCommand,
|
|
1241
|
+
runErrorbookDeprecateCommand,
|
|
1242
|
+
runErrorbookRequalifyCommand,
|
|
1243
|
+
registerErrorbookCommands
|
|
1244
|
+
};
|