@dogfood-lab/ingest 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.
@@ -24,6 +24,8 @@
24
24
  */
25
25
  import { renameSync } from 'node:fs';
26
26
 
27
+ import { sleepSync } from './sleep-sync.js';
28
+
27
29
  export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs = 200 } = {}) {
28
30
  for (let i = 0; i <= retries; i++) {
29
31
  try {
@@ -32,8 +34,12 @@ export function renameWithRetry(tmp, dest, { retries = 10, baseMs = 15, maxMs =
32
34
  } catch (err) {
33
35
  if ((err.code !== 'EPERM' && err.code !== 'EBUSY') || i === retries) throw err;
34
36
  const delay = Math.min(baseMs * (1 << i), maxMs);
35
- const until = Date.now() + delay;
36
- while (Date.now() < until) { /* spin */ }
37
+ // D1B-002-ingest (Stage C humanization fold): replaced the
38
+ // `while (Date.now() < until)` busy-spin with the Atomics.wait-
39
+ // based sleepSync helper. Same workspace-cycle pattern as
40
+ // `ingest/lib/atomic-write.js` — sibling helper, not a
41
+ // cross-package import.
42
+ sleepSync(delay);
37
43
  }
38
44
  }
39
45
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * sleepSync — `Atomics.wait`-based synchronous sleep.
3
+ *
4
+ * Sibling of `packages/findings/lib/file-lock.js`'s private `sleepSync`. The
5
+ * duplication exists because npm workspaces do not allow `ingest → findings`
6
+ * imports (findings already depends on ingest, and the reverse edge would
7
+ * create a workspace dependency cycle — same constraint that forced
8
+ * `packages/ingest/lib/atomic-write.js` to duplicate
9
+ * `packages/findings/lib/atomic-write.js`).
10
+ *
11
+ * The helper exists to replace the `while (Date.now() < until)` busy-spin
12
+ * loops sprinkled across synchronous backoff paths (notably
13
+ * `packages/ingest/lib/rename-with-retry.js`'s EPERM/EBUSY retry on Windows
14
+ * NTFS). Spin loops burn a full CPU core for the entire backoff window
15
+ * — Atomics.wait cedes the thread.
16
+ *
17
+ * Why synchronous: the callsites (atomic-write helpers, rebuild-indexes,
18
+ * event-log appender, rename-with-retry) are themselves synchronous and
19
+ * cascading an `await` would leak the boundary into otherwise-deterministic
20
+ * flush paths — same rationale documented at the findings sibling.
21
+ *
22
+ * @param {number} ms - Milliseconds to sleep. Non-finite or non-positive
23
+ * values are no-ops (we don't trust caller arithmetic to never produce
24
+ * negative deltas if a system clock skews).
25
+ */
26
+ export function sleepSync(ms) {
27
+ if (!Number.isFinite(ms) || ms <= 0) return;
28
+ const sab = new SharedArrayBuffer(4);
29
+ const view = new Int32Array(sab);
30
+ Atomics.wait(view, 0, 0, ms);
31
+ }
@@ -1,36 +1,36 @@
1
- /**
2
- * unsafe-segment.js — central path-segment safety helper.
3
- *
4
- * Three callsites previously defined or duplicated this regex (F-916867-005):
5
- * - packages/ingest/persist.js (canonical instance)
6
- * - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
7
- * - packages/findings/derive/load-records.js (the missing third callsite)
8
- *
9
- * The check rejects path-traversal substrings (`..`) and any path separator
10
- * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
- * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
- * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
13
- *
14
- * F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
15
- * legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
16
- * stood since wave 9; this helper is the productized form.
17
- */
18
-
19
- /**
20
- * Regex matching unsafe substrings in a single path segment.
21
- * Use `.test(segment)` — returns true if the segment is unsafe.
22
- *
23
- * @type {RegExp}
24
- */
25
- export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
26
-
27
- /**
28
- * Predicate form: returns true when the given segment contains a path-traversal
29
- * substring or a path separator.
30
- *
31
- * @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
32
- * @returns {boolean}
33
- */
34
- export function isUnsafeSegment(segment) {
35
- return UNSAFE_SEGMENT.test(segment);
36
- }
1
+ /**
2
+ * unsafe-segment.js — central path-segment safety helper.
3
+ *
4
+ * Three callsites previously defined or duplicated this regex (F-916867-005):
5
+ * - packages/ingest/persist.js (canonical instance)
6
+ * - packages/ingest/load-context.js (loadRepoPolicy + githubScenarioFetcher)
7
+ * - packages/findings/derive/load-records.js (the missing third callsite)
8
+ *
9
+ * The check rejects path-traversal substrings (`..`) and any path separator
10
+ * (`/`, `\`). Single dots remain legal because GitHub permits dotted org/repo
11
+ * names like `next.js`, `mcp-tool-shop.github.io`, `repo.io`. The submission
12
+ * schema's repo pattern `^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$` agrees.
13
+ *
14
+ * F-375053-006 regression — an earlier `/[.\/]/` was over-broad and crashed
15
+ * legitimate submissions inside writeRecord. The narrower `/\.\.|[/\\]/` has
16
+ * stood since wave 9; this helper is the productized form.
17
+ */
18
+
19
+ /**
20
+ * Regex matching unsafe substrings in a single path segment.
21
+ * Use `.test(segment)` — returns true if the segment is unsafe.
22
+ *
23
+ * @type {RegExp}
24
+ */
25
+ export const UNSAFE_SEGMENT = /\.\.|[/\\]/;
26
+
27
+ /**
28
+ * Predicate form: returns true when the given segment contains a path-traversal
29
+ * substring or a path separator.
30
+ *
31
+ * @param {string} segment - A single path-segment candidate (e.g. an org or repo name).
32
+ * @returns {boolean}
33
+ */
34
+ export function isUnsafeSegment(segment) {
35
+ return UNSAFE_SEGMENT.test(segment);
36
+ }
package/load-context.js CHANGED
@@ -1,131 +1,345 @@
1
- /**
2
- * Context loader
3
- *
4
- * Gathers everything the verifier needs:
5
- * - Global policy
6
- * - Repo policy (optional, missing is valid)
7
- * - Scenario definitions from source repo (optional, missing becomes rejection reason)
8
- * - Payload normalization
9
- *
10
- * Scenario loading uses a fetch adapter so it can be stubbed in tests.
11
- */
12
-
13
- import { readFileSync, existsSync } from 'node:fs';
14
- import { join } from 'node:path';
15
- import yaml from 'js-yaml';
16
-
17
- import { isUnsafeSegment } from './lib/unsafe-segment.js';
18
-
19
- /**
20
- * Load the global policy.
21
- *
22
- * @param {string} repoRoot
23
- * @returns {object}
24
- */
25
- export function loadGlobalPolicy(repoRoot) {
26
- const path = join(repoRoot, 'policies', 'global-policy.yaml');
27
- return yaml.load(readFileSync(path, 'utf-8'));
28
- }
29
-
30
- /**
31
- * Load repo-specific policy. Returns null if no policy exists.
32
- *
33
- * @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
34
- * @param {string} repoRoot
35
- * @returns {object|null}
36
- */
37
- export function loadRepoPolicy(repoSlug, repoRoot) {
38
- const [org, repo] = repoSlug.split('/');
39
- if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
40
- const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
41
-
42
- if (!existsSync(path)) return null;
43
- try {
44
- return yaml.load(readFileSync(path, 'utf-8'));
45
- } catch {
46
- console.warn(`load-context: malformed YAML in repo policy for ${repoSlug}`);
47
- return null;
48
- }
49
- }
50
-
51
- /**
52
- * Default scenario fetcher that reads from the local filesystem.
53
- * Used when dogfood-labs is dogfooding itself.
54
- *
55
- * @param {string} repoRoot - Root of the source repo
56
- * @returns {object} Scenario fetch adapter
57
- */
58
- export function localScenarioFetcher(repoRoot) {
59
- return {
60
- async fetch(scenarioId) {
61
- if (!/^[\w-]+$/.test(scenarioId)) return null;
62
- const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
63
- if (!existsSync(path)) return null;
64
- return yaml.load(readFileSync(path, 'utf-8'));
65
- }
66
- };
67
- }
68
-
69
- /**
70
- * GitHub scenario fetcher. Loads scenario definitions from a source repo
71
- * via the GitHub API at a specific commit SHA.
72
- *
73
- * @param {string} token - GitHub PAT
74
- * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
75
- * @param {string} commitSha - Commit to fetch scenarios from
76
- * @returns {object} Scenario fetch adapter
77
- */
78
- export function githubScenarioFetcher(token, repoSlug, commitSha) {
79
- const [org, repo] = repoSlug.split('/');
80
- if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
81
- return { async fetch() { return null; } };
82
- }
83
- return {
84
- async fetch(scenarioId) {
85
- if (!/^[\w-]+$/.test(scenarioId)) return null;
86
- const path = `dogfood/scenarios/${scenarioId}.yaml`;
87
- const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
88
-
89
- try {
90
- const resp = await globalThis.fetch(url, {
91
- headers: {
92
- Authorization: `Bearer ${token}`,
93
- Accept: 'application/vnd.github.raw+json',
94
- 'X-GitHub-Api-Version': '2022-11-28'
95
- }
96
- });
97
- if (!resp.ok) return null;
98
- const text = await resp.text();
99
- return yaml.load(text);
100
- } catch {
101
- return null;
102
- }
103
- }
104
- };
105
- }
106
-
107
- /**
108
- * Load all scenario definitions referenced by a submission's scenario_results.
109
- *
110
- * @param {object} submission
111
- * @param {object} scenarioFetcher - { fetch(scenarioId) => Promise<object|null> }
112
- * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
113
- */
114
- export async function loadScenarios(submission, scenarioFetcher) {
115
- const scenarios = new Map();
116
- const errors = [];
117
-
118
- for (const sr of submission.scenario_results || []) {
119
- const id = sr.scenario_id;
120
- if (scenarios.has(id)) continue;
121
-
122
- const definition = await scenarioFetcher.fetch(id);
123
- if (definition) {
124
- scenarios.set(id, definition);
125
- } else {
126
- errors.push(`scenario "${id}" could not be loaded from source repo`);
127
- }
128
- }
129
-
130
- return { scenarios, errors };
131
- }
1
+ /**
2
+ * Context loader
3
+ *
4
+ * Gathers everything the verifier needs:
5
+ * - Global policy
6
+ * - Repo policy (optional, missing is valid)
7
+ * - Scenario definitions from source repo (optional, missing becomes rejection reason)
8
+ * - Payload normalization
9
+ *
10
+ * Scenario loading uses a fetch adapter so it can be stubbed in tests.
11
+ */
12
+
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import yaml from 'js-yaml';
16
+
17
+ import { validatePayload } from '@dogfood-lab/schemas';
18
+ import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
19
+ import { isUnsafeSegment } from './lib/unsafe-segment.js';
20
+
21
+ /**
22
+ * D2B-005 (Phase 10 Step 1): summarise a `validatePayload('policy', …)`
23
+ * result down to a single-line operator-actionable reason string. The
24
+ * canonical seam returns `{ path, message, keyword, params }` per error;
25
+ * we collapse the first 3 violations into a `path message` join so the
26
+ * sentinel's `reason` field stays grep-friendly without truncating the
27
+ * structural detail an operator needs to find the offending YAML key.
28
+ *
29
+ * Three errors is a deliberate ceiling — a policy with 20 violations
30
+ * almost certainly has a single root cause (wrong section nesting,
31
+ * malformed root); flooding the reason string with 20 lines makes the
32
+ * error harder to read, not easier.
33
+ */
34
+ function summarizePolicyErrors(errors) {
35
+ const trimmed = errors.slice(0, 3).map(e => `${e.path || '/'} ${e.message}`);
36
+ const ellipsis = errors.length > 3 ? `; (+${errors.length - 3} more)` : '';
37
+ return trimmed.join('; ') + ellipsis;
38
+ }
39
+
40
+ /**
41
+ * D1B-006 (Stage C humanization): one structured `logStage(...)` helper,
42
+ * component-pinned to `ingest` so the NDJSON stream is greppable with the
43
+ * rest of the pipeline. Same wave-22 wrapper-strip pattern as `run.js`.
44
+ */
45
+ function logStage(stage, fields = {}) {
46
+ const { stage: _ignored, ...rest } = fields;
47
+ sharedLogStage(stage, { component: 'ingest', ...rest });
48
+ }
49
+
50
+ /**
51
+ * Load the global policy.
52
+ *
53
+ * The global policy is REQUIRED — unlike `loadRepoPolicy` which silently
54
+ * returns null when a repo-specific override is absent, a missing or malformed
55
+ * global policy throws with a structured, operator-actionable message naming
56
+ * the resolved path and the failure mode (missing vs unreadable vs invalid
57
+ * YAML, with line/column from `yaml.YAMLException.mark` when available).
58
+ * Receiver workflows would otherwise crash with a raw `ENOENT` or
59
+ * `YAMLException` stack trace, leaving the operator to guess which file
60
+ * to fix.
61
+ *
62
+ * @param {string} repoRoot
63
+ * @returns {object}
64
+ */
65
+ export function loadGlobalPolicy(repoRoot) {
66
+ const path = join(repoRoot, 'policies', 'global-policy.yaml');
67
+ let raw;
68
+ try {
69
+ raw = readFileSync(path, 'utf-8');
70
+ } catch (e) {
71
+ if (e.code === 'ENOENT') {
72
+ throw new Error(
73
+ `Global policy missing: ${path}\n` +
74
+ `The ingest pipeline requires a global policy file. ` +
75
+ `Create it from policies/global-policy.example.yaml or the project README.`
76
+ );
77
+ }
78
+ throw new Error(`Global policy unreadable: ${path} — ${e.message}`);
79
+ }
80
+ let parsed;
81
+ try {
82
+ parsed = yaml.load(raw);
83
+ } catch (e) {
84
+ const where = e.mark ? ` at line ${e.mark.line + 1}, column ${e.mark.column + 1}` : '';
85
+ throw new Error(
86
+ `Global policy YAML invalid: ${path}${where} ${e.message}\n` +
87
+ `Fix the YAML and re-run.`
88
+ );
89
+ }
90
+
91
+ // D2B-005: schema-gate the parsed policy against policy.schema.json.
92
+ // Pre-fix, a YAML that parsed but didn't conform (extra fields, wrong
93
+ // enums, missing required) silently loaded and the verifier ran with
94
+ // ad-hoc field probing — a structurally-invalid policy could produce
95
+ // "verifier accepts everything." Post-H3 the canonical seam is one
96
+ // call away. Global policy is required + load-once, so a schema fault
97
+ // here is fail-loud (matches the YAML-invalid sibling above).
98
+ const validation = validatePayload('policy', parsed);
99
+ if (!validation.valid) {
100
+ throw new Error(
101
+ `Global policy schema-invalid: ${path} — ${summarizePolicyErrors(validation.errors)}\n` +
102
+ `Fix the policy to conform to policy.schema.json and re-run.`
103
+ );
104
+ }
105
+ return parsed;
106
+ }
107
+
108
+ /**
109
+ * Load repo-specific policy.
110
+ *
111
+ * Three discriminable return shapes:
112
+ * - `null` → no repo-policy file exists; defaults apply
113
+ * (documented design — submission is NOT rejected for absent policy).
114
+ * - parsed YAML object → policy loaded cleanly.
115
+ * - `{ __torn: true, reason, path }` → D1B-006 sentinel: the policy
116
+ * file EXISTS but cannot be parsed. Pre-fix this case returned null,
117
+ * which the verifier then treated as "no policy → defaults apply →
118
+ * accept". That sentinel inversion silently approved every
119
+ * submission against a corrupt policy. The torn sentinel surfaces
120
+ * the broken state so the verifier can reject with a
121
+ * `'policy: <reason>'` rejection AND `policy_valid=false`.
122
+ *
123
+ * The sentinel is intentionally a distinct shape (`__torn: true`) so the
124
+ * verifier pattern-match needs no schema knowledge — the existence of
125
+ * `__torn` discriminates without parsing the policy twice.
126
+ *
127
+ * @param {string} repoSlug - e.g. "mcp-tool-shop-org/dogfood-labs"
128
+ * @param {string} repoRoot
129
+ * @returns {object|null|{ __torn: true, reason: string, path: string }}
130
+ */
131
+ export function loadRepoPolicy(repoSlug, repoRoot) {
132
+ const [org, repo] = repoSlug.split('/');
133
+ if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) return null;
134
+ const path = join(repoRoot, 'policies', 'repos', org, `${repo}.yaml`);
135
+
136
+ if (!existsSync(path)) return null;
137
+ let parsed;
138
+ try {
139
+ parsed = yaml.load(readFileSync(path, 'utf-8'));
140
+ } catch (e) {
141
+ // D1B-006: torn-policy sentinel + structured warn event for YAML
142
+ // parse failures. The verifier (verify/index.js:189-192) surfaces
143
+ // this as a `policy: <reason>` rejection.
144
+ const reason = e && e.message ? e.message : String(e);
145
+ logStage('warn', {
146
+ kind: 'repo_policy_unreadable',
147
+ repo: repoSlug,
148
+ path,
149
+ error: reason
150
+ });
151
+ return { __torn: true, reason, path };
152
+ }
153
+
154
+ // D2B-005 (Phase 10 Step 1): schema-gate the parsed repo policy.
155
+ // Reuses the `__torn` sentinel so the verifier's existing single-
156
+ // branch handler (verify/index.js:189) catches both "YAML failed
157
+ // to parse" (D1B-006) and "YAML parsed but doesn't conform to
158
+ // policy.schema.json" (D2B-005). The verify-side reason prefix
159
+ // (`policy: repo policy unreadable — <reason>`) covers both
160
+ // classes; the embedded schema-violation detail tells the operator
161
+ // which YAML key to fix.
162
+ const validation = validatePayload('policy', parsed);
163
+ if (!validation.valid) {
164
+ const reason = `schema-invalid — ${summarizePolicyErrors(validation.errors)}`;
165
+ logStage('warn', {
166
+ kind: 'repo_policy_unreadable',
167
+ repo: repoSlug,
168
+ path,
169
+ error: reason
170
+ });
171
+ return { __torn: true, reason, path };
172
+ }
173
+ return parsed;
174
+ }
175
+
176
+ /**
177
+ * Default scenario fetcher that reads from the local filesystem.
178
+ * Used when dogfood-labs is dogfooding itself.
179
+ *
180
+ * @param {string} repoRoot - Root of the source repo
181
+ * @returns {object} Scenario fetch adapter
182
+ */
183
+ export function localScenarioFetcher(repoRoot) {
184
+ return {
185
+ async fetch(scenarioId) {
186
+ if (!/^[\w-]+$/.test(scenarioId)) return null;
187
+ const path = join(repoRoot, 'dogfood', 'scenarios', `${scenarioId}.yaml`);
188
+ if (!existsSync(path)) return null;
189
+ return yaml.load(readFileSync(path, 'utf-8'));
190
+ }
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Default per-request timeout for the GitHub scenario fetch. Mirrors the
196
+ * sibling `GITHUB_PROVENANCE_TIMEOUT_MS` constant at
197
+ * `packages/verify/validators/provenance.js`: a hung GitHub API call
198
+ * would otherwise stall ingest until the surrounding GitHub Actions
199
+ * runner timeout fires (default 6h). 30s fails fast with a typed
200
+ * `'timeout'` reason the operator can pivot on.
201
+ */
202
+ export const GITHUB_SCENARIO_FETCH_TIMEOUT_MS = 30000;
203
+
204
+ /**
205
+ * GitHub scenario fetcher. Loads scenario definitions from a source repo
206
+ * via the GitHub API at a specific commit SHA.
207
+ *
208
+ * Two surfaces on the returned adapter:
209
+ * - `fetch(scenarioId)`: legacy contract — returns the scenario object
210
+ * on success, `null` on any failure. Preserved for back-compat with
211
+ * callers that pattern-match on the truthiness of the result.
212
+ * - `fetchWithReason(scenarioId)`: D1B-004 typed contract — always
213
+ * returns `{ scenario, reason }` where `scenario` is the loaded
214
+ * object on success or `null` on failure, and `reason` is one of
215
+ * `'timeout' | 'not_found' | 'parse_error' | 'invalid_id'` on
216
+ * failure (absent on success). The reason gives operators a
217
+ * pivot key when diagnosing a stale scenario-load chain.
218
+ *
219
+ * Both surfaces honour the per-request AbortController timeout
220
+ * (`GITHUB_SCENARIO_FETCH_TIMEOUT_MS`, overridable via `opts.timeoutMs`).
221
+ *
222
+ * @param {string} token - GitHub PAT
223
+ * @param {string} repoSlug - e.g. "mcp-tool-shop-org/shipcheck"
224
+ * @param {string} commitSha - Commit to fetch scenarios from
225
+ * @param {{ timeoutMs?: number, fetchImpl?: typeof fetch }} [opts]
226
+ * @returns {{ fetch(scenarioId: string): Promise<object|null>, fetchWithReason(scenarioId: string): Promise<{ scenario: object|null, reason?: string }> }}
227
+ */
228
+ export function githubScenarioFetcher(token, repoSlug, commitSha, opts = {}) {
229
+ const timeoutMs = opts.timeoutMs ?? GITHUB_SCENARIO_FETCH_TIMEOUT_MS;
230
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => globalThis.fetch(url, init));
231
+
232
+ const [org, repo] = repoSlug.split('/');
233
+ if (!org || !repo || isUnsafeSegment(org) || isUnsafeSegment(repo)) {
234
+ return {
235
+ async fetch() { return null; },
236
+ async fetchWithReason() { return { scenario: null, reason: 'invalid_id' }; }
237
+ };
238
+ }
239
+
240
+ async function fetchWithReason(scenarioId) {
241
+ if (!/^[\w-]+$/.test(scenarioId)) {
242
+ return { scenario: null, reason: 'invalid_id' };
243
+ }
244
+ const path = `dogfood/scenarios/${scenarioId}.yaml`;
245
+ const url = `https://api.github.com/repos/${repoSlug}/contents/${path}?ref=${commitSha}`;
246
+
247
+ // D1B-004: AbortController-bounded request. Copied from
248
+ // `packages/verify/validators/provenance.js:80-104`. The 30s default
249
+ // matches that sibling so a single end-to-end ingest cannot stall
250
+ // longer than ~60s on cumulative GitHub-API timeouts.
251
+ const controller = new AbortController();
252
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
253
+
254
+ let text;
255
+ try {
256
+ const resp = await fetchImpl(url, {
257
+ headers: {
258
+ Authorization: `Bearer ${token}`,
259
+ Accept: 'application/vnd.github.raw+json',
260
+ 'X-GitHub-Api-Version': '2022-11-28'
261
+ },
262
+ signal: controller.signal
263
+ });
264
+ if (!resp.ok) {
265
+ return { scenario: null, reason: 'not_found' };
266
+ }
267
+ text = await resp.text();
268
+ } catch (err) {
269
+ if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
270
+ return { scenario: null, reason: 'timeout' };
271
+ }
272
+ // Network reject, DNS failure, etc. — surface as not_found for
273
+ // back-compat with the legacy null contract.
274
+ return { scenario: null, reason: 'not_found' };
275
+ } finally {
276
+ clearTimeout(timer);
277
+ }
278
+
279
+ try {
280
+ const scenario = yaml.load(text);
281
+ if (!scenario || typeof scenario !== 'object') {
282
+ return { scenario: null, reason: 'parse_error' };
283
+ }
284
+ return { scenario };
285
+ } catch {
286
+ return { scenario: null, reason: 'parse_error' };
287
+ }
288
+ }
289
+
290
+ return {
291
+ // Legacy: success returns the scenario object, failure returns null
292
+ // — every existing caller pattern-matches on truthiness.
293
+ async fetch(scenarioId) {
294
+ const result = await fetchWithReason(scenarioId);
295
+ return result.scenario;
296
+ },
297
+ fetchWithReason
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Load all scenario definitions referenced by a submission's scenario_results.
303
+ *
304
+ * D1B-004 / L1-007 (Wave A2 Stage C amend2): when the fetcher exposes
305
+ * the typed `fetchWithReason` surface (the canonical
306
+ * `githubScenarioFetcher` does; the legacy `localScenarioFetcher` and any
307
+ * legacy test stub may not), the discriminated `reason` (`timeout` /
308
+ * `not_found` / `parse_error` / `invalid_id`) is propagated into the
309
+ * error string so the operator can pivot on the failure class. Otherwise
310
+ * we fall back to the legacy `fetch(id)` truthiness contract.
311
+ *
312
+ * @param {object} submission
313
+ * @param {object} scenarioFetcher - { fetch(scenarioId), fetchWithReason?(scenarioId) }
314
+ * @returns {Promise<{ scenarios: Map<string, object>, errors: string[] }>}
315
+ */
316
+ export async function loadScenarios(submission, scenarioFetcher) {
317
+ const scenarios = new Map();
318
+ const errors = [];
319
+
320
+ const supportsTypedReason = typeof scenarioFetcher.fetchWithReason === 'function';
321
+
322
+ for (const sr of submission.scenario_results || []) {
323
+ const id = sr.scenario_id;
324
+ if (scenarios.has(id)) continue;
325
+
326
+ if (supportsTypedReason) {
327
+ const result = await scenarioFetcher.fetchWithReason(id);
328
+ if (result && result.scenario) {
329
+ scenarios.set(id, result.scenario);
330
+ } else {
331
+ const reason = result && result.reason ? result.reason : 'unknown';
332
+ errors.push(`scenario "${id}" could not be loaded from source repo (reason: ${reason})`);
333
+ }
334
+ } else {
335
+ const definition = await scenarioFetcher.fetch(id);
336
+ if (definition) {
337
+ scenarios.set(id, definition);
338
+ } else {
339
+ errors.push(`scenario "${id}" could not be loaded from source repo`);
340
+ }
341
+ }
342
+ }
343
+
344
+ return { scenarios, errors };
345
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
7
7
  "exports": {
8
8
  ".": "./run.js",
9
- "./lib/*": "./lib/*"
9
+ "./lib/*": "./lib/*",
10
+ "./validate-record.js": "./validate-record.js"
10
11
  },
11
12
  "scripts": {
12
13
  "test": "node --test",
@@ -29,12 +30,10 @@
29
30
  "@dogfood-lab/dogfood-swarm": "^1.2.0",
30
31
  "@dogfood-lab/schemas": "^1.2.0",
31
32
  "@dogfood-lab/verify": "^1.2.0",
32
- "ajv": "^8.18.0",
33
- "ajv-formats": "^3.0.1",
34
33
  "js-yaml": "^4.1.0"
35
34
  },
36
35
  "engines": {
37
- "node": ">=20"
36
+ "node": ">=22"
38
37
  },
39
38
  "author": "mcp-tool-shop",
40
39
  "license": "MIT",