@devinnn/docdrift 0.1.2 → 0.1.4

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 CHANGED
@@ -1,5 +1,38 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
3
36
  var __importDefault = (this && this.__importDefault) || function (mod) {
4
37
  return (mod && mod.__esModule) ? mod : { "default": mod };
5
38
  };
@@ -17,7 +50,16 @@ function getArg(args, flag) {
17
50
  async function main() {
18
51
  const [, , command, ...args] = process.argv;
19
52
  if (!command) {
20
- throw new Error("Usage: docdrift <validate|detect|run|status|sla-check> [options]");
53
+ throw new Error("Usage: docdrift <validate|detect|run|status|sla-check|setup|generate-yaml> [options]\n detect|run: [--base SHA] [--head SHA] (defaults: merge-base with main..HEAD)\n setup|generate-yaml: [--output path] [--force]");
54
+ }
55
+ if (command === "setup" || command === "generate-yaml") {
56
+ require("dotenv").config();
57
+ const { runSetup } = await Promise.resolve().then(() => __importStar(require("./setup")));
58
+ await runSetup({
59
+ outputPath: getArg(args, "--output") ?? "docdrift.yaml",
60
+ force: args.includes("--force"),
61
+ });
62
+ return;
21
63
  }
22
64
  switch (command) {
23
65
  case "validate": {
@@ -25,18 +67,20 @@ async function main() {
25
67
  return;
26
68
  }
27
69
  case "detect": {
28
- const baseSha = (0, index_1.requireSha)(getArg(args, "--base"), "--base");
29
- const headSha = (0, index_1.requireSha)(getArg(args, "--head"), "--head");
30
- const trigger = (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
31
- const result = await (0, index_1.runDetect)({ baseSha, headSha, trigger });
70
+ const { baseSha, headSha } = await (0, index_1.resolveBaseHead)(getArg(args, "--base"), getArg(args, "--head"));
71
+ const trigger = getArg(args, "--trigger") ?? (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
72
+ const prNum = getArg(args, "--pr-number");
73
+ const prNumber = prNum ? parseInt(prNum, 10) : (process.env.GITHUB_PR_NUMBER ? parseInt(process.env.GITHUB_PR_NUMBER, 10) : undefined);
74
+ const result = await (0, index_1.runDetect)({ baseSha, headSha, trigger, prNumber: Number.isFinite(prNumber) ? prNumber : undefined });
32
75
  process.exitCode = result.hasDrift ? 1 : 0;
33
76
  return;
34
77
  }
35
78
  case "run": {
36
- const baseSha = (0, index_1.requireSha)(getArg(args, "--base"), "--base");
37
- const headSha = (0, index_1.requireSha)(getArg(args, "--head"), "--head");
38
- const trigger = (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
39
- const results = await (0, index_1.runDocDrift)({ baseSha, headSha, trigger });
79
+ const { baseSha, headSha } = await (0, index_1.resolveBaseHead)(getArg(args, "--base"), getArg(args, "--head"));
80
+ const trigger = getArg(args, "--trigger") ?? (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
81
+ const prNum = getArg(args, "--pr-number");
82
+ const prNumber = prNum ? parseInt(prNum, 10) : (process.env.GITHUB_PR_NUMBER ? parseInt(process.env.GITHUB_PR_NUMBER, 10) : undefined);
83
+ const results = await (0, index_1.runDocDrift)({ baseSha, headSha, trigger, prNumber: Number.isFinite(prNumber) ? prNumber : undefined });
40
84
  const outPath = node_path_1.default.resolve(".docdrift", "run-output.json");
41
85
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(outPath), { recursive: true });
42
86
  node_fs_1.default.writeFileSync(outPath, JSON.stringify(results, null, 2), "utf-8");
@@ -7,25 +7,84 @@ exports.normalizeConfig = normalizeConfig;
7
7
  const node_path_1 = __importDefault(require("node:path"));
8
8
  /**
9
9
  * Produce a normalized config that the rest of the app consumes.
10
- * Derives openapi/docsite/exclude/requireHumanReview from docAreas when using legacy config.
10
+ * Derives specProviders/openapi/docsite from openapi block or docAreas when not using v2 specProviders.
11
11
  */
12
12
  function normalizeConfig(config) {
13
+ let specProviders;
13
14
  let openapi;
14
15
  let docsite;
15
16
  let exclude = config.exclude ?? [];
16
17
  let requireHumanReview = config.requireHumanReview ?? [];
17
- if (config.openapi && config.docsite) {
18
- // Simple config
18
+ let docAreas = config.docAreas ?? [];
19
+ const allowConceptualOnlyRun = config.allowConceptualOnlyRun ?? false;
20
+ const inferMode = config.inferMode ?? true;
21
+ if (config.specProviders && config.specProviders.length >= 1) {
22
+ specProviders = config.specProviders;
23
+ const firstOpenApi3 = specProviders.find((p) => p.format === "openapi3");
24
+ if (firstOpenApi3 && firstOpenApi3.current.type === "export") {
25
+ openapi = {
26
+ export: firstOpenApi3.current.command,
27
+ generated: firstOpenApi3.current.outputPath,
28
+ published: firstOpenApi3.published,
29
+ };
30
+ }
31
+ else {
32
+ openapi = {
33
+ export: "echo",
34
+ generated: "",
35
+ published: specProviders[0].published,
36
+ };
37
+ }
38
+ const allPaths = specProviders.flatMap((p) => [p.published]);
39
+ const roots = new Set();
40
+ for (const p of allPaths) {
41
+ const parts = p.split("/").filter(Boolean);
42
+ if (parts.length >= 2)
43
+ roots.add(parts[0] + "/" + parts[1]);
44
+ else if (parts.length === 1)
45
+ roots.add(parts[0]);
46
+ }
47
+ docsite = roots.size > 0 ? [...roots] : config.docsite ? (Array.isArray(config.docsite) ? config.docsite : [config.docsite]) : ["."];
48
+ }
49
+ else if (config.openapi && config.docsite) {
50
+ specProviders = [
51
+ {
52
+ format: "openapi3",
53
+ current: { type: "export", command: config.openapi.export, outputPath: config.openapi.generated },
54
+ published: config.openapi.published,
55
+ },
56
+ ];
19
57
  openapi = config.openapi;
20
58
  docsite = Array.isArray(config.docsite) ? config.docsite : [config.docsite];
59
+ if (config.pathMappings?.length) {
60
+ const pathImpacts = new Set();
61
+ config.pathMappings.forEach((p) => p.impacts.forEach((i) => pathImpacts.add(i)));
62
+ requireHumanReview = [...new Set([...requireHumanReview, ...pathImpacts])];
63
+ docAreas = [
64
+ ...docAreas,
65
+ {
66
+ name: "pathMappings",
67
+ mode: "conceptual",
68
+ owners: { reviewers: ["docdrift"] },
69
+ detect: { paths: config.pathMappings },
70
+ patch: { requireHumanConfirmation: true },
71
+ },
72
+ ];
73
+ }
21
74
  }
22
75
  else if (config.docAreas && config.docAreas.length > 0) {
23
- // Legacy: derive from docAreas
24
76
  const firstOpenApiArea = config.docAreas.find((a) => a.detect.openapi);
25
77
  if (!firstOpenApiArea?.detect.openapi) {
26
78
  throw new Error("Legacy config requires at least one docArea with detect.openapi");
27
79
  }
28
80
  const o = firstOpenApiArea.detect.openapi;
81
+ specProviders = [
82
+ {
83
+ format: "openapi3",
84
+ current: { type: "export", command: o.exportCmd, outputPath: o.generatedPath },
85
+ published: o.publishedPath,
86
+ },
87
+ ];
29
88
  openapi = {
30
89
  export: o.exportCmd,
31
90
  generated: o.generatedPath,
@@ -45,7 +104,6 @@ function normalizeConfig(config) {
45
104
  roots.add(parts[0]);
46
105
  }
47
106
  docsite = roots.size > 0 ? [...roots] : [node_path_1.default.dirname(o.publishedPath) || "."];
48
- // Derive requireHumanReview from areas with requireHumanConfirmation or conceptual mode
49
107
  const reviewPaths = new Set();
50
108
  for (const area of config.docAreas) {
51
109
  if (area.patch.requireHumanConfirmation || area.mode === "conceptual") {
@@ -55,14 +113,43 @@ function normalizeConfig(config) {
55
113
  }
56
114
  requireHumanReview = [...reviewPaths];
57
115
  }
116
+ else if (config.version === 2 && config.pathMappings?.length) {
117
+ specProviders = [];
118
+ openapi = { export: "echo", generated: "", published: "" };
119
+ const pathImpacts = new Set();
120
+ config.pathMappings.forEach((p) => p.impacts.forEach((i) => pathImpacts.add(i)));
121
+ requireHumanReview = [...new Set([...requireHumanReview, ...pathImpacts])];
122
+ docAreas = [
123
+ {
124
+ name: "pathMappings",
125
+ mode: "conceptual",
126
+ owners: { reviewers: ["docdrift"] },
127
+ detect: { paths: config.pathMappings },
128
+ patch: { requireHumanConfirmation: true },
129
+ },
130
+ ];
131
+ const roots = new Set();
132
+ for (const p of pathImpacts) {
133
+ const parts = p.split("/").filter(Boolean);
134
+ if (parts.length >= 2)
135
+ roots.add(parts[0] + "/" + parts[1]);
136
+ else if (parts.length === 1)
137
+ roots.add(parts[0]);
138
+ }
139
+ docsite = roots.size > 0 ? [...roots] : config.docsite ? (Array.isArray(config.docsite) ? config.docsite : [config.docsite]) : ["."];
140
+ }
58
141
  else {
59
- throw new Error("Config must include (openapi + docsite) or docAreas");
142
+ throw new Error("Config must include specProviders, (openapi + docsite), or docAreas");
60
143
  }
61
144
  return {
62
145
  ...config,
146
+ specProviders,
63
147
  openapi,
64
148
  docsite,
65
149
  exclude,
66
150
  requireHumanReview,
151
+ docAreas,
152
+ allowConceptualOnlyRun,
153
+ inferMode,
67
154
  };
68
155
  }
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.docDriftConfigSchema = exports.openApiSimpleSchema = void 0;
3
+ exports.docDriftConfigSchema = exports.docDriftConfigBaseSchema = exports.docAreaBaseSchema = exports.specProviderConfigSchema = exports.openApiSimpleSchema = exports.pathRuleSchema = void 0;
4
4
  const zod_1 = require("zod");
5
- const pathRuleSchema = zod_1.z.object({
5
+ /** Path rule: when `match` changes, `impacts` may need updates. Used by docAreas and by simple `paths` block. */
6
+ exports.pathRuleSchema = zod_1.z.object({
6
7
  match: zod_1.z.string().min(1),
7
8
  impacts: zod_1.z.array(zod_1.z.string().min(1)).min(1),
8
9
  });
@@ -17,18 +18,47 @@ exports.openApiSimpleSchema = zod_1.z.object({
17
18
  generated: zod_1.z.string().min(1),
18
19
  published: zod_1.z.string().min(1),
19
20
  });
21
+ /** Spec source: URL, local path, or export command */
22
+ const specSourceSchema = zod_1.z.discriminatedUnion("type", [
23
+ zod_1.z.object({ type: zod_1.z.literal("url"), url: zod_1.z.string().url() }),
24
+ zod_1.z.object({ type: zod_1.z.literal("local"), path: zod_1.z.string().min(1) }),
25
+ zod_1.z.object({
26
+ type: zod_1.z.literal("export"),
27
+ command: zod_1.z.string().min(1),
28
+ outputPath: zod_1.z.string().min(1),
29
+ }),
30
+ ]);
31
+ const specFormatSchema = zod_1.z.enum(["openapi3", "swagger2", "graphql", "fern", "postman"]);
32
+ /** Single spec provider (v2 config) */
33
+ exports.specProviderConfigSchema = zod_1.z.object({
34
+ format: specFormatSchema,
35
+ current: specSourceSchema,
36
+ published: zod_1.z.string().min(1),
37
+ });
38
+ const docAreaDetectBaseSchema = zod_1.z.object({
39
+ openapi: openApiDetectSchema.optional(),
40
+ paths: zod_1.z.array(exports.pathRuleSchema).optional(),
41
+ });
42
+ /** Base schema without refine — for JSON Schema generation (zod-to-json-schema doesn't handle .refine()) */
43
+ exports.docAreaBaseSchema = zod_1.z.object({
44
+ name: zod_1.z.string().min(1),
45
+ mode: zod_1.z.enum(["autogen", "conceptual"]),
46
+ owners: zod_1.z.object({
47
+ reviewers: zod_1.z.array(zod_1.z.string().min(1)).min(1),
48
+ }),
49
+ detect: docAreaDetectBaseSchema,
50
+ patch: zod_1.z.object({
51
+ targets: zod_1.z.array(zod_1.z.string().min(1)).optional(),
52
+ requireHumanConfirmation: zod_1.z.boolean().optional().default(false),
53
+ }),
54
+ });
20
55
  const docAreaSchema = zod_1.z.object({
21
56
  name: zod_1.z.string().min(1),
22
57
  mode: zod_1.z.enum(["autogen", "conceptual"]),
23
58
  owners: zod_1.z.object({
24
59
  reviewers: zod_1.z.array(zod_1.z.string().min(1)).min(1),
25
60
  }),
26
- detect: zod_1.z
27
- .object({
28
- openapi: openApiDetectSchema.optional(),
29
- paths: zod_1.z.array(pathRuleSchema).optional(),
30
- })
31
- .refine((v) => Boolean(v.openapi) || Boolean(v.paths?.length), {
61
+ detect: docAreaDetectBaseSchema.refine((v) => Boolean(v.openapi) || Boolean(v.paths?.length), {
32
62
  message: "docArea.detect must include openapi or paths",
33
63
  }),
34
64
  patch: zod_1.z.object({
@@ -59,17 +89,38 @@ const policySchema = zod_1.z.object({
59
89
  */
60
90
  allowNewFiles: zod_1.z.boolean().optional().default(false),
61
91
  });
62
- exports.docDriftConfigSchema = zod_1.z
63
- .object({
64
- version: zod_1.z.literal(1),
65
- /** Simple config: openapi block (API spec = gate for run) */
92
+ /** Base schema without refine — for JSON Schema generation (zod-to-json-schema doesn't handle .refine()) */
93
+ exports.docDriftConfigBaseSchema = zod_1.z.object({
94
+ version: zod_1.z.union([zod_1.z.literal(1), zod_1.z.literal(2)]),
95
+ specProviders: zod_1.z.array(exports.specProviderConfigSchema).optional(),
66
96
  openapi: exports.openApiSimpleSchema.optional(),
67
- /** Simple config: docsite root path(s) */
68
97
  docsite: zod_1.z.union([zod_1.z.string().min(1), zod_1.z.array(zod_1.z.string().min(1))]).optional(),
69
- /** Paths we never touch (glob patterns) */
70
98
  exclude: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
71
- /** Paths that require human review when touched (we create issue post-PR) */
72
99
  requireHumanReview: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
100
+ pathMappings: zod_1.z.array(exports.pathRuleSchema).optional().default([]),
101
+ allowConceptualOnlyRun: zod_1.z.boolean().optional().default(false),
102
+ inferMode: zod_1.z.boolean().optional().default(true),
103
+ devin: zod_1.z.object({
104
+ apiVersion: zod_1.z.literal("v1"),
105
+ unlisted: zod_1.z.boolean().default(true),
106
+ maxAcuLimit: zod_1.z.number().int().positive().default(2),
107
+ tags: zod_1.z.array(zod_1.z.string().min(1)).default(["docdrift"]),
108
+ customInstructions: zod_1.z.array(zod_1.z.string().min(1)).optional(),
109
+ customInstructionContent: zod_1.z.string().optional(),
110
+ }),
111
+ policy: policySchema,
112
+ docAreas: zod_1.z.array(exports.docAreaBaseSchema).optional().default([]),
113
+ });
114
+ const docDriftConfigObjectSchema = zod_1.z.object({
115
+ version: zod_1.z.union([zod_1.z.literal(1), zod_1.z.literal(2)]),
116
+ specProviders: zod_1.z.array(exports.specProviderConfigSchema).optional(),
117
+ openapi: exports.openApiSimpleSchema.optional(),
118
+ docsite: zod_1.z.union([zod_1.z.string().min(1), zod_1.z.array(zod_1.z.string().min(1))]).optional(),
119
+ exclude: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
120
+ requireHumanReview: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
121
+ pathMappings: zod_1.z.array(exports.pathRuleSchema).optional().default([]),
122
+ allowConceptualOnlyRun: zod_1.z.boolean().optional().default(false),
123
+ inferMode: zod_1.z.boolean().optional().default(true),
73
124
  devin: zod_1.z.object({
74
125
  apiVersion: zod_1.z.literal("v1"),
75
126
  unlisted: zod_1.z.boolean().default(true),
@@ -79,7 +130,16 @@ exports.docDriftConfigSchema = zod_1.z
79
130
  customInstructionContent: zod_1.z.string().optional(),
80
131
  }),
81
132
  policy: policySchema,
82
- /** Legacy: doc areas (optional when openapi+docsite present) */
83
133
  docAreas: zod_1.z.array(docAreaSchema).optional().default([]),
84
- })
85
- .refine((v) => (v.openapi && v.docsite) || v.docAreas.length >= 1, { message: "Config must include (openapi + docsite) or docAreas" });
134
+ });
135
+ exports.docDriftConfigSchema = docDriftConfigObjectSchema.refine((v) => {
136
+ if (v.specProviders && v.specProviders.length >= 1)
137
+ return true;
138
+ if (v.openapi && v.docsite)
139
+ return true;
140
+ if (v.docAreas.length >= 1)
141
+ return true;
142
+ if (v.version === 2 && (v.pathMappings?.length ?? 0) >= 1)
143
+ return true;
144
+ return false;
145
+ }, { message: "Config must include specProviders, (openapi + docsite), docAreas, or (v2 + pathMappings)" });
@@ -11,11 +11,15 @@ async function validateRuntimeConfig(config) {
11
11
  if (config.policy.prCaps.maxFilesTouched < 1) {
12
12
  errors.push("policy.prCaps.maxFilesTouched must be >= 1");
13
13
  }
14
- const commandSet = new Set([
14
+ const exportCommands = [
15
15
  ...config.policy.verification.commands,
16
16
  ...(config.openapi ? [config.openapi.export] : []),
17
17
  ...(config.docAreas ?? []).map((area) => area.detect.openapi?.exportCmd).filter((value) => Boolean(value)),
18
- ]);
18
+ ...(config.specProviders ?? [])
19
+ .filter((p) => p.current.type === "export")
20
+ .map((p) => p.current.command),
21
+ ];
22
+ const commandSet = new Set(exportCommands);
19
23
  for (const command of commandSet) {
20
24
  const binary = commandBinary(command);
21
25
  const result = await (0, exec_1.execCommand)(`command -v ${binary}`);
@@ -8,7 +8,7 @@ const node_path_1 = __importDefault(require("node:path"));
8
8
  const fs_1 = require("../utils/fs");
9
9
  const git_1 = require("../utils/git");
10
10
  const heuristics_1 = require("./heuristics");
11
- const openapi_1 = require("./openapi");
11
+ const registry_1 = require("../spec-providers/registry");
12
12
  async function buildDriftReport(input) {
13
13
  const runInfo = {
14
14
  runId: `${Date.now()}`,
@@ -17,6 +17,7 @@ async function buildDriftReport(input) {
17
17
  headSha: input.headSha,
18
18
  trigger: input.trigger,
19
19
  timestamp: new Date().toISOString(),
20
+ prNumber: input.prNumber,
20
21
  };
21
22
  const evidenceRoot = node_path_1.default.resolve(".docdrift", "evidence", runInfo.runId);
22
23
  (0, fs_1.ensureDir)(evidenceRoot);
@@ -28,10 +29,76 @@ async function buildDriftReport(input) {
28
29
  diffSummary,
29
30
  commits,
30
31
  });
31
- // Gate: run OpenAPI detection first. If no OpenAPI drift, exit (no session).
32
- const openapiResult = await (0, openapi_1.detectOpenApiDriftFromNormalized)(input.config, evidenceRoot);
33
- if (!openapiResult.signal) {
34
- // No OpenAPI drift — gate closed. Return empty.
32
+ const { config } = input;
33
+ const signals = [];
34
+ const impactedDocs = new Set();
35
+ const summaries = [];
36
+ const evidenceFiles = [];
37
+ // 1. Run all spec providers (parallel)
38
+ const providerResults = [];
39
+ if (config.specProviders.length > 0) {
40
+ const results = await Promise.all(config.specProviders.map(async (provider) => {
41
+ const detector = (0, registry_1.getSpecDetector)(provider.format);
42
+ return detector(provider, evidenceRoot);
43
+ }));
44
+ providerResults.push(...results);
45
+ }
46
+ const anySpecDrift = providerResults.some((r) => r.hasDrift && r.signal && r.signal.tier <= 1);
47
+ const allSpecFailedOrNoDrift = providerResults.length === 0 ||
48
+ providerResults.every((r) => !r.hasDrift || (r.signal?.tier ?? 2) > 1);
49
+ if (anySpecDrift) {
50
+ for (const r of providerResults) {
51
+ if (r.hasDrift && r.signal) {
52
+ signals.push(r.signal);
53
+ r.impactedDocs.forEach((d) => impactedDocs.add(d));
54
+ summaries.push(r.summary);
55
+ evidenceFiles.push(...r.evidenceFiles);
56
+ }
57
+ }
58
+ }
59
+ // 2. Path heuristics (always run for aggregation when we have docAreas)
60
+ for (const docArea of config.docAreas) {
61
+ if (docArea.detect.paths?.length) {
62
+ const heuristicResult = (0, heuristics_1.detectHeuristicImpacts)(docArea, changedPaths, evidenceRoot);
63
+ if (heuristicResult.signal) {
64
+ signals.push(heuristicResult.signal);
65
+ heuristicResult.impactedDocs.forEach((d) => impactedDocs.add(d));
66
+ summaries.push(heuristicResult.summary);
67
+ }
68
+ }
69
+ }
70
+ const hasHeuristicMatch = signals.some((s) => s.kind === "heuristic_path_impact");
71
+ // 3. Gate logic
72
+ let runGate = "none";
73
+ if (anySpecDrift) {
74
+ runGate = "spec_drift";
75
+ }
76
+ else if (config.allowConceptualOnlyRun && hasHeuristicMatch) {
77
+ runGate = "conceptual_only";
78
+ }
79
+ else if (config.inferMode && (config.specProviders.length === 0 || allSpecFailedOrNoDrift)) {
80
+ runGate = "infer";
81
+ if (config.specProviders.length > 0) {
82
+ for (const r of providerResults) {
83
+ if (r.signal) {
84
+ signals.push(r.signal);
85
+ r.impactedDocs.forEach((d) => impactedDocs.add(d));
86
+ summaries.push(r.summary);
87
+ }
88
+ }
89
+ }
90
+ if (signals.length === 0) {
91
+ signals.push({
92
+ kind: "infer_mode",
93
+ tier: 2,
94
+ confidence: 0.6,
95
+ evidence: [node_path_1.default.join(evidenceRoot, "changeset.json")],
96
+ });
97
+ changedPaths.forEach((p) => impactedDocs.add(p));
98
+ summaries.push("Infer mode: no spec drift; infer docs from file changes.");
99
+ }
100
+ }
101
+ if (runGate === "none") {
35
102
  const report = {
36
103
  run: {
37
104
  repo: input.repo,
@@ -50,22 +117,9 @@ async function buildDriftReport(input) {
50
117
  evidenceRoot,
51
118
  runInfo,
52
119
  hasOpenApiDrift: false,
120
+ runGate: "none",
53
121
  };
54
122
  }
55
- // Gate passed: aggregate signals and impacted docs.
56
- const signals = [openapiResult.signal];
57
- const impactedDocs = new Set(openapiResult.impactedDocs);
58
- const summaries = [openapiResult.summary];
59
- for (const docArea of input.config.docAreas) {
60
- if (docArea.detect.paths?.length) {
61
- const heuristicResult = (0, heuristics_1.detectHeuristicImpacts)(docArea, changedPaths, evidenceRoot);
62
- if (heuristicResult.signal) {
63
- signals.push(heuristicResult.signal);
64
- }
65
- heuristicResult.impactedDocs.forEach((doc) => impactedDocs.add(doc));
66
- summaries.push(heuristicResult.summary);
67
- }
68
- }
69
123
  const aggregated = {
70
124
  signals,
71
125
  impactedDocs: [...impactedDocs],
@@ -73,7 +127,7 @@ async function buildDriftReport(input) {
73
127
  };
74
128
  const item = {
75
129
  docArea: "docsite",
76
- mode: "autogen",
130
+ mode: runGate === "conceptual_only" ? "conceptual" : "autogen",
77
131
  signals: aggregated.signals,
78
132
  impactedDocs: aggregated.impactedDocs,
79
133
  recommendedAction: aggregated.signals.some((s) => s.tier <= 1) ? "OPEN_PR" : "OPEN_ISSUE",
@@ -96,6 +150,7 @@ async function buildDriftReport(input) {
96
150
  changedPaths,
97
151
  evidenceRoot,
98
152
  runInfo,
99
- hasOpenApiDrift: true,
153
+ hasOpenApiDrift: anySpecDrift,
154
+ runGate,
100
155
  };
101
156
  }
@@ -74,9 +74,52 @@ function buildWholeDocsitePrompt(input) {
74
74
  const newFilesRule = allowNewFiles
75
75
  ? "8) You MAY add new articles, create new folders, and change information architecture when warranted."
76
76
  : "8) You may ONLY edit existing files. Do NOT create new files, new articles, or new folders. Do NOT change information architecture.";
77
+ const driftSummary = input.aggregated.summary?.trim();
78
+ const openapiPublished = input.config.openapi?.published;
79
+ const openapiGenerated = input.config.openapi?.generated;
80
+ const specLine = openapiPublished && openapiGenerated
81
+ ? `Update ${openapiPublished} to match the generated spec (${openapiGenerated}). The attachments contain the full diff.`
82
+ : "Update published docs to match the evidence (attachments).";
83
+ const driftBlock = driftSummary &&
84
+ [
85
+ "DRIFT DETECTED (you must fix this):",
86
+ "---",
87
+ driftSummary,
88
+ "---",
89
+ specLine,
90
+ "",
91
+ ].join("\n");
92
+ const inferBlock = input.runGate === "infer"
93
+ ? [
94
+ "INFER MODE: No API spec diff was available. These file changes may impact docs.",
95
+ "Infer what documentation might need updates from the changed files. Update or create docs as needed.",
96
+ "Do NOT invent APIs; only document what you can infer from the code changes.",
97
+ "",
98
+ ].join("\n")
99
+ : "";
100
+ const draftPrBlock = input.trigger === "pull_request" && input.prNumber
101
+ ? [
102
+ "",
103
+ "This run was triggered by an open API PR. Open a **draft** pull request.",
104
+ `In the PR description, link to the API PR (#${input.prNumber}) and state: "Merge the API PR first, then review this doc PR."`,
105
+ "Use a branch name like docdrift/pr-" + input.prNumber + " or docdrift/preview-<short-sha>.",
106
+ "",
107
+ ].join("\n")
108
+ : "";
109
+ const pathMappings = input.config.pathMappings ?? [];
110
+ const pathMappingsBlock = pathMappings.length > 0
111
+ ? [
112
+ "PATH MAPPINGS (when these code paths change, consider these docs for updates):",
113
+ ...pathMappings.map((p) => `- ${p.match} → ${p.impacts.join(", ")}`),
114
+ "",
115
+ ].join("\n")
116
+ : "";
77
117
  const base = [
78
118
  "You are Devin. Task: update the entire docsite to match the API and code changes.",
79
119
  "",
120
+ driftBlock ?? "",
121
+ inferBlock,
122
+ pathMappingsBlock,
80
123
  "EVIDENCE (attachments):",
81
124
  input.attachmentUrls.map((url, i) => `- ATTACHMENT ${i + 1}: ${url}`).join("\n"),
82
125
  "",
@@ -86,7 +129,8 @@ function buildWholeDocsitePrompt(input) {
86
129
  "3) Update API reference (OpenAPI) and any impacted guides in one PR.",
87
130
  "4) Run verification commands and record results:",
88
131
  ...input.config.policy.verification.commands.map((c) => ` - ${c}`),
89
- "5) Open exactly ONE pull request with a clear title and reviewer-friendly description.",
132
+ "5) Open exactly ONE pull request with a clear title and reviewer-friendly description." +
133
+ draftPrBlock,
90
134
  `6) Docsite scope: ${input.config.docsite.join(", ")}` +
91
135
  excludeNote +
92
136
  requireReviewNote +
@@ -91,12 +91,37 @@ async function devinListSessions(apiKey, params = {}) {
91
91
  }
92
92
  return [];
93
93
  }
94
+ const TERMINAL_STATUSES = [
95
+ "finished",
96
+ "blocked",
97
+ "error",
98
+ "cancelled",
99
+ "done",
100
+ "complete",
101
+ "completed",
102
+ "success",
103
+ "terminated",
104
+ ];
105
+ function hasPrUrl(session) {
106
+ if (typeof session.pull_request_url === "string" && session.pull_request_url)
107
+ return true;
108
+ if (typeof session.pr_url === "string" && session.pr_url)
109
+ return true;
110
+ const structured = (session.structured_output ?? session.data?.structured_output);
111
+ if (structured?.pr?.url)
112
+ return true;
113
+ return false;
114
+ }
94
115
  async function pollUntilTerminal(apiKey, sessionId, timeoutMs = 30 * 60_000) {
95
116
  const started = Date.now();
96
117
  while (Date.now() - started < timeoutMs) {
97
118
  const session = await devinGetSession(apiKey, sessionId);
98
119
  const status = String(session.status_enum ?? session.status ?? "UNKNOWN").toLowerCase();
99
- if (["finished", "blocked", "error", "cancelled", "done", "complete"].includes(status)) {
120
+ if (TERMINAL_STATUSES.includes(status)) {
121
+ return session;
122
+ }
123
+ // Session already produced a PR; stop polling so we don't timeout waiting for status to flip
124
+ if (hasPrUrl(session)) {
100
125
  return session;
101
126
  }
102
127
  await new Promise((resolve) => setTimeout(resolve, 5000));
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseRepo = parseRepo;
4
4
  exports.postCommitComment = postCommitComment;
5
+ exports.postPrComment = postPrComment;
5
6
  exports.createIssue = createIssue;
6
7
  exports.renderRunComment = renderRunComment;
7
8
  exports.renderBlockedIssueBody = renderBlockedIssueBody;
@@ -28,6 +29,18 @@ async function postCommitComment(input) {
28
29
  });
29
30
  return response.data.html_url;
30
31
  }
32
+ /** Post a comment on a pull request (e.g. to link the doc drift PR when trigger is pull_request). */
33
+ async function postPrComment(input) {
34
+ const octokit = new rest_1.Octokit({ auth: input.token });
35
+ const { owner, repo } = parseRepo(input.repository);
36
+ const response = await octokit.issues.createComment({
37
+ owner,
38
+ repo,
39
+ issue_number: input.prNumber,
40
+ body: input.body,
41
+ });
42
+ return response.data.html_url;
43
+ }
31
44
  async function createIssue(input) {
32
45
  const octokit = new rest_1.Octokit({ auth: input.token });
33
46
  const { owner, repo } = parseRepo(input.repository);