arkgate 4.8.8 → 4.8.10

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 (65) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +9 -6
  3. package/bin/ark-check-runtime.mjs +24 -2
  4. package/bin/ark-layer-match.mjs +25 -10
  5. package/bin/ark.mjs +18 -10
  6. package/bin/lib/agent-homes.mjs +1 -1
  7. package/bin/lib/analysis-engine.mjs +8 -8
  8. package/bin/lib/architecture-scan.mjs +91 -4
  9. package/bin/lib/ark-order-facts.mjs +11 -4
  10. package/bin/lib/ark-order-sensors.mjs +103 -3
  11. package/bin/lib/arkrule-file-hints.mjs +255 -20
  12. package/bin/lib/arkrules-sensors.mjs +364 -68
  13. package/bin/lib/baseline-key.mjs +45 -1
  14. package/bin/lib/config-contract.mjs +9 -3
  15. package/bin/lib/config-extras.mjs +1 -0
  16. package/bin/lib/contract-smells.mjs +12 -6
  17. package/bin/lib/diagnostic-catalog.mjs +1 -0
  18. package/bin/lib/doctor-human.mjs +35 -10
  19. package/bin/lib/doctor-next-actions.mjs +24 -3
  20. package/bin/lib/field-install.mjs +23 -2
  21. package/bin/lib/first-run-help.mjs +69 -5
  22. package/bin/lib/gate-files.mjs +108 -22
  23. package/bin/lib/managed-upgrade.mjs +9 -1
  24. package/bin/lib/resolved-candidate-facts.mjs +82 -1
  25. package/bin/lib/rules-inventory.mjs +7 -3
  26. package/bin/lib/upgrade-command.mjs +17 -4
  27. package/bin/lib/upstream-report.mjs +330 -0
  28. package/bin/lib/violations.mjs +51 -15
  29. package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
  30. package/dist/{diagnosticCatalog-DxKCTBbp.d.ts → diagnosticCatalog-biferT4R.d.ts} +11 -5
  31. package/dist/eslint/index.cjs +5 -8
  32. package/dist/eslint/index.d.ts +6 -4
  33. package/dist/eslint/index.js +5 -8
  34. package/dist/index.cjs +30 -33
  35. package/dist/index.d.ts +18 -6
  36. package/dist/index.js +30 -33
  37. package/dist/nestjs/index.cjs +3 -3
  38. package/dist/nestjs/index.d.ts +3 -3
  39. package/dist/nestjs/index.js +2 -2
  40. package/dist/order/index.cjs +1 -1
  41. package/dist/order/index.d.ts +6 -2
  42. package/dist/order/index.js +1 -1
  43. package/dist/runtime/index.cjs +11 -11
  44. package/dist/runtime/index.d.ts +6 -6
  45. package/dist/runtime/index.js +11 -11
  46. package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
  47. package/dist/{types-DxvmJO-D.d.ts → types-tGhZUiGX.d.ts} +1 -1
  48. package/docs/README.md +4 -3
  49. package/docs/agent-guide.md +27 -2
  50. package/docs/ai-gates.md +8 -0
  51. package/docs/arkorder.md +30 -7
  52. package/docs/brownfield-adoption.md +30 -0
  53. package/docs/configuration.md +61 -14
  54. package/docs/develop.md +4 -2
  55. package/docs/diagnostics.md +10 -0
  56. package/docs/package-surface.md +7 -5
  57. package/docs/use.md +11 -0
  58. package/package.json +1 -1
  59. package/schemas/ark.config.schema.json +12 -2
  60. package/server.json +2 -2
  61. package/templates/agent-skills/README.md +1 -1
  62. package/templates/agent-skills/ark-contract/SKILL.md +1 -1
  63. package/templates/agent-skills/ark-explore/SKILL.md +24 -3
  64. package/templates/skills/ark-contract.md +1 -1
  65. package/templates/skills/ark-explore.md +24 -3
@@ -18,6 +18,85 @@ import {
18
18
  } from './invariant-coverage-io.mjs';
19
19
  import { loadArkRuleFileHints } from './arkrule-file-hints.mjs';
20
20
 
