@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 +160 -18
- package/derive/ids.js +31 -3
- package/derive/write-findings.js +28 -0
- package/index.js +1 -1
- package/lib/file-lock.js +4 -3
- package/lib/safe-yaml-load.js +42 -0
- package/package.json +1 -1
- package/reader.js +57 -4
- package/review/event-log.js +60 -76
- package/review/review-artifacts.js +45 -7
- package/review/review-engine.js +100 -23
- package/synthesis/apply-recommendation.js +17 -0
- package/synthesis/doctrine-derivation.js +1 -4
- package/synthesis/pattern-derivation.js +11 -2
- package/synthesis/write-artifacts.js +18 -0
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
return
|
|
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
|
+
}
|
package/derive/write-findings.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/lib/safe-yaml-load.js
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/review/event-log.js
CHANGED
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Append-only review event log.
|
|
3
3
|
*
|
|
4
|
-
* Events are stored
|
|
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 {
|
|
8
|
-
import { resolve
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
94
|
-
const dir = dirname(logPath);
|
|
116
|
+
const dir = getEventDir(rootDir);
|
|
95
117
|
mkdirSync(dir, { recursive: true });
|
|
96
118
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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).
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
-
|
|
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;
|
package/review/review-engine.js
CHANGED
|
@@ -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.
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
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
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
309
|
+
const canonicalBody = yaml.dump(cleanCanonical, { lineWidth: 120, noRefs: true });
|
|
255
310
|
|
|
256
|
-
//
|
|
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 {
|
|
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
|
|
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'
|