@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.
@@ -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.3",
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": {
@@ -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
  }
@@ -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
- ...(params.rejectReason && action === 'reject' ? { reject_reason: params.rejectReason } : {})
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 patterns = loadAcceptedPatterns(rootDir);
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
  }
@@ -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 { writePattern, writeRecommendation, writeDoctrine, loadPatterns, loadRecommendations, loadDoctrines } from './write-artifacts.js';
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 { readFileSync, readdirSync, existsSync } from 'node:fs';
9
- import { resolve, join } from 'node:path';
10
- import yaml from 'js-yaml';
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 patterns = loadAcceptedPatterns(rootDir);
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
- if (!existsSync(dir)) return [];
144
-
145
- const patterns = [];
146
- for (const file of readdirSync(dir)) {
147
- if (!file.endsWith('.yaml')) continue;
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 { readFileSync } from 'node:fs';
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
- const require = createRequire(import.meta.url);
13
- // Resolve the schemas package's json directory via its subpath export.
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 validate = getValidator('dogfood-pattern.schema.json');
32
- const valid = validate(data);
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 validate = getValidator('dogfood-recommendation.schema.json');
38
- const valid = validate(data);
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 validate = getValidator('dogfood-doctrine.schema.json');
44
- const valid = validate(data);
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
  }