@devinnn/docdrift 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/README.md +147 -0
- package/dist/src/cli.js +51 -0
- package/dist/src/config/load.js +25 -0
- package/dist/src/config/schema.js +55 -0
- package/dist/src/config/validate.js +36 -0
- package/dist/src/detect/docsCheck.js +48 -0
- package/dist/src/detect/heuristics.js +44 -0
- package/dist/src/detect/index.js +92 -0
- package/dist/src/detect/openapi.js +123 -0
- package/dist/src/devin/prompts.js +55 -0
- package/dist/src/devin/schemas.js +99 -0
- package/dist/src/devin/v1.js +105 -0
- package/dist/src/evidence/bundle.js +81 -0
- package/dist/src/github/client.js +86 -0
- package/dist/src/index.js +375 -0
- package/dist/src/model/state.js +10 -0
- package/dist/src/model/types.js +2 -0
- package/dist/src/policy/confidence.js +31 -0
- package/dist/src/policy/engine.js +108 -0
- package/dist/src/policy/state.js +17 -0
- package/dist/src/utils/exec.js +24 -0
- package/dist/src/utils/fs.js +39 -0
- package/dist/src/utils/git.js +33 -0
- package/dist/src/utils/glob.js +21 -0
- package/dist/src/utils/hash.js +10 -0
- package/dist/src/utils/json.js +19 -0
- package/dist/src/utils/log.js +26 -0
- package/package.json +42 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.STATE_PATH = void 0;
|
|
7
|
+
exports.runDetect = runDetect;
|
|
8
|
+
exports.runDocDrift = runDocDrift;
|
|
9
|
+
exports.runValidate = runValidate;
|
|
10
|
+
exports.runStatus = runStatus;
|
|
11
|
+
exports.resolveTrigger = resolveTrigger;
|
|
12
|
+
exports.parseDurationHours = parseDurationHours;
|
|
13
|
+
exports.requireSha = requireSha;
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const load_1 = require("./config/load");
|
|
16
|
+
const validate_1 = require("./config/validate");
|
|
17
|
+
const detect_1 = require("./detect");
|
|
18
|
+
const bundle_1 = require("./evidence/bundle");
|
|
19
|
+
const client_1 = require("./github/client");
|
|
20
|
+
const engine_1 = require("./policy/engine");
|
|
21
|
+
const state_1 = require("./policy/state");
|
|
22
|
+
const log_1 = require("./utils/log");
|
|
23
|
+
const prompts_1 = require("./devin/prompts");
|
|
24
|
+
const schemas_1 = require("./devin/schemas");
|
|
25
|
+
const v1_1 = require("./devin/v1");
|
|
26
|
+
function parseStructured(session) {
|
|
27
|
+
return session?.structured_output ?? session?.data?.structured_output ?? {};
|
|
28
|
+
}
|
|
29
|
+
function inferPrUrl(session, structured) {
|
|
30
|
+
if (typeof structured?.pr?.url === "string") {
|
|
31
|
+
return structured.pr.url;
|
|
32
|
+
}
|
|
33
|
+
if (typeof session?.pull_request_url === "string") {
|
|
34
|
+
return session.pull_request_url;
|
|
35
|
+
}
|
|
36
|
+
if (typeof session?.pr_url === "string") {
|
|
37
|
+
return session.pr_url;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
function inferQuestions(structured) {
|
|
42
|
+
const questions = structured?.blocked?.questions;
|
|
43
|
+
if (Array.isArray(questions)) {
|
|
44
|
+
return questions.map(String);
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
"Which conceptual docs should be updated for this behavior change?",
|
|
48
|
+
"What are the exact user-visible semantics after this merge?"
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
async function executeSession(input) {
|
|
52
|
+
const attachmentUrls = [];
|
|
53
|
+
for (const attachmentPath of input.attachmentPaths) {
|
|
54
|
+
const url = await (0, v1_1.devinUploadAttachment)(input.apiKey, attachmentPath);
|
|
55
|
+
attachmentUrls.push(url);
|
|
56
|
+
}
|
|
57
|
+
const prompt = input.item.mode === "autogen"
|
|
58
|
+
? (0, prompts_1.buildAutogenPrompt)({
|
|
59
|
+
item: input.item,
|
|
60
|
+
attachmentUrls,
|
|
61
|
+
verificationCommands: input.config.policy.verification.commands,
|
|
62
|
+
allowlist: input.config.policy.allowlist,
|
|
63
|
+
confidenceThreshold: input.config.policy.confidence.autopatchThreshold
|
|
64
|
+
})
|
|
65
|
+
: (0, prompts_1.buildConceptualPrompt)({
|
|
66
|
+
item: input.item,
|
|
67
|
+
attachmentUrls,
|
|
68
|
+
verificationCommands: input.config.policy.verification.commands,
|
|
69
|
+
allowlist: input.config.policy.allowlist,
|
|
70
|
+
confidenceThreshold: input.config.policy.confidence.autopatchThreshold
|
|
71
|
+
});
|
|
72
|
+
const session = await (0, v1_1.devinCreateSession)(input.apiKey, {
|
|
73
|
+
prompt,
|
|
74
|
+
unlisted: input.config.devin.unlisted,
|
|
75
|
+
max_acu_limit: input.config.devin.maxAcuLimit,
|
|
76
|
+
tags: [...new Set([...(input.config.devin.tags ?? []), "docdrift", input.item.docArea])],
|
|
77
|
+
attachments: attachmentUrls,
|
|
78
|
+
structured_output: {
|
|
79
|
+
schema: schemas_1.PatchPlanSchema
|
|
80
|
+
},
|
|
81
|
+
metadata: {
|
|
82
|
+
repository: input.repository,
|
|
83
|
+
docArea: input.item.docArea,
|
|
84
|
+
mode: input.item.mode
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const finalSession = await (0, v1_1.pollUntilTerminal)(input.apiKey, session.session_id);
|
|
88
|
+
const structured = parseStructured(finalSession);
|
|
89
|
+
const status = String(finalSession.status_enum ?? finalSession.status ?? "").toLowerCase();
|
|
90
|
+
const prUrl = inferPrUrl(finalSession, structured);
|
|
91
|
+
const verificationCommands = Array.isArray(structured?.verification?.commands)
|
|
92
|
+
? structured.verification.commands.map(String)
|
|
93
|
+
: input.config.policy.verification.commands;
|
|
94
|
+
const verificationResults = Array.isArray(structured?.verification?.results)
|
|
95
|
+
? structured.verification.results.map(String)
|
|
96
|
+
: verificationCommands.map(() => "not reported");
|
|
97
|
+
const verification = verificationCommands.map((command, idx) => ({
|
|
98
|
+
command,
|
|
99
|
+
result: verificationResults[idx] ?? "not reported"
|
|
100
|
+
}));
|
|
101
|
+
if (prUrl) {
|
|
102
|
+
return {
|
|
103
|
+
outcome: "PR_OPENED",
|
|
104
|
+
summary: String(structured?.summary ?? "PR opened by Devin"),
|
|
105
|
+
sessionUrl: session.url,
|
|
106
|
+
prUrl,
|
|
107
|
+
verification
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (status === "blocked" || structured?.status === "BLOCKED") {
|
|
111
|
+
return {
|
|
112
|
+
outcome: "BLOCKED",
|
|
113
|
+
summary: String(structured?.blocked?.reason ?? structured?.summary ?? "Session blocked"),
|
|
114
|
+
sessionUrl: session.url,
|
|
115
|
+
questions: inferQuestions(structured),
|
|
116
|
+
verification
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
outcome: "NO_CHANGE",
|
|
121
|
+
summary: String(structured?.summary ?? "Session completed without PR"),
|
|
122
|
+
sessionUrl: session.url,
|
|
123
|
+
verification
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function runDetect(options) {
|
|
127
|
+
const config = (0, load_1.loadConfig)();
|
|
128
|
+
const runtimeValidation = await (0, validate_1.validateRuntimeConfig)(config);
|
|
129
|
+
if (runtimeValidation.errors.length) {
|
|
130
|
+
throw new Error(`Config validation failed:\n${runtimeValidation.errors.join("\n")}`);
|
|
131
|
+
}
|
|
132
|
+
const repo = process.env.GITHUB_REPOSITORY ?? "local/docdrift";
|
|
133
|
+
const { report } = await (0, detect_1.buildDriftReport)({
|
|
134
|
+
config,
|
|
135
|
+
repo,
|
|
136
|
+
baseSha: options.baseSha,
|
|
137
|
+
headSha: options.headSha,
|
|
138
|
+
trigger: options.trigger ?? "manual"
|
|
139
|
+
});
|
|
140
|
+
(0, log_1.logInfo)(`Drift items detected: ${report.items.length}`);
|
|
141
|
+
return { hasDrift: report.items.length > 0 };
|
|
142
|
+
}
|
|
143
|
+
async function runDocDrift(options) {
|
|
144
|
+
const config = (0, load_1.loadConfig)();
|
|
145
|
+
const runtimeValidation = await (0, validate_1.validateRuntimeConfig)(config);
|
|
146
|
+
if (runtimeValidation.errors.length) {
|
|
147
|
+
throw new Error(`Config validation failed:\n${runtimeValidation.errors.join("\n")}`);
|
|
148
|
+
}
|
|
149
|
+
const repo = process.env.GITHUB_REPOSITORY ?? "local/docdrift";
|
|
150
|
+
const commitSha = process.env.GITHUB_SHA ?? options.headSha;
|
|
151
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
152
|
+
const devinApiKey = process.env.DEVIN_API_KEY;
|
|
153
|
+
const { report, runInfo, evidenceRoot } = await (0, detect_1.buildDriftReport)({
|
|
154
|
+
config,
|
|
155
|
+
repo,
|
|
156
|
+
baseSha: options.baseSha,
|
|
157
|
+
headSha: options.headSha,
|
|
158
|
+
trigger: options.trigger ?? "manual"
|
|
159
|
+
});
|
|
160
|
+
const docAreaByName = new Map(config.docAreas.map((area) => [area.name, area]));
|
|
161
|
+
let state = (0, state_1.loadState)();
|
|
162
|
+
const startedAt = Date.now();
|
|
163
|
+
const results = [];
|
|
164
|
+
const metrics = {
|
|
165
|
+
driftItemsDetected: report.items.length,
|
|
166
|
+
prsOpened: 0,
|
|
167
|
+
issuesOpened: 0,
|
|
168
|
+
blockedCount: 0,
|
|
169
|
+
timeToSessionTerminalMs: [],
|
|
170
|
+
docAreaCounts: {},
|
|
171
|
+
noiseRateProxy: 0
|
|
172
|
+
};
|
|
173
|
+
for (const item of report.items) {
|
|
174
|
+
metrics.docAreaCounts[item.docArea] = (metrics.docAreaCounts[item.docArea] ?? 0) + 1;
|
|
175
|
+
const areaConfig = docAreaByName.get(item.docArea);
|
|
176
|
+
if (!areaConfig) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const decision = (0, engine_1.decidePolicy)({
|
|
180
|
+
item,
|
|
181
|
+
docAreaConfig: areaConfig,
|
|
182
|
+
config,
|
|
183
|
+
state,
|
|
184
|
+
repo,
|
|
185
|
+
baseSha: options.baseSha,
|
|
186
|
+
headSha: options.headSha
|
|
187
|
+
});
|
|
188
|
+
if (decision.action === "NOOP") {
|
|
189
|
+
results.push({
|
|
190
|
+
docArea: item.docArea,
|
|
191
|
+
decision,
|
|
192
|
+
outcome: "NO_CHANGE",
|
|
193
|
+
summary: decision.reason
|
|
194
|
+
});
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (decision.action === "UPDATE_EXISTING_PR") {
|
|
198
|
+
const existingPr = state.areaLatestPr[item.docArea];
|
|
199
|
+
const summary = existingPr
|
|
200
|
+
? `Bundled into existing PR: ${existingPr}`
|
|
201
|
+
: "PR cap reached and no existing area PR; escalated";
|
|
202
|
+
const outcome = existingPr ? "NO_CHANGE" : "BLOCKED";
|
|
203
|
+
results.push({
|
|
204
|
+
docArea: item.docArea,
|
|
205
|
+
decision,
|
|
206
|
+
outcome,
|
|
207
|
+
summary,
|
|
208
|
+
prUrl: existingPr
|
|
209
|
+
});
|
|
210
|
+
state = (0, engine_1.applyDecisionToState)({
|
|
211
|
+
state,
|
|
212
|
+
decision,
|
|
213
|
+
docArea: item.docArea,
|
|
214
|
+
outcome,
|
|
215
|
+
link: existingPr
|
|
216
|
+
});
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const bundle = await (0, bundle_1.buildEvidenceBundle)({ runInfo, item, evidenceRoot });
|
|
220
|
+
const attachmentPaths = [...new Set([bundle.archivePath, ...bundle.attachmentPaths])];
|
|
221
|
+
let sessionOutcome = {
|
|
222
|
+
outcome: "NO_CHANGE",
|
|
223
|
+
summary: "Skipped Devin session",
|
|
224
|
+
verification: config.policy.verification.commands.map((command) => ({ command, result: "not run" }))
|
|
225
|
+
};
|
|
226
|
+
if (devinApiKey) {
|
|
227
|
+
const sessionStart = Date.now();
|
|
228
|
+
sessionOutcome = await executeSession({
|
|
229
|
+
apiKey: devinApiKey,
|
|
230
|
+
repository: repo,
|
|
231
|
+
item,
|
|
232
|
+
attachmentPaths,
|
|
233
|
+
config
|
|
234
|
+
});
|
|
235
|
+
metrics.timeToSessionTerminalMs.push(Date.now() - sessionStart);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
(0, log_1.logWarn)("DEVIN_API_KEY not set; running fallback behavior", { docArea: item.docArea });
|
|
239
|
+
sessionOutcome = {
|
|
240
|
+
outcome: "BLOCKED",
|
|
241
|
+
summary: "DEVIN_API_KEY missing; cannot start Devin session",
|
|
242
|
+
questions: ["Set DEVIN_API_KEY in environment or GitHub Actions secrets"],
|
|
243
|
+
verification: config.policy.verification.commands.map((command) => ({ command, result: "not run" }))
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
let issueUrl;
|
|
247
|
+
if (githubToken &&
|
|
248
|
+
(decision.action === "OPEN_ISSUE" || sessionOutcome.outcome === "BLOCKED" || sessionOutcome.outcome === "NO_CHANGE")) {
|
|
249
|
+
issueUrl = await (0, client_1.createIssue)({
|
|
250
|
+
token: githubToken,
|
|
251
|
+
repository: repo,
|
|
252
|
+
issue: {
|
|
253
|
+
title: `[docdrift] ${item.docArea}: docs drift requires input`,
|
|
254
|
+
body: (0, client_1.renderBlockedIssueBody)({
|
|
255
|
+
docArea: item.docArea,
|
|
256
|
+
evidenceSummary: item.summary,
|
|
257
|
+
questions: sessionOutcome.questions ?? ["Please confirm intended behavior and doc wording."],
|
|
258
|
+
sessionUrl: sessionOutcome.sessionUrl
|
|
259
|
+
}),
|
|
260
|
+
labels: ["docdrift"]
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
metrics.issuesOpened += 1;
|
|
264
|
+
sessionOutcome.outcome = "ISSUE_OPENED";
|
|
265
|
+
}
|
|
266
|
+
if (sessionOutcome.outcome === "PR_OPENED") {
|
|
267
|
+
metrics.prsOpened += 1;
|
|
268
|
+
}
|
|
269
|
+
if (sessionOutcome.outcome === "BLOCKED") {
|
|
270
|
+
metrics.blockedCount += 1;
|
|
271
|
+
}
|
|
272
|
+
const result = {
|
|
273
|
+
docArea: item.docArea,
|
|
274
|
+
decision,
|
|
275
|
+
outcome: sessionOutcome.outcome,
|
|
276
|
+
summary: sessionOutcome.summary,
|
|
277
|
+
sessionUrl: sessionOutcome.sessionUrl,
|
|
278
|
+
prUrl: sessionOutcome.prUrl,
|
|
279
|
+
issueUrl
|
|
280
|
+
};
|
|
281
|
+
results.push(result);
|
|
282
|
+
if (githubToken) {
|
|
283
|
+
const body = (0, client_1.renderRunComment)({
|
|
284
|
+
docArea: item.docArea,
|
|
285
|
+
summary: sessionOutcome.summary,
|
|
286
|
+
decision: decision.action,
|
|
287
|
+
outcome: sessionOutcome.outcome,
|
|
288
|
+
sessionUrl: sessionOutcome.sessionUrl,
|
|
289
|
+
prUrl: sessionOutcome.prUrl,
|
|
290
|
+
issueUrl,
|
|
291
|
+
validation: sessionOutcome.verification
|
|
292
|
+
});
|
|
293
|
+
await (0, client_1.postCommitComment)({
|
|
294
|
+
token: githubToken,
|
|
295
|
+
repository: repo,
|
|
296
|
+
commitSha,
|
|
297
|
+
body
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
state = (0, engine_1.applyDecisionToState)({
|
|
301
|
+
state,
|
|
302
|
+
decision,
|
|
303
|
+
docArea: item.docArea,
|
|
304
|
+
outcome: sessionOutcome.outcome,
|
|
305
|
+
link: sessionOutcome.prUrl ?? issueUrl
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
(0, state_1.saveState)(state);
|
|
309
|
+
metrics.noiseRateProxy =
|
|
310
|
+
metrics.driftItemsDetected === 0 ? 0 : Number((metrics.prsOpened / metrics.driftItemsDetected).toFixed(4));
|
|
311
|
+
(0, bundle_1.writeMetrics)(metrics);
|
|
312
|
+
(0, log_1.logInfo)("Run complete", {
|
|
313
|
+
items: report.items.length,
|
|
314
|
+
elapsedMs: Date.now() - startedAt
|
|
315
|
+
});
|
|
316
|
+
return results;
|
|
317
|
+
}
|
|
318
|
+
async function runValidate() {
|
|
319
|
+
const config = (0, load_1.loadConfig)();
|
|
320
|
+
const runtimeValidation = await (0, validate_1.validateRuntimeConfig)(config);
|
|
321
|
+
if (runtimeValidation.errors.length) {
|
|
322
|
+
throw new Error(`Config validation failed:\n${runtimeValidation.errors.join("\n")}`);
|
|
323
|
+
}
|
|
324
|
+
runtimeValidation.warnings.forEach((warning) => (0, log_1.logWarn)(warning));
|
|
325
|
+
(0, log_1.logInfo)("Config is valid");
|
|
326
|
+
}
|
|
327
|
+
async function runStatus(sinceHours = 24) {
|
|
328
|
+
const apiKey = process.env.DEVIN_API_KEY;
|
|
329
|
+
if (!apiKey) {
|
|
330
|
+
throw new Error("DEVIN_API_KEY is required for status command");
|
|
331
|
+
}
|
|
332
|
+
const sessions = await (0, v1_1.devinListSessions)(apiKey, { limit: 50, tag: "docdrift" });
|
|
333
|
+
const cutoff = Date.now() - sinceHours * 60 * 60 * 1000;
|
|
334
|
+
const filtered = sessions.filter((session) => {
|
|
335
|
+
const createdAt = session?.created_at ? Date.parse(String(session.created_at)) : Date.now();
|
|
336
|
+
return Number.isFinite(createdAt) ? createdAt >= cutoff : true;
|
|
337
|
+
});
|
|
338
|
+
if (!filtered.length) {
|
|
339
|
+
(0, log_1.logInfo)(`No docdrift sessions in last ${sinceHours}h`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const session of filtered) {
|
|
343
|
+
const id = String(session.session_id ?? session.id ?? "unknown");
|
|
344
|
+
const status = String(session.status_enum ?? session.status ?? "unknown");
|
|
345
|
+
const url = String(session.url ?? "");
|
|
346
|
+
console.log(`${id}\t${status}\t${url}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function resolveTrigger(eventName) {
|
|
350
|
+
if (eventName === "push") {
|
|
351
|
+
return "push";
|
|
352
|
+
}
|
|
353
|
+
if (eventName === "schedule") {
|
|
354
|
+
return "schedule";
|
|
355
|
+
}
|
|
356
|
+
return "manual";
|
|
357
|
+
}
|
|
358
|
+
function parseDurationHours(value) {
|
|
359
|
+
if (!value) {
|
|
360
|
+
return 24;
|
|
361
|
+
}
|
|
362
|
+
const normalized = value.endsWith("h") ? value.slice(0, -1) : value;
|
|
363
|
+
const parsed = Number(normalized);
|
|
364
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
365
|
+
throw new Error(`Invalid --since value: ${value}`);
|
|
366
|
+
}
|
|
367
|
+
return parsed;
|
|
368
|
+
}
|
|
369
|
+
function requireSha(value, label) {
|
|
370
|
+
if (!value) {
|
|
371
|
+
throw new Error(`Missing required argument: ${label}`);
|
|
372
|
+
}
|
|
373
|
+
return value;
|
|
374
|
+
}
|
|
375
|
+
exports.STATE_PATH = node_path_1.default.resolve(".docdrift", "state.json");
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.emptyState = void 0;
|
|
4
|
+
const emptyState = () => ({
|
|
5
|
+
idempotency: {},
|
|
6
|
+
dailyPrCount: {},
|
|
7
|
+
areaDailyPrOpened: {},
|
|
8
|
+
areaLatestPr: {}
|
|
9
|
+
});
|
|
10
|
+
exports.emptyState = emptyState;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scoreSignals = scoreSignals;
|
|
4
|
+
exports.combineWithDevinPlan = combineWithDevinPlan;
|
|
5
|
+
const tierWeight = {
|
|
6
|
+
0: 1,
|
|
7
|
+
1: 0.9,
|
|
8
|
+
2: 0.6,
|
|
9
|
+
3: 0.35
|
|
10
|
+
};
|
|
11
|
+
function clamp01(value) {
|
|
12
|
+
return Math.max(0, Math.min(1, value));
|
|
13
|
+
}
|
|
14
|
+
function scoreSignals(signals) {
|
|
15
|
+
if (!signals.length) {
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
let complement = 1;
|
|
19
|
+
for (const signal of signals) {
|
|
20
|
+
const weight = tierWeight[signal.tier] ?? 0.3;
|
|
21
|
+
const weighted = clamp01(signal.confidence * weight);
|
|
22
|
+
complement *= 1 - weighted;
|
|
23
|
+
}
|
|
24
|
+
return clamp01(1 - complement);
|
|
25
|
+
}
|
|
26
|
+
function combineWithDevinPlan(detectorConfidence, devinPlanConfidence) {
|
|
27
|
+
if (typeof devinPlanConfidence !== "number") {
|
|
28
|
+
return detectorConfidence;
|
|
29
|
+
}
|
|
30
|
+
return clamp01(detectorConfidence * 0.65 + devinPlanConfidence * 0.35);
|
|
31
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.decidePolicy = decidePolicy;
|
|
4
|
+
exports.applyDecisionToState = applyDecisionToState;
|
|
5
|
+
const glob_1 = require("../utils/glob");
|
|
6
|
+
const hash_1 = require("../utils/hash");
|
|
7
|
+
const confidence_1 = require("./confidence");
|
|
8
|
+
function ymd(iso = new Date().toISOString()) {
|
|
9
|
+
return iso.slice(0, 10);
|
|
10
|
+
}
|
|
11
|
+
function buildIdempotencyKey(input) {
|
|
12
|
+
return (0, hash_1.sha256)(`${input.repo}:${input.docArea}:${input.baseSha}:${input.headSha}:${input.action}`);
|
|
13
|
+
}
|
|
14
|
+
function decidePolicy(input) {
|
|
15
|
+
const { item, docAreaConfig, config, state } = input;
|
|
16
|
+
const confidence = (0, confidence_1.scoreSignals)(item.signals);
|
|
17
|
+
const threshold = config.policy.confidence.autopatchThreshold;
|
|
18
|
+
const today = ymd();
|
|
19
|
+
const hasStrongSignal = item.signals.some((signal) => signal.tier <= 1);
|
|
20
|
+
const prCountToday = state.dailyPrCount[today] ?? 0;
|
|
21
|
+
const capReached = prCountToday >= config.policy.prCaps.maxPrsPerDay;
|
|
22
|
+
const areaDailyKey = `${today}:${item.docArea}`;
|
|
23
|
+
const exceedsFileCap = item.impactedDocs.length > config.policy.prCaps.maxFilesTouched;
|
|
24
|
+
const hasPathOutsideAllowlist = item.impactedDocs.some((filePath) => filePath && !(0, glob_1.isPathAllowed)(filePath, config.policy.allowlist));
|
|
25
|
+
let action = "NOOP";
|
|
26
|
+
let reason = "No action needed";
|
|
27
|
+
if (hasPathOutsideAllowlist) {
|
|
28
|
+
action = "OPEN_ISSUE";
|
|
29
|
+
reason = "Impacted files include non-allowlisted paths";
|
|
30
|
+
}
|
|
31
|
+
else if (exceedsFileCap) {
|
|
32
|
+
action = "OPEN_ISSUE";
|
|
33
|
+
reason = "Impacted files exceed maxFilesTouched policy cap";
|
|
34
|
+
}
|
|
35
|
+
else if (item.mode === "autogen") {
|
|
36
|
+
if (!hasStrongSignal) {
|
|
37
|
+
action = "OPEN_ISSUE";
|
|
38
|
+
reason = "Autogen area without strong signal; escalate as issue";
|
|
39
|
+
}
|
|
40
|
+
else if (confidence < threshold) {
|
|
41
|
+
action = "OPEN_ISSUE";
|
|
42
|
+
reason = `Confidence ${confidence.toFixed(2)} below threshold ${threshold.toFixed(2)}`;
|
|
43
|
+
}
|
|
44
|
+
else if (capReached) {
|
|
45
|
+
action = state.areaLatestPr[item.docArea] ? "UPDATE_EXISTING_PR" : "OPEN_ISSUE";
|
|
46
|
+
reason = "Daily PR cap reached";
|
|
47
|
+
}
|
|
48
|
+
else if (state.areaDailyPrOpened[areaDailyKey]) {
|
|
49
|
+
action = "UPDATE_EXISTING_PR";
|
|
50
|
+
reason = "One PR per doc area per day bundling rule";
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
action = "OPEN_PR";
|
|
54
|
+
reason = "Strong autogen signal with confidence above threshold";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const requireHuman = Boolean(docAreaConfig.patch.requireHumanConfirmation);
|
|
59
|
+
if (!requireHuman && hasStrongSignal && confidence >= Math.min(0.95, threshold + 0.1)) {
|
|
60
|
+
action = "OPEN_PR";
|
|
61
|
+
reason = "Conceptual area is high-confidence and human confirmation not required";
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
action = "OPEN_ISSUE";
|
|
65
|
+
reason = "Conceptual drift defaults to human-in-the-loop issue";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const idempotencyKey = buildIdempotencyKey({
|
|
69
|
+
repo: input.repo,
|
|
70
|
+
docArea: item.docArea,
|
|
71
|
+
baseSha: input.baseSha,
|
|
72
|
+
headSha: input.headSha,
|
|
73
|
+
action
|
|
74
|
+
});
|
|
75
|
+
if (state.idempotency[idempotencyKey]) {
|
|
76
|
+
return {
|
|
77
|
+
action: "NOOP",
|
|
78
|
+
confidence,
|
|
79
|
+
reason: "Idempotency key already processed",
|
|
80
|
+
idempotencyKey
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
action,
|
|
85
|
+
confidence,
|
|
86
|
+
reason,
|
|
87
|
+
idempotencyKey
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function applyDecisionToState(input) {
|
|
91
|
+
const today = ymd();
|
|
92
|
+
const next = JSON.parse(JSON.stringify(input.state));
|
|
93
|
+
const record = {
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
action: input.decision.action,
|
|
96
|
+
outcome: input.outcome,
|
|
97
|
+
link: input.link
|
|
98
|
+
};
|
|
99
|
+
next.idempotency[input.decision.idempotencyKey] = record;
|
|
100
|
+
if (input.outcome === "PR_OPENED") {
|
|
101
|
+
next.dailyPrCount[today] = (next.dailyPrCount[today] ?? 0) + 1;
|
|
102
|
+
next.areaDailyPrOpened[`${today}:${input.docArea}`] = input.link ?? "opened";
|
|
103
|
+
if (input.link) {
|
|
104
|
+
next.areaLatestPr[input.docArea] = input.link;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return next;
|
|
108
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadState = loadState;
|
|
7
|
+
exports.saveState = saveState;
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const state_1 = require("../model/state");
|
|
10
|
+
const fs_1 = require("../utils/fs");
|
|
11
|
+
const statePath = node_path_1.default.resolve(".docdrift", "state.json");
|
|
12
|
+
function loadState() {
|
|
13
|
+
return (0, fs_1.readJsonFile)(statePath, (0, state_1.emptyState)());
|
|
14
|
+
}
|
|
15
|
+
function saveState(state) {
|
|
16
|
+
(0, fs_1.writeJsonFile)(statePath, state);
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.execCommand = execCommand;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const node_util_1 = require("node:util");
|
|
6
|
+
const exec = (0, node_util_1.promisify)(node_child_process_1.exec);
|
|
7
|
+
async function execCommand(command, cwd = process.cwd()) {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout, stderr } = await exec(command, {
|
|
10
|
+
cwd,
|
|
11
|
+
maxBuffer: 10 * 1024 * 1024
|
|
12
|
+
});
|
|
13
|
+
return { command, stdout, stderr, exitCode: 0 };
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
const e = error;
|
|
17
|
+
return {
|
|
18
|
+
command,
|
|
19
|
+
stdout: e.stdout ?? "",
|
|
20
|
+
stderr: e.stderr ?? String(error),
|
|
21
|
+
exitCode: typeof e.code === "number" ? e.code : 1
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ensureDir = ensureDir;
|
|
7
|
+
exports.readJsonFile = readJsonFile;
|
|
8
|
+
exports.writeJsonFile = writeJsonFile;
|
|
9
|
+
exports.safeReadText = safeReadText;
|
|
10
|
+
exports.copyIfExists = copyIfExists;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
function ensureDir(dirPath) {
|
|
14
|
+
node_fs_1.default.mkdirSync(dirPath, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
function readJsonFile(filePath, fallback) {
|
|
17
|
+
if (!node_fs_1.default.existsSync(filePath)) {
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
return JSON.parse(node_fs_1.default.readFileSync(filePath, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
function writeJsonFile(filePath, value) {
|
|
23
|
+
ensureDir(node_path_1.default.dirname(filePath));
|
|
24
|
+
node_fs_1.default.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
25
|
+
}
|
|
26
|
+
function safeReadText(filePath) {
|
|
27
|
+
if (!node_fs_1.default.existsSync(filePath)) {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
return node_fs_1.default.readFileSync(filePath, "utf8");
|
|
31
|
+
}
|
|
32
|
+
function copyIfExists(sourcePath, destPath) {
|
|
33
|
+
if (!node_fs_1.default.existsSync(sourcePath)) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
ensureDir(node_path_1.default.dirname(destPath));
|
|
37
|
+
node_fs_1.default.copyFileSync(sourcePath, destPath);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gitChangedPaths = gitChangedPaths;
|
|
4
|
+
exports.gitDiffSummary = gitDiffSummary;
|
|
5
|
+
exports.gitCommitList = gitCommitList;
|
|
6
|
+
const exec_1 = require("./exec");
|
|
7
|
+
async function gitChangedPaths(baseSha, headSha) {
|
|
8
|
+
const res = await (0, exec_1.execCommand)(`git diff --name-only ${baseSha} ${headSha}`);
|
|
9
|
+
if (res.exitCode !== 0) {
|
|
10
|
+
throw new Error(`Unable to compute changed paths: ${res.stderr}`);
|
|
11
|
+
}
|
|
12
|
+
return res.stdout
|
|
13
|
+
.split("\n")
|
|
14
|
+
.map((v) => v.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
async function gitDiffSummary(baseSha, headSha) {
|
|
18
|
+
const res = await (0, exec_1.execCommand)(`git diff --stat ${baseSha} ${headSha}`);
|
|
19
|
+
if (res.exitCode !== 0) {
|
|
20
|
+
throw new Error(`Unable to compute diff summary: ${res.stderr}`);
|
|
21
|
+
}
|
|
22
|
+
return res.stdout.trim();
|
|
23
|
+
}
|
|
24
|
+
async function gitCommitList(baseSha, headSha) {
|
|
25
|
+
const res = await (0, exec_1.execCommand)(`git log --pretty=format:%H ${baseSha}..${headSha}`);
|
|
26
|
+
if (res.exitCode !== 0) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return res.stdout
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((v) => v.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.globToRegExp = globToRegExp;
|
|
4
|
+
exports.matchesGlob = matchesGlob;
|
|
5
|
+
exports.isPathAllowed = isPathAllowed;
|
|
6
|
+
function escapeRegex(input) {
|
|
7
|
+
return input.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
8
|
+
}
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
const escaped = escapeRegex(glob)
|
|
11
|
+
.replace(/\*\*/g, "::DOUBLE_STAR::")
|
|
12
|
+
.replace(/\*/g, "[^/]*")
|
|
13
|
+
.replace(/::DOUBLE_STAR::/g, ".*");
|
|
14
|
+
return new RegExp(`^${escaped}$`);
|
|
15
|
+
}
|
|
16
|
+
function matchesGlob(glob, value) {
|
|
17
|
+
return globToRegExp(glob).test(value);
|
|
18
|
+
}
|
|
19
|
+
function isPathAllowed(path, allowlist) {
|
|
20
|
+
return allowlist.some((glob) => matchesGlob(glob, path));
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.sha256 = sha256;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
function sha256(input) {
|
|
9
|
+
return node_crypto_1.default.createHash("sha256").update(input).digest("hex");
|
|
10
|
+
}
|