arkgate 4.8.24 → 4.8.25
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 +85 -0
- package/README.md +2 -2
- package/bin/ark-check-runtime.mjs +17 -14
- package/bin/ark-layer-match.mjs +115 -13
- package/bin/ark-mcp-runtime.mjs +36 -31
- package/bin/ark-shared.mjs +4 -23
- package/bin/lib/adoption-stance.mjs +2 -2
- package/bin/lib/adr-path.mjs +3 -3
- package/bin/lib/agent-gates.mjs +0 -61
- package/bin/lib/agent-homes.mjs +12 -21
- package/bin/lib/agent-projection-command.mjs +4 -4
- package/bin/lib/ambient-state.mjs +9 -30
- package/bin/lib/analysis-engine.mjs +14 -14
- package/bin/lib/architecture-scan.mjs +11 -9
- package/bin/lib/ark-order-doctor.mjs +3 -28
- package/bin/lib/ark-order-facts.mjs +6 -99
- package/bin/lib/ark-order-types.mjs +1 -2
- package/bin/lib/ark-run-doctor.mjs +3 -28
- package/bin/lib/ark-run-facts.mjs +30 -40
- package/bin/lib/ark-run-sensors.mjs +1 -1
- package/bin/lib/arkrule-file-hints.mjs +2 -47
- package/bin/lib/arkrules-contract.mjs +124 -133
- package/bin/lib/arkrules-sensors.mjs +7 -5
- package/bin/lib/check-args.mjs +63 -1
- package/bin/lib/ci-and-commands.mjs +8 -72
- package/bin/lib/ci-merge-boundary.mjs +1 -1
- package/bin/lib/class-source-scan.mjs +5 -5
- package/bin/lib/clone-detection.mjs +474 -0
- package/bin/lib/codex-home.mjs +1 -1
- package/bin/lib/config-contract-slices.mjs +44 -6
- package/bin/lib/config-contract.mjs +40 -117
- package/bin/lib/config-extras.mjs +16 -22
- package/bin/lib/contract-smells.mjs +33 -15
- package/bin/lib/core-ratchet.mjs +0 -2
- package/bin/lib/dashboard-cli.mjs +2 -2
- package/bin/lib/deep-module-coach.mjs +2 -4
- package/bin/lib/deploy-path.mjs +13 -38
- package/bin/lib/design-smells.mjs +2 -2
- package/bin/lib/diagnostic-catalog.mjs +8 -1
- package/bin/lib/doctor-advisories.mjs +55 -9
- package/bin/lib/doctor-green-cite.mjs +1 -1
- package/bin/lib/doctor-human.mjs +1 -1
- package/bin/lib/doctor-next-actions.mjs +2 -2
- package/bin/lib/doctor-plan.mjs +4 -6
- package/bin/lib/duplication-io.mjs +572 -0
- package/bin/lib/effective-contract-load.mjs +181 -44
- package/bin/lib/entry-points-io.mjs +424 -0
- package/bin/lib/extra-merge-teeth.mjs +36 -0
- package/bin/lib/field-install.mjs +38 -14
- package/bin/lib/first-run-help.mjs +7 -3
- package/bin/lib/flat-parent-importers.mjs +5 -3
- package/bin/lib/gate-files.mjs +30 -89
- package/bin/lib/github-enforcement.mjs +165 -28
- package/bin/lib/golden-pattern.mjs +1 -1
- package/bin/lib/hook-templates.mjs +16 -16
- package/bin/lib/html-report-advisories.mjs +4 -0
- package/bin/lib/html-report-depth.mjs +2 -2
- package/bin/lib/html-report.mjs +2 -2
- package/bin/lib/import-graph-projection.mjs +112 -0
- package/bin/lib/improvement-compass-doctor.mjs +1 -24
- package/bin/lib/invariant-coverage-io.mjs +4 -3
- package/bin/lib/invariant-coverage.mjs +26 -0
- package/bin/lib/invariant-probe-cli.mjs +347 -0
- package/bin/lib/invariant-probe-io.mjs +165 -0
- package/bin/lib/invariant-probe-runner.mjs +268 -0
- package/bin/lib/invariant-probe-sites.mjs +174 -0
- package/bin/lib/invariant-probe-workspace.mjs +183 -0
- package/bin/lib/invariant-probe.mjs +461 -0
- package/bin/lib/invariant-tests-path.mjs +16 -22
- package/bin/lib/literal-path-drift.mjs +4 -2
- package/bin/lib/managed-upgrade.mjs +2 -8
- package/bin/lib/mcp-adoption.mjs +4 -5
- package/bin/lib/mcp-hook-payload.mjs +4 -4
- package/bin/lib/orphan-modules-io.mjs +458 -0
- package/bin/lib/orphan-modules.mjs +251 -0
- package/bin/lib/outside-importers.mjs +197 -0
- package/bin/lib/package-manager.mjs +3 -3
- package/bin/lib/parse-health.mjs +1 -1
- package/bin/lib/persistence-write-hint.mjs +33 -8
- package/bin/lib/pin-dependent-gates.mjs +2 -2
- package/bin/lib/policy-delta-io.mjs +55 -8
- package/bin/lib/post-green-path.mjs +1 -1
- package/bin/lib/prepare-change.mjs +3 -9
- package/bin/lib/prepare-write.mjs +1 -2
- package/bin/lib/presets.mjs +56 -68
- package/bin/lib/product-copy.mjs +1 -6
- package/bin/lib/project-root.mjs +31 -62
- package/bin/lib/resolved-candidate-facts.mjs +90 -62
- package/bin/lib/rules-under-contract.mjs +72 -8
- package/bin/lib/scan-files.mjs +4 -4
- package/bin/lib/schema-validation.mjs +130 -0
- package/bin/lib/sensor-promote-cli.mjs +2 -11
- package/bin/lib/sensor-promote-io.mjs +33 -22
- package/bin/lib/sensor-promotion.mjs +1 -1
- package/bin/lib/skill-catalog-apply.mjs +1 -1
- package/bin/lib/skill-install.mjs +42 -38
- package/bin/lib/snippet-analysis.mjs +1 -1
- package/bin/lib/start-install-recovery.mjs +1 -1
- package/bin/lib/start-preview.mjs +1 -1
- package/bin/lib/states-transitions-presence.mjs +3 -3
- package/bin/lib/status-command.mjs +2 -18
- package/bin/lib/status-manifest.mjs +18 -26
- package/bin/lib/status-transition-catalog.mjs +0 -6
- package/bin/lib/suggestions.mjs +3 -3
- package/bin/lib/team-parliament-io.mjs +3 -17
- package/bin/lib/team-parliament.mjs +15 -9
- package/bin/lib/upgrade-behavior-changes.mjs +2 -2
- package/bin/lib/upgrade-command.mjs +2 -24
- package/bin/lib/upgrade-outcome.mjs +1 -1
- package/bin/lib/upstream-report.mjs +21 -10
- package/bin/lib/violations.mjs +10 -9
- package/bin/lib/weakest-link.mjs +13 -0
- package/bin/lib/write-path-capabilities.mjs +2 -2
- package/dist/chunk-AHACEKSV.js +16 -0
- package/dist/chunk-UN5EH4QV.cjs +16 -0
- package/dist/{configTypes-BJbaeoj4.d.cts → configTypes-GPvvdleM.d.cts} +12 -1
- package/dist/{configTypes-BJbaeoj4.d.ts → configTypes-GPvvdleM.d.ts} +12 -1
- package/dist/eslint/index.cjs +11 -11
- package/dist/eslint/index.d.cts +10 -1
- package/dist/eslint/index.d.ts +10 -1
- package/dist/eslint/index.js +11 -11
- package/dist/index.cjs +34 -34
- package/dist/index.d.cts +587 -515
- package/dist/index.d.ts +587 -515
- package/dist/index.js +34 -34
- package/dist/nestjs/index.cjs +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +1 -1
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +12 -12
- package/dist/runtime/index.d.cts +174 -136
- package/dist/runtime/index.d.ts +174 -136
- package/dist/runtime/index.js +12 -12
- package/dist/{types-DRinG-vT.d.cts → types-CnauMQQL.d.cts} +33 -25
- package/dist/{types-DRinG-vT.d.ts → types-CnauMQQL.d.ts} +33 -25
- package/docs/README.md +2 -1
- package/docs/agent-guide.md +134 -2
- package/docs/ai-gates.md +15 -1
- package/docs/configuration.md +40 -11
- package/docs/diagnostics.md +118 -2
- package/docs/package-surface.md +4 -1
- package/docs/product-voice.md +7 -1
- package/package.json +11 -2
- package/schemas/ark.config.schema.json +31 -3
- package/schemas/ark.invariant-probe.schema.json +368 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
- package/templates/agent-skills/ark-contract/SKILL.md +17 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +2 -0
- package/templates/agent-skills/ark-explore/SKILL.md +7 -0
- package/templates/agent-skills/ark-fix/SKILL.md +20 -0
- package/templates/agent-skills/ark-place/SKILL.md +8 -0
- package/templates/skills/ark-adopt.md +1 -0
- package/templates/skills/ark-contract.md +17 -0
- package/templates/skills/ark-coverage.md +2 -0
- package/templates/skills/ark-explore.md +7 -0
- package/templates/skills/ark-fix.md +20 -0
- package/templates/skills/ark-place.md +8 -0
- package/bin/lib/ark-order-error.mjs +0 -18
- package/bin/lib/ark-order-invariants.mjs +0 -388
- package/bin/lib/stable-hash.mjs +0 -90
- package/dist/chunk-MBXCFLO4.js +0 -16
- package/dist/chunk-RNQHRDCQ.cjs +0 -16
|
@@ -10,13 +10,14 @@
|
|
|
10
10
|
|
|
11
11
|
import { ARK_ORDER_SCHEMA_DEF, ARK_RUN_SCHEMA_DEF, defaultedArkOrder, defaultedArkRun, validateArkOrderExtra, validateArkRunExtra, } from './config-extras.mjs';
|
|
12
12
|
import { CHILD_SLICES_SCHEMA_DEF, SHARED_IMPORTS_SLICE_SCHEMA_DEF, validateSliceContract, } from './config-contract-slices.mjs';
|
|
13
|
+
import { isObject, propertyPath, validateSchemaNode, valueType } from './schema-validation.mjs';
|
|
13
14
|
import { canonicalStewardId } from './team-parliament.mjs';
|
|
14
15
|
/** Current published ark.config.json schema version (ADR 0027: 1.3 adds optional arkOrder). */
|
|
15
16
|
export const ARK_CONFIG_SCHEMA_VERSION = '1.3';
|
|
16
17
|
/** Closed layer trust tags. Optional; absence is silent. Not a schemaVersion bump. */
|
|
17
18
|
export const LAYER_TRUST_BOUNDARIES = ['public', 'auth', 'admin', 'internal'];
|
|
18
19
|
/** Future house: empty globs are expected; missing owners stay silent even when required. */
|
|
19
|
-
|
|
20
|
+
function isFutureHouseLayer(layer) {
|
|
20
21
|
return layer?.optional === true || layer?.reserved === true || layer?.allowEmpty === true;
|
|
21
22
|
}
|
|
22
23
|
/**
|
|
@@ -141,10 +142,21 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
141
142
|
* maxFiles also bounds structural-hint preload (orchestration-only / thin-adapter /
|
|
142
143
|
* writes-via-aggregate). There is no arkrules.hintBudget. */
|
|
143
144
|
coverage: { $ref: '#/$defs/coverage' },
|
|
144
|
-
/** ADR 0012 — layer name →
|
|
145
|
+
/** ADR 0012 — layer name → one path, or a list of paths merged into that layer. */
|
|
145
146
|
arkRules: {
|
|
146
147
|
type: 'object',
|
|
147
|
-
additionalProperties: {
|
|
148
|
+
additionalProperties: {
|
|
149
|
+
description: 'A project-relative ArkRules path, or a list of paths merged into one catalog for this layer. A string keeps today\'s behaviour. An older build that types this value as a string rejects an array at config load.',
|
|
150
|
+
oneOf: [
|
|
151
|
+
{ type: 'string', minLength: 1 },
|
|
152
|
+
{
|
|
153
|
+
type: 'array',
|
|
154
|
+
minItems: 1,
|
|
155
|
+
uniqueItems: true,
|
|
156
|
+
items: { type: 'string', minLength: 1 },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
148
160
|
default: {},
|
|
149
161
|
},
|
|
150
162
|
/** ADR 0020 — optional ArkRun extra. Absence is silent; unknown keys fail closed. */
|
|
@@ -278,123 +290,33 @@ export class ArkConfigValidationError extends Error {
|
|
|
278
290
|
this.issues = issues;
|
|
279
291
|
}
|
|
280
292
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)
|
|
286
|
-
? `${parent}.${key}`
|
|
287
|
-
: `${parent}[${JSON.stringify(key)}]`;
|
|
288
|
-
}
|
|
289
|
-
function valueType(value) {
|
|
290
|
-
if (value === null)
|
|
291
|
-
return 'null';
|
|
292
|
-
if (Array.isArray(value))
|
|
293
|
-
return 'array';
|
|
294
|
-
return typeof value;
|
|
295
|
-
}
|
|
296
|
-
function resolveSchemaRef(ref, root) {
|
|
297
|
-
const prefix = '#/$defs/';
|
|
298
|
-
if (!ref.startsWith(prefix))
|
|
299
|
-
return undefined;
|
|
300
|
-
return root.$defs[ref.slice(prefix.length)];
|
|
301
|
-
}
|
|
302
|
-
function validateNode(value, schema, path, root, issues) {
|
|
303
|
-
if (schema.$ref) {
|
|
304
|
-
const referenced = resolveSchemaRef(schema.$ref, root);
|
|
305
|
-
if (!referenced) {
|
|
306
|
-
issues.push({ path, message: `schema reference ${schema.$ref} cannot be resolved` });
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
validateNode(value, referenced, path, root, issues);
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
if (schema.const !== undefined && !Object.is(value, schema.const)) {
|
|
313
|
-
issues.push({ path, message: `must equal ${JSON.stringify(schema.const)}` });
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value))) {
|
|
317
|
-
issues.push({ path, message: `must be one of ${schema.enum.map(String).join(', ')}` });
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
if (schema.type === 'object') {
|
|
321
|
-
if (!isObject(value)) {
|
|
322
|
-
issues.push({ path, message: `must be an object; received ${valueType(value)}` });
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
const properties = schema.properties ?? {};
|
|
326
|
-
for (const key of schema.required ?? []) {
|
|
327
|
-
if (value[key] === undefined) {
|
|
328
|
-
issues.push({ path: propertyPath(path, key), message: 'is required' });
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (schema.additionalProperties === false) {
|
|
332
|
-
for (const key of Object.keys(value)) {
|
|
333
|
-
if (!(key in properties)) {
|
|
334
|
-
issues.push({ path: propertyPath(path, key), message: 'unknown field' });
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
else if (schema.additionalProperties !== undefined &&
|
|
339
|
-
schema.additionalProperties !== true &&
|
|
340
|
-
typeof schema.additionalProperties === 'object') {
|
|
341
|
-
const additional = schema.additionalProperties;
|
|
342
|
-
for (const key of Object.keys(value)) {
|
|
343
|
-
if (!(key in properties)) {
|
|
344
|
-
validateNode(value[key], additional, propertyPath(path, key), root, issues);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
for (const [key, childSchema] of Object.entries(properties)) {
|
|
349
|
-
if (value[key] !== undefined) {
|
|
350
|
-
validateNode(value[key], childSchema, propertyPath(path, key), root, issues);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
293
|
+
/** The schema walk has no oneOf, so string-or-array `arkRules` values are checked here. */
|
|
294
|
+
function validateArkRulesRefs(candidate, issues) {
|
|
295
|
+
const refs = candidate.arkRules;
|
|
296
|
+
if (refs === undefined || !isObject(refs))
|
|
353
297
|
return;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
298
|
+
for (const [layer, value] of Object.entries(refs)) {
|
|
299
|
+
const path = propertyPath('$.arkRules', layer);
|
|
300
|
+
if (typeof value === 'string') {
|
|
301
|
+
if (value.length === 0)
|
|
302
|
+
issues.push({ path, message: 'must be a non-empty relative path string' });
|
|
303
|
+
continue;
|
|
359
304
|
}
|
|
360
|
-
if (
|
|
361
|
-
issues.push({ path, message:
|
|
305
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
306
|
+
issues.push({ path, message: 'must be a non-empty path string or an array of paths' });
|
|
307
|
+
continue;
|
|
362
308
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
309
|
+
const seen = new Set();
|
|
310
|
+
value.forEach((entry, index) => {
|
|
311
|
+
const itemPath = `${path}[${index}]`;
|
|
312
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
313
|
+
issues.push({ path: itemPath, message: 'must be a non-empty relative path string' });
|
|
314
|
+
return;
|
|
367
315
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (schema.type === 'string') {
|
|
375
|
-
if (typeof value !== 'string') {
|
|
376
|
-
issues.push({ path, message: `must be a string; received ${valueType(value)}` });
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
if (schema.minLength !== undefined && value.length < schema.minLength) {
|
|
380
|
-
issues.push({ path, message: `must contain at least ${schema.minLength} character(s)` });
|
|
381
|
-
}
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
if (schema.type === 'boolean') {
|
|
385
|
-
if (typeof value !== 'boolean') {
|
|
386
|
-
issues.push({ path, message: `must be a boolean; received ${valueType(value)}` });
|
|
387
|
-
}
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
if (schema.type === 'integer') {
|
|
391
|
-
if (!Number.isInteger(value)) {
|
|
392
|
-
issues.push({ path, message: `must be an integer; received ${valueType(value)}` });
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
396
|
-
issues.push({ path, message: `must be at least ${schema.minimum}` });
|
|
397
|
-
}
|
|
316
|
+
if (seen.has(entry))
|
|
317
|
+
issues.push({ path: itemPath, message: 'duplicate path' });
|
|
318
|
+
seen.add(entry);
|
|
319
|
+
});
|
|
398
320
|
}
|
|
399
321
|
}
|
|
400
322
|
function validateLayerOwners(candidate, issues) {
|
|
@@ -523,11 +445,12 @@ export function migrateArkConfig(input, source = 'ark.config.json') {
|
|
|
523
445
|
export function loadArkConfigContract(input, source = 'ark.config.json') {
|
|
524
446
|
const { candidate, migratedFrom } = migrateArkConfig(input, source);
|
|
525
447
|
const issues = [];
|
|
526
|
-
|
|
448
|
+
validateSchemaNode(candidate, ARK_CONFIG_SCHEMA, '$', ARK_CONFIG_SCHEMA, issues);
|
|
527
449
|
validateArkRunExtra(candidate, issues);
|
|
528
450
|
validateArkOrderExtra(candidate, issues);
|
|
529
451
|
validateLayerOwners(candidate, issues);
|
|
530
452
|
validateSliceContract(candidate, issues);
|
|
453
|
+
validateArkRulesRefs(candidate, issues);
|
|
531
454
|
if (issues.length > 0)
|
|
532
455
|
throw new ArkConfigValidationError(source, issues);
|
|
533
456
|
return { config: candidate, migratedFrom };
|
|
@@ -88,22 +88,26 @@ export function defaultedArkOrder(value) {
|
|
|
88
88
|
xiKeys: value.xiKeys === undefined ? [] : value.xiKeys,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
91
|
+
/** Each named managed layer of an extra must be declared in `layers[]`. */
|
|
92
|
+
function validateManagedLayersDeclared(config, managed, extraKey, issues) {
|
|
93
|
+
if (!Array.isArray(managed))
|
|
94
|
+
return;
|
|
95
|
+
const layerNames = declaredLayerNames(config);
|
|
96
|
+
managed.forEach((name, index) => {
|
|
97
|
+
if (typeof name === 'string' && name.length > 0 && !layerNames.has(name)) {
|
|
98
|
+
issues.push({
|
|
99
|
+
path: `$.${extraKey}.managedLayers[${index}]`,
|
|
100
|
+
message: `layer ${JSON.stringify(name)} is not declared in layers[]`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
91
105
|
export function validateArkRunExtra(config, issues) {
|
|
92
106
|
const extra = config.arkRun;
|
|
93
107
|
if (extra === undefined || !isObject(extra))
|
|
94
108
|
return;
|
|
95
|
-
const layerNames = declaredLayerNames(config);
|
|
96
109
|
const managed = extra.managedLayers;
|
|
97
|
-
|
|
98
|
-
managed.forEach((name, index) => {
|
|
99
|
-
if (typeof name === 'string' && name.length > 0 && !layerNames.has(name)) {
|
|
100
|
-
issues.push({
|
|
101
|
-
path: `$.arkRun.managedLayers[${index}]`,
|
|
102
|
-
message: `layer ${JSON.stringify(name)} is not declared in layers[]`,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
}
|
|
110
|
+
validateManagedLayersDeclared(config, managed, 'arkRun', issues);
|
|
107
111
|
if (extra.mode === 'enforced') {
|
|
108
112
|
const roots = extra.kernelRoots ?? extra.compositionRoots;
|
|
109
113
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
@@ -124,18 +128,8 @@ export function validateArkOrderExtra(config, issues) {
|
|
|
124
128
|
const extra = config.arkOrder;
|
|
125
129
|
if (extra === undefined || !isObject(extra))
|
|
126
130
|
return;
|
|
127
|
-
const layerNames = declaredLayerNames(config);
|
|
128
131
|
const managed = extra.managedLayers;
|
|
129
|
-
|
|
130
|
-
managed.forEach((name, index) => {
|
|
131
|
-
if (typeof name === 'string' && name.length > 0 && !layerNames.has(name)) {
|
|
132
|
-
issues.push({
|
|
133
|
-
path: `$.arkOrder.managedLayers[${index}]`,
|
|
134
|
-
message: `layer ${JSON.stringify(name)} is not declared in layers[]`,
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
}
|
|
132
|
+
validateManagedLayersDeclared(config, managed, 'arkOrder', issues);
|
|
139
133
|
if (extra.mode === 'enforced') {
|
|
140
134
|
const roots = extra.planeRoots;
|
|
141
135
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
@@ -102,15 +102,22 @@ function ackMatchable(...names) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
105
|
+
* Bounded, fail-loud `.ark/` acknowledgment sidecar loader (contract-smell and
|
|
106
|
+
* ambient-state acks). Missing file → `exists: false`. Non-file, oversized,
|
|
107
|
+
* unparsable, over-long, or wrong-shaped content → `invalid: true` with
|
|
108
|
+
* `acks: []` (a broken file never suppresses anything).
|
|
108
109
|
*
|
|
109
110
|
* @param {string} root
|
|
110
|
-
* @
|
|
111
|
+
* @param {string} relPath
|
|
112
|
+
* @param {{
|
|
113
|
+
* shape: string,
|
|
114
|
+
* isWellFormed: (ack: any) => boolean,
|
|
115
|
+
* malformed: string,
|
|
116
|
+
* normalize?: (acks: any[]) => any[],
|
|
117
|
+
* }} spec
|
|
118
|
+
* @returns {{ path: string, exists: boolean, invalid?: boolean, error?: string, acks: any[] }}
|
|
111
119
|
*/
|
|
112
|
-
export function
|
|
113
|
-
const relPath = CONTRACT_SMELL_ACKS_PATH;
|
|
120
|
+
export function loadAckSidecar(root, relPath, spec) {
|
|
114
121
|
const abs = path.join(root, relPath);
|
|
115
122
|
let stats;
|
|
116
123
|
try {
|
|
@@ -128,21 +135,32 @@ export function loadContractSmellAcks(root) {
|
|
|
128
135
|
return invalid(error instanceof Error ? error.message : 'unreadable JSON');
|
|
129
136
|
}
|
|
130
137
|
const acks = Array.isArray(parsed?.acks) ? parsed.acks : null;
|
|
131
|
-
if (!acks) return invalid(
|
|
138
|
+
if (!acks) return invalid(`expected ${spec.shape}`);
|
|
132
139
|
if (acks.length > MAX_ACK_ENTRIES) return invalid(`more than ${MAX_ACK_ENTRIES} entries`);
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
if (!acks.every(spec.isWellFormed)) return invalid(spec.malformed);
|
|
141
|
+
return { path: relPath, exists: true, acks: spec.normalize ? spec.normalize(acks) : acks };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load the optional acknowledgment sidecar. Bounded and fail-loud:
|
|
146
|
+
* non-file, oversized, unparsable, or wrong-shaped content → `invalid: true`
|
|
147
|
+
* with `acks: []` (a broken file never suppresses anything).
|
|
148
|
+
*
|
|
149
|
+
* @param {string} root
|
|
150
|
+
* @returns {{ path: string, exists: boolean, invalid?: boolean, error?: string, acks: Array<{id: string, edge: string, reason?: string}> }}
|
|
151
|
+
*/
|
|
152
|
+
export function loadContractSmellAcks(root) {
|
|
153
|
+
return loadAckSidecar(root, CONTRACT_SMELL_ACKS_PATH, {
|
|
154
|
+
shape: '{ acks: [{ id, edge, reason? }] }',
|
|
155
|
+
isWellFormed: (a) =>
|
|
135
156
|
a !== null &&
|
|
136
157
|
typeof a === 'object' &&
|
|
137
158
|
typeof a.id === 'string' &&
|
|
138
159
|
typeof a.edge === 'string' &&
|
|
139
160
|
a.edge.trim().length > 0 &&
|
|
140
|
-
(a.reviewBy === undefined || typeof a.reviewBy === 'string')
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
return invalid('every ack needs string id, non-empty string edge, and string reviewBy when present');
|
|
144
|
-
}
|
|
145
|
-
return { path: relPath, exists: true, acks };
|
|
161
|
+
(a.reviewBy === undefined || typeof a.reviewBy === 'string'),
|
|
162
|
+
malformed: 'every ack needs string id, non-empty string edge, and string reviewBy when present',
|
|
163
|
+
});
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
/**
|
package/bin/lib/core-ratchet.mjs
CHANGED
|
@@ -8,8 +8,6 @@ import { computeCoverage } from './doctor-plan.mjs';
|
|
|
8
8
|
import { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
9
9
|
import { resolveConfigPathWithinRoot } from './project-root.mjs';
|
|
10
10
|
|
|
11
|
-
export { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
12
|
-
|
|
13
11
|
/**
|
|
14
12
|
* Core layers whose optionality matters once they match files (presets share these names).
|
|
15
13
|
* Used by doctor adoption gaps and `--ratchet-cores`.
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
export const DASHBOARD_DEFAULT_INTERVAL_MS = 2000;
|
|
7
7
|
export const DASHBOARD_DEFAULT_TIMEOUT_MS = 5000;
|
|
8
8
|
export const DASHBOARD_DEFAULT_URL = 'http://127.0.0.1:3000/snapshot';
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const DASHBOARD_MIN_MS = 200;
|
|
10
|
+
const DASHBOARD_MAX_MS = 60_000;
|
|
11
11
|
|
|
12
12
|
export const DASHBOARD_HELP = `arkgate-dashboard (alias ark-dashboard, ark dashboard) — observability TUI.
|
|
13
13
|
Usage: arkgate-dashboard [--url <inspector-url>] [--interval <ms>] [--timeout <ms>] [--once]
|
|
@@ -14,12 +14,10 @@ import {
|
|
|
14
14
|
DEEPENING_CANDIDATE_CAP,
|
|
15
15
|
} from './deepening-coach.mjs';
|
|
16
16
|
|
|
17
|
-
export { buildDeepeningCandidates, ARK_DEEPENING_COACH_SCHEMA_VERSION, DEEPENING_CANDIDATE_CAP };
|
|
18
|
-
|
|
19
17
|
/** Recent-window commit cap for hot-path heuristic (budget). */
|
|
20
|
-
|
|
18
|
+
const HOT_PATH_COMMIT_LIMIT = 200;
|
|
21
19
|
/** Max listed hot paths. */
|
|
22
|
-
|
|
20
|
+
const HOT_PATH_LIST_CAP = 8;
|
|
23
21
|
/** Minimum change hits before a path is “elevated”. */
|
|
24
22
|
export const HOT_PATH_MIN_HITS = 3;
|
|
25
23
|
/** Kill hung git instead of stalling CI. */
|
package/bin/lib/deploy-path.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { readPackageJson, packageScriptsHaveTypecheck } from './gate-files.mjs';
|
|
6
|
+
import { readPackageJson, packageScriptsHaveTypecheck, someNestedPackageJson } from './gate-files.mjs';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Production deploy path quality (universal — any consumer repo).
|
|
@@ -62,44 +62,19 @@ export function detectDeployPathQuality(root) {
|
|
|
62
62
|
let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
|
|
63
63
|
const packageLintScripts = [];
|
|
64
64
|
// Monorepo: package-level scripts count (apps/web, packages/ui, …).
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
72
|
-
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
73
|
-
candidates.push(path.join(root, entry.name, child.name));
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
} catch {
|
|
77
|
-
/* ignore */
|
|
78
|
-
}
|
|
79
|
-
for (const dir of candidates) {
|
|
80
|
-
const pj = path.join(dir, 'package.json');
|
|
81
|
-
if (!fs.existsSync(pj)) continue;
|
|
82
|
-
try {
|
|
83
|
-
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
84
|
-
const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
|
|
85
|
-
if (scriptHasLint(ns)) {
|
|
86
|
-
hasLintScript = true;
|
|
87
|
-
packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
|
|
88
|
-
}
|
|
89
|
-
if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
|
|
90
|
-
const nd = {
|
|
91
|
-
...(nested.dependencies || {}),
|
|
92
|
-
...(nested.devDependencies || {}),
|
|
93
|
-
};
|
|
94
|
-
if (nd.next && !engines.includes('next')) engines.push('next');
|
|
95
|
-
} catch {
|
|
96
|
-
/* ignore */
|
|
97
|
-
}
|
|
98
|
-
}
|
|
65
|
+
someNestedPackageJson(root, (nested, dir) => {
|
|
66
|
+
const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
|
|
67
|
+
if (scriptHasLint(ns)) {
|
|
68
|
+
hasLintScript = true;
|
|
69
|
+
packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
|
|
99
70
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
71
|
+
if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
|
|
72
|
+
const nd = {
|
|
73
|
+
...(nested.dependencies || {}),
|
|
74
|
+
...(nested.devDependencies || {}),
|
|
75
|
+
};
|
|
76
|
+
if (nd.next && !engines.includes('next')) engines.push('next');
|
|
77
|
+
});
|
|
103
78
|
|
|
104
79
|
const ciTexts = collectCiWorkflowTexts(root);
|
|
105
80
|
const ciJoined = ciTexts.join('\n');
|
|
@@ -494,7 +494,7 @@ export function summarizeDesignFitness(smells, ctx = {}) {
|
|
|
494
494
|
export const GREEN_PLAN_POINTER_MAX_IDS = 4;
|
|
495
495
|
|
|
496
496
|
/** When a smell has no evidence path, say so — do not leave the reader hunting. */
|
|
497
|
-
|
|
497
|
+
const SMELL_UNATTRIBUTED =
|
|
498
498
|
'no file — leftover design, not a specific line';
|
|
499
499
|
|
|
500
500
|
/**
|
|
@@ -502,7 +502,7 @@ export const SMELL_UNATTRIBUTED =
|
|
|
502
502
|
* The pointer used to print only the id, so "1 design smell (domain-logic-in-ui)"
|
|
503
503
|
* could not answer "did my change cause this?"
|
|
504
504
|
*/
|
|
505
|
-
|
|
505
|
+
function formatSmellPointerName(id, smells) {
|
|
506
506
|
const match = (smells || []).find((smell) => smell?.id === id);
|
|
507
507
|
const evidence = Array.isArray(match?.evidence)
|
|
508
508
|
? match.evidence.filter((entry) => typeof entry === 'string' && entry.trim())
|
|
@@ -37,6 +37,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
37
37
|
entry('LAYER_REFERENCE_VIOLATION', 'layer', 'Layer reference blocked (snippet / AI gate)', 'Snippet analysis found an intent or string reference that would couple layers in a direction the architecture profile forbids.', 'Move the reference to an allowed layer or introduce a port/event boundary, then re-run the snippet gate.'),
|
|
38
38
|
entry('SHARED_IMPORTS_SLICE', 'layer', 'A shared root imports a slice', 'A declared shared root imports a slice directly. That hop is allowed while sharedImportsSlice is unset or "deny-cross-parent", so this is advisory: the universe wall is direct-only.', 'Move the code the shared root needs into the shared root, or invert the dependency. Set sharedImportsSlice: "deny" to make these edges errors. With "deny-cross-parent", a slice that reaches another universe through a shared root is reported as LAYER_IMPORT_VIOLATION with reasonId CROSS_PARENT_VIA_SHARED. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
39
39
|
entry('CIRCULAR_DEPENDENCY', 'layer', 'Dependency cycle', 'Two or more modules import each other in a loop. Cycles make ownership unclear and break stable layer direction.', 'Extract the shared dependency into a third module, invert one edge behind a port, or merge units that are truly one — then preflight again.'),
|
|
40
|
+
entry('CROSS_WALL_DUPLICATE', 'layer', 'Code copied across a wall', 'Near-identical code sits on two sides of a slice wall (or in two layers that may not import each other). The wall would deny the import, so the code was copied instead. Status details (`--doctor --all`) and the report list it from token fingerprints taken at status time; they never enter the check, so the verdict does not change. Same-slice copies are counted, never listed.', 'Move one copy to a shared home the wall allows — a declared shared root, the universe common folder, or a layer both sides may import — then import it from both sides. One move at a time, through the write gate (/ark-place names the destination). Advisory only — it never fails a run.', { oftenAdvisory: true }),
|
|
41
|
+
entry('CROSS_LAYER_DUPLICATE', 'layer', 'Code copied in two layers', 'Near-identical code sits in two layers, and at least one of them may import the other. Status details (`--doctor --all`) and the report list it from token fingerprints taken at status time; the verdict does not change.', 'Keep one copy in the lower layer both sides may import, and import it from the other. One move at a time, through the write gate. Advisory only — it never fails a run.', { oftenAdvisory: true }),
|
|
40
42
|
// ── capability / ambient ─────────────────────────────────────────────────
|
|
41
43
|
entry('FORBIDDEN_GLOBAL', 'capability', 'Forbidden ambient global or dual import', 'The file’s layer lists this ambient (or its exact import dual, e.g. process / node:process) in forbiddenGlobals. Pure layers must not reach wall-clock, network, process, or similar effects directly.', 'Inject the capability through a small port (Clock, HttpPort, Config, …), bind the implementation outside the walled layer, then preflight again.'),
|
|
42
44
|
entry('CAPABILITY_VIOLATION', 'capability', 'Denied effect capability', 'The layer denies an effect capability (network, filesystem, clock, randomness, environment, process, persistence) and the candidate uses that effect via ambient or import evidence.', 'Define a capability port in the walled layer, bind the implementation in an adapter layer, then preflight again. Never mechanical-safe — port shape is a design decision.'),
|
|
@@ -57,10 +59,13 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
57
59
|
entry('ARKRULE_INVARIANT', 'arkrules', 'ArkRule invariant failed', 'Reserved / remediation-recognized code for invariant-plane failures bound to an ArkRule id (coverage path also emits INVARIANT_UNCOVERED).', 'Fix the invariant for the ArkRule declared in arkrules/<Layer>.json, then preflight again. Do not demote without acknowledgement.'),
|
|
58
60
|
entry('ARKRULE_SCOPE_EMPTY', 'arkrules', 'ArkRule appliesTo matched zero files', 'An ArkRule’s appliesTo globs matched no governed files — the rule cannot observe what it claims to protect.', 'Fix appliesTo globs so they match governed files, or remove the rule. Enforced empty scope fails; advisory empty scope warns.', { oftenAdvisory: true }),
|
|
59
61
|
entry('ARKRULE_HINT_BUDGET_EXHAUSTED', 'arkrules', 'Structural-hint budget exhausted', 'orchestration-only, thin-adapter, and writes-via-aggregate only evaluate files the hint loader preloaded. When eligible governed files exceed that budget (coverage.maxFiles, default 400 — there is no arkrules.hintBudget), those sensors never saw the rest of their scope. Enforced + unreviewed is not green. The finding names exact hinted/governed counts and per-sensor reviewed N/M of scope.', 'Raise coverage.maxFiles in ark.config.json (this cap also bounds structural-hint preload; --doctor names the coupling) so hinted/governed counts match, then re-run with --strict-config. An enforced hint sensor that cannot see its scope fails strict.'),
|
|
60
|
-
entry('ARKRULE_FILE_UNREFERENCED', 'arkrules', 'ArkRules file not referenced', 'A JSON file under arkrules
|
|
62
|
+
entry('ARKRULE_FILE_UNREFERENCED', 'arkrules', 'ArkRules file not referenced', 'A JSON file under arkrules/, or a slice file named arkrules.<Layer>.json beside governed code, is not in the effective catalog, so none of its rules are enforced. Drift looks like governance until someone notices.', 'Reference a central file from arkRules, set childSlices.arkRulesFile so a slice file is discovered, or delete it. Advisory only: it never fails the check.', { oftenAdvisory: true }),
|
|
63
|
+
entry('ARKRULE_DUPLICATE_ID', 'arkrules', 'ArkRule id declared more than once', 'Two ArkRules files in one layer declare the same effective id. Central ids stay bare. A slice file is namespaced as <childId>#<localId>, so the same local id in two slices is not a duplicate.', 'Rename one id, or split the files onto different slices. Config load fails closed. The catalog is not enforced until the id is unique.'),
|
|
64
|
+
entry('ARKRULE_SCOPE_ESCAPES_SLICE', 'arkrules', 'Slice ArkRule appliesTo leaves its slice', 'A file discovered from childSlices.arkRulesFile set appliesTo to a glob that can match outside that child slice. The default, when appliesTo is omitted, is the slice directory.', 'Narrow appliesTo to the slice directory, or omit it. Config load fails closed. The escaping rule is not enforced.'),
|
|
61
65
|
entry('INVARIANT_CATALOG_EMPTY', 'arkrules', 'Domain invariant catalog is empty', 'ArkRules is on and a Domain-role layer has code, but invariants[] has no phrases the code must preserve. Empty looks like “done” until someone fills the catalog.', 'Add 1–2 short phrases to invariants[] in arkrules/<Domain>.json (or the mapped file). Starters show the shape. Advisory unless a domain structure rule is already enforced — then --strict-merge can refuse. Do not freeze this finding.', { oftenAdvisory: true }),
|
|
62
66
|
entry('INVARIANT_UNCOVERED', 'arkrules', 'Invariant without coverage evidence', 'An ArkRules invariant is under contract but no covering test title or declared symbol evidence was found (or coverage is partial). Kind is never-had-tests (adopt residual) vs tests-disappeared (suite exists).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Treat never-had-tests as adopt residual; treat tests-disappeared as a regression. Missing test globs report partial — never fake green. When the message reports an exhausted file budget, raise coverage.maxFiles (or narrow coverage.testGlobs) in ark.config.json.'),
|
|
63
67
|
entry('INVARIANT_COVERAGE_OUTSIDE_ROOTS', 'arkrules', 'Covering test outside the declared coverage roots', 'The only test naming this invariant sits outside coverage.coverageRoots — the places the project declares its runner executes. ArkGate matches declared text and never executes tests, so it cannot tell whether that file is ever run: coverage there is a test that exists, not a test that runs.', 'Move the test under a declared coverage root, or add its root to coverage.coverageRoots in ark.config.json. Advisory: it never fails strict, but promotion to enforced refuses on it.', { oftenAdvisory: true }),
|
|
68
|
+
entry('INVARIANT_PROBE_SURVIVED', 'arkrules', 'Covering tests do not pin the invariant', 'The committed invariant probe (.ark/invariant-probe.json, written by arkgate-check --probe-invariants --write) shows, for files that have not changed since, that the covering tests still pass with the invariant\'s symbol broken on purpose (survived) or never call it (not reached). The test names the rule but does not pin it. A status and inventory line only: it never enters the check, so the verdict does not change.', 'Add a test case that fails when the rule is broken (the line names the change that went unnoticed), then re-run arkgate-check --probe-invariants --write and commit the file. Promotion to enforced refuses while the fresh artifact says survived or not reached; a stale, killed, inconclusive or missing result changes nothing.', { oftenAdvisory: true }),
|
|
64
69
|
entry('INVARIANT_TESTS_PATH_MISSING', 'arkrules', 'Domain-invariant tests path missing under adopted', 'The project is adopted (required CI or explicit advisory) and has domain invariants that want test evidence, but ark.config.json does not name where those tests live. Green coverage without a real tests path is an empty checkbox.', 'Add coverage.testGlobs or coverage.coverageRoots in ark.config.json pointing at a real tests folder, then re-run. Adopted mode fails closed until that path is present. Not freezable.'),
|
|
65
70
|
entry('INVARIANT_COVERAGE_ROOTS_MISSING', 'arkrules', 'Coverage roots missing while an invariant is enforced', 'A catalogued domain invariant is enforced, but ark.config.json does not name coverage.coverageRoots — the folders where the project says its test runner actually goes. Without that declaration, INVARIANT_COVERAGE_OUTSIDE_ROOTS cannot fire and coverage can certify a test no runner runs.', 'Add coverage.coverageRoots in ark.config.json pointing at the folder the test runner uses, then re-run. testGlobs alone is not enough. Fail-closed until that path is present. Not freezable.'),
|
|
66
71
|
// ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
|
|
@@ -133,6 +138,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
133
138
|
// ── literal path drift ───────────────────────────────────────────────────
|
|
134
139
|
entry('LITERAL_PATH_DRIFT', 'drift', 'Literal path moved by a rename', 'A repo path written inside a string, a comment or a docstring no longer resolves, and the rename set says where it went. Nothing in the gate sees this class: `tsc` resolves imports, not strings, and ESLint does not either, so the rename compiles green and the reference lies afterwards. It appears in four forms — the tsconfig alias, a relative literal, a path written without the include-root prefix, and prose — and a hand sweep reliably covers one of them.', 'Apply the suggested replacement, or re-run `npx arkgate-check --path-drift --base-ref <ref> --write` to apply every writable anchored replacement at once. The rewrite is mechanical and one-directional: the destination comes from the rename, it must itself resolve and be path-shaped, and the token is rewritten in the form the author wrote it in. A destination that leaves the alias root of the literal is reported with the target only and must be rewritten by hand.'),
|
|
135
140
|
entry('LITERAL_PATH_UNRESOLVED', 'drift', 'Literal path does not resolve', 'A literal that looks like a repo path does not resolve under this root, and no rename explains where it went. Unlike LITERAL_PATH_DRIFT this is a candidate, not a verdict: with nothing to anchor it, ArkGate cannot tell a dead reference from an illustrative path in a comment, an example in documentation, or a path belonging to another tree.', 'Read the candidate and decide: fix the path, or leave it. Advisory only — it never fails a run and is never rewritten by --write, because there is no destination to propose. Run `--path-drift --all` to list the sweep.', { oftenAdvisory: true }),
|
|
141
|
+
entry('ORPHAN_MODULE', 'drift', 'File nothing imports', 'No governed file, test or entry point imports this module. It is the leftover an agent leaves when a rewrite lands next to the old file. Status reads it from the import facts the check already resolved, so it never changes the verdict. When a dynamic import, an unresolved import or an unmapped entry could reach the file, the item says so (maybe-dynamic, maybe-unresolved) and the list is partial.', 'Delete the file through the write gate, one file at a time. If a framework, a script or a runtime loader uses it, add a glob with a reason to .ark/entry-points.json (optional reviewBy) instead. Advisory only — it never fails a run.', { oftenAdvisory: true }),
|
|
142
|
+
entry('UNUSED_EXPORT', 'drift', 'Export nothing imports by name', 'An export no governed file or test imports by name. Listed only in status details (`--doctor --all`) and the report. Default, namespace, star, dynamic and require use count as using every export, so those files are skipped.', 'Drop the export keyword (or the code) if nothing outside the file needs it, or keep it when it is public API an entry point exposes. Advisory only — it never fails a run.', { oftenAdvisory: true }),
|
|
136
143
|
// ── meta ─────────────────────────────────────────────────────────────────
|
|
137
144
|
entry('ARK_UNKNOWN', 'meta', 'Unknown diagnostic', 'A diagnostic lacked a stable ruleId/code; adapters may emit this fallback so agents never see an empty id.', 'Resolve the underlying finding without weakening ark.config.json, then run Ark again. Prefer fixing the producer to emit a catalogued ruleId.'),
|
|
138
145
|
]);
|
|
@@ -19,6 +19,15 @@ import {
|
|
|
19
19
|
} from './reshape-decisions.mjs';
|
|
20
20
|
import { printParseHealthSection, summarizeParseHealth } from './parse-health.mjs';
|
|
21
21
|
import { detectGraphBlindSpots, printGraphBlindSection } from './graph-blind.mjs';
|
|
22
|
+
import {
|
|
23
|
+
computeOrphanModules,
|
|
24
|
+
printOrphanModulesCompactLine,
|
|
25
|
+
printOrphanModulesSection,
|
|
26
|
+
} from './orphan-modules-io.mjs';
|
|
27
|
+
import {
|
|
28
|
+
computeCrossWallDuplication,
|
|
29
|
+
printCrossWallDuplicationSection,
|
|
30
|
+
} from './duplication-io.mjs';
|
|
22
31
|
import {
|
|
23
32
|
formatArkRulesDoctorLines,
|
|
24
33
|
summarizeRulesUnderContract,
|
|
@@ -143,6 +152,7 @@ export function printCompactExtraDoctorLines(advisories, io) {
|
|
|
143
152
|
io.line(io.warn, flatMoves[0].evidence);
|
|
144
153
|
if (flatMoves.length > 1) io.line(' ', `${flatMoves.length - 1} more flat-parent suggestion(s) in --doctor --all`);
|
|
145
154
|
}
|
|
155
|
+
printOrphanModulesCompactLine(advisories?.orphanModules, io);
|
|
146
156
|
const noDomain = advisories?.noDomainFrontend;
|
|
147
157
|
if (noDomain?.ask) {
|
|
148
158
|
console.log('');
|
|
@@ -237,8 +247,13 @@ function classificationFromCoverage(cov) {
|
|
|
237
247
|
};
|
|
238
248
|
}
|
|
239
249
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
250
|
+
/**
|
|
251
|
+
* `activeViolations` must already exclude frozen baseline keys (report residual parity).
|
|
252
|
+
* `facts.importGraph` is the scan's importer index; `view.details` (Details / report) adds
|
|
253
|
+
* the unused-exports tier and copies across a wall. `view.changed` (a changed-files scope)
|
|
254
|
+
* keeps copies across a wall `not-run`.
|
|
255
|
+
*/
|
|
256
|
+
export function computeDoctorAdvisories(root, config, cov, rules, files, ts, parseHealth, facts, activeViolations, view = {}) {
|
|
242
257
|
const physicalCohesion = computePhysicalCohesion(root, files);
|
|
243
258
|
const decisionMemory = computeReshapeDecisionMemory(root, files);
|
|
244
259
|
physicalCohesion.reshapeDecisions = decisionMemory.summary;
|
|
@@ -325,6 +340,24 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
325
340
|
parseHealth: parseHealth ?? summarizeParseHealth(),
|
|
326
341
|
// Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
|
|
327
342
|
graphBlindSpots: detectGraphBlindSpots(ts, root, files),
|
|
343
|
+
// ADR 0037: files nothing imports (projection of the resolved import edges; notAScore).
|
|
344
|
+
orphanModules: computeOrphanModules({
|
|
345
|
+
root,
|
|
346
|
+
config: { ...config, rules: rules ?? config?.rules },
|
|
347
|
+
ts,
|
|
348
|
+
importGraph: facts?.importGraph,
|
|
349
|
+
details: view.details === true,
|
|
350
|
+
}),
|
|
351
|
+
// ADR 0038: copies across a wall (doctor-time token fingerprints; Details / report only).
|
|
352
|
+
crossWallDuplication: computeCrossWallDuplication({
|
|
353
|
+
root,
|
|
354
|
+
config,
|
|
355
|
+
rules: rules ?? config?.rules,
|
|
356
|
+
ts,
|
|
357
|
+
files: aliasFiles,
|
|
358
|
+
details: view.details === true,
|
|
359
|
+
changed: view.changed === true,
|
|
360
|
+
}),
|
|
328
361
|
// AR12 — Rules under contract (honest counts; real test I/O, never empty-fileContents stub).
|
|
329
362
|
// P1M: pass classification so extraMergeTeeth cannot arm at 0% governed.
|
|
330
363
|
stewardNudge: collectStewardNudge(root, config),
|
|
@@ -342,13 +375,24 @@ export function printDoctorAdvisories(advisories, io) {
|
|
|
342
375
|
printFlatParentPilot(advisories?.flatParentPilot, io);
|
|
343
376
|
const sliceAliases = advisories?.sliceAliases;
|
|
344
377
|
if (sliceAliases && sliceAliases.notAScore === true && Array.isArray(sliceAliases.moves)) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
378
|
+
if (sliceAliases.moves.length > 0) {
|
|
379
|
+
console.log('');
|
|
380
|
+
console.log(io.color.bold('Slice aliases (owed move)'));
|
|
381
|
+
io.line(io.warn, sliceAliases.debt);
|
|
382
|
+
for (const move of sliceAliases.moves) {
|
|
383
|
+
io.line(io.warn, `${move.from} → ${move.to}. Move these files to ${move.destination}. They are not finished.`);
|
|
384
|
+
if (move.advisory) io.line(io.warn, move.advisory);
|
|
385
|
+
for (const file of move.files ?? []) io.line(' ', file);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (Array.isArray(sliceAliases.pinned) && sliceAliases.pinned.length > 0) {
|
|
389
|
+
console.log('');
|
|
390
|
+
console.log(io.color.bold('Pinned slice aliases'));
|
|
391
|
+
for (const row of sliceAliases.pinned) {
|
|
392
|
+
const label = typeof row.reason === 'string' && row.reason.length > 0 ? ` (${row.reason})` : '';
|
|
393
|
+
io.line(' ', `${row.from} → ${row.to}${label} stays. It is not an owed move.`);
|
|
394
|
+
for (const file of row.files ?? []) io.line(' ', file);
|
|
395
|
+
}
|
|
352
396
|
}
|
|
353
397
|
}
|
|
354
398
|
const hubs = advisories?.sharedWalkHubs;
|
|
@@ -378,6 +422,8 @@ export function printDoctorAdvisories(advisories, io) {
|
|
|
378
422
|
printReshapeDecisionsSection(advisories.physicalCohesion?.reshapeDecisions, io);
|
|
379
423
|
printParseHealthSection(advisories.parseHealth, io);
|
|
380
424
|
printGraphBlindSection(advisories.graphBlindSpots, io);
|
|
425
|
+
printOrphanModulesSection(advisories.orphanModules, io);
|
|
426
|
+
printCrossWallDuplicationSection(advisories.crossWallDuplication, io);
|
|
381
427
|
const nudge = advisories.stewardNudge;
|
|
382
428
|
if ((nudge?.needsStewards || nudge?.drift || nudge?.emptyStewardsPastGrace) && nudge.ask) {
|
|
383
429
|
console.log('');
|
|
@@ -9,7 +9,7 @@ import { REQUIRED_GATE_WORKFLOW } from './gate-files.mjs';
|
|
|
9
9
|
import { CI_MERGE_BOUNDARY_REL } from './ci-merge-boundary.mjs';
|
|
10
10
|
|
|
11
11
|
export const HEALTHY_CLAIM = 'Healthy — nothing to do.';
|
|
12
|
-
|
|
12
|
+
const HEALTHY_KEEP = ' Keep write path + CI.';
|
|
13
13
|
export const UNCITE_SUFFIX =
|
|
14
14
|
'not green until a file, config key, or test is named';
|
|
15
15
|
|
package/bin/lib/doctor-human.mjs
CHANGED
|
@@ -169,7 +169,7 @@ export function printDoctorCompactHuman(view) {
|
|
|
169
169
|
adrPresence: view.adrPresence,
|
|
170
170
|
statesTransitions: view.statesTransitions, statusTransitionCatalog: view.statusTransitionCatalog, noDomainFrontend: view.noDomainFrontend, invariantTestsPath: view.invariantTestsPath, invariantCoverageRoots: view.invariantCoverageRoots,
|
|
171
171
|
},
|
|
172
|
-
{ line, warn }
|
|
172
|
+
{ line, warn, color }
|
|
173
173
|
);
|
|
174
174
|
|
|
175
175
|
if (violations.length === 0) {
|