@dogfood-lab/findings 1.2.3 → 1.3.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/cli.js +57 -12
- package/derive/load-records.js +130 -38
- package/derive/write-findings.js +156 -4
- package/lib/rename-with-retry.js +29 -5
- package/lib/safe-yaml-load.js +169 -0
- package/package.json +2 -4
- package/review/event-log.js +49 -23
- package/review/review-engine.js +22 -1
- package/synthesis/doctrine-derivation.js +13 -4
- package/synthesis/index.js +6 -2
- package/synthesis/recommendation-derivation.js +49 -18
- package/synthesis/validate-artifacts.js +23 -31
- package/synthesis/write-artifacts.js +302 -20
- package/validate.js +22 -40
|
@@ -1,75 +1,357 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Write pattern, recommendation, and doctrine artifacts to disk.
|
|
3
|
+
*
|
|
4
|
+
* D2B-008 sibling family — these singleton writers (`writePattern`,
|
|
5
|
+
* `writeRecommendation`, `writeDoctrine`) share the same id-then-write
|
|
6
|
+
* pattern as the finding writer, and so share the same intra-batch
|
|
7
|
+
* collision class when used in a loop (which the CLI does at
|
|
8
|
+
* packages/findings/cli.js:665-672 / :728-732 / :789-793). The batch
|
|
9
|
+
* helpers `writePatterns` / `writeRecommendations` / `writeDoctrines`
|
|
10
|
+
* mirror `writeFindings(rootDir, findings)`: record the first occurrence
|
|
11
|
+
* per id, refuse subsequent colliders with a structured
|
|
12
|
+
* `PATTERN_ID_COLLISION` / `RECOMMENDATION_ID_COLLISION` /
|
|
13
|
+
* `DOCTRINE_ID_COLLISION` error, return `{ written, errors }`. Callers
|
|
14
|
+
* (CLI / programmatic) check `errors.length > 0` to surface non-zero.
|
|
15
|
+
*
|
|
16
|
+
* L3-001 (Wave A2 amend2 — family seal). The SINGLETON writers had the
|
|
17
|
+
* same silent-clobber class as the singleton `writeFinding`: a second
|
|
18
|
+
* call with the same id silently overwrote via atomicWriteFileSync. The
|
|
19
|
+
* fail-closed guard now lives at the singleton AND batch path via a
|
|
20
|
+
* shared process-level `seenArtifactWrites` Map keyed by
|
|
21
|
+
* `${rootDir}:${kind}:${id}`. Second-with-same-id throws a typed
|
|
22
|
+
* `*IdCollisionError`; batch path collects into errors[]. Operator
|
|
23
|
+
* opt-in for legitimate re-write is `resetSeenArtifactWrites(rootDir?)`.
|
|
3
24
|
*/
|
|
4
25
|
|
|
5
|
-
import { mkdirSync
|
|
6
|
-
import { resolve
|
|
26
|
+
import { mkdirSync } from 'node:fs';
|
|
27
|
+
import { resolve } from 'node:path';
|
|
7
28
|
import yaml from 'js-yaml';
|
|
8
29
|
|
|
9
30
|
import { atomicWriteFileSync } from '../lib/atomic-write.js';
|
|
31
|
+
import { loadYamlDir } from '../lib/safe-yaml-load.js';
|
|
32
|
+
import { validatePattern, validateRecommendation, validateDoctrine } from './validate-artifacts.js';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Process-level memory of artifact ids written via the singletons (and
|
|
36
|
+
* via the singleton-call inside the batch helpers). Keyed by
|
|
37
|
+
* `${rootDir}:${kind}:${id}` so callers writing to distinct roots /
|
|
38
|
+
* kinds don't collide.
|
|
39
|
+
*
|
|
40
|
+
* L3-001 family-seal of D2B-008.
|
|
41
|
+
*/
|
|
42
|
+
const seenArtifactWrites = new Map();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reset the artifact-singleton's in-process collision memory. Mirrors
|
|
46
|
+
* `resetSeenWrites` in derive/write-findings.js. Pass a `rootDir` to
|
|
47
|
+
* clear only entries scoped to that root.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} [rootDir]
|
|
50
|
+
*/
|
|
51
|
+
export function resetSeenArtifactWrites(rootDir) {
|
|
52
|
+
if (rootDir === undefined) {
|
|
53
|
+
seenArtifactWrites.clear();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const prefix = `${rootDir}:`;
|
|
57
|
+
for (const key of seenArtifactWrites.keys()) {
|
|
58
|
+
if (key.startsWith(prefix)) seenArtifactWrites.delete(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Structured collision errors thrown by the singleton writers when called
|
|
64
|
+
* twice in the same process with the same id. Mirror the batch helpers'
|
|
65
|
+
* collision codes so the operator-facing vocabulary is identical.
|
|
66
|
+
*/
|
|
67
|
+
export class PatternIdCollisionError extends Error {
|
|
68
|
+
constructor(patternId) {
|
|
69
|
+
super(`pattern_id collision: '${patternId}' already written in this process; refused to silently clobber (D2B-008 / L3-001 family-seal). Call resetSeenArtifactWrites() if a legitimate re-write is intended.`);
|
|
70
|
+
this.name = 'PatternIdCollisionError';
|
|
71
|
+
this.code = 'PATTERN_ID_COLLISION';
|
|
72
|
+
this.patternId = patternId;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class RecommendationIdCollisionError extends Error {
|
|
77
|
+
constructor(recommendationId) {
|
|
78
|
+
super(`recommendation_id collision: '${recommendationId}' already written in this process; refused to silently clobber (D2B-008 / L3-001 family-seal). Call resetSeenArtifactWrites() if a legitimate re-write is intended.`);
|
|
79
|
+
this.name = 'RecommendationIdCollisionError';
|
|
80
|
+
this.code = 'RECOMMENDATION_ID_COLLISION';
|
|
81
|
+
this.recommendationId = recommendationId;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class DoctrineIdCollisionError extends Error {
|
|
86
|
+
constructor(doctrineId) {
|
|
87
|
+
super(`doctrine_id collision: '${doctrineId}' already written in this process; refused to silently clobber (D2B-008 / L3-001 family-seal). Call resetSeenArtifactWrites() if a legitimate re-write is intended.`);
|
|
88
|
+
this.name = 'DoctrineIdCollisionError';
|
|
89
|
+
this.code = 'DOCTRINE_ID_COLLISION';
|
|
90
|
+
this.doctrineId = doctrineId;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Structured validation-error classes for the synthesis writers. Mirror
|
|
96
|
+
* `FindingValidationError` in derive/write-findings.js and
|
|
97
|
+
* `RecordValidationError` in @dogfood-lab/ingest/validate-record.js — typed
|
|
98
|
+
* with `.code` so callers can pattern-match. D2B-002.
|
|
99
|
+
*/
|
|
100
|
+
export class PatternValidationError extends Error {
|
|
101
|
+
constructor(errors, patternId) {
|
|
102
|
+
super(`pattern failed schema validation${patternId ? ` (${patternId})` : ''}: ${errors.map(e => `${e.path || '/'} ${e.message}`).join('; ')}`);
|
|
103
|
+
this.name = 'PatternValidationError';
|
|
104
|
+
this.code = 'PATTERN_SCHEMA_INVALID';
|
|
105
|
+
this.patternId = patternId;
|
|
106
|
+
this.errors = errors;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class RecommendationValidationError extends Error {
|
|
111
|
+
constructor(errors, recommendationId) {
|
|
112
|
+
super(`recommendation failed schema validation${recommendationId ? ` (${recommendationId})` : ''}: ${errors.map(e => `${e.path || '/'} ${e.message}`).join('; ')}`);
|
|
113
|
+
this.name = 'RecommendationValidationError';
|
|
114
|
+
this.code = 'RECOMMENDATION_SCHEMA_INVALID';
|
|
115
|
+
this.recommendationId = recommendationId;
|
|
116
|
+
this.errors = errors;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class DoctrineValidationError extends Error {
|
|
121
|
+
constructor(errors, doctrineId) {
|
|
122
|
+
super(`doctrine failed schema validation${doctrineId ? ` (${doctrineId})` : ''}: ${errors.map(e => `${e.path || '/'} ${e.message}`).join('; ')}`);
|
|
123
|
+
this.name = 'DoctrineValidationError';
|
|
124
|
+
this.code = 'DOCTRINE_SCHEMA_INVALID';
|
|
125
|
+
this.doctrineId = doctrineId;
|
|
126
|
+
this.errors = errors;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
10
129
|
|
|
11
130
|
/**
|
|
12
131
|
* Write a pattern to disk.
|
|
132
|
+
*
|
|
133
|
+
* D2B-002 — fail-closed schema gate BEFORE touching the filesystem. The CLI
|
|
134
|
+
* had NO validation here at HEAD (worse than `writeFinding`, which had at
|
|
135
|
+
* least the CLI-side gate). Now neither the CLI nor any programmatic caller
|
|
136
|
+
* can persist a malformed pattern.
|
|
137
|
+
*
|
|
138
|
+
* L3-001 — same-process same-id refusal (family seal of D2B-008).
|
|
13
139
|
*/
|
|
14
140
|
export function writePattern(rootDir, pattern) {
|
|
141
|
+
const v = validatePattern(pattern);
|
|
142
|
+
if (!v.valid) throw new PatternValidationError(v.errors, pattern?.pattern_id);
|
|
143
|
+
|
|
144
|
+
const id = pattern?.pattern_id;
|
|
145
|
+
if (id !== undefined && id !== null) {
|
|
146
|
+
const key = `${rootDir}:pattern:${id}`;
|
|
147
|
+
if (seenArtifactWrites.has(key)) throw new PatternIdCollisionError(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
15
150
|
const dir = resolve(rootDir, 'patterns');
|
|
16
151
|
mkdirSync(dir, { recursive: true });
|
|
17
152
|
const path = resolve(dir, `${pattern.pattern_id}.yaml`);
|
|
18
153
|
atomicWriteFileSync(path, yaml.dump(JSON.parse(JSON.stringify(pattern)), { lineWidth: 120, noRefs: true }));
|
|
154
|
+
|
|
155
|
+
if (id !== undefined && id !== null) seenArtifactWrites.set(`${rootDir}:pattern:${id}`, true);
|
|
19
156
|
return path;
|
|
20
157
|
}
|
|
21
158
|
|
|
22
159
|
/**
|
|
23
160
|
* Write a recommendation to disk.
|
|
161
|
+
*
|
|
162
|
+
* D2B-002 — fail-closed schema gate.
|
|
163
|
+
* L3-001 — same-process same-id refusal (family seal of D2B-008).
|
|
24
164
|
*/
|
|
25
165
|
export function writeRecommendation(rootDir, rec) {
|
|
166
|
+
const v = validateRecommendation(rec);
|
|
167
|
+
if (!v.valid) throw new RecommendationValidationError(v.errors, rec?.recommendation_id);
|
|
168
|
+
|
|
169
|
+
const id = rec?.recommendation_id;
|
|
170
|
+
if (id !== undefined && id !== null) {
|
|
171
|
+
const key = `${rootDir}:recommendation:${id}`;
|
|
172
|
+
if (seenArtifactWrites.has(key)) throw new RecommendationIdCollisionError(id);
|
|
173
|
+
}
|
|
174
|
+
|
|
26
175
|
const dir = resolve(rootDir, 'recommendations');
|
|
27
176
|
mkdirSync(dir, { recursive: true });
|
|
28
177
|
const path = resolve(dir, `${rec.recommendation_id}.yaml`);
|
|
29
178
|
atomicWriteFileSync(path, yaml.dump(JSON.parse(JSON.stringify(rec)), { lineWidth: 120, noRefs: true }));
|
|
179
|
+
|
|
180
|
+
if (id !== undefined && id !== null) seenArtifactWrites.set(`${rootDir}:recommendation:${id}`, true);
|
|
30
181
|
return path;
|
|
31
182
|
}
|
|
32
183
|
|
|
33
184
|
/**
|
|
34
185
|
* Write a doctrine to disk.
|
|
186
|
+
*
|
|
187
|
+
* D2B-002 — fail-closed schema gate.
|
|
188
|
+
* L3-001 — same-process same-id refusal (family seal of D2B-008).
|
|
35
189
|
*/
|
|
36
190
|
export function writeDoctrine(rootDir, doc) {
|
|
191
|
+
const v = validateDoctrine(doc);
|
|
192
|
+
if (!v.valid) throw new DoctrineValidationError(v.errors, doc?.doctrine_id);
|
|
193
|
+
|
|
194
|
+
const id = doc?.doctrine_id;
|
|
195
|
+
if (id !== undefined && id !== null) {
|
|
196
|
+
const key = `${rootDir}:doctrine:${id}`;
|
|
197
|
+
if (seenArtifactWrites.has(key)) throw new DoctrineIdCollisionError(id);
|
|
198
|
+
}
|
|
199
|
+
|
|
37
200
|
const dir = resolve(rootDir, 'doctrine');
|
|
38
201
|
mkdirSync(dir, { recursive: true });
|
|
39
202
|
const path = resolve(dir, `${doc.doctrine_id}.yaml`);
|
|
40
203
|
atomicWriteFileSync(path, yaml.dump(JSON.parse(JSON.stringify(doc)), { lineWidth: 120, noRefs: true }));
|
|
204
|
+
|
|
205
|
+
if (id !== undefined && id !== null) seenArtifactWrites.set(`${rootDir}:doctrine:${id}`, true);
|
|
41
206
|
return path;
|
|
42
207
|
}
|
|
43
208
|
|
|
44
209
|
/**
|
|
45
|
-
*
|
|
210
|
+
* Internal — generic batch writer with intra-batch id-collision guard.
|
|
211
|
+
*
|
|
212
|
+
* @template T
|
|
213
|
+
* @param {Array<T>} items
|
|
214
|
+
* @param {(item: T) => string | undefined} getId
|
|
215
|
+
* @param {(item: T) => string} doWrite - performs the write, returns path
|
|
216
|
+
* @param {string} idLabel - error-field name on the error record (e.g. 'patternId')
|
|
217
|
+
* @param {string} code - structured collision code (e.g. 'PATTERN_ID_COLLISION')
|
|
218
|
+
* @returns {{ written: string[], errors: Array<object> }}
|
|
219
|
+
*/
|
|
220
|
+
function writeBatch(items, getId, doWrite, idLabel, code) {
|
|
221
|
+
const written = [];
|
|
222
|
+
const errors = [];
|
|
223
|
+
const seen = new Map(); // id → index of first occurrence
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < items.length; i++) {
|
|
226
|
+
const item = items[i];
|
|
227
|
+
const id = getId(item);
|
|
228
|
+
|
|
229
|
+
if (id !== undefined && id !== null && seen.has(id)) {
|
|
230
|
+
const firstIdx = seen.get(id);
|
|
231
|
+
errors.push({
|
|
232
|
+
[idLabel]: id,
|
|
233
|
+
code,
|
|
234
|
+
error: `intra-batch ${idLabel.replace(/Id$/, '_id')} collision: '${id}' already claimed by index ${firstIdx}; refused write at index ${i} to avoid silent clobber (D2B-008)`
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const path = doWrite(item);
|
|
241
|
+
written.push(path);
|
|
242
|
+
if (id !== undefined && id !== null) seen.set(id, i);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
// D2B-002 — preserve structured codes
|
|
245
|
+
// (PATTERN_SCHEMA_INVALID etc.) so batch callers see the same
|
|
246
|
+
// vocabulary as singleton callers.
|
|
247
|
+
const errRec = { [idLabel]: id, error: err.message };
|
|
248
|
+
if (err && err.code) errRec.code = err.code;
|
|
249
|
+
errors.push(errRec);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { written, errors };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Write multiple patterns to disk, refusing intra-batch `pattern_id` collision.
|
|
258
|
+
* See file header for D2B-008 rationale.
|
|
259
|
+
*
|
|
260
|
+
* @param {string} rootDir
|
|
261
|
+
* @param {Array} patterns
|
|
262
|
+
* @returns {{ written: string[], errors: Array<{ patternId?: string, code?: string, error: string }> }}
|
|
263
|
+
*/
|
|
264
|
+
export function writePatterns(rootDir, patterns) {
|
|
265
|
+
return writeBatch(
|
|
266
|
+
patterns,
|
|
267
|
+
p => p?.pattern_id,
|
|
268
|
+
p => writePattern(rootDir, p),
|
|
269
|
+
'patternId',
|
|
270
|
+
'PATTERN_ID_COLLISION'
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Write multiple recommendations to disk, refusing intra-batch
|
|
276
|
+
* `recommendation_id` collision. See file header for D2B-008 rationale.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} rootDir
|
|
279
|
+
* @param {Array} recommendations
|
|
280
|
+
* @returns {{ written: string[], errors: Array<{ recommendationId?: string, code?: string, error: string }> }}
|
|
281
|
+
*/
|
|
282
|
+
export function writeRecommendations(rootDir, recommendations) {
|
|
283
|
+
return writeBatch(
|
|
284
|
+
recommendations,
|
|
285
|
+
r => r?.recommendation_id,
|
|
286
|
+
r => writeRecommendation(rootDir, r),
|
|
287
|
+
'recommendationId',
|
|
288
|
+
'RECOMMENDATION_ID_COLLISION'
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Write multiple doctrines to disk, refusing intra-batch `doctrine_id`
|
|
294
|
+
* collision. See file header for D2B-008 rationale.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} rootDir
|
|
297
|
+
* @param {Array} doctrines
|
|
298
|
+
* @returns {{ written: string[], errors: Array<{ doctrineId?: string, code?: string, error: string }> }}
|
|
299
|
+
*/
|
|
300
|
+
export function writeDoctrines(rootDir, doctrines) {
|
|
301
|
+
return writeBatch(
|
|
302
|
+
doctrines,
|
|
303
|
+
d => d?.doctrine_id,
|
|
304
|
+
d => writeDoctrine(rootDir, d),
|
|
305
|
+
'doctrineId',
|
|
306
|
+
'DOCTRINE_ID_COLLISION'
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Load all patterns from disk (legacy array shape).
|
|
312
|
+
*
|
|
313
|
+
* Torn pattern YAML files are NO LONGER silently dropped — they surface
|
|
314
|
+
* via `loadPatternsWithSkips`. H2 / F-721047-010 — silent-loader closure.
|
|
46
315
|
*/
|
|
47
316
|
export function loadPatterns(rootDir) {
|
|
48
|
-
return
|
|
317
|
+
return loadPatternsWithSkips(rootDir).entries.map(e => e.data);
|
|
49
318
|
}
|
|
50
319
|
|
|
51
320
|
/**
|
|
52
|
-
* Load all recommendations from disk.
|
|
321
|
+
* Load all recommendations from disk (legacy array shape).
|
|
322
|
+
*
|
|
323
|
+
* Torn recommendation YAML files are NO LONGER silently dropped — they
|
|
324
|
+
* surface via `loadRecommendationsWithSkips`. H2 / F-721047-010.
|
|
53
325
|
*/
|
|
54
326
|
export function loadRecommendations(rootDir) {
|
|
55
|
-
return
|
|
327
|
+
return loadRecommendationsWithSkips(rootDir).entries.map(e => e.data);
|
|
56
328
|
}
|
|
57
329
|
|
|
58
330
|
/**
|
|
59
|
-
* Load all doctrines from disk.
|
|
331
|
+
* Load all doctrines from disk (legacy array shape).
|
|
332
|
+
*
|
|
333
|
+
* Torn doctrine YAML files are NO LONGER silently dropped — they surface
|
|
334
|
+
* via `loadDoctrinesWithSkips`. H2 / F-721047-010.
|
|
60
335
|
*/
|
|
61
336
|
export function loadDoctrines(rootDir) {
|
|
62
|
-
return
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
337
|
+
return loadDoctrinesWithSkips(rootDir).entries.map(e => e.data);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Audit-honesty loaders: surface structured skip records for any torn
|
|
342
|
+
* artifact YAML files.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} rootDir
|
|
345
|
+
* @returns {{ entries: Array<{ path: string, data: object }>, skipped: Array<{ path: string, error: string }> }}
|
|
346
|
+
*/
|
|
347
|
+
export function loadPatternsWithSkips(rootDir) {
|
|
348
|
+
return loadYamlDir(resolve(rootDir, 'patterns'), { recursive: false });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function loadRecommendationsWithSkips(rootDir) {
|
|
352
|
+
return loadYamlDir(resolve(rootDir, 'recommendations'), { recursive: false });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function loadDoctrinesWithSkips(rootDir) {
|
|
356
|
+
return loadYamlDir(resolve(rootDir, 'doctrine'), { recursive: false });
|
|
75
357
|
}
|
package/validate.js
CHANGED
|
@@ -1,35 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Finding schema validator.
|
|
3
3
|
* Validates YAML finding files against dogfood-finding.schema.json.
|
|
4
|
+
*
|
|
5
|
+
* H3 hop 2: delegates to the canonical {@link validatePayload} from
|
|
6
|
+
* `@dogfood-lab/schemas`. Pre-H3 this module compiled its own
|
|
7
|
+
* Ajv2020 + ajv-formats instance for the finding schema; that
|
|
8
|
+
* duplicated the verifier's compile path and was the second of four
|
|
9
|
+
* sites contributing to the C1 two-Ajv structural gap. The migration
|
|
10
|
+
* collapses finding-validation to the single cached validator the
|
|
11
|
+
* canonical seam shares with the rest of the workspace.
|
|
12
|
+
*
|
|
13
|
+
* Return contract preserved: `{ valid, errors: [{ path, message, params }] }`.
|
|
14
|
+
* The canonical ValidationError now also carries `keyword`; finding
|
|
15
|
+
* callers don't read it today, but it is forwarded so a downstream
|
|
16
|
+
* consumer that wraps validateFinding (e.g. a future review-time
|
|
17
|
+
* keyword pin parallel to ingest.test.js:208-220) can lean on it.
|
|
4
18
|
*/
|
|
5
19
|
|
|
6
20
|
import { readFileSync } from 'node:fs';
|
|
7
|
-
import { createRequire } from 'node:module';
|
|
8
|
-
import Ajv2020 from 'ajv/dist/2020.js';
|
|
9
|
-
import addFormats from 'ajv-formats';
|
|
10
21
|
import yaml from 'js-yaml';
|
|
11
|
-
|
|
12
|
-
const require = createRequire(import.meta.url);
|
|
13
|
-
|
|
14
|
-
/** Load and compile the finding schema once. */
|
|
15
|
-
function createValidator() {
|
|
16
|
-
const schemaPath = require.resolve('@dogfood-lab/schemas/json/dogfood-finding.schema.json');
|
|
17
|
-
const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
|
|
18
|
-
|
|
19
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
20
|
-
addFormats(ajv);
|
|
21
|
-
|
|
22
|
-
return ajv.compile(schema);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let _validator = null;
|
|
26
|
-
|
|
27
|
-
function getValidator() {
|
|
28
|
-
if (!_validator) {
|
|
29
|
-
_validator = createValidator();
|
|
30
|
-
}
|
|
31
|
-
return _validator;
|
|
32
|
-
}
|
|
22
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
33
23
|
|
|
34
24
|
/**
|
|
35
25
|
* Parse a YAML finding file and return the data.
|
|
@@ -52,23 +42,15 @@ export function parseFinding(filePath) {
|
|
|
52
42
|
/**
|
|
53
43
|
* Validate a parsed finding object against the schema.
|
|
54
44
|
* @param {object} finding - Parsed finding data.
|
|
55
|
-
* @returns {{ valid: boolean, errors: Array<{ path: string, message: string }> }}
|
|
45
|
+
* @returns {{ valid: boolean, errors: Array<{ path: string, message: string, keyword?: string, params?: object }> }}
|
|
56
46
|
*/
|
|
57
47
|
export function validateFinding(finding) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const errors = (validate.errors || []).map(err => ({
|
|
66
|
-
path: err.instancePath || '/',
|
|
67
|
-
message: err.message || 'unknown error',
|
|
68
|
-
params: err.params
|
|
69
|
-
}));
|
|
70
|
-
|
|
71
|
-
return { valid: false, errors };
|
|
48
|
+
// H3: delegate to canonical seam. The pre-H3 implementation manually
|
|
49
|
+
// mapped Ajv.errors → { path, message, params }; canonical does the
|
|
50
|
+
// same and adds `keyword`. Forward the full canonical shape — finding
|
|
51
|
+
// callers consume `path`/`message`/`params` today, and the keyword
|
|
52
|
+
// forward keeps the door open for future review-side keyword pins.
|
|
53
|
+
return validatePayload('finding', finding);
|
|
72
54
|
}
|
|
73
55
|
|
|
74
56
|
/**
|