@dogfood-lab/findings 1.2.3 → 1.3.1
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
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
console.log(`Written: ${relative(ROOT,
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
console.log(`Written: ${relative(ROOT,
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
console.log(`Written: ${relative(ROOT,
|
|
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.`);
|
package/derive/load-records.js
CHANGED
|
@@ -3,38 +3,60 @@
|
|
|
3
3
|
* Discovers and loads verified dogfood records from the filesystem.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { readdirSync,
|
|
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
|
|
44
|
+
const entries = [];
|
|
45
|
+
const skipped = [];
|
|
28
46
|
|
|
29
47
|
// Accepted records
|
|
30
48
|
const acceptedDir = resolve(rootDir, 'records', org, repo);
|
|
31
|
-
|
|
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
|
-
|
|
55
|
+
const rej = walkRecordsWithSkips(rejectedDir, true);
|
|
56
|
+
entries.push(...rej.entries);
|
|
57
|
+
skipped.push(...rej.skipped);
|
|
36
58
|
|
|
37
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
+
return { entries, skipped };
|
|
93
133
|
}
|
|
94
134
|
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
181
|
+
return { entries, skipped };
|
|
118
182
|
}
|
|
119
183
|
|
|
120
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/derive/write-findings.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
package/lib/rename-with-retry.js
CHANGED
|
@@ -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
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
}
|