@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.
@@ -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, existsSync, readdirSync, readFileSync } from 'node:fs';
6
- import { resolve, join } from 'node:path';
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
- * Load all patterns from disk.
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 loadArtifacts(resolve(rootDir, 'patterns'));
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 loadArtifacts(resolve(rootDir, 'recommendations'));
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 loadArtifacts(resolve(rootDir, 'doctrine'));
63
- }
64
-
65
- function loadArtifacts(dir) {
66
- if (!existsSync(dir)) return [];
67
- return readdirSync(dir)
68
- .filter(f => f.endsWith('.yaml'))
69
- .map(f => {
70
- try {
71
- return yaml.load(readFileSync(join(dir, f), 'utf-8'));
72
- } catch { return null; }
73
- })
74
- .filter(Boolean);
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
- const validate = getValidator();
59
- const valid = validate(finding);
60
-
61
- if (valid) {
62
- return { valid: true, errors: [] };
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
  /**