arkgate 2.11.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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * MCP bin constants, dual-bin detection, and adoption gap collection.
3
+ * Deploy-path quality lives in deploy-path.mjs (re-exported below).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { arkCommand } from '../ark-shared.mjs';
8
+ import { CORE_LAYER_NAMES } from './core-layers.mjs';
9
+ import { falseGreenAdoptionGap } from './field-install.mjs';
10
+ import { assessCodexHomeMcp, codexConfigPath } from './codex-home.mjs';
11
+ import { detectWritePathCapabilities } from './write-path-detect.mjs';
12
+ import { skillTemplateNames } from './skill-install.mjs';
13
+ import { detectDeployPathQuality } from './deploy-path.mjs';
14
+
15
+ export { detectDeployPathQuality };
16
+
17
+ export const COMMAND_GATE_TEXT_FILES = [
18
+ '.claude/settings.json', 'AGENTS.md', '.cursor/rules/ark.mdc', '.windsurf/rules/ark.md',
19
+ '.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
20
+ '.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
21
+ '.grok/hooks/ark-write-gate.json', '.grok/config.toml',
22
+ ];
23
+ export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
24
+ // Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
25
+ // before re-emitting a single preferred bin — otherwise a partial rename leaves
26
+ // args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
27
+ export const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
28
+ export const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
29
+ export const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
30
+ // PREFERRED_MCP_BIN lives in hook-templates.mjs (re-exported above).
31
+ export const PREFERRED_CHECK_BIN = 'arkgate-check';
32
+ export const PREFERRED_CLI_BIN = 'arkgate';
33
+ // Runner argv noise that is not a bin argument (pnpm exec form).
34
+ export const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
35
+ // The runner token immediately before an ark command in a text command string.
36
+ // Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
37
+ // Longer bin names first so `arkgate-check` is not partially matched as `ark`.
38
+ export const RUNNER_BEFORE_ARK =
39
+ /\b(?:npx|pnpm --config\.verify-deps-before-run=false exec|pnpm exec|yarn)(?= (?:arkgate-check|arkgate-mcp|arkgate|ark-check|ark-mcp|ark)\b)/g;
40
+
41
+ /** Keep only MCP server flags from existing args (drop runner tokens + any ark* bin names). */
42
+
43
+ export function stripMcpServerArgs(args) {
44
+ if (!Array.isArray(args) || args.length === 0) {
45
+ return ['--root', '.', '--config', 'ark.config.json'];
46
+ }
47
+ const kept = args.filter(
48
+ (entry) =>
49
+ typeof entry === 'string' &&
50
+ !MCP_RUNNER_ARGV.has(entry) &&
51
+ !ARK_MCP_BINS.has(entry) &&
52
+ !ARK_CHECK_BINS.has(entry) &&
53
+ !ARK_CLI_BINS.has(entry)
54
+ );
55
+ return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
56
+ }
57
+
58
+ /** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
59
+ export function mcpArgsHaveDuplicateBins(args) {
60
+ if (!Array.isArray(args)) return false;
61
+ const hits = args.filter((entry) => ARK_MCP_BINS.has(entry));
62
+ return hits.length > 1 || (hits.length === 1 && args.indexOf(hits[0]) !== args.lastIndexOf(hits[0]));
63
+ }
64
+
65
+ export function brokenMcpGateFiles(root) {
66
+ const bad = [];
67
+ for (const rel of COMMAND_GATE_JSON_FILES) {
68
+ let json;
69
+ try {
70
+ json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
71
+ } catch {
72
+ continue;
73
+ }
74
+ const ark = json?.mcpServers?.ark;
75
+ if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
76
+ }
77
+ return bad;
78
+ }
79
+
80
+ /**
81
+ * Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
82
+ * @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
83
+ */
84
+ export function collectAdoptionGaps(root, config, coverage) {
85
+ const gaps = [];
86
+ const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
87
+ const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
88
+
89
+ // --- Write path: prepare-write / autoPatch / reject-only (W5) ---
90
+ const writePath = detectWritePathCapabilities(root);
91
+ // Only surface write-path gaps when the project has adopted gates (or has partial install).
92
+ // Producer package tree always has templates — still report capability for dogfood honesty.
93
+ if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
94
+ // Producer may be repair-capable via own templates; still useful. Skip "none" on pure
95
+ // consumer repos with zero Ark files? missingGates already covers that.
96
+ if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
97
+ gaps.push(writePath.gap);
98
+ }
99
+ }
100
+
101
+ // --- Repo MCP dual-bin ---
102
+ const dualMcp = brokenMcpGateFiles(root);
103
+ const mcp = {
104
+ dualBinFiles: dualMcp,
105
+ ok: dualMcp.length === 0,
106
+ };
107
+ if (dualMcp.length > 0) {
108
+ gaps.push({
109
+ id: 'mcp-dual-bin',
110
+ severity: 'warn',
111
+ message: `Broken MCP argv in ${dualMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp`,
112
+ fix: arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands'),
113
+ });
114
+ }
115
+
116
+ // --- Host completeness (only when project already adopted gates) ---
117
+ const hosts = [];
118
+ if (adopted && !isProducer) {
119
+ const skillNames = skillTemplateNames();
120
+ const hostChecks = [
121
+ {
122
+ host: 'grok',
123
+ dir: '.grok',
124
+ skill: (n) => path.join(root, '.grok', 'skills', n, 'SKILL.md'),
125
+ extras: [
126
+ ['.grok/hooks/ark-write-gate.json', 'write-gate hook'],
127
+ ['.grok/config.toml', 'project MCP config'],
128
+ ],
129
+ toolsFlag: 'grok',
130
+ },
131
+ {
132
+ host: 'claude',
133
+ dir: '.claude',
134
+ skill: (n) => path.join(root, '.claude', 'skills', n, 'SKILL.md'),
135
+ extras: [['.claude/settings.json', 'settings/hooks']],
136
+ toolsFlag: 'claude',
137
+ },
138
+ {
139
+ host: 'cursor',
140
+ dir: '.cursor',
141
+ skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
142
+ extras: [['.cursor/mcp.json', 'MCP config']],
143
+ toolsFlag: 'cursor',
144
+ },
145
+ ];
146
+ for (const h of hostChecks) {
147
+ if (!fs.existsSync(path.join(root, h.dir))) continue;
148
+ const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
149
+ const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
150
+ const complete = missingSkills.length === 0 && missingExtras.length === 0;
151
+ hosts.push({
152
+ host: h.host,
153
+ present: true,
154
+ complete,
155
+ missingSkills: missingSkills.length,
156
+ missingExtras: missingExtras.map(([, label]) => label),
157
+ });
158
+ if (!complete) {
159
+ gaps.push({
160
+ id: `host-${h.host}-incomplete`,
161
+ severity: 'warn',
162
+ message: `${h.host} dir present but incomplete (${missingSkills.length} skill(s) missing${
163
+ missingExtras.length ? `; missing ${missingExtras.map(([, l]) => l).join(', ')}` : ''
164
+ })`,
165
+ fix: arkCommand(
166
+ root,
167
+ 'ark-check',
168
+ `--install-agent-gates --tools ${h.toolsFlag} --force`
169
+ ),
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ // --- Codex home MCP (temp path / wrong root / multi-project) ---
176
+ let codexHome = null;
177
+ if (adopted && !isProducer) {
178
+ const codexFile = codexConfigPath();
179
+ let toml = '';
180
+ try {
181
+ if (fs.existsSync(codexFile)) toml = fs.readFileSync(codexFile, 'utf8');
182
+ } catch {
183
+ toml = '';
184
+ }
185
+ if (toml.includes('[mcp_servers.ark]')) {
186
+ const assessed = assessCodexHomeMcp(toml, root);
187
+ codexHome = {
188
+ file: codexFile,
189
+ root: assessed.root,
190
+ tempPath: assessed.tempPath,
191
+ wrongRoot: assessed.wrongRoot,
192
+ preferredBin: assessed.preferredBin,
193
+ needsRewrite: assessed.needsRewrite,
194
+ multiProject: assessed.multiProject,
195
+ scopedTable: assessed.scopedTable,
196
+ };
197
+ if (assessed.gap) {
198
+ gaps.push({
199
+ id: assessed.gap.id,
200
+ severity: assessed.gap.severity,
201
+ message: assessed.gap.message,
202
+ fix: arkCommand(root, 'ark-check', assessed.gap.fixArgs),
203
+ });
204
+ }
205
+ }
206
+ }
207
+
208
+ // --- Core layers optional but populated ---
209
+ const coreOptional = [];
210
+ const layerRows = coverage?.layers ?? [];
211
+ const countByName = new Map(layerRows.map((r) => [r.name, r.files]));
212
+ for (const layer of config?.layers ?? []) {
213
+ if (!CORE_LAYER_NAMES.has(layer.name)) continue;
214
+ if (layer.optional !== true) continue;
215
+ const files = countByName.get(layer.name) ?? 0;
216
+ if (files > 0) {
217
+ coreOptional.push({ layer: layer.name, files });
218
+ gaps.push({
219
+ id: `core-optional-${layer.name}`,
220
+ severity: 'info',
221
+ message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
222
+ fix: `${arkCommand(root, 'ark-check', '--ratchet-cores')} (when architecture is green: 0 active violations)`,
223
+ });
224
+ }
225
+ }
226
+
227
+ // --- Origin report ---
228
+ const originJson = path.join(root, '.ark', 'reports', 'origin.json');
229
+ const originReport = {
230
+ present: fs.existsSync(originJson),
231
+ path: '.ark/reports/origin.json',
232
+ };
233
+ if (adopted && !originReport.present && (coverage?.governed?.percent ?? 0) >= 50) {
234
+ gaps.push({
235
+ id: 'origin-report-missing',
236
+ severity: 'info',
237
+ message: 'No origin architecture snapshot under .ark/reports/ yet',
238
+ fix: arkCommand(root, 'ark-check', '--report ark-report.html'),
239
+ });
240
+ }
241
+
242
+ // --- Baseline policy ---
243
+ const baselinePath = path.join(root, '.ark-baseline.json');
244
+ const baselineExists = fs.existsSync(baselinePath);
245
+ let frozenKeys = 0;
246
+ if (baselineExists) {
247
+ try {
248
+ const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
249
+ frozenKeys = Array.isArray(raw.violations) ? raw.violations.length : 0;
250
+ } catch {
251
+ frozenKeys = 0;
252
+ }
253
+ }
254
+ let primaryPathUsesBaseline = false;
255
+ try {
256
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
257
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
258
+ primaryPathUsesBaseline = Object.values(scripts).some(
259
+ (s) => typeof s === 'string' && s.includes('--baseline')
260
+ );
261
+ } catch {
262
+ /* no package.json */
263
+ }
264
+ if (!primaryPathUsesBaseline) {
265
+ try {
266
+ const wfDir = path.join(root, '.github', 'workflows');
267
+ if (fs.existsSync(wfDir)) {
268
+ for (const f of fs.readdirSync(wfDir)) {
269
+ if (!/\.ya?ml$/i.test(f)) continue;
270
+ const text = fs.readFileSync(path.join(wfDir, f), 'utf8');
271
+ if (text.includes('--baseline') && (text.includes('ark-check') || text.includes('arkgate-check'))) {
272
+ primaryPathUsesBaseline = true;
273
+ break;
274
+ }
275
+ }
276
+ }
277
+ } catch {
278
+ /* ignore */
279
+ }
280
+ }
281
+ const baseline = {
282
+ exists: baselineExists,
283
+ frozenKeys,
284
+ primaryPathUsesBaseline,
285
+ signal: baselineExists
286
+ ? frozenKeys === 0
287
+ ? 'keep-empty'
288
+ : 'active-ratchet'
289
+ : 'absent',
290
+ };
291
+ if (adopted && baselineExists && frozenKeys === 0 && !primaryPathUsesBaseline) {
292
+ gaps.push({
293
+ id: 'baseline-unused',
294
+ severity: 'info',
295
+ message:
296
+ 'Empty .ark-baseline.json exists but primary scripts/CI do not pass --baseline (policy unclear)',
297
+ fix: 'Either add --baseline .ark-baseline.json to check:architecture / CI, or remove the unused baseline file',
298
+ });
299
+ }
300
+
301
+ // --- Educational layer balance (not a violation) ---
302
+ let layerBalance = null;
303
+ const total = layerRows.reduce((s, r) => s + (r.files || 0), 0);
304
+ if (total >= 20) {
305
+ const presentation = layerRows.find((r) => r.name === 'PresentationAdapters');
306
+ const domain = layerRows.find((r) => r.name === 'DomainModel');
307
+ if (presentation && domain) {
308
+ const pShare = presentation.files / total;
309
+ const dShare = domain.files / total;
310
+ if (pShare >= 0.5 && dShare < 0.1) {
311
+ layerBalance = {
312
+ kind: 'presentation-heavy-thin-domain',
313
+ presentationFiles: presentation.files,
314
+ domainFiles: domain.files,
315
+ totalFiles: total,
316
+ educational:
317
+ 'Presentation holds most of the tree while DomainModel is thin — common for UI apps; consider extracting domain types/use-cases as the product grows. Educational only (not a gate failure).',
318
+ };
319
+ }
320
+ }
321
+ }
322
+
323
+ // --- Empty scope: contract matches no TS/JS ---
324
+ if (!isProducer && (coverage?.governed?.totalFiles ?? coverage?.totalFiles) === 0) {
325
+ gaps.push({
326
+ id: 'empty-scope',
327
+ severity: 'warn',
328
+ message:
329
+ 'Empty scope: include paths match 0 TypeScript/JS files — checks are not governing this tree',
330
+ fix: `${arkCommand(root, 'ark-check', '--suggest-include')} then ${arkCommand(root, 'ark-check', '--adopt-contract --write')}`,
331
+ });
332
+ }
333
+
334
+ // --- Deploy-path quality (ESLint/types that production build hosts run) ---
335
+ // Universal: any Next/CRA/Nuxt (etc.) consumer. Not architecture — still adoption.
336
+ // Skip pure library producer (this monorepo) to avoid self-noise.
337
+ let deployPath = null;
338
+ if (!isProducer) {
339
+ deployPath = detectDeployPathQuality(root);
340
+ const eng =
341
+ deployPath.engines.length > 0 ? deployPath.engines.join('/') : 'production';
342
+ if (deployPath.embedsLintInBuild && !deployPath.hasLintScript) {
343
+ gaps.push({
344
+ id: 'deploy-path-lint-script-missing',
345
+ severity: 'warn',
346
+ message: `${eng} production build runs ESLint — no package.json lint script, so failures often surface first on the deploy host`,
347
+ fix: 'Add a package.json "lint" script (e.g. eslint .) matching production ESLint config; run it in CI and before merge',
348
+ });
349
+ } else if (
350
+ deployPath.embedsLintInBuild &&
351
+ deployPath.hasLintScript &&
352
+ deployPath.hasCiWorkflows &&
353
+ !deployPath.ciRunsLint
354
+ ) {
355
+ gaps.push({
356
+ id: 'deploy-path-lint-not-in-ci',
357
+ severity: 'warn',
358
+ message: `${eng} production build runs ESLint — CI workflows exist but do not run lint, so deploy hosts may be the first fail`,
359
+ fix: 'Add a CI step that runs your package.json lint script (npm run lint / pnpm lint / yarn lint) and require it before deploy',
360
+ });
361
+ } else if (
362
+ deployPath.embedsLintInBuild &&
363
+ deployPath.hasLintScript &&
364
+ !deployPath.hasCiWorkflows
365
+ ) {
366
+ gaps.push({
367
+ id: 'deploy-path-lint-no-ci',
368
+ severity: 'info',
369
+ message: `${eng} production build runs ESLint — no CI workflows detected; push-to-host builds may be the first lint fail`,
370
+ fix: 'Add CI (or a pre-push hook) that runs lint before the deploy host builds; keep branch protection required when using GitHub',
371
+ });
372
+ }
373
+
374
+ if (deployPath.embedsTypecheckInBuild && !deployPath.hasTypecheckScript) {
375
+ gaps.push({
376
+ id: 'deploy-path-typecheck-script-missing',
377
+ severity: 'info',
378
+ message: `${eng} production build typechecks — no package.json typecheck script for local/CI parity`,
379
+ fix: 'Add "typecheck": "tsc --noEmit" (or framework equivalent) and run it in CI alongside lint',
380
+ });
381
+ } else if (
382
+ deployPath.embedsTypecheckInBuild &&
383
+ deployPath.hasTypecheckScript &&
384
+ deployPath.hasCiWorkflows &&
385
+ !deployPath.ciRunsTypecheck
386
+ ) {
387
+ gaps.push({
388
+ id: 'deploy-path-typecheck-not-in-ci',
389
+ severity: 'info',
390
+ message: `${eng} production build typechecks — CI does not run typecheck; type errors may appear first on the deploy host`,
391
+ fix: 'Add a CI step for npm run typecheck (or your typecheck script) and require it before deploy',
392
+ });
393
+ }
394
+ }
395
+
396
+ // --- False-green contract (field-install detector; doctor skillGaps already cover missing skills) ---
397
+ let contractFalseGreen = null;
398
+ if (!isProducer && config) {
399
+ const gap = falseGreenAdoptionGap(root, config, coverage);
400
+ if (gap) {
401
+ contractFalseGreen = { risk: true, message: gap.message, fix: gap.fix };
402
+ gaps.push(gap);
403
+ }
404
+ }
405
+
406
+ return {
407
+ gaps,
408
+ hosts,
409
+ mcp,
410
+ codexHome,
411
+ coreOptional,
412
+ originReport,
413
+ baseline,
414
+ layerBalance,
415
+ deployPath,
416
+ contractFalseGreen,
417
+ writePath,
418
+ };
419
+ }
420
+
421
+ // Gate files whose Ark command runner doesn't match this project's package manager — the
422
+ // advisory (and --migrate-commands) target. Returns [] for npm/unknown projects (npx is right)
423
+ // so the check is silent unless there's a real mismatch.
@@ -259,9 +259,12 @@ export const ARCHITECTURE_PRESETS = {
259
259
  { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
260
260
  { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
261
261
  { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
262
+ { from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
262
263
  { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
263
264
  { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
265
+ { from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
264
266
  { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
267
+ { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
265
268
  ],
266
269
  },
267
270
  root