arkgate 2.9.2 → 2.11.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 (50) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +3 -4
  4. package/bin/ark-check.mjs +41 -16
  5. package/bin/ark-mcp.mjs +335 -111
  6. package/bin/ark.mjs +35 -5
  7. package/bin/lib/agent-gates.mjs +161 -8
  8. package/bin/lib/architecture-scan.mjs +23 -1
  9. package/bin/lib/auto-patch.mjs +264 -0
  10. package/bin/lib/baseline-key.mjs +17 -0
  11. package/bin/lib/config-warnings.mjs +22 -0
  12. package/bin/lib/core-layers.mjs +7 -0
  13. package/bin/lib/core-ratchet.mjs +3 -7
  14. package/bin/lib/doctor-plan.mjs +83 -5
  15. package/bin/lib/port-proof.mjs +309 -0
  16. package/bin/lib/prepare-write.mjs +130 -0
  17. package/bin/lib/remediation.mjs +21 -0
  18. package/bin/lib/safety-diagnostics.mjs +263 -0
  19. package/bin/lib/scan-files.mjs +51 -6
  20. package/bin/lib/violations.mjs +3 -3
  21. package/dist/index.cjs +115 -11
  22. package/dist/index.cjs.map +1 -1
  23. package/dist/index.d.cts +5 -3
  24. package/dist/index.d.ts +5 -3
  25. package/dist/index.js +115 -11
  26. package/dist/index.js.map +1 -1
  27. package/dist/nestjs/index.cjs +18 -5
  28. package/dist/nestjs/index.cjs.map +1 -1
  29. package/dist/nestjs/index.d.cts +1 -1
  30. package/dist/nestjs/index.d.ts +1 -1
  31. package/dist/nestjs/index.js +18 -5
  32. package/dist/nestjs/index.js.map +1 -1
  33. package/dist/runtime/index.cjs +115 -11
  34. package/dist/runtime/index.cjs.map +1 -1
  35. package/dist/runtime/index.d.cts +1 -1
  36. package/dist/runtime/index.d.ts +1 -1
  37. package/dist/runtime/index.js +115 -11
  38. package/dist/runtime/index.js.map +1 -1
  39. package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
  40. package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
  41. package/docs/agent-guide.md +15 -1
  42. package/docs/ai-gates.md +63 -5
  43. package/docs/enthusiast/how-to-agent-gates.md +8 -0
  44. package/docs/enthusiast/reference-commands.md +1 -1
  45. package/docs/package-surface.md +2 -2
  46. package/docs/production-hardening.md +5 -0
  47. package/package.json +6 -2
  48. package/server.json +2 -2
  49. package/templates/skills/ark-explain.md +1 -1
  50. package/templates/skills/ark-loop.md +2 -1
package/bin/ark.mjs CHANGED
@@ -32,22 +32,33 @@ function parseArgs(argv) {
32
32
  strict: true,
33
33
  install: true,
34
34
  help: false,
35
+ version: false,
36
+ };
37
+
38
+ const requireValue = (flag, index) => {
39
+ const value = argv[index + 1];
40
+ if (value === undefined || value.startsWith('-')) {
41
+ throw new Error(`Missing value for ${flag}. Run ark --help for usage.`);
42
+ }
43
+ return value;
35
44
  };
36
45
 
37
46
  // Scan from the first user token (index 2) so a leading flag like `ark --help` is
38
47
  // recognized: the command is the first NON-dash argument, not blindly argv[2].
39
48
  for (let i = 2; i < argv.length; i += 1) {
40
49
  const arg = argv[i];
41
- if (arg === '--root') args.root = path.resolve(argv[++i]);
50
+ if (arg === '--root') args.root = path.resolve(requireValue(arg, i++));
42
51
  else if (arg === '--yes' || arg === '-y') args.yes = true;
43
52
  else if (arg === '--force') args.force = true;
44
53
  else if (arg === '--no-strict') args.strict = false;
45
54
  else if (arg === '--no-install') args.install = false;
46
- else if (arg === '--preset') args.preset = argv[++i];
47
- else if (arg === '--archetype') args.archetype = argv[++i];
48
- else if (arg === '--tools') args.tools = argv[++i];
55
+ else if (arg === '--preset') args.preset = requireValue(arg, i++);
56
+ else if (arg === '--archetype') args.archetype = requireValue(arg, i++);
57
+ else if (arg === '--tools') args.tools = requireValue(arg, i++);
49
58
  else if (arg === '--help' || arg === '-h' || arg === 'help') args.help = true;
59
+ else if (arg === '--version' || arg === '-V') args.version = true;
50
60
  else if (!arg.startsWith('-') && args.command === undefined) args.command = arg;
61
+ else throw new Error(`Unknown argument: ${arg}. Run ark --help for usage.`);
51
62
  }
