arkgate 2.12.0 → 3.0.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 (88) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/README.md +90 -51
  3. package/bin/ark-check.mjs +156 -39
  4. package/bin/ark-mcp.mjs +119 -6
  5. package/bin/ark-shared.mjs +216 -129
  6. package/bin/ark.mjs +134 -34
  7. package/bin/lib/adapter-contract.mjs +93 -0
  8. package/bin/lib/agent-gates.mjs +13 -0
  9. package/bin/lib/analysis-engine.mjs +1171 -0
  10. package/bin/lib/architecture-scan.mjs +84 -127
  11. package/bin/lib/ci-and-commands.mjs +40 -3
  12. package/bin/lib/codex-home.mjs +7 -0
  13. package/bin/lib/config-contract.mjs +331 -0
  14. package/bin/lib/config-warnings.mjs +7 -205
  15. package/bin/lib/doctor-plan.mjs +43 -16
  16. package/bin/lib/enforcement-profiles.mjs +97 -0
  17. package/bin/lib/field-install.mjs +67 -10
  18. package/bin/lib/gate-files.mjs +42 -3
  19. package/bin/lib/graph-cycles.mjs +4 -54
  20. package/bin/lib/hook-templates.mjs +33 -1
  21. package/bin/lib/host-support-matrix.mjs +83 -0
  22. package/bin/lib/install-migrate.mjs +99 -30
  23. package/bin/lib/mcp-adoption.mjs +35 -3
  24. package/bin/lib/open-html.mjs +75 -0
  25. package/bin/lib/presets.mjs +45 -4
  26. package/bin/lib/safety-diagnostics.mjs +36 -15
  27. package/bin/lib/scan-files.mjs +12 -1
  28. package/bin/lib/skill-install.mjs +72 -1
  29. package/bin/lib/source-policy.mjs +36 -0
  30. package/bin/lib/start-preview.mjs +271 -0
  31. package/bin/lib/ts-resolve.mjs +13 -3
  32. package/bin/lib/weakest-link.mjs +417 -0
  33. package/bin/lib/write-path-capabilities.mjs +186 -0
  34. package/bin/lib/write-path-detect.mjs +62 -99
  35. package/compat/nestjs.cjs +2 -0
  36. package/compat/nestjs.d.ts +2 -0
  37. package/compat/nestjs.js +1 -0
  38. package/compat/runtime.cjs +2 -0
  39. package/compat/runtime.d.ts +2 -0
  40. package/compat/runtime.js +1 -0
  41. package/dist/configContract-BxSIwVRo.d.cts +259 -0
  42. package/dist/configContract-BxSIwVRo.d.ts +259 -0
  43. package/dist/eslint/index.cjs +500 -61
  44. package/dist/eslint/index.d.cts +36 -20
  45. package/dist/eslint/index.d.ts +36 -20
  46. package/dist/eslint/index.js +500 -61
  47. package/dist/index.cjs +1349 -2741
  48. package/dist/index.d.cts +449 -483
  49. package/dist/index.d.ts +449 -483
  50. package/dist/index.js +1325 -2687
  51. package/docs/agent-guide.md +58 -34
  52. package/docs/ai-gates.md +79 -21
  53. package/docs/configuration.md +97 -0
  54. package/docs/enthusiast/README.md +3 -3
  55. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  56. package/docs/migrate-from-ark-runtime-kernel.md +5 -3
  57. package/docs/package-surface.md +19 -19
  58. package/docs/production-hardening.md +31 -5
  59. package/docs/threat-model.md +65 -0
  60. package/docs/typescript-support.md +30 -3
  61. package/package.json +46 -11
  62. package/schemas/ark.analysis-result.schema.json +91 -0
  63. package/schemas/ark.config.schema.json +750 -0
  64. package/server.json +2 -2
  65. package/templates/hooks/pre-commit-ark +37 -0
  66. package/templates/skills/ark-architect.md +3 -2
  67. package/templates/skills/ark-coverage.md +2 -2
  68. package/templates/skills/ark-runtime.md +8 -5
  69. package/templates/skills/ark-upgrade.md +36 -16
  70. package/tests/fixtures/ts-consumer/ark.config.json +2 -0
  71. package/dist/eslint/index.cjs.map +0 -1
  72. package/dist/eslint/index.js.map +0 -1
  73. package/dist/index.cjs.map +0 -1
  74. package/dist/index.js.map +0 -1
  75. package/dist/nestjs/index.cjs +0 -2498
  76. package/dist/nestjs/index.cjs.map +0 -1
  77. package/dist/nestjs/index.d.cts +0 -22
  78. package/dist/nestjs/index.d.ts +0 -22
  79. package/dist/nestjs/index.js +0 -2474
  80. package/dist/nestjs/index.js.map +0 -1
  81. package/dist/runtime/index.cjs +0 -3352
  82. package/dist/runtime/index.cjs.map +0 -1
  83. package/dist/runtime/index.d.cts +0 -2
  84. package/dist/runtime/index.d.ts +0 -2
  85. package/dist/runtime/index.js +0 -3270
  86. package/dist/runtime/index.js.map +0 -1
  87. package/dist/types-BZ17b9i5.d.cts +0 -1068
  88. package/dist/types-BZ17b9i5.d.ts +0 -1068
