arkgate 4.0.0 → 4.1.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 +142 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/graph-blind.mjs +1 -1
- package/bin/lib/html-report-advisories.mjs +8 -25
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +100 -1
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +389 -5
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/upgrade-command.mjs +325 -14
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -2
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +38 -0
- package/docs/configuration.md +59 -7
- package/docs/package-surface.md +3 -2
- package/docs/product-voice.md +10 -1
- package/docs/typescript-support.md +9 -5
- package/docs/use.md +7 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +23 -5
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
- package/templates/skills/ark-upgrade.md +57 -11
|
@@ -97,24 +97,37 @@ function baseViolation(rule, file, message, line = 1) {
|
|
|
97
97
|
failsStrict,
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* When layerForFile is provided, structure sensors require the same classification
|
|
102
|
+
* as the layer plane: unclassified paths are skipped (NEW-ARKRULES-UNCLASSIFIED-ATTRIBUTION).
|
|
103
|
+
* Without layerForFile, appliesTo globs alone scope the rule (unit tests / pure eval).
|
|
104
|
+
*/
|
|
105
|
+
function isInRuleLayer(file, rule, layerForFile) {
|
|
106
|
+
if (!layerForFile)
|
|
107
|
+
return true;
|
|
108
|
+
const layer = layerForFile(file);
|
|
109
|
+
// Unclassified (null/undefined/"") must not inherit rule.provenance.layer attribution.
|
|
110
|
+
if (!layer)
|
|
111
|
+
return false;
|
|
112
|
+
return layer === rule.provenance.layer;
|
|
113
|
+
}
|
|
100
114
|
function shapesForRule(rule, shapes, layerForFile) {
|
|
101
115
|
return shapes.filter((shape) => {
|
|
102
116
|
if (!shape.exported)
|
|
103
117
|
return false;
|
|
104
118
|
if (!matchesAppliesTo(shape.file, rule.appliesTo))
|
|
105
119
|
return false;
|
|
106
|
-
if (layerForFile)
|
|
107
|
-
|
|
108
|
-
if (layer && layer !== rule.provenance.layer)
|
|
109
|
-
return false;
|
|
110
|
-
}
|
|
120
|
+
if (!isInRuleLayer(shape.file, rule, layerForFile))
|
|
121
|
+
return false;
|
|
111
122
|
return true;
|
|
112
123
|
});
|
|
113
124
|
}
|
|
114
125
|
function evaluateAggregatePrivateState(rule, shapes, layerForFile) {
|
|
115
126
|
const out = [];
|
|
116
127
|
for (const shape of shapesForRule(rule, shapes, layerForFile)) {
|
|
117
|
-
|
|
128
|
+
// P1-L — require real mutability (setters or non-readonly public fields).
|
|
129
|
+
// Readonly public props on intentional value/entity shapes are false-positive-prone.
|
|
130
|
+
if (shape.hasPublicSetters || shape.hasPublicMutableFields) {
|
|
118
131
|
out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes public mutable state (sensor aggregate-private-state).`));
|
|
119
132
|
}
|
|
120
133
|
}
|
|
@@ -124,6 +137,14 @@ function evaluateAlwaysValidFactory(rule, shapes, layerForFile) {
|
|
|
124
137
|
const out = [];
|
|
125
138
|
for (const shape of shapesForRule(rule, shapes, layerForFile)) {
|
|
126
139
|
if (shape.hasPublicConstructor && !shape.hasStaticFactory) {
|
|
140
|
+
// P1-L — prefer false negatives on intentional DDD / DI aggregates: a public
|
|
141
|
+
// constructor alone is weak evidence. Only fire when mutable public surface
|
|
142
|
+
// exists (needs an always-valid construction story).
|
|
143
|
+
const needsAlwaysValidStory = shape.hasPublicMutableFields ||
|
|
144
|
+
shape.hasPublicSetters ||
|
|
145
|
+
(shape.mutatingMethods?.length ?? 0) > 0;
|
|
146
|
+
if (!needsAlwaysValidStory)
|
|
147
|
+
continue;
|
|
127
148
|
out.push(baseViolation(rule, shape.file, `Exported class ${shape.className} exposes a public constructor without a static factory (sensor always-valid-factory).`));
|
|
128
149
|
}
|
|
129
150
|
}
|
|
@@ -145,11 +166,8 @@ function evaluateOrchestrationOnly(rule, input) {
|
|
|
145
166
|
for (const file of input.files) {
|
|
146
167
|
if (!matchesAppliesTo(file, rule.appliesTo))
|
|
147
168
|
continue;
|
|
148
|
-
if (input.layerForFile)
|
|
149
|
-
|
|
150
|
-
if (layer && layer !== rule.provenance.layer)
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
169
|
+
if (!isInRuleLayer(file, rule, input.layerForFile))
|
|
170
|
+
continue;
|
|
153
171
|
if (input.fileHints?.[file]?.orchestrationHeavy) {
|
|
154
172
|
out.push(baseViolation(rule, file, `File appears to embed domain branching beyond guard-and-delegate orchestration (sensor orchestration-only).`));
|
|
155
173
|
}
|
|
@@ -161,11 +179,8 @@ function evaluateThinAdapter(rule, input) {
|
|
|
161
179
|
for (const file of input.files) {
|
|
162
180
|
if (!matchesAppliesTo(file, rule.appliesTo))
|
|
163
181
|
continue;
|
|
164
|
-
if (input.layerForFile)
|
|
165
|
-
|
|
166
|
-
if (layer && layer !== rule.provenance.layer)
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
182
|
+
if (!isInRuleLayer(file, rule, input.layerForFile))
|
|
183
|
+
continue;
|
|
169
184
|
if (input.fileHints?.[file]?.adapterThick) {
|
|
170
185
|
out.push(baseViolation(rule, file, `Adapter module mixes domain branching, persistence, and mapping beyond a thin adapter (sensor thin-adapter).`));
|
|
171
186
|
}
|
|
@@ -358,11 +373,26 @@ export function extractClassShapesFromSource(file, content) {
|
|
|
358
373
|
i += 1;
|
|
359
374
|
}
|
|
360
375
|
const body = content.slice(start, i - 1);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
376
|
+
// P1-L — prefer false negatives: readonly public props are not mutable state.
|
|
377
|
+
// Strip only readonly property *declarations*, not whole lines (co-located
|
|
378
|
+
// `public readonly id; public count = 0` must keep the mutable field).
|
|
379
|
+
// Also ignore comment lines that merely mention the word "readonly".
|
|
380
|
+
const bodyNoReadonly = body
|
|
381
|
+
.split('\n')
|
|
382
|
+
.map((line) => {
|
|
383
|
+
if (/^\s*\/\//.test(line) || /^\s*\/\*|\*\//.test(line))
|
|
384
|
+
return line;
|
|
385
|
+
// Remove `public readonly foo` / `readonly foo` decls; leave other decls.
|
|
386
|
+
return line
|
|
387
|
+
.replace(/(?:public\s+|protected\s+)?readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, '')
|
|
388
|
+
.replace(/(?:^|[\s;{])readonly\s+[a-zA-Z_][a-zA-Z0-9_]*\s*(?::[^=;]+)?(?:=\s*[^;]+)?[;,]?/g, ' ');
|
|
389
|
+
})
|
|
390
|
+
.join('\n');
|
|
391
|
+
const hasPublicMutableFields = /(?:^|\n)\s*(?:public\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly.replace(/(?:public\s+|private\s+|protected\s+|static\s+|async\s+|get\s+|set\s+)/g, '')) &&
|
|
392
|
+
/(?:^|\n)\s*(public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/m.test(bodyNoReadonly);
|
|
393
|
+
// Public mutable field: "public foo" (not readonly) or unadorned assignable field.
|
|
394
|
+
const publicField = /(?:^|\n)\s*public\s+(?!static|async|get|set|constructor|readonly)[a-zA-Z_]/.test(bodyNoReadonly) ||
|
|
395
|
+
/(?:^|[\n;])\s*[a-zA-Z_][a-zA-Z0-9_]*\s*:\s*[^=;\n]+[;=]/m.test(bodyNoReadonly
|
|
366
396
|
.split('\n')
|
|
367
397
|
.filter((line) => !/^\s*(private|protected|static|constructor|get |set |async |\/)/.test(line))
|
|
368
398
|
.join('\n'));
|
|
@@ -372,10 +402,15 @@ export function extractClassShapesFromSource(file, content) {
|
|
|
372
402
|
const hasStaticFactory = /(?:^|[\n;{])\s*static\s+(?:async\s+)?(?:create|of|from|parse|build|make|new)\s*[<(]/.test(body) ||
|
|
373
403
|
/(?:^|[\n;{])\s*static\s+(?:async\s+)?[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\)\s*:\s*[A-Za-z_]/.test(body);
|
|
374
404
|
const mutatingMethods = [];
|
|
405
|
+
// P1-L / P1L-STRUCTURE-NOISE-CONTROL-FLOW: fluent control-flow helpers (Property.if,
|
|
406
|
+
// .match, .when) often assign this.* for chaining — not domain mutators. Prefer FN.
|
|
407
|
+
const CONTROL_FLOW_METHOD_NAMES = new Set(['if', 'match', 'when']);
|
|
375
408
|
const methodRe = /(?:^|\n)\s*(?:public\s+|private\s+|protected\s+|async\s+)*(?!constructor|get|set|static)([a-zA-Z_][a-zA-Z0-9_]*)\s*\([^)]*\)\s*(?::\s*[^{]+)?\{/g;
|
|
376
409
|
let methodMatch;
|
|
377
410
|
while ((methodMatch = methodRe.exec(body)) !== null) {
|
|
378
411
|
const name = methodMatch[1];
|
|
412
|
+
if (CONTROL_FLOW_METHOD_NAMES.has(name))
|
|
413
|
+
continue;
|
|
379
414
|
const mStart = methodMatch.index + methodMatch[0].length;
|
|
380
415
|
let mDepth = 1;
|
|
381
416
|
let j = mStart;
|
|
@@ -394,7 +429,13 @@ export function extractClassShapesFromSource(file, content) {
|
|
|
394
429
|
mutatingMethods.push({ name, referencesGuardOrPublish });
|
|
395
430
|
}
|
|
396
431
|
const methodCount = (body.match(/(?:^|\n)\s*(?:public\s+|private\s+|protected\s+)?(?:async\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s*\(/g) ?? []).length;
|
|
397
|
-
|
|
432
|
+
// P1-L — raise anemic bar: need multiple public fields and essentially no behavior.
|
|
433
|
+
// Single-field bags and intentional property bags with one helper stay quiet.
|
|
434
|
+
// Count fields after line starts *or* after `;` so one-line class bodies still work.
|
|
435
|
+
const publicFieldCount = (bodyNoReadonly.match(/(?:^|[\n;])\s*(?:public\s+)?(?!constructor|static|get|set|private|protected|readonly)[a-zA-Z_][a-zA-Z0-9_]*\s*[:=]/g) ?? []).length;
|
|
436
|
+
const dataOnly = methodCount <= 1 &&
|
|
437
|
+
publicFieldCount >= 2 &&
|
|
438
|
+
(publicField || hasPublicMutableFields);
|
|
398
439
|
shapes.push({
|
|
399
440
|
file,
|
|
400
441
|
className,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { falseGreenAdoptionGap } from './field-install.mjs';
|
|
15
15
|
import { renderHostSupportMatrixMarkdown } from './host-support-matrix.mjs';
|
|
16
16
|
import { PREFERRED_MCP_BIN } from './hook-templates.mjs';
|
|
17
|
-
import { readPackageJson } from './gate-files.mjs';
|
|
17
|
+
import { hasCheckArchitectureScript, readPackageJson } from './gate-files.mjs';
|
|
18
18
|
|
|
19
19
|
// Field-install helpers re-exported for callers that import from this module.
|
|
20
20
|
export {
|
|
@@ -88,6 +88,97 @@ export function checkArchitectureScriptSnippet(root) {
|
|
|
88
88
|
return `"check:architecture": "${arkCheckCommand(root)}"`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Insert a package.json scripts entry while preserving indentation / formatting
|
|
93
|
+
* (same contract as pinArkgateDevDependency format-preserving edits).
|
|
94
|
+
* @param {string} source
|
|
95
|
+
* @param {string} scriptName
|
|
96
|
+
* @param {string} scriptValue
|
|
97
|
+
*/
|
|
98
|
+
function addPackageScriptPreservingFormat(source, scriptName, scriptValue) {
|
|
99
|
+
const multiline = /\r?\n/.test(source);
|
|
100
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
101
|
+
const rootPropertyIndent = source.match(/\r?\n([ \t]+)"[^"\n]+"\s*:/)?.[1] ?? ' ';
|
|
102
|
+
const indentUnit = rootPropertyIndent;
|
|
103
|
+
const encoded = JSON.stringify(scriptValue);
|
|
104
|
+
const key = JSON.stringify(scriptName);
|
|
105
|
+
const scriptsMatch = /"scripts"\s*:\s*\{/.exec(source);
|
|
106
|
+
|
|
107
|
+
if (scriptsMatch) {
|
|
108
|
+
const open = source.indexOf('{', scriptsMatch.index);
|
|
109
|
+
let depth = 0;
|
|
110
|
+
let quoted = false;
|
|
111
|
+
let escaped = false;
|
|
112
|
+
let close = -1;
|
|
113
|
+
for (let index = open; index < source.length; index += 1) {
|
|
114
|
+
const char = source[index];
|
|
115
|
+
if (quoted) {
|
|
116
|
+
if (escaped) escaped = false;
|
|
117
|
+
else if (char === '\\') escaped = true;
|
|
118
|
+
else if (char === '"') quoted = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (char === '"') quoted = true;
|
|
122
|
+
else if (char === '{') depth += 1;
|
|
123
|
+
else if (char === '}' && --depth === 0) {
|
|
124
|
+
close = index;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (close === -1) throw new Error('Unbalanced package.json scripts object');
|
|
129
|
+
const body = source.slice(open + 1, close);
|
|
130
|
+
if (!multiline) {
|
|
131
|
+
const addition = body.trim() ? `,${key}:${encoded}` : `${key}:${encoded}`;
|
|
132
|
+
return `${source.slice(0, close)}${addition}${source.slice(close)}`;
|
|
133
|
+
}
|
|
134
|
+
const beforeClose = source.slice(0, close);
|
|
135
|
+
const trailing = beforeClose.match(/\s*$/)?.[0] ?? '';
|
|
136
|
+
const contentEnd = close - trailing.length;
|
|
137
|
+
const closingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
|
|
138
|
+
const propertyIndent = `${closingIndent}${indentUnit}`;
|
|
139
|
+
const addition = body.trim()
|
|
140
|
+
? `,${eol}${propertyIndent}${key}: ${encoded}`
|
|
141
|
+
: `${propertyIndent}${key}: ${encoded}`;
|
|
142
|
+
return `${source.slice(0, contentEnd)}${addition}${eol}${closingIndent}${source.slice(close)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rootClose = source.lastIndexOf('}');
|
|
146
|
+
if (rootClose === -1) throw new Error('Unbalanced package.json object');
|
|
147
|
+
const rootBody = source.slice(0, rootClose);
|
|
148
|
+
if (!multiline) {
|
|
149
|
+
const separator = rootBody.trim().endsWith('{') ? '' : ',';
|
|
150
|
+
return `${rootBody}${separator}"scripts":{${key}:${encoded}}${source.slice(rootClose)}`;
|
|
151
|
+
}
|
|
152
|
+
const trailing = rootBody.match(/\s*$/)?.[0] ?? '';
|
|
153
|
+
const contentEnd = rootClose - trailing.length;
|
|
154
|
+
const rootClosingIndent = trailing.slice(trailing.lastIndexOf('\n') + 1);
|
|
155
|
+
const separator = source.slice(0, contentEnd).trimEnd().endsWith('{') ? '' : ',';
|
|
156
|
+
const addition = `${separator}${eol}${rootPropertyIndent}"scripts": {${eol}${rootPropertyIndent}${indentUnit}${key}: ${encoded}${eol}${rootPropertyIndent}}`;
|
|
157
|
+
return `${source.slice(0, contentEnd)}${addition}${eol}${rootClosingIndent}${source.slice(rootClose)}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Ensure package.json has `check:architecture` so local/CI parity is not a post-start gap.
|
|
162
|
+
* Never overwrites an existing script (even if stale). Preserves package.json formatting.
|
|
163
|
+
*
|
|
164
|
+
* @param {string} root
|
|
165
|
+
* @param {{ write?: boolean }} [opts]
|
|
166
|
+
* @returns {{ changed: boolean, reason: 'added'|'already'|'no-package-json', script?: string }}
|
|
167
|
+
*/
|
|
168
|
+
export function ensureCheckArchitectureScript(root, opts = {}) {
|
|
169
|
+
const write = opts.write !== false;
|
|
170
|
+
const pkgPath = path.join(root, 'package.json');
|
|
171
|
+
if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
|
|
172
|
+
if (hasCheckArchitectureScript(root)) return { changed: false, reason: 'already' };
|
|
173
|
+
const script = arkCheckCommand(root);
|
|
174
|
+
if (write) {
|
|
175
|
+
const source = fs.readFileSync(pkgPath, 'utf8');
|
|
176
|
+
const next = addPackageScriptPreservingFormat(source, 'check:architecture', script);
|
|
177
|
+
fs.writeFileSync(pkgPath, next.endsWith('\n') ? next : `${next}\n`);
|
|
178
|
+
}
|
|
179
|
+
return { changed: true, reason: 'added', script };
|
|
180
|
+
}
|
|
181
|
+
|
|
91
182
|
// Canonical agent contract. AGENTS.md and the Cursor rule both derive from this single
|
|
92
183
|
// source so the steps can never drift out of sync between the two files. `steps(checkCommand)`
|
|
93
184
|
// is a builder because the check command's runner prefix varies with the package manager.
|
|
@@ -105,7 +196,30 @@ const AGENT_CONTRACT = {
|
|
|
105
196
|
cursorValidateStep: `Validate the full post-edit file content with the \`validate_code\` tool before writing whenever your runtime supports it.`,
|
|
106
197
|
};
|
|
107
198
|
|
|
108
|
-
|
|
199
|
+
/**
|
|
200
|
+
* Placement table for AGENTS.md. Prefer live `ark.config.json` layers when provided
|
|
201
|
+
* so custom contracts (e.g. 8-layer monorepo) do not get a stock 11-layer table.
|
|
202
|
+
*
|
|
203
|
+
* @param {Array<{ name?: string, layer?: string, patterns?: string[], intentPrefixes?: string[], prefixes?: string[] }>|null|undefined} [layers]
|
|
204
|
+
*/
|
|
205
|
+
export function layerPlacementTable(layers) {
|
|
206
|
+
if (Array.isArray(layers) && layers.length > 0) {
|
|
207
|
+
const rows = layers
|
|
208
|
+
.map((layer) => {
|
|
209
|
+
const name = layer.name ?? layer.layer ?? 'Unknown';
|
|
210
|
+
const patterns = (layer.patterns ?? [])
|
|
211
|
+
.map((pattern) => `\`${pattern}\``)
|
|
212
|
+
.join(', ') || '—';
|
|
213
|
+
const prefixes = (layer.intentPrefixes ?? layer.prefixes ?? [])
|
|
214
|
+
.map((prefix) => `\`${prefix}\``)
|
|
215
|
+
.join(', ') || '—';
|
|
216
|
+
return `| ${name} | ${patterns} | ${prefixes} |`;
|
|
217
|
+
})
|
|
218
|
+
.join('\n');
|
|
219
|
+
return `| Layer | Patterns (from ark.config.json) | Intent prefixes |
|
|
220
|
+
|-------|----------------------------------|-----------------|
|
|
221
|
+
${rows}`;
|
|
222
|
+
}
|
|
109
223
|
const rows = DEFAULT_INTENT_PREFIXES.map((entry) => {
|
|
110
224
|
const dirs = (DEFAULT_LAYER_DIRECTORIES[entry.layer] ?? [])
|
|
111
225
|
.map((directory) => `\`${directory}/\``)
|
|
@@ -117,6 +231,23 @@ export function layerPlacementTable() {
|
|
|
117
231
|
${rows}`;
|
|
118
232
|
}
|
|
119
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Load project layers for AGENTS generation. Returns null when config is absent/invalid
|
|
236
|
+
* so callers fall back to the stock 11-layer table.
|
|
237
|
+
* @param {string} root
|
|
238
|
+
*/
|
|
239
|
+
export function loadConfigLayersForAgents(root) {
|
|
240
|
+
try {
|
|
241
|
+
const cfgPath = path.join(root, 'ark.config.json');
|
|
242
|
+
if (!fs.existsSync(cfgPath)) return null;
|
|
243
|
+
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
244
|
+
if (!Array.isArray(cfg?.layers) || cfg.layers.length === 0) return null;
|
|
245
|
+
return cfg.layers;
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
120
251
|
export function agentInstructions(root) {
|
|
121
252
|
const checkCmd = arkCheckCommand(root);
|
|
122
253
|
const startCmd = arkCommand(root, 'ark', 'start');
|
|
@@ -124,6 +255,20 @@ export function agentInstructions(root) {
|
|
|
124
255
|
const steps = AGENT_CONTRACT.steps(checkCmd)
|
|
125
256
|
.map((step, index) => `${index + 1}. ${step}`)
|
|
126
257
|
.join('\n');
|
|
258
|
+
const liveLayers = loadConfigLayersForAgents(root);
|
|
259
|
+
const placementTable = layerPlacementTable(liveLayers);
|
|
260
|
+
const placementBody = liveLayers
|
|
261
|
+
? `\`ark.config.json\` is authoritative for this project. Place new code in these **${liveLayers.length}** configured layer(s) — do not invent an ungoverned location or assume the stock 11-layer layout:
|
|
262
|
+
|
|
263
|
+
${placementTable}
|
|
264
|
+
|
|
265
|
+
When creating a NEW kind of code that no existing layer covers, add a layer to \`ark.config.json\` first (via \`/ark-contract\`), then place the file.`
|
|
266
|
+
: `\`ark.config.json\` is authoritative for this project. When creating a NEW kind of code
|
|
267
|
+
that no existing layer covers (a saga, a background job, a read model, ...), use the
|
|
268
|
+
default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
|
|
269
|
+
an ungoverned location:
|
|
270
|
+
|
|
271
|
+
${placementTable}`;
|
|
127
272
|
return `# Ark Enforcement
|
|
128
273
|
|
|
129
274
|
## Default agent flow (if unsure, do only this)
|
|
@@ -181,12 +326,7 @@ ${steps}
|
|
|
181
326
|
|
|
182
327
|
## Where new code belongs
|
|
183
328
|
|
|
184
|
-
|
|
185
|
-
that no existing layer covers (a saga, a background job, a read model, ...), use the
|
|
186
|
-
default 11-layer placement below and add the layer to \`ark.config.json\` — do not invent
|
|
187
|
-
an ungoverned location:
|
|
188
|
-
|
|
189
|
-
${layerPlacementTable()}
|
|
329
|
+
${placementBody}
|
|
190
330
|
|
|
191
331
|
The project is only considered Ark-enforced when its host-appropriate write path is configured
|
|
192
332
|
and the CI check passes. Only Claude/Grok provide a hard local write boundary; Cursor/Codex use
|
package/bin/lib/core-ratchet.mjs
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
* Keeps ark-check.mjs orchestration-only (dispatch only).
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
|
-
import path from 'node:path';
|
|
7
6
|
import { arkCommand } from '../ark-shared.mjs';
|
|
8
7
|
import { computeCoverage } from './doctor-plan.mjs';
|
|
9
8
|
import { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
9
|
+
import { resolveConfigPathWithinRoot } from './project-root.mjs';
|
|
10
10
|
|
|
11
11
|
export { CORE_LAYER_NAMES } from './core-layers.mjs';
|
|
12
12
|
|
|
@@ -68,9 +68,6 @@ export function runRatchetCores(root, config, files, rules, violations, args, de
|
|
|
68
68
|
const displayPathFromRoot = deps.displayPathFromRoot;
|
|
69
69
|
const cov = computeCoverage(root, config, files, rules);
|
|
70
70
|
const activeCount = Array.isArray(violations) ? violations.length : 0;
|
|
71
|
-
const configPath = path.isAbsolute(args.config)
|
|
72
|
-
? args.config
|
|
73
|
-
: path.join(root, args.config || 'ark.config.json');
|
|
74
71
|
|
|
75
72
|
const refuse = (code, message, extra = {}) => {
|
|
76
73
|
if (args.json) {
|
|
@@ -81,6 +78,14 @@ export function runRatchetCores(root, config, files, rules, violations, args, de
|
|
|
81
78
|
process.exitCode = code;
|
|
82
79
|
};
|
|
83
80
|
|
|
81
|
+
// Contain --config writes under project root (S0 security).
|
|
82
|
+
const contained = resolveConfigPathWithinRoot(root, args.config || 'ark.config.json');
|
|
83
|
+
if (!contained.ok) {
|
|
84
|
+
refuse(2, contained.error);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const configPath = contained.configPath;
|
|
88
|
+
|
|
84
89
|
if (activeCount > 0) {
|
|
85
90
|
refuse(
|
|
86
91
|
2,
|
|
@@ -48,7 +48,14 @@ export function computeDoctorAdvisories(root, config, cov, rules, files, ts, par
|
|
|
48
48
|
// Y09 direction: advisory graph-blind spots (template-interpolation); never hard verdict.
|
|
49
49
|
graphBlindSpots: detectGraphBlindSpots(ts, root, files),
|
|
50
50
|
// AR12 — Rules under contract (honest counts; real test I/O, never empty-fileContents stub).
|
|
51
|
-
|
|
51
|
+
// P1M: pass classification so extraMergeTeeth cannot arm at 0% governed.
|
|
52
|
+
rulesUnderContract: summarizeRulesUnderContract(root, config, factPaths, {
|
|
53
|
+
governedPercent: cov?.governed?.percent ?? null,
|
|
54
|
+
populatedLayerCount: Array.isArray(cov?.layers)
|
|
55
|
+
? cov.layers.filter((row) => (row?.files ?? 0) > 0).length
|
|
56
|
+
: null,
|
|
57
|
+
classifiedFiles: cov?.governed?.classifiedFiles ?? null,
|
|
58
|
+
}),
|
|
52
59
|
};
|
|
53
60
|
}
|
|
54
61
|
|