@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/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
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-yaml-load — single-source structured loader for the silent-loader family
|
|
3
|
+
* (F-721047-010 / D2 amend wave 1).
|
|
4
|
+
*
|
|
5
|
+
* **Why this helper exists.** Multiple sites in `packages/findings/**` were
|
|
6
|
+
* implementing the same pattern: walk a directory, parse `.yaml` (or `.json`)
|
|
7
|
+
* files, and silently swallow every error with `try { ... } catch {}`. A torn
|
|
8
|
+
* YAML file, an EACCES, a transient ENOENT — all disappeared, producing
|
|
9
|
+
* partial results that looked complete. The advisor verified four such sites
|
|
10
|
+
* (`review/event-log.js walkYaml`, `synthesis/recommendation-derivation.js
|
|
11
|
+
* loadAcceptedPatterns`, `synthesis/write-artifacts.js loadArtifacts`,
|
|
12
|
+
* `derive/load-records.js walkRecords/findRecordFile`) and surfaced two
|
|
13
|
+
* advisor-found siblings in `derive/load-records.js`. The audit's framing
|
|
14
|
+
* was that this was a "closed" anti-pattern but the header in
|
|
15
|
+
* `lib/atomic-write.js` actually says it's OPEN — so this fix closes a
|
|
16
|
+
* known-open pattern rather than regressing on a closure claim.
|
|
17
|
+
*
|
|
18
|
+
* **The contract.**
|
|
19
|
+
* loadYamlFile(path) → { data, error }
|
|
20
|
+
* loadJsonFile(path) → { data, error }
|
|
21
|
+
* loadYamlDir(dir, { recursive }) → { entries: [{path, data}], skipped: [{path, error}] }
|
|
22
|
+
* loadJsonDir(dir, { recursive }) → same shape
|
|
23
|
+
*
|
|
24
|
+
* - On success: `data` is the parsed payload, `error` is `null`.
|
|
25
|
+
* - On failure: `data` is `null`, `error` is a string ("YAML parse error: …",
|
|
26
|
+
* "JSON parse error: …", "Read error: …" etc.). The caller can log the
|
|
27
|
+
* structured skip, fail loud, or continue — but never silently disappear.
|
|
28
|
+
* - This is the same shape `validate.js parseFinding` returns, deliberately
|
|
29
|
+
* so existing consumers can swap in this helper without reshaping.
|
|
30
|
+
*
|
|
31
|
+
* **What this is NOT.** This is not a schema validator. It only does the
|
|
32
|
+
* read+parse step. Schema validation lives in `validate.js` for findings
|
|
33
|
+
* and in `@dogfood-lab/schemas` for the other contracts.
|
|
34
|
+
*
|
|
35
|
+
* **Concurrency.** No locking — this is a pure read helper. Callers that
|
|
36
|
+
* need atomicity layer `withFileLock` on top (see `review/event-log.js
|
|
37
|
+
* appendEvent`).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
41
|
+
import { join, extname } from 'node:path';
|
|
42
|
+
import yaml from 'js-yaml';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a YAML file. Returns `{ data, error }` — never throws for parse or
|
|
46
|
+
* read failures.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} filePath - Absolute path to a YAML file.
|
|
49
|
+
* @returns {{ data: unknown, error: string | null }}
|
|
50
|
+
*/
|
|
51
|
+
export function loadYamlFile(filePath) {
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return { data: null, error: `Read error: ${err.message}` };
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const data = yaml.load(raw);
|
|
60
|
+
return { data, error: null };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { data: null, error: `YAML parse error: ${err.message}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a JSON file. Returns `{ data, error }` — never throws for parse or
|
|
68
|
+
* read failures.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} filePath - Absolute path to a JSON file.
|
|
71
|
+
* @returns {{ data: unknown, error: string | null }}
|
|
72
|
+
*/
|
|
73
|
+
export function loadJsonFile(filePath) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return { data: null, error: `Read error: ${err.message}` };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const data = JSON.parse(raw);
|
|
82
|
+
return { data, error: null };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
return { data: null, error: `JSON parse error: ${err.message}` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Walk a directory tree and load every matching file using `loadFile`.
|
|
90
|
+
*
|
|
91
|
+
* Returns `{ entries, skipped }`:
|
|
92
|
+
* - `entries`: `[{ path, data }]` — successful loads.
|
|
93
|
+
* - `skipped`: `[{ path, error }]` — files that couldn't be read or parsed,
|
|
94
|
+
* each with a structured error string. The caller decides whether to
|
|
95
|
+
* log, throw, or just count.
|
|
96
|
+
*
|
|
97
|
+
* If `dir` does not exist, returns `{ entries: [], skipped: [] }` (an empty
|
|
98
|
+
* directory is not an error).
|
|
99
|
+
*
|
|
100
|
+
* If `readdirSync` or `statSync` on a child throws, the child is recorded in
|
|
101
|
+
* `skipped` rather than aborting the whole walk. A torn permission on one
|
|
102
|
+
* subdir cannot make sibling subdirs disappear.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} dir - Absolute path to the directory.
|
|
105
|
+
* @param {{ recursive?: boolean, ext?: string, loadFile?: (path: string) => { data: unknown, error: string | null } }} [opts]
|
|
106
|
+
* @returns {{ entries: Array<{ path: string, data: unknown }>, skipped: Array<{ path: string, error: string }> }}
|
|
107
|
+
*/
|
|
108
|
+
function walkDir(dir, { recursive = true, ext = '.yaml', loadFile = loadYamlFile } = {}) {
|
|
109
|
+
const entries = [];
|
|
110
|
+
const skipped = [];
|
|
111
|
+
|
|
112
|
+
if (!existsSync(dir)) return { entries, skipped };
|
|
113
|
+
|
|
114
|
+
function visit(d) {
|
|
115
|
+
let children;
|
|
116
|
+
try {
|
|
117
|
+
children = readdirSync(d);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
skipped.push({ path: d, error: `readdir error: ${err.message}` });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const child of children) {
|
|
123
|
+
const full = join(d, child);
|
|
124
|
+
let stat;
|
|
125
|
+
try {
|
|
126
|
+
stat = statSync(full);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
skipped.push({ path: full, error: `stat error: ${err.message}` });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (stat.isDirectory()) {
|
|
132
|
+
if (recursive) visit(full);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (extname(child) !== ext) continue;
|
|
136
|
+
const { data, error } = loadFile(full);
|
|
137
|
+
if (error !== null) {
|
|
138
|
+
skipped.push({ path: full, error });
|
|
139
|
+
} else {
|
|
140
|
+
entries.push({ path: full, data });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
visit(dir);
|
|
146
|
+
return { entries, skipped };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Walk a directory tree and load every `.yaml` file. See `walkDir` for shape.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} dir
|
|
153
|
+
* @param {{ recursive?: boolean }} [opts]
|
|
154
|
+
* @returns {{ entries: Array<{ path: string, data: unknown }>, skipped: Array<{ path: string, error: string }> }}
|
|
155
|
+
*/
|
|
156
|
+
export function loadYamlDir(dir, opts = {}) {
|
|
157
|
+
return walkDir(dir, { ...opts, ext: '.yaml', loadFile: loadYamlFile });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Walk a directory tree and load every `.json` file. See `walkDir` for shape.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} dir
|
|
164
|
+
* @param {{ recursive?: boolean }} [opts]
|
|
165
|
+
* @returns {{ entries: Array<{ path: string, data: unknown }>, skipped: Array<{ path: string, error: string }> }}
|
|
166
|
+
*/
|
|
167
|
+
export function loadJsonDir(dir, opts = {}) {
|
|
168
|
+
return walkDir(dir, { ...opts, ext: '.json', loadFile: loadJsonFile });
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/findings",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"findings": "./cli.js"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test findings.test.js derive/derive.test.js review/review.test.js synthesis/synthesis.test.js advise/advise.test.js lib/atomic-write.test.js"
|
|
21
|
+
"test": "node --test findings.test.js derive/derive.test.js derive/load-records-skip.test.js derive/d2b-008-collision-guard.test.js derive/d2b-002-write-schema-gate.test.js review/review.test.js review/event-log-loader.test.js review/h4-engine-auto-reject-reason.test.js synthesis/synthesis.test.js synthesis/loaders-skip.test.js synthesis/d2b-001-derive-skipped-signal.test.js advise/advise.test.js lib/atomic-write.test.js lib/safe-yaml-load.test.js lib/d1b-002-findings-sleepsync.test.js"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"index.js",
|
|
@@ -41,12 +41,10 @@
|
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@dogfood-lab/ingest": "^1.2.0",
|
|
43
43
|
"@dogfood-lab/schemas": "^1.2.0",
|
|
44
|
-
"ajv": "^8.18.0",
|
|
45
|
-
"ajv-formats": "^3.0.1",
|
|
46
44
|
"js-yaml": "^4.1.0"
|
|
47
45
|
},
|
|
48
46
|
"engines": {
|
|
49
|
-
"node": ">=
|
|
47
|
+
"node": ">=22"
|
|
50
48
|
},
|
|
51
49
|
"author": "mcp-tool-shop",
|
|
52
50
|
"license": "MIT",
|
package/reader.js
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Finding reader/lister.
|
|
3
|
-
* Discovers findings from the filesystem, supports filtering and lookup.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdirSync, existsSync, statSync } from 'node:fs';
|
|
7
|
-
import { resolve, join, basename, extname } from 'node:path';
|
|
8
|
-
import { parseFinding, validateFinding } from './validate.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Discover all .yaml finding files under a root directory.
|
|
12
|
-
* Walks findings/<org>/<repo>/*.yaml
|
|
13
|
-
*
|
|
14
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
15
|
-
* @returns {string[]} Array of absolute paths to finding files.
|
|
16
|
-
*/
|
|
17
|
-
export function discoverFindings(rootDir) {
|
|
18
|
-
const findingsDir = resolve(rootDir, 'findings');
|
|
19
|
-
if (!existsSync(findingsDir)) return [];
|
|
20
|
-
|
|
21
|
-
const paths = [];
|
|
22
|
-
|
|
23
|
-
// Walk: findings/<org>/<repo>/*.yaml
|
|
24
|
-
for (const org of listDirs(findingsDir)) {
|
|
25
|
-
const orgDir = join(findingsDir, org);
|
|
26
|
-
for (const repo of listDirs(orgDir)) {
|
|
27
|
-
const repoDir = join(orgDir, repo);
|
|
28
|
-
for (const file of readdirSync(repoDir)) {
|
|
29
|
-
if (extname(file) === '.yaml') {
|
|
30
|
-
paths.push(resolve(repoDir, file));
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return paths.sort();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Discover finding files from fixtures directory.
|
|
41
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
42
|
-
* @param {'valid' | 'invalid'} kind - Which fixture set.
|
|
43
|
-
* @returns {string[]} Array of absolute paths.
|
|
44
|
-
*/
|
|
45
|
-
export function discoverFixtures(rootDir, kind) {
|
|
46
|
-
const dir = resolve(rootDir, 'fixtures', 'findings', kind);
|
|
47
|
-
if (!existsSync(dir)) return [];
|
|
48
|
-
|
|
49
|
-
return readdirSync(dir)
|
|
50
|
-
.filter(f => extname(f) === '.yaml')
|
|
51
|
-
.map(f => resolve(dir, f))
|
|
52
|
-
.sort();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Load all findings from disk (real or fixtures).
|
|
57
|
-
* Returns parsed + validated findings.
|
|
58
|
-
*
|
|
59
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
60
|
-
* @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
|
|
61
|
-
* @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
|
|
62
|
-
*/
|
|
63
|
-
export function loadFindings(rootDir, opts = {}) {
|
|
64
|
-
const paths = opts.fixtures
|
|
65
|
-
? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
|
|
66
|
-
: discoverFindings(rootDir);
|
|
67
|
-
|
|
68
|
-
return paths.map(filePath => {
|
|
69
|
-
const { data, error } = parseFinding(filePath);
|
|
70
|
-
if (error) {
|
|
71
|
-
return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
|
|
72
|
-
}
|
|
73
|
-
const result = validateFinding(data);
|
|
74
|
-
return { path: filePath, data, ...result };
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Find a single finding by its finding_id.
|
|
80
|
-
* Searches real findings first, then fixtures.
|
|
81
|
-
*
|
|
82
|
-
* @param {string} rootDir - The dogfood-labs repo root.
|
|
83
|
-
* @param {string} findingId - The finding_id to look up.
|
|
84
|
-
* @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
|
|
85
|
-
*/
|
|
86
|
-
export function findById(rootDir, findingId) {
|
|
87
|
-
// Search real findings
|
|
88
|
-
for (const filePath of discoverFindings(rootDir)) {
|
|
89
|
-
const { data } = parseFinding(filePath);
|
|
90
|
-
if (data && data.finding_id === findingId) {
|
|
91
|
-
const result = validateFinding(data);
|
|
92
|
-
return { path: filePath, data, ...result };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Search valid fixtures
|
|
97
|
-
for (const filePath of discoverFixtures(rootDir, 'valid')) {
|
|
98
|
-
const { data } = parseFinding(filePath);
|
|
99
|
-
if (data && data.finding_id === findingId) {
|
|
100
|
-
const result = validateFinding(data);
|
|
101
|
-
return { path: filePath, data, ...result };
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Filter a list of loaded findings.
|
|
110
|
-
*
|
|
111
|
-
* @param {Array<{ data: object }>} findings - Loaded findings.
|
|
112
|
-
* @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
|
|
113
|
-
* @returns {Array}
|
|
114
|
-
*/
|
|
115
|
-
export function filterFindings(findings, filters = {}) {
|
|
116
|
-
return findings.filter(f => {
|
|
117
|
-
if (!f.data) return false;
|
|
118
|
-
if (filters.repo && f.data.repo !== filters.repo) return false;
|
|
119
|
-
if (filters.status && f.data.status !== filters.status) return false;
|
|
120
|
-
if (filters.surface && f.data.product_surface !== filters.surface) return false;
|
|
121
|
-
if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
|
|
122
|
-
if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
|
|
123
|
-
return true;
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Check for duplicate finding_ids across all findings.
|
|
129
|
-
* @param {Array<{ data: object, path: string }>} findings
|
|
130
|
-
* @returns {Array<{ findingId: string, paths: string[] }>}
|
|
131
|
-
*/
|
|
132
|
-
export function findDuplicates(findings) {
|
|
133
|
-
const seen = new Map();
|
|
134
|
-
for (const f of findings) {
|
|
135
|
-
if (!f.data || !f.data.finding_id) continue;
|
|
136
|
-
const id = f.data.finding_id;
|
|
137
|
-
if (!seen.has(id)) seen.set(id, []);
|
|
138
|
-
seen.get(id).push(f.path);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return Array.from(seen.entries())
|
|
142
|
-
.filter(([, paths]) => paths.length > 1)
|
|
143
|
-
.map(([findingId, paths]) => ({ findingId, paths }));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** List subdirectories of a directory. */
|
|
147
|
-
function listDirs(dir) {
|
|
148
|
-
if (!existsSync(dir)) return [];
|
|
149
|
-
return readdirSync(dir).filter(name => {
|
|
150
|
-
try {
|
|
151
|
-
return statSync(join(dir, name)).isDirectory();
|
|
152
|
-
} catch {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Finding reader/lister.
|
|
3
|
+
* Discovers findings from the filesystem, supports filtering and lookup.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { resolve, join, basename, extname } from 'node:path';
|
|
8
|
+
import { parseFinding, validateFinding } from './validate.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Discover all .yaml finding files under a root directory.
|
|
12
|
+
* Walks findings/<org>/<repo>/*.yaml
|
|
13
|
+
*
|
|
14
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
15
|
+
* @returns {string[]} Array of absolute paths to finding files.
|
|
16
|
+
*/
|
|
17
|
+
export function discoverFindings(rootDir) {
|
|
18
|
+
const findingsDir = resolve(rootDir, 'findings');
|
|
19
|
+
if (!existsSync(findingsDir)) return [];
|
|
20
|
+
|
|
21
|
+
const paths = [];
|
|
22
|
+
|
|
23
|
+
// Walk: findings/<org>/<repo>/*.yaml
|
|
24
|
+
for (const org of listDirs(findingsDir)) {
|
|
25
|
+
const orgDir = join(findingsDir, org);
|
|
26
|
+
for (const repo of listDirs(orgDir)) {
|
|
27
|
+
const repoDir = join(orgDir, repo);
|
|
28
|
+
for (const file of readdirSync(repoDir)) {
|
|
29
|
+
if (extname(file) === '.yaml') {
|
|
30
|
+
paths.push(resolve(repoDir, file));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return paths.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Discover finding files from fixtures directory.
|
|
41
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
42
|
+
* @param {'valid' | 'invalid'} kind - Which fixture set.
|
|
43
|
+
* @returns {string[]} Array of absolute paths.
|
|
44
|
+
*/
|
|
45
|
+
export function discoverFixtures(rootDir, kind) {
|
|
46
|
+
const dir = resolve(rootDir, 'fixtures', 'findings', kind);
|
|
47
|
+
if (!existsSync(dir)) return [];
|
|
48
|
+
|
|
49
|
+
return readdirSync(dir)
|
|
50
|
+
.filter(f => extname(f) === '.yaml')
|
|
51
|
+
.map(f => resolve(dir, f))
|
|
52
|
+
.sort();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Load all findings from disk (real or fixtures).
|
|
57
|
+
* Returns parsed + validated findings.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
60
|
+
* @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
|
|
61
|
+
* @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
|
|
62
|
+
*/
|
|
63
|
+
export function loadFindings(rootDir, opts = {}) {
|
|
64
|
+
const paths = opts.fixtures
|
|
65
|
+
? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
|
|
66
|
+
: discoverFindings(rootDir);
|
|
67
|
+
|
|
68
|
+
return paths.map(filePath => {
|
|
69
|
+
const { data, error } = parseFinding(filePath);
|
|
70
|
+
if (error) {
|
|
71
|
+
return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
|
|
72
|
+
}
|
|
73
|
+
const result = validateFinding(data);
|
|
74
|
+
return { path: filePath, data, ...result };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Find a single finding by its finding_id.
|
|
80
|
+
* Searches real findings first, then fixtures.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rootDir - The dogfood-labs repo root.
|
|
83
|
+
* @param {string} findingId - The finding_id to look up.
|
|
84
|
+
* @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
|
|
85
|
+
*/
|
|
86
|
+
export function findById(rootDir, findingId) {
|
|
87
|
+
// Search real findings
|
|
88
|
+
for (const filePath of discoverFindings(rootDir)) {
|
|
89
|
+
const { data } = parseFinding(filePath);
|
|
90
|
+
if (data && data.finding_id === findingId) {
|
|
91
|
+
const result = validateFinding(data);
|
|
92
|
+
return { path: filePath, data, ...result };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Search valid fixtures
|
|
97
|
+
for (const filePath of discoverFixtures(rootDir, 'valid')) {
|
|
98
|
+
const { data } = parseFinding(filePath);
|
|
99
|
+
if (data && data.finding_id === findingId) {
|
|
100
|
+
const result = validateFinding(data);
|
|
101
|
+
return { path: filePath, data, ...result };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Filter a list of loaded findings.
|
|
110
|
+
*
|
|
111
|
+
* @param {Array<{ data: object }>} findings - Loaded findings.
|
|
112
|
+
* @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
|
|
113
|
+
* @returns {Array}
|
|
114
|
+
*/
|
|
115
|
+
export function filterFindings(findings, filters = {}) {
|
|
116
|
+
return findings.filter(f => {
|
|
117
|
+
if (!f.data) return false;
|
|
118
|
+
if (filters.repo && f.data.repo !== filters.repo) return false;
|
|
119
|
+
if (filters.status && f.data.status !== filters.status) return false;
|
|
120
|
+
if (filters.surface && f.data.product_surface !== filters.surface) return false;
|
|
121
|
+
if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
|
|
122
|
+
if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
|
|
123
|
+
return true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check for duplicate finding_ids across all findings.
|
|
129
|
+
* @param {Array<{ data: object, path: string }>} findings
|
|
130
|
+
* @returns {Array<{ findingId: string, paths: string[] }>}
|
|
131
|
+
*/
|
|
132
|
+
export function findDuplicates(findings) {
|
|
133
|
+
const seen = new Map();
|
|
134
|
+
for (const f of findings) {
|
|
135
|
+
if (!f.data || !f.data.finding_id) continue;
|
|
136
|
+
const id = f.data.finding_id;
|
|
137
|
+
if (!seen.has(id)) seen.set(id, []);
|
|
138
|
+
seen.get(id).push(f.path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return Array.from(seen.entries())
|
|
142
|
+
.filter(([, paths]) => paths.length > 1)
|
|
143
|
+
.map(([findingId, paths]) => ({ findingId, paths }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** List subdirectories of a directory. */
|
|
147
|
+
function listDirs(dir) {
|
|
148
|
+
if (!existsSync(dir)) return [];
|
|
149
|
+
return readdirSync(dir).filter(name => {
|
|
150
|
+
try {
|
|
151
|
+
return statSync(join(dir, name)).isDirectory();
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
package/review/event-log.js
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
* Events are stored as YAML arrays in reviews/<YYYY>/<date>-finding-review-log.yaml
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync
|
|
8
|
-
import { resolve, dirname
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
8
|
+
import { resolve, dirname } from 'node:path';
|
|
9
9
|
import { randomBytes } from 'node:crypto';
|
|
10
10
|
import yaml from 'js-yaml';
|
|
11
11
|
|
|
12
12
|
import { withFileLock } from '../lib/file-lock.js';
|
|
13
13
|
import { renameWithRetry } from '../lib/rename-with-retry.js';
|
|
14
|
+
import { loadYamlDir } from '../lib/safe-yaml-load.js';
|
|
14
15
|
|
|
15
16
|
let _eventCounter = 0;
|
|
16
17
|
|
|
@@ -147,31 +148,56 @@ export function getEventsForFinding(rootDir, findingId) {
|
|
|
147
148
|
|
|
148
149
|
/**
|
|
149
150
|
* Read all events across all findings.
|
|
151
|
+
*
|
|
152
|
+
* Legacy shape: returns an array of events. Torn log files are NOT silently
|
|
153
|
+
* dropped — they surface as structured skip records via the sibling API
|
|
154
|
+
* `getAllEventsWithSkips()`. Callers that need to see which log files
|
|
155
|
+
* failed to load should use that API; this one is kept array-shaped for
|
|
156
|
+
* backward compatibility with the existing read sites (CLI display,
|
|
157
|
+
* `getEventsForFinding`, the review.test.js / event-log-race.test.js
|
|
158
|
+
* fixtures).
|
|
159
|
+
*
|
|
160
|
+
* H1 / F-721047-010 — silent-loader closure: previously the internal
|
|
161
|
+
* `walkYaml` helper wrapped the whole `readdir`/`statSync`/`readFileSync`/
|
|
162
|
+
* `yaml.load` loop in a single `try { ... } catch { /* skip bad files * / }`,
|
|
163
|
+
* which made torn YAML, EACCES, and ENOENT all disappear with no signal.
|
|
164
|
+
* The walk is now delegated to `loadYamlDir`, which returns
|
|
165
|
+
* `{ entries, skipped }` — every torn file is recorded with a structured
|
|
166
|
+
* error rather than dropped.
|
|
150
167
|
*/
|
|
151
168
|
export function getAllEvents(rootDir) {
|
|
152
|
-
|
|
153
|
-
|
|
169
|
+
return getAllEventsWithSkips(rootDir).events;
|
|
170
|
+
}
|
|
154
171
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Read all events across all findings, plus a list of any log files that
|
|
174
|
+
* failed to load (torn YAML, EACCES, etc.). The structured-skip API the
|
|
175
|
+
* audit asked for — callers that care about pipeline honesty (rebuild
|
|
176
|
+
* scripts, doctor commands, CI checks) should consume this one rather
|
|
177
|
+
* than the array-shaped legacy `getAllEvents`.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} rootDir
|
|
180
|
+
* @returns {{ events: object[], skipped: Array<{ path: string, error: string }> }}
|
|
181
|
+
*/
|
|
182
|
+
export function getAllEventsWithSkips(rootDir) {
|
|
183
|
+
const reviewsDir = resolve(rootDir, 'reviews');
|
|
184
|
+
if (!existsSync(reviewsDir)) return { events: [], skipped: [] };
|
|
159
185
|
|
|
160
|
-
|
|
161
|
-
}
|
|
186
|
+
const { entries, skipped } = loadYamlDir(reviewsDir, { recursive: true });
|
|
162
187
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
} catch { /* skip bad files */ }
|
|
188
|
+
const events = [];
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
const data = entry.data;
|
|
191
|
+
if (!data) continue;
|
|
192
|
+
if (Array.isArray(data)) {
|
|
193
|
+
events.push(...data);
|
|
194
|
+
} else {
|
|
195
|
+
// A single-event YAML file (defensive: shouldn't happen for daily logs,
|
|
196
|
+
// but the original walkYaml tolerated either shape).
|
|
197
|
+
events.push(data);
|
|
198
|
+
}
|
|
176
199
|
}
|
|
200
|
+
|
|
201
|
+
events.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
202
|
+
return { events, skipped };
|
|
177
203
|
}
|