@@ -0,0 +1,331 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/configContract.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/config-contract.mjs). Zero Node I/O.
9
+ */
10
+
11
+ export const ARK_CONFIG_SCHEMA_VERSION = '1.0';
12
+ export const ARK_CONFIG_SCHEMA_URL = 'https://unpkg.com/arkgate@2/schemas/ark.config.schema.json';
13
+ const DEFAULT_LAYER_NAMES = [
14
+ 'DomainModel',
15
+ 'ApplicationOrchestration',
16
+ 'PersistenceAdapters',
17
+ 'IntegrationAdapters',
18
+ 'WorkflowSagaEngine',
19
+ 'BackgroundJobsScheduling',
20
+ 'PresentationAdapters',
21
+ 'ReportingReadModels',
22
+ 'ExtensibilityMetadata',
23
+ 'SecurityAuditObservability',
24
+ 'Kernel',
25
+ ];
26
+ const DEFAULT_ALLOWED_FLOWS = new Set([
27
+ 'PresentationAdapters->ApplicationOrchestration',
28
+ 'ApplicationOrchestration->DomainModel',
29
+ 'WorkflowSagaEngine->ApplicationOrchestration',
30
+ 'WorkflowSagaEngine->DomainModel',
31
+ 'BackgroundJobsScheduling->ApplicationOrchestration',
32
+ ]);
33
+ function createDefaultRules() {
34
+ const rules = [];
35
+ for (const from of DEFAULT_LAYER_NAMES) {
36
+ for (const to of DEFAULT_LAYER_NAMES) {
37
+ if (from === to || DEFAULT_ALLOWED_FLOWS.has(`${from}->${to}`))
38
+ continue;
39
+ rules.push({ from, to, allowed: false });
40
+ }
41
+ }
42
+ return rules;
43
+ }
44
+ export const DEFAULT_ARK_CONFIG_RULES = createDefaultRules();
45
+ export const ARK_CONFIG_MIGRATIONS = [
46
+ { from: 'unversioned', to: ARK_CONFIG_SCHEMA_VERSION },
47
+ ];
48
+ const stringArraySchema = {
49
+ type: 'array',
50
+ items: { type: 'string', minLength: 1 },
51
+ uniqueItems: true,
52
+ };
53
+ export const ARK_CONFIG_SCHEMA = {
54
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
55
+ $id: ARK_CONFIG_SCHEMA_URL,
56
+ title: 'ArkGate architecture contract',
57
+ description: 'Versioned contract consumed identically by ArkGate CLI, MCP, and ESLint surfaces.',
58
+ type: 'object',
59
+ additionalProperties: false,
60
+ required: ['$schema', 'schemaVersion', 'include', 'layers', 'rules'],
61
+ properties: {
62
+ $schema: {
63
+ type: 'string',
64
+ minLength: 1,
65
+ default: ARK_CONFIG_SCHEMA_URL,
66
+ description: 'Editor-facing URL or local path for this JSON Schema.',
67
+ },
68
+ schemaVersion: {
69
+ type: 'string',
70
+ const: ARK_CONFIG_SCHEMA_VERSION,
71
+ default: ARK_CONFIG_SCHEMA_VERSION,
72
+ },
73
+ name: { type: 'string', minLength: 1 },
74
+ include: { ...stringArraySchema, minItems: 1, default: ['src'] },
75
+ exclude: { ...stringArraySchema, default: [] },
76
+ excludeGenerated: { type: 'boolean', default: true },
77
+ frameworkOverlay: { type: 'string', minLength: 1 },
78
+ layers: {
79
+ type: 'array',
80
+ default: [],
81
+ items: { $ref: '#/$defs/layer' },
82
+ },
83
+ rules: {
84
+ type: 'array',
85
+ default: DEFAULT_ARK_CONFIG_RULES,
86
+ items: { $ref: '#/$defs/rule' },
87
+ },
88
+ cyclePolicy: {
89
+ type: 'string',
90
+ enum: ['strict', 'soft', 'framework-soft', 'off'],
91
+ default: 'strict',
92
+ },
93
+ dynamicImportAllowlist: { ...stringArraySchema, default: [] },
94
+ safety: {
95
+ $ref: '#/$defs/safety',
96
+ default: {
97
+ maxTsSuppressions: 0,
98
+ maxAnyCasts: 0,
99
+ allowInMemory: false,
100
+ allowDisabledPeerIsolation: false,
101
+ },
102
+ },
103
+ },
104
+ $defs: {
105
+ layer: {
106
+ type: 'object',
107
+ additionalProperties: false,
108
+ required: ['name', 'patterns'],
109
+ properties: {
110
+ name: { type: 'string', minLength: 1 },
111
+ patterns: { ...stringArraySchema, minItems: 1 },
112
+ exclude: stringArraySchema,
113
+ intentPrefixes: stringArraySchema,
114
+ description: { type: 'string', minLength: 1 },
115
+ forbiddenGlobals: stringArraySchema,
116
+ mayImportInfrastructure: { type: 'boolean' },
117
+ optional: { type: 'boolean' },
118
+ },
119
+ },
120
+ rule: {
121
+ type: 'object',
122
+ additionalProperties: false,
123
+ required: ['from', 'to', 'allowed'],
124
+ properties: {
125
+ from: { type: 'string', minLength: 1 },
126
+ to: { type: 'string', minLength: 1 },
127
+ allowed: { type: 'boolean' },
128
+ message: { type: 'string', minLength: 1 },
129
+ peerIsolation: { type: 'boolean' },
130
+ sliceFolders: { ...stringArraySchema, minItems: 1 },
131
+ },
132
+ },
133
+ safety: {
134
+ type: 'object',
135
+ additionalProperties: false,
136
+ properties: {
137
+ maxTsSuppressions: { type: 'integer', minimum: 0, default: 0 },
138
+ maxAnyCasts: { type: 'integer', minimum: 0, default: 0 },
139
+ allowInMemory: { type: 'boolean', default: false },
140
+ allowDisabledPeerIsolation: { type: 'boolean', default: false },
141
+ },
142
+ },
143
+ },
144
+ };
145
+ export class ArkConfigValidationError extends Error {
146
+ issues;
147
+ source;
148
+ constructor(source, issues) {
149
+ super(`Invalid ArkGate config (${source}):\n${issues
150
+ .map((issue) => `- ${issue.path}: ${issue.message}`)
151
+ .join('\n')}`);
152
+ this.name = 'ArkConfigValidationError';
153
+ this.source = source;
154
+ this.issues = issues;
155
+ }
156
+ }
157
+ function isObject(value) {
158
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
159
+ }
160
+ function propertyPath(parent, key) {
161
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
162
+ ? `${parent}.${key}`
163
+ : `${parent}[${JSON.stringify(key)}]`;
164
+ }
165
+ function valueType(value) {
166
+ if (value === null)
167
+ return 'null';
168
+ if (Array.isArray(value))
169
+ return 'array';
170
+ return typeof value;
171
+ }
172
+ function resolveSchemaRef(ref, root) {
173
+ const prefix = '#/$defs/';
174
+ if (!ref.startsWith(prefix))
175
+ return undefined;
176
+ return root.$defs[ref.slice(prefix.length)];
177
+ }
178
+ function validateNode(value, schema, path, root, issues) {
179
+ if (schema.$ref) {
180
+ const referenced = resolveSchemaRef(schema.$ref, root);
181
+ if (!referenced) {
182
+ issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
183
+ return;
184
+ }
185
+ validateNode(value, referenced, path, root, issues);
186
+ return;
187
+ }
188
+ if (schema.const !== undefined && !Object.is(value, schema.const)) {
189
+ issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
190
+ return;
191
+ }
192
+ if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
193
+ issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
194
+ return;
195
+ }
196
+ if (schema.type === 'object') {
197
+ if (!isObject(value)) {
198
+ issues.push({ path, message: `must be an object; received ${valueType(value)}` });
199
+ return;
200
+ }
201
+ const properties = schema.properties ?? {};
202
+ for (const key of schema.required ?? []) {
203
+ if (value[key] === undefined) {
204
+ issues.push({ path: propertyPath(path, key), message: 'is required' });
205
+ }
206
+ }
207
+ if (schema.additionalProperties === false) {
208
+ for (const key of Object.keys(value)) {
209
+ if (!(key in properties)) {
210
+ issues.push({ path: propertyPath(path, key), message: 'unknown field' });
211
+ }
212
+ }
213
+ }
214
+ for (const [key, childSchema] of Object.entries(properties)) {
215
+ if (value[key] !== undefined) {
216
+ validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
217
+ }
218
+ }
219
+ return;
220
+ }
221
+ if (schema.type === 'array') {
222
+ if (!Array.isArray(value)) {
223
+ issues.push({ path, message: `must be an array; received ${valueType(value)}` });
224
+ return;
225
+ }
226
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
227
+ issues.push({ path, message: `must contain at least ${schema.minItems} item(s)` });
228
+ }
229
+ if (schema.uniqueItems) {
230
+ const serialized = value.map((entry) => JSON.stringify(entry));
231
+ if (new Set(serialized).size !== serialized.length) {
232
+ issues.push({ path, message: 'must not contain duplicate items' });
233
+ }
234
+ }
235
+ if (schema.items) {
236
+ value.forEach((entry, index) => validateNode(entry, schema.items, `${path}[${index}]`, root, issues));
237
+ }
238
+ return;
239
+ }
240
+ if (schema.type === 'string') {
241
+ if (typeof value !== 'string') {
242
+ issues.push({ path, message: `must be a string; received ${valueType(value)}` });
243
+ return;
244
+ }
245
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
246
+ issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
247
+ }
248
+ return;
249
+ }
250
+ if (schema.type === 'boolean') {
251
+ if (typeof value !== 'boolean') {
252
+ issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
253
+ }
254
+ return;
255
+ }
256
+ if (schema.type === 'integer') {
257
+ if (!Number.isInteger(value)) {
258
+ issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
259
+ return;
260
+ }
261
+ if (schema.minimum !== undefined && value < schema.minimum) {
262
+ issues.push({ path, message: `must be at least ${schema.minimum}` });
263
+ }
264
+ }
265
+ }
266
+ function defaultedConfig(input) {
267
+ return {
268
+ ...input,
269
+ $schema: input.$schema === undefined ? ARK_CONFIG_SCHEMA_URL : input.$schema,
270
+ schemaVersion: input.schemaVersion === undefined ? ARK_CONFIG_SCHEMA_VERSION : input.schemaVersion,
271
+ include: input.include === undefined ? ['src'] : input.include,
272
+ layers: input.layers === undefined ? [] : input.layers,
273
+ rules: input.rules === undefined
274
+ ? DEFAULT_ARK_CONFIG_RULES.map((rule) => ({ ...rule }))
275
+ : input.rules,
276
+ };
277
+ }
278
+ export function migrateArkConfig(input, source = 'ark.config.json') {
279
+ if (!isObject(input)) {
280
+ throw new ArkConfigValidationError(source, [
281
+ { path: '$', message: `must be an object; received ${valueType(input)}` },
282
+ ]);
283
+ }
284
+ const migratedFrom = input.schemaVersion === undefined ? 'unversioned' : null;
285
+ if (input.schemaVersion !== undefined &&
286
+ input.schemaVersion !== ARK_CONFIG_SCHEMA_VERSION) {
287
+ throw new ArkConfigValidationError(source, [
288
+ {
289
+ path: '$.schemaVersion',
290
+ message: `unsupported version ${JSON.stringify(input.schemaVersion)}; expected ${ARK_CONFIG_SCHEMA_VERSION}`,
291
+ },
292
+ ]);
293
+ }
294
+ return { candidate: defaultedConfig(input), migratedFrom };
295
+ }
296
+ export function loadArkConfigContract(input, source = 'ark.config.json') {
297
+ const { candidate, migratedFrom } = migrateArkConfig(input, source);
298
+ const issues = [];
299
+ validateNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
300
+ if (issues.length > 0)
301
+ throw new ArkConfigValidationError(source, issues);
302
+ return { config: candidate, migratedFrom };
303
+ }
304
+ export function parseArkConfigJson(json, source = 'ark.config.json') {
305
+ let input;
306
+ try {
307
+ input = JSON.parse(json);
308
+ }
309
+ catch (error) {
310
+ throw new ArkConfigValidationError(source, [
311
+ {
312
+ path: '$',
313
+ message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
314
+ },
315
+ ]);
316
+ }
317
+ return loadArkConfigContract(input, source);
318
+ }
319
+ export function withArkConfigMetadata(config) {
320
+ const result = {
321
+ $schema: typeof config.$schema === 'string' && config.$schema.length > 0
322
+ ? config.$schema
323
+ : ARK_CONFIG_SCHEMA_URL,
324
+ schemaVersion: ARK_CONFIG_SCHEMA_VERSION,
325
+ };
326
+ for (const [key, value] of Object.entries(config)) {
327
+ if (key !== '$schema' && key !== 'schemaVersion')
328
+ result[key] = value;
329
+ }
330
+ return result;
331
+ }
@@ -5,12 +5,10 @@
5
5
  import path from 'node:path';
