@devinnn/docdrift 0.1.1 → 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/README.md CHANGED
@@ -9,6 +9,7 @@ Docs that never lie: detect drift between merged code and docs, then open low-no
9
9
  - `detect --base <sha> --head <sha>`
10
10
  - `run --base <sha> --head <sha>`
11
11
  - `status --since 24h`
12
+ - `sla-check` — Check for doc-drift PRs open 7+ days and open a reminder issue
12
13
  - GitHub Action: `/Users/cameronking/Desktop/sideproject/docdrift/.github/workflows/devin-doc-drift.yml`
13
14
  - Repo-local config: `/Users/cameronking/Desktop/sideproject/docdrift/docdrift.yaml`
14
15
  - Demo API + OpenAPI exporter + driftable docs
@@ -16,17 +17,18 @@ Docs that never lie: detect drift between merged code and docs, then open low-no
16
17
 
17
18
  ## Why this is low-noise
18
19
 
19
- - One PR per doc area per day (bundling rule).
20
- - Global PR/day cap.
21
- - Confidence gating and allowlist enforcement.
22
- - Conceptual docs default to issue escalation with targeted questions.
20
+ - **Single session, single PR** One Devin session handles the whole docsite (API reference + guides).
21
+ - **Gate on API spec diff** — We only run when OpenAPI drift is detected; no session for docs-check-only failures.
22
+ - **requireHumanReview** When the PR touches guides/prose, we open an issue after the PR to direct attention.
23
+ - **7-day SLA** If a doc-drift PR is open 7+ days, we open a reminder issue (configurable `slaDays`; use `sla-check` CLI or cron workflow).
24
+ - Confidence gating and allowlist/exclude enforcement.
23
25
  - Idempotency key prevents duplicate actions for same repo/SHAs/action.
24
26
 
25
- ## Detection tiers
27
+ ## Detection and gate
26
28
 
27
- - Tier 0: docsite verification (`npm run docs:gen` then `npm run docs:build`)
28
- - Tier 1: OpenAPI drift (`openapi/generated.json` vs `apps/docs-site/openapi/openapi.json`)
29
- - Tier 2: heuristic path impacts (e.g. `apps/api/src/auth/**` -> `apps/docs-site/docs/guides/auth.md`)
29
+ - **Gate:** We only run a Devin session when **OpenAPI drift** is detected. No drift → no session.
30
+ - Tier 1: OpenAPI drift (`openapi/generated.json` vs published spec)
31
+ - Tier 2: Heuristic path impacts from docAreas (e.g. `apps/api/src/auth/**` guides)
30
32
 
31
33
  Output artifacts (under `.docdrift/`):
32
34
 
@@ -38,13 +40,13 @@ When you run docdrift as a package (e.g. `npx docdrift` or from another repo), a
38
40
  ## Core flow (`docdrift run`)
39
41
 
40
42
  1. Validate config and command availability.
41
- 2. Build drift report.
43
+ 2. Build drift report. **Gate:** If no OpenAPI drift, exit (no session).
42
44
  3. Policy decision (`OPEN_PR | UPDATE_EXISTING_PR | OPEN_ISSUE | NOOP`).
43
- 4. Build evidence bundle (`.docdrift/evidence/<runId>/<docArea>` + tarball).
44
- 5. Upload attachments to Devin v1 and create session.
45
- 6. Poll session to terminal status.
45
+ 4. Build one aggregated evidence bundle for the whole docsite.
46
+ 5. One Devin session with whole-docsite prompt; poll to terminal status.
47
+ 6. If PR opened and touches `requireHumanReview` paths → create issue to direct attention.
46
48
  7. Surface result via GitHub commit comment; open issue on blocked/low-confidence paths.
47
- 8. Persist state in `.docdrift/state.json` and write `.docdrift/metrics.json`.
49
+ 8. Persist state (including `lastDocDriftPrUrl` for SLA); write `.docdrift/metrics.json`.
48
50
 
49
51
  ## Where the docs are (this repo)
50
52
 