52
63
 
53
64
  return args;
@@ -88,6 +99,15 @@ Non-interactive (no TTY): uses the same defaults as --yes — never calls readli
88
99
  `;
89
100
  }
90
101
 
102
+ function cliVersion() {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
105
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
106
+ } catch {
107
+ return 'unknown';
108
+ }
109
+ }
110
+
91
111
  // The package-manager command that adds arkgate as a dev dependency.
92
112
  // Prefer an explicit version/range when pin already chose one (avoid pin=^2.9.0 then
93
113
  // `npm i arkgate@latest` rewriting package.json to a different range).
@@ -627,7 +647,17 @@ async function start(args) {
627
647
  }
628
648
 
629
649
  async function main() {
630
- const args = parseArgs(process.argv);
650
+ let args;
651
+ try {
652
+ args = parseArgs(process.argv);
653
+ } catch (error) {
654
+ console.error(error instanceof Error ? error.message : String(error));
655
+ return 2;
656
+ }
657
+ if (args.version) {
658
+ console.log(cliVersion());
659
+ return 0;
660
+ }
631
661
  if (args.help || !args.command) {
632
662
  console.log(usage());
633
663
  return 0;
@@ -22,7 +22,7 @@ import {
22
22
  createElevenLayerConfig,
23
23
  applyFrameworkLayoutOverlays,
24
24
  } from '../ark-shared.mjs';
25
- import { CORE_LAYER_NAMES } from './core-ratchet.mjs';
25
+ import { CORE_LAYER_NAMES } from './core-layers.mjs';
26
26
  import { falseGreenAdoptionGap } from './field-install.mjs';
27
27
  import {
28
28
  assessCodexHomeMcp,
@@ -184,7 +184,11 @@ export function hasArkWorkflow(root) {
184
184
  .some((file) => {
185
185
  try {
186
186
  const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
187
- return /\bark-check\b/.test(content) || /\bcheck:architecture\b/.test(content);
187
+ return (
188
+ /\bark-check\b/.test(content) ||
189
+ /\bcheck:architecture\b/.test(content) ||
190
+ /\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
191
+ );
188
192
  } catch {
189
193
  return false;
190
194
  }
@@ -362,8 +366,8 @@ export function checkArgsForRoot(root, { requireGates = false } = {}) {
362
366
  const baselineFlag = fs.existsSync(path.join(root, '.ark-baseline.json'))
363
367
  ? ' --baseline .ark-baseline.json'
364
368
  : '';
365
- const gatesFlag = requireGates ? ' --require-gates' : '';
366
- return `--root . --config ark.config.json --strict-config${baselineFlag}${gatesFlag}`;
369
+ const profile = requireGates ? '--strict' : '--strict-config';
370
+ return `--root . --config ark.config.json ${profile}${baselineFlag}`;
367
371
  }
368
372
 
369
373
  // Field-install helpers live in field-install.mjs (keep agent-gates scannable).
@@ -702,7 +706,9 @@ export function claudeSettings(root) {
702
706
  hooks: [
703
707
  {
704
708
  type: 'command',
705
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
709
+ // W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
710
+ // (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
711
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
706
712
  },
707
713
  ],
708
714
  },
@@ -758,7 +764,8 @@ export function grokHooks(root) {
758
764
  {
759
765
  type: 'command',
760
766
  timeout: 30,
761
- command: `${runner} ${PREFERRED_MCP_BIN} --hook --root "${grokRoot}" --config ark.config.json`,
767
+ // W4: --hook-repair structured autoPatch on deny (hard block still).
768
+ command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
762
769
  },
763
770
  ],
764
771
  },
@@ -1284,15 +1291,160 @@ function collectCiWorkflowTexts(root) {
1284
1291
  return texts;
1285
1292
  }
1286
1293
 
1294
+ /**
1295
+ * W5 — Write-path capability surface for doctor (stable additive JSON).
1296
+ *
1297
+ * Detects whether installed agent gates expose:
1298
+ * - MCP prepare-write / validate_code (autoPatch) tools
1299
+ * - PreToolUse hook in reject-only vs repair mode (--hook-repair / ARK_HOOK_REPAIR)
1300
+ *
1301
+ * Never claims silent apply; "repair" means host can re-inject a patch after hard deny.
1302
+ *
1303
+ * @returns {{
1304
+ * mode: 'repair' | 'reject-only' | 'mcp-only' | 'none',
1305
+ * prepareWrite: boolean,
1306
+ * autoPatch: boolean,
1307
+ * hookPresent: boolean,
1308
+ * hookRepair: boolean,
1309
+ * mcpPresent: boolean,
1310
+ * evidence: string[],
1311
+ * gap: null | { id: string, severity: string, message: string, fix: string },
1312
+ * }}
1313
+ */
1314
+ export function detectWritePathCapabilities(root) {
1315
+ const evidence = [];
1316
+ let hookPresent = false;
1317
+ let hookRepair = false;
1318
+
1319
+ const hookFiles = [
1320
+ '.claude/settings.json',
1321
+ '.grok/hooks/ark-write-gate.json',
1322
+ ];
1323
+ for (const rel of hookFiles) {
1324
+ const abs = path.join(root, rel);
1325
+ if (!fs.existsSync(abs)) continue;
1326
+ let text = '';
1327
+ try {
1328
+ text = fs.readFileSync(abs, 'utf8');
1329
+ } catch {
1330
+ continue;
1331
+ }
1332
+ // PreToolUse / write-gate command referencing ark(-gate)?-mcp --hook
1333
+ if (
1334
+ /--hook\b/.test(text) ||
1335
+ /\b(ark|arkgate)-mcp\b[\s\S]{0,80}--hook\b/.test(text) ||
1336
+ /\b--hook\b[\s\S]{0,80}\b(ark|arkgate)-mcp\b/.test(text)
1337
+ ) {
1338
+ hookPresent = true;
1339
+ evidence.push(rel);
1340
+ }
1341
+ if (
1342
+ /--hook-repair\b/.test(text) ||
1343
+ /ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text)
1344
+ ) {
1345
+ hookRepair = true;
1346
+ if (!evidence.includes(rel)) evidence.push(rel);
1347
+ }
1348
+ }
1349
+
1350
+ let mcpPresent = false;
1351
+ const mcpFiles = ['.mcp.json', '.cursor/mcp.json', '.grok/config.toml'];
1352
+ for (const rel of mcpFiles) {
1353
+ const abs = path.join(root, rel);
1354
+ if (!fs.existsSync(abs)) continue;
1355
+ let text = '';
1356
+ try {
1357
+ text = fs.readFileSync(abs, 'utf8');
1358
+ } catch {
1359
+ continue;
1360
+ }
1361
+ if (
1362
+ /\b(ark|arkgate)-mcp\b/.test(text) ||
1363
+ /mcp_servers\.ark\b/.test(text) ||
1364
+ /"ark"\s*:\s*\{/.test(text) ||
1365
+ /mcpServers[\s\S]*\bark\b/.test(text)
1366
+ ) {
1367
+ mcpPresent = true;
1368
+ evidence.push(rel);
1369
+ }
1370
+ }
1371
+
1372
+ // Package tools when MCP is wired: ark_prepare_write + validate_code(autoPatch).
1373
+ // Hook repair emits machine-readable autoPatch without silent write.
1374
+ const prepareWrite = mcpPresent;
1375
+ const autoPatch = mcpPresent || hookRepair;
1376
+
1377
+ /** @type {'repair' | 'reject-only' | 'mcp-only' | 'none'} */
1378
+ let mode = 'none';
1379
+ if (hookPresent && hookRepair) mode = 'repair';
1380
+ else if (hookPresent && !hookRepair) mode = 'reject-only';
1381
+ else if (mcpPresent) mode = 'mcp-only';
1382
+
1383
+ let gap = null;
1384
+ if (mode === 'none') {
1385
+ gap = {
1386
+ id: 'write-path-none',
1387
+ severity: 'warn',
1388
+ message:
1389
+ 'Write path is not installed — no PreToolUse hook and no Ark MCP. Agents write without architecture gate or prepare-write.',
1390
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
1391
+ };
1392
+ } else if (mode === 'reject-only') {
1393
+ gap = {
1394
+ id: 'write-path-reject-only',
1395
+ severity: 'info',
1396
+ message: mcpPresent
1397
+ ? 'PreToolUse hook is reject-only (hard block, no ARK_REPAIR_JSON). MCP still exposes prepare-write/autoPatch — enable --hook-repair so the write boundary itself can re-inject patches.'
1398
+ : 'Write path is reject-only (hard block with prose; no repair payload). Enable --hook-repair or ARK_HOOK_REPAIR=1 so hosts can re-inject patches without full re-draft.',
1399
+ fix: arkCommand(
1400
+ root,
1401
+ 'ark-check',
1402
+ '--install-agent-gates --tools claude,grok --force'
1403
+ ),
1404
+ };
1405
+ } else if (mode === 'mcp-only') {
1406
+ gap = {
1407
+ id: 'write-path-mcp-only',
1408
+ severity: 'info',
1409
+ message:
1410
+ 'MCP exposes prepare-write / autoPatch tools, but no PreToolUse write hook is installed — enforcement is advisory unless the agent calls tools.',
1411
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates --tools claude,grok'),
1412
+ };
1413
+ }
1414
+
1415
+ return {
1416
+ mode,
1417
+ prepareWrite,
1418
+ autoPatch,
1419
+ hookPresent,
1420
+ hookRepair,
1421
+ mcpPresent,
1422
+ evidence: [...new Set(evidence)],
1423
+ gap,
1424
+ };
1425
+ }
1426
+
1287
1427
  /**
1288
1428
  * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
1289
- * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null }}
1429
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
1290
1430
  */
1291
1431
  export function collectAdoptionGaps(root, config, coverage) {
1292
1432
  const gaps = [];
1293
1433
  const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
1294
1434
  const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
1295
1435
 
1436
+ // --- Write path: prepare-write / autoPatch / reject-only (W5) ---
1437
+ const writePath = detectWritePathCapabilities(root);
1438
+ // Only surface write-path gaps when the project has adopted gates (or has partial install).
1439
+ // Producer package tree always has templates — still report capability for dogfood honesty.
1440
+ if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
1441
+ // Producer may be repair-capable via own templates; still useful. Skip "none" on pure
1442
+ // consumer repos with zero Ark files? missingGates already covers that.
1443
+ if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
1444
+ gaps.push(writePath.gap);
1445
+ }
1446
+ }
1447
+
1296
1448
  // --- Repo MCP dual-bin ---
1297
1449
  const dualMcp = brokenMcpGateFiles(root);
1298
1450
  const mcp = {
@@ -1609,6 +1761,7 @@ export function collectAdoptionGaps(root, config, coverage) {
1609
1761
  layerBalance,
1610
1762
  deployPath,
1611
1763
  contractFalseGreen,
1764
+ writePath,
1612
1765
  };
1613
1766
  }
1614
1767
 
@@ -1973,4 +2126,4 @@ export function runInstallAgentGates(args) {
1973
2126
  }
1974
2127
  }
1975
2128
  warnLockfileConflict(root);
1976
- }
2129
+ }
@@ -25,6 +25,7 @@ import {
25
25
  textOfModuleSpecifier,
26
26
  typeOnlyExportNames,
27
27
  } from './ast-scan.mjs';
28
+ import { provePortProofInject } from './port-proof.mjs';
28
29
  import {
29
30
  intentLayersFromManifest,
30
31
  layerForIntent,
@@ -33,6 +34,7 @@ import {
33
34
  } from './config-warnings.mjs';
34
35
  import { detectCycles } from './graph-cycles.mjs';
35
36
  import { normalize } from './scan-files.mjs';
37
+ import { collectSafetyDiagnostics } from './safety-diagnostics.mjs';
36
38
  import {
37
39
  createCompilerOptionsLookup,
38
40
  createModuleResolutionHost,
@@ -186,6 +188,8 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
186
188
 
187
189
  const violations = [];
188
190
  const warnings = collectConfigWarnings(root, config, files, rules, manifest);
191
+ const safety = collectSafetyDiagnostics(ts, root, config, files);
192
+ warnings.push(...safety.warnings);
189
193
  const cacheKey = args.noCache ? undefined : scanCacheKey(root, args);
190
194
  const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
191
195
  const nextCacheFiles = {};
@@ -264,6 +268,23 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
264
268
  !targetCached?.hasTopLevelSideEffects &&
265
269
  named.every((n) => targetTypeNames.has(n));
266
270
  const peerIsolation = Boolean(rule.peerIsolation);
271
+ // W6: port-proof eligibility (value import only; fail-closed static proof).
272
+ let portProofEligible = false;
273
+ if (
274
+ !edge.typeOnly &&
275
+ !peerIsolation &&
276
+ edge.kind === 'import' &&
277
+ !targetTypeOnlyExports &&
278
+ !namedBindingsTypeOnly
279
+ ) {
280
+ try {
281
+ const srcText = fs.readFileSync(file, 'utf8');
282
+ const proof = provePortProofInject(ts, srcText, { filePath: file });
283
+ portProofEligible = Boolean(proof.eligible);
284
+ } catch {
285
+ portProofEligible = false;
286
+ }
287
+ }
267
288
  violations.push({
268
289
  ruleId: 'LAYER_IMPORT_VIOLATION',
269
290
  file: relFile,
@@ -275,6 +296,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
275
296
  ...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
276
297
  ...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
277
298
  ...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
299
+ ...(portProofEligible ? { portProofEligible: true } : {}),
278
300
  ...(edge.kind ? { edgeKind: edge.kind } : {}),
279
301
  ...(peerIsolation ? { peerIsolation: true } : {}),
280
302
  message:
@@ -307,5 +329,5 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
307
329
  }
308
330
  }
309
331
 
310
- return { violations, warnings };
332
+ return { violations, warnings, safety: safety.report };
311
333
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * W1 — Write-boundary autoPatch for mechanical-safe kinds that can be fixed
3
+ * by rewriting only the file being written (import type conversions).
4
+ *
5
+ * Multi-file kinds (type-only-import-move, pure-type-file-relocate) are classified
6
+ * but do not emit a single-file autoPatch — they need agent judgment / multi-file moves.
7
+ *
8
+ * Trust: post-patch revalidation must be green or the patch is discarded.
9
+ * Never invents new mechanical-safe kinds beyond classifyRemediation.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import {
14
+ isTypeOnlyModuleReference,
15
+ namedModuleBindings,
16
+ sourceFileExportsOnlyTypes,
17
+ sourceFileHasTopLevelSideEffects,
18
+ typeOnlyExportNames,
19
+ } from './ast-scan.mjs';
20
+ import { classifyRemediation } from './remediation.mjs';
21
+
22
+ const EXT_CANDIDATES = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', ''];
23
+
24
+ /**
25
+ * True when abs stays under root (no path.relative escape).
26
+ * Empty relative path (abs === root) counts as inside.
27
+ * @param {string} root
28
+ * @param {string} abs
29
+ */
30
+ function isUnderRoot(root, abs) {
31
+ if (!root || !abs) return false;
32
+ const rel = path.relative(path.resolve(root), path.resolve(abs));
33
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
34
+ }
35
+
36
+ export function resolveImportFileAbs(root, fromFilePath, specifier) {
37
+ if (typeof specifier !== 'string' || !specifier) return null;
38
+ if (!specifier.startsWith('./') && !specifier.startsWith('../')) return null;
39
+ if (!fromFilePath || !root) return null;
40
+ const rootAbs = path.resolve(root);
41
+ const fromAbs = path.isAbsolute(fromFilePath)
42
+ ? path.resolve(fromFilePath)
43
+ : path.resolve(rootAbs, fromFilePath);
44
+ // Refuse resolution when the importer itself is outside the project root.
45
+ if (!isUnderRoot(rootAbs, fromAbs)) return null;
46
+ const base = path.resolve(path.dirname(fromAbs), specifier);
47
+ const tryFile = (candidate) => {
48
+ try {
49
+ if (!isUnderRoot(rootAbs, candidate)) return null;
50
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
51
+ } catch {
52
+ /* continue */
53
+ }
54
+ return null;
55
+ };
56
+ for (const ext of EXT_CANDIDATES) {
57
+ const hit = tryFile(base + ext);
58
+ if (hit) return hit;
59
+ }
60
+ for (const ext of EXT_CANDIDATES) {
61
+ if (!ext) continue;
62
+ const hit = tryFile(path.join(base, `index${ext}`));
63
+ if (hit) return hit;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Classify a target module for import-type conversion eligibility.
70
+ * @returns {{ pureTypeModule: boolean, typeOnlyNames: Set<string>, hasTopLevelSideEffects: boolean } | null}
71
+ */
72
+ export function inspectTargetModule(ts, targetSource) {
73
+ if (!ts || typeof targetSource !== 'string') return null;
74
+ const sf = ts.createSourceFile('target.ts', targetSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
75
+ const pureTypeModule = sourceFileExportsOnlyTypes(ts, sf);
76
+ const typeOnlyNames = new Set(typeOnlyExportNames(ts, sf));
77
+ const hasTopLevelSideEffects = sourceFileHasTopLevelSideEffects(ts, sf);
78
+ return { pureTypeModule, typeOnlyNames, hasTopLevelSideEffects };
79
+ }
80
+
81
+ /**
82
+ * Decide remediation kind for converting a non-type-only import of `specifier`
83
+ * with named bindings `bindingNames` (or null for non-named).
84
+ * @returns {{ kind: string, confidence: number } | null}
85
+ */
86
+ export function classifyImportTypeConversion(inspect, bindingNames) {
87
+ if (!inspect) return null;
88
+ if (inspect.pureTypeModule) {
89
+ return {
90
+ kind: 'import-type-from-pure-type-module',
91
+ confidence: 0.85,
92
+ };
93
+ }
94
+ // R6 parity with architecture-scan: refuse import-type convert when target has
95
+ // top-level side effects (would skip runtime init after import type).
96
+ if (inspect.hasTopLevelSideEffects) return null;
97
+ if (Array.isArray(bindingNames) && bindingNames.length > 0) {
98
+ if (bindingNames.every((n) => inspect.typeOnlyNames.has(n))) {
99
+ return {
100
+ kind: 'import-type-of-type-exports',
101
+ confidence: 0.86,
102
+ };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Rewrite eligible static imports/exports to type-only form.
110
+ * @returns {{ source: string, remediationKind: string, confidence: number } | null}
111
+ */
112
+ export function applyImportTypeAutoPatch(ts, source, opts = {}) {
113
+ if (!ts || typeof source !== 'string') return null;
114
+ const { root, filePath, resolveTargetAbs = resolveImportFileAbs } = opts;
115
+ const sf = ts.createSourceFile(
116
+ filePath || 'file.ts',
117
+ source,
118
+ ts.ScriptTarget.Latest,
119
+ true,
120
+ ts.ScriptKind.TS
121
+ );
122
+
123
+ /** @type {Array<{ start: number, end: number, text: string }>} */
124
+ const replacements = [];
125
+ let bestKind = null;
126
+ let bestConfidence = 0;
127
+
128
+ for (const stmt of sf.statements) {
129
+ if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt)) continue;
130
+ if (isTypeOnlyModuleReference(ts, stmt)) continue;
131
+ const specNode = stmt.moduleSpecifier;
132
+ if (!specNode || !ts.isStringLiteralLike(specNode)) continue;
133
+ const specifier = specNode.text;
134
+ const abs = resolveTargetAbs(root, filePath, specifier);
135
+ if (!abs) continue;
136
+ let targetText;
137
+ try {
138
+ targetText = fs.readFileSync(abs, 'utf8');
139
+ } catch {
140
+ continue;
141
+ }
142
+ const inspect = inspectTargetModule(ts, targetText);
143
+ const bindings = namedModuleBindings(ts, stmt);
144
+ // Named imports/re-exports only. Default, namespace, and side-effect imports stay
145
+ // judgment (never auto-convert to import type).
146
+ const conversion = classifyImportTypeConversion(inspect, bindings);
147
+ if (ts.isImportDeclaration(stmt)) {
148
+ const clause = stmt.importClause;
149
+ if (!clause) continue; // side-effect
150
+ if (clause.name) continue; // default import — judgment
151
+ if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) continue;
152
+ }
153
+ if (!conversion) continue;
154
+
155
+ const full = source.slice(stmt.getStart(sf), stmt.getEnd());
156
+ let next = full;
157
+ if (ts.isImportDeclaration(stmt)) {
158
+ // `import { A } from 'x'` → `import type { A } from 'x'`
159
+ // `import { type A, B }` partial already type-only per binding — only full convert
160
+ if (/^\s*import\s+type\b/.test(full)) continue;
161
+ next = full.replace(/^(\s*import)(\s+)/, '$1 type$2');
162
+ } else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
163
+ if (/^\s*export\s+type\b/.test(full)) continue;
164
+ next = full.replace(/^(\s*export)(\s+)/, '$1 type$2');
165
+ }
166
+ if (next === full) continue;
167
+ replacements.push({ start: stmt.getStart(sf), end: stmt.getEnd(), text: next });
168
+ if (conversion.confidence >= bestConfidence) {
169
+ bestConfidence = conversion.confidence;
170
+ bestKind = conversion.kind;
171
+ }
172
+ }
173
+
174
+ if (replacements.length === 0 || !bestKind) return null;
175
+ // Apply from end so offsets stay valid
176
+ replacements.sort((a, b) => b.start - a.start);
177
+ let out = source;
178
+ for (const r of replacements) {
179
+ out = out.slice(0, r.start) + r.text + out.slice(r.end);
180
+ }
181
+ if (out === source) return null;
182
+ return {
183
+ source: out,
184
+ remediationKind: bestKind,
185
+ confidence: bestConfidence,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Run gate validation, try mechanical-safe single-file autoPatch, re-validate.
191
+ *
192
+ * @param {{
193
+ * source: string,
194
+ * filePath?: string,
195
+ * root: string,
196
+ * ts: object,
197
+ * validate: (source: string) => { valid: boolean, violations?: any[] },
198
+ * resolveTargetAbs?: Function,
199
+ * }} opts
200
+ */
201
+ export function validateWithAutoPatch(opts) {
202
+ const { source, filePath, root, ts, validate, resolveTargetAbs } = opts;
203
+ const result = validate(source);
204
+ const base = {
205
+ valid: Boolean(result.valid),
206
+ violations: Array.isArray(result.violations) ? result.violations : [],
207
+ };
208
+
209
+ // Attach remediation classification for LAYER_IMPORT violations when flags known
210
+ const violations = base.violations.map((v) => {
211
+ const verdict = classifyRemediation({
212
+ ruleId: v.ruleId || v.code,
213
+ typeOnly: v.typeOnly,
214
+ sourcePureTypeModule: v.sourcePureTypeModule,
215
+ targetTypeOnlyExports: v.targetTypeOnlyExports,
216
+ namedBindingsTypeOnly: v.namedBindingsTypeOnly,
217
+ portProofEligible: v.portProofEligible ?? v.details?.portProofEligible,
218
+ peerIsolation: v.peerIsolation ?? v.details?.peerIsolation,
219
+ edgeKind: v.edgeKind ?? v.details?.importKind,
220
+ fromLayer: v.fromLayer,
221
+ toLayer: v.toLayer,
222
+ target: v.target,
223
+ });
224
+ return {
225
+ ...v,
226
+ remediationClass: verdict.class,
227
+ remediationKind: verdict.remediationKind,
228
+ remediationConfidence: verdict.confidence,
229
+ };
230
+ });
231
+
232
+ if (base.valid) {
233
+ return { valid: true, violations: [], autoPatch: null };
234
+ }
235
+
236
+ // Write-path autoPatch: import-type mechanical-safe only (W1).
237
+ // W6 port-proof inject is judgment (signature change) — never re-inject on write path.
238
+ const attempt = applyImportTypeAutoPatch(ts, source, {
239
+ root,
240
+ filePath,
241
+ resolveTargetAbs: resolveTargetAbs || resolveImportFileAbs,
242
+ });
243
+
244
+ if (!attempt) {
245
+ return { valid: false, violations, autoPatch: null };
246
+ }
247
+
248
+ const after = validate(attempt.source);
249
+ if (!after.valid) {
250
+ // Discard — never return an unvalidated patch
251
+ return { valid: false, violations, autoPatch: null };
252
+ }
253
+
254
+ return {
255
+ valid: false,
256
+ violations,
257
+ autoPatch: {
258
+ source: attempt.source,
259
+ remediationKind: attempt.remediationKind,
260
+ confidence: attempt.confidence,
261
+ valid: true,
262
+ },
263
+ };
264
+ }
@@ -21,3 +21,20 @@ export function baselineKey(violation) {
21
21
  violation.target ?? '',
22
22
  ].join('|');
23
23
  }
24
+ /**
25
+ * Stable per-occurrence keys for a list of violations.
26
+ *
27
+ * The first occurrence keeps the historical v1 key so existing baselines remain
28
+ * compatible. Repeated violations with the same identity gain a `#N` suffix;
29
+ * adding a second identical violation is therefore new debt instead of being
30
+ * silently suppressed by the first occurrence's key.
31
+ */
32
+ export function baselineOccurrenceKeys(violations) {
33
+ const counts = new Map();
34
+ return violations.map((violation) => {
35
+ const base = baselineKey(violation);
36
+ const occurrence = (counts.get(base) ?? 0) + 1;
37
+ counts.set(base, occurrence);
38
+ return occurrence === 1 ? base : `${base}#${occurrence}`;
39
+ });
40
+ }
@@ -55,6 +55,28 @@ export function configWarning(ruleId, message, extra = {}) {
55
55
 
56
56
  export function collectConfigWarnings(root, config, files, rules, manifest) {
57
57
  const warnings = [];
58
+ if (
59
+ config.dynamicImportAllowlist !== undefined &&
60
+ (!Array.isArray(config.dynamicImportAllowlist) ||
61
+ config.dynamicImportAllowlist.some((entry) => typeof entry !== 'string'))
62
+ ) {
63
+ warnings.push(
64
+ configWarning(
65
+ 'CONFIG_INVALID_DYNAMIC_IMPORT_ALLOWLIST',
66
+ 'dynamicImportAllowlist must be an array of file globs.'
67
+ )
68
+ );
69
+ }
70
+ if (config.safety !== undefined && (config.safety === null || typeof config.safety !== 'object' || Array.isArray(config.safety))) {
71
+ warnings.push(configWarning('CONFIG_INVALID_SAFETY', 'safety must be an object.'));
72
+ } else if (config.safety) {
73
+ for (const key of ['maxTsSuppressions', 'maxAnyCasts']) {
74
+ const value = config.safety[key];
75
+ if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
76
+ warnings.push(configWarning('CONFIG_INVALID_SAFETY_THRESHOLD', `safety.${key} must be a non-negative integer.`));
77
+ }
78
+ }
79
+ }
58
80
  const layers = Array.isArray(config.layers) ? config.layers : [];
59
81
  const manifestLayers = Array.isArray(manifest?.architecture?.layers)
60
82
  ? manifest.architecture.layers
@@ -0,0 +1,7 @@
1
+ /** Core layers whose optionality matters once they match files. */
2
+ export const CORE_LAYER_NAMES = new Set([
3
+ 'DomainModel',
4
+ 'ApplicationOrchestration',
5
+ 'PresentationAdapters',
6
+ 'PersistenceAdapters',
7
+ ]);