arkgate 4.6.4 → 4.6.5
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 +39 -5
- package/README.md +6 -5
- package/bin/ark-check-runtime.mjs +98 -14
- package/bin/ark-mcp-runtime.mjs +18 -30
- package/bin/lib/adapter-contract.mjs +13 -9
- package/bin/lib/agent-projection-command.mjs +18 -0
- package/bin/lib/agent-projection.mjs +2 -2
- package/bin/lib/analysis-engine.mjs +5 -5
- package/bin/lib/ci-and-commands.mjs +3 -3
- package/bin/lib/ci-merge-boundary.mjs +89 -0
- package/bin/lib/config-contract.mjs +2 -0
- package/bin/lib/diagnostic-catalog.mjs +5 -4
- package/bin/lib/doctor-next-actions.mjs +17 -5
- package/bin/lib/doctor-plan.mjs +14 -0
- package/bin/lib/enforcement-honesty.mjs +2 -0
- package/bin/lib/graph-blind.mjs +15 -6
- package/bin/lib/install-migrate.mjs +10 -0
- package/bin/lib/invariant-coverage.mjs +6 -2
- package/bin/lib/managed-upgrade.mjs +8 -3
- package/bin/lib/presets.mjs +22 -0
- package/bin/lib/remediation.mjs +74 -10
- package/bin/lib/skill-install.mjs +2 -0
- package/bin/lib/snippet-analysis.mjs +40 -8
- package/dist/{configTypes-B8uIcLaG.d.ts → configTypes-l6XiwiC1.d.ts} +7 -0
- package/dist/eslint/index.cjs +3 -3
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +3 -3
- package/dist/index.cjs +26 -26
- package/dist/index.d.ts +11 -2
- package/dist/index.js +29 -29
- package/docs/README.md +3 -2
- package/docs/agent-guide.md +10 -0
- package/docs/brownfield-adoption.md +7 -1
- package/docs/configuration.md +2 -1
- package/docs/develop.md +4 -2
- package/docs/diagnostics.md +15 -5
- package/docs/package-surface.md +3 -2
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +6 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +7 -0
- package/templates/agent-skills/ark-explore/SKILL.md +6 -0
- package/templates/agent-skills/ark-place/SKILL.md +11 -4
- package/templates/agent-skills/ark-upgrade/SKILL.md +9 -2
- package/templates/skills/ark-adopt.md +7 -0
- package/templates/skills/ark-explore.md +6 -0
- package/templates/skills/ark-place.md +11 -4
- package/templates/skills/ark-upgrade.md +9 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class writePath / CI honesty file (`.ark/ci-merge-boundary.json`).
|
|
3
|
+
* Agents must read this instead of grepping node_modules dist.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const CI_MERGE_BOUNDARY_REL = '.ark/ci-merge-boundary.json';
|
|
9
|
+
export const CI_MERGE_BOUNDARY_SCHEMA = '1.0';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{
|
|
13
|
+
* writePath?: object,
|
|
14
|
+
* github?: { requiredStatusConfigured?: boolean, plan?: string, canRequire?: boolean, error?: string },
|
|
15
|
+
* }} input
|
|
16
|
+
*/
|
|
17
|
+
export function buildCiMergeBoundary(input = {}) {
|
|
18
|
+
const writePath = input.writePath && typeof input.writePath === 'object' ? input.writePath : {};
|
|
19
|
+
const inventory = writePath.inventory?.hosts && typeof writePath.inventory.hosts === 'object'
|
|
20
|
+
? writePath.inventory.hosts
|
|
21
|
+
: {};
|
|
22
|
+
const perHost = {};
|
|
23
|
+
for (const [host, record] of Object.entries(inventory)) {
|
|
24
|
+
const caps = record?.capabilities && typeof record.capabilities === 'object' ? record.capabilities : {};
|
|
25
|
+
const configured = Boolean(record?.configured || caps['hard-write'] || caps['advisory-write']);
|
|
26
|
+
const fired = Boolean(writePath.enforcementState?.localWrite?.runtimeObserved);
|
|
27
|
+
const hard = caps['hard-write'] === true;
|
|
28
|
+
perHost[host] = {
|
|
29
|
+
configured,
|
|
30
|
+
fired,
|
|
31
|
+
state: configured && !fired ? 'configured-not-fired' : fired ? 'observed' : 'absent',
|
|
32
|
+
writePath: hard ? 'hard' : caps['advisory-write'] ? 'soft' : 'none',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hookConfigured = Object.values(perHost).some((h) => h.configured);
|
|
37
|
+
const hookFired = Object.values(perHost).some((h) => h.fired);
|
|
38
|
+
const github = input.github && typeof input.github === 'object' ? input.github : {};
|
|
39
|
+
const workflowPresent = Boolean(
|
|
40
|
+
writePath.capabilities?.['merge-gate'] || writePath.inventory?.capabilities?.['merge-gate']
|
|
41
|
+
);
|
|
42
|
+
const required = github.requiredStatusConfigured === true;
|
|
43
|
+
const canRequire = github.canRequire !== false && github.plan !== 'free';
|
|
44
|
+
let ciState = 'absent';
|
|
45
|
+
if (workflowPresent && required) ciState = 'required';
|
|
46
|
+
else if (workflowPresent && !canRequire) ciState = 'present-but-github-free-cannot-require';
|
|
47
|
+
else if (workflowPresent) ciState = 'present-but-not-required';
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
schemaVersion: CI_MERGE_BOUNDARY_SCHEMA,
|
|
51
|
+
notAScore: true,
|
|
52
|
+
path: CI_MERGE_BOUNDARY_REL,
|
|
53
|
+
hook: {
|
|
54
|
+
configured: hookConfigured,
|
|
55
|
+
fired: hookFired,
|
|
56
|
+
state: hookConfigured && !hookFired ? 'configured-not-fired' : hookFired ? 'observed' : 'absent',
|
|
57
|
+
},
|
|
58
|
+
writePath: perHost,
|
|
59
|
+
ci: {
|
|
60
|
+
workflowPresent,
|
|
61
|
+
requiredStatusConfigured: required,
|
|
62
|
+
state: ciState,
|
|
63
|
+
},
|
|
64
|
+
githubPlan: {
|
|
65
|
+
plan: github.plan ?? (canRequire ? 'unknown' : 'free'),
|
|
66
|
+
canRequire,
|
|
67
|
+
reason: canRequire ? null : 'github-free-cannot-require',
|
|
68
|
+
},
|
|
69
|
+
hookGreenIsNotTreeGreen: true,
|
|
70
|
+
scriptedEditsBypassPreToolUse: true,
|
|
71
|
+
note:
|
|
72
|
+
'Do not reverse-engineer node_modules/arkgate/dist. This file is the honesty surface for writePath and CI.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function writeCiMergeBoundary(root, input = {}) {
|
|
77
|
+
const payload = buildCiMergeBoundary(input);
|
|
78
|
+
const dir = path.join(root, '.ark');
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const dest = path.join(dir, 'ci-merge-boundary.json');
|
|
81
|
+
const next = `${JSON.stringify(payload, null, 2)}\n`;
|
|
82
|
+
try {
|
|
83
|
+
if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') === next) return payload;
|
|
84
|
+
} catch {
|
|
85
|
+
/* rewrite */
|
|
86
|
+
}
|
|
87
|
+
fs.writeFileSync(dest, next);
|
|
88
|
+
return payload;
|
|
89
|
+
}
|
|
@@ -32,7 +32,7 @@ function entry(ruleId, category, title, why, fix, extras) {
|
|
|
32
32
|
*/
|
|
33
33
|
export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
34
34
|
// ── layer / graph ────────────────────────────────────────────────────────
|
|
35
|
-
entry('LAYER_IMPORT_VIOLATION', 'layer', 'Layer import not allowed', 'A module import (or re-export) crosses a layer edge that ark.config.json does not allow. The architecture contract forbids that dependency direction so outer infrastructure cannot leak into pure or inner layers.', '
|
|
35
|
+
entry('LAYER_IMPORT_VIOLATION', 'layer', 'Layer import not allowed', 'A module import (or re-export) crosses a layer edge that ark.config.json does not allow. The architecture contract forbids that dependency direction so outer infrastructure cannot leak into pure or inner layers.', '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
38
|
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.'),
|
|
@@ -55,7 +55,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
55
55
|
entry('ARKRULE_STRUCTURE', 'arkrules', 'ArkRule structure sensor failed', 'An opt-in ArkRules structure sensor (private state, factory shape, event publish, …) failed on a governed file for a declared arkruleId.', 'Restore the declared structure for the ArkRule (see arkruleSource), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.'),
|
|
56
56
|
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
57
|
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
|
-
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).', 'Add a test title or declared symbol covering the arkruleId, then preflight again. Missing test globs report partial — never fake green.'),
|
|
58
|
+
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.'),
|
|
59
59
|
// ── atomic preflight / change set ────────────────────────────────────────
|
|
60
60
|
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.'),
|
|
61
61
|
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.'),
|
|
@@ -69,7 +69,8 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
69
69
|
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.'),
|
|
70
70
|
entry('DESIGN_SMELL_REGRESSION', 'preflight', 'Design smell regression on base-relative ratchet', 'Compared to the base ref, the candidate introduces or worsens a blocking design-smell class (e.g. domain-logic-in-ui) under --fail-on-new-smells.', 'Revert the regression or redesign so the smell does not worsen versus base, then re-run with the same base ref.'),
|
|
71
71
|
// ── analysis completeness / host ─────────────────────────────────────────
|
|
72
|
-
entry('ANALYSIS_PARSE_INCOMPLETE', 'analysis', 'Parse incomplete', 'Governed source could not be fully parsed;
|
|
72
|
+
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.'),
|
|
73
|
+
entry('LEXICAL_EVIDENCE_INCOMPLETE', 'analysis', 'Lexical evidence incomplete', 'Single-file validation cannot prove project module resolution. The write hook is already the verdict.', 'Re-run `npx arkgate-check --root . --config ark.config.json`, or treat the hook deny as final. Do not call ark_prepare_change from a hook deny.'),
|
|
73
74
|
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.'),
|
|
74
75
|
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.'),
|
|
75
76
|
// ── AI snippet gate policy surface ───────────────────────────────────────
|
|
@@ -88,7 +89,7 @@ export const DIAGNOSTIC_CATALOG = Object.freeze([
|
|
|
88
89
|
entry('CONFIG_INVALID_FORBIDDEN_GLOBALS', 'config', 'Invalid forbiddenGlobals', 'A layer’s forbiddenGlobals is not an array of strings; the entry is ignored.', 'Use an array of strings (e.g. ["fetch", "Date.now"]).', { oftenAdvisory: true }),
|
|
89
90
|
entry('CONFIG_LAYER_WITHOUT_PATTERNS', 'config', 'Layer without patterns', 'A named layer has no file patterns and will never classify files.', 'Add patterns globs that match the layer’s source tree.', { oftenAdvisory: true }),
|
|
90
91
|
entry('CONFIG_INVALID_LAYER_PATTERN', 'config', 'Invalid layer pattern', 'A layer pattern is not a valid glob / failed to compile.', 'Fix the pattern syntax for that layer.', { oftenAdvisory: true }),
|
|
91
|
-
entry('CONFIG_LAYER_PATTERN_NO_MATCHES', 'config', 'Layer pattern matched no files', 'A layer pattern matched zero included files (often a typo or include mismatch).', 'Adjust the pattern or include roots
|
|
92
|
+
entry('CONFIG_LAYER_PATTERN_NO_MATCHES', 'config', 'Layer pattern matched no files', 'A layer pattern matched zero included files (often a typo or include mismatch). Reserved/allowEmpty houses do not emit this.', 'Adjust the pattern or include roots, or mark the layer reserved/allowEmpty if the glob is a future house.', { oftenAdvisory: true }),
|
|
92
93
|
entry('CONFIG_DUPLICATE_LAYER', 'config', 'Duplicate layer name', 'The same layer name appears more than once in configuration.', 'Rename or merge duplicate layer entries.', { oftenAdvisory: true }),
|
|
93
94
|
entry('CONFIG_RULE_UNKNOWN_FROM_LAYER', 'config', 'Rule unknown from layer', 'A dependency rule references a source layer name that is not declared.', 'Fix the rule’s from field to a declared layer name.', { oftenAdvisory: true }),
|
|
94
95
|
entry('CONFIG_RULE_UNKNOWN_TO_LAYER', 'config', 'Rule unknown to layer', 'A dependency rule references a target layer name that is not declared.', 'Fix the rule’s to field to a declared layer name.', { oftenAdvisory: true }),
|
|
@@ -9,6 +9,16 @@ import { mergePostGreenTopActions } from './post-green-path.mjs';
|
|
|
9
9
|
|
|
10
10
|
export function collectDoctorNextActions(ctx) {
|
|
11
11
|
const actions = [];
|
|
12
|
+
const gatesInstalled = Array.isArray(ctx.gatesMissing) && ctx.gatesMissing.length === 0;
|
|
13
|
+
const planAEmpty = !ctx.activeCount;
|
|
14
|
+
const enforceEmptyPlan =
|
|
15
|
+
ctx.operatingMode === 'enforce' && planAEmpty && gatesInstalled;
|
|
16
|
+
if (enforceEmptyPlan) {
|
|
17
|
+
actions.push(
|
|
18
|
+
ctx.postGreenPath?.action ||
|
|
19
|
+
'/ark-explore shape-focus → /ark-autopilot (one B pilot) # ENFORCE + empty plan A → Shape, not reinstall gates'
|
|
20
|
+
);
|
|
21
|
+
}
|
|
12
22
|
if (!ctx.analysisComplete) actions.push('restore complete analysis, then rerun ark-check --doctor');
|
|
13
23
|
if (ctx.designSmells.length > 0 && ctx.postGreenPath) actions.push(ctx.postGreenPath.action);
|
|
14
24
|
if (ctx.coverageHonesty.greenIsNotEnforcement && ctx.coverageHonesty.worseThanNoGate) {
|
|
@@ -31,8 +41,8 @@ export function collectDoctorNextActions(ctx) {
|
|
|
31
41
|
`resolve the non-baselined violations — see the classified plan (${arkCommand(ctx.root, 'ark-check', '--plan')}), then /ark-autopilot`
|
|
32
42
|
);
|
|
33
43
|
}
|
|
34
|
-
if (ctx.writePath?.gap?.fix) actions.push(ctx.writePath.gap.fix);
|
|
35
|
-
if (ctx.gatesMissing.length > 0) {
|
|
44
|
+
if (ctx.writePath?.gap?.fix && !gatesInstalled) actions.push(ctx.writePath.gap.fix);
|
|
45
|
+
if (!gatesInstalled && ctx.gatesMissing.length > 0) {
|
|
36
46
|
actions.push(`install gates (${arkCommand(ctx.root, 'ark-check', '--install-agent-gates')})`);
|
|
37
47
|
}
|
|
38
48
|
const humanSkillGaps = skillGapsForActiveHost(ctx.skillGaps);
|
|
@@ -45,10 +55,12 @@ export function collectDoctorNextActions(ctx) {
|
|
|
45
55
|
if (legacyCodex) {
|
|
46
56
|
actions.push('install Codex SKILL.md catalog (--install-agent-gates --skills-only --tools codex --force)');
|
|
47
57
|
}
|
|
48
|
-
if (remMiss
|
|
49
|
-
actions.push('
|
|
58
|
+
if (remMiss > 0) {
|
|
59
|
+
actions.push('install missing /ark-* skills (--install-agent-gates --skills-only --force)');
|
|
60
|
+
} else if (remStale > 0) {
|
|
61
|
+
actions.push('refresh stale /ark-* skills (--install-agent-gates --skills-only --force) — gates are installed, catalog is stale');
|
|
50
62
|
}
|
|
51
|
-
if (ctx.codexHomeGap && ctx.codexConcernActive) {
|
|
63
|
+
if (ctx.codexHomeGap && ctx.codexConcernActive && ctx.codexHomeGap.preferProject !== true) {
|
|
52
64
|
actions.push(
|
|
53
65
|
ctx.codexHomeGap.catalogMetadataInvalid
|
|
54
66
|
? 'repair invalid Codex home catalog metadata after verifying the newest installed version'
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
buildDeepModuleCoachAdvisory,
|
|
76
76
|
printDeepModuleCoachSection,
|
|
77
77
|
} from './deep-module-coach.mjs';
|
|
78
|
+
import { writeCiMergeBoundary } from './ci-merge-boundary.mjs';
|
|
78
79
|
|
|
79
80
|
const color = {
|
|
80
81
|
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
@@ -535,6 +536,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
535
536
|
const adoption = collectAdoptionGaps(root, config, cov);
|
|
536
537
|
// Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
|
|
537
538
|
const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
|
|
539
|
+
let ciMergeBoundary = null;
|
|
540
|
+
try {
|
|
541
|
+
ciMergeBoundary = writeCiMergeBoundary(root, {
|
|
542
|
+
writePath,
|
|
543
|
+
github: adoption.deployPath?.github ?? writePath.enforcementState?.ciMerge ?? {},
|
|
544
|
+
});
|
|
545
|
+
} catch {
|
|
546
|
+
ciMergeBoundary = null;
|
|
547
|
+
}
|
|
538
548
|
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
539
549
|
const occurrenceKeys = baselineOccurrenceKeys(violations);
|
|
540
550
|
const currentKeys = new Set(occurrenceKeys);
|
|
@@ -682,6 +692,8 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
682
692
|
(options.writeJson ?? console.log)(
|
|
683
693
|
JSON.stringify(
|
|
684
694
|
{
|
|
695
|
+
schemaVersion: '1.0',
|
|
696
|
+
envelope: 'doctor',
|
|
685
697
|
ok: analysisComplete && (options.designDelta?.valid ?? true),
|
|
686
698
|
doctor: {
|
|
687
699
|
completeness,
|
|
@@ -767,6 +779,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
767
779
|
skillGaps,
|
|
768
780
|
...(agentHomeGaps.length > 0 ? { agentHomeGaps } : {}),
|
|
769
781
|
staleRunnerFiles: staleRunners,
|
|
782
|
+
ciMergeBoundary,
|
|
770
783
|
writePath: {
|
|
771
784
|
activeHost: writePath.activeHost,
|
|
772
785
|
support: writePath.support,
|
|
@@ -897,6 +910,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
897
910
|
safetyHasEntries,
|
|
898
911
|
showNewHere,
|
|
899
912
|
designFitness,
|
|
913
|
+
operatingMode,
|
|
900
914
|
});
|
|
901
915
|
console.log('');
|
|
902
916
|
if (isDoctorHealthyNothingToDo(designFitness, uniqueActions)) {
|
package/bin/lib/graph-blind.mjs
CHANGED
|
@@ -16,8 +16,16 @@ import { normalize } from './scan-files.mjs';
|
|
|
16
16
|
|
|
17
17
|
const MAX_LIST = 8;
|
|
18
18
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
19
|
+
/** Floor for tiny trees. Large included Next.js trees must still scan. */
|
|
20
|
+
const GRAPH_SCAN_FLOOR = 2500;
|
|
21
|
+
/** Hard cap so a 10k fixture cannot blow the doctor resident warm UX ceiling. */
|
|
22
|
+
const GRAPH_SCAN_HARD_CAP = 8000;
|
|
23
|
+
|
|
24
|
+
/** Threshold scales with the included tree — never a hard 2500 that defers a 3300-file app. */
|
|
25
|
+
export function graphScanLimit(includedCount) {
|
|
26
|
+
const count = Number(includedCount) || 0;
|
|
27
|
+
return Math.min(GRAPH_SCAN_HARD_CAP, Math.max(GRAPH_SCAN_FLOOR, count));
|
|
28
|
+
}
|
|
21
29
|
const LEXICAL_GATE = /\b(?:import|require)\s*\(|\bimport\s+\w+\s*=\s*require\s*\(/;
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -123,9 +131,9 @@ export function detectGraphBlindSpots(ts, root, files = []) {
|
|
|
123
131
|
};
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
// Large-tree deferral:
|
|
127
|
-
|
|
128
|
-
if (files.length >
|
|
134
|
+
// Large-tree deferral: only above the proportional cap (included count, floor 2500, cap 8000).
|
|
135
|
+
const scanLimit = graphScanLimit(files.length);
|
|
136
|
+
if (files.length > scanLimit) {
|
|
129
137
|
return {
|
|
130
138
|
available: true,
|
|
131
139
|
advisory: true,
|
|
@@ -136,7 +144,7 @@ export function detectGraphBlindSpots(ts, root, files = []) {
|
|
|
136
144
|
otherNonLiteralCount: 0,
|
|
137
145
|
truncated: 0,
|
|
138
146
|
edges: [],
|
|
139
|
-
note: `Graph-blind full scan deferred (${files.length} files > ${
|
|
147
|
+
note: `Graph-blind full scan deferred (${files.length} files > proportional limit ${scanLimit}). Non-literal dynamic import/require remain unresolvable in architecture analysis; advisory incomplete-graph honesty is not enumerated at this scale.`,
|
|
140
148
|
};
|
|
141
149
|
}
|
|
142
150
|
|
|
@@ -177,6 +185,7 @@ export function detectGraphBlindSpots(ts, root, files = []) {
|
|
|
177
185
|
available: true,
|
|
178
186
|
advisory: true,
|
|
179
187
|
blockerGrade: false,
|
|
188
|
+
deferred: false,
|
|
180
189
|
count: edges.length,
|
|
181
190
|
templateInterpolationCount,
|
|
182
191
|
otherNonLiteralCount,
|
|
@@ -635,6 +635,16 @@ export function runInstallAgentGates(args) {
|
|
|
635
635
|
homeResults.push({ relativePath: dir, status: 'failed' });
|
|
636
636
|
}
|
|
637
637
|
if (homeResults.length === 0) {
|
|
638
|
+
const skillName = (skill) =>
|
|
639
|
+
Array.isArray(skill) ? skill[0] : skill?.name || skill;
|
|
640
|
+
const projectHasCatalog = skills.some((skill) =>
|
|
641
|
+
fs.existsSync(path.join(root, '.agents', 'skills', skillName(skill), 'SKILL.md'))
|
|
642
|
+
);
|
|
643
|
+
if (projectHasCatalog) {
|
|
644
|
+
console.log(
|
|
645
|
+
' Project .agents/skills already has this catalog; home write is optional. Prefer the project copy.'
|
|
646
|
+
);
|
|
647
|
+
}
|
|
638
648
|
for (const result of installSkillCatalog({
|
|
639
649
|
directory: dir,
|
|
640
650
|
skills,
|
|
@@ -79,11 +79,14 @@ export function evaluateInvariantCoverage(input) {
|
|
|
79
79
|
if (!covered || partial) {
|
|
80
80
|
// Enforced + proven uncovered → failsStrict; partial always advisory (never fake green).
|
|
81
81
|
const failsStrict = inv.mode === 'enforced' && !partial;
|
|
82
|
+
const kind = testGlobsMissing || testFiles.length === 0 ? 'never-had-tests' : 'tests-disappeared';
|
|
82
83
|
violations.push({
|
|
83
84
|
ruleId: 'INVARIANT_UNCOVERED',
|
|
84
85
|
message: partial
|
|
85
|
-
? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered.`
|
|
86
|
-
:
|
|
86
|
+
? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered (never-had-tests).`
|
|
87
|
+
: kind === 'tests-disappeared'
|
|
88
|
+
? `Invariant ${inv.id} is not covered by a test title or declared symbol (tests-disappeared — suite exists).`
|
|
89
|
+
: `Invariant ${inv.id} is not covered by a test title or declared symbol (never-had-tests).`,
|
|
87
90
|
file: inv.provenance.sourceFile,
|
|
88
91
|
line: 1,
|
|
89
92
|
arkruleId: inv.id,
|
|
@@ -91,6 +94,7 @@ export function evaluateInvariantCoverage(input) {
|
|
|
91
94
|
fromLayer: inv.provenance.layer,
|
|
92
95
|
severity: failsStrict ? 'error' : 'warning',
|
|
93
96
|
failsStrict,
|
|
97
|
+
kind,
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
100
|
}
|
|
@@ -134,7 +134,7 @@ const HOST_SIGNALS = {
|
|
|
134
134
|
],
|
|
135
135
|
codex: ['.codex/hooks.json', '.codex/config.toml', '.agents/skills/ark-upgrade/SKILL.md'],
|
|
136
136
|
grok: ['.grok/config.toml', '.grok/hooks/ark-write-gate.json', '.grok/skills/ark-upgrade/SKILL.md'],
|
|
137
|
-
antigravity: ['.agents/hooks.json'
|
|
137
|
+
antigravity: ['.agents/hooks.json'],
|
|
138
138
|
opencode: ['opencode.json', '.opencode/skills/ark-upgrade/SKILL.md'],
|
|
139
139
|
windsurf: ['.windsurf/rules/ark.md', '.windsurf/workflows/ark-upgrade.md'],
|
|
140
140
|
cline: ['.clinerules/ark.md', '.clinerules/workflows/ark-upgrade.md'],
|
|
@@ -307,8 +307,13 @@ function resolveSelection(root, options, manifest) {
|
|
|
307
307
|
const explicit = normalizeToolsList(options.tools);
|
|
308
308
|
const compactHost = compactRouterHost(root);
|
|
309
309
|
let hosts;
|
|
310
|
-
if (explicit.length > 0)
|
|
311
|
-
|
|
310
|
+
if (explicit.length > 0) {
|
|
311
|
+
const kept = manifest
|
|
312
|
+
? normalizeHosts(manifest.hosts)
|
|
313
|
+
: detectedManagedHosts(root);
|
|
314
|
+
// Preview/apply default is hosts-keep: --tools unions, never retires other hosts.
|
|
315
|
+
hosts = normalizeHosts([...kept, ...explicit]);
|
|
316
|
+
} else if (manifest) hosts = normalizeHosts(manifest.hosts);
|
|
312
317
|
else if (compactHost !== null) hosts = compactHost === 'none' ? [] : normalizeHosts([compactHost]);
|
|
313
318
|
else {
|
|
314
319
|
hosts = detectedManagedHosts(root);
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -179,6 +179,28 @@ export const PERSISTENCE_PATH_PATTERNS = Object.freeze([
|
|
|
179
179
|
* Application orchestration under lib without the whole-src-lib vacuum.
|
|
180
180
|
* Never use lone `src/**` or bare `src/lib/**` as Application on Next/event trees.
|
|
181
181
|
*/
|
|
182
|
+
/** Types and constants — SharedKernel, not Application vacuum. */
|
|
183
|
+
export const SHARED_KERNEL_PATH_PATTERNS = Object.freeze([
|
|
184
|
+
'src/shared/**',
|
|
185
|
+
'src/types/**',
|
|
186
|
+
'src/**/types.ts',
|
|
187
|
+
'src/**/constants.ts',
|
|
188
|
+
'src/**/types/**',
|
|
189
|
+
'src/**/constants/**',
|
|
190
|
+
'**/shared/kernel/**',
|
|
191
|
+
'src/shared/kernel/**',
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
/** Wiring / DI / bootstrap — CompositionRoot, not Domain. */
|
|
195
|
+
export const COMPOSITION_ROOT_PATH_PATTERNS = Object.freeze([
|
|
196
|
+
'src/**/composition/**',
|
|
197
|
+
'src/**/factories/**',
|
|
198
|
+
'src/**/container.ts',
|
|
199
|
+
'src/**/bootstrap.ts',
|
|
200
|
+
'**/composition/**',
|
|
201
|
+
'**/factories/**',
|
|
202
|
+
]);
|
|
203
|
+
|
|
182
204
|
export const APPLICATION_LIB_ORCHESTRATION_PATTERNS = Object.freeze([
|
|
183
205
|
'src/lib/actions/**',
|
|
184
206
|
'src/lib/services/**',
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -41,17 +41,57 @@ export const KNOWN_FIX_CLASSES = [
|
|
|
41
41
|
'break-cycle',
|
|
42
42
|
'review-contract',
|
|
43
43
|
];
|
|
44
|
+
const PURE_SHARED_RE = /(^|\/)(constants|types|enums|shared-types|shared\/(?:types|constants)|test-projects)(\/|\.|$)|(?:^|\/)[^/]*(?:constants|types)(?:\.[cm]?[jt]sx?)?$/i;
|
|
45
|
+
const KERNEL_EMIT_RE = /(^|\/)(?:kernel(?:\/|$)|events?(?:\/|\.|$)|bootstrap(?:\.[cm]?[jt]sx?)?$|emitter(?:\.[cm]?[jt]sx?)?$)|(?:^|\/)(?:intents?|publish)(?:\/|\.|$)/i;
|
|
46
|
+
const USE_CASE_RE = /(use-?cases?|usecases?|application|orchestrat|services?|handlers?)(\/|\.|$)/i;
|
|
47
|
+
export function classifyLayerImportKind(target, extra) {
|
|
48
|
+
const value = String(target ?? '')
|
|
49
|
+
.replace(/\\/g, '/')
|
|
50
|
+
.trim();
|
|
51
|
+
const from = String(extra?.fromLayer ?? '');
|
|
52
|
+
const to = String(extra?.toLayer ?? '');
|
|
53
|
+
if (PURE_SHARED_RE.test(value))
|
|
54
|
+
return 'pure-shared';
|
|
55
|
+
if (from === 'PersistenceAdapters' &&
|
|
56
|
+
(KERNEL_EMIT_RE.test(value) || /events?|intents?|kernel|bootstrap/i.test(`${to} ${value}`))) {
|
|
57
|
+
return 'kernel-emit';
|
|
58
|
+
}
|
|
59
|
+
if (USE_CASE_RE.test(value) ||
|
|
60
|
+
((from === 'DomainModel' || from === 'ApplicationOrchestration') &&
|
|
61
|
+
to === 'PersistenceAdapters')) {
|
|
62
|
+
return 'use-case';
|
|
63
|
+
}
|
|
64
|
+
if (!value)
|
|
65
|
+
return 'unknown';
|
|
66
|
+
return 'unknown';
|
|
67
|
+
}
|
|
68
|
+
export function layerImportNextAction(violation) {
|
|
69
|
+
if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
|
|
70
|
+
return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
|
|
71
|
+
}
|
|
72
|
+
if (violation.peerIsolation) {
|
|
73
|
+
return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
|
|
74
|
+
}
|
|
75
|
+
const kind = classifyLayerImportKind(typeof violation.target === 'string' ? violation.target : '', {
|
|
76
|
+
fromLayer: typeof violation.fromLayer === 'string' ? violation.fromLayer : undefined,
|
|
77
|
+
toLayer: typeof violation.toLayer === 'string' ? violation.toLayer : undefined,
|
|
78
|
+
});
|
|
79
|
+
if (kind === 'pure-shared') {
|
|
80
|
+
return 'Adopt the imported constants/types/pure module into DomainModel or SharedKernel (do not inject a port). Then preflight again.';
|
|
81
|
+
}
|
|
82
|
+
if (kind === 'kernel-emit') {
|
|
83
|
+
return 'Persistence must not emit. Inject a port or move the event map to SharedTypes; do not import kernel/events/bootstrap from a repository. Then preflight again.';
|
|
84
|
+
}
|
|
85
|
+
if (kind === 'use-case' || violation.portProofEligible) {
|
|
86
|
+
return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
|
|
87
|
+
}
|
|
88
|
+
return 'Classify the import: if it is constants/types/pure, adopt into DomainModel or SharedKernel; define a port only if the target is a real use-case. Then preflight again.';
|
|
89
|
+
}
|
|
44
90
|
/** One deterministic re-entry action shared by human and machine denial surfaces. */
|
|
45
91
|
export function deterministicNextAction(violation) {
|
|
46
92
|
switch (violation.ruleId) {
|
|
47
93
|
case 'LAYER_IMPORT_VIOLATION':
|
|
48
|
-
|
|
49
|
-
return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
|
|
50
|
-
}
|
|
51
|
-
if (violation.peerIsolation) {
|
|
52
|
-
return 'Extract the shared dependency to a shared layer, test at the public interface, then preflight again.';
|
|
53
|
-
}
|
|
54
|
-
return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, test at the public interface, then preflight again.`;
|
|
94
|
+
return layerImportNextAction(violation);
|
|
55
95
|
case 'FORBIDDEN_GLOBAL':
|
|
56
96
|
return `Inject ${violation.target ?? 'the capability'} through a port, test at the public interface, then preflight again.`;
|
|
57
97
|
case 'CAPABILITY_VIOLATION':
|
|
@@ -222,9 +262,33 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
222
262
|
'Cross-slice import blocked (peerIsolation). Do not import another feature/context directly — extract shared code to a shared layer, or coordinate via events/ports. Moving code across slices is a judgment call, not a mechanical auto-fix.';
|
|
223
263
|
}
|
|
224
264
|
else {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
265
|
+
const kind = classifyLayerImportKind(typeof violation.target === 'string' ? violation.target : '', {
|
|
266
|
+
fromLayer: typeof violation.fromLayer === 'string' ? violation.fromLayer : undefined,
|
|
267
|
+
toLayer: typeof violation.toLayer === 'string' ? violation.toLayer : undefined,
|
|
268
|
+
});
|
|
269
|
+
if (kind === 'pure-shared') {
|
|
270
|
+
enriched.fixClass = 'file-move';
|
|
271
|
+
enriched.effort = 'small';
|
|
272
|
+
enriched.enthusiastHint =
|
|
273
|
+
'This import is constants/types/pure — adopt it into DomainModel or SharedKernel. Do not inject a port.';
|
|
274
|
+
}
|
|
275
|
+
else if (kind === 'kernel-emit') {
|
|
276
|
+
enriched.fixClass = 'port-inversion';
|
|
277
|
+
enriched.effort = 'medium';
|
|
278
|
+
enriched.enthusiastHint =
|
|
279
|
+
'A repository must not import kernel/events/bootstrap. Persistence does not emit — inject a port or move the map to SharedTypes.';
|
|
280
|
+
}
|
|
281
|
+
else if (kind === 'use-case' || violation.portProofEligible) {
|
|
282
|
+
enriched.fixClass = 'port-inversion';
|
|
283
|
+
enriched.effort = 'medium';
|
|
284
|
+
enriched.enthusiastHint = `${violation.fromLayer ?? 'This layer'} must not import ${violation.toLayer ?? 'that layer'} directly. Define an interface (port) where you need the capability and inject the implementation from the outer layer.`;
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
enriched.fixClass = 'review-contract';
|
|
288
|
+
enriched.effort = 'medium';
|
|
289
|
+
enriched.enthusiastHint =
|
|
290
|
+
'Do not assume a port. If the target is constants/types/pure, adopt it into Domain or SharedKernel; define a port only for a real use-case.';
|
|
291
|
+
}
|
|
228
292
|
}
|
|
229
293
|
break;
|
|
230
294
|
case 'FORBIDDEN_GLOBAL':
|
|
@@ -688,7 +688,9 @@ export function assessCodexSkillParity(root) {
|
|
|
688
688
|
|
|
689
689
|
const repoNeedsAttention =
|
|
690
690
|
repoInPlay && (repo.missing > 0 || repo.stale > 0 || repo.legacyPromptsOnly);
|
|
691
|
+
const repoCatalogComplete = repoInPlay && repo.missing === 0 && !repo.legacyPromptsOnly;
|
|
691
692
|
const homeNeedsAttention =
|
|
693
|
+
!repoCatalogComplete &&
|
|
692
694
|
newerHomeCatalog === null &&
|
|
693
695
|
homeInPlay &&
|
|
694
696
|
(home.missing > 0 ||
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
/** Fail-closed completeness evidence for one proposed source snippet. */
|
|
2
2
|
import { ANALYSIS_COMPLETENESS } from './analysis-completeness.mjs';
|
|
3
3
|
|
|
4
|
+
export function flattenTsParseDiagnostics(ts, diagnostics, sourceFile) {
|
|
5
|
+
if (!Array.isArray(diagnostics) || !ts) return [];
|
|
6
|
+
return diagnostics.map((diagnostic) => {
|
|
7
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
|
8
|
+
let line = 1;
|
|
9
|
+
let column = 1;
|
|
10
|
+
if (typeof diagnostic.start === 'number' && sourceFile?.getLineAndCharacterOfPosition) {
|
|
11
|
+
const pos = sourceFile.getLineAndCharacterOfPosition(diagnostic.start);
|
|
12
|
+
line = (pos.line ?? 0) + 1;
|
|
13
|
+
column = (pos.character ?? 0) + 1;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
line,
|
|
17
|
+
column,
|
|
18
|
+
message: String(message || '').trim() || 'parse error',
|
|
19
|
+
code: diagnostic.code,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
function finding(ruleId, message, file, nextAction) {
|
|
5
25
|
return {
|
|
6
26
|
ruleId,
|
|
@@ -54,6 +74,11 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
54
74
|
if (!Array.isArray(parsed.parseDiagnostics)) throw new Error('parse diagnostics unavailable');
|
|
55
75
|
const diagnosticCount = parsed.parseDiagnostics.length;
|
|
56
76
|
if (diagnosticCount > 0) {
|
|
77
|
+
const tsDiagnostics = flattenTsParseDiagnostics(ts, parsed.parseDiagnostics, parsed);
|
|
78
|
+
const first = tsDiagnostics[0];
|
|
79
|
+
const detail = first
|
|
80
|
+
? `line ${first.line}: ${first.message}`
|
|
81
|
+
: `${diagnosticCount} parse diagnostic(s)`;
|
|
57
82
|
return {
|
|
58
83
|
mode: 'lexical-compatibility',
|
|
59
84
|
valid: false,
|
|
@@ -62,18 +87,25 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
62
87
|
completenessReasons: [
|
|
63
88
|
{
|
|
64
89
|
code: 'ANALYSIS_PARSE_INCOMPLETE',
|
|
65
|
-
message: `The proposed source has ${diagnosticCount} parse diagnostic(s)
|
|
90
|
+
message: `The proposed source has ${diagnosticCount} parse diagnostic(s). ${detail}`,
|
|
66
91
|
...(file ? { file } : {}),
|
|
92
|
+
line: first?.line,
|
|
93
|
+
tsDiagnostics,
|
|
67
94
|
},
|
|
68
95
|
],
|
|
69
96
|
violations: [
|
|
70
97
|
...base.violations,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
98
|
+
{
|
|
99
|
+
...finding(
|
|
100
|
+
'ANALYSIS_PARSE_INCOMPLETE',
|
|
101
|
+
`Analysis partial: ${detail}`,
|
|
102
|
+
file,
|
|
103
|
+
'Incremental mid-edit parse errors are normal. Finish the source, then re-run `npx arkgate-check` (or the write hook). Do not call ark_prepare_change from a hook deny.'
|
|
104
|
+
),
|
|
105
|
+
line: first?.line ?? 1,
|
|
106
|
+
column: first?.column ?? 1,
|
|
107
|
+
evidence: { tsDiagnostics, diagnosticCount },
|
|
108
|
+
},
|
|
77
109
|
],
|
|
78
110
|
};
|
|
79
111
|
}
|
|
@@ -87,7 +119,7 @@ export function validateSnippetAnalysis({ gate, ts, source, context = {} }) {
|
|
|
87
119
|
{
|
|
88
120
|
code: 'LEXICAL_EVIDENCE_INCOMPLETE',
|
|
89
121
|
message:
|
|
90
|
-
'Single-file validation cannot prove project module resolution or
|
|
122
|
+
'Single-file validation cannot prove project module resolution. The write hook is already the verdict, or re-run `npx arkgate-check --root . --config ark.config.json`. Do not call ark_prepare_change from a hook deny.',
|
|
91
123
|
...(file ? { file } : {}),
|
|
92
124
|
},
|
|
93
125
|
],
|
|
@@ -24,6 +24,13 @@ type ArkConfigLayer = {
|
|
|
24
24
|
pure?: boolean;
|
|
25
25
|
mayImportInfrastructure?: boolean;
|
|
26
26
|
optional?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Future house: empty globs are expected. `--strict-config` must not fail.
|
|
29
|
+
* Typo warning (`CONFIG_LAYER_PATTERN_NO_MATCHES`) is skipped.
|
|
30
|
+
*/
|
|
31
|
+
reserved?: boolean;
|
|
32
|
+
/** Alias of reserved — empty pattern matches are allowed. */
|
|
33
|
+
allowEmpty?: boolean;
|
|
27
34
|
};
|
|
28
35
|
type ArkConfigRule = {
|
|
29
36
|
from: string;
|