package/dist/src/cli.js CHANGED
@@ -17,7 +17,7 @@ function getArg(args, flag) {
17
17
  async function main() {
18
18
  const [, , command, ...args] = process.argv;
19
19
  if (!command) {
20
- throw new Error("Usage: docdrift <validate|detect|run|status> [options]");
20
+ throw new Error("Usage: docdrift <validate|detect|run|status|sla-check> [options]\n detect|run: [--base SHA] [--head SHA] (defaults: merge-base with main..HEAD)");
21
21
  }
22
22
  switch (command) {
23
23
  case "validate": {
@@ -25,18 +25,20 @@ async function main() {
25
25
  return;
26
26
  }
27
27
  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 });
28
+ const { baseSha, headSha } = await (0, index_1.resolveBaseHead)(getArg(args, "--base"), getArg(args, "--head"));
29
+ const trigger = getArg(args, "--trigger") ?? (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
30
+ const prNum = getArg(args, "--pr-number");
31
+ const prNumber = prNum ? parseInt(prNum, 10) : (process.env.GITHUB_PR_NUMBER ? parseInt(process.env.GITHUB_PR_NUMBER, 10) : undefined);
32
+ const result = await (0, index_1.runDetect)({ baseSha, headSha, trigger, prNumber: Number.isFinite(prNumber) ? prNumber : undefined });
32
33
  process.exitCode = result.hasDrift ? 1 : 0;
33
34
  return;
34
35
  }
35
36
  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 });
37
+ const { baseSha, headSha } = await (0, index_1.resolveBaseHead)(getArg(args, "--base"), getArg(args, "--head"));
38
+ const trigger = getArg(args, "--trigger") ?? (0, index_1.resolveTrigger)(process.env.GITHUB_EVENT_NAME);
39
+ const prNum = getArg(args, "--pr-number");
40
+ const prNumber = prNum ? parseInt(prNum, 10) : (process.env.GITHUB_PR_NUMBER ? parseInt(process.env.GITHUB_PR_NUMBER, 10) : undefined);
41
+ const results = await (0, index_1.runDocDrift)({ baseSha, headSha, trigger, prNumber: Number.isFinite(prNumber) ? prNumber : undefined });
40
42
  const outPath = node_path_1.default.resolve(".docdrift", "run-output.json");
41
43
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(outPath), { recursive: true });
42
44
  node_fs_1.default.writeFileSync(outPath, JSON.stringify(results, null, 2), "utf-8");
@@ -49,6 +51,10 @@ async function main() {
49
51
  await (0, index_1.runStatus)(sinceHours);
50
52
  return;
51
53
  }
54
+ case "sla-check": {
55
+ await (0, index_1.runSlaCheck)();
56
+ return;
57
+ }
52
58
  default:
53
59
  throw new Error(`Unknown command: ${command}`);
54
60
  }
@@ -4,9 +4,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.loadConfig = loadConfig;
7
+ exports.loadNormalizedConfig = loadNormalizedConfig;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const js_yaml_1 = __importDefault(require("js-yaml"));
11
+ const normalize_1 = require("./normalize");
10
12
  const schema_1 = require("./schema");
11
13
  function loadConfig(configPath = "docdrift.yaml") {
12
14
  const resolved = node_path_1.default.resolve(configPath);
@@ -36,3 +38,8 @@ function loadConfig(configPath = "docdrift.yaml") {
36
38
  }
37
39
  return data;
38
40
  }
