@dogfood-lab/findings 1.2.2 → 1.3.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/derive/dedupe.js CHANGED
@@ -1,107 +1,107 @@
1
- /**
2
- * Deduplication logic for derived candidate findings.
3
- *
4
- * Dedupe law:
5
- * - Same record re-derived → same ID → skip if identical candidate exists
6
- * - Same ID with non-candidate status → collision, not overwrite
7
- * - Multiple evidence points in one record that map to one lesson → one finding
8
- */
9
-
10
- import { computeDedupeKey } from './ids.js';
11
-
12
- /**
13
- * Deduplicate a list of raw derived candidates.
14
- * Returns unique candidates with collision info.
15
- *
16
- * @param {Array} candidates - Raw derived candidates from rules.
17
- * @returns {{ unique: Array, skipped: number }}
18
- */
19
- export function dedupeWithinBatch(candidates) {
20
- const seen = new Map();
21
- const unique = [];
22
- let skipped = 0;
23
-
24
- for (const c of candidates) {
25
- const key = computeDedupeKey({
26
- repo: c.repo,
27
- issue_kind: c.issue_kind,
28
- root_cause_kind: c.root_cause_kind,
29
- journey_stage: c.journey_stage,
30
- slug: c.finding_id
31
- });
32
-
33
- if (seen.has(key)) {
34
- skipped++;
35
- continue;
36
- }
37
- seen.set(key, true);
38
- unique.push(c);
39
- }
40
-
41
- return { unique, skipped };
42
- }
43
-
44
- /**
45
- * Check existing findings on disk for collisions.
46
- *
47
- * @param {Array} candidates - Candidate findings to check.
48
- * @param {Array<{ data: object }>} existingFindings - Already-loaded findings from disk.
49
- * @returns {{ toWrite: Array, skippedUnchanged: number, collisions: Array<{ findingId: string, existingStatus: string }> }}
50
- */
51
- export function dedupeAgainstExisting(candidates, existingFindings) {
52
- const existingById = new Map();
53
- for (const f of existingFindings) {
54
- if (f.data?.finding_id) {
55
- existingById.set(f.data.finding_id, f.data);
56
- }
57
- }
58
-
59
- const toWrite = [];
60
- let skippedUnchanged = 0;
61
- const collisions = [];
62
-
63
- for (const c of candidates) {
64
- const existing = existingById.get(c.finding_id);
65
-
66
- if (!existing) {
67
- toWrite.push(c);
68
- continue;
69
- }
70
-
71
- // Same ID exists — check status
72
- if (existing.status !== 'candidate') {
73
- // Non-candidate status: collision, don't overwrite
74
- collisions.push({
75
- findingId: c.finding_id,
76
- existingStatus: existing.status
77
- });
78
- continue;
79
- }
80
-
81
- // Same ID, still candidate — skip if unchanged
82
- if (isSameCandidate(c, existing)) {
83
- skippedUnchanged++;
84
- continue;
85
- }
86
-
87
- // Same ID, still candidate, but content changed — refresh
88
- toWrite.push(c);
89
- }
90
-
91
- return { toWrite, skippedUnchanged, collisions };
92
- }
93
-
94
- /**
95
- * Check if two findings are substantively identical.
96
- * Ignores timestamps and derivation metadata.
97
- */
98
- function isSameCandidate(a, b) {
99
- return (
100
- a.issue_kind === b.issue_kind &&
101
- a.root_cause_kind === b.root_cause_kind &&
102
- a.remediation_kind === b.remediation_kind &&
103
- a.transfer_scope === b.transfer_scope &&
104
- a.summary === b.summary &&
105
- a.title === b.title
106
- );
107
- }
1
+ /**
2
+ * Deduplication logic for derived candidate findings.
3
+ *
4
+ * Dedupe law:
5
+ * - Same record re-derived → same ID → skip if identical candidate exists
6
+ * - Same ID with non-candidate status → collision, not overwrite
7
+ * - Multiple evidence points in one record that map to one lesson → one finding
8
+ */
9
+
10
+ import { computeDedupeKey } from './ids.js';
11
+
12
+ /**
13
+ * Deduplicate a list of raw derived candidates.
14
+ * Returns unique candidates with collision info.
15
+ *
16
+ * @param {Array} candidates - Raw derived candidates from rules.
17
+ * @returns {{ unique: Array, skipped: number }}
18
+ */
19
+ export function dedupeWithinBatch(candidates) {
20
+ const seen = new Map();
21
+ const unique = [];
22
+ let skipped = 0;
23
+
24
+ for (const c of candidates) {
25
+ const key = computeDedupeKey({
26
+ repo: c.repo,
27
+ issue_kind: c.issue_kind,
28
+ root_cause_kind: c.root_cause_kind,
29
+ journey_stage: c.journey_stage,
30
+ slug: c.finding_id
31
+ });
32
+
33
+ if (seen.has(key)) {
34
+ skipped++;
35
+ continue;
36
+ }
37
+ seen.set(key, true);
38
+ unique.push(c);
39
+ }
40
+
41
+ return { unique, skipped };
42
+ }
43
+
44
+ /**
45
+ * Check existing findings on disk for collisions.
46
+ *
47
+ * @param {Array} candidates - Candidate findings to check.
48
+ * @param {Array<{ data: object }>} existingFindings - Already-loaded findings from disk.
49
+ * @returns {{ toWrite: Array, skippedUnchanged: number, collisions: Array<{ findingId: string, existingStatus: string }> }}
50
+ */
51
+ export function dedupeAgainstExisting(candidates, existingFindings) {
52
+ const existingById = new Map();
53
+ for (const f of existingFindings) {
54
+ if (f.data?.finding_id) {
55
+ existingById.set(f.data.finding_id, f.data);
56
+ }
57
+ }
58
+
59
+ const toWrite = [];
60
+ let skippedUnchanged = 0;
61
+ const collisions = [];
62
+
63
+ for (const c of candidates) {
64
+ const existing = existingById.get(c.finding_id);
65
+
66
+ if (!existing) {
67
+ toWrite.push(c);
68
+ continue;
69
+ }
70
+
71
+ // Same ID exists — check status
72
+ if (existing.status !== 'candidate') {
73
+ // Non-candidate status: collision, don't overwrite
74
+ collisions.push({
75
+ findingId: c.finding_id,
76
+ existingStatus: existing.status
77
+ });
78
+ continue;
79
+ }
80
+
81
+ // Same ID, still candidate — skip if unchanged
82
+ if (isSameCandidate(c, existing)) {
83
+ skippedUnchanged++;
84
+ continue;
85
+ }
86
+
87
+ // Same ID, still candidate, but content changed — refresh
88
+ toWrite.push(c);
89
+ }
90
+
91
+ return { toWrite, skippedUnchanged, collisions };
92
+ }
93
+
94
+ /**
95
+ * Check if two findings are substantively identical.
96
+ * Ignores timestamps and derivation metadata.
97
+ */
98
+ function isSameCandidate(a, b) {
99
+ return (
100
+ a.issue_kind === b.issue_kind &&
101
+ a.root_cause_kind === b.root_cause_kind &&
102
+ a.remediation_kind === b.remediation_kind &&
103
+ a.transfer_scope === b.transfer_scope &&
104
+ a.summary === b.summary &&
105
+ a.title === b.title
106
+ );
107
+ }
@@ -1,187 +1,187 @@
1
- /**
2
- * Main derivation engine.
3
- * Takes verified dogfood records and deterministically emits candidate findings.
4
- *
5
- * Usage:
6
- * deriveFromRecord(record, { rejected }) → CandidateFinding[]
7
- * deriveFromRecords(records) → { candidates, stats }
8
- */
9
-
10
- import { RULES } from './rules.js';
11
- import { generateFindingId } from './ids.js';
12
- import { dedupeWithinBatch } from './dedupe.js';
13
-
14
- /**
15
- * Derive candidate findings from a single verified record.
16
- *
17
- * Returns a plain array of candidates for back-compat. To get per-rule errors,
18
- * use `deriveFromRecordWithErrors` which returns `{ candidates, ruleErrors }`.
19
- *
20
- * @param {object} record - Full persisted dogfood record.
21
- * @param {{ rejected?: boolean }} opts
22
- * @returns {Array} - Zero or more schema-valid candidate finding objects.
23
- */
24
- export function deriveFromRecord(record, opts = {}) {
25
- return deriveFromRecordWithErrors(record, opts).candidates;
26
- }
27
-
28
- /**
29
- * Derive candidate findings from a single verified record, exposing per-rule errors.
30
- *
31
- * Each rule's `applies` and `derive` runs in isolation. A rule that throws does
32
- * NOT crash the engine, but the error IS recorded in `ruleErrors` and ALSO
33
- * logged to stderr so operators see it in CI logs. Callers MUST treat a non-empty
34
- * ruleErrors as a partial failure — the engine "succeeded" only if `ruleErrors`
35
- * is empty.
36
- *
37
- * @param {object} record
38
- * @param {{ rejected?: boolean }} opts
39
- * @returns {{ candidates: Array, ruleErrors: Array<{ ruleId: string, runId: string, message: string }> }}
40
- */
41
- export function deriveFromRecordWithErrors(record, opts = {}) {
42
- const rejected = opts.rejected ?? false;
43
- const repo = record.repo || '';
44
- const repoSlug = repo.split('/').pop() || 'unknown';
45
- const runId = record.run_id || 'unknown';
46
- const now = new Date().toISOString();
47
-
48
- const raw = [];
49
- const ruleErrors = [];
50
-
51
- // Iterate per-scenario so multi-scenario records emit findings tied to the
52
- // RIGHT scenario — not just scenario_results[0]. The scenario-aware helpers
53
- // in rules.js (scenarioSurface, scenarioMode, scenarioId, failedSteps,
54
- // scenarioVerdict) all read index 0; instead of touching every rule, we
55
- // present each rule a per-scenario VIEW of the record where scenario_results
56
- // contains only that one scenario. The finding's execution_mode and
57
- // scenario_ids in assembleFinding then come from the right scenario.
58
- //
59
- // Backward compat: a single-scenario record runs one iteration with the
60
- // same scenario at index 0 → byte-identical output to the old code path.
61
- // Rules that already iterated all scenarios internally (rule-blocked-scenario,
62
- // rule-execution-mode-gap) emit per-iteration findings; dedupeWithinBatch
63
- // collapses any duplicates by dedupe key.
64
- const scenarios = Array.isArray(record.scenario_results) && record.scenario_results.length > 0
65
- ? record.scenario_results
66
- : [null];
67
-
68
- for (const scenario of scenarios) {
69
- const scenarioView = scenario === null
70
- ? record
71
- : { ...record, scenario_results: [scenario] };
72
- const ctx = { record: scenarioView, rejected, repoSlug };
73
-
74
- for (const rule of RULES) {
75
- try {
76
- if (rule.applies(ctx)) {
77
- const emitted = rule.derive(ctx);
78
- for (const e of emitted) {
79
- raw.push(assembleFinding(e, scenarioView, rule, repoSlug, now));
80
- }
81
- }
82
- } catch (err) {
83
- const message = err && err.message ? err.message : String(err);
84
- const entry = { ruleId: rule.ruleId, runId, message };
85
- ruleErrors.push(entry);
86
- // Surface the failure on stderr so operators see it in CI logs.
87
- // Do NOT crash the engine — other rules still get a chance to run.
88
- console.error(
89
- `[derive] rule '${rule.ruleId}' threw on run_id=${runId}: ${message}`
90
- );
91
- }
92
- }
93
- }
94
-
95
- // Dedupe within this record's batch
96
- const { unique } = dedupeWithinBatch(raw);
97
- return { candidates: unique, ruleErrors };
98
- }
99
-
100
- /**
101
- * Derive candidate findings from multiple records.
102
- *
103
- * The returned `ruleErrors` collects every per-rule throw across every record.
104
- * `stats.ruleErrors` is the count for quick scoreboarding. Callers MUST treat a
105
- * non-zero ruleErrors count as a partial failure — derivation did NOT fully
106
- * succeed if any rule threw.
107
- *
108
- * @param {Array<{ record: object, rejected: boolean }>} entries
109
- * @returns {{ candidates: Array, ruleErrors: Array<{ ruleId: string, runId: string, message: string }>, stats: { recordsProcessed: number, rulesEvaluated: number, candidatesEmitted: number, deduped: number, ruleErrors: number } }}
110
- */
111
- export function deriveFromRecords(entries) {
112
- const allCandidates = [];
113
- const allRuleErrors = [];
114
- let rulesEvaluated = 0;
115
-
116
- for (const entry of entries) {
117
- rulesEvaluated += RULES.length;
118
- const { candidates, ruleErrors } = deriveFromRecordWithErrors(
119
- entry.record,
120
- { rejected: entry.rejected }
121
- );
122
- allCandidates.push(...candidates);
123
- allRuleErrors.push(...ruleErrors);
124
- }
125
-
126
- const { unique, skipped } = dedupeWithinBatch(allCandidates);
127
-
128
- return {
129
- candidates: unique,
130
- ruleErrors: allRuleErrors,
131
- stats: {
132
- recordsProcessed: entries.length,
133
- rulesEvaluated,
134
- candidatesEmitted: unique.length,
135
- deduped: skipped,
136
- ruleErrors: allRuleErrors.length
137
- }
138
- };
139
- }
140
-
141
- /**
142
- * Assemble a full schema-valid finding object from rule output.
143
- */
144
- function assembleFinding(raw, record, rule, repoSlug, now) {
145
- const findingId = generateFindingId(repoSlug, raw.slug);
146
-
147
- return {
148
- schema_version: '1.0.0',
149
- finding_id: findingId,
150
- title: raw.title,
151
- status: 'candidate',
152
- repo: record.repo,
153
- product_surface: raw.product_surface,
154
- execution_mode: record.scenario_results?.[0]?.execution_mode,
155
- journey_stage: raw.journey_stage,
156
- issue_kind: raw.issue_kind,
157
- root_cause_kind: raw.root_cause_kind,
158
- remediation_kind: raw.remediation_kind,
159
- transfer_scope: raw.transfer_scope,
160
- summary: raw.summary,
161
- source_record_ids: [record.run_id],
162
- scenario_ids: record.scenario_results
163
- ?.map(s => s.scenario_id)
164
- .filter(Boolean) || [],
165
- evidence: raw.evidence,
166
- derived: {
167
- method: 'deterministic_rule',
168
- rule_id: rule.ruleId,
169
- derived_at: now,
170
- rationale: raw.rationale
171
- },
172
- created_at: now,
173
- updated_at: now
174
- };
175
- }
176
-
177
- /**
178
- * Get the rule inventory (for explain/list).
179
- */
180
- export function getRuleInventory() {
181
- return RULES.map(r => ({
182
- ruleId: r.ruleId,
183
- description: r.description
184
- }));
185
- }
186
-
187
- export { RULES };
1
+ /**
2
+ * Main derivation engine.
3
+ * Takes verified dogfood records and deterministically emits candidate findings.
4
+ *
5
+ * Usage:
6
+ * deriveFromRecord(record, { rejected }) → CandidateFinding[]
7
+ * deriveFromRecords(records) → { candidates, stats }
8
+ */
9
+
10
+ import { RULES } from './rules.js';
11
+ import { generateFindingId } from './ids.js';
12
+ import { dedupeWithinBatch } from './dedupe.js';
13
+
14
+ /**
15
+ * Derive candidate findings from a single verified record.
16
+ *
17
+ * Returns a plain array of candidates for back-compat. To get per-rule errors,
18
+ * use `deriveFromRecordWithErrors` which returns `{ candidates, ruleErrors }`.
19
+ *
20
+ * @param {object} record - Full persisted dogfood record.
21
+ * @param {{ rejected?: boolean }} opts
22
+ * @returns {Array} - Zero or more schema-valid candidate finding objects.
23
+ */
24
+ export function deriveFromRecord(record, opts = {}) {
25
+ return deriveFromRecordWithErrors(record, opts).candidates;
26
+ }
27
+
28
+ /**
29
+ * Derive candidate findings from a single verified record, exposing per-rule errors.
30
+ *
31
+ * Each rule's `applies` and `derive` runs in isolation. A rule that throws does
32
+ * NOT crash the engine, but the error IS recorded in `ruleErrors` and ALSO
33
+ * logged to stderr so operators see it in CI logs. Callers MUST treat a non-empty
34
+ * ruleErrors as a partial failure — the engine "succeeded" only if `ruleErrors`
35
+ * is empty.
36
+ *
37
+ * @param {object} record
38
+ * @param {{ rejected?: boolean }} opts
39
+ * @returns {{ candidates: Array, ruleErrors: Array<{ ruleId: string, runId: string, message: string }> }}
40
+ */
41
+ export function deriveFromRecordWithErrors(record, opts = {}) {
42
+ const rejected = opts.rejected ?? false;
43
+ const repo = record.repo || '';
44
+ const repoSlug = repo.split('/').pop() || 'unknown';
45
+ const runId = record.run_id || 'unknown';
46
+ const now = new Date().toISOString();
47
+
48
+ const raw = [];
49
+ const ruleErrors = [];
50
+
51
+ // Iterate per-scenario so multi-scenario records emit findings tied to the
52
+ // RIGHT scenario — not just scenario_results[0]. The scenario-aware helpers
53
+ // in rules.js (scenarioSurface, scenarioMode, scenarioId, failedSteps,
54
+ // scenarioVerdict) all read index 0; instead of touching every rule, we
55
+ // present each rule a per-scenario VIEW of the record where scenario_results
56
+ // contains only that one scenario. The finding's execution_mode and
57
+ // scenario_ids in assembleFinding then come from the right scenario.
58
+ //
59
+ // Backward compat: a single-scenario record runs one iteration with the
60
+ // same scenario at index 0 → byte-identical output to the old code path.
61
+ // Rules that already iterated all scenarios internally (rule-blocked-scenario,
62
+ // rule-execution-mode-gap) emit per-iteration findings; dedupeWithinBatch
63
+ // collapses any duplicates by dedupe key.
64
+ const scenarios = Array.isArray(record.scenario_results) && record.scenario_results.length > 0
65
+ ? record.scenario_results
66
+ : [null];
67
+
68
+ for (const scenario of scenarios) {
69
+ const scenarioView = scenario === null
70
+ ? record
71
+ : { ...record, scenario_results: [scenario] };
72
+ const ctx = { record: scenarioView, rejected, repoSlug };
73
+
74
+ for (const rule of RULES) {
75
+ try {
76
+ if (rule.applies(ctx)) {
77
+ const emitted = rule.derive(ctx);
78
+ for (const e of emitted) {
79
+ raw.push(assembleFinding(e, scenarioView, rule, repoSlug, now));
80
+ }
81
+ }
82
+ } catch (err) {
83
+ const message = err && err.message ? err.message : String(err);
84
+ const entry = { ruleId: rule.ruleId, runId, message };
85
+ ruleErrors.push(entry);
86
+ // Surface the failure on stderr so operators see it in CI logs.
87
+ // Do NOT crash the engine — other rules still get a chance to run.
88
+ console.error(
89
+ `[derive] rule '${rule.ruleId}' threw on run_id=${runId}: ${message}`
90
+ );
91
+ }
92
+ }
93
+ }
94
+
95
+ // Dedupe within this record's batch
96
+ const { unique } = dedupeWithinBatch(raw);
97
+ return { candidates: unique, ruleErrors };
98
+ }
99
+
100
+ /**
101
+ * Derive candidate findings from multiple records.
102
+ *
103
+ * The returned `ruleErrors` collects every per-rule throw across every record.
104
+ * `stats.ruleErrors` is the count for quick scoreboarding. Callers MUST treat a
105
+ * non-zero ruleErrors count as a partial failure — derivation did NOT fully
106
+ * succeed if any rule threw.
107
+ *
108
+ * @param {Array<{ record: object, rejected: boolean }>} entries
109
+ * @returns {{ candidates: Array, ruleErrors: Array<{ ruleId: string, runId: string, message: string }>, stats: { recordsProcessed: number, rulesEvaluated: number, candidatesEmitted: number, deduped: number, ruleErrors: number } }}
110
+ */
111
+ export function deriveFromRecords(entries) {
112
+ const allCandidates = [];
113
+ const allRuleErrors = [];
114
+ let rulesEvaluated = 0;
115
+
116
+ for (const entry of entries) {
117
+ rulesEvaluated += RULES.length;
118
+ const { candidates, ruleErrors } = deriveFromRecordWithErrors(
119
+ entry.record,
120
+ { rejected: entry.rejected }
121
+ );
122
+ allCandidates.push(...candidates);
123
+ allRuleErrors.push(...ruleErrors);
124
+ }
125
+
126
+ const { unique, skipped } = dedupeWithinBatch(allCandidates);
127
+
128
+ return {
129
+ candidates: unique,
130
+ ruleErrors: allRuleErrors,
131
+ stats: {
132
+ recordsProcessed: entries.length,
133
+ rulesEvaluated,
134
+ candidatesEmitted: unique.length,
135
+ deduped: skipped,
136
+ ruleErrors: allRuleErrors.length
137
+ }
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Assemble a full schema-valid finding object from rule output.
143
+ */
144
+ function assembleFinding(raw, record, rule, repoSlug, now) {
145
+ const findingId = generateFindingId(repoSlug, raw.slug);
146
+
147
+ return {
148
+ schema_version: '1.0.0',
149
+ finding_id: findingId,
150
+ title: raw.title,
151
+ status: 'candidate',
152
+ repo: record.repo,
153
+ product_surface: raw.product_surface,
154
+ execution_mode: record.scenario_results?.[0]?.execution_mode,
155
+ journey_stage: raw.journey_stage,
156
+ issue_kind: raw.issue_kind,
157
+ root_cause_kind: raw.root_cause_kind,
158
+ remediation_kind: raw.remediation_kind,
159
+ transfer_scope: raw.transfer_scope,
160
+ summary: raw.summary,
161
+ source_record_ids: [record.run_id],
162
+ scenario_ids: record.scenario_results
163
+ ?.map(s => s.scenario_id)
164
+ .filter(Boolean) || [],
165
+ evidence: raw.evidence,
166
+ derived: {
167
+ method: 'deterministic_rule',
168
+ rule_id: rule.ruleId,
169
+ derived_at: now,
170
+ rationale: raw.rationale
171
+ },
172
+ created_at: now,
173
+ updated_at: now
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Get the rule inventory (for explain/list).
179
+ */
180
+ export function getRuleInventory() {
181
+ return RULES.map(r => ({
182
+ ruleId: r.ruleId,
183
+ description: r.description
184
+ }));
185
+ }
186
+
187
+ export { RULES };