arkgate 3.7.0 → 3.8.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 (77) hide show
  1. package/CHANGELOG.md +76 -1145
  2. package/README.md +59 -19
  3. package/bin/ark-check-runtime.mjs +1598 -0
  4. package/bin/ark-check.mjs +32 -1565
  5. package/bin/ark-mcp-runtime.mjs +1976 -0
  6. package/bin/ark-mcp.mjs +84 -1495
  7. package/bin/ark-shared.mjs +34 -38
  8. package/bin/ark.mjs +33 -66
  9. package/bin/lib/adapter-contract.mjs +161 -9
  10. package/bin/lib/agent-gates.mjs +1 -0
  11. package/bin/lib/analysis-completeness.mjs +28 -0
  12. package/bin/lib/analysis-engine.mjs +8 -8
  13. package/bin/lib/analysis-policy.mjs +27 -0
  14. package/bin/lib/architecture-scan.mjs +70 -357
  15. package/bin/lib/auto-patch.mjs +76 -8
  16. package/bin/lib/ci-and-commands.mjs +1 -1
  17. package/bin/lib/codex-home.mjs +43 -16
  18. package/bin/lib/design-delta.mjs +4 -0
  19. package/bin/lib/doctor-advisories.mjs +4 -3
  20. package/bin/lib/doctor-plan.mjs +40 -41
  21. package/bin/lib/enforcement-state.mjs +2 -0
  22. package/bin/lib/github-enforcement.mjs +443 -0
  23. package/bin/lib/hook-templates.mjs +12 -148
  24. package/bin/lib/html-report-advisories.mjs +1 -1
  25. package/bin/lib/html-report-depth.mjs +9 -0
  26. package/bin/lib/html-report.mjs +5 -5
  27. package/bin/lib/install-migrate.mjs +83 -79
  28. package/bin/lib/managed-upgrade.mjs +622 -0
  29. package/bin/lib/mcp-adoption.mjs +3 -1
  30. package/bin/lib/parse-health.mjs +6 -5
  31. package/bin/lib/port-proof.mjs +2 -2
  32. package/bin/lib/prepare-change.mjs +68 -38
  33. package/bin/lib/prepare-write.mjs +7 -1
  34. package/bin/lib/resident-doctor-client.mjs +55 -0
  35. package/bin/lib/resident-hook.mjs +247 -0
  36. package/bin/lib/resolved-candidate-facts.mjs +1160 -0
  37. package/bin/lib/scan-files.mjs +19 -6
  38. package/bin/lib/snippet-analysis.mjs +119 -0
  39. package/bin/lib/source-policy.mjs +24 -0
  40. package/bin/lib/typescript-host.mjs +15 -18
  41. package/bin/lib/unavailable-analysis.mjs +76 -0
  42. package/bin/lib/upgrade-command.mjs +115 -0
  43. package/bin/lib/weakest-link.mjs +21 -179
  44. package/bin/lib/write-path-capabilities.mjs +167 -16
  45. package/bin/lib/write-path-detect.mjs +3 -2
  46. package/dist/eslint/index.cjs +3 -3
  47. package/dist/eslint/index.d.ts +3 -0
  48. package/dist/eslint/index.js +3 -3
  49. package/dist/index.cjs +7 -7
  50. package/dist/index.d.ts +1073 -141
  51. package/dist/index.js +7 -7
  52. package/docs/agent-guide.md +103 -59
  53. package/docs/ai-gates.md +97 -16
  54. package/docs/demos/01-write-gate-self-correction.md +2 -2
  55. package/docs/enthusiast/README.md +10 -10
  56. package/docs/enthusiast/how-to-gallery-starter.md +2 -2
  57. package/docs/enthusiast/reference-commands.md +18 -1
  58. package/docs/enthusiast/tutorial-first-project.md +2 -2
  59. package/docs/package-surface.md +98 -12
  60. package/docs/typescript-support.md +109 -37
  61. package/package.json +32 -4
  62. package/schemas/ark.analysis-result.schema.json +159 -2
  63. package/schemas/ark.design-delta.schema.json +1 -0
  64. package/schemas/ark.enforcement-state.schema.json +84 -0
  65. package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
  66. package/server.json +2 -2
  67. package/templates/skills/ark-explore.md +5 -5
  68. package/templates/skills/ark-fix.md +1 -1
  69. package/templates/skills/ark-runtime.md +15 -8
  70. package/templates/skills/ark-upgrade.md +122 -182
  71. package/bin/lib/ai-velocity.mjs +0 -293
  72. package/bin/lib/graph-cycles.mjs +0 -6
  73. package/bin/lib/safety-diagnostics.mjs +0 -284
  74. package/bin/lib/ts-resolve.mjs +0 -228
  75. package/dist/configTypes-DAPvBqK6.d.cts +0 -61
  76. package/dist/eslint/index.d.cts +0 -146
  77. package/dist/index.d.cts +0 -986