41
+ /** Load and normalize config for use by detection/run (always has openapi, docsite, etc.) */
42
+ function loadNormalizedConfig(configPath = "docdrift.yaml") {
43
+ const config = loadConfig(configPath);
44
+ return (0, normalize_1.normalizeConfig)(config);
45
+ }
@@ -0,0 +1,155 @@
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.normalizeConfig = normalizeConfig;
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ /**
9
+ * Produce a normalized config that the rest of the app consumes.
10
+ * Derives specProviders/openapi/docsite from openapi block or docAreas when not using v2 specProviders.
11
+ */
12
+ function normalizeConfig(config) {
13
+ let specProviders;
14
+ let openapi;
15
+ let docsite;
16
+ let exclude = config.exclude ?? [];
17
+ let requireHumanReview = config.requireHumanReview ?? [];
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
+ ];
57
+ openapi = config.openapi;
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
+ }
74
+ }
75
+ else if (config.docAreas && config.docAreas.length > 0) {
76
+ const firstOpenApiArea = config.docAreas.find((a) => a.detect.openapi);
77
+ if (!firstOpenApiArea?.detect.openapi) {
78
+ throw new Error("Legacy config requires at least one docArea with detect.openapi");
79
+ }
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
+ ];
88
+ openapi = {
89
+ export: o.exportCmd,
90
+ generated: o.generatedPath,
91
+ published: o.publishedPath,
92
+ };
93
+ const allPaths = [o.publishedPath];
94
+ for (const area of config.docAreas) {
95
+ area.patch.targets?.forEach((t) => allPaths.push(t));
96
+ area.detect.paths?.forEach((p) => p.impacts.forEach((i) => allPaths.push(i)));
97
+ }
98
+ const roots = new Set();
99
+ for (const p of allPaths) {
100
+ const parts = p.split("/").filter(Boolean);
101
+ if (parts.length >= 2)
102
+ roots.add(parts[0] + "/" + parts[1]);
103
+ else if (parts.length === 1)
104
+ roots.add(parts[0]);
105
+ }
106
+ docsite = roots.size > 0 ? [...roots] : [node_path_1.default.dirname(o.publishedPath) || "."];
107
+ const reviewPaths = new Set();
108
+ for (const area of config.docAreas) {
109
+ if (area.patch.requireHumanConfirmation || area.mode === "conceptual") {
110
+ area.patch.targets?.forEach((t) => reviewPaths.add(t));
111
+ area.detect.paths?.forEach((p) => p.impacts.forEach((i) => reviewPaths.add(i)));
112
+ }
113
+ }
114
+ requireHumanReview = [...reviewPaths];
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
+ }
141
+ else {
142
+ throw new Error("Config must include specProviders, (openapi + docsite), or docAreas");
143
+ }
144
+ return {
145
+ ...config,
146
+ specProviders,
147
+ openapi,
148
+ docsite,
149
+ exclude,
150
+ requireHumanReview,
151
+ docAreas,
152
+ allowConceptualOnlyRun,
153
+ inferMode,
154
+ };
155
+ }
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.docDriftConfigSchema = 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
  });
@@ -11,18 +12,53 @@ const openApiDetectSchema = zod_1.z.object({
11
12
  generatedPath: zod_1.z.string().min(1),
12
13
  publishedPath: zod_1.z.string().min(1),
13
14
  });
