@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/ids.js
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stable ID generation and dedupe key computation for derived findings.
|
|
3
|
-
*
|
|
4
|
-
* ID law: same record + same rule + same lesson slug = same finding ID.
|
|
5
|
-
* No timestamp noise in IDs.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Generate a stable finding ID from derivation context.
|
|
10
|
-
* Format: dfind-<repo-slug>-<lesson-slug>
|
|
11
|
-
*
|
|
12
|
-
* @param {string} repoSlug - e.g. "repo-crawler-mcp"
|
|
13
|
-
* @param {string} lessonSlug - e.g. "surface-misclassification"
|
|
14
|
-
* @returns {string}
|
|
15
|
-
*/
|
|
16
|
-
export function generateFindingId(repoSlug, lessonSlug) {
|
|
17
|
-
const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
|
|
18
|
-
return normalized;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Compute a dedupe key for collision detection.
|
|
23
|
-
* Two findings with the same dedupe key are considered the same lesson.
|
|
24
|
-
*
|
|
25
|
-
* @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
|
|
26
|
-
* @returns {string}
|
|
27
|
-
*/
|
|
28
|
-
export function computeDedupeKey(fields) {
|
|
29
|
-
return [
|
|
30
|
-
fields.repo,
|
|
31
|
-
fields.issue_kind,
|
|
32
|
-
fields.root_cause_kind,
|
|
33
|
-
fields.journey_stage,
|
|
34
|
-
fields.slug
|
|
35
|
-
].join('::');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Sanitize a string for use in finding IDs.
|
|
40
|
-
* Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim.
|
|
41
|
-
*/
|
|
42
|
-
function sanitize(s) {
|
|
43
|
-
return s
|
|
44
|
-
.toLowerCase()
|
|
45
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
46
|
-
.replace(/-+/g, '-')
|
|
47
|
-
.replace(/^-|-$/g, '');
|
|
48
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Stable ID generation and dedupe key computation for derived findings.
|
|
3
|
+
*
|
|
4
|
+
* ID law: same record + same rule + same lesson slug = same finding ID.
|
|
5
|
+
* No timestamp noise in IDs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a stable finding ID from derivation context.
|
|
10
|
+
* Format: dfind-<repo-slug>-<lesson-slug>
|
|
11
|
+
*
|
|
12
|
+
* @param {string} repoSlug - e.g. "repo-crawler-mcp"
|
|
13
|
+
* @param {string} lessonSlug - e.g. "surface-misclassification"
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function generateFindingId(repoSlug, lessonSlug) {
|
|
17
|
+
const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute a dedupe key for collision detection.
|
|
23
|
+
* Two findings with the same dedupe key are considered the same lesson.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function computeDedupeKey(fields) {
|
|
29
|
+
return [
|
|
30
|
+
fields.repo,
|
|
31
|
+
fields.issue_kind,
|
|
32
|
+
fields.root_cause_kind,
|
|
33
|
+
fields.journey_stage,
|
|
34
|
+
fields.slug
|
|
35
|
+
].join('::');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize a string for use in finding IDs.
|
|
40
|
+
* Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim.
|
|
41
|
+
*/
|
|
42
|
+
function sanitize(s) {
|
|
43
|
+
return s
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
46
|
+
.replace(/-+/g, '-')
|
|
47
|
+
.replace(/^-|-$/g, '');
|
|
48
|
+
}
|
package/derive/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Derivation engine exports.
|
|
3
|
-
*/
|
|
4
|
-
export { deriveFromRecord, deriveFromRecordWithErrors, deriveFromRecords, getRuleInventory, RULES } from './derive-findings.js';
|
|
5
|
-
export { generateFindingId, computeDedupeKey } from './ids.js';
|
|
6
|
-
export { dedupeWithinBatch, dedupeAgainstExisting } from './dedupe.js';
|
|
7
|
-
export { loadRecordsForRepo, loadRecordById, loadAllRecords } from './load-records.js';
|
|
8
|
-
export { writeFinding, writeFindings } from './write-findings.js';
|
|
9
|
-
export { getRuleById } from './rules.js';
|
|
1
|
+
/**
|
|
2
|
+
* Derivation engine exports.
|
|
3
|
+
*/
|
|
4
|
+
export { deriveFromRecord, deriveFromRecordWithErrors, deriveFromRecords, getRuleInventory, RULES } from './derive-findings.js';
|
|
5
|
+
export { generateFindingId, computeDedupeKey } from './ids.js';
|
|
6
|
+
export { dedupeWithinBatch, dedupeAgainstExisting } from './dedupe.js';
|
|
7
|
+
export { loadRecordsForRepo, loadRecordById, loadAllRecords } from './load-records.js';
|
|
8
|
+
export { writeFinding, writeFindings } from './write-findings.js';
|
|
9
|
+
export { getRuleById } from './rules.js';
|
package/derive/load-records.js
CHANGED
|
@@ -1,153 +1,245 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Record loader for the derivation engine.
|
|
3
|
-
* Discovers and loads verified dogfood records from the filesystem.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdirSync,
|
|
7
|
-
import { resolve, join, extname } from 'node:path';
|
|
8
|
-
|
|
9
|
-
import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
|
|
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
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Record loader for the derivation engine.
|
|
3
|
+
* Discovers and loads verified dogfood records from the filesystem.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdirSync, existsSync, statSync } from 'node:fs';
|
|
7
|
+
import { resolve, join, extname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
|
|
10
|
+
import { loadJsonFile } from '../lib/safe-yaml-load.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
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.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} rootDir - dogfood-labs repo root.
|
|
20
|
+
* @param {string} repoKey - Full org/repo key (e.g. "mcp-tool-shop-org/repo-crawler-mcp").
|
|
21
|
+
* @returns {Array<{ record: object, rejected: boolean, path: string }>}
|
|
22
|
+
*/
|
|
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) {
|
|
36
|
+
const [org, repo] = repoKey.split('/');
|
|
37
|
+
// Path-traversal guard: F-916867-005. Mirrors persist.js + load-context.js
|
|
38
|
+
// via the central helper at @dogfood-lab/ingest/lib/unsafe-segment.js.
|
|
39
|
+
// A malformed repoKey (`..` or path-separator) would otherwise resolve
|
|
40
|
+
// outside the records tree and silently load (or skip) unrelated files.
|
|
41
|
+
if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
|
|
42
|
+
return { entries: [], skipped: [] };
|
|
43
|
+
}
|
|
44
|
+
const entries = [];
|
|
45
|
+
const skipped = [];
|
|
46
|
+
|
|
47
|
+
// Accepted records
|
|
48
|
+
const acceptedDir = resolve(rootDir, 'records', org, repo);
|
|
49
|
+
const acc = walkRecordsWithSkips(acceptedDir, false);
|
|
50
|
+
entries.push(...acc.entries);
|
|
51
|
+
skipped.push(...acc.skipped);
|
|
52
|
+
|
|
53
|
+
// Rejected records
|
|
54
|
+
const rejectedDir = resolve(rootDir, 'records', '_rejected', org, repo);
|
|
55
|
+
const rej = walkRecordsWithSkips(rejectedDir, true);
|
|
56
|
+
entries.push(...rej.entries);
|
|
57
|
+
skipped.push(...rej.skipped);
|
|
58
|
+
|
|
59
|
+
return { entries, skipped };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load a single record by run_id.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} rootDir - dogfood-labs repo root.
|
|
66
|
+
* @param {string} runId - The run_id to find.
|
|
67
|
+
* @returns {{ record: object, rejected: boolean, path: string } | null}
|
|
68
|
+
*/
|
|
69
|
+
export function loadRecordById(rootDir, runId) {
|
|
70
|
+
// Search accepted records
|
|
71
|
+
const acceptedRoot = resolve(rootDir, 'records');
|
|
72
|
+
const found = findRecordFile(acceptedRoot, runId, false);
|
|
73
|
+
if (found) return found;
|
|
74
|
+
|
|
75
|
+
// Search rejected records
|
|
76
|
+
const rejectedRoot = resolve(rootDir, 'records', '_rejected');
|
|
77
|
+
return findRecordFile(rejectedRoot, runId, true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
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.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} rootDir - dogfood-labs repo root.
|
|
87
|
+
* @returns {Array<{ record: object, rejected: boolean, path: string }>}
|
|
88
|
+
*/
|
|
89
|
+
export function loadAllRecords(rootDir) {
|
|
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 = [];
|
|
102
|
+
|
|
103
|
+
// Accepted records
|
|
104
|
+
const recordsDir = resolve(rootDir, 'records');
|
|
105
|
+
if (existsSync(recordsDir)) {
|
|
106
|
+
for (const org of listDirs(recordsDir)) {
|
|
107
|
+
if (org === '_rejected') continue;
|
|
108
|
+
const orgDir = join(recordsDir, org);
|
|
109
|
+
for (const repo of listDirs(orgDir)) {
|
|
110
|
+
const repoDir = join(orgDir, repo);
|
|
111
|
+
const w = walkRecordsWithSkips(repoDir, false);
|
|
112
|
+
entries.push(...w.entries);
|
|
113
|
+
skipped.push(...w.skipped);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Rejected records
|
|
119
|
+
const rejectedDir = resolve(rootDir, 'records', '_rejected');
|
|
120
|
+
if (existsSync(rejectedDir)) {
|
|
121
|
+
for (const org of listDirs(rejectedDir)) {
|
|
122
|
+
const orgDir = join(rejectedDir, org);
|
|
123
|
+
for (const repo of listDirs(orgDir)) {
|
|
124
|
+
const repoDir = join(orgDir, repo);
|
|
125
|
+
const w = walkRecordsWithSkips(repoDir, true);
|
|
126
|
+
entries.push(...w.entries);
|
|
127
|
+
skipped.push(...w.skipped);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { entries, skipped };
|
|
133
|
+
}
|
|
134
|
+
|
|
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 };
|
|
148
|
+
|
|
149
|
+
function walk(d) {
|
|
150
|
+
let children;
|
|
151
|
+
try {
|
|
152
|
+
children = readdirSync(d);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
skipped.push({ path: d, error: `readdir error: ${err.message}` });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const entry of children) {
|
|
158
|
+
const full = join(d, entry);
|
|
159
|
+
let stat;
|
|
160
|
+
try {
|
|
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 });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
walk(dir);
|
|
181
|
+
return { entries, skipped };
|
|
182
|
+
}
|
|
183
|
+
|
|
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
|
+
*/
|
|
193
|
+
function findRecordFile(rootDir, runId, rejected) {
|
|
194
|
+
if (!existsSync(rootDir)) return null;
|
|
195
|
+
|
|
196
|
+
function search(dir) {
|
|
197
|
+
let children;
|
|
198
|
+
try {
|
|
199
|
+
children = readdirSync(dir);
|
|
200
|
+
} catch {
|
|
201
|
+
// Permission/transient error on the directory — fall through; the
|
|
202
|
+
// caller's outer search will try sibling roots.
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
for (const entry of children) {
|
|
206
|
+
const full = join(dir, entry);
|
|
207
|
+
let stat;
|
|
208
|
+
try {
|
|
209
|
+
stat = statSync(full);
|
|
210
|
+
} catch {
|
|
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 };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return search(rootDir);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function listDirs(dir) {
|
|
240
|
+
if (!existsSync(dir)) return [];
|
|
241
|
+
return readdirSync(dir).filter(name => {
|
|
242
|
+
try { return statSync(join(dir, name)).isDirectory(); }
|
|
243
|
+
catch { return false; }
|
|
244
|
+
});
|
|
245
|
+
}
|