arkgate 4.1.0 → 4.2.0

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 (63) hide show
  1. package/CHANGELOG.md +124 -2
  2. package/README.md +24 -14
  3. package/bin/ark-check-runtime.mjs +16 -5
  4. package/bin/ark-mcp-runtime.mjs +766 -64
  5. package/bin/lib/agent-gates.mjs +1 -0
  6. package/bin/lib/ark-gitignore.mjs +88 -0
  7. package/bin/lib/ci-and-commands.mjs +33 -8
  8. package/bin/lib/codex-home.mjs +90 -8
  9. package/bin/lib/design-smells.mjs +71 -9
  10. package/bin/lib/doctor-plan.mjs +47 -39
  11. package/bin/lib/effective-contract-load.mjs +73 -9
  12. package/bin/lib/enforcement-honesty.mjs +78 -22
  13. package/bin/lib/enforcement-state.mjs +1 -1
  14. package/bin/lib/gate-files.mjs +441 -9
  15. package/bin/lib/github-enforcement.mjs +168 -7
  16. package/bin/lib/hook-templates.mjs +12 -11
  17. package/bin/lib/host-support-matrix.mjs +91 -17
  18. package/bin/lib/html-report-depth.mjs +13 -2
  19. package/bin/lib/html-report-evolution.mjs +114 -0
  20. package/bin/lib/html-report.mjs +18 -97
  21. package/bin/lib/import-resolve.mjs +33 -11
  22. package/bin/lib/install-activation.mjs +87 -0
  23. package/bin/lib/install-migrate.mjs +66 -50
  24. package/bin/lib/managed-upgrade.mjs +10 -41
  25. package/bin/lib/mcp-adoption.mjs +15 -5
  26. package/bin/lib/pilot-loop.mjs +25 -8
  27. package/bin/lib/project-identity.mjs +103 -0
  28. package/bin/lib/report-snapshot-context.mjs +28 -0
  29. package/bin/lib/resident-hook.mjs +33 -9
  30. package/bin/lib/rules-inventory.mjs +100 -8
  31. package/bin/lib/skill-install.mjs +272 -22
  32. package/bin/lib/skill-write.mjs +899 -0
  33. package/bin/lib/start-preview.mjs +84 -1
  34. package/bin/lib/upgrade-command.mjs +2 -5
  35. package/bin/lib/write-path-detect.mjs +2 -2
  36. package/dist/index.cjs +13 -13
  37. package/dist/index.d.ts +194 -2
  38. package/dist/index.js +13 -13
  39. package/docs/README.md +6 -4
  40. package/docs/agent-guide.md +115 -17
  41. package/docs/ai-gates.md +133 -25
  42. package/docs/assets/ark-write-gate.svg +2 -2
  43. package/docs/develop.md +16 -6
  44. package/docs/enthusiast/how-to-agent-gates.md +6 -0
  45. package/docs/package-surface.md +16 -9
  46. package/docs/product-voice.md +22 -4
  47. package/docs/use.md +3 -1
  48. package/package.json +3 -1
  49. package/schemas/ark.project-identity.schema.json +116 -0
  50. package/server.json +2 -2
  51. package/templates/skills/ark-adopt.md +9 -0
  52. package/templates/skills/ark-architect.md +12 -2
  53. package/templates/skills/ark-autopilot.md +9 -0
  54. package/templates/skills/ark-contract.md +11 -1
  55. package/templates/skills/ark-coverage.md +9 -0
  56. package/templates/skills/ark-explain.md +13 -1
  57. package/templates/skills/ark-explore.md +9 -0
  58. package/templates/skills/ark-fix.md +10 -1
  59. package/templates/skills/ark-loop.md +11 -2
  60. package/templates/skills/ark-place.md +17 -6
  61. package/templates/skills/ark-runtime.md +8 -0
  62. package/templates/skills/ark-think.md +14 -2
  63. package/templates/skills/ark-upgrade.md +9 -0
@@ -16,7 +16,7 @@ import { detectWritePathCapabilities } from './write-path-detect.mjs';
16
16
  import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
17
17
  import { detectDeployPathQuality } from './deploy-path.mjs';
18
18
  import { collectWeakestLinkGaps } from './weakest-link.mjs';
19
- import { withCiProviderEvidence } from './enforcement-state.mjs';
19
+ import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';
20
20
 
