arkgate 4.0.0 → 4.1.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 (57) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/graph-blind.mjs +1 -1
  20. package/bin/lib/html-report-advisories.mjs +8 -25
  21. package/bin/lib/html-report-depth.mjs +167 -3
  22. package/bin/lib/html-report.mjs +12 -5
  23. package/bin/lib/install-migrate.mjs +109 -6
  24. package/bin/lib/managed-upgrade.mjs +100 -1
  25. package/bin/lib/presets.mjs +314 -46
  26. package/bin/lib/project-root.mjs +268 -0
  27. package/bin/lib/remediation.mjs +12 -11
  28. package/bin/lib/rules-inventory.mjs +71 -29
  29. package/bin/lib/rules-under-contract.mjs +389 -5
  30. package/bin/lib/start-preview.mjs +48 -14
  31. package/bin/lib/suggestions.mjs +118 -3
  32. package/bin/lib/unavailable-analysis.mjs +2 -0
  33. package/bin/lib/upgrade-command.mjs +325 -14
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/dist/eslint/index.cjs +2 -2
  36. package/dist/eslint/index.d.ts +27 -2
  37. package/dist/eslint/index.js +2 -2
  38. package/dist/index.cjs +16 -14
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +16 -14
  41. package/docs/README.md +3 -2
  42. package/docs/ai-gates.md +15 -11
  43. package/docs/brownfield-adoption.md +38 -0
  44. package/docs/configuration.md +59 -7
  45. package/docs/package-surface.md +3 -2
  46. package/docs/product-voice.md +10 -1
  47. package/docs/typescript-support.md +9 -5
  48. package/docs/use.md +7 -5
  49. package/package.json +3 -1
  50. package/server.json +3 -3
  51. package/templates/architecture-playbook.json +3 -0
  52. package/templates/layers/shared-types.starter.json +29 -0
  53. package/templates/skills/ark-adopt.md +2 -0
  54. package/templates/skills/ark-explain.md +23 -5
  55. package/templates/skills/ark-explore.md +21 -1
  56. package/templates/skills/ark-fix.md +16 -5
  57. package/templates/skills/ark-upgrade.md +57 -11
@@ -97,24 +97,37 @@ function baseViolation(rule, file, message, line = 1) {
97
97
  failsStrict,
98
98
  };
99
99
  }
100
+ /**
101
+ * When layerForFile is provided, structure sensors require the same classification
102
+ * as the layer plane: unclassified paths are skipped (NEW-ARKRULES-UNCLASSIFIED-ATTRIBUTION).
103
+ * Without layerForFile, appliesTo globs alone scope the rule (unit tests / pure eval).
104
+ */
105
+ function isInRuleLayer(file, rule, layerForFile) {
106
+ if (!layerForFile)
107
+ return true;
108
+ const layer = layerForFile(file);
109
+ // Unclassified (null/undefined/"") must not inherit rule.provenance.layer attribution.
110
+ if (!layer)
111
+ return false;
112
+ return layer === rule.provenance.layer;
113
+ }
100
114
  function shapesForRule(rule, shapes, layerForFile) {
101
115
  return shapes.filter((shape) => {
102
116
  if (!shape.exported)
103
117
  return false;
104
118
  if (!matchesAppliesTo(shape.file, rule.appliesTo))
105
119
  return false;
106
- if (layerForFile) {
107
- const layer = layerForFile(shape.file);
108
- if (layer && layer !== rule.provenance.layer)
109
- return false;
110
- }
120
+ if (!isInRuleLayer(shape.file, rule, layerForFile))
121
+ return false;
111
122
  return true;
112
123
  });
113
124
  }
