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.
- package/CHANGELOG.md +87 -0
- package/README.md +12 -2
- package/SECURITY.md +3 -4
- package/bin/ark-check.mjs +41 -16
- package/bin/ark-mcp.mjs +335 -111
- package/bin/ark.mjs +35 -5
- package/bin/lib/agent-gates.mjs +161 -8
- package/bin/lib/architecture-scan.mjs +23 -1
- package/bin/lib/auto-patch.mjs +264 -0
- package/bin/lib/baseline-key.mjs +17 -0
- package/bin/lib/config-warnings.mjs +22 -0
- package/bin/lib/core-layers.mjs +7 -0
- package/bin/lib/core-ratchet.mjs +3 -7
- package/bin/lib/doctor-plan.mjs +83 -5
- package/bin/lib/port-proof.mjs +309 -0
- package/bin/lib/prepare-write.mjs +130 -0
- package/bin/lib/remediation.mjs +21 -0
- package/bin/lib/safety-diagnostics.mjs +263 -0
- package/bin/lib/scan-files.mjs +51 -6
- package/bin/lib/violations.mjs +3 -3
- package/dist/index.cjs +115 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +115 -11
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +18 -5
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +18 -5
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +115 -11
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +115 -11
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
- package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
- package/docs/agent-guide.md +15 -1
- package/docs/ai-gates.md +63 -5
- package/docs/enthusiast/how-to-agent-gates.md +8 -0
- package/docs/enthusiast/reference-commands.md +1 -1
- package/docs/package-surface.md +2 -2
- package/docs/production-hardening.md +5 -0
- package/package.json +6 -2
- package/server.json +2 -2
- package/templates/skills/ark-explain.md +1 -1
- package/templates/skills/ark-loop.md +2 -1
package/bin/lib/core-ratchet.mjs
CHANGED
|
@@ -6,18 +6,14 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { arkCommand } from '../ark-shared.mjs';
|
|
8
8
|
import { computeCoverage } from './doctor-plan.mjs';
|
|
9
|
+
import { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
10
|
+
|
|
11
|
+
export { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* Core layers whose optionality matters once they match files (presets share these names).
|
|
12
15
|
* Used by doctor adoption gaps and `--ratchet-cores`.
|
|
13
16
|
*/
|
|
14
|
-
export const CORE_LAYER_NAMES = new Set([
|
|
15
|
-
'DomainModel',
|
|
16
|
-
'ApplicationOrchestration',
|
|
17
|
-
'PresentationAdapters',
|
|
18
|
-
'PersistenceAdapters',
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
17
|
/**
|
|
22
18
|
* Plan a ratchet of optional→required for core layers that already match files.
|
|
23
19
|
* Empty cores stay optional (avoids false ENFORCE theatre). Pure — does not write disk.
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -14,11 +14,12 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
collectAdoptionGaps,
|
|
16
16
|
detectSkillGaps,
|
|
17
|
+
detectWritePathCapabilities,
|
|
17
18
|
missingGates,
|
|
18
19
|
staleRunnerGateFiles,
|
|
19
20
|
} from './agent-gates.mjs';
|
|
20
21
|
import {
|
|
21
|
-
|
|
22
|
+
baselineOccurrenceKeys,
|
|
22
23
|
readBaseline,
|
|
23
24
|
summarizeViolations,
|
|
24
25
|
violationEdge,
|
|
@@ -268,10 +269,13 @@ 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
|
-
const
|
|
275
|
+
const occurrenceKeys = baselineOccurrenceKeys(violations);
|
|
276
|
+
const currentKeys = new Set(occurrenceKeys);
|
|
273
277
|
const suppressed = baseline.exists
|
|
274
|
-
?
|
|
278
|
+
? occurrenceKeys.filter((key) => baseline.keys.has(key)).length
|
|
275
279
|
: 0;
|
|
276
280
|
const staleBaseline = baseline.exists
|
|
277
281
|
? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
|
|
@@ -323,7 +327,28 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
323
327
|
gatesMissing,
|
|
324
328
|
skillGaps,
|
|
325
329
|
staleRunnerFiles: staleRunners,
|
|
330
|
+
// W5 — prepare-write / autoPatch / reject-only awareness (stable additive)
|
|
331
|
+
writePath: {
|
|
332
|
+
mode: writePath.mode,
|
|
333
|
+
prepareWrite: writePath.prepareWrite,
|
|
334
|
+
autoPatch: writePath.autoPatch,
|
|
335
|
+
hookPresent: writePath.hookPresent,
|
|
336
|
+
hookRepair: writePath.hookRepair,
|
|
337
|
+
mcpPresent: writePath.mcpPresent,
|
|
338
|
+
evidence: writePath.evidence,
|
|
339
|
+
...(writePath.gap
|
|
340
|
+
? {
|
|
341
|
+
gap: {
|
|
342
|
+
id: writePath.gap.id,
|
|
343
|
+
severity: writePath.gap.severity,
|
|
344
|
+
message: writePath.gap.message,
|
|
345
|
+
fix: writePath.gap.fix,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: { gap: null }),
|
|
349
|
+
},
|
|
326
350
|
adoption,
|
|
351
|
+
safety: options.safety,
|
|
327
352
|
newHere: showNewHere
|
|
328
353
|
? {
|
|
329
354
|
show: true,
|
|
@@ -469,6 +494,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
469
494
|
}
|
|
470
495
|
}
|
|
471
496
|
|
|
497
|
+
console.log('');
|
|
498
|
+
console.log(color.bold('Write path (agent)'));
|
|
499
|
+
const writePathLabels = {
|
|
500
|
+
repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
|
|
501
|
+
'reject-only': 'reject-only — hard block with prose; no repair payload',
|
|
502
|
+
'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
|
|
503
|
+
none: 'none — no write gate hook and no Ark MCP',
|
|
504
|
+
};
|
|
505
|
+
const wpMark =
|
|
506
|
+
writePath.mode === 'repair'
|
|
507
|
+
? ok
|
|
508
|
+
: writePath.mode === 'none'
|
|
509
|
+
? bad
|
|
510
|
+
: warn;
|
|
511
|
+
line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
|
|
512
|
+
line(
|
|
513
|
+
writePath.prepareWrite ? ok : warn,
|
|
514
|
+
`prepare-write (MCP): ${writePath.prepareWrite ? 'yes' : 'no'}`
|
|
515
|
+
);
|
|
516
|
+
line(
|
|
517
|
+
writePath.autoPatch ? ok : warn,
|
|
518
|
+
`autoPatch surface: ${writePath.autoPatch ? 'yes' : 'no'}`
|
|
519
|
+
);
|
|
520
|
+
if (writePath.gap) {
|
|
521
|
+
line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
|
|
522
|
+
if (writePath.gap.fix) {
|
|
523
|
+
line(' ', color.dim(`Fix: ${writePath.gap.fix}`));
|
|
524
|
+
actions.push(writePath.gap.fix);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
472
528
|
console.log('');
|
|
473
529
|
console.log(color.bold('Gates & skills'));
|
|
474
530
|
if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
|
|
@@ -538,11 +594,33 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
538
594
|
line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
|
|
539
595
|
}
|
|
540
596
|
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log(color.bold('Safety / bypass resistance'));
|
|
599
|
+
const safety = options.safety;
|
|
600
|
+
if (!safety) {
|
|
601
|
+
line(warn, 'Safety diagnostics unavailable');
|
|
602
|
+
} else {
|
|
603
|
+
const rows = [
|
|
604
|
+
['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
|
|
605
|
+
['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
|
|
606
|
+
['Explicit any casts', safety.anyCasts],
|
|
607
|
+
['InMemory stores in production source', safety.inMemoryProductionStores],
|
|
608
|
+
['Rules with peerIsolation: false', safety.disabledPeerIsolationRules],
|
|
609
|
+
];
|
|
610
|
+
for (const [label, entries] of rows) {
|
|
611
|
+
line(entries.length === 0 ? ok : warn, `${label}: ${entries.length}`);
|
|
612
|
+
}
|
|
613
|
+
if (rows.some(([, entries]) => entries.length > 0)) {
|
|
614
|
+
actions.push('resolve strict safety diagnostics before treating CI as enforcement');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
541
618
|
console.log('');
|
|
542
619
|
if (actions.length === 0) {
|
|
543
620
|
console.log(color.green('✔ Healthy — nothing to do.'));
|
|
544
621
|
} else {
|
|
545
|
-
|
|
546
|
-
|
|
622
|
+
const uniqueActions = [...new Set(actions.filter(Boolean))];
|
|
623
|
+
console.log(color.bold(`Top actions (${uniqueActions.length}):`));
|
|
624
|
+
uniqueActions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
|
|
547
625
|
}
|
|
548
626
|
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W6 — Verified structural transform: port-proof inject binding (single narrow kind).
|
|
3
|
+
*
|
|
4
|
+
* Scope (intentionally tiny — false mechanical-safe is worse than extra judgment):
|
|
5
|
+
* - Exactly one static named value import (no default / namespace / side-effect / export-from)
|
|
6
|
+
* - Binding used ONLY as property-access call receiver: `binding.method(...)`
|
|
7
|
+
* - All uses inside `function` declarations (not module-level, not classes, not arrows)
|
|
8
|
+
* - No require / dynamic import
|
|
9
|
+
*
|
|
10
|
+
* Transform (single file):
|
|
11
|
+
* 1. Remove the import
|
|
12
|
+
* 2. Emit `export type <Port> = { method: (...args: unknown[]) => unknown; ... }`
|
|
13
|
+
* 3. Add `binding: Port` as last parameter of each function that uses it
|
|
14
|
+
* 4. Leave call expressions verbatim (`binding.method(...)`)
|
|
15
|
+
*
|
|
16
|
+
* Static proof of behavior preservation (module-local):
|
|
17
|
+
* If the injected parameter equals the value previously bound by the import, every
|
|
18
|
+
* statement in each rewritten function evaluates identically — call expressions are
|
|
19
|
+
* preserved character-for-character after rebinding. No adapter file is invented;
|
|
20
|
+
* the outer layer must pass the implementation (classic port inject).
|
|
21
|
+
*
|
|
22
|
+
* Fail closed: any unmatched use pattern → not eligible (judgment).
|
|
23
|
+
*/
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} ts typescript module
|
|
28
|
+
* @param {string} source
|
|
29
|
+
* @param {{ filePath?: string, importLocalName?: string, importSpecifier?: string }} [opts]
|
|
30
|
+
* @returns {{ eligible: boolean, reason?: string, bindingName?: string, methods?: string[], functionNames?: string[], specifier?: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function provePortProofInject(ts, source, opts = {}) {
|
|
33
|
+
if (!ts || typeof source !== 'string') {
|
|
34
|
+
return { eligible: false, reason: 'missing-ts-or-source' };
|
|
35
|
+
}
|
|
36
|
+
const sf = ts.createSourceFile(
|
|
37
|
+
opts.filePath || 'file.ts',
|
|
38
|
+
source,
|
|
39
|
+
ts.ScriptTarget.Latest,
|
|
40
|
+
true,
|
|
41
|
+
ts.ScriptKind.TS
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** @type {import('typescript').ImportDeclaration | null} */
|
|
45
|
+
let targetImport = null;
|
|
46
|
+
let bindingName = null;
|
|
47
|
+
let specifier = null;
|
|
48
|
+
let namedImportCount = 0;
|
|
49
|
+
|
|
50
|
+
for (const stmt of sf.statements) {
|
|
51
|
+
if (!ts.isImportDeclaration(stmt)) continue;
|
|
52
|
+
if (stmt.importClause?.isTypeOnly) continue;
|
|
53
|
+
const clause = stmt.importClause;
|
|
54
|
+
if (!clause) continue; // side-effect
|
|
55
|
+
if (clause.name) {
|
|
56
|
+
// default import — never port-proof
|
|
57
|
+
return { eligible: false, reason: 'default-import' };
|
|
58
|
+
}
|
|
59
|
+
const named = clause.namedBindings;
|
|
60
|
+
if (!named || !ts.isNamedImports(named)) {
|
|
61
|
+
return { eligible: false, reason: 'namespace-or-missing-named' };
|
|
62
|
+
}
|
|
63
|
+
if (named.elements.some((el) => el.isTypeOnly)) {
|
|
64
|
+
// partial type-only mixed — not this transform
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (named.elements.length !== 1) {
|
|
68
|
+
return { eligible: false, reason: 'multi-named-import' };
|
|
69
|
+
}
|
|
70
|
+
namedImportCount += 1;
|
|
71
|
+
if (namedImportCount > 1) {
|
|
72
|
+
return { eligible: false, reason: 'multiple-value-imports' };
|
|
73
|
+
}
|
|
74
|
+
const el = named.elements[0];
|
|
75
|
+
bindingName = el.name.text;
|
|
76
|
+
specifier = stmt.moduleSpecifier && ts.isStringLiteralLike(stmt.moduleSpecifier)
|
|
77
|
+
? stmt.moduleSpecifier.text
|
|
78
|
+
: null;
|
|
79
|
+
if (!specifier || (!specifier.startsWith('./') && !specifier.startsWith('../'))) {
|
|
80
|
+
return { eligible: false, reason: 'non-relative-specifier' };
|
|
81
|
+
}
|
|
82
|
+
if (opts.importLocalName && opts.importLocalName !== bindingName) {
|
|
83
|
+
return { eligible: false, reason: 'binding-mismatch' };
|
|
84
|
+
}
|
|
85
|
+
if (opts.importSpecifier && opts.importSpecifier !== specifier) {
|
|
86
|
+
// soft: still allow if only one import
|
|
87
|
+
}
|
|
88
|
+
targetImport = stmt;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!targetImport || !bindingName) {
|
|
92
|
+
return { eligible: false, reason: 'no-single-named-value-import' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const methods = new Set();
|
|
96
|
+
const functionNames = new Set();
|
|
97
|
+
let freeBindingUse = false;
|
|
98
|
+
|
|
99
|
+
// Walk top-level: only function declarations may use the binding (as method calls).
|
|
100
|
+
function walkTop(node) {
|
|
101
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
102
|
+
if (node.body) {
|
|
103
|
+
visitBody(node.body, node.name?.text);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Any binding use outside function decls is invalid
|
|
108
|
+
if (ts.isIdentifier(node) && node.text === bindingName) {
|
|
109
|
+
if (node.parent && ts.isImportSpecifier(node.parent)) return;
|
|
110
|
+
freeBindingUse = true;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
ts.forEachChild(node, walkTop);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function visitBody(body, fnName) {
|
|
117
|
+
function walk(node) {
|
|
118
|
+
if (ts.isIdentifier(node) && node.text === bindingName) {
|
|
119
|
+
const parent = node.parent;
|
|
120
|
+
if (
|
|
121
|
+
parent &&
|
|
122
|
+
ts.isPropertyAccessExpression(parent) &&
|
|
123
|
+
parent.expression === node &&
|
|
124
|
+
parent.parent &&
|
|
125
|
+
ts.isCallExpression(parent.parent) &&
|
|
126
|
+
parent.parent.expression === parent
|
|
127
|
+
) {
|
|
128
|
+
methods.add(parent.name.text);
|
|
129
|
+
if (fnName) functionNames.add(fnName);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
freeBindingUse = true;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// Nested functions: still require property-call form
|
|
136
|
+
if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node)) {
|
|
137
|
+
// Nested non-declarations: fail closed (narrow transform)
|
|
138
|
+
if (!ts.isFunctionDeclaration(node)) {
|
|
139
|
+
// Check if binding used inside — if yes, ineligible
|
|
140
|
+
let nestedUse = false;
|
|
141
|
+
const check = (n) => {
|
|
142
|
+
if (ts.isIdentifier(n) && n.text === bindingName) nestedUse = true;
|
|
143
|
+
else ts.forEachChild(n, check);
|
|
144
|
+
};
|
|
145
|
+
if (node.body) check(node.body);
|
|
146
|
+
if (nestedUse) freeBindingUse = true;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
ts.forEachChild(node, walk);
|
|
151
|
+
}
|
|
152
|
+
walk(body);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const stmt of sf.statements) {
|
|
156
|
+
walkTop(stmt);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (freeBindingUse) {
|
|
160
|
+
return { eligible: false, reason: 'free-or-non-call-use' };
|
|
161
|
+
}
|
|
162
|
+
if (methods.size === 0 || functionNames.size === 0) {
|
|
163
|
+
return { eligible: false, reason: 'no-method-calls-in-functions' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
eligible: true,
|
|
168
|
+
bindingName,
|
|
169
|
+
methods: [...methods].sort(),
|
|
170
|
+
functionNames: [...functionNames].sort(),
|
|
171
|
+
specifier,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Apply port-proof inject when prove succeeds.
|
|
177
|
+
* @returns {{ source: string, remediationKind: string, confidence: number, proof: object } | null}
|
|
178
|
+
*/
|
|
179
|
+
export function applyPortProofInject(ts, source, opts = {}) {
|
|
180
|
+
const proof = provePortProofInject(ts, source, opts);
|
|
181
|
+
if (!proof.eligible) return null;
|
|
182
|
+
|
|
183
|
+
const sf = ts.createSourceFile(
|
|
184
|
+
opts.filePath || 'file.ts',
|
|
185
|
+
source,
|
|
186
|
+
ts.ScriptTarget.Latest,
|
|
187
|
+
true,
|
|
188
|
+
ts.ScriptKind.TS
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const bindingName = proof.bindingName;
|
|
192
|
+
const methods = proof.methods;
|
|
193
|
+
const portTypeName = `${capitalize(bindingName)}Port`;
|
|
194
|
+
|
|
195
|
+
// Find import to remove
|
|
196
|
+
/** @type {{ start: number, end: number } | null} */
|
|
197
|
+
let importSpan = null;
|
|
198
|
+
for (const stmt of sf.statements) {
|
|
199
|
+
if (!ts.isImportDeclaration(stmt)) continue;
|
|
200
|
+
const clause = stmt.importClause;
|
|
201
|
+
if (!clause?.namedBindings || !ts.isNamedImports(clause.namedBindings)) continue;
|
|
202
|
+
if (clause.namedBindings.elements.length !== 1) continue;
|
|
203
|
+
if (clause.namedBindings.elements[0].name.text !== bindingName) continue;
|
|
204
|
+
// getStart skips leading trivia so we don't leave a stray indent before the port type.
|
|
205
|
+
importSpan = { start: stmt.getStart(sf), end: stmt.getEnd() };
|
|
206
|
+
if (source[importSpan.end] === '\r') importSpan.end += 1;
|
|
207
|
+
if (source[importSpan.end] === '\n') importSpan.end += 1;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (!importSpan) return null;
|
|
211
|
+
|
|
212
|
+
// Functions that need the port param
|
|
213
|
+
const fnEdits = [];
|
|
214
|
+
for (const stmt of sf.statements) {
|
|
215
|
+
if (!ts.isFunctionDeclaration(stmt) || !stmt.name || !stmt.body) continue;
|
|
216
|
+
if (!proof.functionNames.includes(stmt.name.text)) continue;
|
|
217
|
+
// Already has a param named bindingName?
|
|
218
|
+
const params = stmt.parameters || [];
|
|
219
|
+
if (params.some((p) => ts.isIdentifier(p.name) && p.name.text === bindingName)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// Fail closed: rest params / non-identifier patterns would yield illegal TS
|
|
223
|
+
// (e.g. function f(...args, db: Port)).
|
|
224
|
+
if (
|
|
225
|
+
params.some(
|
|
226
|
+
(p) =>
|
|
227
|
+
p.dotDotDotToken ||
|
|
228
|
+
!ts.isIdentifier(p.name)
|
|
229
|
+
)
|
|
230
|
+
) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
if (params.length > 0) {
|
|
234
|
+
const at = params[params.length - 1].getEnd();
|
|
235
|
+
fnEdits.push({
|
|
236
|
+
start: at,
|
|
237
|
+
end: at,
|
|
238
|
+
text: `, ${bindingName}: ${portTypeName}`,
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
const open = source.indexOf('(', stmt.name.getEnd());
|
|
242
|
+
if (open < 0) continue;
|
|
243
|
+
const at = open + 1;
|
|
244
|
+
fnEdits.push({
|
|
245
|
+
start: at,
|
|
246
|
+
end: at,
|
|
247
|
+
text: `${bindingName}: ${portTypeName}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (fnEdits.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
const portDecl =
|
|
254
|
+
`export type ${portTypeName} = {\n` +
|
|
255
|
+
methods.map((m) => ` ${m}: (...args: unknown[]) => unknown;`).join('\n') +
|
|
256
|
+
`\n};\n\n`;
|
|
257
|
+
|
|
258
|
+
// Apply edits from end to start
|
|
259
|
+
const edits = [
|
|
260
|
+
{ start: importSpan.start, end: importSpan.end, text: portDecl },
|
|
261
|
+
...fnEdits,
|
|
262
|
+
].sort((a, b) => b.start - a.start);
|
|
263
|
+
|
|
264
|
+
let out = source;
|
|
265
|
+
for (const e of edits) {
|
|
266
|
+
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
267
|
+
}
|
|
268
|
+
if (out === source) return null;
|
|
269
|
+
|
|
270
|
+
// Re-prove on result: binding should no longer need the import; methods still present
|
|
271
|
+
// (as params). Eligibility after transform is "no value import" — proof may fail for
|
|
272
|
+
// different reasons. Validate shape: no import of binding's original specifier as value.
|
|
273
|
+
if (new RegExp(`import\\s*\\{[^}]*\\b${escapeRe(bindingName)}\\b`).test(out)) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
if (!out.includes(`export type ${portTypeName}`)) return null;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
source: out,
|
|
280
|
+
remediationKind: 'port-proof-inject-binding',
|
|
281
|
+
confidence: 0.8,
|
|
282
|
+
proof: {
|
|
283
|
+
bindingName,
|
|
284
|
+
methods,
|
|
285
|
+
functionNames: proof.functionNames,
|
|
286
|
+
specifier: proof.specifier,
|
|
287
|
+
portTypeName,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function capitalize(s) {
|
|
293
|
+
if (!s) return 'Port';
|
|
294
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function escapeRe(s) {
|
|
298
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* True when relative path basename matches a common path segment of the import target.
|
|
303
|
+
* Used only as a soft filter when wiring from ark-check violations.
|
|
304
|
+
*/
|
|
305
|
+
export function specifierLooksLikeTarget(specifier, violationTargetRel) {
|
|
306
|
+
if (!specifier || !violationTargetRel) return true;
|
|
307
|
+
const base = path.basename(violationTargetRel, path.extname(violationTargetRel));
|
|
308
|
+
return specifier.includes(base) || violationTargetRel.includes(specifier.replace(/^\.\//, ''));
|
|
309
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2 — ark_prepare_write composition helpers.
|
|
3
|
+
*
|
|
4
|
+
* Place + constrain + validate + autoPatch + judgmentBrief + content identity.
|
|
5
|
+
* Pure-ish: no second architecture contract — callers pass placement + validate.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { validateWithAutoPatch } from './auto-patch.mjs';
|
|
9
|
+
import { classifyRemediation, enrichViolationWithFixClass } from './remediation.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable content identity for host commit / cache keys.
|
|
13
|
+
* @param {string} source
|
|
14
|
+
* @returns {{ contentHash: string, byteLength: number }}
|
|
15
|
+
*/
|
|
16
|
+
export function contentIdentity(source) {
|
|
17
|
+
const text = typeof source === 'string' ? source : '';
|
|
18
|
+
const contentHash = `sha256:${crypto.createHash('sha256').update(text, 'utf8').digest('hex')}`;
|
|
19
|
+
return { contentHash, byteLength: Buffer.byteLength(text, 'utf8') };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One judgment decision for the agent when autoPatch is absent or insufficient.
|
|
24
|
+
* @param {Array<object>} violations
|
|
25
|
+
* @returns {null | { fixClass: string, decision: string, remediationClass: string, remediationKind?: string }}
|
|
26
|
+
*/
|
|
27
|
+
export function buildJudgmentBrief(violations) {
|
|
28
|
+
if (!Array.isArray(violations) || violations.length === 0) return null;
|
|
29
|
+
for (const v of violations) {
|
|
30
|
+
const ruleId = v.ruleId || v.code;
|
|
31
|
+
const shaped = {
|
|
32
|
+
ruleId,
|
|
33
|
+
typeOnly: v.typeOnly ?? v.details?.typeOnly,
|
|
34
|
+
sourcePureTypeModule: v.sourcePureTypeModule,
|
|
35
|
+
targetTypeOnlyExports: v.targetTypeOnlyExports,
|
|
36
|
+
namedBindingsTypeOnly: v.namedBindingsTypeOnly,
|
|
37
|
+
peerIsolation: v.peerIsolation ?? v.details?.peerIsolation,
|
|
38
|
+
edgeKind: v.edgeKind ?? v.details?.importKind,
|
|
39
|
+
fromLayer: v.fromLayer,
|
|
40
|
+
toLayer: v.toLayer,
|
|
41
|
+
target: v.target,
|
|
42
|
+
message: v.message,
|
|
43
|
+
};
|
|
44
|
+
const verdict = classifyRemediation(shaped);
|
|
45
|
+
if (verdict.class === 'mechanical-safe') continue;
|
|
46
|
+
const enriched = enrichViolationWithFixClass(shaped);
|
|
47
|
+
return {
|
|
48
|
+
fixClass: enriched.fixClass,
|
|
49
|
+
decision: enriched.enthusiastHint,
|
|
50
|
+
remediationClass: verdict.class,
|
|
51
|
+
...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// All mechanical-safe or unclassifiable — still offer first enriched hint
|
|
55
|
+
const first = violations[0];
|
|
56
|
+
const ruleId = first.ruleId || first.code;
|
|
57
|
+
const shaped = { ...first, ruleId };
|
|
58
|
+
const enriched = enrichViolationWithFixClass(shaped);
|
|
59
|
+
const verdict = classifyRemediation(shaped);
|
|
60
|
+
return {
|
|
61
|
+
fixClass: enriched.fixClass,
|
|
62
|
+
decision: enriched.enthusiastHint,
|
|
63
|
+
remediationClass: verdict.class,
|
|
64
|
+
...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compose placement + write-boundary validation into one prepare_write result.
|
|
70
|
+
*
|
|
71
|
+
* @param {{
|
|
72
|
+
* source: string,
|
|
73
|
+
* placement: object,
|
|
74
|
+
* root: string,
|
|
75
|
+
* ts: object,
|
|
76
|
+
* validate: (source: string) => { valid: boolean, violations?: any[] },
|
|
77
|
+
* resolveTargetAbs?: Function,
|
|
78
|
+
* }} opts
|
|
79
|
+
*/
|
|
80
|
+
export function composePrepareWrite(opts) {
|
|
81
|
+
const { source, placement, root, ts, validate, resolveTargetAbs } = opts;
|
|
82
|
+
if (typeof source !== 'string') {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: 'source is required (string)',
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const identity = contentIdentity(source);
|
|
89
|
+
const filePath = placement?.filePath;
|
|
90
|
+
const gate = validateWithAutoPatch({
|
|
91
|
+
source,
|
|
92
|
+
filePath,
|
|
93
|
+
root,
|
|
94
|
+
ts,
|
|
95
|
+
validate,
|
|
96
|
+
resolveTargetAbs,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// judgmentBrief when invalid and no mechanical autoPatch (agent must decide).
|
|
100
|
+
// When autoPatch is present, omit brief — host should apply the patch first.
|
|
101
|
+
const judgment =
|
|
102
|
+
!gate.valid && !gate.autoPatch ? buildJudgmentBrief(gate.violations) : null;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
filePath: placement?.filePath ?? null,
|
|
107
|
+
layer: placement?.layer ?? null,
|
|
108
|
+
governed: placement?.governed,
|
|
109
|
+
proposed: placement?.proposed,
|
|
110
|
+
mayImport: placement?.mayImport,
|
|
111
|
+
mustNotImport: placement?.mustNotImport,
|
|
112
|
+
forbiddenGlobals: placement?.forbiddenGlobals ?? [],
|
|
113
|
+
...(placement?.mayImportInfrastructure ? { mayImportInfrastructure: true } : {}),
|
|
114
|
+
...(placement?.suggestedLayers ? { suggestedLayers: placement.suggestedLayers } : {}),
|
|
115
|
+
...(placement?.message ? { placementMessage: placement.message } : {}),
|
|
116
|
+
...(placement?.note ? { placementNote: placement.note } : {}),
|
|
117
|
+
...(placement?.description ? { description: placement.description } : {}),
|
|
118
|
+
valid: gate.valid,
|
|
119
|
+
violations: gate.violations,
|
|
120
|
+
...(gate.autoPatch ? { autoPatch: gate.autoPatch } : {}),
|
|
121
|
+
...(judgment ? { judgmentBrief: judgment } : {}),
|
|
122
|
+
contentHash: identity.contentHash,
|
|
123
|
+
byteLength: identity.byteLength,
|
|
124
|
+
...(gate.autoPatch
|
|
125
|
+
? {
|
|
126
|
+
autoPatchContentHash: contentIdentity(gate.autoPatch.source).contentHash,
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -19,6 +19,14 @@ export const MECHANICAL_SAFE_KINDS = [
|
|
|
19
19
|
'type-only-import-move',
|
|
20
20
|
'import-type-from-pure-type-module',
|
|
21
21
|
'import-type-of-type-exports',
|
|
22
|
+
// port-proof-inject-binding is intentionally NOT mechanical-safe (signature change).
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* Judgment-class kinds that still have a named transform / plan label (eval corpus vocabulary).
|
|
26
|
+
* Never auto-apply without multi-file / caller proof.
|
|
27
|
+
*/
|
|
28
|
+
export const JUDGMENT_SUGGESTED_KINDS = [
|
|
29
|
+
'port-proof-inject-binding',
|
|
22
30
|
];
|
|
23
31
|
/** fixClass values from enrichViolationWithFixClass (eval corpus / reports). */
|
|
24
32
|
export const KNOWN_FIX_CLASSES = [
|
|
@@ -91,6 +99,19 @@ export function classifyRemediation(violation) {
|
|
|
91
99
|
rationale: 'Named bindings are type-only exports of the target module (even if the file also exports values): convert to `import type` / `export type` (erased at runtime). Gate verifies.',
|
|
92
100
|
};
|
|
93
101
|
}
|
|
102
|
+
// W6: port-proof inject is a *suggested* shape when proof holds, but always judgment
|
|
103
|
+
// for auto-apply — adding a required parameter breaks external call sites.
|
|
104
|
+
if (violation?.portProofEligible &&
|
|
105
|
+
edgeKind !== 'require' &&
|
|
106
|
+
edgeKind !== 'dynamic-import' &&
|
|
107
|
+
!violation?.typeOnly) {
|
|
108
|
+
return {
|
|
109
|
+
class: 'judgment',
|
|
110
|
+
confidence: 0.82,
|
|
111
|
+
remediationKind: 'port-proof-inject-binding',
|
|
112
|
+
rationale: 'Port-proof shape: single named value import used only as binding.method(...) in function declarations. Inject as a port parameter (body-local calls preserved) — outer layer must pass the impl. Not mechanical-safe auto-apply: call arity changes. Apply via agent judgment / multi-file plan.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
94
115
|
return {
|
|
95
116
|
class: 'judgment',
|
|
96
117
|
confidence: 0.7,
|