arkgate 2.11.0 → 2.13.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 +147 -0
- package/README.md +70 -41
- package/bin/ark-check.mjs +95 -36
- package/bin/ark-mcp.mjs +11 -5
- package/bin/ark-shared.mjs +88 -56
- package/bin/ark.mjs +97 -29
- package/bin/lib/agent-gates.mjs +79 -2093
- package/bin/lib/architecture-scan.mjs +8 -0
- package/bin/lib/ci-and-commands.mjs +392 -0
- package/bin/lib/codex-home.mjs +7 -0
- package/bin/lib/config-contract.mjs +331 -0
- package/bin/lib/deploy-path.mjs +205 -0
- package/bin/lib/doctor-plan.mjs +43 -16
- package/bin/lib/enforcement-profiles.mjs +97 -0
- package/bin/lib/gate-files.mjs +223 -0
- package/bin/lib/hook-templates.mjs +99 -0
- package/bin/lib/host-support-matrix.mjs +77 -0
- package/bin/lib/install-migrate.mjs +473 -0
- package/bin/lib/mcp-adoption.mjs +455 -0
- package/bin/lib/open-html.mjs +75 -0
- package/bin/lib/presets.mjs +6 -2
- package/bin/lib/safety-diagnostics.mjs +31 -11
- package/bin/lib/skill-install.mjs +323 -0
- package/bin/lib/ts-resolve.mjs +2 -1
- package/bin/lib/typescript-host.mjs +88 -0
- package/bin/lib/weakest-link.mjs +417 -0
- package/bin/lib/write-path-capabilities.mjs +182 -0
- package/bin/lib/write-path-detect.mjs +101 -0
- package/dist/configContract-iBLxx5Tz.d.cts +53 -0
- package/dist/configContract-iBLxx5Tz.d.ts +53 -0
- package/dist/eslint/index.cjs +375 -13
- package/dist/eslint/index.cjs.map +1 -1
- package/dist/eslint/index.d.cts +30 -20
- package/dist/eslint/index.d.ts +30 -20
- package/dist/eslint/index.js +375 -13
- package/dist/eslint/index.js.map +1 -1
- package/dist/index.cjs +723 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +95 -5
- package/dist/index.d.ts +95 -5
- package/dist/index.js +716 -61
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +150 -42
- package/dist/nestjs/index.cjs.map +1 -1
- package/dist/nestjs/index.d.cts +2 -1
- package/dist/nestjs/index.d.ts +2 -1
- package/dist/nestjs/index.js +150 -42
- package/dist/nestjs/index.js.map +1 -1
- package/dist/runtime/index.cjs +723 -61
- package/dist/runtime/index.cjs.map +1 -1
- package/dist/runtime/index.d.cts +3 -2
- package/dist/runtime/index.d.ts +3 -2
- package/dist/runtime/index.js +716 -61
- package/dist/runtime/index.js.map +1 -1
- package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
- package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
- package/docs/agent-guide.md +43 -21
- package/docs/ai-gates.md +53 -18
- package/docs/configuration.md +97 -0
- package/docs/enthusiast/README.md +3 -3
- package/docs/enthusiast/how-to-agent-gates.md +7 -3
- package/docs/migrate-from-ark-runtime-kernel.md +3 -0
- package/docs/package-surface.md +22 -10
- package/docs/production-hardening.md +15 -2
- package/docs/threat-model.md +65 -0
- package/docs/typescript-support.md +3 -3
- package/package.json +15 -2
- package/schemas/ark.config.schema.json +750 -0
- package/server.json +2 -2
- package/templates/hooks/pre-commit-ark +37 -0
- package/templates/skills/ark-autopilot.md +77 -45
- package/templates/skills/ark-coverage.md +2 -2
- package/templates/skills/ark-explain.md +2 -1
- package/templates/skills/ark-explore.md +135 -34
- package/templates/skills/ark-runtime.md +8 -5
- package/templates/skills/ark-upgrade.md +36 -16
- package/tests/fixtures/ts-consumer/ark.config.json +2 -0
|
@@ -0,0 +1,455 @@
|
|
|
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 { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
|
|
13
|
+
import { detectDeployPathQuality } from './deploy-path.mjs';
|
|
14
|
+
import { collectWeakestLinkGaps } from './weakest-link.mjs';
|
|
15
|
+
|
|
16
|
+
export { detectDeployPathQuality };
|
|
17
|
+
|
|
18
|
+
export const COMMAND_GATE_TEXT_FILES = [
|
|
19
|
+
'.claude/settings.json', 'AGENTS.md', '.cursor/rules/ark.mdc', '.windsurf/rules/ark.md',
|
|
20
|
+
'.clinerules/ark.md', '.github/copilot-instructions.md', '.kiro/steering/ark.md',
|
|
21
|
+
'.roo/rules/ark.md', '.continue/rules/ark.md', 'GEMINI.md', 'package.json',
|
|
22
|
+
'.grok/hooks/ark-write-gate.json', '.grok/config.toml',
|
|
23
|
+
];
|
|
24
|
+
export const COMMAND_GATE_JSON_FILES = ['.mcp.json', '.cursor/mcp.json'];
|
|
25
|
+
// Primary CLI names (product) + one-major aliases. migrate-commands must strip ALL of these
|
|
26
|
+
// before re-emitting a single preferred bin — otherwise a partial rename leaves
|
|
27
|
+
// args: ["ark-mcp", "arkgate-mcp", ...] which breaks stdio MCP hosts.
|
|
28
|
+
export const ARK_MCP_BINS = new Set(['arkgate-mcp', 'ark-mcp']);
|
|
29
|
+
export const ARK_CHECK_BINS = new Set(['arkgate-check', 'ark-check']);
|
|
30
|
+
export const ARK_CLI_BINS = new Set(['arkgate', 'ark']);
|
|
31
|
+
// PREFERRED_MCP_BIN lives in hook-templates.mjs (re-exported above).
|
|
32
|
+
export const PREFERRED_CHECK_BIN = 'arkgate-check';
|
|
33
|
+
export const PREFERRED_CLI_BIN = 'arkgate';
|
|
34
|
+
// Runner argv noise that is not a bin argument (pnpm exec form).
|
|
35
|
+
export const MCP_RUNNER_ARGV = new Set(['exec', '--config.verify-deps-before-run=false']);
|
|
36
|
+
// The runner token immediately before an ark command in a text command string.
|
|
37
|
+
// Matches npm/yarn runners and both pnpm forms (legacy `pnpm exec` + verify-deps-safe form).
|
|
38
|
+
// Longer bin names first so `arkgate-check` is not partially matched as `ark`.
|
|
39
|
+
export const RUNNER_BEFORE_ARK =
|
|
40
|
+
/\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;
|
|
41
|
+
|
|
42
|
+
/** Keep only MCP server flags from existing args (drop runner tokens + any ark* bin names). */
|
|
43
|
+
|
|
44
|
+
export function stripMcpServerArgs(args) {
|
|
45
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
46
|
+
return ['--root', '.', '--config', 'ark.config.json'];
|
|
47
|
+
}
|
|
48
|
+
const kept = args.filter(
|
|
49
|
+
(entry) =>
|
|
50
|
+
typeof entry === 'string' &&
|
|
51
|
+
!MCP_RUNNER_ARGV.has(entry) &&
|
|
52
|
+
!ARK_MCP_BINS.has(entry) &&
|
|
53
|
+
!ARK_CHECK_BINS.has(entry) &&
|
|
54
|
+
!ARK_CLI_BINS.has(entry)
|
|
55
|
+
);
|
|
56
|
+
return kept.length > 0 ? kept : ['--root', '.', '--config', 'ark.config.json'];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** True when mcpServers.ark.args list more than one Ark MCP bin (broken dual rename). */
|
|
60
|
+
export function mcpArgsHaveDuplicateBins(args) {
|
|
61
|
+
if (!Array.isArray(args)) return false;
|
|
62
|
+
const hits = args.filter((entry) => ARK_MCP_BINS.has(entry));
|
|
63
|
+
return hits.length > 1 || (hits.length === 1 && args.indexOf(hits[0]) !== args.lastIndexOf(hits[0]));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function brokenMcpGateFiles(root) {
|
|
67
|
+
const bad = [];
|
|
68
|
+
for (const rel of COMMAND_GATE_JSON_FILES) {
|
|
69
|
+
let json;
|
|
70
|
+
try {
|
|
71
|
+
json = JSON.parse(fs.readFileSync(path.join(root, rel), 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const ark = json?.mcpServers?.ark;
|
|
76
|
+
if (ark && mcpArgsHaveDuplicateBins(ark.args)) bad.push(rel);
|
|
77
|
+
}
|
|
78
|
+
return bad;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
|
|
83
|
+
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
|
|
84
|
+
*/
|
|
85
|
+
export function collectAdoptionGaps(root, config, coverage) {
|
|
86
|
+
const gaps = [];
|
|
87
|
+
const adopted = fs.existsSync(path.join(root, 'AGENTS.md'));
|
|
88
|
+
const isProducer = fs.existsSync(path.join(root, 'templates', 'skills'));
|
|
89
|
+
|
|
90
|
+
// --- Write path: prepare-write / autoPatch / reject-only (W5) ---
|
|
91
|
+
const writePath = detectWritePathCapabilities(root);
|
|
92
|
+
// Only surface write-path gaps when the project has adopted gates (or has partial install).
|
|
93
|
+
// Producer package tree always has templates — still report capability for dogfood honesty.
|
|
94
|
+
if (writePath.gap && (adopted || writePath.hookPresent || writePath.mcpPresent || isProducer)) {
|
|
95
|
+
// Producer may be repair-capable via own templates; still useful. Skip "none" on pure
|
|
96
|
+
// consumer repos with zero Ark files? missingGates already covers that.
|
|
97
|
+
if (!(writePath.mode === 'none' && !adopted && !isProducer)) {
|
|
98
|
+
gaps.push(writePath.gap);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Repo MCP dual-bin ---
|
|
103
|
+
const dualMcp = brokenMcpGateFiles(root);
|
|
104
|
+
const mcp = {
|
|
105
|
+
dualBinFiles: dualMcp,
|
|
106
|
+
ok: dualMcp.length === 0,
|
|
107
|
+
};
|
|
108
|
+
if (dualMcp.length > 0) {
|
|
109
|
+
gaps.push({
|
|
110
|
+
id: 'mcp-dual-bin',
|
|
111
|
+
severity: 'warn',
|
|
112
|
+
message: `Broken MCP argv in ${dualMcp.join(', ')}: more than one of ark-mcp/arkgate-mcp`,
|
|
113
|
+
fix: arkCommand(root, 'ark-check', '--install-agent-gates --migrate-commands'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Host completeness (only when project already adopted gates) ---
|
|
118
|
+
const hosts = [];
|
|
119
|
+
if (adopted && !isProducer) {
|
|
120
|
+
const skillNames = skillTemplateNames();
|
|
121
|
+
const hostChecks = [
|
|
122
|
+
{
|
|
123
|
+
host: 'grok',
|
|
124
|
+
dir: '.grok',
|
|
125
|
+
skill: (n) => path.join(root, '.grok', 'skills', n, 'SKILL.md'),
|
|
126
|
+
extras: [
|
|
127
|
+
['.grok/hooks/ark-write-gate.json', 'write-gate hook'],
|
|
128
|
+
['.grok/config.toml', 'project MCP config'],
|
|
129
|
+
],
|
|
130
|
+
toolsFlag: 'grok',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
host: 'claude',
|
|
134
|
+
dir: '.claude',
|
|
135
|
+
skill: (n) => path.join(root, '.claude', 'skills', n, 'SKILL.md'),
|
|
136
|
+
extras: [['.claude/settings.json', 'settings/hooks']],
|
|
137
|
+
toolsFlag: 'claude',
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
host: 'cursor',
|
|
141
|
+
dir: '.cursor',
|
|
142
|
+
skill: (n) => path.join(root, '.cursor', 'commands', `${n}.md`),
|
|
143
|
+
extras: [['.cursor/mcp.json', 'MCP config']],
|
|
144
|
+
toolsFlag: 'cursor',
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
for (const h of hostChecks) {
|
|
148
|
+
if (!fs.existsSync(path.join(root, h.dir))) continue;
|
|
149
|
+
const missingSkills = skillNames.filter((n) => !fs.existsSync(h.skill(n)));
|
|
150
|
+
const missingExtras = h.extras.filter(([rel]) => !fs.existsSync(path.join(root, rel)));
|
|
151
|
+
const complete = missingSkills.length === 0 && missingExtras.length === 0;
|
|
152
|
+
hosts.push({
|
|
153
|
+
host: h.host,
|
|
154
|
+
present: true,
|
|
155
|
+
complete,
|
|
156
|
+
missingSkills: missingSkills.length,
|
|
157
|
+
missingExtras: missingExtras.map(([, label]) => label),
|
|
158
|
+
});
|
|
159
|
+
if (!complete) {
|
|
160
|
+
gaps.push({
|
|
161
|
+
id: `host-${h.host}-incomplete`,
|
|
162
|
+
severity: 'warn',
|
|
163
|
+
message: `${h.host} dir present but incomplete (${missingSkills.length} skill(s) missing${
|
|
164
|
+
missingExtras.length ? `; missing ${missingExtras.map(([, l]) => l).join(', ')}` : ''
|
|
165
|
+
})`,
|
|
166
|
+
fix: arkCommand(
|
|
167
|
+
root,
|
|
168
|
+
'ark-check',
|
|
169
|
+
`--install-agent-gates --tools ${h.toolsFlag} --force`
|
|
170
|
+
),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Codex home MCP (temp path / wrong root / multi-project) ---
|
|
177
|
+
let codexHome = null;
|
|
178
|
+
if (adopted && !isProducer) {
|
|
179
|
+
const codexFile = codexConfigPath();
|
|
180
|
+
let toml = '';
|
|
181
|
+
try {
|
|
182
|
+
if (fs.existsSync(codexFile)) toml = fs.readFileSync(codexFile, 'utf8');
|
|
183
|
+
} catch {
|
|
184
|
+
toml = '';
|
|
185
|
+
}
|
|
186
|
+
if (toml.includes('[mcp_servers.ark]')) {
|
|
187
|
+
const assessed = assessCodexHomeMcp(toml, root);
|
|
188
|
+
codexHome = {
|
|
189
|
+
file: codexFile,
|
|
190
|
+
root: assessed.root,
|
|
191
|
+
tempPath: assessed.tempPath,
|
|
192
|
+
wrongRoot: assessed.wrongRoot,
|
|
193
|
+
preferredBin: assessed.preferredBin,
|
|
194
|
+
needsRewrite: assessed.needsRewrite,
|
|
195
|
+
multiProject: assessed.multiProject,
|
|
196
|
+
scopedTable: assessed.scopedTable,
|
|
197
|
+
};
|
|
198
|
+
if (assessed.gap) {
|
|
199
|
+
// Temp/upgrade MCP roots stay urgent (fail-closed). Non-temp Codex-home debt
|
|
200
|
+
// is deferred only when the session host is known and is not Codex
|
|
201
|
+
// (Grok/Claude/Cursor…). Unknown host (CI/plain shell) keeps original severity.
|
|
202
|
+
const tempUrgent = Boolean(assessed.tempPath);
|
|
203
|
+
const activeHost = detectActiveAgentHost();
|
|
204
|
+
const deferred =
|
|
205
|
+
!tempUrgent && activeHost != null && activeHost !== 'codex';
|
|
206
|
+
const severity = deferred ? 'info' : assessed.gap.severity;
|
|
207
|
+
const message = deferred
|
|
208
|
+
? `Deferred (fix when using Codex): ${assessed.gap.message}`
|
|
209
|
+
: assessed.gap.message;
|
|
210
|
+
gaps.push({
|
|
211
|
+
id: assessed.gap.id,
|
|
212
|
+
severity,
|
|
213
|
+
deferred,
|
|
214
|
+
message,
|
|
215
|
+
fix: arkCommand(root, 'ark-check', assessed.gap.fixArgs),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Core layers optional but populated ---
|
|
222
|
+
const coreOptional = [];
|
|
223
|
+
const layerRows = coverage?.layers ?? [];
|
|
224
|
+
const countByName = new Map(layerRows.map((r) => [r.name, r.files]));
|
|
225
|
+
for (const layer of config?.layers ?? []) {
|
|
226
|
+
if (!CORE_LAYER_NAMES.has(layer.name)) continue;
|
|
227
|
+
if (layer.optional !== true) continue;
|
|
228
|
+
const files = countByName.get(layer.name) ?? 0;
|
|
229
|
+
if (files > 0) {
|
|
230
|
+
coreOptional.push({ layer: layer.name, files });
|
|
231
|
+
gaps.push({
|
|
232
|
+
id: `core-optional-${layer.name}`,
|
|
233
|
+
severity: 'info',
|
|
234
|
+
message: `Core layer ${layer.name} has ${files} file(s) but is still optional: true — contract is weaker than the tree`,
|
|
235
|
+
fix: `${arkCommand(root, 'ark-check', '--ratchet-cores')} (when architecture is green: 0 active violations)`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Origin report ---
|
|
241
|
+
const originJson = path.join(root, '.ark', 'reports', 'origin.json');
|
|
242
|
+
const originReport = {
|
|
243
|
+
present: fs.existsSync(originJson),
|
|
244
|
+
path: '.ark/reports/origin.json',
|
|
245
|
+
};
|
|
246
|
+
if (adopted && !originReport.present && (coverage?.governed?.percent ?? 0) >= 50) {
|
|
247
|
+
gaps.push({
|
|
248
|
+
id: 'origin-report-missing',
|
|
249
|
+
severity: 'info',
|
|
250
|
+
message: 'No origin architecture snapshot under .ark/reports/ yet',
|
|
251
|
+
fix: arkCommand(root, 'ark-check', '--report ark-report.html'),
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Baseline policy ---
|
|
256
|
+
const baselinePath = path.join(root, '.ark-baseline.json');
|
|
257
|
+
const baselineExists = fs.existsSync(baselinePath);
|
|
258
|
+
let frozenKeys = 0;
|
|
259
|
+
if (baselineExists) {
|
|
260
|
+
try {
|
|
261
|
+
const raw = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
262
|
+
frozenKeys = Array.isArray(raw.violations) ? raw.violations.length : 0;
|
|
263
|
+
} catch {
|
|
264
|
+
frozenKeys = 0;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
let primaryPathUsesBaseline = false;
|
|
268
|
+
try {
|
|
269
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
270
|
+
const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
|
|
271
|
+
primaryPathUsesBaseline = Object.values(scripts).some(
|
|
272
|
+
(s) => typeof s === 'string' && s.includes('--baseline')
|
|
273
|
+
);
|
|
274
|
+
} catch {
|
|
275
|
+
/* no package.json */
|
|
276
|
+
}
|
|
277
|
+
if (!primaryPathUsesBaseline) {
|
|
278
|
+
try {
|
|
279
|
+
const wfDir = path.join(root, '.github', 'workflows');
|
|
280
|
+
if (fs.existsSync(wfDir)) {
|
|
281
|
+
for (const f of fs.readdirSync(wfDir)) {
|
|
282
|
+
if (!/\.ya?ml$/i.test(f)) continue;
|
|
283
|
+
const text = fs.readFileSync(path.join(wfDir, f), 'utf8');
|
|
284
|
+
if (text.includes('--baseline') && (text.includes('ark-check') || text.includes('arkgate-check'))) {
|
|
285
|
+
primaryPathUsesBaseline = true;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
/* ignore */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const baseline = {
|
|
295
|
+
exists: baselineExists,
|
|
296
|
+
frozenKeys,
|
|
297
|
+
primaryPathUsesBaseline,
|
|
298
|
+
signal: baselineExists
|
|
299
|
+
? frozenKeys === 0
|
|
300
|
+
? 'keep-empty'
|
|
301
|
+
: 'active-ratchet'
|
|
302
|
+
: 'absent',
|
|
303
|
+
};
|
|
304
|
+
if (adopted && baselineExists && frozenKeys === 0 && !primaryPathUsesBaseline) {
|
|
305
|
+
gaps.push({
|
|
306
|
+
id: 'baseline-unused',
|
|
307
|
+
severity: 'info',
|
|
308
|
+
message:
|
|
309
|
+
'Empty .ark-baseline.json exists but primary scripts/CI do not pass --baseline (policy unclear)',
|
|
310
|
+
fix: 'Either add --baseline .ark-baseline.json to check:architecture / CI, or remove the unused baseline file',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- Educational layer balance (not a violation) ---
|
|
315
|
+
let layerBalance = null;
|
|
316
|
+
const total = layerRows.reduce((s, r) => s + (r.files || 0), 0);
|
|
317
|
+
if (total >= 20) {
|
|
318
|
+
const presentation = layerRows.find((r) => r.name === 'PresentationAdapters');
|
|
319
|
+
const domain = layerRows.find((r) => r.name === 'DomainModel');
|
|
320
|
+
if (presentation && domain) {
|
|
321
|
+
const pShare = presentation.files / total;
|
|
322
|
+
const dShare = domain.files / total;
|
|
323
|
+
if (pShare >= 0.5 && dShare < 0.1) {
|
|
324
|
+
layerBalance = {
|
|
325
|
+
kind: 'presentation-heavy-thin-domain',
|
|
326
|
+
presentationFiles: presentation.files,
|
|
327
|
+
domainFiles: domain.files,
|
|
328
|
+
totalFiles: total,
|
|
329
|
+
educational:
|
|
330
|
+
'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).',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// --- Empty scope: contract matches no TS/JS ---
|
|
337
|
+
if (!isProducer && (coverage?.governed?.totalFiles ?? coverage?.totalFiles) === 0) {
|
|
338
|
+
gaps.push({
|
|
339
|
+
id: 'empty-scope',
|
|
340
|
+
severity: 'warn',
|
|
341
|
+
message:
|
|
342
|
+
'Empty scope: include paths match 0 TypeScript/JS files — checks are not governing this tree',
|
|
343
|
+
fix: `${arkCommand(root, 'ark-check', '--suggest-include')} then ${arkCommand(root, 'ark-check', '--adopt-contract --write')}`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- Deploy-path quality (ESLint/types that production build hosts run) ---
|
|
348
|
+
// Universal: any Next/CRA/Nuxt (etc.) consumer. Not architecture — still adoption.
|
|
349
|
+
// Skip pure library producer (this monorepo) to avoid self-noise.
|
|
350
|
+
let deployPath = null;
|
|
351
|
+
if (!isProducer) {
|
|
352
|
+
deployPath = detectDeployPathQuality(root);
|
|
353
|
+
const eng =
|
|
354
|
+
deployPath.engines.length > 0 ? deployPath.engines.join('/') : 'production';
|
|
355
|
+
if (deployPath.embedsLintInBuild && !deployPath.hasLintScript) {
|
|
356
|
+
gaps.push({
|
|
357
|
+
id: 'deploy-path-lint-script-missing',
|
|
358
|
+
severity: 'warn',
|
|
359
|
+
message: `${eng} production build runs ESLint — no package.json lint script, so failures often surface first on the deploy host`,
|
|
360
|
+
fix: 'Add a package.json "lint" script (e.g. eslint .) matching production ESLint config; run it in CI and before merge',
|
|
361
|
+
});
|
|
362
|
+
} else if (
|
|
363
|
+
deployPath.embedsLintInBuild &&
|
|
364
|
+
deployPath.hasLintScript &&
|
|
365
|
+
deployPath.hasCiWorkflows &&
|
|
366
|
+
!deployPath.ciRunsLint
|
|
367
|
+
) {
|
|
368
|
+
gaps.push({
|
|
369
|
+
id: 'deploy-path-lint-not-in-ci',
|
|
370
|
+
severity: 'warn',
|
|
371
|
+
message: `${eng} production build runs ESLint — CI workflows exist but do not run lint, so deploy hosts may be the first fail`,
|
|
372
|
+
fix: 'Add a CI step that runs your package.json lint script (npm run lint / pnpm lint / yarn lint) and require it before deploy',
|
|
373
|
+
});
|
|
374
|
+
} else if (
|
|
375
|
+
deployPath.embedsLintInBuild &&
|
|
376
|
+
deployPath.hasLintScript &&
|
|
377
|
+
!deployPath.hasCiWorkflows
|
|
378
|
+
) {
|
|
379
|
+
gaps.push({
|
|
380
|
+
id: 'deploy-path-lint-no-ci',
|
|
381
|
+
severity: 'info',
|
|
382
|
+
message: `${eng} production build runs ESLint — no CI workflows detected; push-to-host builds may be the first lint fail`,
|
|
383
|
+
fix: 'Add CI (or a pre-push hook) that runs lint before the deploy host builds; keep branch protection required when using GitHub',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (deployPath.embedsTypecheckInBuild && !deployPath.hasTypecheckScript) {
|
|
388
|
+
gaps.push({
|
|
389
|
+
id: 'deploy-path-typecheck-script-missing',
|
|
390
|
+
severity: 'info',
|
|
391
|
+
message: `${eng} production build typechecks — no package.json typecheck script for local/CI parity`,
|
|
392
|
+
fix: 'Add "typecheck": "tsc --noEmit" (or framework equivalent) and run it in CI alongside lint',
|
|
393
|
+
});
|
|
394
|
+
} else if (
|
|
395
|
+
deployPath.embedsTypecheckInBuild &&
|
|
396
|
+
deployPath.hasTypecheckScript &&
|
|
397
|
+
deployPath.hasCiWorkflows &&
|
|
398
|
+
!deployPath.ciRunsTypecheck
|
|
399
|
+
) {
|
|
400
|
+
gaps.push({
|
|
401
|
+
id: 'deploy-path-typecheck-not-in-ci',
|
|
402
|
+
severity: 'info',
|
|
403
|
+
message: `${eng} production build typechecks — CI does not run typecheck; type errors may appear first on the deploy host`,
|
|
404
|
+
fix: 'Add a CI step for npm run typecheck (or your typecheck script) and require it before deploy',
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// --- False-green contract (field-install detector; doctor skillGaps already cover missing skills) ---
|
|
410
|
+
let contractFalseGreen = null;
|
|
411
|
+
if (!isProducer && config) {
|
|
412
|
+
const gap = falseGreenAdoptionGap(root, config, coverage);
|
|
413
|
+
if (gap) {
|
|
414
|
+
contractFalseGreen = { risk: true, message: gap.message, fix: gap.fix };
|
|
415
|
+
gaps.push(gap);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- Q3 weakest-link: CI / pre-commit / config drift (local FS; optional GH via ARK_DOCTOR_GITHUB=1) ---
|
|
420
|
+
const includeGithub =
|
|
421
|
+
process.env.ARK_DOCTOR_GITHUB === '1' ||
|
|
422
|
+
process.env.ARK_DOCTOR_GITHUB === 'true';
|
|
423
|
+
const weakest = collectWeakestLinkGaps(root, {
|
|
424
|
+
adopted,
|
|
425
|
+
isProducer,
|
|
426
|
+
includeGithub,
|
|
427
|
+
});
|
|
428
|
+
for (const g of weakest.gaps) {
|
|
429
|
+
gaps.push(g);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
gaps,
|
|
434
|
+
hosts,
|
|
435
|
+
mcp,
|
|
436
|
+
codexHome,
|
|
437
|
+
coreOptional,
|
|
438
|
+
originReport,
|
|
439
|
+
baseline,
|
|
440
|
+
layerBalance,
|
|
441
|
+
deployPath,
|
|
442
|
+
contractFalseGreen,
|
|
443
|
+
writePath,
|
|
444
|
+
enforcement: {
|
|
445
|
+
ci: weakest.ci,
|
|
446
|
+
preCommit: weakest.preCommit,
|
|
447
|
+
drift: weakest.drift,
|
|
448
|
+
github: weakest.github,
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Gate files whose Ark command runner doesn't match this project's package manager — the
|
|
454
|
+
// advisory (and --migrate-commands) target. Returns [] for npm/unknown projects (npx is right)
|
|
455
|
+
// so the check is silent unless there's a real mismatch.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a local HTML file in the default browser (macOS / Windows / Linux).
|
|
3
|
+
* Used after ark-check --report writes a showcase/beginner report.
|
|
4
|
+
*/
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether report generation should open the browser.
|
|
12
|
+
* Default: yes for interactive local TTY; never in CI/Vitest unless forced.
|
|
13
|
+
*
|
|
14
|
+
* @param {{ force?: boolean, noOpen?: boolean, env?: NodeJS.ProcessEnv, isTty?: boolean }} [opts]
|
|
15
|
+
*/
|
|
16
|
+
export function shouldOpenHtmlReport(opts = {}) {
|
|
17
|
+
const env = opts.env ?? process.env;
|
|
18
|
+
if (opts.noOpen) return false;
|
|
19
|
+
if (opts.force) return true;
|
|
20
|
+
const off = env.ARK_NO_OPEN_REPORT;
|
|
21
|
+
if (off === '1' || off === 'true' || off === 'yes') return false;
|
|
22
|
+
if (env.CI === 'true' || env.CI === '1') return false;
|
|
23
|
+
if (env.GITHUB_ACTIONS === 'true') return false;
|
|
24
|
+
if (env.VITEST) return false;
|
|
25
|
+
const tty = opts.isTty ?? Boolean(process.stdout.isTTY);
|
|
26
|
+
return tty;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Launch the OS default browser for a local file path (non-blocking).
|
|
31
|
+
* Errors are swallowed — report generation must not fail if the browser cannot open.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} filePath
|
|
34
|
+
* @param {{ platform?: NodeJS.Platform, spawn?: typeof spawn }} [opts]
|
|
35
|
+
* @returns {{ ok: boolean, command?: string, reason?: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function openHtmlInBrowser(filePath, opts = {}) {
|
|
38
|
+
const abs = path.resolve(filePath);
|
|
39
|
+
if (!fs.existsSync(abs)) {
|
|
40
|
+
return { ok: false, reason: 'missing-file' };
|
|
41
|
+
}
|
|
42
|
+
const platform = opts.platform ?? process.platform;
|
|
43
|
+
const spawnFn = opts.spawn ?? spawn;
|
|
44
|
+
|
|
45
|
+
/** @type {string} */
|
|
46
|
+
let command;
|
|
47
|
+
/** @type {string[]} */
|
|
48
|
+
let args;
|
|
49
|
+
if (platform === 'darwin') {
|
|
50
|
+
command = 'open';
|
|
51
|
+
args = [abs];
|
|
52
|
+
} else if (platform === 'win32') {
|
|
53
|
+
command = 'rundll32.exe';
|
|
54
|
+
args = ['url.dll,FileProtocolHandler', pathToFileURL(abs).href];
|
|
55
|
+
} else {
|
|
56
|
+
command = 'xdg-open';
|
|
57
|
+
args = [abs];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const child = spawnFn(command, args, {
|
|
62
|
+
detached: true,
|
|
63
|
+
stdio: 'ignore',
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
shell: false,
|
|
66
|
+
});
|
|
67
|
+
child.on('error', () => {
|
|
68
|
+
/* browser missing (e.g. headless Linux) — ignore */
|
|
69
|
+
});
|
|
70
|
+
child.unref();
|
|
71
|
+
return { ok: true, command };
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
74
|
+
}
|
|
75
|
+
}
|
package/bin/lib/presets.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_INTENT_PREFIXES,
|
|
9
9
|
resolveIncludeRoots,
|
|
10
10
|
} from '../ark-shared.mjs';
|
|
11
|
+
import { withArkConfigMetadata } from './config-contract.mjs';
|
|
11
12
|
|
|
12
13
|
export function denyUpward(names) {
|
|
13
14
|
const rules = [];
|
|
@@ -49,8 +50,8 @@ export function peerIsolationEdges(layerNames, sliceFolders, message) {
|
|
|
49
50
|
// (that carves out legitimate `src/shared/kernel/**` SharedKernel paths).
|
|
50
51
|
export const FRAMEWORK_INTERNAL_EXCLUDE = ['src/kernel/**', '**/src/kernel/**'];
|
|
51
52
|
export function presetWithOverlays(baseConfig, root) {
|
|
52
|
-
|
|
53
|
-
return
|
|
53
|
+
const config = root ? applyFrameworkLayoutOverlays(baseConfig, root) : baseConfig;
|
|
54
|
+
return withArkConfigMetadata(config);
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export const ARCHITECTURE_PRESETS = {
|
|
@@ -259,9 +260,12 @@ export const ARCHITECTURE_PRESETS = {
|
|
|
259
260
|
{ from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
|
|
260
261
|
{ from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
|
|
261
262
|
{ from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
|
|
263
|
+
{ from: 'ApplicationOrchestration', to: 'PersistenceAdapters', allowed: false },
|
|
262
264
|
{ from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
|
|
263
265
|
{ from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
|
|
266
|
+
{ from: 'PresentationAdapters', to: 'DomainModel', allowed: false },
|
|
264
267
|
{ from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
|
|
268
|
+
{ from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
|
|
265
269
|
],
|
|
266
270
|
},
|
|
267
271
|
root
|
|
@@ -146,15 +146,20 @@ export function collectSafetyDiagnostics(ts, root, config, files) {
|
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
if (ts.isCallExpression(node)
|
|
149
|
+
if (ts.isCallExpression(node)) {
|
|
150
|
+
const dynamicImport = node.expression?.kind === ts.SyntaxKind.ImportKeyword;
|
|
151
|
+
const directRequire =
|
|
152
|
+
ts.isIdentifier(node.expression) && node.expression.text === 'require';
|
|
150
153
|
const argument = node.arguments[0];
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
const unresolved =
|
|
155
|
+
(dynamicImport || directRequire) &&
|
|
156
|
+
(!argument || !ts.isStringLiteralLike(argument));
|
|
157
|
+
if (unresolved && !matchesAny(relFile, dynamicAllowlist)) {
|
|
158
|
+
report.nonLiteralDynamicImports.push({
|
|
159
|
+
file: relFile,
|
|
160
|
+
line: lineOf(sourceFile, node.getStart(sourceFile)),
|
|
161
|
+
kind: directRequire ? 'require' : 'import',
|
|
162
|
+
});
|
|
158
163
|
}
|
|
159
164
|
}
|
|
160
165
|
|
|
@@ -216,13 +221,28 @@ export function collectSafetyDiagnostics(ts, root, config, files) {
|
|
|
216
221
|
}
|
|
217
222
|
|
|
218
223
|
const warnings = [];
|
|
219
|
-
|
|
220
|
-
|
|
224
|
+
const nonLiteralImports = report.nonLiteralDynamicImports.filter(
|
|
225
|
+
(entry) => entry.kind !== 'require'
|
|
226
|
+
);
|
|
227
|
+
if (nonLiteralImports.length > 0) {
|
|
228
|
+
const first = nonLiteralImports[0];
|
|
221
229
|
warnings.push({
|
|
222
230
|
ruleId: 'DYNAMIC_IMPORT_NOT_ALLOWLISTED',
|
|
223
231
|
file: first.file,
|
|
224
232
|
line: first.line,
|
|
225
|
-
message: `${
|
|
233
|
+
message: `${nonLiteralImports.length} non-literal dynamic import(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const nonLiteralRequires = report.nonLiteralDynamicImports.filter(
|
|
237
|
+
(entry) => entry.kind === 'require'
|
|
238
|
+
);
|
|
239
|
+
if (nonLiteralRequires.length > 0) {
|
|
240
|
+
const first = nonLiteralRequires[0];
|
|
241
|
+
warnings.push({
|
|
242
|
+
ruleId: 'DYNAMIC_REQUIRE_NOT_ALLOWLISTED',
|
|
243
|
+
file: first.file,
|
|
244
|
+
line: first.line,
|
|
245
|
+
message: `${nonLiteralRequires.length} non-literal require call(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
|
|
226
246
|
});
|
|
227
247
|
}
|
|
228
248
|
if (report.tsSuppressions.length > maxTsSuppressions) {
|