21
21
  export { detectDeployPathQuality };
22
22
 
@@ -120,7 +120,7 @@ export function brokenMcpGateFiles(root) {
120
120
 
121
121
  /**
122
122
  * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
123
- * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
123
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object, runtimeActivation: object }}
124
124
  */
125
125
  export function collectAdoptionGaps(root, config, coverage) {
126
126
  const gaps = [];
@@ -259,7 +259,11 @@ export function collectAdoptionGaps(root, config, coverage) {
259
259
  return false;
260
260
  }
261
261
  })();
262
- if (adopted && !isProducer && !codexProjectMcp) {
262
+ const runtimeActivation = codexRuntimeActivation({
263
+ configuredOnDisk: codexProjectMcp,
264
+ restartRequired: codexProjectMcp,
265
+ });
266
+ if (adopted && !isProducer) {
263
267
  const codexFile = codexConfigPath();
264
268
  let toml = '';
265
269
  try {
@@ -278,6 +282,8 @@ export function collectAdoptionGaps(root, config, coverage) {
278
282
  needsRewrite: assessed.needsRewrite,
279
283
  multiProject: assessed.multiProject,
280
284
  scopedTable: assessed.scopedTable,
285
+ projectConfiguredOnDisk: codexProjectMcp,
286
+ runtimeIdentityVerified: false,
281
287
  };
282
288
  if (assessed.gap) {
283
289
  // Temp/upgrade MCP roots stay urgent (fail-closed). Non-temp Codex-home debt
@@ -288,9 +294,12 @@ export function collectAdoptionGaps(root, config, coverage) {
288
294
  const deferred =
289
295
  !tempUrgent && activeHost != null && activeHost !== 'codex';
290
296
  const severity = deferred ? 'info' : assessed.gap.severity;
297
+ const localRisk = codexProjectMcp
298
+ ? 'Project config exists on disk, but the active runtime identity is unverified. '
299
+ : '';
291
300
  const message = deferred
292
- ? `Deferred (fix when using Codex): ${assessed.gap.message}`
293
- : assessed.gap.message;
301
+ ? `Deferred (fix when using Codex): ${localRisk}${assessed.gap.message}`
302
+ : `${localRisk}${assessed.gap.message}`;
294
303
  gaps.push({
295
304
  id: assessed.gap.id,
296
305
  severity,
@@ -526,6 +535,7 @@ export function collectAdoptionGaps(root, config, coverage) {
526
535
  deployPath,
527
536
  contractFalseGreen,
528
537
  writePath,
538
+ runtimeActivation,
529
539
  enforcement: {
530
540
  ci: weakest.ci,
531
541
  preCommit: weakest.preCommit,
@@ -5,7 +5,10 @@
5
5
  * extraction-card payload, and compares residual after re-doctor on pilot paths.
6
6
  * Judgment only — never mechanical-safe; never multi-pilot batch apply.
7
7
  */
8
- import { buildPatternBetsFromSmells } from './design-smells.mjs';
8
+ import {
9
+ buildPatternBetsFromSmells,
10
+ isNonProductionPilotPath,
11
+ } from './design-smells.mjs';
9
12
 
10
13
  /** Stable product id for JSON / tests. */
11
14
  export const PILOT_LOOP_ID = 'one-pilot-redoctor';
@@ -44,14 +47,28 @@ export function fileEvidencePaths(evidence = []) {
44
47
  );
45
48
  }
46
49
 
50
+ function pilotFilesForBet(bet, preferredFiles) {
51
+ const rawFiles = preferredFiles?.length
52
+ ? preferredFiles
53
+ : fileEvidencePaths(bet?.evidence);
54
+ if (bet?.smellId !== 'god-module') return rawFiles;
55
+ const files = rawFiles.filter((file) => !isNonProductionPilotPath(file));
56
+ const excludedPilot =
57
+ rawFiles.length === 0 &&
58
+ typeof bet.pilot === 'string' &&
59
+ isNonProductionPilotPath(bet.pilot);
60
+ return (rawFiles.length > 0 && files.length === 0) || excludedPilot
61
+ ? null
62
+ : files;
63
+ }
64
+
47
65
  /**
48
66
  * Score a pattern bet for "do this pilot first".
49
67
  * Prefers concrete src/ files and higher-impact smell ids.
50
68
  * @param {object} bet
51
69
  * @param {number} index
52
70
  */
53
- function scoreBet(bet, index) {
54
- const files = fileEvidencePaths(bet?.evidence);
71
+ function scoreBet(bet, index, files = fileEvidencePaths(bet?.evidence)) {
55
72
  const smellPri = SMELL_PRIORITY[bet?.smellId] ?? 50;
56
73
  // Higher score wins; concrete files dominate; then smell priority; stable by index.
57
74
  return files.length * 100 - smellPri * 10 - index;
@@ -64,9 +81,8 @@ function scoreBet(bet, index) {
64
81
  */
65
82
  export function extractionCardFromBet(bet, preferredFiles) {
66
83
  if (!bet || typeof bet !== 'object') return null;
67
- const files = preferredFiles?.length
68
- ? preferredFiles
69
- : fileEvidencePaths(bet.evidence);
84
+ const files = pilotFilesForBet(bet, preferredFiles);
85
+ if (files === null) return null;
70
86
  const evidence = files.length ? files : (bet.evidence || []).slice(0, 8);
71
87
  const pilotTarget =
72
88
  files[0] ||
@@ -123,8 +139,9 @@ export function selectNextPilot(patternBets, options = {}) {
123
139
  if (!bet || bet.neverMechanicalSafe === false) continue;
124
140
  // Skip anything that claims mechanical-safe (honesty).
125
141
  if (bet.class === 'mechanical-safe') continue;
126
- const files = fileEvidencePaths(bet.evidence);
127
- const sc = scoreBet(bet, i);
142
+ const files = pilotFilesForBet(bet);
143
+ if (files === null) continue;
144
+ const sc = scoreBet(bet, i, files);
128
145
  if (sc > bestScore) {
129
146
  bestScore = sc;
130
147
  best = { bet, files };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/projectIdentity.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/project-identity.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_PROJECT_IDENTITY_SCHEMA_VERSION = '1.0';
12
+ export const ARK_PROJECT_IDENTITY_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.project-identity.schema.json';
13
+ const sha256Pattern = '^sha256:[a-f0-9]{64}$';
14
+ export const PROJECT_EXPECTATION_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ properties: {
18
+ expectedRoot: {
19
+ type: 'string',
20
+ minLength: 1,
21
+ description: 'Absolute expected workspace/project directory. The initial authoritative handshake ' +
22
+ 'requires the exact project root; descendant calls also require expectedProjectId.',
23
+ },
24
+ expectedProjectId: {
25
+ type: 'string',
26
+ pattern: sha256Pattern,
27
+ description: 'Project id previously returned by ark_identity or ark_manifest.',
28
+ },
29
+ },
30
+ };
31
+ export const PROJECT_BINDING_SCHEMA = {
32
+ type: 'object',
33
+ additionalProperties: false,
34
+ required: ['status', 'authoritative'],
35
+ properties: {
36
+ status: { enum: ['matched', 'unverified', 'mismatch'] },
37
+ authoritative: { type: 'boolean' },
38
+ expectedRoot: { type: 'string', minLength: 1 },
39
+ expectedProjectId: { type: 'string', pattern: sha256Pattern },
40
+ code: {
41
+ enum: [
42
+ 'PROJECT_ROOT_MISMATCH',
43
+ 'PROJECT_ID_MISMATCH',
44
+ 'INVALID_PROJECT_EXPECTATION',
45
+ ],
46
+ },
47
+ message: { type: 'string', minLength: 1 },
48
+ },
49
+ };
50
+ export const ARK_PROJECT_IDENTITY_SCHEMA = {
51
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
52
+ $id: ARK_PROJECT_IDENTITY_SCHEMA_URL,
53
+ title: 'ArkGate MCP project identity',
54
+ description: 'Stable project binding plus separate runtime and architecture-contract evidence.',
55
+ type: 'object',
56
+ additionalProperties: false,
57
+ required: [
58
+ 'schemaVersion',
59
+ 'projectId',
60
+ 'resolvedRoot',
61
+ 'resolvedConfigPath',
62
+ 'arkgateVersion',
63
+ 'contractHash',
64
+ 'contractSource',
65
+ 'runtimeId',
66
+ 'processStartedAt',
67
+ ],
68
+ properties: {
69
+ schemaVersion: { const: ARK_PROJECT_IDENTITY_SCHEMA_VERSION },
70
+ projectId: { type: 'string', pattern: sha256Pattern },
71
+ resolvedRoot: { type: 'string', minLength: 1 },
72
+ resolvedConfigPath: { type: 'string', minLength: 1 },
73
+ arkgateVersion: { type: 'string', minLength: 1 },
74
+ contractHash: { type: 'string', pattern: sha256Pattern },
75
+ contractSource: { enum: ['project', 'default-profile', 'manifest'] },
76
+ runtimeId: { type: 'string', minLength: 1 },
77
+ processStartedAt: { type: 'string', format: 'date-time' },
78
+ },
79
+ $defs: {
80
+ expectation: PROJECT_EXPECTATION_SCHEMA,
81
+ binding: PROJECT_BINDING_SCHEMA,
82
+ },
83
+ };
84
+ /**
85
+ * Stable identity: contract edits and MCP restarts must not change which project
86
+ * this is. Callers must pass canonical real paths and a SHA-256 hex function.
87
+ */
88
+ export function createProjectId(resolvedRoot, resolvedConfigPath, sha256Hex) {
89
+ if (!resolvedRoot || !resolvedConfigPath) {
90
+ throw new Error('Project identity requires resolvedRoot and resolvedConfigPath.');
91
+ }
92
+ const digest = sha256Hex(JSON.stringify({ resolvedRoot, resolvedConfigPath })).toLowerCase();
93
+ if (!/^[a-f0-9]{64}$/.test(digest)) {
94
+ throw new Error('Project identity hash adapter must return 64 hexadecimal SHA-256 characters.');
95
+ }
96
+ return `sha256:${digest}`;
97
+ }
98
+ export function createProjectIdentity(input) {
99
+ return {
100
+ schemaVersion: ARK_PROJECT_IDENTITY_SCHEMA_VERSION,
101
+ ...input,
102
+ };
103
+ }
@@ -0,0 +1,28 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ /** Best-effort, shell-free Git/worktree evidence for report snapshots. */
4
+ export function captureGitSnapshot(root) {
5
+ const run = (args) =>
6
+ execFileSync('git', ['-C', root, ...args], {
7
+ encoding: 'utf8',
8
+ stdio: ['ignore', 'pipe', 'ignore'],
9
+ }).trim();
10
+ try {
11
+ const headSha = run(['rev-parse', '--verify', 'HEAD']);
12
+ let branch = null;
13
+ try {
14
+ branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || null;
15
+ } catch {
16
+ // Detached HEAD is valid release/report evidence.
17
+ }
18
+ let dirty = null;
19
+ try {
20
+ dirty = run(['status', '--porcelain=v1', '--untracked-files=normal']).length > 0;
21
+ } catch {
22
+ // Keep the commit identity even when worktree state is unavailable.
23
+ }
24
+ return { available: true, headSha, branch, dirty };
25
+ } catch {
26
+ return { available: false, headSha: null, branch: null, dirty: null };
27
+ }
28
+ }
@@ -67,20 +67,44 @@ function residentRuntimeIdentity(launcher) {
67
67
  return hash.digest('hex');
68
68
  }
69
69
 
70
+ function realpathOrResolve(value) {
71
+ const resolved = path.resolve(value);
72
+ try { return fs.realpathSync(resolved); } catch { return resolved; }
73
+ }
74
+
75
+ export function residentInvocationIdentity({ root, config, manifest, tsconfig }) {
76
+ const lexicalRoot = path.resolve(root);
77
+ const realRoot = realpathOrResolve(lexicalRoot);
78
+ const projectPath = (value) => {
79
+ if (!value) return null;
80
+ const absolute = path.isAbsolute(value)
81
+ ? path.resolve(value)
82
+ : path.resolve(lexicalRoot, value);
83
+ const relative = path.relative(lexicalRoot, absolute);
84
+ const contained =
85
+ relative === '' ||
86
+ (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
87
+ return realpathOrResolve(contained ? path.resolve(realRoot, relative) : absolute);
88
+ };
89
+ return {
90
+ root: realRoot,
91
+ config: projectPath(config),
92
+ manifest: projectPath(manifest),
93
+ tsconfig: projectPath(tsconfig),
94
+ };
95
+ }
96
+
70
97
  export function residentHookEndpoint({ root, config, manifest, tsconfig, launcher }) {
71
- let realRoot;
72
- try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
98
+ const invocation = residentInvocationIdentity({ root, config, manifest, tsconfig });
99
+ const realLauncher = realpathOrResolve(launcher);
73
100
  const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
74
101
  const directory = path.join(os.tmpdir(), `arkgate-${uid}`);
75
102
  const digest = createHash('sha256').update(JSON.stringify({
76
- root: realRoot,
77
- config: path.resolve(root, config),
78
- manifest: manifest ? path.resolve(root, manifest) : null,
79
- tsconfig: tsconfig ? path.resolve(root, tsconfig) : null,
80
- launcher: path.resolve(launcher),
81
- executable: process.execPath,
103
+ ...invocation,
104
+ launcher: realLauncher,
105
+ executable: realpathOrResolve(process.execPath),
82
106
  protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
83
- runtimeIdentity: residentRuntimeIdentity(path.resolve(launcher)),
107
+ runtimeIdentity: residentRuntimeIdentity(realLauncher),
84
108
  })).digest('hex').slice(0, 24);
85
109
  return {
86
110
  directory,
@@ -11,11 +11,63 @@
11
11
  function lineOf(content, index) {
12
12
  return content.slice(0, index).split('\n').length;
13
13
  }
14
+ function normalizeInventoryPath(file) {
15
+ return file.replace(/\\/g, '/').replace(/^\.\//, '');
16
+ }
17
+ function ownsIntent(intentPrefixes, intentRoots) {
18
+ return intentPrefixes.some((prefix) => {
19
+ const normalized = prefix.trim().replace(/\.+$/, '');
20
+ return intentRoots.some((root) => normalized === root || normalized.startsWith(`${root}.`));
21
+ });
22
+ }
23
+ function isDomainLayer(layer, intentPrefixes = []) {
24
+ return (/domain|entity|aggregate|model/i.test(layer) ||
25
+ ownsIntent(intentPrefixes, ['Domain']));
26
+ }
27
+ function isControllerEligibleLayer(layer, intentPrefixes = []) {
28
+ return (/application|orchestration|presentation|adapter|framework|interface|delivery|transport|inbound|controller/i.test(layer) ||
29
+ ownsIntent(intentPrefixes, [
30
+ 'Application',
31
+ 'Orchestration',
32
+ 'Presentation',
33
+ 'Adapter',
34
+ 'Interface',
35
+ 'Delivery',
36
+ 'Transport',
37
+ ]));
38
+ }
39
+ function isNonPilotSurface(file) {
40
+ return (/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|excluded|exclusions?)(?:\/|$)/i.test(file) ||
41
+ /(?:^|\/)[^/]*\.(?:test|spec|fixture|mock|stub|seed|seeder)\.[^/]+$/i.test(file) ||
42
+ /(?:^|\/)(?:seed|seeder|fixture|mock|stub)\.[^/]+$/i.test(file));
43
+ }
14
44
  export function buildRulesInventory(input) {
15
45
  const candidates = [];
16
46
  let seq = 0;
47
+ const fileLayers = new Map(Object.entries(input.fileLayers ?? {}).map(([file, layer]) => [
48
+ normalizeInventoryPath(file),
49
+ layer,
50
+ ]));
51
+ const layerIntentPrefixes = new Map((input.layerContexts ?? []).map((layer) => [
52
+ layer.name,
53
+ layer.intentPrefixes ?? [],
54
+ ]));
55
+ const domainLayer = (input.layerContexts ?? []).find((layer) => isDomainLayer(layer.name, layer.intentPrefixes))?.name ?? 'DomainModel';
17
56
  for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
18
- const posix = file.replace(/\\/g, '/');
57
+ const posix = normalizeInventoryPath(file);
58
+ // Test data, fixtures, seeds, migrations, and explicit exclusions may retain
59
+ // representative smells, but are not production extraction pilots.
60
+ if (isNonPilotSurface(posix))
61
+ continue;
62
+ // Generated mirrors are evidence for their canonical source, not a second
63
+ // extraction candidate.
64
+ if (/GENERATED FILE\s+[—-]\s+do not edit by hand/i.test(content.slice(0, 320)))
65
+ continue;
66
+ const hasGovernedLayer = fileLayers.has(posix);
67
+ const governedLayer = fileLayers.get(posix);
68
+ const governedIntentPrefixes = governedLayer
69
+ ? layerIntentPrefixes.get(governedLayer) ?? []
70
+ : [];
19
71
  // P2-N — clear UI bags only (components/theme/styles). Do NOT blanket-skip all
20
72
  // app/pages (server actions / route handlers live there and stay inventoriable).
21
73
  const isUiChrome = /(?:^|\/)(?:components|ui|layouts|styles|hooks|theme|tokens|i18n|locales?)(?:\/|$)/i.test(posix) ||
@@ -23,14 +75,22 @@ export function buildRulesInventory(input) {
23
75
  /(?:page|layout|loading|error|template|default)\.(?:tsx|jsx)$/i.test(posix);
24
76
  const isApiRoute = /(?:^|\/)(?:app|pages)(?:\/[^/]+)*\/api(?:\/|$)/i.test(posix);
25
77
  const isServerAction = /(?:^|\/)actions?(?:\/|\.|$)/i.test(posix) || /['"]use server['"]/.test(content);
26
- const isController = /controller|handler|resolver/i.test(file) ||
78
+ const controllerShape = /controller|handler|resolver/i.test(file) ||
27
79
  isApiRoute ||
28
80
  isServerAction ||
29
81
  (/route\.(?:ts|js|tsx|jsx)$/i.test(posix) && !isUiChrome) ||
30
82
  /@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content) ||
31
83
  /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b/.test(content) ||
32
84
  /\bexport\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=/.test(content);
33
- const isDomain = /domain|entity|aggregate|model/i.test(file);
85
+ const isController = hasGovernedLayer
86
+ ? Boolean(governedLayer &&
87
+ isControllerEligibleLayer(governedLayer, governedIntentPrefixes) &&
88
+ controllerShape)
89
+ : controllerShape;
90
+ const isDomain = hasGovernedLayer
91
+ ? Boolean(governedLayer && isDomainLayer(governedLayer, governedIntentPrefixes))
92
+ : /domain|entity|aggregate|model/i.test(file);
93
+ const magicConstantEligible = !hasGovernedLayer || isDomain || isController;
34
94
  // validation-in-controller (API/Nest/server-action handlers — not pure UI chrome)
35
95
  if (isController && !isUiChrome) {
36
96
  const valRe = /\b(if\s*\([^)]{0,80}(amount|total|price|qty|quantity|balance)[^)]{0,40}\)|throw new (Error|BadRequest|ValidationError)|z\.object\(|yup\.|class-validator|@Is[A-Z])/g;
@@ -44,8 +104,9 @@ export function buildRulesInventory(input) {
44
104
  line: lineOf(content, m.index),
45
105
  message: 'Business validation appears in a controller/handler — extract an invariant or Domain rule.',
46
106
  confidence: 'direct-evidence',
107
+ governedLayer,
47
108
  suggestedArkRule: {
48
- layer: 'DomainModel',
109
+ layer: domainLayer,
49
110
  invariantId: `INV-EXTRACT-${seq}`,
50
111
  sensor: 'invariant-coverage',
51
112
  },
@@ -67,9 +128,21 @@ export function buildRulesInventory(input) {
67
128
  // Narrow DEFAULT_/REQUEST_/STORAGE_ — only known infra tokens, not all DEFAULT_* seeds
68
129
  /^(?:DEFAULT_(?:BASE_URL|TIMEOUT(?:_MS)?|RETRY|PORT|HOST|HEADERS?|CACHE|TTL|MS|LOCALE|LANG|TIMEZONE|TZ)|REQUEST_(?:TIMEOUT(?:_MS)?|HEADERS?|RETRY|ID_PREFIX)|STORAGE_(?:KEY|PREFIX|BUCKET)|DAY_MS$|APP_DOMAIN$|BASE_URL$)$/i.test(name) ||
69
130
  // Known I/O bag prefixes that are never domain seeds in field clones
70
- /^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name);
131
+ /^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name) ||
132
+ // Development identities and PostgreSQL type OIDs are technical wiring, not
133
+ // business literals. Keep this narrow so Domain limits/status seeds still surface.
134
+ /^(?:DEV|DEMO|SEED|FIXTURE)_[A-Z0-9_]+$/i.test(name) ||
135
+ /^(?:PG|POSTGRES|OID)_[A-Z0-9_]+$/i.test(name) ||
136
+ /_(?:OID|OIDS)$/i.test(name) ||
137
+ /^(?:INT2|INT4|INT8|FLOAT4|FLOAT8|NUMERIC|DATE|TIME|TIMESTAMP|TIMESTAMPTZ|JSON|JSONB|UUID)OID$/i.test(name) ||
138
+ /(?:^|_)(?:SCHEMA|PROTOCOL|RESOLVER|FORMAT)_(?:URL|URI|VERSION|ID|IDENTITY)$/i.test(name);
71
139
  while ((magic = magicRe.exec(content)) !== null) {
72
140
  const name = magic[2];
141
+ // With governed layer evidence, generic Tooling/Kernel constants are not
142
+ // business-rule candidates. Controller-shaped boundaries stay eligible
143
+ // because business policy can leak into them.
144
+ if (!magicConstantEligible)
145
+ continue;
73
146
  if (isInfraMagicName(name))
74
147
  continue;
75
148
  // P2-N: skip remaining ALL_CAPS noise only on clear UI chrome (not all of app/).
@@ -88,7 +161,8 @@ export function buildRulesInventory(input) {
88
161
  line: lineOf(content, magic.index),
89
162
  message: `Magic business constant ${name} may belong in a Domain policy or invariant catalog.`,
90
163
  confidence: 'heuristic',
91
- suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${name}` },
164
+ governedLayer,
165
+ suggestedArkRule: { layer: domainLayer, invariantId: `INV-${name}` },
92
166
  neverMechanicalSafe: true,
93
167
  });
94
168
  }
@@ -109,8 +183,9 @@ export function buildRulesInventory(input) {
109
183
  line: lineOf(content, c.index),
110
184
  message: `Class ${c[1]} looks anemic (data-heavy, few behaviors).`,
111
185
  confidence: 'heuristic',
186
+ governedLayer,
112
187
  suggestedArkRule: {
113
- layer: 'DomainModel',
188
+ layer: domainLayer,
114
189
  structureId: 'no-anemic-model',
115
190
  sensor: 'no-anemic-model',
116
191
  },
@@ -131,6 +206,16 @@ export function buildRulesInventory(input) {
131
206
  const mutRe = /this\.\w+\s*=/g;
132
207
  let mut;
133
208
  while ((mut = mutRe.exec(content)) !== null) {
209
+ const classStart = content.lastIndexOf('class ', mut.index);
210
+ const classHeaderEnd = classStart >= 0 ? content.indexOf('{', classStart) : -1;
211
+ const classHeader = classStart >= 0 && classHeaderEnd >= classStart && classHeaderEnd < mut.index
212
+ ? content.slice(classStart, classHeaderEnd)
213
+ : '';
214
+ // Error metadata assignment is constructor wiring, not aggregate
215
+ // mutation. Keep the exclusion local to the containing class header.
216
+ if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
217
+ continue;
218
+ }
134
219
  const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
135
220
  if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
136
221
  seq += 1;
@@ -141,8 +226,9 @@ export function buildRulesInventory(input) {
141
226
  line: lineOf(content, mut.index),
142
227
  message: 'Domain field mutation without nearby guard/publish call.',
143
228
  confidence: 'heuristic',
229
+ governedLayer,
144
230
  suggestedArkRule: {
145
- layer: 'DomainModel',
231
+ layer: domainLayer,
146
232
  structureId: 'events-on-mutation',
147
233
  sensor: 'domain-event-on-mutation',
148
234
  },
@@ -155,6 +241,12 @@ export function buildRulesInventory(input) {
155
241
  }
156
242
  }
157
243
  const contracted = new Set(input.contractedRuleIds ?? []);
244
+ candidates.sort((a, b) => Number(b.confidence === 'direct-evidence') -
245
+ Number(a.confidence === 'direct-evidence') ||
246
+ a.file.localeCompare(b.file) ||
247
+ a.line - b.line ||
248
+ a.kind.localeCompare(b.kind) ||
249
+ a.id.localeCompare(b.id));
158
250
  const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
159
251
  (c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
160
252
  return {