@ngockhoale/ukit 3.4.29 → 3.4.31

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 (49) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/manifests/documentation.yaml +100 -0
  3. package/package.json +1 -1
  4. package/src/core/executionContracts.js +32 -0
  5. package/src/core/permissionPolicy.js +1 -1
  6. package/src/decision/client.js +15 -0
  7. package/src/decision/protocol.js +12 -14
  8. package/src/index/buildIndex.js +244 -4
  9. package/src/index/impactContext.js +91 -11
  10. package/src/index/languageTools.js +126 -0
  11. package/src/index/paths.js +5 -1
  12. package/src/index/playbookRegistry.js +108 -9
  13. package/src/index/queryIndex.js +415 -1
  14. package/src/index/routeCatalog.js +1 -1
  15. package/src/index/routeResolver.js +2 -2
  16. package/src/index/taskRouting.js +28 -4
  17. package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
  18. package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
  19. package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
  20. package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
  21. package/template_project/.claude/hooks/record-execution.mjs +21 -0
  22. package/template_project/.claude/hooks/skill-router.sh +187 -16
  23. package/template_project/.claude/settings.json +43 -1
  24. package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
  25. package/template_project/.claude/ukit/index/fix-loop-escalation.mjs +10 -4
  26. package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
  27. package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
  28. package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
  29. package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
  30. package/template_project/.claude/ukit/index/query-index.mjs +100 -7
  31. package/template_project/.claude/ukit/index/review-verdict.mjs +14 -4
  32. package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
  33. package/template_project/.claude/ukit/index/route-resolver.mjs +34 -2
  34. package/template_project/.claude/ukit/index/route-task.mjs +243 -38
  35. package/template_project/.claude/ukit/index/sidecar-decision.mjs +3 -59
  36. package/template_project/.claude/ukit/index/unic-decision.mjs +27 -14
  37. package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +43 -0
  38. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +612 -56
  39. package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
  40. package/template_project/.claude/ukit/runtime/loop-state.mjs +1 -1
  41. package/template_project/.claude/ukit/runtime/operation-evidence.mjs +87 -0
  42. package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
  43. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
  44. package/template_project/.omp/hooks/pre/ukit-bridge.js +40 -5
  45. package/template_project/AGENTS.md +1 -1
  46. package/template_project/CLAUDE.md +1 -1
  47. package/template_project/instructions/core.md +1 -1
  48. package/template_user/playbooks/feature-implementation.md +9 -0
  49. package/template_user/playbooks/migration.md +4 -0
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import crypto from 'node:crypto';
4
- import { spawnSync } from 'node:child_process';
4
+ import { execFile, spawnSync } from 'node:child_process';
5
5
  import { withFileLock } from '../../runtime/token-utils.mjs';
6
6
  import { withAsyncLock } from '../../runtime/async-lock.mjs';
7
7
 
@@ -133,7 +133,11 @@ const CODE_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '
133
133
  const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
134
134
  const TRACKED_EXTENSIONS = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json', '.yaml', '.yml', '.md', '.sh']);
135
135
  const DISCOVERED_EXTENSIONS = new Set([...TRACKED_EXTENSIONS, ...STYLE_EXTENSIONS]);
136
- export const INDEX_SCHEMA_VERSION = 8;
136
+ // v9: symbols.json gains global-registered ('global') and Vue 'method' rows,
137
+ // SQL 'column' rows with parent/schema, and calls.json gains per-object SQL
138
+ // reference records — a v8 cached artifact must not be reused for unchanged
139
+ // files or the new extractions silently vanish.
140
+ export const INDEX_SCHEMA_VERSION = 9;
137
141
  export const DEFAULT_INDEX_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
138
142
  // Parse-phase read/extract concurrency — deliberately tighter than the
139
143
  // discovery stat pool because parsing holds file content in memory.