@@ -2,373 +2,86 @@
2
2
  * Architecture check pipeline: content scan → import graph → layer edges → cycles.
3
3
  * Extracted from ark-check entry (R3). Entry remains orchestration + presentation.
4
4
  */
5
- import fs from 'node:fs';
6
5
  import path from 'node:path';
7
- import {
8
- layerForFile,
9
- looksLikeIntent,
10
- } from '../ark-shared.mjs';
11
- import {
12
- isArkPublishCandidate,
13
- isPublishCall,
14
- lineOf,
15
- namedModuleBindings,
16
- objectHasProperty,
17
- publishHasSource,
18
- publishSourceLiteral,
19
- sourceFileExportsOnlyTypes,
20
- sourceFileHasTopLevelSideEffects,
21
- stringLiteralText,
22
- typeOnlyExportNames,
23
- } from './ast-scan.mjs';
24
- import { provePortProofInject } from './port-proof.mjs';
25
6
  import { summarizeParseHealth } from './parse-health.mjs';
26
7
  import {
27
- intentLayersFromManifest,
28
- layerForIntent,
29
- isBlocked,
30
- collectConfigWarnings,
31
- } from './config-warnings.mjs';
32
- import {
33
- ambientCoveredByForbiddenGlobals,
34
- collectCapabilityUses,
35
- collectForbiddenCapabilityUses,
36
- effectiveCapabilityDeny,
37
- evaluateArchitectureGraph,
38
- extractSemanticDependencies,
39
- forbiddenGlobalForModuleSpecifier,
8
+ analyzeTrustedResolvedProject,
9
+ loadContract,
40
10
  } from './analysis-engine.mjs';