6
6
  import {
7
7
  DEFAULT_INTENT_PREFIXES,
8
- globToRegExp,
9
- layerForFile,
10
- patternSpecificity,
11
8
  resolveIntentLayer,
12
9
  } from '../ark-shared.mjs';
13
10
  import { findDeniedEdgeRule } from '../ark-layer-match.mjs';
11
+ import { collectAnalysisConfigWarnings } from './analysis-engine.mjs';
14
12
  import { normalize } from './scan-files.mjs';
15
13
 
16
14
  export function intentLayersFromManifest(manifest) {
@@ -54,206 +52,10 @@ export function configWarning(ruleId, message, extra = {}) {
54
52
  }
55
53
 
56
54
  export function collectConfigWarnings(root, config, files, rules, manifest) {
57
- const warnings = [];
58
- if (
59
- config.dynamicImportAllowlist !== undefined &&
60
- (!Array.isArray(config.dynamicImportAllowlist) ||
61
- config.dynamicImportAllowlist.some((entry) => typeof entry !== 'string'))
62
- ) {
63
- warnings.push(
64
- configWarning(
65
- 'CONFIG_INVALID_DYNAMIC_IMPORT_ALLOWLIST',
66
- 'dynamicImportAllowlist must be an array of file globs.'
67
- )
68
- );
69
- }
70
- if (config.safety !== undefined && (config.safety === null || typeof config.safety !== 'object' || Array.isArray(config.safety))) {
71
- warnings.push(configWarning('CONFIG_INVALID_SAFETY', 'safety must be an object.'));
72
- } else if (config.safety) {
73
- for (const key of ['maxTsSuppressions', 'maxAnyCasts']) {
74
- const value = config.safety[key];
75
- if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
76
- warnings.push(configWarning('CONFIG_INVALID_SAFETY_THRESHOLD', `safety.${key} must be a non-negative integer.`));
77
- }
78
- }
79
- }
80
- const layers = Array.isArray(config.layers) ? config.layers : [];
81
- const manifestLayers = Array.isArray(manifest?.architecture?.layers)
82
- ? manifest.architecture.layers
83
- : [];
84
- const knownLayers = new Set([
85
- ...layers.map((layer) => layer.name).filter(Boolean),
86
- ...manifestLayers.map((layer) => layer.name).filter(Boolean),
87
- ]);
88
-
89
- if (layers.length === 0) {
90
- warnings.push(
91
- configWarning(
92
- 'CONFIG_NO_LAYERS',
93
- 'No file layers are configured; ark-check cannot classify files for import-boundary enforcement.'
94
- )
95
- );
96
- }
97
-
98
- const seenLayers = new Set();
99
- const duplicateLayers = new Set();
100
- for (const layer of layers) {
101
- if (!layer.name) {
102
- warnings.push(
103
- configWarning('CONFIG_LAYER_WITHOUT_NAME', 'A configured layer is missing a name.')
104
- );
105
- continue;
106
- }
107
- if (seenLayers.has(layer.name)) duplicateLayers.add(layer.name);
108
- seenLayers.add(layer.name);
109
-
110
- if (
111
- layer.forbiddenGlobals !== undefined &&
112
- (!Array.isArray(layer.forbiddenGlobals) ||
113
- layer.forbiddenGlobals.some((entry) => typeof entry !== 'string'))
114
- ) {
115
- warnings.push(
116
- configWarning(
117
- 'CONFIG_INVALID_FORBIDDEN_GLOBALS',
118
- `Layer "${layer.name}" has an invalid forbiddenGlobals value; expected an array of strings (e.g. ["fetch", "Date.now"]). The entry is ignored.`,
119
- { layer: layer.name }
120
- )
121
- );
122
- }
123
-
124
- const patterns = Array.isArray(layer.patterns) ? layer.patterns : [];
125
- if (patterns.length === 0) {
126
- warnings.push(
127
- configWarning(
128
- 'CONFIG_LAYER_WITHOUT_PATTERNS',
129
- `Layer "${layer.name}" has no file patterns and will never classify files.`,
130
- { layer: layer.name }
131
- )
132
- );
133
- continue;
134
- }
135
-
136
- for (const pattern of patterns) {
137
- let re;
138
- try {
139
- re = globToRegExp(pattern);
140
- } catch (err) {
141
- warnings.push(
142
- configWarning(
143
- 'CONFIG_INVALID_LAYER_PATTERN',
144
- `Layer "${layer.name}" has an invalid pattern "${pattern}": ${
145
- err instanceof Error ? err.message : String(err)
146
- }`,
147
- { layer: layer.name, pattern }
148
- )
149
- );
150
- continue;
151
- }
152
-
153
- const matched = files.some((file) => {
154
- const rel = normalize(path.relative(root, file));
155
- return re.test(rel);
156
- });
157
- if (!matched && !layer.optional) {
158
- // Advisory only under --strict-config: monorepo/Next presets ship many optional-looking
159
- // globs (e.g. src/layouts/**, app/**) that never match when include is ["frontend"].
160
- // Failing the release gate on dead preset globs caused false CI red while architecture
161
- // edges were clean on multi-package hosts. Real safety is import violations +
162
- // CONFIG_UNCLASSIFIED_FILES / invalid patterns.
163
- warnings.push(
164
- configWarning(
165
- 'CONFIG_LAYER_PATTERN_NO_MATCHES',
166
- `Layer "${layer.name}" pattern "${pattern}" matched no included files.`,
167
- { layer: layer.name, pattern, failsStrict: false }
168
- )
169
- );
170
- }
171
- }
172
- }
173
-
174
- for (const name of duplicateLayers) {
175
- warnings.push(
176
- configWarning(
177
- 'CONFIG_DUPLICATE_LAYER',
178
- `Layer "${name}" is configured more than once.`,
179
- { layer: name }
180
- )
181
- );
182
- }
183
-
184
- if (knownLayers.size > 0) {
185
- for (const rule of rules ?? []) {
186
- if (rule.from && !knownLayers.has(rule.from)) {
187
- warnings.push(
188
- configWarning(
189
- 'CONFIG_RULE_UNKNOWN_FROM_LAYER',
190
- `Rule references unknown source layer "${rule.from}".`,
191
- { fromLayer: rule.from, toLayer: rule.to }
192
- )
193
- );
194
- }
195
- if (rule.to && !knownLayers.has(rule.to)) {
196
- warnings.push(
197
- configWarning(
198
- 'CONFIG_RULE_UNKNOWN_TO_LAYER',
199
- `Rule references unknown target layer "${rule.to}".`,
200
- { fromLayer: rule.from, toLayer: rule.to }
201
- )
202
- );
203
- }
204
- }
205
- }
206
-
207
- // Ambiguous overlap: a file matched by two different layers at the SAME top specificity.
208
- // layerForFile breaks the tie by declaration order, but the config is genuinely undecided
209
- // (unlike a facade split, where the surface pattern is strictly more specific and wins
210
- // cleanly). Surface the layer pairs so the author disambiguates instead of relying on order.
211
- const ambiguousPairs = new Set();
212
- if (layers.length > 1) {
213
- for (const file of files) {
214
- const rel = normalize(path.relative(root, file));
215
- let topScore = -1;
216
- let topLayers = [];
217
- for (const layer of layers) {
218
- for (const pattern of layer.patterns ?? []) {
219
- if (!globToRegExp(pattern).test(rel)) continue;
220
- const score = patternSpecificity(pattern);
221
- if (score > topScore) {
222
- topScore = score;
223
- topLayers = [layer.name];
224
- } else if (score === topScore && !topLayers.includes(layer.name)) {
225
- topLayers.push(layer.name);
226
- }
227
- }
228
- }
229
- if (topLayers.length > 1) {
230
- ambiguousPairs.add([...topLayers].sort().join(' + '));
231
- }
232
- }
233
- }
234
- if (ambiguousPairs.size > 0) {
235
- warnings.push(
236
- configWarning(
237
- 'CONFIG_AMBIGUOUS_LAYERS',
238
- `Some files match multiple layers at equal specificity; classification falls back to declaration order. Disambiguate the overlapping patterns: ${[...ambiguousPairs].join(', ')}.`,
239
- { pairs: [...ambiguousPairs] }
240
- )
241
- );
242
- }
243
-
244
- const unclassified = files.filter((file) => !layerForFile(root, file, layers));
245
- if (unclassified.length > 0) {
246
- warnings.push(
247
- configWarning(
248
- 'CONFIG_UNCLASSIFIED_FILES',
249
- `${unclassified.length} included source file(s) are not matched by any configured layer; ark-check will not enforce import rules for those source files.`,
250
- {
251
- count: unclassified.length,
252
- samples: unclassified.slice(0, 5).map((file) => normalize(path.relative(root, file))),
253
- }
254
- )
255
- );
256
- }
257
-
258
- return warnings;
55
+ return collectAnalysisConfigWarnings({
56
+ config,
57
+ rules,
58
+ manifest,
59
+ files: files.map((file) => normalize(path.relative(root, file))),
60
+ });
259
61
  }
