@dogfood-lab/findings 1.2.2 → 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,63 +1,215 @@
1
- /**
2
- * Write derived candidate findings to disk as YAML files.
3
- */
4
-
5
- import { mkdirSync, existsSync } from 'node:fs';
6
- import { resolve, dirname } from 'node:path';
7
- import yaml from 'js-yaml';
8
-
9
- import { atomicWriteFileSync } from '../lib/atomic-write.js';
10
-
11
- /**
12
- * Write a candidate finding to its canonical location.
13
- *
14
- * findings/<org>/<repo>/<finding_id>.yaml
15
- *
16
- * @param {string} rootDir - dogfood-labs repo root.
17
- * @param {object} finding - Schema-valid candidate finding object.
18
- * @returns {string} - Path written.
19
- */
20
- export function writeFinding(rootDir, finding) {
21
- const [org, repo] = (finding.repo || '').split('/');
22
- if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
23
-
24
- const dir = resolve(rootDir, 'findings', org, repo);
25
- mkdirSync(dir, { recursive: true });
26
-
27
- const filePath = resolve(dir, `${finding.finding_id}.yaml`);
28
-
29
- // Strip undefined values for clean YAML
30
- const clean = JSON.parse(JSON.stringify(finding));
31
- const yamlStr = yaml.dump(clean, {
32
- lineWidth: 120,
33
- noRefs: true,
34
- quotingType: '"',
35
- forceQuotes: false
36
- });
37
-
38
- atomicWriteFileSync(filePath, yamlStr);
39
- return filePath;
40
- }
41
-
42
- /**
43
- * Write multiple findings and return write stats.
44
- *
45
- * @param {string} rootDir
46
- * @param {Array} findings
47
- * @returns {{ written: string[], errors: Array<{ findingId: string, error: string }> }}
48
- */
49
- export function writeFindings(rootDir, findings) {
50
- const written = [];
51
- const errors = [];
52
-
53
- for (const f of findings) {
54
- try {
55
- const path = writeFinding(rootDir, f);
56
- written.push(path);
57
- } catch (err) {
58
- errors.push({ findingId: f.finding_id, error: err.message });
59
- }
60
- }
61
-
62
- return { written, errors };
63
- }
1
+ /**
2
+ * Write derived candidate findings to disk as YAML files.
3
+ *
4
+ * D2B-008 — finding_id collision guard. The id generator
5
+ * (`generateFindingId` → `dfind-<repoSlug>-<lessonSlug>`) does NOT yet
6
+ * discriminate by `rule_id`, so two rules that emit the same lesson slug
7
+ * for the same repo land on the same `finding_id`. AT HEAD a naive batch
8
+ * write silently clobbered the first finding via the atomic temp+rename
9
+ * (operator-invisible data loss). The batch helper `writeFindings` records
10
+ * the first occurrence per id and REFUSES every subsequent write with a
11
+ * structured `FINDING_ID_COLLISION` error routed through the existing
12
+ * `errors[]` channel — the CLI's `if (errors.length > 0) exit(1)` branch
13
+ * then propagates non-zero. The first write still lands so a
14
+ * single-finding batch keeps working.
15
+ *
16
+ * L3-001 (Wave A2 amend2 — family seal). The SINGLETON `writeFinding`
17
+ * had the same silent-clobber class on a different verb: two programmatic
18
+ * calls to `writeFinding(rootDir, finding)` with the same `finding_id`
19
+ * silently overwrote the first via atomicWriteFileSync. The fix-closed
20
+ * guard now lives at the singleton AND batch path via a shared
21
+ * process-level `seenWrites` Map keyed by `${rootDir}:${finding_id}`.
22
+ * Second-with-same-id throws `FindingIdCollisionError` (with `.code =
23
+ * FINDING_ID_COLLISION`); batch path collects into errors[]. The
24
+ * `resetSeenWrites(rootDir?)` helper exists for the narrow case of a
25
+ * caller that legitimately re-writes after an intentional disk wipe
26
+ * (test isolation, etc.).
27
+ *
28
+ * The structural fix (putting `rule_id` into the id slug so collisions
29
+ * stop happening in the first place) is needs-design and deferred to a
30
+ * follow-on wave; this guard is the fail-closed stopgap.
31
+ */
32
+
33
+ import { mkdirSync, existsSync } from 'node:fs';
34
+ import { resolve, dirname } from 'node:path';
35
+ import yaml from 'js-yaml';
36
+
37
+ import { atomicWriteFileSync } from '../lib/atomic-write.js';
38
+ import { validateFinding } from '../validate.js';
39
+
40
+ /**
41
+ * Structured error thrown when `writeFinding` receives a finding that fails
42
+ * `dogfood-finding.schema.json`. Mirrors `RecordValidationError` in
43
+ * @dogfood-lab/ingest/validate-record.js — typed class with `.code` so
44
+ * callers can pattern-match without grepping `.message`.
45
+ *
46
+ * D2B-002 — library-path schema gate. The CLI already validated via
47
+ * `validateFinding` in `cli.js derive`, but programmatic callers of
48
+ * `writeFinding` had no gate. Now neither can persist a malformed finding.
49
+ */
50
+ export class FindingValidationError extends Error {
51
+ constructor(errors, findingId) {
52
+ const summary = errors
53
+ .map(e => `${e.path || '/'} ${e.message}`)
54
+ .join('; ');
55
+ super(`finding failed schema validation${findingId ? ` (${findingId})` : ''}: ${summary}`);
56
+ this.name = 'FindingValidationError';
57
+ this.code = 'FINDING_SCHEMA_INVALID';
58
+ this.findingId = findingId;
59
+ this.errors = errors;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Structured error thrown by the singleton `writeFinding` when called
65
+ * twice in the same process with the same `finding_id`. Mirrors the
66
+ * batch helper's `FINDING_ID_COLLISION` code (same vocabulary across
67
+ * singleton + batch paths). L3-001 family seal of D2B-008.
68
+ */
69
+ export class FindingIdCollisionError extends Error {
70
+ constructor(findingId) {
71
+ super(`finding_id collision: '${findingId}' already written in this process; refused to silently clobber (D2B-008 / L3-001 family-seal). Call resetSeenWrites() if a legitimate re-write is intended.`);
72
+ this.name = 'FindingIdCollisionError';
73
+ this.code = 'FINDING_ID_COLLISION';
74
+ this.findingId = findingId;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Process-level memory of ids that have already been written via
80
+ * `writeFinding` (singleton) OR the singleton-call inside `writeFindings`
81
+ * (batch). Keyed by `${rootDir}:${finding_id}` so callers writing to
82
+ * distinct roots don't collide and so the test harness's per-test
83
+ * `mkdtempSync` roots are naturally isolated.
84
+ *
85
+ * Exposed as a Map (rather than Set) so test isolation can clear only
86
+ * a single root via `resetSeenWrites(rootDir)`.
87
+ */
88
+ const seenWrites = new Map();
89
+
90
+ /**
91
+ * Reset the singleton's in-process collision memory. Pass a `rootDir` to
92
+ * clear only entries scoped to that root (cheap, narrow); pass nothing
93
+ * to clear everything (only useful for full-process resets in tests).
94
+ *
95
+ * @param {string} [rootDir]
96
+ */
97
+ export function resetSeenWrites(rootDir) {
98
+ if (rootDir === undefined) {
99
+ seenWrites.clear();
100
+ return;
101
+ }
102
+ const prefix = `${rootDir}:`;
103
+ for (const key of seenWrites.keys()) {
104
+ if (key.startsWith(prefix)) seenWrites.delete(key);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Write a candidate finding to its canonical location.
110
+ *
111
+ * findings/<org>/<repo>/<finding_id>.yaml
112
+ *
113
+ * @param {string} rootDir - dogfood-labs repo root.
114
+ * @param {object} finding - Schema-valid candidate finding object.
115
+ * @returns {string} - Path written.
116
+ */
117
+ export function writeFinding(rootDir, finding) {
118
+ // D2B-002 — fail-closed schema gate BEFORE touching the filesystem. The
119
+ // CLI already validates via `validateFinding` in `derive`; this protects
120
+ // every other call site (synthesis, review, programmatic, tests) so no
121
+ // path can persist a malformed finding.
122
+ const validation = validateFinding(finding);
123
+ if (!validation.valid) {
124
+ throw new FindingValidationError(validation.errors, finding?.finding_id);
125
+ }
126
+
127
+ const [org, repo] = (finding.repo || '').split('/');
128
+ if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
129
+
130
+ // L3-001 (Wave A2 amend2): same-process same-id refusal. Programmatic
131
+ // callers that loop over assembleFinding output now fail-closed at the
132
+ // singleton path, not silently at the atomicWriteFileSync.
133
+ const id = finding.finding_id;
134
+ if (id !== undefined && id !== null) {
135
+ const key = `${rootDir}:${id}`;
136
+ if (seenWrites.has(key)) {
137
+ throw new FindingIdCollisionError(id);
138
+ }
139
+ }
140
+
141
+ const dir = resolve(rootDir, 'findings', org, repo);
142
+ mkdirSync(dir, { recursive: true });
143
+
144
+ const filePath = resolve(dir, `${finding.finding_id}.yaml`);
145
+
146
+ // Strip undefined values for clean YAML
147
+ const clean = JSON.parse(JSON.stringify(finding));
148
+ const yamlStr = yaml.dump(clean, {
149
+ lineWidth: 120,
150
+ noRefs: true,
151
+ quotingType: '"',
152
+ forceQuotes: false
153
+ });
154
+
155
+ atomicWriteFileSync(filePath, yamlStr);
156
+
157
+ if (id !== undefined && id !== null) {
158
+ seenWrites.set(`${rootDir}:${id}`, true);
159
+ }
160
+ return filePath;
161
+ }
162
+
163
+ /**
164
+ * Write multiple findings to disk, refusing any intra-batch collision on
165
+ * `finding_id`. See file header for the D2B-008 rationale.
166
+ *
167
+ * Returns `{ written, errors }`:
168
+ * - `written`: paths of findings successfully written (first occurrence per id).
169
+ * - `errors`: structured records for refused colliding writes (and any
170
+ * programmatic write failures). Collision records carry
171
+ * `{ findingId, code: 'FINDING_ID_COLLISION', error }`.
172
+ *
173
+ * The CLI at packages/findings/cli.js:425+ already exits non-zero when
174
+ * `errors.length > 0`, so collisions now break CI rather than silently
175
+ * destroying findings.
176
+ *
177
+ * @param {string} rootDir
178
+ * @param {Array} findings
179
+ * @returns {{ written: string[], errors: Array<{ findingId: string, code?: string, error: string }> }}
180
+ */
181
+ export function writeFindings(rootDir, findings) {
182
+ const written = [];
183
+ const errors = [];
184
+ const seenIds = new Map(); // finding_id → index of first occurrence (for debug)
185
+
186
+ for (let i = 0; i < findings.length; i++) {
187
+ const f = findings[i];
188
+ const id = f.finding_id;
189
+
190
+ // Intra-batch collision guard — fail-closed.
191
+ if (id !== undefined && id !== null && seenIds.has(id)) {
192
+ const firstIdx = seenIds.get(id);
193
+ errors.push({
194
+ findingId: id,
195
+ code: 'FINDING_ID_COLLISION',
196
+ error: `intra-batch finding_id collision: '${id}' already claimed by index ${firstIdx}; refused write at index ${i} to avoid silent clobber (D2B-008)`
197
+ });
198
+ continue;
199
+ }
200
+
201
+ try {
202
+ const path = writeFinding(rootDir, f);
203
+ written.push(path);
204
+ if (id !== undefined && id !== null) seenIds.set(id, i);
205
+ } catch (err) {
206
+ // D2B-002 — preserve structured codes (FINDING_SCHEMA_INVALID etc.) so
207
+ // batch callers see the same vocabulary as singleton callers.
208
+ const errRec = { findingId: id, error: err.message };
209
+ if (err && err.code) errRec.code = err.code;
210
+ errors.push(errRec);
211
+ }
212
+ }
213
+
214
+ return { written, errors };
215
+ }
package/index.js CHANGED
@@ -1,11 +1,11 @@
1
- /**
2
- * @dogfood-labs/findings
3
- *
4
- * Finding contract spine for dogfood-labs.
5
- * The fourth contract alongside record, scenario, and policy.
6
- *
7
- * Exports: validation, reading, listing, filtering, duplicate detection.
8
- */
9
-
10
- export { parseFinding, validateFinding, validateFindingFile } from './validate.js';
11
- export { discoverFindings, discoverFixtures, loadFindings, findById, filterFindings, findDuplicates } from './reader.js';
1
+ /**
2
+ * @dogfood-labs/findings
3
+ *
4
+ * Finding contract spine for dogfood-labs.
5
+ * The fourth contract alongside record, scenario, and policy.
6
+ *
7
+ * Exports: validation, reading, listing, filtering, duplicate detection.
8
+ */
9
+
10
+ export { parseFinding, validateFinding, validateFindingFile } from './validate.js';
11
+ export { discoverFindings, discoverFixtures, loadFindings, findById, filterFindings, findDuplicates } from './reader.js';