arkgate 2.9.2 → 2.10.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.
- package/CHANGELOG.md +44 -0
- package/README.md +6 -0
- package/bin/ark-mcp.mjs +282 -102
- package/bin/lib/agent-gates.mjs +152 -3
- package/bin/lib/architecture-scan.mjs +19 -0
- package/bin/lib/auto-patch.mjs +264 -0
- package/bin/lib/doctor-plan.mjs +54 -0
- package/bin/lib/port-proof.mjs +309 -0
- package/bin/lib/prepare-write.mjs +130 -0
- package/bin/lib/remediation.mjs +21 -0
- package/dist/index.cjs +13 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +13 -4
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +13 -4
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.js +13 -4
- package/dist/runtime/index.js.map +1 -1
- package/docs/agent-guide.md +14 -0
- package/docs/ai-gates.md +43 -3
- package/docs/enthusiast/how-to-agent-gates.md +8 -0
- package/docs/enthusiast/reference-commands.md +1 -1
- package/package.json +2 -1
- package/server.json +2 -2
- package/templates/skills/ark-explain.md +1 -1
- package/templates/skills/ark-loop.md +2 -1
package/bin/lib/agent-gates.mjs
CHANGED
|
@@ -702,7 +702,9 @@ export function claudeSettings(root) {
|
|
|
702
702
|
hooks: [
|
|
703
703
|
{
|
|
704
704
|
type: 'command',
|
|
705
|
-
|
|
705
|
+
// W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
|
|
706
|
+
// (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
|
|
707
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
706
708
|
},
|
|
707
709
|
],
|
|
708
710
|
},
|
|
@@ -758,7 +760,8 @@ export function grokHooks(root) {
|
|
|
758
760
|
{
|
|
759
761
|
type: 'command',
|
|
760
762
|
timeout: 30,
|
|
761
|
-
|
|
763
|
+
// W4: --hook-repair → structured autoPatch on deny (hard block still).
|
|
764
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
|
|
762
765
|
},
|
|
763
766
|
],
|
|
764
767
|
},
|
|
@@ -1284,15 +1287,160 @@ function collectCiWorkflowTexts(root) {
|
|
|
1284
1287
|
return texts;
|
|
1285
1288
|
}
|
|
1286
1289
|
|
|
1290
|
+
/**
|
|
1291
|
+
* W5 — Write-path capability surface for doctor (stable additive JSON).
|
|
1292
|
+
*
|
|
1293
|
+
* Detects whether installed agent gates expose:
|
|
1294
|
+
* - MCP prepare-write / validate_code (autoPatch) tools
|
|
1295
|
+
* - PreToolUse hook in reject-only vs repair mode (--hook-repair / ARK_HOOK_REPAIR)
|
|
1296
|
+
*
|
|
1297
|
+
* Never claims silent apply; "repair" means host can re-inject a patch after hard deny.
|
|
1298
|
+
*
|
|
1299
|
+
* @returns {{
|
|
1300
|
+
* mode: 'repair' | 'reject-only' | 'mcp-only' | 'none',
|
|
1301
|
+
* prepareWrite: boolean,
|
|
1302
|
+
* autoPatch: boolean,
|
|
1303
|
+
* hookPresent: boolean,
|
|
1304
|
+
* hookRepair: boolean,
|
|
1305
|
+
* mcpPresent: boolean,
|
|
1306
|
+
* evidence: string[],
|
|
1307
|
+
* gap: null | { id: string, severity: string, message: string, fix: string },
|
|
1308
|
+
* }}
|
|
1309
|
+
*/
|
|
1310
|
+
export function detectWritePathCapabilities(root) {
|
|
1311
|
+
const evidence = [];
|
|
1312
|
+
let hookPresent = false;
|
|
1313
|
+
let hookRepair = false;
|
|
1314
|
+
|
|
1315
|
+
const hookFiles = [
|
|
1316
|
+
'.claude/settings.json',
|
|
1317
|
+
'.grok/hooks/ark-write-gate.json',
|
|
1318
|
+
];
|
|
1319
|
+
for (const rel of hookFiles) {
|
|
1320
|
+
const abs = path.join(root, rel);
|
|
1321
|
+
if (!fs.existsSync(abs)) continue;
|
|
1322
|
+
let text = '';
|
|
1323
|
+
try {
|
|
1324
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
1325
|
+
} catch {
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
// PreToolUse / write-gate command referencing ark(-gate)?-mcp --hook
|
|
1329
|
+
if (
|
|
1330
|
+
/--hook\b/.test(text) ||
|
|
1331
|
+
/\b(ark|arkgate)-mcp\b[\s\S]{0,80}--hook\b/.test(text) ||
|
|
1332
|
+
/\b--hook\b[\s\S]{0,80}\b(ark|arkgate)-mcp\b/.test(text)
|
|
1333
|
+
) {
|
|
1334
|
+
hookPresent = true;
|
|
1335
|
+
evidence.push(rel);
|
|
1336
|
+
}
|
|
1337
|
+
if (
|
|
1338
|
+
/--hook-repair\b/.test(text) ||
|
|
1339
|
+
/ARK_HOOK_REPAIR\s*=\s*['"]?(1|true|yes|on)/i.test(text)
|
|
1340
|
+
) {
|
|
1341
|
+
hookRepair = true;
|
|
1342
|
+
if (!evidence.includes(rel)) evidence.push(rel);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
let mcpPresent = false;
|
|
1347
|
+
const mcpFiles = ['.mcp.json', '.cursor/mcp.json', '.grok/config.toml'];
|
|
1348
|
+
for (const rel of mcpFiles) {
|
|
1349
|
+
const abs = path.join(root, rel);
|
|
1350
|
+
if (!fs.existsSync(abs)) continue;
|
|
1351
|
+
let text = '';
|
|
1352
|
+
try {
|
|
1353
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
1354
|
+
} catch {
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (
|
|
1358
|
+
/\b(ark|arkgate)-mcp\b/.test(text) ||
|
|
1359
|
+
/mcp_servers\.ark\b/.test(text) ||
|
|
1360
|
+
/"ark"\s*:\s*\{/.test(text) ||
|
|
1361
|
+
/mcpServers[\s\S]*\bark\b/.test(text)
|
|
1362
|
+
) {
|
|
1363
|
+
mcpPresent = true;
|
|
1364
|
+
evidence.push(rel);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Package tools when MCP is wired: ark_prepare_write + validate_code(autoPatch).
|
|
1369
|
+
// Hook repair emits machine-readable autoPatch without silent write.
|
|
1370
|
+
const prepareWrite = mcpPresent;
|
|
1371
|
+
const autoPatch = mcpPresent || hookRepair;
|
|
1372
|
+
|
|
1373
|
+
/** @type {'repair' | 'reject-only' | 'mcp-only' | 'none'} */
|
|
1374
|
+
let mode = 'none';
|
|
1375
|
+
if (hookPresent && hookRepair) mode = 'repair';
|
|
1376
|
+
else if (hookPresent && !hookRepair) mode = 'reject-only';
|
|
1377
|
+
else if (mcpPresent) mode = 'mcp-only';
|
|
1378
|
+
|
|
1379
|
+
let gap = null;
|
|
1380
|
+
if (mode === 'none') {
|
|
1381
|
+
gap = {
|
|
1382
|
+
id: 'write-path-none',
|
|
1383
|
+
severity: 'warn',
|
|
1384
|
+
message:
|
|
1385
|
+
'Write path is not installed — no PreToolUse hook and no Ark MCP. Agents write without architecture gate or prepare-write.',
|
|
1386
|
+
fix: arkCommand(root, 'ark-check', '--install-agent-gates'),
|
|
1387
|
+
};
|
|
1388
|
+
} else if (mode === 'reject-only') {
|
|
1389
|
+
gap = {
|
|
1390
|
+
id: 'write-path-reject-only',
|
|
1391
|
+
severity: 'info',
|
|
1392
|
+
message: mcpPresent
|
|
1393
|
+
? '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.'
|
|
1394
|
+
: '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.',
|
|
1395
|
+
fix: arkCommand(
|
|
1396
|
+
root,
|
|
1397
|
+
'ark-check',
|
|
1398
|
+
'--install-agent-gates --tools claude,grok --force'
|
|
1399
|
+
),
|
|
1400
|
+
};
|
|
1401
|
+
} else if (mode === 'mcp-only') {
|
|
1402
|
+
gap = {
|
|
1403
|
+
id: 'write-path-mcp-only',
|
|
1404
|
+
severity: 'info',
|
|
1405
|
+
message:
|
|
1406
|
+
'MCP exposes prepare-write / autoPatch tools, but no PreToolUse write hook is installed — enforcement is advisory unless the agent calls tools.',
|
|
1407
|
+
fix: arkCommand(root, 'ark-check', '--install-agent-gates --tools claude,grok'),
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return {
|
|
1412
|
+
mode,
|
|
1413
|
+
prepareWrite,
|
|
1414
|
+
autoPatch,
|
|
1415
|
+
hookPresent,
|
|
1416
|
+
hookRepair,
|
|
1417
|
+
mcpPresent,
|
|
1418
|
+
evidence: [...new Set(evidence)],
|
|
1419
|
+
gap,
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1287
1423
|
/**
|
|
1288
1424
|
* 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 }}
|
|
1425
|
+
* @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
1426
|
*/
|
|
1291
1427
|
export function collectAdoptionGaps(root, config, coverage) {
|
|
1292
1428
|
const gaps = [];
|
|
1293
1429
|
const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
|
|
1294
1430
|
const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
|
|
1295
1431
|
|
|
1432
|
+
// --- Write path: prepare-write / autoPatch / reject-only (W5) ---
|
|
1433
|
+
const writePath = detectWritePathCapabilities(root);
|
|
1434
|
+
// Only surface write-path gaps when the project has adopted gates (or has partial install).
|
|
1435
|
+
// Producer package tree always has templates — still report capability for dogfood honesty.
|
|
1436
|
+
if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
|
|
1437
|
+
// Producer may be repair-capable via own templates; still useful. Skip "none" on pure
|
|
1438
|
+
// consumer repos with zero Ark files? missingGates already covers that.
|
|
1439
|
+
if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
|
|
1440
|
+
gaps.push(writePath.gap);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1296
1444
|
// --- Repo MCP dual-bin ---
|
|
1297
1445
|
const dualMcp = brokenMcpGateFiles(root);
|
|
1298
1446
|
const mcp = {
|
|
@@ -1609,6 +1757,7 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
1609
1757
|
layerBalance,
|
|
1610
1758
|
deployPath,
|
|
1611
1759
|
contractFalseGreen,
|
|
1760
|
+
writePath,
|
|
1612
1761
|
};
|
|
1613
1762
|
}
|
|
1614
1763
|
|
|
@@ -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,
|
|
@@ -264,6 +265,23 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
264
265
|
!targetCached?.hasTopLevelSideEffects &&
|
|
265
266
|
named.every((n) => targetTypeNames.has(n));
|
|
266
267
|
const peerIsolation = Boolean(rule.peerIsolation);
|
|
268
|
+
// W6: port-proof eligibility (value import only; fail-closed static proof).
|
|
269
|
+
let portProofEligible = false;
|
|
270
|
+
if (
|
|
271
|
+
!edge.typeOnly &&
|
|
272
|
+
!peerIsolation &&
|
|
273
|
+
edge.kind === 'import' &&
|
|
274
|
+
!targetTypeOnlyExports &&
|
|
275
|
+
!namedBindingsTypeOnly
|
|
276
|
+
) {
|
|
277
|
+
try {
|
|
278
|
+
const srcText = fs.readFileSync(file, 'utf8');
|
|
279
|
+
const proof = provePortProofInject(ts, srcText, { filePath: file });
|
|
280
|
+
portProofEligible = Boolean(proof.eligible);
|
|
281
|
+
} catch {
|
|
282
|
+
portProofEligible = false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
267
285
|
violations.push({
|
|
268
286
|
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
269
287
|
file: relFile,
|
|
@@ -275,6 +293,7 @@ export function runArchitectureScan({ root, config, manifest, rules, files, ts,
|
|
|
275
293
|
...(targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
|
|
276
294
|
...(sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
|
|
277
295
|
...(namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
|
|
296
|
+
...(portProofEligible ? { portProofEligible: true } : {}),
|
|
278
297
|
...(edge.kind ? { edgeKind: edge.kind } : {}),
|
|
279
298
|
...(peerIsolation ? { peerIsolation: true } : {}),
|
|
280
299
|
message:
|
|
@@ -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
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
collectAdoptionGaps,
|
|
16
16
|
detectSkillGaps,
|
|
17
|
+
detectWritePathCapabilities,
|
|
17
18
|
missingGates,
|
|
18
19
|
staleRunnerGateFiles,
|
|
19
20
|
} from './agent-gates.mjs';
|
|
@@ -268,6 +269,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
268
269
|
const skillGaps = detectSkillGaps(root);
|
|
269
270
|
const staleRunners = staleRunnerGateFiles(root);
|
|
270
271
|
const adoption = collectAdoptionGaps(root, config, cov);
|
|
272
|
+
// Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
|
|
273
|
+
const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
|
|
271
274
|
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
272
275
|
const currentKeys = new Set(violations.map(baselineKey));
|
|
273
276
|
const suppressed = baseline.exists
|
|
@@ -323,6 +326,26 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
323
326
|
gatesMissing,
|
|
324
327
|
skillGaps,
|
|
325
328
|
staleRunnerFiles: staleRunners,
|
|
329
|
+
// W5 — prepare-write / autoPatch / reject-only awareness (stable additive)
|
|
330
|
+
writePath: {
|
|
331
|
+
mode: writePath.mode,
|
|
332
|
+
prepareWrite: writePath.prepareWrite,
|
|
333
|
+
autoPatch: writePath.autoPatch,
|
|
334
|
+
hookPresent: writePath.hookPresent,
|
|
335
|
+
hookRepair: writePath.hookRepair,
|
|
336
|
+
mcpPresent: writePath.mcpPresent,
|
|
337
|
+
evidence: writePath.evidence,
|
|
338
|
+
...(writePath.gap
|
|
339
|
+
? {
|
|
340
|
+
gap: {
|
|
341
|
+
id: writePath.gap.id,
|
|
342
|
+
severity: writePath.gap.severity,
|
|
343
|
+
message: writePath.gap.message,
|
|
344
|
+
fix: writePath.gap.fix,
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
: { gap: null }),
|
|
348
|
+
},
|
|
326
349
|
adoption,
|
|
327
350
|
newHere: showNewHere
|
|
328
351
|
? {
|
|
@@ -469,6 +492,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
469
492
|
}
|
|
470
493
|
}
|
|
471
494
|
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log(color.bold('Write path (agent)'));
|
|
497
|
+
const writePathLabels = {
|
|
498
|
+
repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
|
|
499
|
+
'reject-only': 'reject-only — hard block with prose; no repair payload',
|
|
500
|
+
'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
|
|
501
|
+
none: 'none — no write gate hook and no Ark MCP',
|
|
502
|
+
};
|
|
503
|
+
const wpMark =
|
|
504
|
+
writePath.mode === 'repair'
|
|
505
|
+
? ok
|
|
506
|
+
: writePath.mode === 'none'
|
|
507
|
+
? bad
|
|
508
|
+
: warn;
|
|
509
|
+
line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
|
|
510
|
+
line(
|
|
511
|
+
writePath.prepareWrite ? ok : warn,
|
|
512
|
+
`prepare-write (MCP): ${writePath.prepareWrite ? 'yes' : 'no'}`
|
|
513
|
+
);
|
|
514
|
+
line(
|
|
515
|
+
writePath.autoPatch ? ok : warn,
|
|
516
|
+
`autoPatch surface: ${writePath.autoPatch ? 'yes' : 'no'}`
|
|
517
|
+
);
|
|
518
|
+
if (writePath.gap) {
|
|
519
|
+
line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
|
|
520
|
+
if (writePath.gap.fix) {
|
|
521
|
+
line(' ', color.dim(`Fix: ${writePath.gap.fix}`));
|
|
522
|
+
actions.push(writePath.gap.fix);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
472
526
|
console.log('');
|
|
473
527
|
console.log(color.bold('Gates & skills'));
|
|
474
528
|
if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
|