114
125
  function evaluateAggregatePrivateState(rule, shapes, layerForFile) {
115
126
  const out = [];
116
127
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
117
- if (shape.hasPublicMutableFields || shape.hasPublicSetters) {
128
+ // P1-L — require real mutability (setters or non-readonly public fields).
129
+ // Readonly public props on intentional value/entity shapes are false-positive-prone.
130
+ if (shape.hasPublicSetters || shape.hasPublicMutableFields) {
118
131
  out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes public mutable state (sensor aggregate-private-state).`));
119
132
  }
120
133
  }
@@ -124,6 +137,14 @@ function evaluateAlwaysValidFactory(rule, shapes, layerForFile) {
124
137
  const out = [];
125
138
  for (const shape of shapesForRule(rule, shapes, layerForFile)) {
126
139
  if (shape.hasPublicConstructor && !shape.hasStaticFactory) {
140
+ // P1-L — prefer false negatives on intentional DDD / DI aggregates: a public
141
+ // constructor alone is weak evidence. Only fire when mutable public surface
142
+ // exists (needs an always-valid construction story).
143
+ const needsAlwaysValidStory = shape.hasPublicMutableFields ||
144
+ shape.hasPublicSetters ||
145
+ (shape.mutatingMethods?.length ?? 0) > 0;
146
+ if (!needsAlwaysValidStory)
147
+ continue;
127
148
  out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes a public constructor without a static factory (sensor always-valid-factory).`));
128
149
  }
129
150
  }
