@ngockhoale/ukit 3.4.28 → 3.4.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/manifests/documentation.yaml +88 -0
- package/package.json +1 -1
- package/src/core/buildPlan.js +11 -1
- package/src/core/permissionPolicy.js +1 -1
- package/src/core/status.js +3 -1
- package/src/index/buildIndex.js +244 -4
- package/src/index/impactContext.js +91 -11
- package/src/index/languageTools.js +126 -0
- package/src/index/paths.js +5 -1
- package/src/index/playbookRegistry.js +108 -9
- package/src/index/queryIndex.js +415 -1
- package/src/index/routeCatalog.js +1 -1
- package/src/index/taskRouting.js +25 -3
- package/template_project/.claude/commands/ukit/handoff-create.md +5 -0
- package/template_project/.claude/commands/ukit/handoff-fullstack.md +43 -4
- package/template_project/.claude/hooks/block-dangerous.mjs +7 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +235 -21
- package/template_project/.claude/hooks/record-execution.mjs +23 -0
- package/template_project/.claude/hooks/skill-router.sh +105 -7
- package/template_project/.claude/settings.json +21 -1
- package/template_project/.claude/ukit/index/cleanup-task-worktree.mjs +1 -1
- package/template_project/.claude/ukit/index/impact-context.mjs +70 -15
- package/template_project/.claude/ukit/index/land-worktree.mjs +2 -2
- package/template_project/.claude/ukit/index/lib/index-core.mjs +883 -14
- package/template_project/.claude/ukit/index/playbook-registry.mjs +108 -9
- package/template_project/.claude/ukit/index/query-index.mjs +100 -7
- package/template_project/.claude/ukit/index/route-catalog.mjs +1 -1
- package/template_project/.claude/ukit/index/route-task.mjs +208 -31
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +455 -24
- package/template_project/.claude/ukit/runtime/handoff-intent.mjs +122 -6
- package/template_project/.claude/ukit/runtime/resumable-run.mjs +417 -0
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +635 -28
- package/template_project/.omp/hooks/pre/ukit-bridge.js +32 -4
- package/template_user/playbooks/feature-implementation.md +9 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
5314
|
+
...verifiedCallers.map((item) => item.filePath),
|
|
4507
5315
|
...mirrorCounterparts.map((item) => item.filePath),
|
|
4508
5316
|
]);
|
|
4509
|
-
const
|
|
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:
|
|
4526
|
-
callers:
|
|
4527
|
-
importers:
|
|
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:
|
|
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
|
|