@dogfood-lab/findings 1.2.2 → 1.2.3
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/advise/advice-bundle.js +155 -155
- package/advise/index.js +5 -5
- package/advise/query.js +182 -182
- package/derive/dedupe.js +107 -107
- package/derive/derive-findings.js +187 -187
- package/derive/ids.js +48 -48
- package/derive/index.js +9 -9
- package/derive/load-records.js +153 -153
- package/derive/rules.js +415 -415
- package/derive/write-findings.js +63 -63
- package/index.js +11 -11
- package/lib/file-lock.js +359 -359
- package/package.json +2 -2
- package/reader.js +156 -156
- package/review/index.js +6 -6
- package/review/transitions.js +79 -79
- package/synthesis/doctrine-derivation.js +128 -128
- package/synthesis/index.js +8 -8
- package/synthesis/pattern-derivation.js +184 -184
- package/synthesis/recommendation-derivation.js +156 -156
- package/synthesis/validate-artifacts.js +46 -46
- package/synthesis/write-artifacts.js +75 -75
- package/validate.js +87 -87
package/advise/query.js
CHANGED
|
@@ -1,182 +1,182 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Query layer over accepted dogfood learning artifacts.
|
|
3
|
-
*
|
|
4
|
-
* Retrieves findings, patterns, recommendations, and doctrine
|
|
5
|
-
* filtered by surface, execution mode, journey stage, issue kind,
|
|
6
|
-
* and transfer scope. Respects ranking and caps.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { loadFindings } from '../reader.js';
|
|
10
|
-
import { loadPatterns, loadRecommendations, loadDoctrines } from '../synthesis/write-artifacts.js';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Query accepted findings by scope.
|
|
14
|
-
*
|
|
15
|
-
* @param {string} rootDir
|
|
16
|
-
* @param {object} scope
|
|
17
|
-
* @param {string} [scope.surface]
|
|
18
|
-
* @param {string} [scope.executionMode]
|
|
19
|
-
* @param {string} [scope.journeyStage]
|
|
20
|
-
* @param {string} [scope.issueKind]
|
|
21
|
-
* @param {string} [scope.repo]
|
|
22
|
-
* @param {number} [scope.limit=8]
|
|
23
|
-
* @returns {Array}
|
|
24
|
-
*/
|
|
25
|
-
export function queryFindings(rootDir, scope = {}) {
|
|
26
|
-
const all = loadFindings(rootDir);
|
|
27
|
-
const limit = scope.limit || 8;
|
|
28
|
-
|
|
29
|
-
const accepted = all.filter(f =>
|
|
30
|
-
f.valid &&
|
|
31
|
-
f.data?.status === 'accepted' &&
|
|
32
|
-
!f.data?.invalidation?.is_invalidated
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
let results = accepted.map(f => f.data);
|
|
36
|
-
|
|
37
|
-
if (scope.surface) results = results.filter(f => f.product_surface === scope.surface);
|
|
38
|
-
if (scope.executionMode) results = results.filter(f => f.execution_mode === scope.executionMode);
|
|
39
|
-
if (scope.journeyStage) results = results.filter(f => f.journey_stage === scope.journeyStage);
|
|
40
|
-
if (scope.issueKind) results = results.filter(f => f.issue_kind === scope.issueKind);
|
|
41
|
-
if (scope.repo) results = results.filter(f => f.repo === scope.repo);
|
|
42
|
-
|
|
43
|
-
// Rank: more specific transfer_scope first, then by surface match
|
|
44
|
-
results = rankByRelevance(results, scope);
|
|
45
|
-
|
|
46
|
-
return results.slice(0, limit);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Query accepted patterns by scope.
|
|
51
|
-
*/
|
|
52
|
-
export function queryPatterns(rootDir, scope = {}) {
|
|
53
|
-
const all = loadPatterns(rootDir);
|
|
54
|
-
const limit = scope.limit || 5;
|
|
55
|
-
|
|
56
|
-
let results = all.filter(p =>
|
|
57
|
-
p.status === 'accepted' &&
|
|
58
|
-
!(p.review?.last_action === 'invalidate')
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
if (scope.surface) {
|
|
62
|
-
results = results.filter(p =>
|
|
63
|
-
(p.dimensions?.product_surfaces || []).includes(scope.surface)
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
if (scope.issueKind) {
|
|
67
|
-
results = results.filter(p =>
|
|
68
|
-
(p.dimensions?.issue_kinds || []).includes(scope.issueKind)
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Rank: strong > emerging, more specific scope first
|
|
73
|
-
results.sort((a, b) => {
|
|
74
|
-
const strengthOrder = { portfolio_stable: 0, strong: 1, emerging: 2 };
|
|
75
|
-
const aStr = strengthOrder[a.pattern_strength] ?? 3;
|
|
76
|
-
const bStr = strengthOrder[b.pattern_strength] ?? 3;
|
|
77
|
-
if (aStr !== bStr) return aStr - bStr;
|
|
78
|
-
return scopeSpecificity(b.transfer_scope) - scopeSpecificity(a.transfer_scope);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
return results.slice(0, limit);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Query accepted recommendations by scope.
|
|
86
|
-
*/
|
|
87
|
-
export function queryRecommendations(rootDir, scope = {}) {
|
|
88
|
-
const all = loadRecommendations(rootDir);
|
|
89
|
-
const limit = scope.limit || 5;
|
|
90
|
-
|
|
91
|
-
let results = all.filter(r => r.status === 'accepted');
|
|
92
|
-
|
|
93
|
-
if (scope.surface) {
|
|
94
|
-
results = results.filter(r =>
|
|
95
|
-
(r.applies_to?.product_surfaces || []).includes(scope.surface)
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
if (scope.executionMode) {
|
|
99
|
-
results = results.filter(r =>
|
|
100
|
-
!r.applies_to?.execution_modes?.length ||
|
|
101
|
-
r.applies_to.execution_modes.includes(scope.executionMode)
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Rank: strong confidence > emerging
|
|
106
|
-
results.sort((a, b) => {
|
|
107
|
-
const confOrder = { proven: 0, strong: 1, emerging: 2 };
|
|
108
|
-
return (confOrder[a.confidence] ?? 3) - (confOrder[b.confidence] ?? 3);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
return results.slice(0, limit);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Query accepted doctrine by scope.
|
|
116
|
-
*/
|
|
117
|
-
export function queryDoctrine(rootDir, scope = {}) {
|
|
118
|
-
const all = loadDoctrines(rootDir);
|
|
119
|
-
const limit = scope.limit || 5;
|
|
120
|
-
|
|
121
|
-
let results = all.filter(d => d.status === 'accepted');
|
|
122
|
-
|
|
123
|
-
if (scope.surface) {
|
|
124
|
-
// Doctrine applies if its scope is broad enough or matches the surface
|
|
125
|
-
// org_wide always applies; surface_archetype applies if pattern surfaces match
|
|
126
|
-
results = results.filter(d =>
|
|
127
|
-
d.transfer_scope === 'org_wide' ||
|
|
128
|
-
d.transfer_scope === 'execution_mode' ||
|
|
129
|
-
true // surface_archetype applies broadly — patterns already scoped it
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
results.sort((a, b) => {
|
|
134
|
-
const strOrder = { foundational: 0, proven: 1, emerging: 2 };
|
|
135
|
-
return (strOrder[a.strength] ?? 3) - (strOrder[b.strength] ?? 3);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
return results.slice(0, limit);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Extract top failure classes from accepted findings for a scope.
|
|
143
|
-
*/
|
|
144
|
-
export function queryFailureClasses(rootDir, scope = {}) {
|
|
145
|
-
const findings = queryFindings(rootDir, { ...scope, limit: 50 });
|
|
146
|
-
const counts = new Map();
|
|
147
|
-
|
|
148
|
-
for (const f of findings) {
|
|
149
|
-
const key = f.issue_kind;
|
|
150
|
-
counts.set(key, (counts.get(key) || 0) + 1);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return [...counts.entries()]
|
|
154
|
-
.sort((a, b) => b[1] - a[1])
|
|
155
|
-
.slice(0, 3)
|
|
156
|
-
.map(([issueKind, count]) => ({ issueKind, count }));
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ─── Ranking helpers ────────────────────────────────────────
|
|
160
|
-
|
|
161
|
-
const SCOPE_ORDER = ['repo_local', 'surface_local', 'surface_archetype', 'execution_mode', 'org_wide'];
|
|
162
|
-
|
|
163
|
-
function scopeSpecificity(scope) {
|
|
164
|
-
const idx = SCOPE_ORDER.indexOf(scope);
|
|
165
|
-
return idx >= 0 ? SCOPE_ORDER.length - idx : 0; // higher = more specific
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function rankByRelevance(findings, scope) {
|
|
169
|
-
return findings.sort((a, b) => {
|
|
170
|
-
// Exact surface match first
|
|
171
|
-
const aSurface = scope.surface && a.product_surface === scope.surface ? 1 : 0;
|
|
172
|
-
const bSurface = scope.surface && b.product_surface === scope.surface ? 1 : 0;
|
|
173
|
-
if (aSurface !== bSurface) return bSurface - aSurface;
|
|
174
|
-
|
|
175
|
-
// More specific scope first
|
|
176
|
-
const aSpec = scopeSpecificity(a.transfer_scope);
|
|
177
|
-
const bSpec = scopeSpecificity(b.transfer_scope);
|
|
178
|
-
if (aSpec !== bSpec) return bSpec - aSpec;
|
|
179
|
-
|
|
180
|
-
return 0;
|
|
181
|
-
});
|
|
182
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Query layer over accepted dogfood learning artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Retrieves findings, patterns, recommendations, and doctrine
|
|
5
|
+
* filtered by surface, execution mode, journey stage, issue kind,
|
|
6
|
+
* and transfer scope. Respects ranking and caps.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { loadFindings } from '../reader.js';
|
|
10
|
+
import { loadPatterns, loadRecommendations, loadDoctrines } from '../synthesis/write-artifacts.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Query accepted findings by scope.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} rootDir
|
|
16
|
+
* @param {object} scope
|
|
17
|
+
* @param {string} [scope.surface]
|
|
18
|
+
* @param {string} [scope.executionMode]
|
|
19
|
+
* @param {string} [scope.journeyStage]
|
|
20
|
+
* @param {string} [scope.issueKind]
|
|
21
|
+
* @param {string} [scope.repo]
|
|
22
|
+
* @param {number} [scope.limit=8]
|
|
23
|
+
* @returns {Array}
|
|
24
|
+
*/
|
|
25
|
+
export function queryFindings(rootDir, scope = {}) {
|
|
26
|
+
const all = loadFindings(rootDir);
|
|
27
|
+
const limit = scope.limit || 8;
|
|
28
|
+
|
|
29
|
+
const accepted = all.filter(f =>
|
|
30
|
+
f.valid &&
|
|
31
|
+
f.data?.status === 'accepted' &&
|
|
32
|
+
!f.data?.invalidation?.is_invalidated
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
let results = accepted.map(f => f.data);
|
|
36
|
+
|
|
37
|
+
if (scope.surface) results = results.filter(f => f.product_surface === scope.surface);
|
|
38
|
+
if (scope.executionMode) results = results.filter(f => f.execution_mode === scope.executionMode);
|
|
39
|
+
if (scope.journeyStage) results = results.filter(f => f.journey_stage === scope.journeyStage);
|
|
40
|
+
if (scope.issueKind) results = results.filter(f => f.issue_kind === scope.issueKind);
|
|
41
|
+
if (scope.repo) results = results.filter(f => f.repo === scope.repo);
|
|
42
|
+
|
|
43
|
+
// Rank: more specific transfer_scope first, then by surface match
|
|
44
|
+
results = rankByRelevance(results, scope);
|
|
45
|
+
|
|
46
|
+
return results.slice(0, limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Query accepted patterns by scope.
|
|
51
|
+
*/
|
|
52
|
+
export function queryPatterns(rootDir, scope = {}) {
|
|
53
|
+
const all = loadPatterns(rootDir);
|
|
54
|
+
const limit = scope.limit || 5;
|
|
55
|
+
|
|
56
|
+
let results = all.filter(p =>
|
|
57
|
+
p.status === 'accepted' &&
|
|
58
|
+
!(p.review?.last_action === 'invalidate')
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (scope.surface) {
|
|
62
|
+
results = results.filter(p =>
|
|
63
|
+
(p.dimensions?.product_surfaces || []).includes(scope.surface)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (scope.issueKind) {
|
|
67
|
+
results = results.filter(p =>
|
|
68
|
+
(p.dimensions?.issue_kinds || []).includes(scope.issueKind)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Rank: strong > emerging, more specific scope first
|
|
73
|
+
results.sort((a, b) => {
|
|
74
|
+
const strengthOrder = { portfolio_stable: 0, strong: 1, emerging: 2 };
|
|
75
|
+
const aStr = strengthOrder[a.pattern_strength] ?? 3;
|
|
76
|
+
const bStr = strengthOrder[b.pattern_strength] ?? 3;
|
|
77
|
+
if (aStr !== bStr) return aStr - bStr;
|
|
78
|
+
return scopeSpecificity(b.transfer_scope) - scopeSpecificity(a.transfer_scope);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return results.slice(0, limit);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Query accepted recommendations by scope.
|
|
86
|
+
*/
|
|
87
|
+
export function queryRecommendations(rootDir, scope = {}) {
|
|
88
|
+
const all = loadRecommendations(rootDir);
|
|
89
|
+
const limit = scope.limit || 5;
|
|
90
|
+
|
|
91
|
+
let results = all.filter(r => r.status === 'accepted');
|
|
92
|
+
|
|
93
|
+
if (scope.surface) {
|
|
94
|
+
results = results.filter(r =>
|
|
95
|
+
(r.applies_to?.product_surfaces || []).includes(scope.surface)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (scope.executionMode) {
|
|
99
|
+
results = results.filter(r =>
|
|
100
|
+
!r.applies_to?.execution_modes?.length ||
|
|
101
|
+
r.applies_to.execution_modes.includes(scope.executionMode)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Rank: strong confidence > emerging
|
|
106
|
+
results.sort((a, b) => {
|
|
107
|
+
const confOrder = { proven: 0, strong: 1, emerging: 2 };
|
|
108
|
+
return (confOrder[a.confidence] ?? 3) - (confOrder[b.confidence] ?? 3);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return results.slice(0, limit);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Query accepted doctrine by scope.
|
|
116
|
+
*/
|
|
117
|
+
export function queryDoctrine(rootDir, scope = {}) {
|
|
118
|
+
const all = loadDoctrines(rootDir);
|
|
119
|
+
const limit = scope.limit || 5;
|
|
120
|
+
|
|
121
|
+
let results = all.filter(d => d.status === 'accepted');
|
|
122
|
+
|
|
123
|
+
if (scope.surface) {
|
|
124
|
+
// Doctrine applies if its scope is broad enough or matches the surface
|
|
125
|
+
// org_wide always applies; surface_archetype applies if pattern surfaces match
|
|
126
|
+
results = results.filter(d =>
|
|
127
|
+
d.transfer_scope === 'org_wide' ||
|
|
128
|
+
d.transfer_scope === 'execution_mode' ||
|
|
129
|
+
true // surface_archetype applies broadly — patterns already scoped it
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
results.sort((a, b) => {
|
|
134
|
+
const strOrder = { foundational: 0, proven: 1, emerging: 2 };
|
|
135
|
+
return (strOrder[a.strength] ?? 3) - (strOrder[b.strength] ?? 3);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return results.slice(0, limit);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract top failure classes from accepted findings for a scope.
|
|
143
|
+
*/
|
|
144
|
+
export function queryFailureClasses(rootDir, scope = {}) {
|
|
145
|
+
const findings = queryFindings(rootDir, { ...scope, limit: 50 });
|
|
146
|
+
const counts = new Map();
|
|
147
|
+
|
|
148
|
+
for (const f of findings) {
|
|
149
|
+
const key = f.issue_kind;
|
|
150
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return [...counts.entries()]
|
|
154
|
+
.sort((a, b) => b[1] - a[1])
|
|
155
|
+
.slice(0, 3)
|
|
156
|
+
.map(([issueKind, count]) => ({ issueKind, count }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Ranking helpers ────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const SCOPE_ORDER = ['repo_local', 'surface_local', 'surface_archetype', 'execution_mode', 'org_wide'];
|
|
162
|
+
|
|
163
|
+
function scopeSpecificity(scope) {
|
|
164
|
+
const idx = SCOPE_ORDER.indexOf(scope);
|
|
165
|
+
return idx >= 0 ? SCOPE_ORDER.length - idx : 0; // higher = more specific
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rankByRelevance(findings, scope) {
|
|
169
|
+
return findings.sort((a, b) => {
|
|
170
|
+
// Exact surface match first
|
|
171
|
+
const aSurface = scope.surface && a.product_surface === scope.surface ? 1 : 0;
|
|
172
|
+
const bSurface = scope.surface && b.product_surface === scope.surface ? 1 : 0;
|
|
173
|
+
if (aSurface !== bSurface) return bSurface - aSurface;
|
|
174
|
+
|
|
175
|
+
// More specific scope first
|
|
176
|
+
const aSpec = scopeSpecificity(a.transfer_scope);
|
|
177
|
+
const bSpec = scopeSpecificity(b.transfer_scope);
|
|
178
|
+
if (aSpec !== bSpec) return bSpec - aSpec;
|
|
179
|
+
|
|
180
|
+
return 0;
|
|
181
|
+
});
|
|
182
|
+
}
|
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
|
+
}
|