arkgate 2.8.1 → 2.8.3

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,152 @@
1
+ /**
2
+ * Core-layer optionality ratchet — pure plan + CLI runner.
3
+ * Keeps ark-check.mjs orchestration-only (dispatch only).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { arkCommand } from '../ark-shared.mjs';
8
+ import { computeCoverage } from './doctor-plan.mjs';
9
+
10
+ /**
11
+ * Core layers whose optionality matters once they match files (presets share these names).
12
+ * Used by doctor adoption gaps and `--ratchet-cores`.
13
+ */
14
+ export const CORE_LAYER_NAMES = new Set([
15
+ 'DomainModel',
16
+ 'ApplicationOrchestration',
17
+ 'PresentationAdapters',
18
+ 'PersistenceAdapters',
19
+ ]);
20
+
21
+ /**
22
+ * Plan a ratchet of optional→required for core layers that already match files.
23
+ * Empty cores stay optional (avoids false ENFORCE theatre). Pure — does not write disk.
24
+ *
25
+ * @param {object} config ark.config.json shape
26
+ * @param {{ name: string, files: number }[]} layerRows coverage layer rows
27
+ */
28
+ export function planPopulatedCoreRatchet(config, layerRows = []) {
29
+ const countByName = new Map(
30
+ (Array.isArray(layerRows) ? layerRows : []).map((row) => [row.name, Number(row.files) || 0])
31
+ );
32
+ const ratcheted = [];
33
+ const alreadyStrict = [];
34
+ const stillOptionalEmpty = [];
35
+ const nextLayers = (config?.layers ?? []).map((layer) => {
36
+ if (!CORE_LAYER_NAMES.has(layer.name)) return layer;
37
+ const files = countByName.get(layer.name) ?? 0;
38
+ if (layer.optional !== true) {
39
+ if (files > 0) alreadyStrict.push({ layer: layer.name, files });
40
+ return layer;
41
+ }
42
+ if (files <= 0) {
43
+ stillOptionalEmpty.push(layer.name);
44
+ return layer;
45
+ }
46
+ ratcheted.push({ layer: layer.name, files });
47
+ return { ...layer, optional: false };
48
+ });
49
+ return {
50
+ ratcheted,
51
+ alreadyStrict,
52
+ stillOptionalEmpty,
53
+ config: { ...(config ?? {}), layers: nextLayers },
54
+ changed: ratcheted.length > 0,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * When the architecture is green (raw violations = 0, not baselined), ratchet populated
60
+ * core layers from optional→required so doctor can honestly report ENFORCE.
61
+ * Empty cores stay optional. Always writes when changes apply (like --update-baseline).
62
+ *
63
+ * @param {string} root
64
+ * @param {object} config
65
+ * @param {string[]} files
66
+ * @param {object[]} rules
67
+ * @param {object[]} violations raw scan violations (baseline ignored — must be truly clean)
68
+ * @param {{ json?: boolean, config?: string }} args
69
+ * @param {{ displayPathFromRoot: (root: string, abs: string) => string }} deps
70
+ */
71
+ export function runRatchetCores(root, config, files, rules, violations, args, deps) {
72
+ const displayPathFromRoot = deps.displayPathFromRoot;
73
+ const cov = computeCoverage(root, config, files, rules);
74
+ const activeCount = Array.isArray(violations) ? violations.length : 0;
75
+ const configPath = path.isAbsolute(args.config)
76
+ ? args.config
77
+ : path.join(root, args.config || 'ark.config.json');
78
+
79
+ const refuse = (code, message, extra = {}) => {
80
+ if (args.json) {
81
+ console.log(JSON.stringify({ ok: false, error: message, ...extra }, null, 2));
82
+ } else {
83
+ console.error(message);
84
+ }
85
+ process.exitCode = code;
86
+ };
87
+
88
+ if (activeCount > 0) {
89
+ refuse(
90
+ 2,
91
+ `Refusing --ratchet-cores: ${activeCount} active architecture violation(s) (raw graph; baseline does not count). Resolve them first (ark-check --plan), then re-run.`,
92
+ { activeViolations: activeCount, governed: cov.governed }
93
+ );
94
+ return;
95
+ }
96
+ if (cov.totalFiles === 0 || (cov.governed?.percent ?? 0) < 50) {
97
+ refuse(
98
+ 2,
99
+ `Refusing --ratchet-cores: governed coverage is too low (${cov.governed?.percent ?? 0}% of ${cov.totalFiles} files). Classify ungoverned code first.`,
100
+ { governed: cov.governed }
101
+ );
102
+ return;
103
+ }
104
+
105
+ const plan = planPopulatedCoreRatchet(config, cov.layers);
106
+ if (!plan.changed) {
107
+ const payload = {
108
+ ok: true,
109
+ changed: false,
110
+ message: 'No optional core layers with files — nothing to ratchet.',
111
+ alreadyStrict: plan.alreadyStrict,
112
+ stillOptionalEmpty: plan.stillOptionalEmpty,
113
+ governed: cov.governed,
114
+ };
115
+ if (args.json) console.log(JSON.stringify(payload, null, 2));
116
+ else {
117
+ console.log(payload.message);
118
+ if (plan.stillOptionalEmpty.length > 0) {
119
+ console.log(`Still optional (empty patterns): ${plan.stillOptionalEmpty.join(', ')}`);
120
+ }
121
+ }
122
+ return;
123
+ }
124
+
125
+ fs.writeFileSync(configPath, `${JSON.stringify(plan.config, null, 2)}\n`);
126
+ const payload = {
127
+ ok: true,
128
+ changed: true,
129
+ configPath: displayPathFromRoot(root, configPath),
130
+ ratcheted: plan.ratcheted,
131
+ stillOptionalEmpty: plan.stillOptionalEmpty,
132
+ governed: cov.governed,
133
+ next: arkCommand(root, 'ark-check', '--doctor'),
134
+ };
135
+ if (args.json) {
136
+ console.log(JSON.stringify(payload, null, 2));
137
+ } else {
138
+ console.log(
139
+ `Ratcheted ${plan.ratcheted.length} core layer(s) to optional: false (populated only):`
140
+ );
141
+ for (const row of plan.ratcheted) {
142
+ console.log(` ${row.layer} (${row.files} file(s))`);
143
+ }
144
+ if (plan.stillOptionalEmpty.length > 0) {
145
+ console.log(
146
+ `Left optional (empty patterns — avoid false ENFORCE): ${plan.stillOptionalEmpty.join(', ')}`
147
+ );
148
+ }
149
+ console.log(`Wrote ${displayPathFromRoot(root, configPath)}`);
150
+ console.log(`Confirm: ${payload.next}`);
151
+ }
152
+ }
@@ -290,6 +290,15 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
290
290
  governedPercent: cov.governed.percent,
291
291
  planMet: activeCount === 0 && cov.governed.percent >= 50,
292
292
  mature: cov.governed.totalFiles >= 150,
293
+ totalFiles: cov.governed.totalFiles,
294
+ emptyLayers: cov.emptyLayers,
295
+ coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
296
+ presentationShare: (() => {
297
+ const total = cov.governed.totalFiles || 0;
298
+ if (total <= 0) return null;
299
+ const p = cov.layers.find((r) => r.name === 'PresentationAdapters');
300
+ return p ? p.files / total : null;
301
+ })(),
293
302
  }),
