cwtools-shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generated/mcpTools.d.ts +1112 -0
- package/dist/generated/mcpTools.js +1247 -0
- package/dist/host/diagnostics.d.ts +41 -0
- package/dist/host/diagnostics.js +18 -0
- package/dist/host/filesystem.d.ts +18 -0
- package/dist/host/filesystem.js +2 -0
- package/dist/host/hostServices.d.ts +49 -0
- package/dist/host/hostServices.js +2 -0
- package/dist/host/indexing.d.ts +54 -0
- package/dist/host/indexing.js +2 -0
- package/dist/host/lsp.d.ts +8 -0
- package/dist/host/lsp.js +27 -0
- package/dist/host/readiness.d.ts +11 -0
- package/dist/host/readiness.js +71 -0
- package/dist/host/vanillaCache.d.ts +12 -0
- package/dist/host/vanillaCache.js +56 -0
- package/dist/host/vsCodeHostServices.d.ts +27 -0
- package/dist/host/vsCodeHostServices.js +30 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +41 -0
- package/dist/knowledge/diagnosticRouting.d.ts +13 -0
- package/dist/knowledge/diagnosticRouting.js +57 -0
- package/dist/knowledge/gameKnowledge.d.ts +20 -0
- package/dist/knowledge/gameKnowledge.js +52 -0
- package/dist/knowledge/rules.d.ts +162 -0
- package/dist/knowledge/rules.js +1383 -0
- package/dist/knowledge/workflowHints.d.ts +11 -0
- package/dist/knowledge/workflowHints.js +31 -0
- package/dist/project/knowledge.d.ts +15 -0
- package/dist/project/knowledge.js +209 -0
- package/dist/project/profile.d.ts +45 -0
- package/dist/project/profile.js +177 -0
- package/dist/safety/localisation.d.ts +33 -0
- package/dist/safety/localisation.js +105 -0
- package/dist/safety/paths.d.ts +21 -0
- package/dist/safety/paths.js +122 -0
- package/dist/safety/writes.d.ts +3 -0
- package/dist/safety/writes.js +19 -0
- package/dist/tools/mcpSchema.d.ts +5 -0
- package/dist/tools/mcpSchema.js +19 -0
- package/dist/tools/names.d.ts +6 -0
- package/dist/tools/names.js +51 -0
- package/dist/tools/pdxBlock.d.ts +17 -0
- package/dist/tools/pdxBlock.js +140 -0
- package/dist/tools/registry.d.ts +10 -0
- package/dist/tools/registry.js +2 -0
- package/dist/tools/schema.d.ts +36 -0
- package/dist/tools/schema.js +21 -0
- package/dist/tools/symbols.d.ts +60 -0
- package/dist/tools/symbols.js +625 -0
- package/dist/tools/toolHandlers.d.ts +4 -0
- package/dist/tools/toolHandlers.js +291 -0
- package/package.json +24 -0
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.queryRulesWithHost = queryRulesWithHost;
|
|
37
|
+
exports.queryCwtSchemaWithHost = queryCwtSchemaWithHost;
|
|
38
|
+
exports.searchRuleCapabilitiesWithHost = searchRuleCapabilitiesWithHost;
|
|
39
|
+
exports.explainScopeWithHost = explainScopeWithHost;
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const crypto = __importStar(require("crypto"));
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
async function queryRulesWithHost(host, args) {
|
|
44
|
+
const { cache, meta } = await loadCwtRulesMemoized(host);
|
|
45
|
+
let rules = args.category === 'trigger'
|
|
46
|
+
? cache.triggers
|
|
47
|
+
: args.category === 'effect'
|
|
48
|
+
? cache.effects
|
|
49
|
+
: args.category === 'modifier'
|
|
50
|
+
? cache.modifiers
|
|
51
|
+
: cache.scopeChanges;
|
|
52
|
+
if (args.name) {
|
|
53
|
+
const needle = args.name.toLowerCase();
|
|
54
|
+
const filtered = rules.filter(rule => rule.name.toLowerCase().includes(needle));
|
|
55
|
+
if (filtered.length === 0 && rules.length > 0) {
|
|
56
|
+
rules = rules
|
|
57
|
+
.map(rule => ({ rule, score: levenshtein(needle, rule.name.toLowerCase()) }))
|
|
58
|
+
.sort((a, b) => a.score - b.score)
|
|
59
|
+
.slice(0, 5)
|
|
60
|
+
.map(item => ({
|
|
61
|
+
...item.rule,
|
|
62
|
+
description: `[FUZZY SUGGESTION] ${item.rule.description}`,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
rules = filtered;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (args.scope) {
|
|
70
|
+
const scope = args.scope.toLowerCase();
|
|
71
|
+
rules = rules.filter(rule => rule.scopes.length === 0
|
|
72
|
+
|| rule.scopes.some(candidate => {
|
|
73
|
+
const lower = candidate.toLowerCase();
|
|
74
|
+
return lower === scope || lower === 'all' || lower === 'any';
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
const truncated = rules.length > 80;
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
status: 'ready',
|
|
81
|
+
source: 'cwtools-node-rules',
|
|
82
|
+
data: {
|
|
83
|
+
rules: rules.slice(0, 80),
|
|
84
|
+
totalCount: rules.length,
|
|
85
|
+
truncated,
|
|
86
|
+
source: 'cwtools-node-rules',
|
|
87
|
+
rulesGeneration: meta.generation,
|
|
88
|
+
rulesContentHash: meta.contentHash,
|
|
89
|
+
warnings: [
|
|
90
|
+
...(rules.length === 0
|
|
91
|
+
? ['No CWT rule files were loaded for the active game/rules source; check rules configuration or reload CWTools before trusting empty results.']
|
|
92
|
+
: []),
|
|
93
|
+
'Phase 1 fallback: rules are parsed from CWT/log files. Add cwtools.ai.queryRules to make LSP the long-term semantic source.',
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function queryCwtSchemaWithHost(host, args = {}) {
|
|
99
|
+
const target = String(args.target ?? args.file ?? args.directory ?? '').trim();
|
|
100
|
+
const normalizedTarget = normalizeCwtSchemaTarget(host, target);
|
|
101
|
+
const name = args.name?.trim();
|
|
102
|
+
const limit = Math.max(1, Math.min(Number(args.limit ?? 5) || 5, 20));
|
|
103
|
+
const roots = await resolveRulesConfigPaths(host);
|
|
104
|
+
const candidates = [];
|
|
105
|
+
for (const root of roots) {
|
|
106
|
+
const files = await findCwtSchemaFiles(host, root, 1000);
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
let contentLower;
|
|
109
|
+
if (name) {
|
|
110
|
+
const read = await readRulesTextFile(host, file).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
111
|
+
contentLower = read.exists ? read.content.toLowerCase() : undefined;
|
|
112
|
+
}
|
|
113
|
+
const relativeRuleFile = path.relative(root, file).replace(/\\/g, '/');
|
|
114
|
+
const relNoExt = relativeRuleFile.replace(/\.cwt$/i, '');
|
|
115
|
+
const scored = scoreCwtSchemaFile(relNoExt, normalizedTarget, name, contentLower);
|
|
116
|
+
if (scored.score > 0) {
|
|
117
|
+
candidates.push({
|
|
118
|
+
file,
|
|
119
|
+
root,
|
|
120
|
+
relativeRuleFile,
|
|
121
|
+
score: scored.score,
|
|
122
|
+
matchedBy: scored.matchedBy,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
candidates.sort((a, b) => b.score - a.score || a.relativeRuleFile.localeCompare(b.relativeRuleFile));
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const entityCandidates = [];
|
|
130
|
+
const matches = [];
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
const key = `${candidate.root}|${candidate.relativeRuleFile}`;
|
|
133
|
+
if (seen.has(key))
|
|
134
|
+
continue;
|
|
135
|
+
seen.add(key);
|
|
136
|
+
if (matches.length >= limit)
|
|
137
|
+
break;
|
|
138
|
+
const excerpt = await buildCwtSchemaSnippet(host, candidate.file, name, !!args.includeContent);
|
|
139
|
+
entityCandidates.push(...await extractCwtSchemaEntities(host, candidate.file, candidate.root, candidate.relativeRuleFile, normalizedTarget, name));
|
|
140
|
+
matches.push({
|
|
141
|
+
ruleFile: candidate.file,
|
|
142
|
+
relativeRuleFile: candidate.relativeRuleFile,
|
|
143
|
+
sourceRoot: candidate.root,
|
|
144
|
+
score: candidate.score,
|
|
145
|
+
matchedBy: candidate.matchedBy,
|
|
146
|
+
...excerpt,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const entities = entityCandidates
|
|
150
|
+
.sort((a, b) => scoreCwtSchemaEntity(b, normalizedTarget, name) - scoreCwtSchemaEntity(a, normalizedTarget, name)
|
|
151
|
+
|| a.relativeRuleFile.localeCompare(b.relativeRuleFile)
|
|
152
|
+
|| a.line - b.line)
|
|
153
|
+
.slice(0, Math.min(50, Math.max(10, limit * 10)));
|
|
154
|
+
const warnings = [];
|
|
155
|
+
if (roots.length === 0) {
|
|
156
|
+
warnings.push('No active CWT config roots were found. Check the rules configuration or reload CWTools.');
|
|
157
|
+
}
|
|
158
|
+
if (matches.length === 0) {
|
|
159
|
+
warnings.push('No matching CWT schema file was found. This is not proof the construct is legal or illegal; retry with a broader target directory or inspect completion/diagnostics.');
|
|
160
|
+
}
|
|
161
|
+
if (matches.length > 0 && entities.length === 0) {
|
|
162
|
+
warnings.push('Matched CWT files did not expose type[...] summaries. Use the returned snippets directly, then confirm with completions/diagnostics or a verified current-version example.');
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
status: 'ready',
|
|
167
|
+
source: 'cwtools-node-rules',
|
|
168
|
+
data: {
|
|
169
|
+
status: matches.length > 0 ? 'ready' : 'not_found',
|
|
170
|
+
target: target || undefined,
|
|
171
|
+
normalizedTarget: normalizedTarget || undefined,
|
|
172
|
+
name,
|
|
173
|
+
rulesRoots: roots,
|
|
174
|
+
matches,
|
|
175
|
+
entities,
|
|
176
|
+
entityCount: entities.length,
|
|
177
|
+
warnings,
|
|
178
|
+
_hint: 'CWT schema is the primary legality source. Use entities for active type/path/subtype evidence, and use snippets for exact schema keys and comments. schemaKeys are CWT metadata keys, not necessarily direct game-script fields. If the schema is structural only, confirm intended usage with a verified vanilla archetype or mature project example before writing, then validate with completions/diagnostics.',
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function searchRuleCapabilitiesWithHost(host, args = {}) {
|
|
183
|
+
const { cache, meta } = await loadCwtRulesMemoized(host);
|
|
184
|
+
const categories = args.category && args.category !== 'all'
|
|
185
|
+
? [args.category]
|
|
186
|
+
: ['trigger', 'effect', 'scope_change', 'modifier'];
|
|
187
|
+
const rules = categories.flatMap(category => category === 'trigger' ? cache.triggers
|
|
188
|
+
: category === 'effect' ? cache.effects
|
|
189
|
+
: category === 'scope_change' ? cache.scopeChanges
|
|
190
|
+
: cache.modifiers);
|
|
191
|
+
const currentScope = args.currentScope?.trim().toLowerCase();
|
|
192
|
+
const desiredPushScope = args.desiredPushScope?.trim().toLowerCase();
|
|
193
|
+
const intentTokens = expandIntentTokens(args.intent ?? '');
|
|
194
|
+
const candidates = rules
|
|
195
|
+
.map(rule => scoreRuleCapability(rule, intentTokens, currentScope, desiredPushScope))
|
|
196
|
+
.filter(candidate => candidate.score > 0)
|
|
197
|
+
.sort((a, b) => b.score - a.score || a.rule.name.localeCompare(b.rule.name));
|
|
198
|
+
const limit = Math.max(1, Math.min(Number(args.limit ?? 10) || 10, 50));
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
status: 'ready',
|
|
202
|
+
source: 'cwtools-node-rules',
|
|
203
|
+
data: {
|
|
204
|
+
status: 'ready',
|
|
205
|
+
candidates: candidates.slice(0, limit),
|
|
206
|
+
totalConsidered: rules.length,
|
|
207
|
+
source: 'cwtools-node-rules',
|
|
208
|
+
rulesGeneration: meta.generation,
|
|
209
|
+
rulesContentHash: meta.contentHash,
|
|
210
|
+
warnings: [
|
|
211
|
+
...(rules.length === 0
|
|
212
|
+
? ['No CWT rule files were loaded for the active game/rules source; check rules configuration or reload CWTools before trusting empty results.']
|
|
213
|
+
: []),
|
|
214
|
+
'semanticHints are retrieval hints only; validate legality with hardFacts, completion, parse/diagnostics, or verified examples.',
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function explainScopeWithHost(host, args) {
|
|
220
|
+
const { cache, meta } = await loadCwtRulesMemoized(host);
|
|
221
|
+
const query = args.scope.trim();
|
|
222
|
+
if (cache.scopes.size === 0) {
|
|
223
|
+
return {
|
|
224
|
+
ok: false,
|
|
225
|
+
status: 'ready',
|
|
226
|
+
source: 'cwtools-node-rules',
|
|
227
|
+
data: {
|
|
228
|
+
status: 'not_found',
|
|
229
|
+
scope: query,
|
|
230
|
+
suggestions: [],
|
|
231
|
+
rulesGeneration: meta.generation,
|
|
232
|
+
rulesContentHash: meta.contentHash,
|
|
233
|
+
},
|
|
234
|
+
error: {
|
|
235
|
+
code: 'rules_source_empty',
|
|
236
|
+
message: 'No scopes were loaded from scopes.cwt. Check the active CWT rules source or reload rules; this is not evidence that the scope is invalid.',
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const scope = cache.scopes.get(query.toLowerCase());
|
|
241
|
+
if (!scope) {
|
|
242
|
+
const suggestions = Array.from(new Set(Array.from(cache.scopes.values()).map(item => item.name)))
|
|
243
|
+
.filter(name => name.toLowerCase().includes(query.toLowerCase()) || levenshtein(query.toLowerCase(), name.toLowerCase()) <= 3)
|
|
244
|
+
.slice(0, 10);
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
status: 'ready',
|
|
248
|
+
source: 'cwtools-node-rules',
|
|
249
|
+
data: {
|
|
250
|
+
status: 'not_found',
|
|
251
|
+
scope: query,
|
|
252
|
+
suggestions,
|
|
253
|
+
rulesGeneration: meta.generation,
|
|
254
|
+
rulesContentHash: meta.contentHash,
|
|
255
|
+
},
|
|
256
|
+
error: {
|
|
257
|
+
code: 'scope_not_found',
|
|
258
|
+
message: `Scope '${query}' was not found in scopes.cwt.`,
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const hints = [];
|
|
263
|
+
const detail = [
|
|
264
|
+
scope.description,
|
|
265
|
+
scope.aliases.length ? `aliases: ${scope.aliases.join(', ')}` : '',
|
|
266
|
+
scope.isSubscopeOf.length ? `is_subscope_of: ${scope.isSubscopeOf.join(', ')}` : '',
|
|
267
|
+
].filter(Boolean).join('; ');
|
|
268
|
+
if (detail) {
|
|
269
|
+
hints.push({
|
|
270
|
+
text: `Scope ${scope.name}: ${detail}`,
|
|
271
|
+
source: 'scopes.cwt',
|
|
272
|
+
file: scope.file,
|
|
273
|
+
line: scope.line,
|
|
274
|
+
confidence: 'hint',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
status: 'ready',
|
|
280
|
+
source: 'cwtools-node-rules',
|
|
281
|
+
data: {
|
|
282
|
+
status: 'ready',
|
|
283
|
+
scope: query,
|
|
284
|
+
canonicalName: scope.name,
|
|
285
|
+
aliases: scope.aliases,
|
|
286
|
+
isSubscopeOf: scope.isSubscopeOf,
|
|
287
|
+
description: scope.description,
|
|
288
|
+
source: { file: scope.file, line: scope.line },
|
|
289
|
+
semanticHints: hints,
|
|
290
|
+
rulesGeneration: meta.generation,
|
|
291
|
+
rulesContentHash: meta.contentHash,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function normalizeCwtSchemaTarget(host, value) {
|
|
296
|
+
let normalized = value.trim().replace(/\\/g, '/');
|
|
297
|
+
if (!normalized)
|
|
298
|
+
return '';
|
|
299
|
+
if (path.isAbsolute(value)) {
|
|
300
|
+
const relative = path.relative(host.workspaceRoot, value).replace(/\\/g, '/');
|
|
301
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
|
|
302
|
+
normalized = relative;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
normalized = normalized.replace(/^file:\/\/\/?/i, '').replace(/^\/+/, '');
|
|
306
|
+
const knownRoots = ['common', 'events', 'interface', 'gfx', 'sound', 'map', 'music', 'localisation', 'localization', 'history', 'decisions'];
|
|
307
|
+
const lower = normalized.toLowerCase();
|
|
308
|
+
for (const root of knownRoots) {
|
|
309
|
+
const marker = `${root}/`;
|
|
310
|
+
const idx = lower.indexOf(marker);
|
|
311
|
+
if (idx >= 0) {
|
|
312
|
+
normalized = normalized.slice(idx);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const hadKnownExt = /\.(txt|gui|gfx|asset|entity|cwt|shader)$/i.test(normalized);
|
|
317
|
+
normalized = normalized.replace(/\.(txt|gui|gfx|asset|entity|cwt|shader)$/i, '');
|
|
318
|
+
if (hadKnownExt) {
|
|
319
|
+
normalized = normalized.split('/').slice(0, -1).join('/');
|
|
320
|
+
}
|
|
321
|
+
return normalized.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '').toLowerCase();
|
|
322
|
+
}
|
|
323
|
+
async function findCwtSchemaFiles(host, root, maxFiles) {
|
|
324
|
+
if (host.rules?.listCwtFiles) {
|
|
325
|
+
return (await host.rules.listCwtFiles(root, { limit: maxFiles })).slice(0, maxFiles);
|
|
326
|
+
}
|
|
327
|
+
const rootRelative = workspaceRelativePath(host.workspaceRoot, root);
|
|
328
|
+
if (!rootRelative)
|
|
329
|
+
return [];
|
|
330
|
+
const results = [];
|
|
331
|
+
const ignoredDirs = new Set(['.git', 'node_modules', 'logs']);
|
|
332
|
+
const walk = async (relativeDir, depth) => {
|
|
333
|
+
if (results.length >= maxFiles || depth > 8)
|
|
334
|
+
return;
|
|
335
|
+
let entries;
|
|
336
|
+
try {
|
|
337
|
+
entries = await host.filesystem.list(relativeDir === '.' ? '' : relativeDir);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
if (results.length >= maxFiles)
|
|
344
|
+
break;
|
|
345
|
+
const childRelative = relativeDir === '.' ? entry.name : `${relativeDir}/${entry.name}`;
|
|
346
|
+
const fullPath = path.join(host.workspaceRoot, childRelative);
|
|
347
|
+
if (entry.type === 'directory') {
|
|
348
|
+
if (!ignoredDirs.has(entry.name))
|
|
349
|
+
await walk(childRelative, depth + 1);
|
|
350
|
+
}
|
|
351
|
+
else if (entry.type === 'file' && entry.name.toLowerCase().endsWith('.cwt')) {
|
|
352
|
+
results.push(fullPath);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
await walk(rootRelative, 0);
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
function workspaceRelativePath(workspaceRoot, fullPath) {
|
|
360
|
+
const relative = path.relative(workspaceRoot, fullPath);
|
|
361
|
+
if (relative === '')
|
|
362
|
+
return '.';
|
|
363
|
+
if (relative.startsWith('..') || path.isAbsolute(relative))
|
|
364
|
+
return undefined;
|
|
365
|
+
return relative.replace(/\\/g, '/');
|
|
366
|
+
}
|
|
367
|
+
function scoreCwtSchemaFile(relativeNoExt, normalizedTarget, name, contentLower) {
|
|
368
|
+
const rel = relativeNoExt.toLowerCase().replace(/\\/g, '/');
|
|
369
|
+
const base = rel.split('/').pop() ?? rel;
|
|
370
|
+
const targetParts = normalizedTarget.split('/').filter(Boolean);
|
|
371
|
+
const targetLast = targetParts[targetParts.length - 1] ?? '';
|
|
372
|
+
const matchedBy = [];
|
|
373
|
+
let score = 0;
|
|
374
|
+
if (normalizedTarget) {
|
|
375
|
+
if (rel === normalizedTarget) {
|
|
376
|
+
score += 100;
|
|
377
|
+
matchedBy.push('exact-cwt-path');
|
|
378
|
+
}
|
|
379
|
+
if (rel.endsWith(`/${normalizedTarget}`)) {
|
|
380
|
+
score += 90;
|
|
381
|
+
matchedBy.push('suffix-cwt-path');
|
|
382
|
+
}
|
|
383
|
+
if (normalizedTarget.startsWith(`${rel}/`)) {
|
|
384
|
+
score += 80;
|
|
385
|
+
matchedBy.push('target-under-cwt-path');
|
|
386
|
+
}
|
|
387
|
+
if (rel.includes(normalizedTarget)) {
|
|
388
|
+
score += 50;
|
|
389
|
+
matchedBy.push('contains-cwt-path');
|
|
390
|
+
}
|
|
391
|
+
if (targetLast) {
|
|
392
|
+
const normalizedBase = normalizeCwtNameSegment(base);
|
|
393
|
+
const normalizedTargetLast = normalizeCwtNameSegment(targetLast);
|
|
394
|
+
if (normalizedBase === normalizedTargetLast) {
|
|
395
|
+
score += 45;
|
|
396
|
+
matchedBy.push('entity-family-name');
|
|
397
|
+
}
|
|
398
|
+
else if (normalizedBase.includes(normalizedTargetLast) || normalizedTargetLast.includes(normalizedBase)) {
|
|
399
|
+
score += 25;
|
|
400
|
+
matchedBy.push('near-entity-family-name');
|
|
401
|
+
}
|
|
402
|
+
else if (levenshtein(normalizedBase, normalizedTargetLast) <= 3) {
|
|
403
|
+
score += 15;
|
|
404
|
+
matchedBy.push('fuzzy-entity-family-name');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (name?.trim()) {
|
|
409
|
+
const needle = name.trim().toLowerCase();
|
|
410
|
+
if (contentLower?.includes(needle)) {
|
|
411
|
+
score += 35;
|
|
412
|
+
matchedBy.push('name-in-cwt-content');
|
|
413
|
+
}
|
|
414
|
+
if (base.includes(needle)) {
|
|
415
|
+
score += 20;
|
|
416
|
+
matchedBy.push('name-in-cwt-file');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { score, matchedBy };
|
|
420
|
+
}
|
|
421
|
+
function normalizeCwtNameSegment(segment) {
|
|
422
|
+
return segment
|
|
423
|
+
.toLowerCase()
|
|
424
|
+
.replace(/_consolidated$/i, '')
|
|
425
|
+
.replace(/ies$/i, 'y')
|
|
426
|
+
.replace(/s$/i, '');
|
|
427
|
+
}
|
|
428
|
+
async function buildCwtSchemaSnippet(host, filePath, name, includeContent) {
|
|
429
|
+
const read = await readRulesTextFile(host, filePath).catch(error => ({
|
|
430
|
+
content: `Error reading CWT schema: ${error instanceof Error ? error.message : String(error)}`,
|
|
431
|
+
hasBom: false,
|
|
432
|
+
exists: false,
|
|
433
|
+
}));
|
|
434
|
+
if (!read.exists)
|
|
435
|
+
return { snippet: 'CWT schema file was not readable.', truncated: false };
|
|
436
|
+
if (read.content.length > 1000000) {
|
|
437
|
+
return { snippet: '[CWT file is larger than 1MB; narrow the query with name/target.]', truncated: true };
|
|
438
|
+
}
|
|
439
|
+
const lines = read.content.split(/\r?\n/);
|
|
440
|
+
const maxLines = includeContent ? 220 : 90;
|
|
441
|
+
let start = 0;
|
|
442
|
+
if (name?.trim()) {
|
|
443
|
+
const needle = name.trim().toLowerCase();
|
|
444
|
+
const hit = lines.findIndex(line => line.toLowerCase().includes(needle));
|
|
445
|
+
if (hit >= 0)
|
|
446
|
+
start = Math.max(0, hit - 25);
|
|
447
|
+
}
|
|
448
|
+
const endExclusive = Math.min(lines.length, start + maxLines);
|
|
449
|
+
return {
|
|
450
|
+
snippet: lines
|
|
451
|
+
.slice(start, endExclusive)
|
|
452
|
+
.map((line, index) => `${start + index + 1} | ${line}`)
|
|
453
|
+
.join('\n'),
|
|
454
|
+
startLine: start + 1,
|
|
455
|
+
endLine: endExclusive,
|
|
456
|
+
truncated: endExclusive < lines.length,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function extractCwtSchemaEntities(host, filePath, sourceRoot, relativeRuleFile, normalizedTarget, name) {
|
|
460
|
+
const read = await readRulesTextFile(host, filePath).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
461
|
+
if (!read.exists || read.content.length > 1000000)
|
|
462
|
+
return [];
|
|
463
|
+
const lines = read.content.split(/\r?\n/);
|
|
464
|
+
const summaries = [];
|
|
465
|
+
const needle = name?.trim().toLowerCase();
|
|
466
|
+
for (let index = 0; index < lines.length; index++) {
|
|
467
|
+
const line = lines[index];
|
|
468
|
+
if (line === undefined)
|
|
469
|
+
continue;
|
|
470
|
+
const typeMatch = line.match(/^\s*type\[([^\]]+)\]\s*=\s*\{/i);
|
|
471
|
+
if (!typeMatch)
|
|
472
|
+
continue;
|
|
473
|
+
const typeName = typeMatch[1];
|
|
474
|
+
if (!typeName)
|
|
475
|
+
continue;
|
|
476
|
+
const end = findCwtBlockEnd(lines, index);
|
|
477
|
+
const blockLines = lines.slice(index, end + 1);
|
|
478
|
+
const schemaBlock = findCwtSchemaBlock(lines, typeName.trim());
|
|
479
|
+
const summary = summarizeCwtTypeBlock({
|
|
480
|
+
name: typeName.trim(),
|
|
481
|
+
filePath,
|
|
482
|
+
sourceRoot,
|
|
483
|
+
relativeRuleFile,
|
|
484
|
+
startLine: index + 1,
|
|
485
|
+
block: blockLines.join('\n'),
|
|
486
|
+
blockLines,
|
|
487
|
+
schemaBlock,
|
|
488
|
+
});
|
|
489
|
+
if (cwtEntityMatches(summary, normalizedTarget, needle))
|
|
490
|
+
summaries.push(summary);
|
|
491
|
+
index = end;
|
|
492
|
+
}
|
|
493
|
+
return summaries;
|
|
494
|
+
}
|
|
495
|
+
function findCwtBlockEnd(lines, startIndex) {
|
|
496
|
+
let depth = 0;
|
|
497
|
+
let opened = false;
|
|
498
|
+
for (let index = startIndex; index < lines.length; index++) {
|
|
499
|
+
const line = (lines[index] ?? '').replace(/#.*$/, '');
|
|
500
|
+
for (const char of line) {
|
|
501
|
+
if (char === '{') {
|
|
502
|
+
depth++;
|
|
503
|
+
opened = true;
|
|
504
|
+
}
|
|
505
|
+
else if (char === '}') {
|
|
506
|
+
depth--;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (opened && depth <= 0)
|
|
510
|
+
return index;
|
|
511
|
+
}
|
|
512
|
+
return Math.min(lines.length - 1, startIndex + 120);
|
|
513
|
+
}
|
|
514
|
+
function summarizeCwtTypeBlock(args) {
|
|
515
|
+
const pathMatch = args.block.match(/^\s*path\s*=\s*"([^"]+)"/mi)
|
|
516
|
+
?? args.block.match(/^\s*path\s*=\s*([^\s#]+)/mi);
|
|
517
|
+
const nameFieldMatch = args.block.match(/^\s*name_field\s*=\s*"?([^\s#"]+)"?/mi);
|
|
518
|
+
const graphRelatedMatch = args.block.match(/^\s*graph_related_types\s*=\s*\{([^}]+)\}/mi);
|
|
519
|
+
const schemaKeys = [];
|
|
520
|
+
for (const line of args.blockLines) {
|
|
521
|
+
const keyMatch = line.match(/^\s*([A-Za-z_][\w.-]*)\s*=/);
|
|
522
|
+
if (!keyMatch)
|
|
523
|
+
continue;
|
|
524
|
+
const key = keyMatch[1];
|
|
525
|
+
if (!key)
|
|
526
|
+
continue;
|
|
527
|
+
if (!schemaKeys.includes(key))
|
|
528
|
+
schemaKeys.push(key);
|
|
529
|
+
if (schemaKeys.length >= 30)
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
const subtypes = Array.from(args.block.matchAll(/\bsubtype\[([^\]]+)\]/gi))
|
|
533
|
+
.map(match => match[1]?.trim() ?? '')
|
|
534
|
+
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index)
|
|
535
|
+
.slice(0, 40);
|
|
536
|
+
const typeKeyFilters = Array.from(args.block.matchAll(/^\s*##\s*type_key_filter\s*=\s*([^\s#}]+)/gmi))
|
|
537
|
+
.map(match => match[1]?.replace(/^"|"$/g, '').trim() ?? '')
|
|
538
|
+
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index)
|
|
539
|
+
.slice(0, 80);
|
|
540
|
+
const graphRelatedText = graphRelatedMatch?.[1];
|
|
541
|
+
const graphRelatedTypes = graphRelatedText
|
|
542
|
+
? graphRelatedText
|
|
543
|
+
.split(/\s+/)
|
|
544
|
+
.map(value => value.replace(/^"|"$/g, '').trim())
|
|
545
|
+
.filter(Boolean)
|
|
546
|
+
.slice(0, 40)
|
|
547
|
+
: undefined;
|
|
548
|
+
const snippetLines = args.blockLines.slice(0, 36);
|
|
549
|
+
return {
|
|
550
|
+
name: args.name,
|
|
551
|
+
path: pathMatch?.[1]?.trim(),
|
|
552
|
+
nameField: nameFieldMatch?.[1]?.trim(),
|
|
553
|
+
ruleFile: args.filePath,
|
|
554
|
+
relativeRuleFile: args.relativeRuleFile,
|
|
555
|
+
sourceRoot: args.sourceRoot,
|
|
556
|
+
line: args.startLine,
|
|
557
|
+
subtypes,
|
|
558
|
+
typeKeyFilters,
|
|
559
|
+
schemaKeys,
|
|
560
|
+
graphRelatedTypes,
|
|
561
|
+
shaderReferences: extractCwtShaderReferences(args.schemaBlock ?? ''),
|
|
562
|
+
matchedBy: [],
|
|
563
|
+
snippet: snippetLines.map((line, index) => `${args.startLine + index} | ${line}`).join('\n'),
|
|
564
|
+
truncated: snippetLines.length < args.blockLines.length,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
function findCwtSchemaBlock(lines, typeName) {
|
|
568
|
+
const escaped = typeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
569
|
+
const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*\\{`, 'i');
|
|
570
|
+
for (let index = 0; index < lines.length; index++) {
|
|
571
|
+
if (!pattern.test(lines[index] ?? ''))
|
|
572
|
+
continue;
|
|
573
|
+
return lines.slice(index, findCwtBlockEnd(lines, index) + 1).join('\n');
|
|
574
|
+
}
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
function extractCwtShaderReferences(schemaBlock) {
|
|
578
|
+
const references = [];
|
|
579
|
+
const seen = new Set();
|
|
580
|
+
const stack = [];
|
|
581
|
+
let depth = 0;
|
|
582
|
+
const lines = schemaBlock.split(/\r?\n/);
|
|
583
|
+
for (let index = 0; index < lines.length; index++) {
|
|
584
|
+
const line = stripCwtLineComment(lines[index] ?? '');
|
|
585
|
+
let leadingClose = 0;
|
|
586
|
+
while (leadingClose < line.length && /\s/.test(line[leadingClose]))
|
|
587
|
+
leadingClose++;
|
|
588
|
+
while (line[leadingClose] === '}') {
|
|
589
|
+
depth = Math.max(0, depth - 1);
|
|
590
|
+
leadingClose++;
|
|
591
|
+
while (stack.length > 0 && stack[stack.length - 1].depth > depth)
|
|
592
|
+
stack.pop();
|
|
593
|
+
while (leadingClose < line.length && /\s/.test(line[leadingClose]))
|
|
594
|
+
leadingClose++;
|
|
595
|
+
}
|
|
596
|
+
const assignment = line.slice(leadingClose).match(/^([A-Za-z_][\w.-]*)\s*=\s*(.*)$/);
|
|
597
|
+
const key = assignment?.[1];
|
|
598
|
+
const rhs = assignment?.[2]?.trim();
|
|
599
|
+
if (index > 0 && key && rhs) {
|
|
600
|
+
const argumentPath = [...stack.map(item => item.key), key].join('.').toLowerCase();
|
|
601
|
+
const add = (reference) => {
|
|
602
|
+
const identity = `${reference.argumentPath}|${reference.referenceKind}`;
|
|
603
|
+
if (!seen.has(identity)) {
|
|
604
|
+
seen.add(identity);
|
|
605
|
+
references.push(reference);
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
if (/^\$shader_effect\b/i.test(rhs)) {
|
|
609
|
+
add({ argumentPath, referenceKind: 'shader_effect', dynamicValuePolicy: 'allow_expression' });
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
const filepath = rhs.match(/^filepath\[\s*([^,\]]*)\s*,\s*(\.shader)\s*\]/i);
|
|
613
|
+
if (filepath) {
|
|
614
|
+
add({
|
|
615
|
+
argumentPath,
|
|
616
|
+
referenceKind: 'shader_file',
|
|
617
|
+
dynamicValuePolicy: 'literal_or_parameter',
|
|
618
|
+
pathPrefix: filepath[1]?.trim().replace(/\\/g, '/'),
|
|
619
|
+
extension: filepath[2].toLowerCase(),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
let opens = 0;
|
|
625
|
+
let closes = 0;
|
|
626
|
+
let quoted = false;
|
|
627
|
+
for (const char of line.slice(leadingClose)) {
|
|
628
|
+
if (char === '"')
|
|
629
|
+
quoted = !quoted;
|
|
630
|
+
else if (!quoted && char === '{')
|
|
631
|
+
opens++;
|
|
632
|
+
else if (!quoted && char === '}')
|
|
633
|
+
closes++;
|
|
634
|
+
}
|
|
635
|
+
if (index > 0 && key && rhs?.startsWith('{') && opens > closes) {
|
|
636
|
+
stack.push({ key: key.toLowerCase(), depth: depth + 1 });
|
|
637
|
+
}
|
|
638
|
+
depth += opens - closes;
|
|
639
|
+
while (stack.length > 0 && stack[stack.length - 1].depth > depth)
|
|
640
|
+
stack.pop();
|
|
641
|
+
}
|
|
642
|
+
return references;
|
|
643
|
+
}
|
|
644
|
+
function stripCwtLineComment(line) {
|
|
645
|
+
let quoted = false;
|
|
646
|
+
let escaped = false;
|
|
647
|
+
for (let index = 0; index < line.length; index++) {
|
|
648
|
+
const char = line[index];
|
|
649
|
+
if (escaped)
|
|
650
|
+
escaped = false;
|
|
651
|
+
else if (char === '\\' && quoted)
|
|
652
|
+
escaped = true;
|
|
653
|
+
else if (char === '"')
|
|
654
|
+
quoted = !quoted;
|
|
655
|
+
else if (char === '#' && !quoted)
|
|
656
|
+
return line.slice(0, index);
|
|
657
|
+
}
|
|
658
|
+
return line;
|
|
659
|
+
}
|
|
660
|
+
function cwtEntityMatches(summary, normalizedTarget, needle) {
|
|
661
|
+
const matchedBy = [];
|
|
662
|
+
const haystack = [
|
|
663
|
+
summary.name,
|
|
664
|
+
summary.path ?? '',
|
|
665
|
+
summary.nameField ?? '',
|
|
666
|
+
summary.relativeRuleFile,
|
|
667
|
+
...summary.subtypes,
|
|
668
|
+
...(summary.typeKeyFilters ?? []),
|
|
669
|
+
...summary.schemaKeys,
|
|
670
|
+
...(summary.graphRelatedTypes ?? []),
|
|
671
|
+
...(summary.shaderReferences ?? []).flatMap(reference => [reference.argumentPath, reference.referenceKind]),
|
|
672
|
+
].join('\n').toLowerCase().replace(/\\/g, '/');
|
|
673
|
+
if (!normalizedTarget && !needle)
|
|
674
|
+
matchedBy.push('listed-from-matched-cwt-file');
|
|
675
|
+
if (normalizedTarget) {
|
|
676
|
+
const targetLast = normalizedTarget.split('/').filter(Boolean).pop() ?? normalizedTarget;
|
|
677
|
+
const summaryPath = summary.path?.toLowerCase().replace(/\\/g, '/');
|
|
678
|
+
const relativeNoExt = summary.relativeRuleFile.toLowerCase().replace(/\\/g, '/').replace(/\.cwt$/i, '');
|
|
679
|
+
if (summaryPath === normalizedTarget)
|
|
680
|
+
matchedBy.push('exact-entity-path');
|
|
681
|
+
else if (summaryPath?.includes(normalizedTarget))
|
|
682
|
+
matchedBy.push('target-in-entity-path');
|
|
683
|
+
else if (relativeNoExt === normalizedTarget)
|
|
684
|
+
matchedBy.push('exact-cwt-path');
|
|
685
|
+
else if (targetLast && haystack.includes(targetLast.toLowerCase()))
|
|
686
|
+
matchedBy.push('target-token-in-entity');
|
|
687
|
+
}
|
|
688
|
+
if (needle && haystack.includes(needle))
|
|
689
|
+
matchedBy.push('name-in-entity-summary');
|
|
690
|
+
summary.matchedBy = matchedBy;
|
|
691
|
+
return matchedBy.length > 0;
|
|
692
|
+
}
|
|
693
|
+
function scoreCwtSchemaEntity(summary, normalizedTarget, name) {
|
|
694
|
+
let score = 0;
|
|
695
|
+
if (summary.matchedBy.includes('exact-entity-path'))
|
|
696
|
+
score += 100;
|
|
697
|
+
if (summary.matchedBy.includes('target-in-entity-path'))
|
|
698
|
+
score += 80;
|
|
699
|
+
if (summary.matchedBy.includes('exact-cwt-path'))
|
|
700
|
+
score += 70;
|
|
701
|
+
if (summary.matchedBy.includes('target-token-in-entity'))
|
|
702
|
+
score += 45;
|
|
703
|
+
if (summary.matchedBy.includes('name-in-entity-summary'))
|
|
704
|
+
score += 35;
|
|
705
|
+
if (summary.matchedBy.includes('listed-from-matched-cwt-file'))
|
|
706
|
+
score += 10;
|
|
707
|
+
const normalizedPath = summary.path?.toLowerCase().replace(/\\/g, '/') ?? '';
|
|
708
|
+
if (normalizedTarget && normalizedPath === normalizedTarget)
|
|
709
|
+
score += 20;
|
|
710
|
+
if (name && summary.name.toLowerCase() === name.toLowerCase())
|
|
711
|
+
score += 20;
|
|
712
|
+
return score;
|
|
713
|
+
}
|
|
714
|
+
// ─── Parsed-rules memoization (plan §7.4) ───────────────────────────────────
|
|
715
|
+
//
|
|
716
|
+
// loadCwtRules used to re-read and re-parse every rule file on each query.
|
|
717
|
+
// The memo keeps one parsed CwtRuleCache per host identity, invalidated by an
|
|
718
|
+
// mtime/size signature over a bounded candidate file set (12 files per config
|
|
719
|
+
// root). `generation` is a per-host monotonic reload counter; `contentHash` is
|
|
720
|
+
// sha256 (16 hex chars) over the length-prefixed concatenation of every
|
|
721
|
+
// candidate rule file's content — the same algorithm the extension-side
|
|
722
|
+
// LspToolHandler uses, so both ends describe rule revisions with the same
|
|
723
|
+
// hash semantics. The cache is process-local and bounded
|
|
724
|
+
// (CWT_RULES_MEMO_MAX_ENTRIES).
|
|
725
|
+
const CWT_RULE_FILE_CANDIDATES = [
|
|
726
|
+
'scopes.cwt',
|
|
727
|
+
path.join('logs', 'trigger_docs.log'),
|
|
728
|
+
path.join('logs', 'modifiers.log'),
|
|
729
|
+
'triggers.cwt',
|
|
730
|
+
'trigger.cwt',
|
|
731
|
+
path.join('generated', 'triggers.generated.cwt'),
|
|
732
|
+
'effects.cwt',
|
|
733
|
+
'effect.cwt',
|
|
734
|
+
path.join('generated', 'effects.generated.cwt'),
|
|
735
|
+
'modifier.cwt',
|
|
736
|
+
'scope_changes.cwt',
|
|
737
|
+
path.join('generated', 'scope_changes.generated.cwt'),
|
|
738
|
+
];
|
|
739
|
+
const CWT_RULES_MEMO_MAX_ENTRIES = 8;
|
|
740
|
+
/**
|
|
741
|
+
* When no candidate rule file exists on disk, the mtime signature cannot
|
|
742
|
+
* observe changes (e.g. a fully virtual rules host), so such entries are
|
|
743
|
+
* re-validated at most once per this interval.
|
|
744
|
+
*/
|
|
745
|
+
const CWT_RULES_MEMO_REFRESH_MS = 30000;
|
|
746
|
+
const cwtRulesMemo = new Map();
|
|
747
|
+
function computeRulesSignature(configPaths) {
|
|
748
|
+
const parts = [];
|
|
749
|
+
let sawDiskFiles = false;
|
|
750
|
+
for (const configPath of configPaths) {
|
|
751
|
+
for (const file of CWT_RULE_FILE_CANDIDATES) {
|
|
752
|
+
const fullPath = path.join(configPath, file);
|
|
753
|
+
try {
|
|
754
|
+
const stat = fs.statSync(fullPath);
|
|
755
|
+
parts.push(`${fullPath}:${stat.mtimeMs}:${stat.size}`);
|
|
756
|
+
sawDiskFiles = true;
|
|
757
|
+
}
|
|
758
|
+
catch {
|
|
759
|
+
parts.push(`${fullPath}:missing`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return { signature: parts.join('|'), sawDiskFiles };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* sha256 (truncated to 16 hex chars) over the length-prefixed concatenation
|
|
767
|
+
* of every existing candidate rule file's content, read through the host so
|
|
768
|
+
* the hash reflects exactly what was parsed. The extension-side
|
|
769
|
+
* LspToolHandler uses the same length-prefixed algorithm over its fs reads,
|
|
770
|
+
* so both ends share rule-revision hash semantics (plan §7.4).
|
|
771
|
+
*/
|
|
772
|
+
async function computeRulesContentHash(host, configPaths) {
|
|
773
|
+
const hash = crypto.createHash('sha256');
|
|
774
|
+
for (const configPath of configPaths) {
|
|
775
|
+
for (const file of CWT_RULE_FILE_CANDIDATES) {
|
|
776
|
+
const read = await readRulesTextFile(host, path.join(configPath, file)).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
777
|
+
if (!read.exists)
|
|
778
|
+
continue;
|
|
779
|
+
hash.update(`${read.content.length}:`);
|
|
780
|
+
hash.update(read.content);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return hash.digest('hex').slice(0, 16);
|
|
784
|
+
}
|
|
785
|
+
function cwtRulesHostKey(host) {
|
|
786
|
+
return [host.workspaceRoot, host.rules?.gameId ?? '', (host.rules?.configDirs ?? []).join(';')].join('|');
|
|
787
|
+
}
|
|
788
|
+
async function loadCwtRulesMemoized(host) {
|
|
789
|
+
const configPaths = await resolveRulesConfigPaths(host);
|
|
790
|
+
const hostKey = cwtRulesHostKey(host);
|
|
791
|
+
const { signature, sawDiskFiles } = computeRulesSignature(configPaths);
|
|
792
|
+
const memo = cwtRulesMemo.get(hostKey);
|
|
793
|
+
if (memo && memo.signature === signature && (memo.sawDiskFiles || host.now() - memo.computedAt < CWT_RULES_MEMO_REFRESH_MS)) {
|
|
794
|
+
return { cache: memo.cache, meta: { generation: memo.generation, contentHash: memo.contentHash } };
|
|
795
|
+
}
|
|
796
|
+
const cache = await loadCwtRulesFromPaths(host, configPaths);
|
|
797
|
+
const entry = {
|
|
798
|
+
signature,
|
|
799
|
+
sawDiskFiles,
|
|
800
|
+
generation: (memo?.generation ?? 0) + 1,
|
|
801
|
+
// Computed after the reload; re-reads the bounded candidate set through
|
|
802
|
+
// the host, which is acceptable because reloads are rare.
|
|
803
|
+
contentHash: await computeRulesContentHash(host, configPaths),
|
|
804
|
+
cache,
|
|
805
|
+
computedAt: host.now(),
|
|
806
|
+
};
|
|
807
|
+
cwtRulesMemo.set(hostKey, entry);
|
|
808
|
+
// Bounded: insertion-order eviction once the cap is exceeded.
|
|
809
|
+
while (cwtRulesMemo.size > CWT_RULES_MEMO_MAX_ENTRIES) {
|
|
810
|
+
const oldest = cwtRulesMemo.keys().next().value;
|
|
811
|
+
if (oldest === undefined)
|
|
812
|
+
break;
|
|
813
|
+
cwtRulesMemo.delete(oldest);
|
|
814
|
+
}
|
|
815
|
+
return { cache, meta: { generation: entry.generation, contentHash: entry.contentHash } };
|
|
816
|
+
}
|
|
817
|
+
async function loadCwtRulesFromPaths(host, configPaths) {
|
|
818
|
+
for (const configPath of configPaths) {
|
|
819
|
+
const triggerDocs = await readRulesTextFile(host, path.join(configPath, 'logs', 'trigger_docs.log')).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
820
|
+
const docs = triggerDocs.exists
|
|
821
|
+
? parseDocsLog(triggerDocs.content, path.join(configPath, 'logs', 'trigger_docs.log'))
|
|
822
|
+
: new Map();
|
|
823
|
+
const scopesRead = await readRulesTextFile(host, path.join(configPath, 'scopes.cwt')).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
824
|
+
const scopes = scopesRead.exists
|
|
825
|
+
? parseScopesFile(scopesRead.content, path.join(configPath, 'scopes.cwt'))
|
|
826
|
+
: new Map();
|
|
827
|
+
const triggers = await readRuleFiles(host, configPath, ['triggers.cwt', 'trigger.cwt', path.join('generated', 'triggers.generated.cwt')], 'trigger', docs, scopes);
|
|
828
|
+
const effects = await readRuleFiles(host, configPath, ['effects.cwt', 'effect.cwt', path.join('generated', 'effects.generated.cwt')], 'effect', docs, scopes);
|
|
829
|
+
const scopeChanges = await readRuleFiles(host, configPath, ['scope_changes.cwt', path.join('generated', 'scope_changes.generated.cwt')], 'scope_change', docs, scopes);
|
|
830
|
+
const modifierAliases = await readRuleFiles(host, configPath, ['modifier.cwt'], 'modifier', docs, scopes);
|
|
831
|
+
const modifierLog = await readModifiersLog(host, path.join(configPath, 'logs', 'modifiers.log'));
|
|
832
|
+
const modifiers = [...modifierAliases];
|
|
833
|
+
const modifierNames = new Set(modifiers.map(rule => rule.name.toLowerCase()));
|
|
834
|
+
for (const rule of modifierLog) {
|
|
835
|
+
if (!modifierNames.has(rule.name.toLowerCase()))
|
|
836
|
+
modifiers.push(rule);
|
|
837
|
+
}
|
|
838
|
+
if (triggers.length > 0 || effects.length > 0 || scopeChanges.length > 0 || modifiers.length > 0) {
|
|
839
|
+
return { triggers, effects, scopeChanges, modifiers, scopes };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return { triggers: [], effects: [], scopeChanges: [], modifiers: [], scopes: new Map() };
|
|
843
|
+
}
|
|
844
|
+
async function resolveRulesConfigPaths(host) {
|
|
845
|
+
const gameId = normalizeGameId(host.rules?.gameId) ?? await readProjectGameId(host);
|
|
846
|
+
const explicitDirs = host.rules?.configDirs ?? [];
|
|
847
|
+
const paths = [];
|
|
848
|
+
const add = (candidate) => {
|
|
849
|
+
if (!candidate?.trim())
|
|
850
|
+
return;
|
|
851
|
+
const normalized = path.resolve(candidate);
|
|
852
|
+
if (!paths.some(existing => samePath(existing, normalized)))
|
|
853
|
+
paths.push(normalized);
|
|
854
|
+
};
|
|
855
|
+
const addConfigDirOrRoot = (candidate) => {
|
|
856
|
+
if (!candidate?.trim())
|
|
857
|
+
return;
|
|
858
|
+
add(candidate);
|
|
859
|
+
add(path.join(candidate, 'config'));
|
|
860
|
+
};
|
|
861
|
+
for (const candidate of explicitDirs)
|
|
862
|
+
addConfigDirOrRoot(candidate);
|
|
863
|
+
const games = gameId ? [gameId] : ['stellaris'];
|
|
864
|
+
for (const game of games) {
|
|
865
|
+
addConfigDirOrRoot(path.join(host.workspaceRoot, '.cwtools', game));
|
|
866
|
+
addConfigDirOrRoot(path.join(host.workspaceRoot, 'release', 'rules', game));
|
|
867
|
+
addConfigDirOrRoot(path.join(host.workspaceRoot, 'submodules', `cwtools-${game}-config`));
|
|
868
|
+
if (game === 'stellaris')
|
|
869
|
+
add(path.join(host.workspaceRoot, 'submodules', 'cwtools-stellaris-config', 'config'));
|
|
870
|
+
}
|
|
871
|
+
return paths;
|
|
872
|
+
}
|
|
873
|
+
async function readProjectGameId(host) {
|
|
874
|
+
const fromProfileHost = await host.projectProfile?.readProfile().catch(() => null);
|
|
875
|
+
const profile = fromProfileHost ?? await readProjectProfileFile(host);
|
|
876
|
+
if (!profile || typeof profile !== 'object')
|
|
877
|
+
return undefined;
|
|
878
|
+
const game = profile.game;
|
|
879
|
+
if (!game || typeof game !== 'object')
|
|
880
|
+
return undefined;
|
|
881
|
+
return normalizeGameId(game.id);
|
|
882
|
+
}
|
|
883
|
+
async function readProjectProfileFile(host) {
|
|
884
|
+
let profilePath = path.join(host.workspaceRoot, '.cwtools', 'project', 'profile.json');
|
|
885
|
+
let read = await host.filesystem.readTextFile(profilePath).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
886
|
+
if (!read.exists) {
|
|
887
|
+
const legacyPath = path.join(host.workspaceRoot, '.cwtools-ai', 'project', 'profile.json');
|
|
888
|
+
read = await host.filesystem.readTextFile(legacyPath).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
889
|
+
}
|
|
890
|
+
if (!read.exists)
|
|
891
|
+
return null;
|
|
892
|
+
try {
|
|
893
|
+
return JSON.parse(read.content);
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function normalizeGameId(gameId) {
|
|
900
|
+
if (typeof gameId !== 'string')
|
|
901
|
+
return undefined;
|
|
902
|
+
const normalized = gameId.trim().toLowerCase();
|
|
903
|
+
return normalized || undefined;
|
|
904
|
+
}
|
|
905
|
+
function samePath(left, right) {
|
|
906
|
+
const a = path.resolve(left);
|
|
907
|
+
const b = path.resolve(right);
|
|
908
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
909
|
+
}
|
|
910
|
+
async function readRulesTextFile(host, filePath) {
|
|
911
|
+
if (host.rules?.readTextFile)
|
|
912
|
+
return host.rules.readTextFile(filePath);
|
|
913
|
+
return host.filesystem.readTextFile(filePath);
|
|
914
|
+
}
|
|
915
|
+
async function readRuleFiles(host, configPath, relativeFiles, category, docs, scopes) {
|
|
916
|
+
const rules = [];
|
|
917
|
+
for (const relativeFile of relativeFiles) {
|
|
918
|
+
rules.push(...await readRulesFile(host, path.join(configPath, relativeFile), category, docs, scopes));
|
|
919
|
+
}
|
|
920
|
+
return rules;
|
|
921
|
+
}
|
|
922
|
+
async function readRulesFile(host, filePath, category, docs, scopes) {
|
|
923
|
+
const read = await readRulesTextFile(host, filePath).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
924
|
+
if (!read.exists)
|
|
925
|
+
return [];
|
|
926
|
+
return parseCwtFile(read.content, filePath, category, docs, scopes);
|
|
927
|
+
}
|
|
928
|
+
async function readModifiersLog(host, filePath) {
|
|
929
|
+
const read = await readRulesTextFile(host, filePath).catch(() => ({ exists: false, content: '', hasBom: false }));
|
|
930
|
+
if (!read.exists)
|
|
931
|
+
return [];
|
|
932
|
+
const results = [];
|
|
933
|
+
for (const line of read.content.split(/\r?\n/)) {
|
|
934
|
+
const match = line.trim().match(/^- ([\w.-]+), Category: (.*)/);
|
|
935
|
+
if (match?.[1]) {
|
|
936
|
+
results.push({
|
|
937
|
+
name: match[1],
|
|
938
|
+
description: `Categories: ${match[2] ?? ''}`,
|
|
939
|
+
scopes: [],
|
|
940
|
+
syntax: match[1],
|
|
941
|
+
category: 'modifier',
|
|
942
|
+
sourceFile: filePath,
|
|
943
|
+
hardFacts: {
|
|
944
|
+
category: 'modifier',
|
|
945
|
+
syntax: match[1],
|
|
946
|
+
cwtSource: { file: filePath, line: results.length + 1 },
|
|
947
|
+
},
|
|
948
|
+
semanticHints: [{
|
|
949
|
+
text: `Categories: ${match[2] ?? ''}`,
|
|
950
|
+
source: 'modifiers.log',
|
|
951
|
+
file: filePath,
|
|
952
|
+
confidence: 'hint',
|
|
953
|
+
}],
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return results;
|
|
958
|
+
}
|
|
959
|
+
function parseDocsLog(content, filePath) {
|
|
960
|
+
const docs = new Map();
|
|
961
|
+
let current;
|
|
962
|
+
const lines = content.split(/\r?\n/);
|
|
963
|
+
for (let i = 0; i < lines.length; i++) {
|
|
964
|
+
const line = lines[i] ?? '';
|
|
965
|
+
const nameMatch = line.match(/^([\w.-]+)\s*-/);
|
|
966
|
+
if (nameMatch?.[1]) {
|
|
967
|
+
current = {
|
|
968
|
+
name: nameMatch[1],
|
|
969
|
+
description: line.slice(nameMatch[0].length).trim(),
|
|
970
|
+
syntaxLines: [],
|
|
971
|
+
line: i + 1,
|
|
972
|
+
};
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
const scopeMatch = line.match(/^Supported Scopes:\s*(.*)/);
|
|
976
|
+
if (scopeMatch?.[1] && current) {
|
|
977
|
+
docs.set(current.name, {
|
|
978
|
+
description: current.description,
|
|
979
|
+
syntax: current.syntaxLines.join('\n').trim(),
|
|
980
|
+
scopes: splitWords(scopeMatch[1]).filter(scope => scope !== 'none'),
|
|
981
|
+
file: filePath,
|
|
982
|
+
line: current.line,
|
|
983
|
+
});
|
|
984
|
+
current = undefined;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (current) {
|
|
988
|
+
if (line.trim().length > 0)
|
|
989
|
+
current.syntaxLines.push(line);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return docs;
|
|
993
|
+
}
|
|
994
|
+
function parseScopesFile(content, filePath) {
|
|
995
|
+
const scopes = new Map();
|
|
996
|
+
let pendingDescription = '';
|
|
997
|
+
let current;
|
|
998
|
+
const lines = content.split(/\r?\n/);
|
|
999
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1000
|
+
const line = (lines[i] ?? '').trim();
|
|
1001
|
+
const commentMatch = line.match(/^#+\s*(.+)$/);
|
|
1002
|
+
if (commentMatch?.[1] && !line.startsWith('## ')) {
|
|
1003
|
+
pendingDescription = commentMatch[1].trim();
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
const scopeMatch = line.match(/^([A-Za-z][\w.-]*)\s*=\s*\{\s*$/);
|
|
1007
|
+
if (scopeMatch?.[1]) {
|
|
1008
|
+
current = {
|
|
1009
|
+
name: scopeMatch[1],
|
|
1010
|
+
aliases: [],
|
|
1011
|
+
isSubscopeOf: [],
|
|
1012
|
+
description: pendingDescription || undefined,
|
|
1013
|
+
file: filePath,
|
|
1014
|
+
line: i + 1,
|
|
1015
|
+
};
|
|
1016
|
+
pendingDescription = '';
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (current) {
|
|
1020
|
+
const aliasesMatch = line.match(/^aliases\s*=\s*\{([^}]*)\}/);
|
|
1021
|
+
if (aliasesMatch?.[1])
|
|
1022
|
+
current.aliases = splitWords(aliasesMatch[1]);
|
|
1023
|
+
const subscopeMatch = line.match(/^is_subscope_of\s*=\s*\{([^}]*)\}/);
|
|
1024
|
+
if (subscopeMatch?.[1])
|
|
1025
|
+
current.isSubscopeOf = splitWords(subscopeMatch[1]);
|
|
1026
|
+
if (line === '}') {
|
|
1027
|
+
if (current.name !== 'types') {
|
|
1028
|
+
scopes.set(current.name.toLowerCase(), current);
|
|
1029
|
+
for (const alias of current.aliases)
|
|
1030
|
+
scopes.set(alias.toLowerCase(), current);
|
|
1031
|
+
}
|
|
1032
|
+
current = undefined;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return scopes;
|
|
1037
|
+
}
|
|
1038
|
+
function parseCwtFile(content, filePath, category, docs, scopes) {
|
|
1039
|
+
const results = [];
|
|
1040
|
+
let currentScopes = [];
|
|
1041
|
+
let currentSupportedScopes = [];
|
|
1042
|
+
let currentPushScope;
|
|
1043
|
+
let currentTypeKeyFilter;
|
|
1044
|
+
let currentDesc = '';
|
|
1045
|
+
const lines = content.split(/\r?\n/);
|
|
1046
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1047
|
+
const rawLine = lines[i] ?? '';
|
|
1048
|
+
const line = rawLine.trim();
|
|
1049
|
+
const directiveMatch = line.match(/^##\s*([A-Za-z_]+)\s*=\s*(.*)$/);
|
|
1050
|
+
const directive = directiveMatch?.[1]?.toLowerCase();
|
|
1051
|
+
const directiveValue = directiveMatch?.[2]?.trim() ?? '';
|
|
1052
|
+
if (directive === 'scope') {
|
|
1053
|
+
currentScopes = splitRuleValueList(directiveValue);
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (directive === 'supported_scopes') {
|
|
1057
|
+
currentSupportedScopes = splitRuleValueList(directiveValue);
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
if (directive === 'push_scope') {
|
|
1061
|
+
currentPushScope = stripRuleValueBraces(directiveValue).split(/\s+/)[0];
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (directive === 'type_key_filter') {
|
|
1065
|
+
currentTypeKeyFilter = stripRuleValueBraces(directiveValue).split(/\s+/)[0];
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
const scopeMatch = line.match(/^##\s*scope\s*=\s*\{?\s*([^}]*)\}?\s*$/i);
|
|
1069
|
+
if (scopeMatch?.[1]) {
|
|
1070
|
+
currentScopes = splitWords(scopeMatch[1]);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (line.startsWith('###')) {
|
|
1074
|
+
currentDesc = line.replace(/^#+\s*/, '').trim();
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (line.startsWith('## ') && !line.startsWith('## scope')) {
|
|
1078
|
+
const comment = line.slice(3).trim();
|
|
1079
|
+
if (comment && !/^(cardinality|replace_scope)/i.test(comment))
|
|
1080
|
+
currentDesc = comment;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
const nameMatch = line.match(/^alias\[(?:trigger|effect|modifier):([^\]]+)\]\s*=\s*(.*)/);
|
|
1084
|
+
if (nameMatch?.[1]) {
|
|
1085
|
+
const name = nameMatch[1];
|
|
1086
|
+
const doc = docs.get(name);
|
|
1087
|
+
const cwtBlockText = collectCwtBlockText(lines, i);
|
|
1088
|
+
const scopesForRule = doc?.scopes.length
|
|
1089
|
+
? doc.scopes
|
|
1090
|
+
: currentSupportedScopes.length
|
|
1091
|
+
? currentSupportedScopes
|
|
1092
|
+
: currentScopes;
|
|
1093
|
+
const syntax = doc?.syntax || normalizeInlineSyntax(name, nameMatch[2] ?? '');
|
|
1094
|
+
const description = doc?.description || currentDesc;
|
|
1095
|
+
const semanticHints = buildSemanticHints({
|
|
1096
|
+
description,
|
|
1097
|
+
doc,
|
|
1098
|
+
cwtDescription: currentDesc,
|
|
1099
|
+
scopes,
|
|
1100
|
+
relatedScopeNames: [
|
|
1101
|
+
...scopesForRule,
|
|
1102
|
+
...(currentPushScope ? [currentPushScope] : []),
|
|
1103
|
+
...extractScopeNamesFromSyntax(syntax),
|
|
1104
|
+
...extractScopeNamesFromSyntax(cwtBlockText),
|
|
1105
|
+
],
|
|
1106
|
+
cwtFile: filePath,
|
|
1107
|
+
cwtLine: i + 1,
|
|
1108
|
+
});
|
|
1109
|
+
results.push({
|
|
1110
|
+
name,
|
|
1111
|
+
description,
|
|
1112
|
+
scopes: scopesForRule,
|
|
1113
|
+
syntax,
|
|
1114
|
+
category,
|
|
1115
|
+
sourceFile: filePath,
|
|
1116
|
+
sourceLine: i + 1,
|
|
1117
|
+
hardFacts: {
|
|
1118
|
+
category,
|
|
1119
|
+
supportedScopes: scopesForRule,
|
|
1120
|
+
pushScope: currentPushScope,
|
|
1121
|
+
typeKeyFilter: currentTypeKeyFilter,
|
|
1122
|
+
valueReferences: extractCwtValueReferences(cwtBlockText),
|
|
1123
|
+
syntax,
|
|
1124
|
+
cwtSource: { file: filePath, line: i + 1 },
|
|
1125
|
+
},
|
|
1126
|
+
semanticHints,
|
|
1127
|
+
});
|
|
1128
|
+
currentScopes = [];
|
|
1129
|
+
currentSupportedScopes = [];
|
|
1130
|
+
currentPushScope = undefined;
|
|
1131
|
+
currentTypeKeyFilter = undefined;
|
|
1132
|
+
currentDesc = '';
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
return results;
|
|
1136
|
+
}
|
|
1137
|
+
function buildSemanticHints(args) {
|
|
1138
|
+
const hints = [];
|
|
1139
|
+
const seen = new Set();
|
|
1140
|
+
const add = (hint) => {
|
|
1141
|
+
const key = `${hint.source}:${hint.text}`;
|
|
1142
|
+
if (seen.has(key) || !hint.text.trim())
|
|
1143
|
+
return;
|
|
1144
|
+
seen.add(key);
|
|
1145
|
+
hints.push(hint);
|
|
1146
|
+
};
|
|
1147
|
+
if (args.doc?.description) {
|
|
1148
|
+
add({
|
|
1149
|
+
text: args.doc.description,
|
|
1150
|
+
source: 'trigger_docs.log',
|
|
1151
|
+
file: args.doc.file,
|
|
1152
|
+
line: args.doc.line,
|
|
1153
|
+
confidence: 'hint',
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
if (args.cwtDescription && args.cwtDescription !== args.doc?.description) {
|
|
1157
|
+
add({
|
|
1158
|
+
text: args.cwtDescription,
|
|
1159
|
+
source: 'cwt-comment',
|
|
1160
|
+
file: args.cwtFile,
|
|
1161
|
+
line: args.cwtLine,
|
|
1162
|
+
confidence: 'hint',
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
for (const scopeName of args.relatedScopeNames) {
|
|
1166
|
+
const scope = args.scopes.get(scopeName.toLowerCase());
|
|
1167
|
+
if (!scope)
|
|
1168
|
+
continue;
|
|
1169
|
+
const details = [
|
|
1170
|
+
scope.description,
|
|
1171
|
+
scope.aliases.length ? `aliases: ${scope.aliases.join(', ')}` : '',
|
|
1172
|
+
scope.isSubscopeOf.length ? `is_subscope_of: ${scope.isSubscopeOf.join(', ')}` : '',
|
|
1173
|
+
].filter(Boolean).join('; ');
|
|
1174
|
+
if (!details)
|
|
1175
|
+
continue;
|
|
1176
|
+
add({
|
|
1177
|
+
text: `Scope ${scope.name}: ${details}`,
|
|
1178
|
+
source: 'scopes.cwt',
|
|
1179
|
+
file: scope.file,
|
|
1180
|
+
line: scope.line,
|
|
1181
|
+
confidence: 'hint',
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
return hints.slice(0, 8);
|
|
1185
|
+
}
|
|
1186
|
+
function scoreRuleCapability(rule, intentTokens, currentScope, desiredPushScope) {
|
|
1187
|
+
let score = 0;
|
|
1188
|
+
const reasons = [];
|
|
1189
|
+
const supportedScopes = rule.hardFacts?.supportedScopes ?? rule.scopes;
|
|
1190
|
+
const pushScope = rule.hardFacts?.pushScope?.toLowerCase();
|
|
1191
|
+
const searchable = [
|
|
1192
|
+
rule.name,
|
|
1193
|
+
rule.description,
|
|
1194
|
+
rule.syntax,
|
|
1195
|
+
...(rule.semanticHints ?? []).map(hint => hint.text),
|
|
1196
|
+
].join(' ').toLowerCase();
|
|
1197
|
+
const ruleName = rule.name.toLowerCase();
|
|
1198
|
+
if (currentScope) {
|
|
1199
|
+
const matchesScope = supportedScopes.some(scope => {
|
|
1200
|
+
const lower = scope.toLowerCase();
|
|
1201
|
+
return lower === currentScope || lower === 'all' || lower === 'any';
|
|
1202
|
+
});
|
|
1203
|
+
if (matchesScope) {
|
|
1204
|
+
score += 60;
|
|
1205
|
+
reasons.push(`supported in current scope '${currentScope}'`);
|
|
1206
|
+
}
|
|
1207
|
+
else if (supportedScopes.length > 0) {
|
|
1208
|
+
score -= 20;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
if (desiredPushScope) {
|
|
1212
|
+
if (pushScope === desiredPushScope) {
|
|
1213
|
+
score += 120;
|
|
1214
|
+
reasons.push(`pushes scope to '${desiredPushScope}'`);
|
|
1215
|
+
}
|
|
1216
|
+
else if (searchable.includes(desiredPushScope)) {
|
|
1217
|
+
score += 15;
|
|
1218
|
+
reasons.push(`mentions '${desiredPushScope}'`);
|
|
1219
|
+
}
|
|
1220
|
+
else if (rule.category === 'scope_change') {
|
|
1221
|
+
score -= 10;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
for (const token of intentTokens) {
|
|
1225
|
+
if (token.length <= 1)
|
|
1226
|
+
continue;
|
|
1227
|
+
if (ruleName.includes(token)) {
|
|
1228
|
+
score += 25;
|
|
1229
|
+
reasons.push(`name matches '${token}'`);
|
|
1230
|
+
}
|
|
1231
|
+
else if (searchable.includes(token)) {
|
|
1232
|
+
score += 8;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
const wantsEvery = intentTokens.some(token => token === 'iterate' || token === 'every' || token === 'all');
|
|
1236
|
+
if (wantsEvery) {
|
|
1237
|
+
if (ruleName.startsWith('every_')) {
|
|
1238
|
+
score += 45;
|
|
1239
|
+
reasons.push('matches every/all iteration intent');
|
|
1240
|
+
}
|
|
1241
|
+
else if (/^(any|count|random|ordered)_/.test(ruleName)) {
|
|
1242
|
+
score -= 15;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (wantsEvery
|
|
1246
|
+
&& currentScope === 'fleet'
|
|
1247
|
+
&& desiredPushScope === 'ship'
|
|
1248
|
+
&& ruleName.includes('_owned_ship')
|
|
1249
|
+
&& !intentTokens.includes('controlled')) {
|
|
1250
|
+
score += 8;
|
|
1251
|
+
reasons.push('preferred default fleet-to-ship iterator variant');
|
|
1252
|
+
}
|
|
1253
|
+
if (intentTokens.includes('random') && ruleName.startsWith('random_')) {
|
|
1254
|
+
score += 20;
|
|
1255
|
+
reasons.push('matches random selection intent');
|
|
1256
|
+
}
|
|
1257
|
+
if (intentTokens.includes('event') && ruleName.endsWith('_event')) {
|
|
1258
|
+
score += 20;
|
|
1259
|
+
reasons.push('matches event firing intent');
|
|
1260
|
+
}
|
|
1261
|
+
if (rule.semanticHints?.some(hint => hint.source === 'trigger_docs.log')) {
|
|
1262
|
+
score += 3;
|
|
1263
|
+
}
|
|
1264
|
+
return {
|
|
1265
|
+
rule,
|
|
1266
|
+
score,
|
|
1267
|
+
reasons: Array.from(new Set(reasons)).slice(0, 8),
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
function expandIntentTokens(intent) {
|
|
1271
|
+
const lower = intent.toLowerCase();
|
|
1272
|
+
const direct = lower
|
|
1273
|
+
.split(/[^a-z0-9_.:-]+/i)
|
|
1274
|
+
.map(token => token.trim())
|
|
1275
|
+
.filter(Boolean);
|
|
1276
|
+
const synonyms = [
|
|
1277
|
+
[/舰队|艦隊/g, ['fleet']],
|
|
1278
|
+
[/舰船|艦船|飞船|飛船|船只|船\b/g, ['ship']],
|
|
1279
|
+
[/国家|國家|帝国|帝國/g, ['country']],
|
|
1280
|
+
[/行星|星球/g, ['planet']],
|
|
1281
|
+
[/殖民地/g, ['colony']],
|
|
1282
|
+
[/航母|载体|載體|承载|承載/g, ['carrier']],
|
|
1283
|
+
[/事件/g, ['event']],
|
|
1284
|
+
[/遍历|遍歷|每个|每個|所有/g, ['iterate', 'every']],
|
|
1285
|
+
[/随机|隨機/g, ['random']],
|
|
1286
|
+
[/作用域|范围|範圍/g, ['scope']],
|
|
1287
|
+
[/触发器|觸發器/g, ['trigger']],
|
|
1288
|
+
[/效果|效应|效應/g, ['effect']],
|
|
1289
|
+
];
|
|
1290
|
+
const expanded = [...direct];
|
|
1291
|
+
for (const [pattern, tokens] of synonyms) {
|
|
1292
|
+
pattern.lastIndex = 0;
|
|
1293
|
+
if (pattern.test(intent))
|
|
1294
|
+
expanded.push(...tokens);
|
|
1295
|
+
}
|
|
1296
|
+
return Array.from(new Set(expanded));
|
|
1297
|
+
}
|
|
1298
|
+
function extractScopeNamesFromSyntax(syntax) {
|
|
1299
|
+
const results = [];
|
|
1300
|
+
for (const match of syntax.matchAll(/<event\.([A-Za-z][\w.-]*)>/g)) {
|
|
1301
|
+
if (match[1])
|
|
1302
|
+
results.push(match[1]);
|
|
1303
|
+
}
|
|
1304
|
+
return results;
|
|
1305
|
+
}
|
|
1306
|
+
function collectCwtBlockText(lines, startIndex) {
|
|
1307
|
+
const collected = [];
|
|
1308
|
+
let depth = 0;
|
|
1309
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
1310
|
+
const line = lines[i] ?? '';
|
|
1311
|
+
collected.push(line);
|
|
1312
|
+
for (const ch of line) {
|
|
1313
|
+
if (ch === '{')
|
|
1314
|
+
depth++;
|
|
1315
|
+
else if (ch === '}')
|
|
1316
|
+
depth--;
|
|
1317
|
+
}
|
|
1318
|
+
if ((i === startIndex && depth === 0) || (i > startIndex && depth <= 0))
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
return collected.join('\n');
|
|
1322
|
+
}
|
|
1323
|
+
function extractCwtValueReferences(blockText) {
|
|
1324
|
+
const references = [];
|
|
1325
|
+
const seen = new Set();
|
|
1326
|
+
const add = (argumentPath, access, typeName) => {
|
|
1327
|
+
const normalizedType = typeName.trim().toLowerCase();
|
|
1328
|
+
if (!normalizedType || references.length >= 32)
|
|
1329
|
+
return;
|
|
1330
|
+
const key = `${argumentPath.toLowerCase()}|${access}|${normalizedType}`;
|
|
1331
|
+
if (seen.has(key))
|
|
1332
|
+
return;
|
|
1333
|
+
seen.add(key);
|
|
1334
|
+
references.push({ argumentPath, access, typeName: normalizedType });
|
|
1335
|
+
};
|
|
1336
|
+
for (const rawLine of blockText.split(/\r?\n/)) {
|
|
1337
|
+
const line = rawLine.replace(/#.*$/, '');
|
|
1338
|
+
const assignment = line.match(/^\s*(alias\[(?:trigger|effect|modifier):[^\]]+\]|[A-Za-z_][\w.-]*)\s*=\s*(.*)$/i);
|
|
1339
|
+
if (!assignment?.[1] || assignment[2] === undefined)
|
|
1340
|
+
continue;
|
|
1341
|
+
const argumentPath = assignment[1].toLowerCase().startsWith('alias[') ? '$value' : assignment[1];
|
|
1342
|
+
const rhs = assignment[2].trim();
|
|
1343
|
+
const typed = rhs.match(/^(value_set|value|scope)\[([^\]]+)\]/i);
|
|
1344
|
+
if (typed?.[1] && typed[2]) {
|
|
1345
|
+
add(argumentPath, typed[1].toLowerCase(), typed[2]);
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const entityType = rhs.match(/^<([^>]+)>/);
|
|
1349
|
+
if (entityType?.[1])
|
|
1350
|
+
add(argumentPath, 'type', entityType[1]);
|
|
1351
|
+
}
|
|
1352
|
+
return references;
|
|
1353
|
+
}
|
|
1354
|
+
function normalizeInlineSyntax(name, raw) {
|
|
1355
|
+
const trimmed = raw.trim();
|
|
1356
|
+
if (!trimmed || trimmed === '{')
|
|
1357
|
+
return `${name} = { ... }`;
|
|
1358
|
+
return `${name} = ${trimmed}`;
|
|
1359
|
+
}
|
|
1360
|
+
function splitRuleValueList(value) {
|
|
1361
|
+
return splitWords(stripRuleValueBraces(value));
|
|
1362
|
+
}
|
|
1363
|
+
function stripRuleValueBraces(value) {
|
|
1364
|
+
return value.replace(/^\{\s*/, '').replace(/\s*\}$/, '').trim();
|
|
1365
|
+
}
|
|
1366
|
+
function splitWords(value) {
|
|
1367
|
+
return value.split(/\s+/).map(part => part.trim()).filter(Boolean);
|
|
1368
|
+
}
|
|
1369
|
+
function levenshtein(a, b) {
|
|
1370
|
+
const matrix = [];
|
|
1371
|
+
for (let i = 0; i <= b.length; i++)
|
|
1372
|
+
matrix[i] = [i];
|
|
1373
|
+
for (let j = 0; j <= a.length; j++)
|
|
1374
|
+
matrix[0][j] = j;
|
|
1375
|
+
for (let i = 1; i <= b.length; i++) {
|
|
1376
|
+
for (let j = 1; j <= a.length; j++) {
|
|
1377
|
+
matrix[i][j] = b.charAt(i - 1) === a.charAt(j - 1)
|
|
1378
|
+
? matrix[i - 1][j - 1]
|
|
1379
|
+
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return matrix[b.length][a.length];
|
|
1383
|
+
}
|