@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,240 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Debt signal clustering logic
|
|
3
|
+
//
|
|
4
|
+
// Groups debt_signal objects into debt_finding clusters.
|
|
5
|
+
//
|
|
6
|
+
// Clustering rules:
|
|
7
|
+
// - file: signals share the same location.filePath
|
|
8
|
+
// - module: signals share the same parent directory of filePath
|
|
9
|
+
// - theme: signals share the same signalKind category
|
|
10
|
+
// - Precedence: file > module > theme
|
|
11
|
+
// - A signal belongs to exactly one cluster.
|
|
12
|
+
// - Signals with no file, module, or theme form singleton clusters.
|
|
13
|
+
//
|
|
14
|
+
// Finding ID determinism:
|
|
15
|
+
// Finding IDs are derived from sorted signalIds + clusterReason using a
|
|
16
|
+
// stable hash, so re-running clustering on the same input produces the
|
|
17
|
+
// same debt_finding artifacts.
|
|
18
|
+
//
|
|
19
|
+
// Output: array of debt_finding objects with embedded signal references,
|
|
20
|
+
// signalIds, and aggregated score.
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { scoreCluster } from "./score.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive a deterministic finding ID from sorted signal IDs and the cluster reason.
|
|
28
|
+
* Uses SHA-256 truncated to a UUID-v4-format string for stable cross-run identity.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<string>} signalIds — sorted array of signal UUIDs
|
|
31
|
+
* @param {string} clusterReason — "file", "module", "theme", or "singleton"
|
|
32
|
+
* @returns {string} UUID-v4-format deterministic ID
|
|
33
|
+
*/
|
|
34
|
+
function deriveFindingId(signalIds, clusterReason) {
|
|
35
|
+
const sorted = [...signalIds].sort();
|
|
36
|
+
const input = sorted.join(":") + "|" + clusterReason;
|
|
37
|
+
const hash = createHash("sha256").update(input).digest("hex");
|
|
38
|
+
// Format as UUID v4: 8-4-4-4-12 using first 32 hex chars
|
|
39
|
+
const variantNibble = (parseInt(hash[16], 16) & 0x3 | 0x8).toString(16);
|
|
40
|
+
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variantNibble}${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Derive deterministic timestamps from signal timestamps.
|
|
45
|
+
* Uses the earliest signal timestamp for createdAt and the latest for updatedAt.
|
|
46
|
+
*
|
|
47
|
+
* @param {Array<object>} signals
|
|
48
|
+
* @returns {{ createdAt: string, updatedAt: string }}
|
|
49
|
+
*/
|
|
50
|
+
function deriveTimestamps(signals) {
|
|
51
|
+
const timestamps = signals.map(s => s.timestamp).filter(Boolean).sort();
|
|
52
|
+
if (timestamps.length === 0) {
|
|
53
|
+
const epoch = "1970-01-01T00:00:00.000Z";
|
|
54
|
+
return { createdAt: epoch, updatedAt: epoch };
|
|
55
|
+
}
|
|
56
|
+
return { createdAt: timestamps[0], updatedAt: timestamps[timestamps.length - 1] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the file key from a signal: location.filePath, or undefined.
|
|
61
|
+
* @param {object} signal
|
|
62
|
+
* @returns {string|undefined}
|
|
63
|
+
*/
|
|
64
|
+
function fileKey(signal) {
|
|
65
|
+
return signal.location?.filePath || undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract the module key from a signal:
|
|
70
|
+
* parent directory of filePath (dirname).
|
|
71
|
+
* Returns undefined when no filePath exists — module clustering is
|
|
72
|
+
* strictly directory-based and does not fall back to signalKind.
|
|
73
|
+
* Theme clustering handles signalKind grouping separately.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} signal
|
|
76
|
+
* @returns {string|undefined}
|
|
77
|
+
*/
|
|
78
|
+
function moduleKey(signal) {
|
|
79
|
+
const fp = signal.location?.filePath;
|
|
80
|
+
if (!fp) return undefined;
|
|
81
|
+
const lastSlash = fp.lastIndexOf("/");
|
|
82
|
+
if (lastSlash >= 0) return fp.slice(0, lastSlash);
|
|
83
|
+
return fp; // no directory separator — treat as its own module
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Extract the theme key from a signal: signalKind category.
|
|
88
|
+
* @param {object} signal
|
|
89
|
+
* @returns {string|undefined}
|
|
90
|
+
*/
|
|
91
|
+
function themeKey(signal) {
|
|
92
|
+
return signal.signalKind || undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Group signals by a key extraction function.
|
|
97
|
+
* Returns Map<key, signal[]> where key may be undefined for signals lacking that dimension.
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<object>} signals
|
|
100
|
+
* @param {(signal: object) => string|undefined} keyFn
|
|
101
|
+
* @returns {Map<string|undefined, Array<object>>}
|
|
102
|
+
*/
|
|
103
|
+
function groupByKey(signals, keyFn) {
|
|
104
|
+
const map = new Map();
|
|
105
|
+
for (const signal of signals) {
|
|
106
|
+
const key = keyFn(signal);
|
|
107
|
+
if (!map.has(key)) map.set(key, []);
|
|
108
|
+
map.get(key).push(signal);
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a debt_finding from a cluster of signals.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<object>} signals — debt_signal-compatible objects
|
|
117
|
+
* @param {string} clusterReason — "file", "module", "theme", or "singleton"
|
|
118
|
+
* @returns {object} enriched debt_finding shape with internal fields
|
|
119
|
+
*/
|
|
120
|
+
function buildFinding(signals, clusterReason) {
|
|
121
|
+
const score = scoreCluster(signals);
|
|
122
|
+
|
|
123
|
+
// Build title from cluster reason + primary category
|
|
124
|
+
const categories = [...new Set(signals.map(s => s.signalKind).filter(Boolean))].sort();
|
|
125
|
+
const primaryCategory = categories[0] || "unknown";
|
|
126
|
+
|
|
127
|
+
// Build location summary
|
|
128
|
+
const filePaths = [...new Set(
|
|
129
|
+
signals.map(s => s.location?.filePath).filter(Boolean)
|
|
130
|
+
)].sort();
|
|
131
|
+
|
|
132
|
+
const title = filePaths.length === 1
|
|
133
|
+
? `${clusterReason}: ${primaryCategory} in ${filePaths[0]}`
|
|
134
|
+
: `${clusterReason}: ${primaryCategory} (${signals.length} signals)`;
|
|
135
|
+
|
|
136
|
+
const description = `Clustered by ${clusterReason}. ` +
|
|
137
|
+
`Categories: ${categories.join(", ")}. ` +
|
|
138
|
+
`Files: ${filePaths.length > 0 ? filePaths.join(", ") : "none"}.`;
|
|
139
|
+
|
|
140
|
+
const sortedSignalIds = signals.map(s => s.id).sort();
|
|
141
|
+
const id = deriveFindingId(sortedSignalIds, clusterReason);
|
|
142
|
+
const { createdAt, updatedAt } = deriveTimestamps(signals);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
id,
|
|
146
|
+
signalIds: sortedSignalIds,
|
|
147
|
+
validationStatus: "pending",
|
|
148
|
+
score,
|
|
149
|
+
remediationShape: "watch_only", // placeholder; shaped later
|
|
150
|
+
title: title.slice(0, 200),
|
|
151
|
+
description: description.slice(0, 500),
|
|
152
|
+
locationSummary: filePaths.length > 0
|
|
153
|
+
? { filePaths, primaryFilePath: filePaths[0] }
|
|
154
|
+
: undefined,
|
|
155
|
+
createdAt,
|
|
156
|
+
updatedAt,
|
|
157
|
+
// Internal fields for shaping (not in the DebtFindingSchema output)
|
|
158
|
+
_clusterReason: clusterReason,
|
|
159
|
+
_signalCount: signals.length,
|
|
160
|
+
_categories: categories,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Cluster debt_signal objects into debt_finding arrays.
|
|
166
|
+
*
|
|
167
|
+
* Multi-pass algorithm:
|
|
168
|
+
* 1. Group by file (location.filePath exact match)
|
|
169
|
+
* 2. From remaining, group by module (directory of filePath)
|
|
170
|
+
* 3. From remaining, group by theme (signalKind)
|
|
171
|
+
* 4. Remaining → singleton clusters
|
|
172
|
+
*
|
|
173
|
+
* Each signal appears in exactly one cluster.
|
|
174
|
+
* Clusters with only one signal when grouped by a dimension
|
|
175
|
+
* are deferred to the next pass.
|
|
176
|
+
*
|
|
177
|
+
* @param {Array<object>} signals — array of debt_signal-compatible objects
|
|
178
|
+
* @returns {Array<object>} array of clean debt_finding shapes (no internal fields)
|
|
179
|
+
*/
|
|
180
|
+
export function clusterSignals(signals) {
|
|
181
|
+
const enriched = clusterSignalsEnriched(signals);
|
|
182
|
+
return enriched.map(({ _clusterReason, _signalCount, _categories, ...clean }) => clean);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Cluster signals and return findings enriched with internal metadata
|
|
187
|
+
* for shaping (_clusterReason, _signalCount, _categories).
|
|
188
|
+
*
|
|
189
|
+
* @param {Array<object>} signals — array of debt_signal-compatible objects
|
|
190
|
+
* @returns {Array<object>} array of enriched debt_finding shapes
|
|
191
|
+
*/
|
|
192
|
+
export function clusterSignalsEnriched(signals) {
|
|
193
|
+
if (!Array.isArray(signals)) return [];
|
|
194
|
+
if (signals.length === 0) return [];
|
|
195
|
+
|
|
196
|
+
const findings = [];
|
|
197
|
+
let remaining = [...signals];
|
|
198
|
+
|
|
199
|
+
// Pass 1: file
|
|
200
|
+
const fileGroups = groupByKey(remaining, fileKey);
|
|
201
|
+
remaining = [];
|
|
202
|
+
for (const [key, group] of fileGroups) {
|
|
203
|
+
if (key === undefined || group.length <= 1) {
|
|
204
|
+
remaining.push(...group);
|
|
205
|
+
} else {
|
|
206
|
+
findings.push(buildFinding(group, "file"));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Pass 2: module (directory-based only; no signalKind fallback)
|
|
211
|
+
const moduleGroups = groupByKey(remaining, moduleKey);
|
|
212
|
+
remaining = [];
|
|
213
|
+
for (const [key, group] of moduleGroups) {
|
|
214
|
+
if (key === undefined || group.length <= 1) {
|
|
215
|
+
remaining.push(...group);
|
|
216
|
+
} else {
|
|
217
|
+
findings.push(buildFinding(group, "module"));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Pass 3: theme
|
|
222
|
+
const themeGroups = groupByKey(remaining, themeKey);
|
|
223
|
+
remaining = [];
|
|
224
|
+
for (const [key, group] of themeGroups) {
|
|
225
|
+
if (key === undefined || group.length <= 1) {
|
|
226
|
+
remaining.push(...group);
|
|
227
|
+
} else {
|
|
228
|
+
findings.push(buildFinding(group, "theme"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Pass 4: singletons for any remaining
|
|
233
|
+
for (const signal of remaining) {
|
|
234
|
+
findings.push(buildFinding([signal], "singleton"));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Stable output order: sort findings by id
|
|
238
|
+
findings.sort((a, b) => a.id.localeCompare(b.id));
|
|
239
|
+
return findings;
|
|
240
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const DebtFindingValidationStatus = z.enum([
|
|
4
|
+
"pending",
|
|
5
|
+
"validated",
|
|
6
|
+
"rejected",
|
|
7
|
+
"stale",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const DebtFindingRemediationShape = z.enum([
|
|
11
|
+
"item",
|
|
12
|
+
"epic",
|
|
13
|
+
"defer",
|
|
14
|
+
"watch_only",
|
|
15
|
+
"dismissed",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export const DebtFindingLocationSummary = z.strictObject({
|
|
19
|
+
filePaths: z.array(z.string().min(1)).optional(),
|
|
20
|
+
primaryFilePath: z.string().min(1).optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const DebtFindingSchema = z.strictObject({
|
|
24
|
+
id: z.string().uuid(),
|
|
25
|
+
signalIds: z.array(z.string().uuid()).min(1),
|
|
26
|
+
validationStatus: DebtFindingValidationStatus,
|
|
27
|
+
score: z.number().min(0).max(100).optional(),
|
|
28
|
+
remediationShape: DebtFindingRemediationShape,
|
|
29
|
+
title: z.string().min(1).max(200),
|
|
30
|
+
description: z.string().min(1).optional(),
|
|
31
|
+
locationSummary: DebtFindingLocationSummary.optional(),
|
|
32
|
+
createdAt: z.string().datetime(),
|
|
33
|
+
updatedAt: z.string().datetime(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Remediation item schema — a bounded, PR-sized fix ready for the execution loop
|
|
38
|
+
// ============================================================================
|
|
39
|
+
export const RemediationItemSchema = z.strictObject({
|
|
40
|
+
kind: z.literal("remediation_item"),
|
|
41
|
+
findingId: z.string().uuid(),
|
|
42
|
+
title: z.string().min(1).max(200),
|
|
43
|
+
description: z.string().min(1),
|
|
44
|
+
acceptanceCriteria: z.array(z.string().min(1)).min(1),
|
|
45
|
+
score: z.number().min(0).max(100),
|
|
46
|
+
primaryFilePath: z.string().min(1).optional(),
|
|
47
|
+
filePaths: z.array(z.string().min(1)).min(1),
|
|
48
|
+
signalIds: z.array(z.string().uuid()).min(1),
|
|
49
|
+
sourceType: z.string().min(1).optional(),
|
|
50
|
+
createdAt: z.string().datetime(),
|
|
51
|
+
updatedAt: z.string().datetime(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// Debt epic schema — cross-cutting work that needs decomposition before execution
|
|
56
|
+
// ============================================================================
|
|
57
|
+
export const DebtEpicSchema = z.strictObject({
|
|
58
|
+
kind: z.literal("debt_epic"),
|
|
59
|
+
findingId: z.string().uuid(),
|
|
60
|
+
title: z.string().min(1).max(200),
|
|
61
|
+
description: z.string().min(1),
|
|
62
|
+
score: z.number().min(0).max(100),
|
|
63
|
+
filePaths: z.array(z.string().min(1)).min(1),
|
|
64
|
+
signalIds: z.array(z.string().uuid()).min(1),
|
|
65
|
+
estimatedItems: z.number().int().positive(),
|
|
66
|
+
createdAt: z.string().datetime(),
|
|
67
|
+
updatedAt: z.string().datetime(),
|
|
68
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const DebtSignalSourceType = z.enum([
|
|
4
|
+
"pr_review_deep_persona",
|
|
5
|
+
"repo_audit",
|
|
6
|
+
"flaky_test",
|
|
7
|
+
"ci_failure",
|
|
8
|
+
"manual_review",
|
|
9
|
+
"dependency_alert",
|
|
10
|
+
"review_churn",
|
|
11
|
+
"incident_followup",
|
|
12
|
+
"workflow_pain",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
export const DebtSignalSeverity = z.enum([
|
|
16
|
+
"info",
|
|
17
|
+
"low",
|
|
18
|
+
"medium",
|
|
19
|
+
"high",
|
|
20
|
+
"critical",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export const DebtSignalLocation = z.strictObject({
|
|
24
|
+
filePath: z.string().min(1).optional(),
|
|
25
|
+
lineStart: z.number().int().positive().optional(),
|
|
26
|
+
lineEnd: z.number().int().positive().optional(),
|
|
27
|
+
commitSha: z.string().regex(/^[a-f0-9]{7,40}$/).optional(),
|
|
28
|
+
url: z.string().url().optional(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const DebtSignalRepository = z.strictObject({
|
|
32
|
+
owner: z.string().min(1),
|
|
33
|
+
name: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const DebtSignalSchema = z.strictObject({
|
|
37
|
+
id: z.string().uuid(),
|
|
38
|
+
sourceType: DebtSignalSourceType,
|
|
39
|
+
signalKind: z.string().min(1).max(100),
|
|
40
|
+
location: DebtSignalLocation,
|
|
41
|
+
severityHint: DebtSignalSeverity,
|
|
42
|
+
timestamp: z.string().datetime(),
|
|
43
|
+
rawPayload: z.record(z.string(), z.unknown()).optional(),
|
|
44
|
+
repository: DebtSignalRepository.optional(),
|
|
45
|
+
confidence: z.number().min(0).max(1).default(1),
|
|
46
|
+
});
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DebtSignalSchema } from "./debt-signal.mjs";
|
|
3
|
+
import { loadDevLoopConfig } from "../config/config.mjs";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Flag phrase inventory — derived from personas.deep prompt in defaults.yaml
|
|
7
|
+
//
|
|
8
|
+
// Confidence values use the canonical DebtSignalSchema 0..1 range (0.9 = 90%).
|
|
9
|
+
// This matches the schema default: z.number().min(0).max(1).default(1).
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
/** @type {Array<{ phrase: RegExp, category: string, severity: string, confidence: number }>} */
|
|
13
|
+
const FLAG_PATTERNS = [
|
|
14
|
+
{
|
|
15
|
+
phrase: /(?:crossing|crossed|exceeds?)\s+(?:1000|1,000)\+?\s+lines/i,
|
|
16
|
+
category: "file_size",
|
|
17
|
+
severity: "high",
|
|
18
|
+
confidence: 0.9,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
phrase: /conditionals?\s+bolted\s+onto\s+unrelated\s+paths/i,
|
|
22
|
+
category: "spaghetti_branching",
|
|
23
|
+
severity: "high",
|
|
24
|
+
confidence: 0.9,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
phrase: /\bspaghetti\b/i,
|
|
28
|
+
category: "spaghetti_branching",
|
|
29
|
+
severity: "high",
|
|
30
|
+
confidence: 0.9,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
phrase: /thin\s+wrapper/i,
|
|
34
|
+
category: "thin_wrapper",
|
|
35
|
+
severity: "medium",
|
|
36
|
+
confidence: 0.9,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
phrase: /re-export\s*[- ]?only/i,
|
|
40
|
+
category: "thin_wrapper",
|
|
41
|
+
severity: "medium",
|
|
42
|
+
confidence: 0.9,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
phrase: /identity\s+abstraction/i,
|
|
46
|
+
category: "thin_wrapper",
|
|
47
|
+
severity: "medium",
|
|
48
|
+
confidence: 0.9,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
phrase: /feature\s+logic\s+leaking\s+into/i,
|
|
52
|
+
category: "leaky_feature_logic",
|
|
53
|
+
severity: "high",
|
|
54
|
+
confidence: 0.9,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
phrase: /leaking\s+into\s+shared/i,
|
|
58
|
+
category: "leaky_feature_logic",
|
|
59
|
+
severity: "high",
|
|
60
|
+
confidence: 0.9,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
phrase: /cast[- ]?heavy/i,
|
|
64
|
+
category: "weak_contract",
|
|
65
|
+
severity: "medium",
|
|
66
|
+
confidence: 0.9,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
phrase: /optionality[- ]?heavy/i,
|
|
70
|
+
category: "weak_contract",
|
|
71
|
+
severity: "medium",
|
|
72
|
+
confidence: 0.9,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
phrase: /any[- ]?typed\s+contract/i,
|
|
76
|
+
category: "weak_contract",
|
|
77
|
+
severity: "medium",
|
|
78
|
+
confidence: 0.9,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
phrase: /code\s+judo/i,
|
|
82
|
+
category: "simplification_opportunity",
|
|
83
|
+
severity: "medium",
|
|
84
|
+
confidence: 0.9,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
phrase: /prefer\s+deletion\s+over\s+addition/i,
|
|
88
|
+
category: "simplification_opportunity",
|
|
89
|
+
severity: "medium",
|
|
90
|
+
confidence: 0.9,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const FILE_PATH_RE = /[\w/\-.]+\.(?:m?js|ts|tsx|jsx|mjs)/i;
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Helpers
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Match a comment body against known deep-persona flag phrases.
|
|
102
|
+
* Returns the match when a specific pattern matches, or null when no
|
|
103
|
+
* patterns match the body.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} body
|
|
106
|
+
* @returns {{ category: string, severity: string, confidence: number, matchedPhrase: string|null }|null}
|
|
107
|
+
*/
|
|
108
|
+
function matchDeepPersonaFlags(body) {
|
|
109
|
+
for (const pattern of FLAG_PATTERNS) {
|
|
110
|
+
if (pattern.phrase.test(body)) {
|
|
111
|
+
return {
|
|
112
|
+
category: pattern.category,
|
|
113
|
+
severity: pattern.severity,
|
|
114
|
+
confidence: pattern.confidence,
|
|
115
|
+
matchedPhrase: pattern.phrase.source,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract a file path from a comment body, or return an empty string if none found.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} body
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
function extractFilePath(body) {
|
|
130
|
+
const match = body.match(FILE_PATH_RE);
|
|
131
|
+
return match ? match[0] : "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a debt_signal object from a matched deep-persona comment.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} comment - Normalized comment from parseReviewThreads output
|
|
138
|
+
* @param {string} category - Inferred category
|
|
139
|
+
* @param {string} severity - Severity hint
|
|
140
|
+
* @param {number} confidence - Confidence score (0..1)
|
|
141
|
+
* @param {string|null} matchedPhrase - The regex source that matched
|
|
142
|
+
* @param {{ prNumber: string|number, prUrl: string }} prMeta
|
|
143
|
+
* @returns {object}
|
|
144
|
+
*/
|
|
145
|
+
function buildDebtSignal(comment, category, severity, confidence, matchedPhrase, prMeta) {
|
|
146
|
+
const filePath = extractFilePath(comment.body);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: randomUUID(),
|
|
150
|
+
sourceType: "pr_review_deep_persona",
|
|
151
|
+
signalKind: category,
|
|
152
|
+
location: filePath ? { filePath } : {},
|
|
153
|
+
severityHint: severity,
|
|
154
|
+
timestamp: new Date().toISOString(),
|
|
155
|
+
confidence,
|
|
156
|
+
rawPayload: {
|
|
157
|
+
description: comment.body,
|
|
158
|
+
metadata: {
|
|
159
|
+
prNumber: String(prMeta.prNumber),
|
|
160
|
+
prUrl: prMeta.prUrl,
|
|
161
|
+
commentId: comment.id,
|
|
162
|
+
threadId: comment.threadId,
|
|
163
|
+
isResolved: comment.isResolved ?? false,
|
|
164
|
+
category,
|
|
165
|
+
matchedPhrase,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================================
|
|
172
|
+
// Public API
|
|
173
|
+
// ============================================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Extract deep-persona debt_signal artifacts from normalized review-thread JSON.
|
|
177
|
+
*
|
|
178
|
+
* Accepts the output of `parseReviewThreads()` (the `comments` array with
|
|
179
|
+
* normalized `{ id, threadId, author, body, isActionable }` entries).
|
|
180
|
+
*
|
|
181
|
+
* Filters to only bot-authored comments that match known deep-persona flag
|
|
182
|
+
* phrases. Bots are identified by `author.isBot === true` or `author.type === "Bot"`.
|
|
183
|
+
*
|
|
184
|
+
* @param {{ comments: Array<{ id: string, threadId: string, author: { login: string, type: string, isBot: boolean }, body: string, isActionable?: boolean, isResolved?: boolean }>, threads?: Array<{ id: string, isResolved: boolean }> }} parsedOutput - parseReviewThreads() output
|
|
185
|
+
* @param {{ prNumber: string|number, prUrl: string }} prMeta
|
|
186
|
+
* @returns {Array<object>} Array of debt_signal objects compatible with DebtSignalSchema
|
|
187
|
+
*/
|
|
188
|
+
export function extractDeepPersonaSignals(parsedOutput, prMeta) {
|
|
189
|
+
if (!parsedOutput || !Array.isArray(parsedOutput.comments)) {
|
|
190
|
+
throw new Error("Invalid parsed output: expected { comments: [...] } from parseReviewThreads()");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Build a thread-id → isResolved map for fast lookup
|
|
194
|
+
const threadResolved = new Map();
|
|
195
|
+
if (Array.isArray(parsedOutput.threads)) {
|
|
196
|
+
for (const thread of parsedOutput.threads) {
|
|
197
|
+
threadResolved.set(thread.id, Boolean(thread.isResolved));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const signals = [];
|
|
202
|
+
|
|
203
|
+
for (const comment of parsedOutput.comments) {
|
|
204
|
+
// Only process bot-authored comments (all Copilot personas emit as bots)
|
|
205
|
+
if (!comment.author || (!comment.author.isBot && comment.author.type !== "Bot")) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!comment.body || comment.body.trim().length === 0) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const match = matchDeepPersonaFlags(comment.body);
|
|
214
|
+
if (!match) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Enrich comment with isResolved from thread data
|
|
219
|
+
const isResolved = threadResolved.has(comment.threadId)
|
|
220
|
+
? threadResolved.get(comment.threadId)
|
|
221
|
+
: (comment.isResolved ?? false);
|
|
222
|
+
|
|
223
|
+
const signal = buildDebtSignal(
|
|
224
|
+
{ ...comment, isResolved },
|
|
225
|
+
match.category,
|
|
226
|
+
match.severity,
|
|
227
|
+
match.confidence,
|
|
228
|
+
match.matchedPhrase,
|
|
229
|
+
prMeta,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Validate against canonical schema — throw on regression
|
|
233
|
+
signals.push(DebtSignalSchema.parse(signal));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return signals;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Return the known deep-persona flag phrase regex sources for inspection.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Array<string>}
|
|
243
|
+
*/
|
|
244
|
+
export function getDeepPersonaFlagPhrases() {
|
|
245
|
+
return FLAG_PATTERNS.map((p) => p.phrase.source);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Verify that all known deep-persona flag phrase regex patterns match
|
|
250
|
+
* the loaded deep persona prompt text. Returns an array of regex sources
|
|
251
|
+
* whose patterns did not find any match in the prompt (empty = all match).
|
|
252
|
+
*
|
|
253
|
+
* @returns {Promise<Array<string>>}
|
|
254
|
+
*/
|
|
255
|
+
export async function verifyPromptStability() {
|
|
256
|
+
const { config, errors } = await loadDevLoopConfig();
|
|
257
|
+
if (errors.length > 0) {
|
|
258
|
+
throw new Error("Cannot verify prompt stability: config load errors: " +
|
|
259
|
+
errors.map(e => e.message).join("; "));
|
|
260
|
+
}
|
|
261
|
+
const deepPrompt = config?.personas?.deep?.prompt ?? "";
|
|
262
|
+
|
|
263
|
+
return FLAG_PATTERNS
|
|
264
|
+
.filter((p) => !p.phrase.test(deepPrompt))
|
|
265
|
+
.map((p) => p.phrase.source);
|
|
266
|
+
}
|