@@ -327,8 +327,14 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
327
327
  gatesMissing,
328
328
  skillGaps,
329
329
  staleRunnerFiles: staleRunners,
330
- // W5 — prepare-write / autoPatch / reject-only awareness (stable additive)
330
+ // Active-host guarantees plus separate repo-wide inventory.
331
331
  writePath: {
332
+ activeHost: writePath.activeHost,
333
+ support: writePath.support,
334
+ supportSummary: writePath.supportSummary,
335
+ capabilities: writePath.capabilities,
336
+ capabilityEvidence: writePath.capabilityEvidence,
337
+ inventory: writePath.inventory,
332
338
  mode: writePath.mode,
333
339
  prepareWrite: writePath.prepareWrite,
334
340
  autoPatch: writePath.autoPatch,
@@ -404,7 +410,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
404
410
  adapt:
405
411
  'Align — contract and folders still disagree, or coverage is weak / debt is open. You do not pick this mode. Next: classify ungoverned dirs (/ark-contract, /ark-adopt), run the plan (/ark-autopilot or /ark-loop). Gates do not fully protect you yet.',
406
412
  enforce:
407
- 'Guard — contract governs enough real code and edges are clean enough for gates to protect you. You do not pick this mode; you arrived here. Next: keep CI/write gates on; only NEW violations should fail.',
413
+ 'Guard — contract coverage is honest and checked edges are clean. You do not pick this mode; you arrived here. Next: keep the host-appropriate write path and CI check on; only NEW violations should fail.',
408
414
  };
