@devinnn/docdrift 0.1.4 → 0.1.6

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
@@ -50,7 +50,14 @@ function getArg(args, flag) {
50
50
  async function main() {
51
51
  const [, , command, ...args] = process.argv;
52
52
  if (!command) {
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]");
53
+ throw new Error("Usage: docdrift <validate|detect|run|status|sla-check|setup|generate-yaml> [options]\n" +
54
+ " validate Validate docdrift.yaml (v2 config)\n" +
55
+ " detect Check for drift [--base SHA] [--head SHA]\n" +
56
+ " run Full run with Devin [--base SHA] [--head SHA]\n" +
57
+ " status Show run status [--since 24h]\n" +
58
+ " sla-check Check SLA for unmerged PRs\n" +
59
+ " setup Interactive setup (generates v2 docdrift.yaml)\n" +
60
+ " generate-yaml Generate config [--output path] [--force]");
54
61
  }
55
62
  if (command === "setup" || command === "generate-yaml") {
56
63
  require("dotenv").config();
@@ -16,8 +16,7 @@ function normalizeConfig(config) {
16
16
  let exclude = config.exclude ?? [];
17
17
  let requireHumanReview = config.requireHumanReview ?? [];
18
18
  let docAreas = config.docAreas ?? [];
19
- const allowConceptualOnlyRun = config.allowConceptualOnlyRun ?? false;
20
- const inferMode = config.inferMode ?? true;
19
+ const mode = config.mode ?? "strict";
21
20
  if (config.specProviders && config.specProviders.length >= 1) {
22
21
  specProviders = config.specProviders;
23
22
  const firstOpenApi3 = specProviders.find((p) => p.format === "openapi3");
@@ -149,7 +148,6 @@ function normalizeConfig(config) {
149
148
  exclude,
150
149
  requireHumanReview,
151
150
  docAreas,
152
- allowConceptualOnlyRun,
153
- inferMode,
151
+ mode,
154
152
  };
155
153
  }
@@ -98,8 +98,8 @@ exports.docDriftConfigBaseSchema = zod_1.z.object({
98
98
  exclude: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
99
99
  requireHumanReview: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
100
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),
101
+ /** strict: only run on spec drift. auto: also run when pathMappings match (no spec drift). */
102
+ mode: zod_1.z.enum(["strict", "auto"]).optional().default("strict"),
103
103
  devin: zod_1.z.object({
104
104
  apiVersion: zod_1.z.literal("v1"),
105
105
  unlisted: zod_1.z.boolean().default(true),
@@ -119,8 +119,8 @@ const docDriftConfigObjectSchema = zod_1.z.object({
119
119
  exclude: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
120
120
  requireHumanReview: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
121
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),
122
+ /** strict: only run on spec drift. auto: also run when pathMappings match (no spec drift). */
123
+ mode: zod_1.z.enum(["strict", "auto"]).optional().default("strict"),
124
124
  devin: zod_1.z.object({
125
125
  apiVersion: zod_1.z.literal("v1"),
126
126
  unlisted: zod_1.z.boolean().default(true),
@@ -7,6 +7,7 @@ exports.buildDriftReport = buildDriftReport;
7
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
+ const glob_1 = require("../utils/glob");
10
11
  const heuristics_1 = require("./heuristics");
11
12
  const registry_1 = require("../spec-providers/registry");
12
13
  async function buildDriftReport(input) {
@@ -68,15 +69,21 @@ async function buildDriftReport(input) {
68
69
  }
69
70
  }
70
71
  const hasHeuristicMatch = signals.some((s) => s.kind === "heuristic_path_impact");
72
+ const pathMappings = config.pathMappings ?? [];
73
+ const hasPathMappingMatch = pathMappings.length > 0 &&
74
+ changedPaths.some((p) => pathMappings.some((m) => (0, glob_1.matchesGlob)(m.match, p)));
71
75
  // 3. Gate logic
76
+ const isAuto = config.mode === "auto";
72
77
  let runGate = "none";
73
78
  if (anySpecDrift) {
74
79
  runGate = "spec_drift";
75
80
  }
76
- else if (config.allowConceptualOnlyRun && hasHeuristicMatch) {
81
+ else if (isAuto && hasHeuristicMatch) {
77
82
  runGate = "conceptual_only";
78
83
  }
79
- else if (config.inferMode && (config.specProviders.length === 0 || allSpecFailedOrNoDrift)) {
84
+ else if (isAuto &&
85
+ hasPathMappingMatch &&
86
+ (config.specProviders.length === 0 || allSpecFailedOrNoDrift)) {
80
87
  runGate = "infer";
81
88
  if (config.specProviders.length > 0) {
82
89
  for (const r of providerResults) {
@@ -97,15 +97,29 @@ function buildWholeDocsitePrompt(input) {
97
97
  "",
98
98
  ].join("\n")
99
99
  : "";
100
- const draftPrBlock = input.trigger === "pull_request" && input.prNumber
101
- ? [
100
+ const draftPrBlock = (() => {
101
+ if (input.trigger !== "pull_request" || !input.prNumber)
102
+ return "";
103
+ if (input.existingDocdriftPr) {
104
+ return [
105
+ "",
106
+ "CRITICAL: An existing doc-drift PR already exists for this API PR.",
107
+ `You MUST UPDATE that PR — do NOT create a new one.`,
108
+ `- Existing PR: #${input.existingDocdriftPr.number} (${input.existingDocdriftPr.url})`,
109
+ `- Branch to update: ${input.existingDocdriftPr.headRef}`,
110
+ "Checkout that branch, pull latest main, apply your doc changes, push. The existing PR will update.",
111
+ "Do NOT open a new pull request.",
112
+ "",
113
+ ].join("\n");
114
+ }
115
+ return [
102
116
  "",
103
117
  "This run was triggered by an open API PR. Open a **draft** pull request.",
104
118
  `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>.",
119
+ "Use branch name docdrift/pr-" + input.prNumber + " (required for future runs to update this PR).",
106
120
  "",
107
- ].join("\n")
108
- : "";
121
+ ].join("\n");
122
+ })();
109
123
  const pathMappings = input.config.pathMappings ?? [];
110
124
  const pathMappingsBlock = pathMappings.length > 0
111
125
  ? [
@@ -10,6 +10,7 @@ exports.renderRequireHumanReviewIssueBody = renderRequireHumanReviewIssueBody;
10
10
  exports.renderSlaIssueBody = renderSlaIssueBody;
11
11
  exports.isPrOpen = isPrOpen;
12
12
  exports.listOpenPrsWithLabel = listOpenPrsWithLabel;
13
+ exports.findExistingDocdriftPrForSource = findExistingDocdriftPrForSource;
13
14
  const rest_1 = require("@octokit/rest");
14
15
  function parseRepo(full) {
15
16
  const [owner, repo] = full.split("/");
@@ -172,3 +173,26 @@ async function listOpenPrsWithLabel(token, repository, label) {
172
173
  created_at: pr.created_at ?? "",
173
174
  }));
174
175
  }
176
+ /** Find an existing open docdrift PR for a given source PR number.
177
+ * Looks for PRs from branch docdrift/pr-{sourcePrNumber} (Devin's convention).
178
+ * Returns the first match so we can instruct Devin to update it instead of creating a new one.
179
+ */
180
+ async function findExistingDocdriftPrForSource(token, repository, sourcePrNumber) {
181
+ const octokit = new rest_1.Octokit({ auth: token });
182
+ const { owner, repo } = parseRepo(repository);
183
+ const branchName = `docdrift/pr-${sourcePrNumber}`;
184
+ const { data } = await octokit.pulls.list({
185
+ owner,
186
+ repo,
187
+ state: "open",
188
+ head: branchName,
189
+ });
190
+ const pr = data[0];
191
+ if (!pr)
192
+ return null;
193
+ return {
194
+ number: pr.number,
195
+ url: pr.html_url ?? "",
196
+ headRef: pr.head?.ref ?? branchName,
197
+ };
198
+ }
package/dist/src/index.js CHANGED
@@ -97,6 +97,7 @@ async function executeSessionSingle(input) {
97
97
  runGate: input.runGate,
98
98
  trigger: input.trigger,
99
99
  prNumber: input.prNumber,
100
+ existingDocdriftPr: input.existingDocdriftPr,
100
101
  });
101
102
  const session = await (0, v1_1.devinCreateSession)(input.apiKey, {
102
103
  prompt,
@@ -256,6 +257,16 @@ async function runDocDrift(options) {
256
257
  }
257
258
  const bundle = await (0, bundle_1.buildEvidenceBundle)({ runInfo, item, evidenceRoot });
258
259
  const attachmentPaths = [...new Set([bundle.archivePath, ...bundle.attachmentPaths])];
260
+ let existingDocdriftPr;
261
+ if (githubToken && runInfo.trigger === "pull_request" && runInfo.prNumber) {
262
+ existingDocdriftPr = (await (0, client_1.findExistingDocdriftPrForSource)(githubToken, repo, runInfo.prNumber)) ?? undefined;
263
+ if (existingDocdriftPr) {
264
+ (0, log_1.logInfo)("Found existing docdrift PR for source PR; will instruct Devin to update it", {
265
+ existingPr: existingDocdriftPr.number,
266
+ headRef: existingDocdriftPr.headRef,
267
+ });
268
+ }
269
+ }
259
270
  let sessionOutcome = {
260
271
  outcome: "NO_CHANGE",
261
272
  summary: "Skipped Devin session",
@@ -276,6 +287,7 @@ async function runDocDrift(options) {
276
287
  runGate,
277
288
  trigger: runInfo.trigger,
278
289
  prNumber: runInfo.prNumber,
290
+ existingDocdriftPr,
279
291
  });
280
292
  metrics.timeToSessionTerminalMs.push(Date.now() - sessionStart);
281
293
  }
@@ -296,7 +308,7 @@ async function runDocDrift(options) {
296
308
  metrics.prsOpened += 1;
297
309
  state.lastDocDriftPrUrl = sessionOutcome.prUrl;
298
310
  state.lastDocDriftPrOpenedAt = new Date().toISOString();
299
- if (githubToken && runInfo.trigger === "pull_request" && runInfo.prNumber) {
311
+ if (githubToken && runInfo.trigger === "pull_request" && runInfo.prNumber && !existingDocdriftPr) {
300
312
  await (0, client_1.postPrComment)({
301
313
  token: githubToken,
302
314
  repository: repo,
@@ -322,19 +334,18 @@ async function runDocDrift(options) {
322
334
  }
323
335
  }
324
336
  else if (githubToken &&
325
- (decision.action === "OPEN_ISSUE" ||
326
- sessionOutcome.outcome === "BLOCKED" ||
327
- sessionOutcome.outcome === "NO_CHANGE")) {
337
+ sessionOutcome.outcome === "BLOCKED" &&
338
+ sessionOutcome.summary.includes("DEVIN_API_KEY")) {
328
339
  issueUrl = await (0, client_1.createIssue)({
329
340
  token: githubToken,
330
341
  repository: repo,
331
342
  issue: {
332
- title: "[docdrift] docsite: docs drift requires input",
343
+ title: "[docdrift] Configuration required set DEVIN_API_KEY",
333
344
  body: (0, client_1.renderBlockedIssueBody)({
334
345
  docArea: item.docArea,
335
- evidenceSummary: item.summary,
346
+ evidenceSummary: sessionOutcome.summary,
336
347
  questions: sessionOutcome.questions ?? [
337
- "Please confirm intended behavior and doc wording.",
348
+ "Set DEVIN_API_KEY in GitHub Actions secrets or environment.",
338
349
  ],
339
350
  sessionUrl: sessionOutcome.sessionUrl,
340
351
  }),
@@ -342,10 +353,11 @@ async function runDocDrift(options) {
342
353
  },
343
354
  });
344
355
  metrics.issuesOpened += 1;
345
- if (sessionOutcome.outcome !== "PR_OPENED") {
346
- sessionOutcome.outcome = "ISSUE_OPENED";
347
- }
348
356
  }
357
+ // Note: We do NOT create "docs drift requires input" issues for Devin-reported BLOCKED
358
+ // (evidence questions) or for OPEN_ISSUE/NO_CHANGE. Issues are only created for:
359
+ // (1) requireHumanReview when a PR touches those paths, (2) 7-day SLA reminders,
360
+ // and (3) DEVIN_API_KEY missing. See docdrift-yml.md.
349
361
  if (sessionOutcome.outcome === "BLOCKED") {
350
362
  metrics.blockedCount += 1;
351
363
  }
@@ -15,20 +15,24 @@ const pathRuleSchema = zod_1.z.object({
15
15
  match: zod_1.z.string().min(1),
16
16
  impacts: zod_1.z.array(zod_1.z.string().min(1)).min(1),
17
17
  });
18
+ const specProviderSchema = zod_1.z.object({
19
+ format: zod_1.z.enum(["openapi3", "swagger2", "graphql", "fern", "postman"]),
20
+ current: zod_1.z.object({
21
+ type: zod_1.z.literal("export"),
22
+ command: zod_1.z.string().min(1),
23
+ outputPath: zod_1.z.string().min(1),
24
+ }),
25
+ published: zod_1.z.string().min(1),
26
+ });
18
27
  const InferenceSchema = zod_1.z.object({
19
28
  suggestedConfig: zod_1.z.object({
20
- version: zod_1.z.union([zod_1.z.literal(1), zod_1.z.literal(2)]).optional(),
21
- openapi: zod_1.z
22
- .object({
23
- export: zod_1.z.string().min(1),
24
- generated: zod_1.z.string().min(1),
25
- published: zod_1.z.string().min(1),
26
- })
27
- .optional(),
29
+ version: zod_1.z.literal(2).optional(),
30
+ specProviders: zod_1.z.array(specProviderSchema).optional(),
28
31
  docsite: zod_1.z.union([zod_1.z.string().min(1), zod_1.z.array(zod_1.z.string().min(1))]).optional(),
29
32
  exclude: zod_1.z.array(zod_1.z.string().min(1)).optional(),
30
33
  requireHumanReview: zod_1.z.array(zod_1.z.string().min(1)).optional(),
31
34
  pathMappings: zod_1.z.array(pathRuleSchema).optional(),
35
+ mode: zod_1.z.enum(["strict", "auto"]).optional(),
32
36
  devin: zod_1.z
33
37
  .object({
34
38
  apiVersion: zod_1.z.literal("v1"),
@@ -96,15 +100,21 @@ function writeCache(cwd, fingerprintHash, inference) {
96
100
  }
97
101
  function heuristicInference(fingerprint) {
98
102
  const scripts = fingerprint.rootPackage.scripts || {};
99
- const openapiExport = scripts["openapi:export"] ?? scripts["openapi:generate"] ?? "npm run openapi:export";
103
+ const scriptNames = Object.keys(scripts);
104
+ const openapiScriptName = scriptNames.find((s) => s === "openapi:export" || s === "openapi:generate");
105
+ const openapiExport = openapiScriptName ? `npm run ${openapiScriptName}` : "npm run openapi:export";
100
106
  const firstOpenapi = fingerprint.foundPaths.openapi[0];
101
107
  const firstDocsite = fingerprint.foundPaths.docusaurusConfig[0]
102
108
  ? node_path_1.default.dirname(fingerprint.foundPaths.docusaurusConfig[0]).replace(/\\/g, "/")
103
109
  : fingerprint.foundPaths.docsDirs[0]
104
110
  ? node_path_1.default.dirname(fingerprint.foundPaths.docsDirs[0]).replace(/\\/g, "/")
105
111
  : "apps/docs-site";
106
- const published = firstOpenapi ?? `${firstDocsite}/openapi/openapi.json`;
107
- const generated = firstOpenapi ?? "openapi/generated.json";
112
+ const published = firstOpenapi && firstOpenapi.includes(firstDocsite)
113
+ ? firstOpenapi
114
+ : `${firstDocsite}/openapi/openapi.json`;
115
+ const generated = firstOpenapi && !firstOpenapi.includes(firstDocsite)
116
+ ? firstOpenapi
117
+ : "openapi/generated.json";
108
118
  const verificationCommands = [];
109
119
  if (scripts["docs:gen"])
110
120
  verificationCommands.push("npm run docs:gen");
@@ -112,19 +122,35 @@ function heuristicInference(fingerprint) {
112
122
  verificationCommands.push("npm run docs:build");
113
123
  if (verificationCommands.length === 0)
114
124
  verificationCommands.push("npm run build");
125
+ const treeKeys = Object.keys(fingerprint.fileTree);
126
+ const hasAppsApi = treeKeys.some((k) => k === "apps/api" || k.startsWith("apps/api/"));
127
+ const matchGlob = hasAppsApi ? "apps/api/**" : "**/api/**";
128
+ const allowlist = treeKeys.some((k) => k === "apps" || k.startsWith("apps/"))
129
+ ? ["openapi/**", "apps/**"]
130
+ : ["openapi/**", `${firstDocsite}/**`];
131
+ const requireHumanReview = fingerprint.foundPaths.docsDirs.length > 0
132
+ ? [`${firstDocsite}/docs/guides/**`]
133
+ : [];
115
134
  return {
116
135
  suggestedConfig: {
117
- version: 1,
118
- openapi: { export: openapiExport, generated, published },
136
+ version: 2,
137
+ specProviders: [
138
+ {
139
+ format: "openapi3",
140
+ current: { type: "export", command: openapiExport, outputPath: generated },
141
+ published,
142
+ },
143
+ ],
119
144
  docsite: firstDocsite,
120
145
  exclude: ["**/CHANGELOG*", "**/blog/**"],
121
- requireHumanReview: [],
122
- pathMappings: [{ match: "**/api/**", impacts: [`${firstDocsite}/docs/**`, `${firstDocsite}/openapi/**`] }],
146
+ requireHumanReview,
147
+ pathMappings: [{ match: matchGlob, impacts: [`${firstDocsite}/docs/**`, `${firstDocsite}/openapi/**`] }],
148
+ mode: "strict",
123
149
  devin: { apiVersion: "v1", unlisted: true, maxAcuLimit: 2, tags: ["docdrift"] },
124
150
  policy: {
125
151
  prCaps: { maxPrsPerDay: 5, maxFilesTouched: 30 },
126
152
  confidence: { autopatchThreshold: 0.8 },
127
- allowlist: ["openapi/**", firstDocsite + "/**"],
153
+ allowlist,
128
154
  verification: { commands: verificationCommands },
129
155
  slaDays: 7,
130
156
  slaLabel: "docdrift",
@@ -133,11 +159,11 @@ function heuristicInference(fingerprint) {
133
159
  },
134
160
  choices: [
135
161
  {
136
- key: "openapi.export",
162
+ key: "specProviders.0.current.command",
137
163
  question: "OpenAPI export command",
138
164
  options: [{ value: openapiExport, label: openapiExport, recommended: true }],
139
165
  defaultIndex: 0,
140
- help: "Command that generates the OpenAPI spec.",
166
+ help: "Use the npm script that generates the spec (e.g. npm run openapi:export).",
141
167
  confidence: "medium",
142
168
  },
143
169
  {
@@ -32,22 +32,59 @@ function applyOverrides(base, overrides) {
32
32
  function setByKey(obj, key, value) {
33
33
  const parts = key.split(".");
34
34
  let cur = obj;
35
- for (let i = 0; i < parts.length - 1; i++) {
35
+ let i = 0;
36
+ while (i < parts.length - 1) {
36
37
  const p = parts[i];
37
- if (!(p in cur) || typeof cur[p] !== "object" || cur[p] === null || Array.isArray(cur[p])) {
38
- cur[p] = {};
38
+ const nextPart = parts[i + 1];
39
+ const isArrayIndex = nextPart !== undefined && /^\d+$/.test(nextPart);
40
+ const existing = cur[p];
41
+ if (existing != null && typeof existing === "object") {
42
+ if (Array.isArray(existing) && isArrayIndex) {
43
+ const idx = parseInt(nextPart, 10);
44
+ let el = existing[idx];
45
+ if (el == null || typeof el !== "object") {
46
+ el = {};
47
+ existing[idx] = el;
48
+ }
49
+ cur = el;
50
+ i += 2;
51
+ continue;
52
+ }
53
+ if (!Array.isArray(existing)) {
54
+ cur = existing;
55
+ }
56
+ else {
57
+ cur[p] = isArrayIndex ? [] : {};
58
+ cur = cur[p];
59
+ }
39
60
  }
40
- cur = cur[p];
61
+ else {
62
+ const next = isArrayIndex ? [] : {};
63
+ cur[p] = next;
64
+ cur = next;
65
+ }
66
+ i++;
41
67
  }
42
68
  cur[parts[parts.length - 1]] = value;
43
69
  }
44
70
  const DEFAULT_CONFIG = {
45
- version: 1,
46
- openapi: { export: "npm run openapi:export", generated: "openapi/generated.json", published: "apps/docs-site/openapi/openapi.json" },
71
+ version: 2,
72
+ specProviders: [
73
+ {
74
+ format: "openapi3",
75
+ current: {
76
+ type: "export",
77
+ command: "npm run openapi:export",
78
+ outputPath: "openapi/generated.json",
79
+ },
80
+ published: "apps/docs-site/openapi/openapi.json",
81
+ },
82
+ ],
47
83
  docsite: "apps/docs-site",
48
84
  exclude: [],
49
85
  requireHumanReview: [],
50
86
  pathMappings: [],
87
+ mode: "strict",
51
88
  devin: {
52
89
  apiVersion: "v1",
53
90
  unlisted: true,
@@ -3,23 +3,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SYSTEM_PROMPT = void 0;
4
4
  exports.SYSTEM_PROMPT = `You are a docdrift config expert. Given a repo fingerprint (file tree, package.json scripts, and detected paths), infer a partial docdrift.yaml configuration and a list of interactive choices for the user.
5
5
 
6
- ## Docdrift config (simple mode)
6
+ ## Docdrift config (v2)
7
7
 
8
- Minimal valid config uses: version, openapi, docsite, pathMappings, devin, policy.
8
+ Minimal valid config uses: version: 2, specProviders (or pathMappings only for path-only setups), docsite, devin, policy.
9
9
 
10
10
  Example:
11
11
  \`\`\`yaml
12
- version: 1
13
- openapi:
14
- export: "npm run openapi:export"
15
- generated: "openapi/generated.json"
16
- published: "apps/docs-site/openapi/openapi.json"
12
+ version: 2
13
+ specProviders:
14
+ - format: openapi3
15
+ current:
16
+ type: export
17
+ command: "npm run openapi:export"
18
+ outputPath: "openapi/generated.json"
19
+ published: "apps/docs-site/openapi/openapi.json"
17
20
  docsite: "apps/docs-site"
18
21
  pathMappings:
19
22
  - match: "apps/api/**"
20
23
  impacts: ["apps/docs-site/docs/**", "apps/docs-site/openapi/**"]
21
24
  exclude: ["**/CHANGELOG*", "apps/docs-site/blog/**"]
22
25
  requireHumanReview: []
26
+ mode: strict
23
27
  devin:
24
28
  apiVersion: v1
25
29
  unlisted: true
@@ -38,25 +42,88 @@ policy:
38
42
 
39
43
  ## Field rules
40
44
 
41
- - openapi.export: Command to generate OpenAPI spec (e.g. "npm run openapi:export"). Prefer an existing script from root or workspace package.json.
42
- - openapi.generated: Path where the export writes the spec (e.g. "openapi/generated.json").
43
- - openapi.published: Path where the docsite consumes the spec (often under docsite, e.g. "apps/docs-site/openapi/openapi.json").
45
+ - version: Always use 2.
46
+ - specProviders: Array of spec sources. For OpenAPI: format "openapi3", current.type "export", current.command = npm script (e.g. "npm run openapi:export"), current.outputPath = where export writes (e.g. "openapi/generated.json"), published = docsite path (e.g. "apps/docs-site/openapi/openapi.json"). Never use raw script body; use "npm run <scriptName>".
44
47
  - docsite: Path to the docs site root (Docusaurus, Next.js docs, VitePress, MkDocs). Single string or array of strings.
45
48
  - pathMappings: Array of { match, impacts }. match = glob for source/API code; impacts = globs for doc files that may need updates when match changes.
49
+ - mode: "strict" (only run on spec drift) or "auto" (also run when pathMappings match without spec drift). Default: strict.
46
50
  - policy.verification.commands: Commands to run after patching (e.g. "npm run docs:gen", "npm run docs:build"). Must exist in repo.
47
51
  - exclude: Globs to never touch (e.g. blog, CHANGELOG).
48
52
  - requireHumanReview: Globs that require human review when touched (e.g. guides).
49
53
 
54
+ ## Path-only config (no OpenAPI)
55
+
56
+ If no OpenAPI/spec found, use version: 2 with pathMappings only (no specProviders):
57
+ \`\`\`yaml
58
+ version: 2
59
+ docsite: "apps/docs-site"
60
+ pathMappings: [...]
61
+ mode: auto
62
+ \`\`\`
63
+
50
64
  ## Common patterns
51
65
 
52
- - Docusaurus: docsite often has docusaurus.config.*; docs:gen may be "docusaurus -- gen-api-docs api"; openapi published path often under docsite/openapi/.
66
+ - Docusaurus: docsite often has docusaurus.config.*; docs:gen may be "docusaurus -- gen-api-docs api"; published path often under docsite/openapi/.
53
67
  - Next/VitePress/MkDocs: docsite is the app root; look for docs/ or similar.
54
68
 
55
69
  ## Output rules
56
70
 
57
- 1. Infer suggestedConfig from the fingerprint. Only include fields you can confidently infer. Use existing paths and scripts from the fingerprint; do not invent paths that are not present.
58
- 2. For each field where confidence is medium or low, OR where multiple valid options exist, add an entry to choices with: key (e.g. "openapi.export"), question, options (array of { value, label, recommended? }), defaultIndex, help?, warning?, confidence ("high"|"medium"|"low").
71
+ 1. Infer suggestedConfig from the fingerprint. Use version: 2. Only include fields you can confidently infer. Use existing paths and scripts from the fingerprint; do not invent paths that are not present.
72
+ 2. For each field where confidence is medium or low, OR where multiple valid options exist, add an entry to choices with: key (e.g. "specProviders.0.current.command"), question, options (array of { value, label, recommended? }), defaultIndex, help?, warning?, confidence ("high"|"medium"|"low").
59
73
  3. Add to skipQuestions the keys for which you are highly confident so the CLI will not ask the user.
60
74
  4. Prefer fewer, high-quality choices. If truly uncertain, set confidence to "low" and provide 2–3 options.
61
75
  5. Do not suggest paths that do not exist in the fingerprint. Prefer existing package.json scripts for export and verification commands.
62
- 6. suggestedConfig must be a valid partial docdrift config; policy.allowlist and policy.verification.commands are required if you include policy. devin.apiVersion must be "v1" if you include devin.`;
76
+ 6. suggestedConfig must be a valid partial docdrift config; policy.allowlist and policy.verification.commands are required if you include policy. devin.apiVersion must be "v1" if you include devin.
77
+
78
+ ## Example docdrift.yaml
79
+ # yaml-language-server: $schema=./docdrift.schema.json
80
+ version: 2
81
+
82
+ specProviders:
83
+ - format: openapi3
84
+ current:
85
+ type: export
86
+ command: "npm run openapi:export"
87
+ outputPath: "openapi/generated.json"
88
+ published: "apps/docs-site/openapi/openapi.json"
89
+
90
+ docsite: "apps/docs-site"
91
+ mode: strict
92
+
93
+ pathMappings:
94
+ - match: "apps/api/**"
95
+ impacts: ["apps/docs-site/docs/**", "apps/docs-site/openapi/**"]
96
+
97
+ exclude:
98
+ - "apps/docs-site/blog/**"
99
+ - "**/CHANGELOG*"
100
+
101
+ requireHumanReview:
102
+ - "apps/docs-site/docs/guides/**"
103
+
104
+ devin:
105
+ apiVersion: v1
106
+ unlisted: true
107
+ maxAcuLimit: 2
108
+ tags:
109
+ - docdrift
110
+ customInstructions:
111
+ - "DocDrift.md"
112
+
113
+ policy:
114
+ prCaps:
115
+ maxPrsPerDay: 5
116
+ maxFilesTouched: 30
117
+ confidence:
118
+ autopatchThreshold: 0.8
119
+ allowlist:
120
+ - "openapi/**"
121
+ - "apps/**"
122
+ verification:
123
+ commands:
124
+ - "npm run docs:gen"
125
+ - "npm run docs:build"
126
+ slaDays: 7
127
+ slaLabel: docdrift
128
+ allowNewFiles: false
129
+ `;
@@ -183,13 +183,13 @@
183
183
  },
184
184
  "default": []
185
185
  },
186
- "allowConceptualOnlyRun": {
187
- "type": "boolean",
188
- "default": false
189
- },
190
- "inferMode": {
191
- "type": "boolean",
192
- "default": true
186
+ "mode": {
187
+ "type": "string",
188
+ "enum": [
189
+ "strict",
190
+ "auto"
191
+ ],
192
+ "default": "strict"
193
193
  },
194
194
  "devin": {
195
195
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devinnn/docdrift",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
5
  "description": "Detect and remediate documentation drift with Devin sessions",
6
6
  "main": "dist/src/index.js",