cwtools-shared 0.2.1 → 0.2.2

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.
@@ -147,7 +147,7 @@ export interface CwtRuleValueReference {
147
147
  }
148
148
  export interface RuleSemanticHint {
149
149
  text: string;
150
- source: 'trigger_docs.log' | 'scopes.cwt' | 'cwt-comment' | 'modifiers.log';
150
+ source: 'trigger_docs.log' | 'scopes.cwt' | 'links.cwt' | 'cwt-comment' | 'modifiers.log';
151
151
  file?: string;
152
152
  line?: number;
153
153
  confidence: 'hint';
@@ -50,8 +50,11 @@ async function queryRulesWithHost(host, args) {
50
50
  ? cache.modifiers
51
51
  : cache.scopeChanges;
52
52
  if (args.name) {
53
- const needle = args.name.toLowerCase();
54
- const filtered = rules.filter(rule => rule.name.toLowerCase().includes(needle));
53
+ const needle = normalizeRuleNameQuery(args.name, args.category);
54
+ const filtered = rules
55
+ .filter(rule => rule.name.toLowerCase().includes(needle))
56
+ .sort((a, b) => scoreRuleNameMatch(a.name, needle) - scoreRuleNameMatch(b.name, needle)
57
+ || a.name.localeCompare(b.name));
55
58
  if (filtered.length === 0 && rules.length > 0) {
56
59
  rules = rules
57
60
  .map(rule => ({ rule, score: levenshtein(needle, rule.name.toLowerCase()) }))
@@ -322,13 +325,45 @@ function normalizeCwtSchemaTarget(host, value) {
322
325
  }
323
326
  async function findCwtSchemaFiles(host, root, maxFiles) {
324
327
  if (host.rules?.listCwtFiles) {
325
- return (await host.rules.listCwtFiles(root, { limit: maxFiles })).slice(0, maxFiles);
328
+ return (await host.rules.listCwtFiles(root, { limit: maxFiles }))
329
+ .slice(0, maxFiles)
330
+ .sort((a, b) => a.localeCompare(b));
331
+ }
332
+ if (fs.existsSync(root)) {
333
+ const results = [];
334
+ const ignoredDirs = new Set(['.git', 'node_modules', 'logs', 'config']);
335
+ const walkDisk = (dir, depth) => {
336
+ if (results.length >= maxFiles || depth > 8)
337
+ return;
338
+ let entries;
339
+ try {
340
+ entries = fs.readdirSync(dir, { withFileTypes: true });
341
+ }
342
+ catch {
343
+ return;
344
+ }
345
+ entries.sort((a, b) => a.name.localeCompare(b.name));
346
+ for (const entry of entries) {
347
+ if (results.length >= maxFiles)
348
+ break;
349
+ const fullPath = path.join(dir, entry.name);
350
+ if (entry.isDirectory()) {
351
+ if (!ignoredDirs.has(entry.name))
352
+ walkDisk(fullPath, depth + 1);
353
+ }
354
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.cwt')) {
355
+ results.push(fullPath);
356
+ }
357
+ }
358
+ };
359
+ walkDisk(root, 0);
360
+ return results.sort((a, b) => a.localeCompare(b));
326
361
  }
327
362
  const rootRelative = workspaceRelativePath(host.workspaceRoot, root);
328
363
  if (!rootRelative)
329
364
  return [];
330
365
  const results = [];
331
- const ignoredDirs = new Set(['.git', 'node_modules', 'logs']);
366
+ const ignoredDirs = new Set(['.git', 'node_modules', 'logs', 'config']);
332
367
  const walk = async (relativeDir, depth) => {
333
368
  if (results.length >= maxFiles || depth > 8)
334
369
  return;
@@ -339,6 +374,7 @@ async function findCwtSchemaFiles(host, root, maxFiles) {
339
374
  catch {
340
375
  return;
341
376
  }
377
+ entries.sort((a, b) => a.name.localeCompare(b.name));
342
378
  for (const entry of entries) {
343
379
  if (results.length >= maxFiles)
344
380
  break;
@@ -354,7 +390,16 @@ async function findCwtSchemaFiles(host, root, maxFiles) {
354
390
  }
355
391
  };
356
392
  await walk(rootRelative, 0);
357
- return results;
393
+ return results.sort((a, b) => a.localeCompare(b));
394
+ }
395
+ async function collectCwtRuleSourceFiles(host, root, includeMissingLogs) {
396
+ const files = await findCwtSchemaFiles(host, root, CWT_RULE_FILE_SCAN_LIMIT);
397
+ for (const relativeLog of CWT_RULE_LOG_CANDIDATES) {
398
+ const fullPath = path.join(root, relativeLog);
399
+ if (includeMissingLogs || fs.existsSync(fullPath))
400
+ files.push(fullPath);
401
+ }
402
+ return Array.from(new Set(files)).sort((a, b) => a.localeCompare(b));
358
403
  }
359
404
  function workspaceRelativePath(workspaceRoot, fullPath) {
360
405
  const relative = path.relative(workspaceRoot, fullPath);
@@ -715,27 +760,18 @@ function scoreCwtSchemaEntity(summary, normalizedTarget, name) {
715
760
  //
716
761
  // loadCwtRules used to re-read and re-parse every rule file on each query.
717
762
  // 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
763
+ // mtime/size signature over every .cwt file under the active config roots plus
764
+ // the docs/modifier logs. `generation` is a per-host monotonic reload counter;
765
+ // `contentHash` is sha256 (16 hex chars) over the length-prefixed concatenation
766
+ // of the same files' content — the same algorithm the extension-side
722
767
  // LspToolHandler uses, so both ends describe rule revisions with the same
723
768
  // hash semantics. The cache is process-local and bounded
724
769
  // (CWT_RULES_MEMO_MAX_ENTRIES).
725
- const CWT_RULE_FILE_CANDIDATES = [
726
- 'scopes.cwt',
770
+ const CWT_RULE_LOG_CANDIDATES = [
727
771
  path.join('logs', 'trigger_docs.log'),
728
772
  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
773
  ];
774
+ const CWT_RULE_FILE_SCAN_LIMIT = 5000;
739
775
  const CWT_RULES_MEMO_MAX_ENTRIES = 8;
740
776
  /**
741
777
  * When no candidate rule file exists on disk, the mtime signature cannot
@@ -744,12 +780,11 @@ const CWT_RULES_MEMO_MAX_ENTRIES = 8;
744
780
  */
745
781
  const CWT_RULES_MEMO_REFRESH_MS = 30000;
746
782
  const cwtRulesMemo = new Map();
747
- function computeRulesSignature(configPaths) {
783
+ async function computeRulesSignature(host, configPaths) {
748
784
  const parts = [];
749
785
  let sawDiskFiles = false;
750
786
  for (const configPath of configPaths) {
751
- for (const file of CWT_RULE_FILE_CANDIDATES) {
752
- const fullPath = path.join(configPath, file);
787
+ for (const fullPath of await collectCwtRuleSourceFiles(host, configPath, true)) {
753
788
  try {
754
789
  const stat = fs.statSync(fullPath);
755
790
  parts.push(`${fullPath}:${stat.mtimeMs}:${stat.size}`);
@@ -772,8 +807,8 @@ function computeRulesSignature(configPaths) {
772
807
  async function computeRulesContentHash(host, configPaths) {
773
808
  const hash = crypto.createHash('sha256');
774
809
  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 }));
810
+ for (const file of await collectCwtRuleSourceFiles(host, configPath, true)) {
811
+ const read = await readRulesTextFile(host, file).catch(() => ({ exists: false, content: '', hasBom: false }));
777
812
  if (!read.exists)
778
813
  continue;
779
814
  hash.update(`${read.content.length}:`);
@@ -788,7 +823,7 @@ function cwtRulesHostKey(host) {
788
823
  async function loadCwtRulesMemoized(host) {
789
824
  const configPaths = await resolveRulesConfigPaths(host);
790
825
  const hostKey = cwtRulesHostKey(host);
791
- const { signature, sawDiskFiles } = computeRulesSignature(configPaths);
826
+ const { signature, sawDiskFiles } = await computeRulesSignature(host, configPaths);
792
827
  const memo = cwtRulesMemo.get(hostKey);
793
828
  if (memo && memo.signature === signature && (memo.sawDiskFiles || host.now() - memo.computedAt < CWT_RULES_MEMO_REFRESH_MS)) {
794
829
  return { cache: memo.cache, meta: { generation: memo.generation, contentHash: memo.contentHash } };
@@ -824,10 +859,25 @@ async function loadCwtRulesFromPaths(host, configPaths) {
824
859
  const scopes = scopesRead.exists
825
860
  ? parseScopesFile(scopesRead.content, path.join(configPath, 'scopes.cwt'))
826
861
  : 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);
862
+ const triggers = [];
863
+ const effects = [];
864
+ const scopeChanges = [];
865
+ const modifierAliases = [];
866
+ for (const file of await findCwtSchemaFiles(host, configPath, CWT_RULE_FILE_SCAN_LIMIT)) {
867
+ const relativeFile = path.relative(configPath, file).replace(/\\/g, '/');
868
+ const parsed = await readRulesFile(host, file, scopeChangeFileCategoryOverride(relativeFile), docs, scopes);
869
+ for (const rule of parsed) {
870
+ if (rule.category === 'trigger')
871
+ triggers.push(rule);
872
+ else if (rule.category === 'effect')
873
+ effects.push(rule);
874
+ else if (rule.category === 'modifier')
875
+ modifierAliases.push(rule);
876
+ else
877
+ scopeChanges.push(rule);
878
+ }
879
+ scopeChanges.push(...await readLinksFile(host, file, scopes));
880
+ }
831
881
  const modifierLog = await readModifiersLog(host, path.join(configPath, 'logs', 'modifiers.log'));
832
882
  const modifiers = [...modifierAliases];
833
883
  const modifierNames = new Set(modifiers.map(rule => rule.name.toLowerCase()));
@@ -925,6 +975,12 @@ async function readRulesFile(host, filePath, category, docs, scopes) {
925
975
  return [];
926
976
  return parseCwtFile(read.content, filePath, category, docs, scopes);
927
977
  }
978
+ function scopeChangeFileCategoryOverride(relativeRuleFile) {
979
+ const base = path.posix.basename(relativeRuleFile.replace(/\\/g, '/')).toLowerCase();
980
+ return base === 'scope_changes.cwt' || base === 'scope_changes.generated.cwt' || base === 'scope_change.cwt'
981
+ ? 'scope_change'
982
+ : undefined;
983
+ }
928
984
  async function readModifiersLog(host, filePath) {
929
985
  const read = await readRulesTextFile(host, filePath).catch(() => ({ exists: false, content: '', hasBom: false }));
930
986
  if (!read.exists)
@@ -1035,7 +1091,7 @@ function parseScopesFile(content, filePath) {
1035
1091
  }
1036
1092
  return scopes;
1037
1093
  }
1038
- function parseCwtFile(content, filePath, category, docs, scopes) {
1094
+ function parseCwtFile(content, filePath, categoryOverride, docs, scopes) {
1039
1095
  const results = [];
1040
1096
  let currentScopes = [];
1041
1097
  let currentSupportedScopes = [];
@@ -1080,9 +1136,11 @@ function parseCwtFile(content, filePath, category, docs, scopes) {
1080
1136
  currentDesc = comment;
1081
1137
  continue;
1082
1138
  }
1083
- const nameMatch = line.match(/^alias\[(?:trigger|effect|modifier):([^\]]+)\]\s*=\s*(.*)/);
1084
- if (nameMatch?.[1]) {
1085
- const name = nameMatch[1];
1139
+ const nameMatch = line.match(/^alias\[(trigger|effect|modifier):([^\]]+)\]\s*=\s*(.*)/);
1140
+ if (nameMatch?.[1] && nameMatch[2]) {
1141
+ const aliasKind = nameMatch[1];
1142
+ const category = categoryOverride ?? aliasKind;
1143
+ const name = nameMatch[2];
1086
1144
  const doc = docs.get(name);
1087
1145
  const cwtBlockText = collectCwtBlockText(lines, i);
1088
1146
  const scopesForRule = doc?.scopes.length
@@ -1090,7 +1148,7 @@ function parseCwtFile(content, filePath, category, docs, scopes) {
1090
1148
  : currentSupportedScopes.length
1091
1149
  ? currentSupportedScopes
1092
1150
  : currentScopes;
1093
- const syntax = doc?.syntax || normalizeInlineSyntax(name, nameMatch[2] ?? '');
1151
+ const syntax = doc?.syntax || normalizeInlineSyntax(name, nameMatch[3] ?? '');
1094
1152
  const description = doc?.description || currentDesc;
1095
1153
  const semanticHints = buildSemanticHints({
1096
1154
  description,
@@ -1134,6 +1192,92 @@ function parseCwtFile(content, filePath, category, docs, scopes) {
1134
1192
  }
1135
1193
  return results;
1136
1194
  }
1195
+ async function readLinksFile(host, filePath, scopes) {
1196
+ const read = await readRulesTextFile(host, filePath).catch(() => ({ exists: false, content: '', hasBom: false }));
1197
+ if (!read.exists || !/^\s*links\s*=\s*\{/im.test(read.content))
1198
+ return [];
1199
+ return parseLinksCwtFile(read.content, filePath, scopes);
1200
+ }
1201
+ function parseLinksCwtFile(content, filePath, scopes) {
1202
+ const results = [];
1203
+ const lines = content.split(/\r?\n/);
1204
+ let inLinks = false;
1205
+ let depth = 0;
1206
+ let current;
1207
+ for (let i = 0; i < lines.length; i++) {
1208
+ const rawLine = lines[i] ?? '';
1209
+ const line = stripCwtLineComment(rawLine).trim();
1210
+ if (!inLinks) {
1211
+ if (/^links\s*=\s*\{/.test(line)) {
1212
+ inLinks = true;
1213
+ depth = countBraceDelta(line);
1214
+ }
1215
+ continue;
1216
+ }
1217
+ if (!current && depth === 1) {
1218
+ const linkMatch = line.match(/^([A-Za-z_][\w.-]*)\s*=\s*\{\s*$/);
1219
+ if (linkMatch?.[1])
1220
+ current = { name: linkMatch[1], line: i + 1, inputScopes: [] };
1221
+ }
1222
+ else if (current) {
1223
+ const inputMatch = line.match(/^input_scopes\s*=\s*(.*)$/i);
1224
+ if (inputMatch?.[1])
1225
+ current.inputScopes = splitRuleValueList(inputMatch[1]).map(normalizeScopeName);
1226
+ const outputMatch = line.match(/^output_scope\s*=\s*(.*)$/i);
1227
+ if (outputMatch?.[1]) {
1228
+ const outputScope = stripRuleValueBraces(outputMatch[1]).split(/\s+/)[0];
1229
+ if (outputScope)
1230
+ current.outputScope = normalizeScopeName(outputScope);
1231
+ }
1232
+ }
1233
+ depth += countBraceDelta(line);
1234
+ if (current && depth <= 1) {
1235
+ if (current.outputScope) {
1236
+ const inputScopes = current.inputScopes.length ? current.inputScopes : ['all'];
1237
+ const syntax = `${current.name} = scope link (${inputScopes.join(' | ')} -> ${current.outputScope})`;
1238
+ const linkHint = {
1239
+ text: `Legal scope link '${current.name}' accepts input scopes { ${inputScopes.join(' ')} } and outputs '${current.outputScope}'. Context pointers such as from/prev/root/this select the current input scope; they are not fixed object fields.`,
1240
+ source: 'links.cwt',
1241
+ file: filePath,
1242
+ line: current.line,
1243
+ confidence: 'hint',
1244
+ };
1245
+ const scopeHints = buildSemanticHints({
1246
+ description: '',
1247
+ cwtDescription: '',
1248
+ scopes,
1249
+ relatedScopeNames: [...inputScopes, current.outputScope],
1250
+ cwtFile: filePath,
1251
+ cwtLine: current.line,
1252
+ });
1253
+ results.push({
1254
+ name: current.name,
1255
+ description: `Legal scope link from { ${inputScopes.join(' ')} } to ${current.outputScope}.`,
1256
+ scopes: inputScopes,
1257
+ syntax,
1258
+ category: 'scope_change',
1259
+ sourceFile: filePath,
1260
+ sourceLine: current.line,
1261
+ hardFacts: {
1262
+ category: 'scope_change',
1263
+ supportedScopes: inputScopes,
1264
+ pushScope: current.outputScope,
1265
+ valueReferences: [],
1266
+ syntax,
1267
+ cwtSource: { file: filePath, line: current.line },
1268
+ },
1269
+ semanticHints: [linkHint, ...scopeHints].slice(0, 8),
1270
+ });
1271
+ }
1272
+ current = undefined;
1273
+ }
1274
+ if (inLinks && depth <= 0) {
1275
+ inLinks = false;
1276
+ current = undefined;
1277
+ }
1278
+ }
1279
+ return results;
1280
+ }
1137
1281
  function buildSemanticHints(args) {
1138
1282
  const hints = [];
1139
1283
  const seen = new Set();
@@ -1287,7 +1431,12 @@ function expandIntentTokens(intent) {
1287
1431
  [/触发器|觸發器/g, ['trigger']],
1288
1432
  [/效果|效应|效應/g, ['effect']],
1289
1433
  ];
1290
- const expanded = [...direct];
1434
+ const expanded = [];
1435
+ for (const token of direct) {
1436
+ expanded.push(token);
1437
+ if (/[.:]/.test(token))
1438
+ expanded.push(...token.split(/[.:]+/).filter(Boolean));
1439
+ }
1291
1440
  for (const [pattern, tokens] of synonyms) {
1292
1441
  pattern.lastIndex = 0;
1293
1442
  if (pattern.test(intent))
@@ -1363,6 +1512,34 @@ function splitRuleValueList(value) {
1363
1512
  function stripRuleValueBraces(value) {
1364
1513
  return value.replace(/^\{\s*/, '').replace(/\s*\}$/, '').trim();
1365
1514
  }
1515
+ function normalizeRuleNameQuery(name, category) {
1516
+ const lowered = name.trim().toLowerCase();
1517
+ if (category !== 'scope_change' || !lowered.includes('.'))
1518
+ return lowered;
1519
+ const parts = lowered.split('.').map(part => part.trim()).filter(Boolean);
1520
+ return parts[parts.length - 1] ?? lowered;
1521
+ }
1522
+ function scoreRuleNameMatch(name, needle) {
1523
+ const lower = name.toLowerCase();
1524
+ if (lower === needle)
1525
+ return 0;
1526
+ if (lower.startsWith(needle))
1527
+ return 1;
1528
+ return 2;
1529
+ }
1530
+ function normalizeScopeName(scope) {
1531
+ return scope.replace(/^["']|["']$/g, '').trim().toLowerCase();
1532
+ }
1533
+ function countBraceDelta(line) {
1534
+ let delta = 0;
1535
+ for (const ch of line) {
1536
+ if (ch === '{')
1537
+ delta += 1;
1538
+ else if (ch === '}')
1539
+ delta -= 1;
1540
+ }
1541
+ return delta;
1542
+ }
1366
1543
  function splitWords(value) {
1367
1544
  return value.split(/\s+/).map(part => part.trim()).filter(Boolean);
1368
1545
  }
package/package.json CHANGED
@@ -1,24 +1,24 @@
1
- {
2
- "name": "cwtools-shared",
3
- "version": "0.2.1",
4
- "description": "Shared protocol and tool contracts for the CWTools MCP server",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/Aa728848/cwtools-mcp.git"
9
- },
10
- "engines": {
11
- "node": ">=18"
12
- },
13
- "main": "dist/index.js",
14
- "types": "dist/index.d.ts",
15
- "files": [
16
- "dist",
17
- "package.json"
18
- ],
19
- "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "prepack": "npm run build",
22
- "test:contracts": "ts-mocha -p tsconfig.test.json \"src/test/**/*.test.ts\""
23
- }
24
- }
1
+ {
2
+ "name": "cwtools-shared",
3
+ "version": "0.2.2",
4
+ "description": "Shared protocol and tool contracts for the CWTools MCP server",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Aa728848/cwtools-mcp.git"
9
+ },
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "main": "dist/index.js",
14
+ "types": "dist/index.d.ts",
15
+ "files": [
16
+ "dist",
17
+ "package.json"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json",
21
+ "prepack": "npm run build",
22
+ "test:contracts": "ts-mocha -p tsconfig.test.json \"src/test/**/*.test.ts\""
23
+ }
24
+ }