409
415
  line(modeMark, `${mode.toUpperCase()} — ${modeHelp[mode]}`);
410
416
  if (emptyScope) {
@@ -496,26 +502,37 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
496
502
 
497
503
  console.log('');
498
504
  console.log(color.bold('Write path (agent)'));
505
+ const capabilities = writePath.capabilities;
499
506
  const writePathLabels = {
500
507
  repair: 'repair-capable — hard block + machine-readable autoPatch / ARK_REPAIR_JSON',
501
508
  'reject-only': 'reject-only — hard block with prose; no repair payload',
502
509
  'mcp-only': 'MCP tools only — prepare-write/autoPatch available; no PreToolUse hook',
503
- none: 'none — no write gate hook and no Ark MCP',
510
+ none: 'no write gate hook and no Ark MCP',
504
511
  };
505
512
  const wpMark =
506
- writePath.mode === 'repair'
513
+ capabilities['hard-write']
507
514
  ? ok
508
- : writePath.mode === 'none'
509
- ? bad
510
- : warn;
515
+ : capabilities['advisory-write'] || capabilities['merge-gate']
516
+ ? warn
517
+ : bad;
518
+ line(' ', `Active host: ${writePath.activeHost}`);
519
+ line(' ', `Supported profile: ${writePath.supportSummary}`);
511
520
  line(wpMark, `Mode: ${writePath.mode} — ${writePathLabels[writePath.mode] || writePath.mode}`);
512
521
  line(
513
- writePath.prepareWrite ? ok : warn,
514
- `prepare-write (MCP): ${writePath.prepareWrite ? 'yes' : 'no'}`
522
+ capabilities['hard-write'] ? ok : warn,
523
+ `Hard write boundary: ${capabilities['hard-write'] ? 'yes' : 'no'}`
524
+ );
525
+ line(
526
+ warn,
527
+ `Advisory write tools (MCP): ${capabilities['advisory-write'] ? 'yes' : 'no'}`
515
528
  );
516
529
  line(
517
- writePath.autoPatch ? ok : warn,
518
- `autoPatch surface: ${writePath.autoPatch ? 'yes' : 'no'}`
530
+ capabilities['merge-gate'] ? ok : bad,
531
+ `CI check (--strict-merge): ${capabilities['merge-gate'] ? 'yes' : 'no'} (merge blocking requires a required status)`
532
+ );
533
+ line(
534
+ capabilities['repair-payload'] ? ok : warn,
535
+ `Repair payload at hard boundary: ${capabilities['repair-payload'] ? 'yes' : 'no'}`
519
536
  );
520
537
  if (writePath.gap) {
521
538
  line(writePath.gap.severity === 'warn' ? warn : warn, writePath.gap.message);
@@ -527,7 +544,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
527
544
 
528
545
  console.log('');
529
546
  console.log(color.bold('Gates & skills'));
530
- if (gatesMissing.length === 0) line(ok, 'Gate files present (AGENTS.md, .mcp.json, CI, write gate)');
547
+ if (gatesMissing.length === 0) line(ok, 'Shared gate files present (AGENTS.md, .mcp.json, CI)');
531
548
  else {
532
549
  line(bad, `Missing gates: ${gatesMissing.join(', ')}`);
533
550
  actions.push(`install gates (${arkCommand(root, 'ark-check', '--install-agent-gates')})`);
@@ -570,10 +587,20 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
570
587
  );
571
588
  } else {
572
589
  for (const gap of adoption.gaps) {
573
- const mark = gap.severity === 'warn' ? warn : gap.severity === 'info' ? warn : bad;
590
+ // Deferred Codex-home debt (non-temp) is annotated, not a top action, when the
591
+ // session host is not Codex — fix when that host is used.
592
+ const mark = gap.deferred
593
+ ? color.dim('·')
594
+ : gap.severity === 'warn'
595
+ ? warn
596
+ : gap.severity === 'info'
597
+ ? warn
598
+ : bad;
574
599
  line(mark, gap.message);
575
- if (gap.fix) line(' ', color.dim(`Fix: ${gap.fix}`));
576
- actions.push(gap.fix || gap.message);
600
+ if (gap.fix) {
601
+ line(' ', color.dim(gap.deferred ? `When using Codex: ${gap.fix}` : `Fix: ${gap.fix}`));
602
+ }
603
+ if (!gap.deferred) actions.push(gap.fix || gap.message);
577
604
  }
578
605
  if (adoption.layerBalance) {
579
606
  line(warn, color.dim(adoption.layerBalance.educational));
@@ -601,7 +628,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
601
628
  line(warn, 'Safety diagnostics unavailable');
602
629
  } else {
603
630
  const rows = [
604
- ['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
631
+ ['Non-literal dynamic dependencies', safety.nonLiteralDynamicImports],
605
632
  ['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
606
633
  ['Explicit any casts', safety.anyCasts],
607
634
  ['InMemory stores in production source', safety.inMemoryProductionStores],