@dev-loops/core 0.1.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/bin/capture-deep-persona-signals.mjs +143 -0
- package/bin/ensure-phase-files.mjs +7 -0
- package/bin/log-bash-exit-1.mjs +7 -0
- package/bin/parse-review-threads.mjs +7 -0
- package/package.json +78 -0
- package/src/analysis/change-classifier.mjs +146 -0
- package/src/analysis/diff-analyzer.mjs +285 -0
- package/src/bash-exit-one.mjs +130 -0
- package/src/cli/helpers.mjs +22 -0
- package/src/cli/primitives.mjs +70 -0
- package/src/cli/retry-wrapper.mjs +169 -0
- package/src/cli/subcommand-runner.mjs +246 -0
- package/src/config/config.mjs +965 -0
- package/src/debt/cluster.mjs +240 -0
- package/src/debt/debt-finding.mjs +68 -0
- package/src/debt/debt-signal.mjs +46 -0
- package/src/debt/deep-persona-signals.mjs +266 -0
- package/src/debt/remediation-to-issue.mjs +121 -0
- package/src/debt/score.mjs +127 -0
- package/src/debt/shape.mjs +214 -0
- package/src/github/copilot-helpers.mjs +343 -0
- package/src/github/repo-slug.mjs +105 -0
- package/src/github/review-threads.mjs +343 -0
- package/src/harness/adapter.mjs +57 -0
- package/src/harness/index.mjs +3 -0
- package/src/harness/noop-adapter.mjs +22 -0
- package/src/harness/pi-adapter.mjs +47 -0
- package/src/loop/async-start-contract.mjs +170 -0
- package/src/loop/conductor-routing.mjs +817 -0
- package/src/loop/copilot-ci-status.mjs +255 -0
- package/src/loop/copilot-loop-iterations.mjs +161 -0
- package/src/loop/copilot-loop-state.mjs +510 -0
- package/src/loop/handoff-envelope.mjs +800 -0
- package/src/loop/issue-refinement-artifact.mjs +268 -0
- package/src/loop/lifecycle-state.mjs +342 -0
- package/src/loop/phase-files.mjs +187 -0
- package/src/loop/policy-constants.mjs +17 -0
- package/src/loop/pr-gate-coordination.mjs +1278 -0
- package/src/loop/public-dev-loop-routing-contract.mjs +277 -0
- package/src/loop/public-dev-loop-routing.mjs +1746 -0
- package/src/loop/queue-board-ordering.mjs +38 -0
- package/src/loop/queue-board-sync.mjs +223 -0
- package/src/loop/queue-driver.mjs +164 -0
- package/src/loop/queue-parallel.mjs +190 -0
- package/src/loop/queue-state.mjs +230 -0
- package/src/loop/retrospective-checkpoint.mjs +178 -0
- package/src/loop/reviewer-loop-state.mjs +456 -0
- package/src/loop/run-inspection.mjs +604 -0
- package/src/loop/steering.mjs +793 -0
- package/src/loop/timeout-policy.mjs +73 -0
- package/src/loop/tracker-first-loop-state.mjs +87 -0
- package/src/loop/tracker-pr-state.mjs +301 -0
- package/src/loop/worktree-guard.mjs +141 -0
- package/src/refinement/ac-dod-matrix.mjs +95 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Remediation item → GitHub issue bridge
|
|
3
|
+
//
|
|
4
|
+
// Converts a shaped remediation_item into a GitHub issue via `gh issue create`
|
|
5
|
+
// so the debt pipeline can feed into the existing dev-loop execution path.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { RemediationItemSchema } from "./debt-finding.mjs";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Payload builder
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert a remediation_item artifact into a GitHub-issue-compatible payload
|
|
17
|
+
* with structured body, acceptance criteria, and debt labels.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} remediationItem — RemediationItemSchema-valid artifact
|
|
20
|
+
* @returns {{ title: string, body: string, labels: string[] }}
|
|
21
|
+
*/
|
|
22
|
+
export function remediationToIssuePayload(remediationItem) {
|
|
23
|
+
const parsed = RemediationItemSchema.parse(remediationItem);
|
|
24
|
+
const acSection = parsed.acceptanceCriteria
|
|
25
|
+
.map((ac, i) => `${i + 1}. ${ac}`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
|
|
28
|
+
const body = [
|
|
29
|
+
`## Remediation Item`,
|
|
30
|
+
``,
|
|
31
|
+
`**Finding ID:** ${parsed.findingId}`,
|
|
32
|
+
`**Score:** ${parsed.score}`,
|
|
33
|
+
`**Primary file:** ${parsed.primaryFilePath || "N/A"}`,
|
|
34
|
+
``,
|
|
35
|
+
`### Description`,
|
|
36
|
+
parsed.description,
|
|
37
|
+
``,
|
|
38
|
+
`### Acceptance Criteria`,
|
|
39
|
+
acSection,
|
|
40
|
+
``,
|
|
41
|
+
`### Source`,
|
|
42
|
+
`Generated by debt pipeline from ${parsed.signalIds.length} signal(s).`,
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
title: parsed.title,
|
|
47
|
+
body,
|
|
48
|
+
labels: ["workflow"],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Error message extraction
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Coerce an error's stderr into a string.
|
|
58
|
+
* gh may write a Buffer or Uint8Array when execFileSync throws.
|
|
59
|
+
*/
|
|
60
|
+
function errorMessage(err) {
|
|
61
|
+
if (typeof err.stderr === "string" && err.stderr.length > 0) {
|
|
62
|
+
return err.stderr.trim();
|
|
63
|
+
}
|
|
64
|
+
if (err.stderr && typeof err.stderr.toString === "function") {
|
|
65
|
+
const s = err.stderr.toString("utf-8").trim();
|
|
66
|
+
if (s.length > 0) return s;
|
|
67
|
+
}
|
|
68
|
+
if (err.message) return err.message;
|
|
69
|
+
return "gh issue create failed";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// GitHub issue creation
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create a GitHub issue from a remediation_item artifact.
|
|
78
|
+
*
|
|
79
|
+
* Calls `gh issue create` with the generated payload. The issue is assigned
|
|
80
|
+
* to @me via --assignee.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} remediationItem — RemediationItemSchema-valid artifact
|
|
83
|
+
* @param {{ owner: string, name: string }} repo — parsed repository identifier
|
|
84
|
+
* @returns {{ ok: true, issueNumber: number, issueUrl: string } | { ok: false, error: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function createRemediationIssue(remediationItem, repo) {
|
|
87
|
+
const { title, body, labels } = remediationToIssuePayload(remediationItem);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const args = [
|
|
91
|
+
"issue", "create",
|
|
92
|
+
"--title", title,
|
|
93
|
+
"--body", body,
|
|
94
|
+
"--assignee", "@me",
|
|
95
|
+
"--repo", `${repo.owner}/${repo.name}`,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
for (const label of labels) {
|
|
99
|
+
args.push("--label", label);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = execFileSync("gh", args, {
|
|
103
|
+
encoding: "utf-8",
|
|
104
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
105
|
+
}).trim();
|
|
106
|
+
|
|
107
|
+
// gh issue create output is typically the issue URL, e.g.
|
|
108
|
+
// https://github.com/owner/name/issues/123
|
|
109
|
+
const url = result;
|
|
110
|
+
const match = url.match(/\/issues\/(\d+)/);
|
|
111
|
+
const issueNumber = match ? parseInt(match[1], 10) : null;
|
|
112
|
+
|
|
113
|
+
if (!issueNumber) {
|
|
114
|
+
return { ok: false, error: `Could not parse issue number from gh output: ${url}` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { ok: true, issueNumber, issueUrl: url };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, error: errorMessage(err) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Debt signal cluster scoring model
|
|
3
|
+
//
|
|
4
|
+
// Transforms a cluster of debt_signal objects into a numeric 0-100 score.
|
|
5
|
+
//
|
|
6
|
+
// Three dimensions:
|
|
7
|
+
// - Frequency: how many signals in the cluster, capped and normalized
|
|
8
|
+
// - Severity: average severity hint value from signal source metadata
|
|
9
|
+
// - Impact: blast-radius / churn-risk heuristic based on signal properties
|
|
10
|
+
//
|
|
11
|
+
// Guarantees:
|
|
12
|
+
// - Deterministic: same inputs → same score
|
|
13
|
+
// - Monotonicity: higher frequency/severity/impact → higher or equal score
|
|
14
|
+
// - Boundary handling: zero inputs, single-signal clusters, max clusters
|
|
15
|
+
//
|
|
16
|
+
// Severity mapping (canonical):
|
|
17
|
+
// info=1, low=2, medium=3, high=4, critical=5
|
|
18
|
+
//
|
|
19
|
+
// Weights are hardcoded constants in this slice; not configurable.
|
|
20
|
+
// The formula is: score = frequencyScore * 0.35 + severityScore * 0.40 + impactScore * 0.25
|
|
21
|
+
// clamped to 0-100.
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map a DebtSignalSeverity string to a numeric weight 1-5.
|
|
26
|
+
* Returns 1 for unrecognized values (defensive).
|
|
27
|
+
*
|
|
28
|
+
* @param {string} severityHint
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
function severityWeight(severityHint) {
|
|
32
|
+
const map = { info: 1, low: 2, medium: 3, high: 4, critical: 5 };
|
|
33
|
+
return map[severityHint] ?? 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute the frequency score component (0-100).
|
|
38
|
+
* Uses a logarithmic cap so the first few signals matter most.
|
|
39
|
+
* 1 signal → 20, 3 signals → 58, 5 signals → 76, 10 signals → 100.
|
|
40
|
+
*
|
|
41
|
+
* @param {number} signalCount
|
|
42
|
+
* @returns {number}
|
|
43
|
+
*/
|
|
44
|
+
function frequencyScore(signalCount) {
|
|
45
|
+
if (signalCount <= 0) return 0;
|
|
46
|
+
if (signalCount === 1) return 20;
|
|
47
|
+
// Logarithmic scaling: 20 + 80 * log2(count) / log2(10), capped at 100
|
|
48
|
+
const raw = 20 + 80 * (Math.log2(signalCount) / Math.log2(10));
|
|
49
|
+
return Math.min(100, Math.round(raw));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute the severity score component (0-100).
|
|
54
|
+
* Average severity weight mapped: 1→20, 3→60, 5→100.
|
|
55
|
+
*
|
|
56
|
+
* @param {Array<{ severityHint: string }>} signals
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
function severityScore(signals) {
|
|
60
|
+
if (signals.length === 0) return 0;
|
|
61
|
+
const total = signals.reduce((sum, s) => sum + severityWeight(s.severityHint), 0);
|
|
62
|
+
const avg = total / signals.length;
|
|
63
|
+
return Math.round(avg * 20); // 1..5 → 20..100
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compute the impact score component (0-100).
|
|
68
|
+
* Heuristic based on:
|
|
69
|
+
* - file presence: signals with known file paths are more actionable (+30 base if any)
|
|
70
|
+
* - confidence: average signal confidence boosts impact
|
|
71
|
+
* - signalKind diversity: more unique categories = broader blast radius
|
|
72
|
+
* Returns 0 for empty clusters.
|
|
73
|
+
*
|
|
74
|
+
* @param {Array<{ location?: { filePath?: string }, confidence?: number, signalKind?: string }>} signals
|
|
75
|
+
* @returns {number}
|
|
76
|
+
*/
|
|
77
|
+
function impactScore(signals) {
|
|
78
|
+
if (signals.length === 0) return 0;
|
|
79
|
+
|
|
80
|
+
let score = 0;
|
|
81
|
+
|
|
82
|
+
// File presence: +30 if any signal has a file path
|
|
83
|
+
const hasFilePath = signals.some(s => s.location?.filePath);
|
|
84
|
+
if (hasFilePath) score += 30;
|
|
85
|
+
|
|
86
|
+
// Confidence boost: average confidence * 40
|
|
87
|
+
const confidences = signals.map(s => s.confidence ?? 1);
|
|
88
|
+
const avgConfidence = confidences.reduce((a, b) => a + b, 0) / confidences.length;
|
|
89
|
+
score += Math.round(avgConfidence * 40);
|
|
90
|
+
|
|
91
|
+
// Category diversity: unique signalKinds * 5, capped at 30
|
|
92
|
+
const uniqueKinds = new Set(signals.map(s => s.signalKind).filter(Boolean));
|
|
93
|
+
const diversityBonus = Math.min(30, uniqueKinds.size * 5);
|
|
94
|
+
score += diversityBonus;
|
|
95
|
+
|
|
96
|
+
return Math.min(100, score);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Weight constant — keeps module self-documenting and testable.
|
|
101
|
+
* @type {{ frequency: number, severity: number, impact: number }}
|
|
102
|
+
*/
|
|
103
|
+
export const SCORE_WEIGHTS = Object.freeze({
|
|
104
|
+
frequency: 0.35,
|
|
105
|
+
severity: 0.40,
|
|
106
|
+
impact: 0.25,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Score a cluster of debt_signal objects.
|
|
111
|
+
*
|
|
112
|
+
* @param {Array<{ severityHint: string, location?: { filePath?: string }, confidence?: number, signalKind?: string }>} signals — array of debt_signal-compatible objects
|
|
113
|
+
* @returns {number} integer score 0-100
|
|
114
|
+
*/
|
|
115
|
+
export function scoreCluster(signals) {
|
|
116
|
+
if (!Array.isArray(signals) || signals.length === 0) return 0;
|
|
117
|
+
|
|
118
|
+
const freq = frequencyScore(signals.length);
|
|
119
|
+
const sev = severityScore(signals);
|
|
120
|
+
const imp = impactScore(signals);
|
|
121
|
+
|
|
122
|
+
const raw = freq * SCORE_WEIGHTS.frequency +
|
|
123
|
+
sev * SCORE_WEIGHTS.severity +
|
|
124
|
+
imp * SCORE_WEIGHTS.impact;
|
|
125
|
+
|
|
126
|
+
return Math.min(100, Math.max(0, Math.round(raw)));
|
|
127
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Debt finding shaping rules
|
|
3
|
+
//
|
|
4
|
+
// Turns each scored debt_finding into exactly one outcome:
|
|
5
|
+
// - remediation_item: bounded, PR-sized fix with clear acceptance criteria
|
|
6
|
+
// - debt_epic: cross-cutting, needs decomposition before execution
|
|
7
|
+
// - defer: acknowledged, scheduled for future review
|
|
8
|
+
// - watch: low confidence, needs more signals before action
|
|
9
|
+
// - dismiss: false positive or already fixed
|
|
10
|
+
//
|
|
11
|
+
// Thresholds are hardcoded constants in this slice; not configurable.
|
|
12
|
+
// Exported for contract tests that verify predicate boundaries.
|
|
13
|
+
//
|
|
14
|
+
// Actionable outcomes (remediation_item, debt_epic) are gated on having
|
|
15
|
+
// at least one filePath. Theme-only clusters without file paths are
|
|
16
|
+
// downgraded to watch regardless of score.
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Threshold constants
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/** Score >= ITEM_THRESHOLD → eligible for remediation_item or debt_epic */
|
|
24
|
+
export const ITEM_THRESHOLD = 65;
|
|
25
|
+
|
|
26
|
+
/** Score >= DEFER_THRESHOLD → defer */
|
|
27
|
+
export const DEFER_THRESHOLD = 50;
|
|
28
|
+
|
|
29
|
+
/** Score >= WATCH_THRESHOLD → watch */
|
|
30
|
+
export const WATCH_THRESHOLD = 30;
|
|
31
|
+
|
|
32
|
+
/** Below WATCH_THRESHOLD → dismiss */
|
|
33
|
+
|
|
34
|
+
/** Signals above this count → debt_epic (even with score >= ITEM_THRESHOLD) */
|
|
35
|
+
export const EPIC_SIGNAL_COUNT_THRESHOLD = 3;
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Shape outcome
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/** @typedef {"remediation_item"|"debt_epic"|"defer"|"watch"|"dismiss"} ShapeOutcome */
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Helpers
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deterministic epoch constant for timestamp fallback.
|
|
49
|
+
* "1970-01-01T00:00:00.000Z"
|
|
50
|
+
*/
|
|
51
|
+
const EPOCH_TS = "1970-01-01T00:00:00.000Z";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pass through finding timestamps. Falls back to epoch when finding
|
|
55
|
+
* timestamps are missing — never uses wall-clock time.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} finding — enriched debt_finding with createdAt/updatedAt
|
|
58
|
+
* @returns {{ createdAt: string, updatedAt: string }}
|
|
59
|
+
*/
|
|
60
|
+
function deriveTimestamps(finding) {
|
|
61
|
+
return {
|
|
62
|
+
createdAt: finding.createdAt || EPOCH_TS,
|
|
63
|
+
updatedAt: finding.updatedAt || finding.createdAt || EPOCH_TS,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Check whether a finding has actionable file paths.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} finding — enriched debt_finding
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function hasFilePaths(finding) {
|
|
74
|
+
const paths = finding.locationSummary?.filePaths;
|
|
75
|
+
return Array.isArray(paths) && paths.length > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Pure predicate: classify a finding into a shape outcome
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Determine the shape outcome for a debt_finding.
|
|
84
|
+
* Actionable outcomes (remediation_item, debt_epic) require at least
|
|
85
|
+
* one filePath; theme-only clusters without file paths are downgraded
|
|
86
|
+
* to watch.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} finding — debt_finding shape with at least { score, _signalCount, signalIds }
|
|
89
|
+
* @returns {ShapeOutcome}
|
|
90
|
+
*/
|
|
91
|
+
function classifyShape(finding) {
|
|
92
|
+
const score = finding.score ?? 0;
|
|
93
|
+
const signalCount = finding._signalCount ?? (finding.signalIds?.length ?? 1);
|
|
94
|
+
|
|
95
|
+
if (score >= ITEM_THRESHOLD && hasFilePaths(finding)) {
|
|
96
|
+
return signalCount > EPIC_SIGNAL_COUNT_THRESHOLD ? "debt_epic" : "remediation_item";
|
|
97
|
+
}
|
|
98
|
+
if (score >= ITEM_THRESHOLD) {
|
|
99
|
+
// High score but no file paths — downgrade to watch
|
|
100
|
+
return "watch";
|
|
101
|
+
}
|
|
102
|
+
if (score >= DEFER_THRESHOLD) return "defer";
|
|
103
|
+
if (score >= WATCH_THRESHOLD) return "watch";
|
|
104
|
+
return "dismiss";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ============================================================================
|
|
108
|
+
// Artifact builders
|
|
109
|
+
// ============================================================================
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a remediation_item artifact from a finding.
|
|
113
|
+
* Uses structured _categories from the cluster.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} finding — enriched debt_finding with _categories array
|
|
116
|
+
* @returns {object} RemediationItemSchema-compatible shape
|
|
117
|
+
*/
|
|
118
|
+
function buildRemediationItem(finding) {
|
|
119
|
+
const cats = (finding._categories && finding._categories.length > 0)
|
|
120
|
+
? finding._categories.join(", ")
|
|
121
|
+
: "unknown";
|
|
122
|
+
const { createdAt, updatedAt } = deriveTimestamps(finding);
|
|
123
|
+
return {
|
|
124
|
+
kind: "remediation_item",
|
|
125
|
+
findingId: finding.id,
|
|
126
|
+
title: finding.title,
|
|
127
|
+
description: finding.description || `Remediation for finding ${finding.id}`,
|
|
128
|
+
acceptanceCriteria: [
|
|
129
|
+
`Address ${cats} issues in affected files`,
|
|
130
|
+
`Verify no regression in existing test suite`,
|
|
131
|
+
`Maintain >= 90% coverage`,
|
|
132
|
+
],
|
|
133
|
+
score: finding.score ?? 0,
|
|
134
|
+
primaryFilePath: finding.locationSummary?.primaryFilePath,
|
|
135
|
+
filePaths: finding.locationSummary?.filePaths,
|
|
136
|
+
signalIds: finding.signalIds,
|
|
137
|
+
sourceType: "debt_pipeline",
|
|
138
|
+
createdAt,
|
|
139
|
+
updatedAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Build a debt_epic artifact from a finding.
|
|
145
|
+
*
|
|
146
|
+
* @param {object} finding — enriched debt_finding
|
|
147
|
+
* @returns {object} DebtEpicSchema-compatible shape
|
|
148
|
+
*/
|
|
149
|
+
function buildDebtEpic(finding) {
|
|
150
|
+
const { createdAt, updatedAt } = deriveTimestamps(finding);
|
|
151
|
+
return {
|
|
152
|
+
kind: "debt_epic",
|
|
153
|
+
findingId: finding.id,
|
|
154
|
+
title: finding.title,
|
|
155
|
+
description: finding.description || `Epic for finding ${finding.id}`,
|
|
156
|
+
score: finding.score ?? 0,
|
|
157
|
+
filePaths: finding.locationSummary?.filePaths,
|
|
158
|
+
signalIds: finding.signalIds,
|
|
159
|
+
estimatedItems: Math.ceil((finding._signalCount ?? 1) / 2),
|
|
160
|
+
createdAt,
|
|
161
|
+
updatedAt,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ============================================================================
|
|
166
|
+
// Public API
|
|
167
|
+
// ============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Shape a single enriched finding into its outcome.
|
|
171
|
+
*
|
|
172
|
+
* Returns an object with { outcome, artifact } where artifact is defined
|
|
173
|
+
* for remediation_item and debt_epic outcomes, and null for defer/watch/dismiss.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} finding — enriched debt_finding (with _signalCount)
|
|
176
|
+
* @returns {{ outcome: ShapeOutcome, artifact: object|null }}
|
|
177
|
+
*/
|
|
178
|
+
export function shapeFinding(finding) {
|
|
179
|
+
const outcome = classifyShape(finding);
|
|
180
|
+
|
|
181
|
+
let artifact = null;
|
|
182
|
+
if (outcome === "remediation_item") {
|
|
183
|
+
artifact = buildRemediationItem(finding);
|
|
184
|
+
} else if (outcome === "debt_epic") {
|
|
185
|
+
artifact = buildDebtEpic(finding);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { outcome, artifact };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Shape an array of enriched findings, returning the outcome + artifact for each.
|
|
193
|
+
*
|
|
194
|
+
* @param {Array<object>} findings — enriched debt_finding array
|
|
195
|
+
* @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
|
|
196
|
+
*/
|
|
197
|
+
export function shapeFindings(findings) {
|
|
198
|
+
return findings.map(f => {
|
|
199
|
+
const { outcome, artifact } = shapeFinding(f);
|
|
200
|
+
return { outcome, artifact, findingId: f.id };
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Run the full pipeline: cluster → score → shape, return shaped artifacts.
|
|
206
|
+
*
|
|
207
|
+
* @param {Array<object>} signals — debt_signal-compatible array
|
|
208
|
+
* @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
|
|
209
|
+
*/
|
|
210
|
+
export async function runPipeline(signals) {
|
|
211
|
+
const { clusterSignalsEnriched } = await import("./cluster.mjs");
|
|
212
|
+
const findings = clusterSignalsEnriched(signals);
|
|
213
|
+
return shapeFindings(findings);
|
|
214
|
+
}
|