294
303
  governed: cov.governed,
295
304
  emptyLayers: cov.emptyLayers,
@@ -345,11 +354,18 @@ export function runDoctor(root, config, files, rules, violations, asJson, option
345
354
  console.log(color.bold(`Ark doctor — ${path.basename(path.resolve(root)) || '.'}`));
346
355
 
347
356
  const emptyScope = cov.governed.totalFiles === 0;
357
+ const totalFiles = cov.governed.totalFiles || 0;
358
+ const presentationRow = cov.layers.find((r) => r.name === 'PresentationAdapters');
348
359
  const mode = resolveOperatingMode({
349
360
  governedPercent: emptyScope ? 0 : cov.governed.percent,
350
361
  planMet:
351
362
  activeCount === 0 && !emptyScope && cov.governed.percent >= 50,
352
363
  mature: cov.governed.totalFiles >= 150,
364
+ totalFiles: cov.governed.totalFiles,
365
+ emptyLayers: cov.emptyLayers,
366
+ coreOptionalWithFiles: adoption.coreOptional?.length ?? 0,
367
+ presentationShare:
368
+ totalFiles > 0 && presentationRow ? presentationRow.files / totalFiles : null,
353
369
  });
354
370
  console.log('');
355
371
  console.log(color.bold('Operating mode'));
@@ -153,6 +153,17 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
153
153
  const governedPercent = coverage?.governed?.percent ?? null;
154
154
  const totalFiles = coverage?.governed?.totalFiles ?? 0;
155
155
  const classifiedFiles = coverage?.governed?.classifiedFiles ?? 0;
156
+ const emptyLayers = (coverage?.layers ?? [])
157
+ .filter((r) => (r.files ?? 0) === 0)
158
+ .map((r) => r.name);
159
+ const presentationRow = (coverage?.layers ?? []).find(
160
+ (r) => r.name === 'PresentationAdapters'
161
+ );
162
+ const coreOptionalWithFiles = (config?.layers ?? []).filter((layer) => {
163
+ if (layer.optional !== true) return false;
164
+ const row = (coverage?.layers ?? []).find((r) => r.name === layer.name);
165
+ return (row?.files ?? 0) > 0;
166
+ }).length;
156
167
  const mode = resolveOperatingMode({
157
168
  governedPercent: totalFiles === 0 ? 0 : governedPercent,
158
169
  planMet:
@@ -162,6 +173,10 @@ export function computeReportFitness({ coverage, violations, ok, enforcement, co
162
173
  (governedPercent == null || governedPercent >= 50),
163
174
  mature: totalFiles >= 150,
164
175
  totalFiles,
176
+ emptyLayers,
177
+ coreOptionalWithFiles,
178
+ presentationShare:
179
+ totalFiles > 0 && presentationRow ? presentationRow.files / totalFiles : null,
165
180
  });
166
181
  const modeLabel = { suggest: 'SUGGEST', adapt: 'ADAPT', enforce: 'ENFORCE' }[mode] || String(mode).toUpperCase();
167
182
  const modeBlurb = {
@@ -267,22 +267,29 @@ export const ARCHITECTURE_PRESETS = {
267
267
  name: 'DomainModel',
268
268
  description: 'Shared types and pure view-models (optional on UI-first trees).',
269
269
  // Avoid bare **/types.ts — see monorepo DomainModel note (false Domain on core/**/types.ts).
270
- patterns: ['**/domain/**', '**/cinematic/types.ts'],
270
+ patterns: [
271
+ '**/domain/**',
272
+ '**/cinematic/types.ts',
273
+ // Common pure types bag (not **/types.ts — that traps core/**/types.ts).
274
+ 'src/lib/types.ts',
275
+ ],
271
276
  exclude: FRAMEWORK_INTERNAL_EXCLUDE,
272
277
  forbiddenGlobals: DEFAULT_DOMAIN_FORBIDDEN_GLOBALS,
273
278
  optional: true,
274
279
  },
275
280
  {
276
- name: 'PresentationAdapters',
277
- description: 'UI, routes, hooks, components, compositions.',
281
+ name: 'ApplicationOrchestration',
282
+ description: 'Server actions, features, and non-UI lib orchestration (when present).',
278
283
  patterns: [
279
- '**/src/**',
280
- '**/components/**',
281
- '**/hooks/**',
282
- '**/lib/**',
283
- '**/routes/**',
284
- '**/app/**',
285
- '**/pages/**',
284
+ 'src/features/**',
285
+ 'src/server/**',
286
+ 'src/services/**',
287
+ 'src/use-cases/**',
288
+ 'src/actions/**',
289
+ 'src/lib/actions/**',
290
+ 'src/lib/services/**',
291
+ '**/lib/actions/**',
292
+ '**/lib/services/**',
286
293
  ],
287
294
  exclude: FRAMEWORK_INTERNAL_EXCLUDE,
288
295
  optional: true,
@@ -290,7 +297,61 @@ export const ARCHITECTURE_PRESETS = {
290
297
  {
291
298
  name: 'PersistenceAdapters',
292
299
  description: 'Client data access and external API adapters (when present).',
293
- patterns: ['**/infrastructure/**', '**/adapters/**', '**/repositories/**'],
300
+ // Prefer specific data-client bags over a presentation catch-all on **/lib/**
301
+ patterns: [
302
+ '**/infrastructure/**',
303
+ '**/adapters/**',
304
+ '**/repositories/**',
305
+ '**/persistence/**',
306
+ 'src/db/**',
307
+ 'src/data/**',
308
+ 'src/lib/db/**',
309
+ 'src/lib/prisma/**',
310
+ 'src/lib/supabase/**',
311
+ 'src/lib/airtable/**',
312
+ 'src/lib/firebase/**',
313
+ 'src/lib/firestore/**',
314
+ 'src/lib/mongodb/**',
315
+ 'src/lib/mongoose/**',
316
+ 'src/lib/drizzle/**',
317
+ 'src/lib/kysely/**',
318
+ '**/lib/supabase/**',
319
+ '**/lib/airtable/**',
320
+ '**/lib/prisma/**',
321
+ '**/lib/db/**',
322
+ ],
323
+ exclude: FRAMEWORK_INTERNAL_EXCLUDE,
324
+ optional: true,
325
+ },
326
+ {
327
+ name: 'PresentationAdapters',
328
+ description: 'UI, routes, hooks, components (not a whole-src bag).',
329
+ // No **/src/** or bare **/lib/** — those swallowed data clients and forced false ENFORCE.
330
+ patterns: [
331
+ '**/components/**',
332
+ '**/hooks/**',
333
+ '**/routes/**',
334
+ '**/app/**',
335
+ '**/pages/**',
336
+ 'src/app/**',
337
+ 'src/pages/**',
338
+ 'src/components/**',
339
+ 'src/hooks/**',
340
+ 'src/ui/**',
341
+ 'src/layouts/**',
342
+ 'app/**',
343
+ 'pages/**',
344
+ 'components/**',
345
+ // Next middleware edge entry (classic + Next 16 proxy rename)
346
+ 'src/middleware.ts',
347
+ 'src/middleware.js',
348
+ 'middleware.ts',
349
+ 'middleware.js',
350
+ 'src/proxy.ts',
351
+ 'src/proxy.js',
352
+ 'proxy.ts',
353
+ 'proxy.js',
354
+ ],
294
355
  exclude: FRAMEWORK_INTERNAL_EXCLUDE,
295
356
  optional: true,
296
357
  },
@@ -298,7 +359,20 @@ export const ARCHITECTURE_PRESETS = {
298
359
  rules: [
299
360
  { from: 'DomainModel', to: 'PresentationAdapters', allowed: false },
300
361
  { from: 'DomainModel', to: 'PersistenceAdapters', allowed: false },
301
- { from: 'PresentationAdapters', to: 'PersistenceAdapters', allowed: false },
362
+ { from: 'DomainModel', to: 'ApplicationOrchestration', allowed: false },
363
+ { from: 'ApplicationOrchestration', to: 'PresentationAdapters', allowed: false },
364
+ // Next RSC often imports data clients from routes; deny is ideal but day-one
365
+ // ui-surface keeps this as a soft guidance edge (allowed) until ports exist —
366
+ // Persistence → Presentation stays denied when that edge appears.
367
+ {
368
+ from: 'PresentationAdapters',
369
+ to: 'PersistenceAdapters',
370
+ allowed: true,
371
+ message:
372
+ 'UI/routes may reach data clients on day one (RSC); prefer application ports as the product grows.',
373
+ },
374
+ { from: 'PersistenceAdapters', to: 'PresentationAdapters', allowed: false },
375
+ { from: 'PersistenceAdapters', to: 'ApplicationOrchestration', allowed: false },
302
376
  ],
303
377
  },
304
378
  root
package/dist/index.cjs CHANGED
@@ -80,7 +80,7 @@ __export(index_exports, {
80
80
  module.exports = __toCommonJS(index_exports);
81
81
 
82
82
  // src/version.ts
83
- var version = "2.8.1";
83
+ var version = "2.8.3";
84
84
 
85
85
  // src/kernel/intent/IntentRegistry.ts
86
86
  var IntentRegistry = class {