arkgate 4.6.5 → 4.6.7

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 (51) hide show
  1. package/CHANGELOG.md +72 -2106
  2. package/README.md +11 -9
  3. package/bin/ark-check-runtime.mjs +36 -332
  4. package/bin/ark-mcp-runtime.mjs +7 -323
  5. package/bin/ark-shared.mjs +24 -158
  6. package/bin/ark.mjs +13 -3
  7. package/bin/lib/adoption-stance.mjs +104 -0
  8. package/bin/lib/check-args.mjs +173 -0
  9. package/bin/lib/check-config-detect.mjs +101 -0
  10. package/bin/lib/check-watch.mjs +80 -0
  11. package/bin/lib/ci-merge-boundary.mjs +4 -2
  12. package/bin/lib/deep-module-coach.mjs +3 -0
  13. package/bin/lib/design-delta.mjs +2 -2
  14. package/bin/lib/design-smells.mjs +1 -1
  15. package/bin/lib/diagnostic-catalog.mjs +1 -1
  16. package/bin/lib/doctor-advisories.mjs +2 -2
  17. package/bin/lib/doctor-human.mjs +509 -0
  18. package/bin/lib/doctor-next-actions.mjs +20 -2
  19. package/bin/lib/doctor-plan.mjs +86 -456
  20. package/bin/lib/enforcement-honesty.mjs +70 -0
  21. package/bin/lib/first-run-help.mjs +8 -7
  22. package/bin/lib/github-enforcement.mjs +22 -9
  23. package/bin/lib/html-report-advisories.mjs +10 -2
  24. package/bin/lib/html-report.mjs +26 -9
  25. package/bin/lib/mcp-adoption.mjs +19 -0
  26. package/bin/lib/mcp-hook-payload.mjs +328 -0
  27. package/bin/lib/package-manager.mjs +174 -0
  28. package/bin/lib/policy-delta-io.mjs +5 -1
  29. package/bin/lib/post-green-path.mjs +5 -1
  30. package/bin/lib/product-copy.mjs +6 -3
  31. package/bin/lib/start-preview.mjs +12 -22
  32. package/bin/lib/status-command.mjs +16 -0
  33. package/bin/lib/status-manifest.mjs +8 -2
  34. package/bin/lib/team-parliament-io.mjs +66 -2
  35. package/bin/lib/team-parliament.mjs +25 -5
  36. package/bin/lib/unavailable-analysis.mjs +1 -0
  37. package/dist/index.cjs +2 -2
  38. package/dist/index.d.ts +10 -2
  39. package/dist/index.js +2 -2
  40. package/docs/README.md +6 -10
  41. package/docs/ai-gates.md +12 -5
  42. package/docs/configuration.md +9 -1
  43. package/docs/diagnostics.md +2 -2
  44. package/docs/package-surface.md +6 -4
  45. package/docs/product-voice.md +6 -4
  46. package/docs/threat-model.md +2 -2
  47. package/docs/use.md +5 -4
  48. package/package.json +1 -1
  49. package/schemas/ark.design-delta.schema.json +1 -1
  50. package/server.json +2 -2
  51. package/templates/agent-skills/README.md +1 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * D0 adoption stance — required merge status or explicit advisory-only ack.
