arkgate 3.9.2 → 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 +73 -0
- package/README.md +16 -5
- 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/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 +14 -1
- package/bin/lib/doctor-plan.mjs +21 -0
- package/bin/lib/effective-contract-load.mjs +116 -0
- package/bin/lib/field-install.mjs +104 -0
- package/bin/lib/graph-blind.mjs +19 -0
- package/bin/lib/html-report-advisories.mjs +24 -0
- 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/policy-delta-io.mjs +33 -0
- 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 +2 -2
- 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 +23 -0
- package/templates/skills/ark-explain.md +23 -0
- package/templates/skills/ark-explore.md +26 -1
- package/templates/skills/ark-fix.md +23 -0
- package/templates/skills/ark-loop.md +23 -0
- package/templates/skills/ark-place.md +26 -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/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
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AR12 — doctor/HTML "Rules under contract" counts (not a score).
|
|
3
|
+
* Uses real file I/O for coverage evidence (never empty-fileContents stub).
|
|
4
|
+
*/
|
|
5
|
+
import { loadEffectiveArkRulesFromDisk } from './effective-contract-load.mjs';
|
|
6
|
+
import { evaluateInvariantCoverage } from './invariant-coverage.mjs';
|
|
7
|
+
import { loadInvariantCoverageInputs } from './invariant-coverage-io.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} root
|
|
11
|
+
* @param {Record<string, unknown>} config
|
|
12
|
+
* @param {{ files?: Array<{ path: string }> }} [facts] optional facts for path set
|
|
13
|
+
*/
|
|
14
|
+
export function summarizeRulesUnderContract(root, config, facts) {
|
|
15
|
+
if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
|
|
16
|
+
return {
|
|
17
|
+
active: false,
|
|
18
|
+
structureRules: 0,
|
|
19
|
+
invariants: 0,
|
|
20
|
+
coveredInvariants: 0,
|
|
21
|
+
uncoveredInvariants: 0,
|
|
22
|
+
notAScore: true,
|
|
23
|
+
note: 'No arkRules map — intra-layer ArkRules are opt-in.',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const loaded = loadEffectiveArkRulesFromDisk(root, config);
|
|
28
|
+
if (loaded.errors?.length) {
|
|
29
|
+
return {
|
|
30
|
+
active: true,
|
|
31
|
+
loadErrors: loaded.errors,
|
|
32
|
+
notAScore: true,
|
|
33
|
+
note: 'ArkRules references failed to load (fail closed on full check).',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const structureRules = loaded.arkRules.structure?.length ?? 0;
|
|
37
|
+
const invariants = loaded.arkRules.invariants?.length ?? 0;
|
|
38
|
+
const coverageInputs =
|
|
39
|
+
invariants > 0
|
|
40
|
+
? loadInvariantCoverageInputs(root, facts ?? { files: [] })
|
|
41
|
+
: { fileContents: {}, testFiles: [], testGlobsMissing: false };
|
|
42
|
+
const coverage = evaluateInvariantCoverage({
|
|
43
|
+
arkRules: loaded.arkRules,
|
|
44
|
+
fileContents: coverageInputs.fileContents,
|
|
45
|
+
testFiles: coverageInputs.testFiles,
|
|
46
|
+
testGlobsMissing: coverageInputs.testGlobsMissing,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
active: true,
|
|
50
|
+
structureRules,
|
|
51
|
+
invariants,
|
|
52
|
+
coveredInvariants: coverage.coverage.filter((c) => c.covered).length,
|
|
53
|
+
uncoveredInvariants: coverage.coverage.filter((c) => !c.covered).length,
|
|
54
|
+
partialCoverage: coverage.partial,
|
|
55
|
+
testFilesScanned: coverageInputs.testFiles.length,
|
|
56
|
+
notAScore: true,
|
|
57
|
+
note: 'Counts only — never a score. Green with uncovered residual must say so.',
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
active: true,
|
|
62
|
+
notAScore: true,
|
|
63
|
+
note: error instanceof Error ? error.message : String(error),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -76,20 +76,31 @@ function change(pathname, before, after) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Compact start onboarding budget. Exported for pure unit tests.
|
|
81
|
+
* Gate surface (non-arkrules) ≤ maxFiles; arkrules count toward bytes only.
|
|
82
|
+
*/
|
|
83
|
+
export function setupBudget(changes) {
|
|
80
84
|
const generatedChanges = changes.filter((item) => item.path !== 'package.json');
|
|
85
|
+
// Compact gate surface stays ≤8 files (MCP + one host + CI + AGENTS + config).
|
|
86
|
+
// ArkRules starters (arkrules/*.json) are opt-in contract content and count against
|
|
87
|
+
// the byte budget only — AR08 4.0 emit was failing field start at 10/8 otherwise.
|
|
88
|
+
const gateChanges = generatedChanges.filter((item) => !item.path.startsWith('arkrules/'));
|
|
89
|
+
const arkrulesFiles = generatedChanges.length - gateChanges.length;
|
|
81
90
|
const bytes = generatedChanges.reduce(
|
|
82
91
|
(total, item) => total + (item.afterBase64 ? Buffer.from(item.afterBase64, 'base64').length : 0),
|
|
83
92
|
0
|
|
84
93
|
);
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
const maxFiles = 8;
|
|
95
|
+
const maxBytes = 32 * 1024;
|
|
87
96
|
return {
|
|
88
97
|
files: generatedChanges.length,
|
|
98
|
+
gateFiles: gateChanges.length,
|
|
99
|
+
arkrulesFiles,
|
|
89
100
|
bytes,
|
|
90
|
-
maxFiles
|
|
91
|
-
maxBytes
|
|
92
|
-
ok:
|
|
101
|
+
maxFiles,
|
|
102
|
+
maxBytes,
|
|
103
|
+
ok: gateChanges.length <= maxFiles && bytes < maxBytes,
|
|
93
104
|
};
|
|
94
105
|
}
|
|
95
106
|
|
|
@@ -117,7 +128,13 @@ export function renderStartPreview(preview) {
|
|
|
117
128
|
console.log(`Your project looks like: ${preview.analysis.label} (${preview.analysis.archetype}, confidence ${preview.analysis.confidence}).`);
|
|
118
129
|
}
|
|
119
130
|
console.log(`Projected governed coverage: ${preview.projectedCoverage.percent ?? 'unknown'}% (${preview.projectedCoverage.classifiedFiles}/${preview.projectedCoverage.totalFiles} files)`);
|
|
120
|
-
|
|
131
|
+
const budget = preview.setupBudget;
|
|
132
|
+
const arkrulesNote =
|
|
133
|
+
budget.arkrulesFiles > 0 ? ` (+${budget.arkrulesFiles} arkrules)` : '';
|
|
134
|
+
const gateCount = budget.gateFiles ?? budget.files;
|
|
135
|
+
console.log(
|
|
136
|
+
`Compact setup budget: ${gateCount}/${budget.maxFiles} gate files${arkrulesNote}, ${budget.bytes}/${budget.maxBytes} bytes${budget.ok ? '' : ' (exceeded)'}.`
|
|
137
|
+
);
|
|
121
138
|
console.log('Files to create/edit/delete:');
|
|
122
139
|
if (preview.changes.length === 0) console.log(' (none)');
|
|
123
140
|
for (const change of preview.changes) {
|