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
@@ -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.
@@ -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
- baselineKey,
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 currentKeys = new Set(violations.map(baselineKey));
275
+ const occurrenceKeys = baselineOccurrenceKeys(violations);
276
+ const currentKeys = new Set(occurrenceKeys);
273
277
  const suppressed = baseline.exists
274
- ? violations.filter((v) => baseline.keys.has(baselineKey(v))).length
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
- console.log(color.bold(`Top actions (${actions.length}):`));
546
- actions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
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
+ }
@@ -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,