arkgate 4.1.1 → 4.2.1
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 +107 -3
- package/README.md +16 -4
- package/bin/ark-check-runtime.mjs +16 -5
- package/bin/ark-mcp-runtime.mjs +766 -64
- package/bin/ark-shared.mjs +16 -4
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/ci-and-commands.mjs +16 -7
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +36 -36
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +16 -3
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +11 -89
- package/bin/lib/import-resolve.mjs +33 -11
- package/bin/lib/install-activation.mjs +87 -0
- package/bin/lib/install-migrate.mjs +66 -50
- package/bin/lib/managed-upgrade.mjs +10 -41
- package/bin/lib/mcp-adoption.mjs +15 -5
- package/bin/lib/physical-cohesion.mjs +2 -1
- package/bin/lib/pilot-loop.mjs +25 -8
- package/bin/lib/project-identity.mjs +103 -0
- package/bin/lib/report-snapshot-context.mjs +28 -0
- package/bin/lib/resident-hook.mjs +33 -9
- package/bin/lib/rules-inventory.mjs +100 -8
- package/bin/lib/skill-install.mjs +272 -22
- package/bin/lib/skill-write.mjs +899 -0
- package/bin/lib/start-preview.mjs +84 -1
- package/bin/lib/upgrade-command.mjs +2 -5
- package/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +5 -3
- package/docs/agent-guide.md +110 -14
- package/docs/ai-gates.md +103 -18
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +15 -9
- package/docs/product-voice.md +13 -1
- package/package.json +7 -1
- package/schemas/ark.project-identity.schema.json +116 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +9 -0
- package/templates/skills/ark-architect.md +12 -2
- package/templates/skills/ark-autopilot.md +9 -0
- package/templates/skills/ark-contract.md +11 -1
- package/templates/skills/ark-coverage.md +9 -0
- package/templates/skills/ark-explain.md +13 -1
- package/templates/skills/ark-explore.md +9 -0
- package/templates/skills/ark-fix.md +10 -1
- package/templates/skills/ark-loop.md +11 -2
- package/templates/skills/ark-place.md +17 -6
- package/templates/skills/ark-runtime.md +8 -0
- package/templates/skills/ark-think.md +14 -2
- package/templates/skills/ark-upgrade.md +9 -0
|
@@ -10,6 +10,35 @@ import {
|
|
|
10
10
|
loadArkRulesContract,
|
|
11
11
|
} from './arkrules-contract.mjs';
|
|
12
12
|
|
|
13
|
+
function normalizeProjectRelativePath(value) {
|
|
14
|
+
const normalized = value.replace(/\\/g, '/');
|
|
15
|
+
if (
|
|
16
|
+
!normalized ||
|
|
17
|
+
normalized.startsWith('/') ||
|
|
18
|
+
/^[A-Za-z]:/.test(normalized) ||
|
|
19
|
+
normalized.includes('\0')
|
|
20
|
+
) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
const segments = [];
|
|
24
|
+
for (const segment of normalized.split('/')) {
|
|
25
|
+
if (!segment || segment === '.') continue;
|
|
26
|
+
if (segment === '..') return undefined;
|
|
27
|
+
segments.push(segment);
|
|
28
|
+
}
|
|
29
|
+
return segments.length > 0 ? segments.join('/') : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWithinRoot(root, candidate) {
|
|
33
|
+
const relative = path.relative(root, candidate);
|
|
34
|
+
return (
|
|
35
|
+
relative === '' ||
|
|
36
|
+
(!relative.startsWith(`..${path.sep}`) &&
|
|
37
|
+
relative !== '..' &&
|
|
38
|
+
!path.isAbsolute(relative))
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
/**
|
|
14
43
|
* @param {string} root
|
|
15
44
|
* @param {Record<string, unknown>} config loaded ark.config.json object
|
|
@@ -29,6 +58,7 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
29
58
|
const warnings = [];
|
|
30
59
|
const parts = [];
|
|
31
60
|
const referenced = new Set();
|
|
61
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
32
62
|
|
|
33
63
|
for (const layer of Object.keys(refs).sort()) {
|
|
34
64
|
const relRaw = refs[layer];
|
|
@@ -37,10 +67,12 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
37
67
|
errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
|
|
38
68
|
continue;
|
|
39
69
|
}
|
|
40
|
-
|
|
70
|
+
const rel = normalizeProjectRelativePath(relRaw);
|
|
71
|
+
if (!rel) {
|
|
41
72
|
errors.push({
|
|
42
73
|
path: pathKey,
|
|
43
|
-
message:
|
|
74
|
+
message:
|
|
75
|
+
'must be a project-relative path without absolute roots or parent-directory traversal',
|
|
44
76
|
});
|
|
45
77
|
continue;
|
|
46
78
|
}
|
|
@@ -52,17 +84,42 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
52
84
|
continue;
|
|
53
85
|
}
|
|
54
86
|
|
|
55
|
-
const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
56
87
|
referenced.add(rel);
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
88
|
+
const lexicalTarget = path.resolve(canonicalRoot, ...rel.split('/'));
|
|
89
|
+
if (!isWithinRoot(canonicalRoot, lexicalTarget)) {
|
|
90
|
+
errors.push({
|
|
91
|
+
path: pathKey,
|
|
92
|
+
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!fs.existsSync(lexicalTarget)) {
|
|
60
97
|
errors.push({
|
|
61
98
|
path: pathKey,
|
|
62
99
|
message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
|
|
63
100
|
});
|
|
64
101
|
continue;
|
|
65
102
|
}
|
|
103
|
+
let absolute;
|
|
104
|
+
try {
|
|
105
|
+
absolute = fs.realpathSync(lexicalTarget);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
errors.push({
|
|
108
|
+
path: pathKey,
|
|
109
|
+
message: `referenced ArkRules file ${JSON.stringify(rel)} could not be resolved: ${
|
|
110
|
+
error instanceof Error ? error.message : String(error)
|
|
111
|
+
}`,
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!isWithinRoot(canonicalRoot, absolute)) {
|
|
116
|
+
errors.push({
|
|
117
|
+
path: pathKey,
|
|
118
|
+
message: `referenced ArkRules path ${JSON.stringify(rel)} resolves outside the project root`,
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
opts.observeInput?.(absolute, 'arkrules');
|
|
66
123
|
let content;
|
|
67
124
|
try {
|
|
68
125
|
content = fs.readFileSync(absolute, 'utf8');
|
|
@@ -90,9 +147,16 @@ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
|
|
|
90
147
|
}
|
|
91
148
|
|
|
92
149
|
// Drift: unreferenced files under arkrules/
|
|
93
|
-
const arkrulesDir = path.join(
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
const arkrulesDir = path.join(canonicalRoot, 'arkrules');
|
|
151
|
+
const resolvedArkRulesDir = fs.existsSync(arkrulesDir)
|
|
152
|
+
? fs.realpathSync(arkrulesDir)
|
|
153
|
+
: undefined;
|
|
154
|
+
if (
|
|
155
|
+
resolvedArkRulesDir &&
|
|
156
|
+
isWithinRoot(canonicalRoot, resolvedArkRulesDir) &&
|
|
157
|
+
fs.statSync(resolvedArkRulesDir).isDirectory()
|
|
158
|
+
) {
|
|
159
|
+
for (const name of fs.readdirSync(resolvedArkRulesDir).sort()) {
|
|
96
160
|
if (!name.endsWith('.json')) continue;
|
|
97
161
|
const rel = `arkrules/${name}`;
|
|
98
162
|
if (!referenced.has(rel)) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated from enforcement-state.source.mjs — run npm run generate:packaged-tooling.
|
|
2
|
-
import m from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function
|
|
2
|
+
import m from"node:fs";import{createRequire as q}from"node:module";import h from"node:path";const n="unverified";function O({configuredOnDisk:a=!1,restartRequired:e=a}={}){return{configuredOnDisk:!!a,restartRequired:!!e,runtimeObserved:!1,identityMatch:n,active:!1}}function M(a){const e=h.join(a,"package.json");try{if(JSON.parse(m.readFileSync(e,"utf8"))?.name==="arkgate"&&m.statSync(h.join(a,"bin","ark-check.mjs"),{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"package.json + bin/ark-check.mjs (self-host)",selfHost:!0}}catch{}try{const r=q(e).resolve("arkgate/package.json"),o=h.dirname(r),i=JSON.parse(m.readFileSync(r,"utf8")),s=h.join(o,"bin","ark-check.mjs");if(i?.name==="arkgate"&&m.statSync(s,{throwIfNoEntry:!1})?.isFile())return{installed:!0,source:"arkgate/package.json via project resolver",selfHost:!1}}catch{}return{installed:!1,source:"arkgate/package.json unresolved from project",selfHost:!1}}function S(a){return a.length>0?a:["filesystem scan (no matching configuration)"]}function g({supported:a,configuredPaths:e,installed:r,active:o,runtimeObserved:i,operation:s,operationCoverage:u,bypassable:v,required:c,hard:t,sources:l}){const p=e.length>0,d=!!r.installed;return{supported:a,analyzed:!0,configured:p,installed:d,active:o,runtimeObserved:i,operation:s,operationCoverage:u,bypassable:v,required:c,hard:t,evidence:[...S(e).map(f=>({field:"configured",source:f,value:p})),{field:"installed",source:r.source,value:d},{field:"active",source:l.active,value:o},{field:"runtimeObserved",source:l.runtimeObserved,value:i},{field:"operationCoverage",source:l.operationCoverage,value:u},{field:"bypassable",source:l.bypassable,value:v},{field:"required",source:l.required,value:c},{field:"hard",source:l.hard,value:t}]}}function E(a,e){const r=M(a),o=!!e.support?.capabilities?.["hard-write"],i=!!e.support?.capabilities?.["advisory-write"],s=e.capabilityEvidence["hard-write"],u=e.capabilityEvidence["advisory-write"],v=e.capabilityEvidence["merge-gate"],c=e.enforcementLadder.localWrite,t=typeof c.operationCovered=="boolean",l=t?c.operationCovered:n,p=t&&l===!0,d=!!(o&&r.installed&&p&&c.hard===!0),f=t?p&&r.installed:o&&s.length>0&&r.installed?n:!1,b=i&&u.length>0&&r.installed?n:!1,y=!!(e.ci?.failClosed&&v.length>0),C=y&&r.installed?n:!1;return{schemaVersion:"1.1",activeHost:e.activeHost,localWrite:g({supported:o,configuredPaths:s,installed:r,active:f,runtimeObserved:t,operation:t?c.operation??null:null,operationCoverage:l,bypassable:d?!1:o&&!t?n:!0,required:n,hard:d,sources:{active:t?"observed PreToolUse attempt":"runtime observation unavailable",runtimeObserved:t?"fresh PreToolUse invocation":"runtime observation unavailable",operationCoverage:t?"active-host operation matcher":"operation not observed",bypassable:d?"observed hard write boundary":"host runtime bypass evidence unavailable",required:"local host policy unavailable",hard:d?"fresh covered active-host invocation":"hardness not proven for this invocation"}}),advisoryMcp:g({supported:i,configuredPaths:u,installed:r,active:b,runtimeObserved:!1,operation:null,operationCoverage:n,bypassable:!0,required:n,hard:!1,sources:{active:"MCP runtime observation unavailable",runtimeObserved:"doctor did not observe an MCP tool invocation",operationCoverage:"advisory MCP is caller-invoked",bypassable:"advisory MCP does not intercept every write",required:"local host policy unavailable",hard:"MCP presence is advisory and never proves a hard boundary"}}),ciMerge:g({supported:!0,configuredPaths:y?v:[],installed:r,active:C,runtimeObserved:!1,operation:"merge",operationCoverage:y?n:!1,bypassable:y?n:!0,required:n,hard:!1,sources:{active:"CI run and provider enforcement not observed",runtimeObserved:"provider evidence unavailable",operationCoverage:"required-status operation coverage unavailable",bypassable:"branch-protection evidence unavailable",required:"branch-protection evidence unavailable",hard:"merge hardness requires fresh provider evidence"}})}}function $(a,e,r,o){return{...a,...o,evidence:[...a.evidence.filter(i=>!e.includes(i.field)),...e.map(i=>({field:i,source:r,value:o[i]}))]}}function x(a,e){if(!e)return a;const r=e.reason==="provider-policy-unavailable-plan"||e.policyReason==="unavailable-plan",o=e.available===!0,i=!o&&!r&&(e.reason==="provider-enforcement-unverified"||e.reason==="gh-cli-unavailable"||e.reason==="gh-repo-unavailable"||!!e.reason);if(!o&&e.runtimeObserved!==!0&&!r&&!i)return a;const s=o?typeof e.arkCheckRequired=="boolean"?e.arkCheckRequired:n:r?!1:n,u=!!(a.enforcementState.ciMerge.configured&&a.enforcementState.ciMerge.installed),v=s===!0?u:s===!1?!1:u?n:!1,c=v===!0?e.arkCheckSourceBound===!1?!0:n:s===!1?!0:u?n:!0,t=e.runtimeObserved===!0,l=o?`GitHub branch protection (${e.repo??"repository"}:${e.branch??"default"})`:r?`GitHub provider policy unavailable (plan) (${e.repo??"repository"}:${e.branch??"default"})`:`GitHub CI runtime (${e.repo??"repository"})`,p=s,d=v===!0&&c===!1&&p===!0,f=$(a.enforcementState.ciMerge,["active","runtimeObserved","operationCoverage","bypassable","required","hard"],l,{active:v,runtimeObserved:t,operationCoverage:p,bypassable:c,required:s,hard:d}),b={...a,enforcementState:{...a.enforcementState,ciMerge:f},enforcementLadder:{...a.enforcementLadder,ciMerge:{...a.enforcementLadder.ciMerge,requiredStatus:s,...e.latestCiRun?{latestCiRun:e.latestCiRun}:{},...r?{providerPolicy:"unavailable-plan"}:{}}}};return(r||e.reason)&&(b.providerEnforcement={available:o,reason:e.reason||(r?"provider-policy-unavailable-plan":"provider-enforcement-unverified"),policyReason:e.policyReason||(r?"unavailable-plan":null),runtimeObserved:t,latestCiRun:e.latestCiRun??null,hard:d===!0}),b}function k(a,e){const r=o=>o===!0?"yes":o===!1?"no":String(o);return`${a} \u2014 supported: ${r(e.supported)} \xB7 analyzed: ${r(e.analyzed)} \xB7 configured: ${r(e.configured)} \xB7 installed: ${r(e.installed)} \xB7 runtime observed: ${r(e.runtimeObserved)} \xB7 operation: ${e.operation??"none"} \xB7 operation covered: ${r(e.operationCoverage)} \xB7 active: ${r(e.active)} \xB7 bypassable: ${r(e.bypassable)} \xB7 required: ${r(e.required)} \xB7 hard: ${r(e.hard)}`}function B(a){const e=[{level:a.localWrite.active===!0?"ok":"warn",text:k("Local write",a.localWrite)},{level:"warn",text:k("Advisory MCP",a.advisoryMcp)},{level:a.ciMerge.required===!0?"ok":"warn",text:k("CI merge",a.ciMerge)}];return a.localWrite.active===n&&a.localWrite.hard===!1&&e.push({level:"bad",text:"RED FLAG: local hook assets exist, but this active-host operation was not observed at runtime; hard blocking is unverified."}),a.activeHost==="unknown"&&e.push({level:"warn",text:"Active host unknown for this invocation \u2014 enforcementState is session projection only. See writePath.inventory for on-disk host hooks; hard write is never claimed without runtime proof."}),e}export{E as buildEnforcementState,O as codexRuntimeActivation,B as enforcementDoctorLines,M as packageInstallation,x as withCiProviderEvidence};
|
package/bin/lib/gate-files.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { codexProjectMcpIsValid } from './codex-home.mjs';
|
|
8
|
+
import { enforcingArkRunText } from './github-enforcement.mjs';
|
|
7
9
|
|
|
8
10
|
export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
11
|
export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
|
|
@@ -120,6 +122,7 @@ export const REQUIRED_GATE_FILES = [
|
|
|
120
122
|
];
|
|
121
123
|
const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
122
124
|
const COMPACT_ROUTER = /<!--\s*arkgate:compact-router host=([a-z]+)\s*-->/;
|
|
125
|
+
const FAIL_CLOSED_ARK_FLAG = /(?:^|\s)--(?:strict|strict-merge|require-gates)(?=\s|$)/;
|
|
123
126
|
|
|
124
127
|
const COMPACT_HOST_FILES = {
|
|
125
128
|
claude: ['.claude/settings.json'],
|
|
@@ -149,24 +152,454 @@ export function isCompactRouterAgentsContent(text) {
|
|
|
149
152
|
}
|
|
150
153
|
|
|
151
154
|
function hasCompactHostRegistration(root, host) {
|
|
152
|
-
if (host === 'none') return
|
|
155
|
+
if (host === 'none') return hasArkMcpRegistration(root);
|
|
156
|
+
if (host === 'cursor') return hasArkMcpRegistration(root, '.cursor/mcp.json');
|
|
157
|
+
if (host === 'codex') return hasCodexCompactRegistration(root);
|
|
153
158
|
const files = COMPACT_HOST_FILES[host];
|
|
154
159
|
return Boolean(files) && files.every((relativePath) => fs.existsSync(path.join(root, relativePath)));
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
function executableName(value) {
|
|
163
|
+
return path.basename(String(value).trim().replace(/\\/g, '/')).replace(/\.(?:cmd|exe)$/i, '');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function arkMcpArgs(server) {
|
|
167
|
+
if (!server || typeof server !== 'object' || typeof server.command !== 'string') return null;
|
|
168
|
+
if (server.args !== undefined && !Array.isArray(server.args)) return null;
|
|
169
|
+
const args = server.args ?? [];
|
|
170
|
+
if (!args.every((value) => typeof value === 'string')) return null;
|
|
171
|
+
const command = executableName(server.command);
|
|
172
|
+
const isArkBin = (value) => /^(?:arkgate-mcp|ark-mcp)(?:\.mjs)?$/.test(executableName(value));
|
|
173
|
+
if ([server.command, ...args].filter(isArkBin).length !== 1) return null;
|
|
174
|
+
if (isArkBin(server.command)) return args;
|
|
175
|
+
if ((command === 'npx' || command === 'yarn') && isArkBin(args[0])) return args.slice(1);
|
|
176
|
+
if (command === 'pnpm') {
|
|
177
|
+
const binIndex =
|
|
178
|
+
args[0] === 'exec'
|
|
179
|
+
? 1
|
|
180
|
+
: args[0] === '--config.verify-deps-before-run=false' && args[1] === 'exec'
|
|
181
|
+
? 2
|
|
182
|
+
: -1;
|
|
183
|
+
return binIndex >= 0 && isArkBin(args[binIndex]) ? args.slice(binIndex + 1) : null;
|
|
184
|
+
}
|
|
185
|
+
if (command === 'node') {
|
|
186
|
+
const script = String(args[0] ?? '').replace(/\\/g, '/');
|
|
187
|
+
return /(?:^|\/)bin\/ark-mcp\.mjs$/.test(script) ? args.slice(1) : null;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function projectBindingArguments(args) {
|
|
193
|
+
if (args.length !== 4) return null;
|
|
194
|
+
const values = {};
|
|
195
|
+
for (let index = 0; index < args.length; index += 2) {
|
|
196
|
+
const name = args[index];
|
|
197
|
+
if ((name !== '--root' && name !== '--config') || values[name] !== undefined) return null;
|
|
198
|
+
const value = args[index + 1];
|
|
199
|
+
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) return null;
|
|
200
|
+
values[name] = value;
|
|
201
|
+
}
|
|
202
|
+
return values['--root'] && values['--config']
|
|
203
|
+
? { root: values['--root'], config: values['--config'] }
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function nativePathInput(value) {
|
|
208
|
+
const text = String(value).trim();
|
|
209
|
+
return path.sep === '/' ? text.replace(/\\/g, '/') : text.replace(/\//g, '\\');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function canonicalNativePath(value) {
|
|
213
|
+
const absolute = path.resolve(value);
|
|
214
|
+
let canonical = absolute;
|
|
215
|
+
try {
|
|
216
|
+
canonical = fs.realpathSync.native(absolute);
|
|
217
|
+
} catch {
|
|
218
|
+
/* A missing candidate still compares by its normalized absolute path. */
|
|
219
|
+
}
|
|
220
|
+
const normalized = path.normalize(canonical);
|
|
221
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function bindingTargetsProject(binding, root, invocationRoot = root) {
|
|
225
|
+
const resolvedRoot = path.resolve(invocationRoot, nativePathInput(binding.root));
|
|
226
|
+
if (canonicalNativePath(resolvedRoot) !== canonicalNativePath(root)) return false;
|
|
227
|
+
const nativeConfig = nativePathInput(binding.config);
|
|
228
|
+
const resolvedConfig = path.isAbsolute(nativeConfig)
|
|
229
|
+
? nativeConfig
|
|
230
|
+
: path.resolve(resolvedRoot, nativeConfig);
|
|
231
|
+
return (
|
|
232
|
+
canonicalNativePath(resolvedConfig) ===
|
|
233
|
+
canonicalNativePath(path.join(root, 'ark.config.json'))
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function registrationTargetsProject(server, args, root) {
|
|
238
|
+
const binding = projectBindingArguments(args);
|
|
239
|
+
if (!binding) return false;
|
|
240
|
+
if (server.cwd !== undefined && (typeof server.cwd !== 'string' || !server.cwd.trim())) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const invocationRoot = server.cwd
|
|
244
|
+
? path.resolve(root, nativePathInput(server.cwd))
|
|
245
|
+
: root;
|
|
246
|
+
return bindingTargetsProject(binding, root, invocationRoot);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function hasArkMcpRegistration(root, relativePath = '.mcp.json') {
|
|
250
|
+
try {
|
|
251
|
+
const server = readJson(path.join(root, relativePath))?.mcpServers?.ark;
|
|
252
|
+
const args = arkMcpArgs(server);
|
|
253
|
+
return Boolean(args && registrationTargetsProject(server, args, root));
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function commandArkMcpArgs(command) {
|
|
260
|
+
if (typeof command !== 'string') return null;
|
|
261
|
+
const words = [];
|
|
262
|
+
let consumed = 0;
|
|
263
|
+
for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(&&|\|\||[;|#])|([^\s;&|#]+)/g)) {
|
|
264
|
+
if (command.slice(consumed, match.index).trim() || match[3]) return null;
|
|
265
|
+
words.push(match[1] ?? match[2] ?? match[4]);
|
|
266
|
+
consumed = Number(match.index) + match[0].length;
|
|
267
|
+
}
|
|
268
|
+
if (command.slice(consumed).trim()) return null;
|
|
269
|
+
return arkMcpArgs({ command: words[0], args: words.slice(1) });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function codexHookArguments(args, expectedModes) {
|
|
273
|
+
const allowedModes = new Set(expectedModes);
|
|
274
|
+
const seenModes = new Set();
|
|
275
|
+
const values = {};
|
|
276
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
277
|
+
const name = args[index];
|
|
278
|
+
if (allowedModes.has(name)) {
|
|
279
|
+
if (seenModes.has(name)) return null;
|
|
280
|
+
seenModes.add(name);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (!['--root', '--root-env', '--config'].includes(name) || values[name] !== undefined) {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const value = args[++index];
|
|
287
|
+
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) return null;
|
|
288
|
+
values[name] = value;
|
|
289
|
+
}
|
|
290
|
+
if (
|
|
291
|
+
seenModes.size !== allowedModes.size ||
|
|
292
|
+
!values['--root'] ||
|
|
293
|
+
!values['--root-env'] ||
|
|
294
|
+
!values['--config']
|
|
295
|
+
) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
root: values['--root'],
|
|
300
|
+
rootEnv: values['--root-env'],
|
|
301
|
+
config: values['--config'],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function codexHookCommandIsValid(command, root, expectedModes) {
|
|
306
|
+
const args = commandArkMcpArgs(command);
|
|
307
|
+
if (!args) return false;
|
|
308
|
+
const binding = codexHookArguments(args, expectedModes);
|
|
309
|
+
return Boolean(
|
|
310
|
+
binding &&
|
|
311
|
+
binding.rootEnv === 'CODEX_PROJECT_DIR' &&
|
|
312
|
+
bindingTargetsProject(binding, root)
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function matcherHasExactTools(matcher, expectedTools) {
|
|
317
|
+
if (typeof matcher !== 'string') return false;
|
|
318
|
+
const tools = matcher.split('|').map((tool) => tool.trim()).filter(Boolean);
|
|
319
|
+
const unique = new Set(tools);
|
|
320
|
+
return (
|
|
321
|
+
tools.length === expectedTools.length &&
|
|
322
|
+
unique.size === expectedTools.length &&
|
|
323
|
+
expectedTools.every((tool) => unique.has(tool))
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function hookGroupHasValidCodexContract(group, root, expectedModes, expectedTools = null) {
|
|
328
|
+
if (!Array.isArray(group)) return false;
|
|
329
|
+
return group.some(
|
|
330
|
+
(entry) =>
|
|
331
|
+
entry &&
|
|
332
|
+
typeof entry === 'object' &&
|
|
333
|
+
(!expectedTools || matcherHasExactTools(entry.matcher, expectedTools)) &&
|
|
334
|
+
Array.isArray(entry.hooks) &&
|
|
335
|
+
entry.hooks.some(
|
|
336
|
+
(hook) =>
|
|
337
|
+
hook &&
|
|
338
|
+
typeof hook === 'object' &&
|
|
339
|
+
hook.type === 'command' &&
|
|
340
|
+
codexHookCommandIsValid(hook.command, root, expectedModes)
|
|
341
|
+
)
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function hasCodexCompactRegistration(root) {
|
|
346
|
+
try {
|
|
347
|
+
const config = fs.readFileSync(path.join(root, '.codex', 'config.toml'), 'utf8');
|
|
348
|
+
const hooks = readJson(path.join(root, '.codex', 'hooks.json'))?.hooks;
|
|
349
|
+
return (
|
|
350
|
+
codexProjectMcpIsValid(config, root) &&
|
|
351
|
+
hookGroupHasValidCodexContract(hooks?.SessionStart, root, ['--session-context']) &&
|
|
352
|
+
hookGroupHasValidCodexContract(hooks?.PreToolUse, root, [
|
|
353
|
+
'--hook',
|
|
354
|
+
'--hook-repair',
|
|
355
|
+
'--fail-on-new-smells',
|
|
356
|
+
], ['ApplyPatch', 'apply_patch', 'Write', 'Edit', 'MultiEdit'])
|
|
357
|
+
);
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function architectureScript(root) {
|
|
364
|
+
try {
|
|
365
|
+
const script = readPackageJson(root)?.scripts?.['check:architecture'];
|
|
366
|
+
return typeof script === 'string' ? script : '';
|
|
367
|
+
} catch {
|
|
368
|
+
return '';
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isFailClosedArchitectureScript(script) {
|
|
373
|
+
if (!script) return false;
|
|
374
|
+
if (
|
|
375
|
+
/(?:^|;|\n|(?<!&)&(?!&))\s*exit(?:\s+\/b)?\s+0(?=\s*(?:;|&&|\|\||#|$))/im.test(
|
|
376
|
+
script
|
|
377
|
+
)
|
|
378
|
+
) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
const body = script
|
|
382
|
+
.split('\n')
|
|
383
|
+
.map((line) => ` ${line}`)
|
|
384
|
+
.join('\n');
|
|
385
|
+
const workflow = `jobs:
|
|
386
|
+
ark:
|
|
387
|
+
runs-on: ubuntu-latest
|
|
388
|
+
steps:
|
|
389
|
+
- run: |
|
|
390
|
+
${body}
|
|
391
|
+
`;
|
|
392
|
+
return FAIL_CLOSED_ARK_FLAG.test(enforcingArkRunText(workflow));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function hasArkAgentsContract(root) {
|
|
396
|
+
try {
|
|
397
|
+
const content = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
|
|
398
|
+
const directCheck =
|
|
399
|
+
/\b(?:arkgate-check|ark-check)\b[\s\S]{0,240}--(?:strict-config|strict-merge|strict)\b/.test(
|
|
400
|
+
content
|
|
401
|
+
);
|
|
402
|
+
const scriptCheck =
|
|
403
|
+
/\b(?:npm|pnpm)\s+run\s+check:architecture\b|\byarn(?:\s+run)?\s+check:architecture\b/.test(
|
|
404
|
+
content
|
|
405
|
+
) && isFailClosedArchitectureScript(architectureScript(root));
|
|
406
|
+
return (
|
|
407
|
+
/^#{1,6}\s+Ark(?:Gate)?\s+Enforcement\b/im.test(content) &&
|
|
408
|
+
/\bark\.config\.json\b/i.test(content) &&
|
|
409
|
+
/\bauthoritative\b/i.test(content) &&
|
|
410
|
+
(directCheck || scriptCheck)
|
|
411
|
+
);
|
|
412
|
+
} catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function withFailClosedArkActions(content) {
|
|
418
|
+
const lines = String(content).split('\n');
|
|
419
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
420
|
+
const match = lines[index].match(
|
|
421
|
+
/^(\s*)(-\s+)?uses:\s*['"]?pedroknigge\/arkgate@[^'"\s#]+['"]?\s*(?:#.*)?$/i
|
|
422
|
+
);
|
|
423
|
+
if (!match) continue;
|
|
424
|
+
const propertyIndent = match[1].length + (match[2] ? 2 : 0);
|
|
425
|
+
let start = index;
|
|
426
|
+
let stepIndent = match[2] ? match[1].length : null;
|
|
427
|
+
if (stepIndent === null) {
|
|
428
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
429
|
+
const indent = lines[cursor].match(/^\s*/)?.[0].length ?? 0;
|
|
430
|
+
if (/^\s*-\s+/.test(lines[cursor]) && indent < propertyIndent) {
|
|
431
|
+
start = cursor;
|
|
432
|
+
stepIndent = indent;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (stepIndent === null) continue;
|
|
438
|
+
let end = lines.length;
|
|
439
|
+
for (let cursor = start + 1; cursor < lines.length; cursor += 1) {
|
|
440
|
+
if (!lines[cursor].trim()) continue;
|
|
441
|
+
const indent = lines[cursor].match(/^\s*/)?.[0].length ?? 0;
|
|
442
|
+
if (indent < stepIndent || (indent === stepIndent && /^\s*-\s+/.test(lines[cursor]))) {
|
|
443
|
+
end = cursor;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const block = lines.slice(start, end).join('\n');
|
|
448
|
+
const strictInput = block.match(/^\s*strict-config:\s*(.*?)\s*(?:#.*)?$/im)?.[1];
|
|
449
|
+
if (strictInput !== undefined && !/^['"]?true['"]?$/i.test(strictInput)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
lines[index] = lines[index].replace(/\buses:/, 'run:').replace(
|
|
453
|
+
/['"]?pedroknigge\/arkgate@[^'"\s#]+['"]?/i,
|
|
454
|
+
'ark-check --strict-merge'
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return lines.join('\n');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function workflowJobSections(content) {
|
|
461
|
+
const lines = String(content).split('\n');
|
|
462
|
+
const jobsIndex = lines.findIndex((line) =>
|
|
463
|
+
/^\s*(?:"jobs"|'jobs'|jobs):\s*(?:#.*)?$/.test(line)
|
|
464
|
+
);
|
|
465
|
+
if (jobsIndex < 0) return { lines, jobs: [] };
|
|
466
|
+
const jobsIndent = lines[jobsIndex].match(/^\s*/)?.[0].length ?? 0;
|
|
467
|
+
let jobIndent = null;
|
|
468
|
+
let jobsEnd = lines.length;
|
|
469
|
+
const headers = [];
|
|
470
|
+
for (let index = jobsIndex + 1; index < lines.length; index += 1) {
|
|
471
|
+
if (!lines[index].trim() || /^\s*#/.test(lines[index])) continue;
|
|
472
|
+
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
473
|
+
if (indent <= jobsIndent) {
|
|
474
|
+
jobsEnd = index;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
const header = lines[index].match(
|
|
478
|
+
/^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*(?:#.*)?$/
|
|
479
|
+
);
|
|
480
|
+
if (!header) continue;
|
|
481
|
+
jobIndent ??= indent;
|
|
482
|
+
if (indent === jobIndent) {
|
|
483
|
+
headers.push({ id: header[1] ?? header[2] ?? header[3], start: index });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const jobs = headers.map((header, index) => {
|
|
487
|
+
const end = headers[index + 1]?.start ?? jobsEnd;
|
|
488
|
+
const propertyIndents = lines
|
|
489
|
+
.slice(header.start + 1, end)
|
|
490
|
+
.filter((line) => line.trim() && !/^\s*#/.test(line))
|
|
491
|
+
.map((line) => line.match(/^\s*/)?.[0].length ?? 0)
|
|
492
|
+
.filter((indent) => indent > Number(jobIndent));
|
|
493
|
+
return {
|
|
494
|
+
...header,
|
|
495
|
+
end,
|
|
496
|
+
propertyIndent:
|
|
497
|
+
propertyIndents.length > 0 ? Math.min(...propertyIndents) : Number(jobIndent) + 2,
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
return { lines, jobs };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function jobProperty(lines, job, name) {
|
|
504
|
+
const matcher = new RegExp(
|
|
505
|
+
`^\\s*(?:"${name}"|'${name}'|${name}):\\s*(.*?)\\s*(?:#.*)?$`,
|
|
506
|
+
'i'
|
|
507
|
+
);
|
|
508
|
+
for (let index = job.start + 1; index < job.end; index += 1) {
|
|
509
|
+
if ((lines[index].match(/^\s*/)?.[0].length ?? 0) !== job.propertyIndent) continue;
|
|
510
|
+
const match = lines[index].match(matcher);
|
|
511
|
+
if (match) return { index, value: match[1].trim() };
|
|
512
|
+
}
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function unquoteYamlScalar(value) {
|
|
517
|
+
const text = String(value).trim();
|
|
518
|
+
const match = text.match(/^(['"])(.*)\1$/);
|
|
519
|
+
return match ? match[2].trim() : text;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function jobCondition(lines, job) {
|
|
523
|
+
const condition = jobProperty(lines, job, 'if');
|
|
524
|
+
if (!condition) return 'default';
|
|
525
|
+
const value = unquoteYamlScalar(condition.value);
|
|
526
|
+
if (/^(?:\$\{\{\s*)?always\(\)(?:\s*\}\})?$/i.test(value)) return 'always';
|
|
527
|
+
if (/^(?:true|\$\{\{\s*true\s*\}\})$/i.test(value)) return 'true';
|
|
528
|
+
return 'conditional';
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function jobNeeds(lines, job) {
|
|
532
|
+
const property = jobProperty(lines, job, 'needs');
|
|
533
|
+
if (!property) return { ids: [], indexes: [], valid: true };
|
|
534
|
+
const indexes = [property.index];
|
|
535
|
+
if (property.value) {
|
|
536
|
+
const value = unquoteYamlScalar(property.value);
|
|
537
|
+
const raw = value.startsWith('[') && value.endsWith(']')
|
|
538
|
+
? value.slice(1, -1).split(',')
|
|
539
|
+
: [value];
|
|
540
|
+
const ids = raw.map(unquoteYamlScalar).filter((id) => /^[A-Za-z0-9_-]+$/.test(id));
|
|
541
|
+
return { ids, indexes, valid: ids.length === raw.length && ids.length > 0 };
|
|
542
|
+
}
|
|
543
|
+
const ids = [];
|
|
544
|
+
for (let index = property.index + 1; index < job.end; index += 1) {
|
|
545
|
+
if (!lines[index].trim() || /^\s*#/.test(lines[index])) {
|
|
546
|
+
indexes.push(index);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const indent = lines[index].match(/^\s*/)?.[0].length ?? 0;
|
|
550
|
+
if (indent <= job.propertyIndent) break;
|
|
551
|
+
indexes.push(index);
|
|
552
|
+
const item = lines[index].match(/^\s*-\s*(['"]?)([A-Za-z0-9_-]+)\1\s*(?:#.*)?$/);
|
|
553
|
+
if (!item) return { ids: [], indexes, valid: false };
|
|
554
|
+
ids.push(item[2]);
|
|
555
|
+
}
|
|
556
|
+
return { ids, indexes, valid: ids.length > 0 };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function withVerifiedDependencyJobs(content) {
|
|
560
|
+
const { lines, jobs } = workflowJobSections(content);
|
|
561
|
+
const byId = new Map(jobs.map((job) => [job.id, job]));
|
|
562
|
+
const guaranteed = (job, seen = new Set()) => {
|
|
563
|
+
if (!job || seen.has(job.id)) return false;
|
|
564
|
+
const condition = jobCondition(lines, job);
|
|
565
|
+
if (condition === 'conditional') return false;
|
|
566
|
+
if (condition === 'always') return true;
|
|
567
|
+
const needs = jobNeeds(lines, job);
|
|
568
|
+
if (!needs.valid) return false;
|
|
569
|
+
const nextSeen = new Set(seen).add(job.id);
|
|
570
|
+
return needs.ids.every((id) => guaranteed(byId.get(id), nextSeen));
|
|
571
|
+
};
|
|
572
|
+
for (const job of jobs) {
|
|
573
|
+
const needs = jobNeeds(lines, job);
|
|
574
|
+
if (
|
|
575
|
+
needs.valid &&
|
|
576
|
+
needs.ids.length > 0 &&
|
|
577
|
+
needs.ids.every((id) => guaranteed(byId.get(id)))
|
|
578
|
+
) {
|
|
579
|
+
// The shared analyzer treats every `needs` as skippable. Hide it only after
|
|
580
|
+
// this dependency chain is proven unconditional; keep uncertain/skipped needs visible.
|
|
581
|
+
for (const index of needs.indexes) lines[index] = '';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return lines.join('\n');
|
|
585
|
+
}
|
|
586
|
+
|
|
157
587
|
export function hasArkWorkflow(root) {
|
|
158
588
|
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
159
589
|
if (!fs.existsSync(workflowsDir)) return false;
|
|
590
|
+
const declaredScript = architectureScript(root);
|
|
591
|
+
const script = isFailClosedArchitectureScript(declaredScript) ? declaredScript : '';
|
|
160
592
|
return fs
|
|
161
593
|
.readdirSync(workflowsDir)
|
|
162
594
|
.filter((file) => /\.ya?ml$/i.test(file))
|
|
163
595
|
.some((file) => {
|
|
164
596
|
try {
|
|
165
597
|
const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
|
|
166
|
-
return (
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
598
|
+
return FAIL_CLOSED_ARK_FLAG.test(
|
|
599
|
+
enforcingArkRunText(
|
|
600
|
+
withVerifiedDependencyJobs(withFailClosedArkActions(content)),
|
|
601
|
+
script
|
|
602
|
+
)
|
|
170
603
|
);
|
|
171
604
|
} catch {
|
|
172
605
|
return false;
|
|
@@ -176,10 +609,9 @@ export function hasArkWorkflow(root) {
|
|
|
176
609
|
|
|
177
610
|
export function missingGates(root) {
|
|
178
611
|
const compactHost = compactRouterHost(root);
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const missing = required.filter((relativePath) => !fs.existsSync(path.join(root, relativePath)));
|
|
612
|
+
const missing = [];
|
|
613
|
+
if (!hasArkAgentsContract(root)) missing.push('AGENTS.md');
|
|
614
|
+
if (!compactHost && !hasArkMcpRegistration(root)) missing.push('.mcp.json');
|
|
183
615
|
if (compactHost && !hasCompactHostRegistration(root, compactHost)) {
|
|
184
616
|
missing.push(`compact host registration (${compactHost})`);
|
|
185
617
|
}
|