@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.
- package/advise/advice-bundle.js +155 -155
- package/advise/index.js +5 -5
- package/advise/query.js +182 -182
- package/cli.js +57 -12
- package/derive/dedupe.js +107 -107
- package/derive/derive-findings.js +187 -187
- package/derive/ids.js +48 -48
- package/derive/index.js +9 -9
- package/derive/load-records.js +245 -153
- package/derive/rules.js +415 -415
- package/derive/write-findings.js +215 -63
- package/index.js +11 -11
- package/lib/file-lock.js +359 -359
- package/lib/rename-with-retry.js +29 -5
- package/lib/safe-yaml-load.js +169 -0
- package/package.json +3 -5
- package/reader.js +156 -156
- package/review/event-log.js +49 -23
- package/review/index.js +6 -6
- package/review/review-engine.js +22 -1
- package/review/transitions.js +79 -79
- package/synthesis/doctrine-derivation.js +137 -128
- package/synthesis/index.js +12 -8
- package/synthesis/pattern-derivation.js +184 -184
- package/synthesis/recommendation-derivation.js +187 -156
- package/synthesis/validate-artifacts.js +38 -46
- package/synthesis/write-artifacts.js +357 -75
- package/validate.js +69 -87
package/derive/write-findings.js
CHANGED
|
@@ -1,63 +1,215 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Write derived candidate findings to disk as YAML files.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
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';
|