@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
|
@@ -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 };
|
package/derive/ids.js
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stable ID generation and dedupe key computation for derived findings.
|
|
3
|
-
*
|
|
4
|
-
* ID law: same record + same rule + same lesson slug = same finding ID.
|
|
5
|
-
* No timestamp noise in IDs.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Generate a stable finding ID from derivation context.
|
|
10
|
-
* Format: dfind-<repo-slug>-<lesson-slug>
|
|
11
|
-
*
|
|
12
|
-
* @param {string} repoSlug - e.g. "repo-crawler-mcp"
|
|
13
|
-
* @param {string} lessonSlug - e.g. "surface-misclassification"
|
|
14
|
-
* @returns {string}
|
|
15
|
-
*/
|
|
16
|
-
export function generateFindingId(repoSlug, lessonSlug) {
|
|
17
|
-
const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
|
|
18
|
-
return normalized;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Compute a dedupe key for collision detection.
|
|
23
|
-
* Two findings with the same dedupe key are considered the same lesson.
|
|
24
|
-
*
|
|
25
|
-
* @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
|
|
26
|
-
* @returns {string}
|
|
27
|
-
*/
|
|
28
|
-
export function computeDedupeKey(fields) {
|
|
29
|
-
return [
|
|
30
|
-
fields.repo,
|
|
31
|
-
fields.issue_kind,
|
|
32
|
-
fields.root_cause_kind,
|
|
33
|
-
fields.journey_stage,
|
|
34
|
-
fields.slug
|
|
35
|
-
].join('::');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Sanitize a string for use in finding IDs.
|
|
40
|
-
* Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim.
|
|
41
|
-
*/
|
|
42
|
-
function sanitize(s) {
|
|
43
|
-
return s
|
|
44
|
-
.toLowerCase()
|
|
45
|
-
.replace(/[^a-z0-9-]/g, '-')
|
|
46
|
-
.replace(/-+/g, '-')
|
|
47
|
-
.replace(/^-|-$/g, '');
|
|
48
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Stable ID generation and dedupe key computation for derived findings.
|
|
3
|
+
*
|
|
4
|
+
* ID law: same record + same rule + same lesson slug = same finding ID.
|
|
5
|
+
* No timestamp noise in IDs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a stable finding ID from derivation context.
|
|
10
|
+
* Format: dfind-<repo-slug>-<lesson-slug>
|
|
11
|
+
*
|
|
12
|
+
* @param {string} repoSlug - e.g. "repo-crawler-mcp"
|
|
13
|
+
* @param {string} lessonSlug - e.g. "surface-misclassification"
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
export function generateFindingId(repoSlug, lessonSlug) {
|
|
17
|
+
const normalized = `dfind-${sanitize(repoSlug)}-${sanitize(lessonSlug)}`;
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute a dedupe key for collision detection.
|
|
23
|
+
* Two findings with the same dedupe key are considered the same lesson.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ repo: string, issue_kind: string, root_cause_kind: string, journey_stage: string, slug: string }} fields
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function computeDedupeKey(fields) {
|
|
29
|
+
return [
|
|
30
|
+
fields.repo,
|
|
31
|
+
fields.issue_kind,
|
|
32
|
+
fields.root_cause_kind,
|
|
33
|
+
fields.journey_stage,
|
|
34
|
+
fields.slug
|
|
35
|
+
].join('::');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize a string for use in finding IDs.
|
|
40
|
+
* Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim.
|
|
41
|
+
*/
|
|
42
|
+
function sanitize(s) {
|
|
43
|
+
return s
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
46
|
+
.replace(/-+/g, '-')
|
|
47
|
+
.replace(/^-|-$/g, '');
|
|
48
|
+
}
|
package/derive/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Derivation engine exports.
|
|
3
|
-
*/
|
|
4
|
-
export { deriveFromRecord, deriveFromRecordWithErrors, deriveFromRecords, getRuleInventory, RULES } from './derive-findings.js';
|
|
5
|
-
export { generateFindingId, computeDedupeKey } from './ids.js';
|
|
6
|
-
export { dedupeWithinBatch, dedupeAgainstExisting } from './dedupe.js';
|
|
7
|
-
export { loadRecordsForRepo, loadRecordById, loadAllRecords } from './load-records.js';
|
|
8
|
-
export { writeFinding, writeFindings } from './write-findings.js';
|
|
9
|
-
export { getRuleById } from './rules.js';
|
|
1
|
+
/**
|
|
2
|
+
* Derivation engine exports.
|
|
3
|
+
*/
|
|
4
|
+
export { deriveFromRecord, deriveFromRecordWithErrors, deriveFromRecords, getRuleInventory, RULES } from './derive-findings.js';
|
|
5
|
+
export { generateFindingId, computeDedupeKey } from './ids.js';
|
|
6
|
+
export { dedupeWithinBatch, dedupeAgainstExisting } from './dedupe.js';
|
|
7
|
+
export { loadRecordsForRepo, loadRecordById, loadAllRecords } from './load-records.js';
|
|
8
|
+
export { writeFinding, writeFindings } from './write-findings.js';
|
|
9
|
+
export { getRuleById } from './rules.js';
|