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.
Files changed (53) hide show
  1. package/dist/generated/mcpTools.d.ts +1112 -0
  2. package/dist/generated/mcpTools.js +1247 -0
  3. package/dist/host/diagnostics.d.ts +41 -0
  4. package/dist/host/diagnostics.js +18 -0
  5. package/dist/host/filesystem.d.ts +18 -0
  6. package/dist/host/filesystem.js +2 -0
  7. package/dist/host/hostServices.d.ts +49 -0
  8. package/dist/host/hostServices.js +2 -0
  9. package/dist/host/indexing.d.ts +54 -0
  10. package/dist/host/indexing.js +2 -0
  11. package/dist/host/lsp.d.ts +8 -0
  12. package/dist/host/lsp.js +27 -0
  13. package/dist/host/readiness.d.ts +11 -0
  14. package/dist/host/readiness.js +71 -0
  15. package/dist/host/vanillaCache.d.ts +12 -0
  16. package/dist/host/vanillaCache.js +56 -0
  17. package/dist/host/vsCodeHostServices.d.ts +27 -0
  18. package/dist/host/vsCodeHostServices.js +30 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.js +41 -0
  21. package/dist/knowledge/diagnosticRouting.d.ts +13 -0
  22. package/dist/knowledge/diagnosticRouting.js +57 -0
  23. package/dist/knowledge/gameKnowledge.d.ts +20 -0
  24. package/dist/knowledge/gameKnowledge.js +52 -0
  25. package/dist/knowledge/rules.d.ts +162 -0
  26. package/dist/knowledge/rules.js +1383 -0
  27. package/dist/knowledge/workflowHints.d.ts +11 -0
  28. package/dist/knowledge/workflowHints.js +31 -0
  29. package/dist/project/knowledge.d.ts +15 -0
  30. package/dist/project/knowledge.js +209 -0
  31. package/dist/project/profile.d.ts +45 -0
  32. package/dist/project/profile.js +177 -0
  33. package/dist/safety/localisation.d.ts +33 -0
  34. package/dist/safety/localisation.js +105 -0
  35. package/dist/safety/paths.d.ts +21 -0
  36. package/dist/safety/paths.js +122 -0
  37. package/dist/safety/writes.d.ts +3 -0
  38. package/dist/safety/writes.js +19 -0
  39. package/dist/tools/mcpSchema.d.ts +5 -0
  40. package/dist/tools/mcpSchema.js +19 -0
  41. package/dist/tools/names.d.ts +6 -0
  42. package/dist/tools/names.js +51 -0
  43. package/dist/tools/pdxBlock.d.ts +17 -0
  44. package/dist/tools/pdxBlock.js +140 -0
  45. package/dist/tools/registry.d.ts +10 -0
  46. package/dist/tools/registry.js +2 -0
  47. package/dist/tools/schema.d.ts +36 -0
  48. package/dist/tools/schema.js +21 -0
  49. package/dist/tools/symbols.d.ts +60 -0
  50. package/dist/tools/symbols.js +625 -0
  51. package/dist/tools/toolHandlers.d.ts +4 -0
  52. package/dist/tools/toolHandlers.js +291 -0
  53. 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
+ }