@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 CHANGED
@@ -51,6 +51,9 @@ import {
51
51
  writePattern,
52
52
  writeRecommendation,
53
53
  writeDoctrine,
54
+ writePatterns,
55
+ writeRecommendations,
56
+ writeDoctrines,
54
57
  loadPatterns,
55
58
  loadRecommendations,
56
59
  loadDoctrines
@@ -429,7 +432,10 @@ Filters (for list):
429
432
  if (errors.length > 0) {
430
433
  console.error(`Errors: ${errors.length}`);
431
434
  for (const e of errors) {
432
- console.error(` ${e.findingId}: ${e.error}`);
435
+ // L2-004 (Wave A2 amend2): surface the structured `.code` so
436
+ // operators can grep for FINDING_ID_COLLISION etc., matching
437
+ // the sibling artifact CLIs (cli.js:676/756/832).
438
+ console.error(` ${e.findingId || '<unknown>'}: ${e.code || 'WRITE_ERROR'} ${e.error}`);
433
439
  }
434
440
  process.exit(1);
435
441
  }
@@ -663,9 +669,16 @@ Filters (for list):
663
669
  }
664
670
 
665
671
  if (write && patterns.length > 0) {
666
- for (const p of patterns) {
667
- const path = writePattern(ROOT, p);
668
- console.log(`Written: ${relative(ROOT, path)}`);
672
+ const { written, errors } = writePatterns(ROOT, patterns);
673
+ for (const p of written) {
674
+ console.log(`Written: ${relative(ROOT, p)}`);
675
+ }
676
+ if (errors.length > 0) {
677
+ console.error(`Errors: ${errors.length}`);
678
+ for (const e of errors) {
679
+ console.error(` ${e.patternId || '<unknown>'}: ${e.code || 'WRITE_ERROR'} ${e.error}`);
680
+ }
681
+ process.exit(1);
669
682
  }
670
683
  } else if (!write && patterns.length > 0) {
671
684
  console.log(`(dry-run) ${patterns.length} pattern(s) would be written. Use --write to materialize.`);
@@ -714,7 +727,17 @@ Filters (for list):
714
727
  const sub = positional[0];
715
728
  if (sub === 'derive') {
716
729
  const write = flags.write;
717
- const { recommendations, stats } = deriveRecommendations(ROOT);
730
+ const { recommendations, skipped, stats } = deriveRecommendations(ROOT);
731
+
732
+ // D2B-001 — surface structured skip signal to operators. The derive
733
+ // engine still emits clean recommendations from the patterns it could
734
+ // read; the skipped list documents the partial-completion honestly.
735
+ if (skipped && skipped.length > 0) {
736
+ console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
737
+ for (const s of skipped) {
738
+ console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
739
+ }
740
+ }
718
741
 
719
742
  console.log(`Patterns considered: ${stats.patternsConsidered}`);
720
743
  console.log(`Recommendations emitted: ${stats.recommendationsEmitted}\n`);
@@ -726,9 +749,16 @@ Filters (for list):
726
749
  }
727
750
 
728
751
  if (write && recommendations.length > 0) {
729
- for (const r of recommendations) {
730
- const path = writeRecommendation(ROOT, r);
731
- console.log(`Written: ${relative(ROOT, path)}`);
752
+ const { written, errors } = writeRecommendations(ROOT, recommendations);
753
+ for (const p of written) {
754
+ console.log(`Written: ${relative(ROOT, p)}`);
755
+ }
756
+ if (errors.length > 0) {
757
+ console.error(`Errors: ${errors.length}`);
758
+ for (const e of errors) {
759
+ console.error(` ${e.recommendationId || '<unknown>'}: ${e.code || 'WRITE_ERROR'} ${e.error}`);
760
+ }
761
+ process.exit(1);
732
762
  }
733
763
  } else if (!write && recommendations.length > 0) {
734
764
  console.log(`(dry-run) ${recommendations.length} recommendation(s) would be written. Use --write to materialize.`);
@@ -774,7 +804,15 @@ Filters (for list):
774
804
  const sub = positional[0];
775
805
  if (sub === 'derive') {
776
806
  const write = flags.write;
777
- const { doctrines, stats } = deriveDoctrine(ROOT);
807
+ const { doctrines, skipped, stats } = deriveDoctrine(ROOT);
808
+
809
+ // D2B-001 — surface structured skip signal to operators.
810
+ if (skipped && skipped.length > 0) {
811
+ console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
812
+ for (const s of skipped) {
813
+ console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
814
+ }
815
+ }
778
816
 
779
817
  console.log(`Patterns considered: ${stats.patternsConsidered}`);
780
818
  console.log(`Doctrines emitted: ${stats.doctrinesEmitted}`);
@@ -787,9 +825,16 @@ Filters (for list):
787
825
  }
788
826
 
789
827
  if (write && doctrines.length > 0) {
790
- for (const d of doctrines) {
791
- const path = writeDoctrine(ROOT, d);
792
- console.log(`Written: ${relative(ROOT, path)}`);
828
+ const { written, errors } = writeDoctrines(ROOT, doctrines);
829
+ for (const p of written) {
830
+ console.log(`Written: ${relative(ROOT, p)}`);
831
+ }
832
+ if (errors.length > 0) {
833
+ console.error(`Errors: ${errors.length}`);
834
+ for (const e of errors) {
835
+ console.error(` ${e.doctrineId || '<unknown>'}: ${e.code || 'WRITE_ERROR'} ${e.error}`);
836
+ }
837
+ process.exit(1);
793
838
  }
794
839
  } else if (!write && doctrines.length > 0) {
795
840
  console.log(`(dry-run) ${doctrines.length} doctrine(s) would be written. Use --write to materialize.`);
@@ -3,38 +3,60 @@
3
3
  * Discovers and loads verified dogfood records from the filesystem.
4
4
  */
5
5
 
6
- import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
6
+ import { readdirSync, existsSync, statSync } from 'node:fs';
7
7
  import { resolve, join, extname } from 'node:path';
8
8
 
9
9
  import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
10
+ import { loadJsonFile } from '../lib/safe-yaml-load.js';
10
11
 
11
12
  /**
12
- * Load all records for a specific repo.
13
+ * Load all records for a specific repo (legacy array shape).
14
+ *
15
+ * Torn record JSON files are NO LONGER silently dropped — they surface via
16
+ * the sibling `loadRecordsForRepoWithSkips`. H2 / F-721047-010 — silent-
17
+ * loader closure. The advisor-surfaced sibling at line :110.
13
18
  *
14
19
  * @param {string} rootDir - dogfood-labs repo root.
15
20
  * @param {string} repoKey - Full org/repo key (e.g. "mcp-tool-shop-org/repo-crawler-mcp").
16
21
  * @returns {Array<{ record: object, rejected: boolean, path: string }>}
17
22
  */
18
23
  export function loadRecordsForRepo(rootDir, repoKey) {
24
+ return loadRecordsForRepoWithSkips(rootDir, repoKey).entries;
25
+ }
26
+
27
+ /**
28
+ * Audit-honesty variant of `loadRecordsForRepo`: returns both the loaded
29
+ * records and a list of torn / unreadable JSON files.
30
+ *
31
+ * @param {string} rootDir
32
+ * @param {string} repoKey
33
+ * @returns {{ entries: Array<{ record: object, rejected: boolean, path: string }>, skipped: Array<{ path: string, error: string }> }}
34
+ */
35
+ export function loadRecordsForRepoWithSkips(rootDir, repoKey) {
19
36
  const [org, repo] = repoKey.split('/');
20
37
  // Path-traversal guard: F-916867-005. Mirrors persist.js + load-context.js
21
38
  // via the central helper at @dogfood-lab/ingest/lib/unsafe-segment.js.
22
39
  // A malformed repoKey (`..` or path-separator) would otherwise resolve
23
40
  // outside the records tree and silently load (or skip) unrelated files.
24
41
  if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
25
- return [];
42
+ return { entries: [], skipped: [] };
26
43
  }
27
- const results = [];
44
+ const entries = [];
45
+ const skipped = [];
28
46
 
29
47
  // Accepted records
30
48
  const acceptedDir = resolve(rootDir, 'records', org, repo);
31
- results.push(...walkRecords(acceptedDir, false));
49
+ const acc = walkRecordsWithSkips(acceptedDir, false);
50
+ entries.push(...acc.entries);
51
+ skipped.push(...acc.skipped);
32
52
 
33
53
  // Rejected records
34
54
  const rejectedDir = resolve(rootDir, 'records', '_rejected', org, repo);
35
- results.push(...walkRecords(rejectedDir, true));
55
+ const rej = walkRecordsWithSkips(rejectedDir, true);
56
+ entries.push(...rej.entries);
57
+ skipped.push(...rej.skipped);
36
58
 
37
- return results;
59
+ return { entries, skipped };
38
60
  }
39
61
 
40
62
  /**
@@ -56,13 +78,27 @@ export function loadRecordById(rootDir, runId) {
56
78
  }
57
79
 
58
80
  /**
59
- * Load all records across all repos.
81
+ * Load all records across all repos (legacy array shape).
82
+ *
83
+ * Torn record JSON files are NO LONGER silently dropped — they surface via
84
+ * the sibling `loadAllRecordsWithSkips`. H2 / F-721047-010.
60
85
  *
61
86
  * @param {string} rootDir - dogfood-labs repo root.
62
87
  * @returns {Array<{ record: object, rejected: boolean, path: string }>}
63
88
  */
64
89
  export function loadAllRecords(rootDir) {
65
- const results = [];
90
+ return loadAllRecordsWithSkips(rootDir).entries;
91
+ }
92
+
93
+ /**
94
+ * Audit-honesty variant of `loadAllRecords`.
95
+ *
96
+ * @param {string} rootDir
97
+ * @returns {{ entries: Array<{ record: object, rejected: boolean, path: string }>, skipped: Array<{ path: string, error: string }> }}
98
+ */
99
+ export function loadAllRecordsWithSkips(rootDir) {
100
+ const entries = [];
101
+ const skipped = [];
66
102
 
67
103
  // Accepted records
68
104
  const recordsDir = resolve(rootDir, 'records');
@@ -72,7 +108,9 @@ export function loadAllRecords(rootDir) {
72
108
  const orgDir = join(recordsDir, org);
73
109
  for (const repo of listDirs(orgDir)) {
74
110
  const repoDir = join(orgDir, repo);
75
- results.push(...walkRecords(repoDir, false));
111
+ const w = walkRecordsWithSkips(repoDir, false);
112
+ entries.push(...w.entries);
113
+ skipped.push(...w.skipped);
76
114
  }
77
115
  }
78
116
  }
@@ -84,58 +122,112 @@ export function loadAllRecords(rootDir) {
84
122
  const orgDir = join(rejectedDir, org);
85
123
  for (const repo of listDirs(orgDir)) {
86
124
  const repoDir = join(orgDir, repo);
87
- results.push(...walkRecords(repoDir, true));
125
+ const w = walkRecordsWithSkips(repoDir, true);
126
+ entries.push(...w.entries);
127
+ skipped.push(...w.skipped);
88
128
  }
89
129
  }
90
130
  }
91
131
 
92
- return results;
132
+ return { entries, skipped };
93
133
  }
94
134
 
95
- /** Walk a record directory tree and load all .json files. */
96
- function walkRecords(dir, rejected) {
97
- if (!existsSync(dir)) return [];
98
- const results = [];
135
+ /**
136
+ * Walk a record directory tree, load all `.json` files via the shared
137
+ * `loadJsonFile` helper, and return `{ entries, skipped }`. Torn JSON
138
+ * files are NO LONGER silently dropped — they appear in `skipped` with
139
+ * a structured `{ path, error }`.
140
+ *
141
+ * H2 / F-721047-010 — silent-loader closure (advisor-surfaced sibling at
142
+ * load-records.js:110).
143
+ */
144
+ function walkRecordsWithSkips(dir, rejected) {
145
+ const entries = [];
146
+ const skipped = [];
147
+ if (!existsSync(dir)) return { entries, skipped };
99
148
 
100
149
  function walk(d) {
101
- for (const entry of readdirSync(d)) {
150
+ let children;
151
+ try {
152
+ children = readdirSync(d);
153
+ } catch (err) {
154
+ skipped.push({ path: d, error: `readdir error: ${err.message}` });
155
+ return;
156
+ }
157
+ for (const entry of children) {
102
158
  const full = join(d, entry);
159
+ let stat;
103
160
  try {
104
- if (statSync(full).isDirectory()) {
105
- walk(full);
106
- } else if (extname(entry) === '.json') {
107
- const data = JSON.parse(readFileSync(full, 'utf-8'));
108
- results.push({ record: data, rejected, path: full });
109
- }
110
- } catch {
111
- // Skip unreadable files
161
+ stat = statSync(full);
162
+ } catch (err) {
163
+ skipped.push({ path: full, error: `stat error: ${err.message}` });
164
+ continue;
165
+ }
166
+ if (stat.isDirectory()) {
167
+ walk(full);
168
+ continue;
169
+ }
170
+ if (extname(entry) !== '.json') continue;
171
+ const { data, error } = loadJsonFile(full);
172
+ if (error !== null) {
173
+ skipped.push({ path: full, error });
174
+ } else {
175
+ entries.push({ record: data, rejected, path: full });
112
176
  }
113
177
  }
114
178
  }
115
179
 
116
180
  walk(dir);
117
- return results;
181
+ return { entries, skipped };
118
182
  }
119
183
 
120
- /** Find a specific record file by run_id pattern. */
184
+ /**
185
+ * Find a specific record file by run_id pattern.
186
+ *
187
+ * H2 / F-721047-010 — silent-loader closure (advisor-surfaced sibling at
188
+ * load-records.js:137). Replaces the bare `try { ... } catch {}` with the
189
+ * structured `loadJsonFile` helper. A torn JSON file that happens to
190
+ * include the run_id in its name no longer silently masks the actual
191
+ * record under a different (perhaps later-renamed) sibling.
192
+ */
121
193
  function findRecordFile(rootDir, runId, rejected) {
122
194
  if (!existsSync(rootDir)) return null;
123
195
 
124
196
  function search(dir) {
125
- for (const entry of readdirSync(dir)) {
197
+ let children;
198
+ try {
199
+ children = readdirSync(dir);
200
+ } catch {
201
+ // Permission/transient error on the directory — fall through; the
202
+ // caller's outer search will try sibling roots.
203
+ return null;
204
+ }
205
+ for (const entry of children) {
126
206
  const full = join(dir, entry);
207
+ let stat;
127
208
  try {
128
- if (statSync(full).isDirectory()) {
129
- const found = search(full);
130
- if (found) return found;
131
- } else if (extname(entry) === '.json' && entry.includes(runId)) {
132
- const data = JSON.parse(readFileSync(full, 'utf-8'));
133
- if (data.run_id === runId) {
134
- return { record: data, rejected, path: full };
135
- }
136
- }
209
+ stat = statSync(full);
137
210
  } catch {
138
- // Skip
211
+ continue;
212
+ }
213
+ if (stat.isDirectory()) {
214
+ const found = search(full);
215
+ if (found) return found;
216
+ continue;
217
+ }
218
+ if (extname(entry) !== '.json') continue;
219
+ if (!entry.includes(runId)) continue;
220
+ const { data, error } = loadJsonFile(full);
221
+ if (error !== null) {
222
+ // A torn JSON file that happens to be named after the run_id —
223
+ // surface this loudly via console.error rather than silently
224
+ // hiding it. The caller still gets null and may find a sibling.
225
+ // eslint-disable-next-line no-console
226
+ console.error(`findRecordFile: torn record JSON at ${full}: ${error}`);
227
+ continue;
228
+ }
229
+ if (data && data.run_id === runId) {
230
+ return { record: data, rejected, path: full };
139
231
  }
140
232
  }
141
233
  return null;
@@ -1,5 +1,33 @@
1
1
  /**
2
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.
3
31
  */
4
32
 
5
33
  import { mkdirSync, existsSync } from 'node:fs';
@@ -7,6 +35,75 @@ import { resolve, dirname } from 'node:path';
7
35
  import yaml from 'js-yaml';
8
36
 
9
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
+ }
10
107
 
11
108
  /**
12
109
  * Write a candidate finding to its canonical location.
@@ -18,9 +115,29 @@ import { atomicWriteFileSync } from '../lib/atomic-write.js';
18
115
  * @returns {string} - Path written.
19
116
  */
20
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
+
21
127
  const [org, repo] = (finding.repo || '').split('/');
22
128
  if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
23
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
+
24
141
  const dir = resolve(rootDir, 'findings', org, repo);
25
142
  mkdirSync(dir, { recursive: true });
26
143
 
@@ -36,26 +153,61 @@ export function writeFinding(rootDir, finding) {
36
153
  });
37
154
 
38
155
  atomicWriteFileSync(filePath, yamlStr);
156
+
157
+ if (id !== undefined && id !== null) {
158
+ seenWrites.set(`${rootDir}:${id}`, true);
159
+ }
39
160
  return filePath;
40
161
  }
41
162
 
42
163
  /**
43
- * Write multiple findings and return write stats.
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.
44
176
  *
45
177
  * @param {string} rootDir
46
178
  * @param {Array} findings
47
- * @returns {{ written: string[], errors: Array<{ findingId: string, error: string }> }}
179
+ * @returns {{ written: string[], errors: Array<{ findingId: string, code?: string, error: string }> }}
48
180
  */
49
181
  export function writeFindings(rootDir, findings) {
50
182
  const written = [];
51
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
+ }
52
200
 
53
- for (const f of findings) {
54
201
  try {
55
202
  const path = writeFinding(rootDir, f);
56
203
  written.push(path);
204
+ if (id !== undefined && id !== null) seenIds.set(id, i);
57
205
  } catch (err) {
58
- errors.push({ findingId: f.finding_id, error: err.message });
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);
59
211
  }
60
212
  }
61
213
 
@@ -16,6 +16,13 @@
16
16
  * appender) are themselves synchronous, and Promise-based retries would
17
17
  * leak the async boundary into otherwise-deterministic flush paths.
18
18
  *
19
+ * D1B-002-findings — the backoff used to be a CPU-busy `while (Date.now() <
20
+ * until)` spin. The repo already ships `sleepSync` via `Atomics.wait` on a
21
+ * tiny SharedArrayBuffer (see `file-lock.js:286`). Atomics.wait yields the
22
+ * thread instead of pegging a core, with the same sync API. The retry
23
+ * envelope and visible behaviour are preserved; only the cost of the wait
24
+ * changes.
25
+ *
19
26
  * @param {string} tmp - Source path (the just-written temp file).
20
27
  * @param {string} dest - Destination path (the canonical artifact).
21
28
  * @param {object} [opts]
@@ -25,6 +32,24 @@
25
32
  */
26
33
  import { renameSync } from 'node:fs';
27
34
 
35
+ /**
36
+ * Sleep synchronously for `ms` milliseconds via `Atomics.wait` on a tiny
37
+ * SharedArrayBuffer. Yields the thread (does not spin). Mirrors the
38
+ * `sleepSync` in `file-lock.js:286` — the contract is identical; the
39
+ * helper is duplicated here because rename-with-retry must not import the
40
+ * larger file-lock module (cyclic-import risk: atomic-write imports
41
+ * rename-with-retry, and file-lock imports atomic-write transitively
42
+ * through the lock-event write path).
43
+ *
44
+ * @param {number} ms
45
+ */
46
+ function sleepSync(ms) {
47
+ if (ms <= 0) return;
48
+ const sab = new SharedArrayBuffer(4);
49
+ const view = new Int32Array(sab);
50
+ Atomics.wait(view, 0, 0, ms);
51
+ }
52
+
28
53
  export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
29
54
  for (let i = 0; i <= retries; i++) {
30
55
  try {
@@ -33,11 +58,10 @@ export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs =
33
58
  } catch (err) {
34
59
  if ((err.code !== 'EPERM' && err.code !== 'EBUSY') || i === retries) throw err;
35
60
  const delay = Math.min(baseMs * (1 << i), maxMs);
36
- // Synchronous sleep the public API is sync (matches renameSync), so
37
- // setTimeout would change the contract. Spin-wait is acceptable here
38
- // because the delays are bounded (≤200ms) and the failure mode is rare.
39
- const until = Date.now() + delay;
40
- while (Date.now() < until) { /* spin */ }
61
+ // Synchronous sleep via Atomics.wait yields the thread instead of
62
+ // spinning. The public API stays sync (matches renameSync). The
63
+ // delays remain bounded (≤200ms) and the failure mode is rare.
64
+ sleepSync(delay);
41
65
  }
42
66
  }
43
67
  }