arkgate 2.10.0 → 2.12.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 +107 -0
- package/README.md +21 -12
- package/SECURITY.md +3 -4
- package/bin/ark-check.mjs +41 -16
- package/bin/ark-mcp.mjs +54 -10
- package/bin/ark.mjs +87 -24
- package/bin/lib/agent-gates.mjs +68 -2090
- package/bin/lib/architecture-scan.mjs +4 -1
- package/bin/lib/baseline-key.mjs +17 -0
- package/bin/lib/ci-and-commands.mjs +386 -0
- package/bin/lib/config-warnings.mjs +22 -0
- package/bin/lib/core-layers.mjs +7 -0
- package/bin/lib/core-ratchet.mjs +3 -7
- package/bin/lib/deploy-path.mjs +205 -0
- package/bin/lib/doctor-plan.mjs +29 -5
- package/bin/lib/gate-files.mjs +223 -0
- package/bin/lib/hook-templates.mjs +99 -0
- package/bin/lib/install-migrate.mjs +442 -0
- package/bin/lib/mcp-adoption.mjs +423 -0
- package/bin/lib/presets.mjs +3 -0
- package/bin/lib/safety-diagnostics.mjs +263 -0
- package/bin/lib/scan-files.mjs +51 -6
- package/bin/lib/skill-install.mjs +259 -0
- package/bin/lib/typescript-host.mjs +88 -0
- package/bin/lib/violations.mjs +3 -3
- package/bin/lib/write-path-detect.mjs +138 -0
- package/dist/index.cjs +103 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +103 -8
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +18 -5
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +1 -1
- package/dist/nestjs/index.d.ts +1 -1
- package/dist/nestjs/index.js +18 -5
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +103 -8
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +103 -8
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-D6Q8WHes.d.cts → types-BZ17b9i5.d.cts} +5 -1
- package/dist/{types-D6Q8WHes.d.ts → types-BZ17b9i5.d.ts} +5 -1
- package/docs/agent-guide.md +12 -2
- package/docs/ai-gates.md +20 -2
- package/docs/package-surface.md +10 -3
- package/docs/production-hardening.md +5 -0
- package/package.json +5 -2
- package/server.json +2 -2
- package/templates/skills/ark-autopilot.md +77 -45
- package/templates/skills/ark-explain.md +2 -1
- package/templates/skills/ark-explore.md +135 -34
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production deploy-path quality signals (install modularization).
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { readPackageJson, packageScriptsHaveTypecheck } from './gate-files.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Production deploy path quality (universal — any consumer repo).
|
|
10
|
+
* Detects when the production build host runs ESLint / typecheck as part of
|
|
11
|
+
* `build` (e.g. Next.js "Linting and checking validity of types") so failures
|
|
12
|
+
* surface first on Vercel/Netlify/etc. unless CI/pre-merge runs the same checks.
|
|
13
|
+
* Framework signals only (deps + scripts + config) — never project-specific.
|
|
14
|
+
*
|
|
15
|
+
* @returns {{
|
|
16
|
+
* embedsLintInBuild: boolean,
|
|
17
|
+
* embedsTypecheckInBuild: boolean,
|
|
18
|
+
* engines: string[],
|
|
19
|
+
* hasLintScript: boolean,
|
|
20
|
+
* hasTypecheckScript: boolean,
|
|
21
|
+
* ciRunsLint: boolean,
|
|
22
|
+
* ciRunsTypecheck: boolean,
|
|
23
|
+
* eslintIgnoreDuringBuilds: boolean,
|
|
24
|
+
* }}
|
|
25
|
+
*/
|
|
26
|
+
export function detectDeployPathQuality(root) {
|
|
27
|
+
const pkg = readPackageJson(root) || {};
|
|
28
|
+
const deps = {
|
|
29
|
+
...(pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {}),
|
|
30
|
+
...(pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {}),
|
|
31
|
+
...(pkg.peerDependencies && typeof pkg.peerDependencies === 'object' ? pkg.peerDependencies : {}),
|
|
32
|
+
};
|
|
33
|
+
const scripts =
|
|
34
|
+
pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
35
|
+
const buildScript = typeof scripts.build === 'string' ? scripts.build : '';
|
|
36
|
+
|
|
37
|
+
const engines = [];
|
|
38
|
+
// Next.js production build runs ESLint + typecheck by default (unless opted out).
|
|
39
|
+
if (deps.next || /\bnext\s+build\b/.test(buildScript)) engines.push('next');
|
|
40
|
+
// Nuxt 3+ can lint via modules; only flag when build clearly invokes nuxt build + eslint tooling present.
|
|
41
|
+
if ((deps.nuxt || deps['nuxt3'] || /\bnuxt\s+build\b/.test(buildScript)) && (deps.eslint || hasEslintConfig(root))) {
|
|
42
|
+
engines.push('nuxt');
|
|
43
|
+
}
|
|
44
|
+
// Create React App historically failed build on ESLint errors.
|
|
45
|
+
if (deps['react-scripts'] || /\breact-scripts\s+build\b/.test(buildScript)) engines.push('cra');
|
|
46
|
+
|
|
47
|
+
const eslintIgnoreDuringBuilds = engines.includes('next') && nextIgnoresEslintDuringBuilds(root);
|
|
48
|
+
const embedsLintInBuild = engines.length > 0 && !eslintIgnoreDuringBuilds;
|
|
49
|
+
// Next still typechecks during build even when eslint.ignoreDuringBuilds is true.
|
|
50
|
+
const embedsTypecheckInBuild = engines.includes('next') || engines.includes('nuxt');
|
|
51
|
+
|
|
52
|
+
const scriptHasLint = (s) =>
|
|
53
|
+
Boolean(
|
|
54
|
+
s &&
|
|
55
|
+
((typeof s.lint === 'string' && s.lint.trim()) ||
|
|
56
|
+
(typeof s.eslint === 'string' && s.eslint.trim()) ||
|
|
57
|
+
(typeof s['lint:ci'] === 'string' && s['lint:ci'].trim()) ||
|
|
58
|
+
(typeof s['check:lint'] === 'string' && s['check:lint'].trim()))
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
let hasLintScript = scriptHasLint(scripts);
|
|
62
|
+
let hasTypecheckScript = packageScriptsHaveTypecheck(scripts);
|
|
63
|
+
const packageLintScripts = [];
|
|
64
|
+
// Monorepo: package-level scripts count (apps/web, packages/ui, …).
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
67
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
68
|
+
const candidates = [path.join(root, entry.name)];
|
|
69
|
+
// one more level: packages/foo
|
|
70
|
+
try {
|
|
71
|
+
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
72
|
+
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
73
|
+
candidates.push(path.join(root, entry.name, child.name));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
/* ignore */
|
|
78
|
+
}
|
|
79
|
+
for (const dir of candidates) {
|
|
80
|
+
const pj = path.join(dir, 'package.json');
|
|
81
|
+
if (!fs.existsSync(pj)) continue;
|
|
82
|
+
try {
|
|
83
|
+
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
84
|
+
const ns = nested.scripts && typeof nested.scripts === 'object' ? nested.scripts : {};
|
|
85
|
+
if (scriptHasLint(ns)) {
|
|
86
|
+
hasLintScript = true;
|
|
87
|
+
packageLintScripts.push(path.relative(root, dir).split(path.sep).join('/'));
|
|
88
|
+
}
|
|
89
|
+
if (packageScriptsHaveTypecheck(ns)) hasTypecheckScript = true;
|
|
90
|
+
const nd = {
|
|
91
|
+
...(nested.dependencies || {}),
|
|
92
|
+
...(nested.devDependencies || {}),
|
|
93
|
+
};
|
|
94
|
+
if (nd.next && !engines.includes('next')) engines.push('next');
|
|
95
|
+
} catch {
|
|
96
|
+
/* ignore */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ciTexts = collectCiWorkflowTexts(root);
|
|
105
|
+
const ciJoined = ciTexts.join('\n');
|
|
106
|
+
const ciRunsLint =
|
|
107
|
+
ciTexts.length > 0 &&
|
|
108
|
+
(/\bnpm\s+run\s+lint\b/i.test(ciJoined) ||
|
|
109
|
+
/\bpnpm\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
110
|
+
/\byarn\s+(?:run\s+)?lint\b/i.test(ciJoined) ||
|
|
111
|
+
/\bbun\s+run\s+lint\b/i.test(ciJoined) ||
|
|
112
|
+
/\beslint\b/i.test(ciJoined) ||
|
|
113
|
+
/\blint:ci\b/i.test(ciJoined) ||
|
|
114
|
+
/\bcheck:lint\b/i.test(ciJoined) ||
|
|
115
|
+
// package-level: working-directory + lint, or path/filter lint
|
|
116
|
+
(packageLintScripts.length > 0 &&
|
|
117
|
+
packageLintScripts.some((p) => ciJoined.includes(p) && /lint/i.test(ciJoined))));
|
|
118
|
+
const ciRunsTypecheck =
|
|
119
|
+
ciTexts.length > 0 &&
|
|
120
|
+
(/\btypecheck\b/i.test(ciJoined) ||
|
|
121
|
+
/\btype-check\b/i.test(ciJoined) ||
|
|
122
|
+
/\bcheck:types\b/i.test(ciJoined) ||
|
|
123
|
+
/\btsc\s+--noEmit\b/i.test(ciJoined));
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
embedsLintInBuild,
|
|
127
|
+
embedsTypecheckInBuild,
|
|
128
|
+
engines,
|
|
129
|
+
hasLintScript,
|
|
130
|
+
hasTypecheckScript,
|
|
131
|
+
ciRunsLint,
|
|
132
|
+
ciRunsTypecheck,
|
|
133
|
+
eslintIgnoreDuringBuilds,
|
|
134
|
+
hasCiWorkflows: ciTexts.length > 0,
|
|
135
|
+
packageLintScripts,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasEslintConfig(root) {
|
|
140
|
+
return [
|
|
141
|
+
'eslint.config.mjs',
|
|
142
|
+
'eslint.config.js',
|
|
143
|
+
'eslint.config.cjs',
|
|
144
|
+
'eslint.config.ts',
|
|
145
|
+
'.eslintrc.json',
|
|
146
|
+
'.eslintrc.cjs',
|
|
147
|
+
'.eslintrc.js',
|
|
148
|
+
'.eslintrc.yml',
|
|
149
|
+
'.eslintrc.yaml',
|
|
150
|
+
].some((f) => fs.existsSync(path.join(root, f)));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** next.config.* eslint.ignoreDuringBuilds: true → production build will not fail on ESLint. */
|
|
154
|
+
function nextIgnoresEslintDuringBuilds(root) {
|
|
155
|
+
const names = [
|
|
156
|
+
'next.config.ts',
|
|
157
|
+
'next.config.mts',
|
|
158
|
+
'next.config.js',
|
|
159
|
+
'next.config.mjs',
|
|
160
|
+
'next.config.cjs',
|
|
161
|
+
];
|
|
162
|
+
for (const name of names) {
|
|
163
|
+
const file = path.join(root, name);
|
|
164
|
+
if (!fs.existsSync(file)) continue;
|
|
165
|
+
try {
|
|
166
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
167
|
+
// Common patterns: ignoreDuringBuilds: true | ignoreDuringBuilds: true,
|
|
168
|
+
if (/ignoreDuringBuilds\s*:\s*true/.test(text)) return true;
|
|
169
|
+
} catch {
|
|
170
|
+
/* ignore */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function collectCiWorkflowTexts(root) {
|
|
177
|
+
const texts = [];
|
|
178
|
+
const pushFile = (rel) => {
|
|
179
|
+
try {
|
|
180
|
+
const full = path.join(root, rel);
|
|
181
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
|
182
|
+
texts.push(fs.readFileSync(full, 'utf8'));
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
pushFile('.gitlab-ci.yml');
|
|
189
|
+
pushFile('bitbucket-pipelines.yml');
|
|
190
|
+
pushFile('azure-pipelines.yml');
|
|
191
|
+
pushFile('.circleci/config.yml');
|
|
192
|
+
const wfDir = path.join(root, '.github', 'workflows');
|
|
193
|
+
try {
|
|
194
|
+
if (fs.existsSync(wfDir)) {
|
|
195
|
+
for (const f of fs.readdirSync(wfDir)) {
|
|
196
|
+
if (!/\.ya?ml$/i.test(f)) continue;
|
|
197
|
+
pushFile(path.join('.github', 'workflows', f));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore */
|
|
202
|
+
}
|
|
203
|
+
return texts;
|
|
204
|
+
}
|
|
205
|
+
|
package/bin/lib/doctor-plan.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
staleRunnerGateFiles,
|
|
20
20
|
} from './agent-gates.mjs';
|
|
21
21
|
import {
|
|
22
|
-
|
|
22
|
+
baselineOccurrenceKeys,
|
|
23
23
|
readBaseline,
|
|
24
24
|
summarizeViolations,
|
|
25
25
|
violationEdge,
|
|
@@ -272,9 +272,10 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
272
272
|
// Prefer writePath from adoption (same detector); recompute only if missing (tests/stubs).
|
|
273
273
|
const writePath = adoption.writePath ?? detectWritePathCapabilities(root);
|
|
274
274
|
const baseline = readBaseline(root, '.ark-baseline.json');
|
|
275
|
-
const
|
|
275
|
+
const occurrenceKeys = baselineOccurrenceKeys(violations);
|
|
276
|
+
const currentKeys = new Set(occurrenceKeys);
|
|
276
277
|
const suppressed = baseline.exists
|
|
277
|
-
?
|
|
278
|
+
? occurrenceKeys.filter((key) => baseline.keys.has(key)).length
|
|
278
279
|
: 0;
|
|
279
280
|
const staleBaseline = baseline.exists
|
|
280
281
|
? [...baseline.keys].filter((key) => !currentKeys.has(key)).length
|
|
@@ -347,6 +348,7 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
347
348
|
: { gap: null }),
|
|
348
349
|
},
|
|
349
350
|
adoption,
|
|
351
|
+
safety: options.safety,
|
|
350
352
|
newHere: showNewHere
|
|
351
353
|
? {
|
|
352
354
|
show: true,
|
|
@@ -592,11 +594,33 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
|
|
|
592
594
|
line(ok, 'Origin architecture snapshot present (.ark/reports/origin.json)');
|
|
593
595
|
}
|
|
594
596
|
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log(color.bold('Safety / bypass resistance'));
|
|
599
|
+
const safety = options.safety;
|
|
600
|
+
if (!safety) {
|
|
601
|
+
line(warn, 'Safety diagnostics unavailable');
|
|
602
|
+
} else {
|
|
603
|
+
const rows = [
|
|
604
|
+
['Non-literal dynamic imports', safety.nonLiteralDynamicImports],
|
|
605
|
+
['@ts-ignore / @ts-nocheck', safety.tsSuppressions],
|
|
606
|
+
['Explicit any casts', safety.anyCasts],
|
|
607
|
+
['InMemory stores in production source', safety.inMemoryProductionStores],
|
|
608
|
+
['Rules with peerIsolation: false', safety.disabledPeerIsolationRules],
|
|
609
|
+
];
|
|
610
|
+
for (const [label, entries] of rows) {
|
|
611
|
+
line(entries.length === 0 ? ok : warn, `${label}: ${entries.length}`);
|
|
612
|
+
}
|
|
613
|
+
if (rows.some(([, entries]) => entries.length > 0)) {
|
|
614
|
+
actions.push('resolve strict safety diagnostics before treating CI as enforcement');
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
595
618
|
console.log('');
|
|
596
619
|
if (actions.length === 0) {
|
|
597
620
|
console.log(color.green('✔ Healthy — nothing to do.'));
|
|
598
621
|
} else {
|
|
599
|
-
|
|
600
|
-
|
|
622
|
+
const uniqueActions = [...new Set(actions.filter(Boolean))];
|
|
623
|
+
console.log(color.bold(`Top actions (${uniqueActions.length}):`));
|
|
624
|
+
uniqueActions.forEach((action, index) => console.log(` ${index + 1}. ${action}`));
|
|
601
625
|
}
|
|
602
626
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate file IO: package.json helpers, template writes, required gates.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
export const __packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
9
|
+
export const __arkCheckCli = path.join(__packageRoot, 'bin', 'ark-check.mjs');
|
|
10
|
+
|
|
11
|
+
export function readJson(file) {
|
|
12
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readPackageJson(root) {
|
|
16
|
+
const file = path.join(root, 'package.json');
|
|
17
|
+
if (!fs.existsSync(file)) return null;
|
|
18
|
+
return readJson(file);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hasCheckArchitectureScript(root) {
|
|
22
|
+
const pkg = readPackageJson(root);
|
|
23
|
+
return Boolean(pkg?.scripts?.['check:architecture']);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether package.json scripts already expose a typecheck-like command.
|
|
28
|
+
* Shared by deploy-path quality + typecheck bootstrap (single definition).
|
|
29
|
+
* @param {Record<string, unknown>|null|undefined} scripts
|
|
30
|
+
*/
|
|
31
|
+
export function packageScriptsHaveTypecheck(scripts) {
|
|
32
|
+
if (!scripts || typeof scripts !== 'object') return false;
|
|
33
|
+
return Boolean(
|
|
34
|
+
(typeof scripts.typecheck === 'string' && scripts.typecheck.trim()) ||
|
|
35
|
+
(typeof scripts['type-check'] === 'string' && scripts['type-check'].trim()) ||
|
|
36
|
+
(typeof scripts['check:types'] === 'string' && scripts['check:types'].trim()) ||
|
|
37
|
+
(typeof scripts.tsc === 'string' && /\btsc\b/.test(scripts.tsc))
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Root package (and shallow nested packages) already have a typecheck script.
|
|
43
|
+
* Does not scan CI or framework configs — only package.json scripts.
|
|
44
|
+
* @param {string} root
|
|
45
|
+
*/
|
|
46
|
+
export function treeHasTypecheckScript(root) {
|
|
47
|
+
const pkg = readPackageJson(root);
|
|
48
|
+
if (packageScriptsHaveTypecheck(pkg?.scripts)) return true;
|
|
49
|
+
try {
|
|
50
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
51
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
52
|
+
const candidates = [path.join(root, entry.name)];
|
|
53
|
+
try {
|
|
54
|
+
for (const child of fs.readdirSync(path.join(root, entry.name), { withFileTypes: true })) {
|
|
55
|
+
if (child.isDirectory() && !child.name.startsWith('.')) {
|
|
56
|
+
candidates.push(path.join(root, entry.name, child.name));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
for (const dir of candidates) {
|
|
63
|
+
const pj = path.join(dir, 'package.json');
|
|
64
|
+
if (!fs.existsSync(pj)) continue;
|
|
65
|
+
try {
|
|
66
|
+
const nested = JSON.parse(fs.readFileSync(pj, 'utf8'));
|
|
67
|
+
if (packageScriptsHaveTypecheck(nested.scripts)) return true;
|
|
68
|
+
} catch {
|
|
69
|
+
/* ignore */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
/* ignore */
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Add a conservative `typecheck` script when the host has a TS/JS project config
|
|
81
|
+
* but no typecheck-like script yet. Never overwrites an existing script.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} root
|
|
84
|
+
* @param {{ write?: boolean }} [opts]
|
|
85
|
+
* @returns {{
|
|
86
|
+
* changed: boolean,
|
|
87
|
+
* reason: 'added' | 'already' | 'no-tsconfig' | 'no-package-json',
|
|
88
|
+
* script?: string,
|
|
89
|
+
* }}
|
|
90
|
+
*/
|
|
91
|
+
export function ensureTypecheckScript(root, opts = {}) {
|
|
92
|
+
const write = opts.write !== false;
|
|
93
|
+
const hasTsconfig =
|
|
94
|
+
fs.existsSync(path.join(root, 'tsconfig.json')) ||
|
|
95
|
+
fs.existsSync(path.join(root, 'jsconfig.json'));
|
|
96
|
+
if (!hasTsconfig) return { changed: false, reason: 'no-tsconfig' };
|
|
97
|
+
|
|
98
|
+
const pkgPath = path.join(root, 'package.json');
|
|
99
|
+
if (!fs.existsSync(pkgPath)) return { changed: false, reason: 'no-package-json' };
|
|
100
|
+
|
|
101
|
+
if (treeHasTypecheckScript(root)) {
|
|
102
|
+
return { changed: false, reason: 'already' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const pkg = readPackageJson(root) || {};
|
|
106
|
+
const scripts =
|
|
107
|
+
pkg.scripts && typeof pkg.scripts === 'object' ? { ...pkg.scripts } : {};
|
|
108
|
+
const script = 'tsc --noEmit';
|
|
109
|
+
scripts.typecheck = script;
|
|
110
|
+
if (write) {
|
|
111
|
+
const next = { ...pkg, scripts };
|
|
112
|
+
fs.writeFileSync(pkgPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
113
|
+
}
|
|
114
|
+
return { changed: true, reason: 'added', script };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const REQUIRED_GATE_FILES = [
|
|
118
|
+
'AGENTS.md',
|
|
119
|
+
'.mcp.json',
|
|
120
|
+
];
|
|
121
|
+
const REQUIRED_GATE_WORKFLOW = '.github/workflows/*.yml running ark-check';
|
|
122
|
+
|
|
123
|
+
export function hasArkWorkflow(root) {
|
|
124
|
+
const workflowsDir = path.join(root, '.github', 'workflows');
|
|
125
|
+
if (!fs.existsSync(workflowsDir)) return false;
|
|
126
|
+
return fs
|
|
127
|
+
.readdirSync(workflowsDir)
|
|
128
|
+
.filter((file) => /\.ya?ml$/i.test(file))
|
|
129
|
+
.some((file) => {
|
|
130
|
+
try {
|
|
131
|
+
const content = fs.readFileSync(path.join(workflowsDir, file), 'utf8');
|
|
132
|
+
return (
|
|
133
|
+
/\bark-check\b/.test(content) ||
|
|
134
|
+
/\bcheck:architecture\b/.test(content) ||
|
|
135
|
+
/\buses\s*:\s*['"]?[^'"\s#]+\/arkgate@/i.test(content)
|
|
136
|
+
);
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function missingGates(root) {
|
|
144
|
+
const missing = REQUIRED_GATE_FILES.filter(
|
|
145
|
+
(relativePath) => !fs.existsSync(path.join(root, relativePath))
|
|
146
|
+
);
|
|
147
|
+
if (!hasArkWorkflow(root)) missing.push(REQUIRED_GATE_WORKFLOW);
|
|
148
|
+
return missing;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function ensureDirForFile(file) {
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* True when AGENTS.md is wholly Ark-owned (header is Ark Enforcement).
|
|
157
|
+
* Project guides that merely append an Ark section must remain non-Ark so --force
|
|
158
|
+
* never wipes them.
|
|
159
|
+
*/
|
|
160
|
+
export function isArkAgentsContent(text) {
|
|
161
|
+
if (typeof text !== 'string' || !text.trim()) return false;
|
|
162
|
+
const head = text.trimStart().slice(0, 120);
|
|
163
|
+
return /^#\s*Ark(Gate)?\s+Enforcement\b/.test(head);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* True when AGENTS.md is the **library mother-repo** self-hosted guide (Identity block).
|
|
168
|
+
* Never replace with the consumer install template — even under `--force`.
|
|
169
|
+
*/
|
|
170
|
+
export function isSelfHostedLibraryAgents(text) {
|
|
171
|
+
if (typeof text !== 'string' || !text.trim()) return false;
|
|
172
|
+
return (
|
|
173
|
+
/##\s*Identity\s*[—\-–-]\s*read this first/i.test(text) ||
|
|
174
|
+
/mother\s*\/\s*canonical development repository/i.test(text) ||
|
|
175
|
+
/Git\s*\/\s*clone only/i.test(text)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function writeTemplate(root, relativePath, content, force) {
|
|
180
|
+
const fullPath = path.join(root, relativePath);
|
|
181
|
+
if (relativePath === 'AGENTS.md' && fs.existsSync(fullPath)) {
|
|
182
|
+
let existing = '';
|
|
183
|
+
try {
|
|
184
|
+
existing = fs.readFileSync(fullPath, 'utf8');
|
|
185
|
+
} catch {
|
|
186
|
+
existing = '';
|
|
187
|
+
}
|
|
188
|
+
// Library authoring tree: keep Identity + 4-layer dogfood contract forever.
|
|
189
|
+
if (existing && isSelfHostedLibraryAgents(existing)) {
|
|
190
|
+
return { relativePath, status: 'skipped-self-hosted' };
|
|
191
|
+
}
|
|
192
|
+
if (existing && !isArkAgentsContent(existing)) {
|
|
193
|
+
// Never clobber a project-owned AGENTS.md — even with --force.
|
|
194
|
+
// If Ark section not present yet, merge once; subsequent runs leave it alone.
|
|
195
|
+
const hasArkSection =
|
|
196
|
+
/#\s*Ark(Gate)?\s+Enforcement\b/.test(existing) ||
|
|
197
|
+
/ark\.config\.json is authoritative/i.test(existing);
|
|
198
|
+
if (force && isArkAgentsContent(content) && !hasArkSection) {
|
|
199
|
+
try {
|
|
200
|
+
const merged = `${existing.replace(/\s*$/, '')}\n\n---\n\n${content}`;
|
|
201
|
+
ensureDirForFile(fullPath);
|
|
202
|
+
fs.writeFileSync(fullPath, merged);
|
|
203
|
+
return { relativePath, status: 'merged' };
|
|
204
|
+
} catch {
|
|
205
|
+
return { relativePath, status: 'failed' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { relativePath, status: 'skipped-non-ark' };
|
|
209
|
+
}
|
|
210
|
+
if (!force && isArkAgentsContent(existing)) {
|
|
211
|
+
return { relativePath, status: 'skipped' };
|
|
212
|
+
}
|
|
213
|
+
} else if (fs.existsSync(fullPath) && !force) {
|
|
214
|
+
return { relativePath, status: 'skipped' };
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
ensureDirForFile(fullPath);
|
|
218
|
+
fs.writeFileSync(fullPath, content);
|
|
219
|
+
return { relativePath, status: 'written' };
|
|
220
|
+
} catch {
|
|
221
|
+
return { relativePath, status: 'failed' };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host hook / MCP project templates for agent-gate install (Claude, Grok).
|
|
3
|
+
* Kept out of agent-gates.mjs so install orchestration stays scannable (explore gap #5).
|
|
4
|
+
*/
|
|
5
|
+
import { execCommandParts, execRunner } from '../ark-shared.mjs';
|
|
6
|
+
|
|
7
|
+
/** Preferred MCP binary name for generated hooks (package dual-bin). */
|
|
8
|
+
export const PREFERRED_MCP_BIN = 'arkgate-mcp';
|
|
9
|
+
|
|
10
|
+
export function claudeSettings(root) {
|
|
11
|
+
const runner = execRunner(root);
|
|
12
|
+
return `${JSON.stringify({
|
|
13
|
+
hooks: {
|
|
14
|
+
// Inject the contract at session start so the agent knows the architecture from
|
|
15
|
+
// the first token. Project-scoped by design; --session-context is also a silent
|
|
16
|
+
// no-op when no ark.config.json exists, so it can never leak into other projects.
|
|
17
|
+
SessionStart: [
|
|
18
|
+
{
|
|
19
|
+
hooks: [
|
|
20
|
+
{
|
|
21
|
+
type: 'command',
|
|
22
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
PreToolUse: [
|
|
28
|
+
{
|
|
29
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
30
|
+
hooks: [
|
|
31
|
+
{
|
|
32
|
+
type: 'command',
|
|
33
|
+
// W4: --hook-repair emits ARK_REPAIR_JSON / ARK_AUTOPATCH_JSON on deny
|
|
34
|
+
// (still exit 2 — never silent write). Omit --hook-repair for reject-only prose.
|
|
35
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "$CLAUDE_PROJECT_DIR" --config ark.config.json`,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
}, null, 2)}\n`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Grok Build project config: MCP registration (commit-friendly relative paths — unlike
|
|
45
|
+
// Codex's global config.toml, Grok loads .grok/config.toml from the project).
|
|
46
|
+
export function grokProjectConfig(root) {
|
|
47
|
+
const { command, args } = execCommandParts(root, PREFERRED_MCP_BIN, [
|
|
48
|
+
'--root',
|
|
49
|
+
'.',
|
|
50
|
+
'--config',
|
|
51
|
+
'ark.config.json',
|
|
52
|
+
]);
|
|
53
|
+
const argsToml = args.map((value) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`).join(', ');
|
|
54
|
+
return `# Generated by ark-check --install-agent-gates (Grok Build project scope).
|
|
55
|
+
# Restart Grok (or /mcps → refresh) after changes. Also loads repo-root .mcp.json.
|
|
56
|
+
[mcp_servers.ark]
|
|
57
|
+
command = "${command.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
|
|
58
|
+
args = [${argsToml}]
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Grok Build hooks: same arkgate-mcp contracts as Claude. Grok sets both
|
|
63
|
+
// GROK_WORKSPACE_ROOT and CLAUDE_PROJECT_DIR (Claude-compatible alias). Prefer
|
|
64
|
+
// GROK_* with fallback so hooks still work if only one is present.
|
|
65
|
+
// Matcher keeps Claude names (Write|Edit|MultiEdit) and Grok natives
|
|
66
|
+
// (write|search_replace) — Grok aliases both directions.
|
|
67
|
+
export function grokHooks(root) {
|
|
68
|
+
const runner = execRunner(root);
|
|
69
|
+
// Nested defaults: Grok native → Claude alias → project cwd (hook cwd is the workspace).
|
|
70
|
+
const grokRoot = '${GROK_WORKSPACE_ROOT:-${CLAUDE_PROJECT_DIR:-.}}';
|
|
71
|
+
return `${JSON.stringify({
|
|
72
|
+
hooks: {
|
|
73
|
+
SessionStart: [
|
|
74
|
+
{
|
|
75
|
+
hooks: [
|
|
76
|
+
{
|
|
77
|
+
type: 'command',
|
|
78
|
+
timeout: 30,
|
|
79
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --session-context --root "${grokRoot}" --config ark.config.json`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
PreToolUse: [
|
|
85
|
+
{
|
|
86
|
+
matcher: 'Write|Edit|MultiEdit|write|search_replace',
|
|
87
|
+
hooks: [
|
|
88
|
+
{
|
|
89
|
+
type: 'command',
|
|
90
|
+
timeout: 30,
|
|
91
|
+
// W4: --hook-repair → structured autoPatch on deny (hard block still).
|
|
92
|
+
command: `${runner} ${PREFERRED_MCP_BIN} --hook --hook-repair --root "${grokRoot}" --config ark.config.json`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
}, null, 2)}\n`;
|
|
99
|
+
}
|