arkgate 4.8.22 → 4.8.24
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 +91 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +27 -78
- package/bin/ark-dashboard.mjs +56 -53
- package/bin/ark-layer-match.mjs +1117 -38
- package/bin/ark-mcp-runtime.mjs +412 -138
- package/bin/ark-mcp.mjs +68 -4
- package/bin/ark-shared.mjs +39 -0
- package/bin/ark.mjs +65 -97
- package/bin/lib/adapter-contract-types.mjs +5 -0
- package/bin/lib/adapter-contract.mjs +15 -0
- package/bin/lib/agent-projection-command.mjs +5 -1
- package/bin/lib/analysis-engine.mjs +15 -11
- package/bin/lib/architecture-scan.mjs +50 -6
- package/bin/lib/ark-order-doctor.mjs +1 -1
- package/bin/lib/ark-order-facts.mjs +410 -15
- package/bin/lib/ark-order-invariants.mjs +51 -9
- package/bin/lib/ark-order-sensors.mjs +28 -101
- package/bin/lib/ark-order-types.mjs +2 -0
- package/bin/lib/ark-run-doctor.mjs +14 -0
- package/bin/lib/ark-run-facts.mjs +434 -11
- package/bin/lib/ark-run-sensors.mjs +5 -3
- package/bin/lib/arkgate-pins.mjs +91 -0
- package/bin/lib/arkrules-sensors.mjs +50 -249
- package/bin/lib/check-args.mjs +23 -1
- package/bin/lib/ci-and-commands.mjs +16 -6
- package/bin/lib/class-source-scan.mjs +479 -0
- package/bin/lib/codex-home.mjs +4 -2
- package/bin/lib/config-contract-slices.mjs +701 -0
- package/bin/lib/config-contract.mjs +5 -2
- package/bin/lib/config-warnings.mjs +32 -0
- package/bin/lib/dashboard-cli.mjs +62 -0
- package/bin/lib/design-delta.mjs +1 -1
- package/bin/lib/diagnostic-catalog.mjs +45 -6
- package/bin/lib/doctor-advisories.mjs +84 -1
- package/bin/lib/doctor-next-actions.mjs +17 -2
- package/bin/lib/doctor-plan.mjs +7 -9
- package/bin/lib/effective-contract-load.mjs +121 -59
- package/bin/lib/enforcement-honesty.mjs +22 -0
- package/bin/lib/enforcement-profiles.mjs +40 -4
- package/bin/lib/first-run-help.mjs +3 -2
- package/bin/lib/flat-parent-importers.mjs +137 -0
- package/bin/lib/flat-parent-pilot.mjs +192 -0
- package/bin/lib/gate-files.mjs +29 -2
- package/bin/lib/git-change-scope.mjs +109 -0
- package/bin/lib/github-enforcement.mjs +1 -1
- package/bin/lib/hook-templates.mjs +18 -16
- package/bin/lib/host-support-matrix.mjs +3 -1
- package/bin/lib/html-report-advisories.mjs +2 -0
- package/bin/lib/html-report-depth.mjs +3 -1
- package/bin/lib/import-resolve.mjs +13 -1
- package/bin/lib/install-migrate.mjs +50 -73
- package/bin/lib/invariant-coverage.mjs +59 -11
- package/bin/lib/mcp-adoption.mjs +9 -1
- package/bin/lib/mcp-hook-payload.mjs +401 -61
- package/bin/lib/package-manager.mjs +59 -1
- package/bin/lib/passthrough-commands.mjs +83 -0
- package/bin/lib/persistence-write-hint.mjs +81 -0
- package/bin/lib/physical-cohesion.mjs +16 -18
- package/bin/lib/pilot-loop.mjs +21 -8
- package/bin/lib/pin-dependent-gates.mjs +117 -0
- package/bin/lib/policy-delta-io.mjs +168 -10
- package/bin/lib/prepare-change.mjs +120 -6
- package/bin/lib/prepare-write.mjs +1 -0
- package/bin/lib/presets.mjs +3 -5
- package/bin/lib/remediation.mjs +23 -4
- package/bin/lib/resolved-candidate-facts.mjs +315 -55
- package/bin/lib/rules-inventory.mjs +22 -3
- package/bin/lib/rules-under-contract.mjs +179 -5
- package/bin/lib/scan-files.mjs +32 -0
- package/bin/lib/snippet-analysis.mjs +140 -6
- package/bin/lib/source-policy.mjs +34 -0
- package/bin/lib/stable-hash.mjs +90 -0
- package/bin/lib/start-install-recovery.mjs +71 -1
- package/bin/lib/start-preview.mjs +30 -0
- package/bin/lib/status-command.mjs +33 -4
- package/bin/lib/status-manifest.mjs +32 -4
- package/bin/lib/team-parliament-io.mjs +79 -75
- package/bin/lib/team-parliament.mjs +6 -1
- package/bin/lib/write-path-capabilities.mjs +4 -2
- package/dist/chunk-A5EIY3HI.cjs +5 -0
- package/dist/chunk-MBXCFLO4.js +16 -0
- package/dist/chunk-RNQHRDCQ.cjs +16 -0
- package/dist/chunk-SVSQVGFJ.js +5 -0
- package/dist/{configTypes-CTELo87-.d.ts → configTypes-BJbaeoj4.d.cts} +53 -4
- package/dist/configTypes-BJbaeoj4.d.ts +246 -0
- package/dist/eslint/index.cjs +17 -4
- package/dist/eslint/index.d.cts +359 -0
- package/dist/eslint/index.d.ts +178 -158
- package/dist/eslint/index.js +10 -4
- package/dist/index.cjs +38 -37
- package/dist/index.d.cts +5475 -0
- package/dist/index.d.ts +3408 -296
- package/dist/index.js +38 -37
- package/dist/inspectorListen-6DQVJ4DC.js +1 -0
- package/dist/inspectorListen-KGMIQ2TF.cjs +1 -0
- package/dist/nestjs/index.cjs +1 -18
- package/dist/nestjs/index.d.cts +22 -0
- package/dist/nestjs/index.d.ts +1 -3
- package/dist/nestjs/index.js +1 -18
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.cts +155 -0
- package/dist/order/index.d.ts +15 -3
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +13 -30
- package/dist/{diagnosticCatalog-WD2TPSwZ.d.ts → runtime/index.d.cts} +814 -290
- package/dist/runtime/index.d.ts +2678 -108
- package/dist/runtime/index.js +13 -30
- package/dist/{types-yDNEQ603.d.ts → types-DRinG-vT.d.cts} +576 -140
- package/dist/types-DRinG-vT.d.ts +1792 -0
- package/docs/README.md +3 -1
- package/docs/agent-guide.md +92 -15
- package/docs/ai-gates.md +160 -26
- package/docs/arkorder.md +32 -15
- package/docs/brownfield-adoption.md +5 -1
- package/docs/configuration.md +273 -12
- package/docs/develop.md +2 -1
- package/docs/diagnostics.md +122 -13
- package/docs/package-surface.md +31 -8
- package/docs/product-voice.md +1 -1
- package/package.json +59 -18
- package/schemas/ark.analysis-result.schema.json +15 -0
- package/schemas/ark.config.schema.json +163 -4
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/schemas/ark.status-manifest.schema.json +22 -0
- package/server.json +3 -9
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +4 -1
- package/templates/agent-skills/ark-order/SKILL.md +4 -2
- package/templates/agent-skills/ark-runtime/SKILL.md +6 -1
- package/templates/arkrules/PersistenceAdapters.json +1 -1
- package/templates/arkrules/PresentationAdapters.json +1 -1
- package/templates/skills/ark-adopt.md +4 -1
- package/templates/skills/ark-order.md +4 -2
- package/templates/skills/ark-runtime.md +6 -1
- package/dist/types-fQ9-KrBj.d.ts +0 -183
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { ARK_ORDER_SCHEMA_DEF, ARK_RUN_SCHEMA_DEF, defaultedArkOrder, defaultedArkRun, validateArkOrderExtra, validateArkRunExtra, } from './config-extras.mjs';
|
|
12
|
+
import { CHILD_SLICES_SCHEMA_DEF, SHARED_IMPORTS_SLICE_SCHEMA_DEF, validateSliceContract, } from './config-contract-slices.mjs';
|
|
12
13
|
import { canonicalStewardId } from './team-parliament.mjs';
|
|
13
14
|
/** Current published ark.config.json schema version (ADR 0027: 1.3 adds optional arkOrder). */
|
|
14
15
|
export const ARK_CONFIG_SCHEMA_VERSION = '1.3';
|
|
@@ -217,7 +218,7 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
217
218
|
sliceIdentity: {
|
|
218
219
|
type: 'string',
|
|
219
220
|
enum: ['path', 'stars'],
|
|
220
|
-
description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus
|
|
221
|
+
description: 'How a starred sliceFolders prefix is named. Absent and path keep today\'s ids (every literal and star binding). stars keeps the last literal plus every star binding (a star before the last literal is kept too), so parallel trees share one feature id. Bare names are unchanged.',
|
|
221
222
|
},
|
|
222
223
|
sharedRoots: { ...stringArraySchema, minItems: 1 },
|
|
223
224
|
allowedCrossSlice: {
|
|
@@ -233,7 +234,8 @@ export const ARK_CONFIG_SCHEMA = {
|
|
|
233
234
|
},
|
|
234
235
|
},
|
|
235
236
|
},
|
|
236
|
-
sharedImportsSlice:
|
|
237
|
+
sharedImportsSlice: SHARED_IMPORTS_SLICE_SCHEMA_DEF,
|
|
238
|
+
childSlices: CHILD_SLICES_SCHEMA_DEF,
|
|
237
239
|
},
|
|
238
240
|
},
|
|
239
241
|
safety: {
|
|
@@ -525,6 +527,7 @@ export function loadArkConfigContract(input, source = 'ark.config.json') {
|
|
|
525
527
|
validateArkRunExtra(candidate, issues);
|
|
526
528
|
validateArkOrderExtra(candidate, issues);
|
|
527
529
|
validateLayerOwners(candidate, issues);
|
|
530
|
+
validateSliceContract(candidate, issues);
|
|
528
531
|
if (issues.length > 0)
|
|
529
532
|
throw new ArkConfigValidationError(source, issues);
|
|
530
533
|
return { config: candidate, migratedFrom };
|
|
@@ -59,3 +59,35 @@ export function collectConfigWarnings(root, config, files, rules, manifest) {
|
|
|
59
59
|
files: files.map((file) => normalize(path.relative(root, file))),
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Machine-readable refusal for an invalid contract under `--json` / `--doctor --json`.
|
|
65
|
+
* The human message still goes to stderr; stdout must never be empty for JSON consumers.
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {unknown} error
|
|
70
|
+
* @param {string[]} argv
|
|
71
|
+
* @returns {string|null} JSON line for stdout, or null when not a config-contract error / not --json
|
|
72
|
+
*/
|
|
73
|
+
export function configInvalidJsonEnvelope(error, argv) {
|
|
74
|
+
if (!argv.includes('--json')) return null;
|
|
75
|
+
const e = /** @type {{ name?: string, issues?: Array<{path: string, message: string}>, source?: string }} */ (error);
|
|
76
|
+
if (e?.name !== 'ArkConfigValidationError' || !Array.isArray(e.issues)) {
|
|
77
|
+
// Any other fatal error still answers --json with JSON (exit 2 stays the caller's job),
|
|
78
|
+
// so a CI step that parses stdout never sees an empty or plain-text body.
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
const policyBase = /^(Cannot (read|resolve) policy base|Policy base )/.test(message);
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
ok: false,
|
|
83
|
+
error: policyBase ? 'POLICY_BASE_UNREADABLE' : 'CHECK_ERROR',
|
|
84
|
+
message,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return JSON.stringify({
|
|
88
|
+
ok: false,
|
|
89
|
+
error: 'CONFIG_INVALID',
|
|
90
|
+
configPath: e.source,
|
|
91
|
+
messages: e.issues.map((issue) => `${issue.path}: ${issue.message}`),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared, pure helpers for the observability dashboard CLI (`ark-dashboard`,
|
|
3
|
+
* `arkgate-dashboard`, and the `ark dashboard` passthrough). No I/O here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DASHBOARD_DEFAULT_INTERVAL_MS = 2000;
|
|
7
|
+
export const DASHBOARD_DEFAULT_TIMEOUT_MS = 5000;
|
|
8
|
+
export const DASHBOARD_DEFAULT_URL = 'http://127.0.0.1:3000/snapshot';
|
|
9
|
+
export const DASHBOARD_MIN_MS = 200;
|
|
10
|
+
export const DASHBOARD_MAX_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export const DASHBOARD_HELP = `arkgate-dashboard (alias ark-dashboard, ark dashboard) — observability TUI.
|
|
13
|
+
Usage: arkgate-dashboard [--url <inspector-url>] [--interval <ms>] [--timeout <ms>] [--once]
|
|
14
|
+
Polls an ArkRun inspector; it does not start the kernel.
|
|
15
|
+
|
|
16
|
+
-u, --url <url> Inspector URL: handle.url (root) or handle.snapshotUrl.
|
|
17
|
+
Default: $ARK_DASHBOARD_URL, else ${DASHBOARD_DEFAULT_URL}.
|
|
18
|
+
startInspector() binds a random port unless you pass
|
|
19
|
+
{ port }, so pass the URL your app printed.
|
|
20
|
+
-i, --interval <ms> Poll interval, clamped to ${DASHBOARD_MIN_MS}-${DASHBOARD_MAX_MS} (default ${DASHBOARD_DEFAULT_INTERVAL_MS}).
|
|
21
|
+
-t, --timeout <ms> Fetch timeout, clamped to ${DASHBOARD_MIN_MS}-${DASHBOARD_MAX_MS} (default ${DASHBOARD_DEFAULT_TIMEOUT_MS}).
|
|
22
|
+
--once Render one frame and exit (non-zero when the kernel is unreachable).
|
|
23
|
+
-h, --help Show this help.
|
|
24
|
+
-v, --version Print the arkgate version.`;
|
|
25
|
+
|
|
26
|
+
export const DASHBOARD_EPHEMERAL_PORT_HINT =
|
|
27
|
+
'Hint: startInspector() binds a random port by default (port 0). Pass --url <handle.url> ' +
|
|
28
|
+
'(printed by your app), set ARK_DASHBOARD_URL, or start the inspector with startInspector({ port: 3000 }).';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Clamp a millisecond flag into [min, max]. Non-numeric input uses the default.
|
|
32
|
+
* @param {string | undefined} raw
|
|
33
|
+
* @param {number} fallback
|
|
34
|
+
*/
|
|
35
|
+
export function clampDashboardMs(raw, fallback) {
|
|
36
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
|
|
37
|
+
const parsed = Number.parseInt(String(raw), 10);
|
|
38
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
39
|
+
return Math.min(DASHBOARD_MAX_MS, Math.max(DASHBOARD_MIN_MS, parsed));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Derive a sibling inspector route (`/outbox`, `/workflows`) from the URL the
|
|
44
|
+
* user passed. Accepts the inspector root (`handle.url`, with or without a
|
|
45
|
+
* trailing slash), `/snapshot`, `/snapshot/`, and prefix mounts (`/x/snapshot`).
|
|
46
|
+
* @param {string} inspectorUrl
|
|
47
|
+
* @param {string} suffix e.g. '/outbox'
|
|
48
|
+
* @returns {string | null}
|
|
49
|
+
*/
|
|
50
|
+
export function dashboardSiblingUrl(inspectorUrl, suffix) {
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(inspectorUrl);
|
|
53
|
+
let basePath = u.pathname.replace(/\/+$/, '');
|
|
54
|
+
if (basePath.endsWith('/snapshot')) basePath = basePath.slice(0, -'/snapshot'.length);
|
|
55
|
+
u.pathname = `${basePath}${suffix}`;
|
|
56
|
+
u.search = '';
|
|
57
|
+
u.hash = '';
|
|
58
|
+
return u.toString();
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/bin/lib/design-delta.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Generated from design-delta.source.mjs — run npm run generate:packaged-tooling.
|
|
2
|
-
import{spawnSync as O}from"node:child_process";import B from"node:crypto";import j from"node:fs";import w from"node:path";import{layerForFile as T}from"../ark-shared.mjs";import{loadGoldenPattern as I}from"./golden-pattern.mjs";import{collectGovernedFiles as q}from"./scan-files.mjs";const P="1.0",_=Object.freeze(["domain-logic-in-ui"]),b=/\.[cm]?[jt]sx?$/i,W=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,U=/^(can|should|calculate|compute)[A-Z_]|policy/i,z=/^(can|should)[A-Z_]|policy/i,H=/^(calculate|compute)[A-Z_]/i,D=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,G=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,J=new Set(["includes","some","every","has"]);function h(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function $(e){return`sha256:${B.createHash("sha256").update(e,"utf8").digest("hex")}`}function C(e,t){const s=[...t].map(n=>[h(n.path),$(n.content)]).sort(([n],[i])=>n.localeCompare(i));return $(JSON.stringify({config:e,files:s}))}function V(e,t){const s=t.toLowerCase();return s.endsWith(".tsx")?e.ScriptKind.TSX:s.endsWith(".jsx")?e.ScriptKind.JSX:s.endsWith(".js")||s.endsWith(".mjs")||s.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function Z(e,t,s){const n=T(e,s,t?.layers??[]);return W.test(s)||/presentation|ui|view/i.test(n??"")}function Q(e,t){const s=[],n=i=>{if(e.isFunctionDeclaration(i)&&i.name&&i.body)s.push({name:i.name.text,body:i.body,node:i});else if(e.isVariableDeclaration(i)&&e.isIdentifier(i.name)){const a=i.initializer;a&&(e.isArrowFunction(a)||e.isFunctionExpression(a))&&s.push({name:i.name.text,body:a.body,node:i})}else e.isMethodDeclaration(i)&&i.name&&e.isIdentifier(i.name)&&i.body&&s.push({name:i.name.text,body:i.body,node:i});e.forEachChild(i,n)};return n(t),s}function X(e,t){if(!U.test(t.name)||D.test(t.name))return null;let s=0,n=0,i=0,a=0,r=!1,o=!1;const l=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),p=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),f=c=>{if(e.isJsxElement(c)||e.isJsxSelfClosingElement(c)||e.isJsxFragment(c)){o=!0;return}if(e.isBinaryExpression(c)&&(l.has(c.operatorToken.kind)&&(s+=1),u.has(c.operatorToken.kind)&&(n+=1),p.has(c.operatorToken.kind)&&(i+=1)),e.isCallExpression(c)){let g="";e.isIdentifier(c.expression)?g=c.expression.text:e.isPropertyAccessExpression(c.expression)&&(g=c.expression.name.text),J.has(g)&&(a+=1),(G.test(g)||D.test(g))&&(r=!0)}e.forEachChild(c,f)};if(f(t.body),o||r)return null;const m=s+n+a,v=i;let S,d;if(H.test(t.name)&&v>0)S="calculation-rule",d=v;else if(z.test(t.name)&&m>0)S="authorization-policy-rule",d=m;else return null;return{kind:S,magnitude:d,detail:`comparisons:${s};logical:${n};predicates:${a};arithmetic:${i}`}}function Y(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function E({root:e,config:t,records:s,ts:n,goldenPattern:i}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const a=[];for(const r of[...s].sort((o,l)=>h(o.path).localeCompare(h(l.path)))){const o=h(r.path);if(!b.test(o)||o.endsWith(".d.ts")||!Z(e,t,o))continue;const l=n.createSourceFile(o,String(r.content),n.ScriptTarget.Latest,!0,V(n,o));for(const u of Q(n,l)){const p=X(n,u);if(!p)continue;const f=`domain-logic-in-ui|${o}|${u.name}|${p.kind}`,m=l.getLineAndCharacterOfPosition(u.node.getStart(l)).line+1;a.push({smellId:"domain-logic-in-ui",fingerprint:$(f),identity:f,evidence:{kind:p.kind,path:o,line:m,symbol:u.name,detail:p.detail,magnitude:p.magnitude},repairHint:Y(i,u.name)})}}return a.sort((r,o)=>r.identity.localeCompare(o.identity))}function L({mode:e,baseIdentity:t,candidateIdentity:s,touchedPaths:n,baseFindings:i,candidateFindings:a,createdPathsOnly:r=!1,baseTreePaths:o=[]}){const l=new Set([...n].map(h)),u=new Set([...o].map(h)),p=new Map(i.map(d=>[d.identity,d])),f=new Set(i),m=[];let v=0;for(const d of a){let c=p.get(d.identity);if(!c){const y=[...f].filter(k=>k.smellId===d.smellId&&k.evidence.symbol===d.evidence.symbol&&k.evidence.kind===d.evidence.kind);y.length===1&&([c]=y)}c&&f.delete(c);const g=c?.evidence?.magnitude??0,x=d.evidence.magnitude;if(!l.has(d.evidence.path)){c&&(v+=1);continue}c?x>g?m.push({...d,classification:"worsened",baseMagnitude:g,candidateMagnitude:x}):v+=1:m.push({...d,classification:"new",baseMagnitude:0,candidateMagnitude:x})}const S=r?m.filter(d=>d.classification==="new"&&!u.has(h(d.evidence.path))):m;return{schemaVersion:P,mode:e,complete:!0,valid:S.length===0,base:t,candidate:s,supportedSmellIds:[..._],touchedPaths:[...l].sort(),changes:S,baseFindingCount:i.length,candidateFindingCount:a.length,historicalResidualCount:v,enforcementScope:r?"created-paths":"touched-new-or-worsened"}}function F(e,t){const s=[];for(const n of q(e,t)){const i=h(w.relative(e,n));!b.test(i)||i.endsWith(".d.ts")||s.push({path:i,content:j.readFileSync(n,"utf8")})}return s}function ee(e,t,s,n){const i=new Map(s.map(a=>[h(a.path),a.content]));for(const a of n){const r=h(a.path);!b.test(r)||r.endsWith(".d.ts")||!T(e,r,t?.layers??[])||(a.delete===!0?i.delete(r):typeof a.content=="string"&&i.set(r,a.content))}return[...i].map(([a,r])=>({path:a,content:r}))}function de({root:e,config:t,changes:s,ts:n}){const i=F(e,t),a=ee(e,t,i,s??[]),r=I(e),o=E({root:e,config:t,records:i,ts:n,goldenPattern:r}),l=E({root:e,config:t,records:a,ts:n,goldenPattern:r});return L({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:C(t,i)},candidateIdentity:{kind:"candidate-tree",value:C(t,a)},touchedPaths:(s??[]).map(u=>u.path),baseFindings:o,candidateFindings:l})}function K(e,t,s={}){return O("git",t,{cwd:e,encoding:s.encoding??"utf8",maxBuffer:64*1024*1024,input:s.input})}function M(e,t,s){const n=K(e,t);if(n.status!==0)throw new Error(`${s}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function A(e,t,s){const n=K(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${s}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(h).filter(Boolean)}function R(e,t,s){const n=K(e,["show",`${t}
|
|
2
|
+
import{spawnSync as O}from"node:child_process";import B from"node:crypto";import j from"node:fs";import w from"node:path";import{layerForFile as T}from"../ark-shared.mjs";import{loadGoldenPattern as I}from"./golden-pattern.mjs";import{collectGovernedFiles as q}from"./scan-files.mjs";const P="1.0",_=Object.freeze(["domain-logic-in-ui"]),b=/\.[cm]?[jt]sx?$/i,W=/(?:^|\/)(?:components?|pages|hooks|ui|views|screens)(?:\/|$)|(?:^|\/)app\/(?!api\/)/i,U=/^(can|should|calculate|compute)[A-Z_]|policy/i,z=/^(can|should)[A-Z_]|policy/i,H=/^(calculate|compute)[A-Z_]/i,D=/(?:route|routing|path|label|className|style|render|display|view|modal|dialog|tooltip|navigate|navigation|href|tab|menu|component|toast|breadcrumb|sidebar|drawer|popover|layout|theme|icon)/i,G=/^(?:use[A-Z_]|render|navigate|redirect|push|replace|open|close|show|hide|setState|set[A-Z_]|toast|alert|confirm)/,J=new Set(["includes","some","every","has"]);function h(e){return String(e||"").replace(/\\/g,"/").replace(/^\.\//,"")}function $(e){return`sha256:${B.createHash("sha256").update(e,"utf8").digest("hex")}`}function C(e,t){const s=[...t].map(n=>[h(n.path),$(n.content)]).sort(([n],[i])=>n.localeCompare(i));return $(JSON.stringify({config:e,files:s}))}function V(e,t){const s=t.toLowerCase();return s.endsWith(".tsx")?e.ScriptKind.TSX:s.endsWith(".jsx")?e.ScriptKind.JSX:s.endsWith(".js")||s.endsWith(".mjs")||s.endsWith(".cjs")?e.ScriptKind.JS:e.ScriptKind.TS}function Z(e,t,s){const n=T(e,s,t?.layers??[]);return W.test(s)||/presentation|ui|view/i.test(n??"")}function Q(e,t){const s=[],n=i=>{if(e.isFunctionDeclaration(i)&&i.name&&i.body)s.push({name:i.name.text,body:i.body,node:i});else if(e.isVariableDeclaration(i)&&e.isIdentifier(i.name)){const a=i.initializer;a&&(e.isArrowFunction(a)||e.isFunctionExpression(a))&&s.push({name:i.name.text,body:a.body,node:i})}else e.isMethodDeclaration(i)&&i.name&&e.isIdentifier(i.name)&&i.body&&s.push({name:i.name.text,body:i.body,node:i});e.forEachChild(i,n)};return n(t),s}function X(e,t){if(!U.test(t.name)||D.test(t.name))return null;let s=0,n=0,i=0,a=0,r=!1,o=!1;const l=new Set([e.SyntaxKind.EqualsEqualsToken,e.SyntaxKind.EqualsEqualsEqualsToken,e.SyntaxKind.ExclamationEqualsToken,e.SyntaxKind.ExclamationEqualsEqualsToken,e.SyntaxKind.LessThanToken,e.SyntaxKind.LessThanEqualsToken,e.SyntaxKind.GreaterThanToken,e.SyntaxKind.GreaterThanEqualsToken,e.SyntaxKind.InKeyword,e.SyntaxKind.InstanceOfKeyword]),u=new Set([e.SyntaxKind.AmpersandAmpersandToken,e.SyntaxKind.BarBarToken,e.SyntaxKind.QuestionQuestionToken]),p=new Set([e.SyntaxKind.PlusToken,e.SyntaxKind.MinusToken,e.SyntaxKind.AsteriskToken,e.SyntaxKind.SlashToken,e.SyntaxKind.PercentToken,e.SyntaxKind.AsteriskAsteriskToken]),f=c=>{if(e.isJsxElement(c)||e.isJsxSelfClosingElement(c)||e.isJsxFragment(c)){o=!0;return}if(e.isBinaryExpression(c)&&(l.has(c.operatorToken.kind)&&(s+=1),u.has(c.operatorToken.kind)&&(n+=1),p.has(c.operatorToken.kind)&&(i+=1)),e.isCallExpression(c)){let g="";e.isIdentifier(c.expression)?g=c.expression.text:e.isPropertyAccessExpression(c.expression)&&(g=c.expression.name.text),J.has(g)&&(a+=1),(G.test(g)||D.test(g))&&(r=!0)}e.forEachChild(c,f)};if(f(t.body),o||r)return null;const m=s+n+a,v=i;let S,d;if(H.test(t.name)&&v>0)S="calculation-rule",d=v;else if(z.test(t.name)&&m>0)S="authorization-policy-rule",d=m;else return null;return{kind:S,magnitude:d,detail:`comparisons:${s};logical:${n};predicates:${a};arithmetic:${i}`}}function Y(e,t){return e?.present&&e.golden?.newCodeHome?`Move ${t} to ${e.golden.newCodeHome} following golden pattern "${e.golden.name}", then import the pure rule from the UI.`:`Move ${t} into the project's Domain/shared pure-rules home and import it from the UI; do not weaken ark.config.json.`}function E({root:e,config:t,records:s,ts:n,goldenPattern:i}){if(!n?.createSourceFile)throw new Error("TypeScript parser is required for design-delta analysis.");const a=[];for(const r of[...s].sort((o,l)=>h(o.path).localeCompare(h(l.path)))){const o=h(r.path);if(!b.test(o)||o.endsWith(".d.ts")||!Z(e,t,o))continue;const l=n.createSourceFile(o,String(r.content),n.ScriptTarget.Latest,!0,V(n,o));for(const u of Q(n,l)){const p=X(n,u);if(!p)continue;const f=`domain-logic-in-ui|${o}|${u.name}|${p.kind}`,m=l.getLineAndCharacterOfPosition(u.node.getStart(l)).line+1;a.push({smellId:"domain-logic-in-ui",fingerprint:$(f),identity:f,evidence:{kind:p.kind,path:o,line:m,symbol:u.name,detail:p.detail,magnitude:p.magnitude},repairHint:Y(i,u.name)})}}return a.sort((r,o)=>r.identity.localeCompare(o.identity))}function L({mode:e,baseIdentity:t,candidateIdentity:s,touchedPaths:n,baseFindings:i,candidateFindings:a,createdPathsOnly:r=!1,baseTreePaths:o=[]}){const l=new Set([...n].map(h)),u=new Set([...o].map(h)),p=new Map(i.map(d=>[d.identity,d])),f=new Set(i),m=[];let v=0;for(const d of a){let c=p.get(d.identity);if(!c){const y=[...f].filter(k=>k.smellId===d.smellId&&k.evidence.symbol===d.evidence.symbol&&k.evidence.kind===d.evidence.kind);y.length===1&&([c]=y)}c&&f.delete(c);const g=c?.evidence?.magnitude??0,x=d.evidence.magnitude;if(!l.has(d.evidence.path)){c&&(v+=1);continue}c?x>g?m.push({...d,classification:"worsened",baseMagnitude:g,candidateMagnitude:x}):v+=1:m.push({...d,classification:"new",baseMagnitude:0,candidateMagnitude:x})}const S=r?m.filter(d=>d.classification==="new"&&!u.has(h(d.evidence.path))):m;return{schemaVersion:P,mode:e,complete:!0,valid:S.length===0,base:t,candidate:s,supportedSmellIds:[..._],touchedPaths:[...l].sort(),changes:S,baseFindingCount:i.length,candidateFindingCount:a.length,historicalResidualCount:v,enforcementScope:r?"created-paths":"touched-new-or-worsened"}}function F(e,t){const s=[];for(const n of q(e,t)){const i=h(w.relative(e,n));!b.test(i)||i.endsWith(".d.ts")||s.push({path:i,content:j.readFileSync(n,"utf8")})}return s}function ee(e,t,s,n){const i=new Map(s.map(a=>[h(a.path),a.content]));for(const a of n){const r=h(a.path);!b.test(r)||r.endsWith(".d.ts")||!T(e,r,t?.layers??[])||(a.delete===!0?i.delete(r):typeof a.content=="string"&&i.set(r,a.content))}return[...i].map(([a,r])=>({path:a,content:r}))}function de({root:e,config:t,changes:s,ts:n}){const i=F(e,t),a=ee(e,t,i,s??[]),r=I(e),o=E({root:e,config:t,records:i,ts:n,goldenPattern:r}),l=E({root:e,config:t,records:a,ts:n,goldenPattern:r});return L({mode:"write-candidate",baseIdentity:{kind:"candidate-tree",value:C(t,i)},candidateIdentity:{kind:"candidate-tree",value:C(t,a)},touchedPaths:(s??[]).map(u=>u.path),baseFindings:o,candidateFindings:l})}function K(e,t,s={}){return O("git",t,{cwd:e,encoding:s.encoding??"utf8",maxBuffer:64*1024*1024,input:s.input})}function M(e,t,s){const n=K(e,t);if(n.status!==0)throw new Error(`${s}: ${(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.trim()}function A(e,t,s){const n=K(e,t,{encoding:"buffer"});if(n.status!==0)throw new Error(`${s}: ${String(n.stderr||n.stdout||"git command failed").trim()}`);return n.stdout.toString("utf8").split("\0").map(h).filter(Boolean)}function R(e,t,s){const n=K(e,["show",`${t}:./${s}`]);if(n.status!==0)throw new Error(`base file unavailable (${s}): ${(n.stderr||"").trim()}`);return n.stdout}function N(e,t){return{schemaVersion:P,mode:"git-base",complete:!1,valid:!1,base:{kind:"git-tree",value:String(e||"<missing>")},candidate:{kind:"candidate-tree",value:"<unavailable>"},supportedSmellIds:[..._],touchedPaths:[],changes:[],baseFindingCount:0,candidateFindingCount:0,historicalResidualCount:0,error:t}}function te({root:e,config:t,configPath:s="ark.config.json",baseRef:n,ts:i,createdPathsOnly:a=!1,missingBase:r="fail-closed"}){const o=r==="skip";if(typeof n!="string"||!n.trim())return N(n,o?"design-delta skipped: no resolvable Git base ref.":"--fail-on-new-smells requires --base-ref <git-ref>.");try{if(n.startsWith("-"))throw new Error('base ref must not start with "-".');const l=M(e,["rev-parse","--verify",`${n}^{commit}`],"base ref is unresolvable"),u=M(e,["rev-parse","--verify",`${l}^{tree}`],"base tree is unresolvable"),p=h(w.isAbsolute(s)?w.relative(e,s):s);if(!p||p.startsWith("../"))throw new Error("base config path must be inside the project root.");const f=JSON.parse(R(e,l,p)),m=A(e,["ls-tree","-r","--name-only","-z",l],"base tree listing failed").filter(y=>b.test(y)&&!y.endsWith(".d.ts")),v=m.filter(y=>T(e,y,f?.layers??[])).map(y=>({path:y,content:R(e,l,y)})),S=F(e,t),d=[...A(e,["diff","--relative","--name-only","-z",l,"--"],"candidate diff failed"),...A(e,["ls-files","--others","--exclude-standard","-z"],"untracked-file scan failed")],c=I(e),g=E({root:e,config:f,records:v,ts:i,goldenPattern:c}),x=E({root:e,config:t,records:S,ts:i,goldenPattern:c});return L({mode:"git-base",baseIdentity:{kind:"git-tree",value:u,commit:l},candidateIdentity:{kind:"candidate-tree",value:C(t,S)},touchedPaths:d,baseFindings:g,candidateFindings:x,createdPathsOnly:!!a,baseTreePaths:m})}catch(l){return N(n,l instanceof Error?l.message:String(l))}}function ne(e){return e?.complete?e.valid?`Design delta passed: 0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s).`:[`Design delta blocked ${e.changes.length} new/worsened supported smell(s):`,...e.changes.map(t=>`- [${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""} (${t.classification})
|
|
3
3
|
Next action: ${t.repairHint}`)].join(`
|
|
4
4
|
`):`Design delta unavailable: ${e?.error||"unknown error"}`}function ue({enabled:e,createdPathsOnly:t,missingBase:s,...n}){const i=s==="skip",a=e?te({...n,createdPathsOnly:!!t,missingBase:i?"skip":"fail-closed"}):null,r=a&&i&&!a.complete?null:a;return{result:r,combineEdges:({activeViolationCount:o,strictConfig:l,strictWarningCount:u,policyValid:p})=>{const f=o===0&&(!l||u===0)&&p;return{edgeValid:f,observedOk:f&&(r?.valid??!0)}},exitCode:o=>r&&!r.complete?2:o,failureText:()=>r&&!r.valid?ne(r):null}}function pe(e){return e?e.complete?e.valid?[{level:"ok",text:`0 new/worsened supported smells across ${e.touchedPaths.length} touched path(s)`}]:[{level:"bad",text:`${e.changes.length} new/worsened supported smell(s) block this candidate`},...e.changes.slice(0,5).flatMap(t=>[{level:"plain",text:`[${t.smellId}] ${t.evidence.path}:${t.evidence.line??1} ${t.evidence.symbol??""}`},{level:"dim",text:`fix: ${t.repairHint}`}])]:[{level:"bad",text:`Unavailable \u2014 ${e.error||"base/candidate evidence incomplete"}`}]:[]}export{P as DESIGN_DELTA_SCHEMA_VERSION,_ as DESIGN_DELTA_SUPPORTED_SMELLS,E as analyzeDesignFindings,ue as createDesignDeltaCheck,pe as designDeltaDoctorLines,te as evaluateGitDesignDelta,de as evaluateWriteDesignDelta,ne as formatDesignDeltaBlock};
|
|
@@ -35,6 +35,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
35
35
|
entry('LAYER_IMPORT_VIOLATION', 'layer', 'This import is not allowed', 'This file imported a folder it may not reach. The write doesn’t land. The same check fails the pull request.', 'Branch by import kind: constants/types/pure → adopt into DomainModel or SharedKernel (do not invent a port); kernel/events/bootstrap from Persistence → inject a port or move the map to SharedTypes (Persistence must not emit); define a port only when the target is a real use-case. Type-only edges use `import type`. Then preflight again. Do not weaken the layer rule without a hash-bound policy acknowledgement.'),
|
|
36
36
|
entry('LAYER_INTENT_REFERENCE_VIOLATION', 'layer', 'Intent referenced across a blocked layer edge', 'A string intent (or intent-like reference) names a layer that the file’s layer may not reach under the contract rules — the same plane as import edges, for event/intent coupling.', 'Reference that intent from a layer allowed to know about it (usually an adapter or application layer), or relocate the reference — then preflight again.'),
|
|
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
|
+
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 }),
|
|
38
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.'),
|
|
39
40
|
// ── capability / ambient ─────────────────────────────────────────────────
|
|
40
41
|
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.'),
|
|
@@ -56,18 +57,19 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
56
57
|
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.'),
|
|
57
58
|
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 }),
|
|
58
59
|
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/ is not referenced by the arkRules map in ark.config.json (or no map exists), so none of its rules are enforced or reported. Drift looks like governance until someone notices.', 'Reference the file from arkRules ("<Layer>": "arkrules/<file>.json"), or delete it. Advisory only: it never fails the check.', { oftenAdvisory: true }),
|
|
59
61
|
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 }),
|
|
60
62
|
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.'),
|
|
61
63
|
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 }),
|
|
62
64
|
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.'),
|
|
63
65
|
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.'),
|
|
64
66
|
// ── ArkRun (opt-in extra; RN05 dual-depth nextAction) ────────────────────
|
|
65
|
-
entry('ARKRUN_MISSING_ROOT', 'arkrun', 'No kernel factory in composition roots', 'The ArkRun extra is on but no createArkKernel / createStrictArkKernel /
|
|
66
|
-
entry('ARKRUN_KERNEL_IN_DOMAIN', 'arkrun', 'Domain-role layer imports the kernel', 'A Domain-role layer imports arkgate/runtime, @arkgate/runtime, or kernel types. Domain stays kernel-free; composition roots and adapters own the factory.', 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from arkgate/runtime (same npm package; @arkgate/runtime is deprecated), then preflight again. Never mechanical-safe.'),
|
|
67
|
+
entry('ARKRUN_MISSING_ROOT', 'arkrun', 'No kernel factory in composition roots', 'The ArkRun extra is on but no kernel factory (createArkKernel / createStrictArkKernel / createLenientArkKernel, their *FromConfig variants, or ArkModule.forRoot / forRootAsync imported from arkgate/nestjs) was found in arkRun.compositionRoots, so agents can skip the kernel while the write gate stays green.', 'Import createStrictArkKernel from arkgate/runtime (same npm package; @arkgate/runtime is deprecated) and call it in a composition root listed in arkRun.compositionRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.'),
|
|
68
|
+
entry('ARKRUN_KERNEL_IN_DOMAIN', 'arkrun', 'Domain-role layer imports the kernel', 'A Domain-role layer imports arkgate/runtime, arkgate/nestjs, @arkgate/runtime, or kernel types. Domain stays kernel-free; composition roots and adapters own the factory.', 'Move the kernel import out of the Domain-role layer into a composition root or adapter. Import from arkgate/runtime (same npm package; @arkgate/runtime is deprecated), then preflight again. Never mechanical-safe.'),
|
|
67
69
|
entry('ARKRUN_DIRECT_NEW', 'arkrun', 'Managed type constructed with new', 'A managed non-Domain file constructs an admitted type with new outside an ArkRun composition-root factory, skipping kernel resolve/registration.', 'Resolve the type from the kernel instead of constructing it with new, then preflight again. Never mechanical-safe — rewiring construction is a design decision.'),
|
|
68
|
-
entry('ARKRUN_UNDECLARED_EMIT', 'arkrun', 'Emit name not in raises/sends', 'A publisher / publish / raise / send call
|
|
69
|
-
entry('ARKRUN_UNDECLARED_HANDLE', 'arkrun', 'Handle name not in reactsTo', 'A subscribe / registerHandler call
|
|
70
|
-
entry('ARKRUN_UNDECLARED_DEPEND', 'arkrun', 'Depend name not in uses', 'A resolve / resolveSingleton call
|
|
70
|
+
entry('ARKRUN_UNDECLARED_EMIT', 'arkrun', 'Emit name not in raises/sends', 'A publisher / publish / raise / send call on a receiver traced to the kernel names an intent (string literal or same-file define/defineIntent binding) that is not listed in the file’s raises or sends declaration.', 'Add the existing call-site name to raises or sends on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new emit stays judgment.'),
|
|
71
|
+
entry('ARKRUN_UNDECLARED_HANDLE', 'arkrun', 'Handle name not in reactsTo', 'A subscribe / registerHandler call on a receiver traced to the kernel names an intent (string literal or same-file define/defineIntent binding) that is not listed in the file’s reactsTo declaration.', 'Add the existing call-site name to reactsTo on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new handle stays judgment.'),
|
|
72
|
+
entry('ARKRUN_UNDECLARED_DEPEND', 'arkrun', 'Depend name not in uses', 'A resolve / resolveSingleton call on a receiver traced to the kernel names a component that is not listed in the file’s uses declaration.', 'Add the existing call-site name to uses on the managed component, then preflight again. Mechanical-safe only when that literal already exists and the edit is the declaration list; inventing a new depend stays judgment.'),
|
|
71
73
|
entry('ARKRUN_TRANSPORT_BYPASS', 'arkrun', 'Homemade broker or emitter import', 'A managed layer imports a closed broker/queue/emitter specifier (EventEmitter, queue clients, …) instead of the ArkRun kernel transport.', 'Send through the ArkRun kernel transport instead of importing that broker or emitter, then preflight again. Never mechanical-safe — homemade buses stay judgment.'),
|
|
72
74
|
entry('ARKORDER_MISSING_PLANE', 'arkorder', 'No createOrderPlane in plane roots', 'The ArkOrder extra is on but no createOrderPlane factory was found in arkOrder.planeRoots, so agents can skip the pattern plane while the write gate stays green.', 'Import createOrderPlane from arkgate/order and call it in a plane root listed in arkOrder.planeRoots, then preflight again. Never mechanical-safe — factory placement is a design decision.'),
|
|
73
75
|
entry('ARKORDER_KERNEL_IN_DOMAIN', 'arkorder', 'Domain-role layer imports the order plane', 'A Domain-role layer imports arkgate/order. Domain stays plane-free; planeRoots own the factory.', 'Move the arkgate/order import out of the Domain-role layer into a plane root or adapter, then preflight again. Never mechanical-safe.'),
|
|
@@ -79,6 +81,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
79
81
|
entry('ARKORDER_XI_TTL', 'arkorder', 'Slow key carries a freshness field', 'ξ named ttl/freshUntil/maxAge. Freshness belongs on σ. A slow parameter that expires per transaction is not slow.', 'Move freshness onto σ (freshUntil) and keep ξ stable, then preflight again. Never mechanical-safe.'),
|
|
80
82
|
entry('ARKORDER_STALE_SIGMA', 'arkorder', 'σ is stale', 'ingest ran after σ.freshUntil (or sigmaMaxAgeMs). ξ does not TTL.', 'Call refreshSigma and ingest again, or proposeRelease then apply(ProposeResult) if the pattern changed. Never mechanical-safe.'),
|
|
81
83
|
entry('ARKORDER_UNVALVED_RELEASE', 'arkorder', 'Second freeze without the valve', 'release() already froze the big choice. A later release() with a different value does not land. First freeze is release(); later change is proposeRelease then apply.', 'Change the choice with proposeRelease then apply. release() is only the first freeze. Never mechanical-safe.'),
|
|
84
|
+
entry('ARKORDER_STALE_PROPOSAL', 'arkorder', 'Proposal is not bound to the current Release', 'apply() got a ProposeResult computed against another Release (another apply landed first), one missing its base binding, or one whose reviewed blast radius is not the transition that would commit. The binding is data, not a capability: a proposal that states the current base and the exact transition applies whoever built it.', 'Run proposeRelease again against the current Release, review the new blast radius, then apply that proposal. Never mechanical-safe.'),
|
|
82
85
|
// ── atomic preflight / change set ────────────────────────────────────────
|
|
83
86
|
entry('INVALID_CHANGE_PATH', 'preflight', 'Unsafe change path', 'A change set entry is not a safe, non-empty project-relative path (absolute, escape, empty, or NUL).', 'Use canonical project-relative paths only in the atomic change set, then preflight again.'),
|
|
84
87
|
entry('DUPLICATE_CHANGE_PATH', 'preflight', 'Duplicate path in change set', 'The atomic change set lists more than one operation for the same path.', 'Collapse to one create/update/delete per path, then preflight again.'),
|
|
@@ -90,10 +93,11 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
90
93
|
entry('CANDIDATE_CONTENT_HASH_MISMATCH', 'preflight', 'Candidate content hash mismatch', 'The candidate file content hash does not match the hash expected for the declared change.', 'Rebuild candidate facts from the exact proposed content, then preflight again.'),
|
|
91
94
|
entry('UNDECLARED_CANDIDATE_CHANGE', 'preflight', 'Undeclared candidate change', 'Candidate facts differ from base for a path that was not listed in the explicit change set.', 'Declare every path that changes in the atomic change set, then preflight again.'),
|
|
92
95
|
entry('ATOMIC_PREFLIGHT_UNAVAILABLE', 'preflight', 'Atomic preflight unavailable', 'The host/MCP path could not run the atomic preflight engine (missing facts, incomplete setup, or unsupported mode).', 'Use resolved-candidate facts / ark_prepare_change with a complete batch, or fall back to ark-check on disk. Do not treat missing preflight as green.'),
|
|
96
|
+
entry('WRITE_GATE_UNAVAILABLE', 'preflight', 'Write gate could not run', 'The write gate could not load its own inputs (ark.config.json missing, unreadable, or invalid; a referenced ArkRules file missing or invalid; or the built library missing), so it cannot judge this governed source write (existing files included), or a write would leave ark.config.json or a referenced ArkRules file unloadable. No checker, no write: the hook blocks instead of letting the write through unchecked.', 'Fix the reported input (repair ark.config.json or the ArkRules file, or run `npm run build` / reinstall arkgate from npm), then retry the same write. Do not remove the hook to get past it.'),
|
|
93
97
|
entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces a created-path domain-logic-in-ui file under --strict-merge, or introduces or worsens a blocking design-smell class under --fail-on-new-smells.', 'Move the new UI business rule out of the created file (or revert a --fail-on-new-smells regression), then re-run with the same base ref.'),
|
|
94
98
|
// ── analysis completeness / host ─────────────────────────────────────────
|
|
95
99
|
entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed; evidence includes the TypeScript diagnostic (line + message). Incremental mid-edit parse is normal for agents. Contract exclude paths skip the write hook.', 'Finish the source or fix the reported syntax error, then re-run `npx arkgate-check`. The write hook does not deny solely on mid-edit parse. Partial never means pass.'),
|
|
96
|
-
entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority.', '
|
|
100
|
+
entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'This check only saw one file, so it cannot fully prove how the import resolves. The result is provisional — `ark-check` on the project is the authority.', 'For a complete verdict, run `npx arkgate-check --root . --config ark.config.json` (or ark_prepare_change over MCP with the full candidate batch). Read lexicalValid for the one-file result.'),
|
|
97
101
|
entry('ANALYSIS_COVERS_NO_FILES', 'analysis', 'Analysis covered no files', 'No file matched the contract include and layer patterns under the analyzed root, so the run had nothing to check. Every rule is vacuously satisfied on an empty set: a green here would read exactly like a green over a governed tree while certifying nothing. Usual causes are a --root that is not the tree the contract describes (including a contract found outside the requested root, whose directory is then adopted as the project root), include / exclude patterns that match nothing, or layer patterns written for a different layout.', 'Point --root at the tree the contract describes, or keep the contract inside that tree, or fix the include / exclude / layer patterns so they match real files — then re-run `npx arkgate-check --root . --config ark.config.json`. This is a refusal about ArkGate\u2019s own inputs, not a finding about your code; no baseline or policy acknowledgement can suppress it.'),
|
|
98
102
|
entry('ANALYSIS_HOST_UNAVAILABLE', 'analysis', 'Analysis host unavailable', 'No usable TypeScript / analysis host was available for this invocation.', 'Install a supported TypeScript version visible to the project, then re-run. Unavailable analysis is fail-closed.'),
|
|
99
103
|
entry('ADAPTER_NOT_ALLOWED_FOR_PORT', 'adapter', 'Adapter not allowed for port', 'Runtime/port wiring selected an adapter implementation that the architecture profile does not allow for that port.', 'Bind an allowed adapter for the port, or adjust the profile with an explicit policy decision — then re-run.'),
|
|
@@ -121,6 +125,11 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
121
125
|
entry('CONFIG_UNCLASSIFIED_FILES', 'config', 'Unclassified included files', 'Included source files match no layer pattern; import rules will not enforce on them.', 'Extend layer patterns or narrow include so every governed file is classified.', { oftenAdvisory: true }),
|
|
122
126
|
entry('CONFIG_LAYER_MISSING_OWNER', 'config', 'Layer missing owner', 'requireLayerOwners is on and this layer has no owners. Writes to that house fail closed, like a folder with no name on the door.', 'Add a GitHub handle or email to that layer’s owners in ark.config.json (/ark-adopt), then re-run. Reserved/allowEmpty houses may stay unnamed. Turn the flag off only if you no longer want owners required.'),
|
|
123
127
|
entry('CONFIG_SLICE_IDENTITY_COLLISION', 'config', 'Starred slice prefixes share one id', 'sliceIdentity is stars and two different sliceFolders prefixes bind as the same slice id. Parallel trees of one feature do that on purpose; unrelated trees that share the last folder name merge by accident.', 'Keep stars when those prefixes are one feature. Otherwise use path, or rename the last literal so the prefixes do not bind as the same id. Doctor names both paths.', { oftenAdvisory: true }),
|
|
128
|
+
entry('CONFIG_CHILD_SLICES_VERSION', 'config', 'Config needs a newer arkgate than a pinned copy', 'ark.config.json uses a slice key (childSlices, sharedImportsSlice "deny-cross-parent" or its object form, sliceIdentity, sharedImportsSlice, childSlices.message, siblings.ratchet) that an arkgate pinned in this repo rejects at config load. Emitted only with evidence of that pin: an exact package.json dependency, the installed node_modules/arkgate, package-lock.json, a scripts/*hook* or host hook command, or a CI workflow. Silent otherwise.', 'Bump the named file to the version the finding gives (or newer), then run Ark again. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
129
|
+
entry('CONFIG_CHILD_SLICE_EXTENDS', 'config', 'Child slice id does not extend its universe', 'A path resolved a child id that is not the universe id plus a further segment. The child wall does not treat that id as a child, so the edge is universe common until the folders line up.', 'Point childSlices.sliceFolders at children under the same universe id the outer sliceFolders already names. Doctor names the path.', { oftenAdvisory: true }),
|
|
130
|
+
entry('CONFIG_CHILD_SLICE_CROSS_UNIVERSE', 'config', 'Child slice allowance cannot cross the universe wall', 'childSlices.allowedCrossSlice names a pattern that can match two universes. That list clears only a sibling crossing inside one universe. The universe wall still denies the edge.', 'Narrow the pattern so both sides share one universe prefix (features/projects/* to features/projects/d2d-item), or remove the entry. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
131
|
+
entry('CONFIG_CHILD_SLICES_INERT', 'config', 'childSlices on a rule that cannot run it', 'A rule sets childSlices without peerIsolation: true and allowed: false. The child wall runs only inside a universe wall, so on this rule childSlices enforces nothing. Config load still accepts it (arkgate 4.8.23 did too).', 'Add "peerIsolation": true and "allowed": false to the same rule so the child wall runs, or remove childSlices. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
132
|
+
entry('CONFIG_SLICE_LEGACY_STARS_ID', 'config', 'Slice id written in the 4.8.23 stars form', 'Under sliceIdentity "stars", a sliceAliases target or an allowedCrossSlice entry names the 4.8.23 stars id, which dropped a star binding before the last literal (modules/*/api/* bound api/v1). The id now keeps that binding (orders/api/v1). For one release the entry keeps its 4.8.23 meaning, so verdicts do not change.', 'Rewrite the entry with the new id the warning names (for example orders/api/v1/x instead of api/v1/x). A sliceAliases target that maps to more than one universe shape is rejected at config load. This warning does not fail the check.', { oftenAdvisory: true }),
|
|
124
133
|
// ── literal path drift ───────────────────────────────────────────────────
|
|
125
134
|
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.'),
|
|
126
135
|
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 }),
|
|
@@ -183,3 +192,33 @@ export function catalogFixForRuleId(ruleId) {
|
|
|
183
192
|
export function catalogWhyForRuleId(ruleId) {
|
|
184
193
|
return getDiagnosticCatalogEntry(ruleId)?.why;
|
|
185
194
|
}
|
|
195
|
+
export const SLICE_REASON_HINTS = Object.freeze([
|
|
196
|
+
Object.freeze({
|
|
197
|
+
reasonId: 'CROSS_PARENT_SLICE',
|
|
198
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
199
|
+
title: 'Cross-universe slice',
|
|
200
|
+
why: 'This import crosses two universe slices. The child wall cannot allow another universe.',
|
|
201
|
+
fix: 'Keep the import inside one universe, or move the shared code into a folder both universes may use.',
|
|
202
|
+
}),
|
|
203
|
+
Object.freeze({
|
|
204
|
+
reasonId: 'CROSS_SIBLING_SLICE',
|
|
205
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
206
|
+
title: 'Cross-sibling slice',
|
|
207
|
+
why: 'This import crosses two feature slices inside one universe.',
|
|
208
|
+
fix: 'Import universe-common code, or move the shared piece into the feature that owns it.',
|
|
209
|
+
advisoryNote: 'This crossing is a warning (siblings advisory). With a baseline in use, a new one fails only when the baseline already records an advisory crossing of this rule and the count grows, or when siblings.ratchet is true; ratchet false measures only.',
|
|
210
|
+
}),
|
|
211
|
+
Object.freeze({
|
|
212
|
+
reasonId: 'CROSS_PARENT_VIA_SHARED',
|
|
213
|
+
ruleId: 'LAYER_IMPORT_VIOLATION',
|
|
214
|
+
title: 'Cross-universe through a shared root',
|
|
215
|
+
why: 'A slice reaches another universe only through a shared root. ark-check and CI report this path. The write hook and ESLint see one edge at a time and do not block it.',
|
|
216
|
+
fix: 'Stop the shared root from importing the other universe, or move that import into the universe that owns it. A shared hop that stays inside one universe is allowed. If the shared hop is a composition root (bootstrap, DI registrations), list it in sharedImportsSlice.stopAt; a path through a stop is not a crossing.',
|
|
217
|
+
}),
|
|
218
|
+
]);
|
|
219
|
+
const SLICE_REASON_BY_ID = new Map(SLICE_REASON_HINTS.map((hint) => [hint.reasonId, hint]));
|
|
220
|
+
export function sliceReasonHint(reasonId) {
|
|
221
|
+
if (typeof reasonId !== 'string' || reasonId.length === 0)
|
|
222
|
+
return undefined;
|
|
223
|
+
return SLICE_REASON_BY_ID.get(reasonId);
|
|
224
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
computePhysicalCohesion,
|
|
12
12
|
printPhysicalCohesionSection,
|
|
13
13
|
} from './physical-cohesion.mjs';
|
|
14
|
+
import { collectFlatParentPilot, printFlatParentPilot } from './flat-parent-pilot.mjs';
|
|
14
15
|
import {
|
|
15
16
|
computeDecisionAwareReshapePilot,
|
|
16
17
|
computeReshapeDecisionMemory,
|
|
@@ -37,7 +38,12 @@ import {
|
|
|
37
38
|
} from './ark-order-doctor.mjs';
|
|
38
39
|
import { composeMergePlanesHonesty } from './extra-merge-teeth.mjs';
|
|
39
40
|
import { collectPrototypeShortcutsResidual } from './prototype-shortcuts.mjs';
|
|
40
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
crossParentViaSharedHubs,
|
|
43
|
+
sliceAliasReport,
|
|
44
|
+
sliceCountReport,
|
|
45
|
+
sliceIdentityCollisions,
|
|
46
|
+
} from '../ark-layer-match.mjs';
|
|
41
47
|
|
|
42
48
|
export function attachExtraDoctorSections(rulesUnderContract, config, classification, findings) {
|
|
43
49
|
const arkRulesMerge = {
|
|
@@ -56,6 +62,7 @@ export function attachExtraDoctorSections(rulesUnderContract, config, classifica
|
|
|
56
62
|
findings,
|
|
57
63
|
classification,
|
|
58
64
|
arkRules: arkRulesMerge,
|
|
65
|
+
layerFlow: { layers: config?.layers, rules: config?.rules },
|
|
59
66
|
});
|
|
60
67
|
const arkOrder = summarizeArkOrderSection({
|
|
61
68
|
arkOrder: config?.arkOrder,
|
|
@@ -130,6 +137,12 @@ export function printCompactExtraDoctorLines(advisories, io) {
|
|
|
130
137
|
io.line(io.warn, collision.message);
|
|
131
138
|
}
|
|
132
139
|
}
|
|
140
|
+
const flatMoves = advisories?.flatParentPilot?.moves;
|
|
141
|
+
if (Array.isArray(flatMoves) && flatMoves.length > 0 && typeof flatMoves[0]?.evidence === 'string') {
|
|
142
|
+
console.log('');
|
|
143
|
+
io.line(io.warn, flatMoves[0].evidence);
|
|
144
|
+
if (flatMoves.length > 1) io.line(' ', `${flatMoves.length - 1} more flat-parent suggestion(s) in --doctor --all`);
|
|
145
|
+
}
|
|
133
146
|
const noDomain = advisories?.noDomainFrontend;
|
|
134
147
|
if (noDomain?.ask) {
|
|
135
148
|
console.log('');
|
|
@@ -189,6 +202,31 @@ export function printCompactExtraDoctorLines(advisories, io) {
|
|
|
189
202
|
}
|
|
190
203
|
}
|
|
191
204
|
|
|
205
|
+
function stripEdgeSlashes(value, edge) {
|
|
206
|
+
let start = 0;
|
|
207
|
+
let end = value.length;
|
|
208
|
+
if (edge !== 'end') {
|
|
209
|
+
while (start < end && value[start] === '/') start += 1;
|
|
210
|
+
}
|
|
211
|
+
if (edge !== 'start') {
|
|
212
|
+
while (end > start && value[end - 1] === '/') end -= 1;
|
|
213
|
+
}
|
|
214
|
+
return value.slice(start, end);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Governed file lists are absolute. Alias globs are project-relative. */
|
|
218
|
+
function aliasScanPath(root, entry) {
|
|
219
|
+
const raw = typeof entry === 'string' ? entry : typeof entry?.path === 'string' ? entry.path : '';
|
|
220
|
+
if (!raw) return null;
|
|
221
|
+
const norm = raw.replace(/\\/g, '/');
|
|
222
|
+
const base = stripEdgeSlashes(String(root ?? '').replace(/\\/g, '/'), 'end');
|
|
223
|
+
const rel =
|
|
224
|
+
base && (norm === base || norm.startsWith(`${base}/`))
|
|
225
|
+
? stripEdgeSlashes(norm.slice(base.length), 'start')
|
|
226
|
+
: stripEdgeSlashes(norm, 'start');
|
|
227
|
+
return rel.length > 0 ? rel : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
192
230
|
function classificationFromCoverage(cov) {
|
|
193
231
|
return {
|
|
194
232
|
governedPercent: cov?.governed?.percent ?? null,
|
|
@@ -256,7 +294,27 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
256
294
|
files,
|
|
257
295
|
});
|
|
258
296
|
const sliceIdentityHits = sliceIdentityCollisions(rules ?? config?.rules);
|
|
297
|
+
const slices = sliceCountReport(activeViolations);
|
|
298
|
+
const sharedWalkHubs = crossParentViaSharedHubs(activeViolations);
|
|
299
|
+
const aliasFiles = [];
|
|
300
|
+
for (const entry of Array.isArray(files) ? files : []) {
|
|
301
|
+
const rel = aliasScanPath(root, entry);
|
|
302
|
+
if (rel) aliasFiles.push(rel);
|
|
303
|
+
}
|
|
304
|
+
const sliceAliases = sliceAliasReport(rules ?? config?.rules, aliasFiles);
|
|
305
|
+
const flatParentPilot = collectFlatParentPilot({
|
|
306
|
+
root,
|
|
307
|
+
files: aliasFiles,
|
|
308
|
+
rules: rules ?? config?.rules,
|
|
309
|
+
layers: config?.layers,
|
|
310
|
+
facts,
|
|
311
|
+
ts,
|
|
312
|
+
});
|
|
259
313
|
return {
|
|
314
|
+
...(slices ? { slices } : {}),
|
|
315
|
+
...(sharedWalkHubs ? { sharedWalkHubs } : {}),
|
|
316
|
+
...(sliceAliases ? { sliceAliases } : {}),
|
|
317
|
+
...(flatParentPilot ? { flatParentPilot } : {}),
|
|
260
318
|
...(prototypeShortcuts ? { prototypeShortcuts } : {}),
|
|
261
319
|
...(sliceIdentityHits.length > 0
|
|
262
320
|
? { sliceIdentity: { notAScore: true, collisions: sliceIdentityHits } }
|
|
@@ -271,12 +329,37 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
271
329
|
// P1M: pass classification so extraMergeTeeth cannot arm at 0% governed.
|
|
272
330
|
stewardNudge: collectStewardNudge(root, config),
|
|
273
331
|
rulesUnderContract,
|
|
332
|
+
// AR15: doctor.rulesMigration — inventoried / under contract / frozen (not a score).
|
|
333
|
+
...(rulesUnderContract?.rulesMigration
|
|
334
|
+
? { rulesMigration: rulesUnderContract.rulesMigration }
|
|
335
|
+
: {}),
|
|
274
336
|
arkRun,
|
|
275
337
|
arkOrder,
|
|
276
338
|
};
|
|
277
339
|
}
|
|
278
340
|
|
|
279
341
|
export function printDoctorAdvisories(advisories, io) {
|
|
342
|
+
printFlatParentPilot(advisories?.flatParentPilot, io);
|
|
343
|
+
const sliceAliases = advisories?.sliceAliases;
|
|
344
|
+
if (sliceAliases && sliceAliases.notAScore === true && Array.isArray(sliceAliases.moves)) {
|
|
345
|
+
console.log('');
|
|
346
|
+
console.log(io.color.bold('Slice aliases (owed move)'));
|
|
347
|
+
io.line(io.warn, sliceAliases.debt);
|
|
348
|
+
for (const move of sliceAliases.moves) {
|
|
349
|
+
io.line(io.warn, `${move.from} → ${move.to}. Move these files to ${move.destination}. They are not finished.`);
|
|
350
|
+
if (move.advisory) io.line(io.warn, move.advisory);
|
|
351
|
+
for (const file of move.files ?? []) io.line(' ', file);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
const hubs = advisories?.sharedWalkHubs;
|
|
355
|
+
if (hubs && hubs.notAScore === true && Array.isArray(hubs.hubs) && hubs.hubs.length > 0) {
|
|
356
|
+
console.log('');
|
|
357
|
+
console.log(io.color.bold('Shared walk hubs (not a score)'));
|
|
358
|
+
for (const hub of hubs.hubs) {
|
|
359
|
+
io.line(io.warn, `${hub.file} sits on ${hub.count} of ${hubs.total} CROSS_PARENT_VIA_SHARED paths.`);
|
|
360
|
+
}
|
|
361
|
+
io.line(' ', io.color.dim(`Next: ${hubs.nextAction}`));
|
|
362
|
+
}
|
|
280
363
|
const sliceIdentity = advisories?.sliceIdentity;
|
|
281
364
|
if (Array.isArray(sliceIdentity?.collisions) && sliceIdentity.collisions.length > 0) {
|
|
282
365
|
console.log('');
|
|
@@ -24,6 +24,19 @@ export function packageUnresolvedNextAction(ctx) {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* PACKAGE_PIN_ABSENT leads when a generated gate file (hook, MCP entry, CI workflow, script)
|
|
29
|
+
* runs Ark through the local runner: without the pin that command 404s or runs a stale global.
|
|
30
|
+
*/
|
|
31
|
+
export function packagePinAbsentPrimaryAction(ctx) {
|
|
32
|
+
if (ctx?.selfHost === true) return null;
|
|
33
|
+
const truth = ctx?.packageVersionTruth;
|
|
34
|
+
if (truth?.code !== 'PACKAGE_PIN_ABSENT') return null;
|
|
35
|
+
const files = Array.isArray(truth.dependentGateFiles) ? truth.dependentGateFiles : [];
|
|
36
|
+
if (files.length === 0) return null;
|
|
37
|
+
return `Add arkgate to package.json devDependencies and install — ${files.join(', ')} run Ark through the local runner, which does not resolve without the pin (PACKAGE_PIN_ABSENT)`;
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** Compact doctor JSON #1 — same rank as collectDoctorNextActions when package is missing. */
|
|
28
41
|
export function preferredDoctorPrimaryNextAction({
|
|
29
42
|
adopted,
|
|
@@ -35,6 +48,7 @@ export function preferredDoctorPrimaryNextAction({
|
|
|
35
48
|
} = {}) {
|
|
36
49
|
return (
|
|
37
50
|
packageUnresolvedNextAction({ packageInstalled, selfHost, packageVersionTruth }) ||
|
|
51
|
+
packagePinAbsentPrimaryAction({ selfHost, packageVersionTruth }) ||
|
|
38
52
|
(adopted === 'not-adopted' ? NOT_ADOPTED_NEXT_ACTION : postGreenPath?.action ?? dualTruthNext ?? null)
|
|
39
53
|
);
|
|
40
54
|
}
|
|
@@ -149,7 +163,8 @@ export function collectDoctorNextActions(ctx) {
|
|
|
149
163
|
if (notAdopted || ctx.adopted === ADOPTED_NOT || ctx.adopted == null) {
|
|
150
164
|
actions.push(ctx.notAdoptedNextAction || NOT_ADOPTED_NEXT_ACTION);
|
|
151
165
|
}
|
|
152
|
-
const
|
|
166
|
+
const pinAbsentPrimary = packagePinAbsentPrimaryAction(ctx);
|
|
167
|
+
const resolvePkg = packageUnresolvedNextAction(ctx) || pinAbsentPrimary;
|
|
153
168
|
if (resolvePkg) actions.push(resolvePkg);
|
|
154
169
|
const nudge = ctx.stewardNudge;
|
|
155
170
|
if (
|
|
@@ -220,7 +235,7 @@ export function collectDoctorNextActions(ctx) {
|
|
|
220
235
|
ctx.dualTruthNext ||
|
|
221
236
|
'bump package.json arkgate pin to match this CLI (or install without --no-install)'
|
|
222
237
|
);
|
|
223
|
-
} else if (ctx.packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT') {
|
|
238
|
+
} else if (ctx.packageVersionTruth?.code === 'PACKAGE_PIN_ABSENT' && !pinAbsentPrimary) {
|
|
224
239
|
actions.push(
|
|
225
240
|
ctx.dualTruthNext ||
|
|
226
241
|
'Add arkgate to package.json and install so CI/npx resolve this CLI (PACKAGE_PIN_ABSENT)'
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import * as arkShared from '../ark-shared.mjs';
|
|
13
13
|
import { summarizeRulesUnderContract } from './rules-under-contract.mjs';
|
|
14
14
|
import { describePackageVersionDualTruth } from './field-install.mjs';
|
|
15
|
+
import { withPinDependentGateFiles } from './pin-dependent-gates.mjs';
|
|
15
16
|
import { detectAgentHomeGaps } from './agent-homes.mjs';
|
|
16
17
|
import { collectDoctorNextActions, preferredDoctorPrimaryNextAction } from './doctor-next-actions.mjs';
|
|
17
18
|
import { printDoctorCompactHuman, printDoctorDetailsHuman } from './doctor-human.mjs';
|
|
@@ -21,10 +22,10 @@ import { collectStatesTransitionsResidual } from './states-transitions-presence.
|
|
|
21
22
|
import { collectStatusTransitionCatalogResidual } from './status-transition-catalog.mjs';
|
|
22
23
|
import { collectNoDomainFrontendResidual } from './no-domain-frontend.mjs';
|
|
23
24
|
import { collectInvariantCoverageResiduals } from './invariant-tests-path.mjs';
|
|
25
|
+
import { anyChildWallAdvisory, anySliceAlias, applyAdvisorySiblingRatchet } from '../ark-layer-match.mjs';
|
|
24
26
|
export { printAdrPresenceHint };
|
|
25
27
|
export { printDoctorCompactHuman, printDoctorDetailsHuman };
|
|
26
28
|
export { summarizeRulesUnderContract };
|
|
27
|
-
|
|
28
29
|
/** Optional S3 dual-match classifier when ark-shared exports it (soft dep for S5 landing). */
|
|
29
30
|
const matchingLayersForRelativePath =
|
|
30
31
|
typeof arkShared.matchingLayersForRelativePath === 'function'
|
|
@@ -533,7 +534,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
533
534
|
const skillGaps = detectSkillGaps(root);
|
|
534
535
|
const agentHomeGaps = detectAgentHomeGaps(root);
|
|
535
536
|
// Dual-truth: CLI version vs package.json pin (field residual after upgrade --no-install).
|
|
536
|
-
const packageVersionTruth = describePackageVersionDualTruth(root);
|
|
537
|
+
const packageVersionTruth = withPinDependentGateFiles(root, describePackageVersionDualTruth(root));
|
|
537
538
|
const staleRunners = staleRunnerGateFiles(root);
|
|
538
539
|
const adoption = collectAdoptionGaps(root, config, cov);
|
|
539
540
|
// Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
|
|
@@ -557,12 +558,9 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
557
558
|
? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
|
|
558
559
|
: 0;
|
|
559
560
|
const activeCount = violations.length - suppressed;
|
|
560
|
-
// productHonesty: blocking = failsStrict !== false only
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
if (!baseline.exists) return true;
|
|
564
|
-
return !baseline.keys.has(occurrenceKeys[index]);
|
|
565
|
-
}).length;
|
|
561
|
+
// productHonesty: blocking = failsStrict !== false only, after the same sibling ratchet as --baseline.
|
|
562
|
+
const judged = baseline.exists ? applyAdvisorySiblingRatchet(violations, occurrenceKeys, baseline.keys, { rules, layers: config?.layers }) : violations;
|
|
563
|
+
const blockingActive = judged.filter((v, i) => v.failsStrict !== false && !(baseline.exists && baseline.keys.has(occurrenceKeys[i]))).length;
|
|
566
564
|
const emptyScopeEarly = cov.emptyScope === true || cov.governed.totalFiles === 0;
|
|
567
565
|
const presentationRowEarly = cov.layers.find((r) => r.name === 'PresentationAdapters');
|
|
568
566
|
const totalFilesEarly = cov.governed.totalFiles || 0;
|
|
@@ -694,7 +692,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
694
692
|
github: githubForBoundary,
|
|
695
693
|
adoptionStance: stanceFile,
|
|
696
694
|
stewardNudge: doctorAdvisories.stewardNudge,
|
|
697
|
-
physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion),
|
|
695
|
+
physicalCohesionResidual: physicalCohesionResidualRemains(doctorAdvisories.physicalCohesion), childWallAdvisory: anyChildWallAdvisory(config?.rules), sliceAliasDebt: anySliceAlias(config?.rules),
|
|
698
696
|
});
|
|
699
697
|
|
|
700
698
|
// Improvement compass: projection only — never feeds ok/valid/goal.met.
|