arkgate 3.9.1 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +111 -0
- package/README.md +16 -4
- package/bin/ark-check-runtime.mjs +75 -3
- package/bin/ark-mcp-runtime.mjs +94 -0
- package/bin/lib/adapter-contract.mjs +14 -1
- package/bin/lib/ambient-state.mjs +64 -8
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +35 -2
- package/bin/lib/arkrule-file-hints.mjs +71 -0
- package/bin/lib/arkrules-contract.mjs +382 -0
- package/bin/lib/arkrules-sensors.mjs +411 -0
- package/bin/lib/config-contract.mjs +85 -6
- package/bin/lib/doctor-advisories.mjs +22 -5
- package/bin/lib/doctor-plan.mjs +68 -13
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/enforcement-honesty.mjs +225 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +254 -0
- package/bin/lib/html-report-advisories.mjs +29 -3
- package/bin/lib/install-migrate.mjs +20 -2
- package/bin/lib/invariant-coverage-io.mjs +157 -0
- package/bin/lib/invariant-coverage.mjs +127 -0
- package/bin/lib/pilot-loop.mjs +19 -0
- package/bin/lib/policy-delta-io.mjs +33 -0
- package/bin/lib/post-green-path.mjs +22 -1
- package/bin/lib/presets.mjs +241 -1
- package/bin/lib/remediation.mjs +28 -0
- package/bin/lib/resolved-candidate-facts.mjs +14 -1
- package/bin/lib/rules-inventory.mjs +144 -0
- package/bin/lib/rules-under-contract.mjs +66 -0
- package/bin/lib/start-preview.mjs +24 -7
- package/bin/lib/upgrade-command.mjs +48 -2
- package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +1 -1
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +14 -7
- package/dist/index.d.ts +615 -20
- package/dist/index.js +13 -6
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +7 -3
- package/docs/ai-gates.md +6 -1
- package/docs/brownfield-adoption.md +20 -0
- package/docs/configuration.md +37 -4
- package/docs/develop.md +8 -2
- package/docs/enthusiast/README.md +11 -0
- package/docs/package-surface.md +13 -11
- package/docs/product-voice.md +9 -2
- package/docs/use.md +9 -0
- package/package.json +4 -17
- package/schemas/ark.analysis-result.schema.json +9 -1
- package/schemas/ark.arkrules.schema.json +141 -0
- package/schemas/ark.config.schema.json +10 -2
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/arkrules/ApplicationOrchestration.json +14 -0
- package/templates/arkrules/DomainModel.json +32 -0
- package/templates/arkrules/PersistenceAdapters.json +14 -0
- package/templates/arkrules/PresentationAdapters.json +14 -0
- package/templates/skills/ark-adopt.md +28 -1
- package/templates/skills/ark-architect.md +23 -0
- package/templates/skills/ark-autopilot.md +27 -1
- package/templates/skills/ark-contract.md +27 -1
- package/templates/skills/ark-coverage.md +30 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +30 -2
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +30 -0
- package/templates/skills/ark-runtime.md +4 -0
- package/templates/skills/ark-think.md +24 -1
- package/templates/skills/ark-upgrade.md +23 -0
- package/compat/nestjs.cjs +0 -2
- package/compat/nestjs.d.ts +0 -2
- package/compat/nestjs.js +0 -1
- package/compat/runtime.cjs +0 -2
- package/compat/runtime.d.ts +0 -2
- package/compat/runtime.js +0 -1
package/bin/lib/pilot-loop.mjs
CHANGED
|
@@ -166,6 +166,13 @@ export function formatExtractionCard(card) {
|
|
|
166
166
|
*/
|
|
167
167
|
export function summarizePilotLoop(opts = {}) {
|
|
168
168
|
const designWeak = opts.designWeak === true;
|
|
169
|
+
// Same forbid bits as DESIGN_WEAK_HONESTY_FLAGS (both auto-apply aliases).
|
|
170
|
+
const forbid = {
|
|
171
|
+
multiPilotBatchForbidden: true,
|
|
172
|
+
autoApplyForbidden: true,
|
|
173
|
+
autoApplyPlanBForbidden: true,
|
|
174
|
+
};
|
|
175
|
+
|
|
169
176
|
if (!designWeak) {
|
|
170
177
|
return {
|
|
171
178
|
active: false,
|
|
@@ -173,6 +180,7 @@ export function summarizePilotLoop(opts = {}) {
|
|
|
173
180
|
reason: 'not-design-weak',
|
|
174
181
|
oneAtATime: true,
|
|
175
182
|
neverMechanicalSafe: true,
|
|
183
|
+
...forbid,
|
|
176
184
|
};
|
|
177
185
|
}
|
|
178
186
|
|
|
@@ -186,21 +194,32 @@ export function summarizePilotLoop(opts = {}) {
|
|
|
186
194
|
reason: 'no-pattern-bets',
|
|
187
195
|
oneAtATime: true,
|
|
188
196
|
neverMechanicalSafe: true,
|
|
197
|
+
...forbid,
|
|
189
198
|
};
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
const remaining = Array.isArray(opts.patternBets) ? opts.patternBets.length : 0;
|
|
202
|
+
const queued = Math.max(0, remaining - 1);
|
|
193
203
|
|
|
194
204
|
return {
|
|
195
205
|
active: true,
|
|
196
206
|
id: PILOT_LOOP_ID,
|
|
197
207
|
oneAtATime: true,
|
|
198
208
|
neverMechanicalSafe: true,
|
|
209
|
+
...forbid,
|
|
199
210
|
remainingBets: remaining,
|
|
211
|
+
// Remaining pattern bets are a queue, never concurrent pilots.
|
|
212
|
+
queuedBets: queued,
|
|
213
|
+
...(queued > 0
|
|
214
|
+
? {
|
|
215
|
+
queueNote: `${queued} additional pattern bet(s) stay queued — run the single nextPilot only, then re-doctor before selecting another.`,
|
|
216
|
+
}
|
|
217
|
+
: {}),
|
|
200
218
|
nextPilot,
|
|
201
219
|
instruction:
|
|
202
220
|
'Apply ONE pilot from nextPilot (extraction card), then re-doctor. ' +
|
|
203
221
|
'Do not multi-pilot batch. patternBets never mechanical-safe. ' +
|
|
222
|
+
'Never silent auto-apply of plan B. ' +
|
|
204
223
|
'Success = reduced smell evidence on pilot paths; residual outside pilot may remain.',
|
|
205
224
|
cardText: formatExtractionCard(nextPilot),
|
|
206
225
|
};
|
|
@@ -2,6 +2,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { analyzePolicyDelta } from './analysis-engine.mjs';
|
|
5
|
+
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
6
|
+
import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
|
|
7
|
+
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
5
8
|
|
|
6
9
|
function readJsonFile(filePath, label) {
|
|
7
10
|
if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
|
|
@@ -151,11 +154,41 @@ export function analyzePolicyTransition({
|
|
|
151
154
|
throw new Error('Policy delta was requested but no policy base could be resolved.');
|
|
152
155
|
}
|
|
153
156
|
if (!base) return undefined;
|
|
157
|
+
|
|
158
|
+
// AR02/AR11: load Effective ArkRules so mode edits inside arkrules/*.json classify,
|
|
159
|
+
// and attach candidate coverage so covered advisory→enforced can auto-allow.
|
|
160
|
+
const baseLoad = loadEffectiveArkRulesFromDisk(root, base.config);
|
|
161
|
+
const candidateLoad = loadEffectiveArkRulesFromDisk(root, candidateConfig);
|
|
162
|
+
if (candidateLoad.errors.length > 0) {
|
|
163
|
+
const message = candidateLoad.errors
|
|
164
|
+
.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
165
|
+
.join('\n');
|
|
166
|
+
throw new Error(`Invalid candidate Effective Contract:\n${message}`);
|
|
167
|
+
}
|
|
168
|
+
// Base load errors: best-effort empty (base git tree may lack arkrules files on disk).
|
|
169
|
+
const baseArkRules = baseLoad.errors.length > 0 ? undefined : baseLoad.arkRules;
|
|
170
|
+
const candidateArkRules = candidateLoad.arkRules;
|
|
171
|
+
|
|
172
|
+
let candidateInvariantCoverage;
|
|
173
|
+
if ((candidateArkRules?.invariants?.length ?? 0) > 0) {
|
|
174
|
+
const coverageInputs = loadInvariantCoverageInputs(root, { files: [] });
|
|
175
|
+
const evaluated = evaluateInvariantCoverage({
|
|
176
|
+
arkRules: candidateArkRules,
|
|
177
|
+
fileContents: coverageInputs.fileContents,
|
|
178
|
+
testFiles: coverageInputs.testFiles,
|
|
179
|
+
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
180
|
+
});
|
|
181
|
+
candidateInvariantCoverage = evaluated.coverage;
|
|
182
|
+
}
|
|
183
|
+
|
|
154
184
|
return analyzePolicyDelta({
|
|
155
185
|
baseConfig: base.config,
|
|
156
186
|
candidateConfig,
|
|
157
187
|
acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
|
|
158
188
|
baseSource: base.source,
|
|
159
189
|
candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
|
|
190
|
+
...(baseArkRules ? { baseArkRules } : {}),
|
|
191
|
+
candidateArkRules,
|
|
192
|
+
...(candidateInvariantCoverage ? { candidateInvariantCoverage } : {}),
|
|
160
193
|
});
|
|
161
194
|
}
|
|
@@ -23,6 +23,22 @@ export const POST_GREEN_PRIMARY_ACTION =
|
|
|
23
23
|
export const POST_GREEN_PRIMARY_SHORT =
|
|
24
24
|
'/ark-explore shape-focus → /ark-autopilot (apply B with OK) # Shape residual';
|
|
25
25
|
|
|
26
|
+
/** Placement coaching while design residual remains (new code only). */
|
|
27
|
+
export const POST_GREEN_PLACEMENT_COACHING =
|
|
28
|
+
'New code: /ark-place (contract + optional golden). Design residual: map smells → ONE pilot via pilotLoop.nextPilot → re-doctor. Never multi-pilot batch; never silent auto-apply of plan B.';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Shared design-weak honesty flags for plan/doctor/pilot JSON.
|
|
32
|
+
* Canonical auto-apply forbid is `autoApplyForbidden`; `autoApplyPlanBForbidden`
|
|
33
|
+
* is the same bit (alias for plan-B wording) so agents can key either path.
|
|
34
|
+
*/
|
|
35
|
+
export const DESIGN_WEAK_HONESTY_FLAGS = Object.freeze({
|
|
36
|
+
healthyFinishedForbidden: true,
|
|
37
|
+
multiPilotBatchForbidden: true,
|
|
38
|
+
autoApplyForbidden: true,
|
|
39
|
+
autoApplyPlanBForbidden: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* @param {{ designWeak?: boolean } | null | undefined} designFitness
|
|
28
44
|
* @returns {null | {
|
|
@@ -33,7 +49,11 @@ export const POST_GREEN_PRIMARY_SHORT =
|
|
|
33
49
|
* flow: string,
|
|
34
50
|
* action: string,
|
|
35
51
|
* short: string,
|
|
52
|
+
* placementCoaching: string,
|
|
36
53
|
* neverMechanicalSafe: true,
|
|
54
|
+
* multiPilotBatchForbidden: true,
|
|
55
|
+
* autoApplyForbidden: true,
|
|
56
|
+
* autoApplyPlanBForbidden: true,
|
|
37
57
|
* healthyFinishedForbidden: true,
|
|
38
58
|
* }}
|
|
39
59
|
*/
|
|
@@ -47,8 +67,9 @@ export function buildPostGreenNextAction(designFitness) {
|
|
|
47
67
|
flow: 'shape-focus',
|
|
48
68
|
action: POST_GREEN_PRIMARY_ACTION,
|
|
49
69
|
short: POST_GREEN_PRIMARY_SHORT,
|
|
70
|
+
placementCoaching: POST_GREEN_PLACEMENT_COACHING,
|
|
50
71
|
neverMechanicalSafe: true,
|
|
51
|
-
|
|
72
|
+
...DESIGN_WEAK_HONESTY_FLAGS,
|
|
52
73
|
};
|
|
53
74
|
}
|
|
54
75
|
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Architecture starter presets for ark-check init/coverage suggestions.
|
|
3
3
|
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
4
7
|
import {
|
|
5
8
|
applyFrameworkLayoutOverlays,
|
|
6
9
|
createElevenLayerConfig,
|
|
@@ -11,6 +14,9 @@ import {
|
|
|
11
14
|
} from '../ark-shared.mjs';
|
|
12
15
|
import { withArkConfigMetadata } from './config-contract.mjs';
|
|
13
16
|
|
|
17
|
+
const PRESETS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ARKRULES_TEMPLATES_DIR = path.join(PRESETS_DIR, '../../templates/arkrules');
|
|
19
|
+
|
|
14
20
|
export function denyUpward(names) {
|
|
15
21
|
const rules = [];
|
|
16
22
|
for (let i = 0; i < names.length; i += 1) {
|
|
@@ -50,9 +56,243 @@ export function peerIsolationEdges(layerNames, sliceFolders, message) {
|
|
|
50
56
|
// `src/**/domain/**` would otherwise swallow `src/kernel/domain`. Do NOT use `**/kernel/**`
|
|
51
57
|
// (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
|
|
52
58
|
export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
|
|
59
|
+
/**
|
|
60
|
+
* AR08 — attach lean arkRules map. Keys are always exact project layer names.
|
|
61
|
+
* Sensor roles (domain-structure / orchestration / adapter-thin / generic) are
|
|
62
|
+
* independent of display names so renamed layers still get the right starter.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_ARKRULES_REFS = {
|
|
65
|
+
DomainModel: 'arkrules/DomainModel.json',
|
|
66
|
+
ApplicationOrchestration: 'arkrules/ApplicationOrchestration.json',
|
|
67
|
+
PresentationAdapters: 'arkrules/PresentationAdapters.json',
|
|
68
|
+
PersistenceAdapters: 'arkrules/PersistenceAdapters.json',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Sensor roles used when selecting or synthesizing per-layer templates. */
|
|
72
|
+
export const ARKRULES_SENSOR_ROLES = Object.freeze({
|
|
73
|
+
DOMAIN_STRUCTURE: 'domain-structure',
|
|
74
|
+
ORCHESTRATION: 'orchestration',
|
|
75
|
+
ADAPTER_THIN: 'adapter-thin',
|
|
76
|
+
GENERIC: 'generic',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Exact-name aliases → sensor role. Prefer this table over heuristics when the
|
|
81
|
+
* project uses a known vocabulary (hexagonal, monorepo field renames, etc.).
|
|
82
|
+
*/
|
|
83
|
+
export const LAYER_SENSOR_ROLE_ALIASES = Object.freeze({
|
|
84
|
+
// Domain / pure model
|
|
85
|
+
DomainModel: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
|
|
86
|
+
Domain: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
|
|
87
|
+
Entities: ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE,
|
|
88
|
+
// Application / use cases
|
|
89
|
+
ApplicationOrchestration: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
|
|
90
|
+
Application: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
|
|
91
|
+
UseCases: ARKRULES_SENSOR_ROLES.ORCHESTRATION,
|
|
92
|
+
// Presentation / UI adapters
|
|
93
|
+
PresentationAdapters: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
94
|
+
Presentation: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
95
|
+
UI: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
96
|
+
WebPresentation: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
97
|
+
ApiComposition: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
98
|
+
// Persistence / infrastructure adapters
|
|
99
|
+
PersistenceAdapters: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
100
|
+
Infrastructure: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
101
|
+
Persistence: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
102
|
+
Data: ARKRULES_SENSOR_ROLES.ADAPTER_THIN,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a stable sensor role for a project layer name.
|
|
107
|
+
* Exact alias first, then specific adapter/domain cues, then broad application
|
|
108
|
+
* heuristics (so ApplicationAdapters → thin-adapter, not orchestration).
|
|
109
|
+
*/
|
|
110
|
+
export function resolveLayerSensorRole(layerName) {
|
|
111
|
+
if (typeof layerName !== 'string' || layerName.length === 0) {
|
|
112
|
+
return ARKRULES_SENSOR_ROLES.GENERIC;
|
|
113
|
+
}
|
|
114
|
+
if (LAYER_SENSOR_ROLE_ALIASES[layerName]) return LAYER_SENSOR_ROLE_ALIASES[layerName];
|
|
115
|
+
const n = layerName.toLowerCase();
|
|
116
|
+
if (/(^|[^a-z])(domain|entit)/.test(n) || n.includes('domainmodel')) {
|
|
117
|
+
return ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE;
|
|
118
|
+
}
|
|
119
|
+
// Adapter / I/O cues before broad "application" — ApplicationAdapters must not
|
|
120
|
+
// inherit orchestration-only sensors.
|
|
121
|
+
if (
|
|
122
|
+
n.includes('present') ||
|
|
123
|
+
n.includes('persist') ||
|
|
124
|
+
n.includes('infra') ||
|
|
125
|
+
n.includes('adapter') ||
|
|
126
|
+
n.includes('repository') ||
|
|
127
|
+
/(^|[^a-z])(ui|web|data)([^a-z]|$)/.test(n)
|
|
128
|
+
) {
|
|
129
|
+
return ARKRULES_SENSOR_ROLES.ADAPTER_THIN;
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
n.includes('application') ||
|
|
133
|
+
n.includes('orchestr') ||
|
|
134
|
+
n.includes('usecase') ||
|
|
135
|
+
n.includes('use-case') ||
|
|
136
|
+
n.includes('use_case')
|
|
137
|
+
) {
|
|
138
|
+
return ARKRULES_SENSOR_ROLES.ORCHESTRATION;
|
|
139
|
+
}
|
|
140
|
+
return ARKRULES_SENSOR_ROLES.GENERIC;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Pick the shipped archetype filename for a layer (role + presentation vs persistence cue).
|
|
145
|
+
* Returns null for generic molds.
|
|
146
|
+
*/
|
|
147
|
+
export function archetypeTemplateFileForLayer(layerName, role = resolveLayerSensorRole(layerName)) {
|
|
148
|
+
if (role === ARKRULES_SENSOR_ROLES.DOMAIN_STRUCTURE) return 'DomainModel.json';
|
|
149
|
+
if (role === ARKRULES_SENSOR_ROLES.ORCHESTRATION) return 'ApplicationOrchestration.json';
|
|
150
|
+
if (role === ARKRULES_SENSOR_ROLES.ADAPTER_THIN) {
|
|
151
|
+
const n = String(layerName).toLowerCase();
|
|
152
|
+
if (
|
|
153
|
+
n.includes('present') ||
|
|
154
|
+
n.includes('ui') ||
|
|
155
|
+
n.includes('web') ||
|
|
156
|
+
n.includes('page') ||
|
|
157
|
+
n.includes('widget') ||
|
|
158
|
+
n.includes('api')
|
|
159
|
+
) {
|
|
160
|
+
return 'PresentationAdapters.json';
|
|
161
|
+
}
|
|
162
|
+
return 'PersistenceAdapters.json';
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Layer names used as single path segments under arkrules/ must not escape the dir
|
|
169
|
+
* (reject `/`, `\`, `..`, absolute, empty). Mirrors managed-upgrade isSafeRelativePath.
|
|
170
|
+
*/
|
|
171
|
+
export function isSafeArkRulesLayerName(layerName) {
|
|
172
|
+
if (typeof layerName !== 'string' || layerName.length === 0) return false;
|
|
173
|
+
if (layerName.includes('\0') || layerName.includes('/') || layerName.includes('\\')) return false;
|
|
174
|
+
if (layerName === '.' || layerName === '..' || layerName.includes('..')) return false;
|
|
175
|
+
// Single path segment: letters/digits/underscore/hyphen; optional leading letter preferred
|
|
176
|
+
// but allow common PascalCase layer vocabularies.
|
|
177
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(layerName)) return false;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Relative path for a layer's arkrules file (always arkrules/<exactLayerName>.json). */
|
|
182
|
+
export function arkRulesPathForLayer(layerName) {
|
|
183
|
+
if (!isSafeArkRulesLayerName(layerName)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`unsafe arkRules layer name ${JSON.stringify(layerName)}: must be a single path segment (A-Za-z0-9_-) under arkrules/`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return `arkrules/${layerName}.json`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** True when rel is a safe project-relative arkrules/*.json path (no escape). */
|
|
192
|
+
export function isSafeArkRulesRelativePath(rel) {
|
|
193
|
+
if (typeof rel !== 'string' || rel.length === 0) return false;
|
|
194
|
+
if (rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel)) return false;
|
|
195
|
+
const normalized = path.posix.normalize(rel);
|
|
196
|
+
if (normalized !== rel || normalized === '.' || normalized.startsWith('../')) return false;
|
|
197
|
+
if (!normalized.startsWith('arkrules/') || !normalized.endsWith('.json')) return false;
|
|
198
|
+
// Exactly one segment under arkrules/ (no nested dirs).
|
|
199
|
+
const rest = normalized.slice('arkrules/'.length);
|
|
200
|
+
if (!rest || rest.includes('/') || rest === '..' || rest.includes('..')) return false;
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build starter ArkRules JSON for a project layer: archetype clone with rewritten
|
|
206
|
+
* `layer` field, or an empty generic mold the agent can refine.
|
|
207
|
+
*/
|
|
208
|
+
export function buildArkRulesTemplateForLayer(layerName, role = resolveLayerSensorRole(layerName)) {
|
|
209
|
+
const archetype = archetypeTemplateFileForLayer(layerName, role);
|
|
210
|
+
if (archetype) {
|
|
211
|
+
const sourcePath = path.join(ARKRULES_TEMPLATES_DIR, archetype);
|
|
212
|
+
if (fs.existsSync(sourcePath)) {
|
|
213
|
+
try {
|
|
214
|
+
const parsed = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
|
|
215
|
+
return {
|
|
216
|
+
...parsed,
|
|
217
|
+
layer: layerName,
|
|
218
|
+
};
|
|
219
|
+
} catch {
|
|
220
|
+
// Fall through to generic mold.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Generic mold: valid empty contract keyed to the exact project layer name.
|
|
225
|
+
// Structure is empty so agents refine without inheriting the wrong sensors.
|
|
226
|
+
return {
|
|
227
|
+
$schema: 'https://unpkg.com/arkgate/schemas/ark.arkrules.schema.json',
|
|
228
|
+
schemaVersion: '1.0',
|
|
229
|
+
layer: layerName,
|
|
230
|
+
structure: [],
|
|
231
|
+
invariants: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Attach arkRules map for every declared layer. Keys = exact layer names.
|
|
237
|
+
* Existing map entries are preserved (never overwritten).
|
|
238
|
+
*/
|
|
239
|
+
export function withDefaultArkRules(config) {
|
|
240
|
+
const layers = config.layers ?? [];
|
|
241
|
+
if (layers.length === 0) return config;
|
|
242
|
+
const arkRules = { ...(config.arkRules ?? {}) };
|
|
243
|
+
let changed = false;
|
|
244
|
+
for (const layer of layers) {
|
|
245
|
+
const name = layer?.name;
|
|
246
|
+
if (typeof name !== 'string' || name.length === 0) continue;
|
|
247
|
+
if (arkRules[name]) continue;
|
|
248
|
+
if (!isSafeArkRulesLayerName(name)) continue;
|
|
249
|
+
arkRules[name] = arkRulesPathForLayer(name);
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
if (!changed) return config;
|
|
253
|
+
return { ...config, arkRules };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Write starter arkrules/*.json for each arkRules map entry (AR08).
|
|
258
|
+
* Uses sensor-role mapping so renamed layers get the right archetype;
|
|
259
|
+
* unknown layers get a generic empty mold. Skips existing files unless force=true.
|
|
260
|
+
* Rejects paths that escape arkrules/ (mirror managed-upgrade path safety).
|
|
261
|
+
*/
|
|
262
|
+
export function writeArkRulesTemplates(root, config, { force = false } = {}) {
|
|
263
|
+
const refs = config?.arkRules;
|
|
264
|
+
if (!refs || typeof refs !== 'object') return [];
|
|
265
|
+
const written = [];
|
|
266
|
+
const resolvedRoot = path.resolve(root);
|
|
267
|
+
for (const [layerName, rel] of Object.entries(refs)) {
|
|
268
|
+
if (typeof rel !== 'string' || !rel.endsWith('.json')) continue;
|
|
269
|
+
if (!isSafeArkRulesRelativePath(rel)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`refusing arkRules write for unsafe path ${JSON.stringify(rel)} (layer ${JSON.stringify(layerName)})`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const target = path.resolve(resolvedRoot, rel);
|
|
275
|
+
const relToRoot = path.relative(resolvedRoot, target);
|
|
276
|
+
if (!relToRoot || relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) {
|
|
277
|
+
throw new Error(`arkRules path escapes project root: ${rel}`);
|
|
278
|
+
}
|
|
279
|
+
const underArkRules = path.relative(path.join(resolvedRoot, 'arkrules'), target);
|
|
280
|
+
if (!underArkRules || underArkRules.startsWith('..') || path.isAbsolute(underArkRules)) {
|
|
281
|
+
throw new Error(`arkRules path escapes arkrules/: ${rel}`);
|
|
282
|
+
}
|
|
283
|
+
if (fs.existsSync(target) && !force) continue;
|
|
284
|
+
const role = resolveLayerSensorRole(layerName);
|
|
285
|
+
const body = buildArkRulesTemplateForLayer(layerName, role);
|
|
286
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
287
|
+
fs.writeFileSync(target, `${JSON.stringify(body, null, 2)}\n`);
|
|
288
|
+
written.push(rel);
|
|
289
|
+
}
|
|
290
|
+
return written;
|
|
291
|
+
}
|
|
292
|
+
|
|
53
293
|
export function presetWithOverlays(baseConfig, root) {
|
|
54
294
|
const config = root ? applyFrameworkLayoutOverlays(baseConfig, root) : baseConfig;
|
|
55
|
-
return withArkConfigMetadata(config);
|
|
295
|
+
return withArkConfigMetadata(withDefaultArkRules(config));
|
|
56
296
|
}
|
|
57
297
|
|
|
58
298
|
export const ARCHITECTURE_PRESETS = {
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -62,7 +62,18 @@ export function deterministicNextAction(violation) {
|
|
|
62
62
|
return 'Publish through a registered intent creator, then run Ark again.';
|
|
63
63
|
case 'PUBLISH_MISSING_SOURCE':
|
|
64
64
|
return 'Add metadata.source to the publish call, then run Ark again.';
|
|
65
|
+
case 'ARKRULE_STRUCTURE':
|
|
66
|
+
case 'ARKRULE_INVARIANT':
|
|
67
|
+
case 'INVARIANT_UNCOVERED':
|
|
68
|
+
return `Fix the structure or invariant for ${typeof violation.arkruleId === 'string' && violation.arkruleId.length > 0
|
|
69
|
+
? violation.arkruleId
|
|
70
|
+
: 'the ArkRule'} (declared in ${typeof violation.arkruleSource === 'string' && violation.arkruleSource.length > 0
|
|
71
|
+
? violation.arkruleSource
|
|
72
|
+
: 'arkrules/<Layer>.json'}), then preflight again. Do not demote the rule without a hash-bound policy acknowledgement.`;
|
|
65
73
|
default:
|
|
74
|
+
if (typeof violation.ruleId === 'string' && violation.ruleId.startsWith('ARKRULE_')) {
|
|
75
|
+
return `Fix the ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : violation.ruleId}, then preflight again.`;
|
|
76
|
+
}
|
|
66
77
|
return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
|
|
67
78
|
}
|
|
68
79
|
}
|
|
@@ -164,6 +175,16 @@ export function classifyRemediation(violation) {
|
|
|
164
175
|
rationale: 'Dependency cycle: breaking it means deciding which side owns the shared abstraction.',
|
|
165
176
|
};
|
|
166
177
|
}
|
|
178
|
+
if (ruleId === 'ARKRULE_STRUCTURE' ||
|
|
179
|
+
ruleId === 'ARKRULE_INVARIANT' ||
|
|
180
|
+
ruleId === 'INVARIANT_UNCOVERED' ||
|
|
181
|
+
(typeof ruleId === 'string' && ruleId.startsWith('ARKRULE_'))) {
|
|
182
|
+
return {
|
|
183
|
+
class: 'judgment',
|
|
184
|
+
confidence: 0.85,
|
|
185
|
+
rationale: 'ArkRule structure/invariant findings are never mechanical-safe — restore private state, factory shape, event publish, coverage, or redesign the aggregate with judgment.',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
167
188
|
if (typeof ruleId === 'string' && ruleId.length > 0) {
|
|
168
189
|
return {
|
|
169
190
|
class: 'judgment',
|
|
@@ -239,6 +260,13 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
239
260
|
enriched.enthusiastHint =
|
|
240
261
|
'Reference that intent from a layer allowed to know about it — usually an adapter or application layer, not the domain core.';
|
|
241
262
|
break;
|
|
263
|
+
case 'ARKRULE_STRUCTURE':
|
|
264
|
+
case 'ARKRULE_INVARIANT':
|
|
265
|
+
case 'INVARIANT_UNCOVERED':
|
|
266
|
+
enriched.fixClass = 'review-contract';
|
|
267
|
+
enriched.effort = 'medium';
|
|
268
|
+
enriched.enthusiastHint = `ArkRule ${typeof violation.arkruleId === 'string' ? violation.arkruleId : 'rule'} failed${typeof violation.arkruleSource === 'string' ? ` (from ${violation.arkruleSource})` : ''}. Restore the declared structure or add a covering test/symbol — do not demote without acknowledgement.`;
|
|
269
|
+
break;
|
|
242
270
|
case 'CIRCULAR_DEPENDENCY':
|
|
243
271
|
enriched.fixClass = 'break-cycle';
|
|
244
272
|
enriched.effort = 'medium';
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
typeOnlyExportNames,
|
|
35
35
|
} from './ast-scan.mjs';
|
|
36
36
|
import { provePortProofInject } from './port-proof.mjs';
|
|
37
|
+
import { extractClassShapesFromSource } from './arkrules-sensors.mjs';
|
|
37
38
|
import {
|
|
38
39
|
collectGovernedFiles,
|
|
39
40
|
isGovernableSourceFile,
|
|
@@ -995,6 +996,8 @@ export function resolveCandidateFacts({
|
|
|
995
996
|
const publishCalls = [];
|
|
996
997
|
const intentReferences = [];
|
|
997
998
|
const safetyUses = [];
|
|
999
|
+
/** ADR 0013 class-shape facts for ArkRules structure sensors. */
|
|
1000
|
+
const classShapes = [];
|
|
998
1001
|
|
|
999
1002
|
for (const candidate of candidateFiles) {
|
|
1000
1003
|
const sourceFile = ts.createSourceFile(
|
|
@@ -1076,6 +1079,15 @@ export function resolveCandidateFacts({
|
|
|
1076
1079
|
safetyUses.push(
|
|
1077
1080
|
...collectSafetyUses(ts, sourceFile, candidate.path, candidate.content, semanticDependencies)
|
|
1078
1081
|
);
|
|
1082
|
+
// Class-shape extraction is text-conservative (false negatives over false positives).
|
|
1083
|
+
// Only TS/TSX candidates; sensors consume the same shape via facts.classShapes.
|
|
1084
|
+
if (/\.(tsx?|mts|cts)$/i.test(candidate.path)) {
|
|
1085
|
+
try {
|
|
1086
|
+
classShapes.push(...extractClassShapesFromSource(candidate.path, candidate.content));
|
|
1087
|
+
} catch {
|
|
1088
|
+
// Never fail the resolver for shape extraction; sensors stay silent on this file.
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1079
1091
|
}
|
|
1080
1092
|
|
|
1081
1093
|
const dependencies = [];
|
|
@@ -1140,7 +1152,7 @@ export function resolveCandidateFacts({
|
|
|
1140
1152
|
|
|
1141
1153
|
const projectPackageName = readPackageName(canonicalRoot, observeInput);
|
|
1142
1154
|
return createTrustedResolvedCandidateFacts({
|
|
1143
|
-
schemaVersion: '1.
|
|
1155
|
+
schemaVersion: '1.1',
|
|
1144
1156
|
completeness: completenessReasons.length === 0 ? 'complete' : 'partial',
|
|
1145
1157
|
completenessReasons,
|
|
1146
1158
|
resolverIdentity: RESOLVED_FACTS_RESOLVER_IDENTITY,
|
|
@@ -1156,5 +1168,6 @@ export function resolveCandidateFacts({
|
|
|
1156
1168
|
publishCalls,
|
|
1157
1169
|
intentReferences,
|
|
1158
1170
|
safetyUses,
|
|
1171
|
+
classShapes,
|
|
1159
1172
|
});
|
|
1160
1173
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/rulesInventory.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/rules-inventory.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function lineOf(content, index) {
|
|
12
|
+
return content.slice(0, index).split('\n').length;
|
|
13
|
+
}
|
|
14
|
+
export function buildRulesInventory(input) {
|
|
15
|
+
const candidates = [];
|
|
16
|
+
let seq = 0;
|
|
17
|
+
for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
|
|
18
|
+
const isController = /controller|route|handler|resolver/i.test(file) ||
|
|
19
|
+
/@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content);
|
|
20
|
+
const isDomain = /domain|entity|aggregate|model/i.test(file);
|
|
21
|
+
// validation-in-controller
|
|
22
|
+
if (isController) {
|
|
23
|
+
const valRe = /\b(if\s*\([^)]{0,80}(amount|total|price|qty|quantity|balance)[^)]{0,40}\)|throw new (Error|BadRequest|ValidationError)|z\.object\(|yup\.|class-validator|@Is[A-Z])/g;
|
|
24
|
+
let m;
|
|
25
|
+
while ((m = valRe.exec(content)) !== null) {
|
|
26
|
+
seq += 1;
|
|
27
|
+
candidates.push({
|
|
28
|
+
id: `inv-val-${seq}`,
|
|
29
|
+
kind: 'validation-in-controller',
|
|
30
|
+
file,
|
|
31
|
+
line: lineOf(content, m.index),
|
|
32
|
+
message: 'Business validation appears in a controller/handler — extract an invariant or Domain rule.',
|
|
33
|
+
confidence: 'direct-evidence',
|
|
34
|
+
suggestedArkRule: {
|
|
35
|
+
layer: 'DomainModel',
|
|
36
|
+
invariantId: `INV-EXTRACT-${seq}`,
|
|
37
|
+
sensor: 'invariant-coverage',
|
|
38
|
+
},
|
|
39
|
+
neverMechanicalSafe: true,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// magic business constants (heuristic)
|
|
44
|
+
const magicRe = /\b(const|let)\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(\d{2,}|['"][^'"]{8,}['"])/g;
|
|
45
|
+
let magic;
|
|
46
|
+
while ((magic = magicRe.exec(content)) !== null) {
|
|
47
|
+
if (/TEST|SPEC|TIMEOUT|PORT|VERSION|MAX_RETRY/i.test(magic[2]))
|
|
48
|
+
continue;
|
|
49
|
+
seq += 1;
|
|
50
|
+
candidates.push({
|
|
51
|
+
id: `inv-magic-${seq}`,
|
|
52
|
+
kind: 'magic-business-constant',
|
|
53
|
+
file,
|
|
54
|
+
line: lineOf(content, magic.index),
|
|
55
|
+
message: `Magic business constant ${magic[2]} may belong in a Domain policy or invariant catalog.`,
|
|
56
|
+
confidence: 'heuristic',
|
|
57
|
+
suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${magic[2]}` },
|
|
58
|
+
neverMechanicalSafe: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// anemic entity
|
|
62
|
+
if (isDomain) {
|
|
63
|
+
const classRe = /export\s+class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([^}]{0,800})\}/g;
|
|
64
|
+
let c;
|
|
65
|
+
while ((c = classRe.exec(content)) !== null) {
|
|
66
|
+
const body = c[2] ?? '';
|
|
67
|
+
const methods = (body.match(/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
|
|
68
|
+
const fields = (body.match(/:\s*[A-Za-z]/g) ?? []).length;
|
|
69
|
+
if (fields >= 2 && methods <= 1) {
|
|
70
|
+
seq += 1;
|
|
71
|
+
candidates.push({
|
|
72
|
+
id: `inv-anemic-${seq}`,
|
|
73
|
+
kind: 'anemic-entity',
|
|
74
|
+
file,
|
|
75
|
+
line: lineOf(content, c.index),
|
|
76
|
+
message: `Class ${c[1]} looks anemic (data-heavy, few behaviors).`,
|
|
77
|
+
confidence: 'heuristic',
|
|
78
|
+
suggestedArkRule: {
|
|
79
|
+
layer: 'DomainModel',
|
|
80
|
+
structureId: 'no-anemic-model',
|
|
81
|
+
sensor: 'no-anemic-model',
|
|
82
|
+
},
|
|
83
|
+
neverMechanicalSafe: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// mutation without guard in domain
|
|
89
|
+
if (isDomain) {
|
|
90
|
+
const mutRe = /this\.\w+\s*=/g;
|
|
91
|
+
let mut;
|
|
92
|
+
while ((mut = mutRe.exec(content)) !== null) {
|
|
93
|
+
const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
|
|
94
|
+
if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
|
|
95
|
+
seq += 1;
|
|
96
|
+
candidates.push({
|
|
97
|
+
id: `inv-mut-${seq}`,
|
|
98
|
+
kind: 'mutation-without-guard',
|
|
99
|
+
file,
|
|
100
|
+
line: lineOf(content, mut.index),
|
|
101
|
+
message: 'Domain field mutation without nearby guard/publish call.',
|
|
102
|
+
confidence: 'heuristic',
|
|
103
|
+
suggestedArkRule: {
|
|
104
|
+
layer: 'DomainModel',
|
|
105
|
+
structureId: 'events-on-mutation',
|
|
106
|
+
sensor: 'domain-event-on-mutation',
|
|
107
|
+
},
|
|
108
|
+
neverMechanicalSafe: true,
|
|
109
|
+
});
|
|
110
|
+
break; // one per file is enough for inventory ranking
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const contracted = new Set(input.contractedRuleIds ?? []);
|
|
116
|
+
const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
|
|
117
|
+
(c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
|
|
118
|
+
return {
|
|
119
|
+
candidates,
|
|
120
|
+
inventoried: candidates.length,
|
|
121
|
+
underContract,
|
|
122
|
+
frozen: (input.frozenKeys ?? []).length,
|
|
123
|
+
notAScore: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Build a pilotLoop extraction card for the top inventory candidate (AR14). */
|
|
127
|
+
export function inventoryToExtractionCard(candidate) {
|
|
128
|
+
return {
|
|
129
|
+
pilot: `Extract rule candidate ${candidate.id} (${candidate.kind})`,
|
|
130
|
+
pilotTarget: candidate.file,
|
|
131
|
+
smellId: candidate.kind,
|
|
132
|
+
move: `Declare in arkrules/${candidate.suggestedArkRule?.layer ?? 'DomainModel'}.json, implement pure Domain logic, add covering test.`,
|
|
133
|
+
doNot: [
|
|
134
|
+
'Do not auto-apply codemods',
|
|
135
|
+
'Do not promote to enforced without coverage evidence',
|
|
136
|
+
'Do not batch multiple extractions',
|
|
137
|
+
],
|
|
138
|
+
successSignal: 'Doctor reports candidate under contract; gate green with residual honest.',
|
|
139
|
+
killSwitch: 'Stop if extraction requires multi-module redesign without a clear aggregate owner.',
|
|
140
|
+
neverMechanicalSafe: true,
|
|
141
|
+
class: 'judgment',
|
|
142
|
+
next: 'Run ark_prepare_change / preflight, then re-doctor.',
|
|
143
|
+
};
|
|
144
|
+
}
|