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.
- package/CHANGELOG.md +141 -3
- package/README.md +9 -6
- package/bin/ark-check-runtime.mjs +24 -2
- package/bin/ark-layer-match.mjs +25 -10
- package/bin/ark.mjs +18 -10
- package/bin/lib/agent-homes.mjs +1 -1
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/architecture-scan.mjs +91 -4
- package/bin/lib/ark-order-facts.mjs +11 -4
- package/bin/lib/ark-order-sensors.mjs +103 -3
- package/bin/lib/arkrule-file-hints.mjs +255 -20
- package/bin/lib/arkrules-sensors.mjs +364 -68
- package/bin/lib/baseline-key.mjs +45 -1
- package/bin/lib/config-contract.mjs +9 -3
- package/bin/lib/config-extras.mjs +1 -0
- package/bin/lib/contract-smells.mjs +12 -6
- package/bin/lib/diagnostic-catalog.mjs +1 -0
- package/bin/lib/doctor-human.mjs +35 -10
- package/bin/lib/doctor-next-actions.mjs +24 -3
- package/bin/lib/field-install.mjs +23 -2
- package/bin/lib/first-run-help.mjs +69 -5
- package/bin/lib/gate-files.mjs +108 -22
- package/bin/lib/managed-upgrade.mjs +9 -1
- package/bin/lib/resolved-candidate-facts.mjs +82 -1
- package/bin/lib/rules-inventory.mjs +7 -3
- package/bin/lib/upgrade-command.mjs +17 -4
- package/bin/lib/upstream-report.mjs +330 -0
- package/bin/lib/violations.mjs +51 -15
- package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
- package/dist/{diagnosticCatalog-DxKCTBbp.d.ts → diagnosticCatalog-biferT4R.d.ts} +11 -5
- package/dist/eslint/index.cjs +5 -8
- package/dist/eslint/index.d.ts +6 -4
- package/dist/eslint/index.js +5 -8
- package/dist/index.cjs +30 -33
- package/dist/index.d.ts +18 -6
- package/dist/index.js +30 -33
- package/dist/nestjs/index.cjs +3 -3
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +2 -2
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.ts +6 -2
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +11 -11
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +11 -11
- package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
- package/dist/{types-DxvmJO-D.d.ts → types-tGhZUiGX.d.ts} +1 -1
- package/docs/README.md +4 -3
- package/docs/agent-guide.md +27 -2
- package/docs/ai-gates.md +8 -0
- package/docs/arkorder.md +30 -7
- package/docs/brownfield-adoption.md +30 -0
- package/docs/configuration.md +61 -14
- package/docs/develop.md +4 -2
- package/docs/diagnostics.md +10 -0
- package/docs/package-surface.md +7 -5
- package/docs/use.md +11 -0
- package/package.json +1 -1
- package/schemas/ark.config.schema.json +12 -2
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +1 -1
- package/templates/agent-skills/ark-explore/SKILL.md +24 -3
- package/templates/skills/ark-contract.md +1 -1
- 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
|
-
//
|
|
80
|
-
const fileHints =
|
|
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:
|
|
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
|
-
|
|
14
|
-
const
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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 =
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
69
|
-
|
|
268
|
+
for (const rel of eligible) {
|
|
269
|
+
pushFile(rel);
|
|
70
270
|
}
|
|
71
271
|
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
}
|