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.
- package/CHANGELOG.md +122 -0
- package/README.md +90 -51
- package/bin/ark-check.mjs +156 -39
- package/bin/ark-mcp.mjs +119 -6
- package/bin/ark-shared.mjs +216 -129
- package/bin/ark.mjs +134 -34
- package/bin/lib/adapter-contract.mjs +93 -0
- package/bin/lib/agent-gates.mjs +13 -0
- package/bin/lib/analysis-engine.mjs +1171 -0
- package/bin/lib/architecture-scan.mjs +84 -127
- package/bin/lib/ci-and-commands.mjs +40 -3
- package/bin/lib/codex-home.mjs +7 -0
- package/bin/lib/config-contract.mjs +331 -0
- package/bin/lib/config-warnings.mjs +7 -205
- package/bin/lib/doctor-plan.mjs +43 -16
- package/bin/lib/enforcement-profiles.mjs +97 -0
- package/bin/lib/field-install.mjs +67 -10
- package/bin/lib/gate-files.mjs +42 -3
- package/bin/lib/graph-cycles.mjs +4 -54
- package/bin/lib/hook-templates.mjs +33 -1
- package/bin/lib/host-support-matrix.mjs +83 -0
- package/bin/lib/install-migrate.mjs +99 -30
- package/bin/lib/mcp-adoption.mjs +35 -3
- package/bin/lib/open-html.mjs +75 -0
- package/bin/lib/presets.mjs +45 -4
- package/bin/lib/safety-diagnostics.mjs +36 -15
- package/bin/lib/scan-files.mjs +12 -1
- package/bin/lib/skill-install.mjs +72 -1
- package/bin/lib/source-policy.mjs +36 -0
- package/bin/lib/start-preview.mjs +271 -0
- package/bin/lib/ts-resolve.mjs +13 -3
- package/bin/lib/weakest-link.mjs +417 -0
- package/bin/lib/write-path-capabilities.mjs +186 -0
- package/bin/lib/write-path-detect.mjs +62 -99
- package/compat/nestjs.cjs +2 -0
- package/compat/nestjs.d.ts +2 -0
- package/compat/nestjs.js +1 -0
- package/compat/runtime.cjs +2 -0
- package/compat/runtime.d.ts +2 -0
- package/compat/runtime.js +1 -0
- package/dist/configContract-BxSIwVRo.d.cts +259 -0
- package/dist/configContract-BxSIwVRo.d.ts +259 -0
- package/dist/eslint/index.cjs +500 -61
- package/dist/eslint/index.d.cts +36 -20
- package/dist/eslint/index.d.ts +36 -20
- package/dist/eslint/index.js +500 -61
- package/dist/index.cjs +1349 -2741
- package/dist/index.d.cts +449 -483
- package/dist/index.d.ts +449 -483
- package/dist/index.js +1325 -2687
- package/docs/agent-guide.md +58 -34
- package/docs/ai-gates.md +79 -21
- package/docs/configuration.md +97 -0
- package/docs/enthusiast/README.md +3 -3
- package/docs/enthusiast/how-to-agent-gates.md +7 -3
- package/docs/migrate-from-ark-runtime-kernel.md +5 -3
- package/docs/package-surface.md +19 -19
- package/docs/production-hardening.md +31 -5
- package/docs/threat-model.md +65 -0
- package/docs/typescript-support.md +30 -3
- package/package.json +46 -11
- package/schemas/ark.analysis-result.schema.json +91 -0
- package/schemas/ark.config.schema.json +750 -0
- package/server.json +2 -2
- package/templates/hooks/pre-commit-ark +37 -0
- package/templates/skills/ark-architect.md +3 -2
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-runtime.md +8 -5
- package/templates/skills/ark-upgrade.md +36 -16
- package/tests/fixtures/ts-consumer/ark.config.json +2 -0
- package/dist/eslint/index.cjs.map +0 -1
- package/dist/eslint/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nestjs/index.cjs +0 -2498
- package/dist/nestjs/index.cjs.map +0 -1
- package/dist/nestjs/index.d.cts +0 -22
- package/dist/nestjs/index.d.ts +0 -22
- package/dist/nestjs/index.js +0 -2474
- package/dist/nestjs/index.js.map +0 -1
- package/dist/runtime/index.cjs +0 -3352
- package/dist/runtime/index.cjs.map +0 -1
- package/dist/runtime/index.d.cts +0 -2
- package/dist/runtime/index.d.ts +0 -2
- package/dist/runtime/index.js +0 -3270
- package/dist/runtime/index.js.map +0 -1
- package/dist/types-BZ17b9i5.d.cts +0 -1068
- 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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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: '
|
|
510
|
+
none: 'no write gate hook and no Ark MCP',
|
|
504
511
|
};
|
|
505
512
|
const wpMark =
|
|
506
|
-
|
|
513
|
+
capabilities['hard-write']
|
|
507
514
|
? ok
|
|
508
|
-
:
|
|
509
|
-
?
|
|
510
|
-
:
|
|
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
|
-
|
|
514
|
-
`
|
|
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
|
-
|
|
518
|
-
`
|
|
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, '
|
|
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
|
-
|
|
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)
|
|
576
|
-
|
|
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
|
|
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],
|