41
- import { normalize } from './scan-files.mjs';
42
- import { collectSafetyDiagnostics } from './safety-diagnostics.mjs';
43
- import { classifyPublishFacts } from './source-policy.mjs';
44
- import {
45
- createCompilerOptionsLookup,
46
- createModuleResolutionHost,
47
- loadScanCache,
48
- resolveImport,
49
- saveScanCache,
50
- scanCacheKey,
51
- } from './ts-resolve.mjs';
52
-
53
- /**
54
- * Parse one governed source file into content violations + module edges.
55
- */
56
- export function scanSourceFile(ts, root, config, rules, manifestIntentLayers, file, sourceLayer) {
57
- const source = fs.readFileSync(file, 'utf8');
58
- const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true);
59
- const violations = [];
60
- const edges = [];
61
-
62
- const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
63
- const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
64
- ? layerConfig.forbiddenGlobals.filter((entry) => typeof entry === 'string')
65
- : [];
66
- for (const use of collectForbiddenCapabilityUses(ts, sourceFile, forbiddenGlobals)) {
67
- violations.push({
68
- ruleId: 'FORBIDDEN_GLOBAL',
69
- file: normalize(path.relative(root, file)),
70
- line: use.line,
71
- fromLayer: sourceLayer,
72
- target: use.name,
73
- message: `${sourceLayer} must not use the ambient global "${use.name}".`,
74
- });
75
- }
76
-
77
- // U04 — opted-in capability walls (ADR 0009). One violation, one voice: an
78
- // ambient use already covered by this layer's forbiddenGlobals reports only
79
- // FORBIDDEN_GLOBAL (D7 dedup); absence of the surface adds nothing.
80
- const capabilityDeny = new Set(effectiveCapabilityDeny(layerConfig ?? {}));
81
- if (capabilityDeny.size > 0) {
82
- for (const use of collectCapabilityUses(ts, sourceFile)) {
83
- if (!capabilityDeny.has(use.capability)) continue;
84
- if (
85
- (use.source === 'ambient-global' &&
86
- ambientCoveredByForbiddenGlobals(use.symbol, forbiddenGlobals)) ||
87
- (use.source === 'import-based' &&
88
- forbiddenGlobalForModuleSpecifier(use.symbol, forbiddenGlobals))
89
- ) {
90
- continue;
91
- }
92
- violations.push({
93
- ruleId: 'CAPABILITY_VIOLATION',
94
- file: normalize(path.relative(root, file)),
95
- line: use.line,
96
- fromLayer: sourceLayer,
97
- target: use.symbol,
98
- capability: use.capability,
99
- message:
100
- use.source === 'import-based'
101
- ? `${sourceLayer} denies the ${use.capability} capability; found import of "${use.symbol}".`
102
- : `${sourceLayer} denies the ${use.capability} capability; found ambient "${use.symbol}".`,
103
- });
104
- }
105
- }
106
-
107
- const checkModuleEdge = (specifier, node, kind, typeOnly = false) => {
108
- const namedBindings = namedModuleBindings(ts, node);
109
- edges.push({
110
- specifier,
111
- line: lineOf(sourceFile, node.getStart(sourceFile)),
112
- kind,
113
- typeOnly,
114
- ...(namedBindings ? { namedBindings } : {}),
115
- });
116
- };
117
-
118
- for (const dependency of extractSemanticDependencies(ts, sourceFile)) {
119
- if (!dependency.specifier) continue;
120
- checkModuleEdge(
121
- dependency.specifier,
122
- dependency.node,
123
- dependency.kind,
124
- dependency.typeOnly
125
- );
126
- }
127
-
128
- const needsPolicyWalk =
129
- /\bpublish\s*\(|\bintent\b/.test(source) ||
130
- /['"`]\s*[A-Z][A-Za-z0-9_]*\./.test(source);
131
- const visit = (node) => {
132
- if (ts.isCallExpression(node)) {
133
- if (isPublishCall(ts, node)) {
134
- const firstArg = node.arguments[0];
135
- const rawIntent = stringLiteralText(ts, firstArg);
136
- for (const finding of classifyPublishFacts({
137
- publishCall: true,
138
- rawIntentName: rawIntent,
139
- objectHasIntent: objectHasProperty(ts, firstArg, 'intent'),
140
- arkPublishCandidate: isArkPublishCandidate(ts, node),
141
- hasSource: publishHasSource(ts, node),
142
- })) {
143
- violations.push({
144
- ruleId: finding.ruleId,
145
- file: normalize(path.relative(root, file)),
146
- line: lineOf(sourceFile, node.getStart(sourceFile)),
147
- ...(finding.ruleId === 'PUBLISH_MISSING_SOURCE' ? { fromLayer: sourceLayer } : {}),
148
- message: finding.message,
149
- });
150
- }
151
-
152
- const sourceIntent = publishSourceLiteral(ts, node);
153
- if (sourceIntent && looksLikeIntent(sourceIntent)) {
154
- const sourceIntentLayer = layerForIntent(
155
- sourceIntent,
156
- config.layers,
157
- manifestIntentLayers
158
- );
159
- if (sourceIntentLayer && sourceIntentLayer !== sourceLayer) {
160
- violations.push({
161
- ruleId: 'PUBLISH_SOURCE_LAYER_MISMATCH',
162
- file: normalize(path.relative(root, file)),
163
- line: lineOf(sourceFile, node.getStart(sourceFile)),
164
- fromLayer: sourceLayer,
165
- toLayer: sourceIntentLayer,
166
- target: sourceIntent,
167
- message:
168
- `Publish source "${sourceIntent}" resolves to ${sourceIntentLayer}, but the publishing file is classified as ${sourceLayer}.`,
169
- });
170
- }
171
- }
172
- }
173
- }
174
-
175
- if (ts.isStringLiteralLike(node) && looksLikeIntent(node.text)) {
176
- const targetLayer = layerForIntent(node.text, config.layers, manifestIntentLayers);
177
- const rule = targetLayer ? isBlocked(rules, sourceLayer, targetLayer) : undefined;
178
- if (rule) {
179
- violations.push({
180
- ruleId: 'LAYER_INTENT_REFERENCE_VIOLATION',
181
- file: normalize(path.relative(root, file)),
182
- line: lineOf(sourceFile, node.getStart(sourceFile)),
183
- fromLayer: sourceLayer,
184
- toLayer: targetLayer,
185
- target: node.text,
186
- message:
187
- rule.message ??
188
- `${sourceLayer} must not reference ${targetLayer} intent ${node.text}.`,
189
- });
11
+ import { effectiveAnalysisConfig } from './analysis-policy.mjs';
12
+ import { resolveCandidateFacts } from './resolved-candidate-facts.mjs';
13
+
14
+ /** Resolve canonical facts and optionally retain filesystem probes for resident invalidation. */
15
+ export function resolveArchitectureSnapshot({
16
+ root,
17
+ config,
18
+ manifest,
19
+ rules,
20
+ files,
21
+ ts,
22
+ args,
23
+ captureInputs = true,
24
+ }) {
25
+ const observedInputs = captureInputs ? new Map() : undefined;
26
+ const observeInput = observedInputs
27
+ ? (inputPath, kind) => {
28
+ const absolute = path.resolve(inputPath);
29
+ const kinds = observedInputs.get(absolute) ?? new Set();
30
+ kinds.add(kind);
31
+ observedInputs.set(absolute, kinds);
190
32
  }
191
- }
192
-
193
- ts.forEachChild(node, visit);
194
- };
195
- if (needsPolicyWalk) visit(sourceFile);
196
- return {
197
- contentViolations: violations,
198
- edges,
199
- parseDiagnosticCount: sourceFile.parseDiagnostics?.length ?? 0,
200
- exportsOnlyTypes: sourceFileExportsOnlyTypes(ts, sourceFile),
201
- typeOnlyExportNames: typeOnlyExportNames(ts, sourceFile),
202
- hasTopLevelSideEffects: sourceFileHasTopLevelSideEffects(ts, sourceFile),
33
+ : undefined;
34
+ const configPath = args?.config
35
+ ? path.resolve(root, args.config)
36
+ : path.join(root, 'ark.config.json');
37
+ observeInput?.(configPath, 'ark-config');
38
+ if (args?.manifest) observeInput?.(path.resolve(root, args.manifest), 'manifest');
39
+ const effectiveConfig = effectiveAnalysisConfig(
40
+ { ...config, rules: rules ?? config.rules },
41
+ manifest
42
+ );
43
+ const facts = resolveCandidateFacts({
44
+ root,
45
+ config: effectiveConfig,
46
+ ts,
47
+ ...(args?.tsconfig ? { tsconfig: args.tsconfig } : {}),
48
+ observeInput,
49
+ });
50
+ const loadedContract = loadContract(effectiveConfig, configPath);
51
+ const analyzed = analyzeTrustedResolvedProject({ contract: loadedContract, facts });
52
+ const parseHealth = summarizeParseHealth(
53
+ facts.files.map((file) => ({
54
+ relFile: file.path,
55
+ entry: { parseDiagnosticCount: file.parseDiagnosticCount },
56
+ }))
57
+ );
58
+ const result = {
59
+ violations: analyzed.ir.violations,
60
+ warnings: analyzed.ir.warnings,
61
+ safety: analyzed.safety,
62
+ parseHealth,
63
+ completeness: analyzed.completeness,
64
+ completenessReasons: analyzed.completenessReasons,
65
+ valid: analyzed.valid,
66
+ strictValid: analyzed.strictValid,
67
+ mode: analyzed.mode,
68
+ policyHash: analyzed.policyHash,
69
+ resolverIdentity: analyzed.resolverIdentity,
70
+ factsHash: analyzed.factsHash,
71
+ candidateTreeHash: analyzed.candidateTreeHash,
203
72
  };
73
+ const inputs = observedInputs
74
+ ? [...observedInputs]
75
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
76
+ .map(([inputPath, kinds]) => ({ path: inputPath, kinds: [...kinds].sort() }))
77
+ : [];
78
+ return { facts, result, inputs };
204
79
  }
205
80
 
206
81
  /**
207
82
  * Full architecture scan for governed files.
208
83
  * @returns {{ violations: object[], warnings: object[] }}
209
84
  */
210
- export function runArchitectureScan({ root, config, manifest, rules, files, ts, args }) {
211
- const manifestIntentLayers = intentLayersFromManifest(manifest);
212
- const compilerOptionsFor = createCompilerOptionsLookup(ts, root, args.tsconfig);
213
- const moduleHost = createModuleResolutionHost(ts);
214
-
215
- const warnings = collectConfigWarnings(root, config, files, rules, manifest);
216
- const safety = collectSafetyDiagnostics(ts, root, config, files);
217
- warnings.push(...safety.warnings);
218
- const cacheKey = args.noCache ? undefined : scanCacheKey(root, args, ts.version);
219
- const cachedFiles = cacheKey ? loadScanCache(root, cacheKey) : undefined;
220
- const nextCacheFiles = {};
221
-
222
- const scanned = [];
223
- for (const file of files) {
224
- const sourceLayer = layerForFile(root, file, config.layers);
225
- if (!sourceLayer) continue;
226
- const relFile = normalize(path.relative(root, file));
227
- const stat = fs.statSync(file);
228
- const fileKey = `${stat.mtimeMs}:${stat.size}`;
229
- const cached = cachedFiles?.[relFile];
230
- const entry =
231
- cached &&
232
- cached.fileKey === fileKey &&
233
- Number.isSafeInteger(cached.parseDiagnosticCount) &&
234
- cached.parseDiagnosticCount >= 0
235
- ? cached
236
- : {
237
- fileKey,
238
- ...scanSourceFile(
239
- ts,
240
- root,
241
- config,
242
- rules,
243
- manifestIntentLayers,
244
- file,
245
- sourceLayer
246
- ),
247
- };
248
- nextCacheFiles[relFile] = entry;
249
- scanned.push({ file, sourceLayer, relFile, entry });
250
- }
251
-
252
- const engineEdges = [];
253
- // Y08 derives exact forbidden-global module duals from dependency facts
254
- // already stored in every current cache entry. This stays downstream of the
255
- // cache: no second AST scan, no cache-shape/version bump, and warm pre-Y08
256
- // entries gain the verdict immediately.
257
- const forbiddenModuleDualViolations = [];
258
- const forbiddenModuleDualKeys = new Set();
259
- for (const { file, sourceLayer, relFile, entry } of scanned) {
260
- const layerConfig = config.layers.find((layer) => layer.name === sourceLayer);
261
- const forbiddenGlobals = Array.isArray(layerConfig?.forbiddenGlobals)
262
- ? layerConfig.forbiddenGlobals.filter((item) => typeof item === 'string')
263
- : [];
264
- for (const edge of entry.edges) {
265
- if (!edge.typeOnly) {
266
- const forbiddenGlobal = forbiddenGlobalForModuleSpecifier(
267
- edge.specifier,
268
- forbiddenGlobals
269
- );
270
- if (forbiddenGlobal) {
271
- forbiddenModuleDualKeys.add(`${relFile}\0${edge.specifier}`);
272
- forbiddenModuleDualViolations.push({
273
- ruleId: 'FORBIDDEN_GLOBAL',
274
- file: relFile,
275
- line: edge.line,
276
- fromLayer: sourceLayer,
277
- target: edge.specifier,
278
- edgeKind: edge.kind,
279
- message: `${sourceLayer} must not use module "${edge.specifier}" because it is the import form of forbidden global "${forbiddenGlobal}".`,
280
- });
281
- }
282
- }
283
- const target = resolveImport(
284
- ts,
285
- edge.specifier,
286
- file,
287
- compilerOptionsFor(file),
288
- moduleHost,
289
- root
290
- );
291
- const targetLayer = target ? layerForFile(root, target, config.layers) : undefined;
292
- const relTarget = target ? normalize(path.relative(root, target)) : undefined;
293
- const targetCached = relTarget ? nextCacheFiles[relTarget] : undefined;
294
- const staticEdge = edge.kind === 'import' || edge.kind === 'export';
295
- const targetTypeOnlyExports =
296
- staticEdge && Boolean(targetCached?.exportsOnlyTypes) && !edge.typeOnly;
297
- const sourcePureTypeModule = Boolean(entry.exportsOnlyTypes);
298
- const targetTypeNames = new Set(targetCached?.typeOnlyExportNames || []);
299
- const named = edge.namedBindings;
300
- const namedBindingsTypeOnly =
301
- staticEdge &&
302
- Array.isArray(named) &&
303
- named.length > 0 &&
304
- targetTypeNames.size > 0 &&
305
- !targetCached?.hasTopLevelSideEffects &&
306
- named.every((name) => targetTypeNames.has(name));
307
- const deniedRule = targetLayer
308
- ? isBlocked(rules, sourceLayer, targetLayer, {
309
- fromPath: relFile,
310
- toPath: relTarget,
311
- layers: config.layers,
312
- })
313
- : undefined;
314
- let portProofEligible = false;
315
- if (
316
- deniedRule &&
317
- !deniedRule.peerIsolation &&
318
- !edge.typeOnly &&
319
- edge.kind === 'import' &&
320
- !targetTypeOnlyExports &&
321
- !namedBindingsTypeOnly
322
- ) {
323
- try {
324
- const source = fs.readFileSync(file, 'utf8');
325
- portProofEligible = Boolean(provePortProofInject(ts, source, { filePath: file }).eligible);
326
- } catch {
327
- portProofEligible = false;
328
- }
329
- }
330
- engineEdges.push({
331
- from: relFile,
332
- fromLayer: sourceLayer,
333
- to: relTarget,
334
- toLayer: targetLayer,
335
- line: edge.line,
336
- kind: edge.kind,
337
- typeOnly: edge.typeOnly,
338
- targetTypeOnlyExports,
339
- sourcePureTypeModule,
340
- namedBindingsTypeOnly,
341
- portProofEligible,
342
- });
343
- }
344
- }
345
-
346
- if (cacheKey) saveScanCache(root, cacheKey, nextCacheFiles);
347
-
348
- // A cache written before Y08 can retain the overlapping capability-wall
349
- // finding for this import. Filter by file+specifier (not line: D7 explicitly
350
- // does not depend on cross-engine line anchors), then let FORBIDDEN_GLOBAL be
351
- // the one declared-policy voice.
352
- const contentViolations = scanned
353
- .flatMap(({ entry }) => entry.contentViolations)
354
- .filter(
355
- (violation) =>
356
- !(
357
- violation.ruleId === 'CAPABILITY_VIOLATION' &&
358
- forbiddenModuleDualKeys.has(`${violation.file}\0${violation.target}`)
359
- )
360
- );
361
-
362
- return {
363
- ...evaluateArchitectureGraph({
364
- config,
365
- rules,
366
- files: scanned.map(({ relFile }) => relFile),
367
- contentViolations: [...contentViolations, ...forbiddenModuleDualViolations],
368
- edges: engineEdges,
369
- warnings,
370
- safety: safety.report,
371
- }),
372
- parseHealth: summarizeParseHealth(scanned),
373
- };
85
+ export function runArchitectureScan(options) {
86
+ return resolveArchitectureSnapshot({ ...options, captureInputs: false }).result;
374
87
  }
@@ -194,15 +194,35 @@ export function applyImportTypeAutoPatch(ts, source, opts = {}) {
194
194
  * filePath?: string,
195
195
  * root: string,
196
196
  * ts: object,
197
- * validate: (source: string) => { valid: boolean, violations?: any[] },
197
+ * validate: (source: string) => { valid: boolean, completeness?: string, violations?: any[] },
198
198
  * resolveTargetAbs?: Function,
199
199
  * }} opts
200
200
  */
201
201
  export function validateWithAutoPatch(opts) {
202
202
  const { source, filePath, root, ts, validate, resolveTargetAbs } = opts;
203
203
  const result = validate(source);
204
+ const completeness = result.completeness === 'unavailable' ? 'unavailable' : 'partial';
205
+ const completenessReasons =
206
+ Array.isArray(result.completenessReasons) && result.completenessReasons.length > 0
207
+ ? result.completenessReasons
208
+ : [
209
+ {
210
+ code:
211
+ completeness === 'unavailable'
212
+ ? 'ANALYSIS_UNAVAILABLE'
213
+ : 'LEXICAL_EVIDENCE_INCOMPLETE',
214
+ message:
215
+ completeness === 'unavailable'
216
+ ? 'Single-file analysis is unavailable.'
217
+ : 'Single-file validation cannot prove complete candidate resolver evidence.',
218
+ },
219
+ ];
204
220
  const base = {
205
- valid: Boolean(result.valid),
221
+ mode: 'lexical-compatibility',
222
+ valid: false,
223
+ lexicalValid: Boolean(result.lexicalValid ?? result.valid),
224
+ completeness,
225
+ completenessReasons,
206
226
  violations: Array.isArray(result.violations) ? result.violations : [],
207
227
  };
208
228
 
@@ -229,8 +249,16 @@ export function validateWithAutoPatch(opts) {
229
249
  };
230
250
  });
231
251
 
232
- if (base.valid) {
233
- return { valid: true, violations: [], autoPatch: null };
252
+ if (base.lexicalValid) {
253
+ return {
254
+ mode: base.mode,
255
+ valid: false,
256
+ lexicalValid: true,
257
+ completeness: base.completeness,
258
+ completenessReasons: base.completenessReasons,
259
+ violations: [],
260
+ autoPatch: null,
261
+ };
234
262
  }
235
263
 
236
264
  // Write-path autoPatch: import-type mechanical-safe only (W1).
@@ -242,23 +270,63 @@ export function validateWithAutoPatch(opts) {
242
270
  });
243
271
 
244
272
  if (!attempt) {
245
- return { valid: false, violations, autoPatch: null };
273
+ return {
274
+ mode: base.mode,
275
+ valid: false,
276
+ lexicalValid: false,
277
+ completeness: base.completeness,
278
+ completenessReasons: base.completenessReasons,
279
+ violations,
280
+ autoPatch: null,
281
+ };
246
282
  }
247
283
 
248
284
  const after = validate(attempt.source);
249
- if (!after.valid) {
285
+ if (!(after.lexicalValid ?? after.valid)) {
250
286
  // Discard — never return an unvalidated patch
251
- return { valid: false, violations, autoPatch: null };
287
+ return {
288
+ mode: base.mode,
289
+ valid: false,
290
+ lexicalValid: false,
291
+ completeness: base.completeness,
292
+ completenessReasons: base.completenessReasons,
293
+ violations,
294
+ autoPatch: null,
295
+ };
252
296
  }
253
297
 
298
+ const afterCompleteness = after.completeness === 'unavailable' ? 'unavailable' : 'partial';
299
+ const afterCompletenessReasons =
300
+ Array.isArray(after.completenessReasons) && after.completenessReasons.length > 0
301
+ ? after.completenessReasons
302
+ : [
303
+ {
304
+ code:
305
+ afterCompleteness === 'unavailable'
306
+ ? 'ANALYSIS_UNAVAILABLE'
307
+ : 'LEXICAL_EVIDENCE_INCOMPLETE',
308
+ message:
309
+ afterCompleteness === 'unavailable'
310
+ ? 'Single-file analysis is unavailable.'
311
+ : 'Single-file validation cannot prove complete candidate resolver evidence.',
312
+ },
313
+ ];
254
314
  return {
315
+ mode: base.mode,
255
316
  valid: false,
317
+ lexicalValid: false,
318
+ completeness: base.completeness,
319
+ completenessReasons: base.completenessReasons,
256
320
  violations,
257
321
  autoPatch: {
322
+ mode: 'lexical-compatibility',
258
323
  source: attempt.source,
259
324
  remediationKind: attempt.remediationKind,
260
325
  confidence: attempt.confidence,
261
- valid: true,
326
+ valid: false,
327
+ lexicalValid: true,
328
+ completeness: afterCompleteness,
329
+ completenessReasons: afterCompletenessReasons,
262
330
  },
263
331
  };
264
332
  }
@@ -426,6 +426,6 @@ ${nodeSetup}
426
426
  ${qualityBlock ? `${qualityBlock}\n` : ''} - name: Ark architecture check
427
427
  env:
428
428
  ARK_POLICY_BASE_REF: \${{ github.event.pull_request.base.sha || github.event.before }}
429
- run: ${pm.run}
429
+ run: ${pm.run} --fail-on-new-smells --base-ref "\${{ github.event.pull_request.base.sha || github.event.before }}"
430
430
  `;
431
431
  }
@@ -68,11 +68,32 @@ export function codexProjectSlug(absRoot) {
68
68
  return `${base}_${hash}`;
69
69
  }
70
70
 
71
+ /** Parse the one generated-style TOML args array from an MCP table body. */
72
+ export function extractCodexArgsFromBlock(block) {
73
+ if (!block || typeof block !== 'string') return null;
74
+ const matches = [
75
+ ...block.matchAll(/^[ \t]*args[ \t]*=[ \t]*\[([^\]\r\n]*)\][ \t]*(?:#.*)?$/gm),
76
+ ];
77
+ if (matches.length !== 1) return null;
78
+ const tokens = [...matches[0][1].matchAll(/"(?:\\.|[^"\\])*"|'[^']*'/g)];
79
+ const shape = matches[0][1].replace(/"(?:\\.|[^"\\])*"|'[^']*'/g, '__ARK_STRING__');
80
+ if (!/^[ \t]*(?:__ARK_STRING__(?:[ \t]*,[ \t]*__ARK_STRING__)*[ \t]*,?)?[ \t]*$/.test(shape)) {
81
+ return null;
82
+ }
83
+ try {
84
+ return tokens.map((match) =>
85
+ match[0].startsWith('"') ? JSON.parse(match[0]) : match[0].slice(1, -1)
86
+ );
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
71
92
  /** Extract `--root` from one TOML mcp_servers table body. */
72
93
  export function extractCodexRootFromBlock(block) {
73
- if (!block || typeof block !== 'string') return null;
74
- const m = block.match(/"--root"\s*,\s*"([^"]+)"/);
75
- return m ? m[1] : null;
94
+ const args = extractCodexArgsFromBlock(block);
95
+ const index = args?.indexOf('--root') ?? -1;
96
+ return index >= 0 && typeof args[index + 1] === 'string' ? args[index + 1] : null;
76
97
  }
77
98
 
78
99
  /**
@@ -82,20 +103,19 @@ export function extractCodexRootFromBlock(block) {
82
103
  export function listCodexArkServerTables(tomlText) {
83
104
  if (!tomlText || typeof tomlText !== 'string') return [];
84
105
  const out = [];
85
- const headerRe = /\[mcp_servers\.(ark(?:_[a-zA-Z0-9_-]*)?)\]/g;
106
+ const headerRe =
107
+ /^[ \t]*\[[ \t]*(?:"mcp_servers"|'mcp_servers'|mcp_servers)[ \t]*\.[ \t]*(?:"(ark(?:_[a-zA-Z0-9_-]*)?)"|'(ark(?:_[a-zA-Z0-9_-]*)?)'|(ark(?:_[a-zA-Z0-9_-]*)?))[ \t]*\][ \t]*(?:#.*)?$/gm;
86
108
  const headers = [];
87
109
  let hm;
88
110
  while ((hm = headerRe.exec(tomlText)) !== null) {
89
- headers.push({ table: hm[1], index: hm.index });
111
+ headers.push({ table: hm[1] ?? hm[2] ?? hm[3], index: hm.index });
90
112
  }
91
113
  for (let i = 0; i < headers.length; i++) {
92
114
  const start = headers[i].index;
93
- let end = i + 1 < headers.length ? headers[i + 1].index : tomlText.length;
94
- if (i + 1 >= headers.length) {
95
- const rest = tomlText.slice(start + 1);
96
- const other = rest.search(/\n\[/);
97
- if (other >= 0) end = start + 1 + other;
98
- }
115
+ let end = tomlText.length;
116
+ const rest = tomlText.slice(start + 1);
117
+ const other = rest.search(/\n(?=[ \t]*\[)/);
118
+ if (other >= 0) end = start + 1 + other;
99
119
  const block = tomlText.slice(start, end).replace(/\s+$/, '\n');
100
120
  out.push({
101
121
  table: headers[i].table,
@@ -148,9 +168,14 @@ export function codexScopedTableForRoot(tomlText, absRoot) {
148
168
  /** True when project TOML owns the primary Ark MCP binding for that project. */
149
169
  export function codexProjectMcpIsValid(tomlText, projectRoot) {
150
170
  const resolvedRoot = path.resolve(projectRoot);
171
+ if (listCodexArkServerTables(tomlText).filter((entry) => entry.table === 'ark').length !== 1) {
172
+ return false;
173
+ }
151
174
  const primary = codexPrimaryTable(tomlText);
152
- if (!primary?.root || !/\b(ark|arkgate)-mcp\b/.test(primary.block)) return false;
153
- const config = primary.block.match(/"--config"\s*,\s*"([^"]+)"/)?.[1];
175
+ const args = extractCodexArgsFromBlock(primary?.block);
176
+ if (!primary?.root || !args?.some((value) => /^(ark|arkgate)-mcp$/.test(value))) return false;
177
+ const configIndex = args.indexOf('--config');
178
+ const config = configIndex >= 0 ? args[configIndex + 1] : null;
154
179
  if (!config) return false;
155
180
  try {
156
181
  return (
@@ -170,7 +195,9 @@ export function extractCodexArkRootFromToml(tomlText) {
170
195
  export function codexArkBlockHasPreferredBin(tomlText) {
171
196
  const primary = codexPrimaryTable(tomlText);
172
197
  if (!primary) return false;
173
- const bins = [...primary.block.matchAll(/"(arkgate-mcp|ark-mcp)"/g)].map((m) => m[1]);
198
+ const bins = (extractCodexArgsFromBlock(primary.block) ?? []).filter((value) =>
199
+ /^(arkgate-mcp|ark-mcp)$/.test(value)
200
+ );
174
201
  if (bins.length > 1) return false;
175
202
  return bins.length === 1 && bins[0] === PREFERRED_CODEX_MCP_BIN;
176
203
  }
@@ -180,7 +207,7 @@ export function codexArkBlockHasPreferredBin(tomlText) {
180
207
  * Permanent different project roots are NOT broken — multi-project uses a secondary table.
181
208
  */
182
209
  export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
183
- if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) return true;
210
+ if (!codexPrimaryTable(tomlText)) return true;
184
211
  const rootArg = extractCodexArkRootFromToml(tomlText);
185
212
  if (!rootArg || isTempOrUpgradeRoot(rootArg)) return true;
186
213
  try {
@@ -210,7 +237,7 @@ export function codexArkBlockNeedsRewrite(tomlText, absRoot) {
210
237
  */
211
238
  export function assessCodexHomeMcp(tomlText, absRoot) {
212
239
  const resolvedRoot = path.resolve(absRoot);
213
- if (!tomlText || !tomlText.includes('[mcp_servers.ark]')) {
240
+ if (!codexPrimaryTable(tomlText)) {
214
241
  return {
215
242
  root: null,
216
243
  tempPath: false,