@dogfood-lab/findings 1.2.3 → 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/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
|
@@ -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,8 +41,6 @@
|
|
|
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": {
|
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
|
}
|
package/review/review-engine.js
CHANGED
|
@@ -101,13 +101,34 @@ export function performAction(rootDir, params) {
|
|
|
101
101
|
|
|
102
102
|
// Apply review metadata
|
|
103
103
|
const now = new Date().toISOString();
|
|
104
|
+
|
|
105
|
+
// H4 / F-stage-a-h4 — auto-populate review.reject_reason whenever the
|
|
106
|
+
// target status is 'rejected'. Pre-amend, the engine only set the field
|
|
107
|
+
// for `action === 'reject'` and silently produced rejected findings on
|
|
108
|
+
// merge / supersede without a structured reason. The H4 schema if/then
|
|
109
|
+
// would then reject those findings at the contract boundary — but the
|
|
110
|
+
// engine was producing them. The fix:
|
|
111
|
+
//
|
|
112
|
+
// - Operator-supplied `params.rejectReason` always wins (override).
|
|
113
|
+
// - `merge` and `supersede` auto-default to `'merged_into_canonical'`
|
|
114
|
+
// since lineage already carries `superseded_by` / `merged_from`.
|
|
115
|
+
// - For `action === 'reject'`, the engine does NOT invent a default —
|
|
116
|
+
// defaulting would erase operator intent. The schema enforces that
|
|
117
|
+
// the field is set; an operator who forgets sees a validation error
|
|
118
|
+
// downstream and learns to pass one. (This is the intentional
|
|
119
|
+
// test-and-tell path in h4-engine-auto-reject-reason.test.js.)
|
|
120
|
+
let effectiveRejectReason = params.rejectReason;
|
|
121
|
+
if (!effectiveRejectReason && toStatus === 'rejected' && (action === 'merge' || action === 'supersede')) {
|
|
122
|
+
effectiveRejectReason = 'merged_into_canonical';
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
finding.review = {
|
|
105
126
|
reviewed_by: actor,
|
|
106
127
|
reviewed_at: now,
|
|
107
128
|
last_action: action,
|
|
108
129
|
...(params.reason ? { decision_reason: params.reason } : {}),
|
|
109
130
|
...(params.notes ? { review_notes: params.notes } : {}),
|
|
110
|
-
...(
|
|
131
|
+
...(effectiveRejectReason ? { reject_reason: effectiveRejectReason } : {})
|
|
111
132
|
};
|
|
112
133
|
|
|
113
134
|
// Update timestamps
|
|
@@ -11,16 +11,23 @@
|
|
|
11
11
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
12
12
|
import { resolve, join } from 'node:path';
|
|
13
13
|
import yaml from 'js-yaml';
|
|
14
|
-
import { loadAcceptedPatterns } from './recommendation-derivation.js';
|
|
14
|
+
import { loadAcceptedPatterns, loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Derive doctrine from strong accepted patterns.
|
|
18
18
|
*
|
|
19
|
+
* D2B-001 — return shape carries a `skipped: [{path, error}]` field so the
|
|
20
|
+
* silent-loader signal propagates through the derive API. A torn pattern
|
|
21
|
+
* that WOULD have been considered for doctrine synthesis no longer
|
|
22
|
+
* disappears with no signal. Legacy callers reading `doctrines` / `stats`
|
|
23
|
+
* are unaffected.
|
|
24
|
+
*
|
|
19
25
|
* @param {string} rootDir - dogfood-labs repo root
|
|
20
|
-
* @returns {{ doctrines: Array, stats: { patternsConsidered: number, doctrinesEmitted: number, belowThreshold: number } }}
|
|
26
|
+
* @returns {{ doctrines: Array, skipped: Array<{path: string, error: string}>, stats: { patternsConsidered: number, doctrinesEmitted: number, belowThreshold: number, patternsSkipped: number } }}
|
|
21
27
|
*/
|
|
22
28
|
export function deriveDoctrine(rootDir) {
|
|
23
|
-
const
|
|
29
|
+
const { entries, skipped } = loadAcceptedPatternsWithSkips(rootDir);
|
|
30
|
+
const patterns = entries.map(e => e.data);
|
|
24
31
|
const strong = patterns.filter(p =>
|
|
25
32
|
p.pattern_strength === 'strong' || p.pattern_strength === 'portfolio_stable'
|
|
26
33
|
);
|
|
@@ -44,10 +51,12 @@ export function deriveDoctrine(rootDir) {
|
|
|
44
51
|
|
|
45
52
|
return {
|
|
46
53
|
doctrines,
|
|
54
|
+
skipped, // D2B-001: structured skip list for operator legibility
|
|
47
55
|
stats: {
|
|
48
56
|
patternsConsidered: patterns.length,
|
|
49
57
|
doctrinesEmitted: doctrines.length,
|
|
50
|
-
belowThreshold
|
|
58
|
+
belowThreshold,
|
|
59
|
+
patternsSkipped: skipped.length
|
|
51
60
|
}
|
|
52
61
|
};
|
|
53
62
|
}
|
package/synthesis/index.js
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* Synthesis layer exports.
|
|
3
3
|
*/
|
|
4
4
|
export { derivePatterns } from './pattern-derivation.js';
|
|
5
|
-
export { deriveRecommendations, loadAcceptedPatterns } from './recommendation-derivation.js';
|
|
5
|
+
export { deriveRecommendations, loadAcceptedPatterns, loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
|
|
6
6
|
export { deriveDoctrine } from './doctrine-derivation.js';
|
|
7
7
|
export { validatePattern, validateRecommendation, validateDoctrine } from './validate-artifacts.js';
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
writePattern, writeRecommendation, writeDoctrine,
|
|
10
|
+
writePatterns, writeRecommendations, writeDoctrines,
|
|
11
|
+
loadPatterns, loadRecommendations, loadDoctrines
|
|
12
|
+
} from './write-artifacts.js';
|
|
@@ -5,18 +5,26 @@
|
|
|
5
5
|
* pattern kind, dimensions, and transfer scope.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
import
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { loadYamlDir } from '../lib/safe-yaml-load.js';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Derive recommendations from accepted patterns.
|
|
14
14
|
*
|
|
15
|
+
* D2B-001 — return shape carries a `skipped: [{path, error}]` field so the
|
|
16
|
+
* silent-loader signal that Wave A1 wired through `loadYamlDir` now propagates
|
|
17
|
+
* through the derive API to the operator surface (CLI). Legacy callers that
|
|
18
|
+
* only read `recommendations` / `stats` are unaffected — the field is
|
|
19
|
+
* additive. A torn pattern that WOULD have been considered for recommendation
|
|
20
|
+
* synthesis no longer disappears with no signal.
|
|
21
|
+
*
|
|
15
22
|
* @param {string} rootDir - dogfood-labs repo root
|
|
16
|
-
* @returns {{ recommendations: Array, stats: { patternsConsidered: number, recommendationsEmitted: number } }}
|
|
23
|
+
* @returns {{ recommendations: Array, skipped: Array<{path: string, error: string}>, stats: { patternsConsidered: number, recommendationsEmitted: number, patternsSkipped: number } }}
|
|
17
24
|
*/
|
|
18
25
|
export function deriveRecommendations(rootDir) {
|
|
19
|
-
const
|
|
26
|
+
const { entries, skipped } = loadAcceptedPatternsWithSkips(rootDir);
|
|
27
|
+
const patterns = entries.map(e => e.data);
|
|
20
28
|
const recommendations = [];
|
|
21
29
|
|
|
22
30
|
for (const pat of patterns) {
|
|
@@ -26,9 +34,11 @@ export function deriveRecommendations(rootDir) {
|
|
|
26
34
|
|
|
27
35
|
return {
|
|
28
36
|
recommendations,
|
|
37
|
+
skipped, // D2B-001: structured skip list for operator legibility
|
|
29
38
|
stats: {
|
|
30
39
|
patternsConsidered: patterns.length,
|
|
31
|
-
recommendationsEmitted: recommendations.length
|
|
40
|
+
recommendationsEmitted: recommendations.length,
|
|
41
|
+
patternsSkipped: skipped.length
|
|
32
42
|
}
|
|
33
43
|
};
|
|
34
44
|
}
|
|
@@ -137,20 +147,41 @@ function fmtSurfaces(pattern) {
|
|
|
137
147
|
|
|
138
148
|
/**
|
|
139
149
|
* Load accepted patterns from disk.
|
|
150
|
+
*
|
|
151
|
+
* Legacy array shape: returns only accepted patterns. Torn pattern YAML
|
|
152
|
+
* files are NO LONGER silently dropped — they surface via the sibling
|
|
153
|
+
* structured API `loadAcceptedPatternsWithSkips`. This call delegates to
|
|
154
|
+
* that one and discards the skipped list for callers that don't yet care
|
|
155
|
+
* about pipeline honesty.
|
|
156
|
+
*
|
|
157
|
+
* H2 / F-721047-010 — silent-loader closure: the previous implementation
|
|
158
|
+
* wrapped `yaml.load(readFileSync(...))` in a bare `try { ... } catch {}`,
|
|
159
|
+
* silently dropping every torn pattern. Now delegated to the shared
|
|
160
|
+
* `loadYamlDir` helper which returns structured skip records.
|
|
140
161
|
*/
|
|
141
162
|
function loadAcceptedPatterns(rootDir) {
|
|
163
|
+
return loadAcceptedPatternsWithSkips(rootDir).entries.map(e => e.data);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Load accepted patterns from disk, surfacing structured skip records for
|
|
168
|
+
* any torn pattern file. The audit-honesty API.
|
|
169
|
+
*
|
|
170
|
+
* Only `entries[].data` whose `status === 'accepted'` are returned in
|
|
171
|
+
* entries — the legacy `loadAcceptedPatterns` filter is preserved. The
|
|
172
|
+
* `skipped` list captures every torn file (status unknowable) so a torn
|
|
173
|
+
* pattern that WOULD have been accepted does not silently vanish.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} rootDir
|
|
176
|
+
* @returns {{ entries: Array<{ path: string, data: object }>, skipped: Array<{ path: string, error: string }> }}
|
|
177
|
+
*/
|
|
178
|
+
function loadAcceptedPatternsWithSkips(rootDir) {
|
|
142
179
|
const dir = resolve(rootDir, 'patterns');
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const data = yaml.load(readFileSync(join(dir, file), 'utf-8'));
|
|
150
|
-
if (data?.status === 'accepted') patterns.push(data);
|
|
151
|
-
} catch { /* skip */ }
|
|
152
|
-
}
|
|
153
|
-
return patterns;
|
|
180
|
+
const { entries, skipped } = loadYamlDir(dir, { recursive: false });
|
|
181
|
+
return {
|
|
182
|
+
entries: entries.filter(e => e.data?.status === 'accepted'),
|
|
183
|
+
skipped,
|
|
184
|
+
};
|
|
154
185
|
}
|
|
155
186
|
|
|
156
|
-
export { loadAcceptedPatterns };
|
|
187
|
+
export { loadAcceptedPatterns, loadAcceptedPatternsWithSkips };
|
|
@@ -1,46 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema validation for pattern, recommendation, and doctrine artifacts.
|
|
3
|
+
*
|
|
4
|
+
* H3 hop 1: delegates to the canonical {@link validatePayload} from
|
|
5
|
+
* `@dogfood-lab/schemas`. Pre-H3 this module compiled its own
|
|
6
|
+
* Ajv2020 + ajv-formats instance per schema; that duplicated the
|
|
7
|
+
* verifier's compile path and created the C1 two-Ajv structural gap
|
|
8
|
+
* (same JSON Schema → two distinct compiled validators in two
|
|
9
|
+
* sibling packages). The migration collapses pattern, recommendation,
|
|
10
|
+
* and doctrine to the single cached validator the canonical seam
|
|
11
|
+
* shares with the rest of the workspace.
|
|
12
|
+
*
|
|
13
|
+
* Return contract preserved: `{ valid, errors: [{ path, message }] }`.
|
|
14
|
+
* Synthesis callers ignored `params` and `keyword` historically — we
|
|
15
|
+
* project the canonical ValidationError down to the narrower shape
|
|
16
|
+
* the synthesis layer actually uses.
|
|
3
17
|
*/
|
|
4
18
|
|
|
5
|
-
import {
|
|
6
|
-
import { dirname } from 'node:path';
|
|
7
|
-
import { createRequire } from 'node:module';
|
|
8
|
-
import Ajv2020 from 'ajv/dist/2020.js';
|
|
9
|
-
import addFormats from 'ajv-formats';
|
|
10
|
-
import yaml from 'js-yaml';
|
|
19
|
+
import { validatePayload } from '@dogfood-lab/schemas';
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const SCHEMAS_DIR = dirname(
|
|
15
|
-
require.resolve('@dogfood-lab/schemas/json/dogfood-pattern.schema.json')
|
|
16
|
-
);
|
|
17
|
-
|
|
18
|
-
const _validators = {};
|
|
19
|
-
|
|
20
|
-
function getValidator(schemaFile) {
|
|
21
|
-
if (!_validators[schemaFile]) {
|
|
22
|
-
const schema = JSON.parse(readFileSync(`${SCHEMAS_DIR}/${schemaFile}`, 'utf-8'));
|
|
23
|
-
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
24
|
-
addFormats(ajv);
|
|
25
|
-
_validators[schemaFile] = ajv.compile(schema);
|
|
26
|
-
}
|
|
27
|
-
return _validators[schemaFile];
|
|
21
|
+
function projectErrors(errors) {
|
|
22
|
+
return errors.map(e => ({ path: e.path, message: e.message }));
|
|
28
23
|
}
|
|
29
24
|
|
|
30
25
|
export function validatePattern(data) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return { valid, errors: valid ? [] : (validate.errors || []).map(e => ({ path: e.instancePath || '/', message: e.message })) };
|
|
26
|
+
const result = validatePayload('pattern', data);
|
|
27
|
+
return { valid: result.valid, errors: projectErrors(result.errors) };
|
|
34
28
|
}
|
|
35
29
|
|
|
36
30
|
export function validateRecommendation(data) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
return { valid, errors: valid ? [] : (validate.errors || []).map(e => ({ path: e.instancePath || '/', message: e.message })) };
|
|
31
|
+
const result = validatePayload('recommendation', data);
|
|
32
|
+
return { valid: result.valid, errors: projectErrors(result.errors) };
|
|
40
33
|
}
|
|
41
34
|
|
|
42
35
|
export function validateDoctrine(data) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
return { valid, errors: valid ? [] : (validate.errors || []).map(e => ({ path: e.instancePath || '/', message: e.message })) };
|
|
36
|
+
const result = validatePayload('doctrine', data);
|
|
37
|
+
return { valid: result.valid, errors: projectErrors(result.errors) };
|
|
46
38
|
}
|