15
+ /** Simple config: short field names for openapi block */
16
+ exports.openApiSimpleSchema = zod_1.z.object({
17
+ export: zod_1.z.string().min(1),
18
+ generated: zod_1.z.string().min(1),
19
+ published: zod_1.z.string().min(1),
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
+ });
14
55
  const docAreaSchema = zod_1.z.object({
15
56
  name: zod_1.z.string().min(1),
16
57
  mode: zod_1.z.enum(["autogen", "conceptual"]),
17
58
  owners: zod_1.z.object({
18
59
  reviewers: zod_1.z.array(zod_1.z.string().min(1)).min(1),
19
60
  }),
20
- detect: zod_1.z
21
- .object({
22
- openapi: openApiDetectSchema.optional(),
23
- paths: zod_1.z.array(pathRuleSchema).optional(),
24
- })
25
- .refine((v) => Boolean(v.openapi) || Boolean(v.paths?.length), {
61
+ detect: docAreaDetectBaseSchema.refine((v) => Boolean(v.openapi) || Boolean(v.paths?.length), {
26
62
  message: "docArea.detect must include openapi or paths",
27
63
  }),
28
64
  patch: zod_1.z.object({
@@ -30,8 +66,61 @@ const docAreaSchema = zod_1.z.object({
30
66
  requireHumanConfirmation: zod_1.z.boolean().optional().default(false),
31
67
  }),
32
68
  });
33
- exports.docDriftConfigSchema = zod_1.z.object({
34
- version: zod_1.z.literal(1),
69
+ const policySchema = zod_1.z.object({
70
+ prCaps: zod_1.z.object({
71
+ maxPrsPerDay: zod_1.z.number().int().positive().default(1),
72
+ maxFilesTouched: zod_1.z.number().int().positive().default(12),
73
+ }),
74
+ confidence: zod_1.z.object({
75
+ autopatchThreshold: zod_1.z.number().min(0).max(1).default(0.8),
76
+ }),
77
+ allowlist: zod_1.z.array(zod_1.z.string().min(1)).min(1),
78
+ verification: zod_1.z.object({
79
+ commands: zod_1.z.array(zod_1.z.string().min(1)).min(1),
80
+ }),
81
+ /** Days before opening SLA issue for unmerged doc-drift PRs. 0 = disabled. */
82
+ slaDays: zod_1.z.number().int().min(0).optional().default(7),
83
+ /** Label to identify doc-drift PRs for SLA check (only these PRs count). */
84
+ slaLabel: zod_1.z.string().min(1).optional().default("docdrift"),
85
+ /**
86
+ * If false (default): Devin may only edit existing files. No new articles, no new folders.
87
+ * If true: Devin may add new articles, create folders, change information architecture.
88
+ * Gives teams control to prevent doc sprawl; mainly applies to conceptual/guides.
89
+ */
90
+ allowNewFiles: zod_1.z.boolean().optional().default(false),
91
+ });
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(),
96
+ openapi: exports.openApiSimpleSchema.optional(),
97
+ docsite: zod_1.z.union([zod_1.z.string().min(1), zod_1.z.array(zod_1.z.string().min(1))]).optional(),
98
+ exclude: zod_1.z.array(zod_1.z.string().min(1)).optional().default([]),
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),
35
124
  devin: zod_1.z.object({
36
125
  apiVersion: zod_1.z.literal("v1"),
37
126
  unlisted: zod_1.z.boolean().default(true),
@@ -40,18 +129,17 @@ exports.docDriftConfigSchema = zod_1.z.object({
40
129
  customInstructions: zod_1.z.array(zod_1.z.string().min(1)).optional(),
41
130
  customInstructionContent: zod_1.z.string().optional(),
42
131
  }),
43
- policy: zod_1.z.object({
44
- prCaps: zod_1.z.object({
45
- maxPrsPerDay: zod_1.z.number().int().positive().default(1),
46
- maxFilesTouched: zod_1.z.number().int().positive().default(12),
47
- }),
48
- confidence: zod_1.z.object({
49
- autopatchThreshold: zod_1.z.number().min(0).max(1).default(0.8),
50
- }),
51
- allowlist: zod_1.z.array(zod_1.z.string().min(1)).min(1),
52
- verification: zod_1.z.object({
53
- commands: zod_1.z.array(zod_1.z.string().min(1)).min(1),
54
- }),
55
- }),
56
- docAreas: zod_1.z.array(docAreaSchema).min(1),
132
+ policy: policySchema,
133
+ docAreas: zod_1.z.array(docAreaSchema).optional().default([]),
57
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,12 +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
- ...config.docAreas
17
- .map((area) => area.detect.openapi?.exportCmd)
18
- .filter((value) => Boolean(value)),
19
- ]);
16
+ ...(config.openapi ? [config.openapi.export] : []),
17
+ ...(config.docAreas ?? []).map((area) => area.detect.openapi?.exportCmd).filter((value) => Boolean(value)),
18
+ ...(config.specProviders ?? [])
19
+ .filter((p) => p.current.type === "export")
20
+ .map((p) => p.current.command),
21
+ ];
22
+ const commandSet = new Set(exportCommands);
20
23
  for (const command of commandSet) {
21
24
  const binary = commandBinary(command);
22
25
  const result = await (0, exec_1.execCommand)(`command -v ${binary}`);
@@ -24,7 +27,7 @@ async function validateRuntimeConfig(config) {
24
27
  errors.push(`Command not found for '${command}' (binary: ${binary})`);
25
28
  }
26
29
  }
27
- for (const area of config.docAreas) {
30
+ for (const area of config.docAreas ?? []) {
28
31
  if (area.mode === "autogen" && !area.patch.targets?.length) {
29
32
  warnings.push(`docArea '${area.name}' is autogen but has no patch.targets`);
30
33
  }