@dogfood-lab/ingest 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/ingest",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Ingestion pipeline for testing-os. Thin glue: dispatch → verifier → persist → indexes.",
6
6
  "main": "run.js",
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
28
- import { join, relative } from 'node:path';
28
+ import { join, relative, sep } from 'node:path';
29
29
  import { randomBytes } from 'node:crypto';
30
30
 
31
31
  import { logStage as sharedLogStage } from '@dogfood-lab/dogfood-swarm/lib/log-stage.js';
@@ -49,14 +49,44 @@ function logStage(stage, fields = {}) {
49
49
  /**
50
50
  * Recursively find all .json files under a directory.
51
51
  *
52
+ * d3-ingest-B005 (Stage C humanization): the `readdirSync` is wrapped so an
53
+ * unreadable subtree (EACCES, ENOTDIR, a Windows lock — the same class
54
+ * renameWithRetry defends against) degrades to "that subtree's records are
55
+ * missing from this rebuild" instead of throwing and aborting the WHOLE scan.
56
+ * This restores parity with the per-FILE tolerance of `loadRecord` just below
57
+ * (module header: "does NOT crash on a single bad file") and mirrors the
58
+ * in-repo precedent in `packages/portfolio/lib/parse-regression-pins.js`'s
59
+ * `walkSourceFiles`. The skip is NOT silent: a structured
60
+ * `logStage('warn', { kind: 'dir_unreadable', path })` NDJSON line makes it
61
+ * greppable alongside the rest of the pipeline (`"kind":"dir_unreadable"`), so
62
+ * the operator can see which subtree was dropped and why the index is partial.
63
+ * Exported so the guard is unit-testable in isolation (the single wrapped
64
+ * `readdirSync` serves both the top-level call and every recursive descent).
65
+ *
52
66
  * @param {string} dir
53
67
  * @returns {string[]}
54
68
  */
55
- function findJsonFiles(dir) {
69
+ export function findJsonFiles(dir) {
56
70
  const results = [];
57
71
  if (!existsSync(dir)) return results;
58
72
 
59
- const entries = readdirSync(dir, { withFileTypes: true });
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(dir, { withFileTypes: true });
76
+ } catch (err) {
77
+ // Unreadable directory (locked / permission-restricted / not a dir).
78
+ // Skip this subtree rather than sinking the whole rebuild, but emit a
79
+ // structured, greppable warn naming the path so the partial index is
80
+ // explained — not a silent swallow.
81
+ logStage('warn', {
82
+ kind: 'dir_unreadable',
83
+ reason: err && err.code ? err.code : 'readdir_failed',
84
+ path: dir,
85
+ error: err && err.message ? err.message : String(err),
86
+ });
87
+ return results;
88
+ }
89
+
60
90
  for (const entry of entries) {
61
91
  const fullPath = join(dir, entry.name);
62
92
  if (entry.isDirectory()) {
@@ -115,7 +145,17 @@ export function rebuildIndexes(repoRoot, options = {}) {
115
145
  const skipped = [];
116
146
 
117
147
  for (const f of [...acceptedFiles, ...rejectedFiles]) {
118
- const relPath = relative(repoRoot, f);
148
+ // SEED-1 (d3-ingest-001) — posixify at the serialization boundary.
149
+ // `relative()` returns OS-native separators (backslashes on win32). This
150
+ // value becomes `record._path` and is serialized verbatim into all three
151
+ // committed index `path` fields (latest-by-repo / failing / stale), which
152
+ // downstream consumers read as raw.githubusercontent.com URL fragments
153
+ // (docs/policy-contract.md Gate F) — a backslash there is a broken URL.
154
+ // Normalize ONCE here, at the single source the whole family flows through,
155
+ // so every serialized path is forward-slash regardless of host OS. Mirrors
156
+ // the canonical transform proven in
157
+ // packages/portfolio/lib/parse-regression-pins.js:75. NEVER a win32-skip.
158
+ const relPath = relative(repoRoot, f).split(sep).join('/');
119
159
  const { record, error } = loadRecord(f);
120
160
  if (error) {
121
161
  corrupted.push({ path: relPath, error });
package/run.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * - regenerate indexes
18
18
  */
19
19
 
20
- import { resolve, dirname } from 'node:path';
20
+ import { resolve, dirname, sep } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { randomBytes } from 'node:crypto';
23
23
 
@@ -30,6 +30,27 @@ import { rebuildIndexes } from './rebuild-indexes.js';
30
30
 
31
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
32
 
33
+ /**
34
+ * SEED-1 (d3-ingest-003) — posixify a path-shaped value at the operator/log
35
+ * SERIALIZATION boundary. `computeRecordPath`/`writeRecord` return OS-native
36
+ * paths (backslash-separated, absolute, on win32) because those values are
37
+ * also used for real filesystem operations. But the moment a path crosses into
38
+ * CLI JSON output, NDJSON log lines, or a downstream report
39
+ * (dogfood-swarm persist.js records `report.dogfood.path`), it must be
40
+ * forward-slash so operators and log pivots see one canonical shape across
41
+ * OSes — and so a copy-paste into a raw.githubusercontent URL is not a broken
42
+ * link. We posixify ONLY here, at the emit sites, leaving the returned fs paths
43
+ * OS-native for the filesystem layer. Mirrors the boundary-normalize doctrine
44
+ * already used in rebuild-indexes.js and parse-regression-pins.js. NEVER a
45
+ * win32-skip.
46
+ *
47
+ * @param {string|null} p
48
+ * @returns {string|null}
49
+ */
50
+ function posixifyPath(p) {
51
+ return typeof p === 'string' ? p.split(sep).join('/') : p;
52
+ }
53
+
33
54
  /**
34
55
  * Emit a single structured stage-transition log line via the shared helper.
35
56
  *
@@ -238,7 +259,10 @@ export async function ingest(submission, options) {
238
259
  logStage('persist_complete', {
239
260
  submission_id: submissionId,
240
261
  correlation_id,
241
- path,
262
+ // d3-ingest-003: posixify at the log boundary — `path` is OS-native from
263
+ // writeRecord (used for the fs write); the NDJSON log surface gets forward
264
+ // slashes so log pivots are byte-identical across OSes.
265
+ path: posixifyPath(path),
242
266
  written,
243
267
  duplicate: !written,
244
268
  duration_ms: Date.now() - persistStart
@@ -281,11 +305,12 @@ export async function ingest(submission, options) {
281
305
  failed_stage: 'rebuild_indexes',
282
306
  message: err.message,
283
307
  stack: truncatedStack,
284
- record_persisted_at: path,
308
+ // d3-ingest-003: operator-facing path → posixify at the log boundary.
309
+ record_persisted_at: posixifyPath(path),
285
310
  recovery: 'next ingest will trigger a full rebuild of indexes/'
286
311
  });
287
312
  console.error(
288
- `WARNING: record persisted at ${path}, but index rebuild failed: ${err.message}\n` +
313
+ `WARNING: record persisted at ${posixifyPath(path)}, but index rebuild failed: ${err.message}\n` +
289
314
  ` indexes/ may be stale until next ingest. To force rebuild now, re-run any test ingest.\n` +
290
315
  ` stack: ${stackPreview}`
291
316
  );
@@ -429,7 +454,10 @@ export async function verifyOnly(submission, options) {
429
454
  submission_id: submissionId,
430
455
  correlation_id,
431
456
  status: record.verification?.status ?? null,
432
- would_persist_to
457
+ // d3-ingest-003: posixify at the log boundary. The returned
458
+ // `would_persist_to` below stays OS-native so callers that resolve it
459
+ // against the filesystem keep a real fs path.
460
+ would_persist_to: posixifyPath(would_persist_to)
433
461
  });
434
462
 
435
463
  return { record, would_persist_to, verify_only: true };
@@ -467,7 +495,19 @@ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__dirname
467
495
 
468
496
  if (isMain) {
469
497
  const args = process.argv.slice(2);
470
- const repoRoot = resolve(__dirname, '../..');
498
+ // SEED-2 (d3-ingest-002) — make the CLI's repoRoot overridable so callers
499
+ // (notably dogfood-swarm's commands/persist.js execSync, and any test
500
+ // harness) can redirect every record write + index rebuild into a sandbox
501
+ // instead of the REAL working tree. Without this the only way to sandbox was
502
+ // a brittle source-copy of this file (the setupTempRunJs run.js-copy in
503
+ // d1b-001-cli-toplevel-error-event.test.js) that rewrote __dirname's `../..`
504
+ // walk. A production caller passes the real root explicitly; a test passes a
505
+ // temp dir; the default preserves the historical behavior when the env var
506
+ // is unset. resolve() makes a relative override absolute so the downstream
507
+ // join()s stay anchored.
508
+ const repoRoot = process.env.INGEST_REPO_ROOT
509
+ ? resolve(process.env.INGEST_REPO_ROOT)
510
+ : resolve(__dirname, '../..');
471
511
 
472
512
  // Parse CLI flags
473
513
  let submissionJson;
@@ -476,9 +516,28 @@ if (isMain) {
476
516
  const positionalArgs = [];
477
517
 
478
518
  for (let i = 0; i < args.length; i++) {
479
- if (args[i] === '--provenance' && args[i + 1]) {
480
- provenanceMode = args[++i];
481
- } else if (args[i] === '--file' && args[i + 1]) {
519
+ // Accept BOTH the space form (`--flag value`) and the equals form
520
+ // (`--flag=value`). The prior parser matched only the space form, so a
521
+ // caller passing `--provenance=stub --file=...` (the shape dogfood-swarm's
522
+ // commands/persist.js builds for its execSync invocation) fell through to
523
+ // positionalArgs — `--provenance` then read as missing and the CLI exited 2
524
+ // ("--provenance flag is required"), silently breaking `swarm persist
525
+ // --ingest` on every platform. (dogfood-swarm self-audit follow-up.)
526
+ let arg = args[i];
527
+ let inlineValue = null;
528
+ if (arg.startsWith('--')) {
529
+ const eq = arg.indexOf('=');
530
+ if (eq !== -1) {
531
+ inlineValue = arg.slice(eq + 1);
532
+ arg = arg.slice(0, eq);
533
+ }
534
+ }
535
+ const hasValue = inlineValue !== null || args[i + 1] !== undefined;
536
+ const takeValue = () => (inlineValue !== null ? inlineValue : args[++i]);
537
+
538
+ if (arg === '--provenance' && hasValue) {
539
+ provenanceMode = takeValue();
540
+ } else if (arg === '--file' && hasValue) {
482
541
  const { readFileSync } = await import('node:fs');
483
542
  // D1B-001 family (operator-legibility): a --file read failure
484
543
  // (ENOENT/EACCES) routes through the structured error event and exits 2
@@ -488,7 +547,7 @@ if (isMain) {
488
547
  // correlation id here (the same pivot the JSON.parse catch uses when
489
548
  // there is no submission to derive a run_id from yet).
490
549
  try {
491
- submissionJson = readFileSync(resolve(args[++i]), 'utf-8');
550
+ submissionJson = readFileSync(resolve(takeValue()), 'utf-8');
492
551
  } catch (err) {
493
552
  emitCliErrorEvent({
494
553
  failedStage: 'cli_read_file',
@@ -498,9 +557,9 @@ if (isMain) {
498
557
  });
499
558
  process.exit(2);
500
559
  }
501
- } else if (args[i] === '--payload' && args[i + 1]) {
502
- submissionJson = args[++i];
503
- } else if (args[i] === '--verify-only') {
560
+ } else if (arg === '--payload' && hasValue) {
561
+ submissionJson = takeValue();
562
+ } else if (arg === '--verify-only') {
504
563
  // F-252714-058: dry-run the pipeline without writing or rebuilding
505
564
  // indexes. CI / operators preview what WOULD have been persisted.
506
565
  verifyOnlyFlag = true;
@@ -625,7 +684,10 @@ if (isMain) {
625
684
  status: result.record.verification.status,
626
685
  run_id: result.record.run_id ?? null,
627
686
  verdict: result.record.overall_verdict?.verified ?? null,
628
- would_persist_to: result.would_persist_to,
687
+ // d3-ingest-003: posixify path-shaped CLI output so the operator
688
+ // contract is identical across OSes (a Windows backslash here breaks
689
+ // any downstream URL-build/string-match).
690
+ would_persist_to: posixifyPath(result.would_persist_to),
629
691
  verify_only: true,
630
692
  rejection_reasons: result.record.verification.rejection_reasons ?? []
631
693
  }));
@@ -648,7 +710,9 @@ if (isMain) {
648
710
  status: result.record.verification.status,
649
711
  run_id: result.record.run_id ?? null,
650
712
  verdict: result.record.overall_verdict?.verified ?? null,
651
- path: result.path,
713
+ // d3-ingest-003: posixify path-shaped CLI output (same family as
714
+ // would_persist_to above). dogfood-swarm's persist.js pivots on this.
715
+ path: posixifyPath(result.path),
652
716
  written: result.written,
653
717
  rejection_reasons: result.record.verification.rejection_reasons ?? []
654
718
  }));