@dogfood-lab/findings 1.5.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 +144 -18
- 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 +29 -1
- package/review/review-engine.js +100 -23
- package/synthesis/doctrine-derivation.js +1 -4
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
|
|
@@ -310,7 +351,16 @@ Filters (for list):
|
|
|
310
351
|
--surface <cli|desktop|web|api|mcp-server|npm-package|plugin|library>
|
|
311
352
|
--issue-kind <kind>
|
|
312
353
|
--transfer-scope <scope>
|
|
313
|
-
--
|
|
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.`);
|
|
314
364
|
process.exit(0);
|
|
315
365
|
}
|
|
316
366
|
|
|
@@ -328,9 +378,17 @@ Filters (for list):
|
|
|
328
378
|
if (flags.surface) filters.surface = flags.surface;
|
|
329
379
|
if (flags['issue-kind']) filters.issueKind = flags['issue-kind'];
|
|
330
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;
|
|
331
385
|
|
|
332
386
|
const filtered = filterFindings(allFindings, filters);
|
|
333
387
|
|
|
388
|
+
if (flags.json) {
|
|
389
|
+
emitJson(filtered.map(f => buildFindingJSON(f, ROOT)));
|
|
390
|
+
}
|
|
391
|
+
|
|
334
392
|
if (filtered.length === 0) {
|
|
335
393
|
console.log('No findings found.');
|
|
336
394
|
process.exit(0);
|
|
@@ -357,6 +415,10 @@ Filters (for list):
|
|
|
357
415
|
process.exit(1);
|
|
358
416
|
}
|
|
359
417
|
|
|
418
|
+
if (flags.json) {
|
|
419
|
+
emitJson(buildFindingJSON(result, ROOT));
|
|
420
|
+
}
|
|
421
|
+
|
|
360
422
|
console.log(formatFindingDetail(result, ROOT));
|
|
361
423
|
process.exit(0);
|
|
362
424
|
}
|
|
@@ -384,11 +446,22 @@ Filters (for list):
|
|
|
384
446
|
}
|
|
385
447
|
}
|
|
386
448
|
|
|
387
|
-
// 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).
|
|
388
454
|
let failed = 0;
|
|
389
455
|
let passed = 0;
|
|
390
456
|
|
|
391
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
|
+
|
|
392
465
|
for (const f of realFindings) {
|
|
393
466
|
if (f.valid) {
|
|
394
467
|
console.log(`PASS: ${relative(ROOT, f.path)}`);
|
|
@@ -442,6 +515,7 @@ Filters (for list):
|
|
|
442
515
|
}
|
|
443
516
|
|
|
444
517
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
518
|
+
console.log(failed > 0 ? `VERDICT: FAIL (${failed} failed)` : 'VERDICT: PASS');
|
|
445
519
|
process.exit(failed > 0 ? 1 : 0);
|
|
446
520
|
}
|
|
447
521
|
|
|
@@ -451,8 +525,13 @@ Filters (for list):
|
|
|
451
525
|
const all = flags.all;
|
|
452
526
|
const write = flags.write;
|
|
453
527
|
|
|
454
|
-
// 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.
|
|
455
533
|
let entries = [];
|
|
534
|
+
let skippedRecords = [];
|
|
456
535
|
if (recordId) {
|
|
457
536
|
const entry = loadRecordById(ROOT, recordId);
|
|
458
537
|
if (!entry) {
|
|
@@ -461,14 +540,18 @@ Filters (for list):
|
|
|
461
540
|
}
|
|
462
541
|
entries = [entry];
|
|
463
542
|
} else if (repoKey) {
|
|
464
|
-
|
|
465
|
-
|
|
543
|
+
const loaded = loadRecordsForRepoWithSkips(ROOT, repoKey);
|
|
544
|
+
entries = loaded.entries;
|
|
545
|
+
skippedRecords = loaded.skipped;
|
|
546
|
+
if (entries.length === 0 && skippedRecords.length === 0) {
|
|
466
547
|
console.error(`No records found for repo: ${repoKey}`);
|
|
467
548
|
process.exit(1);
|
|
468
549
|
}
|
|
469
550
|
} else if (all) {
|
|
470
|
-
|
|
471
|
-
|
|
551
|
+
const loaded = loadAllRecordsWithSkips(ROOT);
|
|
552
|
+
entries = loaded.entries;
|
|
553
|
+
skippedRecords = loaded.skipped;
|
|
554
|
+
if (entries.length === 0 && skippedRecords.length === 0) {
|
|
472
555
|
console.error('No records found.');
|
|
473
556
|
process.exit(1);
|
|
474
557
|
}
|
|
@@ -477,6 +560,20 @@ Filters (for list):
|
|
|
477
560
|
process.exit(2);
|
|
478
561
|
}
|
|
479
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
|
+
|
|
480
577
|
// Derive
|
|
481
578
|
const { candidates, ruleErrors, stats } = deriveFromRecords(entries);
|
|
482
579
|
|
|
@@ -711,6 +808,9 @@ Filters (for list):
|
|
|
711
808
|
process.exit(2);
|
|
712
809
|
}
|
|
713
810
|
const events = getEventsForFinding(ROOT, findingId);
|
|
811
|
+
if (flags.json) {
|
|
812
|
+
emitJson(events);
|
|
813
|
+
}
|
|
714
814
|
if (events.length === 0) {
|
|
715
815
|
console.log(`No review history for: ${findingId}`);
|
|
716
816
|
process.exit(0);
|
|
@@ -731,6 +831,13 @@ Filters (for list):
|
|
|
731
831
|
|
|
732
832
|
if (command === 'queue') {
|
|
733
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
|
+
}
|
|
734
841
|
if (queue.length === 0) {
|
|
735
842
|
console.log('Review queue is empty.');
|
|
736
843
|
process.exit(0);
|
|
@@ -821,7 +928,12 @@ Filters (for list):
|
|
|
821
928
|
}
|
|
822
929
|
|
|
823
930
|
if (sub === 'list') {
|
|
824
|
-
|
|
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);
|
|
825
937
|
if (patterns.length === 0) { console.log('No patterns found.'); process.exit(0); }
|
|
826
938
|
for (const p of patterns) {
|
|
827
939
|
console.log(`[${p.status}] ${p.pattern_id} (${p.pattern_strength || 'unknown'})`);
|
|
@@ -839,6 +951,8 @@ Filters (for list):
|
|
|
839
951
|
const p = all.find(x => x.pattern_id === id);
|
|
840
952
|
if (!p) { console.error(`Pattern not found: ${id}`); process.exit(1); }
|
|
841
953
|
|
|
954
|
+
if (flags.json) emitJson(p);
|
|
955
|
+
|
|
842
956
|
console.log(`Pattern: ${p.pattern_id}`);
|
|
843
957
|
console.log(`Title: ${p.title}`);
|
|
844
958
|
console.log(`Status: ${p.status}`);
|
|
@@ -963,7 +1077,12 @@ Filters (for list):
|
|
|
963
1077
|
}
|
|
964
1078
|
|
|
965
1079
|
if (sub === 'list') {
|
|
966
|
-
|
|
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);
|
|
967
1086
|
if (recs.length === 0) { console.log('No recommendations found.'); process.exit(0); }
|
|
968
1087
|
for (const r of recs) {
|
|
969
1088
|
console.log(`[${r.status}] ${r.recommendation_id}`);
|
|
@@ -979,6 +1098,7 @@ Filters (for list):
|
|
|
979
1098
|
const all = loadRecommendations(ROOT);
|
|
980
1099
|
const r = all.find(x => x.recommendation_id === id);
|
|
981
1100
|
if (!r) { console.error(`Recommendation not found: ${id}`); process.exit(1); }
|
|
1101
|
+
if (flags.json) emitJson(r);
|
|
982
1102
|
console.log(`Recommendation: ${r.recommendation_id}`);
|
|
983
1103
|
console.log(`Title: ${r.title}`);
|
|
984
1104
|
console.log(`Status: ${r.status}`);
|
|
@@ -1056,7 +1176,12 @@ Filters (for list):
|
|
|
1056
1176
|
}
|
|
1057
1177
|
|
|
1058
1178
|
if (sub === 'list') {
|
|
1059
|
-
|
|
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);
|
|
1060
1185
|
if (docs.length === 0) { console.log('No doctrine found.'); process.exit(0); }
|
|
1061
1186
|
for (const d of docs) {
|
|
1062
1187
|
console.log(`[${d.status}] ${d.doctrine_id} [${d.strength}]`);
|
|
@@ -1072,6 +1197,7 @@ Filters (for list):
|
|
|
1072
1197
|
const all = loadDoctrines(ROOT);
|
|
1073
1198
|
const d = all.find(x => x.doctrine_id === id);
|
|
1074
1199
|
if (!d) { console.error(`Doctrine not found: ${id}`); process.exit(1); }
|
|
1200
|
+
if (flags.json) emitJson(d);
|
|
1075
1201
|
console.log(`Doctrine: ${d.doctrine_id}`);
|
|
1076
1202
|
console.log(`Title: ${d.title}`);
|
|
1077
1203
|
console.log(`Status: ${d.status}`);
|
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
|
@@ -175,11 +175,36 @@ export function findById(rootDir, findingId) {
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
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
|
+
|
|
178
198
|
/**
|
|
179
199
|
* Filter a list of loaded findings.
|
|
180
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
|
+
*
|
|
181
206
|
* @param {Array<{ data: object }>} findings - Loaded findings.
|
|
182
|
-
* @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
|
|
183
208
|
* @returns {Array}
|
|
184
209
|
*/
|
|
185
210
|
export function filterFindings(findings, filters = {}) {
|
|
@@ -190,6 +215,9 @@ export function filterFindings(findings, filters = {}) {
|
|
|
190
215
|
if (filters.surface && f.data.product_surface !== filters.surface) return false;
|
|
191
216
|
if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
|
|
192
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
|
+
}
|
|
193
221
|
return true;
|
|
194
222
|
});
|
|
195
223
|
}
|
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
|
|
|
@@ -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.
|