arkgate 4.8.23 → 4.8.24

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +24 -77
  4. package/bin/ark-dashboard.mjs +56 -53
  5. package/bin/ark-layer-match.mjs +596 -112
  6. package/bin/ark-mcp-runtime.mjs +412 -138
  7. package/bin/ark-mcp.mjs +68 -4
  8. package/bin/ark-shared.mjs +39 -0
  9. package/bin/ark.mjs +65 -97
  10. package/bin/lib/adapter-contract-types.mjs +5 -0
  11. package/bin/lib/adapter-contract.mjs +15 -0
  12. package/bin/lib/agent-projection-command.mjs +5 -1
  13. package/bin/lib/analysis-engine.mjs +15 -11
  14. package/bin/lib/architecture-scan.mjs +50 -6
  15. package/bin/lib/ark-order-doctor.mjs +1 -1
  16. package/bin/lib/ark-order-facts.mjs +410 -15
  17. package/bin/lib/ark-order-invariants.mjs +51 -9
  18. package/bin/lib/ark-order-sensors.mjs +28 -101
  19. package/bin/lib/ark-order-types.mjs +2 -0
  20. package/bin/lib/ark-run-doctor.mjs +14 -0
  21. package/bin/lib/ark-run-facts.mjs +434 -11
  22. package/bin/lib/ark-run-sensors.mjs +5 -3
  23. package/bin/lib/arkgate-pins.mjs +91 -0
  24. package/bin/lib/arkrules-sensors.mjs +50 -249
  25. package/bin/lib/check-args.mjs +23 -1
  26. package/bin/lib/ci-and-commands.mjs +16 -6
  27. package/bin/lib/class-source-scan.mjs +479 -0
  28. package/bin/lib/codex-home.mjs +4 -2
  29. package/bin/lib/config-contract-slices.mjs +701 -0
  30. package/bin/lib/config-contract.mjs +5 -493
  31. package/bin/lib/config-warnings.mjs +32 -0
  32. package/bin/lib/dashboard-cli.mjs +62 -0
  33. package/bin/lib/design-delta.mjs +1 -1
  34. package/bin/lib/diagnostic-catalog.mjs +16 -9
  35. package/bin/lib/doctor-advisories.mjs +25 -1
  36. package/bin/lib/doctor-next-actions.mjs +17 -2
  37. package/bin/lib/doctor-plan.mjs +6 -8
  38. package/bin/lib/effective-contract-load.mjs +121 -59
  39. package/bin/lib/enforcement-profiles.mjs +40 -4
  40. package/bin/lib/first-run-help.mjs +3 -2
  41. package/bin/lib/flat-parent-importers.mjs +137 -0
  42. package/bin/lib/flat-parent-pilot.mjs +40 -100
  43. package/bin/lib/gate-files.mjs +29 -2
  44. package/bin/lib/git-change-scope.mjs +109 -0
  45. package/bin/lib/github-enforcement.mjs +1 -1
  46. package/bin/lib/hook-templates.mjs +18 -16
  47. package/bin/lib/host-support-matrix.mjs +3 -1
  48. package/bin/lib/import-resolve.mjs +13 -1
  49. package/bin/lib/install-migrate.mjs +50 -73
  50. package/bin/lib/invariant-coverage.mjs +59 -11
  51. package/bin/lib/mcp-adoption.mjs +9 -1
  52. package/bin/lib/mcp-hook-payload.mjs +401 -61
  53. package/bin/lib/package-manager.mjs +59 -1
  54. package/bin/lib/passthrough-commands.mjs +83 -0
  55. package/bin/lib/persistence-write-hint.mjs +81 -0
  56. package/bin/lib/pin-dependent-gates.mjs +117 -0
  57. package/bin/lib/policy-delta-io.mjs +168 -10
  58. package/bin/lib/prepare-change.mjs +120 -6
  59. package/bin/lib/prepare-write.mjs +1 -0
  60. package/bin/lib/presets.mjs +3 -5
  61. package/bin/lib/remediation.mjs +23 -4
  62. package/bin/lib/resolved-candidate-facts.mjs +315 -55
  63. package/bin/lib/rules-inventory.mjs +22 -3
  64. package/bin/lib/rules-under-contract.mjs +179 -5
  65. package/bin/lib/scan-files.mjs +32 -0
  66. package/bin/lib/snippet-analysis.mjs +140 -6
  67. package/bin/lib/source-policy.mjs +34 -0
  68. package/bin/lib/stable-hash.mjs +90 -0
  69. package/bin/lib/start-install-recovery.mjs +71 -1
  70. package/bin/lib/start-preview.mjs +30 -0
  71. package/bin/lib/status-command.mjs +33 -4
  72. package/bin/lib/status-manifest.mjs +32 -4
  73. package/bin/lib/team-parliament-io.mjs +77 -75
  74. package/bin/lib/team-parliament.mjs +6 -1
  75. package/bin/lib/write-path-capabilities.mjs +4 -2
  76. package/dist/chunk-A5EIY3HI.cjs +5 -0
  77. package/dist/chunk-MBXCFLO4.js +16 -0
  78. package/dist/chunk-RNQHRDCQ.cjs +16 -0
  79. package/dist/chunk-SVSQVGFJ.js +5 -0
  80. package/dist/{configTypes-Bbf_MFGe.d.ts → configTypes-BJbaeoj4.d.cts} +18 -3
  81. package/dist/configTypes-BJbaeoj4.d.ts +246 -0
  82. package/dist/eslint/index.cjs +17 -4
  83. package/dist/eslint/index.d.cts +359 -0
  84. package/dist/eslint/index.d.ts +130 -156
  85. package/dist/eslint/index.js +10 -4
  86. package/dist/index.cjs +35 -34
  87. package/dist/index.d.cts +5475 -0
  88. package/dist/index.d.ts +3312 -299
  89. package/dist/index.js +35 -34
  90. package/dist/inspectorListen-6DQVJ4DC.js +1 -0
  91. package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
  92. package/dist/nestjs/index.cjs +1 -18
  93. package/dist/nestjs/index.d.cts +22 -0
  94. package/dist/nestjs/index.d.ts +1 -3
  95. package/dist/nestjs/index.js +1 -18
  96. package/dist/order/index.cjs +1 -1
  97. package/dist/order/index.d.cts +155 -0
  98. package/dist/order/index.d.ts +15 -3
  99. package/dist/order/index.js +1 -1
  100. package/dist/runtime/index.cjs +13 -30
  101. package/dist/{diagnosticCatalog-35mrwAHe.d.ts → runtime/index.d.cts} +804 -290
  102. package/dist/runtime/index.d.ts +2678 -108
  103. package/dist/runtime/index.js +13 -30
  104. package/dist/{types-Bdj32QwA.d.ts → types-DRinG-vT.d.cts} +576 -140
  105. package/dist/types-DRinG-vT.d.ts +1792 -0
  106. package/docs/README.md +2 -1
  107. package/docs/agent-guide.md +84 -15
  108. package/docs/ai-gates.md +160 -26
  109. package/docs/arkorder.md +32 -15
  110. package/docs/brownfield-adoption.md +5 -1
  111. package/docs/configuration.md +196 -26
  112. package/docs/develop.md +2 -1
  113. package/docs/diagnostics.md +96 -19
  114. package/docs/package-surface.md +31 -8
  115. package/docs/product-voice.md +1 -1
  116. package/package.json +59 -18
  117. package/schemas/ark.analysis-result.schema.json +15 -0
  118. package/schemas/ark.config.schema.json +47 -10
  119. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  120. package/schemas/ark.status-manifest.schema.json +22 -0
  121. package/server.json +3 -9
  122. package/templates/agent-skills/README.md +1 -1
  123. package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
  124. package/templates/agent-skills/ark-order/SKILL.md +4 -2
  125. package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
  126. package/templates/arkrules/PersistenceAdapters.json +1 -1
  127. package/templates/arkrules/PresentationAdapters.json +1 -1
  128. package/templates/skills/ark-adopt.md +4 -1
  129. package/templates/skills/ark-order.md +4 -2
  130. package/templates/skills/ark-runtime.md +6 -1
  131. package/dist/types-Ik_OakcI.d.ts +0 -183
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Subcommands of `arkgate` / `ark` that hand their own flags to a sibling bin untouched:
3
+ * `dashboard` (bin/ark-dashboard.mjs) and `mcp` (bin/ark-mcp.mjs).
4
+ *
5
+ * `mcp` exists because `npx arkgate@<version> <args>` always runs the bin named after the
6
+ * package (`arkgate`), never `arkgate-mcp`. The MCP Registry descriptor (`server.json`) is
7
+ * launched exactly that way, so `arkgate mcp --root .` must start the stdio MCP server.
8
+ * `arkgate-mcp` / `ark-mcp` are also accepted as the first argument, so a descriptor or host
9
+ * config that spells the bin name still reaches the server. That does not repair registry
10
+ * entries already published: each pins the arkgate version it was published with, and
11
+ * versions through 4.8.23 have no passthrough. Only a republished descriptor fixes those.
12
+ */
13
+ import { spawn, spawnSync } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const here = path.dirname(fileURLToPath(import.meta.url));
18
+ const arkDashboard = path.join(here, '..', 'ark-dashboard.mjs');
19
+ const arkMcp = path.join(here, '..', 'ark-mcp.mjs');
20
+
21
+ /** First positional arguments that start the MCP server (stdio JSON-RPC). */
22
+ export const MCP_COMMANDS = Object.freeze(['mcp', 'arkgate-mcp', 'ark-mcp']);
23
+
24
+ /** First positional arguments whose remaining argv belongs to another executable. */
25
+ export function isPassthroughCommand(command) {
26
+ return command === 'dashboard' || command === 'report' || MCP_COMMANDS.includes(command);
27
+ }
28
+
29
+ export function isMcpCommand(command) {
30
+ return MCP_COMMANDS.includes(command);
31
+ }
32
+
33
+ export { DASHBOARD_HELP as dashboardHelp } from './dashboard-cli.mjs';
34
+
35
+ export function withDashboardHelp(text, detailed) {
36
+ const extra = detailed
37
+ ? ' arkgate dashboard [--url <inspector-url>] [--interval <ms>] [--once]\n arkgate mcp [--root <project>] [--config <path>]\n arkgate report [--root <project>] [--json] [--title <text>] [--finding <ref>] [--submit] [--i-confirm-submit]\n'
38
+ : '';
39
+ const extraDesc = detailed
40
+ ? ' dashboard ANSI observability TUI (spawns ark-dashboard).\n mcp Stdio MCP server (same as arkgate-mcp; used by npx and the MCP Registry).\n report Draft an upstream GitHub issue for pedroknigge/arkgate. Create needs --submit plus confirm. --yes does not submit.\n'
41
+ : '';
42
+ if (detailed) {
43
+ return text
44
+ .replace(' arkgate agents-md [--root', `${extra} arkgate agents-md [--root`)
45
+ .replace(' agents-md Version-matched', `${extraDesc} agents-md Version-matched`);
46
+ }
47
+ return text.replace(
48
+ ' arkgate-check --doctor status — one next step\n',
49
+ ' arkgate-check --doctor status — one next step\n arkgate dashboard observability TUI (inspector)\n arkgate report draft an upstream GitHub issue\n'
50
+ );
51
+ }
52
+
53
+ export function runDashboard(passthroughArgs) {
54
+ const result = spawnSync(process.execPath, [arkDashboard, ...passthroughArgs], {
55
+ stdio: 'inherit',
56
+ encoding: 'utf8',
57
+ });
58
+ return result.status ?? 1;
59
+ }
60
+
61
+ /**
62
+ * Run bin/ark-mcp.mjs with the caller's stdio. Nothing is written to stdout here: stdout is
63
+ * the JSON-RPC channel. Termination signals are forwarded so the host can stop the server.
64
+ */
65
+ export function runMcp(passthroughArgs) {
66
+ return new Promise((resolve) => {
67
+ const child = spawn(process.execPath, [arkMcp, ...passthroughArgs], { stdio: 'inherit' });
68
+ const forward = (signal) => {
69
+ if (child.exitCode === null) child.kill(signal);
70
+ };
71
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
72
+ for (const signal of signals) process.on(signal, forward);
73
+ const done = (code) => {
74
+ for (const signal of signals) process.off(signal, forward);
75
+ resolve(code);
76
+ };
77
+ child.on('error', (error) => {
78
+ process.stderr.write(`arkgate mcp: ${error.message}\n`);
79
+ done(1);
80
+ });
81
+ child.on('exit', (code, signal) => done(code ?? (signal ? 1 : 0)));
82
+ });
83
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/persistenceWriteHint.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/persistence-write-hint.mjs). Zero Node I/O.
9
+ */
10
+
11
+ /** IO / ORM import evidence. postgres and drizzle-orm include package subpaths. */
12
+ export const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
13
+ /** Path-alias / local db module (`@/lib/db`) without resolving tsconfig. */
14
+ export const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)/;
15
+ /** Named imports from a local persistence module (same module names as IO_ALIAS_IMPORT_RE). */
16
+ const LOCAL_CLIENT_IMPORT_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle|orm)(?:\.[cm]?[jt]sx?)?['"]/g;
17
+ /** Conventional client identifiers (PrismaClient included via the /i flag). */
18
+ export const PERSISTENCE_CLIENT_NAMES = [
19
+ 'db',
20
+ 'tx',
21
+ 'client',
22
+ 'prisma',
23
+ 'prismaClient',
24
+ 'drizzle',
25
+ ];
26
+ const WRITE_VERB_SOURCE = '(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))';
27
+ const SQL_WRITE_SOURCE = '\\bINSERT\\s+INTO\\b|\\bUPDATE\\s+[A-Za-z_][\\w.]*\\s+SET\\b|\\bDELETE\\s+FROM\\b';
28
+ /** Call arguments with one level of nested parentheses: `({ log: fn() })`. */
29
+ const CALL_ARGS_SOURCE = '\\((?:[^()]|\\([^()]*\\))*\\)';
30
+ /** Receivers built inline: `new PrismaClient(...)` / `drizzle(...)`. */
31
+ const INLINE_CLIENT_SOURCE = `\\bnew\\s+PrismaClient\\s*${CALL_ARGS_SOURCE}|\\bdrizzle\\s*${CALL_ARGS_SOURCE}`;
32
+ /**
33
+ * `const orm = new PrismaClient()` / `private orm = new PrismaClient()` /
34
+ * `this.orm = new PrismaClient()` / `const sql = postgres(url)` / `db = drizzle(pool)`.
35
+ */
36
+ const DRIVER_BINDING_RE = /(?<![\w$])([A-Za-z_$][\w$]*)\s*[?!]?\s*(?::[^=;\n]+)?=(?![=>])\s*(?:await\s+)?(?:new\s+(?:PrismaClient|Pool|Client|MongoClient|Sequelize|Kysely|Knex)\b|(?:drizzle|knex|Knex|postgres)\s*\()/g;
37
+ /** `orm: PrismaClient` / `db: Kysely<DB>` / `tx: Prisma.TransactionClient` — params and fields. */
38
+ const DRIVER_ANNOTATION_RE = /(?<![\w$])([A-Za-z_$][\w$]*)\s*[?!]?\s*:\s*(?:Readonly\s*<\s*)?(?:Prisma\s*\.\s*TransactionClient|PrismaClient|Pool|PoolClient|MongoClient|Sequelize|Kysely|Knex|DataSource|EntityManager|NodePgDatabase|PostgresJsDatabase|BetterSQLite3Database|MySql2Database|LibSQLDatabase)\b/g;
39
+ function escapeRegExp(value) {
40
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+ export function sourceImportsPersistenceDriverText(content) {
43
+ return IO_IMPORT_HINT_RE.test(content) || IO_ALIAS_IMPORT_RE.test(content);
44
+ }
45
+ function collectFirstGroup(re, content, names) {
46
+ const global = new RegExp(re.source, 'g');
47
+ let match;
48
+ while ((match = global.exec(content)) !== null) {
49
+ if (match[1])
50
+ names.add(match[1]);
51
+ }
52
+ }
53
+ /**
54
+ * Identifiers that hold a persistence client in this file (sorted, unique): bound to a
55
+ * driver constructor, annotated with a driver client type, or imported by name from a
56
+ * local persistence module.
57
+ */
58
+ export function persistenceClientBindings(content) {
59
+ const names = new Set();
60
+ collectFirstGroup(DRIVER_BINDING_RE, content, names);
61
+ collectFirstGroup(DRIVER_ANNOTATION_RE, content, names);
62
+ const imports = new RegExp(LOCAL_CLIENT_IMPORT_RE.source, 'g');
63
+ let match;
64
+ while ((match = imports.exec(content)) !== null) {
65
+ for (const element of (match[1] ?? '').split(',')) {
66
+ const parts = element.trim().replace(/^type\s+/, '').split(/\s+as\s+/);
67
+ const local = (parts[1] ?? parts[0] ?? '').trim();
68
+ if (/^[A-Za-z_$][\w$]*$/.test(local))
69
+ names.add(local);
70
+ }
71
+ }
72
+ return [...names].sort();
73
+ }
74
+ /** Receiver-bound write evidence (ORM verb on a persistence client, or raw SQL write). */
75
+ export function sourceHasPersistenceWrite(content) {
76
+ const receivers = [...PERSISTENCE_CLIENT_NAMES, ...persistenceClientBindings(content)]
77
+ .map(escapeRegExp)
78
+ .join('|');
79
+ const re = new RegExp(`(?:\\b(?:${receivers})(?![\\w$])|${INLINE_CLIENT_SOURCE})(?:\\s*\\.\\s*[A-Za-z_]\\w*)*\\s*\\.\\s*${WRITE_VERB_SOURCE}\\s*\\(|${SQL_WRITE_SOURCE}`, 'i');
80
+ return re.test(content);
81
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Generated gate files whose Ark command only resolves through a local arkgate pin.
3
+ *
4
+ * `npx ark-check`, `pnpm exec arkgate-mcp` and `yarn ark-check` reach arkgate's bins only
5
+ * when package.json declares arkgate. Without that pin the same line asks npm for a package
6
+ * named `ark-check` (404) or runs a stale global copy. The pinned form
7
+ * (`npx -y -p arkgate@<exact>`) does not depend on the pin and is not reported.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { npxArkgatePrefixLength } from './package-manager.mjs';
12
+
13
+ const ARK_BIN = /^(?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)$/;
14
+ const LOCAL_RUNNER_TEXT =
15
+ /(?:^|[\s"'`;&|(])(?:npx|pnpm(?:\s+--config\.verify-deps-before-run=false)?\s+exec|yarn)\s+(?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)(?=[\s"'`;&|)]|$)/m;
16
+ const TOML_SERVER = /command\s*=\s*"([^"]*)"\s*\r?\nargs\s*=\s*\[([^\]]*)\]/g;
17
+
18
+ /** Host hooks, package scripts, and MCP registrations Ark generates. */
19
+ export const PIN_DEPENDENT_CANDIDATES = [
20
+ '.claude/settings.json',
21
+ '.codex/hooks.json',
22
+ '.cursor/hooks.json',
23
+ '.grok/hooks/ark-write-gate.json',
24
+ '.agents/hooks.json',
25
+ '.mcp.json',
26
+ '.cursor/mcp.json',
27
+ '.agents/mcp_config.json',
28
+ 'opencode.json',
29
+ '.codex/config.toml',
30
+ '.grok/config.toml',
31
+ 'package.json',
32
+ ];
33
+
34
+ function readText(file) {
35
+ try {
36
+ return fs.readFileSync(file, 'utf8');
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /** True when this argv runs an Ark bin through a runner that needs the local pin. */
43
+ export function argvDependsOnArkgatePin(command, args) {
44
+ if (typeof command !== 'string' || !Array.isArray(args)) return false;
45
+ const runner = path.basename(command.trim().replace(/\\/g, '/')).replace(/\.(?:cmd|exe)$/i, '');
46
+ if (runner === 'npx') return npxArkgatePrefixLength(args) === 0 && ARK_BIN.test(args[0] ?? '');
47
+ if (runner === 'yarn') return ARK_BIN.test(args[0] ?? '');
48
+ if (runner === 'pnpm') {
49
+ const exec = args.indexOf('exec');
50
+ return exec >= 0 && ARK_BIN.test(args[exec + 1] ?? '');
51
+ }
52
+ return false;
53
+ }
54
+
55
+ function jsonServersDependOnPin(text) {
56
+ let json;
57
+ try {
58
+ json = JSON.parse(text);
59
+ } catch {
60
+ return false;
61
+ }
62
+ const servers = [json?.mcpServers?.ark, json?.servers?.ark].filter(
63
+ (server) => server && typeof server === 'object'
64
+ );
65
+ if (servers.some((server) => argvDependsOnArkgatePin(server.command, server.args ?? []))) {
66
+ return true;
67
+ }
68
+ const argv = json?.mcp?.ark?.command;
69
+ return Array.isArray(argv) && argvDependsOnArkgatePin(argv[0], argv.slice(1));
70
+ }
71
+
72
+ function tomlServersDependOnPin(text) {
73
+ for (const match of text.matchAll(TOML_SERVER)) {
74
+ const args = [...match[2].matchAll(/"([^"]*)"/g)].map((entry) => entry[1]);
75
+ if (argvDependsOnArkgatePin(match[1], args)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ function fileDependsOnPin(relativePath, text) {
81
+ if (LOCAL_RUNNER_TEXT.test(text)) return true;
82
+ if (relativePath.endsWith('.toml')) return tomlServersDependOnPin(text);
83
+ if (relativePath.endsWith('.json')) return jsonServersDependOnPin(text);
84
+ return false;
85
+ }
86
+
87
+ function workflowFiles(root) {
88
+ const dir = path.join(root, '.github', 'workflows');
89
+ try {
90
+ return fs
91
+ .readdirSync(dir)
92
+ .filter((name) => /\.ya?ml$/i.test(name))
93
+ .sort()
94
+ .map((name) => `.github/workflows/${name}`);
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ /** Project-relative generated files whose Ark command needs arkgate in package.json. */
101
+ export function pinDependentGateFiles(root) {
102
+ const found = [];
103
+ for (const rel of [...PIN_DEPENDENT_CANDIDATES, ...workflowFiles(root)]) {
104
+ const text = readText(path.join(root, rel));
105
+ if (text && fileDependsOnPin(rel, text)) found.push(rel);
106
+ }
107
+ return found;
108
+ }
109
+
110
+ /**
111
+ * Attach `dependentGateFiles` to a PACKAGE_PIN_ABSENT truth so doctor can lead with the pin
112
+ * when a generated file would 404 without it. Other codes pass through unchanged.
113
+ */
114
+ export function withPinDependentGateFiles(root, truth) {
115
+ if (truth?.code !== 'PACKAGE_PIN_ABSENT') return truth;
116
+ return { ...truth, dependentGateFiles: pinDependentGateFiles(root) };
117
+ }
@@ -1,8 +1,14 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { analyzePolicyDelta } from './analysis-engine.mjs';
5
- import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
4
+ import { analyzePolicyDelta, stableSerialize } from './analysis-engine.mjs';
5
+ import { loadArkConfigContract } from './config-contract.mjs';
6
+ import {
7
+ directoryArkRulesReader,
8
+ loadEffectiveArkRules,
9
+ loadEffectiveArkRulesFromDisk,
10
+ objectArkRulesReader,
11
+ } from './effective-contract-load.mjs';
6
12
  import {
7
13
  coverageOptionsFromConfig,
8
14
  invariantIdsFromCatalog,
@@ -85,16 +91,52 @@ function configPathInRepository(root, configPath, top) {
85
91
  return relative;
86
92
  }
87
93
 
94
+ /**
95
+ * ArkRules files at the base ref, relative to the project root inside the repository.
96
+ * A path absent at the ref (added by the candidate) reads as `missing`.
97
+ */
98
+ function baseRefArkRulesReader(root, top, ref) {
99
+ const prefix = path
100
+ .relative(fs.realpathSync(top), fs.realpathSync(root))
101
+ .split(path.sep)
102
+ .join('/');
103
+ const spec = (rel) => `${ref}:${prefix ? `${prefix}/` : ''}${rel}`;
104
+ return {
105
+ readArkRulesFile: (rel) => {
106
+ const shown = runGit(top, ['show', spec(rel)]);
107
+ if (shown.status === 0) return { ok: true, content: shown.stdout };
108
+ const exists = runGit(top, ['cat-file', '-e', spec(rel)]);
109
+ return {
110
+ ok: false,
111
+ missing: exists.status !== 0 && !shown.error,
112
+ message: `Cannot read policy base ArkRules ${spec(rel)}: ${
113
+ shown.stderr?.trim() || shown.error?.message || 'git show failed'
114
+ }`,
115
+ };
116
+ },
117
+ };
118
+ }
119
+
88
120
  export function resolvePolicyBaseConfig({
89
121
  root,
90
122
  configPath,
91
123
  basePath,
92
124
  baseRef,
125
+ adoptMissingBase = false,
93
126
  env = process.env,
94
127
  }) {
95
128
  if (basePath) {
96
129
  const absolute = path.isAbsolute(basePath) ? basePath : path.resolve(root, basePath);
97
- return { config: readJsonFile(absolute, 'Policy base'), source: absolute, ref: null };
130
+ const config = readJsonFile(absolute, 'Policy base');
131
+ // A base config FILE carries its ArkRules next to it (same relative paths as the
132
+ // project), never from the candidate working tree: that would compare the
133
+ // candidate catalog with itself and hide every demotion/deletion.
134
+ return {
135
+ config,
136
+ source: absolute,
137
+ ref: null,
138
+ readArkRulesFile: directoryArkRulesReader(fs.realpathSync(path.dirname(absolute))),
139
+ };
98
140
  }
99
141
 
100
142
  const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
@@ -119,7 +161,8 @@ export function resolvePolicyBaseConfig({
119
161
  // A newly adopted contract has no predecessor to weaken. CI-provided and
120
162
  // auto-discovered bases may therefore omit the config; an explicit CLI ref
121
163
  // remains fail-closed because the caller asked to compare that exact input.
122
- if (refExists.status === 0 && !baseRef) return null;
164
+ // A team base bound by --base / discovery (adoptMissingBase) is not such a request.
165
+ if (refExists.status === 0 && (!baseRef || adoptMissingBase)) return null;
123
166
  if (requestedRef) {
124
167
  throw new Error(
125
168
  `Cannot read policy base ${ref}:${relativeConfig}: ${result.stderr.trim() || 'git show failed'}`
@@ -132,6 +175,7 @@ export function resolvePolicyBaseConfig({
132
175
  config: JSON.parse(result.stdout),
133
176
  source: `git:${ref}:${relativeConfig}`,
134
177
  ref,
178
+ ...baseRefArkRulesReader(root, top, ref),
135
179
  };
136
180
  } catch (error) {
137
181
  throw new Error(
@@ -148,6 +192,119 @@ export function readPolicyAcknowledgement(root, acknowledgementPath) {
148
192
  return readJsonFile(absolute, 'Policy acknowledgement');
149
193
  }
150
194
 
195
+ /**
196
+ * Effective ArkRules of the policy base. A path the base config references but the
197
+ * base ref does not contain yet counts as an empty layer (git refs only). Any other
198
+ * failure is fail-closed: silently dropping the base catalog would make every
199
+ * ArkRule demotion or deletion classify as neutral.
200
+ */
201
+ export function loadBaseArkRules(base) {
202
+ const loaded = loadEffectiveArkRules(base.config, base.readArkRulesFile, {
203
+ missingAsEmpty: base.ref != null,
204
+ });
205
+ if (loaded.errors.length > 0) {
206
+ const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
207
+ const remedy =
208
+ base.ref == null
209
+ ? `\nA --policy-base file reads its ArkRules next to it: copy the base catalog to the same relative paths under ${path.dirname(base.source)}, or compare against a git ref with --policy-base-ref.`
210
+ : '';
211
+ throw new Error(
212
+ `Policy base ArkRules could not be loaded (${base.source}); ArkRules transitions cannot be classified:\n${message}${remedy}`
213
+ );
214
+ }
215
+ return loaded.arkRules;
216
+ }
217
+
218
+ function hasArkRulesMap(config) {
219
+ const refs = config?.arkRules;
220
+ return Boolean(refs && typeof refs === 'object' && Object.keys(refs).length > 0);
221
+ }
222
+
223
+ function loadedOrThrow(loaded, label) {
224
+ if (loaded.errors.length === 0) return loaded.arkRules;
225
+ const message = loaded.errors.map((issue) => `- ${issue.path}: ${issue.message}`).join('\n');
226
+ throw new Error(`${label} ArkRules could not be loaded:\n${message}`);
227
+ }
228
+
229
+ function normalizedArkRulesMap(config) {
230
+ let normalized = config;
231
+ try {
232
+ normalized = loadArkConfigContract(config, 'candidateConfig').config;
233
+ } catch {
234
+ // An invalid candidate is reported by analyzePolicyDelta; compare it as given.
235
+ }
236
+ return stableSerialize(hasArkRulesMap(normalized) ? normalized.arkRules : null);
237
+ }
238
+
239
+ /**
240
+ * True when a supplied `candidateConfig` names the same ArkRules catalog files as the
241
+ * loaded project config, so the candidate catalog is the one on disk. Both sides are
242
+ * normalised first: the project config in memory carries loader defaults (`$schema`,
243
+ * upgraded `schemaVersion`), so passing `ark.config.json` verbatim must still match.
244
+ */
245
+ export function candidateArkRulesMatchProject(candidateConfig, projectConfig) {
246
+ if (candidateConfig === undefined) return true;
247
+ if (!candidateConfig || typeof candidateConfig !== 'object' || Array.isArray(candidateConfig)) {
248
+ return false;
249
+ }
250
+ return normalizedArkRulesMap(candidateConfig) === normalizedArkRulesMap(projectConfig);
251
+ }
252
+
253
+ /**
254
+ * MCP `ark_policy_delta`: Effective ArkRules for both sides of a config transition.
255
+ * Base ArkRules must be SUPPLIED as data (`baseArkRuleFiles`, keyed by the project-
256
+ * relative paths in `baseConfig.arkRules`) whenever the base config maps any: the
257
+ * server only sees the candidate working tree, and reading the base catalog from it
258
+ * would classify every ArkRule demotion or deletion as neutral. The candidate comes
259
+ * from `candidateArkRuleFiles`, or from disk when the candidate is the project config.
260
+ */
261
+ export function resolvePolicyDeltaArkRules({
262
+ root,
263
+ baseConfig,
264
+ candidateConfig,
265
+ candidateIsProjectConfig,
266
+ baseArkRuleFiles,
267
+ candidateArkRuleFiles,
268
+ }) {
269
+ let baseArkRules;
270
+ if (hasArkRulesMap(baseConfig)) {
271
+ if (!baseArkRuleFiles || typeof baseArkRuleFiles !== 'object') {
272
+ throw new Error(
273
+ 'baseConfig maps arkRules, so the ArkRules transition cannot be classified without the base catalog: ' +
274
+ 'pass baseArkRuleFiles as { "<path from baseConfig.arkRules>": <ArkRules file JSON> }.'
275
+ );
276
+ }
277
+ baseArkRules = loadedOrThrow(
278
+ loadEffectiveArkRules(baseConfig, objectArkRulesReader(baseArkRuleFiles)),
279
+ 'Base'
280
+ );
281
+ } else {
282
+ baseArkRules = loadEffectiveArkRules(baseConfig, () => ({ ok: false, message: '' })).arkRules;
283
+ }
284
+
285
+ let candidateArkRules;
286
+ if (candidateArkRuleFiles && typeof candidateArkRuleFiles === 'object') {
287
+ candidateArkRules = loadedOrThrow(
288
+ loadEffectiveArkRules(candidateConfig, objectArkRulesReader(candidateArkRuleFiles)),
289
+ 'Candidate'
290
+ );
291
+ } else if (candidateIsProjectConfig) {
292
+ candidateArkRules = loadedOrThrow(
293
+ loadEffectiveArkRulesFromDisk(root, candidateConfig),
294
+ 'Candidate'
295
+ );
296
+ } else if (hasArkRulesMap(candidateConfig)) {
297
+ throw new Error(
298
+ 'candidateConfig maps different arkRules files than the project contract: pass candidateArkRuleFiles ' +
299
+ 'as { "<path from candidateConfig.arkRules>": <ArkRules file JSON> }.'
300
+ );
301
+ } else {
302
+ candidateArkRules = loadEffectiveArkRules(candidateConfig, () => ({ ok: false, message: '' }))
303
+ .arkRules;
304
+ }
305
+ return { baseArkRules, candidateArkRules };
306
+ }
307
+
151
308
  export function analyzePolicyTransition({
152
309
  root,
153
310
  configPath,
@@ -155,18 +312,21 @@ export function analyzePolicyTransition({
155
312
  strictMerge,
156
313
  basePath,
157
314
  baseRef,
315
+ adoptMissingBase = false,
158
316
  acknowledgementPath,
159
317
  }) {
160
318
  if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
161
- const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef });
162
- if (!base && (basePath || baseRef || acknowledgementPath)) {
319
+ const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef, adoptMissingBase });
320
+ if (!base && (basePath || (baseRef && !adoptMissingBase) || acknowledgementPath)) {
163
321
  throw new Error('Policy delta was requested but no policy base could be resolved.');
164
322
  }
165
323
  if (!base) return undefined;
166
324
 
167
325
  // AR02/AR11: load Effective ArkRules so mode edits inside arkrules/*.json classify,
168
326
  // and attach candidate coverage so covered advisory→enforced can auto-allow.
169
- const baseLoad = loadEffectiveArkRulesFromDisk(root, base.config);
327
+ // Base ArkRules come from the SAME source as the base config (git ref or the
328
+ // base file's directory), never from the candidate working tree.
329
+ const baseArkRules = loadBaseArkRules(base);
170
330
  const candidateLoad = loadEffectiveArkRulesFromDisk(root, candidateConfig);
171
331
  if (candidateLoad.errors.length > 0) {
172
332
  const message = candidateLoad.errors
@@ -174,8 +334,6 @@ export function analyzePolicyTransition({
174
334
  .join('\n');
175
335
  throw new Error(`Invalid candidate Effective Contract:\n${message}`);
176
336
  }
177
- // Base load errors: best-effort empty (base git tree may lack arkrules files on disk).
178
- const baseArkRules = baseLoad.errors.length > 0 ? undefined : baseLoad.arkRules;
179
337
  const candidateArkRules = candidateLoad.arkRules;
180
338
 
181
339
  let candidateInvariantCoverage;
@@ -205,7 +363,7 @@ export function analyzePolicyTransition({
205
363
  acknowledgement,
206
364
  baseSource: base.source,
207
365
  candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
208
- ...(baseArkRules ? { baseArkRules } : {}),
366
+ baseArkRules,
209
367
  candidateArkRules,
210
368
  ...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
211
369
  }),