21
+ const HINT_CACHE_CAP = 16;
22
+ /** Process-local hint map keyed by scoped path + content hash. Not a second engine. */
23
+ const hintCache = new Map();
24
+
25
+ function normalizeScanRelPath(root, filePath) {
26
+ if (typeof filePath !== 'string' || filePath.length === 0) return null;
27
+ const resolvedRoot = path.resolve(root);
28
+ const absolute = path.isAbsolute(filePath)
29
+ ? path.resolve(filePath)
30
+ : path.resolve(resolvedRoot, filePath);
31
+ const relative = path.relative(resolvedRoot, absolute).replace(/\\/g, '/');
32
+ if (
33
+ !relative ||
34
+ relative === '..' ||
35
+ relative.startsWith('../') ||
36
+ path.isAbsolute(relative)
37
+ ) {
38
+ return null;
39
+ }
40
+ return relative;
41
+ }
42
+
43
+ /** Empty / missing `files` stays unbounded (full governed set). */
44
+ function fileLocalScope(root, files) {
45
+ if (!Array.isArray(files) || files.length === 0) return null;
46
+ const scoped = new Set();
47
+ for (const file of files) {
48
+ const rel = normalizeScanRelPath(root, file);
49
+ if (rel) scoped.add(rel);
50
+ }
51
+ return scoped.size > 0 ? scoped : null;
52
+ }
53
+
54
+ function filterHintPreload(fileContents, scoped) {
55
+ if (!scoped || !fileContents) return fileContents;
56
+ const out = {};
57
+ for (const [rel, content] of Object.entries(fileContents)) {
58
+ const key = String(rel || '')
59
+ .replace(/\\/g, '/')
60
+ .replace(/^\.\//, '');
61
+ if (scoped.has(key)) out[rel] = content;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function hintCacheKey(root, scopedFiles, arkRules) {
67
+ const filesPart = (scopedFiles ?? [])
68
+ .map((file) => `${file.path}\0${file.contentHash ?? ''}`)
69
+ .sort()
70
+ .join('\n');
71
+ const rulesPart = (arkRules?.structure ?? [])
72
+ .map(
73
+ (rule) =>
74
+ `${rule.sensor ?? ''}\0${rule.mode ?? ''}\0${(rule.appliesTo ?? []).join(',')}`
75
+ )
76
+ .join('\n');
77
+ return `${path.resolve(root)}\0${filesPart}\0${rulesPart}`;
78
+ }
79
+
80
+ function rememberHintCache(key, value) {
81
+ if (hintCache.has(key)) hintCache.delete(key);
82
+ hintCache.set(key, value);
83
+ while (hintCache.size > HINT_CACHE_CAP) {
84
+ const oldest = hintCache.keys().next().value;
85
+ hintCache.delete(oldest);
86
+ }
87
+ }
88
+
89
+ function loadHintsForScope(root, facts, arkRules, preloadedContents, scoped) {
90
+ const hintFacts = scoped
91
+ ? { files: (facts.files ?? []).filter((file) => scoped.has(file.path)) }
92
+ : facts;
93
+ const key = hintCacheKey(root, hintFacts.files, arkRules);
94
+ if (hintCache.has(key)) return hintCache.get(key);
95
+ const hints = loadArkRuleFileHints(root, hintFacts, arkRules, preloadedContents);
96
+ rememberHintCache(key, hints);
97
+ return hints;
98
+ }
99
+
21
100
  /** Resolve canonical facts and optionally retain filesystem probes for resident invalidation. */
22
101
  export function resolveArchitectureSnapshot({
23
102
  root,
@@ -66,9 +145,16 @@ export function resolveArchitectureSnapshot({
66
145
  err.issues = arkRulesLoad.errors;
67
146
  throw err;
68
147
  }
148
+ const scoped = fileLocalScope(root, files);
69
149
  const loadedContract = loadContract(effectiveConfig, configPath, {
70
150
  arkRules: arkRulesLoad.arkRules,
71
151
  });
152
+ const analysisContract = scoped
153
+ ? {
154
+ ...loadedContract,
155
+ classShapes: (facts.classShapes ?? []).filter((shape) => scoped.has(shape.file)),
156
+ }
157
+ : loadedContract;
72
158
  const hasInvariants = (arkRulesLoad.arkRules?.invariants?.length ?? 0) > 0;
73
159
  const coverageInputs = hasInvariants
74
160
  ? loadInvariantCoverageInputs(root, facts, {
@@ -76,15 +162,16 @@ export function resolveArchitectureSnapshot({
76
162
  ...coverageOptionsFromConfig(effectiveConfig),
77
163
  })
78
164
  : undefined;
79
- // AR07: Tooling fileHints for orchestration-only / thin-adapter (reuse coverage contents when present).
80
- const fileHints = loadArkRuleFileHints(
165
+ // File-local structure sensors + hint load honor `files`; graph still uses full facts.
166
+ const fileHints = loadHintsForScope(
81
167
  root,
82
168
  facts,
83
169
  arkRulesLoad.arkRules,
84
- coverageInputs?.fileContents
170
+ filterHintPreload(coverageInputs?.fileContents, scoped),
171
+ scoped
85
172
  );
86
173
  const analyzed = analyzeTrustedResolvedProject({
87
- contract: loadedContract,
174
+ contract: analysisContract,
88
175
  facts,
89
176
  ...(coverageInputs ? { coverageInputs } : {}),
90
177
  ...(fileHints ? { fileHints } : {}),
@@ -10,8 +10,13 @@
10
10
 
11
11
  export const ARKORDER_PLANE_FACTORY = 'createOrderPlane';
12
12
  export const ARKORDER_FORBIDDEN_METHODS = ['update', 'patch', 'set', 'mutate'];
13
- const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|knex|typeorm|mongoose)/;
14
- const PERSISTENCE_WRITE_HINT_RE = /\.(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))\s*\(|\bINSERT\s+INTO\b|\bUPDATE\s+[A-Za-z_][\w.]*\s+SET\b|\bDELETE\s+FROM\b/i;
13
+ /** Keep in lockstep with arkRuleSensors (WRITEAGG-001 / EOSF2-001). */
14
+ const IO_IMPORT_HINT_RE = /\bfrom\s+['"](?:@?prisma\/client|@supabase\/|drizzle-orm(?:\/[^'"]+)?|postgres(?:\/[^'"]+)?|typeorm|knex|mongodb|pg|mysql2|mongoose|better-sqlite3|ioredis|redis|kysely|sequelize)['"]|require\(\s*['"](?:@?prisma\/client|pg|postgres(?:\/[^'"]+)?|drizzle-orm(?:\/[^'"]+)?|knex|typeorm|mongoose)/;
15
+ const IO_ALIAS_IMPORT_RE = /\bfrom\s+['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)(?:\.[cm]?[jt]sx?)?['"]|require\(\s*['"](?:@\/|~\/)?(?:[\w.-]+\/)*(?:db|database|prisma|drizzle)/;
16
+ const PERSISTENCE_WRITE_HINT_RE = /\b(?:db|tx|client|prisma(?:Client)?|drizzle)\b(?:\s*\.\s*[A-Za-z_]\w*)*\s*\.\s*(?:insert(?:One|Many)?|update(?:One|Many)?|upsert|delete(?:One|Many)?|createMany|create|replaceOne|findOneAnd(?:Update|Delete|Replace))\s*\(|\bINSERT\s+INTO\b|\bUPDATE\s+[A-Za-z_][\w.]*\s+SET\b|\bDELETE\s+FROM\b/i;
17
+ function sourceImportsPersistenceDriver(content) {
18
+ return IO_IMPORT_HINT_RE.test(content) || IO_ALIAS_IMPORT_RE.test(content);
19
+ }
15
20
  function escapeRegExp(value) {
16
21
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
22
  }
@@ -49,7 +54,9 @@ export function extractArkOrderGenericUpdatesFromSource(file, content) {
49
54
  while ((match = re.exec(source)) !== null) {
50
55
  const method = match[1];
51
56
  const before = source.slice(Math.max(0, match.index - 80), match.index);
52
- if (!/\b(?:plane|orderPlane|order)\s*$/.test(before) && !/\bcreateOrderPlane\b/.test(source)) {
57
+ // EOSF5-001: Map/URLSearchParams/React order.set is not ξ mutation.
58
+ const calleeIsOrderPlane = /\b(?:plane|orderPlane)\s*(?:\?|!)?$/.test(before);
59
+ if (!calleeIsOrderPlane && !/\bcreateOrderPlane\b/.test(source)) {
53
60
  continue;
54
61
  }
55
62
  facts.push({ file, line: lineAt(content, match.index), method });
@@ -64,7 +71,7 @@ export function extractArkOrderXiFieldWritesFromSource(file, content, xiKeys) {
64
71
  if (xiKeys.length === 0)
65
72
  return [];
66
73
  const source = stripCommentsPreservingLines(content);
67
- if (!IO_IMPORT_HINT_RE.test(source) || !PERSISTENCE_WRITE_HINT_RE.test(source))
74
+ if (!sourceImportsPersistenceDriver(source) || !PERSISTENCE_WRITE_HINT_RE.test(source))
68
75
  return [];
69
76
  const facts = [];
70
77
  const seen = new Set();
@@ -11,6 +11,102 @@
11
11
  import { extractArkOrderGenericUpdatesFromSource, extractArkOrderIngestWritesXiFromSource, extractArkOrderPlaneCallsFromSource, extractArkOrderReleaseKeyCountsFromSource, extractArkOrderXiFieldWritesFromSource, isArkOrderModuleSpecifier, } from './ark-order-facts.mjs';
12
12
  import { extraMergeTeethAllowed, } from './extra-merge-teeth.mjs';
13
13
  import { deterministicNextAction } from './remediation.mjs';
14
+ /**
15
+ * XIWRITE-001: same engine as `globToRegExp` in src/domain/layerMatch.ts.
16
+ * Inlined so generate:cli-pure emits a self-contained bin/lib/ark-order-sensors.mjs
17
+ * (layerMatch is derived to bin/ark-layer-match.mjs, not a bin/lib sibling).
18
+ */
19
+ const appliesToRegexpCache = new Map();
20
+ function escapeAppliesToLiteral(ch) {
21
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
22
+ }
23
+ function normalizeAppliesToGlob(pattern) {
24
+ let out = '';
25
+ for (let i = 0; i < pattern.length; i += 1) {
26
+ const c = pattern[i];
27
+ if (c === '\\' && i + 1 < pattern.length) {
28
+ const next = pattern[i + 1];
29
+ if ('*?{}[],'.includes(next) || next === '\\') {
30
+ out += '\\' + next;
31
+ i += 1;
32
+ continue;
33
+ }
34
+ out += '/';
35
+ continue;
36
+ }
37
+ out += c;
38
+ }
39
+ return out;
40
+ }
41
+ function appliesToBracesBalanced(glob) {
42
+ let depth = 0;
43
+ for (let i = 0; i < glob.length; i += 1) {
44
+ const c = glob[i];
45
+ if (c === '\\') {
46
+ i += 1;
47
+ continue;
48
+ }
49
+ if (c === '{')
50
+ depth += 1;
51
+ else if (c === '}') {
52
+ depth -= 1;
53
+ if (depth < 0)
54
+ return false;
55
+ }
56
+ }
57
+ return depth === 0;
58
+ }
59
+ function globToRegExp(pattern) {
60
+ const cached = appliesToRegexpCache.get(pattern);
61
+ if (cached)
62
+ return cached;
63
+ const glob = normalizeAppliesToGlob(pattern);
64
+ const useBraces = appliesToBracesBalanced(glob);
65
+ let out = '';
66
+ let braceDepth = 0;
67
+ for (let i = 0; i < glob.length; i += 1) {
68
+ const c = glob[i];
69
+ if (c === '\\' && i + 1 < glob.length) {
70
+ out += escapeAppliesToLiteral(glob[i + 1]);
71
+ i += 1;
72
+ }
73
+ else if (c === '*') {
74
+ if (glob[i + 1] === '*') {
75
+ if (glob[i + 2] === '/') {
76
+ out += '(?:.*/)?';
77
+ i += 2;
78
+ }
79
+ else {
80
+ out += '.*';
81
+ i += 1;
82
+ }
83
+ }
84
+ else {
85
+ out += '[^/]*';
86
+ }
87
+ }
88
+ else if (c === '?') {
89
+ out += '[^/]';
90
+ }
91
+ else if (c === '{' && useBraces) {
92
+ out += '(?:';
93
+ braceDepth += 1;
94
+ }
95
+ else if (c === '}' && useBraces && braceDepth > 0) {
96
+ out += ')';
97
+ braceDepth -= 1;
98
+ }
99
+ else if (c === ',' && useBraces && braceDepth > 0) {
100
+ out += '|';
101
+ }
102
+ else {
103
+ out += escapeAppliesToLiteral(c);
104
+ }
105
+ }
106
+ const re = new RegExp(`^${out}$`);
107
+ appliesToRegexpCache.set(pattern, re);
108
+ return re;
109
+ }
14
110
  export const ARKORDER_TIER1_SENSOR_IDS = [
15
111
  'arkorder-missing-plane',
16
112
  'arkorder-kernel-in-domain',
@@ -31,6 +127,11 @@ export const ARKORDER_RULE_IDS = {
31
127
  'arkorder-information-budget': 'ARKORDER_INFORMATION_BUDGET',
32
128
  'arkorder-xi-ttl': 'ARKORDER_XI_TTL',
33
129
  };
130
+ function matchesArkOrderAppliesTo(file, appliesTo) {
131
+ if (!appliesTo || appliesTo.length === 0)
132
+ return true;
133
+ return appliesTo.some((pattern) => globToRegExp(pattern).test(file));
134
+ }
34
135
  function isDomainRoleLayer(layer, intentPrefixes = []) {
35
136
  const name = layer.trim();
36
137
  if (/^domain(?:model)?$/i.test(name) || /^domain(?=[A-Z_\-\s])/i.test(name))
@@ -103,9 +204,6 @@ export function evaluateArkOrderSensors(input) {
103
204
  findings.push(finding(extra, 'arkorder-generic-update', update.file, update.line, `Generic ${update.method}() on the order plane rewrites ξ; Haken forbids it.`, { target: update.method }, teethAllowed));
104
205
  }
105
206
  const xiKeys = extra.xiKeys ?? [];
106
- if (xiKeys.length > extra.maxXiKeys) {
107
- findings.push(finding(extra, 'arkorder-too-many-params', 'ark.config.json', 1, `arkOrder.xiKeys has ${xiKeys.length} keys; maxXiKeys is ${extra.maxXiKeys} (few slow modes).`, { target: String(xiKeys.length) }, teethAllowed));
108
- }
109
207
  for (const release of input.releaseKeyCounts ?? []) {
110
208
  if (release.keyCount <= extra.maxXiKeys)
111
209
  continue;
@@ -119,6 +217,8 @@ export function evaluateArkOrderSensors(input) {
119
217
  const fromLayer = input.layerForFile(write.file);
120
218
  if (!fromLayer || !managed.has(fromLayer))
121
219
  continue;
220
+ if (!matchesArkOrderAppliesTo(write.file, extra.appliesTo))
221
+ continue;
122
222
  findings.push(finding(extra, 'arkorder-xi-field-write', write.file, write.line, `File writes slow key ${JSON.stringify(write.key)} through a persistence driver; route the field through ingest or a pattern change through proposeRelease.`, { fromLayer, target: write.key }, teethAllowed));
123
223
  }
124
224
  findings.sort((left, right) => left.file.localeCompare(right.file) ||
@@ -2,12 +2,20 @@
2
2
  * Tooling I/O for AR07 orchestration-only / thin-adapter fileHints.
3
3
  * Pure derivation lives in Domain (`deriveArkRuleFileHints` / `buildArkRuleFileHints`);
4
4
  * this module loads bounded source text when those sensors are active.
5
+ *
6
+ * The file budget is `coverage.maxFiles` (default 400). There is no
7
+ * `arkrules.hintBudget`. When eligible governed files exceed the budget, the
8
+ * loader records exact hinted/governed counts and a completeness reason so an
9
+ * enforced sensor that never saw its scope cannot look green.
5
10
  */
6
11
  import fs from 'node:fs';
7
12
  import path from 'node:path';
8
13
  import { buildArkRuleFileHints } from './arkrules-sensors.mjs';
9
14
 
10
- const MAX_HINT_FILES = 400;
15
+ /** Default hint-file budget. Same lever as `coverage.maxFiles`. */
16
+ export const DEFAULT_MAX_HINT_FILES = 400;
17
+ /** Hard ceiling — same clamp as coverage.maxFiles. */
18
+ export const MAX_HINT_FILES_CAP = 20_000;
11
19
  const MAX_FILE_BYTES = 256 * 1024;
12
20
 
13
21
  const HINT_SENSORS = new Set([
@@ -16,6 +24,12 @@ const HINT_SENSORS = new Set([
16
24
  'writes-via-aggregate',
17
25
  ]);
18
26
 
27
+ const HINT_BUDGET_META = Symbol.for('arkgate.hintBudget');
28
+
29
+ /** --doctor sentence: coverage.maxFiles also bounds structural-hint preload. */
30
+ export const HINT_BUDGET_DOCTOR_LINE =
31
+ 'coverage.maxFiles (default 400) also bounds structural-hint preload for orchestration-only, thin-adapter, and writes-via-aggregate; there is no separate arkrules.hintBudget.';
32
+
19
33
  /**
20
34
  * @param {{ structure?: Array<{ sensor?: string }> } | null | undefined} arkRules
21
35
  */
@@ -23,36 +37,222 @@ export function needsArkRuleFileHints(arkRules) {
23
37
  return (arkRules?.structure ?? []).some((rule) => HINT_SENSORS.has(rule?.sensor));
24
38
  }
25
39
 
40
+ /**
41
+ * @param {unknown} maxFiles
42
+ * @returns {number}
43
+ */
44
+ export function resolveHintBudget(maxFiles) {
45
+ if (Number.isInteger(maxFiles) && maxFiles > 0) {
46
+ return Math.min(maxFiles, MAX_HINT_FILES_CAP);
47
+ }
48
+ return DEFAULT_MAX_HINT_FILES;
49
+ }
50
+
51
+ /**
52
+ * @param {unknown} hints
53
+ * @returns {null | {
54
+ * hinted: number,
55
+ * governed: number,
56
+ * budget: number,
57
+ * truncated: boolean,
58
+ * sensors: Array<{ sensor: string, reviewed: number, scope: number, mode: string }>,
59
+ * finding: { ruleId: string, message: string, failsStrict: boolean } | null,
60
+ * completenessReason: { code: string, message: string } | null,
61
+ * }}
62
+ */
63
+ export function getArkRuleHintBudget(hints) {
64
+ if (!hints || typeof hints !== 'object') return null;
65
+ return hints[HINT_BUDGET_META] ?? null;
66
+ }
67
+
68
+ /**
69
+ * @param {ReturnType<typeof getArkRuleHintBudget>} [budget]
70
+ * @returns {string}
71
+ */
72
+ export function formatHintBudgetDoctorLine(budget) {
73
+ if (!budget?.truncated) return HINT_BUDGET_DOCTOR_LINE;
74
+ return `${HINT_BUDGET_DOCTOR_LINE} Hinted ${budget.hinted} of ${budget.governed} eligible governed files (budget ${budget.budget}).`;
75
+ }
76
+
77
+ /**
78
+ * Minimal glob match (double-star slash = zero path segments).
79
+ * @param {string} glob
80
+ * @param {string} file
81
+ */
82
+ function matchSimpleGlob(glob, file) {
83
+ const pattern = String(glob || '').replace(/\\/g, '/');
84
+ const target = String(file || '').replace(/\\/g, '/');
85
+ if (!pattern) return false;
86
+ let out = '';
87
+ for (let i = 0; i < pattern.length; i += 1) {
88
+ const c = pattern[i];
89
+ if (c === '*') {
90
+ if (pattern[i + 1] === '*') {
91
+ if (pattern[i + 2] === '/') {
92
+ out += '(?:.*/)?';
93
+ i += 2;
94
+ } else {
95
+ out += '.*';
96
+ i += 1;
97
+ }
98
+ } else {
99
+ out += '[^/]*';
100
+ }
101
+ } else if (c === '?') {
102
+ out += '[^/]';
103
+ } else if (/[.+^${}()|[\]\\]/.test(c)) {
104
+ out += `\\${c}`;
105
+ } else {
106
+ out += c;
107
+ }
108
+ }
109
+ return new RegExp(`^${out}$`).test(target);
110
+ }
111
+
112
+ /**
113
+ * @param {string} file
114
+ * @param {unknown} appliesTo
115
+ */
116
+ function matchesAppliesTo(file, appliesTo) {
117
+ if (!Array.isArray(appliesTo) || appliesTo.length === 0) return true;
118
+ return appliesTo.some(
119
+ (pattern) => typeof pattern === 'string' && matchSimpleGlob(pattern, file)
120
+ );
121
+ }
122
+
123
+ function normalizeRel(relPath) {
124
+ return String(relPath || '')
125
+ .replace(/\\/g, '/')
126
+ .replace(/^\.\//, '');
127
+ }
128
+
129
+ function isHintEligiblePath(rel) {
130
+ if (!rel) return false;
131
+ if (!/\.(tsx?|mts|cts|jsx?|mjs|cjs)$/i.test(rel)) return false;
132
+ if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) return false;
133
+ return true;
134
+ }
135
+
136
+ /**
137
+ * One row per hint sensor (union of appliesTo; enforced if any rule is).
138
+ * @param {{ structure?: Array<{ sensor?: string, mode?: string, appliesTo?: string[] }> } | null | undefined} arkRules
139
+ */
140
+ function hintSensorScopes(arkRules) {
141
+ /** @type {Map<string, { sensor: string, appliesTo: string[], unconstrained: boolean, enforced: boolean }>} */
142
+ const bySensor = new Map();
143
+ for (const rule of arkRules?.structure ?? []) {
144
+ const sensor = rule?.sensor;
145
+ if (!HINT_SENSORS.has(sensor)) continue;
146
+ const applies = Array.isArray(rule.appliesTo)
147
+ ? rule.appliesTo.filter((item) => typeof item === 'string' && item.length > 0)
148
+ : [];
149
+ const existing = bySensor.get(sensor);
150
+ if (!existing) {
151
+ bySensor.set(sensor, {
152
+ sensor,
153
+ appliesTo: [...applies],
154
+ unconstrained: applies.length === 0,
155
+ enforced: rule.mode === 'enforced',
156
+ });
157
+ continue;
158
+ }
159
+ if (applies.length === 0) existing.unconstrained = true;
160
+ else existing.appliesTo.push(...applies);
161
+ if (rule.mode === 'enforced') existing.enforced = true;
162
+ }
163
+ return [...bySensor.values()];
164
+ }
165
+
166
+ /**
167
+ * @param {ReturnType<typeof hintSensorScopes>} scopes
168
+ * @param {string[]} eligible
169
+ * @param {Set<string>} hintedSet
170
+ */
171
+ function sensorCoverage(scopes, eligible, hintedSet) {
172
+ return scopes.map((scope) => {
173
+ const inScope = scope.unconstrained
174
+ ? eligible
175
+ : eligible.filter((file) => matchesAppliesTo(file, scope.appliesTo));
176
+ return {
177
+ sensor: scope.sensor,
178
+ reviewed: inScope.filter((file) => hintedSet.has(file)).length,
179
+ scope: inScope.length,
180
+ mode: scope.enforced ? 'enforced' : 'advisory',
181
+ };
182
+ });
183
+ }
184
+
185
+ function formatSensorSummaries(sensors) {
186
+ return sensors
187
+ .map((row) => `${row.sensor} reviewed ${row.reviewed}/${row.scope} files of its scope`)
188
+ .join('; ');
189
+ }
190
+
191
+ function attachHintBudget(hints, meta) {
192
+ Object.defineProperty(hints, HINT_BUDGET_META, {
193
+ value: meta,
194
+ enumerable: false,
195
+ configurable: true,
196
+ });
197
+ return hints;
198
+ }
199
+
26
200
  /**
27
201
  * Load governed source contents (bounded) and derive fileHints.
28
202
  *
203
+ * Return value stays a path→flags map so architecture-scan can pass it through
204
+ * unchanged. Truncation stats hang on a non-enumerable symbol — read them with
205
+ * `getArkRuleHintBudget`.
206
+ *
29
207
  * @param {string} root
30
208
  * @param {{ files?: Array<{ path: string }> }} facts
31
- * @param {{ structure?: Array<{ sensor?: string }> } | null | undefined} arkRules
209
+ * @param {{ structure?: Array<{ sensor?: string, mode?: string, appliesTo?: string[] }> } | null | undefined} arkRules
32
210
  * @param {Readonly<Record<string, string>>} [preloadedContents] optional reuse from coverage I/O
211
+ * @param {{ maxFiles?: number }} [options] `coverage.maxFiles` when the caller knows it
33
212
  * @returns {Record<string, { orchestrationHeavy?: boolean, adapterThick?: boolean, persistenceWrite?: boolean }> | undefined}
34
213
  */
35
- export function loadArkRuleFileHints(root, facts, arkRules, preloadedContents) {
214
+ export function loadArkRuleFileHints(root, facts, arkRules, preloadedContents, options) {
36
215
  if (!needsArkRuleFileHints(arkRules)) return undefined;
37
216
 
38
- const fileContents = { ...(preloadedContents ?? {}) };
217
+ const explicitBudget =
218
+ Number.isInteger(options?.maxFiles) && options.maxFiles > 0
219
+ ? Math.min(options.maxFiles, MAX_HINT_FILES_CAP)
220
+ : null;
221
+
222
+ const eligible = [];
223
+ const seenEligible = new Set();
224
+ for (const file of facts?.files ?? []) {
225
+ const rel = normalizeRel(file?.path);
226
+ if (!isHintEligiblePath(rel) || seenEligible.has(rel)) continue;
227
+ seenEligible.add(rel);
228
+ eligible.push(rel);
229
+ }
230
+ eligible.sort();
231
+
232
+ let fileContents = { ...(preloadedContents ?? {}) };
233
+ if (explicitBudget != null) {
234
+ const keys = Object.keys(fileContents).sort();
235
+ if (keys.length > explicitBudget) {
236
+ fileContents = Object.fromEntries(
237
+ keys.slice(0, explicitBudget).map((key) => [key, fileContents[key]])
238
+ );
239
+ }
240
+ }
39
241
  const seen = new Set(Object.keys(fileContents));
242
+ // When coverage already preloaded more than the default 400, that preload *is*
243
+ // the coverage.maxFiles lever. Do not trim it unless the caller passed maxFiles.
244
+ const budget =
245
+ explicitBudget ??
246
+ Math.min(Math.max(DEFAULT_MAX_HINT_FILES, seen.size), MAX_HINT_FILES_CAP);
40
247
 
41
248
  const rootResolved = path.resolve(root);
42
249
  const pushFile = (relPath) => {
43
- const rel = String(relPath || '')
44
- .replace(/\\/g, '/')
45
- .replace(/^\.\//, '');
46
- if (!rel || seen.has(rel) || seen.size >= MAX_HINT_FILES) return;
47
- if (!/\.(tsx?|mts|cts|jsx?|mjs|cjs)$/i.test(rel)) return;
48
- if (rel.includes('node_modules/') || rel.endsWith('.d.ts')) return;
250
+ const rel = normalizeRel(relPath);
251
+ if (!rel || seen.has(rel) || seen.size >= budget) return;
252
+ if (!isHintEligiblePath(rel)) return;
49
253
  const absolute = path.resolve(root, rel);
50
254
  const relative = path.relative(rootResolved, absolute);
51
- if (
52
- relative === '' ||
53
- relative.startsWith('..') ||
54
- path.isAbsolute(relative)
55
- ) {
255
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
56
256
  return;
57
257
  }
58
258
  try {
@@ -65,11 +265,46 @@ export function loadArkRuleFileHints(root, facts, arkRules, preloadedContents) {
65
265
  }
66
266
  };
67
267
 
68
- for (const file of facts?.files ?? []) {
69
- if (file?.path) pushFile(file.path);
268
+ for (const rel of eligible) {
269
+ pushFile(rel);
70
270
  }
71
271
 
72
- if (Object.keys(fileContents).length === 0) return undefined;
73
- const hints = buildArkRuleFileHints(fileContents);
74
- return Object.keys(hints).length > 0 ? hints : {};
272
+ const hintedSet = new Set(Object.keys(fileContents));
273
+ const hinted = hintedSet.size;
274
+ const governed = eligible.length;
275
+ const truncated = governed > hinted;
276
+ const sensors = sensorCoverage(hintSensorScopes(arkRules), eligible, hintedSet);
277
+ const enforcedMiss = sensors.some(
278
+ (row) => row.mode === 'enforced' && row.reviewed < row.scope
279
+ );
280
+ const sensorSummary = formatSensorSummaries(sensors);
281
+ const message = truncated
282
+ ? `Structural-hint budget exhausted: hinted ${hinted} of ${governed} eligible governed files (budget ${budget}; raise coverage.maxFiles — this cap also bounds structural-hint preload)${sensorSummary ? `. ${sensorSummary}` : ''}.`
283
+ : null;
284
+ const finding = truncated
285
+ ? {
286
+ ruleId: 'ARKRULE_HINT_BUDGET_EXHAUSTED',
287
+ message,
288
+ failsStrict: enforcedMiss,
289
+ }
290
+ : null;
291
+ const completenessReason = truncated
292
+ ? { code: 'ARKRULE_HINT_BUDGET_EXHAUSTED', message }
293
+ : null;
294
+ const meta = {
295
+ hinted,
296
+ governed,
297
+ budget,
298
+ truncated,
299
+ sensors,
300
+ finding,
301
+ completenessReason,
302
+ };
303
+
304
+ if (Object.keys(fileContents).length === 0 && !truncated) return undefined;
305
+
306
+ const derived = buildArkRuleFileHints(fileContents);
307
+ const hints = derived && typeof derived === 'object' ? derived : {};
308
+ attachHintBudget(hints, meta);
309
+ return hints;
75
310
  }