3
+ * Tooling I/O. Never invents GitHub required from workflow YAML presence.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ export const ADOPTION_STANCE_REL = '.ark/adoption-stance.json';
9
+ export const ADOPTION_STANCE_VALUE = 'advisory-only';
10
+ export const ADOPTED_REQUIRED_MERGE = 'required-merge';
11
+ export const ADOPTED_ADVISORY_ACKED = 'advisory-only-acked';
12
+ export const ADOPTED_NOT = 'not-adopted';
13
+
14
+ export const NOT_ADOPTED_NEXT_ACTION =
15
+ 'Make arkgate-check --strict-merge a required GitHub status, or write .ark/adoption-stance.json with stance: "advisory-only"';
16
+
17
+ export const MERGE_BOUNDARY_NOT_REQUIRED = 'merge-boundary-not-required';
18
+
19
+ /**
20
+ * @param {string} root
21
+ * @returns {{ schemaVersion?: string, stance?: string, ackedAt?: string, reason?: string } | null}
22
+ */
23
+ export function readAdoptionStance(root) {
24
+ const file = path.join(root, ADOPTION_STANCE_REL);
25
+ if (!fs.existsSync(file)) return null;
26
+ try {
27
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ return parsed && typeof parsed === 'object' ? parsed : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function stanceValue(stance) {
35
+ if (typeof stance === 'string') return stance;
36
+ if (stance && typeof stance === 'object' && typeof stance.stance === 'string') {
37
+ return stance.stance;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Closed enum: required-merge | advisory-only-acked | not-adopted.
44
+ * Workflow presence is never enough.
45
+ *
46
+ * @param {{
47
+ * stance?: { stance?: string } | string | null,
48
+ * github?: { arkCheckRequired?: unknown, requiredStatusConfigured?: unknown },
49
+ * ci?: { state?: string, requiredStatusConfigured?: unknown },
50
+ * }} [input]
51
+ */
52
+ export function classifyAdopted(input = {}) {
53
+ const github = input.github && typeof input.github === 'object' ? input.github : {};
54
+ const ci = input.ci && typeof input.ci === 'object' ? input.ci : {};
55
+ const required =
56
+ github.arkCheckRequired === true ||
57
+ github.requiredStatusConfigured === true ||
58
+ ci.requiredStatusConfigured === true ||
59
+ ci.state === 'required';
60
+ if (required) return ADOPTED_REQUIRED_MERGE;
61
+ if (stanceValue(input.stance) === ADOPTION_STANCE_VALUE) return ADOPTED_ADVISORY_ACKED;
62
+ return ADOPTED_NOT;
63
+ }
64
+
65
+ export function isAdopted(kind) {
66
+ return kind === ADOPTED_REQUIRED_MERGE || kind === ADOPTED_ADVISORY_ACKED;
67
+ }
68
+
69
+ /**
70
+ * Map doctor adoption + writePath into the ci-merge-boundary github input.
71
+ * Never sets requiredStatusConfigured false from a missing GitHub query.
72
+ *
73
+ * @param {object} [adoption]
74
+ * @param {object} [writePath]
75
+ */
76
+ export function githubEvidenceForCiMergeBoundary(adoption, writePath) {
77
+ const github =
78
+ adoption?.enforcement?.github && typeof adoption.enforcement.github === 'object'
79
+ ? adoption.enforcement.github
80
+ : {};
81
+ const ci =
82
+ adoption?.enforcement?.ci && typeof adoption.enforcement.ci === 'object'
83
+ ? adoption.enforcement.ci
84
+ : {};
85
+ const ciMerge =
86
+ writePath?.enforcementState?.ciMerge && typeof writePath.enforcementState.ciMerge === 'object'
87
+ ? writePath.enforcementState.ciMerge
88
+ : {};
89
+ const required =
90
+ github.arkCheckRequired === true ||
91
+ github.requiredStatusConfigured === true ||
92
+ ciMerge.required === true;
93
+ return {
94
+ ...github,
95
+ ...(required ? { arkCheckRequired: true, requiredStatusConfigured: true } : {}),
96
+ workflowPresent:
97
+ github.workflowPresent === true ||
98
+ ci.hasArkCheckWorkflow === true ||
99
+ writePath?.capabilities?.['merge-gate'] === true ||
100
+ writePath?.inventory?.capabilities?.['merge-gate'] === true,
101
+ plan: github.plan,
102
+ canRequire: github.canRequire,
103
+ };
104
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * ark-check CLI flag parsing.
3
+ */
4
+ import path from 'node:path';
5
+ import { discoverLocalBaseRef, normalizePolicyBaseRef } from './policy-delta-io.mjs';
6
+
7
+ export function resolveDesignDeltaBaseRef(root, explicit, env = process.env) {
8
+ const flag = typeof explicit === 'string' ? explicit.trim() : '';
9
+ if (flag) return flag;
10
+ const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
11
+ if (envRef) return envRef;
12
+ const githubBase = typeof env.GITHUB_BASE_REF === 'string' ? env.GITHUB_BASE_REF.trim() : '';
13
+ if (githubBase) return `origin/${githubBase}`;
14
+ return discoverLocalBaseRef(root) || undefined;
15
+ }
16
+
17
+ export function parseArgs(argv) {
18
+ const args = {
19
+ root: process.cwd(),
20
+ config: 'ark.config.json',
21
+ manifest: undefined,
22
+ printConfig: undefined,
23
+ tsconfig: undefined,
24
+ json: false,
25
+ strictConfig: false,
26
+ strictMerge: false,
27
+ requireGates: false,
28
+ requireWriteHook: undefined,
29
+ init: false,
30
+ installAgentGates: false,
31
+ compact: false,
32
+ tools: undefined,
33
+ force: false,
34
+ skillsOnly: false,
35
+ baseline: undefined,
36
+ policyBase: undefined,
37
+ policyBaseRef: undefined,
38
+ policyAck: undefined, failOnNewSmells: false, baseRef: undefined,
39
+ contractSession: false,
40
+ contractDiff: false,
41
+ changed: false,
42
+ against: undefined,
43
+ base: undefined,
44
+ persona: undefined,
45
+ author: undefined,
46
+ failUngoverned: false,
47
+ updateBaseline: false,
48
+ noCache: false,
49
+ resident: false,
50
+ coverage: false,
51
+ migrateCommands: false,
52
+ doctor: false,
53
+ plan: false,
54
+ recommend: false,
55
+ writePlan: false,
56
+ listPolicyPacks: false,
57
+ applyPolicyPack: undefined,
58
+ watch: false,
59
+ beginner: false,
60
+ openReport: false,
61
+ noOpenReport: false,
62
+ version: false,
63
+ help: false,
64
+ all: false,
65
+ followConfigRoot: false,
66
+ };
67
+ const requireValue = (flag, index) => {
68
+ const value = argv[index + 1];
69
+ if (value === undefined || value.startsWith('-')) {
70
+ throw new Error(`Missing value for ${flag}. Run arkgate-check --help for usage.`);
71
+ }
72
+ return value;
73
+ };
74
+ for (let i = 2; i < argv.length; i += 1) {
75
+ const arg = argv[i];
76
+ if (arg === '--json') args.json = true;
77
+ else if (arg === '--strict' || arg === '--strict-merge') {
78
+ args.strictConfig = true;
79
+ args.requireGates = true;
80
+ args.strictMerge = true;
81
+ }
82
+ else if (arg === '--strict-config') args.strictConfig = true;
83
+ else if (arg === '--require-gates') {
84
+ args.requireGates = true;
85
+ args.strictConfig = true;
86
+ }
87
+ else if (arg === '--require-write-hook') {
88
+ args.requireWriteHook = requireValue(arg, i++).trim().toLowerCase();
89
+ }
90
+ else if (arg === '--init') args.init = true;
91
+ else if (arg === '--preset') args.preset = requireValue(arg, i++);
92
+ else if (arg === '--install-agent-gates') args.installAgentGates = true;
93
+ else if (arg === '--compact') args.compact = true;
94
+ else if (arg === '--tools') {
95
+ // Consume the next arg only when it isn't another flag (same rule as --baseline),
96
+ // so `--tools --force` can't silently eat --force as a "tool name".
97
+ const next = argv[i + 1];
98
+ if (next !== undefined && !next.startsWith('-')) {
99
+ i += 1;
100
+ args.tools = next
101
+ .split(',')
102
+ .map((tool) => tool.trim().toLowerCase())
103
+ .filter(Boolean);
104
+ } else {
105
+ args.tools = []; // flag without a value — rejected in runInstallAgentGates
106
+ }
107
+ }
108
+ else if (arg === '--force') args.force = true;
109
+ else if (arg === '--follow-config-root') args.followConfigRoot = true;
110
+ else if (arg === '--skills-only') args.skillsOnly = true;
111
+ else if (arg === '--coverage') args.coverage = true;
112
+ else if (arg === '--doctor') args.doctor = true;
113
+ else if (arg === '--plan') args.plan = true;
114
+ else if (arg === '--rules-inventory') args.rulesInventory = true;
115
+ else if (arg === '--recommend') args.recommend = true;
116
+ else if (arg === '--write-plan') args.writePlan = true;
117
+ else if (arg === '--list-policy-packs') args.listPolicyPacks = true;
118
+ else if (arg === '--apply-policy-pack') args.applyPolicyPack = requireValue(arg, i++);
119
+ else if (arg === '--suggest-include') args.suggestInclude = true;
120
+ else if (arg === '--adopt-contract') args.adoptContract = true;
121
+ else if (arg === '--migrate-contract') args.migrateContract = true;
122
+ else if (arg === '--ratchet-cores') args.ratchetCores = true;
123
+ else if (arg === '--write') args.write = true;
124
+ else if (arg === '--watch') args.watch = true;
125
+ else if (arg === '--beginner') args.beginner = true;
126
+ else if (arg === '--codex-home') args.codexHome = true;
127
+ else if (arg === '--claude-home') args.claudeHome = true;
128
+ else if (arg === '--grok-home') args.grokHome = true;
129
+ else if (arg === '--agent-homes') {
130
+ args.agentHomes = true;
131
+ args.codexHome = true;
132
+ args.claudeHome = true;
133
+ args.grokHome = true;
134
+ }
135
+ else if (arg === '--migrate-commands') args.migrateCommands = true;
136
+ else if (arg === '--no-cache') args.noCache = true;
137
+ else if (arg === '--resident') args.resident = true;
138
+ else if (arg === '--report') {
139
+ const next = argv[i + 1];
140
+ args.report = next && !next.startsWith('-') ? argv[++i] : 'ark-report.html';
141
+ }
142
+ else if (arg === '--reset-origin') args.resetOrigin = true;
143
+ else if (arg === '--no-archive') args.noArchive = true;
144
+ else if (arg === '--open') args.openReport = true;
145
+ else if (arg === '--no-open') args.noOpenReport = true;
146
+ else if (arg === '--baseline' || arg === '--update-baseline') {
147
+ if (arg === '--update-baseline') args.updateBaseline = true;
148
+ // optional path value: consume the next arg only when it isn't another flag
149
+ const next = argv[i + 1];
150
+ args.baseline = next && !next.startsWith('-') ? argv[++i] : '.ark-baseline.json';
151
+ }
152
+ else if (arg === '--policy-base') args.policyBase = requireValue(arg, i++);
153
+ else if (arg === '--policy-base-ref') args.policyBaseRef = requireValue(arg, i++);
154
+ else if (arg === '--policy-ack') args.policyAck = requireValue(arg, i++); else if (arg === '--fail-on-new-smells') args.failOnNewSmells = true; else if (arg === '--base-ref') args.baseRef = requireValue(arg, i++);
155
+ else if (arg === '--contract-session') args.contractSession = true;
156
+ else if (arg === '--contract-diff') args.contractDiff = true;
157
+ else if (arg === '--changed') args.changed = true;
158
+ else if (arg === '--against') args.against = requireValue(arg, i++);
159
+ else if (arg === '--base') args.base = requireValue(arg, i++);
160
+ else if (arg === '--persona') args.persona = requireValue(arg, i++);
161
+ else if (arg === '--author') args.author = requireValue(arg, i++);
162
+ else if (arg === '--root') args.root = path.resolve(requireValue(arg, i++));
163
+ else if (arg === '--config') args.config = requireValue(arg, i++);
164
+ else if (arg === '--manifest') args.manifest = requireValue(arg, i++);
165
+ else if (arg === '--print-config') args.printConfig = requireValue(arg, i++);
166
+ else if (arg === '--tsconfig') args.tsconfig = requireValue(arg, i++);
167
+ else if (arg === '--help' || arg === '-h') args.help = true;
168
+ else if (arg === '--all') args.all = true;
169
+ else if (arg === '--version' || arg === '-V') args.version = true;
170
+ else throw new Error(`Unknown argument: ${arg}. Run arkgate-check --help for usage.`);
171
+ }
172
+ return args;
173
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Convention-based ark.config detection used by --init.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
8
+ DEFAULT_INTENT_PREFIXES,
9
+ DEFAULT_LAYER_DIRECTORIES,
10
+ DEFAULT_RULES,
11
+ } from '../ark-shared.mjs';
12
+ import { normalize, walk } from './scan-files.mjs';
13
+ import { suggestLayerForDir } from './suggestions.mjs';
14
+
15
+ /**
16
+ * Infer an ark.config.json from the directories that actually exist in the project,
17
+ * using the same layer→directory conventions as the eleven-layer template. A directory
18
+ * only counts when it contains at least one source file, so an empty scaffold dir can't
19
+ * produce a layer whose pattern matches nothing (which --strict-config would fail).
20
+ */
21
+ export function detectConfig(root) {
22
+ const srcDir = fs.existsSync(path.join(root, 'src')) ? 'src' : '.';
23
+ const layers = [];
24
+
25
+ for (const entry of DEFAULT_INTENT_PREFIXES) {
26
+ const directories = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? []).filter(
27
+ (directory) => walk(path.join(root, srcDir, directory), [], { root }).length > 0
28
+ );
29
+ if (directories.length === 0) continue;
30
+ layers.push({
31
+ name: entry.layer,
32
+ patterns: directories.map((directory) => `${normalize(path.join(srcDir, directory))}/**`),
33
+ intentPrefixes: entry.prefixes,
34
+ ...(entry.layer === 'DomainModel'
35
+ ? { forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS }
36
+ : {}),
37
+ });
38
+ }
39
+
40
+ const names = new Set(layers.map((layer) => layer.name));
41
+ const rules = DEFAULT_RULES.filter((rule) => names.has(rule.from) && names.has(rule.to));
42
+
43
+ return { srcDir, config: { include: [srcDir], layers, rules } };
44
+ }
45
+
46
+ /** Top-level directories under srcDir not covered by any detected layer pattern. */
47
+ export function uncoveredDirectories(root, srcDir, layers) {
48
+ const base = path.join(root, srcDir);
49
+ if (!fs.existsSync(base)) return [];
50
+ return fs
51
+ .readdirSync(base, { withFileTypes: true })
52
+ .filter(
53
+ (entry) =>
54
+ entry.isDirectory() &&
55
+ entry.name !== 'node_modules' &&
56
+ entry.name !== 'dist' &&
57
+ !entry.name.startsWith('.')
58
+ )
59
+ .map((entry) => entry.name)
60
+ .filter((name) => {
61
+ const prefix = `${normalize(path.join(srcDir, name))}/`;
62
+ return !layers.some((layer) =>
63
+ layer.patterns.some((pattern) => pattern.startsWith(prefix))
64
+ );
65
+ });
66
+ }
67
+
68
+ export function proposeForUncovered(root, srcDir, layers) {
69
+ const proposals = [];
70
+ for (const top of uncoveredDirectories(root, srcDir, layers)) {
71
+ const direct = suggestLayerForDir(top);
72
+ if (direct) {
73
+ proposals.push({ dir: `${srcDir}/${top}`, ...direct });
74
+ continue;
75
+ }
76
+ let children = [];
77
+ try {
78
+ children = fs
79
+ .readdirSync(path.join(root, srcDir, top), { withFileTypes: true })
80
+ .filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
81
+ .map((e) => e.name);
82
+ } catch {
83
+ /* not a readable directory — treat as unrecognized below */
84
+ }
85
+ if (children.length > 0) {
86
+ // Descend: propose per child so a mixed `lib/` yields lib/repositories → Persistence
87
+ // AND flags lib/db as unrecognized, instead of dropping the parts Ark can't place.
88
+ for (const child of children) {
89
+ const hit = suggestLayerForDir(child);
90
+ proposals.push(
91
+ hit
92
+ ? { dir: `${srcDir}/${top}/${child}`, ...hit }
93
+ : { dir: `${srcDir}/${top}/${child}`, unrecognized: true }
94
+ );
95
+ }
96
+ } else {
97
+ proposals.push({ dir: `${srcDir}/${top}`, unrecognized: true });
98
+ }
99
+ }
100
+ return proposals;
101
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * ark-check --watch loop (polling fallback when fs.watch is unavailable).
3
+ */
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ function watchFingerprint(target) {
9
+ const pending = [target];
10
+ const entries = [];
11
+ while (pending.length > 0) {
12
+ const current = pending.pop();
13
+ let stat;
14
+ try {
15
+ stat = fs.statSync(current);
16
+ } catch {
17
+ continue;
18
+ }
19
+ entries.push(`${current}:${stat.mtimeMs}:${stat.size}`);
20
+ if (!stat.isDirectory()) continue;
21
+ try {
22
+ for (const name of fs.readdirSync(current)) pending.push(path.join(current, name));
23
+ } catch {
24
+ // A concurrent delete is represented by the next fingerprint.
25
+ }
26
+ }
27
+ return entries.sort().join('|');
28
+ }
29
+
30
+ function watchByPolling(target, onChange) {
31
+ let previous = watchFingerprint(target);
32
+ setInterval(() => {
33
+ const current = watchFingerprint(target);
34
+ if (current === previous) return;
35
+ previous = current;
36
+ onChange();
37
+ }, 250);
38
+ }
39
+
40
+ export async function runWatchMode(args, { cliPath, loadConfig, dim }) {
41
+ const argv = process.argv.slice(2).filter((token) => token !== '--watch');
42
+ let debounce;
43
+ const rerun = () => {
44
+ clearTimeout(debounce);
45
+ debounce = setTimeout(() => {
46
+ const result = spawnSync(process.execPath, [cliPath, ...argv], {
47
+ cwd: args.root,
48
+ stdio: 'inherit',
49
+ env: process.env,
50
+ });
51
+ process.exitCode = result.status ?? 1;
52
+ }, 300);
53
+ };
54
+
55
+ let config;
56
+ try {
57
+ config = loadConfig(args.root, args.config);
58
+ } catch (error) {
59
+ console.error(error instanceof Error ? error.message : String(error));
60
+ process.exitCode = 2;
61
+ return;
62
+ }
63
+
64
+ for (const entry of config.include ?? []) {
65
+ const target = path.join(args.root, entry);
66
+ if (!fs.existsSync(target)) continue;
67
+ try {
68
+ const watcher = fs.watch(target, { recursive: true }, rerun);
69
+ watcher.on('error', () => {
70
+ watcher.close();
71
+ watchByPolling(target, rerun);
72
+ });
73
+ } catch {
74
+ watchByPolling(target, rerun);
75
+ }
76
+ }
77
+
78
+ console.log(dim('Watching governed paths for changes… (Ctrl+C to stop)'));
79
+ await new Promise(() => {});
80
+ }
@@ -37,9 +37,11 @@ export function buildCiMergeBoundary(input = {}) {
37
37
  const hookFired = Object.values(perHost).some((h) => h.fired);
38
38
  const github = input.github && typeof input.github === 'object' ? input.github : {};
39
39
  const workflowPresent = Boolean(
40
- writePath.capabilities?.['merge-gate'] || writePath.inventory?.capabilities?.['merge-gate']
40
+ github.workflowPresent ||
41
+ writePath.capabilities?.['merge-gate'] ||
42
+ writePath.inventory?.capabilities?.['merge-gate']
41
43
  );
42
- const required = github.requiredStatusConfigured === true;
44
+ const required = github.requiredStatusConfigured === true || github.arkCheckRequired === true;
43
45
  const canRequire = github.canRequire !== false && github.plan !== 'free';
44
46
  let ciState = 'absent';
45
47
  if (workflowPresent && required) ciState = 'required';
@@ -22,6 +22,8 @@ export const HOT_PATH_COMMIT_LIMIT = 200;
22
22
  export const HOT_PATH_LIST_CAP = 8;
23
23
  /** Minimum change hits before a path is “elevated”. */
24
24
  export const HOT_PATH_MIN_HITS = 3;
25
+ /** Kill hung git instead of stalling CI. */
26
+ export const SPAWN_TIMEOUT_MS = 8000;
25
27
 
26
28
  /**
27
29
  * Best-effort recent-churn paths from git history.
@@ -40,6 +42,7 @@ export function computeHotPathAdvisory(root, opts = {}) {
40
42
  encoding: 'utf8',
41
43
  maxBuffer: 8 * 1024 * 1024,
42
44
  stdio: ['ignore', 'pipe', 'pipe'],
45
+ timeout: SPAWN_TIMEOUT_MS,
43
46
  });
44
47
 
45
48
  const emptyUnavailable = (reason) => ({
@@ -1,4 +1,4 @@
1
1
  // Generated from design-delta.source.mjs — run npm run generate:packaged-tooling.
2
- import{spawnSync as R}from"node:child_process";import M from"node:crypto";import N from"node:fs";import b from"node:path";import{layerForFile as E}from"../ark-shared.mjs";import{loadGoldenPattern as C}from"./golden-pattern.mjs";import{collectGovernedFiles as O}from"./scan-files.mjs";const K="1.0",A=Object.freeze(["domain-logic-in-ui"]),x=/\.[cm]?[jt]sx?$/i,j=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,q=/^(can|should|calculate|compute)[A-Z_]|policy/i,W=/^(can|should)[A-Z_]|policy/i,U=/^(calculate|compute)[A-Z_]/i,I=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,z=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,B=new Set(["includes","some","every","has"]);function m(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function w(e){return`sha256:${M.createHash("sha256").update(e,"utf8").digest("hex")}`}function k(e,t){const i=[...t].map(n=>[m(n.path),w(n.content)]).sort(([n],[r])=>n.localeCompare(r));return w(JSON.stringify({config:e,files:i}))}function H(e,t){const i=t.toLowerCase();return i.endsWith(".tsx")?e.ScriptKind.TSX:i.endsWith(".jsx")?e.ScriptKind.JSX:i.endsWith(".js")||i.endsWith(".mjs")||i.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function G(e,t,i){const n=E(e,i,t?.layers??[]);return j.test(i)||/presentation|ui|view/i.test(n??"")}function J(e,t){const i=[],n=r=>{if(e.isFunctionDeclaration(r)&&r.name&&r.body)i.push({name:r.name.text,body:r.body,node:r});else if(e.isVariableDeclaration(r)&&e.isIdentifier(r.name)){const s=r.initializer;s&&(e.isArrowFunction(s)||e.isFunctionExpression(s))&&i.push({name:r.name.text,body:s.body,node:r})}else e.isMethodDeclaration(r)&&r.name&&e.isIdentifier(r.name)&&r.body&&i.push({name:r.name.text,body:r.body,node:r});e.forEachChild(r,n)};return n(t),i}function V(e,t){if(!q.test(t.name)||I.test(t.name))return null;let i=0,n=0,r=0,s=0,o=!1,a=!1;const d=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),f=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),c=l=>{if(e.isJsxElement(l)||e.isJsxSelfClosingElement(l)||e.isJsxFragment(l)){a=!0;return}if(e.isBinaryExpression(l)&&(d.has(l.operatorToken.kind)&&(i+=1),u.has(l.operatorToken.kind)&&(n+=1),f.has(l.operatorToken.kind)&&(r+=1)),e.isCallExpression(l)){let S="";e.isIdentifier(l.expression)?S=l.expression.text:e.isPropertyAccessExpression(l.expression)&&(S=l.expression.name.text),B.has(S)&&(s+=1),(z.test(S)||I.test(S))&&(o=!0)}e.forEachChild(l,c)};if(c(t.body),a||o)return null;const p=i+n+s,g=r;let h,y;if(U.test(t.name)&&g>0)h="calculation-rule",y=g;else if(W.test(t.name)&&p>0)h="authorization-policy-rule",y=p;else return null;return{kind:h,magnitude:y,detail:`comparisons:${i};logical:${n};predicates:${s};arithmetic:${r}`}}function Z(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function v({root:e,config:t,records:i,ts:n,goldenPattern:r}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const s=[];for(const o of[...i].sort((a,d)=>m(a.path).localeCompare(m(d.path)))){const a=m(o.path);if(!x.test(a)||a.endsWith(".d.ts")||!G(e,t,a))continue;const d=n.createSourceFile(a,String(o.content),n.ScriptTarget.Latest,!0,H(n,a));for(const u of J(n,d)){const f=V(n,u);if(!f)continue;const c=`domain-logic-in-ui|${a}|${u.name}|${f.kind}`,p=d.getLineAndCharacterOfPosition(u.node.getStart(d)).line+1;s.push({smellId:"domain-logic-in-ui",fingerprint:w(c),identity:c,evidence:{kind:f.kind,path:a,line:p,symbol:u.name,detail:f.detail,magnitude:f.magnitude},repairHint:Z(r,u.name)})}}return s.sort((o,a)=>o.identity.localeCompare(a.identity))}function _({mode:e,baseIdentity:t,candidateIdentity:i,touchedPaths:n,baseFindings:r,candidateFindings:s}){const o=new Set([...n].map(m)),a=new Map(r.map(c=>[c.identity,c])),d=new Set(r),u=[];let f=0;for(const c of s){let p=a.get(c.identity);if(!p){const y=[...d].filter(l=>l.smellId===c.smellId&&l.evidence.symbol===c.evidence.symbol&&l.evidence.kind===c.evidence.kind);y.length===1&&([p]=y)}p&&d.delete(p);const g=p?.evidence?.magnitude??0,h=c.evidence.magnitude;if(!o.has(c.evidence.path)){p&&(f+=1);continue}p?h>g?u.push({...c,classification:"worsened",baseMagnitude:g,candidateMagnitude:h}):f+=1:u.push({...c,classification:"new",baseMagnitude:0,candidateMagnitude:h})}return{schemaVersion:K,mode:e,complete:!0,valid:u.length===0,base:t,candidate:i,supportedSmellIds:[...A],touchedPaths:[...o].sort(),changes:u,baseFindingCount:r.length,candidateFindingCount:s.length,historicalResidualCount:f}}function D(e,t){const i=[];for(const n of O(e,t)){const r=m(b.relative(e,n));!x.test(r)||r.endsWith(".d.ts")||i.push({path:r,content:N.readFileSync(n,"utf8")})}return i}function Q(e,t,i,n){const r=new Map(i.map(s=>[m(s.path),s.content]));for(const s of n){const o=m(s.path);!x.test(o)||o.endsWith(".d.ts")||!E(e,o,t?.layers??[])||(s.delete===!0?r.delete(o):typeof s.content=="string"&&r.set(o,s.content))}return[...r].map(([s,o])=>({path:s,content:o}))}function oe({root:e,config:t,changes:i,ts:n}){const r=D(e,t),s=Q(e,t,r,i??[]),o=C(e),a=v({root:e,config:t,records:r,ts:n,goldenPattern:o}),d=v({root:e,config:t,records:s,ts:n,goldenPattern:o});return _({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:k(t,r)},candidateIdentity:{kind:"candidate-tree",value:k(t,s)},touchedPaths:(i??[]).map(u=>u.path),baseFindings:a,candidateFindings:d})}function T(e,t,i={}){return R("git",t,{cwd:e,encoding:i.encoding??"utf8",maxBuffer:64*1024*1024,input:i.input})}function P(e,t,i){const n=T(e,t);if(n.status!==0)throw new Error(`${i}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function $(e,t,i){const n=T(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${i}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(m).filter(Boolean)}function L(e,t,i){const n=T(e,["show",`${t}:${i}`]);if(n.status!==0)throw new Error(`base file unavailable (${i}): ${(n.stderr||"").trim()}`);return n.stdout}function F(e,t){return{schemaVersion:K,mode:"git-base",complete:!1,valid:!1,base:{kind:"git-tree",value:String(e||"<missing>")},candidate:{kind:"candidate-tree",value:"<unavailable>"},supportedSmellIds:[...A],touchedPaths:[],changes:[],baseFindingCount:0,candidateFindingCount:0,historicalResidualCount:0,error:t}}function X({root:e,config:t,configPath:i="ark.config.json",baseRef:n,ts:r}){if(typeof n!="string"||!n.trim())return F(n,"--fail-on-new-smells requires --base-ref <git-ref>.");try{if(n.startsWith("-"))throw new Error('base ref must not start with "-".');const s=P(e,["rev-parse","--verify",`${n}^{commit}`],"base ref is unresolvable"),o=P(e,["rev-parse","--verify",`${s}^{tree}`],"base tree is unresolvable"),a=m(b.isAbsolute(i)?b.relative(e,i):i);if(!a||a.startsWith("../"))throw new Error("base config path must be inside the project root.");const d=JSON.parse(L(e,s,a)),f=$(e,["ls-tree","-r","--name-only","-z",s],"base tree listing failed").filter(l=>x.test(l)&&!l.endsWith(".d.ts")).filter(l=>E(e,l,d?.layers??[])).map(l=>({path:l,content:L(e,s,l)})),c=D(e,t),p=[...$(e,["diff","--name-only","-z",s,"--"],"candidate diff failed"),...$(e,["ls-files","--others","--exclude-standard","-z"],"untracked-file scan failed")],g=C(e),h=v({root:e,config:d,records:f,ts:r,goldenPattern:g}),y=v({root:e,config:t,records:c,ts:r,goldenPattern:g});return _({mode:"git-base",baseIdentity:{kind:"git-tree",value:o,commit:s},candidateIdentity:{kind:"candidate-tree",value:k(t,c)},touchedPaths:p,baseFindings:h,candidateFindings:y})}catch(s){return F(n,s instanceof Error?s.message:String(s))}}function Y(e){return e?.complete?e.valid?`Design delta passed: 0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s).`:[`Design delta blocked ${e.changes.length} new/worsened supported smell(s):`,...e.changes.map(t=>`- [${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""} (${t.classification})
2
+ import{spawnSync as O}from"node:child_process";import B from"node:crypto";import j from"node:fs";import w from"node:path";import{layerForFile as T}from"../ark-shared.mjs";import{loadGoldenPattern as I}from"./golden-pattern.mjs";import{collectGovernedFiles as q}from"./scan-files.mjs";const P="1.0",_=Object.freeze(["domain-logic-in-ui"]),b=/\.[cm]?[jt]sx?$/i,W=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,U=/^(can|should|calculate|compute)[A-Z_]|policy/i,z=/^(can|should)[A-Z_]|policy/i,H=/^(calculate|compute)[A-Z_]/i,D=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,G=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,J=new Set(["includes","some","every","has"]);function h(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function $(e){return`sha256:${B.createHash("sha256").update(e,"utf8").digest("hex")}`}function C(e,t){const s=[...t].map(n=>[h(n.path),$(n.content)]).sort(([n],[i])=>n.localeCompare(i));return $(JSON.stringify({config:e,files:s}))}function V(e,t){const s=t.toLowerCase();return s.endsWith(".tsx")?e.ScriptKind.TSX:s.endsWith(".jsx")?e.ScriptKind.JSX:s.endsWith(".js")||s.endsWith(".mjs")||s.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function Z(e,t,s){const n=T(e,s,t?.layers??[]);return W.test(s)||/presentation|ui|view/i.test(n??"")}function Q(e,t){const s=[],n=i=>{if(e.isFunctionDeclaration(i)&&i.name&&i.body)s.push({name:i.name.text,body:i.body,node:i});else if(e.isVariableDeclaration(i)&&e.isIdentifier(i.name)){const a=i.initializer;a&&(e.isArrowFunction(a)||e.isFunctionExpression(a))&&s.push({name:i.name.text,body:a.body,node:i})}else e.isMethodDeclaration(i)&&i.name&&e.isIdentifier(i.name)&&i.body&&s.push({name:i.name.text,body:i.body,node:i});e.forEachChild(i,n)};return n(t),s}function X(e,t){if(!U.test(t.name)||D.test(t.name))return null;let s=0,n=0,i=0,a=0,r=!1,o=!1;const l=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),p=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),f=c=>{if(e.isJsxElement(c)||e.isJsxSelfClosingElement(c)||e.isJsxFragment(c)){o=!0;return}if(e.isBinaryExpression(c)&&(l.has(c.operatorToken.kind)&&(s+=1),u.has(c.operatorToken.kind)&&(n+=1),p.has(c.operatorToken.kind)&&(i+=1)),e.isCallExpression(c)){let g="";e.isIdentifier(c.expression)?g=c.expression.text:e.isPropertyAccessExpression(c.expression)&&(g=c.expression.name.text),J.has(g)&&(a+=1),(G.test(g)||D.test(g))&&(r=!0)}e.forEachChild(c,f)};if(f(t.body),o||r)return null;const m=s+n+a,v=i;let S,d;if(H.test(t.name)&&v>0)S="calculation-rule",d=v;else if(z.test(t.name)&&m>0)S="authorization-policy-rule",d=m;else return null;return{kind:S,magnitude:d,detail:`comparisons:${s};logical:${n};predicates:${a};arithmetic:${i}`}}function Y(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function E({root:e,config:t,records:s,ts:n,goldenPattern:i}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const a=[];for(const r of[...s].sort((o,l)=>h(o.path).localeCompare(h(l.path)))){const o=h(r.path);if(!b.test(o)||o.endsWith(".d.ts")||!Z(e,t,o))continue;const l=n.createSourceFile(o,String(r.content),n.ScriptTarget.Latest,!0,V(n,o));for(const u of Q(n,l)){const p=X(n,u);if(!p)continue;const f=`domain-logic-in-ui|${o}|${u.name}|${p.kind}`,m=l.getLineAndCharacterOfPosition(u.node.getStart(l)).line+1;a.push({smellId:"domain-logic-in-ui",fingerprint:$(f),identity:f,evidence:{kind:p.kind,path:o,line:m,symbol:u.name,detail:p.detail,magnitude:p.magnitude},repairHint:Y(i,u.name)})}}return a.sort((r,o)=>r.identity.localeCompare(o.identity))}function L({mode:e,baseIdentity:t,candidateIdentity:s,touchedPaths:n,baseFindings:i,candidateFindings:a,createdPathsOnly:r=!1,baseTreePaths:o=[]}){const l=new Set([...n].map(h)),u=new Set([...o].map(h)),p=new Map(i.map(d=>[d.identity,d])),f=new Set(i),m=[];let v=0;for(const d of a){let c=p.get(d.identity);if(!c){const y=[...f].filter(k=>k.smellId===d.smellId&&k.evidence.symbol===d.evidence.symbol&&k.evidence.kind===d.evidence.kind);y.length===1&&([c]=y)}c&&f.delete(c);const g=c?.evidence?.magnitude??0,x=d.evidence.magnitude;if(!l.has(d.evidence.path)){c&&(v+=1);continue}c?x>g?m.push({...d,classification:"worsened",baseMagnitude:g,candidateMagnitude:x}):v+=1:m.push({...d,classification:"new",baseMagnitude:0,candidateMagnitude:x})}const S=r?m.filter(d=>d.classification==="new"&&!u.has(h(d.evidence.path))):m;return{schemaVersion:P,mode:e,complete:!0,valid:S.length===0,base:t,candidate:s,supportedSmellIds:[..._],touchedPaths:[...l].sort(),changes:S,baseFindingCount:i.length,candidateFindingCount:a.length,historicalResidualCount:v,enforcementScope:r?"created-paths":"touched-new-or-worsened"}}function F(e,t){const s=[];for(const n of q(e,t)){const i=h(w.relative(e,n));!b.test(i)||i.endsWith(".d.ts")||s.push({path:i,content:j.readFileSync(n,"utf8")})}return s}function ee(e,t,s,n){const i=new Map(s.map(a=>[h(a.path),a.content]));for(const a of n){const r=h(a.path);!b.test(r)||r.endsWith(".d.ts")||!T(e,r,t?.layers??[])||(a.delete===!0?i.delete(r):typeof a.content=="string"&&i.set(r,a.content))}return[...i].map(([a,r])=>({path:a,content:r}))}function de({root:e,config:t,changes:s,ts:n}){const i=F(e,t),a=ee(e,t,i,s??[]),r=I(e),o=E({root:e,config:t,records:i,ts:n,goldenPattern:r}),l=E({root:e,config:t,records:a,ts:n,goldenPattern:r});return L({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:C(t,i)},candidateIdentity:{kind:"candidate-tree",value:C(t,a)},touchedPaths:(s??[]).map(u=>u.path),baseFindings:o,candidateFindings:l})}function K(e,t,s={}){return O("git",t,{cwd:e,encoding:s.encoding??"utf8",maxBuffer:64*1024*1024,input:s.input})}function M(e,t,s){const n=K(e,t);if(n.status!==0)throw new Error(`${s}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function A(e,t,s){const n=K(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${s}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(h).filter(Boolean)}function R(e,t,s){const n=K(e,["show",`${t}:${s}`]);if(n.status!==0)throw new Error(`base file unavailable (${s}): ${(n.stderr||"").trim()}`);return n.stdout}function N(e,t){return{schemaVersion:P,mode:"git-base",complete:!1,valid:!1,base:{kind:"git-tree",value:String(e||"<missing>")},candidate:{kind:"candidate-tree",value:"<unavailable>"},supportedSmellIds:[..._],touchedPaths:[],changes:[],baseFindingCount:0,candidateFindingCount:0,historicalResidualCount:0,error:t}}function te({root:e,config:t,configPath:s="ark.config.json",baseRef:n,ts:i,createdPathsOnly:a=!1,missingBase:r="fail-closed"}){const o=r==="skip";if(typeof n!="string"||!n.trim())return N(n,o?"design-delta skipped: no resolvable Git base ref.":"--fail-on-new-smells requires --base-ref <git-ref>.");try{if(n.startsWith("-"))throw new Error('base ref must not start with "-".');const l=M(e,["rev-parse","--verify",`${n}^{commit}`],"base ref is unresolvable"),u=M(e,["rev-parse","--verify",`${l}^{tree}`],"base tree is unresolvable"),p=h(w.isAbsolute(s)?w.relative(e,s):s);if(!p||p.startsWith("../"))throw new Error("base config path must be inside the project root.");const f=JSON.parse(R(e,l,p)),m=A(e,["ls-tree","-r","--name-only","-z",l],"base tree listing failed").filter(y=>b.test(y)&&!y.endsWith(".d.ts")),v=m.filter(y=>T(e,y,f?.layers??[])).map(y=>({path:y,content:R(e,l,y)})),S=F(e,t),d=[...A(e,["diff","--name-only","-z",l,"--"],"candidate diff failed"),...A(e,["ls-files","--others","--exclude-standard","-z"],"untracked-file scan failed")],c=I(e),g=E({root:e,config:f,records:v,ts:i,goldenPattern:c}),x=E({root:e,config:t,records:S,ts:i,goldenPattern:c});return L({mode:"git-base",baseIdentity:{kind:"git-tree",value:u,commit:l},candidateIdentity:{kind:"candidate-tree",value:C(t,S)},touchedPaths:d,baseFindings:g,candidateFindings:x,createdPathsOnly:!!a,baseTreePaths:m})}catch(l){return N(n,l instanceof Error?l.message:String(l))}}function ne(e){return e?.complete?e.valid?`Design delta passed: 0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s).`:[`Design delta blocked ${e.changes.length} new/worsened supported smell(s):`,...e.changes.map(t=>`- [${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""} (${t.classification})
3
3
  Next action: ${t.repairHint}`)].join(`
4
- `):`Design delta unavailable: ${e?.error||"unknown error"}`}function le({enabled:e,...t}){const i=e?X(t):null;return{result:i,combineEdges:({activeViolationCount:n,strictConfig:r,strictWarningCount:s,policyValid:o})=>{const a=n===0&&(!r||s===0)&&o;return{edgeValid:a,observedOk:a&&(i?.valid??!0)}},exitCode:n=>i&&!i.complete?2:n,failureText:()=>i&&!i.valid?Y(i):null}}function ce(e){return e?e.complete?e.valid?[{level:"ok",text:`0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s)`}]:[{level:"bad",text:`${e.changes.length} new/worsened supported smell(s) block this candidate`},...e.changes.slice(0,5).flatMap(t=>[{level:"plain",text:`[${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""}`},{level:"dim",text:`fix: ${t.repairHint}`}])]:[{level:"bad",text:`Unavailable \u2014 ${e.error||"base/candidate evidence incomplete"}`}]:[]}export{K as DESIGN_DELTA_SCHEMA_VERSION,A as DESIGN_DELTA_SUPPORTED_SMELLS,v as analyzeDesignFindings,le as createDesignDeltaCheck,ce as designDeltaDoctorLines,X as evaluateGitDesignDelta,oe as evaluateWriteDesignDelta,Y as formatDesignDeltaBlock};
4
+ `):`Design delta unavailable: ${e?.error||"unknown error"}`}function ue({enabled:e,createdPathsOnly:t,missingBase:s,...n}){const i=s==="skip",a=e?te({...n,createdPathsOnly:!!t,missingBase:i?"skip":"fail-closed"}):null,r=a&&i&&!a.complete?null:a;return{result:r,combineEdges:({activeViolationCount:o,strictConfig:l,strictWarningCount:u,policyValid:p})=>{const f=o===0&&(!l||u===0)&&p;return{edgeValid:f,observedOk:f&&(r?.valid??!0)}},exitCode:o=>r&&!r.complete?2:o,failureText:()=>r&&!r.valid?ne(r):null}}function pe(e){return e?e.complete?e.valid?[{level:"ok",text:`0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s)`}]:[{level:"bad",text:`${e.changes.length} new/worsened supported smell(s) block this candidate`},...e.changes.slice(0,5).flatMap(t=>[{level:"plain",text:`[${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""}`},{level:"dim",text:`fix: ${t.repairHint}`}])]:[{level:"bad",text:`Unavailable \u2014 ${e.error||"base/candidate evidence incomplete"}`}]:[]}export{P as DESIGN_DELTA_SCHEMA_VERSION,_ as DESIGN_DELTA_SUPPORTED_SMELLS,E as analyzeDesignFindings,ue as createDesignDeltaCheck,pe as designDeltaDoctorLines,te as evaluateGitDesignDelta,de as evaluateWriteDesignDelta,ne as formatDesignDeltaBlock};
@@ -483,7 +483,7 @@ export function summarizeDesignFitness(smells, ctx = {}) {
483
483
  smellCount: Array.isArray(smells) ? smells.length : 0,
484
484
  ids: (smells || []).map((s) => s.id),
485
485
  label: designWeak
486
- ? `${operatingModeTitle(ctx.operatingMode, true)} — import rules check out; leftover design work remains (see designSmells / plan B)`
486
+ ? `${operatingModeTitle(ctx.operatingMode, true)} — import rules check out; leftover design work remains`
487
487
  : smells.length > 0
488
488
  ? 'Design smells present alongside open import-rule debt'
489
489
  : 'No deterministic design smells detected',
@@ -67,7 +67,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
67
67
  entry('CANDIDATE_CONTENT_HASH_MISMATCH', 'preflight', 'Candidate content hash mismatch', 'The candidate file content hash does not match the hash expected for the declared change.', 'Rebuild candidate facts from the exact proposed content, then preflight again.'),
68
68
  entry('UNDECLARED_CANDIDATE_CHANGE', 'preflight', 'Undeclared candidate change', 'Candidate facts differ from base for a path that was not listed in the explicit change set.', 'Declare every path that changes in the atomic change set, then preflight again.'),
69
69
  entry('ATOMIC_PREFLIGHT_UNAVAILABLE', 'preflight', 'Atomic preflight unavailable', 'The host/MCP path could not run the atomic preflight engine (missing facts, incomplete setup, or unsupported mode).', 'Use resolved-candidate facts / ark_prepare_change with a complete batch, or fall back to ark-check on disk. Do not treat missing preflight as green.'),
70
- entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces or worsens a blocking design-smell class (e.g. domain-logic-in-ui) under --fail-on-new-smells.', 'Revert the regression or redesign so the smell does not worsen versus base, then re-run with the same base ref.'),
70
+ entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces a created-path domain-logic-in-ui file under --strict-merge, or introduces or worsens a blocking design-smell class under --fail-on-new-smells.', 'Move the new UI business rule out of the created file (or revert a --fail-on-new-smells regression), then re-run with the same base ref.'),
71
71
  // ── analysis completeness / host ─────────────────────────────────────────
72
72
  entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; evidence includes the TypeScript diagnostic (line + message). Incremental mid-edit parse is normal for agents. Contract exclude paths skip the write hook.', 'Finish the source or fix the reported syntax error, then re-run `npx arkgate-check`. The write hook does not deny solely on mid-edit parse. Partial never means pass.'),
73
73
  entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'Single-file validation cannot prove project module resolution. The write hook is already the verdict.', 'Re-run `npx arkgate-check --root . --config ark.config.json`, or treat the hook deny as final. Do not call ark_prepare_change from a hook deny.'),
@@ -73,9 +73,9 @@ export function printDoctorAdvisories(advisories, io) {
73
73
  printParseHealthSection(advisories.parseHealth, io);
74
74
  printGraphBlindSection(advisories.graphBlindSpots, io);
75
75
  const nudge = advisories.stewardNudge;
76
- if ((nudge?.needsStewards || nudge?.drift) && nudge.ask) {
76
+ if ((nudge?.needsStewards || nudge?.drift || nudge?.emptyStewardsPastGrace) && nudge.ask) {
77
77
  console.log('');
78
- console.log(io.color.bold('Stewards (advisory)'));
78
+ console.log(io.color.bold('Stewards'));
79
79
  io.line(io.warn, nudge.ask);
80
80
  if (nudge.nextAction) io.line(' ', io.color.dim(`Next: ${nudge.nextAction}`));
81
81
  }