@dogfood-lab/findings 1.4.0 → 1.6.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 CHANGED
@@ -22,7 +22,8 @@ import {
22
22
  loadFindings,
23
23
  findById,
24
24
  filterFindings,
25
- findDuplicates
25
+ findDuplicates,
26
+ matchesText
26
27
  } from './index.js';
27
28
  import {
28
29
  deriveFromRecord,
@@ -31,10 +32,15 @@ import {
31
32
  getRuleById,
32
33
  dedupeAgainstExisting,
33
34
  loadRecordById,
34
- loadRecordsForRepo,
35
- loadAllRecords,
36
35
  writeFindings
37
36
  } from './derive/index.js';
37
+ // FIND-PROAC-002 — the *WithSkips loaders are not re-exported by derive/index.js
38
+ // (it only re-exports the legacy array-shape variants); import them from the
39
+ // concrete module via the package's ./derive/* subpath export.
40
+ import {
41
+ loadRecordsForRepoWithSkips,
42
+ loadAllRecordsWithSkips
43
+ } from './derive/load-records.js';
38
44
  import {
39
45
  performAction,
40
46
  performMerge,
@@ -74,7 +80,12 @@ import {
74
80
  } from './advise/index.js';
75
81
 
76
82
  const __dirname = dirname(fileURLToPath(import.meta.url));
77
- const ROOT = resolve(__dirname, '../..');
83
+ // FINDINGS_REPO_ROOT lets tests (and out-of-tree operators) point the CLI at an
84
+ // alternate data root without touching the real testing-os tree. Mirrors
85
+ // verify/cli.js VERIFY_REPO_ROOT. Defaults to the monorepo root.
86
+ const ROOT = process.env.FINDINGS_REPO_ROOT
87
+ ? resolve(process.env.FINDINGS_REPO_ROOT)
88
+ : resolve(__dirname, '../..');
78
89
 
79
90
  function parseArgs(argv) {
80
91
  const args = argv.slice(2);
@@ -175,6 +186,36 @@ function formatFindingDetail(f, rootDir) {
175
186
  return lines.filter(l => l !== null).join('\n');
176
187
  }
177
188
 
189
+ /**
190
+ * Emit a value as pure JSON on stdout and exit 0.
191
+ *
192
+ * The read verbs' --json contract mirrors advise/sync-export: stdout is a single
193
+ * JSON document with NO human preamble or trailing log line, so downstream tools
194
+ * (shipcheck, repo-knowledge) can pipe it without scraping the text formatter.
195
+ */
196
+ function emitJson(value) {
197
+ console.log(JSON.stringify(value, null, 2));
198
+ process.exit(0);
199
+ }
200
+
201
+ /**
202
+ * Identity projection for a loaded finding entry.
203
+ *
204
+ * `loadFindings`/`findById` wrap each finding as { path, data, valid, errors }.
205
+ * The --json surface exposes the underlying finding object plus the loader's
206
+ * validity verdict and a repo-relative path, dropping the absolute `path` so the
207
+ * emitted JSON is stable across machines/checkouts. A null `data` (torn file)
208
+ * is preserved so a consumer sees the same skip the human stream surfaces.
209
+ */
210
+ function buildFindingJSON(f, rootDir) {
211
+ return {
212
+ finding: f.data,
213
+ valid: f.valid,
214
+ errors: f.errors || [],
215
+ path: relative(rootDir, f.path)
216
+ };
217
+ }
218
+
178
219
  /**
179
220
  * F2-INTEL-001 — dispatch a synthesis-artifact review subcommand.
180
221
  *
@@ -240,8 +281,8 @@ async function main() {
240
281
  console.log(`dogfood findings — finding contract spine + derivation engine
241
282
 
242
283
  Commands:
243
- list List all findings
244
- show <finding_id> Show a single finding in detail
284
+ list List all findings (--json, --grep/--text <term>)
285
+ show <finding_id> Show a single finding in detail (--json)
245
286
  validate Validate all findings (or --file <path> for one)
246
287
  validate --all Validate all findings + all fixtures
247
288
  derive Derive candidate findings from records
@@ -254,8 +295,8 @@ Commands:
254
295
  merge <ids...> Merge findings (--into <id>, --actor, --reason)
255
296
  reopen <id> Reopen a rejected/accepted finding (--actor, --reason)
256
297
  invalidate <id> Invalidate an accepted finding (--actor, --reason)
257
- history <id> Show review history for a finding
258
- queue Show review queue
298
+ history <id> Show review history for a finding (--json)
299
+ queue Show review queue (--json)
259
300
 
260
301
  Synthesis artifacts (patterns / recommendations / doctrine):
261
302
  patterns derive [--write] Derive patterns from accepted findings
@@ -279,6 +320,14 @@ Synthesis artifacts (patterns / recommendations / doctrine):
279
320
  doctrine <accept|reject> <id> [--actor X] [--reason Y]
280
321
  doctrine queue
281
322
 
323
+ Advice (read-only, derived from accepted artifacts):
324
+ advise --surface <surface> [--execution-mode <mode>] [--repo <org/repo>] [--json]
325
+ Bootstrap guidance for a new repo/surface.
326
+ --json emits the structured advice bundle
327
+ as pure JSON (pipeable; no human text on
328
+ stdout) for shipcheck / repo-knowledge.
329
+ sync-export [--json] Export accepted artifacts for downstream sync.
330
+
282
331
  Note: patterns carry a literal "invalidated" status; recommendations and
283
332
  doctrine do not, so invalidate is supported for patterns only. The review /
284
333
  reopen verbs target the intermediate "reviewed" state, which the artifact
@@ -302,7 +351,16 @@ Filters (for list):
302
351
  --surface <cli|desktop|web|api|mcp-server|npm-package|plugin|library>
303
352
  --issue-kind <kind>
304
353
  --transfer-scope <scope>
305
- --include-fixtures Also list fixture findings`);
354
+ --grep / --text <term> Case-insensitive substring (literal, not regex) over
355
+ title / summary / doctrine_statement. ANDs with the
356
+ exact-enum filters above. Also accepted by
357
+ patterns/recommendations/doctrine list.
358
+ --include-fixtures Also list fixture findings
359
+
360
+ Structured output:
361
+ list, show, history, queue, and patterns/recommendations/doctrine list|show
362
+ accept --json to emit pure JSON (the loaded object(s)) on stdout, mirroring
363
+ advise --json / sync-export --json. Default human text output is unchanged.`);
306
364
  process.exit(0);
307
365
  }
308
366
 
@@ -320,9 +378,17 @@ Filters (for list):
320
378
  if (flags.surface) filters.surface = flags.surface;
321
379
  if (flags['issue-kind']) filters.issueKind = flags['issue-kind'];
322
380
  if (flags['transfer-scope']) filters.transferScope = flags['transfer-scope'];
381
+ // --grep / --text: free-text substring filter (case-insensitive) over the
382
+ // human-facing prose fields. ANDs with the exact-enum filters above.
383
+ const text = flags.grep || flags.text;
384
+ if (typeof text === 'string' && text.length > 0) filters.text = text;
323
385
 
324
386
  const filtered = filterFindings(allFindings, filters);
325
387
 
388
+ if (flags.json) {
389
+ emitJson(filtered.map(f => buildFindingJSON(f, ROOT)));
390
+ }
391
+
326
392
  if (filtered.length === 0) {
327
393
  console.log('No findings found.');
328
394
  process.exit(0);
@@ -349,6 +415,10 @@ Filters (for list):
349
415
  process.exit(1);
350
416
  }
351
417
 
418
+ if (flags.json) {
419
+ emitJson(buildFindingJSON(result, ROOT));
420
+ }
421
+
352
422
  console.log(formatFindingDetail(result, ROOT));
353
423
  process.exit(0);
354
424
  }
@@ -376,11 +446,22 @@ Filters (for list):
376
446
  }
377
447
  }
378
448
 
379
- // Validate all findings + optionally fixtures
449
+ // Validate all findings + optionally fixtures.
450
+ // Verdict-first (D-OUT-001): lead with a one-line VALIDATION header so the
451
+ // operator knows the scope before the per-file stream scrolls, and close
452
+ // with a prominent VERDICT line — matching every other verdict surface in
453
+ // the repo (swarm findings-digest, swarm status, verify --explain).
380
454
  let failed = 0;
381
455
  let passed = 0;
382
456
 
383
457
  const realFindings = loadFindings(ROOT);
458
+ const fixtureCount = all
459
+ ? loadFindings(ROOT, { fixtures: true, fixtureKind: 'valid' }).length +
460
+ loadFindings(ROOT, { fixtures: true, fixtureKind: 'invalid' }).length
461
+ : 0;
462
+ const checkedCount = realFindings.length + fixtureCount;
463
+ console.log(`VALIDATION: ${checkedCount} findings checked`);
464
+
384
465
  for (const f of realFindings) {
385
466
  if (f.valid) {
386
467
  console.log(`PASS: ${relative(ROOT, f.path)}`);
@@ -434,6 +515,7 @@ Filters (for list):
434
515
  }
435
516
 
436
517
  console.log(`\n${passed} passed, ${failed} failed`);
518
+ console.log(failed > 0 ? `VERDICT: FAIL (${failed} failed)` : 'VERDICT: PASS');
437
519
  process.exit(failed > 0 ? 1 : 0);
438
520
  }
439
521
 
@@ -443,8 +525,13 @@ Filters (for list):
443
525
  const all = flags.all;
444
526
  const write = flags.write;
445
527
 
446
- // Load records based on scope
528
+ // Load records based on scope. FIND-PROAC-002 — use the *WithSkips loaders
529
+ // so torn/unreadable record files surface as a structured skip instead of
530
+ // silently shrinking the dataset. A derive run over a partially-readable
531
+ // tree is degraded, not clean: it is reported as such (named files + a
532
+ // prominent skip-count line on stderr) and exits non-zero below.
447
533
  let entries = [];
534
+ let skippedRecords = [];
448
535
  if (recordId) {
449
536
  const entry = loadRecordById(ROOT, recordId);
450
537
  if (!entry) {
@@ -453,14 +540,18 @@ Filters (for list):
453
540
  }
454
541
  entries = [entry];
455
542
  } else if (repoKey) {
456
- entries = loadRecordsForRepo(ROOT, repoKey);
457
- if (entries.length === 0) {
543
+ const loaded = loadRecordsForRepoWithSkips(ROOT, repoKey);
544
+ entries = loaded.entries;
545
+ skippedRecords = loaded.skipped;
546
+ if (entries.length === 0 && skippedRecords.length === 0) {
458
547
  console.error(`No records found for repo: ${repoKey}`);
459
548
  process.exit(1);
460
549
  }
461
550
  } else if (all) {
462
- entries = loadAllRecords(ROOT);
463
- if (entries.length === 0) {
551
+ const loaded = loadAllRecordsWithSkips(ROOT);
552
+ entries = loaded.entries;
553
+ skippedRecords = loaded.skipped;
554
+ if (entries.length === 0 && skippedRecords.length === 0) {
464
555
  console.error('No records found.');
465
556
  process.exit(1);
466
557
  }
@@ -469,6 +560,20 @@ Filters (for list):
469
560
  process.exit(2);
470
561
  }
471
562
 
563
+ // FIND-PROAC-002 — surface torn/unreadable records loudly and refuse to
564
+ // report a clean run. Mirrors the ruleErrors block below (and the
565
+ // synthesis derive skip branches): name each file so it appears in CI logs,
566
+ // print a prominent count, exit non-zero. A torn record means the operator
567
+ // is deriving from an incomplete dataset — that must never look green.
568
+ if (skippedRecords.length > 0) {
569
+ console.error(`${skippedRecords.length} record(s) skipped (torn/unreadable):`);
570
+ for (const s of skippedRecords) {
571
+ console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
572
+ }
573
+ console.error('Refusing to report a clean derive run over an incomplete record set. Fix or remove the torn record(s) above and re-run.');
574
+ process.exit(1);
575
+ }
576
+
472
577
  // Derive
473
578
  const { candidates, ruleErrors, stats } = deriveFromRecords(entries);
474
579
 
@@ -703,6 +808,9 @@ Filters (for list):
703
808
  process.exit(2);
704
809
  }
705
810
  const events = getEventsForFinding(ROOT, findingId);
811
+ if (flags.json) {
812
+ emitJson(events);
813
+ }
706
814
  if (events.length === 0) {
707
815
  console.log(`No review history for: ${findingId}`);
708
816
  process.exit(0);
@@ -723,6 +831,13 @@ Filters (for list):
723
831
 
724
832
  if (command === 'queue') {
725
833
  const queue = getReviewQueue(ROOT);
834
+ if (flags.json) {
835
+ emitJson(queue.map(item => ({
836
+ finding: item.data,
837
+ queue_reason: item.queueReason,
838
+ path: item.path ? relative(ROOT, item.path) : undefined
839
+ })));
840
+ }
726
841
  if (queue.length === 0) {
727
842
  console.log('Review queue is empty.');
728
843
  process.exit(0);
@@ -813,7 +928,12 @@ Filters (for list):
813
928
  }
814
929
 
815
930
  if (sub === 'list') {
816
- const patterns = loadPatterns(ROOT);
931
+ let patterns = loadPatterns(ROOT);
932
+ const text = flags.grep || flags.text;
933
+ if (typeof text === 'string' && text.length > 0) {
934
+ patterns = patterns.filter(p => matchesText(text, [p.title, p.summary]));
935
+ }
936
+ if (flags.json) emitJson(patterns);
817
937
  if (patterns.length === 0) { console.log('No patterns found.'); process.exit(0); }
818
938
  for (const p of patterns) {
819
939
  console.log(`[${p.status}] ${p.pattern_id} (${p.pattern_strength || 'unknown'})`);
@@ -831,6 +951,8 @@ Filters (for list):
831
951
  const p = all.find(x => x.pattern_id === id);
832
952
  if (!p) { console.error(`Pattern not found: ${id}`); process.exit(1); }
833
953
 
954
+ if (flags.json) emitJson(p);
955
+
834
956
  console.log(`Pattern: ${p.pattern_id}`);
835
957
  console.log(`Title: ${p.title}`);
836
958
  console.log(`Status: ${p.status}`);
@@ -955,7 +1077,12 @@ Filters (for list):
955
1077
  }
956
1078
 
957
1079
  if (sub === 'list') {
958
- const recs = loadRecommendations(ROOT);
1080
+ let recs = loadRecommendations(ROOT);
1081
+ const text = flags.grep || flags.text;
1082
+ if (typeof text === 'string' && text.length > 0) {
1083
+ recs = recs.filter(r => matchesText(text, [r.title, r.summary]));
1084
+ }
1085
+ if (flags.json) emitJson(recs);
959
1086
  if (recs.length === 0) { console.log('No recommendations found.'); process.exit(0); }
960
1087
  for (const r of recs) {
961
1088
  console.log(`[${r.status}] ${r.recommendation_id}`);
@@ -971,6 +1098,7 @@ Filters (for list):
971
1098
  const all = loadRecommendations(ROOT);
972
1099
  const r = all.find(x => x.recommendation_id === id);
973
1100
  if (!r) { console.error(`Recommendation not found: ${id}`); process.exit(1); }
1101
+ if (flags.json) emitJson(r);
974
1102
  console.log(`Recommendation: ${r.recommendation_id}`);
975
1103
  console.log(`Title: ${r.title}`);
976
1104
  console.log(`Status: ${r.status}`);
@@ -1048,7 +1176,12 @@ Filters (for list):
1048
1176
  }
1049
1177
 
1050
1178
  if (sub === 'list') {
1051
- const docs = loadDoctrines(ROOT);
1179
+ let docs = loadDoctrines(ROOT);
1180
+ const text = flags.grep || flags.text;
1181
+ if (typeof text === 'string' && text.length > 0) {
1182
+ docs = docs.filter(d => matchesText(text, [d.title, d.statement, d.summary]));
1183
+ }
1184
+ if (flags.json) emitJson(docs);
1052
1185
  if (docs.length === 0) { console.log('No doctrine found.'); process.exit(0); }
1053
1186
  for (const d of docs) {
1054
1187
  console.log(`[${d.status}] ${d.doctrine_id} [${d.strength}]`);
@@ -1064,6 +1197,7 @@ Filters (for list):
1064
1197
  const all = loadDoctrines(ROOT);
1065
1198
  const d = all.find(x => x.doctrine_id === id);
1066
1199
  if (!d) { console.error(`Doctrine not found: ${id}`); process.exit(1); }
1200
+ if (flags.json) emitJson(d);
1067
1201
  console.log(`Doctrine: ${d.doctrine_id}`);
1068
1202
  console.log(`Title: ${d.title}`);
1069
1203
  console.log(`Status: ${d.status}`);
@@ -1095,6 +1229,14 @@ Filters (for list):
1095
1229
  const bundle = generateAdviceBundle(ROOT, { surface, executionMode, repo });
1096
1230
  const a = bundle.advice;
1097
1231
 
1232
+ // --json keeps stdout pure JSON so downstream tools (shipcheck,
1233
+ // repo-knowledge) can pipe the structured bundle without scraping the
1234
+ // human formatter. Matches the sync-export verb's flags.json idiom below.
1235
+ if (flags.json) {
1236
+ console.log(JSON.stringify(bundle, null, 2));
1237
+ process.exit(0);
1238
+ }
1239
+
1098
1240
  console.log(`Advice for: ${[surface, executionMode, repo].filter(Boolean).join(', ') || 'general'}\n`);
1099
1241
 
1100
1242
  if (a.starter_checks.length > 0) {
package/derive/ids.js CHANGED
@@ -5,17 +5,33 @@
5
5
  * No timestamp noise in IDs.
6
6
  */
7
7
 
8
+ import { createHash } from 'node:crypto';
9
+
10
+ /**
11
+ * Delimiter for the boundary hash: a NUL code unit cannot appear in a repo
12
+ * slug (`[a-zA-Z0-9_.-]`) nor in a derived lesson slug, so joining components
13
+ * with it means no component value can forge a false boundary between them.
14
+ */
15
+ const BOUNDARY_DELIM = '\u0000';
16
+
8
17
  /**
9
18
  * Generate a stable finding ID from derivation context.
10
- * Format: dfind-<repo-slug>-<lesson-slug>
19
+ * Format: dfind-<repo-slug>-<lesson-slug>-<boundary-hash>
20
+ *
21
+ * The trailing boundary hash disambiguates inputs whose component boundary is
22
+ * itself an underscore (findings-A-002): `sanitize` folds `_` to `-`, so
23
+ * (repoSlug='a_b', lessonSlug='c') and (repoSlug='a', lessonSlug='b_c') both
24
+ * flatten the human-readable middle to `a-b-c` and previously produced the
25
+ * same id. The hash is computed over the components joined by BOUNDARY_DELIM,
26
+ * so the two tuples hash differently and never collapse to one finding_id.
11
27
  *
12
28
  * @param {string} repoSlug - e.g. "repo-crawler-mcp"
13
29
  * @param {string} lessonSlug - e.g. "surface-misclassification"
14
30
  * @returns {string}
15
31
  */
16
32
  export function generateFindingId(repoSlug, lessonSlug) {
17
- const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
18
- return normalized;
33
+ const boundary = boundaryHash([repoSlug, lessonSlug]);
34
+ return `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}-${boundary}`;
19
35
  }
20
36
 
21
37
  /**
@@ -46,3 +62,15 @@ function sanitize(s) {
46
62
  .replace(/-+/g, '-')
47
63
  .replace(/^-|-$/g, '');
48
64
  }
65
+
66
+ /**
67
+ * Short stable hash of an ordered component tuple, joined by BOUNDARY_DELIM so
68
+ * no component value can forge a false boundary. 8 lowercase-hex chars are
69
+ * ample for the finding/pattern id space (a per-repo, per-lesson namespace).
70
+ *
71
+ * @param {string[]} components
72
+ * @returns {string}
73
+ */
74
+ export function boundaryHash(components) {
75
+ return createHash('sha256').update(components.join(BOUNDARY_DELIM)).digest('hex').slice(0, 8);
76
+ }
@@ -34,6 +34,7 @@ import { mkdirSync, existsSync } from 'node:fs';
34
34
  import { resolve, dirname } from 'node:path';
35
35
  import yaml from 'js-yaml';
36
36
 
37
+ import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
37
38
  import { atomicWriteFileSync } from '../lib/atomic-write.js';
38
39
  import { validateFinding } from '../validate.js';
39
40
 
@@ -75,6 +76,26 @@ export class FindingIdCollisionError extends Error {
75
76
  }
76
77
  }
77
78
 
79
+ /**
80
+ * Structured error thrown when a finding's `repo` splits into an org/repo
81
+ * segment that is a path-traversal vector (`..` or a path separator).
82
+ *
83
+ * findings-A-001 — write-side traversal guard. The READ side
84
+ * (`loadRecordsForRepoWithSkips`) already rejects such segments via
85
+ * `isUnsafeSegment`; the WRITE side did not, so a schema-valid
86
+ * `repo: '../policies'` (the dogfood-finding `repo` pattern admits `.`/`..`)
87
+ * resolved one level under rootDir and wrote outside `findings/`.
88
+ */
89
+ export class FindingUnsafeRepoError extends Error {
90
+ constructor(repo, findingId) {
91
+ super(`unsafe repo path segment in finding${findingId ? ` (${findingId})` : ''}: '${repo}' contains a path-traversal or separator and was refused before any write (findings-A-001).`);
92
+ this.name = 'FindingUnsafeRepoError';
93
+ this.code = 'FINDING_UNSAFE_REPO';
94
+ this.repo = repo;
95
+ this.findingId = findingId;
96
+ }
97
+ }
98
+
78
99
  /**
79
100
  * Process-level memory of ids that have already been written via
80
101
  * `writeFinding` (singleton) OR the singleton-call inside `writeFindings`
@@ -127,6 +148,13 @@ export function writeFinding(rootDir, finding) {
127
148
  const [org, repo] = (finding.repo || '').split('/');
128
149
  if (!org || !repo) throw new Error(`Invalid repo in finding: ${finding.repo}`);
129
150
 
151
+ // findings-A-001 — write-side path-traversal guard. Mirrors the READ side
152
+ // (load-records.js loadRecordsForRepoWithSkips) so a schema-valid
153
+ // `repo: '../policies'` cannot escape `findings/` into a sibling data dir.
154
+ if (isUnsafeSegment(org) || isUnsafeSegment(repo)) {
155
+ throw new FindingUnsafeRepoError(finding.repo, finding.finding_id);
156
+ }
157
+
130
158
  // L3-001 (Wave A2 amend2): same-process same-id refusal. Programmatic
131
159
  // callers that loop over assembleFinding output now fail-closed at the
132
160
  // singleton path, not silently at the atomicWriteFileSync.
package/index.js CHANGED
@@ -8,4 +8,4 @@
8
8
  */
9
9
 
10
10
  export { parseFinding, validateFinding, validateFindingFile } from './validate.js';
11
- export { discoverFindings, discoverFixtures, loadFindings, findById, filterFindings, findDuplicates } from './reader.js';
11
+ export { discoverFindings, discoverFixtures, loadFindings, findById, filterFindings, findDuplicates, matchesText } from './reader.js';
package/lib/file-lock.js CHANGED
@@ -291,14 +291,15 @@ function sleepSync(ms) {
291
291
 
292
292
  /**
293
293
  * Run `fn` while holding an exclusive lock on `targetPath`. The lock is a
294
- * sibling directory at `<targetPath>.lock` see file header for the
295
- * full design rationale.
294
+ * sibling lock FILE at `<targetPath>.lock`, created via `O_EXCL` / `linkSync`
295
+ * compare-and-swap — see file header for the full design rationale (and why a
296
+ * lock file beats the earlier lock-directory design).
296
297
  *
297
298
  * The lock is per-target, so two unrelated `appendEvent` calls writing to
298
299
  * different daily log files do NOT serialize against each other.
299
300
  *
300
301
  * @template T
301
- * @param {string} targetPath - The file being mutated; the lock dir is `<targetPath>.lock`.
302
+ * @param {string} targetPath - The file being mutated; the lock file is `<targetPath>.lock`.
302
303
  * @param {() => T} fn - The critical section. Runs synchronously.
303
304
  * @param {{
304
305
  * timeoutMs?: number,
@@ -41,6 +41,19 @@ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
41
41
  import { join, extname } from 'node:path';
42
42
  import yaml from 'js-yaml';
43
43
 
44
+ /**
45
+ * Generous defensive size cap for a single YAML/JSON document (FIND-PROAC-003).
46
+ *
47
+ * The contract files in this repo (records, findings, patterns, policies) are
48
+ * kilobytes, not megabytes — a multi-MB file is either corrupt or hostile, and
49
+ * handing it to an unbounded `yaml.load` is a silent resource cliff for every
50
+ * loader in the silent-loader family that delegates here. 8 MB is far above any
51
+ * legitimate contract document while still bounding a pathological parse. A file
52
+ * over the cap degrades to a structured skip (the same `{ data, error }` shape
53
+ * as a parse error) instead of risking the process.
54
+ */
55
+ export const MAX_YAML_BYTES = 8 * 1024 * 1024;
56
+
44
57
  /**
45
58
  * Parse a YAML file. Returns `{ data, error }` — never throws for parse or
46
59
  * read failures.
@@ -49,6 +62,21 @@ import yaml from 'js-yaml';
49
62
  * @returns {{ data: unknown, error: string | null }}
50
63
  */
51
64
  export function loadYamlFile(filePath) {
65
+ // FIND-PROAC-003 — bound the parse before reading. A multi-MB YAML file is
66
+ // corrupt or hostile; degrade to a structured skip naming the size rather
67
+ // than driving an unbounded yaml.load. `stat` failures fall through to the
68
+ // read path so a missing file still reports the familiar "Read error: …".
69
+ try {
70
+ const { size } = statSync(filePath);
71
+ if (size > MAX_YAML_BYTES) {
72
+ return {
73
+ data: null,
74
+ error: `YAML too large: ${size} bytes exceeds ${MAX_YAML_BYTES}-byte cap (corrupt or hostile file — skipped)`
75
+ };
76
+ }
77
+ } catch {
78
+ // Defer to readFileSync for the structured error.
79
+ }
52
80
  let raw;
53
81
  try {
54
82
  raw = readFileSync(filePath, 'utf-8');
@@ -71,6 +99,20 @@ export function loadYamlFile(filePath) {
71
99
  * @returns {{ data: unknown, error: string | null }}
72
100
  */
73
101
  export function loadJsonFile(filePath) {
102
+ // FIND-PROAC-003 — same defensive size cap as loadYamlFile. Record files are
103
+ // JSON and travel through here; an over-cap file degrades to a structured skip
104
+ // naming the size instead of an unbounded JSON.parse.
105
+ try {
106
+ const { size } = statSync(filePath);
107
+ if (size > MAX_YAML_BYTES) {
108
+ return {
109
+ data: null,
110
+ error: `JSON too large: ${size} bytes exceeds ${MAX_YAML_BYTES}-byte cap (corrupt or hostile file — skipped)`
111
+ };
112
+ }
113
+ } catch {
114
+ // Defer to readFileSync for the structured error.
115
+ }
74
116
  let raw;
75
117
  try {
76
118
  raw = readFileSync(filePath, 'utf-8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/findings",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
package/reader.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readdirSync, existsSync, statSync } from 'node:fs';
7
- import { resolve, join, basename, extname } from 'node:path';
7
+ import { resolve, join, basename, extname, relative } from 'node:path';
8
8
  import { parseFinding, validateFinding } from './validate.js';
9
9
 
10
10
  /**
@@ -129,9 +129,21 @@ export function loadFindings(rootDir, opts = {}) {
129
129
  * @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
130
130
  */
131
131
  export function findById(rootDir, findingId) {
132
+ // B-002 — collect torn finding files encountered during the scan. The
133
+ // finding_id lives INSIDE the file, so a torn file cannot be matched to the
134
+ // requested id; if the id turns out absent from every clean file, any torn
135
+ // file may be the one hiding it. Surfacing them on stderr (below) mirrors the
136
+ // derive CLI's "N finding(s) skipped (torn/unreadable)" honesty so a torn
137
+ // YAML never silently masquerades as a not-found id.
138
+ const torn = [];
139
+
132
140
  // Search real findings
133
141
  for (const filePath of discoverFindings(rootDir)) {
134
- const { data } = parseFinding(filePath);
142
+ const { data, error } = parseFinding(filePath);
143
+ if (error) {
144
+ torn.push({ path: filePath, error });
145
+ continue;
146
+ }
135
147
  if (data && data.finding_id === findingId) {
136
148
  const result = validateFinding(data);
137
149
  return { path: filePath, data, ...result };
@@ -140,21 +152,59 @@ export function findById(rootDir, findingId) {
140
152
 
141
153
  // Search valid fixtures
142
154
  for (const filePath of discoverFixtures(rootDir, 'valid')) {
143
- const { data } = parseFinding(filePath);
155
+ const { data, error } = parseFinding(filePath);
156
+ if (error) {
157
+ torn.push({ path: filePath, error });
158
+ continue;
159
+ }
144
160
  if (data && data.finding_id === findingId) {
145
161
  const result = validateFinding(data);
146
162
  return { path: filePath, data, ...result };
147
163
  }
148
164
  }
149
165
 
166
+ if (torn.length > 0) {
167
+ // eslint-disable-next-line no-console
168
+ console.error(`${torn.length} finding(s) skipped (torn/unreadable):`);
169
+ for (const t of torn) {
170
+ // eslint-disable-next-line no-console
171
+ console.error(` ${relative(rootDir, t.path)} — ${t.error}`);
172
+ }
173
+ }
174
+
150
175
  return null;
151
176
  }
152
177
 
178
+ /**
179
+ * Case-insensitive substring match of `needle` against any of `haystacks`.
180
+ *
181
+ * Plain substring, not regex: a free-text `--grep` over operator-authored prose
182
+ * should treat `c++`, `*.yaml`, or `node --test` as literal text, not as a
183
+ * pattern that throws or matches surprisingly. Non-string haystacks (a missing
184
+ * optional field) are skipped rather than coerced.
185
+ *
186
+ * @param {string} needle - The search term (already known non-empty by caller).
187
+ * @param {Array<unknown>} haystacks - Candidate text fields.
188
+ * @returns {boolean}
189
+ */
190
+ export function matchesText(needle, haystacks) {
191
+ const q = needle.toLowerCase();
192
+ for (const h of haystacks) {
193
+ if (typeof h === 'string' && h.toLowerCase().includes(q)) return true;
194
+ }
195
+ return false;
196
+ }
197
+
153
198
  /**
154
199
  * Filter a list of loaded findings.
155
200
  *
201
+ * Exact-enum filters (repo/status/surface/issueKind/transferScope) and the
202
+ * free-text `text` filter combine with AND semantics: a finding must satisfy
203
+ * every supplied filter to pass. `text` is a case-insensitive substring match
204
+ * over the human-facing prose fields (title, summary, doctrine_statement).
205
+ *
156
206
  * @param {Array<{ data: object }>} findings - Loaded findings.
157
- * @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
207
+ * @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string, text?: string }} filters
158
208
  * @returns {Array}
159
209
  */
160
210
  export function filterFindings(findings, filters = {}) {
@@ -165,6 +215,9 @@ export function filterFindings(findings, filters = {}) {
165
215
  if (filters.surface && f.data.product_surface !== filters.surface) return false;
166
216
  if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
167
217
  if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
218
+ if (filters.text && !matchesText(filters.text, [f.data.title, f.data.summary, f.data.doctrine_statement])) {
219
+ return false;
220
+ }
168
221
  return true;
169
222
  });
170
223
  }
@@ -1,27 +1,41 @@
1
1
  /**
2
2
  * Append-only review event log.
3
3
  *
4
- * Events are stored as YAML arrays in reviews/<YYYY>/<date>-finding-review-log.yaml
4
+ * Events are stored ONE PER FILE under reviews/<YYYY>/<YYYY-MM-DD>/<event-id>.yaml
5
+ * (the same sharded-immutable-file pattern the records/ store uses). This
6
+ * replaced an earlier "daily YAML array, read-modify-rename under a file lock"
7
+ * design: a Phase-9 50-fork race detector proved that NO lock FILE is reliably
8
+ * exclusive on NTFS under heavy concurrent create churn — two appenders could
9
+ * both win the lock, both read N events, both rename, and one clobber the
10
+ * other's event (~1/3 of saturated runs, every child still exiting 0). A
11
+ * shared mutable array file cannot be made safe under concurrency on that fs.
12
+ * One immutable file per event removes the shared mutable state entirely: each
13
+ * append writes a uniquely-named file, so concurrent appends CANNOT collide or
14
+ * lose an event, and no lock is needed for correctness. `getAllEventsWithSkips`
15
+ * already globs reviews/ recursively and merges per-file events.
5
16
  */
6
17
 
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
- import { resolve, dirname } from 'node:path';
18
+ import { mkdirSync, existsSync, openSync, writeSync, closeSync } from 'node:fs';
19
+ import { resolve } from 'node:path';
9
20
  import { randomBytes } from 'node:crypto';
10
21
  import yaml from 'js-yaml';
11
22
 
12
- import { withFileLock } from '../lib/file-lock.js';
13
- import { renameWithRetry } from '../lib/rename-with-retry.js';
14
23
  import { loadYamlDir } from '../lib/safe-yaml-load.js';
15
24
 
16
25
  let _eventCounter = 0;
17
26
 
18
27
  /**
19
- * Generate a unique event ID.
28
+ * Generate a unique event ID. Includes a random suffix so the id is unique
29
+ * ACROSS processes (the `_eventCounter` is per-process, so two forks would
30
+ * otherwise mint the same `rev-<ts>-<seq>`); this id also names the per-event
31
+ * file, so cross-process uniqueness keeps two concurrent appends from picking
32
+ * the same filename.
20
33
  */
21
34
  export function generateEventId() {
22
35
  const ts = Date.now().toString(36);
23
36
  const seq = (++_eventCounter).toString(36).padStart(4, '0');
24
- return `rev-${ts}-${seq}`;
37
+ const rand = randomBytes(4).toString('hex');
38
+ return `rev-${ts}-${seq}-${rand}`;
25
39
  }
26
40
 
27
41
  /**
@@ -71,82 +85,52 @@ export function getLogPath(rootDir, date = new Date()) {
71
85
  }
72
86
 
73
87
  /**
74
- * Append an event to the review log.
88
+ * Directory holding one date-sharded set of per-event files:
89
+ * reviews/<YYYY>/<YYYY-MM-DD>/. Each appended event is its own file inside.
90
+ */
91
+ export function getEventDir(rootDir, date = new Date()) {
92
+ const year = String(date.getFullYear());
93
+ const month = String(date.getMonth() + 1).padStart(2, '0');
94
+ const day = String(date.getDate()).padStart(2, '0');
95
+ return resolve(rootDir, 'reviews', year, `${year}-${month}-${day}`);
96
+ }
97
+
98
+ /**
99
+ * Append a review event by writing it to its OWN immutable file
100
+ * (reviews/<YYYY>/<YYYY-MM-DD>/<event-id>.yaml). See the module header for why
101
+ * this replaced the daily-array-under-a-lock design: no shared mutable file
102
+ * means concurrent appends physically cannot collide or lose an event, so no
103
+ * lock is needed for correctness — the Phase-9 50-fork race detector that lost
104
+ * an event ~1/3 of saturated runs is structurally impossible here.
75
105
  *
76
- * Atomicity: writes the new event list to a unique temp file then renames it
77
- * over the canonical log file. `rename` is atomic on POSIX and Windows, so a
78
- * concurrent reader sees either the old contents or the new contents — never
79
- * a half-written file. This also makes the operation crash-safe: a Ctrl+C
80
- * between read and write leaves the original log intact.
106
+ * Crash-safety: a single `openSync(path, 'wx')` + write of one event is atomic
107
+ * at the file granularity a crash mid-write leaves a complete event file or
108
+ * none, never a torn shared array. The filename is the (cross-process-unique)
109
+ * event id, so two concurrent appends never target the same path.
81
110
  *
82
- * Concurrency: serialized at the choke point via `withFileLock` on the daily
83
- * log file (F-PIPELINE-011 / W3-PIPE-001 Pattern #4 choke-point fix). The
84
- * read-then-write window is closed by holding a directory-mutex (`<logPath>.lock`)
85
- * across the read → push → rename sequence. Two concurrent `appendEvent` calls
86
- * to the SAME daily log serialize against each other; calls to DIFFERENT daily
87
- * logs (e.g. across a midnight boundary) do not contend. The lock is reclaimed
88
- * if the holder process dies — see `lib/file-lock.js` for the full design
89
- * rationale (why a lock dir, why not `O_APPEND`, stale recovery semantics,
90
- * single-machine scope).
111
+ * @param {string} rootDir
112
+ * @param {object} event - a `createEvent()` result.
113
+ * @returns {string} the path of the event file written.
91
114
  */
92
115
  export function appendEvent(rootDir, event) {
93
- const logPath = getLogPath(rootDir);
94
- const dir = dirname(logPath);
116
+ const dir = getEventDir(rootDir);
95
117
  mkdirSync(dir, { recursive: true });
96
118
 
97
- // FAILS-then-PASSES proof gate (W3-PIPE-001):
98
- // Set DISABLE_APPEND_LOCK=1 in the env to bypass the lock for the explicit
99
- // purpose of demonstrating the race-detection test fails without the fix.
100
- // Wave-30 receipt documents the proof: with the lock, the multi-process
101
- // test passes 50/50 forks across 3 iterations, 20 consecutive test runs.
102
- // With the lock disabled, the test reliably fails (rename collisions on
103
- // unprotected concurrent rebuilds, dropped events).
104
- if (process.env.DISABLE_APPEND_LOCK) {
105
- let events = [];
106
- if (existsSync(logPath)) {
107
- const raw = readFileSync(logPath, 'utf-8');
108
- events = yaml.load(raw) || [];
109
- if (!Array.isArray(events)) events = [events];
110
- }
111
- events.push(event);
112
- const tmpSuffix = randomBytes(4).toString('hex');
113
- const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
114
- writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
115
- // Windows EPERM/EBUSY on rename can fire transiently when AV or
116
- // Search Indexer holds a handle to the freshly written temp. Retry.
117
- renameWithRetry(tmpPath, logPath);
118
- return logPath;
119
+ const eventId = event.review_event_id || generateEventId();
120
+ const eventPath = resolve(dir, `${eventId}.yaml`);
121
+ const body = yaml.dump(event, { lineWidth: 120, noRefs: true });
122
+
123
+ // 'wx' = O_EXCL exclusive-create: asserts the unique id has not collided
124
+ // (it carries a random suffix, so it won't) rather than relying on it. No
125
+ // temp+rename is needed there is no shared file to atomically replace; one
126
+ // event per file is already all-or-nothing.
127
+ const fd = openSync(eventPath, 'wx');
128
+ try {
129
+ writeSync(fd, body);
130
+ } finally {
131
+ closeSync(fd);
119
132
  }
120
-
121
- return withFileLock(logPath, () => {
122
- let events = [];
123
- // Read-or-empty without an `existsSync` precheck: the readFileSync call
124
- // either returns the bytes or throws ENOENT. Avoiding `existsSync` here
125
- // closes a Windows-specific TOCTOU window where the dirent cache could
126
- // report `existsSync(logPath) === false` immediately after a sibling
127
- // process renamed a fresh file into place — which would cause us to
128
- // start with `events = []` and silently OVERWRITE the sibling's events.
129
- // The lock alone wasn't enough; the existsSync gate was the bug.
130
- try {
131
- const raw = readFileSync(logPath, 'utf-8');
132
- const parsed = yaml.load(raw);
133
- if (parsed) events = Array.isArray(parsed) ? parsed : [parsed];
134
- } catch (err) {
135
- if (!err || err.code !== 'ENOENT') throw err;
136
- }
137
-
138
- events.push(event);
139
-
140
- // Atomic write: temp file → rename. Same pattern persist.js + rebuild-indexes.js use.
141
- const tmpSuffix = randomBytes(4).toString('hex');
142
- const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
143
- writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
144
- // renameWithRetry: tolerate the Windows EPERM/EBUSY transient handle race
145
- // even though we hold the per-file lock — antivirus/Search Indexer can
146
- // still grab a handle on the temp during the rename window.
147
- renameWithRetry(tmpPath, logPath);
148
- return logPath;
149
- });
133
+ return eventPath;
150
134
  }
151
135
 
152
136
  /**
@@ -32,10 +32,12 @@
32
32
  * partial coverage, not a silent no-op.
33
33
  */
34
34
 
35
+ import { relative } from 'node:path';
36
+
35
37
  import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
36
38
  import { createEvent, appendEvent } from './event-log.js';
37
39
  import {
38
- resetSeenArtifactWrites,
40
+ resetSeenArtifactWrite,
39
41
  writePattern,
40
42
  writeRecommendation,
41
43
  writeDoctrine,
@@ -44,6 +46,34 @@ import {
44
46
  loadDoctrinesWithSkips
45
47
  } from '../synthesis/write-artifacts.js';
46
48
 
49
+ /**
50
+ * Surface torn/unreadable artifact YAML on stderr — the lookup-path analogue of
51
+ * the derive CLI's "N pattern(s) skipped (torn/unreadable)" reporting
52
+ * (cli.js) and `findRecordFile`'s stderr note (derive/load-records.js).
53
+ *
54
+ * B-001 — `findArtifactById` / `getArtifactReviewQueue` consumed only the
55
+ * loader's `entries`, discarding `skipped[]`. A torn artifact YAML for the very
56
+ * id under accept/show/queue therefore vanished behind a bare "<type> not
57
+ * found", giving the operator no signal that a file was unreadable. The id
58
+ * lives INSIDE the file, so a torn file can't be matched to a requested id;
59
+ * the honest signal is to name the torn files whenever any are present so the
60
+ * operator can distinguish "absent" from "present but unparseable". Returns the
61
+ * skipped list so callers can decide whether the not-found was hint-worthy.
62
+ *
63
+ * @param {string} rootDir
64
+ * @param {string} type - artifact kind, for the message prefix
65
+ * @param {Array<{ path: string, error: string }>} skipped
66
+ */
67
+ function reportArtifactSkips(rootDir, type, skipped) {
68
+ if (!skipped || skipped.length === 0) return;
69
+ // eslint-disable-next-line no-console
70
+ console.error(`${skipped.length} ${type}(s) skipped (torn/unreadable):`);
71
+ for (const s of skipped) {
72
+ // eslint-disable-next-line no-console
73
+ console.error(` ${relative(rootDir, s.path)} — ${s.error}`);
74
+ }
75
+ }
76
+
47
77
  /**
48
78
  * Per-artifact-type configuration: the on-disk directory, the id field name,
49
79
  * the loader (with-skips) and the writer. Mirrors how the finding engine pairs
@@ -87,12 +117,15 @@ const ARTIFACT_TYPES = {
87
117
  export function findArtifactById(rootDir, type, id) {
88
118
  const cfg = ARTIFACT_TYPES[type];
89
119
  if (!cfg) return null;
90
- const { entries } = cfg.loadWithSkips(rootDir);
120
+ const { entries, skipped } = cfg.loadWithSkips(rootDir);
91
121
  for (const entry of entries) {
92
122
  if (entry.data && entry.data[cfg.idKey] === id) {
93
123
  return { data: entry.data, path: entry.path, type };
94
124
  }
95
125
  }
126
+ // B-001 — id absent from the clean entries. A torn file may be hiding it;
127
+ // name the torn files rather than letting a bare not-found mislead.
128
+ reportArtifactSkips(rootDir, type, skipped);
96
129
  return null;
97
130
  }
98
131
 
@@ -217,11 +250,13 @@ export function reviewArtifact(rootDir, params) {
217
250
  });
218
251
 
219
252
  // Persist via the synthesis writer — which RE-VALIDATES the promoted artifact
220
- // against its JSON Schema (fail-closed). `resetSeenArtifactWrites` is the
221
- // documented opt-in for a legitimate re-write of an id already touched in this
222
- // process (the synthesis collision guard otherwise refuses the second write).
253
+ // against its JSON Schema (fail-closed). The reset is the documented opt-in
254
+ // for a legitimate re-write of an id already touched in this process (the
255
+ // synthesis collision guard otherwise refuses the second write). B-003 —
256
+ // scope it to THIS id so a future batch reviewer doesn't disarm the guard for
257
+ // the other ids it has written this process.
223
258
  try {
224
- resetSeenArtifactWrites(rootDir);
259
+ resetSeenArtifactWrite(rootDir, type, id);
225
260
  cfg.write(rootDir, artifact);
226
261
  } catch (err) {
227
262
  return { success: false, error: err.message, code: err.code };
@@ -249,7 +284,10 @@ export function getArtifactReviewQueue(rootDir, type) {
249
284
  for (const t of types) {
250
285
  const cfg = ARTIFACT_TYPES[t];
251
286
  if (!cfg) continue;
252
- const { entries } = cfg.loadWithSkips(rootDir);
287
+ const { entries, skipped } = cfg.loadWithSkips(rootDir);
288
+ // B-001 — a torn artifact silently shrinks the review queue; name it so the
289
+ // operator knows the queue is partial rather than complete.
290
+ reportArtifactSkips(rootDir, t, skipped);
253
291
  for (const entry of entries) {
254
292
  const data = entry.data;
255
293
  if (!data) continue;
@@ -11,7 +11,7 @@ import yaml from 'js-yaml';
11
11
 
12
12
  import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
13
13
  import { createEvent, appendEvent } from './event-log.js';
14
- import { parseFinding } from '../validate.js';
14
+ import { parseFinding, validateFinding } from '../validate.js';
15
15
  import { findById, loadFindings } from '../reader.js';
16
16
  import { atomicWriteFileSync } from '../lib/atomic-write.js';
17
17
 
@@ -113,10 +113,12 @@ export function performAction(rootDir, params) {
113
113
  // - `merge` and `supersede` auto-default to `'merged_into_canonical'`
114
114
  // since lineage already carries `superseded_by` / `merged_from`.
115
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.)
116
+ // defaulting would erase operator intent. F-FIND-001 supersedes the
117
+ // earlier "test-and-tell" path: rather than persisting a finding with
118
+ // no reject_reason and letting the operator discover it downstream, the
119
+ // write-side schema gate below refuses the write at the point of action,
120
+ // so the operator gets a structured error and the canonical store never
121
+ // holds a malformed finding. (See h4-engine-auto-reject-reason.test.js.)
120
122
  let effectiveRejectReason = params.rejectReason;
121
123
  if (!effectiveRejectReason && toStatus === 'rejected' && (action === 'merge' || action === 'supersede')) {
122
124
  effectiveRejectReason = 'merged_into_canonical';
@@ -165,12 +167,30 @@ export function performAction(rootDir, params) {
165
167
  notes: params.notes
166
168
  });
167
169
 
168
- // Persist: update finding artifact
169
- const clean = JSON.parse(JSON.stringify(finding));
170
- atomicWriteFileSync(filePath, yaml.dump(clean, { lineWidth: 120, noRefs: true }));
170
+ // F-FIND-001 schema gate BEFORE the filesystem touch. Every sibling
171
+ // finding writer (derive writeFinding, synthesis writePattern/...) validates
172
+ // first so no path can persist a malformed finding; the review engine was the
173
+ // last on-disk finding writer missing the gate. An operator edit with a
174
+ // typo'd enum or an unknown field is refused here instead of silently
175
+ // corrupting the canonical store. Return-shaped (not thrown) to match this
176
+ // function's `{ success, error }` contract.
177
+ const validation = validateFinding(finding);
178
+ if (!validation.valid) {
179
+ const summary = validation.errors.map(e => `${e.path || '/'} ${e.message}`).join('; ');
180
+ return { success: false, error: `Refused: edit would persist a schema-invalid finding: ${summary}` };
181
+ }
171
182
 
172
- // Persist: append event to log
183
+ const clean = JSON.parse(JSON.stringify(finding));
184
+ const body = yaml.dump(clean, { lineWidth: 120, noRefs: true });
185
+
186
+ // F-FIND-002 — append the audit event BEFORE the artifact write. The trail is
187
+ // append-only and the package advertises a complete one; the unsafe
188
+ // asymmetry is a persisted state change with no event. Appending first means
189
+ // an append failure aborts before the artifact lands (no silent desync); the
190
+ // only residual asymmetry is an event with no state change, which the
191
+ // append-only reader tolerates and an operator can reconcile.
173
192
  appendEvent(rootDir, event);
193
+ atomicWriteFileSync(filePath, body);
174
194
 
175
195
  return { success: true, finding, event };
176
196
  }
@@ -213,6 +233,33 @@ export function performMerge(rootDir, params) {
213
233
  const canonical = canonicalResult.data;
214
234
  const nonCanonical = sources.filter(s => s.data.finding_id !== canonicalId);
215
235
 
236
+ // FIND-PROAC-001 — fail-closed BEFORE the canonical write. Each non-canonical
237
+ // source is superseded downstream via `performAction`, whose F-FIND-001
238
+ // write-side gate re-validates the source and can refuse. Pre-amend,
239
+ // `performMerge` wrote the canonical (stamping `lineage.merged_from = [X]`)
240
+ // and only THEN looped the supersedes, ignoring each `sourceResult.success`.
241
+ // A schema-invalid source X would then leave the canonical asserting it
242
+ // merged from X while X was never actually superseded — a lineage lie reported
243
+ // as `{ success: true }`. Validate every source up front; if any is invalid,
244
+ // refuse the whole merge naming the offending id(s) + reason so the operator
245
+ // can fix the source before retrying, and the canonical never records a
246
+ // merged_from for a source that was never superseded. Matches the canonical
247
+ // gate below and the singleton writers.
248
+ const invalidSources = [];
249
+ for (const s of sources) {
250
+ const v = validateFinding(s.data);
251
+ if (!v.valid) {
252
+ const summary = v.errors.map(e => `${e.path || '/'} ${e.message}`).join('; ');
253
+ invalidSources.push(`${s.data.finding_id} (${summary})`);
254
+ }
255
+ }
256
+ if (invalidSources.length > 0) {
257
+ return {
258
+ success: false,
259
+ error: `Refused: ${invalidSources.length} source finding(s) could not be superseded — fix and re-merge: ${invalidSources.join('; ')}`
260
+ };
261
+ }
262
+
216
263
  // Merge evidence and source_record_ids into canonical
217
264
  const mergedRecordIds = new Set(canonical.source_record_ids || []);
218
265
  const mergedEvidence = [...(canonical.evidence || [])];
@@ -249,12 +296,41 @@ export function performMerge(rootDir, params) {
249
296
  decision_reason: reason
250
297
  };
251
298
 
252
- // Write canonical
299
+ // F-FIND-001 — schema gate the canonical BEFORE any write. The merge mutates
300
+ // source_record_ids / evidence / lineage / review; a malformed result must
301
+ // never reach disk. Matches the singleton writers and `performAction`.
302
+ const validation = validateFinding(canonical);
303
+ if (!validation.valid) {
304
+ const summary = validation.errors.map(e => `${e.path || '/'} ${e.message}`).join('; ');
305
+ return { success: false, error: `Refused: merge would persist a schema-invalid canonical: ${summary}` };
306
+ }
307
+
253
308
  const cleanCanonical = JSON.parse(JSON.stringify(canonical));
254
- atomicWriteFileSync(canonicalResult.path, yaml.dump(cleanCanonical, { lineWidth: 120, noRefs: true }));
309
+ const canonicalBody = yaml.dump(cleanCanonical, { lineWidth: 120, noRefs: true });
255
310
 
256
- // Mark source findings as rejected/superseded
311
+ // F-FIND-002 append the canonical merge event BEFORE the canonical write
312
+ // (same ordering rationale as performAction). An append failure aborts before
313
+ // the canonical or any source mutation lands.
314
+ const mergeEvent = createEvent({
315
+ findingId: canonicalId,
316
+ actor,
317
+ action: 'merge',
318
+ fromStatus: canonical.status,
319
+ toStatus: canonical.status,
320
+ reason,
321
+ mergedFromIds: nonCanonical.map(s => s.data.finding_id)
322
+ });
323
+ appendEvent(rootDir, mergeEvent);
324
+ atomicWriteFileSync(canonicalResult.path, canonicalBody);
325
+
326
+ // Mark source findings as rejected/superseded. Each supersede flows through
327
+ // performAction, so the F-FIND-001 schema gate covers these re-writes too.
328
+ // Sources were pre-validated above (FIND-PROAC-001), so a supersede failure
329
+ // here is unexpected — but never assume success. Collect any failed supersede
330
+ // so a partial merge is reported AS partial rather than as a clean success
331
+ // with a lineage that overstates what actually happened.
257
332
  const events = [];
333
+ const failedSupersedes = [];
258
334
  for (const s of nonCanonical) {
259
335
  const sourceResult = performAction(rootDir, {
260
336
  findingId: s.data.finding_id,
@@ -264,21 +340,22 @@ export function performMerge(rootDir, params) {
264
340
  supersededBy: canonicalId
265
341
  });
266
342
  if (sourceResult.event) events.push(sourceResult.event);
343
+ if (!sourceResult.success) {
344
+ failedSupersedes.push(`${s.data.finding_id}: ${sourceResult.error}`);
345
+ }
267
346
  }
268
347
 
269
- // Log merge event for canonical
270
- const mergeEvent = createEvent({
271
- findingId: canonicalId,
272
- actor,
273
- action: 'merge',
274
- fromStatus: canonical.status,
275
- toStatus: canonical.status,
276
- reason,
277
- mergedFromIds: nonCanonical.map(s => s.data.finding_id)
278
- });
279
- appendEvent(rootDir, mergeEvent);
280
348
  events.push(mergeEvent);
281
349
 
350
+ if (failedSupersedes.length > 0) {
351
+ return {
352
+ success: false,
353
+ error: `Partial merge: canonical ${canonicalId} was written but ${failedSupersedes.length} source(s) could not be superseded: ${failedSupersedes.join('; ')}`,
354
+ canonical,
355
+ events
356
+ };
357
+ }
358
+
282
359
  return { success: true, canonical, events };
283
360
  }
284
361
 
@@ -34,6 +34,7 @@ import { resolve } from 'node:path';
34
34
  import { existsSync, readFileSync } from 'node:fs';
35
35
  import yaml from 'js-yaml';
36
36
 
37
+ import { isUnsafeSegment } from '@dogfood-lab/ingest/lib/unsafe-segment.js';
37
38
  import { atomicWriteFileSync } from '../lib/atomic-write.js';
38
39
  import { findArtifactById } from '../review/review-artifacts.js';
39
40
  import { createEvent, appendEvent } from '../review/event-log.js';
@@ -89,6 +90,22 @@ export function applyRecommendation(rootDir, params) {
89
90
  const action = rec.action || {};
90
91
  const surfaces = rec.applies_to?.product_surfaces || [];
91
92
 
93
+ // findings-A-001 — path-traversal guard on the operator-supplied --policy
94
+ // <org/repo>. `policyPathFor` resolves `policies/repos/<org>/<repo>.yaml`; an
95
+ // org/repo carrying `..` or a separator would escape the policies tree on
96
+ // BOTH the dry-run (path leaked in preview) and write (file touched) paths,
97
+ // so reject here before either branch resolves a path.
98
+ if (params.policyRepo) {
99
+ const [pOrg, pRepo] = String(params.policyRepo).split('/');
100
+ if (!pOrg || !pRepo || isUnsafeSegment(pOrg) || isUnsafeSegment(pRepo)) {
101
+ return structuredError(
102
+ 'RECOMMENDATION_UNSAFE_POLICY',
103
+ `policy repo "${params.policyRepo}" is not a safe org/repo path segment`,
104
+ 'Pass --policy <org/repo> with no ".." or path separators inside the org or repo name.'
105
+ );
106
+ }
107
+ }
108
+
92
109
  // Build the resolution context shared by dry-run and write.
93
110
  const isStructured = STRUCTURED_LIST_ACTIONS.has(action.type);
94
111
  const surface = surfaces.length === 1 ? surfaces[0] : null;
@@ -8,10 +8,7 @@
8
8
  * - Statement must be rule-like, not advisory
9
9
  */
10
10
 
11
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
- import { resolve, join } from 'node:path';
13
- import yaml from 'js-yaml';
14
- import { loadAcceptedPatterns, loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
11
+ import { loadAcceptedPatternsWithSkips } from './recommendation-derivation.js';
15
12
 
16
13
  /**
17
14
  * Derive doctrine from strong accepted patterns.
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { loadFindings } from '../reader.js';
11
+ import { boundaryHash } from '../derive/ids.js';
11
12
 
12
13
  /**
13
14
  * Derive candidate patterns from accepted findings.
@@ -120,7 +121,7 @@ function isFalseRecurrence(findings) {
120
121
  /**
121
122
  * Build a pattern candidate from a cluster.
122
123
  */
123
- function buildPatternCandidate(cluster) {
124
+ export function buildPatternCandidate(cluster) {
124
125
  const { findings, issue_kind, root_cause_kind, remediation_kind } = cluster;
125
126
  const now = new Date().toISOString();
126
127
 
@@ -141,8 +142,16 @@ function buildPatternCandidate(cluster) {
141
142
  // otherwise two clusters that differ only by root_cause_kind collide on pattern_id and the
142
143
  // second writePattern() silently overwrites the first on disk. Surface is added for readability,
143
144
  // not for uniqueness.
145
+ //
146
+ // The human-readable slug runs `.replace(/_/g, '-')` AFTER concatenation, which folds the
147
+ // underscore inside a component into the same `-` that delimits components: issue_kind='a_b' +
148
+ // root_cause='c' and issue_kind='a' + root_cause='b_c' both flatten to `...-a-b-c`
149
+ // (findings-A-002). A trailing boundary hash over the UN-flattened cluster key keeps the two
150
+ // distinct, so legitimately different clusters no longer trip the L3-001 *_ID_COLLISION guard.
144
151
  const surfaceStr = surfaces.size === 1 ? [...surfaces][0] : 'multi-surface';
145
- const slug = `${surfaceStr}-${issue_kind}-${root_cause_kind}`.replace(/_/g, '-');
152
+ const readable = `${surfaceStr}-${issue_kind}-${root_cause_kind}`.replace(/_/g, '-');
153
+ const boundary = boundaryHash([surfaceStr, issue_kind, root_cause_kind]);
154
+ const slug = `${readable}-${boundary}`;
146
155
 
147
156
  // Determine strength
148
157
  const strength = repos.size >= 3 ? 'strong' : repos.size >= 2 ? 'emerging' : 'emerging';
@@ -59,6 +59,24 @@ export function resetSeenArtifactWrites(rootDir) {
59
59
  }
60
60
  }
61
61
 
62
+ /**
63
+ * Reset the collision memory for a SINGLE artifact id only.
64
+ *
65
+ * B-003 — `reviewArtifact` re-writes the one id it is promoting, which
66
+ * legitimately needs that id's guard cleared. Clearing the whole root (via
67
+ * `resetSeenArtifactWrites(rootDir)`) also disarms the silent-clobber guard for
68
+ * every OTHER kind/id — latent for a future batch caller reviewing several ids
69
+ * in one process. Scope the reset to `${rootDir}:${kind}:${id}` so only the id
70
+ * under review is cleared.
71
+ *
72
+ * @param {string} rootDir
73
+ * @param {'pattern'|'recommendation'|'doctrine'} kind
74
+ * @param {string} id
75
+ */
76
+ export function resetSeenArtifactWrite(rootDir, kind, id) {
77
+ seenArtifactWrites.delete(`${rootDir}:${kind}:${id}`);
78
+ }
79
+
62
80
  /**
63
81
  * Structured collision errors thrown by the singleton writers when called
64
82
  * twice in the same process with the same id. Mirror the batch helpers'