@devinnn/docdrift 0.1.2 → 0.1.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/dist/src/cli.js +11 -9
- package/dist/src/config/normalize.js +93 -6
- package/dist/src/config/schema.js +78 -18
- package/dist/src/config/validate.js +6 -2
- package/dist/src/detect/index.js +76 -21
- package/dist/src/devin/prompts.js +45 -1
- package/dist/src/github/client.js +13 -0
- package/dist/src/index.js +68 -10
- package/dist/src/spec-providers/fern.js +123 -0
- package/dist/src/spec-providers/graphql.js +168 -0
- package/dist/src/spec-providers/openapi.js +181 -0
- package/dist/src/spec-providers/postman.js +193 -0
- package/dist/src/spec-providers/registry.js +26 -0
- package/dist/src/spec-providers/swagger2.js +229 -0
- package/dist/src/spec-providers/types.js +2 -0
- package/dist/src/utils/fetch.js +87 -0
- package/dist/src/utils/git.js +20 -0
- package/docdrift.schema.json +438 -0
- package/package.json +9 -4
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
37
|
};
|
|
@@ -12,6 +45,7 @@ exports.runStatus = runStatus;
|
|
|
12
45
|
exports.resolveTrigger = resolveTrigger;
|
|
13
46
|
exports.parseDurationHours = parseDurationHours;
|
|
14
47
|
exports.requireSha = requireSha;
|
|
48
|
+
exports.resolveBaseHead = resolveBaseHead;
|
|
15
49
|
const node_path_1 = __importDefault(require("node:path"));
|
|
16
50
|
const load_1 = require("./config/load");
|
|
17
51
|
const validate_1 = require("./config/validate");
|
|
@@ -60,6 +94,9 @@ async function executeSessionSingle(input) {
|
|
|
60
94
|
aggregated: input.aggregated,
|
|
61
95
|
config: input.config,
|
|
62
96
|
attachmentUrls,
|
|
97
|
+
runGate: input.runGate,
|
|
98
|
+
trigger: input.trigger,
|
|
99
|
+
prNumber: input.prNumber,
|
|
63
100
|
});
|
|
64
101
|
const session = await (0, v1_1.devinCreateSession)(input.apiKey, {
|
|
65
102
|
prompt,
|
|
@@ -123,14 +160,15 @@ async function runDetect(options) {
|
|
|
123
160
|
}
|
|
124
161
|
const repo = process.env.GITHUB_REPOSITORY ?? "local/docdrift";
|
|
125
162
|
const normalized = (0, load_1.loadNormalizedConfig)();
|
|
126
|
-
const { report,
|
|
163
|
+
const { report, runGate } = await (0, detect_1.buildDriftReport)({
|
|
127
164
|
config: normalized,
|
|
128
165
|
repo,
|
|
129
166
|
baseSha: options.baseSha,
|
|
130
167
|
headSha: options.headSha,
|
|
131
168
|
trigger: options.trigger ?? "manual",
|
|
169
|
+
prNumber: options.prNumber,
|
|
132
170
|
});
|
|
133
|
-
(0, log_1.logInfo)(`Drift items detected: ${report.items.length} (
|
|
171
|
+
(0, log_1.logInfo)(`Drift items detected: ${report.items.length} (runGate: ${runGate})`);
|
|
134
172
|
return { hasDrift: report.items.length > 0 };
|
|
135
173
|
}
|
|
136
174
|
async function runDocDrift(options) {
|
|
@@ -144,16 +182,17 @@ async function runDocDrift(options) {
|
|
|
144
182
|
const commitSha = process.env.GITHUB_SHA ?? options.headSha;
|
|
145
183
|
const githubToken = process.env.GITHUB_TOKEN;
|
|
146
184
|
const devinApiKey = process.env.DEVIN_API_KEY;
|
|
147
|
-
const { report, aggregated, runInfo, evidenceRoot,
|
|
185
|
+
const { report, aggregated, runInfo, evidenceRoot, runGate } = await (0, detect_1.buildDriftReport)({
|
|
148
186
|
config: normalized,
|
|
149
187
|
repo,
|
|
150
188
|
baseSha: options.baseSha,
|
|
151
189
|
headSha: options.headSha,
|
|
152
190
|
trigger: options.trigger ?? "manual",
|
|
191
|
+
prNumber: options.prNumber,
|
|
153
192
|
});
|
|
154
|
-
// Gate: no
|
|
155
|
-
if (
|
|
156
|
-
(0, log_1.logInfo)("No
|
|
193
|
+
// Gate: no run (spec drift, conceptual-only, or infer) — exit early, no session
|
|
194
|
+
if (runGate === "none" || report.items.length === 0) {
|
|
195
|
+
(0, log_1.logInfo)("No drift; skipping session");
|
|
157
196
|
return [];
|
|
158
197
|
}
|
|
159
198
|
const item = report.items[0];
|
|
@@ -234,6 +273,9 @@ async function runDocDrift(options) {
|
|
|
234
273
|
aggregated: aggregated,
|
|
235
274
|
attachmentPaths,
|
|
236
275
|
config: normalized,
|
|
276
|
+
runGate,
|
|
277
|
+
trigger: runInfo.trigger,
|
|
278
|
+
prNumber: runInfo.prNumber,
|
|
237
279
|
});
|
|
238
280
|
metrics.timeToSessionTerminalMs.push(Date.now() - sessionStart);
|
|
239
281
|
}
|
|
@@ -254,6 +296,14 @@ async function runDocDrift(options) {
|
|
|
254
296
|
metrics.prsOpened += 1;
|
|
255
297
|
state.lastDocDriftPrUrl = sessionOutcome.prUrl;
|
|
256
298
|
state.lastDocDriftPrOpenedAt = new Date().toISOString();
|
|
299
|
+
if (githubToken && runInfo.trigger === "pull_request" && runInfo.prNumber) {
|
|
300
|
+
await (0, client_1.postPrComment)({
|
|
301
|
+
token: githubToken,
|
|
302
|
+
repository: repo,
|
|
303
|
+
prNumber: runInfo.prNumber,
|
|
304
|
+
body: `## Doc drift detected\n\nDraft doc PR: ${sessionOutcome.prUrl}\n\nMerge your API changes first, then review and merge this doc PR.`,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
257
307
|
const touchedRequireReview = (item.impactedDocs ?? []).filter((p) => normalized.requireHumanReview.some((glob) => (0, glob_1.matchesGlob)(glob, p)));
|
|
258
308
|
if (githubToken && touchedRequireReview.length > 0) {
|
|
259
309
|
issueUrl = await (0, client_1.createIssue)({
|
|
@@ -450,12 +500,12 @@ async function runStatus(sinceHours = 24) {
|
|
|
450
500
|
}
|
|
451
501
|
}
|
|
452
502
|
function resolveTrigger(eventName) {
|
|
453
|
-
if (eventName === "push")
|
|
503
|
+
if (eventName === "push")
|
|
454
504
|
return "push";
|
|
455
|
-
|
|
456
|
-
if (eventName === "schedule") {
|
|
505
|
+
if (eventName === "schedule")
|
|
457
506
|
return "schedule";
|
|
458
|
-
|
|
507
|
+
if (eventName === "pull_request")
|
|
508
|
+
return "pull_request";
|
|
459
509
|
return "manual";
|
|
460
510
|
}
|
|
461
511
|
function parseDurationHours(value) {
|
|
@@ -475,4 +525,12 @@ function requireSha(value, label) {
|
|
|
475
525
|
}
|
|
476
526
|
return value;
|
|
477
527
|
}
|
|
528
|
+
async function resolveBaseHead(baseArg, headArg) {
|
|
529
|
+
const headRef = headArg ?? process.env.GITHUB_SHA ?? "HEAD";
|
|
530
|
+
if (baseArg) {
|
|
531
|
+
return { baseSha: baseArg, headSha: headRef };
|
|
532
|
+
}
|
|
533
|
+
const { resolveDefaultBaseHead } = await Promise.resolve().then(() => __importStar(require("./utils/git")));
|
|
534
|
+
return resolveDefaultBaseHead(headRef);
|
|
535
|
+
}
|
|
478
536
|
exports.STATE_PATH = node_path_1.default.resolve(".docdrift", "state.json");
|
|
@@ -0,0 +1,123 @@
|
|
|
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.detectFernSpecDrift = detectFernSpecDrift;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const fs_1 = require("../utils/fs");
|
|
10
|
+
const json_1 = require("../utils/json");
|
|
11
|
+
function readFernDefinitionDir(dirPath) {
|
|
12
|
+
const out = {};
|
|
13
|
+
if (!node_fs_1.default.existsSync(dirPath) || !node_fs_1.default.statSync(dirPath).isDirectory()) {
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
const entries = node_fs_1.default.readdirSync(dirPath, { withFileTypes: true });
|
|
17
|
+
for (const e of entries) {
|
|
18
|
+
const full = node_path_1.default.join(dirPath, e.name);
|
|
19
|
+
if (e.isDirectory()) {
|
|
20
|
+
Object.assign(out, readFernDefinitionDir(full));
|
|
21
|
+
}
|
|
22
|
+
else if (e.isFile() && (e.name.endsWith(".yml") || e.name.endsWith(".yaml"))) {
|
|
23
|
+
out[full] = node_fs_1.default.readFileSync(full, "utf8");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
async function getCurrentContent(config) {
|
|
29
|
+
const current = config.current;
|
|
30
|
+
if (current.type !== "local") {
|
|
31
|
+
throw new Error("Fern provider only supports local definition folder");
|
|
32
|
+
}
|
|
33
|
+
return readFernDefinitionDir(current.path);
|
|
34
|
+
}
|
|
35
|
+
function contentSignature(files) {
|
|
36
|
+
const sorted = Object.keys(files).sort();
|
|
37
|
+
return (0, json_1.stableStringify)(sorted.map((k) => ({ path: k, content: files[k] })));
|
|
38
|
+
}
|
|
39
|
+
async function detectFernSpecDrift(config, evidenceDir) {
|
|
40
|
+
if (config.format !== "fern") {
|
|
41
|
+
return {
|
|
42
|
+
hasDrift: false,
|
|
43
|
+
summary: `Format ${config.format} is not fern`,
|
|
44
|
+
evidenceFiles: [],
|
|
45
|
+
impactedDocs: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
(0, fs_1.ensureDir)(evidenceDir);
|
|
49
|
+
let currentFiles;
|
|
50
|
+
try {
|
|
51
|
+
currentFiles = await getCurrentContent(config);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
55
|
+
const logPath = node_path_1.default.join(evidenceDir, "fern-export.log");
|
|
56
|
+
node_fs_1.default.writeFileSync(logPath, msg, "utf8");
|
|
57
|
+
return {
|
|
58
|
+
hasDrift: true,
|
|
59
|
+
summary: `Fern definition read failed: ${msg}`,
|
|
60
|
+
evidenceFiles: [logPath],
|
|
61
|
+
impactedDocs: [config.published],
|
|
62
|
+
signal: {
|
|
63
|
+
kind: "weak_evidence",
|
|
64
|
+
tier: 2,
|
|
65
|
+
confidence: 0.35,
|
|
66
|
+
evidence: [logPath],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const publishedPath = config.published;
|
|
71
|
+
let publishedSignature;
|
|
72
|
+
if (node_fs_1.default.existsSync(publishedPath) && node_fs_1.default.statSync(publishedPath).isDirectory()) {
|
|
73
|
+
const publishedFiles = readFernDefinitionDir(publishedPath);
|
|
74
|
+
publishedSignature = contentSignature(publishedFiles);
|
|
75
|
+
}
|
|
76
|
+
else if (node_fs_1.default.existsSync(publishedPath)) {
|
|
77
|
+
publishedSignature = node_fs_1.default.readFileSync(publishedPath, "utf8");
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
return {
|
|
81
|
+
hasDrift: true,
|
|
82
|
+
summary: "Fern published path missing",
|
|
83
|
+
evidenceFiles: [],
|
|
84
|
+
impactedDocs: [config.published],
|
|
85
|
+
signal: {
|
|
86
|
+
kind: "weak_evidence",
|
|
87
|
+
tier: 2,
|
|
88
|
+
confidence: 0.35,
|
|
89
|
+
evidence: [],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const currentSignature = contentSignature(currentFiles);
|
|
94
|
+
if (currentSignature === publishedSignature) {
|
|
95
|
+
return {
|
|
96
|
+
hasDrift: false,
|
|
97
|
+
summary: "No Fern definition drift detected",
|
|
98
|
+
evidenceFiles: [],
|
|
99
|
+
impactedDocs: [config.published],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const summary = "Fern definition YAML changed.";
|
|
103
|
+
const diffPath = node_path_1.default.join(evidenceDir, "fern.diff.txt");
|
|
104
|
+
node_fs_1.default.writeFileSync(diffPath, [
|
|
105
|
+
"# Fern Drift Summary",
|
|
106
|
+
summary,
|
|
107
|
+
"",
|
|
108
|
+
"# Current definition signature (file list + content hash)",
|
|
109
|
+
currentSignature.slice(0, 12000),
|
|
110
|
+
].join("\n"), "utf8");
|
|
111
|
+
return {
|
|
112
|
+
hasDrift: true,
|
|
113
|
+
summary,
|
|
114
|
+
evidenceFiles: [diffPath],
|
|
115
|
+
impactedDocs: [config.published],
|
|
116
|
+
signal: {
|
|
117
|
+
kind: "fern_diff",
|
|
118
|
+
tier: 1,
|
|
119
|
+
confidence: 0.95,
|
|
120
|
+
evidence: [diffPath],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.detectGraphQLSpecDrift = detectGraphQLSpecDrift;
|
|
40
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
41
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
42
|
+
const fs_1 = require("../utils/fs");
|
|
43
|
+
const fetch_1 = require("../utils/fetch");
|
|
44
|
+
const GRAPHQL_INTROSPECTION_QUERY = `
|
|
45
|
+
query IntrospectionQuery {
|
|
46
|
+
__schema {
|
|
47
|
+
types { name kind }
|
|
48
|
+
queryType { name }
|
|
49
|
+
mutationType { name }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
function normalizeGraphQLSchema(content) {
|
|
54
|
+
// Strip comments and normalize whitespace for comparison
|
|
55
|
+
return content
|
|
56
|
+
.replace(/#[^\n]*/g, "")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
async function getCurrentContent(config) {
|
|
61
|
+
const current = config.current;
|
|
62
|
+
if (current.type === "url") {
|
|
63
|
+
const body = { query: GRAPHQL_INTROSPECTION_QUERY };
|
|
64
|
+
const res = await (0, fetch_1.fetchSpecPost)(current.url, body);
|
|
65
|
+
const json = JSON.parse(res);
|
|
66
|
+
const schema = json?.data?.__schema;
|
|
67
|
+
if (!schema) {
|
|
68
|
+
throw new Error("GraphQL introspection did not return __schema");
|
|
69
|
+
}
|
|
70
|
+
return JSON.stringify(schema, null, 2);
|
|
71
|
+
}
|
|
72
|
+
if (current.type === "local") {
|
|
73
|
+
if (!node_fs_1.default.existsSync(current.path)) {
|
|
74
|
+
throw new Error(`GraphQL local path not found: ${current.path}`);
|
|
75
|
+
}
|
|
76
|
+
return node_fs_1.default.readFileSync(current.path, "utf8");
|
|
77
|
+
}
|
|
78
|
+
const { execCommand } = await Promise.resolve().then(() => __importStar(require("../utils/exec")));
|
|
79
|
+
const result = await execCommand(current.command);
|
|
80
|
+
if (result.exitCode !== 0) {
|
|
81
|
+
throw new Error(`GraphQL export failed: ${result.stderr}`);
|
|
82
|
+
}
|
|
83
|
+
if (!node_fs_1.default.existsSync(current.outputPath)) {
|
|
84
|
+
throw new Error(`GraphQL export did not create: ${current.outputPath}`);
|
|
85
|
+
}
|
|
86
|
+
return node_fs_1.default.readFileSync(current.outputPath, "utf8");
|
|
87
|
+
}
|
|
88
|
+
async function detectGraphQLSpecDrift(config, evidenceDir) {
|
|
89
|
+
if (config.format !== "graphql") {
|
|
90
|
+
return {
|
|
91
|
+
hasDrift: false,
|
|
92
|
+
summary: `Format ${config.format} is not graphql`,
|
|
93
|
+
evidenceFiles: [],
|
|
94
|
+
impactedDocs: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
(0, fs_1.ensureDir)(evidenceDir);
|
|
98
|
+
let currentContent;
|
|
99
|
+
try {
|
|
100
|
+
currentContent = await getCurrentContent(config);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
const logPath = node_path_1.default.join(evidenceDir, "graphql-export.log");
|
|
105
|
+
node_fs_1.default.writeFileSync(logPath, msg, "utf8");
|
|
106
|
+
return {
|
|
107
|
+
hasDrift: true,
|
|
108
|
+
summary: `GraphQL current spec failed: ${msg}`,
|
|
109
|
+
evidenceFiles: [logPath],
|
|
110
|
+
impactedDocs: [config.published],
|
|
111
|
+
signal: {
|
|
112
|
+
kind: "weak_evidence",
|
|
113
|
+
tier: 2,
|
|
114
|
+
confidence: 0.35,
|
|
115
|
+
evidence: [logPath],
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (!node_fs_1.default.existsSync(config.published)) {
|
|
120
|
+
return {
|
|
121
|
+
hasDrift: true,
|
|
122
|
+
summary: "GraphQL published file missing",
|
|
123
|
+
evidenceFiles: [],
|
|
124
|
+
impactedDocs: [config.published],
|
|
125
|
+
signal: {
|
|
126
|
+
kind: "weak_evidence",
|
|
127
|
+
tier: 2,
|
|
128
|
+
confidence: 0.35,
|
|
129
|
+
evidence: [],
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const publishedContent = node_fs_1.default.readFileSync(config.published, "utf8");
|
|
134
|
+
const normalizedCurrent = normalizeGraphQLSchema(currentContent);
|
|
135
|
+
const normalizedPublished = normalizeGraphQLSchema(publishedContent);
|
|
136
|
+
if (normalizedCurrent === normalizedPublished) {
|
|
137
|
+
return {
|
|
138
|
+
hasDrift: false,
|
|
139
|
+
summary: "No GraphQL schema drift detected",
|
|
140
|
+
evidenceFiles: [],
|
|
141
|
+
impactedDocs: [config.published],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const summary = "GraphQL schema changed (types or fields differ).";
|
|
145
|
+
const diffPath = node_path_1.default.join(evidenceDir, "graphql.diff.txt");
|
|
146
|
+
node_fs_1.default.writeFileSync(diffPath, [
|
|
147
|
+
"# GraphQL Drift Summary",
|
|
148
|
+
summary,
|
|
149
|
+
"",
|
|
150
|
+
"# Published (excerpt)",
|
|
151
|
+
normalizedPublished.slice(0, 8000),
|
|
152
|
+
"",
|
|
153
|
+
"# Current (excerpt)",
|
|
154
|
+
normalizedCurrent.slice(0, 8000),
|
|
155
|
+
].join("\n"), "utf8");
|
|
156
|
+
return {
|
|
157
|
+
hasDrift: true,
|
|
158
|
+
summary,
|
|
159
|
+
evidenceFiles: [diffPath],
|
|
160
|
+
impactedDocs: [config.published],
|
|
161
|
+
signal: {
|
|
162
|
+
kind: "graphql_diff",
|
|
163
|
+
tier: 1,
|
|
164
|
+
confidence: 0.95,
|
|
165
|
+
evidence: [diffPath],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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.detectOpenApiSpecDrift = detectOpenApiSpecDrift;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const exec_1 = require("../utils/exec");
|
|
10
|
+
const fs_1 = require("../utils/fs");
|
|
11
|
+
const json_1 = require("../utils/json");
|
|
12
|
+
const fetch_1 = require("../utils/fetch");
|
|
13
|
+
function responseFields(spec) {
|
|
14
|
+
const fields = new Set();
|
|
15
|
+
const paths = spec?.paths ?? {};
|
|
16
|
+
for (const [pathName, methods] of Object.entries(paths)) {
|
|
17
|
+
for (const [method, methodDef] of Object.entries(methods)) {
|
|
18
|
+
const schema = methodDef?.responses?.["200"]?.content?.["application/json"]?.schema;
|
|
19
|
+
const properties = schema?.properties ?? {};
|
|
20
|
+
for (const key of Object.keys(properties)) {
|
|
21
|
+
fields.add(`${String(method).toUpperCase()} ${pathName}: ${key}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return fields;
|
|
26
|
+
}
|
|
27
|
+
function summarizeSpecDelta(previousSpec, currentSpec) {
|
|
28
|
+
const previous = responseFields(previousSpec);
|
|
29
|
+
const current = responseFields(currentSpec);
|
|
30
|
+
const added = [...current].filter((item) => !previous.has(item)).sort();
|
|
31
|
+
const removed = [...previous].filter((item) => !current.has(item)).sort();
|
|
32
|
+
const lines = [];
|
|
33
|
+
if (added.length) {
|
|
34
|
+
lines.push(`Added response fields (${added.length}):`);
|
|
35
|
+
lines.push(...added.map((value) => `+ ${value}`));
|
|
36
|
+
}
|
|
37
|
+
if (removed.length) {
|
|
38
|
+
lines.push(`Removed response fields (${removed.length}):`);
|
|
39
|
+
lines.push(...removed.map((value) => `- ${value}`));
|
|
40
|
+
}
|
|
41
|
+
if (!lines.length) {
|
|
42
|
+
return "OpenAPI changed, but no top-level response field changes were detected in 200 responses.";
|
|
43
|
+
}
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
async function getCurrentSpecContent(current, evidenceDir, logPath) {
|
|
47
|
+
const evidenceFiles = [];
|
|
48
|
+
if (current.type === "url") {
|
|
49
|
+
const content = await (0, fetch_1.fetchSpec)(current.url);
|
|
50
|
+
return { content, evidenceFiles };
|
|
51
|
+
}
|
|
52
|
+
if (current.type === "local") {
|
|
53
|
+
if (!node_fs_1.default.existsSync(current.path)) {
|
|
54
|
+
throw new Error(`OpenAPI local path not found: ${current.path}`);
|
|
55
|
+
}
|
|
56
|
+
const content = node_fs_1.default.readFileSync(current.path, "utf8");
|
|
57
|
+
return { content, evidenceFiles };
|
|
58
|
+
}
|
|
59
|
+
// current.type === "export"
|
|
60
|
+
const exportResult = await (0, exec_1.execCommand)(current.command);
|
|
61
|
+
node_fs_1.default.writeFileSync(logPath, [
|
|
62
|
+
`$ ${current.command}`,
|
|
63
|
+
`exitCode: ${exportResult.exitCode}`,
|
|
64
|
+
"\n--- stdout ---",
|
|
65
|
+
exportResult.stdout,
|
|
66
|
+
"\n--- stderr ---",
|
|
67
|
+
exportResult.stderr,
|
|
68
|
+
].join("\n"), "utf8");
|
|
69
|
+
evidenceFiles.push(logPath);
|
|
70
|
+
if (exportResult.exitCode !== 0) {
|
|
71
|
+
throw new Error(`OpenAPI export failed: ${exportResult.stderr}`);
|
|
72
|
+
}
|
|
73
|
+
if (!node_fs_1.default.existsSync(current.outputPath)) {
|
|
74
|
+
throw new Error(`OpenAPI export did not create: ${current.outputPath}`);
|
|
75
|
+
}
|
|
76
|
+
const content = node_fs_1.default.readFileSync(current.outputPath, "utf8");
|
|
77
|
+
return { content, evidenceFiles };
|
|
78
|
+
}
|
|
79
|
+
async function detectOpenApiSpecDrift(config, evidenceDir) {
|
|
80
|
+
if (config.format !== "openapi3") {
|
|
81
|
+
return {
|
|
82
|
+
hasDrift: false,
|
|
83
|
+
summary: `Format ${config.format} is not openapi3`,
|
|
84
|
+
evidenceFiles: [],
|
|
85
|
+
impactedDocs: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
(0, fs_1.ensureDir)(evidenceDir);
|
|
89
|
+
const logPath = node_path_1.default.join(evidenceDir, "openapi3-export.log");
|
|
90
|
+
let currentContent;
|
|
91
|
+
let evidenceFiles;
|
|
92
|
+
try {
|
|
93
|
+
const result = await getCurrentSpecContent(config.current, evidenceDir, logPath);
|
|
94
|
+
currentContent = result.content;
|
|
95
|
+
evidenceFiles = result.evidenceFiles;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
99
|
+
return {
|
|
100
|
+
hasDrift: true,
|
|
101
|
+
summary: `OpenAPI current spec failed: ${msg}`,
|
|
102
|
+
evidenceFiles: [logPath],
|
|
103
|
+
impactedDocs: [config.published],
|
|
104
|
+
signal: {
|
|
105
|
+
kind: "weak_evidence",
|
|
106
|
+
tier: 2,
|
|
107
|
+
confidence: 0.35,
|
|
108
|
+
evidence: [logPath],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (!node_fs_1.default.existsSync(config.published)) {
|
|
113
|
+
return {
|
|
114
|
+
hasDrift: true,
|
|
115
|
+
summary: "OpenAPI published file missing",
|
|
116
|
+
evidenceFiles,
|
|
117
|
+
impactedDocs: [config.published],
|
|
118
|
+
signal: {
|
|
119
|
+
kind: "weak_evidence",
|
|
120
|
+
tier: 2,
|
|
121
|
+
confidence: 0.35,
|
|
122
|
+
evidence: evidenceFiles,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const publishedRaw = node_fs_1.default.readFileSync(config.published, "utf8");
|
|
127
|
+
let currentJson;
|
|
128
|
+
let publishedJson;
|
|
129
|
+
try {
|
|
130
|
+
currentJson = JSON.parse(currentContent);
|
|
131
|
+
publishedJson = JSON.parse(publishedRaw);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return {
|
|
135
|
+
hasDrift: true,
|
|
136
|
+
summary: "OpenAPI invalid JSON",
|
|
137
|
+
evidenceFiles,
|
|
138
|
+
impactedDocs: [config.published],
|
|
139
|
+
signal: {
|
|
140
|
+
kind: "weak_evidence",
|
|
141
|
+
tier: 2,
|
|
142
|
+
confidence: 0.35,
|
|
143
|
+
evidence: evidenceFiles,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const normalizedCurrent = (0, json_1.stableStringify)(currentJson);
|
|
148
|
+
const normalizedPublished = (0, json_1.stableStringify)(publishedJson);
|
|
149
|
+
if (normalizedCurrent === normalizedPublished) {
|
|
150
|
+
return {
|
|
151
|
+
hasDrift: false,
|
|
152
|
+
summary: "No OpenAPI drift detected",
|
|
153
|
+
evidenceFiles,
|
|
154
|
+
impactedDocs: [config.published],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const summary = summarizeSpecDelta(publishedJson, currentJson);
|
|
158
|
+
const diffPath = node_path_1.default.join(evidenceDir, "openapi3.diff.txt");
|
|
159
|
+
node_fs_1.default.writeFileSync(diffPath, [
|
|
160
|
+
"# OpenAPI Drift Summary",
|
|
161
|
+
summary,
|
|
162
|
+
"",
|
|
163
|
+
"# Published (normalized)",
|
|
164
|
+
normalizedPublished,
|
|
165
|
+
"",
|
|
166
|
+
"# Current (normalized)",
|
|
167
|
+
normalizedCurrent,
|
|
168
|
+
].join("\n"), "utf8");
|
|
169
|
+
return {
|
|
170
|
+
hasDrift: true,
|
|
171
|
+
summary,
|
|
172
|
+
evidenceFiles: [...evidenceFiles, diffPath],
|
|
173
|
+
impactedDocs: [config.published],
|
|
174
|
+
signal: {
|
|
175
|
+
kind: "openapi_diff",
|
|
176
|
+
tier: 1,
|
|
177
|
+
confidence: 0.95,
|
|
178
|
+
evidence: [diffPath],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|