@@ -2758,7 +2762,7 @@ function computeSignature(content, lineStartIndex) {
2758
2762
  export function extractSymbols(filePath, content) {
2759
2763
  const out = [];
2760
2764
  const lineStarts = computeLineStarts(content);
2761
- const addSymbol = (name, type, matchIndex = null) => {
2765
+ const addSymbol = (name, type, matchIndex = null, extra = null) => {
2762
2766
  if (!name) return;
2763
2767
  if (matchIndex === null) {
2764
2768
  out.push({ name, type, filePath, line: null, signature: null });
@@ -2766,7 +2770,7 @@ export function extractSymbols(filePath, content) {
2766
2770
  }
2767
2771
  const line = lineNumberForIndex(lineStarts, matchIndex);
2768
2772
  const signature = computeSignature(content, lineStarts[line - 1]);
2769
- out.push({ name, type, filePath, line, signature });
2773
+ out.push(extra ? { name, type, filePath, line, signature, ...extra } : { name, type, filePath, line, signature });
2770
2774
  };
2771
2775
  const addMatches = (regex, type, idx = 1) => {
2772
2776
  for (const match of content.matchAll(regex)) {
@@ -2786,6 +2790,24 @@ export function extractSymbols(filePath, content) {
2786
2790
  addMatches(new RegExp(`CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'index');
2787
2791
  addMatches(new RegExp(`CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE|MATERIALIZED\\s+VIEW)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'type');
2788
2792
  addMatches(new RegExp(`ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?${sqlIdent}`, 'gi'), 'table');
2793
+
2794
+ // Column-level symbols: CREATE TABLE bodies (with parent + schema kept so
2795
+ // same-named columns in different schemas/tables stay distinct) and view
2796
+ // output aliases (`expr AS alias`).
2797
+ const sqlObjects = findSqlObjects(content);
2798
+ for (const [objectIndex, sqlObject] of sqlObjects.entries()) {
2799
+ if (sqlObject.type === 'table') {
2800
+ for (const column of extractSqlTableColumns(content, sqlObject)) {
2801
+ addSymbol(column.name, 'column', column.matchIndex, { parent: sqlObject.name, schema: sqlObject.schema });
2802
+ }
2803
+ }
2804
+ if (sqlObject.type === 'view') {
2805
+ const bodyEnd = objectIndex + 1 < sqlObjects.length ? sqlObjects[objectIndex + 1].matchIndex : content.length;
2806
+ for (const alias of extractSqlViewAliases(content.slice(sqlObject.matchIndex, Math.min(bodyEnd, sqlObject.matchIndex + 20000)))) {
2807
+ addSymbol(alias.name, 'column', sqlObject.matchIndex + alias.matchIndex, { parent: sqlObject.name, schema: sqlObject.schema });
2808
+ }
2809
+ }
2810
+ }
2789
2811
  } else {
2790
2812
  addMatches(/export\s+(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'function');
2791
2813
  addMatches(/export\s+class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g, 'class');
@@ -2798,6 +2820,32 @@ export function extractSymbols(filePath, content) {
2798
2820
  addMatches(/defineComponent\(\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
2799
2821
  addMatches(/export\s+default\s*\{[\s\S]*?name\s*:\s*['"]([^'"]+)['"]/g, 'component-name');
2800
2822
 
2823
+ // Global-registered frontend functions: reachable from anywhere without an
2824
+ // import edge, so impact analysis must treat them as verified dependencies.
2825
+ addMatches(/\b(?:window|globalThis|global)\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
2826
+ addMatches(/\b(?:window|globalThis|global)\s*\[\s*['"]([^'"]+)['"]\s*\]\s*=/g, 'global');
2827
+ addMatches(/\b(?:app|vueApp)\s*\.\s*config\s*\.\s*globalProperties\s*\.\s*(\$?[A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
2828
+ addMatches(/\bVue\s*\.\s*prototype\s*\.\s*(\$?[A-Za-z_$][A-Za-z0-9_$]*)\s*=/g, 'global');
2829
+ addMatches(/\b(?:app|vueApp)\s*\.\s*provide\s*\(\s*['"]([^'"]+)['"]/g, 'global');
2830
+ addMatches(/\b(?:Vue|app|vueApp)\s*\.\s*component\s*\(\s*['"]([^'"]+)['"]/g, 'global');
2831
+
2832
+ // Vue options-API methods: `methods: { submit() {...}, validate: () => ... }`.
2833
+ // Names inside the balanced `methods` block only — body-level matches keep
2834
+ // their real file line through the absolute-index offset.
2835
+ for (const methodsMatch of content.matchAll(/\bmethods\s*:\s*\{/g)) {
2836
+ const methodsBody = sliceBalancedBlock(content, methodsMatch.index + methodsMatch[0].length);
2837
+ if (!methodsBody) {
2838
+ continue;
2839
+ }
2840
+ const methodsStart = methodsMatch.index + methodsMatch[0].length;
2841
+ for (const methodMatch of methodsBody.matchAll(/(?:^|[,{\n]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*\((?:[^()]|\([^)]*\))*\)\s*\{/g)) {
2842
+ addSymbol(methodMatch[1], 'method', methodsStart + methodMatch.index + methodMatch[0].indexOf(methodMatch[1]));
2843
+ }
2844
+ for (const methodMatch of methodsBody.matchAll(/(?:^|[,{]\s*)([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\s*=>)/g)) {
2845
+ addSymbol(methodMatch[1], 'method', methodsStart + methodMatch.index + methodMatch[0].indexOf(methodMatch[1]));
2846
+ }
2847
+ }
2848
+
2801
2849
  if (/\bexport\s+default\b/.test(content)) {
2802
2850
  addSymbol('default', 'default-export');
2803
2851
  }
@@ -2813,13 +2861,126 @@ export function extractSymbols(filePath, content) {
2813
2861
 
2814
2862
  const seen = new Set();
2815
2863
  return out.filter((item) => {
2816
- const key = `${item.filePath}:${item.type}:${item.name}`;
2864
+ const key = `${item.filePath}:${item.type}:${item.name}:${item.parent ?? ''}:${item.schema ?? ''}`;
2817
2865
  if (seen.has(key)) return false;
2818
2866
  seen.add(key);
2819
2867
  return true;
2820
2868
  });
2821
2869
  }
2822
2870
 
2871
+ // Locates every CREATE <object> statement with its schema qualifier (or null)
2872
+ // so same-named objects in different schemas stay distinct records. Used for
2873
+ // column extraction and SQL reference segmentation.
2874
+ function findSqlObjects(content) {
2875
+ const sqlQualifiedIdent = '(?:(["`]?[A-Za-z_][A-Za-z0-9_$]*["`]?)\\s*\\.\\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?';
2876
+ const patterns = [
2877
+ ['table', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
2878
+ ['view', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?VIEW\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
2879
+ ['function', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?FUNCTION\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
2880
+ ['procedure', `CREATE\\s+(?:OR\\s+REPLACE\\s+)?PROCEDURE\\s+${sqlQualifiedIdent}`],
2881
+ ['type', `CREATE\\s+(?:TYPE|DOMAIN|SEQUENCE)\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${sqlQualifiedIdent}`],
2882
+ ];
2883
+ const objects = [];
2884
+ for (const [type, pattern] of patterns) {
2885
+ for (const match of content.matchAll(new RegExp(pattern, 'gi'))) {
2886
+ if (!match[2]) {
2887
+ continue;
2888
+ }
2889
+ objects.push({
2890
+ type,
2891
+ name: match[2],
2892
+ schema: match[1] ? match[1].replace(/["`]/g, '') : null,
2893
+ matchIndex: match.index,
2894
+ });
2895
+ }
2896
+ }
2897
+ return objects.sort((a, b) => a.matchIndex - b.matchIndex);
2898
+ }
2899
+
2900
+ const SQL_COLUMN_CONSTRAINT_KEYWORDS = new Set([
2901
+ 'primary', 'foreign', 'unique', 'check', 'constraint', 'key', 'index', 'exclude', 'like', 'period',
2902
+ ]);
2903
+
2904
+ // Slices a CREATE TABLE body's balanced paren list and emits one row per
2905
+ // top-level column definition (constraint rows are skipped).
2906
+ function extractSqlTableColumns(content, sqlObject) {
2907
+ const headerPattern = new RegExp(
2908
+ `CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?(?:(?:["\`]?[A-Za-z_][A-Za-z0-9_$]*["\`]?)\\s*\\.\\s*)?["\`]?${escapeRegExp(sqlObject.name)}["\`]?`,
2909
+ 'gi',
2910
+ );
2911
+ headerPattern.lastIndex = sqlObject.matchIndex;
2912
+ const headerMatch = headerPattern.exec(content);
2913
+ if (!headerMatch) {
2914
+ return [];
2915
+ }
2916
+ const openParenSearchEnd = Math.min(content.length, headerMatch.index + headerMatch[0].length + 500);
2917
+ const openParen = content.slice(headerMatch.index + headerMatch[0].length - 1, openParenSearchEnd).indexOf('(');
2918
+ if (openParen === -1) {
2919
+ return [];
2920
+ }
2921
+ const openParenAbsolute = headerMatch.index + headerMatch[0].length - 1 + openParen;
2922
+
2923
+ let depth = 0;
2924
+ let closeParen = -1;
2925
+ for (let index = openParenAbsolute; index < content.length; index += 1) {
2926
+ if (content[index] === '(') {
2927
+ depth += 1;
2928
+ } else if (content[index] === ')') {
2929
+ depth -= 1;
2930
+ if (depth === 0) {
2931
+ closeParen = index;
2932
+ break;
2933
+ }
2934
+ }
2935
+ }
2936
+ if (closeParen === -1) {
2937
+ return [];
2938
+ }
2939
+
2940
+ const body = content.slice(openParenAbsolute + 1, closeParen);
2941
+ const columns = [];
2942
+ let topLevelDepth = 0;
2943
+ let segmentStart = 0;
2944
+ const segments = [];
2945
+ for (let index = 0; index < body.length; index += 1) {
2946
+ const char = body[index];
2947
+ if (char === '(') {
2948
+ topLevelDepth += 1;
2949
+ } else if (char === ')') {
2950
+ topLevelDepth -= 1;
2951
+ } else if (char === ',' && topLevelDepth === 0) {
2952
+ segments.push(body.slice(segmentStart, index));
2953
+ segmentStart = index + 1;
2954
+ }
2955
+ }
2956
+ segments.push(body.slice(segmentStart));
2957
+
2958
+ let offset = openParenAbsolute + 1;
2959
+ for (const segment of segments) {
2960
+ const columnMatch = segment.match(/^\s*["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?/);
2961
+ const columnName = columnMatch?.[1];
2962
+ if (columnName && !SQL_COLUMN_CONSTRAINT_KEYWORDS.has(columnName.toLowerCase())) {
2963
+ columns.push({ name: columnName, matchIndex: offset + columnMatch[0].indexOf(columnMatch[1]) });
2964
+ }
2965
+ offset += segment.length + 1;
2966
+ }
2967
+ return columns;
2968
+ }
2969
+
2970
+ // Output aliases inside a CREATE VIEW body segment: `expr AS alias` where the
2971
+ // alias is followed by a column-list boundary.
2972
+ function extractSqlViewAliases(viewSegment) {
2973
+ const aliases = [];
2974
+ for (const match of viewSegment.matchAll(/\bAS\s+["`]?([A-Za-z_][A-Za-z0-9_$]*)["`]?\s*(?=\s*(?:,|FROM|WHERE|;|\n|$))/gi)) {
2975
+ aliases.push({ name: match[1], matchIndex: match.index + match[0].indexOf(match[1]) });
2976
+ }
2977
+ return aliases;
2978
+ }
2979
+
2980
+ function escapeRegExp(value) {
2981
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2982
+ }
2983
+
2823
2984
  function extractImports(filePath, content) {
2824
2985
  const out = [];
2825
2986
  const stripped = content
@@ -2898,7 +3059,89 @@ function sliceBalancedBlock(content, startIndex) {
2898
3059
  return '';
2899
3060
  }
2900
3061
 
3062
+ // SQL keywords that appear before `(` in procedural/plain SQL but are not
3063
+ // object references (SELECT count(, IF(, CAST(...) — plus common column type
3064
+ // names, since `VARCHAR(20)` in a table body is a type, not a call.
3065
+ const SQL_CALL_KEYWORDS = new Set([
3066
+ 'select', 'from', 'where', 'and', 'or', 'not', 'case', 'when', 'then', 'else',
3067
+ 'end', 'join', 'inner', 'left', 'right', 'outer', 'full', 'cross', 'on',
3068
+ 'group', 'order', 'by', 'having', 'limit', 'offset', 'union', 'all',
3069
+ 'distinct', 'as', 'insert', 'into', 'values', 'update', 'set', 'delete',
3070
+ 'create', 'table', 'view', 'function', 'procedure', 'index', 'alter', 'drop',
3071
+ 'if', 'exists', 'begin', 'commit', 'rollback', 'return', 'declare', 'exec',
3072
+ 'execute', 'call', 'with', 'cast', 'coalesce', 'nullif', 'extract', 'primary',
3073
+ 'foreign', 'references', 'key', 'constraint', 'default', 'unique', 'check',
3074
+ 'asc', 'desc', 'is', 'in', 'like', 'between', 'null', 'true', 'false',
3075
+ 'interval', 'date', 'using', 'natural', 'returns', 'language', 'temporary',
3076
+ 'varchar', 'char', 'nvarchar', 'nchar', 'int', 'integer', 'bigint', 'smallint',
3077
+ 'tinyint', 'text', 'boolean', 'bool', 'datetime', 'timestamp', 'timestamptz',
3078
+ 'decimal', 'numeric', 'float', 'double', 'real', 'json', 'jsonb', 'uuid',
3079
+ 'serial', 'bigserial', 'blob', 'bytea', 'bit', 'money', 'xml', 'enum',
3080
+ 'count', 'sum', 'avg', 'min', 'max', 'round', 'upper', 'lower', 'trim',
3081
+ 'length', 'concat', 'substring', 'replace', 'abs', 'ceil', 'floor', 'now',
3082
+ 'current_timestamp', 'current_date', 'year', 'month', 'day', 'hour', 'minute',
3083
+ 'second', 'convert', 'try_cast', 'iif', 'stuff', 'format', 'getdate',
3084
+ ]);
3085
+
3086
+ // SQL reference extraction: one calls-record per created object whose segment
3087
+ // (its statement through the next CREATE) lists the SQL objects it touches —
3088
+ // FROM/JOIN targets, EXEC targets, and `name(` function calls. This is the
3089
+ // dependency edge impact analysis uses to surface dependent views/functions.
3090
+ function extractSqlFunctionCalls(filePath, content) {
3091
+ const objects = findSqlObjects(content);
3092
+ if (objects.length === 0) {
3093
+ return [];
3094
+ }
3095
+ const stripped = content
3096
+ .replace(/\/\*[\s\S]*?\*\//g, '')
3097
+ .replace(/--[^\n]*/g, '');
3098
+
3099
+ const records = [];
3100
+ for (const [objectIndex, sqlObject] of objects.entries()) {
3101
+ const bodyEnd = objectIndex + 1 < objects.length ? objects[objectIndex + 1].matchIndex : content.length;
3102
+ const segment = stripped.slice(sqlObject.matchIndex, Math.min(bodyEnd, sqlObject.matchIndex + 20000));
3103
+
3104
+ const calls = [];
3105
+ const addCall = (rawName) => {
3106
+ if (!rawName) {
3107
+ return;
3108
+ }
3109
+ const bareName = rawName.split('.').pop().replace(/["`]/g, '');
3110
+ if (!bareName || bareName.toLowerCase() === sqlObject.name.toLowerCase()) {
3111
+ return;
3112
+ }
3113
+ if (SQL_CALL_KEYWORDS.has(bareName.toLowerCase())) {
3114
+ return;
3115
+ }
3116
+ if (!calls.includes(bareName)) {
3117
+ calls.push(bareName);
3118
+ }
3119
+ };
3120
+
3121
+ for (const match of segment.matchAll(/\b(?:FROM|JOIN)\s+["`]?([A-Za-z_][A-Za-z0-9_$]*(?:\s*\.\s*["`]?[A-Za-z_][A-Za-z0-9_$]*)?)/gi)) {
3122
+ addCall(match[1]);
3123
+ }
3124
+ for (const match of segment.matchAll(/\bEXEC(?:UTE)?\s+(?:@\w+\s*=\s*)?["`]?([A-Za-z_][A-Za-z0-9_$]*)/gi)) {
3125
+ addCall(match[1]);
3126
+ }
3127
+ for (const match of segment.matchAll(/\b([A-Za-z_][A-Za-z0-9_$]*)\s*\(/g)) {
3128
+ addCall(match[1]);
3129
+ }
3130
+
3131
+ records.push({
3132
+ filePath,
3133
+ symbol: sqlObject.name,
3134
+ exported: true,
3135
+ calls,
3136
+ });
3137
+ }
3138
+ return records;
3139
+ }
3140
+
2901
3141
  function extractFunctionCalls(filePath, content) {
3142
+ if (filePath.endsWith('.sql')) {
3143
+ return extractSqlFunctionCalls(filePath, content);
3144
+ }
2902
3145
  return extractFunctionBlocks(content).map((block) => {
2903
3146
  const calls = [];
2904
3147
  for (const match of block.body.matchAll(/(?:\b|\.)([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
@@ -3165,20 +3408,59 @@ function scoreDirectFileMatch({ queryDescriptor, filePath, searchDescriptor }) {
3165
3408
  reasons.push(`symbol_compact:${symbolDescriptor.name}`);
3166
3409
  }
3167
3410
 
3411
+ let symbolContributed = false;
3412
+
3168
3413
  for (const token of queryDescriptor.tokens) {
3169
3414
  if (symbolDescriptor.descriptor.tokens.has(token)) {
3170
3415
  score += 3;
3416
+ symbolContributed = true;
3171
3417
  reasons.push(`symbol_token:${symbolDescriptor.name}:${token}`);
3172
3418
  } else if (symbolDescriptor.descriptor.rawLower.includes(token)) {
3173
3419
  score += 1;
3420
+ symbolContributed = true;
3174
3421
  reasons.push(`symbol_contains:${symbolDescriptor.name}`);
3175
3422
  }
3176
3423
  }
3424
+
3425
+ // Fuzzy floor for misspelt or compacted symbol queries ('custmer_code' →
3426
+ // 'customer_code'): subsequence or bounded edit-distance match on the
3427
+ // already-precomputed compacts. Only fires when the symbol earned nothing
3428
+ // from the stronger tiers above.
3429
+ if (!symbolContributed && queryDescriptor.compact.length >= 6) {
3430
+ if (symbolFuzzyMatches(queryDescriptor.compact, symbolDescriptor.descriptor.compact)) {
3431
+ score += 3;
3432
+ reasons.push(`symbol_fuzzy:${symbolDescriptor.name}`);
3433
+ }
3434
+ }
3177
3435
  }
3178
3436
 
3179
3437
  return { score, reasons: [...new Set(reasons)], filePath };
3180
3438
  }
3181
3439
 
3440
+ // Fuzzy predicate shared by scoreDirectFileMatch and resolveSymbolCandidates:
3441
+ // ordered containment (either direction, on tokens or compacts) or a bounded
3442
+ // Damerau distance. Cheap length gates run first so the common non-fuzzy case
3443
+ // never builds a distance matrix.
3444
+ function symbolFuzzyMatches(queryCompact, symbolCompact) {
3445
+ if (!queryCompact || !symbolCompact) {
3446
+ return false;
3447
+ }
3448
+ if (Math.abs(queryCompact.length - symbolCompact.length) > 2) {
3449
+ return false;
3450
+ }
3451
+ if (isCompactCharSubsequence(queryCompact, symbolCompact) || isCompactCharSubsequence(symbolCompact, queryCompact)) {
3452
+ return true;
3453
+ }
3454
+ if (
3455
+ queryCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
3456
+ && symbolCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
3457
+ && boundedDamerauDistance(queryCompact, symbolCompact, 2) <= 2
3458
+ ) {
3459
+ return true;
3460
+ }
3461
+ return false;
3462
+ }
3463
+
3182
3464
  function groupBy(items, keyFn) {
3183
3465
  const map = new Map();
3184
3466
  for (const item of items) {
@@ -3189,6 +3471,372 @@ function groupBy(items, keyFn) {
3189
3471
  return map;
3190
3472
  }
3191
3473
 
3474
+ // ── Symbol-level resolution ──
3475
+ // Ranks symbol rows against a (possibly misspelt or phrase-form) query:
3476
+ // exact > normalized (case/underscore/hyphen/camel-insensitive) > ordered
3477
+ // token/compact subsequence > bounded edit distance. Fuzzy-only results never
3478
+ // auto-substitute: they carry confidence 'fuzzy' and needsConfirmation, and any
3479
+ // tie inside `scoreMargin` marks the whole leading band ambiguous.
3480
+ export function resolveSymbolCandidates(query, symbols, { limit = 8, scoreMargin = 5 } = {}) {
3481
+ const normalizedQuery = String(query ?? '').trim();
3482
+ if (!normalizedQuery || !Array.isArray(symbols)) {
3483
+ return [];
3484
+ }
3485
+
3486
+ const baseTokens = normalizeIdentifierTokens(normalizedQuery);
3487
+ if (baseTokens.length === 0) {
3488
+ return [];
3489
+ }
3490
+ const baseCompact = baseTokens.join('');
3491
+ const variants = expandIdentifierQueryVariants(normalizedQuery);
3492
+
3493
+ const rows = [];
3494
+ const seen = new Set();
3495
+ for (const symbol of symbols) {
3496
+ if (!symbol || typeof symbol?.name !== 'string') {
3497
+ continue;
3498
+ }
3499
+ const symbolTokens = normalizeIdentifierTokens(symbol.name);
3500
+ if (symbolTokens.length === 0) {
3501
+ continue;
3502
+ }
3503
+ const dedupeKey = `${symbol.name}\u0000${symbol.filePath ?? ''}\u0000${symbol.parent ?? ''}\u0000${symbol.schema ?? ''}`;
3504
+ if (seen.has(dedupeKey)) {
3505
+ continue;
3506
+ }
3507
+ seen.add(dedupeKey);
3508
+
3509
+ const match = scoreSymbolCandidate({
3510
+ query: normalizedQuery,
3511
+ baseTokens,
3512
+ baseCompact,
3513
+ variants,
3514
+ symbol,
3515
+ symbolTokens,
3516
+ symbolCompact: symbolTokens.join(''),
3517
+ });
3518
+ if (match) {
3519
+ rows.push(match);
3520
+ }
3521
+ }
3522
+
3523
+ rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name) || String(a.filePath ?? '').localeCompare(String(b.filePath ?? '')));
3524
+
3525
+ const top = rows.slice(0, Math.max(limit, 0));
3526
+ const topScore = top[0]?.score ?? 0;
3527
+ const ambiguousBand = top.filter((candidate) => topScore - candidate.score <= scoreMargin);
3528
+ const ambiguous = ambiguousBand.length > 1;
3529
+
3530
+ return top.map((candidate) => ({
3531
+ ...candidate,
3532
+ needsConfirmation: candidate.confidence === 'fuzzy' || ambiguous,
3533
+ }));
3534
+ }
3535
+
3536
+ function scoreSymbolCandidate({ query, baseTokens, baseCompact, variants, symbol, symbolTokens, symbolCompact }) {
3537
+ const row = {
3538
+ name: symbol.name,
3539
+ filePath: symbol.filePath ?? null,
3540
+ line: Number.isFinite(symbol.line) ? symbol.line : null,
3541
+ kind: symbol.type ?? 'symbol',
3542
+ parent: symbol.parent ?? null,
3543
+ schema: symbol.schema ?? null,
3544
+ };
3545
+
3546
+ if (symbol.name === query) {
3547
+ return { ...row, score: 100, reason: 'exact', confidence: 'exact' };
3548
+ }
3549
+ if (symbolCompact === baseCompact) {
3550
+ return { ...row, score: 90, reason: 'normalized', confidence: 'high' };
3551
+ }
3552
+
3553
+ let best = null;
3554
+ for (const variantTokens of variants) {
3555
+ const variantCompact = variantTokens.join('');
3556
+ if (isOrderedTokenSubsequence(variantTokens, symbolTokens)) {
3557
+ const candidate = { score: 70, reason: 'token-subsequence' };
3558
+ if (!best || candidate.score > best.score) {
3559
+ best = candidate;
3560
+ }
3561
+ }
3562
+ if (isCompactCharSubsequence(variantCompact, symbolCompact)) {
3563
+ const candidate = { score: 65, reason: 'compact-subsequence' };
3564
+ if (!best || candidate.score > best.score) {
3565
+ best = candidate;
3566
+ }
3567
+ }
3568
+ if (
3569
+ variantCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
3570
+ && symbolCompact.length >= FUZZY_EDIT_DISTANCE_MIN_LENGTH
3571
+ ) {
3572
+ const distance = boundedDamerauDistance(variantCompact, symbolCompact, 2);
3573
+ if (distance <= 2) {
3574
+ const candidate = { score: 60 - distance * 10, reason: `edit-distance:${distance}` };
3575
+ if (!best || candidate.score > best.score) {
3576
+ best = candidate;
3577
+ }
3578
+ }
3579
+ }
3580
+ }
3581
+
3582
+ if (!best) {
3583
+ return null;
3584
+ }
3585
+ return { ...row, ...best, confidence: 'fuzzy' };
3586
+ }
3587
+
3588
+ // Loads symbols.json (schema-gated, poisoned rows dropped) and resolves the
3589
+ // query against every indexed symbol row, including SQL columns and
3590
+ // global-registered frontend functions.
3591
+ export async function querySymbolCandidates({ rootDir = process.cwd(), query, limit = 8, scoreMargin = 5 } = {}) {
3592
+ const normalizedQuery = String(query ?? '').trim();
3593
+ if (!normalizedQuery) {
3594
+ return [];
3595
+ }
3596
+
3597
+ const absoluteRoot = path.resolve(rootDir);
3598
+ const symbolsArtifact = await readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols);
3599
+ const safeSymbolsArtifact = safeSchemaArtifact(symbolsArtifact);
3600
+ return resolveSymbolCandidates(normalizedQuery, safeItems(safeSymbolsArtifact), { limit, scoreMargin });
3601
+ }
3602
+
3603
+ // ── Fallback source search (index unavailable) ──
3604
+ // Bounded ripgrep-first text search so query-index / impact-context can answer
3605
+ // from source when the index is missing or its rebuild fails. `rg` runs via
3606
+ // child_process when present; otherwise (or on rg failure) a bounded node
3607
+ // directory walk honors the same ignore-dir set as the index builder. Rows are
3608
+ // { filePath, line } and require EVERY query token to appear in the file.
3609
+ export const FALLBACK_SEARCH_IGNORE_DIRS = [
3610
+ 'node_modules', '.git', '.cache', '.next', '.nuxt', '.output', '.vercel',
3611
+ '.turbo', '.yarn', 'dist', 'build', 'out', 'coverage', 'tmp', 'temp',
3612
+ 'vendor', '.venv', 'venv',
3613
+ ];
3614
+ const FALLBACK_SEARCH_IGNORE_DIR_SET = new Set(FALLBACK_SEARCH_IGNORE_DIRS);
3615
+ const FALLBACK_SEARCH_EXTENSIONS = new Set([
3616
+ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.sql', '.json',
3617
+ '.md', '.yaml', '.yml', '.sh', '.css', '.scss', '.sass', '.less', '.html',
3618
+ ]);
3619
+ const FALLBACK_SEARCH_DEFAULTS = {
3620
+ maxFiles: 2000,
3621
+ maxFileBytes: 512 * 1024,
3622
+ rgTimeoutMs: 5000,
3623
+ // Traversal bounds: maxFiles caps MATCHED candidates only, so the walk also needs a cap on
3624
+ // everything it merely visits (huge SQL/data trees, generated dirs not in the ignore set).
3625
+ maxEntries: 20000,
3626
+ maxDepth: 12,
3627
+ maxVerify: 400,
3628
+ walkBudgetMs: 3000,
3629
+ };
3630
+
3631
+ export async function boundedSourceSearch({
3632
+ rootDir = process.cwd(),
3633
+ query,
3634
+ limit = 8,
3635
+ maxFiles = FALLBACK_SEARCH_DEFAULTS.maxFiles,
3636
+ maxFileBytes = FALLBACK_SEARCH_DEFAULTS.maxFileBytes,
3637
+ engine = 'auto',
3638
+ maxEntries = FALLBACK_SEARCH_DEFAULTS.maxEntries,
3639
+ maxDepth = FALLBACK_SEARCH_DEFAULTS.maxDepth,
3640
+ maxVerify = FALLBACK_SEARCH_DEFAULTS.maxVerify,
3641
+ walkBudgetMs = FALLBACK_SEARCH_DEFAULTS.walkBudgetMs,
3642
+ } = {}) {
3643
+ const normalizedQuery = String(query ?? '').trim();
3644
+ if (!normalizedQuery) {
3645
+ return [];
3646
+ }
3647
+ const truncation = new Set();
3648
+ const foldedQuery = buildSearchDescriptor(normalizedQuery).rawLower;
3649
+ const tokens = [...new Set(normalizeIdentifierTokens(foldedQuery))];
3650
+ if (tokens.length === 0) {
3651
+ return [];
3652
+ }
3653
+
3654
+ const absoluteRoot = path.resolve(rootDir);
3655
+ let candidatePaths = null;
3656
+ if (engine === 'auto') {
3657
+ candidatePaths = await ripgrepCandidatePaths({ absoluteRoot, tokens, maxFileBytes });
3658
+ }
3659
+ if (candidatePaths === null) {
3660
+ candidatePaths = await walkCandidatePaths({
3661
+ absoluteRoot, maxFiles, maxFileBytes, maxEntries, maxDepth, walkBudgetMs, truncation,
3662
+ });
3663
+ }
3664
+
3665
+ const rows = [];
3666
+ let verified = 0;
3667
+ for (const relativePath of candidatePaths) {
3668
+ if (verified >= maxVerify) {
3669
+ truncation.add('verify-cap');
3670
+ break;
3671
+ }
3672
+ verified += 1;
3673
+ const verification = await readMatchingLine(absoluteRoot, relativePath, tokens, maxFileBytes);
3674
+ if (!verification) {
3675
+ continue;
3676
+ }
3677
+ rows.push({ filePath: relativePath, line: verification.line, reason: 'fallback-search' });
3678
+ if (rows.length >= Math.max(limit, 0)) {
3679
+ break;
3680
+ }
3681
+ }
3682
+ // A partial scan must never read as exhaustive: callers that care read `rows.truncated`
3683
+ // (non-enumerable, so array equality and JSON output are unchanged).
3684
+ Object.defineProperty(rows, 'truncated', { value: [...truncation], enumerable: false });
3685
+ return rows;
3686
+ }
3687
+
3688
+ function execFileRipgrep(args, timeoutMs) {
3689
+ return new Promise((resolve) => {
3690
+ execFile('rg', args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
3691
+ if (error) {
3692
+ // rg exit code 1 = "no matches" (a valid empty answer; error.code is the
3693
+ // number 1). A spawn failure (code 'ENOENT'), a timeout, or exit ≥2
3694
+ // means rg is unusable and the walk must take over.
3695
+ resolve(error.code === 1 ? [] : null);
3696
+ return;
3697
+ }
3698
+ resolve(String(stdout ?? ''));
3699
+ });
3700
+ });
3701
+ }
3702
+
3703
+ async function ripgrepCandidatePaths({ absoluteRoot, tokens, maxFileBytes }) {
3704
+ const stdout = await execFileRipgrep(
3705
+ [
3706
+ '-i', '--no-heading', '--line-number', '--max-count', '3',
3707
+ '--max-filesize', String(Math.round(maxFileBytes / 1024)) + 'K',
3708
+ ...FALLBACK_SEARCH_IGNORE_DIRS.flatMap((dir) => ['--glob', `!${dir}/**`]),
3709
+ '--glob', '!**/*.min.*',
3710
+ ...tokens.flatMap((token) => ['-e', token]),
3711
+ absoluteRoot,
3712
+ ],
3713
+ FALLBACK_SEARCH_DEFAULTS.rgTimeoutMs,
3714
+ );
3715
+ if (stdout === null) {
3716
+ return null;
3717
+ }
3718
+
3719
+ const paths = [];
3720
+ const seen = new Set();
3721
+ for (const line of stdout.split('\n')) {
3722
+ const separator = line.indexOf(':');
3723
+ if (separator <= 0) {
3724
+ continue;
3725
+ }
3726
+ const matchedPath = line.slice(0, separator);
3727
+ if (!matchedPath || seen.has(matchedPath)) {
3728
+ continue;
3729
+ }
3730
+ seen.add(matchedPath);
3731
+ const relativePath = path.relative(absoluteRoot, matchedPath).replace(/\\/g, '/');
3732
+ if (relativePath && !relativePath.startsWith('..')) {
3733
+ paths.push(relativePath);
3734
+ }
3735
+ }
3736
+ return paths;
3737
+ }
3738
+
3739
+ // Depth-first walk collecting candidate text files (size- and count-bounded).
3740
+ // Token verification happens once in boundedSourceSearch, so the walk only
3741
+ // filters by ignore-set, extension, and size.
3742
+ async function walkCandidatePaths({
3743
+ absoluteRoot, maxFiles, maxFileBytes, maxEntries, maxDepth, walkBudgetMs, truncation = new Set(),
3744
+ }) {
3745
+ const results = [];
3746
+ let visited = 0;
3747
+ const deadline = Date.now() + walkBudgetMs;
3748
+ const exhausted = () => {
3749
+ if (results.length >= maxFiles) { truncation.add('max-files'); return true; }
3750
+ if (visited >= maxEntries) { truncation.add('max-entries'); return true; }
3751
+ if (Date.now() > deadline) { truncation.add('time-budget'); return true; }
3752
+ return false;
3753
+ };
3754
+
3755
+ const walk = async (dir, depth) => {
3756
+ if (exhausted()) {
3757
+ return;
3758
+ }
3759
+ if (depth > maxDepth) {
3760
+ truncation.add('max-depth');
3761
+ return;
3762
+ }
3763
+ let entries;
3764
+ try {
3765
+ entries = await fs.readdir(dir, { withFileTypes: true });
3766
+ } catch {
3767
+ return;
3768
+ }
3769
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
3770
+ if (exhausted()) {
3771
+ return;
3772
+ }
3773
+ visited += 1;
3774
+ if (entry.isSymbolicLink()) {
3775
+ continue; // never follow links out of the tree (cycles, escapes)
3776
+ }
3777
+ const entryPath = path.join(dir, entry.name);
3778
+ if (entry.isDirectory()) {
3779
+ if (FALLBACK_SEARCH_IGNORE_DIR_SET.has(entry.name) || entry.name.startsWith('.')) {
3780
+ continue;
3781
+ }
3782
+ await walk(entryPath, depth + 1);
3783
+ continue;
3784
+ }
3785
+ if (!entry.isFile() || !FALLBACK_SEARCH_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
3786
+ continue;
3787
+ }
3788
+ try {
3789
+ const stat = await fs.stat(entryPath);
3790
+ if (stat.size > maxFileBytes) {
3791
+ continue;
3792
+ }
3793
+ } catch {
3794
+ continue;
3795
+ }
3796
+ results.push(path.relative(absoluteRoot, entryPath).replace(/\\/g, '/'));
3797
+ }
3798
+ };
3799
+
3800
+ await walk(absoluteRoot, 0);
3801
+ return results;
3802
+ }
3803
+
3804
+ async function readMatchingLine(absoluteRoot, relativePath, tokens, maxFileBytes) {
3805
+ if (!relativePath) {
3806
+ return null;
3807
+ }
3808
+ let content;
3809
+ try {
3810
+ content = await fs.readFile(path.join(absoluteRoot, relativePath), 'utf8');
3811
+ } catch {
3812
+ return null;
3813
+ }
3814
+ if (content.length > maxFileBytes) {
3815
+ return null;
3816
+ }
3817
+ const foldedContent = buildSearchDescriptor(content.slice(0, 64 * 1024)).rawLower;
3818
+ if (!tokens.every((token) => foldedContent.includes(token))) {
3819
+ return null;
3820
+ }
3821
+ const rawLines = content.split('\n');
3822
+ const foldedLines = foldedContent.split('\n');
3823
+ for (const [index, rawLine] of rawLines.entries()) {
3824
+ const foldedLine = foldedLines[index] ?? '';
3825
+ if (tokens.every((token) => foldedLine.includes(token))) {
3826
+ return { line: index + 1 };
3827
+ }
3828
+ }
3829
+ // All tokens present in the file but never on one line — still a hit; point
3830
+ // at the first line containing any token.
3831
+ for (const [index, rawLine] of rawLines.entries()) {
3832
+ const foldedLine = foldedLines[index] ?? '';
3833
+ if (tokens.some((token) => foldedLine.includes(token))) {
3834
+ return { line: index + 1 };
3835
+ }
3836
+ }
3837
+ return { line: null };
3838
+ }
3839
+
3192
3840
  function haveStableTrackedFileIdentities(previousRecords, nextRecords) {
3193
3841
  if (previousRecords.length !== nextRecords.length) {
3194
3842
  return false;
@@ -3320,6 +3968,132 @@ export function buildSearchDescriptor(value, { expandVietnameseAliases = false }
3320
3968
  };
3321
3969
  }
3322
3970
 
3971
+ // ── Fuzzy identifier resolution ──
3972
+ // Shared by resolveSymbolCandidates and scoreDirectFileMatch's symbol_fuzzy
3973
+ // tier: camelCase/snake_case/kebab-case identifiers normalize to the same
3974
+ // folded token sequence, ordered subsequences bridge compacted queries
3975
+ // ('checknull' ⊂ check_if_is_null), and a bounded Damerau distance catches
3976
+ // misspellings ('custmer_code' → 'customer_code').
3977
+
3978
+ export function normalizeIdentifierTokens(value) {
3979
+ return tokenizeFoldedText(foldTextForSearch(addCamelBoundaries(value)));
3980
+ }
3981
+
3982
+ // Query variants for phrase-level matching: the folded text as-is, then the
3983
+ // Vietnamese phrase aliases rewritten in place ('kiem tra null' → 'check null'),
3984
+ // then token-level aliases. Each variant is an ordered, deduped token list.
3985
+ export function expandIdentifierQueryVariants(value) {
3986
+ const folded = foldTextForSearch(value);
3987
+ const variants = [tokenizeFoldedText(folded)];
3988
+
3989
+ for (const entry of VIETNAMESE_PHRASE_ALIASES) {
3990
+ entry.regex.lastIndex = 0;
3991
+ if (!entry.regex.test(folded)) {
3992
+ continue;
3993
+ }
3994
+ for (const expansion of entry.expansions) {
3995
+ entry.regex.lastIndex = 0;
3996
+ variants.push(tokenizeFoldedText(folded.replace(entry.regex, expansion)));
3997
+ }
3998
+ }
3999
+
4000
+ variants.push(variants[0].map((token) => VIETNAMESE_TOKEN_ALIASES.get(token)?.[0] ?? token));
4001
+
4002
+ const seen = new Set();
4003
+ return variants
4004
+ .map((tokens) => [...new Set(tokens)])
4005
+ .filter((tokens) => tokens.length > 0)
4006
+ .filter((tokens) => {
4007
+ const key = tokens.join('\u0000');
4008
+ if (seen.has(key)) {
4009
+ return false;
4010
+ }
4011
+ seen.add(key);
4012
+ return true;
4013
+ })
4014
+ .slice(0, 8);
4015
+ }
4016
+
4017
+ // `queryTokens` fits inside `symbolTokens` in order ('check null' ⊆ check,if,null).
4018
+ export function isOrderedTokenSubsequence(queryTokens, symbolTokens) {
4019
+ if (queryTokens.length === 0 || queryTokens.length > symbolTokens.length) {
4020
+ return false;
4021
+ }
4022
+ let cursor = 0;
4023
+ for (const token of symbolTokens) {
4024
+ if (token === queryTokens[cursor]) {
4025
+ cursor += 1;
4026
+ if (cursor === queryTokens.length) {
4027
+ return true;
4028
+ }
4029
+ }
4030
+ }
4031
+ return false;
4032
+ }
4033
+
4034
+ // Character-level ordered containment on compacted forms ('checknull' ⊂ 'checkifnull').
4035
+ export function isCompactCharSubsequence(queryCompact, symbolCompact) {
4036
+ if (!queryCompact || queryCompact.length > symbolCompact.length) {
4037
+ return false;
4038
+ }
4039
+ let cursor = 0;
4040
+ for (const char of symbolCompact) {
4041
+ if (char === queryCompact[cursor]) {
4042
+ cursor += 1;
4043
+ if (cursor === queryCompact.length) {
4044
+ return true;
4045
+ }
4046
+ }
4047
+ }
4048
+ return false;
4049
+ }
4050
+
4051
+ // Damerau-Levenshtein (with transpositions), bounded: anything farther than
4052
+ // `maxDistance` short-circuits to maxDistance + 1 so callers can treat the
4053
+ // result as "beyond threshold" without walking the full matrix.
4054
+ export function boundedDamerauDistance(a, b, maxDistance = 2) {
4055
+ const left = String(a ?? '');
4056
+ const right = String(b ?? '');
4057
+ if (left === right) {
4058
+ return 0;
4059
+ }
4060
+ if (Math.abs(left.length - right.length) > maxDistance) {
4061
+ return maxDistance + 1;
4062
+ }
4063
+
4064
+ let prevPrev = null;
4065
+ let prev = Array.from({ length: right.length + 1 }, (_, j) => j);
4066
+ for (let i = 1; i <= left.length; i += 1) {
4067
+ const current = [i];
4068
+ let rowMin = i;
4069
+ for (let j = 1; j <= right.length; j += 1) {
4070
+ const substitutionCost = left[i - 1] === right[j - 1] ? 0 : 1;
4071
+ let value = Math.min(
4072
+ prev[j] + 1,
4073
+ current[j - 1] + 1,
4074
+ prev[j - 1] + substitutionCost,
4075
+ );
4076
+ if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
4077
+ value = Math.min(value, prevPrev[j - 2] + 1);
4078
+ }
4079
+ current[j] = value;
4080
+ if (value < rowMin) {
4081
+ rowMin = value;
4082
+ }
4083
+ }
4084
+ if (rowMin > maxDistance) {
4085
+ return maxDistance + 1;
4086
+ }
4087
+ prevPrev = prev;
4088
+ prev = current;
4089
+ }
4090
+ return prev[right.length];
4091
+ }
4092
+
4093
+ // Minimum length both compacts must reach before edit-distance fuzzy matching
4094
+ // applies — short identifiers (< 6) produce too many false neighbors.
4095
+ export const FUZZY_EDIT_DISTANCE_MIN_LENGTH = 6;
4096
+
3323
4097
  function addCamelBoundaries(value) {
3324
4098
  return String(value || '')
3325
4099
  .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
@@ -4432,10 +5206,11 @@ export async function resolveImpactContext({
4432
5206
  const normalizedChangedFiles = uniqueImpact(changedFiles.map(normalizeImpactPath));
4433
5207
  const normalizedChangedSymbols = uniqueImpact(changedSymbols.map((symbol) => String(symbol ?? '').trim()));
4434
5208
 
4435
- const [callsArtifact, importsArtifact, filesArtifact, relatedArtifacts] = await Promise.all([
5209
+ const [callsArtifact, importsArtifact, filesArtifact, symbolsArtifact, relatedArtifacts] = await Promise.all([
4436
5210
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.calls),
4437
5211
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.imports),
4438
5212
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
5213
+ readArtifact(absoluteRoot, INDEX_ARTIFACTS.symbols),
4439
5214
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
4440
5215
  ]);
4441
5216
 
@@ -4463,12 +5238,27 @@ export async function resolveImpactContext({
4463
5238
  ...normalizedChangedSymbols,
4464
5239
  ...changedRecords.map((record) => record.symbol),
4465
5240
  ]);
5241
+ // Global-registered symbols (window.X / app.config.globalProperties.$X /
5242
+ // app.component(...)) are reachable without imports, so a bare-name call is
5243
+ // already proof for them.
5244
+ const changedGlobalSymbols = new Set(
5245
+ safeItems(symbolsArtifact)
5246
+ .filter((item) => item.type === 'global' && changedFileSet.has(normalizeImpactPath(item.filePath)))
5247
+ .map((item) => item.name),
5248
+ );
5249
+ const candidateCallers = [];
4466
5250
  if (callerTargets.size > 0) {
4467
5251
  for (const record of callItems) {
4468
5252
  const matchedSymbol = (record.calls ?? []).find((name) => callerTargets.has(name));
4469
5253
  if (!matchedSymbol) continue;
4470
5254
  if (changedFileSet.has(normalizeImpactPath(record.filePath))) continue;
4471
- callers.push({ filePath: record.filePath, symbol: record.symbol, reason: `calls ${matchedSymbol}` });
5255
+
5256
+ candidateCallers.push({
5257
+ filePath: record.filePath,
5258
+ symbol: record.symbol,
5259
+ reason: `calls ${matchedSymbol}`,
5260
+ matchedSymbol,
5261
+ });
4472
5262
  }
4473
5263
  }
4474
5264
 
@@ -4500,45 +5290,121 @@ export async function resolveImpactContext({
4500
5290
  dependencies.push(matched);
4501
5291
  }
4502
5292
  }
5293
+
5294
+ // Callers only count with proof: an import edge into a changed file
5295
+ // (directly / re-export / alias), an explicit SQL reference record, or a
5296
+ // global-registered changed symbol. Name-only matches are reported as
5297
+ // unverifiedImpact instead of pretending exhaustive caller knowledge.
5298
+ const importerSet = new Set(importers);
5299
+ const isVerifiedCaller = (entry) => importerSet.has(entry.filePath)
5300
+ || String(entry.filePath ?? '').toLowerCase().endsWith('.sql')
5301
+ || changedGlobalSymbols.has(entry.matchedSymbol);
5302
+ // Output rows keep the historical { filePath, symbol, reason } shape.
5303
+ const toCallerRow = ({ filePath, symbol, reason }) => ({ filePath, symbol, reason });
5304
+ const verifiedCallers = candidateCallers.filter(isVerifiedCaller).map(toCallerRow);
5305
+ const unverifiedImpact = uniqueImpact(
5306
+ candidateCallers
5307
+ .filter((entry) => !isVerifiedCaller(entry))
5308
+ .map((entry) => entry.filePath),
5309
+ );
5310
+
4503
5311
  const candidateFiles = uniqueImpact([
4504
5312
  ...normalizedChangedFiles,
4505
5313
  ...importers,
4506
- ...callers.map((item) => item.filePath),
5314
+ ...verifiedCallers.map((item) => item.filePath),
4507
5315
  ...mirrorCounterparts.map((item) => item.filePath),
4508
5316
  ]);
4509
- const relatedTests = inferRelatedTestsFromArtifacts({
5317
+ const relatedTestsFromArtifacts = inferRelatedTestsFromArtifacts({
4510
5318
  candidateFiles,
4511
5319
  analogsMap: relatedArtifacts.analogsMap,
4512
5320
  relationsMap: relatedArtifacts.relationsMap,
4513
5321
  limit: budget.maxTests,
4514
5322
  }).map((entry) => entry.filePath);
5323
+ // Importer test files are related tests by definition: they import the
5324
+ // changed file and live under a test directory.
5325
+ const importerTestFiles = importers.filter((filePath) => isLikelyTestFile(filePath));
5326
+ const relatedTests = uniqueImpact([...importerTestFiles, ...relatedTestsFromArtifacts]);
5327
+
4515
5328
  const recommendedTestFiles = uniqueImpact([
4516
5329
  ...relatedTests,
4517
5330
  ...risk.riskLabels.flatMap((label) => IMPACT_RISK_TEST_RECOMMENDATIONS[label] ?? []),
4518
5331
  ]);
4519
5332
 
5333
+ // Stale-entry guard: a renamed/deleted file must not surface as impact.
5334
+ // Only the capped output lists are stat-checked, so the cost stays bounded.
5335
+ const dedupedCallers = dedupeImpactCallers(verifiedCallers);
5336
+ const existingCallers = await filterExistingImpactCallers(absoluteRoot, dedupedCallers);
5337
+ const existingImporters = await filterExistingImpactPaths(absoluteRoot, uniqueImpact(importers));
5338
+ const existingRelatedTests = await filterExistingImpactPaths(absoluteRoot, relatedTests);
5339
+ const existingUnverifiedImpact = await filterExistingImpactPaths(absoluteRoot, unverifiedImpact);
5340
+
5341
+ const calleeList = uniqueImpact(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value));
5342
+ const callerList = existingCallers.slice(0, budget.maxCallers);
5343
+ const importerList = existingImporters.slice(0, budget.maxImporters);
5344
+ const relatedTestList = existingRelatedTests.slice(0, budget.maxTests);
5345
+ const unverifiedList = existingUnverifiedImpact.slice(0, budget.maxCallers);
5346
+
4520
5347
  return {
4521
5348
  changedFiles: normalizedChangedFiles,
4522
5349
  changedSymbols: normalizedChangedSymbols,
4523
5350
  riskLabels: risk.riskLabels,
4524
5351
  requiresImpactCheck: risk.requiresImpactCheck,
4525
- callees: uniqueImpact(callees.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallees),
4526
- callers: uniqueImpact(callers.map((item) => JSON.stringify(item))).map((value) => JSON.parse(value)).slice(0, budget.maxCallers),
4527
- importers: uniqueImpact(importers).slice(0, budget.maxImporters),
5352
+ callees: calleeList.slice(0, budget.maxCallees),
5353
+ callers: callerList,
5354
+ importers: importerList,
4528
5355
  dependencies: uniqueImpact(dependencies).slice(0, budget.maxImporters),
4529
5356
  mirrorCounterparts,
4530
- relatedTests,
5357
+ relatedTests: relatedTestList,
4531
5358
  recommendedTestFiles,
5359
+ unverifiedImpact: unverifiedList,
5360
+ truncated: {
5361
+ callers: Math.max(existingCallers.length - callerList.length, 0),
5362
+ importers: Math.max(existingImporters.length - importerList.length, 0),
5363
+ callees: Math.max(calleeList.length - budget.maxCallees, 0),
5364
+ relatedTests: Math.max(existingRelatedTests.length - relatedTestList.length, 0),
5365
+ unverifiedImpact: Math.max(existingUnverifiedImpact.length - unverifiedList.length, 0),
5366
+ },
4532
5367
  explanations: {
4533
5368
  callees: changedRecords.map((record) => `${record.filePath}:${record.symbol}`),
4534
- callers: callers.map((entry) => `${entry.filePath}:${entry.symbol}`),
5369
+ callers: callerList.map((entry) => `${entry.filePath}:${entry.symbol}`),
4535
5370
  importers,
4536
5371
  relatedTests,
4537
5372
  mirrorCounterparts: mirrorCounterparts.map((entry) => `${entry.source} -> ${entry.filePath}`),
5373
+ unverifiedImpact: unverifiedList,
4538
5374
  },
4539
5375
  };
4540
5376
  }
4541
5377
 
5378
+ function dedupeImpactCallers(callers = []) {
5379
+ const byKey = new Map();
5380
+ for (const caller of callers) {
5381
+ const key = `${caller.filePath}\u0000${caller.symbol}`;
5382
+ if (!byKey.has(key)) {
5383
+ byKey.set(key, caller);
5384
+ }
5385
+ }
5386
+ return [...byKey.values()];
5387
+ }
5388
+
5389
+ async function impactPathExists(rootDir, relativePath) {
5390
+ try {
5391
+ await fs.access(path.join(rootDir, relativePath));
5392
+ return true;
5393
+ } catch {
5394
+ return false;
5395
+ }
5396
+ }
5397
+
5398
+ async function filterExistingImpactPaths(rootDir, values = []) {
5399
+ const checks = await Promise.all(values.map(async (value) => ({ value, exists: await impactPathExists(rootDir, value) })));
5400
+ return checks.filter((check) => check.exists).map((check) => check.value);
5401
+ }
5402
+
5403
+ async function filterExistingImpactCallers(rootDir, callers = []) {
5404
+ const checks = await Promise.all(callers.map(async (entry) => ({ entry, exists: await impactPathExists(rootDir, entry.filePath) })));
5405
+ return checks.filter((check) => check.exists).map((check) => check.entry);
5406
+ }
5407
+
4542
5408
  export function formatImpactConfidenceSummary(impact = {}) {
4543
5409
  const lines = ['Impact checked:'];
4544
5410
  if ((impact.changedFiles ?? []).length > 0) lines.push(`- Changed: ${(impact.changedFiles ?? []).slice(0, 3).join(', ')}`);
@@ -4557,6 +5423,9 @@ export function formatImpactConfidenceSummary(impact = {}) {
4557
5423
  const mirrors = (impact.mirrorCounterparts ?? []).map((item) => item?.filePath).filter(Boolean).slice(0, 3);
4558
5424
  if (mirrors.length > 0) lines.push(`- Mirrors: ${mirrors.join(', ')}`);
4559
5425
 
5426
+ const unverified = (impact.unverifiedImpact ?? []).slice(0, 3);
5427
+ if (unverified.length > 0) lines.push(`- Unverified impact (name-only): ${unverified.join(', ')}`);
5428
+
4560
5429
  const relatedTests = (impact.relatedTests ?? []).slice(0, 4);
4561
5430
  if (relatedTests.length > 0) lines.push(`- Related tests: ${relatedTests.join(', ')}`);
4562
5431