arkgate 3.9.2 → 4.0.1

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 (75) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +16 -4
  3. package/bin/ark-check-runtime.mjs +75 -3
  4. package/bin/ark-mcp-runtime.mjs +94 -0
  5. package/bin/lib/adapter-contract.mjs +14 -1
  6. package/bin/lib/analysis-engine.mjs +8 -8
  7. package/bin/lib/architecture-scan.mjs +35 -2
  8. package/bin/lib/arkrule-file-hints.mjs +71 -0
  9. package/bin/lib/arkrules-contract.mjs +382 -0
  10. package/bin/lib/arkrules-sensors.mjs +411 -0
  11. package/bin/lib/config-contract.mjs +85 -6
  12. package/bin/lib/doctor-advisories.mjs +14 -1
  13. package/bin/lib/doctor-plan.mjs +21 -0
  14. package/bin/lib/effective-contract-load.mjs +116 -0
  15. package/bin/lib/field-install.mjs +104 -0
  16. package/bin/lib/graph-blind.mjs +20 -1
  17. package/bin/lib/html-report-advisories.mjs +12 -5
  18. package/bin/lib/install-migrate.mjs +20 -2
  19. package/bin/lib/invariant-coverage-io.mjs +157 -0
  20. package/bin/lib/invariant-coverage.mjs +127 -0
  21. package/bin/lib/managed-upgrade.mjs +1 -1
  22. package/bin/lib/policy-delta-io.mjs +33 -0
  23. package/bin/lib/presets.mjs +241 -1
  24. package/bin/lib/remediation.mjs +28 -0
  25. package/bin/lib/resolved-candidate-facts.mjs +14 -1
  26. package/bin/lib/rules-inventory.mjs +144 -0
  27. package/bin/lib/rules-under-contract.mjs +320 -0
  28. package/bin/lib/start-preview.mjs +24 -7
  29. package/bin/lib/upgrade-command.mjs +373 -16
  30. package/dist/{configTypes-DAPvBqK6.d.ts → configTypes-CC0FEXoF.d.ts} +16 -3
  31. package/dist/eslint/index.cjs +2 -2
  32. package/dist/eslint/index.d.ts +1 -1
  33. package/dist/eslint/index.js +2 -2
  34. package/dist/index.cjs +14 -7
  35. package/dist/index.d.ts +615 -20
  36. package/dist/index.js +13 -6
  37. package/docs/README.md +5 -3
  38. package/docs/agent-guide.md +7 -3
  39. package/docs/ai-gates.md +6 -1
  40. package/docs/brownfield-adoption.md +22 -0
  41. package/docs/configuration.md +53 -4
  42. package/docs/develop.md +8 -2
  43. package/docs/enthusiast/README.md +11 -0
  44. package/docs/package-surface.md +13 -10
  45. package/docs/product-voice.md +11 -2
  46. package/docs/use.md +11 -0
  47. package/package.json +4 -17
  48. package/schemas/ark.analysis-result.schema.json +9 -1
  49. package/schemas/ark.arkrules.schema.json +141 -0
  50. package/schemas/ark.config.schema.json +10 -2
  51. package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
  52. package/server.json +2 -2
  53. package/templates/arkrules/ApplicationOrchestration.json +14 -0
  54. package/templates/arkrules/DomainModel.json +32 -0
  55. package/templates/arkrules/PersistenceAdapters.json +14 -0
  56. package/templates/arkrules/PresentationAdapters.json +14 -0
  57. package/templates/skills/ark-adopt.md +28 -1
  58. package/templates/skills/ark-architect.md +23 -0
  59. package/templates/skills/ark-autopilot.md +27 -1
  60. package/templates/skills/ark-contract.md +27 -1
  61. package/templates/skills/ark-coverage.md +23 -0
  62. package/templates/skills/ark-explain.md +39 -3
  63. package/templates/skills/ark-explore.md +26 -1
  64. package/templates/skills/ark-fix.md +23 -0
  65. package/templates/skills/ark-loop.md +23 -0
  66. package/templates/skills/ark-place.md +26 -0
  67. package/templates/skills/ark-runtime.md +4 -0
  68. package/templates/skills/ark-think.md +24 -1
  69. package/templates/skills/ark-upgrade.md +80 -11
  70. package/compat/nestjs.cjs +0 -2
  71. package/compat/nestjs.d.ts +0 -2
  72. package/compat/nestjs.js +0 -1
  73. package/compat/runtime.cjs +0 -2
  74. package/compat/runtime.d.ts +0 -2
  75. package/compat/runtime.js +0 -1
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tooling adapter: load Effective Contract from disk for a root config.
3
+ * Pure resolution lives in Domain (`resolveEffectiveContract`); this module owns I/O.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import {
8
+ emptyEffectiveArkRules,
9
+ buildEffectiveArkRules,
10
+ loadArkRulesContract,
11
+ } from './arkrules-contract.mjs';
12
+
13
+ /**
14
+ * @param {string} root
15
+ * @param {Record<string, unknown>} config loaded ark.config.json object
16
+ * @param {{ observeInput?: (abs: string, kind: string) => void }} [opts]
17
+ * @returns {{ arkRules: ReturnType<typeof emptyEffectiveArkRules>, warnings: Array<{path:string,message:string,severity:string}>, errors: Array<{path:string,message:string}> }}
18
+ */
19
+ export function loadEffectiveArkRulesFromDisk(root, config, opts = {}) {
20
+ const refs = config?.arkRules;
21
+ if (!refs || typeof refs !== 'object' || Object.keys(refs).length === 0) {
22
+ return { arkRules: emptyEffectiveArkRules(), warnings: [], errors: [] };
23
+ }
24
+
25
+ const layerNames = new Set(
26
+ Array.isArray(config.layers) ? config.layers.map((layer) => layer.name) : []
27
+ );
28
+ const errors = [];
29
+ const warnings = [];
30
+ const parts = [];
31
+ const referenced = new Set();
32
+
33
+ for (const layer of Object.keys(refs).sort()) {
34
+ const relRaw = refs[layer];
35
+ const pathKey = `$.arkRules[${JSON.stringify(layer)}]`;
36
+ if (typeof relRaw !== 'string' || relRaw.length === 0) {
37
+ errors.push({ path: pathKey, message: 'must be a non-empty relative path string' });
38
+ continue;
39
+ }
40
+ if (relRaw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(relRaw)) {
41
+ errors.push({
42
+ path: pathKey,
43
+ message: 'must be a project-relative path (absolute paths are not allowed)',
44
+ });
45
+ continue;
46
+ }
47
+ if (!layerNames.has(layer)) {
48
+ errors.push({
49
+ path: pathKey,
50
+ message: `layer ${JSON.stringify(layer)} is not declared in layers[]`,
51
+ });
52
+ continue;
53
+ }
54
+
55
+ const rel = relRaw.replace(/\\/g, '/').replace(/^\.\//, '');
56
+ referenced.add(rel);
57
+ const absolute = path.resolve(root, rel);
58
+ opts.observeInput?.(absolute, 'arkrules');
59
+ if (!fs.existsSync(absolute)) {
60
+ errors.push({
61
+ path: pathKey,
62
+ message: `referenced ArkRules file ${JSON.stringify(rel)} is missing`,
63
+ });
64
+ continue;
65
+ }
66
+ let content;
67
+ try {
68
+ content = fs.readFileSync(absolute, 'utf8');
69
+ } catch (error) {
70
+ errors.push({
71
+ path: pathKey,
72
+ message: `referenced ArkRules file ${JSON.stringify(rel)} could not be read: ${
73
+ error instanceof Error ? error.message : String(error)
74
+ }`,
75
+ });
76
+ continue;
77
+ }
78
+ try {
79
+ const loaded = loadArkRulesContract(JSON.parse(content), rel, layer);
80
+ parts.push({ layer, sourceFile: rel, file: loaded.config });
81
+ } catch (error) {
82
+ errors.push({
83
+ path: pathKey,
84
+ message:
85
+ error instanceof Error
86
+ ? error.message
87
+ : `referenced ArkRules file ${JSON.stringify(rel)} failed to load`,
88
+ });
89
+ }
90
+ }
91
+
92
+ // Drift: unreferenced files under arkrules/
93
+ const arkrulesDir = path.join(root, 'arkrules');
94
+ if (fs.existsSync(arkrulesDir) && fs.statSync(arkrulesDir).isDirectory()) {
95
+ for (const name of fs.readdirSync(arkrulesDir).sort()) {
96
+ if (!name.endsWith('.json')) continue;
97
+ const rel = `arkrules/${name}`;
98
+ if (!referenced.has(rel)) {
99
+ warnings.push({
100
+ path: rel,
101
+ message: `ArkRules file ${JSON.stringify(rel)} is not referenced by arkRules and will not be enforced`,
102
+ severity: 'advisory',
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ if (errors.length > 0) {
109
+ return { arkRules: emptyEffectiveArkRules(), warnings, errors };
110
+ }
111
+ return {
112
+ arkRules: buildEffectiveArkRules(parts),
113
+ warnings,
114
+ errors: [],
115
+ };
116
+ }
@@ -201,6 +201,110 @@ export function syncBaselineIntoCheckSurfaces(root, opts = {}) {
201
201
  return { changed, skipped };
202
202
  }
203
203
 
204
+ /**
205
+ * Read declared arkgate pin from consumer package.json (deps or devDeps).
206
+ * @param {string} root
207
+ * @returns {string|null}
208
+ */
209
+ export function readDeclaredArkgatePin(root) {
210
+ const pkgPath = path.join(root, 'package.json');
211
+ if (!fs.existsSync(pkgPath)) return null;
212
+ try {
213
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
214
+ const deps = pkg.dependencies && typeof pkg.dependencies === 'object' ? pkg.dependencies : {};
215
+ const dev = pkg.devDependencies && typeof pkg.devDependencies === 'object' ? pkg.devDependencies : {};
216
+ if (typeof deps.arkgate === 'string') return deps.arkgate;
217
+ if (typeof dev.arkgate === 'string') return dev.arkgate;
218
+ return null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Dual-truth: CLI/package-shipped version vs consumer package.json pin.
226
+ * Used by doctor + upgrade so agents never confuse managed-asset CLI with CI pin.
227
+ *
228
+ * @param {string} root
229
+ * @param {{ cliVersion?: string|null }} [opts]
230
+ * @returns {{
231
+ * dualTruth: boolean,
232
+ * cliVersion: string|null,
233
+ * declaredPin: string|null,
234
+ * code: 'PACKAGE_PIN_BEHIND_CLI' | 'PACKAGE_PIN_MATCHES' | 'PACKAGE_PIN_ABSENT' | 'CLI_VERSION_UNKNOWN',
235
+ * note: string
236
+ * }}
237
+ */
238
+ export function describePackageVersionDualTruth(root, opts = {}) {
239
+ const cliVersion =
240
+ typeof opts.cliVersion === 'string' && opts.cliVersion
241
+ ? opts.cliVersion
242
+ : arkPackageVersion();
243
+ const declaredPin = readDeclaredArkgatePin(root);
244
+ if (!cliVersion) {
245
+ return {
246
+ dualTruth: false,
247
+ cliVersion: null,
248
+ declaredPin,
249
+ code: 'CLI_VERSION_UNKNOWN',
250
+ note: 'Could not read shipped arkgate package version for this CLI.',
251
+ };
252
+ }
253
+ if (!declaredPin) {
254
+ return {
255
+ dualTruth: false,
256
+ cliVersion,
257
+ declaredPin: null,
258
+ code: 'PACKAGE_PIN_ABSENT',
259
+ note: 'No arkgate pin in package.json; CI/npx may not resolve this CLI version.',
260
+ };
261
+ }
262
+ // Normalize ^x.y.z / ~x.y.z / x.y.z for comparison of leading version token.
263
+ const pinCore = String(declaredPin).replace(/^[\^~>=<\s]+/, '').split(/\s+/)[0];
264
+ const matches =
265
+ pinCore === cliVersion ||
266
+ pinCore.startsWith(`${cliVersion}.`) ||
267
+ cliVersion.startsWith(pinCore.split('.').slice(0, 3).join('.'));
268
+ // Dual-truth when declared pin is clearly older major/minor than CLI, or different major.
269
+ const pinParts = pinCore.split('.').map((p) => Number.parseInt(p, 10));
270
+ const cliParts = cliVersion.split('.').map((p) => Number.parseInt(p, 10));
271
+ let behind = false;
272
+ if (
273
+ pinParts.length >= 1 &&
274
+ cliParts.length >= 1 &&
275
+ pinParts.every((n) => Number.isFinite(n)) &&
276
+ cliParts.every((n) => Number.isFinite(n))
277
+ ) {
278
+ for (let i = 0; i < 3; i += 1) {
279
+ const p = pinParts[i] ?? 0;
280
+ const c = cliParts[i] ?? 0;
281
+ if (p < c) {
282
+ behind = true;
283
+ break;
284
+ }
285
+ if (p > c) break;
286
+ }
287
+ } else if (!matches) {
288
+ behind = true;
289
+ }
290
+ if (behind) {
291
+ return {
292
+ dualTruth: true,
293
+ cliVersion,
294
+ declaredPin,
295
+ code: 'PACKAGE_PIN_BEHIND_CLI',
296
+ note: `Managed CLI is arkgate@${cliVersion} but package.json pins ${declaredPin}. Bump the pin or re-run install so CI resolves the same version (common after upgrade --no-install).`,
297
+ };
298
+ }
299
+ return {
300
+ dualTruth: false,
301
+ cliVersion,
302
+ declaredPin,
303
+ code: 'PACKAGE_PIN_MATCHES',
304
+ note: `package.json pin ${declaredPin} is aligned with CLI arkgate@${cliVersion}.`,
305
+ };
306
+ }
307
+
204
308
  /**
205
309
  * Pin `arkgate` in package.json devDependencies (no package manager network call).
206
310
  *
@@ -16,6 +16,8 @@ import { normalize } from './scan-files.mjs';
16
16
 
17
17
  const MAX_LIST = 8;
18
18
  const MAX_FILE_BYTES = 256 * 1024;
19
+ /** Full AST scan stays off huge trees so doctor resident warm keeps the 500 ms UX ceiling. */
20
+ const MAX_FULL_SCAN_FILES = 2500;
19
21
  const LEXICAL_GATE = /\b(?:import|require)\s*\(|\bimport\s+\w+\s*=\s*require\s*\(/;
20
22
 
21
23
  /**
@@ -121,6 +123,23 @@ export function detectGraphBlindSpots(ts, root, files = []) {
121
123
  };
122
124
  }
123
125
 
126
+ // Large-tree deferral: re-reading every path for an advisory sensor would blow the
127
+ // doctorResidentWarm 500 ms absolute UX ceiling at the 10k perf fixture.
128
+ if (files.length > MAX_FULL_SCAN_FILES) {
129
+ return {
130
+ available: true,
131
+ advisory: true,
132
+ blockerGrade: false,
133
+ deferred: true,
134
+ count: 0,
135
+ templateInterpolationCount: 0,
136
+ otherNonLiteralCount: 0,
137
+ truncated: 0,
138
+ edges: [],
139
+ note: `Graph-blind full scan deferred (${files.length} files > ${MAX_FULL_SCAN_FILES}). Non-literal dynamic import/require remain unresolvable in architecture analysis; advisory incomplete-graph honesty is not enumerated at this scale.`,
140
+ };
141
+ }
142
+
124
143
  const resolvedRoot = path.resolve(root);
125
144
  const edges = [];
126
145
  for (const file of files) {
@@ -228,7 +247,7 @@ export function graphBlindSpotsHtml(state, esc = (v) => String(v)) {
228
247
  body = `<p><span class="tag warn">${state.count} unresolvable</span> dynamic edge(s) — graph incomplete (${state.templateInterpolationCount ?? 0} template-interpolation).</p><ul>${list}</ul>${more}<p class="muted">Advisory only — does not change the architecture verdict; edges are blind, not clean.</p>`;
229
248
  }
230
249
  return `
231
- <section data-advisory="graphBlindSpots">
250
+ <section class="section card" data-advisory="graphBlindSpots">
232
251
  <h2>Graph blind spots <span class="muted">(advisory — incomplete graph honesty; never a hard verdict)</span></h2>
233
252
  ${body}
234
253
  </section>`;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { effectiveCapabilityDeny } from './analysis-engine.mjs';
11
11
  import { graphBlindSpotsHtml } from './graph-blind.mjs';
12
+ import { formatRulesUnderContractHtml } from './rules-under-contract.mjs';
12
13
 
13
14
  // htmlEscape is injected by the caller (html-report.mjs) — importing it back
14
15
  // would be a dependency cycle, and the repo's own gate blocks that. The
@@ -114,7 +115,7 @@ function contractHealthHtml(health) {
114
115
  })
115
116
  .join('\n');
116
117
  return `
117
- <section data-advisory="contractHealth">
118
+ <section class="section card" data-advisory="contractHealth">
118
119
  <h2>Contract health <span class="muted">(advisory — meta-lint of the contract itself; never changes the verdict)</span></h2>
119
120
  ${invalid}
120
121
  ${body}
@@ -128,7 +129,7 @@ function ambientStateHtml(state) {
128
129
  if (!state) return '';
129
130
  if (state.available === false) {
130
131
  return `
131
- <section data-advisory="ambientState">
132
+ <section class="section card" data-advisory="ambientState">
132
133
  <h2>Ambient state <span class="muted">(advisory)</span></h2>
133
134
  <p class="muted">${esc(state.note ?? 'Sensor unavailable in this run.')}</p>
134
135
  </section>`;
@@ -147,7 +148,7 @@ function ambientStateHtml(state) {
147
148
  (state.findingCount > 10 ? `<p class="muted">…(+${state.findingCount - 10} more in doctor JSON)</p>` : '') +
148
149
  (state.acknowledged > 0 ? `<p class="muted">acknowledged module state: ${state.acknowledged}</p>` : '');
149
150
  return `
150
- <section data-advisory="ambientState">
151
+ <section class="section card" data-advisory="ambientState">
151
152
  <h2>Ambient state <span class="muted">(advisory — opt-in via pure layers; blocker-grade Y07 parked)</span></h2>
152
153
  ${body}
153
154
  </section>`;
@@ -211,7 +212,7 @@ function physicalCohesionHtml(pc) {
211
212
  ? `<p class="muted">next pilot (proposed, never applied): ${esc(pc.reshapePilot.nextPilot.pilotTarget)} — one pilot at a time via /ark-loop; merges are judgment cards only.</p>`
212
213
  : '';
213
214
  return `
214
- <section data-advisory="physicalCohesion">
215
+ <section class="section card" data-advisory="physicalCohesion">
215
216
  <h2>Physical cohesion <span class="muted">(advisory — facts, not a score; the verdict is unchanged)</span></h2>
216
217
  ${body}
217
218
  ${reshapeDecisionsHtml(pc.reshapeDecisions)}
@@ -230,7 +231,7 @@ function parseHealthHtml(health) {
230
231
  `<ul>${files.map((f) => `<li><code>${esc(f.file)}</code> — ${f.diagnosticCount} parse diagnostic(s)</li>`).join('')}</ul>` +
231
232
  (health.truncated > 0 ? `<p class="muted">…(+${health.truncated} more affected file(s); doctor list capped)</p>` : '');
232
233
  return `
233
- <section data-advisory="parseHealth">
234
+ <section class="section card" data-advisory="parseHealth">
234
235
  <h2>Parse health <span class="muted">(completeness evidence — affected syntax makes analysis partial)</span></h2>
235
236
  ${body}
236
237
  </section>`;
@@ -241,6 +242,11 @@ function parseHealthHtml(health) {
241
242
  * `computeDoctorAdvisories` returns — the parity guard enforces it.
242
243
  * @param escape injected HTML escaper (dependency points html-report → here only)
243
244
  */
245
+ function rulesUnderContractHtml(section) {
246
+ // Detail lives in rules-under-contract.mjs so this file stays under LOC budget.
247
+ return formatRulesUnderContractHtml(section, esc);
248
+ }
249
+
244
250
  export function renderAdvisorySections(advisories, escape) {
245
251
  if (!advisories || typeof advisories !== 'object') return '';
246
252
  if (typeof escape === 'function') esc = escape;
@@ -250,6 +256,7 @@ export function renderAdvisorySections(advisories, escape) {
250
256
  physicalCohesionHtml(advisories.physicalCohesion),
251
257
  parseHealthHtml(advisories.parseHealth),
252
258
  graphBlindSpotsHtml(advisories.graphBlindSpots, esc),
259
+ rulesUnderContractHtml(advisories.rulesUnderContract),
253
260
  ]
254
261
  .filter(Boolean)
255
262
  .join('\n');
@@ -138,14 +138,32 @@ export function warnLockfileConflict(root) {
138
138
  export function buildManagedAssetCatalog({ root, tools, compact = false, skillsOnly = false }) {
139
139
  const selectedTools = tools instanceof Set ? tools : new Set(tools ?? []);
140
140
  const assets = [];
141
+ // Path-keyed: codex + antigravity both target `.agents/skills/*/SKILL.md` (and
142
+ // antigravity + gemini share GEMINI.md). Duplicate plan entries caused apply to
143
+ // write once then fail the second pre-image assert (field: web-predial-ar).
144
+ const byPath = new Map();
141
145
  const add = (relativePath, content, kind = 'gate', scope = 'whole-file') => {
142
- assets.push({
146
+ const existing = byPath.get(relativePath);
147
+ if (existing) {
148
+ // Shared destinations (codex+antigravity skills, antigravity+gemini GEMINI.md)
149
+ // must agree on bytes; silent first-wins would hide divergent host templates.
150
+ if (existing.content !== content || existing.kind !== kind || existing.scope !== scope) {
151
+ throw new Error(
152
+ `managed asset catalog conflict at ${JSON.stringify(relativePath)}: ` +
153
+ `hosts produced different content/kind/scope for the same path`
154
+ );
155
+ }
156
+ return;
157
+ }
158
+ const asset = {
143
159
  relativePath,
144
160
  content,
145
161
  kind,
146
162
  scope,
147
163
  templateId: `${kind}:${relativePath}`,
148
- });
164
+ };
165
+ byPath.set(relativePath, asset);
166
+ assets.push(asset);
149
167
  };
150
168
 
151
169
  if (!skillsOnly) {
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Tooling I/O for ArkRules invariant coverage (AR10).
3
+ * Pure evaluation lives in Domain (`evaluateInvariantCoverage`); this module
4
+ * discovers test files and loads contents from disk (bounded).
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ const DEFAULT_TEST_NAME_RE =
10
+ /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$|\/__tests__\/|\/tests?\//i;
11
+
12
+ /** Max files to load for coverage evidence (budget). */
13
+ const MAX_COVERAGE_FILES = 400;
14
+ /** Max bytes per file when reading for title/symbol mining. */
15
+ const MAX_FILE_BYTES = 256 * 1024;
16
+
17
+ /**
18
+ * True when absolute is root or a file under root (separator-safe).
19
+ * @param {string} root
20
+ * @param {string} absolute
21
+ */
22
+ function isPathInsideRoot(root, absolute) {
23
+ const rootResolved = path.resolve(root);
24
+ const absResolved = path.resolve(absolute);
25
+ if (absResolved === rootResolved) return true;
26
+ const relative = path.relative(rootResolved, absResolved);
27
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
28
+ }
29
+
30
+ /**
31
+ * Minimal glob match for testGlobs (double-star slash = zero path segments).
32
+ * @param {string} glob
33
+ * @param {string} file
34
+ */
35
+ function matchSimpleGlob(glob, file) {
36
+ const pattern = String(glob || '').replace(/\\/g, '/');
37
+ const target = String(file || '').replace(/\\/g, '/');
38
+ if (!pattern) return false;
39
+ let out = '';
40
+ for (let i = 0; i < pattern.length; i += 1) {
41
+ const c = pattern[i];
42
+ if (c === '*') {
43
+ if (pattern[i + 1] === '*') {
44
+ if (pattern[i + 2] === '/') {
45
+ out += '(?:.*/)?';
46
+ i += 2;
47
+ } else {
48
+ out += '.*';
49
+ i += 1;
50
+ }
51
+ } else {
52
+ out += '[^/]*';
53
+ }
54
+ } else if (c === '?') {
55
+ out += '[^/]';
56
+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
57
+ out += `\\${c}`;
58
+ } else {
59
+ out += c;
60
+ }
61
+ }
62
+ return new RegExp(`^${out}$`).test(target);
63
+ }
64
+
65
+ /**
66
+ * @param {string} root
67
+ * @param {{ files?: Array<{ path: string }> }} facts
68
+ * @param {{ testGlobs?: string[] }} [opts]
69
+ * @returns {{ fileContents: Record<string, string>, testFiles: string[], testGlobsMissing: boolean }}
70
+ */
71
+ export function loadInvariantCoverageInputs(root, facts, opts = {}) {
72
+ const fileContents = {};
73
+ const testFiles = [];
74
+ const seen = new Set();
75
+ const testGlobs = Array.isArray(opts.testGlobs)
76
+ ? opts.testGlobs.filter((g) => typeof g === 'string' && g.length > 0)
77
+ : [];
78
+ const useCustomGlobs = testGlobs.length > 0;
79
+
80
+ const isTestPath = (rel) => {
81
+ if (useCustomGlobs) return testGlobs.some((g) => matchSimpleGlob(g, rel));
82
+ return DEFAULT_TEST_NAME_RE.test(rel);
83
+ };
84
+
85
+ const pushFile = (relPath, forceAsTest = false) => {
86
+ const rel = String(relPath || '')
87
+ .replace(/\\/g, '/')
88
+ .replace(/^\.\//, '');
89
+ if (!rel || seen.has(rel) || seen.size >= MAX_COVERAGE_FILES) return;
90
+ const absolute = path.resolve(root, rel);
91
+ if (!isPathInsideRoot(root, absolute)) return;
92
+ try {
93
+ const stat = fs.statSync(absolute);
94
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES) return;
95
+ const content = fs.readFileSync(absolute, 'utf8');
96
+ seen.add(rel);
97
+ fileContents[rel] = content;
98
+ if (forceAsTest || isTestPath(rel)) testFiles.push(rel);
99
+ } catch {
100
+ // skip unreadable
101
+ }
102
+ };
103
+
104
+ for (const file of facts?.files ?? []) {
105
+ if (file?.path) pushFile(file.path);
106
+ }
107
+
108
+ if (useCustomGlobs) {
109
+ // Walk project roots and keep files matching custom globs.
110
+ for (const dir of ['.', 'tests', 'test', 'src', '__tests__', 'spec']) {
111
+ const absDir = path.join(root, dir === '.' ? '' : dir);
112
+ if (!fs.existsSync(absDir)) continue;
113
+ walkTestFiles(absDir, root, (rel) => {
114
+ if (isTestPath(rel)) pushFile(rel, true);
115
+ });
116
+ }
117
+ } else {
118
+ // Walk common test roots when facts only cover production include globs.
119
+ for (const dir of ['tests', 'test', 'src', '__tests__']) {
120
+ const absDir = path.join(root, dir);
121
+ if (!fs.existsSync(absDir)) continue;
122
+ walkTestFiles(absDir, root, (rel) => {
123
+ if (isTestPath(rel)) pushFile(rel, true);
124
+ });
125
+ }
126
+ }
127
+
128
+ const testGlobsMissing = testFiles.length === 0;
129
+ return { fileContents, testFiles, testGlobsMissing };
130
+ }
131
+
132
+ /**
133
+ * @param {string} dir
134
+ * @param {string} root
135
+ * @param {(rel: string) => void} onFile
136
+ * @param {number} [depth]
137
+ */
138
+ function walkTestFiles(dir, root, onFile, depth = 0) {
139
+ if (depth > 8) return;
140
+ let entries;
141
+ try {
142
+ entries = fs.readdirSync(dir, { withFileTypes: true });
143
+ } catch {
144
+ return;
145
+ }
146
+ for (const entry of entries) {
147
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue;
148
+ const absolute = path.join(dir, entry.name);
149
+ if (entry.isDirectory()) {
150
+ walkTestFiles(absolute, root, onFile, depth + 1);
151
+ continue;
152
+ }
153
+ if (!entry.isFile()) continue;
154
+ const rel = path.relative(root, absolute).replace(/\\/g, '/');
155
+ onFile(rel);
156
+ }
157
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Canonical algorithm: src/domain/invariantCoverage.ts
5
+ * Regenerate: node scripts/generate-cli-pure.mjs
6
+ * Drift check: node scripts/generate-cli-pure.mjs --check
7
+ *
8
+ * Pure CLI helper (bin/lib/invariant-coverage.mjs). Zero Node I/O.
9
+ */
10
+
11
+ function titleMatchesInvariant(content, id) {
12
+ // Match describe/it/test string titles containing the invariant id.
13
+ const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ const re = new RegExp(`(?:describe|it|test|context)\\s*\\(\\s*['"\`][^'"\`]*${escaped}[^'"\`]*['"\`]`, 'i');
15
+ return re.test(content) || content.includes(id);
16
+ }
17
+ function symbolPresent(fileContents, symbol) {
18
+ if (!symbol)
19
+ return false;
20
+ // Support Aggregate.method or bare method name.
21
+ const parts = symbol.split('.');
22
+ const needle = parts[parts.length - 1];
23
+ const className = parts.length > 1 ? parts[0] : null;
24
+ for (const content of Object.values(fileContents)) {
25
+ if (className && !content.includes(className))
26
+ continue;
27
+ if (new RegExp(`(?:function\\s+|\\b)${needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*[(<]`).test(content) ||
28
+ content.includes(symbol)) {
29
+ return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+ export function evaluateInvariantCoverage(input) {
35
+ const invariants = input.arkRules.invariants ?? [];
36
+ if (invariants.length === 0) {
37
+ return { coverage: [], violations: [], partial: false };
38
+ }
39
+ const testFiles = input.testFiles ?? [];
40
+ const testGlobsMissing = input.testGlobsMissing === true || testFiles.length === 0;
41
+ const coverage = [];
42
+ const violations = [];
43
+ for (const inv of invariants) {
44
+ const evidence = [];
45
+ const wantsTest = inv.coverage?.test !== false; // default: prefer test evidence when catalogued
46
+ const symbol = inv.coverage?.symbol;
47
+ if (!testGlobsMissing && wantsTest) {
48
+ for (const file of testFiles) {
49
+ const content = input.fileContents[file];
50
+ if (content && titleMatchesInvariant(content, inv.id)) {
51
+ evidence.push('test-title');
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ if (symbol && symbolPresent(input.fileContents, symbol)) {
57
+ evidence.push('symbol');
58
+ }
59
+ // Covered if any requested evidence is present.
60
+ // When coverage declares neither test nor symbol, require at least description-only advisory presence = not covered.
61
+ const requiresEvidence = inv.coverage?.test === true || Boolean(symbol) || inv.coverage === undefined;
62
+ const covered = requiresEvidence && evidence.length > 0
63
+ ? true
64
+ : inv.coverage?.test === false && !symbol
65
+ ? true // explicitly no coverage requirements
66
+ : evidence.length > 0;
67
+ // Partial only when tests are missing *and* no other evidence (e.g. symbol) completed coverage.
68
+ const partial = testGlobsMissing && wantsTest && evidence.length === 0;
69
+ coverage.push({
70
+ invariantId: inv.id,
71
+ layer: inv.provenance.layer,
72
+ sourceFile: inv.provenance.sourceFile,
73
+ mode: inv.mode,
74
+ covered: covered && !partial,
75
+ evidence,
76
+ partial,
77
+ description: inv.description,
78
+ });
79
+ if (!covered || partial) {
80
+ // Enforced + proven uncovered → failsStrict; partial always advisory (never fake green).
81
+ const failsStrict = inv.mode === 'enforced' && !partial;
82
+ violations.push({
83
+ ruleId: 'INVARIANT_UNCOVERED',
84
+ message: partial
85
+ ? `Invariant ${inv.id} coverage cannot be proven (test globs missing or empty); reporting partial, not covered.`
86
+ : `Invariant ${inv.id} is not covered by a test title or declared symbol.`,
87
+ file: inv.provenance.sourceFile,
88
+ line: 1,
89
+ arkruleId: inv.id,
90
+ arkruleSource: inv.provenance.sourceFile,
91
+ fromLayer: inv.provenance.layer,
92
+ severity: failsStrict ? 'error' : 'warning',
93
+ failsStrict,
94
+ });
95
+ }
96
+ }
97
+ // Top-level partial only from entry flags (symbol-only coverage must not stick partial).
98
+ return {
99
+ coverage,
100
+ violations,
101
+ partial: coverage.some((entry) => entry.partial),
102
+ };
103
+ }
104
+ /**
105
+ * Deterministic promotion gate: refuse advisory→enforced when invariant is uncovered.
106
+ */
107
+ export function canPromoteInvariant(coverage) {
108
+ if (!coverage) {
109
+ return {
110
+ ok: false,
111
+ reason: 'No coverage evidence supplied for this invariant; evaluate coverage before promoting to enforced.',
112
+ };
113
+ }
114
+ if (coverage.partial) {
115
+ return {
116
+ ok: false,
117
+ reason: 'Coverage is partial (missing test globs); cannot promote until evidence is complete.',
118
+ };
119
+ }
120
+ if (!coverage.covered) {
121
+ return {
122
+ ok: false,
123
+ reason: `Invariant ${coverage.invariantId} is uncovered; add a test title or symbol before promoting to enforced.`,
124
+ };
125
+ }
126
+ return { ok: true, reason: `Invariant ${coverage.invariantId} has coverage evidence.` };
127
+ }
@@ -689,5 +689,5 @@ export function renderManagedUpgrade(plan, options = {}) {
689
689
  }
690
690
  console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
691
691
  if (options.next) console.log(options.next);
692
- else console.log('Apply the exact preview with: ark upgrade --apply --no-install');
692
+ else console.log('Apply the exact preview with: npx arkgate upgrade --apply --no-install');
693
693
  }