@@ -145,11 +166,8 @@ function evaluateOrchestrationOnly(rule, input) {
145
166
  for (const file of input.files) {
146
167
  if (!matchesAppliesTo(file, rule.appliesTo))
147
168
  continue;
148
- if (input.layerForFile) {
149
- const layer = input.layerForFile(file);
150
- if (layer && layer !== rule.provenance.layer)
151
- continue;
152
- }
169
+ if (!isInRuleLayer(file, rule, input.layerForFile))
170
+ continue;
153
171
  if (input.fileHints?.[file]?.orchestrationHeavy) {
154
172
  out.push(baseViolation(rule, file, `File appears to embed domain branching beyond guard-and-delegate orchestration (sensor orchestration-only).`));
155
173
  }
@@ -161,11 +179,8 @@ function evaluateThinAdapter(rule, input) {
161
179
  for (const file of input.files) {
162
180
  if (!matchesAppliesTo(file, rule.appliesTo))
163
181
  continue;
164
- if (input.layerForFile) {
165
- const layer = input.layerForFile(file);
166
- if (layer && layer !== rule.provenance.layer)
167
- continue;
168
- }
182
+ if (!isInRuleLayer(file, rule, input.layerForFile))
183
+ continue;
169
184
  if (input.fileHints?.[file]?.adapterThick) {
170
185
  out.push(baseViolation(rule, file, `Adapter module mixes domain branching, persistence, and mapping beyond a thin adapter (sensor thin-adapter).`));
171
186
  }
@@ -358,11 +373,26 @@ export function extractClassShapesFromSource(file, content) {
358
373
  i += 1;
359
374
  }
360
375
  const body = content.slice(start, i - 1);
361
- const hasPublicMutableFields = /(?:^|\n)\s*(?:public\s+)?(?:readonly\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(body.replace(/(?:public\s+|private\s+|protected\s+|readonly\s+|static\s+|async\s+|get\s+|set\s+)/g, '')) &&
362
- /(?:^|\n)\s*(public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(body);
363
- // Simpler public field detection: "public foo" or unadorned "foo:" at class level
364
- const publicField = /(?:^|\n)\s*public\s+(?!static|async|get|set|constructor)[a-zA-Z_]/.test(body) ||
365
- /(?:^|\n)\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^=;\n]+[;=]/m.test(body
376
+ // P1-L prefer false negatives: readonly public props are not mutable state.
377
+ // Strip only readonly property *declarations*, not whole lines (co-located
378
+ // `public readonly id; public count = 0` must keep the mutable field).
379
+ // Also ignore comment lines that merely mention the word "readonly".
380
+ const bodyNoReadonly = body
381
+ .split('\n')
382
+ .map((line) => {
383
+ if (/^\s*\/\//.test(line) || /^\s*\/\*|\*\//.test(line))
384
+ return line;
385
+ // Remove `public readonly foo` / `readonly foo` decls; leave other decls.
386
+ return line
387
+ .replace(/(?:public\s+|protected\s+)?readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, '')
388
+ .replace(/(?:^|[\s;{])readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, ' ');
389
+ })
390
+ .join('\n');
391
+ const hasPublicMutableFields = /(?:^|\n)\s*(?:public\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly.replace(/(?:public\s+|private\s+|protected\s+|static\s+|async\s+|get\s+|set\s+)/g, '')) &&
392
+ /(?:^|\n)\s*(public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly);
393
+ // Public mutable field: "public foo" (not readonly) or unadorned assignable field.
394
+ const publicField = /(?:^|\n)\s*public\s+(?!static|async|get|set|constructor|readonly)[a-zA-Z_]/.test(bodyNoReadonly) ||
395
+ /(?:^|[\n;])\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^=;\n]+[;=]/m.test(bodyNoReadonly
366
396
  .split('\n')
367
397
  .filter((line) => !/^\s*(private|protected|static|constructor|get |set |async |\/)/.test(line))
368
398
  .join('\n'));
@@ -372,10 +402,15 @@ export function extractClassShapesFromSource(file, content) {
372
402
  const hasStaticFactory = /(?:^|[\n;{])\s*static\s+(?:async\s+)?(?:create|of|from|parse|build|make|new)\s*[<(]/.test(body) ||
373
403
  /(?:^|[\n;{])\s*static\s+(?:async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*:\s*[A-Za-z_]/.test(body);
374
404
  const mutatingMethods = [];
405
+ // P1-L / P1L-STRUCTURE-NOISE-CONTROL-FLOW: fluent control-flow helpers (Property.if,
406
+ // .match, .when) often assign this.* for chaining — not domain mutators. Prefer FN.
407
+ const CONTROL_FLOW_METHOD_NAMES = new Set(['if', 'match', 'when']);
375
408
  const methodRe = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|async\s+)*(?!constructor|get|set|static)([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{/g;
376
409
  let methodMatch;
377
410
  while ((methodMatch = methodRe.exec(body)) !== null) {
378
411
  const name = methodMatch[1];
412
+ if (CONTROL_FLOW_METHOD_NAMES.has(name))
413
+ continue;
379
414
  const mStart = methodMatch.index + methodMatch[0].length;
380
415
  let mDepth = 1;
381
416
  let j = mStart;
@@ -394,7 +429,13 @@ export function extractClassShapesFromSource(file, content) {
394
429
  mutatingMethods.push({ name, referencesGuardOrPublish });
395
430
  }
396
431
  const methodCount = (body.match(/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
397
- const dataOnly = methodCount <= 1 && (publicField || hasPublicMutableFields);
432
+ // P1-L raise anemic bar: need multiple public fields and essentially no behavior.
433
+ // Single-field bags and intentional property bags with one helper stay quiet.
434
+ // Count fields after line starts *or* after `;` so one-line class bodies still work.
435
+ const publicFieldCount = (bodyNoReadonly.match(/(?:^|[\n;])\s*(?:public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/g) ?? []).length;
436
+ const dataOnly = methodCount <= 1 &&
437
+ publicFieldCount >= 2 &&
438
+ (publicField || hasPublicMutableFields);
398
439
  shapes.push({
399
440
  file,
400
441
  className,
@@ -14,7 +14,7 @@ import {
14
14
  import { falseGreenAdoptionGap } from './field-install.mjs';
15
15
  import { renderHostSupportMatrixMarkdown } from './host-support-matrix.mjs';
16
16
  import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
17
- import { readPackageJson } from './gate-files.mjs';
17
+ import { hasCheckArchitectureScript, readPackageJson } from './gate-files.mjs';
18
18
 
19
19
  // Field-install helpers re-exported for callers that import from this module.
20
20
  export {
@@ -88,6 +88,97 @@ export function checkArchitectureScriptSnippet(root) {
88
88
  return `"check:architecture": "${arkCheckCommand(root)}"`;
89
89
  }
90
90
 
91
+ /**
92
+ * Insert a package.json scripts entry while preserving indentation / formatting
93
+ * (same contract as pinArkgateDevDependency format-preserving edits).
94
+ * @param {string} source
95
+ * @param {string} scriptName
96
+ * @param {string} scriptValue
97
+ */
98
+ function addPackageScriptPreservingFormat(source, scriptName, scriptValue) {
99
+ const multiline = /\r?\n/.test(source);
100
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
101
+ const rootPropertyIndent = source.match(/\r?\n([ \t]+)"[^"\n]+"\s*:/)?.[1] ?? ' ';
102
+ const indentUnit = rootPropertyIndent;
103
+ const encoded = JSON.stringify(scriptValue);
104
+ const key = JSON.stringify(scriptName);
105
+ const scriptsMatch = /"scripts"\s*:\s*\{/.exec(source);
106
+
107
+ if (scriptsMatch) {
108
+ const open = source.indexOf('{', scriptsMatch.index);
109
+ let depth = 0;
110
+ let quoted = false;
111
+ let escaped = false;
112
+ let close = -1;
113
+ for (let index = open; index < source.length; index += 1) {
114
+ const char = source[index];
115
+ if (quoted) {
116
+ if (escaped) escaped = false;
117
+ else if (char === '\\') escaped = true;
118
+ else if (char === '"') quoted = false;
119
+ continue;
120
+ }
121
+ if (char === '"') quoted = true;
122
+ else if (char === '{') depth += 1;
123
+ else if (char === '}' && --depth === 0) {
124
+ close = index;
125
+ break;
126
+ }
127
+ }
128
+ if (close === -1) throw new Error('Unbalanced package.json scripts object');
129
+ const body = source.slice(open + 1, close);
130
+ if (!multiline) {
131
+ const addition = body.trim() ? `,${key}:${encoded}` : `${key}:${encoded}`;
132
+ return `${source.slice(0, close)}${addition}${source.slice(close)}`;
133
+ }
134
+ const beforeClose = source.slice(0, close);
135
+ const trailing = beforeClose.match(/\s*$/)?.[0] ?? '';
136
+ const contentEnd = close - trailing.length;
137
+ const closingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
138
+ const propertyIndent = `${closingIndent}${indentUnit}`;
139
+ const addition = body.trim()
140
+ ? `,${eol}${propertyIndent}${key}: ${encoded}`
141
+ : `${propertyIndent}${key}: ${encoded}`;
142
+ return `${source.slice(0, contentEnd)}${addition}${eol}${closingIndent}${source.slice(close)}`;
143
+ }
144
+
145
+ const rootClose = source.lastIndexOf('}');
146
+ if (rootClose === -1) throw new Error('Unbalanced package.json object');
147
+ const rootBody = source.slice(0, rootClose);
148
+ if (!multiline) {
149
+ const separator = rootBody.trim().endsWith('{') ? '' : ',';
150
+ return `${rootBody}${separator}"scripts":{${key}:${encoded}}${source.slice(rootClose)}`;
151
+ }
152
+ const trailing = rootBody.match(/\s*$/)?.[0] ?? '';
153
+ const contentEnd = rootClose - trailing.length;
154
+ const rootClosingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
155
+ const separator = source.slice(0, contentEnd).trimEnd().endsWith('{') ? '' : ',';
156
+ const addition = `${separator}${eol}${rootPropertyIndent}"scripts": {${eol}${rootPropertyIndent}${indentUnit}${key}: ${encoded}${eol}${rootPropertyIndent}}`;
157
+ return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
158
+ }
159
+
160
+ /**
161
+ * Ensure package.json has `check:architecture` so local/CI parity is not a post-start gap.
162
+ * Never overwrites an existing script (even if stale). Preserves package.json formatting.
163
+ *
164
+ * @param {string} root
165
+ * @param {{ write?: boolean }} [opts]
166
+ * @returns {{ changed: boolean, reason: 'added'|'already'|'no-package-json', script?: string }}
167
+ */
168
+ export function ensureCheckArchitectureScript(root, opts = {}) {
169
+ const write = opts.write !== false;
170
+ const pkgPath = path.join(root, 'package.json');
171
+ if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
172
+ if (hasCheckArchitectureScript(root)) return { changed: false, reason: 'already' };
173
+ const script = arkCheckCommand(root);
174
+ if (write) {
175
+ const source = fs.readFileSync(pkgPath, 'utf8');
176
+ const next = addPackageScriptPreservingFormat(source, 'check:architecture', script);
177
+ fs.writeFileSync(pkgPath, next.endsWith('\n') ? next : `${next}\n`);
178
+ }
179
+ return { changed: true, reason: 'added', script };
180
+ }
181
+
91
182
  // Canonical agent contract. AGENTS.md and the Cursor rule both derive from this single
92
183
  // source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
93
184
  // is a builder because the check command's runner prefix varies with the package manager.
@@ -105,7 +196,30 @@ const AGENT_CONTRACT = {
105
196
  cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
106
197
  };
107
198
 
108
- export function layerPlacementTable() {
199
+ /**
200
+ * Placement table for AGENTS.md. Prefer live `ark.config.json` layers when provided
201
+ * so custom contracts (e.g. 8-layer monorepo) do not get a stock 11-layer table.
202
+ *
203
+ * @param {Array<{ name?: string, layer?: string, patterns?: string[], intentPrefixes?: string[], prefixes?: string[] }>|null|undefined} [layers]
204
+ */
205
+ export function layerPlacementTable(layers) {
206
+ if (Array.isArray(layers) && layers.length > 0) {
207
+ const rows = layers
208
+ .map((layer) => {
209
+ const name = layer.name ?? layer.layer ?? 'Unknown';
210
+ const patterns = (layer.patterns ?? [])
211
+ .map((pattern) => `\`${pattern}\``)
212
+ .join(', ') || '—';
213
+ const prefixes = (layer.intentPrefixes ?? layer.prefixes ?? [])
214
+ .map((prefix) => `\`${prefix}\``)
215
+ .join(', ') || '—';
216
+ return `| ${name} | ${patterns} | ${prefixes} |`;
217
+ })
218
+ .join('\n');
219
+ return `| Layer | Patterns (from ark.config.json) | Intent prefixes |
220
+ |-------|----------------------------------|-----------------|
221
+ ${rows}`;
222
+ }
109
223
  const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
110
224
  const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
111
225
  .map((directory) => `\`${directory}/\``)
@@ -117,6 +231,23 @@ export function layerPlacementTable() {
117
231
  ${rows}`;
118
232
  }
119
233
 
234
+ /**
235
+ * Load project layers for AGENTS generation. Returns null when config is absent/invalid
236
+ * so callers fall back to the stock 11-layer table.
237
+ * @param {string} root
238
+ */
239
+ export function loadConfigLayersForAgents(root) {
240
+ try {
241
+ const cfgPath = path.join(root, 'ark.config.json');
242
+ if (!fs.existsSync(cfgPath)) return null;
243
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
244
+ if (!Array.isArray(cfg?.layers) || cfg.layers.length === 0) return null;
245
+ return cfg.layers;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
120
251
  export function agentInstructions(root) {
121
252
  const checkCmd = arkCheckCommand(root);
122
253
  const startCmd = arkCommand(root, 'ark', 'start');
@@ -124,6 +255,20 @@ export function agentInstructions(root) {
124
255
  const steps = AGENT_CONTRACT.steps(checkCmd)
125
256
  .map((step, index) => `${index + 1}. ${step}`)
126
257
  .join('\n');
258
+ const liveLayers = loadConfigLayersForAgents(root);
259
+ const placementTable = layerPlacementTable(liveLayers);
260
+ const placementBody = liveLayers
261
+ ? `\`ark.config.json\` is authoritative for this project. Place new code in these **${liveLayers.length}** configured layer(s) — do not invent an ungoverned location or assume the stock 11-layer layout:
262
+
263
+ ${placementTable}
264
+
265
+ When creating a NEW kind of code that no existing layer covers, add a layer to \`ark.config.json\` first (via \`/ark-contract\`), then place the file.`
266
+ : `\`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
267
+ that no existing layer covers (a saga, a background job, a read model, ...), use the
268
+ default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
269
+ an ungoverned location:
270
+
271
+ ${placementTable}`;
127
272
  return `# Ark Enforcement
128
273
 
129
274
  ## Default agent flow (if unsure, do only this)
@@ -181,12 +326,7 @@ ${steps}
181
326
 
182
327
  ## Where new code belongs
183
328
 
184
- \`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
185
- that no existing layer covers (a saga, a background job, a read model, ...), use the
186
- default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
187
- an ungoverned location:
188
-
189
- ${layerPlacementTable()}
329
+ ${placementBody}
190
330
 
191
331
  The project is only considered Ark-enforced when its host-appropriate write path is configured
192
332
  and the CI check passes. Only Claude/Grok provide a hard local write boundary; Cursor/Codex use
@@ -3,10 +3,10 @@
3
3
  * Keeps ark-check.mjs orchestration-only (dispatch only).
4
4
  */
5
5
  import fs from 'node:fs';
6
- import path from 'node:path';
7
6
  import { arkCommand } from '../ark-shared.mjs';
8
7
  import { computeCoverage } from './doctor-plan.mjs';
9
8
  import { CORE_LAYER_NAMES } from './core-layers.mjs';
9
+ import { resolveConfigPathWithinRoot } from './project-root.mjs';
10
10
 
11
11
  export { CORE_LAYER_NAMES } from './core-layers.mjs';
12
12
 
@@ -68,9 +68,6 @@ export function runRatchetCores(root, config, files, rules, violations, args, de
68
68
  const displayPathFromRoot = deps.displayPathFromRoot;
69
69
  const cov = computeCoverage(root, config, files, rules);
70
70
  const activeCount = Array.isArray(violations) ? violations.length : 0;
71
- const configPath = path.isAbsolute(args.config)
72
- ? args.config
73
- : path.join(root, args.config || 'ark.config.json');
74
71
 
75
72
  const refuse = (code, message, extra = {}) => {
76
73
  if (args.json) {
@@ -81,6 +78,14 @@ export function runRatchetCores(root, config, files, rules, violations, args, de
81
78
  process.exitCode = code;
82
79
  };
83
80
 
81
+ // Contain --config writes under project root (S0 security).
82
+ const contained = resolveConfigPathWithinRoot(root, args.config || 'ark.config.json');
83
+ if (!contained.ok) {
84
+ refuse(2, contained.error);
85
+ return;
86
+ }
87
+ const configPath = contained.configPath;
88
+
84
89
  if (activeCount > 0) {
85
90
  refuse(
86
91
  2,
@@ -48,7 +48,14 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
48
48
  // Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
49
49
  graphBlindSpots: detectGraphBlindSpots(ts, root, files),
50
50
  // AR12 — Rules under contract (honest counts; real test I/O, never empty-fileContents stub).
51
- rulesUnderContract: summarizeRulesUnderContract(root, config, factPaths),
51
+ // P1M: pass classification so extraMergeTeeth cannot arm at 0% governed.
52
+ rulesUnderContract: summarizeRulesUnderContract(root, config, factPaths, {
53
+ governedPercent: cov?.governed?.percent ?? null,
54
+ populatedLayerCount: Array.isArray(cov?.layers)
55
+ ? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
56
+ : null,
57
+ classifiedFiles: cov?.governed?.classifiedFiles ?? null,
58
+ }),
52
59
  };
53
60
  }
54
61