ctxman 3.1.1 → 3.2.7
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +16 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +618 -16
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +186 -17
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/workspace/index.js +1274 -0
- package/package.json +1 -1
package/lib/pr/pr-context.js
CHANGED
|
@@ -17,6 +17,7 @@ import FileUtils from '../utils/file-utils.js';
|
|
|
17
17
|
import TokenUtils from '../utils/token-utils.js';
|
|
18
18
|
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
19
19
|
import { readFileChecked } from '../utils/safe-read.js';
|
|
20
|
+
import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
|
|
20
21
|
import PrGit, { bufferToText } from './pr-git.js';
|
|
21
22
|
import {
|
|
22
23
|
parseNameStatus,
|
|
@@ -53,6 +54,7 @@ const DEFAULTS = {
|
|
|
53
54
|
redact: true,
|
|
54
55
|
maxFileBytes: 1024 * 1024,
|
|
55
56
|
maxRelatedTests: 50,
|
|
57
|
+
repos: null,
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
// Test files read to look for imports and references: per file, and for all of them
|
|
@@ -65,6 +67,7 @@ const FILTER_NAMES = {
|
|
|
65
67
|
context: '.contextignore',
|
|
66
68
|
'context-include': '.contextinclude',
|
|
67
69
|
};
|
|
70
|
+
const IMPACT_ORDER = { low: 0, medium: 1, high: 2 };
|
|
68
71
|
|
|
69
72
|
/**
|
|
70
73
|
* Check and complete the options
|
|
@@ -273,35 +276,58 @@ function describeDropped(dropped) {
|
|
|
273
276
|
return labels;
|
|
274
277
|
}
|
|
275
278
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* @param {boolean} [options.withFiles] - Include changed files' full contents
|
|
290
|
-
* @param {string} [options.ask] - Question appended at the end
|
|
291
|
-
* @param {string} [options.instructions] - Instructions appended at the end
|
|
292
|
-
* @param {number} [options.budget] - Maximum tokens of the output
|
|
293
|
-
* @param {'markdown'|'xml'|'json'} [options.style='markdown']
|
|
294
|
-
* @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
|
|
295
|
-
* @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
|
|
296
|
-
* token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
|
|
297
|
-
* `budget` (limit, dropped, over), `redactions` and `warnings`
|
|
298
|
-
*/
|
|
299
|
-
export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
300
|
-
const opts = normalizePrOptions(options);
|
|
301
|
-
const rootPath = path.resolve(root);
|
|
302
|
-
if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
|
|
303
|
-
throw new Error(`Not a directory: ${rootPath}`);
|
|
279
|
+
function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
|
|
280
|
+
const realPrimary = fs.existsSync(primaryRoot)
|
|
281
|
+
? fs.realpathSync(primaryRoot)
|
|
282
|
+
: path.resolve(primaryRoot);
|
|
283
|
+
const isContainerOfExtra = extraRepos.some((item) => {
|
|
284
|
+
const raw = typeof item === 'string' ? item : item?.path;
|
|
285
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
286
|
+
const resolved = path.resolve(raw);
|
|
287
|
+
const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
288
|
+
return real !== realPrimary && real.startsWith(realPrimary + path.sep);
|
|
289
|
+
});
|
|
290
|
+
if (isContainerOfExtra || !PrGit.findRepoRoot(realPrimary)) {
|
|
291
|
+
return [...extraRepos];
|
|
304
292
|
}
|
|
293
|
+
return [primaryRoot, ...extraRepos];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function prefixDiffWithAlias(diffText, alias) {
|
|
297
|
+
if (!diffText) return diffText;
|
|
298
|
+
return diffText
|
|
299
|
+
.split('\n')
|
|
300
|
+
.map((line) => {
|
|
301
|
+
if (line.startsWith('diff --git a/')) {
|
|
302
|
+
return line.replace(
|
|
303
|
+
/^diff --git a\/(.+?) b\/(.+)$/,
|
|
304
|
+
`diff --git a/${alias}/$1 b/${alias}/$2`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (line.startsWith('--- a/')) {
|
|
308
|
+
return `--- a/${alias}/${line.slice(6)}`;
|
|
309
|
+
}
|
|
310
|
+
if (line.startsWith('+++ b/')) {
|
|
311
|
+
return `+++ b/${alias}/${line.slice(6)}`;
|
|
312
|
+
}
|
|
313
|
+
if (line.startsWith('rename from ')) {
|
|
314
|
+
return `rename from ${alias}/${line.slice(12)}`;
|
|
315
|
+
}
|
|
316
|
+
if (line.startsWith('rename to ')) {
|
|
317
|
+
return `rename to ${alias}/${line.slice(10)}`;
|
|
318
|
+
}
|
|
319
|
+
if (line.startsWith('copy from ')) {
|
|
320
|
+
return `copy from ${alias}/${line.slice(10)}`;
|
|
321
|
+
}
|
|
322
|
+
if (line.startsWith('copy to ')) {
|
|
323
|
+
return `copy to ${alias}/${line.slice(8)}`;
|
|
324
|
+
}
|
|
325
|
+
return line;
|
|
326
|
+
})
|
|
327
|
+
.join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function collectSingleRepoPr(rootPath, opts) {
|
|
305
331
|
const repoTop = PrGit.findRepoRoot(rootPath);
|
|
306
332
|
if (!repoTop) throw new Error(`Not inside a git repository: ${rootPath}`);
|
|
307
333
|
const repoRoot = fs.realpathSync(repoTop);
|
|
@@ -635,7 +661,10 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
|
635
661
|
);
|
|
636
662
|
}
|
|
637
663
|
|
|
638
|
-
|
|
664
|
+
return { model, scope, redactions, warnings };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function renderModelWithinBudget(model, opts, redactions, warnings, scope) {
|
|
639
668
|
const count = (text) => TokenUtils.calculate(text, 'pr-context.md');
|
|
640
669
|
const dropped = {
|
|
641
670
|
contents: [],
|
|
@@ -691,6 +720,7 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
|
691
720
|
}
|
|
692
721
|
|
|
693
722
|
return {
|
|
723
|
+
...(model.repos ? { repos: model.repos } : {}),
|
|
694
724
|
output: result.output,
|
|
695
725
|
tokens: result.tokens,
|
|
696
726
|
style: opts.style,
|
|
@@ -704,8 +734,9 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
|
704
734
|
budget: model.budget,
|
|
705
735
|
commits: model.commits,
|
|
706
736
|
files: model.files,
|
|
707
|
-
methods: { changed: methods, deleted: deletedMethods },
|
|
708
|
-
relatedTests: tests.map(({ path: testPath, reasons, changed, excluded }) => ({
|
|
737
|
+
methods: { changed: model.methods, deleted: model.deletedMethods },
|
|
738
|
+
relatedTests: model.tests.map(({ path: testPath, reasons, changed, excluded, repo }) => ({
|
|
739
|
+
...(repo ? { repo } : {}),
|
|
709
740
|
path: testPath,
|
|
710
741
|
reasons,
|
|
711
742
|
changed,
|
|
@@ -716,6 +747,236 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
|
716
747
|
};
|
|
717
748
|
}
|
|
718
749
|
|
|
750
|
+
/**
|
|
751
|
+
* Build the review context for a branch, the staged changes or the working tree
|
|
752
|
+
* @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
|
|
753
|
+
* subdirectory limits the change to that directory
|
|
754
|
+
* @param {object} [options]
|
|
755
|
+
* @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
|
|
756
|
+
* HEAD with staged/working)
|
|
757
|
+
* @param {string} [options.head='HEAD'] - Head revision
|
|
758
|
+
* @param {boolean} [options.staged] - Compare the index with the base instead
|
|
759
|
+
* @param {boolean} [options.working] - Compare the working tree (with untracked files)
|
|
760
|
+
* @param {number} [options.unified=3] - Context lines in the diff
|
|
761
|
+
* @param {boolean} [options.diff=true] - Include the unified diff
|
|
762
|
+
* @param {boolean} [options.withTests] - Include related tests' contents
|
|
763
|
+
* @param {boolean} [options.withFiles] - Include changed files' full contents
|
|
764
|
+
* @param {string} [options.ask] - Question appended at the end
|
|
765
|
+
* @param {string} [options.instructions] - Instructions appended at the end
|
|
766
|
+
* @param {number} [options.budget] - Maximum tokens of the output
|
|
767
|
+
* @param {'markdown'|'xml'|'json'} [options.style='markdown']
|
|
768
|
+
* @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
|
|
769
|
+
* @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional workspace repositories
|
|
770
|
+
* @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
|
|
771
|
+
* token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
|
|
772
|
+
* `budget` (limit, dropped, over), `redactions` and `warnings`
|
|
773
|
+
*/
|
|
774
|
+
export async function buildPrContext(root = process.cwd(), options = {}) {
|
|
775
|
+
const opts = normalizePrOptions(options);
|
|
776
|
+
const rootPath = path.resolve(root);
|
|
777
|
+
if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
|
|
778
|
+
throw new Error(`Not a directory: ${rootPath}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
|
|
782
|
+
if (!isWorkspace) {
|
|
783
|
+
const { model, scope, redactions, warnings } = collectSingleRepoPr(rootPath, opts);
|
|
784
|
+
return renderModelWithinBudget(model, opts, redactions, warnings, scope);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(rootPath, options.repos));
|
|
788
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
789
|
+
filesByRepo: new Map(),
|
|
790
|
+
tokens: 0,
|
|
791
|
+
redact: opts.redact,
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
for (const repo of resolvedRepos) {
|
|
795
|
+
if (!PrGit.findRepoRoot(repo.realRoot)) {
|
|
796
|
+
throw new Error(`Not inside a git repository: ${repo.root}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const perRepoResults = [];
|
|
801
|
+
const skippedErrors = [];
|
|
802
|
+
let firstError = null;
|
|
803
|
+
|
|
804
|
+
for (const repo of resolvedRepos) {
|
|
805
|
+
try {
|
|
806
|
+
const single = collectSingleRepoPr(repo.root, { ...opts, budget: null });
|
|
807
|
+
perRepoResults.push({ repo, ...single });
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (error.name === 'PolicyConfigError' || error.name === 'PolicyViolationError') {
|
|
810
|
+
throw error;
|
|
811
|
+
}
|
|
812
|
+
firstError ??= error;
|
|
813
|
+
skippedErrors.push({ repo, error });
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (perRepoResults.length === 0 && firstError) {
|
|
818
|
+
throw firstError;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const warnings = [];
|
|
822
|
+
for (const { repo, error } of skippedErrors) {
|
|
823
|
+
warnings.push(`${repo.alias}: skipped (${error.message})`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const redactions = {};
|
|
827
|
+
const commits = [];
|
|
828
|
+
const files = [];
|
|
829
|
+
const methods = [];
|
|
830
|
+
const deletedMethods = [];
|
|
831
|
+
const diffs = [];
|
|
832
|
+
const allTests = [];
|
|
833
|
+
const contents = [];
|
|
834
|
+
const byStatus = {};
|
|
835
|
+
const filesByRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
|
|
836
|
+
|
|
837
|
+
let highestImpact = { level: 'low', score: 0 };
|
|
838
|
+
|
|
839
|
+
for (const { repo, model: m, redactions: r, warnings: w } of perRepoResults) {
|
|
840
|
+
for (const [type, count] of Object.entries(r)) {
|
|
841
|
+
redactions[type] = (redactions[type] || 0) + count;
|
|
842
|
+
}
|
|
843
|
+
for (const warning of w) {
|
|
844
|
+
warnings.push(`${repo.alias}: ${warning}`);
|
|
845
|
+
}
|
|
846
|
+
if (
|
|
847
|
+
(IMPACT_ORDER[m.impact.level] ?? 0) > (IMPACT_ORDER[highestImpact.level] ?? 0) ||
|
|
848
|
+
m.impact.score > highestImpact.score
|
|
849
|
+
) {
|
|
850
|
+
highestImpact = {
|
|
851
|
+
level:
|
|
852
|
+
(IMPACT_ORDER[m.impact.level] ?? 0) >= (IMPACT_ORDER[highestImpact.level] ?? 0)
|
|
853
|
+
? m.impact.level
|
|
854
|
+
: highestImpact.level,
|
|
855
|
+
score: Math.max(highestImpact.score, m.impact.score),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
for (const [status, cnt] of Object.entries(m.stats.byStatus)) {
|
|
859
|
+
byStatus[status] = (byStatus[status] || 0) + cnt;
|
|
860
|
+
}
|
|
861
|
+
for (const c of m.commits) {
|
|
862
|
+
commits.push({ ...c, repo: repo.alias });
|
|
863
|
+
}
|
|
864
|
+
for (const f of m.files) {
|
|
865
|
+
filesByRepo.get(repo.alias)?.push({ path: f.path, tokens: 0 });
|
|
866
|
+
files.push({
|
|
867
|
+
...f,
|
|
868
|
+
repo: repo.alias,
|
|
869
|
+
path: `${repo.alias}/${f.path}`,
|
|
870
|
+
oldPath: f.oldPath ? `${repo.alias}/${f.oldPath}` : null,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
for (const method of m.methods) {
|
|
874
|
+
methods.push({
|
|
875
|
+
...method,
|
|
876
|
+
repo: repo.alias,
|
|
877
|
+
file: `${repo.alias}/${method.file}`,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
for (const dm of m.deletedMethods) {
|
|
881
|
+
deletedMethods.push({
|
|
882
|
+
...dm,
|
|
883
|
+
repo: repo.alias,
|
|
884
|
+
file: `${repo.alias}/${dm.file}`,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
for (const d of m.diffs) {
|
|
888
|
+
diffs.push({
|
|
889
|
+
...d,
|
|
890
|
+
repo: repo.alias,
|
|
891
|
+
path: `${repo.alias}/${d.path}`,
|
|
892
|
+
text: prefixDiffWithAlias(d.text, repo.alias),
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
for (const t of m.tests) {
|
|
896
|
+
allTests.push({
|
|
897
|
+
...t,
|
|
898
|
+
repo: repo.alias,
|
|
899
|
+
path: `${repo.alias}/${t.path}`,
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
for (const c of m.contents) {
|
|
903
|
+
contents.push({
|
|
904
|
+
...c,
|
|
905
|
+
repo: repo.alias,
|
|
906
|
+
path: `${repo.alias}/${c.path}`,
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const tests = allTests.slice(0, opts.maxRelatedTests);
|
|
912
|
+
if (allTests.length > tests.length) {
|
|
913
|
+
warnings.push(
|
|
914
|
+
`${allTests.length} related tests found across workspace; the first ${tests.length} are listed`
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const uniqueJoin = (values) => [...new Set(values.filter(Boolean))].join(', ');
|
|
919
|
+
const firstModel = perRepoResults[0].model;
|
|
920
|
+
const baseRef = uniqueJoin(perRepoResults.map((x) => x.model.base.ref));
|
|
921
|
+
const baseShort = uniqueJoin(perRepoResults.map((x) => x.model.base.short));
|
|
922
|
+
const headRef = uniqueJoin(perRepoResults.map((x) => x.model.head.ref));
|
|
923
|
+
const headShort = uniqueJoin(perRepoResults.map((x) => x.model.head.short));
|
|
924
|
+
const mergeShort = uniqueJoin(perRepoResults.map((x) => x.model.mergeBase.short));
|
|
925
|
+
|
|
926
|
+
const workspaceModel = {
|
|
927
|
+
repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })),
|
|
928
|
+
mode: firstModel.mode,
|
|
929
|
+
base: { ref: baseRef, sha: firstModel.base.sha, short: baseShort || null },
|
|
930
|
+
head:
|
|
931
|
+
firstModel.mode === 'range'
|
|
932
|
+
? { ref: headRef, sha: firstModel.head.sha, short: headShort || null }
|
|
933
|
+
: { ref: headRef, sha: null, short: null },
|
|
934
|
+
mergeBase: { sha: firstModel.mergeBase.sha, short: mergeShort },
|
|
935
|
+
scope: '',
|
|
936
|
+
impact: highestImpact,
|
|
937
|
+
stats: {
|
|
938
|
+
commits: commits.length,
|
|
939
|
+
files: files.length,
|
|
940
|
+
byStatus,
|
|
941
|
+
additions: files.reduce((sum, f) => sum + f.additions, 0),
|
|
942
|
+
deletions: files.reduce((sum, f) => sum + f.deletions, 0),
|
|
943
|
+
excluded: files.filter((f) => f.excluded).length,
|
|
944
|
+
changedMethods: methods.length,
|
|
945
|
+
addedMethods: methods.filter((m) => m.change === 'added').length,
|
|
946
|
+
deletedMethods: deletedMethods.length,
|
|
947
|
+
relatedTests: tests.length,
|
|
948
|
+
},
|
|
949
|
+
commits,
|
|
950
|
+
files,
|
|
951
|
+
methods,
|
|
952
|
+
deletedMethods,
|
|
953
|
+
diffEnabled: opts.diff,
|
|
954
|
+
diffs,
|
|
955
|
+
tests,
|
|
956
|
+
withTests: opts.withTests,
|
|
957
|
+
withFiles: opts.withFiles,
|
|
958
|
+
contents,
|
|
959
|
+
instructions: opts.instructions,
|
|
960
|
+
ask: opts.ask,
|
|
961
|
+
budget: opts.budget ? { limit: opts.budget, dropped: [], over: false } : null,
|
|
962
|
+
tokens: null,
|
|
963
|
+
logBodiesDropped: false,
|
|
964
|
+
logDropped: false,
|
|
965
|
+
filesDropped: false,
|
|
966
|
+
methodsDropped: false,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const rendered = renderModelWithinBudget(workspaceModel, opts, redactions, warnings, '');
|
|
970
|
+
|
|
971
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
972
|
+
filesByRepo,
|
|
973
|
+
tokens: rendered.tokens,
|
|
974
|
+
redact: opts.redact,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
return rendered;
|
|
978
|
+
}
|
|
979
|
+
|
|
719
980
|
/**
|
|
720
981
|
* What the budget drops, least important first: full file contents, test contents, related
|
|
721
982
|
* test paths, per-file diffs, method sources, the methods list, commit bodies, the commit
|
package/lib/pr/pr-renderer.js
CHANGED
|
@@ -140,6 +140,9 @@ const linesLabel = (item) =>
|
|
|
140
140
|
export function summaryFacts(model) {
|
|
141
141
|
const { stats } = model;
|
|
142
142
|
const facts = [];
|
|
143
|
+
if (Array.isArray(model.repos) && model.repos.length > 0) {
|
|
144
|
+
facts.push(['Repositories', model.repos.map((r) => r.alias).join(', ')]);
|
|
145
|
+
}
|
|
143
146
|
if (model.mode === 'range') {
|
|
144
147
|
facts.push(['Base', `${model.base.ref} (${model.base.short})`]);
|
|
145
148
|
facts.push(['Head', `${model.head.ref} (${model.head.short})`]);
|
|
@@ -206,7 +209,8 @@ const markdown = {
|
|
|
206
209
|
},
|
|
207
210
|
|
|
208
211
|
commit(commit, model) {
|
|
209
|
-
|
|
212
|
+
const repoBadge = commit.repo ? `[${commit.repo}] ` : '';
|
|
213
|
+
let text = `### ${code(commit.short)} ${repoBadge}${commit.subject}\n${commit.author}, ${commit.date}`;
|
|
210
214
|
if (commit.body && !model.logBodiesDropped) {
|
|
211
215
|
text += `\n\n${commit.body
|
|
212
216
|
.split('\n')
|
|
@@ -229,7 +233,7 @@ const markdown = {
|
|
|
229
233
|
const rows = model.files.map((file) => {
|
|
230
234
|
const name =
|
|
231
235
|
file.oldPath && file.oldPath !== file.path && (file.status === 'R' || file.status === 'C')
|
|
232
|
-
? `${code(file.oldPath)}
|
|
236
|
+
? `${code(file.oldPath)} \u2192 ${code(file.path)}`
|
|
233
237
|
: code(file.path);
|
|
234
238
|
const note = file.excluded ? ` (content excluded by ${file.excluded})` : '';
|
|
235
239
|
const added = file.binary ? 'binary' : file.additions;
|
|
@@ -344,8 +348,9 @@ function createXml() {
|
|
|
344
348
|
},
|
|
345
349
|
|
|
346
350
|
commit(commit, model) {
|
|
351
|
+
const repoAttr = commit.repo ? ` repo="${escapeAttr(commit.repo)}"` : '';
|
|
347
352
|
const body = commit.body && !model.logBodiesDropped ? `\n\n${commit.body}` : '';
|
|
348
|
-
return `<commit sha="${commit.short}" author="${escapeAttr(commit.author)}" date="${commit.date}">\n${commit.subject}${body}\n</commit>`;
|
|
353
|
+
return `<commit sha="${commit.short}"${repoAttr} author="${escapeAttr(commit.author)}" date="${commit.date}">\n${commit.subject}${body}\n</commit>`;
|
|
349
354
|
},
|
|
350
355
|
|
|
351
356
|
commits(model) {
|
|
@@ -468,6 +473,7 @@ function createXml() {
|
|
|
468
473
|
const json = {
|
|
469
474
|
summary(model) {
|
|
470
475
|
const summary = {
|
|
476
|
+
...(Array.isArray(model.repos) && model.repos.length > 0 ? { repos: model.repos } : {}),
|
|
471
477
|
mode: model.mode,
|
|
472
478
|
base: model.base,
|
|
473
479
|
head: model.head,
|
|
@@ -483,10 +489,11 @@ const json = {
|
|
|
483
489
|
},
|
|
484
490
|
|
|
485
491
|
commit(commit, model) {
|
|
486
|
-
const { sha, short, author, date, subject, body } = commit;
|
|
487
|
-
|
|
488
|
-
? { sha, short, author, date, subject }
|
|
489
|
-
: { sha, short, author, date, subject
|
|
492
|
+
const { sha, short, author, date, subject, body, repo } = commit;
|
|
493
|
+
const base = repo
|
|
494
|
+
? { repo, sha, short, author, date, subject }
|
|
495
|
+
: { sha, short, author, date, subject };
|
|
496
|
+
return model.logBodiesDropped ? base : { ...base, body };
|
|
490
497
|
},
|
|
491
498
|
|
|
492
499
|
commits(model) {
|
|
@@ -497,6 +504,7 @@ const json = {
|
|
|
497
504
|
files(model) {
|
|
498
505
|
if (model.filesDropped) return null;
|
|
499
506
|
return model.files.map((file) => ({
|
|
507
|
+
...(file.repo ? { repo: file.repo } : {}),
|
|
500
508
|
status: file.status,
|
|
501
509
|
path: file.path,
|
|
502
510
|
oldPath: file.oldPath,
|
|
@@ -510,8 +518,9 @@ const json = {
|
|
|
510
518
|
},
|
|
511
519
|
|
|
512
520
|
method(method) {
|
|
513
|
-
const { file, name, start, end, change, changedLines } = method;
|
|
521
|
+
const { file, name, start, end, change, changedLines, repo } = method;
|
|
514
522
|
return {
|
|
523
|
+
...(repo ? { repo } : {}),
|
|
515
524
|
file,
|
|
516
525
|
name,
|
|
517
526
|
start,
|
|
@@ -527,7 +536,8 @@ const json = {
|
|
|
527
536
|
if (model.methods.length === 0 && model.deletedMethods.length === 0) return null;
|
|
528
537
|
return {
|
|
529
538
|
changed: model.methods.map(json.method),
|
|
530
|
-
deleted: model.deletedMethods.map(({ file, name, start, end }) => ({
|
|
539
|
+
deleted: model.deletedMethods.map(({ file, name, start, end, repo }) => ({
|
|
540
|
+
...(repo ? { repo } : {}),
|
|
531
541
|
file,
|
|
532
542
|
name,
|
|
533
543
|
start,
|
|
@@ -548,7 +558,12 @@ const json = {
|
|
|
548
558
|
},
|
|
549
559
|
|
|
550
560
|
testLine(test) {
|
|
551
|
-
const entry = {
|
|
561
|
+
const entry = {
|
|
562
|
+
...(test.repo ? { repo: test.repo } : {}),
|
|
563
|
+
path: test.path,
|
|
564
|
+
reasons: test.reasons,
|
|
565
|
+
changed: test.changed,
|
|
566
|
+
};
|
|
552
567
|
if (test.excluded) entry.contentExcludedBy = test.excluded;
|
|
553
568
|
return entry;
|
|
554
569
|
},
|
|
@@ -5,13 +5,103 @@ export class EmbeddingProvider {
|
|
|
5
5
|
/**
|
|
6
6
|
* Generates an embedding for the given text.
|
|
7
7
|
* @param {string} text
|
|
8
|
-
* @returns {Promise<number[]>}
|
|
9
|
-
*/
|
|
8
|
+
* @returns {Promise<number[]>}\n */
|
|
10
9
|
async embed(_text) {
|
|
11
10
|
throw new Error('Not implemented');
|
|
12
11
|
}
|
|
13
12
|
}
|
|
14
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Deterministic, zero-dependency local embedding provider (256 dimensions).
|
|
16
|
+
* Uses feature-hashing over identifier tokens and character trigrams with L2 normalization.
|
|
17
|
+
* Requires zero external packages and works 100% offline.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalHashEmbeddingProvider extends EmbeddingProvider {
|
|
20
|
+
constructor(dimension = 256) {
|
|
21
|
+
super();
|
|
22
|
+
this.dimension = dimension;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 32-bit FNV-1a hash function with optional seed.
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
_hash(str, seed = 0x811c9dc5) {
|
|
30
|
+
let h = seed >>> 0;
|
|
31
|
+
for (let i = 0; i < str.length; i++) {
|
|
32
|
+
h ^= str.charCodeAt(i);
|
|
33
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
34
|
+
}
|
|
35
|
+
return h >>> 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generates a deterministic 256-dim L2-normalized embedding for the given text.
|
|
40
|
+
* @param {string} text
|
|
41
|
+
* @returns {Promise<number[]>}
|
|
42
|
+
*/
|
|
43
|
+
async embed(text) {
|
|
44
|
+
const raw = String(text ?? '');
|
|
45
|
+
const vector = new Float64Array(this.dimension);
|
|
46
|
+
|
|
47
|
+
// 1. Extract identifier tokens
|
|
48
|
+
const tokens = raw.match(/[a-zA-Z0-9_$]+/g) || [];
|
|
49
|
+
for (const token of tokens) {
|
|
50
|
+
const lower = token.toLowerCase();
|
|
51
|
+
// Hash whole token
|
|
52
|
+
const h1 = this._hash(lower, 0x12345678);
|
|
53
|
+
const idx1 = h1 % this.dimension;
|
|
54
|
+
const sign1 = (this._hash(lower, 0x87654321) & 1) === 0 ? 1 : -1;
|
|
55
|
+
vector[idx1] += sign1 * 1.5;
|
|
56
|
+
|
|
57
|
+
// Split camelCase/snake_case sub-tokens if composite
|
|
58
|
+
const subTokens = token.replace(/([a-z])([A-Z])/g, '$1 $2').split(/[_\s]+/);
|
|
59
|
+
if (subTokens.length > 1) {
|
|
60
|
+
for (const sub of subTokens) {
|
|
61
|
+
if (sub.length > 1) {
|
|
62
|
+
const subLower = sub.toLowerCase();
|
|
63
|
+
const hSub = this._hash(subLower, 0x2468ace0);
|
|
64
|
+
const idxSub = hSub % this.dimension;
|
|
65
|
+
const signSub = (this._hash(subLower, 0x0eca8642) & 1) === 0 ? 1 : -1;
|
|
66
|
+
vector[idxSub] += signSub * 1.0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Extract character trigrams
|
|
73
|
+
const normalized = raw.toLowerCase().replace(/\s+/g, ' ');
|
|
74
|
+
if (normalized.length >= 3) {
|
|
75
|
+
for (let i = 0; i <= normalized.length - 3; i++) {
|
|
76
|
+
const trigram = normalized.slice(i, i + 3);
|
|
77
|
+
const hTri = this._hash(trigram, 0x13579bdf);
|
|
78
|
+
const idxTri = hTri % this.dimension;
|
|
79
|
+
const signTri = (this._hash(trigram, 0xfdb97531) & 1) === 0 ? 1 : -1;
|
|
80
|
+
vector[idxTri] += signTri * 1.0;
|
|
81
|
+
}
|
|
82
|
+
} else if (normalized.length > 0) {
|
|
83
|
+
const hShort = this._hash(normalized, 0x99887766);
|
|
84
|
+
const idxShort = hShort % this.dimension;
|
|
85
|
+
const signShort = (this._hash(normalized, 0x66778899) & 1) === 0 ? 1 : -1;
|
|
86
|
+
vector[idxShort] += signShort * 1.0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 3. L2 normalize
|
|
90
|
+
let sumSq = 0;
|
|
91
|
+
for (let i = 0; i < this.dimension; i++) {
|
|
92
|
+
sumSq += vector[i] * vector[i];
|
|
93
|
+
}
|
|
94
|
+
const norm = Math.sqrt(sumSq);
|
|
95
|
+
if (norm > 0) {
|
|
96
|
+
for (let i = 0; i < this.dimension; i++) {
|
|
97
|
+
vector[i] /= norm;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Array.from(vector);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
15
105
|
/**
|
|
16
106
|
* Uses @xenova/transformers to generate embeddings locally using ONNX models.
|
|
17
107
|
* Default model: Xenova/all-MiniLM-L6-v2 (384 dimensions)
|
|
@@ -53,6 +143,53 @@ export class TransformersEmbeddingProvider extends EmbeddingProvider {
|
|
|
53
143
|
}
|
|
54
144
|
}
|
|
55
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Tries TransformersEmbeddingProvider if @xenova/transformers is installed
|
|
148
|
+
* and CTXMAN_EMBEDDER !== 'local'; otherwise seamlessly falls back to
|
|
149
|
+
* LocalHashEmbeddingProvider without network or model loading failure.
|
|
150
|
+
*/
|
|
151
|
+
export class AutoEmbeddingProvider extends EmbeddingProvider {
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} [options]
|
|
154
|
+
* @param {string} [options.modelName]
|
|
155
|
+
*/
|
|
156
|
+
constructor(options = {}) {
|
|
157
|
+
super();
|
|
158
|
+
this.modelName = options.modelName;
|
|
159
|
+
this.activeProvider = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async init() {
|
|
163
|
+
if (this.activeProvider) return;
|
|
164
|
+
|
|
165
|
+
if (process.env.CTXMAN_EMBEDDER === 'local') {
|
|
166
|
+
this.activeProvider = new LocalHashEmbeddingProvider();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await import('@xenova/transformers');
|
|
172
|
+
} catch (err) {
|
|
173
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
174
|
+
this.activeProvider = new LocalHashEmbeddingProvider();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const provider = new TransformersEmbeddingProvider(this.modelName);
|
|
181
|
+
await provider.init();
|
|
182
|
+
this.activeProvider = provider;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async embed(text) {
|
|
186
|
+
if (!this.activeProvider) {
|
|
187
|
+
await this.init();
|
|
188
|
+
}
|
|
189
|
+
return this.activeProvider.embed(text);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
56
193
|
/**
|
|
57
194
|
* Mock provider for testing without loading heavy models.
|
|
58
195
|
*/
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import { SqliteVectorStore } from './stores/SqliteVectorStore.js';
|
|
1
2
|
import { LanceDBStore } from './stores/LanceDBStore.js';
|
|
2
3
|
import { ChromaDBStore } from './stores/ChromaDBStore.js';
|
|
3
4
|
import { PineconeStore } from './stores/PineconeStore.js';
|
|
4
5
|
|
|
5
6
|
export class VectorStoreFactory {
|
|
6
7
|
/**
|
|
7
|
-
* @param {string} type
|
|
8
|
+
* @param {string} [type='sqlite']
|
|
8
9
|
* @param {import('./EmbeddingProvider').EmbeddingProvider} embeddingProvider
|
|
9
|
-
* @param {Object} config
|
|
10
|
+
* @param {Object} [config={}]
|
|
10
11
|
*/
|
|
11
|
-
static create(type, embeddingProvider, config = {}) {
|
|
12
|
-
|
|
12
|
+
static create(type = 'sqlite', embeddingProvider, config = {}) {
|
|
13
|
+
const storeType = (type || 'sqlite').toLowerCase();
|
|
14
|
+
switch (storeType) {
|
|
15
|
+
case 'sqlite':
|
|
16
|
+
return new SqliteVectorStore(embeddingProvider, config.path || config.storagePath);
|
|
13
17
|
case 'lancedb':
|
|
14
18
|
return new LanceDBStore(embeddingProvider, config.path);
|
|
15
19
|
case 'chroma':
|
|
@@ -22,3 +26,5 @@ export class VectorStoreFactory {
|
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
29
|
+
|
|
30
|
+
export default VectorStoreFactory;
|