arkgate 2.7.0 → 2.8.1
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 +45 -1
- package/README.md +8 -0
- package/bin/lib/agent-gates.mjs +48 -228
- package/bin/lib/architecture-scan.mjs +20 -0
- package/bin/lib/ast-scan.mjs +232 -4
- package/bin/lib/codex-home.mjs +320 -0
- package/bin/lib/doctor-plan.mjs +2 -0
- package/bin/lib/remediation.mjs +44 -12
- package/bin/lib/ts-resolve.mjs +5 -4
- package/dist/index.cjs +417 -338
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -9
- package/dist/index.d.ts +29 -9
- package/dist/index.js +417 -338
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +452 -373
- 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 +452 -373
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +417 -338
- 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 +417 -338
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-CP3KkwZt.d.cts → types-CSJhEOk2.d.cts} +34 -0
- package/dist/{types-CP3KkwZt.d.ts → types-CSJhEOk2.d.ts} +34 -0
- package/docs/agent-guide.md +1 -1
- package/docs/ai-gates.md +41 -7
- package/docs/brownfield-adoption.md +7 -0
- package/docs/demos/03-copilot-autopilot.md +3 -2
- package/docs/enthusiast/reference-commands.md +2 -2
- package/docs/package-surface.md +1 -1
- package/docs/production-hardening.md +11 -4
- package/package.json +2 -1
- package/server.json +2 -2
- package/templates/skills/ark-explain.md +3 -2
- package/templates/skills/ark-loop.md +2 -1
package/bin/lib/ast-scan.mjs
CHANGED
|
@@ -49,11 +49,15 @@ export function isTypeOnlyModuleReference(ts, node) {
|
|
|
49
49
|
* Used so static value-syntax `import { T }` of a pure-type module can be mechanical-safe
|
|
50
50
|
* (convert to `import type`). Never trust this for require()/import() edges.
|
|
51
51
|
*/
|
|
52
|
+
function hasExportModifier(ts, node) {
|
|
53
|
+
return (
|
|
54
|
+
Array.isArray(node.modifiers) &&
|
|
55
|
+
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
53
60
|
let sawTypeExport = false;
|
|
54
|
-
const hasExportModifier = (node) =>
|
|
55
|
-
Array.isArray(node.modifiers) &&
|
|
56
|
-
node.modifiers.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
57
61
|
|
|
58
62
|
for (const stmt of sourceFile.statements) {
|
|
59
63
|
// Type-only imports OK; value or side-effect imports mean runtime load of deps.
|
|
@@ -84,7 +88,7 @@ export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
|
84
88
|
}
|
|
85
89
|
if (ts.isExportAssignment(stmt)) return false; // export = / export default expr
|
|
86
90
|
if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
|
|
87
|
-
if (hasExportModifier(stmt)) sawTypeExport = true;
|
|
91
|
+
if (hasExportModifier(ts, stmt)) sawTypeExport = true;
|
|
88
92
|
continue;
|
|
89
93
|
}
|
|
90
94
|
// Any other top-level statement (const/fn/class/enum, console.log, if, …) is runtime.
|
|
@@ -93,6 +97,230 @@ export function sourceFileExportsOnlyTypes(ts, sourceFile) {
|
|
|
93
97
|
return sawTypeExport;
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Names that exist in the *value* export space of this module (runtime bindings).
|
|
102
|
+
* Used to subtract dual-space names (e.g. `export type Foo` + `export const Foo`) from
|
|
103
|
+
* type-only export sets so converting `import { Foo }` to `import type` never drops a
|
|
104
|
+
* runtime binding.
|
|
105
|
+
*/
|
|
106
|
+
function collectBindingIdentifiers(ts, nameNode, into) {
|
|
107
|
+
if (!nameNode) return;
|
|
108
|
+
if (ts.isIdentifier(nameNode)) {
|
|
109
|
+
into.add(nameNode.text);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (ts.isObjectBindingPattern(nameNode) || ts.isArrayBindingPattern(nameNode)) {
|
|
113
|
+
for (const el of nameNode.elements) {
|
|
114
|
+
if (ts.isOmittedExpression(el)) continue;
|
|
115
|
+
if (ts.isBindingElement(el)) collectBindingIdentifiers(ts, el.name, into);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function valueExportNames(ts, sourceFile) {
|
|
121
|
+
const names = new Set();
|
|
122
|
+
const add = (n) => {
|
|
123
|
+
if (n) names.add(n);
|
|
124
|
+
};
|
|
125
|
+
for (const stmt of sourceFile.statements) {
|
|
126
|
+
// export const/let/var Foo = …
|
|
127
|
+
if (ts.isVariableStatement(stmt) && hasExportModifier(ts, stmt)) {
|
|
128
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
129
|
+
collectBindingIdentifiers(ts, decl.name, names);
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// export function Foo / export async function Foo
|
|
134
|
+
if (ts.isFunctionDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
135
|
+
add(stmt.name.text);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// export class Foo — value + type space; treat as value so never auto import-type
|
|
139
|
+
if (ts.isClassDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
140
|
+
add(stmt.name.text);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// export enum Foo — value + type
|
|
144
|
+
if (ts.isEnumDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name) {
|
|
145
|
+
add(stmt.name.text);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// export namespace Foo — value + type
|
|
149
|
+
if (ts.isModuleDeclaration(stmt) && hasExportModifier(ts, stmt) && stmt.name && ts.isIdentifier(stmt.name)) {
|
|
150
|
+
add(stmt.name.text);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
154
|
+
// export * from '…' — unknown value surface; cannot prove type-only names alone
|
|
155
|
+
if (!stmt.exportClause) {
|
|
156
|
+
// star re-export can introduce values; flag as opaque by adding a sentinel? callers
|
|
157
|
+
// only check named bindings against explicit type-only sets — leave empty for star.
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ts.isNamespaceExport(stmt.exportClause)) continue;
|
|
161
|
+
if (!ts.isNamedExports(stmt.exportClause)) continue;
|
|
162
|
+
// bare `export { Foo }` / `export { Foo } from '…'` without type keyword — value (or dual)
|
|
163
|
+
if (!stmt.isTypeOnly) {
|
|
164
|
+
for (const el of stmt.exportClause.elements) {
|
|
165
|
+
if (el.isTypeOnly) continue;
|
|
166
|
+
const local = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : el.name?.text;
|
|
167
|
+
add(local);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return names;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* True when an expression may run runtime work if the module is evaluated.
|
|
176
|
+
* Conservative: any call/new/await/tagged-template (or nested) is impure.
|
|
177
|
+
* Literals, identifiers, pure object/array/as/parenthesized trees are pure.
|
|
178
|
+
*/
|
|
179
|
+
export function expressionMayHaveSideEffects(ts, expr) {
|
|
180
|
+
if (!expr) return false;
|
|
181
|
+
if (
|
|
182
|
+
ts.isCallExpression(expr) ||
|
|
183
|
+
ts.isNewExpression(expr) ||
|
|
184
|
+
ts.isAwaitExpression(expr) ||
|
|
185
|
+
ts.isTaggedTemplateExpression(expr) ||
|
|
186
|
+
ts.isYieldExpression?.(expr)
|
|
187
|
+
) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
// Walk children; short-circuit on first impure.
|
|
191
|
+
let impure = false;
|
|
192
|
+
const visit = (node) => {
|
|
193
|
+
if (impure) return;
|
|
194
|
+
if (
|
|
195
|
+
ts.isCallExpression(node) ||
|
|
196
|
+
ts.isNewExpression(node) ||
|
|
197
|
+
ts.isAwaitExpression(node) ||
|
|
198
|
+
ts.isTaggedTemplateExpression(node) ||
|
|
199
|
+
(typeof ts.isYieldExpression === 'function' && ts.isYieldExpression(node))
|
|
200
|
+
) {
|
|
201
|
+
impure = true;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
ts.forEachChild(node, visit);
|
|
205
|
+
};
|
|
206
|
+
ts.forEachChild(expr, visit);
|
|
207
|
+
return impure;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* True when evaluating this module may run non-trivial top-level work.
|
|
212
|
+
* Covers: expression statements, bare side-effect imports, control-flow,
|
|
213
|
+
* any top-level var initializer that call/new/await (exported or not),
|
|
214
|
+
* export-default impure expr, and class static field calls (export-agnostic).
|
|
215
|
+
* Converting `import { Type }` → `import type` would skip those effects — not auto-safe.
|
|
216
|
+
*/
|
|
217
|
+
export function sourceFileHasTopLevelSideEffects(ts, sourceFile) {
|
|
218
|
+
for (const stmt of sourceFile.statements) {
|
|
219
|
+
if (ts.isExpressionStatement(stmt)) return true;
|
|
220
|
+
if (ts.isImportDeclaration(stmt) && !stmt.importClause) return true; // import './x'
|
|
221
|
+
if (
|
|
222
|
+
ts.isIfStatement(stmt) ||
|
|
223
|
+
ts.isForStatement(stmt) ||
|
|
224
|
+
ts.isForInStatement(stmt) ||
|
|
225
|
+
ts.isForOfStatement(stmt) ||
|
|
226
|
+
ts.isWhileStatement(stmt) ||
|
|
227
|
+
ts.isDoStatement(stmt) ||
|
|
228
|
+
ts.isSwitchStatement(stmt) ||
|
|
229
|
+
ts.isTryStatement(stmt) ||
|
|
230
|
+
ts.isThrowStatement(stmt) ||
|
|
231
|
+
ts.isWithStatement?.(stmt)
|
|
232
|
+
) {
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
// Top-level const/let/var x = <maybe impure> — including non-exported.
|
|
236
|
+
// `const db = connect(); export type Row = …` still runs connect on module load;
|
|
237
|
+
// converting `import { Row }` → `import type` would skip that work (R6 honesty).
|
|
238
|
+
if (ts.isVariableStatement(stmt)) {
|
|
239
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
240
|
+
if (decl.initializer && expressionMayHaveSideEffects(ts, decl.initializer)) return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// export default <expr>
|
|
244
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
|
245
|
+
if (stmt.expression && expressionMayHaveSideEffects(ts, stmt.expression)) return true;
|
|
246
|
+
}
|
|
247
|
+
// Class with static field initializers that call — class body evaluates at load
|
|
248
|
+
// whether or not the class is exported.
|
|
249
|
+
if (ts.isClassDeclaration(stmt)) {
|
|
250
|
+
for (const member of stmt.members ?? []) {
|
|
251
|
+
if (
|
|
252
|
+
ts.isPropertyDeclaration(member) &&
|
|
253
|
+
member.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) &&
|
|
254
|
+
member.initializer &&
|
|
255
|
+
expressionMayHaveSideEffects(ts, member.initializer)
|
|
256
|
+
) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Names that are provably type-only exports of this module (erased at runtime).
|
|
267
|
+
* Conservative: class/enum/namespace/const/function exports are excluded even when they
|
|
268
|
+
* also introduce a type. Dual-space names (`export type Foo` + `export const Foo`) are
|
|
269
|
+
* subtracted — converting those to `import type` would drop a runtime binding.
|
|
270
|
+
* Used so `import { Row }` of a type alias from a mixed module can be mechanical-safe.
|
|
271
|
+
*/
|
|
272
|
+
export function typeOnlyExportNames(ts, sourceFile) {
|
|
273
|
+
const names = new Set();
|
|
274
|
+
for (const stmt of sourceFile.statements) {
|
|
275
|
+
if (ts.isTypeAliasDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)) {
|
|
276
|
+
if (hasExportModifier(ts, stmt) && stmt.name) names.add(stmt.name.text);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!ts.isExportDeclaration(stmt)) continue;
|
|
280
|
+
const clause = stmt.exportClause;
|
|
281
|
+
if (!clause || !ts.isNamedExports(clause)) continue;
|
|
282
|
+
for (const el of clause.elements) {
|
|
283
|
+
// `export type { X }` or `export { type X }` — type-only re-exports.
|
|
284
|
+
if (stmt.isTypeOnly || el.isTypeOnly) {
|
|
285
|
+
const local = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : el.name?.text;
|
|
286
|
+
if (local) names.add(local);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Subtract any name that also has a value export (dual-space / value re-export).
|
|
291
|
+
const values = valueExportNames(ts, sourceFile);
|
|
292
|
+
for (const v of values) names.delete(v);
|
|
293
|
+
return [...names];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Local names of named import/export bindings on a module edge, or null when the edge
|
|
298
|
+
* is not a pure named list (default import, namespace, side-effect, export *, export =).
|
|
299
|
+
* PropertyName is preferred so `import { Row as R }` still checks target export `Row`.
|
|
300
|
+
*/
|
|
301
|
+
export function namedModuleBindings(ts, node) {
|
|
302
|
+
if (ts.isImportDeclaration(node)) {
|
|
303
|
+
const clause = node.importClause;
|
|
304
|
+
if (!clause) return null; // side-effect
|
|
305
|
+
if (clause.name) return null; // default import (possibly with named — still not pure-named-only)
|
|
306
|
+
const named = clause.namedBindings;
|
|
307
|
+
if (!named || !ts.isNamedImports(named) || named.elements.length === 0) return null;
|
|
308
|
+
return named.elements.map((el) => {
|
|
309
|
+
const prop = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : null;
|
|
310
|
+
return prop || el.name.text;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (ts.isExportDeclaration(node)) {
|
|
314
|
+
const clause = node.exportClause;
|
|
315
|
+
if (!clause || !ts.isNamedExports(clause) || clause.elements.length === 0) return null;
|
|
316
|
+
return clause.elements.map((el) => {
|
|
317
|
+
const prop = el.propertyName && 'text' in el.propertyName ? el.propertyName.text : null;
|
|
318
|
+
return prop || el.name.text;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
96
324
|
export function propertyName(ts, node) {
|
|
97
325
|
if (!node) return undefined;
|
|
98
326
|
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) return node.text;
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex home config ($CODEX_HOME/config.toml) — paths, multi-project wire, adoption assess.
|
|
3
|
+
* Extracted from agent-gates so install/doctor stay orchestration-only (R7 review).
|
|
4
|
+
*/
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { execCommandParts } from '../ark-shared.mjs';
|
|
10
|
+
|
|
11
|
+
export const PREFERRED_CODEX_MCP_BIN = 'arkgate-mcp';
|
|
12
|
+
|
|
13
|
+
/** Where Codex loads slash-command prompts ($CODEX_HOME/prompts). */
|
|
14
|
+
export function codexPromptsDir() {
|
|
15
|
+
const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
16
|
+
return path.join(base, 'prompts');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Where Codex loads MCP servers ($CODEX_HOME/config.toml) — global, not project-local. */
|
|
20
|
+
export function codexConfigPath() {
|
|
21
|
+
const base = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
22
|
+
return path.join(base, 'config.toml');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Temp / upgrade sandbox roots must never remain as Codex MCP --root. */
|
|
26
|
+
export function isTempOrUpgradeRoot(p) {
|
|
27
|
+
if (!p || typeof p !== 'string') return false;
|
|
28
|
+
const n = p.replace(/\\/g, '/');
|
|
29
|
+
return (
|
|
30
|
+
/\/var\/folders\//i.test(n) ||
|
|
31
|
+
/\/tmp\//i.test(n) ||
|
|
32
|
+
/\/Temp\//i.test(n) ||
|
|
33
|
+
/ark-upgrade/i.test(n) ||
|
|
34
|
+
/\/T\/(?:ark-|grok-)/i.test(n) ||
|
|
35
|
+
/[\\/]AppData[\\/]Local[\\/]Temp[\\/]/i.test(n)
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stable secondary table name: basename + short path hash so two projects named
|
|
41
|
+
* `app` do not collide on `mcp_servers.ark_app`.
|
|
42
|
+
*/
|
|
43
|
+
export function codexProjectSlug(absRoot) {
|
|
44
|
+
const abs = path.resolve(absRoot);
|
|
45
|
+
const base =
|
|
46
|
+
path
|
|
47
|
+
.basename(abs)
|
|
48
|
+
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
49
|
+
.slice(0, 40) || 'project';
|
|
50
|
+
const hash = crypto.createHash('sha1').update(abs).digest('hex').slice(0, 8);
|
|
51
|
+
return `${base}_${hash}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract `--root` from one TOML mcp_servers table body. */
|
|
55
|
+
export function extractCodexRootFromBlock(block) {
|
|
56
|
+
if (!block || typeof block !== 'string') return null;
|
|
57
|
+
const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
|
|
58
|
+
return m ? m[1] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* All Ark MCP server tables in a Codex config.toml.
|
|
63
|
+
* @returns {Array<{ table: string, root: string|null, block: string, start: number, end: number }>}
|
|
64
|
+
*/
|
|
65
|
+
export function listCodexArkServerTables(tomlText) {
|
|
66
|
+
if (!tomlText || typeof tomlText !== 'string') return [];
|
|
67
|
+
const out = [];
|
|
68
|
+
const headerRe = /\[mcp_servers\.(ark(?:_[a-zA-Z0-9_-]*)?)\]/g;
|
|
69
|
+
const headers = [];
|
|
70
|
+
let hm;
|
|
71
|
+
while ((hm = headerRe.exec(tomlText)) !== null) {
|
|
72
|
+
headers.push({ table: hm[1], index: hm.index });
|
|
73
|
+
}
|
|
74
|
+
for (let i = 0; i < headers.length; i++) {
|
|
75
|
+
const start = headers[i].index;
|
|
76
|
+
let end = i + 1 < headers.length ? headers[i + 1].index : tomlText.length;
|
|
77
|
+
if (i + 1 >= headers.length) {
|
|
78
|
+
const rest = tomlText.slice(start + 1);
|
|
79
|
+
const other = rest.search(/\n\[/);
|
|
80
|
+
if (other >= 0) end = start + 1 + other;
|
|
81
|
+
}
|
|
82
|
+
const block = tomlText.slice(start, end).replace(/\s+$/, '\n');
|
|
83
|
+
out.push({
|
|
84
|
+
table: headers[i].table,
|
|
85
|
+
root: extractCodexRootFromBlock(block),
|
|
86
|
+
block,
|
|
87
|
+
start,
|
|
88
|
+
end,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replace an existing `[mcp_servers.<table>]` block or append a new one.
|
|
96
|
+
* `block` should include the header line; trailing whitespace is normalized.
|
|
97
|
+
*/
|
|
98
|
+
export function upsertCodexMcpTable(tomlText, tableName, block) {
|
|
99
|
+
const existing = tomlText || '';
|
|
100
|
+
const normalized = `${String(block).replace(/\s+$/, '')}\n`;
|
|
101
|
+
const tables = listCodexArkServerTables(existing);
|
|
102
|
+
const hit = tables.find((t) => t.table === tableName);
|
|
103
|
+
if (hit) {
|
|
104
|
+
return `${existing.slice(0, hit.start)}${normalized}${existing.slice(hit.end).replace(/^\n+/, '\n')}`;
|
|
105
|
+
}
|
|
106
|
+
if (existing.length === 0) return normalized;
|
|
107
|
+
const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
108
|
+
return `${existing}${sep}${normalized}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Primary table entry or null. */
|
|
112
|
+
export function codexPrimaryTable(tomlText) {
|
|
113
|
+
return listCodexArkServerTables(tomlText).find((t) => t.table === 'ark') ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Secondary (non-primary) table whose --root is this project, if any. */
|
|
117
|
+
export function codexScopedTableForRoot(tomlText, absRoot) {
|
|
118
|
+
const abs = path.resolve(absRoot);
|
|
119
|
+
for (const entry of listCodexArkServerTables(tomlText)) {
|
|
120
|
+
if (entry.table === 'ark') continue;
|
|
121
|
+
if (!entry.root) continue;
|
|
122
|
+
try {
|
|
123
|
+
if (path.resolve(entry.root) === abs) return entry.table;
|
|
124
|
+
} catch {
|
|
125
|
+
/* ignore */
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Extract --root from primary [mcp_servers.ark]. */
|
|
132
|
+
export function extractCodexArkRootFromToml(tomlText) {
|
|
133
|
+
return codexPrimaryTable(tomlText)?.root ?? null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function codexArkBlockHasPreferredBin(tomlText) {
|
|
137
|
+
const primary = codexPrimaryTable(tomlText);
|
|
138
|
+
if (!primary) return false;
|
|
139
|
+
const bins = [...primary.block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
|
|
140
|
+
if (bins.length > 1) return false;
|
|
141
|
+
return bins.length === 1 && bins[0] === PREFERRED_CODEX_MCP_BIN;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True when primary is broken (temp root / dual bin) and should rewrite fail-closed.
|
|
146
|
+
* Permanent different project roots are NOT broken — multi-project uses a secondary table.
|
|
147
|
+
*/
|
|
148
|
+
export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
|
|
149
|
+
if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
|
|
150
|
+
const rootArg = extractCodexArkRootFromToml(tomlText);
|
|
151
|
+
if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
|
|
152
|
+
try {
|
|
153
|
+
if (path.resolve(rootArg) !== path.resolve(absRoot)) {
|
|
154
|
+
if (!isTempOrUpgradeRoot(rootArg)) return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
if (!codexArkBlockHasPreferredBin(tomlText)) return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Assess Codex home MCP vs this project. Pure (no I/O).
|
|
166
|
+
* @returns {{
|
|
167
|
+
* root: string|null,
|
|
168
|
+
* tempPath: boolean,
|
|
169
|
+
* wrongRoot: boolean,
|
|
170
|
+
* preferredBin: boolean,
|
|
171
|
+
* needsRewrite: boolean,
|
|
172
|
+
* multiProject: boolean,
|
|
173
|
+
* scopedTable: string|null,
|
|
174
|
+
* gap: null | { id: string, severity: string, message: string, fixArgs: string }
|
|
175
|
+
* }}
|
|
176
|
+
*/
|
|
177
|
+
export function assessCodexHomeMcp(tomlText, absRoot) {
|
|
178
|
+
const resolvedRoot = path.resolve(absRoot);
|
|
179
|
+
if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) {
|
|
180
|
+
return {
|
|
181
|
+
root: null,
|
|
182
|
+
tempPath: false,
|
|
183
|
+
wrongRoot: false,
|
|
184
|
+
preferredBin: false,
|
|
185
|
+
needsRewrite: false,
|
|
186
|
+
multiProject: false,
|
|
187
|
+
scopedTable: null,
|
|
188
|
+
gap: null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const rootArg = extractCodexArkRootFromToml(tomlText);
|
|
192
|
+
const temp = isTempOrUpgradeRoot(rootArg);
|
|
193
|
+
let wrongRoot = false;
|
|
194
|
+
try {
|
|
195
|
+
wrongRoot = rootArg ? path.resolve(rootArg) !== resolvedRoot : true;
|
|
196
|
+
} catch {
|
|
197
|
+
wrongRoot = true;
|
|
198
|
+
}
|
|
199
|
+
const preferredBin = codexArkBlockHasPreferredBin(tomlText);
|
|
200
|
+
const needsRewrite = codexArkBlockNeedsRewrite(tomlText, resolvedRoot);
|
|
201
|
+
const scopedTable = wrongRoot && !temp ? codexScopedTableForRoot(tomlText, resolvedRoot) : null;
|
|
202
|
+
const multiProject = Boolean(wrongRoot && !temp && !needsRewrite);
|
|
203
|
+
|
|
204
|
+
let gap = null;
|
|
205
|
+
if (needsRewrite) {
|
|
206
|
+
gap = {
|
|
207
|
+
id: 'codex-home-mcp',
|
|
208
|
+
severity: temp || wrongRoot ? 'warn' : 'info',
|
|
209
|
+
message: temp
|
|
210
|
+
? `Codex home MCP --root points at a temp/upgrade path (${rootArg})`
|
|
211
|
+
: wrongRoot
|
|
212
|
+
? `Codex home MCP --root is not this project (${rootArg || 'missing'} ≠ ${resolvedRoot})`
|
|
213
|
+
: `Codex home MCP should use a single ${PREFERRED_CODEX_MCP_BIN} bin with absolute project paths`,
|
|
214
|
+
fixArgs: '--install-agent-gates --codex-home --force',
|
|
215
|
+
};
|
|
216
|
+
} else if (multiProject) {
|
|
217
|
+
gap = {
|
|
218
|
+
id: 'codex-home-multi-project',
|
|
219
|
+
severity: scopedTable ? 'info' : 'warn',
|
|
220
|
+
message: scopedTable
|
|
221
|
+
? `Codex primary [mcp_servers.ark] is bound to another project (${rootArg}); ` +
|
|
222
|
+
`this project is registered as [mcp_servers.${scopedTable}]. ` +
|
|
223
|
+
`Codex may still prefer the primary binding for ark://manifest — rebind if this repo should own it.`
|
|
224
|
+
: `Codex home primary MCP --root is another permanent project ` +
|
|
225
|
+
`(${rootArg || 'missing'} ≠ ${resolvedRoot}). ` +
|
|
226
|
+
`Install without --force adds a scoped [mcp_servers.ark_<slug>] table and leaves primary unchanged; ` +
|
|
227
|
+
`--force rebinds primary to this project.`,
|
|
228
|
+
fixArgs: scopedTable
|
|
229
|
+
? '--install-agent-gates --tools codex --force'
|
|
230
|
+
: '--install-agent-gates --tools codex',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
root: rootArg,
|
|
236
|
+
tempPath: temp,
|
|
237
|
+
wrongRoot,
|
|
238
|
+
preferredBin,
|
|
239
|
+
needsRewrite,
|
|
240
|
+
multiProject,
|
|
241
|
+
scopedTable,
|
|
242
|
+
gap,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Merge [mcp_servers.ark] (or scoped secondary) into Codex home config.toml.
|
|
248
|
+
* Without --force, permanent other-project primary is left alone; this project gets
|
|
249
|
+
* [mcp_servers.ark_<slug>]. Temp/stale roots rewrite fail-closed.
|
|
250
|
+
*
|
|
251
|
+
* All mutations go through upsertCodexMcpTable (single table model).
|
|
252
|
+
*/
|
|
253
|
+
export function wireCodexMcp(root, force) {
|
|
254
|
+
const file = codexConfigPath();
|
|
255
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
256
|
+
const absRoot = path.resolve(root);
|
|
257
|
+
const absConfig = path.join(absRoot, 'ark.config.json');
|
|
258
|
+
const { command, args } = execCommandParts(root, PREFERRED_CODEX_MCP_BIN, [
|
|
259
|
+
'--root',
|
|
260
|
+
esc(absRoot),
|
|
261
|
+
'--config',
|
|
262
|
+
esc(absConfig),
|
|
263
|
+
]);
|
|
264
|
+
const argsToml = args.map((value) => `"${value}"`).join(', ');
|
|
265
|
+
const makeBlock = (table) =>
|
|
266
|
+
`[mcp_servers.${table}]
|
|
267
|
+
command = "${command}"
|
|
268
|
+
args = [${argsToml}]`;
|
|
269
|
+
|
|
270
|
+
let existing = '';
|
|
271
|
+
try {
|
|
272
|
+
if (fs.existsSync(file)) existing = fs.readFileSync(file, 'utf8');
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { status: 'failed', file, message: error.message };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const primary = codexPrimaryTable(existing);
|
|
278
|
+
const hasPrimary = Boolean(primary);
|
|
279
|
+
const existingRoot = primary?.root ?? null;
|
|
280
|
+
let differentProject = false;
|
|
281
|
+
try {
|
|
282
|
+
differentProject = Boolean(existingRoot && path.resolve(existingRoot) !== absRoot);
|
|
283
|
+
} catch {
|
|
284
|
+
differentProject = Boolean(existingRoot);
|
|
285
|
+
}
|
|
286
|
+
const mustRewrite = hasPrimary && codexArkBlockNeedsRewrite(existing, absRoot);
|
|
287
|
+
|
|
288
|
+
const writeToml = (next) => {
|
|
289
|
+
try {
|
|
290
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
291
|
+
fs.writeFileSync(file, next);
|
|
292
|
+
return null;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
return { status: 'failed', file, message: error.message };
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Multi-project: leave primary alone; upsert scoped secondary for this root.
|
|
299
|
+
if (hasPrimary && differentProject && !force && !mustRewrite) {
|
|
300
|
+
const existingScoped = codexScopedTableForRoot(existing, absRoot);
|
|
301
|
+
const table = existingScoped || `ark_${codexProjectSlug(absRoot)}`;
|
|
302
|
+
const next = upsertCodexMcpTable(existing, table, makeBlock(table));
|
|
303
|
+
const err = writeToml(next);
|
|
304
|
+
if (err) return err;
|
|
305
|
+
return { status: 'written-multi', file, table, primaryUnchanged: true };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (hasPrimary && !force && !mustRewrite) {
|
|
309
|
+
return { status: 'skipped', file };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const next = upsertCodexMcpTable(existing, 'ark', makeBlock('ark'));
|
|
313
|
+
const err = writeToml(next);
|
|
314
|
+
if (err) return err;
|
|
315
|
+
return {
|
|
316
|
+
status: hasPrimary ? 'updated' : 'written',
|
|
317
|
+
file,
|
|
318
|
+
...(mustRewrite && !force ? { reason: 'temp-or-stale-root' } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -162,6 +162,8 @@ export function buildRemediationPlan(root, activeViolations, governedPercent = n
|
|
|
162
162
|
...(v.typeOnly ? { typeOnly: true } : {}),
|
|
163
163
|
...(v.targetTypeOnlyExports ? { targetTypeOnlyExports: true } : {}),
|
|
164
164
|
...(v.sourcePureTypeModule ? { sourcePureTypeModule: true } : {}),
|
|
165
|
+
...(v.namedBindingsTypeOnly ? { namedBindingsTypeOnly: true } : {}),
|
|
166
|
+
...(v.edgeKind ? { edgeKind: v.edgeKind } : {}),
|
|
165
167
|
...(verdict.remediationKind ? { remediationKind: verdict.remediationKind } : {}),
|
|
166
168
|
};
|
|
167
169
|
});
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -13,6 +13,25 @@ export const REMEDIATION_CLASSES = [
|
|
|
13
13
|
'judgment',
|
|
14
14
|
'deferred',
|
|
15
15
|
];
|
|
16
|
+
/** All remediationKinds that may return class: mechanical-safe (ordered for docs/tests). */
|
|
17
|
+
export const MECHANICAL_SAFE_KINDS = [
|
|
18
|
+
'pure-type-file-relocate',
|
|
19
|
+
'type-only-import-move',
|
|
20
|
+
'import-type-from-pure-type-module',
|
|
21
|
+
'import-type-of-type-exports',
|
|
22
|
+
];
|
|
23
|
+
/** fixClass values from enrichViolationWithFixClass (eval corpus / reports). */
|
|
24
|
+
export const KNOWN_FIX_CLASSES = [
|
|
25
|
+
'file-move',
|
|
26
|
+
'port-inversion',
|
|
27
|
+
'inject-port',
|
|
28
|
+
'registered-intent',
|
|
29
|
+
'add-source-metadata',
|
|
30
|
+
'fix-source-layer',
|
|
31
|
+
'intent-relocation',
|
|
32
|
+
'break-cycle',
|
|
33
|
+
'review-contract',
|
|
34
|
+
];
|
|
16
35
|
/**
|
|
17
36
|
* Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
|
|
18
37
|
* Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
|
|
@@ -20,6 +39,15 @@ export const REMEDIATION_CLASSES = [
|
|
|
20
39
|
export function classifyRemediation(violation) {
|
|
21
40
|
const ruleId = violation?.ruleId;
|
|
22
41
|
if (ruleId === 'LAYER_IMPORT_VIOLATION') {
|
|
42
|
+
// Single invariant: runtime module loads are never mechanical-safe.
|
|
43
|
+
const edgeKind = violation?.edgeKind;
|
|
44
|
+
if (edgeKind === 'require' || edgeKind === 'dynamic-import') {
|
|
45
|
+
return {
|
|
46
|
+
class: 'judgment',
|
|
47
|
+
confidence: 0.75,
|
|
48
|
+
rationale: 'Runtime module load (require/import()) still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
23
51
|
if (violation?.typeOnly && violation?.sourcePureTypeModule) {
|
|
24
52
|
return {
|
|
25
53
|
class: 'mechanical-safe',
|
|
@@ -37,14 +65,6 @@ export function classifyRemediation(violation) {
|
|
|
37
65
|
};
|
|
38
66
|
}
|
|
39
67
|
if (violation?.targetTypeOnlyExports) {
|
|
40
|
-
const kind = violation.edgeKind;
|
|
41
|
-
if (kind === 'require' || kind === 'dynamic-import') {
|
|
42
|
-
return {
|
|
43
|
-
class: 'judgment',
|
|
44
|
-
confidence: 0.75,
|
|
45
|
-
rationale: 'Runtime module load (require/import()) of a type-only module still executes the target file — not auto-safe; rewrite to a static import type if appropriate.',
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
68
|
return {
|
|
49
69
|
class: 'mechanical-safe',
|
|
50
70
|
confidence: 0.85,
|
|
@@ -52,6 +72,16 @@ export function classifyRemediation(violation) {
|
|
|
52
72
|
rationale: 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
|
|
53
73
|
};
|
|
54
74
|
}
|
|
75
|
+
// R6: value-syntax named import/export of type-only exports from a mixed module.
|
|
76
|
+
// Only set when scan proves no dual-space value export and no top-level side effects.
|
|
77
|
+
if (violation?.namedBindingsTypeOnly) {
|
|
78
|
+
return {
|
|
79
|
+
class: 'mechanical-safe',
|
|
80
|
+
confidence: 0.86,
|
|
81
|
+
remediationKind: 'import-type-of-type-exports',
|
|
82
|
+
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.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
55
85
|
return {
|
|
56
86
|
class: 'judgment',
|
|
57
87
|
confidence: 0.7,
|
|
@@ -92,12 +122,14 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
92
122
|
const enriched = { ...violation };
|
|
93
123
|
switch (violation.ruleId) {
|
|
94
124
|
case 'LAYER_IMPORT_VIOLATION':
|
|
95
|
-
if (violation.typeOnly || violation.targetTypeOnlyExports) {
|
|
125
|
+
if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
|
|
96
126
|
enriched.fixClass = 'file-move';
|
|
97
127
|
enriched.effort = 'small';
|
|
98
|
-
enriched.enthusiastHint = violation.
|
|
99
|
-
? '
|
|
100
|
-
:
|
|
128
|
+
enriched.enthusiastHint = violation.namedBindingsTypeOnly
|
|
129
|
+
? 'Those named imports are type-only exports of the target — use `import type { … }` (or `export type { … }`) so the edge is erased at runtime.'
|
|
130
|
+
: violation.targetTypeOnlyExports
|
|
131
|
+
? 'The imported module only exports types — use `import type` and place the type in a layer both sides may share.'
|
|
132
|
+
: 'This is a type-only import — move the type to a layer both sides may share, or relocate the file to match its role.';
|
|
101
133
|
}
|
|
102
134
|
else {
|
|
103
135
|
enriched.fixClass = 'port-inversion';
|