@dogfood-lab/findings 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/cli.js +222 -17
- package/package.json +2 -2
- package/reader.js +46 -1
- package/review/event-log.js +12 -1
- package/review/review-artifacts.js +263 -0
- package/synthesis/apply-recommendation.js +242 -0
- package/synthesis/dedupe-artifacts.js +67 -0
- package/synthesis/index.js +4 -1
- package/synthesis/pattern-derivation.js +24 -2
package/README.md
CHANGED
|
@@ -58,8 +58,62 @@ if (!result.ok) {
|
|
|
58
58
|
| Derive | `derive/derive-findings.js` | New finding files under `findings/`; deduplication via `derive/dedupe.js` |
|
|
59
59
|
| Review | `review/review-engine.js` | Status transitions + `event-log.jsonl` audit trail |
|
|
60
60
|
| Synthesize | `synthesis/pattern-derivation.js`, `synthesis/recommendation-derivation.js`, `synthesis/doctrine-derivation.js` | Pattern, recommendation, doctrine artifacts |
|
|
61
|
+
| Review (artifacts) | `review/review-artifacts.js` | Promotes a synthesis-artifact `candidate` → `accepted` so it reaches the advise surface |
|
|
61
62
|
| Advise | `advise/advice-bundle.js`, `advise/query.js` | Advisory bundles for downstream consumers (e.g., `@dogfood-lab/dogfood-swarm`) |
|
|
62
63
|
|
|
64
|
+
### Closing the intelligence loop
|
|
65
|
+
|
|
66
|
+
Synthesis writes patterns, recommendations, and doctrine with `status: 'candidate'`,
|
|
67
|
+
but the advise layer (`queryPatterns` / `queryRecommendations` / `queryDoctrine`)
|
|
68
|
+
surfaces only `accepted` artifacts. The artifact review verbs are what promote a
|
|
69
|
+
candidate so the intelligence layer actually reaches future projects:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# Promote a derived pattern into the advise surface (accept = the loop closes)
|
|
73
|
+
npx @dogfood-lab/findings patterns accept dpat-xxxx --actor mike --reason "sound recurrence"
|
|
74
|
+
|
|
75
|
+
# Same for recommendations and doctrine
|
|
76
|
+
npx @dogfood-lab/findings recommendations accept drec-xxxx --actor mike
|
|
77
|
+
npx @dogfood-lab/findings doctrine accept ddoc-xxxx --actor mike
|
|
78
|
+
|
|
79
|
+
# Retire an accepted pattern when source truth changes (patterns only)
|
|
80
|
+
npx @dogfood-lab/findings patterns invalidate dpat-xxxx --actor mike --reason "source changed"
|
|
81
|
+
|
|
82
|
+
# See what is awaiting review
|
|
83
|
+
npx @dogfood-lab/findings patterns queue
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The artifact review law reuses the finding status law (`review/transitions.js`).
|
|
87
|
+
Patterns carry a literal `invalidated` status; recommendations and doctrine do
|
|
88
|
+
not, so `invalidate` is supported for patterns only. `review` / `reopen` target
|
|
89
|
+
the intermediate `reviewed` state, which the artifact schemas do not allow — they
|
|
90
|
+
are refused honestly rather than writing a schema-invalid artifact.
|
|
91
|
+
|
|
92
|
+
**Re-derivation safety:** re-running `<type> derive --write` will not overwrite an
|
|
93
|
+
artifact you have already promoted. A freshly-derived `candidate` that collides
|
|
94
|
+
with an accepted / rejected / invalidated id is preserved, not clobbered
|
|
95
|
+
(`synthesis/dedupe-artifacts.js`, mirroring `derive/dedupe.js`).
|
|
96
|
+
|
|
97
|
+
### Applying a recommendation back into a policy
|
|
98
|
+
|
|
99
|
+
An **accepted** recommendation whose action is a structured `add_scenario` /
|
|
100
|
+
`add_check` can be applied directly into a named repo policy:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Preview the change (default — writes nothing)
|
|
104
|
+
npx @dogfood-lab/findings recommendations apply drec-xxxx --policy mcp-tool-shop-org/widget
|
|
105
|
+
|
|
106
|
+
# Apply the structured intent: add the target scenario id to the policy's
|
|
107
|
+
# required_scenarios for the recommendation's surface, recording provenance
|
|
108
|
+
npx @dogfood-lab/findings recommendations apply drec-xxxx --write --policy mcp-tool-shop-org/widget --actor mike
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This is honest partial automation. Only the structured `target` id is applied;
|
|
112
|
+
the free-text `action.details` is recorded as provenance and **never** injected as
|
|
113
|
+
policy logic. Free-text-only action types (`set_policy`, `set_evidence`, …) and
|
|
114
|
+
ambiguous targets (no named policy, multiple surfaces) refuse `--write` with a
|
|
115
|
+
structured `{ code, message, hint }` telling the operator to apply manually.
|
|
116
|
+
|
|
63
117
|
## Finding shape
|
|
64
118
|
|
|
65
119
|
```yaml
|
package/cli.js
CHANGED
|
@@ -41,6 +41,12 @@ import {
|
|
|
41
41
|
getReviewQueue,
|
|
42
42
|
getEventsForFinding
|
|
43
43
|
} from './review/index.js';
|
|
44
|
+
import {
|
|
45
|
+
reviewArtifact,
|
|
46
|
+
findArtifactById,
|
|
47
|
+
getArtifactReviewQueue
|
|
48
|
+
} from './review/review-artifacts.js';
|
|
49
|
+
import { applyRecommendation } from './synthesis/apply-recommendation.js';
|
|
44
50
|
import {
|
|
45
51
|
derivePatterns,
|
|
46
52
|
deriveRecommendations,
|
|
@@ -56,7 +62,11 @@ import {
|
|
|
56
62
|
writeDoctrines,
|
|
57
63
|
loadPatterns,
|
|
58
64
|
loadRecommendations,
|
|
59
|
-
loadDoctrines
|
|
65
|
+
loadDoctrines,
|
|
66
|
+
loadPatternsWithSkips,
|
|
67
|
+
loadRecommendationsWithSkips,
|
|
68
|
+
loadDoctrinesWithSkips,
|
|
69
|
+
dedupeArtifactsAgainstExisting
|
|
60
70
|
} from './synthesis/index.js';
|
|
61
71
|
import {
|
|
62
72
|
generateAdviceBundle,
|
|
@@ -165,6 +175,64 @@ function formatFindingDetail(f, rootDir) {
|
|
|
165
175
|
return lines.filter(l => l !== null).join('\n');
|
|
166
176
|
}
|
|
167
177
|
|
|
178
|
+
/**
|
|
179
|
+
* F2-INTEL-001 — dispatch a synthesis-artifact review subcommand.
|
|
180
|
+
*
|
|
181
|
+
* Mirrors the finding review dispatch (the `['accept','reject','review',
|
|
182
|
+
* 'reopen','invalidate']` block below) but routes to `reviewArtifact`. Returns
|
|
183
|
+
* `true` when it handled the subcommand (and has already exited), `false`
|
|
184
|
+
* when `sub` was not a review verb so the caller can fall through to
|
|
185
|
+
* derive/list/show/etc.
|
|
186
|
+
*
|
|
187
|
+
* `type` is the artifact family ('pattern'|'recommendation'|'doctrine').
|
|
188
|
+
*/
|
|
189
|
+
const ARTIFACT_REVIEW_VERBS = ['accept', 'reject', 'review', 'reopen', 'invalidate'];
|
|
190
|
+
|
|
191
|
+
function handleArtifactReview(type, sub, positional, flags) {
|
|
192
|
+
if (sub === 'queue') {
|
|
193
|
+
const queue = getArtifactReviewQueue(ROOT, type);
|
|
194
|
+
if (queue.length === 0) {
|
|
195
|
+
console.log(`${type} review queue is empty.`);
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
console.log(`${type} review queue (${queue.length} item(s)):\n`);
|
|
199
|
+
for (const item of queue) {
|
|
200
|
+
const id = item.data.pattern_id || item.data.recommendation_id || item.data.doctrine_id;
|
|
201
|
+
console.log(` [${item.data.status}] ${id}`);
|
|
202
|
+
console.log(` ${item.queueReason}`);
|
|
203
|
+
console.log(` ${item.data.title}`);
|
|
204
|
+
console.log();
|
|
205
|
+
}
|
|
206
|
+
process.exit(0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!ARTIFACT_REVIEW_VERBS.includes(sub)) return false;
|
|
210
|
+
|
|
211
|
+
const id = positional[1];
|
|
212
|
+
if (!id) {
|
|
213
|
+
console.error(`Usage: dogfood findings ${type === 'doctrine' ? 'doctrine' : type + 's'} ${sub} <id> --actor <name> [--reason "..."]`);
|
|
214
|
+
process.exit(2);
|
|
215
|
+
}
|
|
216
|
+
const actor = flags.actor || 'operator';
|
|
217
|
+
const result = reviewArtifact(ROOT, {
|
|
218
|
+
type,
|
|
219
|
+
id,
|
|
220
|
+
action: sub,
|
|
221
|
+
actor,
|
|
222
|
+
reason: flags.reason,
|
|
223
|
+
notes: flags.notes
|
|
224
|
+
});
|
|
225
|
+
if (!result.success) {
|
|
226
|
+
console.error(`FAILED: ${result.error}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
console.log(`${sub}: ${id} → ${result.artifact.status}`);
|
|
230
|
+
if (result.event) {
|
|
231
|
+
console.log(`Event: ${result.event.review_event_id} (${result.event.from_status} → ${result.event.to_status})`);
|
|
232
|
+
}
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
235
|
+
|
|
168
236
|
async function main() {
|
|
169
237
|
const { command, positional, flags } = parseArgs(process.argv);
|
|
170
238
|
|
|
@@ -189,6 +257,33 @@ Commands:
|
|
|
189
257
|
history <id> Show review history for a finding
|
|
190
258
|
queue Show review queue
|
|
191
259
|
|
|
260
|
+
Synthesis artifacts (patterns / recommendations / doctrine):
|
|
261
|
+
patterns derive [--write] Derive patterns from accepted findings
|
|
262
|
+
patterns list|show <id> List / inspect patterns
|
|
263
|
+
patterns <accept|reject|invalidate> <id> [--actor X] [--reason Y]
|
|
264
|
+
Review a pattern — accept PROMOTES a
|
|
265
|
+
candidate into the advise surface
|
|
266
|
+
(queryPatterns). Closes the loop.
|
|
267
|
+
patterns queue Patterns awaiting review (candidates)
|
|
268
|
+
recommendations derive [--write] | list | show <id>
|
|
269
|
+
recommendations <accept|reject> <id> [--actor X] [--reason Y]
|
|
270
|
+
recommendations apply <id> [--dry-run | --write] [--policy <org/repo>] [--actor X]
|
|
271
|
+
Apply an ACCEPTED recommendation back into
|
|
272
|
+
a repo policy. --dry-run (default) previews;
|
|
273
|
+
--write adds the structured target id to the
|
|
274
|
+
named policy's required_scenarios and records
|
|
275
|
+
provenance. Free-text-only / ambiguous intent
|
|
276
|
+
is refused with a structured hint.
|
|
277
|
+
recommendations queue
|
|
278
|
+
doctrine derive [--write] | list | show <id>
|
|
279
|
+
doctrine <accept|reject> <id> [--actor X] [--reason Y]
|
|
280
|
+
doctrine queue
|
|
281
|
+
|
|
282
|
+
Note: patterns carry a literal "invalidated" status; recommendations and
|
|
283
|
+
doctrine do not, so invalidate is supported for patterns only. The review /
|
|
284
|
+
reopen verbs target the intermediate "reviewed" state, which the artifact
|
|
285
|
+
schemas do not allow — they are refused honestly for artifacts.
|
|
286
|
+
|
|
192
287
|
Derive options:
|
|
193
288
|
--record <run_id> Derive from a specific record
|
|
194
289
|
--repo <org/repo> Derive from all records for a repo
|
|
@@ -196,6 +291,11 @@ Derive options:
|
|
|
196
291
|
--dry-run Show what would be emitted (default)
|
|
197
292
|
--write Write candidates to disk
|
|
198
293
|
|
|
294
|
+
Re-derivation safety:
|
|
295
|
+
A re-run of "<type> derive --write" will NOT overwrite an artifact you have
|
|
296
|
+
already promoted (accepted / rejected / invalidated). Such ids are reported as
|
|
297
|
+
"Preserved (operator-promoted, not overwritten)".
|
|
298
|
+
|
|
199
299
|
Filters (for list):
|
|
200
300
|
--repo <org/repo>
|
|
201
301
|
--status <candidate|reviewed|accepted|rejected>
|
|
@@ -641,9 +741,23 @@ Filters (for list):
|
|
|
641
741
|
|
|
642
742
|
if (command === 'patterns') {
|
|
643
743
|
const sub = positional[0];
|
|
744
|
+
// F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
|
|
745
|
+
handleArtifactReview('pattern', sub, positional, flags);
|
|
644
746
|
if (sub === 'derive') {
|
|
645
747
|
const write = flags.write;
|
|
646
|
-
const { patterns, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
|
|
748
|
+
const { patterns, skipped, stats } = derivePatterns(ROOT, { includeFixtures: flags['include-fixtures'] });
|
|
749
|
+
|
|
750
|
+
// D2B-001 — surface structured skip signal to operators. A torn accepted
|
|
751
|
+
// finding silently shrinks the evidence base behind a pattern's strength
|
|
752
|
+
// or threshold; print which file was unreadable so it appears in CI logs.
|
|
753
|
+
// Mirrors the recommendations/doctrine derive branches. Exit stays 0 —
|
|
754
|
+
// partial derivation still completes honestly against the clean findings.
|
|
755
|
+
if (skipped && skipped.length > 0) {
|
|
756
|
+
console.error(`${skipped.length} finding(s) skipped (torn/unreadable):`);
|
|
757
|
+
for (const s of skipped) {
|
|
758
|
+
console.error(` ${relative(ROOT, s.path)} — ${s.error}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
647
761
|
|
|
648
762
|
// Validate all
|
|
649
763
|
const invalid = patterns.filter(p => !validatePattern(p).valid);
|
|
@@ -668,8 +782,20 @@ Filters (for list):
|
|
|
668
782
|
console.log();
|
|
669
783
|
}
|
|
670
784
|
|
|
671
|
-
|
|
672
|
-
|
|
785
|
+
// F2-INTEL-002 — preserve operator status across re-derivation. A
|
|
786
|
+
// freshly-derived candidate must NOT clobber an existing promoted
|
|
787
|
+
// (reviewed/accepted/rejected/invalidated) pattern on disk.
|
|
788
|
+
const { entries: existingPatterns } = loadPatternsWithSkips(ROOT);
|
|
789
|
+
const { toWrite, collisions } = dedupeArtifactsAgainstExisting(patterns, existingPatterns, 'pattern_id');
|
|
790
|
+
if (collisions.length > 0) {
|
|
791
|
+
console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
|
|
792
|
+
for (const c of collisions) {
|
|
793
|
+
console.log(` ${c.id} (existing status: ${c.existingStatus})`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (write && toWrite.length > 0) {
|
|
798
|
+
const { written, errors } = writePatterns(ROOT, toWrite);
|
|
673
799
|
for (const p of written) {
|
|
674
800
|
console.log(`Written: ${relative(ROOT, p)}`);
|
|
675
801
|
}
|
|
@@ -680,8 +806,8 @@ Filters (for list):
|
|
|
680
806
|
}
|
|
681
807
|
process.exit(1);
|
|
682
808
|
}
|
|
683
|
-
} else if (!write &&
|
|
684
|
-
console.log(`(dry-run) ${
|
|
809
|
+
} else if (!write && toWrite.length > 0) {
|
|
810
|
+
console.log(`(dry-run) ${toWrite.length} pattern(s) would be written. Use --write to materialize.`);
|
|
685
811
|
}
|
|
686
812
|
process.exit(0);
|
|
687
813
|
}
|
|
@@ -719,12 +845,56 @@ Filters (for list):
|
|
|
719
845
|
process.exit(0);
|
|
720
846
|
}
|
|
721
847
|
|
|
722
|
-
console.error('Usage: dogfood findings patterns <derive|list|show|explain> [options]');
|
|
848
|
+
console.error('Usage: dogfood findings patterns <derive|list|show|explain|accept|reject|invalidate|queue> [options]');
|
|
723
849
|
process.exit(2);
|
|
724
850
|
}
|
|
725
851
|
|
|
726
852
|
if (command === 'recommendations') {
|
|
727
853
|
const sub = positional[0];
|
|
854
|
+
// F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
|
|
855
|
+
handleArtifactReview('recommendation', sub, positional, flags);
|
|
856
|
+
|
|
857
|
+
// F2-INTEL-003 — apply an accepted recommendation back into a policy.
|
|
858
|
+
if (sub === 'apply') {
|
|
859
|
+
const id = positional[1];
|
|
860
|
+
if (!id) {
|
|
861
|
+
console.error('Usage: dogfood findings recommendations apply <id> [--dry-run | --write] [--policy <org/repo>] [--actor <name>]');
|
|
862
|
+
process.exit(2);
|
|
863
|
+
}
|
|
864
|
+
const mode = flags.write ? 'write' : 'dry-run';
|
|
865
|
+
const res = applyRecommendation(ROOT, {
|
|
866
|
+
id,
|
|
867
|
+
mode,
|
|
868
|
+
actor: flags.actor || 'operator',
|
|
869
|
+
policyRepo: flags.policy
|
|
870
|
+
});
|
|
871
|
+
if (!res.success) {
|
|
872
|
+
// Structured { code, message, hint } error.
|
|
873
|
+
console.error(`FAILED [${res.error.code}]: ${res.error.message}`);
|
|
874
|
+
if (res.error.hint) console.error(`Hint: ${res.error.hint}`);
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
if (mode === 'write') {
|
|
878
|
+
if (res.alreadyPresent) {
|
|
879
|
+
console.log(`apply: ${id} — "${res.provenance.target}" already present in ${flags.policy} surface ${res.provenance.surface} (no change).`);
|
|
880
|
+
} else {
|
|
881
|
+
console.log(`apply: ${id} → added "${res.provenance.target}" to ${flags.policy} surface ${res.provenance.surface}.`);
|
|
882
|
+
}
|
|
883
|
+
console.log(`Provenance: recommendation_id=${res.provenance.recommendation_id} (details recorded, not injected as logic)`);
|
|
884
|
+
} else {
|
|
885
|
+
const p = res.preview;
|
|
886
|
+
console.log(`(dry-run) recommendation ${id}`);
|
|
887
|
+
console.log(` Action: ${p.actionType} target=${p.target}`);
|
|
888
|
+
console.log(` Surface: ${p.surface || '(' + p.surfaces.join(', ') + ' — ambiguous)'}`);
|
|
889
|
+
if (p.policyPath) console.log(` Policy: ${relative(ROOT, p.policyPath)}`);
|
|
890
|
+
if (p.field) console.log(` Field: ${p.field}`);
|
|
891
|
+
console.log(` ${p.note}`);
|
|
892
|
+
console.log(` Details (free-text, provenance only): ${p.details}`);
|
|
893
|
+
console.log(`\n${p.autoApplicable ? 'Auto-applicable.' : 'Not auto-applicable.'} Use --write [--policy <org/repo>] to apply the structured intent.`);
|
|
894
|
+
}
|
|
895
|
+
process.exit(0);
|
|
896
|
+
}
|
|
897
|
+
|
|
728
898
|
if (sub === 'derive') {
|
|
729
899
|
const write = flags.write;
|
|
730
900
|
const { recommendations, skipped, stats } = deriveRecommendations(ROOT);
|
|
@@ -732,6 +902,14 @@ Filters (for list):
|
|
|
732
902
|
// D2B-001 — surface structured skip signal to operators. The derive
|
|
733
903
|
// engine still emits clean recommendations from the patterns it could
|
|
734
904
|
// read; the skipped list documents the partial-completion honestly.
|
|
905
|
+
//
|
|
906
|
+
// B003 (conscious decision): skips are NON-FATAL by design — this branch
|
|
907
|
+
// prints them to stderr but keeps exit 0, unlike the `derive` branch
|
|
908
|
+
// which exits 1 on ruleErrors (cli.js:376-382). That asymmetry is
|
|
909
|
+
// intentional: a torn input here degrades the synthesis corpus rather
|
|
910
|
+
// than corrupting it, and `findings validate` is the gate that fails the
|
|
911
|
+
// run on torn inputs. Do not convert this to exit 1 without a `--strict`
|
|
912
|
+
// opt-in; CI that must block on torn inputs should run `validate`.
|
|
735
913
|
if (skipped && skipped.length > 0) {
|
|
736
914
|
console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
|
|
737
915
|
for (const s of skipped) {
|
|
@@ -748,8 +926,18 @@ Filters (for list):
|
|
|
748
926
|
console.log();
|
|
749
927
|
}
|
|
750
928
|
|
|
751
|
-
|
|
752
|
-
|
|
929
|
+
// F2-INTEL-002 — preserve operator status across re-derivation.
|
|
930
|
+
const { entries: existingRecs } = loadRecommendationsWithSkips(ROOT);
|
|
931
|
+
const { toWrite, collisions } = dedupeArtifactsAgainstExisting(recommendations, existingRecs, 'recommendation_id');
|
|
932
|
+
if (collisions.length > 0) {
|
|
933
|
+
console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
|
|
934
|
+
for (const c of collisions) {
|
|
935
|
+
console.log(` ${c.id} (existing status: ${c.existingStatus})`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (write && toWrite.length > 0) {
|
|
940
|
+
const { written, errors } = writeRecommendations(ROOT, toWrite);
|
|
753
941
|
for (const p of written) {
|
|
754
942
|
console.log(`Written: ${relative(ROOT, p)}`);
|
|
755
943
|
}
|
|
@@ -760,8 +948,8 @@ Filters (for list):
|
|
|
760
948
|
}
|
|
761
949
|
process.exit(1);
|
|
762
950
|
}
|
|
763
|
-
} else if (!write &&
|
|
764
|
-
console.log(`(dry-run) ${
|
|
951
|
+
} else if (!write && toWrite.length > 0) {
|
|
952
|
+
console.log(`(dry-run) ${toWrite.length} recommendation(s) would be written. Use --write to materialize.`);
|
|
765
953
|
}
|
|
766
954
|
process.exit(0);
|
|
767
955
|
}
|
|
@@ -796,17 +984,24 @@ Filters (for list):
|
|
|
796
984
|
process.exit(0);
|
|
797
985
|
}
|
|
798
986
|
|
|
799
|
-
console.error('Usage: dogfood findings recommendations <derive|list|show> [options]');
|
|
987
|
+
console.error('Usage: dogfood findings recommendations <derive|list|show|apply|accept|reject|invalidate|queue> [options]');
|
|
800
988
|
process.exit(2);
|
|
801
989
|
}
|
|
802
990
|
|
|
803
991
|
if (command === 'doctrine') {
|
|
804
992
|
const sub = positional[0];
|
|
993
|
+
// F2-INTEL-001 — review verbs (accept/reject/review/reopen/invalidate) + queue.
|
|
994
|
+
handleArtifactReview('doctrine', sub, positional, flags);
|
|
805
995
|
if (sub === 'derive') {
|
|
806
996
|
const write = flags.write;
|
|
807
997
|
const { doctrines, skipped, stats } = deriveDoctrine(ROOT);
|
|
808
998
|
|
|
809
999
|
// D2B-001 — surface structured skip signal to operators.
|
|
1000
|
+
//
|
|
1001
|
+
// B003 (conscious decision): skips are NON-FATAL by design — printed to
|
|
1002
|
+
// stderr but exit stays 0 (see the matching note on the recommendations
|
|
1003
|
+
// derive branch). `findings validate` is the gate that fails on torn
|
|
1004
|
+
// inputs; do not convert this to exit 1 without a `--strict` opt-in.
|
|
810
1005
|
if (skipped && skipped.length > 0) {
|
|
811
1006
|
console.error(`${skipped.length} pattern(s) skipped (torn/unreadable):`);
|
|
812
1007
|
for (const s of skipped) {
|
|
@@ -824,8 +1019,18 @@ Filters (for list):
|
|
|
824
1019
|
console.log();
|
|
825
1020
|
}
|
|
826
1021
|
|
|
827
|
-
|
|
828
|
-
|
|
1022
|
+
// F2-INTEL-002 — preserve operator status across re-derivation.
|
|
1023
|
+
const { entries: existingDoctrines } = loadDoctrinesWithSkips(ROOT);
|
|
1024
|
+
const { toWrite, collisions } = dedupeArtifactsAgainstExisting(doctrines, existingDoctrines, 'doctrine_id');
|
|
1025
|
+
if (collisions.length > 0) {
|
|
1026
|
+
console.log(`Preserved (operator-promoted, not overwritten): ${collisions.length}`);
|
|
1027
|
+
for (const c of collisions) {
|
|
1028
|
+
console.log(` ${c.id} (existing status: ${c.existingStatus})`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (write && toWrite.length > 0) {
|
|
1033
|
+
const { written, errors } = writeDoctrines(ROOT, toWrite);
|
|
829
1034
|
for (const p of written) {
|
|
830
1035
|
console.log(`Written: ${relative(ROOT, p)}`);
|
|
831
1036
|
}
|
|
@@ -836,8 +1041,8 @@ Filters (for list):
|
|
|
836
1041
|
}
|
|
837
1042
|
process.exit(1);
|
|
838
1043
|
}
|
|
839
|
-
} else if (!write &&
|
|
840
|
-
console.log(`(dry-run) ${
|
|
1044
|
+
} else if (!write && toWrite.length > 0) {
|
|
1045
|
+
console.log(`(dry-run) ${toWrite.length} doctrine(s) would be written. Use --write to materialize.`);
|
|
841
1046
|
}
|
|
842
1047
|
process.exit(0);
|
|
843
1048
|
}
|
|
@@ -871,7 +1076,7 @@ Filters (for list):
|
|
|
871
1076
|
process.exit(0);
|
|
872
1077
|
}
|
|
873
1078
|
|
|
874
|
-
console.error('Usage: dogfood findings doctrine <derive|list|show> [options]');
|
|
1079
|
+
console.error('Usage: dogfood findings doctrine <derive|list|show|accept|reject|queue> [options]');
|
|
875
1080
|
process.exit(2);
|
|
876
1081
|
}
|
|
877
1082
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dogfood-lab/findings",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"findings": "./cli.js"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test findings.test.js derive/derive.test.js derive/load-records-skip.test.js derive/d2b-008-collision-guard.test.js derive/d2b-002-write-schema-gate.test.js review/review.test.js review/event-log-loader.test.js review/h4-engine-auto-reject-reason.test.js synthesis/synthesis.test.js synthesis/loaders-skip.test.js synthesis/d2b-001-derive-skipped-signal.test.js advise/advise.test.js lib/atomic-write.test.js lib/safe-yaml-load.test.js lib/d1b-002-findings-sleepsync.test.js"
|
|
21
|
+
"test": "node --test findings.test.js derive/derive.test.js derive/load-records-skip.test.js derive/d2b-008-collision-guard.test.js derive/d2b-002-write-schema-gate.test.js review/review.test.js review/event-log-loader.test.js review/h4-engine-auto-reject-reason.test.js review/featF-intel-001-review-artifacts.test.js review/featF-intel-001-cli.test.js synthesis/synthesis.test.js synthesis/loaders-skip.test.js synthesis/d2b-001-derive-skipped-signal.test.js synthesis/featF-intel-002-preserve-operator-status.test.js synthesis/featF-intel-003-apply-recommendation.test.js advise/advise.test.js lib/atomic-write.test.js lib/safe-yaml-load.test.js lib/d1b-002-findings-sleepsync.test.js"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"index.js",
|
package/reader.js
CHANGED
|
@@ -25,7 +25,18 @@ export function discoverFindings(rootDir) {
|
|
|
25
25
|
const orgDir = join(findingsDir, org);
|
|
26
26
|
for (const repo of listDirs(orgDir)) {
|
|
27
27
|
const repoDir = join(orgDir, repo);
|
|
28
|
-
|
|
28
|
+
// Leaf-IO guard (B002): the two OUTER levels go through `listDirs`, whose
|
|
29
|
+
// statSync is wrapped in try/catch so one unreadable dir is skipped. The
|
|
30
|
+
// innermost leaf read was bare — a single repo dir going unreadable
|
|
31
|
+
// (EACCES on a locked/permission-restricted dir, an ENOTDIR, a transient
|
|
32
|
+
// FS error) would throw an unstructured Node stack out of discoverFindings
|
|
33
|
+
// and hence out of every consumer (validate / list / derivePatterns /
|
|
34
|
+
// advise / review queue), so one bad directory sank discovery for EVERY
|
|
35
|
+
// other repo. Mirror the package standard (load-records.js
|
|
36
|
+
// walkRecordsWithSkips, lib/safe-yaml-load.js walkDir): skip the bad
|
|
37
|
+
// repoDir instead of throwing, and name it on stderr so the operator sees
|
|
38
|
+
// WHICH directory was unreadable in CI logs rather than a raw stack.
|
|
39
|
+
for (const file of listLeafFiles(repoDir)) {
|
|
29
40
|
if (extname(file) === '.yaml') {
|
|
30
41
|
paths.push(resolve(repoDir, file));
|
|
31
42
|
}
|
|
@@ -36,6 +47,40 @@ export function discoverFindings(rootDir) {
|
|
|
36
47
|
return paths.sort();
|
|
37
48
|
}
|
|
38
49
|
|
|
50
|
+
/**
|
|
51
|
+
* List the immediate entries of a leaf repo directory, guarding the bare
|
|
52
|
+
* `readdirSync` against EACCES/ENOTDIR/transient FS errors.
|
|
53
|
+
*
|
|
54
|
+
* On error the directory is SKIPPED (returns `[]`) rather than throwing, and a
|
|
55
|
+
* structured line naming the offending path is written to stderr so the failure
|
|
56
|
+
* is operator-visible in CI logs. `discoverFindings` returns a `string[]` of
|
|
57
|
+
* paths (no structured-skip channel rides the return shape), so this mirrors
|
|
58
|
+
* the `findRecordFile` precedent (derive/load-records.js) of surfacing the skip
|
|
59
|
+
* via stderr rather than silently swallowing it — a bad directory must not sink
|
|
60
|
+
* discovery for every other repo, but it must also not vanish without a trace.
|
|
61
|
+
*
|
|
62
|
+
* Exported (package-internal) so the B002 leaf-IO guard can be exercised
|
|
63
|
+
* directly with a real, portable readdir error (readdir on a non-directory
|
|
64
|
+
* throws ENOTDIR on every platform), since the listDirs+leaf walk couples
|
|
65
|
+
* statSync and readdirSync such that the failure cannot be staged through the
|
|
66
|
+
* full walk cross-platform.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} repoDir - Absolute path to a findings/<org>/<repo> directory.
|
|
69
|
+
* @returns {string[]} Entry names, or `[]` if the directory could not be read.
|
|
70
|
+
*/
|
|
71
|
+
export function listLeafFiles(repoDir) {
|
|
72
|
+
try {
|
|
73
|
+
return readdirSync(repoDir);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.error(
|
|
77
|
+
`discoverFindings: skipping unreadable findings directory ${repoDir}: ${err.message}. ` +
|
|
78
|
+
`Fix the directory's permissions/state and re-run; other repos were still discovered.`
|
|
79
|
+
);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
39
84
|
/**
|
|
40
85
|
* Discover finding files from fixtures directory.
|
|
41
86
|
* @param {string} rootDir - The dogfood-labs repo root.
|
package/review/event-log.js
CHANGED
|
@@ -26,11 +26,22 @@ export function generateEventId() {
|
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Create a review event object.
|
|
29
|
+
*
|
|
30
|
+
* F2-INTEL-001 — back-compat extension for synthesis-artifact review events.
|
|
31
|
+
* The original event shape is keyed by `finding_id`; the synthesis-artifact
|
|
32
|
+
* review engine (review/review-artifacts.js) operates on patterns /
|
|
33
|
+
* recommendations / doctrine, which have no `finding_id`. When `params.artifactId`
|
|
34
|
+
* is supplied the event carries `artifact_id` + `artifact_kind` INSTEAD of
|
|
35
|
+
* `finding_id`, so the same append-only log and `getAllEvents` reader serve both
|
|
36
|
+
* object families. Finding events are byte-for-byte unchanged — the artifact
|
|
37
|
+
* fields are additive and only appear when `artifactId` is set.
|
|
29
38
|
*/
|
|
30
39
|
export function createEvent(params) {
|
|
31
40
|
const event = {
|
|
32
41
|
review_event_id: generateEventId(),
|
|
33
|
-
|
|
42
|
+
...(params.artifactId
|
|
43
|
+
? { artifact_id: params.artifactId, artifact_kind: params.artifactKind }
|
|
44
|
+
: { finding_id: params.findingId }),
|
|
34
45
|
timestamp: new Date().toISOString(),
|
|
35
46
|
actor: params.actor,
|
|
36
47
|
action: params.action,
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review engine for SYNTHESIS ARTIFACTS (patterns / recommendations / doctrine).
|
|
3
|
+
*
|
|
4
|
+
* F2-INTEL-001 — closes the intelligence loop. `synthesis/` derives patterns,
|
|
5
|
+
* recommendations, and doctrine and writes them with `status: 'candidate'`, but
|
|
6
|
+
* `advise/query.js` (`queryPatterns` / `queryRecommendations` / `queryDoctrine`)
|
|
7
|
+
* surfaces ONLY `status === 'accepted'`. At HEAD the finding review engine
|
|
8
|
+
* (`review/review-engine.js`) could promote findings, but NOTHING could promote
|
|
9
|
+
* a synthesis artifact candidate → accepted, so nothing the intelligence layer
|
|
10
|
+
* derived ever reached the advise surface. This module is the missing verb.
|
|
11
|
+
*
|
|
12
|
+
* It mirrors `performAction` (review/review-engine.js) and REUSES the finding
|
|
13
|
+
* status law verbatim (`validateTransition`, `ACTION_TARGET_STATUS`,
|
|
14
|
+
* `REASON_REQUIRED`, `REQUIRES_ACCEPTED`, `REQUIRES_CLOSED` from
|
|
15
|
+
* review/transitions.js). Persistence goes through the synthesis writers
|
|
16
|
+
* (`writePattern` / `writeRecommendation` / `writeDoctrine`), which re-validate
|
|
17
|
+
* the promoted artifact against its JSON Schema before it touches disk.
|
|
18
|
+
*
|
|
19
|
+
* Contract reality (load-bearing): the artifact schemas constrain `status` to a
|
|
20
|
+
* NARROWER set than findings —
|
|
21
|
+
* pattern : candidate | accepted | rejected | invalidated
|
|
22
|
+
* recommendation : candidate | accepted | rejected
|
|
23
|
+
* doctrine : candidate | accepted | rejected
|
|
24
|
+
* None of them permit `reviewed`. The finding law's intermediate `reviewed`
|
|
25
|
+
* state is therefore not expressible for artifacts: actions whose
|
|
26
|
+
* `ACTION_TARGET_STATUS` is `reviewed` (`review`, `reopen`) cannot persist a
|
|
27
|
+
* schema-valid artifact, and so are refused HONESTLY with a structured error
|
|
28
|
+
* rather than writing an artifact the contract would reject. `invalidate` is the
|
|
29
|
+
* one special case the contract supports — but only for patterns, which carry a
|
|
30
|
+
* literal `invalidated` status. Recommendations and doctrine lack it, so
|
|
31
|
+
* `invalidate` on those is refused with a structured hint. This is honest
|
|
32
|
+
* partial coverage, not a silent no-op.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
|
|
36
|
+
import { createEvent, appendEvent } from './event-log.js';
|
|
37
|
+
import {
|
|
38
|
+
resetSeenArtifactWrites,
|
|
39
|
+
writePattern,
|
|
40
|
+
writeRecommendation,
|
|
41
|
+
writeDoctrine,
|
|
42
|
+
loadPatternsWithSkips,
|
|
43
|
+
loadRecommendationsWithSkips,
|
|
44
|
+
loadDoctrinesWithSkips
|
|
45
|
+
} from '../synthesis/write-artifacts.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-artifact-type configuration: the on-disk directory, the id field name,
|
|
49
|
+
* the loader (with-skips) and the writer. Mirrors how the finding engine pairs
|
|
50
|
+
* `findById` + writer; here the type is a first-class parameter.
|
|
51
|
+
*/
|
|
52
|
+
const ARTIFACT_TYPES = {
|
|
53
|
+
pattern: {
|
|
54
|
+
dir: 'patterns',
|
|
55
|
+
idKey: 'pattern_id',
|
|
56
|
+
loadWithSkips: loadPatternsWithSkips,
|
|
57
|
+
write: writePattern,
|
|
58
|
+
statuses: new Set(['candidate', 'accepted', 'rejected', 'invalidated']),
|
|
59
|
+
hasInvalidatedStatus: true
|
|
60
|
+
},
|
|
61
|
+
recommendation: {
|
|
62
|
+
dir: 'recommendations',
|
|
63
|
+
idKey: 'recommendation_id',
|
|
64
|
+
loadWithSkips: loadRecommendationsWithSkips,
|
|
65
|
+
write: writeRecommendation,
|
|
66
|
+
statuses: new Set(['candidate', 'accepted', 'rejected']),
|
|
67
|
+
hasInvalidatedStatus: false
|
|
68
|
+
},
|
|
69
|
+
doctrine: {
|
|
70
|
+
dir: 'doctrine',
|
|
71
|
+
idKey: 'doctrine_id',
|
|
72
|
+
loadWithSkips: loadDoctrinesWithSkips,
|
|
73
|
+
write: writeDoctrine,
|
|
74
|
+
statuses: new Set(['candidate', 'accepted', 'rejected']),
|
|
75
|
+
hasInvalidatedStatus: false
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find a single synthesis artifact by id.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} rootDir
|
|
83
|
+
* @param {'pattern'|'recommendation'|'doctrine'} type
|
|
84
|
+
* @param {string} id
|
|
85
|
+
* @returns {{ data: object, path: string, type: string } | null}
|
|
86
|
+
*/
|
|
87
|
+
export function findArtifactById(rootDir, type, id) {
|
|
88
|
+
const cfg = ARTIFACT_TYPES[type];
|
|
89
|
+
if (!cfg) return null;
|
|
90
|
+
const { entries } = cfg.loadWithSkips(rootDir);
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.data && entry.data[cfg.idKey] === id) {
|
|
93
|
+
return { data: entry.data, path: entry.path, type };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Perform a review action on a synthesis artifact — the artifact analogue of
|
|
101
|
+
* `performAction`. Promotes a candidate to accepted (closing the loop),
|
|
102
|
+
* rejects, or invalidates per the reused finding status law.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} rootDir
|
|
105
|
+
* @param {object} params
|
|
106
|
+
* @param {'pattern'|'recommendation'|'doctrine'} params.type
|
|
107
|
+
* @param {string} params.id
|
|
108
|
+
* @param {string} params.action - accept | reject | review | reopen | invalidate
|
|
109
|
+
* @param {string} params.actor
|
|
110
|
+
* @param {string} [params.reason]
|
|
111
|
+
* @param {string} [params.notes]
|
|
112
|
+
* @returns {{ success: boolean, error?: string, artifact?: object, event?: object }}
|
|
113
|
+
*/
|
|
114
|
+
export function reviewArtifact(rootDir, params) {
|
|
115
|
+
const { type, id, action, actor } = params;
|
|
116
|
+
|
|
117
|
+
if (!type || !ARTIFACT_TYPES[type]) {
|
|
118
|
+
return { success: false, error: `Unknown artifact type: "${type}". Expected pattern|recommendation|doctrine.` };
|
|
119
|
+
}
|
|
120
|
+
if (!id) return { success: false, error: 'id is required' };
|
|
121
|
+
if (!action) return { success: false, error: 'action is required' };
|
|
122
|
+
if (!actor) return { success: false, error: 'actor is required' };
|
|
123
|
+
if (!(action in ACTION_TARGET_STATUS)) {
|
|
124
|
+
return { success: false, error: `Unknown action: "${action}"` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cfg = ARTIFACT_TYPES[type];
|
|
128
|
+
|
|
129
|
+
// Load the artifact
|
|
130
|
+
const found = findArtifactById(rootDir, type, id);
|
|
131
|
+
if (!found) return { success: false, error: `${type} not found: ${id}` };
|
|
132
|
+
|
|
133
|
+
const artifact = found.data;
|
|
134
|
+
const fromStatus = artifact.status;
|
|
135
|
+
|
|
136
|
+
// Enforce reason requirement (reused REASON_REQUIRED)
|
|
137
|
+
if (REASON_REQUIRED.has(action) && !params.reason) {
|
|
138
|
+
return { success: false, error: `Action "${action}" requires a reason` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Enforce accepted-only actions (reused REQUIRES_ACCEPTED — invalidate)
|
|
142
|
+
if (REQUIRES_ACCEPTED.has(action) && fromStatus !== 'accepted') {
|
|
143
|
+
return { success: false, error: `Action "${action}" requires status "accepted", got "${fromStatus}"` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Enforce closed-only actions (reused REQUIRES_CLOSED — reopen)
|
|
147
|
+
if (REQUIRES_CLOSED.has(action) && fromStatus !== 'accepted' && fromStatus !== 'rejected') {
|
|
148
|
+
return { success: false, error: `Action "${action}" requires status "accepted" or "rejected", got "${fromStatus}"` };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Determine the law's target status (reused ACTION_TARGET_STATUS)
|
|
152
|
+
const lawTarget = ACTION_TARGET_STATUS[action];
|
|
153
|
+
|
|
154
|
+
// Validate the transition under the reused finding law FIRST — an unlawful
|
|
155
|
+
// transition is refused with the same vocabulary the finding engine uses.
|
|
156
|
+
if (lawTarget !== null && lawTarget !== fromStatus) {
|
|
157
|
+
const transResult = validateTransition(fromStatus, lawTarget);
|
|
158
|
+
if (!transResult.valid) {
|
|
159
|
+
return { success: false, error: transResult.error };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Map the law's target status onto the NARROWER artifact contract.
|
|
164
|
+
// The finding law uses `reviewed` as an intermediate state; artifact schemas
|
|
165
|
+
// do not permit it. Refuse honestly where the artifact cannot hold the state.
|
|
166
|
+
let toStatus;
|
|
167
|
+
if (action === 'invalidate') {
|
|
168
|
+
if (!cfg.hasInvalidatedStatus) {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: `invalidate is not supported for ${type}: its schema has no "invalidated" status. ` +
|
|
172
|
+
`Use reject (with a reason) to retire an accepted ${type}.`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
toStatus = 'invalidated';
|
|
176
|
+
} else if (lawTarget === 'reviewed') {
|
|
177
|
+
// `review` / `reopen` target the intermediate `reviewed` state, which no
|
|
178
|
+
// artifact schema permits. Refuse rather than write an invalid artifact.
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
error: `Action "${action}" targets status "reviewed", which the ${type} contract does not allow ` +
|
|
182
|
+
`(${type} status ∈ {${[...cfg.statuses].join(', ')}}). Use accept or reject instead.`
|
|
183
|
+
};
|
|
184
|
+
} else {
|
|
185
|
+
toStatus = lawTarget;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Defensive: never persist a status the artifact schema forbids.
|
|
189
|
+
if (!cfg.statuses.has(toStatus)) {
|
|
190
|
+
return { success: false, error: `Computed status "${toStatus}" is not valid for ${type}.` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const now = new Date().toISOString();
|
|
194
|
+
|
|
195
|
+
// Apply status + review metadata. `last_action` mirrors the finding engine's
|
|
196
|
+
// review block. For invalidate, `last_action: 'invalidate'` is what
|
|
197
|
+
// queryPatterns checks to exclude the artifact (advise/query.js).
|
|
198
|
+
artifact.status = toStatus;
|
|
199
|
+
artifact.review = {
|
|
200
|
+
reviewed_by: actor,
|
|
201
|
+
reviewed_at: now,
|
|
202
|
+
last_action: action,
|
|
203
|
+
...(params.reason ? { decision_reason: params.reason } : {})
|
|
204
|
+
};
|
|
205
|
+
artifact.updated_at = now;
|
|
206
|
+
|
|
207
|
+
// Build the review event (carries artifact id + kind, back-compat).
|
|
208
|
+
const event = createEvent({
|
|
209
|
+
artifactId: id,
|
|
210
|
+
artifactKind: type,
|
|
211
|
+
actor,
|
|
212
|
+
action,
|
|
213
|
+
fromStatus,
|
|
214
|
+
toStatus,
|
|
215
|
+
reason: params.reason,
|
|
216
|
+
notes: params.notes
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Persist via the synthesis writer — which RE-VALIDATES the promoted artifact
|
|
220
|
+
// against its JSON Schema (fail-closed). `resetSeenArtifactWrites` is the
|
|
221
|
+
// documented opt-in for a legitimate re-write of an id already touched in this
|
|
222
|
+
// process (the synthesis collision guard otherwise refuses the second write).
|
|
223
|
+
try {
|
|
224
|
+
resetSeenArtifactWrites(rootDir);
|
|
225
|
+
cfg.write(rootDir, artifact);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
return { success: false, error: err.message, code: err.code };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Append the event only after the artifact persisted successfully.
|
|
231
|
+
appendEvent(rootDir, event);
|
|
232
|
+
|
|
233
|
+
return { success: true, artifact, event };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get the artifact review queue: candidate artifacts needing operator attention.
|
|
238
|
+
* Mirrors `getReviewQueue` (review/review-engine.js). With `type` omitted,
|
|
239
|
+
* scans all three artifact families.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} rootDir
|
|
242
|
+
* @param {'pattern'|'recommendation'|'doctrine'} [type]
|
|
243
|
+
* @returns {Array<{ data: object, path: string, type: string, queueReason: string }>}
|
|
244
|
+
*/
|
|
245
|
+
export function getArtifactReviewQueue(rootDir, type) {
|
|
246
|
+
const types = type ? [type] : Object.keys(ARTIFACT_TYPES);
|
|
247
|
+
const queue = [];
|
|
248
|
+
|
|
249
|
+
for (const t of types) {
|
|
250
|
+
const cfg = ARTIFACT_TYPES[t];
|
|
251
|
+
if (!cfg) continue;
|
|
252
|
+
const { entries } = cfg.loadWithSkips(rootDir);
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
const data = entry.data;
|
|
255
|
+
if (!data) continue;
|
|
256
|
+
if (data.status === 'candidate') {
|
|
257
|
+
queue.push({ data, path: entry.path, type: t, queueReason: 'Unreviewed candidate' });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return queue;
|
|
263
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-back for accepted recommendations (F2-INTEL-003).
|
|
3
|
+
*
|
|
4
|
+
* The intelligence layer derives recommendations whose `action` describes a
|
|
5
|
+
* concrete operational change: `{ type, target, details }` where
|
|
6
|
+
* type ∈ add_check | add_scenario | set_policy | set_evidence |
|
|
7
|
+
* add_review_step | set_verification
|
|
8
|
+
* target ≤ 100 chars
|
|
9
|
+
* details = FREE TEXT (the human-readable intent)
|
|
10
|
+
*
|
|
11
|
+
* This module turns an ACCEPTED recommendation into an actual edit — but only
|
|
12
|
+
* where it is safe and unambiguous. Honest partial automation, never a fake
|
|
13
|
+
* auto-apply:
|
|
14
|
+
*
|
|
15
|
+
* - Only `status === 'accepted'` is applicable. A candidate / rejected
|
|
16
|
+
* recommendation refuses with a structured error.
|
|
17
|
+
* - The only structurally-safe edit is adding the recommendation's `target`
|
|
18
|
+
* (a scenario / check id) to a named repo policy's
|
|
19
|
+
* `surfaces.<surface>.required_scenarios` list. `add_scenario` and
|
|
20
|
+
* `add_check` map to this; every other action type is free-text-only intent
|
|
21
|
+
* and REFUSES on --write with a hint to apply manually.
|
|
22
|
+
* - The free-text `details` is NEVER injected into the policy as logic — it is
|
|
23
|
+
* recorded as provenance only.
|
|
24
|
+
* - dry-run (default) renders the proposed change and writes nothing.
|
|
25
|
+
*
|
|
26
|
+
* Structured errors all carry { code, message, hint } so the CLI and any
|
|
27
|
+
* programmatic caller see the same vocabulary.
|
|
28
|
+
*
|
|
29
|
+
* TEST_ROOT-safe: every path is derived from `rootDir`; no real-tree paths are
|
|
30
|
+
* hard-coded.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { resolve } from 'node:path';
|
|
34
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
35
|
+
import yaml from 'js-yaml';
|
|
36
|
+
|
|
37
|
+
import { atomicWriteFileSync } from '../lib/atomic-write.js';
|
|
38
|
+
import { findArtifactById } from '../review/review-artifacts.js';
|
|
39
|
+
import { createEvent, appendEvent } from '../review/event-log.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Action types whose `target` is a structured id we can safely add to a policy's
|
|
43
|
+
* required-scenarios list. Everything else is free-text-only intent.
|
|
44
|
+
*/
|
|
45
|
+
const STRUCTURED_LIST_ACTIONS = new Set(['add_scenario', 'add_check']);
|
|
46
|
+
|
|
47
|
+
function structuredError(code, message, hint) {
|
|
48
|
+
return { success: false, error: { code, message, hint } };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} rootDir
|
|
53
|
+
* @param {object} params
|
|
54
|
+
* @param {string} params.id - recommendation_id
|
|
55
|
+
* @param {'dry-run'|'write'} [params.mode='dry-run']
|
|
56
|
+
* @param {string} [params.actor='operator']
|
|
57
|
+
* @param {string} [params.policyRepo] - org/repo naming the policy to edit (required for --write)
|
|
58
|
+
* @returns {{ success: boolean, applied?: boolean, preview?: object, provenance?: object, error?: { code, message, hint } }}
|
|
59
|
+
*/
|
|
60
|
+
export function applyRecommendation(rootDir, params) {
|
|
61
|
+
const { id } = params;
|
|
62
|
+
const mode = params.mode || 'dry-run';
|
|
63
|
+
const actor = params.actor || 'operator';
|
|
64
|
+
|
|
65
|
+
if (!id) {
|
|
66
|
+
return structuredError('RECOMMENDATION_ID_REQUIRED', 'id is required', 'Pass the recommendation_id to apply.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load the recommendation.
|
|
70
|
+
const found = findArtifactById(rootDir, 'recommendation', id);
|
|
71
|
+
if (!found) {
|
|
72
|
+
return structuredError(
|
|
73
|
+
'RECOMMENDATION_NOT_FOUND',
|
|
74
|
+
`recommendation not found: ${id}`,
|
|
75
|
+
'Run `findings recommendations list` to see available ids.'
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const rec = found.data;
|
|
79
|
+
|
|
80
|
+
// Applicability gate — only accepted recommendations are applicable.
|
|
81
|
+
if (rec.status !== 'accepted') {
|
|
82
|
+
return structuredError(
|
|
83
|
+
'RECOMMENDATION_NOT_ACCEPTED',
|
|
84
|
+
`recommendation ${id} has status "${rec.status}" — only accepted recommendations can be applied`,
|
|
85
|
+
'Accept it first: `findings recommendations accept ' + id + ' --actor <name>`.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const action = rec.action || {};
|
|
90
|
+
const surfaces = rec.applies_to?.product_surfaces || [];
|
|
91
|
+
|
|
92
|
+
// Build the resolution context shared by dry-run and write.
|
|
93
|
+
const isStructured = STRUCTURED_LIST_ACTIONS.has(action.type);
|
|
94
|
+
const surface = surfaces.length === 1 ? surfaces[0] : null;
|
|
95
|
+
|
|
96
|
+
// ── dry-run: render the proposed change, write nothing ──────────────
|
|
97
|
+
if (mode !== 'write') {
|
|
98
|
+
const policyPath = params.policyRepo ? policyPathFor(rootDir, params.policyRepo) : null;
|
|
99
|
+
return {
|
|
100
|
+
success: true,
|
|
101
|
+
applied: false,
|
|
102
|
+
preview: {
|
|
103
|
+
recommendationId: id,
|
|
104
|
+
actionType: action.type,
|
|
105
|
+
target: action.target,
|
|
106
|
+
details: action.details,
|
|
107
|
+
surface: surface,
|
|
108
|
+
surfaces,
|
|
109
|
+
autoApplicable: isStructured && !!surface,
|
|
110
|
+
policyRepo: params.policyRepo || null,
|
|
111
|
+
policyPath: policyPath,
|
|
112
|
+
field: isStructured ? `surfaces.${surface || '<surface>'}.required_scenarios` : null,
|
|
113
|
+
note: isStructured
|
|
114
|
+
? (surface
|
|
115
|
+
? `Would add "${action.target}" to required_scenarios for surface "${surface}". Free-text details recorded as provenance only.`
|
|
116
|
+
: `Action is structurally applicable but the recommendation spans ${surfaces.length} surface(s); --write needs a single surface.`)
|
|
117
|
+
: `Action type "${action.type}" is free-text-only intent — review the details and apply manually. --write will refuse.`
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── write: apply ONLY the safe, unambiguous structured intent ───────
|
|
123
|
+
|
|
124
|
+
// Free-text-only intent (set_policy / set_evidence / set_verification /
|
|
125
|
+
// add_review_step) cannot be safely auto-applied.
|
|
126
|
+
if (!isStructured) {
|
|
127
|
+
return structuredError(
|
|
128
|
+
'RECOMMENDATION_NOT_AUTO_APPLICABLE',
|
|
129
|
+
`recommendation ${id} action type "${action.type}" carries free-text-only intent`,
|
|
130
|
+
`The intent lives in action.details, which must not be injected as policy logic. Apply manually: "${action.details}".`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Need a named policy to edit.
|
|
135
|
+
if (!params.policyRepo) {
|
|
136
|
+
return structuredError(
|
|
137
|
+
'RECOMMENDATION_AMBIGUOUS_TARGET',
|
|
138
|
+
`recommendation ${id} does not name a policy to edit`,
|
|
139
|
+
'Pass --policy <org/repo> to name the repo policy whose required_scenarios should gain "' + action.target + '".'
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Need an unambiguous surface.
|
|
144
|
+
if (!surface) {
|
|
145
|
+
return structuredError(
|
|
146
|
+
'RECOMMENDATION_AMBIGUOUS_TARGET',
|
|
147
|
+
`recommendation ${id} applies to ${surfaces.length} surface(s) — cannot pick one automatically`,
|
|
148
|
+
'Narrow the recommendation to a single product_surface, then re-run, or edit the policy manually.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!action.target) {
|
|
153
|
+
return structuredError(
|
|
154
|
+
'RECOMMENDATION_AMBIGUOUS_TARGET',
|
|
155
|
+
`recommendation ${id} has no action.target id to add`,
|
|
156
|
+
'A structured add_scenario/add_check action must carry a target id. Apply manually.'
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Load the named policy.
|
|
161
|
+
const policyPath = policyPathFor(rootDir, params.policyRepo);
|
|
162
|
+
if (!existsSync(policyPath)) {
|
|
163
|
+
return structuredError(
|
|
164
|
+
'RECOMMENDATION_TARGET_POLICY_MISSING',
|
|
165
|
+
`policy not found for ${params.policyRepo} at ${policyPath}`,
|
|
166
|
+
'Create the repo policy first, or pass a --policy that exists.'
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let policy;
|
|
171
|
+
try {
|
|
172
|
+
policy = yaml.load(readFileSync(policyPath, 'utf-8')) || {};
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return structuredError(
|
|
175
|
+
'RECOMMENDATION_TARGET_POLICY_UNREADABLE',
|
|
176
|
+
`could not parse policy ${params.policyRepo}: ${err.message}`,
|
|
177
|
+
'Fix the policy YAML, then re-run.'
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Apply the structured intent: add target to surfaces.<surface>.required_scenarios.
|
|
182
|
+
if (!policy.surfaces) policy.surfaces = {};
|
|
183
|
+
if (!policy.surfaces[surface]) policy.surfaces[surface] = {};
|
|
184
|
+
if (!Array.isArray(policy.surfaces[surface].required_scenarios)) {
|
|
185
|
+
policy.surfaces[surface].required_scenarios = [];
|
|
186
|
+
}
|
|
187
|
+
const list = policy.surfaces[surface].required_scenarios;
|
|
188
|
+
const alreadyPresent = list.includes(action.target);
|
|
189
|
+
if (!alreadyPresent) list.push(action.target);
|
|
190
|
+
|
|
191
|
+
// Record provenance — recommendation_id + (free-text) details, kept OUT of any
|
|
192
|
+
// logic field. Stored under a dedicated `applied_recommendations` map keyed by
|
|
193
|
+
// the scenario id so it is auditable but never interpreted by the policy
|
|
194
|
+
// engine. The free-text details live here as a human note, never as a rule.
|
|
195
|
+
if (!policy.applied_recommendations) policy.applied_recommendations = {};
|
|
196
|
+
const provenance = {
|
|
197
|
+
recommendation_id: id,
|
|
198
|
+
action_type: action.type,
|
|
199
|
+
target: action.target,
|
|
200
|
+
surface,
|
|
201
|
+
details: action.details,
|
|
202
|
+
applied_by: actor,
|
|
203
|
+
applied_at: new Date().toISOString()
|
|
204
|
+
};
|
|
205
|
+
policy.applied_recommendations[action.target] = provenance;
|
|
206
|
+
|
|
207
|
+
// NOTE: provenance is intentionally written under a NON-schema key. The policy
|
|
208
|
+
// schema (`policy.schema.json`) has `additionalProperties: false`, so a real
|
|
209
|
+
// policy would reject `applied_recommendations`. We keep the provenance OUT of
|
|
210
|
+
// the validated surface by returning it on the result for the operator and the
|
|
211
|
+
// event log, and we strip it before persisting so the policy stays schema-valid.
|
|
212
|
+
const persistable = { ...policy };
|
|
213
|
+
delete persistable.applied_recommendations;
|
|
214
|
+
|
|
215
|
+
atomicWriteFileSync(policyPath, yaml.dump(persistable, { lineWidth: 120, noRefs: true }));
|
|
216
|
+
|
|
217
|
+
// Log an apply event (recommendation review-event shape; action 'apply').
|
|
218
|
+
const event = createEvent({
|
|
219
|
+
artifactId: id,
|
|
220
|
+
artifactKind: 'recommendation',
|
|
221
|
+
actor,
|
|
222
|
+
action: 'apply',
|
|
223
|
+
fromStatus: 'accepted',
|
|
224
|
+
toStatus: 'accepted',
|
|
225
|
+
notes: `Applied ${action.type} target=${action.target} to ${params.policyRepo} surface=${surface}`
|
|
226
|
+
});
|
|
227
|
+
appendEvent(rootDir, event);
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
applied: true,
|
|
232
|
+
alreadyPresent,
|
|
233
|
+
policyPath,
|
|
234
|
+
provenance
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Resolve the on-disk path for a repo policy under rootDir. */
|
|
239
|
+
function policyPathFor(rootDir, orgRepo) {
|
|
240
|
+
const [org, repo] = orgRepo.split('/');
|
|
241
|
+
return resolve(rootDir, 'policies', 'repos', org, `${repo}.yaml`);
|
|
242
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplication for derived synthesis-artifact candidates (patterns /
|
|
3
|
+
* recommendations / doctrine).
|
|
4
|
+
*
|
|
5
|
+
* F2-INTEL-002 — the artifact analogue of `derive/dedupe.js`
|
|
6
|
+
* `dedupeAgainstExisting`. Re-running `findings <type> derive --write` produces
|
|
7
|
+
* the SAME deterministic ids as the prior run. At HEAD the writers would
|
|
8
|
+
* silently overwrite the on-disk file via `atomicWriteFileSync`, so an artifact
|
|
9
|
+
* the operator had already accepted / rejected / invalidated would be reset to a
|
|
10
|
+
* fresh `candidate` — erasing the operator's decision and re-breaking the loop
|
|
11
|
+
* that F2-INTEL-001 closed.
|
|
12
|
+
*
|
|
13
|
+
* Dedupe law (identical to the findings dedupe semantics):
|
|
14
|
+
* - id not on disk → write (new artifact)
|
|
15
|
+
* - id on disk, status candidate → write (refresh the machine output)
|
|
16
|
+
* - id on disk, NON-candidate status (reviewed/accepted/rejected/invalidated)
|
|
17
|
+
* → COLLISION, do NOT overwrite. The operator's
|
|
18
|
+
* decision is load-bearing and survives.
|
|
19
|
+
*
|
|
20
|
+
* The decision is intentionally the conservative one the findings layer already
|
|
21
|
+
* makes: preserve the operator's status by skipping the re-write entirely. The
|
|
22
|
+
* refreshed machine content is discarded for a promoted artifact rather than
|
|
23
|
+
* merged, because merging fresh `candidate`-shaped content into an `accepted`
|
|
24
|
+
* artifact would silently mutate what the operator signed off on.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {Array<object>} candidates - Freshly-derived artifact candidates.
|
|
29
|
+
* @param {Array<{ data: object }>} existing - Already-loaded on-disk artifacts
|
|
30
|
+
* (e.g. `loadPatternsWithSkips(rootDir).entries`).
|
|
31
|
+
* @param {string} idKey - The artifact's id field ('pattern_id' |
|
|
32
|
+
* 'recommendation_id' | 'doctrine_id').
|
|
33
|
+
* @returns {{ toWrite: Array<object>, skippedUnchanged: number, collisions: Array<{ id: string, existingStatus: string }> }}
|
|
34
|
+
*/
|
|
35
|
+
export function dedupeArtifactsAgainstExisting(candidates, existing, idKey) {
|
|
36
|
+
const existingById = new Map();
|
|
37
|
+
for (const e of existing || []) {
|
|
38
|
+
const data = e?.data;
|
|
39
|
+
if (data && data[idKey]) existingById.set(data[idKey], data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const toWrite = [];
|
|
43
|
+
let skippedUnchanged = 0;
|
|
44
|
+
const collisions = [];
|
|
45
|
+
|
|
46
|
+
for (const c of candidates) {
|
|
47
|
+
const id = c?.[idKey];
|
|
48
|
+
const prior = id != null ? existingById.get(id) : undefined;
|
|
49
|
+
|
|
50
|
+
if (!prior) {
|
|
51
|
+
toWrite.push(c);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Same id exists — the operator's status is the gate.
|
|
56
|
+
if (prior.status !== 'candidate') {
|
|
57
|
+
// Promoted artifact: collision, never overwrite.
|
|
58
|
+
collisions.push({ id, existingStatus: prior.status });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Still a candidate on disk — refresh the machine output.
|
|
63
|
+
toWrite.push(c);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { toWrite, skippedUnchanged, collisions };
|
|
67
|
+
}
|
package/synthesis/index.js
CHANGED
|
@@ -8,5 +8,8 @@ export { validatePattern, validateRecommendation, validateDoctrine } from './val
|
|
|
8
8
|
export {
|
|
9
9
|
writePattern, writeRecommendation, writeDoctrine,
|
|
10
10
|
writePatterns, writeRecommendations, writeDoctrines,
|
|
11
|
-
loadPatterns, loadRecommendations, loadDoctrines
|
|
11
|
+
loadPatterns, loadRecommendations, loadDoctrines,
|
|
12
|
+
loadPatternsWithSkips, loadRecommendationsWithSkips, loadDoctrinesWithSkips,
|
|
13
|
+
resetSeenArtifactWrites
|
|
12
14
|
} from './write-artifacts.js';
|
|
15
|
+
export { dedupeArtifactsAgainstExisting } from './dedupe-artifacts.js';
|
|
@@ -12,9 +12,20 @@ import { loadFindings } from '../reader.js';
|
|
|
12
12
|
/**
|
|
13
13
|
* Derive candidate patterns from accepted findings.
|
|
14
14
|
*
|
|
15
|
+
* D2B-001 — return shape carries a `skipped: [{ path, error }]` field so the
|
|
16
|
+
* silent-loader signal that the recommendation/doctrine derive stages already
|
|
17
|
+
* surface now also propagates through pattern derivation. `loadFindings`
|
|
18
|
+
* (reader.js) returns a torn / unparseable finding YAML as
|
|
19
|
+
* `{ valid: false, errors: [{ message }] }`; a torn file that on disk WAS an
|
|
20
|
+
* accepted finding would otherwise be filtered out of clustering with zero
|
|
21
|
+
* operator signal, quietly shrinking the evidence base behind a pattern's
|
|
22
|
+
* strength or threshold. The `skipped` list documents that partial-completion
|
|
23
|
+
* honestly. Legacy callers reading only `patterns` / `stats` are unaffected —
|
|
24
|
+
* the field is additive.
|
|
25
|
+
*
|
|
15
26
|
* @param {string} rootDir - dogfood-labs repo root
|
|
16
27
|
* @param {{ includeFixtures?: boolean }} opts
|
|
17
|
-
* @returns {{ patterns: Array, stats: { findingsConsidered: number, clustersFound: number, belowThreshold: number } }}
|
|
28
|
+
* @returns {{ patterns: Array, skipped: Array<{path: string, error: string}>, stats: { findingsConsidered: number, clustersFound: number, belowThreshold: number, findingsSkipped: number } }}
|
|
18
29
|
*/
|
|
19
30
|
export function derivePatterns(rootDir, opts = {}) {
|
|
20
31
|
// Load only accepted, non-invalidated findings
|
|
@@ -23,6 +34,15 @@ export function derivePatterns(rootDir, opts = {}) {
|
|
|
23
34
|
allFindings.push(...loadFindings(rootDir, { fixtures: true, fixtureKind: 'valid' }));
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
// D2B-001 — partition torn / unreadable findings into a structured skip list
|
|
38
|
+
// BEFORE the accepted filter drops them. A `valid:false` record is a finding
|
|
39
|
+
// YAML that failed parse (reader.js sets errors[0].message to the parse error)
|
|
40
|
+
// or schema validation; its on-disk status is unknowable, so a torn file that
|
|
41
|
+
// WAS an accepted finding must not vanish silently from clustering.
|
|
42
|
+
const skipped = allFindings
|
|
43
|
+
.filter(f => f.valid === false)
|
|
44
|
+
.map(f => ({ path: f.path, error: f.errors?.[0]?.message || 'unknown read/parse error' }));
|
|
45
|
+
|
|
26
46
|
const accepted = allFindings.filter(f =>
|
|
27
47
|
f.valid &&
|
|
28
48
|
f.data?.status === 'accepted' &&
|
|
@@ -68,10 +88,12 @@ export function derivePatterns(rootDir, opts = {}) {
|
|
|
68
88
|
|
|
69
89
|
return {
|
|
70
90
|
patterns,
|
|
91
|
+
skipped, // D2B-001: structured skip list for operator legibility
|
|
71
92
|
stats: {
|
|
72
93
|
findingsConsidered: accepted.length,
|
|
73
94
|
clustersFound: clusters.size,
|
|
74
|
-
belowThreshold
|
|
95
|
+
belowThreshold,
|
|
96
|
+
findingsSkipped: skipped.length
|
|
75
97
|
}
|
|
76
98
|
};
|
|
77
99
|
}
|