arkgate 4.8.22 → 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.
- package/CHANGELOG.md +91 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +27 -78
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +1117 -38
- package/bin/ark-mcp-runtime.mjs +412 -138
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +39 -0
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/agent-projection-command.mjs +5 -1
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +50 -6
- package/bin/lib/ark-order-doctor.mjs +1 -1
- package/bin/lib/ark-order-facts.mjs +410 -15
- package/bin/lib/ark-order-invariants.mjs +51 -9
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +2 -0
- package/bin/lib/ark-run-doctor.mjs +14 -0
- package/bin/lib/ark-run-facts.mjs +434 -11
- package/bin/lib/ark-run-sensors.mjs +5 -3
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrules-sensors.mjs +50 -249
- package/bin/lib/check-args.mjs +23 -1
- package/bin/lib/ci-and-commands.mjs +16 -6
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/codex-home.mjs +4 -2
- package/bin/lib/config-contract-slices.mjs +701 -0
- package/bin/lib/config-contract.mjs +5 -2
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/diagnostic-catalog.mjs +45 -6
- package/bin/lib/doctor-advisories.mjs +84 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +7 -9
- package/bin/lib/effective-contract-load.mjs +121 -59
- package/bin/lib/enforcement-honesty.mjs +22 -0
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/first-run-help.mjs +3 -2
- package/bin/lib/flat-parent-importers.mjs +137 -0
- package/bin/lib/flat-parent-pilot.mjs +192 -0
- package/bin/lib/gate-files.mjs +29 -2
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +1 -1
- package/bin/lib/hook-templates.mjs +18 -16
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +2 -0
- package/bin/lib/html-report-depth.mjs +3 -1
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage.mjs +59 -11
- package/bin/lib/mcp-adoption.mjs +9 -1
- package/bin/lib/mcp-hook-payload.mjs +401 -61
- package/bin/lib/package-manager.mjs +59 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +81 -0
- package/bin/lib/physical-cohesion.mjs +16 -18
- package/bin/lib/pilot-loop.mjs +21 -8
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +168 -10
- package/bin/lib/prepare-change.mjs +120 -6
- package/bin/lib/prepare-write.mjs +1 -0
- package/bin/lib/presets.mjs +3 -5
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +315 -55
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +179 -5
- package/bin/lib/scan-files.mjs +32 -0
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/stable-hash.mjs +90 -0
- package/bin/lib/start-install-recovery.mjs +71 -1
- package/bin/lib/start-preview.mjs +30 -0
- package/bin/lib/status-command.mjs +33 -4
- package/bin/lib/status-manifest.mjs +32 -4
- package/bin/lib/team-parliament-io.mjs +79 -75
- package/bin/lib/team-parliament.mjs +6 -1
- package/bin/lib/write-path-capabilities.mjs +4 -2
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-MBXCFLO4.js +16 -0
- package/dist/chunk-RNQHRDCQ.cjs +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
- package/dist/configTypes-BJbaeoj4.d.ts +246 -0
- package/dist/eslint/index.cjs +17 -4
- package/dist/eslint/index.d.cts +359 -0
- package/dist/eslint/index.d.ts +178 -158
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +38 -37
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3408 -296
- package/dist/index.js +38 -37
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +92 -15
- package/docs/ai-gates.md +160 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +273 -12
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +122 -13
- package/docs/package-surface.md +31 -8
- package/docs/product-voice.md +1 -1
- package/package.json +59 -18
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +163 -4
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +4 -1
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-runtime.md +6 -1
- package/dist/types-fQ9-KrBj.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
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* as W01 layer roles: a miss costs a warning line, never a verdict.
|
|
16
16
|
*/
|
|
17
17
|
import path from 'node:path';
|
|
18
|
-
import { layerForRelativePath,
|
|
18
|
+
import { layerForRelativePath, resolveGovernedSlice } from '../ark-layer-match.mjs';
|
|
19
19
|
|
|
20
20
|
/** ADR 0010 D3 — corpus-calibrated, fixed. */
|
|
21
21
|
const CLUSTER_MIN = 40;
|
|
@@ -135,23 +135,21 @@ function anchorIsConsolidationSubtree(anchor, concept) {
|
|
|
135
135
|
return String(anchor).split('/').some((part) => part === concept);
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Layer must match. Universe id and child id must match on every rule, using
|
|
140
|
+
* the same resolver as the walls (`sliceIdentity` included). A missing child
|
|
141
|
+
* id is universe common. Two children of one universe do not keep the slice.
|
|
142
|
+
*/
|
|
143
|
+
export function destinationKeepsLayerAndSlice(fromRel, toRel, layers, rules) {
|
|
144
|
+
if (layerForRelativePath(fromRel, layers) !== layerForRelativePath(toRel, layers)) return false;
|
|
141
145
|
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
146
|
+
if (!rule || typeof rule !== 'object') continue;
|
|
147
|
+
const from = resolveGovernedSlice(fromRel, rule);
|
|
148
|
+
const to = resolveGovernedSlice(toRel, rule);
|
|
149
|
+
if ((from.universeId ?? '') !== (to.universeId ?? '')) return false;
|
|
150
|
+
if ((from.childId ?? '') !== (to.childId ?? '')) return false;
|
|
148
151
|
}
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function destinationKeepsLayerAndSlice(fromRel, toRel, layers, sliceFolders) {
|
|
153
|
-
return layerForRelativePath(fromRel, layers) === layerForRelativePath(toRel, layers)
|
|
154
|
-
&& sliceIdForPath(fromRel, sliceFolders) === sliceIdForPath(toRel, sliceFolders);
|
|
152
|
+
return true;
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
function withheldPilot(concept, note) {
|
|
@@ -168,7 +166,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
|
|
|
168
166
|
const top = cohesion?.findings?.[0];
|
|
169
167
|
if (!top) return null;
|
|
170
168
|
const layers = contract?.layers;
|
|
171
|
-
const
|
|
169
|
+
const rules = Array.isArray(contract?.rules) ? contract.rules : [];
|
|
172
170
|
// Full anchor map: the finding's anchors are display-filtered (>= MIRROR_MIN).
|
|
173
171
|
const byAnchor = new Map();
|
|
174
172
|
const relOf = (abs) => path.relative(root, abs).split(path.sep).join('/');
|
|
@@ -193,7 +191,7 @@ export function computeReshapePilot(cohesion, files, root, contract) {
|
|
|
193
191
|
const ranked = classifyPhysical(rel);
|
|
194
192
|
if (!ranked || ranked.concept !== top.concept || ranked.anchor !== anchor.path) return false;
|
|
195
193
|
const to = `${targetDir}/${rel.split('/').at(-1)}`;
|
|
196
|
-
return destinationKeepsLayerAndSlice(rel, to, layers,
|
|
194
|
+
return destinationKeepsLayerAndSlice(rel, to, layers, rules);
|
|
197
195
|
}).sort().map((rel) => ({ from: rel, to: `${targetDir}/${rel.split('/').at(-1)}` }));
|
|
198
196
|
if (legal.length > 0) legalAnchors.push({ ...anchor, targetDir, legal });
|
|
199
197
|
}
|
package/bin/lib/pilot-loop.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildPatternBetsFromSmells,
|
|
10
10
|
isNonProductionPilotPath,
|
|
11
11
|
} from './design-smells.mjs';
|
|
12
|
+
import { flatParentCandidates } from './flat-parent-pilot.mjs';
|
|
12
13
|
|
|
13
14
|
/** Stable product id for JSON / tests. */
|
|
14
15
|
export const PILOT_LOOP_ID = 'one-pilot-redoctor';
|
|
@@ -181,7 +182,7 @@ export function formatExtractionCard(card) {
|
|
|
181
182
|
* One proposed pilot. The loop does not know designWeak or which sensor
|
|
182
183
|
* produced the card — only this list.
|
|
183
184
|
*
|
|
184
|
-
* @typedef {'pattern-bet' | 'reshape'} PilotSource
|
|
185
|
+
* @typedef {'pattern-bet' | 'reshape' | 'flat-parent'} PilotSource
|
|
185
186
|
* @typedef {{
|
|
186
187
|
* source: PilotSource,
|
|
187
188
|
* target: string,
|
|
@@ -243,10 +244,15 @@ function extractionCardFromCandidate(candidate) {
|
|
|
243
244
|
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
244
245
|
.slice(0, 8);
|
|
245
246
|
const reshape = candidate.source === 'reshape';
|
|
247
|
+
const flatParent = candidate.source === 'flat-parent';
|
|
246
248
|
return {
|
|
247
249
|
id: PILOT_LOOP_ID,
|
|
248
|
-
patternBetId:
|
|
249
|
-
|
|
250
|
+
patternBetId: flatParent
|
|
251
|
+
? `flat-parent:${candidate.target}`
|
|
252
|
+
: reshape
|
|
253
|
+
? `reshape:${candidate.target}`
|
|
254
|
+
: `pattern-b:${candidate.target}`,
|
|
255
|
+
smellId: flatParent ? 'flat-parent' : reshape ? 'physical-cohesion' : 'unknown',
|
|
250
256
|
pilot: candidate.target,
|
|
251
257
|
pilotTarget: candidate.target,
|
|
252
258
|
evidence,
|
|
@@ -262,9 +268,11 @@ function extractionCardFromCandidate(candidate) {
|
|
|
262
268
|
loopStep: 'one-pilot',
|
|
263
269
|
reDoctor: 'ark-check --doctor --json',
|
|
264
270
|
rePlan: 'ark-check --plan --json',
|
|
265
|
-
next:
|
|
266
|
-
? '/ark-loop (one
|
|
267
|
-
:
|
|
271
|
+
next: flatParent
|
|
272
|
+
? '/ark-loop (one flat-parent suggestion) | re-doctor after the move'
|
|
273
|
+
: reshape
|
|
274
|
+
? '/ark-loop (one reshape pilot) | re-doctor after pilot'
|
|
275
|
+
: '/ark-autopilot (one cluster / one B pilot) | re-doctor after pilot',
|
|
268
276
|
};
|
|
269
277
|
}
|
|
270
278
|
|
|
@@ -272,8 +280,10 @@ function extractionCardFromCandidate(candidate) {
|
|
|
272
280
|
* Normalize design-weak pattern bets and a proposed reshape pilot into one list.
|
|
273
281
|
* Pattern bets stay in selector rank (best first) and are omitted unless
|
|
274
282
|
* design fitness is design-weak, so that path keeps today's card. A reshape
|
|
275
|
-
* card is appended when physical cohesion proposes one.
|
|
276
|
-
*
|
|
283
|
+
* card is appended when physical cohesion proposes one. A flat-parent move
|
|
284
|
+
* is appended after that: one file with one importer waits behind a card the
|
|
285
|
+
* loop already promised. Doctor builds this list after advisories, so the
|
|
286
|
+
* loop never depends on call order.
|
|
277
287
|
*
|
|
278
288
|
* @param {{
|
|
279
289
|
* designWeak?: boolean,
|
|
@@ -317,6 +327,9 @@ export function collectPilotCandidates(designFitness = {}, advisories = {}) {
|
|
|
317
327
|
...(Array.isArray(card.doNot) ? { doNot: card.doNot } : {}),
|
|
318
328
|
});
|
|
319
329
|
}
|
|
330
|
+
for (const candidate of flatParentCandidates(advisories?.flatParentPilot)) {
|
|
331
|
+
candidates.push(candidate);
|
|
332
|
+
}
|
|
320
333
|
return candidates;
|
|
321
334
|
}
|
|
322
335
|
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
+
baseArkRules,
|
|
209
367
|
candidateArkRules,
|
|
210
368
|
...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
|
|
211
369
|
}),
|