@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.
@@ -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
  }
@@ -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.2.2",
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": ">=20"
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
+ }
@@ -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, readdirSync, statSync } from 'node:fs';
8
- import { resolve, dirname, join } from 'node:path';
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
- const reviewsDir = resolve(rootDir, 'reviews');
153
- if (!existsSync(reviewsDir)) return [];
169
+ return getAllEventsWithSkips(rootDir).events;
170
+ }
154
171
 
155
- const events = [];
156
- walkYaml(reviewsDir, data => {
157
- if (Array.isArray(data)) events.push(...data);
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
- return events.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
161
- }
186
+ const { entries, skipped } = loadYamlDir(reviewsDir, { recursive: true });
162
187
 
163
- /** Walk directory tree for .yaml files, parse and call cb with data. */
164
- function walkYaml(dir, cb) {
165
- for (const entry of readdirSync(dir)) {
166
- const full = join(dir, entry);
167
- try {
168
- if (statSync(full).isDirectory()) {
169
- walkYaml(full, cb);
170
- } else if (entry.endsWith('.yaml')) {
171
- const raw = readFileSync(full, 'utf-8');
172
- const data = yaml.load(raw);
173
- if (data) cb(data);
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
  }