arkgate 3.0.5 → 3.2.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 +92 -1
- package/README.md +58 -21
- package/bin/ark-check.mjs +46 -4
- package/bin/ark-mcp.mjs +267 -26
- package/bin/ark.mjs +47 -0
- package/bin/lib/adapter-contract.mjs +27 -1
- package/bin/lib/analysis-engine.mjs +7 -1169
- package/bin/lib/ci-and-commands.mjs +4 -0
- package/bin/lib/contract-smells.mjs +514 -0
- package/bin/lib/doctor-plan.mjs +15 -4
- package/bin/lib/host-support-matrix.mjs +6 -2
- package/bin/lib/policy-delta-io.mjs +161 -0
- package/bin/lib/prepare-change.mjs +186 -0
- package/bin/lib/remediation.mjs +24 -0
- package/bin/lib/violations.mjs +2 -2
- package/bin/lib/write-path-capabilities.mjs +67 -1
- package/bin/lib/write-path-detect.mjs +4 -3
- package/dist/eslint/index.cjs +3 -977
- package/dist/eslint/index.js +3 -931
- package/dist/index.cjs +6 -1960
- package/dist/index.d.cts +152 -5
- package/dist/index.d.ts +152 -5
- package/dist/index.js +6 -1908
- package/docs/agent-guide.md +39 -5
- package/docs/ai-gates.md +17 -15
- package/docs/configuration.md +44 -0
- package/docs/demos/01-write-gate-self-correction.md +2 -2
- package/docs/enthusiast/README.md +5 -1
- package/docs/enthusiast/how-to-agent-gates.md +3 -5
- package/docs/enthusiast/how-to-policy-pack.md +4 -1
- package/docs/enthusiast/reference-archetypes.md +8 -1
- package/docs/enthusiast/reference-commands.md +8 -2
- package/docs/package-surface.md +12 -2
- package/docs/threat-model.md +10 -6
- package/package.json +7 -6
- package/schemas/ark.analysis-result.schema.json +5 -1
- package/schemas/ark.change-map.schema.json +77 -0
- package/server.json +3 -3
- package/docs/ark-check-example.json +0 -87
- package/docs/demos/03-copilot-autopilot.md +0 -93
- package/docs/migrate-from-ark-runtime-kernel.md +0 -174
- package/docs/production-hardening.md +0 -100
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { analyzePolicyDelta } from './analysis-engine.mjs';
|
|
5
|
+
|
|
6
|
+
function readJsonFile(filePath, label) {
|
|
7
|
+
if (!fs.existsSync(filePath)) throw new Error(`${label} not found: ${filePath}`);
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
10
|
+
} catch (error) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`${label} is not valid JSON (${filePath}): ${error instanceof Error ? error.message : String(error)}`
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runGit(cwd, args) {
|
|
18
|
+
return spawnSync('git', ['-C', cwd, ...args], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeRef(value) {
|
|
25
|
+
return (
|
|
26
|
+
typeof value === 'string' &&
|
|
27
|
+
/^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/.test(value) &&
|
|
28
|
+
!value.includes('..')
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function normalizePolicyBaseRef(value) {
|
|
33
|
+
const ref = typeof value === 'string' ? value.trim() : '';
|
|
34
|
+
return /^0{40,64}$/.test(ref) ? '' : ref;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function repositoryRoot(root) {
|
|
38
|
+
const result = runGit(root, ['rev-parse', '--show-toplevel']);
|
|
39
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function discoverLocalBaseRef(root) {
|
|
43
|
+
const top = repositoryRoot(root);
|
|
44
|
+
if (!top) return null;
|
|
45
|
+
const remoteHead = runGit(top, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
46
|
+
const candidates = [
|
|
47
|
+
remoteHead.status === 0 ? remoteHead.stdout.trim() : null,
|
|
48
|
+
'origin/main',
|
|
49
|
+
'origin/master',
|
|
50
|
+
].filter(Boolean);
|
|
51
|
+
const current = runGit(top, ['branch', '--show-current']);
|
|
52
|
+
const currentBranch = current.status === 0 ? current.stdout.trim() : '';
|
|
53
|
+
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
if (!safeRef(candidate)) continue;
|
|
56
|
+
const exists = runGit(top, ['rev-parse', '--verify', `${candidate}^{commit}`]);
|
|
57
|
+
if (exists.status !== 0) continue;
|
|
58
|
+
if (currentBranch && candidate === `origin/${currentBranch}`) return null;
|
|
59
|
+
const mergeBase = runGit(top, ['merge-base', 'HEAD', candidate]);
|
|
60
|
+
if (mergeBase.status === 0 && safeRef(mergeBase.stdout.trim())) return mergeBase.stdout.trim();
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function configPathInRepository(root, configPath, top) {
|
|
66
|
+
const requested = path.isAbsolute(configPath) ? configPath : path.resolve(root, configPath);
|
|
67
|
+
const absolute = fs.realpathSync(requested);
|
|
68
|
+
const canonicalTop = fs.realpathSync(top);
|
|
69
|
+
const relative = path.relative(canonicalTop, absolute).split(path.sep).join('/');
|
|
70
|
+
if (!relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) {
|
|
71
|
+
throw new Error(`Policy config must be inside the Git repository: ${absolute}`);
|
|
72
|
+
}
|
|
73
|
+
return relative;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolvePolicyBaseConfig({
|
|
77
|
+
root,
|
|
78
|
+
configPath,
|
|
79
|
+
basePath,
|
|
80
|
+
baseRef,
|
|
81
|
+
env = process.env,
|
|
82
|
+
}) {
|
|
83
|
+
if (basePath) {
|
|
84
|
+
const absolute = path.isAbsolute(basePath) ? basePath : path.resolve(root, basePath);
|
|
85
|
+
return { config: readJsonFile(absolute, 'Policy base'), source: absolute, ref: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const envRef = normalizePolicyBaseRef(env.ARK_POLICY_BASE_REF);
|
|
89
|
+
const githubBase = typeof env.GITHUB_BASE_REF === 'string' ? env.GITHUB_BASE_REF.trim() : '';
|
|
90
|
+
const requestedRef = baseRef || envRef || (githubBase ? `origin/${githubBase}` : '');
|
|
91
|
+
const ref = requestedRef || discoverLocalBaseRef(root);
|
|
92
|
+
if (!ref) return null;
|
|
93
|
+
if (!safeRef(ref)) throw new Error(`Unsafe policy base ref: ${ref}`);
|
|
94
|
+
|
|
95
|
+
const top = repositoryRoot(root);
|
|
96
|
+
if (!top) {
|
|
97
|
+
// GitHub and ARK_POLICY_BASE_REF describe the process workspace, which may
|
|
98
|
+
// be different from an explicitly checked nested/temporary project root.
|
|
99
|
+
// Only the CLI flag is an unambiguous request to resolve this exact root.
|
|
100
|
+
if (baseRef) throw new Error(`Cannot resolve policy base ref outside a Git repository: ${ref}`);
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const relativeConfig = configPathInRepository(root, configPath, top);
|
|
104
|
+
const result = runGit(top, ['show', `${ref}:${relativeConfig}`]);
|
|
105
|
+
if (result.status !== 0) {
|
|
106
|
+
const refExists = runGit(top, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
107
|
+
// A newly adopted contract has no predecessor to weaken. CI-provided and
|
|
108
|
+
// auto-discovered bases may therefore omit the config; an explicit CLI ref
|
|
109
|
+
// remains fail-closed because the caller asked to compare that exact input.
|
|
110
|
+
if (refExists.status === 0 && !baseRef) return null;
|
|
111
|
+
if (requestedRef) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Cannot read policy base ${ref}:${relativeConfig}: ${result.stderr.trim() || 'git show failed'}`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return {
|
|
120
|
+
config: JSON.parse(result.stdout),
|
|
121
|
+
source: `git:${ref}:${relativeConfig}`,
|
|
122
|
+
ref,
|
|
123
|
+
};
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Policy base ${ref}:${relativeConfig} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readPolicyAcknowledgement(root, acknowledgementPath) {
|
|
132
|
+
if (!acknowledgementPath) return undefined;
|
|
133
|
+
const absolute = path.isAbsolute(acknowledgementPath)
|
|
134
|
+
? acknowledgementPath
|
|
135
|
+
: path.resolve(root, acknowledgementPath);
|
|
136
|
+
return readJsonFile(absolute, 'Policy acknowledgement');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function analyzePolicyTransition({
|
|
140
|
+
root,
|
|
141
|
+
configPath,
|
|
142
|
+
candidateConfig,
|
|
143
|
+
strictMerge,
|
|
144
|
+
basePath,
|
|
145
|
+
baseRef,
|
|
146
|
+
acknowledgementPath,
|
|
147
|
+
}) {
|
|
148
|
+
if (!strictMerge && !basePath && !baseRef && !acknowledgementPath) return undefined;
|
|
149
|
+
const base = resolvePolicyBaseConfig({ root, configPath, basePath, baseRef });
|
|
150
|
+
if (!base && (basePath || baseRef || acknowledgementPath)) {
|
|
151
|
+
throw new Error('Policy delta was requested but no policy base could be resolved.');
|
|
152
|
+
}
|
|
153
|
+
if (!base) return undefined;
|
|
154
|
+
return analyzePolicyDelta({
|
|
155
|
+
baseConfig: base.config,
|
|
156
|
+
candidateConfig,
|
|
157
|
+
acknowledgement: readPolicyAcknowledgement(root, acknowledgementPath),
|
|
158
|
+
baseSource: base.source,
|
|
159
|
+
candidateSource: path.isAbsolute(configPath) ? configPath : path.join(root, configPath),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadArchitectureChangeMap, loadContract, preflightChange } from './analysis-engine.mjs';
|
|
4
|
+
import { createAdapterResult } from './adapter-contract.mjs';
|
|
5
|
+
import { collectGovernedFiles, isGovernableSourceFile, normalize } from './scan-files.mjs';
|
|
6
|
+
import { isScanExcludedRelative, layerForRelativePath } from '../ark-shared.mjs';
|
|
7
|
+
|
|
8
|
+
function candidatePath(value) {
|
|
9
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
10
|
+
throw new Error('Every change requires a non-empty project-relative path.');
|
|
11
|
+
}
|
|
12
|
+
const portable = value.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
13
|
+
if (path.posix.isAbsolute(portable) || /^[A-Za-z]:\//.test(portable)) {
|
|
14
|
+
throw new Error(`Change path must be project-relative: ${value}`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = path.posix.normalize(portable);
|
|
17
|
+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../') || normalized.includes('\0')) {
|
|
18
|
+
throw new Error(`Change path escapes the project root: ${value}`);
|
|
19
|
+
}
|
|
20
|
+
return normalized;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isIncluded(relativePath, include) {
|
|
24
|
+
return (include ?? []).some((entry) => {
|
|
25
|
+
const root = String(entry).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
|
|
26
|
+
return root === '.' || relativePath === root || relativePath.startsWith(`${root}/`);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertGovernedSource(config, relativePath) {
|
|
31
|
+
if (!isGovernableSourceFile(path.basename(relativePath))) {
|
|
32
|
+
throw new Error(`Atomic preflight only accepts governed production source files: ${relativePath}`);
|
|
33
|
+
}
|
|
34
|
+
if (!isIncluded(relativePath, config.include) || isScanExcludedRelative(relativePath, config)) {
|
|
35
|
+
throw new Error(`Change path is outside the configured source scope: ${relativePath}`);
|
|
36
|
+
}
|
|
37
|
+
if (!layerForRelativePath(relativePath, config.layers)) {
|
|
38
|
+
throw new Error(`Change path is not assigned to an architecture layer: ${relativePath}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertInsideProject(root, relativePath) {
|
|
43
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
44
|
+
let existing = path.join(root, relativePath);
|
|
45
|
+
while (!fs.existsSync(existing)) {
|
|
46
|
+
const parent = path.dirname(existing);
|
|
47
|
+
if (parent === existing) break;
|
|
48
|
+
existing = parent;
|
|
49
|
+
}
|
|
50
|
+
const canonicalExisting = fs.realpathSync(existing);
|
|
51
|
+
const relative = path.relative(canonicalRoot, canonicalExisting);
|
|
52
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
53
|
+
throw new Error(`Change path resolves outside the project root: ${relativePath}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeChangeSet(input) {
|
|
58
|
+
if (!Array.isArray(input)) throw new Error('changes must be an array.');
|
|
59
|
+
return input.map((change, index) => {
|
|
60
|
+
if (!change || typeof change !== 'object' || Array.isArray(change)) {
|
|
61
|
+
throw new Error(`changes[${index}] must be an object.`);
|
|
62
|
+
}
|
|
63
|
+
const normalizedPath = candidatePath(change.path);
|
|
64
|
+
if (change.delete === true && change.content === undefined) {
|
|
65
|
+
return { path: normalizedPath, delete: true };
|
|
66
|
+
}
|
|
67
|
+
if (typeof change.content === 'string' && change.delete === undefined) {
|
|
68
|
+
return { path: normalizedPath, content: change.content };
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`changes[${index}] must contain either content (string) or delete: true, but not both.`
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function baseFilesForChange(root, config, changes) {
|
|
77
|
+
const byPath = new Map(
|
|
78
|
+
collectGovernedFiles(root, config).map((absolute) => [
|
|
79
|
+
normalize(path.relative(root, absolute)),
|
|
80
|
+
{ path: normalize(path.relative(root, absolute)), content: fs.readFileSync(absolute, 'utf8') },
|
|
81
|
+
])
|
|
82
|
+
);
|
|
83
|
+
for (const change of changes) {
|
|
84
|
+
if (byPath.has(change.path) || !isGovernableSourceFile(path.basename(change.path))) continue;
|
|
85
|
+
const absolute = path.join(root, change.path);
|
|
86
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
|
|
87
|
+
byPath.set(change.path, { path: change.path, content: fs.readFileSync(absolute, 'utf8') });
|
|
88
|
+
}
|
|
89
|
+
return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function prepareChangeFromRoot({
|
|
93
|
+
root,
|
|
94
|
+
config,
|
|
95
|
+
configSource,
|
|
96
|
+
changes,
|
|
97
|
+
changeMap,
|
|
98
|
+
changeMapSource,
|
|
99
|
+
compilerOptions,
|
|
100
|
+
}) {
|
|
101
|
+
const normalizedChanges = normalizeChangeSet(changes);
|
|
102
|
+
for (const change of normalizedChanges) {
|
|
103
|
+
assertInsideProject(root, change.path);
|
|
104
|
+
assertGovernedSource(config, change.path);
|
|
105
|
+
}
|
|
106
|
+
const contract = loadContract(config, configSource ?? path.join(root, 'ark.config.json'));
|
|
107
|
+
const loadedChangeMap =
|
|
108
|
+
changeMap === undefined
|
|
109
|
+
? undefined
|
|
110
|
+
: loadArchitectureChangeMap(changeMap, contract.config, changeMapSource);
|
|
111
|
+
const result = preflightChange({
|
|
112
|
+
contract,
|
|
113
|
+
files: baseFilesForChange(root, config, normalizedChanges),
|
|
114
|
+
changes: normalizedChanges,
|
|
115
|
+
...(loadedChangeMap ? { changeMap: loadedChangeMap } : {}),
|
|
116
|
+
...(compilerOptions ? { compilerOptions } : {}),
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
...createAdapterResult({
|
|
120
|
+
valid: result.valid,
|
|
121
|
+
violations: result.violations,
|
|
122
|
+
warnings: result.warnings,
|
|
123
|
+
}),
|
|
124
|
+
...result,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderChangePreflight(result) {
|
|
129
|
+
const convergence = result.convergence;
|
|
130
|
+
if (result.valid) {
|
|
131
|
+
console.log(`✔ Atomic preflight passed for ${result.changes.length} change(s).`);
|
|
132
|
+
console.log(` candidate ${result.candidateTreeHash} · policy ${result.policyHash}`);
|
|
133
|
+
} else {
|
|
134
|
+
const structuralFindings = convergence
|
|
135
|
+
? convergence.summary.missing + convergence.summary.contradictory + convergence.summary.unplanned
|
|
136
|
+
: 0;
|
|
137
|
+
console.error(
|
|
138
|
+
`Atomic preflight rejected ${result.violations.length + structuralFindings} finding(s):`
|
|
139
|
+
);
|
|
140
|
+
for (const finding of result.diagnostics.filter(({ severity }) => severity === 'error')) {
|
|
141
|
+
console.error(
|
|
142
|
+
` - ${finding.ruleId} ${finding.location.file}:${finding.location.line} — ${finding.message}`
|
|
143
|
+
);
|
|
144
|
+
console.error(` Next action: ${finding.nextAction}`);
|
|
145
|
+
}
|
|
146
|
+
for (const finding of convergence?.findings ?? []) {
|
|
147
|
+
if (finding.classification !== 'satisfied') {
|
|
148
|
+
console.error(` - ${finding.id} — ${finding.message}`);
|
|
149
|
+
console.error(` Next action: ${finding.nextAction}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
console.error('No project file was written. Fix the complete change set and preflight again.');
|
|
153
|
+
}
|
|
154
|
+
if (convergence) {
|
|
155
|
+
const { satisfied, missing, contradictory, unplanned } = convergence.summary;
|
|
156
|
+
const write = convergence.structurallyConverged ? console.log : console.error;
|
|
157
|
+
write(
|
|
158
|
+
`Structural convergence: ${convergence.structurallyConverged ? 'passed' : 'failed'} · satisfied ${satisfied} · missing ${missing} · contradictory ${contradictory} · unplanned ${unplanned}.`
|
|
159
|
+
);
|
|
160
|
+
write('Behavioral completion: not evaluated; run the feature acceptance tests separately.');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readChangeSetFile(root, requestPath) {
|
|
165
|
+
const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(fs.readFileSync(absolute, 'utf8'));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Cannot read atomic change set ${absolute}: ${error instanceof Error ? error.message : String(error)}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return Array.isArray(parsed) ? parsed : parsed?.changes;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function readChangeMapFile(root, requestPath) {
|
|
178
|
+
const absolute = path.isAbsolute(requestPath) ? requestPath : path.join(root, requestPath);
|
|
179
|
+
try {
|
|
180
|
+
return { source: absolute, input: JSON.parse(fs.readFileSync(absolute, 'utf8')) };
|
|
181
|
+
} catch (error) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Cannot read architecture change map ${absolute}: ${error instanceof Error ? error.message : String(error)}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -41,6 +41,29 @@ export const KNOWN_FIX_CLASSES = [
|
|
|
41
41
|
'break-cycle',
|
|
42
42
|
'review-contract',
|
|
43
43
|
];
|
|
44
|
+
/** One deterministic re-entry action shared by human and machine denial surfaces. */
|
|
45
|
+
export function deterministicNextAction(violation) {
|
|
46
|
+
switch (violation.ruleId) {
|
|
47
|
+
case 'LAYER_IMPORT_VIOLATION':
|
|
48
|
+
if (violation.typeOnly || violation.targetTypeOnlyExports || violation.namedBindingsTypeOnly) {
|
|
49
|
+
return 'Move the referenced type to a mutually allowed layer, use `import type`, then preflight again.';
|
|
50
|
+
}
|
|
51
|
+
if (violation.peerIsolation) {
|
|
52
|
+
return 'Extract the shared dependency to a shared layer, then preflight again.';
|
|
53
|
+
}
|
|
54
|
+
return `Define a port in ${violation.fromLayer ?? 'the source layer'}, inject the ${violation.toLayer ?? 'outer-layer'} implementation, then preflight again.`;
|
|
55
|
+
case 'FORBIDDEN_GLOBAL':
|
|
56
|
+
return `Inject ${violation.target ?? 'the capability'} through a port, then preflight again.`;
|
|
57
|
+
case 'CIRCULAR_DEPENDENCY':
|
|
58
|
+
return 'Extract the shared dependency into a third module, then preflight again.';
|
|
59
|
+
case 'RAW_EVENT_PUBLISH':
|
|
60
|
+
return 'Publish through a registered intent creator, then run Ark again.';
|
|
61
|
+
case 'PUBLISH_MISSING_SOURCE':
|
|
62
|
+
return 'Add metadata.source to the publish call, then run Ark again.';
|
|
63
|
+
default:
|
|
64
|
+
return `Resolve ${typeof violation.ruleId === 'string' && violation.ruleId.length > 0 ? violation.ruleId : 'ARK_UNKNOWN'} without weakening ark.config.json, then run Ark again.`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
44
67
|
/**
|
|
45
68
|
* Co-pilot work classifier — the TRUST BOUNDARY for auto-apply.
|
|
46
69
|
* Biased toward 'judgment': false mechanical-safe is worse than an extra human approval.
|
|
@@ -214,5 +237,6 @@ export function enrichViolationWithFixClass(violation) {
|
|
|
214
237
|
enriched.enthusiastHint =
|
|
215
238
|
'Read the violation message and the layer rules in ark.config.json, then adjust imports or move code to the correct layer.';
|
|
216
239
|
}
|
|
240
|
+
enriched.nextAction = deterministicNextAction(violation);
|
|
217
241
|
return enriched;
|
|
218
242
|
}
|
package/bin/lib/violations.mjs
CHANGED
|
@@ -12,6 +12,7 @@ const color = {
|
|
|
12
12
|
|
|
13
13
|
/** Canonical: src/domain/baselineKey.ts → bin/lib/baseline-key.mjs (R4). */
|
|
14
14
|
import { baselineKey, baselineOccurrenceKeys } from './baseline-key.mjs';
|
|
15
|
+
import { toAdapterDiagnostic } from './adapter-contract.mjs';
|
|
15
16
|
export { baselineKey, baselineOccurrenceKeys };
|
|
16
17
|
|
|
17
18
|
export function readBaseline(root, baselinePath) {
|
|
@@ -56,8 +57,7 @@ export function printViolation(violation) {
|
|
|
56
57
|
console.error(` ${violation.fromLayer} → ${violation.toLayer}${target}`);
|
|
57
58
|
}
|
|
58
59
|
console.error(` ${violation.message}`);
|
|
59
|
-
|
|
60
|
-
if (hint) console.error(` ${color.dim(`fix: ${hint}`)}`);
|
|
60
|
+
console.error(` ${color.dim(`Next action: ${toAdapterDiagnostic(violation).nextAction}`)}`);
|
|
61
61
|
console.error('');
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -25,6 +25,66 @@ export const WRITE_CAPABILITY_NAMES = [
|
|
|
25
25
|
'repair-payload',
|
|
26
26
|
];
|
|
27
27
|
|
|
28
|
+
function boundaryState({ supported, evidence, active, bypassable, hard = false, extra = {} }) {
|
|
29
|
+
return {
|
|
30
|
+
supported,
|
|
31
|
+
installed: evidence.length > 0,
|
|
32
|
+
active,
|
|
33
|
+
bypassable,
|
|
34
|
+
hard,
|
|
35
|
+
evidence: [...evidence],
|
|
36
|
+
...extra,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function operationCovered(profile, operation) {
|
|
41
|
+
if (!profile || typeof operation !== 'string') return false;
|
|
42
|
+
const normalized = operation.trim().toLowerCase();
|
|
43
|
+
return profile.hookOperations.some((candidate) => candidate.toLowerCase() === normalized);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildEnforcementLadder(activeHost, support, evidence, attempt) {
|
|
47
|
+
const localInstalled = evidence['hard-write'].length > 0;
|
|
48
|
+
const observedPreTool = attempt?.boundary === 'pre-tool';
|
|
49
|
+
const covered = observedPreTool && operationCovered(support, attempt.operation);
|
|
50
|
+
const hard = Boolean(
|
|
51
|
+
support?.capabilities['hard-write'] && (localInstalled || observedPreTool) && covered
|
|
52
|
+
);
|
|
53
|
+
const inferredActive = (installed) => (installed ? 'unverified' : false);
|
|
54
|
+
return {
|
|
55
|
+
schemaVersion: '1.0',
|
|
56
|
+
activeHost,
|
|
57
|
+
localWrite: boundaryState({
|
|
58
|
+
supported: Boolean(support?.capabilities['hard-write']),
|
|
59
|
+
evidence: evidence['hard-write'],
|
|
60
|
+
active: observedPreTool ? covered : inferredActive(localInstalled),
|
|
61
|
+
bypassable: !hard,
|
|
62
|
+
hard,
|
|
63
|
+
extra: {
|
|
64
|
+
installed: localInstalled || observedPreTool,
|
|
65
|
+
completePatch: Boolean(covered && attempt?.completePatch),
|
|
66
|
+
coverage: covered && attempt?.completePatch ? 'complete-patch' : support?.hookSurface ?? null,
|
|
67
|
+
...(observedPreTool
|
|
68
|
+
? { operation: attempt.operation, operationCovered: covered }
|
|
69
|
+
: { operationCovered: 'unverified' }),
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
advisoryMcp: boundaryState({
|
|
73
|
+
supported: Boolean(support?.capabilities['advisory-write']),
|
|
74
|
+
evidence: evidence['advisory-write'],
|
|
75
|
+
active: inferredActive(evidence['advisory-write'].length > 0),
|
|
76
|
+
bypassable: true,
|
|
77
|
+
}),
|
|
78
|
+
ciMerge: boundaryState({
|
|
79
|
+
supported: true,
|
|
80
|
+
evidence: evidence['merge-gate'],
|
|
81
|
+
active: inferredActive(evidence['merge-gate'].length > 0),
|
|
82
|
+
bypassable: 'unknown',
|
|
83
|
+
extra: { requiredStatus: 'unverified' },
|
|
84
|
+
}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
28
88
|
const KNOWN_HOSTS = HOST_SUPPORT_HOSTS;
|
|
29
89
|
|
|
30
90
|
function unique(values) {
|
|
@@ -164,7 +224,7 @@ export function detectWritePathInventory(root) {
|
|
|
164
224
|
};
|
|
165
225
|
}
|
|
166
226
|
|
|
167
|
-
export function buildWritePathCapabilityModel(root, explicitHost) {
|
|
227
|
+
export function buildWritePathCapabilityModel(root, explicitHost, attempt) {
|
|
168
228
|
const inventory = detectWritePathInventory(root);
|
|
169
229
|
const detectedHost = explicitHost ?? detectActiveAgentHost();
|
|
170
230
|
const activeHost = KNOWN_HOSTS.includes(detectedHost) ? detectedHost : 'unknown';
|
|
@@ -183,6 +243,12 @@ export function buildWritePathCapabilityModel(root, explicitHost) {
|
|
|
183
243
|
support: getHostSupportProfile(activeHost),
|
|
184
244
|
capabilities: capabilityMap(capabilityEvidence),
|
|
185
245
|
capabilityEvidence,
|
|
246
|
+
enforcementLadder: buildEnforcementLadder(
|
|
247
|
+
activeHost,
|
|
248
|
+
getHostSupportProfile(activeHost),
|
|
249
|
+
capabilityEvidence,
|
|
250
|
+
attempt
|
|
251
|
+
),
|
|
186
252
|
inventory,
|
|
187
253
|
};
|
|
188
254
|
}
|
|
@@ -15,9 +15,9 @@ function installToolsForHost(activeHost) {
|
|
|
15
15
|
: activeHost;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function detectWritePathCapabilities(root, explicitHost) {
|
|
19
|
-
const model = buildWritePathCapabilityModel(root, explicitHost);
|
|
20
|
-
const { activeHost, support, capabilities, capabilityEvidence, inventory } = model;
|
|
18
|
+
export function detectWritePathCapabilities(root, explicitHost, attempt) {
|
|
19
|
+
const model = buildWritePathCapabilityModel(root, explicitHost, attempt);
|
|
20
|
+
const { activeHost, support, capabilities, capabilityEvidence, enforcementLadder, inventory } = model;
|
|
21
21
|
const hardWrite = capabilities['hard-write'];
|
|
22
22
|
const advisoryWrite = capabilities['advisory-write'];
|
|
23
23
|
const repairPayload = capabilities['repair-payload'];
|
|
@@ -98,6 +98,7 @@ export function detectWritePathCapabilities(root, explicitHost) {
|
|
|
98
98
|
supportSummary: formatHostSupportSummary(support),
|
|
99
99
|
capabilities,
|
|
100
100
|
capabilityEvidence,
|
|
101
|
+
enforcementLadder,
|
|
101
102
|
inventory,
|
|
102
103
|
// Compatibility projection for existing doctor/API consumers.
|
|
103
104
|
mode,
|