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.
Files changed (77) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/README.md +70 -41
  3. package/bin/ark-check.mjs +95 -36
  4. package/bin/ark-mcp.mjs +11 -5
  5. package/bin/ark-shared.mjs +88 -56
  6. package/bin/ark.mjs +97 -29
  7. package/bin/lib/agent-gates.mjs +79 -2093
  8. package/bin/lib/architecture-scan.mjs +8 -0
  9. package/bin/lib/ci-and-commands.mjs +392 -0
  10. package/bin/lib/codex-home.mjs +7 -0
  11. package/bin/lib/config-contract.mjs +331 -0
  12. package/bin/lib/deploy-path.mjs +205 -0
  13. package/bin/lib/doctor-plan.mjs +43 -16
  14. package/bin/lib/enforcement-profiles.mjs +97 -0
  15. package/bin/lib/gate-files.mjs +223 -0
  16. package/bin/lib/hook-templates.mjs +99 -0
  17. package/bin/lib/host-support-matrix.mjs +77 -0
  18. package/bin/lib/install-migrate.mjs +473 -0
  19. package/bin/lib/mcp-adoption.mjs +455 -0
  20. package/bin/lib/open-html.mjs +75 -0
  21. package/bin/lib/presets.mjs +6 -2
  22. package/bin/lib/safety-diagnostics.mjs +31 -11
  23. package/bin/lib/skill-install.mjs +323 -0
  24. package/bin/lib/ts-resolve.mjs +2 -1
  25. package/bin/lib/typescript-host.mjs +88 -0
  26. package/bin/lib/weakest-link.mjs +417 -0
  27. package/bin/lib/write-path-capabilities.mjs +182 -0
  28. package/bin/lib/write-path-detect.mjs +101 -0
  29. package/dist/configContract-iBLxx5Tz.d.cts +53 -0
  30. package/dist/configContract-iBLxx5Tz.d.ts +53 -0
  31. package/dist/eslint/index.cjs +375 -13
  32. package/dist/eslint/index.cjs.map +1 -1
  33. package/dist/eslint/index.d.cts +30 -20
  34. package/dist/eslint/index.d.ts +30 -20
  35. package/dist/eslint/index.js +375 -13
  36. package/dist/eslint/index.js.map +1 -1
  37. package/dist/index.cjs +723 -61
  38. package/dist/index.cjs.map +1 -1
  39. package/dist/index.d.cts +95 -5
  40. package/dist/index.d.ts +95 -5
  41. package/dist/index.js +716 -61
  42. package/dist/index.js.map +1 -1
  43. package/dist/nestjs/index.cjs +150 -42
  44. package/dist/nestjs/index.cjs.map +1 -1
  45. package/dist/nestjs/index.d.cts +2 -1
  46. package/dist/nestjs/index.d.ts +2 -1
  47. package/dist/nestjs/index.js +150 -42
  48. package/dist/nestjs/index.js.map +1 -1
  49. package/dist/runtime/index.cjs +723 -61
  50. package/dist/runtime/index.cjs.map +1 -1
  51. package/dist/runtime/index.d.cts +3 -2
  52. package/dist/runtime/index.d.ts +3 -2
  53. package/dist/runtime/index.js +716 -61
  54. package/dist/runtime/index.js.map +1 -1
  55. package/dist/{types-BZ17b9i5.d.cts → types-BxBwnBpC.d.cts} +9 -36
  56. package/dist/{types-BZ17b9i5.d.ts → types-Wcs_l1_J.d.ts} +9 -36
  57. package/docs/agent-guide.md +43 -21
  58. package/docs/ai-gates.md +53 -18
  59. package/docs/configuration.md +97 -0
  60. package/docs/enthusiast/README.md +3 -3
  61. package/docs/enthusiast/how-to-agent-gates.md +7 -3
  62. package/docs/migrate-from-ark-runtime-kernel.md +3 -0
  63. package/docs/package-surface.md +22 -10
  64. package/docs/production-hardening.md +15 -2
  65. package/docs/threat-model.md +65 -0
  66. package/docs/typescript-support.md +3 -3
  67. package/package.json +15 -2
  68. package/schemas/ark.config.schema.json +750 -0
  69. package/server.json +2 -2
  70. package/templates/hooks/pre-commit-ark +37 -0
  71. package/templates/skills/ark-autopilot.md +77 -45
  72. package/templates/skills/ark-coverage.md +2 -2
  73. package/templates/skills/ark-explain.md +2 -1
  74. package/templates/skills/ark-explore.md +135 -34
  75. package/templates/skills/ark-runtime.md +8 -5
  76. package/templates/skills/ark-upgrade.md +36 -16
  77. 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
+ }
@@ -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
- if (!root) return baseConfig;
53
- return applyFrameworkLayoutOverlays(baseConfig, root);
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) && node.expression?.kind === ts.SyntaxKind.ImportKeyword) {
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
- if (!argument || !ts.isStringLiteralLike(argument)) {
152
- if (!matchesAny(relFile, dynamicAllowlist)) {
153
- report.nonLiteralDynamicImports.push({
154
- file: relFile,
155
- line: lineOf(sourceFile, node.getStart(sourceFile)),
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
- if (report.nonLiteralDynamicImports.length > 0) {
220
- const first = report.nonLiteralDynamicImports[0];
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: `${report.nonLiteralDynamicImports.length} non-literal dynamic import(s) cannot be resolved statically. Add only reviewed files to dynamicImportAllowlist.`,
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) {