mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge remote-tracking branch 'upstream/main' into codex/xaml-support-3202
# Conflicts: # gitnexus/src/core/search/fts-schema.ts
This commit is contained in:
commit
c629383bd5
66 changed files with 8235 additions and 194 deletions
9
.github/workflows/ci-tests.yml
vendored
9
.github/workflows/ci-tests.yml
vendored
|
|
@ -523,6 +523,15 @@ jobs:
|
|||
run: node --import tsx bench/kotlin-star-route-constants/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: tRPC identifier-mount route extractor guards (#3339)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: inline-router control vs identifier-mounted subrouters
|
||||
# and a transitive mount chain; fingerprints route paths and guards
|
||||
# scaling + widening overhead (compose must stay linear in procedure
|
||||
# count, and must not degrade the inline scan).
|
||||
run: node --import tsx bench/trpc-route-extractor/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Cross-language scope-capture fingerprint + scaling guards
|
||||
# Runs even after an earlier guard fails (#2895). Every step here was
|
||||
# fail-fast, so the FIRST failing --check aborted the job and every guard
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# GitNexus (Akon Labs)
|
||||
# GitNexus
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -34,7 +34,6 @@
|
|||
<p>
|
||||
💬 <a href="https://discord.gg/MgJrmsqr62">Discord</a> ·
|
||||
🌐 <a href="https://gitnexus.vercel.app">Web UI</a> ·
|
||||
🏢 <a href="https://akonlabs.com">Enterprise (SaaS & self-hosted)</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
*
|
||||
* Two input modes:
|
||||
* - "github" → GitHub URL (https://github.com/owner/repo)
|
||||
* - "local" → Select a local folder via the browser's native directory picker
|
||||
* - "local" → Select a local folder via the browser's native directory picker,
|
||||
* or drop one onto the upload control
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useId } from 'react';
|
||||
import { useState, useRef, useEffect, useId, type DragEvent as ReactDragEvent } from 'react';
|
||||
import {
|
||||
Github,
|
||||
Gitlab,
|
||||
|
|
@ -27,7 +28,14 @@ import {
|
|||
type JobProgress,
|
||||
} from '../services/backend-client';
|
||||
import { AnalyzeProgress } from './AnalyzeProgress';
|
||||
import { filterRepoFiles } from '@/lib/upload-filter';
|
||||
import { filterRepoFiles, type FilterResult } from '@/lib/upload-filter';
|
||||
import {
|
||||
collectDropEntries,
|
||||
isFolderDropSupported,
|
||||
readDroppedFolder,
|
||||
DropRejection,
|
||||
type DropRejectionReason,
|
||||
} from '@/lib/folder-drop';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatBackendError } from '../i18n/error-messages';
|
||||
|
||||
|
|
@ -55,6 +63,23 @@ function isValidAzureUrl(value: string): boolean {
|
|||
return AZURE_RE.test(value.trim());
|
||||
}
|
||||
|
||||
/** i18n key (under onboarding:repoAnalyzer.upload) for a refused folder drop. */
|
||||
function dropRejectionKey(reason: DropRejectionReason): string {
|
||||
switch (reason) {
|
||||
case 'unsupported':
|
||||
return 'dropUnsupported';
|
||||
case 'tooManyFiles':
|
||||
return 'dropTooManyFiles';
|
||||
case 'notSingleFolder':
|
||||
return 'dropSingleFolder';
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a drag that carries files or folders from the OS, not text or links. */
|
||||
function isFileDrag(e: ReactDragEvent): boolean {
|
||||
return Array.from(e.dataTransfer.types).includes('Files');
|
||||
}
|
||||
|
||||
// ── Mode tabs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ModeTabs({ mode, onChange }: { mode: InputMode; onChange: (m: InputMode) => void }) {
|
||||
|
|
@ -198,9 +223,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
const inputId = useId();
|
||||
const [mode, setMode] = useState<InputMode>('github');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
|
||||
null,
|
||||
);
|
||||
// Files found so far while walking a dropped folder; null when not reading.
|
||||
const [readingCount, setReadingCount] = useState<number | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
// `skippedDirs` is set only for a dropped folder: directories the walk left
|
||||
// out without enumerating them (a directory count, unlike `dropped`).
|
||||
const [uploadSummary, setUploadSummary] = useState<{
|
||||
count: number;
|
||||
dropped: number;
|
||||
skippedDirs?: number;
|
||||
} | null>(null);
|
||||
const [githubUrl, setGithubUrl] = useState('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [gitlabUrl, setGitlabUrl] = useState('');
|
||||
|
|
@ -224,6 +256,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
const requestControllerRef = useRef<AbortController | null>(null);
|
||||
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
// dragenter/dragleave fire for every child boundary crossed; count them so
|
||||
// the highlight does not flicker while the cursor moves over the button.
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -277,6 +312,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
setUploading(false);
|
||||
// The aborted controller also stops a folder walk that is still running.
|
||||
setReadingCount(null);
|
||||
setDragActive(false);
|
||||
dragDepthRef.current = 0;
|
||||
// An aborted request no longer resolves to move `phase` off 'starting';
|
||||
// reset so the new mode's form is immediately usable (also clears a stale
|
||||
// 'error' phase). Only reachable while showInput is true.
|
||||
|
|
@ -287,14 +326,17 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
// exposes an absolute path, so the old typed-path/browse approach couldn't
|
||||
// work — see handleFolderUpload). A typed server path is also still accepted.
|
||||
|
||||
// A folder walk in progress (readingCount) blocks Analyze as well: starting a
|
||||
// request would abort the walk through renewRequestController.
|
||||
const canSubmit =
|
||||
mode === 'github'
|
||||
readingCount === null &&
|
||||
(mode === 'github'
|
||||
? isValidGithubUrl(githubUrl) && (phase === 'input' || phase === 'error')
|
||||
: mode === 'gitlab'
|
||||
? isValidGitlabUrl(gitlabUrl) && (phase === 'input' || phase === 'error')
|
||||
: mode === 'azure'
|
||||
? isValidAzureUrl(azureUrl) && (phase === 'input' || phase === 'error')
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error');
|
||||
: localPath.trim().length > 1 && (phase === 'input' || phase === 'error'));
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (mode === 'github' && !isValidGithubUrl(githubUrl)) {
|
||||
|
|
@ -398,20 +440,33 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
|
||||
// upload endpoint returns a jobId, which then joins the normal SSE flow.
|
||||
const handleFolderUpload = async (fileList: FileList) => {
|
||||
if (uploading || isLoading) return; // guard against a concurrent upload
|
||||
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
|
||||
// guard against a concurrent upload or a folder walk still running
|
||||
if (uploading || isLoading || readingCount !== null) return;
|
||||
await startFolderUpload(filterRepoFiles(fileList), renewRequestController());
|
||||
};
|
||||
|
||||
// Shared tail of the picker and drop paths: `filtered` is the client-side
|
||||
// filter result, `controller` owns the request, `skippedDirs` is set for a
|
||||
// drop and counts the directories the walk left out before reading them
|
||||
// (the picker enumerates everything and lets filterRepoFiles drop the files
|
||||
// instead, so its count arrives inside `filtered.droppedCount`).
|
||||
const startFolderUpload = async (
|
||||
filtered: FilterResult,
|
||||
controller: AbortController,
|
||||
skippedDirs?: number,
|
||||
) => {
|
||||
const { files, manifest, droppedCount } = filtered;
|
||||
if (files.length === 0) {
|
||||
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setUploadSummary({ count: files.length, dropped: droppedCount });
|
||||
setUploadSummary({ count: files.length, dropped: droppedCount, skippedDirs });
|
||||
setUploading(true);
|
||||
setPhase('starting');
|
||||
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
|
||||
// sensible fallback if the server's complete event omits repoName.
|
||||
const folderName = manifest[0]?.split('/')[0] ?? null;
|
||||
const controller = renewRequestController();
|
||||
try {
|
||||
const { jobId } = await uploadFolder(files, manifest, controller.signal);
|
||||
if (controller.signal.aborted) {
|
||||
|
|
@ -436,6 +491,76 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
}
|
||||
};
|
||||
|
||||
// Drag and drop a folder onto the upload control. The entries have to be
|
||||
// taken from dataTransfer synchronously inside the drop handler (browsers
|
||||
// empty `items` once the handler yields). Only a single folder is accepted,
|
||||
// mirroring the server's one-top-level-directory rule, and the walk runs
|
||||
// under the same request controller as the upload so a mode switch or an
|
||||
// unmount aborts both.
|
||||
const isDropBlocked = () => uploading || phase === 'starting' || readingCount !== null;
|
||||
|
||||
const handleDragEnter = (e: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (isDropBlocked()) return;
|
||||
dragDepthRef.current += 1;
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
// Without preventDefault the drop never fires and the browser navigates
|
||||
// to the dropped file instead, so it is called even while blocked.
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = isDropBlocked() ? 'none' : 'copy';
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
// No type check here: some engines hand dragleave an empty `types` list,
|
||||
// and a stray decrement is harmless because the depth is clamped at 0.
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!isFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDragActive(false);
|
||||
if (isDropBlocked()) return;
|
||||
const controller = renewRequestController();
|
||||
setValidationError(null);
|
||||
setUploadSummary(null);
|
||||
setReadingCount(0);
|
||||
try {
|
||||
const entries = collectDropEntries(e.dataTransfer); // sync, before any await
|
||||
const { files, skipped, oversized } = await readDroppedFolder(entries, {
|
||||
signal: controller.signal,
|
||||
onProgress: setReadingCount,
|
||||
});
|
||||
// Only the walk that still owns the request controller may clear the
|
||||
// reading mutex. An aborted drop that settles after a later drop started
|
||||
// must not steal the live walk's lock (readingCount === null is what
|
||||
// unblocks Analyze and a second drop).
|
||||
if (requestControllerRef.current === controller) setReadingCount(null);
|
||||
if (controller.signal.aborted) return;
|
||||
const filtered = filterRepoFiles(files);
|
||||
await startFolderUpload(
|
||||
{ ...filtered, droppedCount: filtered.droppedCount + oversized },
|
||||
controller,
|
||||
skipped,
|
||||
);
|
||||
} catch (err) {
|
||||
if (requestControllerRef.current === controller) setReadingCount(null);
|
||||
if (controller.signal.aborted) return;
|
||||
setValidationError(
|
||||
err instanceof DropRejection
|
||||
? t(`onboarding:repoAnalyzer.upload.${dropRejectionKey(err.reason)}`, { max: err.max })
|
||||
: formatBackendError(err, t),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
sseControllerRef.current?.abort();
|
||||
sseControllerRef.current = null;
|
||||
|
|
@ -453,6 +578,7 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
setPhase('input');
|
||||
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
|
||||
setUploading(false);
|
||||
setReadingCount(null);
|
||||
setUploadSummary(null);
|
||||
};
|
||||
|
||||
|
|
@ -658,9 +784,19 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Local folder input */}
|
||||
{/* Local folder input. The whole panel is the drop target for a folder:
|
||||
a drop that lands on the path input or the label is caught too, and
|
||||
the browser's default (navigating to the dropped file) never fires. */}
|
||||
{showInput && mode === 'local' && (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="space-y-2"
|
||||
data-testid="folder-drop-zone"
|
||||
data-drag-active={dragActive || undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<label
|
||||
htmlFor={`${inputId}-local`}
|
||||
className="block text-xs font-medium tracking-wider text-text-secondary uppercase"
|
||||
|
|
@ -702,7 +838,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
)}
|
||||
</div>
|
||||
{/* Upload a folder from your computer — no server path or mount needed.
|
||||
The browser can't expose an absolute path, so we upload the files. */}
|
||||
The browser can't expose an absolute path, so we upload the files.
|
||||
Dropping a folder onto this panel walks it with webkitGetAsEntry and
|
||||
feeds the same upload path. */}
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
|
|
@ -722,12 +860,35 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
type="button"
|
||||
data-testid="upload-folder"
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
|
||||
disabled={isLoading || readingCount !== null}
|
||||
className={`flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border bg-elevated px-3 py-2 text-xs font-medium transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50 ${
|
||||
dragActive
|
||||
? 'border-accent/50 text-text-primary shadow-[0_0_0_3px_rgba(124,58,237,0.08)]'
|
||||
: 'border-border-subtle text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{t('onboarding:repoAnalyzer.upload.button')}
|
||||
</button>
|
||||
{isFolderDropSupported() && !isDropBlocked() && (
|
||||
<p className="text-center text-[11px] text-text-muted" data-testid="drop-hint">
|
||||
{t(
|
||||
dragActive
|
||||
? 'onboarding:repoAnalyzer.upload.dropActive'
|
||||
: 'onboarding:repoAnalyzer.upload.dropHint',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{readingCount !== null && (
|
||||
<div role="status" aria-busy="true" data-testid="drop-reading" className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
|
||||
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('onboarding:repoAnalyzer.upload.reading', { fileCount: readingCount })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{uploading && (
|
||||
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
|
||||
|
|
@ -740,10 +901,16 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
|
|||
)}
|
||||
{uploadSummary && !uploading && phase !== 'error' && (
|
||||
<p className="text-xs text-text-muted" data-testid="upload-summary">
|
||||
{t('onboarding:repoAnalyzer.upload.selected', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
})}
|
||||
{uploadSummary.skippedDirs === undefined
|
||||
? t('onboarding:repoAnalyzer.upload.selected', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
})
|
||||
: t('onboarding:repoAnalyzer.upload.selectedDrop', {
|
||||
fileCount: uploadSummary.count,
|
||||
dropped: uploadSummary.dropped,
|
||||
skippedDirs: uploadSummary.skippedDirs,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
283
gitnexus-web/src/lib/folder-drop.test.ts
Normal file
283
gitnexus-web/src/lib/folder-drop.test.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
collectDropEntries,
|
||||
readDroppedFolder,
|
||||
DropRejection,
|
||||
MAX_DROP_FILES,
|
||||
MAX_PATH_SEGMENTS,
|
||||
} from './folder-drop';
|
||||
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
|
||||
|
||||
// jsdom has no File and Directory Entries API, so the fixtures are plain
|
||||
// objects with the members the walk uses: isFile/isDirectory/name,
|
||||
// createReader().readEntries(ok, err) and file(ok, err).
|
||||
|
||||
function fileEntry(name: string, size = 1): FileSystemFileEntry {
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
|
||||
} as unknown as FileSystemFileEntry;
|
||||
}
|
||||
|
||||
type DirFixture = FileSystemDirectoryEntry & { reads: number; readerCreated: number };
|
||||
|
||||
/** Directory whose reader hands `children` out in `batchSize` chunks, then `[]`. */
|
||||
function dirEntry(name: string, children: FileSystemEntry[], batchSize = 100): DirFixture {
|
||||
const dir = {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
reads: 0,
|
||||
readerCreated: 0,
|
||||
createReader: () => {
|
||||
dir.readerCreated++;
|
||||
let i = 0;
|
||||
return {
|
||||
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
|
||||
dir.reads++;
|
||||
const batch = children.slice(i, i + batchSize);
|
||||
i += batch.length;
|
||||
ok(batch);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return dir as unknown as DirFixture;
|
||||
}
|
||||
|
||||
/** Directory whose reader fails, like a subtree the browser may not read. */
|
||||
function unreadableDir(name: string): FileSystemDirectoryEntry {
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
createReader: () => ({
|
||||
readEntries: (_ok: unknown, err: (e: Error) => void) => err(new Error('EACCES')),
|
||||
}),
|
||||
} as unknown as FileSystemDirectoryEntry;
|
||||
}
|
||||
|
||||
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true): DataTransfer {
|
||||
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
|
||||
return { items, types: ['Files'], files: [] } as unknown as DataTransfer;
|
||||
}
|
||||
|
||||
const paths = (files: File[]) => files.map((f) => f.webkitRelativePath);
|
||||
|
||||
describe('collectDropEntries', () => {
|
||||
it('returns the entries synchronously and skips null (non-file) items', () => {
|
||||
const dir = dirEntry('repo', []);
|
||||
const entries = collectDropEntries(dataTransfer([dir, null]));
|
||||
expect(entries).toEqual([dir]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty transfer', () => {
|
||||
expect(collectDropEntries({ items: [] } as unknown as DataTransfer)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a browser without webkitGetAsEntry', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
collectDropEntries(dataTransfer([dirEntry('repo', [])], false));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(DropRejection);
|
||||
expect((caught as DropRejection).reason).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readDroppedFolder', () => {
|
||||
it('builds <folder>/<rest> paths with forward slashes from entry names', async () => {
|
||||
const root = dirEntry('repo', [
|
||||
fileEntry('README.md'),
|
||||
dirEntry('src', [fileEntry('a.ts'), dirEntry('deep', [fileEntry('b.ts')])]),
|
||||
]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(paths(files)).toEqual(['repo/README.md', 'repo/src/a.ts', 'repo/src/deep/b.ts']);
|
||||
expect(files.every((f) => f instanceof File)).toBe(true);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('loops readEntries until an empty batch', async () => {
|
||||
const children = Array.from({ length: 250 }, (_, i) => fileEntry(`f${i}.ts`));
|
||||
const root = dirEntry('repo', children, 100);
|
||||
const { files } = await readDroppedFolder([root]);
|
||||
expect(files).toHaveLength(250);
|
||||
// two full 100-entry batches, one 50-entry batch, then the empty read
|
||||
expect(root.reads).toBe(4);
|
||||
});
|
||||
|
||||
it('prunes excluded directories before reading them and counts them', async () => {
|
||||
const nodeModules = dirEntry('node_modules', [fileEntry('x.js')]);
|
||||
const git = dirEntry('.git', [fileEntry('HEAD')]);
|
||||
const root = dirEntry('repo', [nodeModules, git, dirEntry('src', [fileEntry('a.ts')])]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(paths(files)).toEqual(['repo/src/a.ts']);
|
||||
expect(skipped).toBe(2);
|
||||
expect(nodeModules.readerCreated).toBe(0);
|
||||
expect(git.readerCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a loose file', async () => {
|
||||
await expect(readDroppedFolder([fileEntry('a.ts')])).rejects.toMatchObject({
|
||||
name: 'DropRejection',
|
||||
reason: 'notSingleFolder',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects several roots', async () => {
|
||||
await expect(
|
||||
readDroppedFolder([dirEntry('a', [fileEntry('x')]), dirEntry('b', [fileEntry('y')])]),
|
||||
).rejects.toMatchObject({ reason: 'notSingleFolder' });
|
||||
});
|
||||
|
||||
it('rejects an empty drop', async () => {
|
||||
await expect(readDroppedFolder([])).rejects.toMatchObject({ reason: 'notSingleFolder' });
|
||||
});
|
||||
|
||||
it('stops past MAX_DROP_FILES', async () => {
|
||||
const children = Array.from({ length: MAX_DROP_FILES + 1 }, (_, i) => fileEntry(`f${i}`));
|
||||
await expect(readDroppedFolder([dirEntry('repo', children)])).rejects.toMatchObject({
|
||||
reason: 'tooManyFiles',
|
||||
max: MAX_DROP_FILES,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not count oversized files toward MAX_DROP_FILES', async () => {
|
||||
const oversized = fileEntry('big.bin', MAX_FILE_BYTES + 1);
|
||||
const keepers = Array.from({ length: MAX_DROP_FILES }, (_, i) => fileEntry(`f${i}`));
|
||||
const { files, oversized: oversizedCount } = await readDroppedFolder([
|
||||
dirEntry('repo', [oversized, ...keepers]),
|
||||
]);
|
||||
expect(files).toHaveLength(MAX_DROP_FILES);
|
||||
expect(paths(files)).not.toContain('repo/big.bin');
|
||||
expect(oversizedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('skips oversized files before they reach filterRepoFiles', async () => {
|
||||
const { files, skipped, oversized } = await readDroppedFolder([
|
||||
dirEntry('repo', [fileEntry('a.ts'), fileEntry('big.bin', MAX_FILE_BYTES + 1)]),
|
||||
]);
|
||||
expect(paths(files)).toEqual(['repo/a.ts']);
|
||||
expect(skipped).toBe(0);
|
||||
expect(oversized).toBe(1);
|
||||
});
|
||||
|
||||
it('aborts mid-walk and does not read later siblings', async () => {
|
||||
const controller = new AbortController();
|
||||
const later = dirEntry('later', [fileEntry('z.ts')]);
|
||||
const root = dirEntry('repo', [
|
||||
dirEntry(
|
||||
'first',
|
||||
Array.from({ length: 60 }, (_, i) => fileEntry(`f${i}`)),
|
||||
),
|
||||
later,
|
||||
]);
|
||||
const promise = readDroppedFolder([root], {
|
||||
signal: controller.signal,
|
||||
// progress is reported every 50 files; abort on the first report
|
||||
onProgress: () => controller.abort(),
|
||||
});
|
||||
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(later.readerCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects when the abort lands during the last file read', async () => {
|
||||
const controller = new AbortController();
|
||||
const last = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name: 'last.ts',
|
||||
fullPath: '/browser-root/last.ts',
|
||||
file: (ok: (f: File) => void) => {
|
||||
controller.abort();
|
||||
ok(new File(['x'], 'last.ts'));
|
||||
},
|
||||
} as unknown as FileSystemFileEntry;
|
||||
const onProgress = vi.fn();
|
||||
await expect(
|
||||
readDroppedFolder([dirEntry('repo', [fileEntry('a.ts'), last])], {
|
||||
signal: controller.signal,
|
||||
onProgress,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(onProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an unreadable subtree and keeps the rest', async () => {
|
||||
const root = dirEntry('repo', [unreadableDir('locked'), dirEntry('src', [fileEntry('a.ts')])]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(paths(files)).toEqual(['repo/src/a.ts']);
|
||||
expect(skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips a file whose file() call fails and keeps the rest', async () => {
|
||||
const broken = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name: 'dangling-symlink',
|
||||
fullPath: '/browser-root/dangling-symlink',
|
||||
file: (_ok: unknown, err: (e: Error) => void) =>
|
||||
err(new DOMException('not found', 'NotFoundError')),
|
||||
} as unknown as FileSystemFileEntry;
|
||||
const root = dirEntry('repo', [fileEntry('a.ts'), broken, fileEntry('b.ts')]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(paths(files)).toEqual(['repo/a.ts', 'repo/b.ts']);
|
||||
expect(skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('answers a folder named like build output without walking it', async () => {
|
||||
const root = dirEntry('dist', [fileEntry('bundle.js')]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(files).toEqual([]);
|
||||
expect(skipped).toBe(1);
|
||||
expect(root.readerCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('skips files that would exceed the server path depth', async () => {
|
||||
// A file directly inside a directory at depth d has d + 1 segments.
|
||||
const build = (depth: number, leaf: string): FileSystemEntry => {
|
||||
let entry: FileSystemEntry = fileEntry(leaf);
|
||||
for (let i = depth; i >= 2; i--) entry = dirEntry(`d${i}`, [entry]);
|
||||
return entry;
|
||||
};
|
||||
// root (1) + 62 dirs = 63 segments, leaf makes 64: allowed
|
||||
const okChain = build(63, 'ok.ts');
|
||||
// root (1) + 63 dirs = 64 segments, leaf would make 65: skipped
|
||||
const tooDeep = build(64, 'deep.ts');
|
||||
const root = dirEntry('repo', [okChain, tooDeep]);
|
||||
const { files, skipped } = await readDroppedFolder([root]);
|
||||
expect(skipped).toBe(1);
|
||||
const rels = paths(files);
|
||||
expect(rels).toHaveLength(1);
|
||||
const [only] = rels;
|
||||
expect(only?.endsWith('/ok.ts')).toBe(true);
|
||||
expect(only?.split('/')).toHaveLength(MAX_PATH_SEGMENTS);
|
||||
});
|
||||
|
||||
it('reports progress every 50 files and once at the end', async () => {
|
||||
const onProgress = vi.fn();
|
||||
const children = Array.from({ length: 120 }, (_, i) => fileEntry(`f${i}`));
|
||||
await readDroppedFolder([dirEntry('repo', children)], { onProgress });
|
||||
expect(onProgress.mock.calls.map((c) => c[0])).toEqual([50, 100, 120]);
|
||||
});
|
||||
|
||||
it('produces files that filterRepoFiles turns into an aligned manifest', async () => {
|
||||
const root = dirEntry('repo', [
|
||||
fileEntry('a.ts'),
|
||||
fileEntry('big.bin', MAX_FILE_BYTES + 1),
|
||||
dirEntry('src', [fileEntry('b.ts')]),
|
||||
]);
|
||||
const { files } = await readDroppedFolder([root]);
|
||||
const result = filterRepoFiles(files);
|
||||
expect(result.manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
|
||||
expect(result.droppedCount).toBe(0);
|
||||
result.files.forEach((f, i) => expect(f.webkitRelativePath).toBe(result.manifest[i]));
|
||||
});
|
||||
});
|
||||
214
gitnexus-web/src/lib/folder-drop.ts
Normal file
214
gitnexus-web/src/lib/folder-drop.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Read a folder that was dragged and dropped onto the analyzer.
|
||||
*
|
||||
* The `webkitdirectory` picker hands us a `FileList` whose entries carry
|
||||
* `webkitRelativePath` (`<folder>/<rest>`). A drop hands us
|
||||
* `DataTransferItem`s instead, and the `File`s behind them report an empty
|
||||
* `webkitRelativePath`, so the tree has to be walked with the File and
|
||||
* Directory Entries API (`webkitGetAsEntry`). This module does that walk and
|
||||
* returns real `File` objects shaped exactly like the picker's, so the result
|
||||
* goes through the unchanged `filterRepoFiles -> uploadFolder` path and the
|
||||
* server sees the same manifest either way.
|
||||
*
|
||||
* Two platform constraints shape the API:
|
||||
* - `dataTransfer.items` is readable only synchronously inside the drop
|
||||
* handler, so `collectDropEntries` is sync and must run before the first
|
||||
* `await`; `readDroppedFolder` does the async walk afterwards.
|
||||
* - `FileSystemDirectoryReader.readEntries` returns batches (Chromium caps a
|
||||
* batch at 100 entries) and signals the end with an empty array, so it is
|
||||
* called in a loop.
|
||||
*
|
||||
* Directories on the shared exclusion list are pruned before they are read:
|
||||
* `node_modules` is never enumerated, which is the point of dropping over
|
||||
* picking on a large checkout (the picker enumerates everything first and
|
||||
* only then filters).
|
||||
*/
|
||||
|
||||
import { EXCLUDED_DIRS, MAX_FILE_BYTES } from './upload-filter';
|
||||
|
||||
/** Matches the server's `maxFiles` (DEFAULT_INGEST_LIMITS in upload-ingest.ts). */
|
||||
export const MAX_DROP_FILES = 20000;
|
||||
|
||||
/**
|
||||
* Matches the server's MAX_PATH_DEPTH: a manifest entry may have at most 64
|
||||
* `/`-separated segments including the folder name and the file name.
|
||||
*/
|
||||
export const MAX_PATH_SEGMENTS = 64;
|
||||
|
||||
/** How many files are collected between two `onProgress` calls. */
|
||||
const PROGRESS_EVERY = 50;
|
||||
|
||||
export type DropRejectionReason = 'unsupported' | 'notSingleFolder' | 'tooManyFiles';
|
||||
|
||||
/** Thrown when a drop cannot be turned into a single-folder upload. */
|
||||
export class DropRejection extends Error {
|
||||
constructor(
|
||||
readonly reason: DropRejectionReason,
|
||||
readonly max?: number,
|
||||
) {
|
||||
super(`Folder drop rejected: ${reason}`);
|
||||
this.name = 'DropRejection';
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReadDroppedFolderOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Called with the running number of files collected so far. */
|
||||
onProgress?: (filesFound: number) => void;
|
||||
}
|
||||
|
||||
export interface DroppedFolder {
|
||||
/** Every file under the dropped folder, `webkitRelativePath` set to `<folder>/<rest>`. */
|
||||
files: File[];
|
||||
/**
|
||||
* Directories left out of `files`: pruned because their name is on the
|
||||
* exclusion list, deeper than the server accepts, or unreadable. Their
|
||||
* contents were never enumerated, so this is a directory count, not a file
|
||||
* count. Unreadable single files are counted here as well.
|
||||
*/
|
||||
skipped: number;
|
||||
/**
|
||||
* Readable files omitted because they exceed MAX_FILE_BYTES. They do not
|
||||
* count toward MAX_DROP_FILES; the drop summary adds this to droppedCount
|
||||
* so it matches the picker (filterRepoFiles) skip count.
|
||||
*/
|
||||
oversized: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the browser can hand out directory entries for a drop. A missing
|
||||
* `DataTransferItem` global (test environments) counts as supported; only an
|
||||
* engine that has the class without `webkitGetAsEntry` cannot read folders.
|
||||
*/
|
||||
export function isFolderDropSupported(): boolean {
|
||||
return (
|
||||
typeof DataTransferItem === 'undefined' || 'webkitGetAsEntry' in DataTransferItem.prototype
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the `FileSystemEntry` of every dropped item. Synchronous on purpose:
|
||||
* call it first thing in the drop handler, before any `await`. Throws
|
||||
* `DropRejection('unsupported')` when the browser has no `webkitGetAsEntry`;
|
||||
* the picker button keeps working there.
|
||||
*/
|
||||
export function collectDropEntries(dataTransfer: DataTransfer): FileSystemEntry[] {
|
||||
const items = dataTransfer.items;
|
||||
if (!items || items.length === 0) return [];
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (typeof item.webkitGetAsEntry !== 'function') throw new DropRejection('unsupported');
|
||||
const entry = item.webkitGetAsEntry();
|
||||
// Non-file items (dragged text, URLs) yield null and are simply ignored.
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk exactly one dropped directory into `File`s. Rejects with
|
||||
* `DropRejection('notSingleFolder')` for loose files, no folder, or several
|
||||
* roots (the server accepts one top-level folder per upload), with
|
||||
* `DropRejection('tooManyFiles')` past MAX_DROP_FILES, and with the signal's
|
||||
* reason (an `AbortError`) when aborted mid-walk.
|
||||
*/
|
||||
export async function readDroppedFolder(
|
||||
entries: FileSystemEntry[],
|
||||
{ signal, onProgress }: ReadDroppedFolderOptions = {},
|
||||
): Promise<DroppedFolder> {
|
||||
if (entries.length !== 1 || !entries[0].isDirectory) {
|
||||
throw new DropRejection('notSingleFolder');
|
||||
}
|
||||
const root = entries[0] as FileSystemDirectoryEntry;
|
||||
const files: File[] = [];
|
||||
let skipped = 0;
|
||||
let oversized = 0;
|
||||
|
||||
// A folder that is itself named like build output (`dist`, `out`, ...) would
|
||||
// lose every file to filterRepoFiles anyway (the root is a path segment
|
||||
// too); answer without walking it.
|
||||
if (EXCLUDED_DIRS.has(root.name)) return { files, skipped: 1, oversized: 0 };
|
||||
|
||||
const throwIfAborted = () => {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
const report = (count: number) => {
|
||||
if (!signal?.aborted) onProgress?.(count);
|
||||
};
|
||||
|
||||
// Paths are joined from entry names, never taken from `entry.fullPath`: the
|
||||
// result is `<root>/<rest>` with forward slashes in every browser and never
|
||||
// carries a leading slash, which the server rejects.
|
||||
const walk = async (dir: FileSystemDirectoryEntry, prefix: string, segments: number) => {
|
||||
// Files inside this directory would have `segments + 1` path segments.
|
||||
if (segments + 1 > MAX_PATH_SEGMENTS) {
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
const reader = dir.createReader();
|
||||
for (;;) {
|
||||
throwIfAborted();
|
||||
let batch: FileSystemEntry[];
|
||||
try {
|
||||
batch = await readBatch(reader);
|
||||
} catch {
|
||||
// An unreadable subtree (permissions) is skipped, like the picker
|
||||
// silently omits files it cannot read; the rest of the drop proceeds.
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
if (batch.length === 0) return;
|
||||
for (const child of batch) {
|
||||
throwIfAborted();
|
||||
const rel = `${prefix}/${child.name}`;
|
||||
if (child.isDirectory) {
|
||||
if (EXCLUDED_DIRS.has(child.name)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await walk(child as FileSystemDirectoryEntry, rel, segments + 1);
|
||||
} else if (child.isFile) {
|
||||
let file: File;
|
||||
try {
|
||||
file = await getFile(child as FileSystemFileEntry);
|
||||
} catch {
|
||||
// Deleted or renamed mid-walk, a dangling symlink, a locked file:
|
||||
// skip it the same way an unreadable directory is skipped.
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
throwIfAborted();
|
||||
// Do not push oversized files: they used to count toward
|
||||
// MAX_DROP_FILES, so a tree the picker accepts (20k keepers +
|
||||
// oversized siblings) was rejected as tooManyFiles. Count them so
|
||||
// the drop summary's droppedCount still matches the picker.
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
oversized++;
|
||||
continue;
|
||||
}
|
||||
// A dropped File reports '' here while the picker reports
|
||||
// `<folder>/<rest>`. An own property shadows the prototype getter, so
|
||||
// filterRepoFiles (and therefore the server) sees one shape.
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: rel, configurable: true });
|
||||
files.push(file);
|
||||
if (files.length > MAX_DROP_FILES) {
|
||||
throw new DropRejection('tooManyFiles', MAX_DROP_FILES);
|
||||
}
|
||||
if (files.length % PROGRESS_EVERY === 0) report(files.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(root, root.name, 1);
|
||||
throwIfAborted();
|
||||
report(files.length);
|
||||
return { files, skipped, oversized };
|
||||
}
|
||||
|
||||
const readBatch = (reader: FileSystemDirectoryReader) =>
|
||||
new Promise<FileSystemEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
|
||||
|
||||
const getFile = (entry: FileSystemFileEntry) =>
|
||||
new Promise<File>((resolve, reject) => entry.file(resolve, reject));
|
||||
|
|
@ -72,7 +72,14 @@
|
|||
"button": "Upload a folder",
|
||||
"uploading": "Uploading…",
|
||||
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
|
||||
"empty": "No analyzable files found in that folder."
|
||||
"selectedDrop": "{{fileCount, number}} files ready ({{skippedDirs, number}} folders and {{dropped, number}} files skipped: .git, node_modules, build output, files over 25 MB)",
|
||||
"empty": "No analyzable files found in that folder.",
|
||||
"dropHint": "or drop a folder here",
|
||||
"dropActive": "Release to upload this folder",
|
||||
"reading": "Reading folder… {{fileCount, number}} files found",
|
||||
"dropSingleFolder": "Drop a single folder, not files or several folders.",
|
||||
"dropTooManyFiles": "That folder holds more than {{max, number}} files. Drop a subfolder instead.",
|
||||
"dropUnsupported": "This browser cannot read a dropped folder. Use the “Upload a folder” button instead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,14 @@
|
|||
"button": "上传文件夹",
|
||||
"uploading": "上传中…",
|
||||
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
|
||||
"empty": "该文件夹中未找到可分析的文件。"
|
||||
"selectedDrop": "已准备 {{fileCount, number}} 个文件(已跳过 {{skippedDirs, number}} 个文件夹和 {{dropped, number}} 个文件:.git、node_modules、构建产物、超过 25 MB 的文件)",
|
||||
"empty": "该文件夹中未找到可分析的文件。",
|
||||
"dropHint": "或将文件夹拖放到此处",
|
||||
"dropActive": "松开以上传此文件夹",
|
||||
"reading": "正在读取文件夹… 已找到 {{fileCount, number}} 个文件",
|
||||
"dropSingleFolder": "请拖放单个文件夹,而不是文件或多个文件夹。",
|
||||
"dropTooManyFiles": "该文件夹的文件超过 {{max, number}} 个,请改为拖放子文件夹。",
|
||||
"dropUnsupported": "此浏览器无法读取拖放的文件夹,请改用“上传文件夹”按钮。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
gitnexus-web/test/unit/repo-analyzer-folder-drop.test.tsx
Normal file
334
gitnexus-web/test/unit/repo-analyzer-folder-drop.test.tsx
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
/**
|
||||
* Folder drag-and-drop on RepoAnalyzer's Local Folder tab.
|
||||
*
|
||||
* A dropped folder is walked client-side (src/lib/folder-drop.ts) and then
|
||||
* takes the exact path the "Upload a folder" picker takes: filterRepoFiles ->
|
||||
* uploadFolder -> streamAnalyzeProgress. These tests drive the drop with
|
||||
* fixture FileSystemEntry objects (jsdom has no File and Directory Entries
|
||||
* API) and assert the upload the server would receive.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import {
|
||||
cancelAnalyze,
|
||||
streamAnalyzeProgress,
|
||||
uploadFolder,
|
||||
} from '../../src/services/backend-client';
|
||||
|
||||
vi.mock('../../src/services/backend-client', () => ({
|
||||
BackendError: class BackendError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly retryAfterMs?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
}
|
||||
},
|
||||
startAnalyze: vi.fn(),
|
||||
cancelAnalyze: vi.fn(),
|
||||
streamAnalyzeProgress: vi.fn(),
|
||||
uploadFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
const JOB = { jobId: 'job-1', status: 'queued' };
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function fileEntry(name: string, size = 1): FileSystemFileEntry {
|
||||
return {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
file: (ok: (f: File) => void) => ok(new File([new Uint8Array(size)], name)),
|
||||
} as unknown as FileSystemFileEntry;
|
||||
}
|
||||
|
||||
/** A file whose `file()` callback is released manually, to hold the walk open. */
|
||||
function deferredFileEntry(name: string) {
|
||||
let release: (() => void) | undefined;
|
||||
const entry = {
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
file: (ok: (f: File) => void) => {
|
||||
release = () => ok(new File(['x'], name));
|
||||
},
|
||||
} as unknown as FileSystemFileEntry;
|
||||
return { entry, release: () => release?.() };
|
||||
}
|
||||
|
||||
function dirEntry(name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry {
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
fullPath: `/browser-root/${name}`,
|
||||
createReader: () => {
|
||||
let done = false;
|
||||
return {
|
||||
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
|
||||
if (done) return ok([]);
|
||||
done = true;
|
||||
ok(children);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as FileSystemDirectoryEntry;
|
||||
}
|
||||
|
||||
function dataTransfer(entries: (FileSystemEntry | null)[], supported = true) {
|
||||
const items = entries.map((e) => (supported ? { webkitGetAsEntry: () => e } : {}));
|
||||
return { items, types: ['Files'], files: [], dropEffect: 'none' };
|
||||
}
|
||||
|
||||
const filesDrag = { dataTransfer: { types: ['Files'], items: [], dropEffect: 'none' } };
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
|
||||
vi.mocked(uploadFolder).mockResolvedValue(JOB);
|
||||
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
|
||||
});
|
||||
|
||||
function renderLocalTab() {
|
||||
const onComplete = vi.fn();
|
||||
render(<RepoAnalyzer variant="onboarding" onComplete={onComplete} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
return { zone: screen.getByTestId('folder-drop-zone'), onComplete };
|
||||
}
|
||||
|
||||
const REPO = () =>
|
||||
dirEntry('repo', [
|
||||
fileEntry('a.ts'),
|
||||
dirEntry('src', [fileEntry('b.ts')]),
|
||||
dirEntry('node_modules', [fileEntry('dep.js')]),
|
||||
dirEntry('.git', [fileEntry('HEAD')]),
|
||||
]);
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('folder drop on the Local Folder tab', () => {
|
||||
it('uploads a dropped folder through the existing upload path', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
const [files = [], manifest = [], signal] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
|
||||
expect(manifest).toEqual(['repo/a.ts', 'repo/src/b.ts']);
|
||||
expect(files.map((f) => f.webkitRelativePath)).toEqual(manifest);
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(streamAnalyzeProgress).toHaveBeenCalledWith(
|
||||
'job-1',
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the reading state while the folder is walked', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
const held = deferredFileEntry('slow.ts');
|
||||
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-folder')).toBeDisabled();
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
held.release();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('drop-reading')).toBeNull();
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refuses a loose file', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([fileEntry('a.ts')]) });
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText('Drop a single folder, not files or several folders.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refuses several folders at once', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.drop(zone, {
|
||||
dataTransfer: dataTransfer([
|
||||
dirEntry('a', [fileEntry('x')]),
|
||||
dirEntry('b', [fileEntry('y')]),
|
||||
]),
|
||||
});
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText('Drop a single folder, not files or several folders.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('explains when the browser cannot read dropped folders', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()], false) });
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This browser cannot read a dropped folder. Use the “Upload a folder” button instead.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-folder')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows the existing empty-folder message for a folder without analyzable files', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.drop(zone, {
|
||||
dataTransfer: dataTransfer([dirEntry('repo', [dirEntry('node_modules', [fileEntry('x')])])]),
|
||||
});
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('No analyzable files found in that folder.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('aborts the walk on a mode switch and never uploads', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
const held = deferredFileEntry('slow.ts');
|
||||
const repo = dirEntry('repo', [fileEntry('a.ts'), held.entry]);
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([repo]) });
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
|
||||
await act(async () => {
|
||||
held.release();
|
||||
});
|
||||
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId('drop-reading')).toBeNull();
|
||||
expect(screen.queryByText(/single folder|cannot read/)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the highlight while the cursor crosses the button, clears it on leave', () => {
|
||||
const { zone } = renderLocalTab();
|
||||
const button = screen.getByTestId('upload-folder');
|
||||
|
||||
fireEvent.dragEnter(zone, filesDrag);
|
||||
expect(zone).toHaveAttribute('data-drag-active', 'true');
|
||||
expect(screen.getByTestId('drop-hint')).toHaveTextContent('Release to upload this folder');
|
||||
|
||||
fireEvent.dragEnter(button, filesDrag);
|
||||
fireEvent.dragLeave(button, filesDrag);
|
||||
expect(zone).toHaveAttribute('data-drag-active', 'true');
|
||||
|
||||
fireEvent.dragLeave(zone, filesDrag);
|
||||
expect(zone).not.toHaveAttribute('data-drag-active');
|
||||
expect(screen.getByTestId('drop-hint')).toHaveTextContent('or drop a folder here');
|
||||
});
|
||||
|
||||
it('catches a drop that lands on the path input inside the panel', async () => {
|
||||
renderLocalTab();
|
||||
|
||||
fireEvent.drop(screen.getByPlaceholderText(/project/), {
|
||||
dataTransfer: dataTransfer([REPO()]),
|
||||
});
|
||||
await act(async () => {});
|
||||
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables Analyze while a dropped folder is being read', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
fireEvent.change(screen.getByPlaceholderText(/project/), { target: { value: '/srv/repo' } });
|
||||
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeEnabled();
|
||||
|
||||
const held = deferredFileEntry('slow.ts');
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo', [held.entry])]) });
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole('button', { name: /Analyze Repository/ })).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
held.release();
|
||||
});
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores drags that carry no files', () => {
|
||||
const { zone } = renderLocalTab();
|
||||
|
||||
fireEvent.dragEnter(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
|
||||
expect(zone).not.toHaveAttribute('data-drag-active');
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: { types: ['text/plain'], items: [] } });
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let an aborted drop clear a later drop's reading state", async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
const heldA = deferredFileEntry('slow-a.ts');
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([dirEntry('repo-a', [heldA.entry])]) });
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /GitHub/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
|
||||
const zoneB = screen.getByTestId('folder-drop-zone');
|
||||
const heldB = deferredFileEntry('slow-b.ts');
|
||||
fireEvent.drop(zoneB, { dataTransfer: dataTransfer([dirEntry('repo-b', [heldB.entry])]) });
|
||||
await act(async () => {});
|
||||
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
heldA.release();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('drop-reading')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('upload-folder')).toBeDisabled();
|
||||
expect(uploadFolder).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
heldB.release();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('drop-reading')).toBeNull();
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
const [, manifest] = vi.mocked(uploadFolder).mock.calls[0] ?? [];
|
||||
expect(manifest).toEqual(['repo-b/slow-b.ts']);
|
||||
});
|
||||
|
||||
it('ignores a drop while an upload is already in flight', async () => {
|
||||
const { zone } = renderLocalTab();
|
||||
vi.mocked(uploadFolder).mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
|
||||
await act(async () => {});
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.drop(zone, { dataTransfer: dataTransfer([REPO()]) });
|
||||
await act(async () => {});
|
||||
expect(uploadFolder).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -187,8 +187,8 @@ Your AI agent gets **17 tools** (15 per-repo + 2 group) automatically:
|
|||
| Tool | What It Does |
|
||||
| ---------------- | ---------------------------------------------------------------------- |
|
||||
| `list_repos` | Discover all indexed repositories (paginated — `limit`/`offset`) |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF) |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation |
|
||||
| `query` | Process-grouped hybrid search (BM25 + semantic + RRF); optional `chain_depth` expands each result's call chain |
|
||||
| `context` | 360-degree symbol view — categorized refs, process participation, HTTP routes, `is_entry_point` flag; optional `chain_depth` call-chain expansion |
|
||||
| `impact` | Blast radius analysis with depth grouping and confidence |
|
||||
| `trace` | Shortest directed path between two symbols (call + class-member edges) |
|
||||
| `detect_changes` | Git-diff impact — maps changed lines to affected processes |
|
||||
|
|
|
|||
|
|
@ -43,30 +43,56 @@ export function runFingerprintParityCheck(report, leftArm, rightArm) {
|
|||
);
|
||||
}
|
||||
|
||||
export function runBaselineCheck(report, baselinePath) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
||||
function isFiniteNonNegativeNumber(value) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
}
|
||||
|
||||
function missingBudgetError(field) {
|
||||
return `no numeric ${field} in baselines.json — a missing budget is a DELETED GATE, not a passing arm`;
|
||||
}
|
||||
|
||||
/** Pure --check errors. Missing/non-numeric budgets must fail closed. */
|
||||
export function collectBaselineErrors(report, baseline) {
|
||||
const errors = [];
|
||||
if (report.fingerprint !== baseline.fingerprint) {
|
||||
errors.push(`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint}`);
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
if (!isFiniteNonNegativeNumber(baseline.scaling_budget)) {
|
||||
errors.push(missingBudgetError('scaling_budget'));
|
||||
} else if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
errors.push(`scaling_ratio ${report.scaling_ratio} > ${baseline.scaling_budget}`);
|
||||
}
|
||||
if (
|
||||
baseline.absolute_ms_budget !== undefined &&
|
||||
report.absolute_ms > baseline.absolute_ms_budget
|
||||
) {
|
||||
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
|
||||
if (Object.hasOwn(report, 'absolute_ms')) {
|
||||
if (!isFiniteNonNegativeNumber(baseline.absolute_ms_budget)) {
|
||||
errors.push(missingBudgetError('absolute_ms_budget'));
|
||||
} else if (report.absolute_ms > baseline.absolute_ms_budget) {
|
||||
errors.push(`absolute_ms ${report.absolute_ms} > ${baseline.absolute_ms_budget}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
baseline.widening_overhead_budget !== undefined &&
|
||||
report.widening_overhead > baseline.widening_overhead_budget
|
||||
) {
|
||||
errors.push(
|
||||
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
|
||||
);
|
||||
if (Object.hasOwn(report, 'widening_overhead')) {
|
||||
if (!isFiniteNonNegativeNumber(baseline.widening_overhead_budget)) {
|
||||
errors.push(missingBudgetError('widening_overhead_budget'));
|
||||
} else if (report.widening_overhead > baseline.widening_overhead_budget) {
|
||||
errors.push(
|
||||
`widening_overhead ${report.widening_overhead} > ${baseline.widening_overhead_budget}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
failIfNeeded(report, errors);
|
||||
if (Object.hasOwn(report, 'chain_scaling_ratio')) {
|
||||
if (!isFiniteNonNegativeNumber(baseline.chain_scaling_budget)) {
|
||||
errors.push(missingBudgetError('chain_scaling_budget'));
|
||||
} else if (report.chain_scaling_ratio > baseline.chain_scaling_budget) {
|
||||
errors.push(
|
||||
`chain_scaling_ratio ${report.chain_scaling_ratio} > ${baseline.chain_scaling_budget}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function runBaselineCheck(report, baselinePath) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
|
||||
failIfNeeded(report, collectBaselineErrors(report, baseline));
|
||||
console.log(JSON.stringify({ ok: true, report }, null, 2));
|
||||
}
|
||||
|
||||
|
|
|
|||
12
gitnexus/bench/trpc-route-extractor/baselines.json
Normal file
12
gitnexus/bench/trpc-route-extractor/baselines.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/trpc-route-extractor/measure.mjs --check (#3339). fingerprint is sha256 over 800 identifier-mounted route identities (method, path, handler name, line) from one appRouter composing 800 same-file subrouters. inline is the shape-ballasted control; chain is the C# concentrated-namespace analogue (depth-N identifier mounts).",
|
||||
"fingerprint": "72f7286ba5b8cf37c68f0707ee96ae91025e3f102a09a544d02ec015ca19f5fa",
|
||||
"scaling_budget": 1.8,
|
||||
"_scaling_note": "(t_large/t_small)/(800/250) on the sibling-mount arm. Measured 1.55–1.62 after memoizing mount paths (char-scan + GC band); a per-procedure remount walk is quadratic (~3+).",
|
||||
"chain_scaling_budget": 2.8,
|
||||
"_chain_scaling_note": "Same ratio on the depth-N chain. Output path text is Θ(n²), so the procedure-count ratio sits near 1.7–2.0 with a memoized table (CI band); the pre-memo walk measured 11.7 (O(n³) ancestor-array copies).",
|
||||
"widening_overhead_budget": 3.5,
|
||||
"_widening_overhead_note": "mount_large_ms / inline_large_ms. Inline carries one ballast const per procedure so file length is comparable. Measured about 2.4; budget guards a compose-step blow-up without treating scan-of-more-tokens as a failure.",
|
||||
"absolute_ms_budget": 160,
|
||||
"_absolute_ms_note": "Sibling-mount compose for 800 routers. Measured about 41 ms; 4× ceiling matches the route-constant benches' CI headroom."
|
||||
}
|
||||
128
gitnexus/bench/trpc-route-extractor/measure.mjs
Normal file
128
gitnexus/bench/trpc-route-extractor/measure.mjs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Build-free throughput + identity bench for tRPC identifier-mounted subrouters.
|
||||
*
|
||||
* Arms:
|
||||
* - inline: `pN: publicProcedure.query(...)` inside one appRouter (control)
|
||||
* - mount: same-file `const rNRouter = t.router({ list }); appRouter = { rN: rNRouter }`
|
||||
* (the #3339 composition path)
|
||||
* - chain: depth-N identifier mounts with a procedure at every level (the
|
||||
* C# "concentrated namespace" analog — a remount walk is O(depth ×
|
||||
* procedures); a once-built path table stays linear)
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/trpc-route-extractor/measure.mjs
|
||||
* node --import tsx bench/trpc-route-extractor/measure.mjs --check
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { extractTrpcRoutes } from '../../src/core/ingestion/route-extractors/trpc.ts';
|
||||
import { minSample } from '../lib/identity-guard.mjs';
|
||||
import { fingerprintIds, runBaselineCheck, runCountCheck } from '../lib/route-constant-guard.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
const FILE = 'src/server/trpc/routers/app.ts';
|
||||
const SMALL = 250;
|
||||
const LARGE = 800;
|
||||
const REPS = 15;
|
||||
const WARMUP = 5;
|
||||
|
||||
function header() {
|
||||
return [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function generateInline(procedureCount) {
|
||||
// Shape-equivalent ballast so widening_overhead compares compose cost, not
|
||||
// file length: each mount arm procedure is a `const rNRouter = t.router`.
|
||||
const pads = Array.from({ length: procedureCount }, (_, i) => `const _pad${i} = ${i};`).join(
|
||||
'\n',
|
||||
);
|
||||
const keys = Array.from(
|
||||
{ length: procedureCount },
|
||||
(_, i) => ` p${i}: publicProcedure.query(() => null),`,
|
||||
).join('\n');
|
||||
return `${header()}${pads}\nexport const appRouter = t.router({\n${keys}\n});\n`;
|
||||
}
|
||||
|
||||
function generateMount(routerCount) {
|
||||
const routers = Array.from(
|
||||
{ length: routerCount },
|
||||
(_, i) => `const r${i}Router = t.router({\n list: publicProcedure.query(() => null),\n});\n`,
|
||||
).join('');
|
||||
const mounts = Array.from({ length: routerCount }, (_, i) => ` r${i}: r${i}Router,`).join('\n');
|
||||
return `${header()}${routers}export const appRouter = t.router({\n${mounts}\n});\n`;
|
||||
}
|
||||
|
||||
function generateChain(depth) {
|
||||
const parts = [header()];
|
||||
parts.push('const r0Router = t.router({\n list: publicProcedure.query(() => null),\n});\n');
|
||||
for (let i = 1; i < depth; i++) {
|
||||
parts.push(
|
||||
`const r${i}Router = t.router({\n n${i - 1}: r${i - 1}Router,\n list: publicProcedure.query(() => null),\n});\n`,
|
||||
);
|
||||
}
|
||||
parts.push(`export const appRouter = t.router({\n n${depth - 1}: r${depth - 1}Router,\n});\n`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
const GENERATORS = {
|
||||
inline: generateInline,
|
||||
mount: generateMount,
|
||||
chain: generateChain,
|
||||
};
|
||||
|
||||
function measure(mode, procedureCount) {
|
||||
const source = GENERATORS[mode](procedureCount);
|
||||
const { last, ms } = minSample(
|
||||
() => {
|
||||
const routes = extractTrpcRoutes(FILE, source);
|
||||
return routes.map((r) => `${r.httpMethod} ${r.routePath} ${r.methodName} ${r.lineNumber}`);
|
||||
},
|
||||
WARMUP,
|
||||
REPS,
|
||||
);
|
||||
return {
|
||||
procedures: procedureCount,
|
||||
ms,
|
||||
routes: last.length,
|
||||
fingerprint: fingerprintIds(last),
|
||||
};
|
||||
}
|
||||
|
||||
function scalingRatio(large, small) {
|
||||
return Number((large.ms / small.ms / (LARGE / SMALL)).toFixed(3));
|
||||
}
|
||||
|
||||
const report = {
|
||||
inline_small: measure('inline', SMALL),
|
||||
inline_large: measure('inline', LARGE),
|
||||
mount_small: measure('mount', SMALL),
|
||||
mount_large: measure('mount', LARGE),
|
||||
chain_small: measure('chain', SMALL),
|
||||
chain_large: measure('chain', LARGE),
|
||||
};
|
||||
report.scaling_ratio = scalingRatio(report.mount_large, report.mount_small);
|
||||
report.chain_scaling_ratio = scalingRatio(report.chain_large, report.chain_small);
|
||||
report.widening_overhead = Number(
|
||||
(report.mount_large.ms / Math.max(report.inline_large.ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.absolute_ms = report.mount_large.ms;
|
||||
report.fingerprint = report.mount_large.fingerprint;
|
||||
|
||||
runCountCheck(report, 'routes', {
|
||||
inline_large: LARGE,
|
||||
mount_large: LARGE,
|
||||
chain_large: LARGE,
|
||||
});
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runBaselineCheck(report, BASELINE_PATH);
|
||||
|
|
@ -9,13 +9,22 @@ import fs from 'fs/promises';
|
|||
import path from 'path';
|
||||
import { logger } from '../core/logger.js';
|
||||
import {
|
||||
findRegistryEntryByRepoPath,
|
||||
findRepo,
|
||||
unregisterRepo,
|
||||
listRegisteredRepos,
|
||||
getStoragePaths,
|
||||
removeBranchIndex,
|
||||
} from '../storage/repo-manager.js';
|
||||
import { requireDeletableStoragePath, StorageDeletionError } from '../storage/storage-resolver.js';
|
||||
import { formatStaleSlotLine } from './stale-branch-format.js';
|
||||
import { listLocalHeads } from '../storage/git.js';
|
||||
import {
|
||||
isContainedBranchDir,
|
||||
isDeleteCandidate,
|
||||
listStaleBranchSlots,
|
||||
removeBranchSlot,
|
||||
staleListingBlock,
|
||||
} from '../storage/stale-branch-slots.js';
|
||||
import {
|
||||
cleanParkedLbugSidecars,
|
||||
inspectLbugSidecars,
|
||||
|
|
@ -27,8 +36,107 @@ export const cleanCommand = async (options?: {
|
|||
force?: boolean;
|
||||
all?: boolean;
|
||||
lbugSidecars?: boolean;
|
||||
stale?: boolean;
|
||||
branch?: string;
|
||||
}) => {
|
||||
// --stale: reclaim leftover per-branch slots whose recorded branch is not
|
||||
// a live local head (#3331). Exclusive arm before --branch.
|
||||
if (options?.stale) {
|
||||
const cwd = process.cwd();
|
||||
const repo = await findRepo(cwd);
|
||||
if (!repo) {
|
||||
console.log(t('clean.notFoundHere'));
|
||||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
|
||||
let storagePath: string;
|
||||
try {
|
||||
storagePath = await requireDeletableStoragePath({
|
||||
path: repo.repoPath,
|
||||
storagePath: repo.storagePath,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof StorageDeletionError) {
|
||||
logger.error(`Refusing to clean leftover branch indexes: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const slots = await listStaleBranchSlots({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branches: entry?.branches,
|
||||
includeSize: !options.force,
|
||||
});
|
||||
const listingBlock = staleListingBlock(slots);
|
||||
if (listingBlock === 'heads-unavailable') {
|
||||
console.log(t('clean.stale.headsUnavailable'));
|
||||
for (const slot of slots.filter((row) => row.reason === 'heads-unavailable')) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (listingBlock === 'listing-failed') {
|
||||
console.log(t('clean.stale.listingFailed'));
|
||||
return;
|
||||
}
|
||||
const candidates = slots.filter(isDeleteCandidate);
|
||||
const probeFailed = slots.filter((slot) => slot.reason === 'probe-failed');
|
||||
const printProbeFailed = (): void => {
|
||||
console.log(t('clean.stale.probeFailed'));
|
||||
for (const slot of probeFailed) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
};
|
||||
if (candidates.length === 0) {
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
return;
|
||||
}
|
||||
console.log(t('clean.stale.none'));
|
||||
return;
|
||||
}
|
||||
if (!options.force) {
|
||||
console.log(t('clean.stale.preview', { count: candidates.length }));
|
||||
for (const slot of candidates) {
|
||||
console.log(` - ${formatStaleSlotLine(slot)}`);
|
||||
}
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
}
|
||||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
for (const slot of candidates) {
|
||||
const heads = listLocalHeads(repo.repoPath);
|
||||
if (heads === null) {
|
||||
console.log(t('clean.stale.headsUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (heads.includes(slot.branch)) {
|
||||
console.log(t('clean.stale.skippedLive', { branch: slot.branch }));
|
||||
continue;
|
||||
}
|
||||
const result = await removeBranchSlot({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branch: slot.branch,
|
||||
dir: slot.dir,
|
||||
});
|
||||
if (!result.ok) {
|
||||
console.log(t('clean.stale.failed', { branch: slot.branch }));
|
||||
logger.error({ err: result.error }, 'Failed to delete leftover branch index:');
|
||||
continue;
|
||||
}
|
||||
console.log(t('clean.stale.deleted', { branch: slot.branch }));
|
||||
}
|
||||
if (probeFailed.length > 0) {
|
||||
printProbeFailed();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
|
||||
// Resolve against the RECORDED branches[] summary (never by slugging the
|
||||
// user's raw input, which can disagree with the index-time-sanitized label).
|
||||
|
|
@ -40,7 +148,7 @@ export const cleanCommand = async (options?: {
|
|||
return;
|
||||
}
|
||||
const entries = await listRegisteredRepos();
|
||||
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
|
||||
const entry = findRegistryEntryByRepoPath(entries, repo.repoPath);
|
||||
const summary = entry?.branches?.find((b) => b.branch === options.branch);
|
||||
if (!summary) {
|
||||
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
|
||||
|
|
@ -61,10 +169,7 @@ export const cleanCommand = async (options?: {
|
|||
}
|
||||
const { lbugPath } = getStoragePaths(repo.repoPath, summary.branch, storagePath);
|
||||
const branchDir = path.dirname(lbugPath);
|
||||
// Safety guard: the target MUST live under the validated
|
||||
// storage slot's `branches/` directory before any destructive fs.rm.
|
||||
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
|
||||
if (!branchDir.startsWith(branchesRoot)) {
|
||||
if (!isContainedBranchDir(storagePath, branchDir)) {
|
||||
logger.error(
|
||||
`Refusing to clean branch index outside the validated storage slot: ${branchDir}`,
|
||||
);
|
||||
|
|
@ -75,13 +180,17 @@ export const cleanCommand = async (options?: {
|
|||
console.log(`\n${t('common.runForceConfirm')}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.rm(branchDir, { recursive: true, force: true });
|
||||
await removeBranchIndex(repo.repoPath, summary.branch);
|
||||
console.log(t('clean.deletedBranch', { branch: summary.branch }));
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete branch index:');
|
||||
const result = await removeBranchSlot({
|
||||
repoPath: repo.repoPath,
|
||||
storagePath,
|
||||
branch: summary.branch,
|
||||
dir: branchDir,
|
||||
});
|
||||
if (!result.ok) {
|
||||
logger.error({ err: result.error }, 'Failed to delete branch index:');
|
||||
return;
|
||||
}
|
||||
console.log(t('clean.deletedBranch', { branch: summary.branch }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ import { updateEligibleInstallSync } from '../core/install-context.js';
|
|||
import { readValidatedUpdateCacheSync, type ValidatedUpdateCache } from '../core/update-cache.js';
|
||||
import { t } from './i18n/index.js';
|
||||
import { cachedUpdateNoticeLine } from './update-notice.js';
|
||||
import { formatSlotSize, staleReasonLabel } from './stale-branch-format.js';
|
||||
import {
|
||||
findRegistryEntryByRepoPath,
|
||||
findRepo,
|
||||
listRegisteredRepos,
|
||||
} from '../storage/repo-manager.js';
|
||||
import {
|
||||
isDeleteCandidate,
|
||||
listStaleBranchSlots,
|
||||
staleListingBlock,
|
||||
type StaleBranchSlot,
|
||||
} from '../storage/stale-branch-slots.js';
|
||||
|
||||
function isCombiningMark(codePoint: number): boolean {
|
||||
return (
|
||||
|
|
@ -191,6 +203,35 @@ export function nativeStatusLine(check: NativeCheckResult): string {
|
|||
return ` ${padDisplayEnd('native', 10)}${nativeStatusText(check)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cwd leftover-slot lines (#3331). Pure: no deletes and no registry scan.
|
||||
* When heads cannot be listed, do not title rows as orphaned or name
|
||||
* `clean --stale` (#3337): that command refuses to delete in the same state.
|
||||
*/
|
||||
export function orphanedBranchSlotDoctorLines(slots: StaleBranchSlot[]): string[] {
|
||||
if (slots.length === 0) return [];
|
||||
const listingBlock = staleListingBlock(slots);
|
||||
if (listingBlock === 'heads-unavailable') {
|
||||
return [t('clean.stale.headsUnavailable')];
|
||||
}
|
||||
if (listingBlock === 'listing-failed') {
|
||||
return [t('clean.stale.listingFailed')];
|
||||
}
|
||||
const lines = [t('doctor.orphanedBranches')];
|
||||
let total = 0;
|
||||
for (const slot of slots) {
|
||||
total += slot.sizeBytes;
|
||||
lines.push(
|
||||
` ${slot.branch} ${staleReasonLabel(slot.reason)} ${formatSlotSize(slot.sizeBytes)}`,
|
||||
);
|
||||
}
|
||||
lines.push(` ${t('doctor.orphanedBranches.total', { size: formatSlotSize(total) })}`);
|
||||
if (slots.some(isDeleteCandidate)) {
|
||||
lines.push(` ${t('doctor.orphanedBranches.reclaim')}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function nativeStatusText(check: NativeCheckResult): string {
|
||||
if (check.ok) return '✓ lbugjs.node loaded';
|
||||
switch (check.kind) {
|
||||
|
|
@ -359,4 +400,22 @@ export const doctorCommand = async () => {
|
|||
console.log(` ${padDisplayEnd('', 12)}${cudaRedirect.detail}`);
|
||||
}
|
||||
}
|
||||
// Doctor stays runtime-global. Add only a cwd leftover-slot section when
|
||||
// this process is inside an indexed repo. Look up that repo's registry row
|
||||
// for recorded branch slugs; do not report leftovers for every registered
|
||||
// repo, and never delete.
|
||||
const [cwdRepo, entries] = await Promise.all([findRepo(process.cwd()), listRegisteredRepos()]);
|
||||
if (!cwdRepo) return;
|
||||
const entry = findRegistryEntryByRepoPath(entries, cwdRepo.repoPath);
|
||||
const slots = await listStaleBranchSlots({
|
||||
repoPath: cwdRepo.repoPath,
|
||||
storagePath: cwdRepo.storagePath,
|
||||
branches: entry?.branches,
|
||||
});
|
||||
const orphanLines = orphanedBranchSlotDoctorLines(slots);
|
||||
if (orphanLines.length === 0) return;
|
||||
console.log('');
|
||||
for (const line of orphanLines) {
|
||||
console.log(line);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'clean|--all': 'help.option.clean.all',
|
||||
'clean|--branch <name>': 'help.option.clean.branch',
|
||||
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
|
||||
'clean|--stale': 'help.option.clean.stale',
|
||||
'remove|-f, --force': 'help.option.force.confirmation',
|
||||
'wiki|-f, --force': 'help.option.wiki.force',
|
||||
'wiki|--provider <provider>': 'help.option.wiki.provider',
|
||||
|
|
|
|||
|
|
@ -60,7 +60,28 @@ export const en = {
|
|||
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
|
||||
'clean.notFoundHere': 'No indexed repository found in this directory.',
|
||||
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
|
||||
'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
|
||||
'clean.branchNotIndexed':
|
||||
'No indexed branch named "{{branch}}" for this repository. Use `gitnexus clean --stale` to reclaim leftover branch indexes, or `gitnexus list` to see recorded names.',
|
||||
'clean.stale.none': 'No leftover branch indexes to reclaim.',
|
||||
'clean.stale.preview': 'This will delete {{count}} leftover branch index(es):',
|
||||
'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}',
|
||||
'clean.stale.registryOnlyPath': '(registry only)',
|
||||
'clean.stale.headsUnavailable':
|
||||
'Could not list local heads; leftover branch indexes were not deleted. Re-run `gitnexus clean --stale` when git is available.',
|
||||
'clean.stale.listingFailed':
|
||||
'Could not read leftover branch index directories; leftover indexes were not deleted. Check permissions on the branches/ directory and re-run `gitnexus clean --stale`.',
|
||||
'clean.stale.probeFailed':
|
||||
'Could not inspect leftover branch index path(s); those slots were not deleted.',
|
||||
'clean.stale.deleted': 'Deleted leftover branch index: {{branch}}',
|
||||
'clean.stale.failed': 'Could not delete leftover branch index "{{branch}}".',
|
||||
'clean.stale.skippedLive':
|
||||
'Skipped leftover branch index "{{branch}}" — it is a local head again.',
|
||||
'clean.stale.reason.refMissing': 'not a local head',
|
||||
'clean.stale.reason.diskOnly': 'leftover directory (no registry row)',
|
||||
'clean.stale.reason.registryOnly': 'registry row (directory gone)',
|
||||
'clean.stale.reason.headsUnavailable': 'could not list local heads',
|
||||
'clean.stale.reason.probeFailed': 'could not inspect slot path',
|
||||
'clean.stale.reason.listingFailed': 'could not list leftover directories',
|
||||
'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
|
||||
'clean.deletedBranch': 'Deleted branch index: {{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
|
||||
|
|
@ -116,6 +137,9 @@ export const en = {
|
|||
'doctor.runtime': 'Runtime',
|
||||
'doctor.capabilities': 'Capabilities',
|
||||
'doctor.embeddings': 'Embeddings',
|
||||
'doctor.orphanedBranches': 'Orphaned branch indexes',
|
||||
'doctor.orphanedBranches.total': 'Total: {{size}}',
|
||||
'doctor.orphanedBranches.reclaim': 'Reclaim with: gitnexus clean --stale',
|
||||
'doctor.labels.os': 'OS:',
|
||||
'doctor.labels.node': 'Node:',
|
||||
'doctor.labels.gitnexus': 'GitNexus:',
|
||||
|
|
@ -283,6 +307,7 @@ export const en = {
|
|||
'help.option.clean.branch': 'Delete only the named branch index (not the workspace index)',
|
||||
'help.option.clean.lbugSidecars':
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
'help.option.clean.stale': 'Reclaim leftover branch indexes that are not a live local head',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
'help.option.wiki.provider':
|
||||
'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)',
|
||||
|
|
|
|||
|
|
@ -59,7 +59,26 @@ export const zhCN = {
|
|||
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
|
||||
'clean.notFoundHere': '当前目录未找到已索引仓库。',
|
||||
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
|
||||
'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。',
|
||||
'clean.branchNotIndexed':
|
||||
'该仓库没有名为 “{{branch}}” 的已索引分支。使用 `gitnexus clean --stale` 回收残留分支索引,或使用 `gitnexus list` 查看已记录名称。',
|
||||
'clean.stale.none': '没有可回收的残留分支索引。',
|
||||
'clean.stale.preview': '将删除 {{count}} 个残留分支索引:',
|
||||
'clean.stale.item': '{{branch}} {{reason}} {{path}} {{size}}',
|
||||
'clean.stale.registryOnlyPath': '(仅注册表)',
|
||||
'clean.stale.headsUnavailable':
|
||||
'无法列出本地分支,因此未删除残留分支索引。请在 git 可用后重新运行 `gitnexus clean --stale`。',
|
||||
'clean.stale.listingFailed':
|
||||
'无法读取残留分支索引目录,因此未删除残留索引。请检查 branches/ 目录权限后重新运行 `gitnexus clean --stale`。',
|
||||
'clean.stale.probeFailed': '无法检查残留分支索引路径,因此未删除这些槽位。',
|
||||
'clean.stale.deleted': '已删除残留分支索引:{{branch}}',
|
||||
'clean.stale.failed': '无法删除残留分支索引 “{{branch}}”。',
|
||||
'clean.stale.skippedLive': '已跳过残留分支索引 “{{branch}}” — 它再次成为本地分支。',
|
||||
'clean.stale.reason.refMissing': '不是本地分支',
|
||||
'clean.stale.reason.diskOnly': '残留目录(无注册表记录)',
|
||||
'clean.stale.reason.registryOnly': '注册表记录(目录已不存在)',
|
||||
'clean.stale.reason.headsUnavailable': '无法列出本地分支',
|
||||
'clean.stale.reason.probeFailed': '无法检查槽位路径',
|
||||
'clean.stale.reason.listingFailed': '无法列出残留目录',
|
||||
'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}',
|
||||
'clean.deletedBranch': '已删除分支索引:{{branch}}',
|
||||
'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
|
||||
|
|
@ -112,6 +131,9 @@ export const zhCN = {
|
|||
'doctor.runtime': '运行时',
|
||||
'doctor.capabilities': '能力',
|
||||
'doctor.embeddings': '嵌入',
|
||||
'doctor.orphanedBranches': '残留分支索引',
|
||||
'doctor.orphanedBranches.total': '合计:{{size}}',
|
||||
'doctor.orphanedBranches.reclaim': '回收命令:gitnexus clean --stale',
|
||||
'doctor.labels.os': '系统:',
|
||||
'doctor.labels.node': 'Node:',
|
||||
'doctor.labels.gitnexus': 'GitNexus:',
|
||||
|
|
@ -262,6 +284,7 @@ export const zhCN = {
|
|||
'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
|
||||
'help.option.clean.lbugSidecars':
|
||||
'清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
|
||||
'help.option.clean.stale': '回收已不是本地 head 的残留分支索引',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
'help.option.wiki.provider':
|
||||
'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)',
|
||||
|
|
|
|||
|
|
@ -349,6 +349,7 @@ program
|
|||
.option('-f, --force', 'Skip confirmation prompt')
|
||||
.option('--all', 'Clean all indexed repos')
|
||||
.option('--branch <name>', 'Delete only the named branch index (not the workspace index)')
|
||||
.option('--stale', 'Reclaim leftover branch indexes that are not a live local head')
|
||||
.option(
|
||||
'--lbug-sidecars',
|
||||
'Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)',
|
||||
|
|
|
|||
37
gitnexus/src/cli/stale-branch-format.ts
Normal file
37
gitnexus/src/cli/stale-branch-format.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { t } from './i18n/index.js';
|
||||
import { type StaleBranchReason, type StaleBranchSlot } from '../storage/stale-branch-slots.js';
|
||||
|
||||
export const formatSlotSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
export const staleReasonLabel = (reason: StaleBranchReason): string => {
|
||||
switch (reason) {
|
||||
case 'ref-missing':
|
||||
return t('clean.stale.reason.refMissing');
|
||||
case 'disk-only':
|
||||
return t('clean.stale.reason.diskOnly');
|
||||
case 'registry-only':
|
||||
return t('clean.stale.reason.registryOnly');
|
||||
case 'heads-unavailable':
|
||||
return t('clean.stale.reason.headsUnavailable');
|
||||
case 'probe-failed':
|
||||
return t('clean.stale.reason.probeFailed');
|
||||
case 'listing-failed':
|
||||
return t('clean.stale.reason.listingFailed');
|
||||
default: {
|
||||
const _exhaustive: never = reason;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const formatStaleSlotLine = (slot: StaleBranchSlot): string =>
|
||||
t('clean.stale.item', {
|
||||
branch: slot.branch,
|
||||
reason: staleReasonLabel(slot.reason),
|
||||
path: slot.dir ?? t('clean.stale.registryOnlyPath'),
|
||||
size: formatSlotSize(slot.sizeBytes),
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@ import fsp from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { stalenessStatus, type StalenessInfo, type StalenessStatus } from '../staleness-status.js';
|
||||
import { mapConcurrent } from '../../lib/utils.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
loadMeta,
|
||||
|
|
@ -84,6 +85,7 @@ export interface GroupToolPort {
|
|||
limit?: number;
|
||||
max_symbols?: number;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
impactByUid(
|
||||
|
|
@ -113,6 +115,7 @@ export interface GroupToolPort {
|
|||
uid?: string;
|
||||
file_path?: string;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
// ── Cross-repo trace support (optional on the port) ────────────────
|
||||
|
|
@ -441,6 +444,49 @@ function rejectRetiredSyncParams(params: Record<string, unknown>): { error: stri
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Advertised MCP query page bounds (tools.ts schema). Mirrored so core stays MCP-free. */
|
||||
const GROUP_QUERY_DEFAULT_LIMIT = 10;
|
||||
const GROUP_QUERY_MAX_LIMIT = 100;
|
||||
const GROUP_QUERY_DEFAULT_MAX_SYMBOLS = 25;
|
||||
const GROUP_QUERY_MAX_SYMBOLS = 200;
|
||||
const GROUP_CHAIN_MAX_DEPTH = 3;
|
||||
/** Cap per-member fan-out for group query/context. Complements LocalBackend's per-query BFS cap. */
|
||||
const GROUP_MEMBER_CONCURRENCY = 4;
|
||||
|
||||
function parseGroupQueryBound(
|
||||
value: unknown,
|
||||
field: 'limit' | 'max_symbols',
|
||||
fallback: number,
|
||||
max: number,
|
||||
): { ok: true; value: number } | { ok: false; error: string } {
|
||||
if (value === undefined) return { ok: true, value: fallback };
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid "${field}": expected an integer in [1, ${max}], got ${describeValue(value)}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function parseGroupChainDepth(
|
||||
value: unknown,
|
||||
): { ok: true; value: number | undefined } | { ok: false; error: string } {
|
||||
if (value === undefined) return { ok: true, value: undefined };
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isInteger(value) ||
|
||||
value < 0 ||
|
||||
value > GROUP_CHAIN_MAX_DEPTH
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid "chain_depth": expected an integer in [0, ${GROUP_CHAIN_MAX_DEPTH}], got ${describeValue(value)}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
export class GroupService {
|
||||
constructor(private readonly port: GroupToolPort) {}
|
||||
|
||||
|
|
@ -645,6 +691,11 @@ export class GroupService {
|
|||
if (!uid && !target) {
|
||||
return { group: name, error: 'target or uid is required', results: [] };
|
||||
}
|
||||
const parsedChainDepth = parseGroupChainDepth(params.chain_depth);
|
||||
if (parsedChainDepth.ok === false) {
|
||||
return { group: name, error: parsedChainDepth.error, results: [] };
|
||||
}
|
||||
const chain_depth = parsedChainDepth.value;
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
let config: GroupConfig;
|
||||
|
|
@ -672,35 +723,44 @@ export class GroupService {
|
|||
repoInSubgroup(repoPath, subgroup, subgroupExact),
|
||||
);
|
||||
|
||||
const results: GroupContextResult['results'] = await Promise.all(
|
||||
memberEntries.map(async ([repoPath, registryName]) => {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const payload = await this.port.context(repoObj, {
|
||||
name: target || undefined,
|
||||
uid,
|
||||
file_path,
|
||||
include_content,
|
||||
});
|
||||
const results: GroupContextResult['results'] = (
|
||||
await mapConcurrent(
|
||||
memberEntries,
|
||||
async ([repoPath, registryName]) => {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const payload = await this.port.context(repoObj, {
|
||||
name: target || undefined,
|
||||
uid,
|
||||
file_path,
|
||||
include_content,
|
||||
chain_depth,
|
||||
});
|
||||
|
||||
if (servicePrefix) {
|
||||
const st = (payload as { status?: string })?.status;
|
||||
const sym = (payload as { symbol?: { filePath?: string } })?.symbol;
|
||||
if (st === 'found' && !fileMatchesServicePrefix(sym?.filePath, servicePrefix)) {
|
||||
return { repoPath, registryName, payload: {} };
|
||||
if (servicePrefix) {
|
||||
const st = (payload as { status?: string })?.status;
|
||||
const sym = (payload as { symbol?: { filePath?: string } })?.symbol;
|
||||
if (st === 'found' && !fileMatchesServicePrefix(sym?.filePath, servicePrefix)) {
|
||||
return { repoPath, registryName, payload: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { repoPath, registryName, payload };
|
||||
} catch (e) {
|
||||
return {
|
||||
repoPath,
|
||||
registryName,
|
||||
payload: { error: e instanceof Error ? e.message : String(e) },
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { repoPath, registryName, payload };
|
||||
} catch (e) {
|
||||
return {
|
||||
repoPath,
|
||||
registryName,
|
||||
payload: { error: e instanceof Error ? e.message : String(e) },
|
||||
};
|
||||
}
|
||||
},
|
||||
{ concurrency: GROUP_MEMBER_CONCURRENCY },
|
||||
)
|
||||
).map((result, index) => {
|
||||
if (result) return result;
|
||||
const [repoPath, registryName] = memberEntries[index]!;
|
||||
return { repoPath, registryName, payload: {} };
|
||||
});
|
||||
|
||||
return {
|
||||
group: name,
|
||||
|
|
@ -723,7 +783,25 @@ export class GroupService {
|
|||
}
|
||||
const servicePrefix = normalizeServicePrefix(params.service);
|
||||
|
||||
const limit = typeof params.limit === 'number' && params.limit > 0 ? params.limit : 5;
|
||||
const parsedLimit = parseGroupQueryBound(
|
||||
params.limit,
|
||||
'limit',
|
||||
GROUP_QUERY_DEFAULT_LIMIT,
|
||||
GROUP_QUERY_MAX_LIMIT,
|
||||
);
|
||||
if (parsedLimit.ok === false) return { error: parsedLimit.error };
|
||||
const parsedMaxSymbols = parseGroupQueryBound(
|
||||
params.max_symbols,
|
||||
'max_symbols',
|
||||
GROUP_QUERY_DEFAULT_MAX_SYMBOLS,
|
||||
GROUP_QUERY_MAX_SYMBOLS,
|
||||
);
|
||||
if (parsedMaxSymbols.ok === false) return { error: parsedMaxSymbols.error };
|
||||
const parsedChainDepth = parseGroupChainDepth(params.chain_depth);
|
||||
if (parsedChainDepth.ok === false) return { error: parsedChainDepth.error };
|
||||
const limit = parsedLimit.value;
|
||||
const max_symbols = parsedMaxSymbols.value;
|
||||
const chain_depth = parsedChainDepth.value;
|
||||
const subgroup = typeof params.subgroup === 'string' ? params.subgroup : undefined;
|
||||
const subgroupExact = params.subgroupExact === true;
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
|
|
@ -740,33 +818,42 @@ export class GroupService {
|
|||
repoInSubgroup(repoPath, subgroup, subgroupExact),
|
||||
);
|
||||
|
||||
const perRepo = await Promise.all(
|
||||
memberEntries.map(async ([repoPath, registryName]) => {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const queryResult = (await this.port.query(repoObj, {
|
||||
query: queryText,
|
||||
limit,
|
||||
max_symbols: 10,
|
||||
include_content: false,
|
||||
})) as {
|
||||
processes?: Array<Record<string, unknown>>;
|
||||
process_symbols?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const processes = servicePrefix
|
||||
? filterQueryByServicePrefix(queryResult, servicePrefix).processes
|
||||
: queryResult.processes || [];
|
||||
const scored = processes.map((p, idx) => ({
|
||||
...p,
|
||||
_rrf_score: 1 / (idx + 1 + 60),
|
||||
_repo: repoPath,
|
||||
}));
|
||||
return { repo: repoPath, score: 0, processes: scored as unknown[] };
|
||||
} catch {
|
||||
return { repo: repoPath, score: 0, processes: [] as unknown[] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const perRepo = (
|
||||
await mapConcurrent(
|
||||
memberEntries,
|
||||
async ([repoPath, registryName]) => {
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const queryResult = (await this.port.query(repoObj, {
|
||||
query: queryText,
|
||||
limit,
|
||||
max_symbols,
|
||||
include_content: false,
|
||||
chain_depth,
|
||||
})) as {
|
||||
processes?: Array<Record<string, unknown>>;
|
||||
process_symbols?: Array<Record<string, unknown>>;
|
||||
};
|
||||
const processes = servicePrefix
|
||||
? filterQueryByServicePrefix(queryResult, servicePrefix).processes
|
||||
: queryResult.processes || [];
|
||||
const scored = processes.map((p, idx) => ({
|
||||
...p,
|
||||
_rrf_score: 1 / (idx + 1 + 60),
|
||||
_repo: repoPath,
|
||||
}));
|
||||
return { repo: repoPath, score: 0, processes: scored as unknown[] };
|
||||
} catch {
|
||||
return { repo: repoPath, score: 0, processes: [] as unknown[] };
|
||||
}
|
||||
},
|
||||
{ concurrency: GROUP_MEMBER_CONCURRENCY },
|
||||
)
|
||||
).map((result, index) => {
|
||||
if (result) return result;
|
||||
const [repoPath] = memberEntries[index]!;
|
||||
return { repo: repoPath, score: 0, processes: [] as unknown[] };
|
||||
});
|
||||
|
||||
const allProcesses = perRepo.flatMap((r) => r.processes as Array<Record<string, unknown>>);
|
||||
allProcesses.sort((a, b) => (b._rrf_score as number) - (a._rrf_score as number));
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from './route-extractors/route-path.js';
|
||||
import { extractReturnTypeName } from './type-extractors/shared.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from './route-extractors/data-route-table.js';
|
||||
import { toZeroBasedLine } from './utils/line-base.js';
|
||||
|
||||
const MAX_EXPORTS_PER_FILE = 500;
|
||||
const MAX_TYPE_NAME_LENGTH = 256;
|
||||
|
|
@ -48,6 +49,48 @@ interface RouteHandlerResolutionContext {
|
|||
readonly files: readonly RouteResolutionFile[];
|
||||
readonly resolveImportTarget: (parsedImport: ParsedImport, fromFile: string) => string | null;
|
||||
readonly isExportedSymbol: (nodeId: string) => boolean;
|
||||
/** 0-based graph-node startLine for same-name tRPC handler disambiguation. */
|
||||
readonly nodeStartLine?: (nodeId: string) => number | undefined;
|
||||
}
|
||||
|
||||
/** Same Function/Method gate as data-route resolution (`routeCallables`). */
|
||||
function routeCallableDefs(defs: readonly SymbolDefinition[]): readonly SymbolDefinition[] {
|
||||
return defs.filter((def) => def.type === 'Function' || def.type === 'Method');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a same-file route handler from `lookupExactAll` hits.
|
||||
*
|
||||
* Graph nodes store 0-based `startLine`; `ExtractedRoute.lineNumber` is 1-based
|
||||
* (`i + 1` in the tRPC scanner). File-index lookup is not callable-only, so
|
||||
* drop Property/Variable/Const (and other non-callables) first. When several
|
||||
* callables share a name, prefer the unique def whose node startLine equals
|
||||
* `toZeroBasedLine(route.lineNumber)`. If that exact match is missing (common
|
||||
* when the arrow starts on the line after `.mutation(`), take the unique def
|
||||
* whose startLine is the nearest `>=` target. Zero or 2+ winners (or no line
|
||||
* reader) → `undefined` (fail-open).
|
||||
*/
|
||||
function pickSameFileHandler(
|
||||
defs: readonly SymbolDefinition[],
|
||||
route: { lineNumber: number },
|
||||
getStartLine?: (nodeId: string) => number | undefined,
|
||||
): SymbolDefinition | undefined {
|
||||
defs = routeCallableDefs(defs);
|
||||
if (defs.length === 1) return defs[0];
|
||||
if (defs.length > 1 && getStartLine !== undefined) {
|
||||
const targetLine = toZeroBasedLine(route.lineNumber);
|
||||
const withLines = defs
|
||||
.map((def) => ({ def, startLine: Number(getStartLine(def.nodeId)) }))
|
||||
.filter((entry) => Number.isFinite(entry.startLine));
|
||||
const exact = withLines.filter((entry) => entry.startLine === targetLine);
|
||||
if (exact.length === 1) return exact[0].def;
|
||||
const atOrAfter = withLines.filter((entry) => entry.startLine >= targetLine);
|
||||
if (atOrAfter.length === 0) return undefined;
|
||||
const nearestLine = Math.min(...atOrAfter.map((entry) => entry.startLine));
|
||||
const nearest = atOrAfter.filter((entry) => entry.startLine === nearestLine);
|
||||
return nearest.length === 1 ? nearest[0].def : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Record one exported graph node into the incremental ExportedTypeMap. */
|
||||
|
|
@ -207,7 +250,35 @@ export const processRoutesFromExtracted = async (
|
|||
await yieldToEventLoop();
|
||||
}
|
||||
|
||||
if (!route.controllerName || !route.methodName) continue;
|
||||
if (!route.methodName) continue;
|
||||
|
||||
// tRPC routes carry NO controller: a router is an object binding, not a
|
||||
// class, so the extractor leaves controllerName unset and names the
|
||||
// handler from the callback identifier when present, otherwise from its
|
||||
// object-literal key. Bind the same-file symbol directly.
|
||||
// No unique match → skip, fail-open. Laravel routes always set
|
||||
// controllerName and Django routes leave methodName null, so this
|
||||
// branch is tRPC-only by construction — the laravel guessed-method
|
||||
// fallback below never sees a controller-less route.
|
||||
if (!route.controllerName) {
|
||||
const handler = pickSameFileHandler(
|
||||
model.symbols.lookupExactAll(route.filePath, route.methodName),
|
||||
route,
|
||||
(id) => graph.getNode(id)?.properties.startLine as number | undefined,
|
||||
);
|
||||
if (!handler) continue;
|
||||
const sourceId = generateId('File', route.filePath);
|
||||
const relId = generateId('CALLS', sourceId + ':route->' + handler.nodeId);
|
||||
graph.addRelationship({
|
||||
id: relId,
|
||||
sourceId,
|
||||
targetId: handler.nodeId,
|
||||
type: 'CALLS',
|
||||
confidence: ROUTE_EDGE_CONFIDENCE,
|
||||
reason: 'trpc-route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the controller class. Qualified-first: when the routes file
|
||||
// disambiguated the controller (a `use` import or inline `::class` FQN, both
|
||||
|
|
@ -302,8 +373,9 @@ export function resolveRouteHandlerSymbols(
|
|||
// stamp always belongs to the route that actually won the Route node.
|
||||
const claimed = new Set<string>();
|
||||
|
||||
// Resolve a single same-file symbol by name, refusing to guess on ambiguity:
|
||||
// exactly one match → its nodeId; zero or many → undefined (fail-open).
|
||||
// Resolve a single same-file symbol by name. Exactly one match → its nodeId.
|
||||
// Zero or many matches → undefined (fail-open). tRPC same-name handlers
|
||||
// use pickSameFileHandler at the controller-less call site instead.
|
||||
const uniqueSymbolId = (filePath: string, name: string): string | undefined => {
|
||||
const defs = model.symbols.lookupExactAll(filePath, name);
|
||||
return defs.length === 1 ? defs[0]?.nodeId : undefined;
|
||||
|
|
@ -315,7 +387,7 @@ export function resolveRouteHandlerSymbols(
|
|||
};
|
||||
|
||||
const routeCallables = (defs: readonly SymbolDefinition[]): readonly SymbolDefinition[] =>
|
||||
defs.filter((def) => def.type === 'Function' || def.type === 'Method');
|
||||
routeCallableDefs(defs);
|
||||
|
||||
const exportedRouteCallables = (
|
||||
defs: readonly SymbolDefinition[],
|
||||
|
|
@ -429,6 +501,12 @@ export function resolveRouteHandlerSymbols(
|
|||
if (controllerDefs.length === 1) controllerDef = controllerDefs[0];
|
||||
}
|
||||
if (controllerDef) methodId = uniqueSymbolId(controllerDef.filePath, route.methodName);
|
||||
} else if (!route.controllerName && route.methodName) {
|
||||
methodId = pickSameFileHandler(
|
||||
model.symbols.lookupExactAll(route.filePath, route.methodName),
|
||||
route,
|
||||
routeContext?.nodeStartLine,
|
||||
)?.nodeId;
|
||||
}
|
||||
claim(route.routePath, route.prefix ?? null, route.httpMethod, methodId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,38 @@ const DIRECTIVES = new Set(['Name', 'Key', 'Class']);
|
|||
const META = XMLParser.getMetaDataSymbol() as symbol;
|
||||
type XmlNode = Record<string, unknown> & { [key: symbol]: XMLMetaData | undefined };
|
||||
|
||||
function containsDoctypeDeclaration(source: string): boolean {
|
||||
let cursor = 0;
|
||||
while ((cursor = source.indexOf('<', cursor)) !== -1) {
|
||||
if (source.startsWith('<!--', cursor)) {
|
||||
const end = source.indexOf('-->', cursor + 4);
|
||||
if (end === -1) return false;
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith('<![CDATA[', cursor)) {
|
||||
const end = source.indexOf(']]>', cursor + 9);
|
||||
if (end === -1) return false;
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith('<?', cursor)) {
|
||||
const end = source.indexOf('?>', cursor + 2);
|
||||
if (end === -1) return false;
|
||||
cursor = end + 2;
|
||||
continue;
|
||||
}
|
||||
if (/^<!DOCTYPE\b/i.test(source.slice(cursor))) return true;
|
||||
cursor++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractXamlDeclarations(source: string): DocumentDeclaration[] {
|
||||
// fast-xml-parser normalizes newlines before computing its metadata offsets.
|
||||
source = source.replace(/\r\n?/g, '\n');
|
||||
// Repository documents are untrusted. Never resolve DTDs or expand entities.
|
||||
if (/<!DOCTYPE\b/i.test(source)) throw new Error('DTD declarations are not supported');
|
||||
if (containsDoctypeDeclaration(source)) throw new Error('DTD declarations are not supported');
|
||||
if (XMLValidator.validate(source) !== true) {
|
||||
throw new Error('Invalid XML');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,8 +127,26 @@ export function calculateEntryPointScore(
|
|||
// Name pattern scoring
|
||||
let nameMultiplier = 1.0;
|
||||
|
||||
// tRPC procedures often use accessor-style names
|
||||
// (`getX`, `setX`, `isX`) that legitimately ARE entry points. Without this
|
||||
// exemption, the 0.3× utility penalty cancels the 3.0× tRPC framework boost
|
||||
// (net 0.9, below baseline), preventing procedures like `setSettings`
|
||||
// from becoming Process entry points and hiding them from `query` results.
|
||||
// Only accessor/predicate names skip the penalty — helpers such as
|
||||
// `formatDate`, `_internal`, or `parseInput` in the same router file still
|
||||
// match UTILITY_PATTERNS. The 3.0× tRPC framework boost stays path-based.
|
||||
// The exemption follows detectFrameworkFromPath so T3 (`/api/routers/`) and
|
||||
// `/app/trpc/routers/` layouts get the same treatment as `/trpc/routers/`.
|
||||
// Optional chaining still guards paths that fail the tRPC predicates
|
||||
// (e.g. generic `/routers/*.js` without `/trpc/` or `/api/routers/`).
|
||||
// JS/JSX routers that match those predicates get `framework: 'trpc'`
|
||||
// the same way TS/TSX routers do.
|
||||
const frameworkHint = filePath ? detectFrameworkFromPath(filePath) : null;
|
||||
const trpcAccessorExemption =
|
||||
frameworkHint?.framework === 'trpc' && /^(get|set|is|has|can|should|will|did)[A-Z]/.test(name);
|
||||
|
||||
// Check negative patterns first (utilities get penalized)
|
||||
if (UTILITY_PATTERNS.some((p) => p.test(name))) {
|
||||
if (!trpcAccessorExemption && UTILITY_PATTERNS.some((p) => p.test(name))) {
|
||||
nameMultiplier = 0.3; // Significant penalty
|
||||
reasons.push('utility-pattern');
|
||||
} else {
|
||||
|
|
@ -141,14 +159,10 @@ export function calculateEntryPointScore(
|
|||
}
|
||||
}
|
||||
|
||||
// Framework detection bonus (Phase 2)
|
||||
let frameworkMultiplier = 1.0;
|
||||
if (filePath) {
|
||||
const frameworkHint = detectFrameworkFromPath(filePath);
|
||||
if (frameworkHint) {
|
||||
frameworkMultiplier = frameworkHint.entryPointMultiplier;
|
||||
reasons.push(`framework:${frameworkHint.reason}`);
|
||||
}
|
||||
if (frameworkHint) {
|
||||
frameworkMultiplier = frameworkHint.entryPointMultiplier;
|
||||
reasons.push(`framework:${frameworkHint.reason}`);
|
||||
}
|
||||
|
||||
// Calculate final score
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
|
|||
return { framework: 'nextjs-app', entryPointMultiplier: 2.0, reason: 'nextjs-layout' };
|
||||
}
|
||||
|
||||
// tRPC router files — before the expo-router /app/ catch-all so
|
||||
// `src/app/trpc/routers/*.ts` is tRPC, not an Expo screen. `/routers/`
|
||||
// alone also matches Vue Router / Next.js-style folders and must not
|
||||
// take the 3.0 entry-point multiplier.
|
||||
if (
|
||||
(p.endsWith('.ts') || p.endsWith('.tsx') || p.endsWith('.js') || p.endsWith('.jsx')) &&
|
||||
((p.includes('/trpc/') && p.includes('/routers/')) || p.includes('/api/routers/'))
|
||||
) {
|
||||
return { framework: 'trpc', entryPointMultiplier: 3.0, reason: 'trpc-router' };
|
||||
}
|
||||
|
||||
// Expo Router - screen/layout/api files in app/ directory
|
||||
if (
|
||||
p.includes('/app/') &&
|
||||
|
|
|
|||
|
|
@ -520,6 +520,17 @@ interface LanguageProviderConfig {
|
|||
reader: (relativePath: string) => string | null,
|
||||
parser?: Parser | null,
|
||||
) => ExtractedRoute[];
|
||||
/**
|
||||
* Extract routes from file text without an AST.
|
||||
*
|
||||
* Content-based (regex / line scan), not tree-sitter. tRPC uses this hook:
|
||||
* procedure routers are recognized from source text (`publicProcedure.query`)
|
||||
* rather than grammar captures. The parse worker calls this when the hook is
|
||||
* defined. Providers that need a path-gate apply it inside the hook.
|
||||
*
|
||||
* Default: undefined (no text-route extraction).
|
||||
*/
|
||||
readonly extractTextRoutes?: (filePath: string, content: string) => ExtractedRoute[];
|
||||
|
||||
/**
|
||||
* Extract routes that a parsed file declares in its own AST.
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captu
|
|||
import {
|
||||
deriveDefaultExportHocName,
|
||||
isBlockedDefaultExportHoc,
|
||||
isBlockedPairCallbackRegistration,
|
||||
isDefaultExportHocFunctionNode,
|
||||
} from '../../ts-js-hoc-utils.js';
|
||||
|
||||
|
|
@ -1094,6 +1095,13 @@ export function emitJsScopeCaptures(
|
|||
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
|
||||
continue;
|
||||
}
|
||||
// Pair-value built-in registrations (`{ timer: setTimeout(() => …) }`,
|
||||
// `{ later: promise.then(() => …) }`) bind handles/values, not
|
||||
// callables. See `isBlockedPairCallbackRegistration` for why this
|
||||
// gate lives emit-side rather than in the query predicates.
|
||||
if (arrowNode !== null && isBlockedPairCallbackRegistration(arrowNode)) {
|
||||
continue;
|
||||
}
|
||||
// #2723 — see the matching filter in `typescript/captures.ts`.
|
||||
if (arrowNode !== null && isShadowedCjsExportAssignment(arrowNode, tree.rootNode)) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -241,6 +241,200 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
key: (string (string_fragment) @declaration.name)
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
;; HOC-wrapped pair values: create: procedure.mutation(async ({ input }) => { ... }).
|
||||
;; tRPC, Express route definitions, and similar frameworks use this pattern where
|
||||
;; an object property's value is a call_expression (like .mutation()/.query()) that
|
||||
;; wraps an arrow_function or function_expression as a callback argument.
|
||||
;; Without these patterns, tRPC procedures are invisible — the arrow registers as
|
||||
;; anonymous and all calls inside fall back to file-level attribution.
|
||||
;;
|
||||
;; AST shape:
|
||||
;; pair
|
||||
;; key: property_identifier "create"
|
||||
;; value: call_expression
|
||||
;; function: identifier | member_expression
|
||||
;; arguments: arguments
|
||||
;; arrow_function | function_expression
|
||||
;;
|
||||
;; Anchor discipline: same as direct pairs — on the inner arrow/function, not
|
||||
;; the outer call_expression. The arrow's range matches its @scope.function range,
|
||||
;; so pass2AttachDeclarations auto-hoists the binding to the parent scope.
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
; Member-expression variants exclude callback-taking array methods —
|
||||
; '{ visible: items.filter(item => item.active) }' is a value holding an
|
||||
; array, not a Function — same exclusion as the HOC variable rules below.
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; String-key pair variants: { 'create': procedure.mutation(async () => ...) }.
|
||||
;; Quoted object keys are identical shapes to the identifier-key pairs above —
|
||||
;; a tRPC router that quotes its keys (lint-enforced or JSON-ish style) would
|
||||
;; otherwise leave its procedures anonymous and file-level attributed.
|
||||
;; Quoted-key identifier callees ({ 'handler': wrap(() => {}) }) match these
|
||||
;; string-key rules, not the identifier-key block above; member-expression
|
||||
;; callees keep the array-method exclusion.
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; Curried pair HOC: create: publicProcedure.mutation(withAuth(async () => {})).
|
||||
;; Existing pair-HOC rules require the arrow to be a DIRECT argument of the
|
||||
;; pair's call_expression. They miss mutation's argument being another
|
||||
;; call_expression (withAuth(...)). Object-pair only — do NOT add a
|
||||
;; variable-level nested-HOC rule (\`const X = memo(forwardRef(...))\` must
|
||||
;; stay a Variable; see typescript-hoc-wrapped.test.ts).
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; HOC-wrapped variable declarations: const X = HOC((args) => { ... }).
|
||||
;; Covers React.forwardRef, memo, useCallback, useMemo, observer,
|
||||
;; debounce, and any user-defined HOC factory.
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ import {
|
|||
ARRAY_METHOD_HOC_BLOCKLIST_SET,
|
||||
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
|
||||
deriveDefaultExportHocName,
|
||||
isBlockedCallbackRegistrationCall,
|
||||
} from '../ts-js-hoc-utils.js';
|
||||
import {
|
||||
emitTsScopeCaptures,
|
||||
|
|
@ -127,6 +128,7 @@ import {
|
|||
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
|
||||
import { extractNestRoutes } from '../route-extractors/nest.js';
|
||||
import { extractTrpcRoutes, shouldScanForTrpcRoutes } from '../route-extractors/trpc.js';
|
||||
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
|
|
@ -135,6 +137,20 @@ const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes
|
|||
...extractNestRoutes(...args),
|
||||
];
|
||||
|
||||
const extractJsTsTextRoutes = (path: string, content: string) =>
|
||||
shouldScanForTrpcRoutes(path) ? extractTrpcRoutes(path, content) : [];
|
||||
|
||||
const pairKeyName = (keyNode: SyntaxNode | null | undefined): string | null => {
|
||||
if (!keyNode) return null;
|
||||
if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') {
|
||||
return keyNode.text;
|
||||
}
|
||||
if (keyNode.type === 'string') {
|
||||
return keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment')?.text ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
* anonymous AST nodes — they take their name from the surrounding
|
||||
|
|
@ -188,20 +204,9 @@ const tsExtractFunctionName = (
|
|||
// tree-sitter-typescript uses `pair`; tree-sitter-javascript also exposes
|
||||
// `pair`. (Older grammars used `property_assignment`; we accept both.)
|
||||
if (parent.type === 'pair' || parent.type === 'property_assignment') {
|
||||
const keyNode = parent.childForFieldName?.('key');
|
||||
if (!keyNode) return { funcName: null, label: 'Function' };
|
||||
if (keyNode.type === 'property_identifier' || keyNode.type === 'identifier') {
|
||||
return { funcName: keyNode.text, label: 'Function' };
|
||||
}
|
||||
if (keyNode.type === 'string') {
|
||||
// `"add-item": () => ...` — the literal text inside the quotes.
|
||||
const fragment = keyNode.children?.find((c: SyntaxNode) => c.type === 'string_fragment');
|
||||
const text = fragment?.text ?? null;
|
||||
return { funcName: text, label: 'Function' };
|
||||
}
|
||||
// computed_property_name (`[ACTION_KEY]`) and other dynamic keys have
|
||||
// no static name — fall through anonymous.
|
||||
return { funcName: null, label: 'Function' };
|
||||
// no static name — pairKeyName returns null and we stay anonymous.
|
||||
return { funcName: pairKeyName(parent.childForFieldName?.('key')), label: 'Function' };
|
||||
}
|
||||
|
||||
// HOC-wrapped variable declarations: `const Button = forwardRef((p, r) => { ... })`,
|
||||
|
|
@ -268,6 +273,17 @@ const tsExtractFunctionName = (
|
|||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
// HOC-wrapped pair: `create: procedure.mutation(async ({ input }) => { ... })`.
|
||||
// tRPC and similar frameworks wrap callbacks inside .mutation()/.query() calls
|
||||
// that are themselves pair values. The arrow's parent is `arguments`,
|
||||
// grandparent is `call_expression`, great-grandparent is `pair`.
|
||||
if (declarator?.type === 'pair' || declarator?.type === 'property_assignment') {
|
||||
if (isBlockedCallbackRegistrationCall(callExpr)) {
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
return { funcName: pairKeyName(declarator.childForFieldName?.('key')), label: 'Function' };
|
||||
}
|
||||
|
||||
return { funcName: null, label: 'Function' };
|
||||
}
|
||||
|
||||
|
|
@ -408,6 +424,12 @@ export const typescriptProvider = defineLanguage({
|
|||
'expo-router',
|
||||
],
|
||||
},
|
||||
{
|
||||
framework: 'trpc',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'trpc-procedure',
|
||||
patterns: ['initTRPC', 'createTRPCRouter', '@trpc/server', '@trpc/client'],
|
||||
},
|
||||
] satisfies AstFrameworkPatternConfig[],
|
||||
treeSitterQueries: TYPESCRIPT_QUERIES,
|
||||
typeConfig: typescriptConfig,
|
||||
|
|
@ -469,6 +491,9 @@ export const typescriptProvider = defineLanguage({
|
|||
// to a literal; nothing else in this pipeline can see that shape. TS and JS
|
||||
// share the grammar, so they share the extractor.
|
||||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
// Path-gate lives here (language provider), not in the shared parse worker.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
});
|
||||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
|
|
@ -482,6 +507,12 @@ export const javascriptProvider = defineLanguage({
|
|||
reason: 'nestjs-decorator',
|
||||
patterns: ['@Controller', '@Get', '@Post', '@Put', '@Delete', '@Patch'],
|
||||
},
|
||||
{
|
||||
framework: 'trpc',
|
||||
entryPointMultiplier: 3.0,
|
||||
reason: 'trpc-procedure',
|
||||
patterns: ['initTRPC', 'createTRPCRouter', '@trpc/server', '@trpc/client'],
|
||||
},
|
||||
{
|
||||
framework: 'expo-router',
|
||||
entryPointMultiplier: 2.5,
|
||||
|
|
@ -544,4 +575,6 @@ export const javascriptProvider = defineLanguage({
|
|||
arityCompatibility: jsArityCompatibility,
|
||||
// See the TypeScript provider above.
|
||||
extractDecoratorRoutes: extractJsTsRoutes,
|
||||
// Content-based (not AST): tRPC procedure routers are scanned from source text.
|
||||
extractTextRoutes: extractJsTsTextRoutes,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import { synthesizeReceiverChainCapture } from '../../utils/receiver-chain-captu
|
|||
import {
|
||||
deriveDefaultExportHocName,
|
||||
isBlockedDefaultExportHoc,
|
||||
isBlockedPairCallbackRegistration,
|
||||
isDefaultExportHocFunctionNode,
|
||||
} from '../../ts-js-hoc-utils.js';
|
||||
|
||||
|
|
@ -528,6 +529,13 @@ export function emitTsScopeCaptures(
|
|||
if (arrowNode !== null && isBlockedDefaultExportHoc(arrowNode)) {
|
||||
continue;
|
||||
}
|
||||
// Pair-value built-in registrations (`{ timer: setTimeout(() => …) }`,
|
||||
// `{ later: promise.then(() => …) }`) bind handles/values, not
|
||||
// callables. See `isBlockedPairCallbackRegistration` for why this
|
||||
// gate lives emit-side rather than in the query predicates.
|
||||
if (arrowNode !== null && isBlockedPairCallbackRegistration(arrowNode)) {
|
||||
continue;
|
||||
}
|
||||
// #2723: a CJS export assignment must not register a SECOND module-scope
|
||||
// declaration for a name the file already declares lexically — the name
|
||||
// would become ambiguous and the resolver would drop the intra-module
|
||||
|
|
|
|||
|
|
@ -321,6 +321,211 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
key: (string (string_fragment) @declaration.name)
|
||||
value: (function_expression) @declaration.function)
|
||||
|
||||
;; HOC-wrapped pair values: create: procedure.mutation(async ({ input }) => { ... }).
|
||||
;; tRPC, Express route definitions, and similar frameworks use this pattern where
|
||||
;; an object property's value is a call_expression (like .mutation()/.query()) that
|
||||
;; wraps an arrow_function or function_expression as a callback argument.
|
||||
;; Without these patterns, tRPC procedures are invisible — the arrow registers as
|
||||
;; anonymous and all calls inside fall back to file-level attribution.
|
||||
;;
|
||||
;; AST shape:
|
||||
;; pair
|
||||
;; key: property_identifier "create"
|
||||
;; value: call_expression
|
||||
;; function: identifier | member_expression
|
||||
;; arguments: arguments
|
||||
;; arrow_function | function_expression
|
||||
;;
|
||||
;; Anchor discipline: same as direct pairs — on the inner arrow/function, not
|
||||
;; the outer call_expression. The arrow's range matches its @scope.function range,
|
||||
;; so pass2AttachDeclarations auto-hoists the binding to the parent scope.
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
; Member-expression variants exclude callback-taking array methods —
|
||||
; '{ visible: items.filter(item => item.active) }' is a value holding an
|
||||
; array, not a Function — same exclusion as the HOC variable rules below.
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; String-key pair variants: { 'create': procedure.mutation(async () => ...) }.
|
||||
;; Quoted object keys are identical shapes to the identifier-key pairs above —
|
||||
;; a tRPC router that quotes its keys (lint-enforced or JSON-ish style) would
|
||||
;; otherwise leave its procedures anonymous and file-level attributed.
|
||||
;; Quoted-key identifier callees ({ 'handler': wrap(() => {}) }) match these
|
||||
;; string-key rules, not the identifier-key block above; member-expression
|
||||
;; callees keep the array-method exclusion.
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; Curried pair HOC: create: publicProcedure.mutation(withAuth(async () => {})).
|
||||
;; Existing pair-HOC rules require the arrow to be a DIRECT argument of the
|
||||
;; pair's call_expression. They miss mutation's argument being another
|
||||
;; call_expression (withAuth(...)). Object-pair only — do NOT add a
|
||||
;; variable-level nested-HOC rule (\`const X = memo(forwardRef(...))\` must
|
||||
;; stay a Variable; see typescript-hoc-wrapped.test.ts).
|
||||
;;
|
||||
;; AST shape:
|
||||
;; pair
|
||||
;; key: property_identifier | string
|
||||
;; value: call_expression
|
||||
;; function: identifier | member_expression
|
||||
;; arguments: arguments
|
||||
;; call_expression
|
||||
;; function: identifier
|
||||
;; arguments: arguments
|
||||
;; arrow_function | function_expression
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @declaration.name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @declaration.name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression) @declaration.function)))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE})
|
||||
|
||||
;; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
;;
|
||||
;; Covers the dominant React UI idiom (\`React.forwardRef\`, \`React.memo\`,
|
||||
|
|
@ -1195,6 +1400,12 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
function: (await_expression
|
||||
(identifier) @reference.name)) @reference.call.free
|
||||
|
||||
;; NOTE: Curried applications f(x)(y) are deliberately NOT anchored here. The
|
||||
;; inner f(x) call is captured by the plain free-call pattern above; anchoring
|
||||
;; the outer application would record f as called with (y) — wrong arity and
|
||||
;; argument types for overload resolution (see free-call-fallback's
|
||||
;; site.arity / site.argumentTypes filtering).
|
||||
|
||||
;; References — member calls: \`obj.method()\` (includes optional chain).
|
||||
;; The (_) wildcard matches any named receiver including \`this\` /
|
||||
;; \`super\` (both are named nodes in tree-sitter-typescript, unlike C#'s
|
||||
|
|
|
|||
|
|
@ -1907,6 +1907,10 @@ export async function runChunkedParseAndResolve(
|
|||
files: routeResolutionFiles,
|
||||
resolveImportTarget: resolveRouteImportTarget,
|
||||
isExportedSymbol: (nodeId: string) => graph.getNode(nodeId)?.properties.isExported === true,
|
||||
nodeStartLine: (id) => {
|
||||
const n = graph.getNode(id);
|
||||
return typeof n?.properties.startLine === 'number' ? n.properties.startLine : undefined;
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
|
|
|||
783
gitnexus/src/core/ingestion/route-extractors/trpc.ts
Normal file
783
gitnexus/src/core/ingestion/route-extractors/trpc.ts
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
import type { ExtractedRoute } from './laravel.js';
|
||||
|
||||
const HTTP_METHOD_MAP: Record<string, string> = {
|
||||
query: 'GET',
|
||||
mutation: 'POST',
|
||||
subscription: 'WS',
|
||||
};
|
||||
|
||||
// Shared by the file gate and the line scanner. A tRPC terminal is
|
||||
// `.query(` / `.mutation(` / `.subscription(` only after a Procedure
|
||||
// builder (`publicProcedure`, `t.procedure`) or at the start of a
|
||||
// line (prettier-broken chain). `db.query(` / `obj.query(` must not
|
||||
// match. Chained `.input(...).query(` is accepted in the scanner when
|
||||
// parenDepth is back at the procedure key (the input parens closed).
|
||||
// The `/m` flag lets the file gate see a line-start `.query(` in
|
||||
// whole-file text.
|
||||
const TERMINAL_CALL_RE =
|
||||
/(?:(?<=Procedure)|(?<=\bprocedure)|^)\s*\.\s*(query|mutation|subscription)\s*\(/m;
|
||||
|
||||
// Procedure keys may sit at the start of an indented line, or mid-line after
|
||||
// `{` / `,` in a compact router (`t.router({ health: publicProcedure.query(...) })`).
|
||||
// Quoted keys (`'create'` / `"admin-panel"`) are the same procedure name as the
|
||||
// unquoted identifier. Unquoted stays `\w+`; quoted allows hyphens and similar
|
||||
// identifier-like punctuation (`$`, `.`). Dual groups: name = m[1] || m[3].
|
||||
// `procedure` is the official `const procedure = t.procedure` alias (also the
|
||||
// HOC-pair shape in the TS/JS queries); it is not covered by `\w*Procedure`.
|
||||
const PROCEDURE_KEY_RE =
|
||||
/(?:^|[{,])\s*(?:['"]([\w$.-]+)['"]|((\w+)))\s*:\s*(\w*Procedure|t\.procedure|procedure)\b/;
|
||||
|
||||
/** Normalize slashes and prefix `/` so a repo-root `routers/foo.ts` matches `/routers/`. */
|
||||
export function shouldScanForTrpcRoutes(filePath: string): boolean {
|
||||
let p = filePath.replace(/\\/g, '/');
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
return p.includes('/routers/') || p.includes('/trpc/') || p.includes('/server/');
|
||||
}
|
||||
|
||||
function hasTrpcTerminalKeyword(content: string): boolean {
|
||||
return (
|
||||
content.includes('query') || content.includes('mutation') || content.includes('subscription')
|
||||
);
|
||||
}
|
||||
|
||||
function prefixFromBinding(
|
||||
masked: string,
|
||||
content: string,
|
||||
filePath: string,
|
||||
binding: RegExpMatchArray | null,
|
||||
): string | null {
|
||||
// Comments / string literals must not supply a prefix (`// const fooRouter =`
|
||||
// or a `.merge('post.'` inside a string). maskNonCode preserves length, so a
|
||||
// hit on the mask can be re-read from the original for quoted merge text.
|
||||
|
||||
// Prefer the exported *Router binding; otherwise the first const/let/var.
|
||||
// A file-wide first `.merge('post.', …)` used to prefix every procedure,
|
||||
// including a later `export const appRouter = t.router({ health })`.
|
||||
if (binding) {
|
||||
const rhsStart = binding.index + binding[0].length;
|
||||
const rhsMasked = masked.slice(rhsStart);
|
||||
|
||||
// Only `t.merge` / `trpc.merge` / `tRPC.merge` / `*Router.merge` on THIS
|
||||
// binding is a tRPC prefix. `defaults.merge('internal', …)` is lodash-style
|
||||
// and cannot match here; a preceding `t.merge('post.', postRouter)` is
|
||||
// ignored when this binding is `t.router` / `createTRPCRouter` / `router(`.
|
||||
if (/^(?:t|trpc|tRPC|\w+Router)\s*\.\s*merge\s*\(/.test(rhsMasked)) {
|
||||
const mergeMatch = content
|
||||
.slice(rhsStart)
|
||||
.match(/\.merge\s*\(\s*(?:"([^"]+)"|'([^']+)')\s*,/);
|
||||
if (mergeMatch) {
|
||||
// A '.merge('post.', ...)' prefix composes the route-path key; a stray
|
||||
// leading/trailing dot would double up when we join ('post..list') —
|
||||
// strip both edges and treat an all-dot prefix as no prefix.
|
||||
const merged = (mergeMatch[1] ?? mergeMatch[2] ?? '').replace(/^\.+|\.+$/g, '');
|
||||
return merged.length > 0 ? merged : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^(?:createTRPCRouter|\w+\s*\.\s*router|router)\s*\(/.test(rhsMasked)) {
|
||||
// `appRouter` / `rootRouter` is the root binding, not a nest key.
|
||||
// Live tRPC paths are `admin.users.list`, not `app.admin.users.list`.
|
||||
// Do not fall through to the filename prefix — this file is the root composer.
|
||||
const base = binding[1].replace(/Router$/i, '');
|
||||
if (/^(app|root)$/i.test(base)) return null;
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
const fileName =
|
||||
filePath
|
||||
.split('/')
|
||||
.pop()
|
||||
?.replace(/\.(ts|tsx|js|jsx)$/, '') ?? '';
|
||||
if (fileName && fileName !== 'index' && fileName !== 'root') return fileName;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Allowlist, not the previous broad w*Procedure wildcard: /\b\w*Procedure\w*\b/
|
||||
// also matched unrelated identifiers (ProcedureBuilder, a local
|
||||
// procedureFactory...), letting files that merely REFERENCE procedures pass
|
||||
// the gate and emit phantom routes. Every real v9-v11 router imports one of
|
||||
// these exact names.
|
||||
function isTrpcRouterFileMasked(masked: string): boolean {
|
||||
// Markers and cheap-checks run on the mask: a comment/string `@trpc/server`
|
||||
// or `initTRPC` must not open the file for an unrelated `fooProcedure.query`.
|
||||
// Real routers keep unquoted identifiers (`initTRPC`, `publicProcedure`).
|
||||
// Cheap reject before the terminal regex: every live procedure still
|
||||
// contains one of these identifiers. Whitespace between `.` and the
|
||||
// name is allowed by TERMINAL_CALL_RE, so we do not require a literal `.query`.
|
||||
if (
|
||||
!masked.includes('query') &&
|
||||
!masked.includes('mutation') &&
|
||||
!masked.includes('subscription')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!TERMINAL_CALL_RE.test(masked)) {
|
||||
// Compact `.input(...).mutation(` is not Procedure-adjacent or
|
||||
// line-start; the scanner binds it via parenDepth.
|
||||
if (!/\.\s*(?:query|mutation|subscription)\s*\(/.test(masked)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
/initTRPC|createTRPCRouter|createTRPCProxyClient|createTRPCNext|@trpc\//.test(masked) ||
|
||||
/\b(?:public|protected|private)Procedure\b/.test(masked)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Brace-depth scanner state shared across the lines of one file. Tracks block
|
||||
* comments and string/template literals so braces inside them never skew the
|
||||
* depth counter — a skewed counter would mis-nest (or never pop) the router
|
||||
* stack below and corrupt the emitted procedure paths.
|
||||
*/
|
||||
interface ScanState {
|
||||
inString: string | null;
|
||||
inBlockComment: boolean;
|
||||
/** Last non-whitespace code char — distinguishes `/regex/` from `a / b`. */
|
||||
prevSignificant: string | null;
|
||||
/**
|
||||
* Last identifier token. Persists across whitespace, comments, and newlines
|
||||
* (`return\n /}/`) so a keyword that introduces an expression can start a
|
||||
* regex. Cleared by any other significant token (`return 1 / 2` stays division).
|
||||
*/
|
||||
lastIdentifier: string | null;
|
||||
/** `(` ++ / `)` -- in this masker; never below 0. */
|
||||
parenDepth: number;
|
||||
/**
|
||||
* Paren depth of a control keyword's opening `(` (`if` / `while` / …),
|
||||
* remembered until that condition's `)` closes.
|
||||
*/
|
||||
controlConditionDepth: number | null;
|
||||
/**
|
||||
* After a control-condition `)`, the next `/` is a regex — until another
|
||||
* significant non-`/` token. `(a + b) / c` and `foo(ok) / x` stay division.
|
||||
*/
|
||||
regexAfterControlClose: boolean;
|
||||
}
|
||||
|
||||
/** Keywords that introduce an expression/statement, so the next `/` is a regex. */
|
||||
const REGEX_AFTER_KEYWORDS = new Set([
|
||||
'return',
|
||||
'throw',
|
||||
'case',
|
||||
'else',
|
||||
'new',
|
||||
'delete',
|
||||
'void',
|
||||
'typeof',
|
||||
'yield',
|
||||
'await',
|
||||
'in',
|
||||
'of',
|
||||
'instanceof',
|
||||
]);
|
||||
|
||||
/** Control keywords whose parenthesized condition makes the following `/` a regex. */
|
||||
const CONTROL_CONDITION_KEYWORDS = new Set(['if', 'while', 'for', 'catch', 'switch', 'with']);
|
||||
|
||||
function createScanState(): ScanState {
|
||||
return {
|
||||
inString: null,
|
||||
inBlockComment: false,
|
||||
prevSignificant: null,
|
||||
lastIdentifier: null,
|
||||
parenDepth: 0,
|
||||
controlConditionDepth: null,
|
||||
regexAfterControlClose: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** `/` starts a regex unless the previous significant char ends a primary (`a / b`). */
|
||||
function previousAllowsDivision(state: ScanState): boolean {
|
||||
if (state.regexAfterControlClose) {
|
||||
return false;
|
||||
}
|
||||
if (state.lastIdentifier !== null && REGEX_AFTER_KEYWORDS.has(state.lastIdentifier)) {
|
||||
return false;
|
||||
}
|
||||
const prev = state.prevSignificant;
|
||||
if (prev === null) return false;
|
||||
// Identifier / number, call/index close, or a just-closed string/template.
|
||||
return /[\w$)\]]/.test(prev) || prev === "'" || prev === '"' || prev === '\u0060';
|
||||
}
|
||||
|
||||
/**
|
||||
* Blank one `/pattern/flags` literal (length-preserving). `start` is the
|
||||
* opening `/`. Character classes keep `/` from ending the pattern.
|
||||
*/
|
||||
function maskRegexLiteral(line: string, out: string[], start: number): number {
|
||||
out[start] = ' ';
|
||||
let i = start + 1;
|
||||
let inClass = false;
|
||||
while (i < line.length) {
|
||||
const c = line[i];
|
||||
out[i] = ' ';
|
||||
if (c === '\\') {
|
||||
if (i + 1 < line.length) out[i + 1] = ' ';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (inClass) {
|
||||
if (c === ']') inClass = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '[') {
|
||||
inClass = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '/') {
|
||||
i++;
|
||||
while (i < line.length && /[a-zA-Z]/.test(line[i])) {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace comments, string/template literals, and regex literals with spaces
|
||||
* so a regex can see only real code. `{` / `}` inside `/}/` must not move
|
||||
* brace depth. Updates `state` so the next line (and `maskSource`) inherit
|
||||
* the live comment/string machine and the last significant code char.
|
||||
*/
|
||||
function maskNonCode(line: string, state: ScanState): string {
|
||||
const out = line.split('');
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const ch = line[i];
|
||||
const next = i + 1 < line.length ? line[i + 1] : '';
|
||||
if (state.inString !== null) {
|
||||
out[i] = ' ';
|
||||
if (ch === '\\') {
|
||||
if (i + 1 < line.length) out[i + 1] = ' ';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === state.inString) {
|
||||
state.inString = null;
|
||||
state.prevSignificant = ch;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (state.inBlockComment) {
|
||||
out[i] = ' ';
|
||||
if (ch === '*' && next === '/') {
|
||||
if (i + 1 < line.length) out[i + 1] = ' ';
|
||||
state.inBlockComment = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '/') {
|
||||
while (i < line.length) {
|
||||
out[i] = ' ';
|
||||
i++;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
out[i] = ' ';
|
||||
if (i + 1 < line.length) out[i + 1] = ' ';
|
||||
state.inBlockComment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_$]/.test(ch)) {
|
||||
let j = i;
|
||||
let ident = '';
|
||||
while (j < line.length && /[\w$]/.test(line[j])) {
|
||||
ident += line[j];
|
||||
j++;
|
||||
}
|
||||
// Property names (`foo.return / x`) are not keyword introducers.
|
||||
state.lastIdentifier = state.prevSignificant === '.' ? null : ident;
|
||||
state.prevSignificant = ident.charAt(ident.length - 1);
|
||||
state.regexAfterControlClose = false;
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && !previousAllowsDivision(state)) {
|
||||
i = maskRegexLiteral(line, out, i);
|
||||
state.prevSignificant = '/';
|
||||
state.lastIdentifier = null;
|
||||
state.regexAfterControlClose = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '\u0060') {
|
||||
// Keep `'create':` / `"admin-panel":` visible so PROCEDURE_KEY_RE and
|
||||
// ROUTER_OPEN_RE can see quoted keys after the mask. A real string
|
||||
// (no `ident` + matching quote + colon) is still blanked.
|
||||
if (ch !== '\u0060') {
|
||||
const quotedKey = line.slice(i).match(/^(['"])([\w$.-]+)\1\s*:/);
|
||||
if (quotedKey) {
|
||||
i += quotedKey[0].length;
|
||||
state.prevSignificant = ':';
|
||||
state.lastIdentifier = null;
|
||||
state.regexAfterControlClose = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out[i] = ' ';
|
||||
state.inString = ch;
|
||||
state.lastIdentifier = null;
|
||||
state.regexAfterControlClose = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '(') {
|
||||
state.parenDepth++;
|
||||
if (state.lastIdentifier !== null && CONTROL_CONDITION_KEYWORDS.has(state.lastIdentifier)) {
|
||||
state.controlConditionDepth = state.parenDepth;
|
||||
}
|
||||
state.prevSignificant = '(';
|
||||
state.lastIdentifier = null;
|
||||
state.regexAfterControlClose = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === ')') {
|
||||
if (
|
||||
state.controlConditionDepth !== null &&
|
||||
state.parenDepth === state.controlConditionDepth
|
||||
) {
|
||||
state.regexAfterControlClose = true;
|
||||
state.controlConditionDepth = null;
|
||||
} else {
|
||||
state.regexAfterControlClose = false;
|
||||
}
|
||||
if (state.parenDepth > 0) state.parenDepth--;
|
||||
state.prevSignificant = ')';
|
||||
state.lastIdentifier = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!/\s/.test(ch)) {
|
||||
state.prevSignificant = ch;
|
||||
state.lastIdentifier = null;
|
||||
state.regexAfterControlClose = false;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
/** Whole-file mask. Length-preserving so indices align with `content`. */
|
||||
function maskSource(content: string): string {
|
||||
const state = createScanState();
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => maskNonCode(line, state))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* A nested router literal ('admin: adminProcedure.router({ ... })'). openDepth
|
||||
* is the brace depth INSIDE the router's object literal, so the frame pops as
|
||||
* soon as depth drops back below it (i.e. at the router's closing brace).
|
||||
*/
|
||||
interface NestFrame {
|
||||
name: string;
|
||||
openDepth: number;
|
||||
/** `(` depth of the `router(` that opened this nest. */
|
||||
parenDepth: number;
|
||||
}
|
||||
|
||||
// Router open: 'name: t.router(', 'name: trpc.router(',
|
||||
// 'name: createTRPCRouter(', 'name: someProcedure.router(', and the bare
|
||||
// 'name: router(' style ('import { router } from "../trpc"' re-exports are
|
||||
// common in v11 codebases). Same `(?:^|[{,])` prefix as PROCEDURE_KEY_RE so a
|
||||
// compact `admin: t.router({ list: ...})` mid-line still pushes nestStack.
|
||||
// The object-literal body opens at the first '{' scanned after the match
|
||||
// (almost always on the same line).
|
||||
const ROUTER_OPEN_RE =
|
||||
/(?:^|[{,])\s*(?:['"]([\w$.-]+)['"]|((\w+)))\s*:\s*(?:(?:t|trpc|tRPC)\s*\.\s*router|createTRPCRouter|\w+Procedure\s*\.\s*router|router)\s*\(/;
|
||||
|
||||
// Same-file `const adminRouter = t.router({` / `createTRPCRouter(` / bare `router(`.
|
||||
const ROUTER_BINDING_RE =
|
||||
/(?:export\s+)?(?:const|let|var)\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(?:(?:t|trpc|tRPC)\s*\.\s*router|createTRPCRouter|router)\s*\(/;
|
||||
|
||||
// Identifier composition: `admin: adminRouter,` / last-property `admin: adminRouter`.
|
||||
// Optional `as` / `satisfies` tail (`admin: adminRouter as const`) still mounts.
|
||||
// `list: publicProcedure.query(` does not match — the next token is `.`.
|
||||
const ROUTER_REF_RE =
|
||||
/(?:^|[{,])\s*(?:['"]([\w$.-]+)['"]|((\w+)))\s*:\s*([A-Za-z_$][\w$]*)\s*(?:(?:as|satisfies)\b[^,}]*)?(?:[,}]|$)/;
|
||||
|
||||
// `export const appRouter = t.merge('post.', postRouter)` — the merge string
|
||||
// is the file prefix; this records a zero-hop mount so postRouter procedures
|
||||
// are not dropped as unmounted.
|
||||
const MERGE_ROUTER_REF_RE =
|
||||
/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:t|trpc|tRPC|\w+Router)\s*\.\s*merge\s*\([^,]+,\s*([A-Za-z_$][\w$]*)\s*\)/;
|
||||
|
||||
// `/g` copies for matchAll. The non-global originals stay lastIndex-safe for `.test()`.
|
||||
const TERMINAL_CALL_RE_G = new RegExp(TERMINAL_CALL_RE.source, 'gm');
|
||||
const PROCEDURE_KEY_RE_G = new RegExp(PROCEDURE_KEY_RE.source, 'g');
|
||||
const ROUTER_OPEN_RE_G = new RegExp(ROUTER_OPEN_RE.source, 'g');
|
||||
const ROUTER_BINDING_RE_G = new RegExp(ROUTER_BINDING_RE.source, 'g');
|
||||
const ROUTER_REF_RE_G = new RegExp(ROUTER_REF_RE.source, 'g');
|
||||
const MERGE_ROUTER_REF_RE_G = new RegExp(MERGE_ROUTER_REF_RE.source, 'g');
|
||||
// Every `.query(` / `.mutation(` / `.subscription(` — the scanner emits
|
||||
// only at the procedure's parenDepth, and only when TERMINAL_CALL_RE
|
||||
// matched or the previous non-space is `)` (chained `.input(...).query(`).
|
||||
const ANY_TERMINAL_RE_G = /\.\s*(query|mutation|subscription)\s*\(/g;
|
||||
|
||||
function matchAll(re: RegExp, text: string): RegExpMatchArray[] {
|
||||
re.lastIndex = 0;
|
||||
return [...text.matchAll(re)];
|
||||
}
|
||||
|
||||
function findPrefixRouterBinding(masked: string): RegExpMatchArray | null {
|
||||
// Prefer the live composer (`appRouter` / `rootRouter`) when this file both
|
||||
// defines a leaf `export const postRouter` and remounts it on `appRouter`.
|
||||
// Falling through to the first exported *Router would treat that leaf as
|
||||
// root and drop the remount key (`blog: postRouter` → `/trpc/post.list`).
|
||||
const composer = masked.match(/export\s+(?:const|let|var)\s+((?:app|root)Router)\s*=\s*/i);
|
||||
if (composer && composer.index !== undefined) return composer;
|
||||
const exportBinding = masked.match(/export\s+(?:const|let|var)\s+(\w+Router)\s*=\s*/);
|
||||
const anyBinding = masked.match(/(?:export\s+)?(?:const|let|var)\s+(\w+Router)\s*=\s*/);
|
||||
const binding = exportBinding ?? anyBinding;
|
||||
if (!binding || binding.index === undefined) return null;
|
||||
return binding;
|
||||
}
|
||||
|
||||
interface RouterMount {
|
||||
parent: string | null;
|
||||
key: string;
|
||||
child: string;
|
||||
/** Inline `t.router({ ... })` names wrapping this identifier mount. */
|
||||
nestParts: string[];
|
||||
}
|
||||
|
||||
interface BindingFrame {
|
||||
name: string;
|
||||
openDepth: number;
|
||||
/** `(` depth of the `router(` that opened this object literal. */
|
||||
parenDepth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk identifier mounts from a nested router up to the file's prefix binding.
|
||||
* Memoized per binding: a depth-N chain used to recopy the ancestor path at
|
||||
* every procedure (O(procedures × depth²) array copies — quadratic-plus on
|
||||
* the C# concentrated-namespace analogue). One table is O(bindings + mounts).
|
||||
*/
|
||||
function buildMountPathLookup(
|
||||
root: string | null,
|
||||
mountsByChild: Map<string, RouterMount[]>,
|
||||
): (binding: string | null) => string[][] {
|
||||
const memo = new Map<string, string[][]>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
return function paths(binding: string | null): string[][] {
|
||||
if (!binding || !root || binding === root) return [[]];
|
||||
const cached = memo.get(binding);
|
||||
if (cached) return cached;
|
||||
// A cycle is not a root: returning [[]] fabricated `/trpc/b.list`.
|
||||
if (visiting.has(binding)) return [];
|
||||
visiting.add(binding);
|
||||
const parents = mountsByChild.get(binding);
|
||||
// Unmounted non-root bindings contribute no paths (pendingEmits drop).
|
||||
let result: string[][] = [];
|
||||
if (parents && parents.length > 0) {
|
||||
const out: string[][] = [];
|
||||
for (const mount of parents) {
|
||||
const hop = mount.key ? [...mount.nestParts, mount.key] : [...mount.nestParts];
|
||||
if (!mount.parent) {
|
||||
out.push(hop);
|
||||
continue;
|
||||
}
|
||||
for (const prefix of paths(mount.parent)) {
|
||||
out.push([...prefix, ...hop]);
|
||||
}
|
||||
}
|
||||
if (out.length > 0) result = out;
|
||||
}
|
||||
visiting.delete(binding);
|
||||
// Memoize live paths immediately so a depth-N chain is one walk, not one
|
||||
// remount per procedure. Leave nested empty results uncached: a cycle cut
|
||||
// returns [] for this hop only, and memoizing that would drop a later live
|
||||
// path (`a.b.list` after `a` ↔ `b`). A finished top-level [] is real
|
||||
// (unmounted / no root path) and is safe to cache.
|
||||
if (result.length > 0 || visiting.size === 0) memo.set(binding, result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function prevNonSpace(text: string, index: number): string | null {
|
||||
for (let p = index - 1; p >= 0; p--) {
|
||||
if (!/\s/.test(text[p])) return text[p];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `create: publicProcedure.mutation(handler)` — the graph Function is
|
||||
// `handler`, not the object key. Inline `async` / `function` / `() =>`
|
||||
// callbacks keep the key so HOC pair naming still matches.
|
||||
const IDENTIFIER_CALLBACK_RESERVED = new Set(['async', 'function', 'await', 'new', 'yield']);
|
||||
const IDENTIFIER_CALLBACK_RE =
|
||||
/^\.\s*(?:query|mutation|subscription)\s*\(\s*([A-Za-z_$][\w$]*)\s*[,)]/;
|
||||
|
||||
function identifierCallbackName(textFromDot: string): string | undefined {
|
||||
const match = textFromDot.match(IDENTIFIER_CALLBACK_RE);
|
||||
if (!match || IDENTIFIER_CALLBACK_RESERVED.has(match[1])) return undefined;
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/** Line-start `^ .query(` matches at column 0; the scanner keys the `.`. */
|
||||
function terminalDotIndex(text: string, start: number): number {
|
||||
const dot = text.indexOf('.', start);
|
||||
return dot === -1 ? start : dot;
|
||||
}
|
||||
|
||||
export function extractTrpcRoutes(filePath: string, content: string): ExtractedRoute[] {
|
||||
// Raw miss is decisive: masking never invents `query` / `mutation` /
|
||||
// `subscription`, so skip the per-char mask on files that cannot be routers.
|
||||
if (!hasTrpcTerminalKeyword(content)) return [];
|
||||
const maskedSource = maskSource(content);
|
||||
if (!isTrpcRouterFileMasked(maskedSource)) return [];
|
||||
|
||||
const routesByPath = new Map<string, ExtractedRoute>();
|
||||
const binding = findPrefixRouterBinding(maskedSource);
|
||||
const prefix = prefixFromBinding(maskedSource, content, filePath, binding);
|
||||
const rootBinding = binding?.[1] ?? null;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const nestStack: NestFrame[] = [];
|
||||
const bindingStack: BindingFrame[] = [];
|
||||
const mounts: RouterMount[] = [];
|
||||
const routerBindingNames = new Set<string>();
|
||||
const scanState = createScanState();
|
||||
let depth = 0;
|
||||
let parenDepth = 0;
|
||||
// Set on a router-open; the first '{' scanned afterwards opens the
|
||||
// router's object literal and pushes the frame (handles both
|
||||
// 'user: t.router({' and the rare '{' on the following line).
|
||||
let pendingRouterName: string | null = null;
|
||||
let pendingBindingName: string | null = null;
|
||||
let currentProcedure: { name: string; depth: number; parenDepth: number } | null = null;
|
||||
|
||||
const pendingEmits: Array<{
|
||||
method: string;
|
||||
localParts: string[];
|
||||
containingBinding: string | null;
|
||||
terminalLine: number;
|
||||
methodName: string;
|
||||
}> = [];
|
||||
|
||||
const emitProcedure = (
|
||||
method: string,
|
||||
proc: { name: string },
|
||||
terminalLine: number,
|
||||
textFromDot: string,
|
||||
): void => {
|
||||
// Nested routers compose the full path ('user.admin.list'): without the
|
||||
// stack, same-named procedures in sibling routers deduped to ONE route
|
||||
// and the survivor carried the wrong path. Identifier mounts
|
||||
// (`admin: adminRouter`) are applied after the scan.
|
||||
pendingEmits.push({
|
||||
method,
|
||||
localParts: [...nestStack.map((frame) => frame.name), proc.name],
|
||||
containingBinding: bindingStack[bindingStack.length - 1]?.name ?? null,
|
||||
terminalLine,
|
||||
methodName: identifierCallbackName(textFromDot) ?? proc.name,
|
||||
});
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Keys / router-opens / terminals all see the same comment/string mask so
|
||||
// a commented-out `create: publicProcedure.query(` cannot steal the
|
||||
// pending key or emit a phantom route.
|
||||
const masked = maskNonCode(line, scanState);
|
||||
|
||||
const routerByIndex = new Map<number, string>();
|
||||
for (const m of matchAll(ROUTER_OPEN_RE_G, masked)) {
|
||||
routerByIndex.set(m.index ?? 0, m[1] || m[3]);
|
||||
}
|
||||
const bindingByIndex = new Map<number, string>();
|
||||
for (const m of matchAll(ROUTER_BINDING_RE_G, masked)) {
|
||||
bindingByIndex.set(m.index ?? 0, m[1]);
|
||||
routerBindingNames.add(m[1]);
|
||||
}
|
||||
const keyByIndex = new Map<number, string>();
|
||||
for (const m of matchAll(PROCEDURE_KEY_RE_G, masked)) {
|
||||
const idx = m.index ?? 0;
|
||||
// 'admin: adminProcedure.router(' matches both; the router-open wins
|
||||
// so we do not poison currentProcedure (the double-prefix bug).
|
||||
if (!routerByIndex.has(idx)) keyByIndex.set(idx, m[1] || m[3]);
|
||||
}
|
||||
const refByIndex = new Map<number, { key: string; child: string }>();
|
||||
for (const m of matchAll(ROUTER_REF_RE_G, masked)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (routerByIndex.has(idx) || keyByIndex.has(idx)) continue;
|
||||
refByIndex.set(idx, { key: m[1] || m[3], child: m[4] });
|
||||
}
|
||||
const terminalByIndex = new Map<number, string>();
|
||||
for (const m of matchAll(TERMINAL_CALL_RE_G, masked)) {
|
||||
terminalByIndex.set(terminalDotIndex(masked, m.index ?? 0), m[1]);
|
||||
}
|
||||
const anyTerminalByIndex = new Map<number, string>();
|
||||
for (const m of matchAll(ANY_TERMINAL_RE_G, masked)) {
|
||||
anyTerminalByIndex.set(m.index ?? 0, m[1]);
|
||||
}
|
||||
|
||||
for (let c = 0; c < masked.length; c++) {
|
||||
const ch = masked[c];
|
||||
if (ch === '{') {
|
||||
depth++;
|
||||
if (pendingBindingName !== null) {
|
||||
bindingStack.push({ name: pendingBindingName, openDepth: depth, parenDepth });
|
||||
pendingBindingName = null;
|
||||
}
|
||||
if (pendingRouterName !== null) {
|
||||
nestStack.push({ name: pendingRouterName, openDepth: depth, parenDepth });
|
||||
pendingRouterName = null;
|
||||
}
|
||||
} else if (ch === '}') {
|
||||
depth--;
|
||||
while (nestStack.length > 0 && nestStack[nestStack.length - 1].openDepth > depth) {
|
||||
nestStack.pop();
|
||||
}
|
||||
while (bindingStack.length > 0 && bindingStack[bindingStack.length - 1].openDepth > depth) {
|
||||
bindingStack.pop();
|
||||
}
|
||||
// Nested `}),` inside `.input(z.object({...}))` returns TO the
|
||||
// recorded depth — the procedure chain is still open. Only a `}`
|
||||
// that drops BELOW the key's object (router / statement close)
|
||||
// clears the pending procedure.
|
||||
if (currentProcedure !== null && depth < currentProcedure.depth) {
|
||||
currentProcedure = null;
|
||||
}
|
||||
} else if (ch === '(') {
|
||||
parenDepth++;
|
||||
} else if (ch === ')') {
|
||||
if (parenDepth > 0) parenDepth--;
|
||||
} else if (ch === ';' && currentProcedure !== null && depth <= currentProcedure.depth) {
|
||||
currentProcedure = null;
|
||||
}
|
||||
|
||||
// Brace (and nest push) at this index first, then router-open / key
|
||||
// that used `{` or `,` as their regex prefix. `{ admin: t.router({`
|
||||
// must set pending on the first `{` *after* that `{` opened the parent,
|
||||
// so the inner `{` is the one that pushes `admin`.
|
||||
const bindingName = bindingByIndex.get(c);
|
||||
if (bindingName !== undefined) {
|
||||
pendingBindingName = bindingName;
|
||||
}
|
||||
const routerName = routerByIndex.get(c);
|
||||
if (routerName !== undefined) {
|
||||
pendingRouterName = routerName;
|
||||
} else {
|
||||
const keyName = keyByIndex.get(c);
|
||||
if (keyName !== undefined) {
|
||||
currentProcedure = { name: keyName, depth, parenDepth };
|
||||
}
|
||||
}
|
||||
const ref = refByIndex.get(c);
|
||||
if (ref !== undefined) {
|
||||
const routerOpen = nestStack[nestStack.length - 1] ?? bindingStack[bindingStack.length - 1];
|
||||
// Only direct properties of a router object: refs inside `.query(` /
|
||||
// callbacks sit at a deeper parenDepth than the router-open `(`.
|
||||
if (!routerOpen || parenDepth <= routerOpen.parenDepth) {
|
||||
mounts.push({
|
||||
parent: bindingStack[bindingStack.length - 1]?.name ?? pendingBindingName,
|
||||
key: ref.key,
|
||||
child: ref.child,
|
||||
nestParts: nestStack.map((frame) => frame.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const candidate = anyTerminalByIndex.get(c);
|
||||
if (
|
||||
candidate !== undefined &&
|
||||
currentProcedure !== null &&
|
||||
parenDepth === currentProcedure.parenDepth &&
|
||||
(terminalByIndex.has(c) || prevNonSpace(masked, c) === ')')
|
||||
) {
|
||||
// Identifier callbacks are `^`-anchored; current line is enough except
|
||||
// prettier-broken `.query(\n handler\n)`. Cap at two following lines
|
||||
// so we do not rescan the file tail on every terminal.
|
||||
emitProcedure(
|
||||
candidate,
|
||||
currentProcedure,
|
||||
i + 1,
|
||||
lines
|
||||
.slice(i, i + 3)
|
||||
.join('\n')
|
||||
.slice(c),
|
||||
);
|
||||
currentProcedure = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of matchAll(MERGE_ROUTER_REF_RE_G, maskedSource)) {
|
||||
mounts.push({
|
||||
parent: m[1],
|
||||
key: '',
|
||||
child: m[2],
|
||||
nestParts: [],
|
||||
});
|
||||
}
|
||||
|
||||
const mountsByChild = new Map<string, RouterMount[]>();
|
||||
for (const mount of mounts) {
|
||||
if (!routerBindingNames.has(mount.child)) continue;
|
||||
const list = mountsByChild.get(mount.child) ?? [];
|
||||
list.push(mount);
|
||||
mountsByChild.set(mount.child, list);
|
||||
}
|
||||
|
||||
const mountPathsOf = buildMountPathLookup(rootBinding, mountsByChild);
|
||||
|
||||
for (const pending of pendingEmits) {
|
||||
const mountPaths = mountPathsOf(pending.containingBinding);
|
||||
for (const mountParts of mountPaths) {
|
||||
const procedurePath = [
|
||||
...(prefix ? [prefix] : []),
|
||||
...mountParts,
|
||||
...pending.localParts,
|
||||
].join('.');
|
||||
|
||||
// Last write wins: `t.router({ list: a, list: b })` is a JS object
|
||||
// literal, so tRPC only ever sees `b`. Keeping the first emit would
|
||||
// bind CALLS to dead handler code. Sibling routers still stay distinct
|
||||
// because `procedurePath` includes the nest (`admin.list` vs `billing.list`).
|
||||
routesByPath.set(procedurePath, {
|
||||
filePath,
|
||||
httpMethod: HTTP_METHOD_MAP[pending.method] ?? 'POST',
|
||||
routePath: '/trpc/' + procedurePath,
|
||||
routeName: procedurePath,
|
||||
// A tRPC router is an object binding, not a class. Route consumers
|
||||
// resolve 'controllerName' through lookupClassByName
|
||||
// (call-processor.ts), which would either skip these routes (no such
|
||||
// class) or mis-link an unrelated same-named class — leave it unset;
|
||||
// call-processor binds the same-file handler symbol directly.
|
||||
controllerName: null,
|
||||
methodName: pending.methodName,
|
||||
middleware: [],
|
||||
prefix: null,
|
||||
// pickSameFileHandler compares this 1-based line to Function
|
||||
// startLine (0-based). The handler is the terminal callback
|
||||
// (`.query` / `.mutation` / `.subscription`), so emit that line
|
||||
// — not the object-key line, which is often earlier after
|
||||
// `.input()` / `.use()` chaining. Same-line key+terminal is unchanged.
|
||||
lineNumber: pending.terminalLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...routesByPath.values()];
|
||||
}
|
||||
|
|
@ -11,7 +11,10 @@
|
|||
* `@reference.inherits` captures in each language's `languages/<lang>/captures.ts`.
|
||||
*/
|
||||
|
||||
import { ARRAY_METHOD_NOT_ANY_OF_PREDICATE } from './ts-js-hoc-utils.js';
|
||||
import {
|
||||
ARRAY_METHOD_NOT_ANY_OF_PREDICATE,
|
||||
DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE,
|
||||
} from './ts-js-hoc-utils.js';
|
||||
|
||||
// TypeScript queries - works with tree-sitter-typescript
|
||||
export const TYPESCRIPT_QUERIES = `
|
||||
|
|
@ -187,6 +190,183 @@ export const TYPESCRIPT_QUERIES = `
|
|||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped pair values: procedure.mutation(async ({ input }) => { ... }).
|
||||
; tRPC, Express route definitions, and similar frameworks use this pattern where
|
||||
; an object property's value is a call_expression wrapping an arrow/function callback.
|
||||
; Mirrors the registry-primary patterns in languages/typescript/query.ts.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; Member-expression variants exclude callback-taking array methods —
|
||||
; '{ visible: items.filter(item => item.active) }' is a Const holding an
|
||||
; array, not a Function — same exclusion as the HOC variable rules below.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; String-key pair variants: { 'create': procedure.mutation(async () => ...) }.
|
||||
; Mirrors the string-key rules in languages/typescript/query.ts so both
|
||||
; pipelines attribute quoted-key procedures identically. Quoted-key identifier
|
||||
; callees ({ 'handler': wrap(() => {}) }) match these string-key rules, not
|
||||
; the identifier-key block above.
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; Curried pair HOC: create: publicProcedure.mutation(withAuth(async () => {})).
|
||||
; Existing pair-HOC rules require the arrow to be a DIRECT argument of the
|
||||
; pair's call_expression. Object-pair only — do NOT add a variable-level
|
||||
; nested-HOC rule (memo(forwardRef(...)) must stay a Variable).
|
||||
; Mirrors languages/typescript/query.ts.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; Mirrors the registry-primary patterns in \`languages/typescript/query.ts\`
|
||||
; so the legacy Call-Resolution DAG and the registry-primary pipeline
|
||||
|
|
@ -396,6 +576,11 @@ export const TYPESCRIPT_QUERIES = `
|
|||
property: (property_identifier) @call.name))
|
||||
(type_arguments)) @call
|
||||
|
||||
; NOTE: Curried applications f(x)(y) are deliberately not anchored — the inner
|
||||
; f(x) is captured by the plain call pattern above, and anchoring the outer
|
||||
; application would mis-attribute its arguments to f (wrong arity/argument
|
||||
; types for overload resolution).
|
||||
|
||||
; Constructor calls: new Foo()
|
||||
(new_expression
|
||||
constructor: (identifier) @call.name) @call
|
||||
|
|
@ -776,6 +961,183 @@ export const JAVASCRIPT_QUERIES = `
|
|||
key: (string (string_fragment) @name)
|
||||
value: (function_expression)) @definition.function
|
||||
|
||||
; HOC-wrapped pair values: procedure.mutation(async ({ input }) => { ... }).
|
||||
; tRPC, Express route definitions, and similar frameworks use this pattern where
|
||||
; an object property's value is a call_expression wrapping an arrow/function callback.
|
||||
; Mirrors the registry-primary patterns in languages/javascript/query.ts.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; Member-expression variants exclude callback-taking array methods —
|
||||
; '{ visible: items.filter(item => item.active) }' is a Const holding an
|
||||
; array, not a Function — same exclusion as the HOC variable rules below.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; String-key pair variants: { 'create': procedure.mutation(async () => ...) }.
|
||||
; Mirrors the string-key rules in languages/javascript/query.ts so both
|
||||
; pipelines attribute quoted-key procedures identically. Quoted-key identifier
|
||||
; callees ({ 'handler': wrap(() => {}) }) match these string-key rules, not
|
||||
; the identifier-key block above.
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; Curried pair HOC: create: publicProcedure.mutation(withAuth(async () => {})).
|
||||
; Existing pair-HOC rules require the arrow to be a DIRECT argument of the
|
||||
; pair's call_expression. Object-pair only — do NOT add a variable-level
|
||||
; nested-HOC rule (memo(forwardRef(...)) must stay a Variable).
|
||||
; Mirrors languages/javascript/query.ts.
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (property_identifier) @name
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (identifier) @hoc
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(arrow_function))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
((pair
|
||||
key: (string (string_fragment) @name)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(call_expression
|
||||
function: (identifier)
|
||||
arguments: (arguments
|
||||
(function_expression))))))
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; HOC-wrapped variable declarations: \`const X = HOC((args) => { ... })\`.
|
||||
; See TYPESCRIPT_QUERIES section above for the full rationale (issue #1166
|
||||
; follow-up — covers forwardRef / memo / useCallback / useMemo / observer
|
||||
|
|
@ -883,9 +1245,9 @@ export const JAVASCRIPT_QUERIES = `
|
|||
value: (call_expression
|
||||
function: (member_expression
|
||||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
arguments: (arguments
|
||||
(arrow_function))))
|
||||
(#not-any-of? @callee "map" "filter" "reduce" "forEach" "find" "findIndex" "some" "every" "flatMap" "sort" "splice" "slice" "concat" "fill" "copyWithin" "join" "flat" "at" "entries" "keys" "values" "indexOf" "lastIndexOf" "includes" "pop" "push" "shift" "unshift" "reverse" "reduceRight" "toSorted" "toReversed" "toSpliced" "with")) @definition.function
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
(variable_declaration
|
||||
(variable_declarator
|
||||
|
|
@ -895,7 +1257,7 @@ export const JAVASCRIPT_QUERIES = `
|
|||
property: (property_identifier) @callee)
|
||||
arguments: (arguments
|
||||
(function_expression))))
|
||||
(#not-any-of? @callee "map" "filter" "reduce" "forEach" "find" "findIndex" "some" "every" "flatMap" "sort" "splice" "slice" "concat" "fill" "copyWithin" "join" "flat" "at" "entries" "keys" "values" "indexOf" "lastIndexOf" "includes" "pop" "push" "shift" "unshift" "reverse" "reduceRight" "toSorted" "toReversed" "toSpliced" "with")) @definition.function
|
||||
${ARRAY_METHOD_NOT_ANY_OF_PREDICATE}) @definition.function
|
||||
|
||||
; HOC-wrapped default exports (JS parity with TS patterns above).
|
||||
(export_statement
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export const ARRAY_METHOD_HOC_BLOCKLIST = [
|
|||
'toReversed',
|
||||
'toSpliced',
|
||||
'with',
|
||||
'then',
|
||||
'catch',
|
||||
'finally',
|
||||
'from',
|
||||
] as const;
|
||||
|
||||
export const ARRAY_METHOD_HOC_BLOCKLIST_SET: ReadonlySet<string> = new Set(
|
||||
|
|
@ -86,10 +90,12 @@ export const ARRAY_METHOD_NOT_ANY_OF_PREDICATE = buildNotAnyOfPredicate(
|
|||
ARRAY_METHOD_HOC_BLOCKLIST,
|
||||
);
|
||||
|
||||
export const DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE = buildNotAnyOfPredicate(
|
||||
'hoc',
|
||||
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST,
|
||||
);
|
||||
// `#not-any-of?` cannot share a query with another `#not-any-of?` on a
|
||||
// different capture: the earlier predicate is ignored. Pair-HOC rules already
|
||||
// use `#not-any-of? @callee`, so the identifier blocklist is `#not-eq?`
|
||||
// chains on `@hoc` instead. Same names, same capture.
|
||||
export const DEFAULT_EXPORT_IDENTIFIER_NOT_ANY_OF_PREDICATE =
|
||||
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST.map((value) => `(#not-eq? @hoc "${value}")`).join('\n ');
|
||||
|
||||
export function deriveDefaultExportHocName(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
|
|
@ -127,3 +133,54 @@ export function isBlockedDefaultExportHoc(node: SyntaxNode): boolean {
|
|||
const callee = callExpr.childForFieldName?.('function');
|
||||
return callee?.type === 'identifier' && DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text);
|
||||
}
|
||||
|
||||
// Core check on a call expression: does it register a callback with a built-in
|
||||
// that never stores the callback as a named callable? Identifier callees
|
||||
// (timers/microtasks) use the export-default blocklist; member callees use the
|
||||
// array/promise-method blocklist. Only calls that actually receive a function
|
||||
// argument qualify.
|
||||
export function isBlockedCallbackRegistrationCall(
|
||||
callExpr: SyntaxNode | null | undefined,
|
||||
): boolean {
|
||||
if (callExpr === null || callExpr === undefined || callExpr.type !== 'call_expression') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callee = callExpr.childForFieldName?.('function');
|
||||
let blocked = false;
|
||||
if (callee?.type === 'identifier') {
|
||||
blocked = DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET.has(callee.text);
|
||||
} else if (callee?.type === 'member_expression') {
|
||||
const property = callee.childForFieldName?.('property');
|
||||
blocked =
|
||||
property?.type === 'property_identifier' && ARRAY_METHOD_HOC_BLOCKLIST_SET.has(property.text);
|
||||
}
|
||||
if (!blocked) return false;
|
||||
|
||||
const args = callExpr.childForFieldName?.('arguments');
|
||||
return (
|
||||
args !== null &&
|
||||
args !== undefined &&
|
||||
args.namedChildren.some(
|
||||
(child) => child.type === 'arrow_function' || child.type === 'function_expression',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Pair-value form: { timer: setTimeout(() => ..., 100) } binds a handle and
|
||||
// { visible: items.filter(...) } binds a value - neither is a callable. The
|
||||
// capture emitters enforce the blocklists emit-side so the guarantee does not
|
||||
// depend on query-predicate sharing behavior in node-tree-sitter 0.21
|
||||
// (implicit-global stringValues across compiled queries). node is the
|
||||
// arrow/function captured inside the call's arguments; the registration call
|
||||
// is two hops up and must sit directly in pair-value position.
|
||||
export function isBlockedPairCallbackRegistration(node: SyntaxNode): boolean {
|
||||
const args = node.parent;
|
||||
if (args === null || args.type !== 'arguments') return false;
|
||||
|
||||
const callExpr = args.parent;
|
||||
if (callExpr === null || callExpr.type !== 'call_expression') return false;
|
||||
if (callExpr.parent?.type !== 'pair') return false;
|
||||
|
||||
return isBlockedCallbackRegistrationCall(callExpr);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
ARRAY_METHOD_HOC_BLOCKLIST_SET,
|
||||
DEFAULT_EXPORT_IDENTIFIER_BLOCKLIST_SET,
|
||||
deriveDefaultExportHocName,
|
||||
isBlockedCallbackRegistrationCall,
|
||||
} from '../ts-js-hoc-utils.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import type { SkippedPath } from './clone-safety.js';
|
||||
|
|
@ -2298,6 +2299,18 @@ const processFileGroup = (
|
|||
if (!defaultNodeLabel) continue;
|
||||
if (provider.shouldSkipDefinitionCapture?.(captureMap, defaultNodeLabel) === true) continue;
|
||||
|
||||
// `{ timer: setTimeout(() => …, 100) }` registers a timer, not a
|
||||
// Function — the TSQ-path twin of the emit-side gate in
|
||||
// languages/*/captures.ts (the query-level `#not-any-of?` predicate
|
||||
// is unreliable: node-tree-sitter 0.21 shares `stringValues` across
|
||||
// `#any-of?` predicates of a compiled query).
|
||||
if (
|
||||
definitionNode?.type === 'pair' &&
|
||||
isBlockedCallbackRegistrationCall(definitionNode.childForFieldName('value'))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
const extractedClassSymbol =
|
||||
definitionNode && provider.classExtractor?.isTypeDeclaration(definitionNode)
|
||||
|
|
@ -3180,6 +3193,13 @@ const processFileGroup = (
|
|||
for (const r of extractedRoutes) result.routes.push(r);
|
||||
}
|
||||
|
||||
// Content-based route extraction via provider hook (path-gate lives on
|
||||
// the provider; the worker does not name languages or frameworks).
|
||||
if (provider.extractTextRoutes) {
|
||||
const textRoutes = provider.extractTextRoutes(file.path, file.content);
|
||||
for (const r of textRoutes) result.routes.push(r);
|
||||
}
|
||||
|
||||
// Extract ORM queries (Prisma, Supabase)
|
||||
extractORMQueries(file.path, parseContent, result.ormQueries);
|
||||
|
||||
|
|
|
|||
|
|
@ -1597,6 +1597,8 @@ async function runFullAnalysisInner(
|
|||
if (options.registryName) {
|
||||
await registerRepo(repoPath, existingMeta, {
|
||||
name: options.registryName,
|
||||
onRename: (previousName, nextName) =>
|
||||
log(`Registry name changed: "${previousName}" -> "${nextName}".`),
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
|
|
@ -2179,6 +2181,8 @@ async function runFullAnalysisInner(
|
|||
if (options.registryName) {
|
||||
await registerRepo(repoPath, existingMeta, {
|
||||
name: options.registryName,
|
||||
onRename: (previousName, nextName) =>
|
||||
log(`Registry name changed: "${previousName}" -> "${nextName}".`),
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
branch: placement.branch,
|
||||
storagePath,
|
||||
|
|
@ -4670,6 +4674,8 @@ async function runFullAnalysisInner(
|
|||
// will look up (#979).
|
||||
const projectName = await registerRepo(repoPath, meta, {
|
||||
name: options.registryName,
|
||||
onRename: (previousName, nextName) =>
|
||||
log(`Registry name changed: "${previousName}" -> "${nextName}".`),
|
||||
allowDuplicateName: options.allowDuplicateName,
|
||||
// Non-primary branch runs upsert into the entry's branches[]; the
|
||||
// primary/flat run (placement.branch === undefined) refreshes the
|
||||
|
|
|
|||
|
|
@ -13,13 +13,17 @@ export interface FTSIndexDefinition {
|
|||
// once they are populated by `descriptionExtractor` (#2270/#2286, issue #2299).
|
||||
//
|
||||
// IMPORTANT: every property must be a real column on its table (see
|
||||
// `core/lbug/schema.ts`). `File` has no `description` column, so it stays
|
||||
// name+content. All other entries below carry a `description` column.
|
||||
// `core/lbug/schema.ts`). `File` and `Route` have no `description` column
|
||||
// (`File` stays name+content; `Route` is name-only). All other entries
|
||||
// below carry a `description` column.
|
||||
//
|
||||
// Tables beyond the original 5 mirror `EMBEDDABLE_LABELS` (embeddings/types.ts):
|
||||
// indexing the same set keeps a symbol's doc comment both keyword- and
|
||||
// semantically-searchable.
|
||||
// Symbol tables beyond the original 5 still mirror `EMBEDDABLE_LABELS`
|
||||
// (embeddings/types.ts): indexing the same set keeps a symbol's doc comment
|
||||
// both keyword- and semantically-searchable. Route is the exception: it is
|
||||
// keyword-only (`NAME_ONLY_PROPERTIES`) and is not in EMBEDDABLE_LABELS, so
|
||||
// it is not semantically searchable.
|
||||
const FTS_PROPERTIES = ['name', 'content', 'description'] as const;
|
||||
const NAME_ONLY_PROPERTIES = ['name'] as const;
|
||||
|
||||
export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
|
||||
// File has no `description` column — keep it name+content only.
|
||||
|
|
@ -50,10 +54,13 @@ export const FTS_INDEXES: readonly FTSIndexDefinition[] = [
|
|||
{ table: 'Variable', indexName: 'variable_fts', properties: FTS_PROPERTIES },
|
||||
// Markdown headings and static document declarations must be discoverable by name.
|
||||
{ table: 'Section', indexName: 'section_fts', properties: FTS_PROPERTIES },
|
||||
// Route URLs (e.g. "/trpc/admin.setSettings") are keyword-
|
||||
// searchable so agents can find a procedure from its URL without first
|
||||
// resolving the URL → handlerSymbolId → Function node. Route has no
|
||||
// `description`/`content` column (see ROUTE_SCHEMA), so this is name-only.
|
||||
{ table: 'Route', indexName: 'route_fts', properties: NAME_ONLY_PROPERTIES },
|
||||
];
|
||||
|
||||
const NAME_ONLY_PROPERTIES = ['name'] as const;
|
||||
|
||||
/** Return the FTS definitions compatible with one persisted content profile. */
|
||||
export const getFtsIndexes = (profile: FtsProfile = 'full'): readonly FTSIndexDefinition[] => {
|
||||
if (profile === 'full') return FTS_INDEXES;
|
||||
|
|
|
|||
|
|
@ -123,6 +123,11 @@ import {
|
|||
EXPLAIN_MAX_LIMIT,
|
||||
PDG_QUERY_DEFAULT_LIMIT,
|
||||
PDG_QUERY_MAX_LIMIT,
|
||||
QUERY_DEFAULT_LIMIT,
|
||||
QUERY_DEFAULT_MAX_SYMBOLS,
|
||||
QUERY_MAX_LIMIT,
|
||||
QUERY_MAX_MAX_SYMBOLS,
|
||||
CONTEXT_CHAIN_MAX_DEPTH,
|
||||
} from '../tools.js';
|
||||
import { foldNumericToolArgumentAliases } from '../tool-arguments.js';
|
||||
import { findImportCycles, IMPORT_CYCLE_LIMIT } from '../../core/graph/import-cycles.js';
|
||||
|
|
@ -180,6 +185,14 @@ const VALUE_CANDIDATE_TYPES: ReadonlySet<string> = new Set(['Const', 'Variable',
|
|||
*/
|
||||
const CANDIDATE_WINDOW = 20;
|
||||
|
||||
/**
|
||||
* Concurrent BFS walks when `query()` attaches per-process `chain`. Each walk
|
||||
* is already node-capped inside `_computeContextChain`; this bounds how many
|
||||
* of those walks run at once so a page of processes (and each group member
|
||||
* hitting this path) cannot open a full page of concurrent graph queries.
|
||||
*/
|
||||
const QUERY_CHAIN_BFS_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* `content` is an index capability rather than a promise that every symbol has
|
||||
* text. Retention `none` intentionally omits it, while `symbol` retains only
|
||||
|
|
@ -1449,6 +1462,57 @@ export function parseListReposPagination(
|
|||
return { limit, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* query() page bounds. Schema min/max is advisory — callTool does not enforce
|
||||
* inputSchema — so the backend rejects out-of-range values the same way
|
||||
* parseListReposPagination / pdg_query do (reject, not clamp).
|
||||
*/
|
||||
function describeBoundValue(raw: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(raw) ?? String(raw);
|
||||
} catch {
|
||||
return typeof raw === 'bigint' ? `${raw}n` : Object.prototype.toString.call(raw);
|
||||
}
|
||||
}
|
||||
|
||||
function parseQueryPageBound(
|
||||
value: unknown,
|
||||
field: 'limit' | 'max_symbols',
|
||||
fallback: number,
|
||||
max: number,
|
||||
): { ok: true; value: number } | { ok: false; error: string } {
|
||||
if (value === undefined) return { ok: true, value: fallback };
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid "${field}": expected an integer in [1, ${max}], got ${describeBoundValue(value)}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function parseChainDepth(
|
||||
value: unknown,
|
||||
): { ok: true; value: number } | { ok: false; error: string } {
|
||||
if (value === undefined) return { ok: true, value: 0 };
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isInteger(value) ||
|
||||
value < 0 ||
|
||||
value > CONTEXT_CHAIN_MAX_DEPTH
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid "chain_depth": expected an integer in [0, ${CONTEXT_CHAIN_MAX_DEPTH}], got ${describeBoundValue(value)}.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
function routeEnrichmentKey(method: string | undefined, url: string): string {
|
||||
return method ? `${method}:${url}` : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2655: a tool result can carry a `staleness` field only if it is a plain
|
||||
* object that isn't an error envelope and doesn't already carry one. Raw-array
|
||||
|
|
@ -2954,6 +3018,7 @@ export class LocalBackend {
|
|||
limit?: number;
|
||||
max_symbols?: number;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
// #2175: each consumer resolves the search_query/query alias itself (there is no
|
||||
|
|
@ -2964,10 +3029,27 @@ export class LocalBackend {
|
|||
return { error: 'search_query (or legacy query) parameter is required and cannot be empty.' };
|
||||
}
|
||||
|
||||
await this.ensureInitialized(repo);
|
||||
const parsedLimit = parseQueryPageBound(
|
||||
params.limit,
|
||||
'limit',
|
||||
QUERY_DEFAULT_LIMIT,
|
||||
QUERY_MAX_LIMIT,
|
||||
);
|
||||
if (parsedLimit.ok === false) return { error: parsedLimit.error };
|
||||
const parsedMaxSymbols = parseQueryPageBound(
|
||||
params.max_symbols,
|
||||
'max_symbols',
|
||||
QUERY_DEFAULT_MAX_SYMBOLS,
|
||||
QUERY_MAX_MAX_SYMBOLS,
|
||||
);
|
||||
if (parsedMaxSymbols.ok === false) return { error: parsedMaxSymbols.error };
|
||||
const parsedChainDepth = parseChainDepth(params.chain_depth);
|
||||
if (parsedChainDepth.ok === false) return { error: parsedChainDepth.error };
|
||||
const processLimit = parsedLimit.value;
|
||||
const maxSymbolsPerProcess = parsedMaxSymbols.value;
|
||||
const requestedChainDepth = parsedChainDepth.value;
|
||||
|
||||
const processLimit = params.limit || 5;
|
||||
const maxSymbolsPerProcess = params.max_symbols || 10;
|
||||
await this.ensureInitialized(repo);
|
||||
const requestedContent = params.include_content ?? false;
|
||||
// Do not trust a lingering graph property when the metadata contract says
|
||||
// source-derived text is unavailable. A full rebuild normally removes the
|
||||
|
|
@ -3070,6 +3152,7 @@ export class LocalBackend {
|
|||
heuristicLabel: string;
|
||||
processType: string;
|
||||
stepCount: number;
|
||||
entryPointId: string;
|
||||
totalScore: number;
|
||||
cohesionBoost: number;
|
||||
symbols: any[];
|
||||
|
|
@ -3105,13 +3188,16 @@ export class LocalBackend {
|
|||
for (const ids of chunk(nodeIds, LBUG_QUERY_BATCH_SIZE)) {
|
||||
// Processes each symbol participates in. `n.id AS nodeId` is prepended as
|
||||
// column 0 so rows from many symbols can be re-associated to their symbol.
|
||||
// also fetch `p.entryPointId` so we can (a) mark the entry
|
||||
// symbol with `is_entry_point: true` in `process_symbols` and (b) look up
|
||||
// the route attached to this process (if any) via ENTRY_POINT_OF.
|
||||
try {
|
||||
const rows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (n)-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
WHERE n.id IN $nodeIds
|
||||
RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step
|
||||
RETURN n.id AS nodeId, p.id AS pid, p.label AS label, p.heuristicLabel AS heuristicLabel, p.processType AS processType, p.stepCount AS stepCount, r.step AS step, p.entryPointId AS entryPointId
|
||||
ORDER BY nodeId, pid, step
|
||||
`,
|
||||
{ nodeIds: ids },
|
||||
|
|
@ -3229,6 +3315,10 @@ export class LocalBackend {
|
|||
const pType = row.processType ?? row[4];
|
||||
const stepCount = row.stepCount ?? row[5];
|
||||
const step = row.step ?? row[6];
|
||||
// entryPointId is the new column from STEP_IN_PROCESS.
|
||||
// Falls back to '' when the Process node predates the property or
|
||||
// the column is null (older index).
|
||||
const entryPointId = row.entryPointId ?? row[7] ?? '';
|
||||
|
||||
if (!processMap.has(pid)) {
|
||||
processMap.set(pid, {
|
||||
|
|
@ -3237,6 +3327,7 @@ export class LocalBackend {
|
|||
heuristicLabel: hLabel,
|
||||
processType: pType,
|
||||
stepCount,
|
||||
entryPointId,
|
||||
totalScore: 0,
|
||||
cohesionBoost: 0,
|
||||
symbols: [],
|
||||
|
|
@ -3255,19 +3346,205 @@ export class LocalBackend {
|
|||
}
|
||||
}
|
||||
|
||||
const routeDefinitions = definitions.filter(
|
||||
(def) => def.type === 'Route' && typeof def.id === 'string' && def.id.length > 0,
|
||||
);
|
||||
if (routeDefinitions.length > 0) {
|
||||
const handlersByRoute = new Map<
|
||||
string,
|
||||
Array<{
|
||||
handlerId: string;
|
||||
handlerName?: string;
|
||||
handlerFilePath?: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
}>
|
||||
>();
|
||||
try {
|
||||
for (const ids of chunk(
|
||||
routeDefinitions.map((def) => def.id as string),
|
||||
LBUG_ID_PROBE_BATCH_SIZE,
|
||||
)) {
|
||||
const rows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
WHERE route.id IN $routeIds
|
||||
RETURN route.id AS routeId, handler.id AS handlerId, handler.name AS handlerName,
|
||||
handler.filePath AS handlerFilePath, route.name AS url, route.method AS method
|
||||
`,
|
||||
{ routeIds: ids },
|
||||
);
|
||||
for (const row of rows) {
|
||||
const routeId = String(row.routeId ?? row[0] ?? '');
|
||||
const handlerId = String(row.handlerId ?? row[1] ?? '');
|
||||
if (!routeId || !handlerId) continue;
|
||||
const list = handlersByRoute.get(routeId) ?? [];
|
||||
list.push({
|
||||
handlerId,
|
||||
handlerName: row.handlerName ?? row[2] ?? undefined,
|
||||
handlerFilePath: row.handlerFilePath ?? row[3] ?? undefined,
|
||||
url: row.url ?? row[4] ?? undefined,
|
||||
method: row.method ?? row[5] ?? undefined,
|
||||
});
|
||||
handlersByRoute.set(routeId, list);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logQueryError('query:route-definition-bridge', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
for (const def of routeDefinitions) {
|
||||
const url = typeof def.name === 'string' && def.name.length > 0 ? def.name : undefined;
|
||||
if (url) {
|
||||
def.follow_up = { tool: 'route_map', route: url };
|
||||
}
|
||||
const handlers = handlersByRoute.get(def.id);
|
||||
const preferred =
|
||||
handlers?.find((h) => h.handlerId.startsWith('Function:')) ?? handlers?.[0];
|
||||
if (preferred) {
|
||||
def.handlerSymbolId = preferred.handlerId;
|
||||
if (preferred.handlerName) def.handlerName = preferred.handlerName;
|
||||
if (preferred.handlerFilePath) def.handlerFilePath = preferred.handlerFilePath;
|
||||
}
|
||||
const routes: Array<{ url: string; method?: string }> = [];
|
||||
const seenRoutes = new Set<string>();
|
||||
for (const handler of handlers ?? []) {
|
||||
const routeUrl = handler.url || url;
|
||||
if (!routeUrl) continue;
|
||||
const methodStr = handler.method ? String(handler.method) : undefined;
|
||||
const key = routeEnrichmentKey(methodStr, routeUrl);
|
||||
if (seenRoutes.has(key)) continue;
|
||||
seenRoutes.add(key);
|
||||
routes.push(methodStr ? { url: routeUrl, method: methodStr } : { url: routeUrl });
|
||||
}
|
||||
if (routes.length === 0 && url) {
|
||||
routes.push({ url });
|
||||
}
|
||||
if (routes.length > 0) def.routes = routes;
|
||||
}
|
||||
}
|
||||
|
||||
timer.stop(); // symbol_lookup
|
||||
|
||||
// Step 3: Rank processes by aggregate score + internal cohesion boost
|
||||
// Rank first — route enrichment is unused in the sort and only the
|
||||
// returned page needs it.
|
||||
timer.start('ranking');
|
||||
const rankedProcesses = Array.from(processMap.values())
|
||||
.map((p) => ({
|
||||
...p,
|
||||
priority: p.totalScore + p.cohesionBoost * 0.1, // cohesion as subtle ranking signal
|
||||
routes: undefined as Array<{ url: string; method?: string }> | undefined,
|
||||
priority: p.totalScore + p.cohesionBoost * 0.1,
|
||||
}))
|
||||
.sort((a, b) => b.priority - a.priority || compareCodeUnits(a.id, b.id))
|
||||
.slice(0, processLimit);
|
||||
timer.stop(); // ranking
|
||||
|
||||
// Route lookup for the ranked page only. Current indexes emit:
|
||||
// (1) Route -[ENTRY_POINT_OF]-> Process (processes.ts)
|
||||
// (2) Route -[ENTRY_POINT_OF]-> <entryFn> (legacy indexes)
|
||||
// (3) handler -[HANDLES_ROUTE]-> Route (routes.ts)
|
||||
// Shape (2) is leftover — call-processor does not emit
|
||||
// Route-[ENTRY_POINT_OF]->Function. HANDLES_ROUTE is what lets an entry
|
||||
// symbol resolve when no Process edge exists.
|
||||
const routesByProcessId = new Map<string, Array<{ url: string; method?: string }>>();
|
||||
if (rankedProcesses.length > 0) {
|
||||
const pidByEntryPoint = new Map<string, string>();
|
||||
for (const p of rankedProcesses) {
|
||||
if (p.entryPointId) pidByEntryPoint.set(p.entryPointId, p.id);
|
||||
}
|
||||
try {
|
||||
const pidList = rankedProcesses.map((p) => p.id);
|
||||
for (const pidChunk of chunk(pidList, LBUG_ID_PROBE_BATCH_SIZE)) {
|
||||
const entryIds = pidChunk
|
||||
.map((pid) => processMap.get(pid)?.entryPointId)
|
||||
.filter((id): id is string => !!id && id.length > 0);
|
||||
const routeRows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
entryIds.length === 0
|
||||
? `
|
||||
MATCH (route:Route)-[r:CodeRelation {type: 'ENTRY_POINT_OF'}]->(p:Process)
|
||||
WHERE p.id IN $pids
|
||||
RETURN p.id AS pid, route.name AS url, route.method AS method
|
||||
`
|
||||
: `
|
||||
MATCH (route:Route)-[r:CodeRelation {type: 'ENTRY_POINT_OF'}]->(p:Process)
|
||||
WHERE p.id IN $pids
|
||||
RETURN p.id AS pid, route.name AS url, route.method AS method
|
||||
UNION ALL
|
||||
MATCH (route:Route)-[r:CodeRelation {type: 'ENTRY_POINT_OF'}]->(entryFn)
|
||||
WHERE entryFn.id IN $entryIds
|
||||
RETURN entryFn.id AS pid, route.name AS url, route.method AS method
|
||||
UNION ALL
|
||||
MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
WHERE handler.id IN $entryIds
|
||||
RETURN handler.id AS pid, route.name AS url, route.method AS method
|
||||
`,
|
||||
{
|
||||
pids: pidChunk,
|
||||
entryIds,
|
||||
},
|
||||
);
|
||||
for (const row of routeRows) {
|
||||
const targetPid = row.pid ?? row[0];
|
||||
const url = row.url ?? row[1];
|
||||
const method = row.method ?? row[2];
|
||||
const owningPid = processMap.has(targetPid)
|
||||
? targetPid
|
||||
: pidByEntryPoint.get(targetPid);
|
||||
if (!owningPid || !url) continue;
|
||||
const methodStr = method ? String(method) : undefined;
|
||||
const dedupKey = routeEnrichmentKey(methodStr, url);
|
||||
const list = routesByProcessId.get(owningPid) ?? [];
|
||||
if (!list.some((r) => routeEnrichmentKey(r.method, r.url) === dedupKey)) {
|
||||
list.push(methodStr ? { url, method: methodStr } : { url });
|
||||
routesByProcessId.set(owningPid, list);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logQueryError('query:route-lookup', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
for (const p of rankedProcesses) {
|
||||
p.routes = routesByProcessId.get(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional BFS chain enrichment (mirrors context({chain_depth})). Attached
|
||||
// per ranked process, keyed on the process's entry-point symbol so the
|
||||
// chain starts where the flow starts. Bounded: one BFS per process (at
|
||||
// most processLimit), each internally capped by _computeContextChain (50
|
||||
// nodes per direction per depth layer), and at most
|
||||
// QUERY_CHAIN_BFS_CONCURRENCY walks in flight. This is the per-query BFS
|
||||
// cap; GroupService.groupQuery also caps member-query fan-out so a group
|
||||
// of N members cannot run 4×N concurrent BFS walks.
|
||||
// Best-effort — a BFS failure drops that process's chain but never fails
|
||||
// the query.
|
||||
const chainByProcessId = new Map<string, any[]>();
|
||||
if (requestedChainDepth > 0 && rankedProcesses.length > 0) {
|
||||
timer.start('chain_enrichment');
|
||||
await mapConcurrent(
|
||||
rankedProcesses,
|
||||
async (p) => {
|
||||
if (!p.entryPointId) return;
|
||||
try {
|
||||
const chain = await this._computeContextChain(
|
||||
repo,
|
||||
p.entryPointId,
|
||||
requestedChainDepth,
|
||||
);
|
||||
chainByProcessId.set(p.id, chain);
|
||||
} catch (e) {
|
||||
logQueryError('query:chain-bfs', e);
|
||||
if (!isBenignMissingTableError(e)) enrichmentDegraded = true;
|
||||
}
|
||||
},
|
||||
{ concurrency: QUERY_CHAIN_BFS_CONCURRENCY },
|
||||
);
|
||||
timer.stop(); // chain_enrichment
|
||||
}
|
||||
|
||||
// Step 4: Build response
|
||||
timer.start('formatting');
|
||||
const processes = rankedProcesses.map((p) => ({
|
||||
|
|
@ -3277,12 +3554,22 @@ export class LocalBackend {
|
|||
symbol_count: p.symbols.length,
|
||||
process_type: p.processType,
|
||||
step_count: p.stepCount,
|
||||
...(p.routes && p.routes.length > 0
|
||||
? {
|
||||
route: p.routes[0].url,
|
||||
method: p.routes[0].method || undefined,
|
||||
routes: p.routes,
|
||||
}
|
||||
: {}),
|
||||
...(chainByProcessId.has(p.id) ? { chain: chainByProcessId.get(p.id) } : {}),
|
||||
}));
|
||||
|
||||
const processSymbols = rankedProcesses.flatMap((p) =>
|
||||
p.symbols.slice(0, maxSymbolsPerProcess).map((s) => ({
|
||||
...s,
|
||||
// remove internal fields
|
||||
// mark the entry-point symbol so an agent reading the
|
||||
// process can tell procedure vs. workflow vs. helper at a glance.
|
||||
...(p.entryPointId && s.id === p.entryPointId ? { is_entry_point: true } : {}),
|
||||
})),
|
||||
);
|
||||
|
||||
|
|
@ -3542,7 +3829,7 @@ export class LocalBackend {
|
|||
`
|
||||
MATCH (n)
|
||||
WHERE n.id IN $nodeIds
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS nodeLabels, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
ORDER BY startLine, id
|
||||
`,
|
||||
{ nodeIds },
|
||||
|
|
@ -3557,7 +3844,7 @@ export class LocalBackend {
|
|||
MATCH (n)
|
||||
WHERE n.filePath = $filePath
|
||||
AND NOT n.id STARTS WITH 'BasicBlock:'
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
RETURN n.id AS id, n.name AS name, labels(n) AS nodeLabels, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
|
||||
ORDER BY startLine, id
|
||||
LIMIT 3
|
||||
`,
|
||||
|
|
@ -3566,10 +3853,11 @@ export class LocalBackend {
|
|||
|
||||
if (symbols.length > 0) {
|
||||
for (const sym of symbols) {
|
||||
const nodeLabels = sym.nodeLabels ?? sym[2];
|
||||
results.push({
|
||||
nodeId: sym.id || sym[0],
|
||||
name: sym.name || sym[1],
|
||||
type: sym.type || sym[2],
|
||||
type: Array.isArray(nodeLabels) ? nodeLabels[0] : nodeLabels,
|
||||
filePath: sym.filePath || sym[3],
|
||||
// Raw 0-based here — `bm25Search` is only called from `query()`,
|
||||
// whose aggregation loop applies `toDisplayLine` once (see below).
|
||||
|
|
@ -4474,6 +4762,7 @@ export class LocalBackend {
|
|||
file_path?: string;
|
||||
kind?: string;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
try {
|
||||
|
|
@ -4498,11 +4787,13 @@ export class LocalBackend {
|
|||
file_path?: string;
|
||||
kind?: string;
|
||||
include_content?: boolean;
|
||||
chain_depth?: number;
|
||||
},
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized(repo);
|
||||
|
||||
const { name, uid, file_path, kind, include_content } = params;
|
||||
const { chain_depth } = params;
|
||||
const requestedContent = include_content ?? false;
|
||||
// Content retention matters only to the opt-in content response. Avoid a
|
||||
// metadata dependency for the long-standing default context operation.
|
||||
|
|
@ -4661,7 +4952,7 @@ export class LocalBackend {
|
|||
executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (p:\`Property\`)
|
||||
MATCH (p:Property)
|
||||
WHERE p.declaredType = $name
|
||||
OR p.declaredType STARTS WITH $genericPrefix
|
||||
OR p.declaredType CONTAINS $genericArg
|
||||
|
|
@ -4760,7 +5051,7 @@ export class LocalBackend {
|
|||
repo.lbugPath,
|
||||
`
|
||||
MATCH (n {id: $symId})-[r:CodeRelation {type: 'STEP_IN_PROCESS'}]->(p:Process)
|
||||
RETURN p.id AS pid, p.heuristicLabel AS label, MIN(r.step) AS step, p.stepCount AS stepCount
|
||||
RETURN p.id AS pid, p.heuristicLabel AS label, MIN(r.step) AS step, p.stepCount AS stepCount, p.entryPointId AS entryPointId
|
||||
ORDER BY pid
|
||||
`,
|
||||
{ symId },
|
||||
|
|
@ -4769,6 +5060,52 @@ export class LocalBackend {
|
|||
logQueryError('context:process-participation', e);
|
||||
}
|
||||
|
||||
// Route lookup — HTTP endpoints this symbol handles, plus entry-point
|
||||
// status. Makes context() self-sufficient: an agent learns the symbol's
|
||||
// route + entry-point status in the same call it already runs before every
|
||||
// edit (AGENTS.md mandates context() pre-edit).
|
||||
// A symbol is the process entry point when entryPointId matches symId.
|
||||
// Process-linked routes come from Route-[ENTRY_POINT_OF]->Process
|
||||
// (processes.ts). Handler routes come from (handler)-[HANDLES_ROUTE]->Route
|
||||
// (routes.ts). A middle-step symbol does not own a process's route.
|
||||
// call-processor does not emit Route-[ENTRY_POINT_OF]->Function.
|
||||
const entryPids = processRows
|
||||
.filter((r: any) => (r.entryPointId ?? r[4]) === symId)
|
||||
.map((r: any) => r.pid || r[0]);
|
||||
const isEntryPoint = entryPids.length > 0;
|
||||
const routes: Array<{ url: string; method?: string }> = [];
|
||||
try {
|
||||
const routeRows = await executeParameterized(
|
||||
repo.lbugPath,
|
||||
`
|
||||
MATCH (route:Route)-[r:CodeRelation {type: 'ENTRY_POINT_OF'}]->(target)
|
||||
WHERE target.id = $symId OR target.id IN $entryPids
|
||||
RETURN route.name AS url, route.method AS method
|
||||
UNION
|
||||
MATCH (handler)-[r:CodeRelation {type: 'HANDLES_ROUTE'}]->(route:Route)
|
||||
WHERE handler.id = $symId
|
||||
RETURN route.name AS url, route.method AS method
|
||||
`,
|
||||
{ symId, entryPids: entryPids.length > 0 ? entryPids : ['__none__'] },
|
||||
);
|
||||
// Dedup on method+url: Route identity includes the HTTP method, and a
|
||||
// symbol can legitimately sit behind both variants of the same URL
|
||||
// (GET/POST pair). URL-only dedup would drop the second endpoint.
|
||||
const seenRoutes = new Set<string>();
|
||||
for (const r of routeRows) {
|
||||
const url = r.url ?? r[0];
|
||||
const method = r.method ?? r[1];
|
||||
const dedupKey = routeEnrichmentKey(method ? String(method) : undefined, url);
|
||||
if (url && !seenRoutes.has(dedupKey)) {
|
||||
seenRoutes.add(dedupKey);
|
||||
routes.push(method ? { url, method } : { url });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Best-effort enrichment — never fail the context call.
|
||||
logQueryError('context:route-lookup', e);
|
||||
}
|
||||
|
||||
// Helper to categorize refs
|
||||
const categorize = (rows: any[]) => {
|
||||
const cats: Record<string, any[]> = {};
|
||||
|
|
@ -4901,6 +5238,18 @@ export class LocalBackend {
|
|||
aopMetadataPromise,
|
||||
]);
|
||||
|
||||
let chain: any[] | undefined;
|
||||
const parsedDepth = parseChainDepth(chain_depth);
|
||||
if (parsedDepth.ok === false) return { error: parsedDepth.error };
|
||||
const requestedDepth = parsedDepth.value;
|
||||
if (requestedDepth > 0) {
|
||||
try {
|
||||
chain = await this._computeContextChain(repo, symId, requestedDepth);
|
||||
} catch (e) {
|
||||
logQueryError('context:chain-bfs', e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'found',
|
||||
...(contentAvailability ? { contentAvailability } : {}),
|
||||
|
|
@ -4920,6 +5269,7 @@ export class LocalBackend {
|
|||
...crossLanguageAnchor,
|
||||
incoming: categorize(incomingRows),
|
||||
outgoing: categorize(outgoingRows),
|
||||
...(chain ? { chain } : {}),
|
||||
...(typedPropertyRows.length > 0
|
||||
? {
|
||||
typed_properties: typedPropertyRows.map((r: any) => ({
|
||||
|
|
@ -4931,6 +5281,8 @@ export class LocalBackend {
|
|||
})),
|
||||
}
|
||||
: {}),
|
||||
...(isEntryPoint ? { is_entry_point: true } : {}),
|
||||
...(routes.length > 0 ? { routes } : {}),
|
||||
processes: processRows.map((r: any) => ({
|
||||
id: r.pid || r[0],
|
||||
name: r.label || r[1],
|
||||
|
|
@ -4940,6 +5292,155 @@ export class LocalBackend {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS chain expansion for `context({chain_depth: N})`.
|
||||
*
|
||||
* Walks CALLS edges up to `maxDepth` hops from the seed symbol, in BOTH
|
||||
* directions (upstream callers AND downstream callees), and returns the
|
||||
* layered result so an agent can see the full procedure→workflow→sub-workflow
|
||||
* chain in a single call instead of chaining context() invocations.
|
||||
*
|
||||
* Output shape (one entry per depth, 1..maxDepth — layers starts empty and
|
||||
* the loop begins at depth 1, so the seed itself is NOT emitted as its own
|
||||
* entry):
|
||||
* [
|
||||
* { depth: 1, upstream: [...callers], downstream: [...callees] },
|
||||
* { depth: 2, upstream: [...], downstream: [...] },
|
||||
* ...
|
||||
* ]
|
||||
*
|
||||
* Test-file nodes are deprioritized (pushed to the end of each list) so real
|
||||
* callers/callees surface first. Cycles are broken via a per-direction `visited`
|
||||
* set (a node visited at depth N in one direction is not re-emitted at
|
||||
* depth N+1 in that same direction even if it has another path back into
|
||||
* the frontier). Upstream and downstream do not share visited, so a
|
||||
* reciprocal CALLS pair (A→B and B→A) still appears on both sides of
|
||||
* layer 1. Hard cap of 50 nodes per direction per depth layer keeps the
|
||||
* response bounded.
|
||||
*/
|
||||
private async _computeContextChain(
|
||||
repo: RepoHandle,
|
||||
seedId: string,
|
||||
maxDepth: number,
|
||||
): Promise<
|
||||
Array<{
|
||||
depth: number;
|
||||
upstream?: any[];
|
||||
downstream?: any[];
|
||||
}>
|
||||
> {
|
||||
// LadybugDB has no regex operator — use CONTAINS clauses. Parameterized
|
||||
// by the returned-node alias: the upstream query returns `caller`, the
|
||||
// downstream query returns `target`, and test-file demotion must rank
|
||||
// the returned node — not the frontier node the edge was reached from.
|
||||
//
|
||||
// `labels(node)`, not `labels(node)[0]`: LadybugDB returns the label as
|
||||
// a scalar string, and subscripting a string is 1-based over characters,
|
||||
// so `[0]` is always '' (see graph-queries.ts and detect_changes).
|
||||
const testOrderExpr = (alias: string) => `
|
||||
CASE
|
||||
WHEN ${alias}.filePath IS NULL THEN 0
|
||||
WHEN ${alias}.filePath CONTAINS '.test.' THEN 1
|
||||
WHEN ${alias}.filePath CONTAINS '.spec.' THEN 1
|
||||
WHEN ${alias}.filePath CONTAINS '__tests__/' THEN 1
|
||||
WHEN ${alias}.filePath CONTAINS '/test/' THEN 1
|
||||
WHEN ${alias}.filePath CONTAINS '/tests/' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
`;
|
||||
|
||||
const layers: Array<{ depth: number; upstream?: any[]; downstream?: any[] }> = [];
|
||||
const visitedUpstream = new Set<string>([seedId]);
|
||||
const visitedDownstream = new Set<string>([seedId]);
|
||||
let upstreamFrontier = [seedId];
|
||||
let downstreamFrontier = [seedId];
|
||||
|
||||
const walk = async (
|
||||
frontier: string[],
|
||||
cypher: string,
|
||||
logLabel: string,
|
||||
visited: Set<string>,
|
||||
): Promise<{ nodes?: any[]; nextFrontier: string[] }> => {
|
||||
if (frontier.length === 0) return { nextFrontier: [] };
|
||||
try {
|
||||
const rows = await executeParameterized(repo.lbugPath, cypher, {
|
||||
frontier,
|
||||
visited: Array.from(visited),
|
||||
});
|
||||
if (rows.length === 0) return { nextFrontier: [] };
|
||||
// MATCH is one row per CALLS edge. Cypher `WITH DISTINCT` applies
|
||||
// LIMIT 50 to unique neighbors; this second pass still collapses
|
||||
// twins if a driver/engine ever returns duplicate rows.
|
||||
const seen = new Set<string>();
|
||||
const fresh = rows.filter((r: any) => {
|
||||
const uid = r.uid;
|
||||
if (!uid || visited.has(uid) || seen.has(uid)) return false;
|
||||
seen.add(uid);
|
||||
return true;
|
||||
});
|
||||
const nodes = fresh.map((r: any) => ({
|
||||
uid: r.uid,
|
||||
name: r.name,
|
||||
filePath: r.filePath,
|
||||
kind: r.kind,
|
||||
}));
|
||||
for (const r of fresh) visited.add(r.uid);
|
||||
return { nodes, nextFrontier: fresh.map((r: any) => r.uid) };
|
||||
} catch (e) {
|
||||
logQueryError(logLabel, e);
|
||||
return { nextFrontier: [] };
|
||||
}
|
||||
};
|
||||
|
||||
for (let depth = 1; depth <= maxDepth; depth++) {
|
||||
const layer: { depth: number; upstream?: any[]; downstream?: any[] } = { depth };
|
||||
|
||||
const upstream = await walk(
|
||||
upstreamFrontier,
|
||||
`
|
||||
MATCH (caller)-[r:CodeRelation]->(n)
|
||||
WHERE r.type = 'CALLS' AND n.id IN $frontier
|
||||
AND NOT caller.id IN $visited
|
||||
WITH DISTINCT caller
|
||||
RETURN caller.id AS uid, caller.name AS name,
|
||||
caller.filePath AS filePath, labels(caller) AS kind,
|
||||
${testOrderExpr('caller')} AS isTest
|
||||
ORDER BY isTest ASC, caller.filePath ASC, caller.name ASC
|
||||
LIMIT 50
|
||||
`,
|
||||
'context:chain-bfs:upstream',
|
||||
visitedUpstream,
|
||||
);
|
||||
if (upstream.nodes !== undefined) layer.upstream = upstream.nodes;
|
||||
upstreamFrontier = upstream.nextFrontier;
|
||||
|
||||
const downstream = await walk(
|
||||
downstreamFrontier,
|
||||
`
|
||||
MATCH (n)-[r:CodeRelation]->(target)
|
||||
WHERE r.type = 'CALLS' AND n.id IN $frontier
|
||||
AND NOT target.id IN $visited
|
||||
WITH DISTINCT target
|
||||
RETURN target.id AS uid, target.name AS name,
|
||||
target.filePath AS filePath, labels(target) AS kind,
|
||||
${testOrderExpr('target')} AS isTest
|
||||
ORDER BY isTest ASC, target.filePath ASC, target.name ASC
|
||||
LIMIT 50
|
||||
`,
|
||||
'context:chain-bfs:downstream',
|
||||
visitedDownstream,
|
||||
);
|
||||
if (downstream.nodes !== undefined) layer.downstream = downstream.nodes;
|
||||
downstreamFrontier = downstream.nextFrontier;
|
||||
|
||||
if (!layer.upstream && !layer.downstream) break;
|
||||
layers.push(layer);
|
||||
if (upstreamFrontier.length === 0 && downstreamFrontier.length === 0) break;
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `target` (file path OR symbol/function name) into a BasicBlock
|
||||
* SOURCE-block anchor, shared by `explain` (TAINTED) and `pdg_query`
|
||||
|
|
@ -8661,6 +9162,7 @@ export class LocalBackend {
|
|||
if (typeof params.limit === 'number') queryArgs.limit = params.limit;
|
||||
if (typeof params.max_symbols === 'number') queryArgs.max_symbols = params.max_symbols;
|
||||
if (params.include_content !== undefined) queryArgs.include_content = params.include_content;
|
||||
if (params.chain_depth !== undefined) queryArgs.chain_depth = params.chain_depth;
|
||||
if (params.service !== undefined && params.service !== null)
|
||||
queryArgs.service = params.service;
|
||||
if (memberRest !== undefined) {
|
||||
|
|
@ -8684,6 +9186,7 @@ export class LocalBackend {
|
|||
if (typeof params.file_path === 'string') contextArgs.file_path = params.file_path;
|
||||
if (params.include_content !== undefined)
|
||||
contextArgs.include_content = params.include_content;
|
||||
if (params.chain_depth !== undefined) contextArgs.chain_depth = params.chain_depth;
|
||||
if (params.service !== undefined && params.service !== null)
|
||||
contextArgs.service = params.service;
|
||||
if (memberRest !== undefined) {
|
||||
|
|
|
|||
|
|
@ -1535,7 +1535,7 @@ export async function pdgLayerStatus(deps: {
|
|||
// one row happens to carry cannot change the returned state or note.
|
||||
const rows = await deps.executeParameterized(
|
||||
deps.lbugPath,
|
||||
`MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type IN ['CDG', 'REACHING_DEF'] RETURN r.type AS type LIMIT 1`,
|
||||
`MATCH (:BasicBlock)-[r:CodeRelation]->(:BasicBlock) WHERE r.type = 'CDG' OR r.type = 'REACHING_DEF' RETURN r.type AS type LIMIT 1`,
|
||||
{},
|
||||
);
|
||||
edgesVisible = Array.isArray(rows) && rows.length > 0;
|
||||
|
|
@ -1709,7 +1709,7 @@ async function bfsReachableBlocks(input: {
|
|||
const rawRows = await exec(
|
||||
lbugPath,
|
||||
`MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
|
||||
WHERE r.type IN ['CDG', 'REACHING_DEF'] AND ${matchEndpoint}.id IN $frontier
|
||||
WHERE (r.type = 'CDG' OR r.type = 'REACHING_DEF') AND ${matchEndpoint}.id IN $frontier
|
||||
RETURN DISTINCT ${collectEndpoint}.id AS id
|
||||
ORDER BY id
|
||||
LIMIT ${probeLimit}`,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ export const PDG_QUERY_MAX_LIMIT = 200;
|
|||
// PDG direct backend callers also enforce it before running traversal.
|
||||
export const IMPACT_MAX_DEPTH = 32;
|
||||
|
||||
/** Advertised query page defaults; backend and group orchestration must match. */
|
||||
export const QUERY_DEFAULT_LIMIT = 10;
|
||||
export const QUERY_DEFAULT_MAX_SYMBOLS = 25;
|
||||
/** Advertised query page maxima (schema + LocalBackend.query reject, not clamp). */
|
||||
export const QUERY_MAX_LIMIT = 100;
|
||||
export const QUERY_MAX_MAX_SYMBOLS = 200;
|
||||
export const CONTEXT_CHAIN_MAX_DEPTH = 3;
|
||||
|
||||
const CWD_AWARE_REPO_OMISSION =
|
||||
'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.';
|
||||
const MUTATING_REPO_OMISSION =
|
||||
|
|
@ -139,9 +147,9 @@ WHEN TO USE: Understanding how code works together. Use this when you need execu
|
|||
AFTER THIS: Use context() on a specific symbol for 360-degree view (callers, callees, categorized refs).
|
||||
|
||||
Returns results grouped by process (execution flow):
|
||||
- processes: ranked execution flows with relevance priority
|
||||
- process_symbols: all symbols in those flows with file locations and module (functional area)
|
||||
- definitions: standalone types/interfaces not in any process
|
||||
- processes: ranked execution flows with relevance priority. When a process has an HTTP endpoint, each item includes route and method string aliases plus routes: [{ url, method? }] (same shape as context). When chain_depth > 0, each item also includes chain — layered upstream callers + downstream callees from the process entry symbol (same BFS as context({chain_depth})).
|
||||
- process_symbols: search-hit symbols in those flows with file locations and module (functional area). When the process entry is among those hits, it is marked is_entry_point: true.
|
||||
- definitions: standalone types/interfaces not in any process. Keyword hits on Route URLs (route_fts) are bridged to their handler via HANDLES_ROUTE (handlerSymbolId, routes) when the edge exists; use route_map({route}) for the full HTTP surface.
|
||||
|
||||
Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank Fusion.
|
||||
|
||||
|
|
@ -173,17 +181,17 @@ ${HOT_READ_STALENESS_NOTE}`,
|
|||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max processes to return (default: 5)',
|
||||
default: 5,
|
||||
description: `Max processes to return (default: ${QUERY_DEFAULT_LIMIT}, min: 1, max: ${QUERY_MAX_LIMIT}). Values outside [1, ${QUERY_MAX_LIMIT}] are rejected.`,
|
||||
default: QUERY_DEFAULT_LIMIT,
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
maximum: QUERY_MAX_LIMIT,
|
||||
},
|
||||
max_symbols: {
|
||||
type: 'number',
|
||||
description: 'Max symbols per process (default: 10)',
|
||||
default: 10,
|
||||
description: `Max symbols per process (default: ${QUERY_DEFAULT_MAX_SYMBOLS}, min: 1, max: ${QUERY_MAX_MAX_SYMBOLS}). Values outside [1, ${QUERY_MAX_MAX_SYMBOLS}] are rejected.`,
|
||||
default: QUERY_DEFAULT_MAX_SYMBOLS,
|
||||
minimum: 1,
|
||||
maximum: 200,
|
||||
maximum: QUERY_MAX_MAX_SYMBOLS,
|
||||
},
|
||||
include_content: {
|
||||
type: 'boolean',
|
||||
|
|
@ -191,6 +199,13 @@ ${HOT_READ_STALENESS_NOTE}`,
|
|||
'Include source text retained for matching symbols (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
|
||||
default: false,
|
||||
},
|
||||
chain_depth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: CONTEXT_CHAIN_MAX_DEPTH,
|
||||
default: 0,
|
||||
description: `Optional: walk CALLS edges up to N hops (0-${CONTEXT_CHAIN_MAX_DEPTH}) from each returned process's entry symbol and attach the layered result as a per-process chain field (upstream callers + downstream callees). 0 = disabled (default). Same BFS semantics as context({chain_depth}) — exposes the procedure→workflow→helper flow behind a concept in one call.`,
|
||||
},
|
||||
maxTokens: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
|
|
@ -292,6 +307,7 @@ ${HOT_READ_STALENESS_NOTE}`,
|
|||
name: 'context',
|
||||
description: `360-degree view of a single code symbol.
|
||||
Shows categorized incoming/outgoing references (calls, imports, extends, implements, methods, properties, overrides), process participation, and file location.
|
||||
Also returns (when applicable): routes: [{ url, method? }] — HTTP endpoints this symbol handles via (handler)-[HANDLES_ROUTE]->Route or a Process-linked Route-[ENTRY_POINT_OF]->Process edge; is_entry_point: true when this symbol is a process entry point; chain — layered CALLS neighbours (upstream callers + downstream callees) when chain_depth > 0.
|
||||
|
||||
WHEN TO USE: After query() to understand a specific symbol in depth. When you need to know all callers, callees, and what execution flows a symbol participates in.
|
||||
AFTER THIS: Use impact() if planning changes, or READ gitnexus://repo/{name}/process/{processName} for full execution trace.
|
||||
|
|
@ -343,6 +359,13 @@ ${HOT_READ_STALENESS_NOTE}`,
|
|||
'Include source text retained for this symbol (default: false). The response reports contentAvailability; indexes built with content retention "none" explicitly report unavailable content.',
|
||||
default: false,
|
||||
},
|
||||
chain_depth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
maximum: CONTEXT_CHAIN_MAX_DEPTH,
|
||||
default: 0,
|
||||
description: `Optional: walk CALLS edges up to N hops (0-${CONTEXT_CHAIN_MAX_DEPTH}) and return the result as a \`chain\` field (downstream callees + upstream callers layered by depth). 0 = disabled (default). 1 = direct neighbours only. 2-${CONTEXT_CHAIN_MAX_DEPTH} = procedure→workflow→sub-workflow depth. Useful for revealing the full tRPC/RPC call chain in a single call instead of chaining context() invocations.`,
|
||||
},
|
||||
maxTokens: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { execFileSync, execSync } from 'child_process';
|
||||
import { execFileSync, execSync, spawnSync } from 'child_process';
|
||||
import { statSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
|
@ -665,6 +665,34 @@ export const getCurrentBranch = (repoPath: string): string | null => {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Local `refs/heads` names, or `null` when the directory is not a git
|
||||
* worktree or git cannot run. An empty array means the listing succeeded
|
||||
* and there are no local heads — that is not a listing failure (#3331).
|
||||
*/
|
||||
export const listLocalHeads = (repoPath: string): string[] | null => {
|
||||
try {
|
||||
const result = spawnSync('git', ['for-each-ref', '--format=%(refname)', 'refs/heads'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
maxBuffer: GIT_PATH_LIST_MAX_BUFFER,
|
||||
});
|
||||
if (result.error || result.status !== 0) return null;
|
||||
const output = (result.stdout ?? '').toString().trim();
|
||||
if (!output) return [];
|
||||
return output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('refs/heads/'))
|
||||
.map((line) => line.slice('refs/heads/'.length))
|
||||
.filter((line) => line.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitize a repository name to prevent argument injection and ensure
|
||||
* cross-platform filesystem compatibility.
|
||||
|
|
|
|||
|
|
@ -771,7 +771,14 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid
|
|||
// captured as unrestricted reexports. Re-extract both facts on warm indexes.
|
||||
// v102: ParsedFile gained callResultAssignmentSites; old durable shards do
|
||||
// not carry the exact assignment identity required by return-type replay.
|
||||
const SCHEMA_BUMP = 102;
|
||||
// v103 (#3339 review): tRPC route extraction changed — nested-router paths,
|
||||
// a tightened entry gate, and controller-less routes now bind handlers via a
|
||||
// same-file CALLS edge. Warm caches replay the flat pre-fix capture set
|
||||
// verbatim (route rows are parse-time facts), so both stores re-extract.
|
||||
// v104 (#3339 review): TS/JS pair-HOC queries now name object-pair
|
||||
// `mutation(withAuth(arrow))` handlers. Warm caches replay the pre-fix
|
||||
// capture set (anonymous arrows, no Function name), so both stores re-extract.
|
||||
const SCHEMA_BUMP = 104;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -191,6 +191,15 @@ export interface RegistryEntry {
|
|||
branches?: BranchSummary[];
|
||||
}
|
||||
|
||||
/** Path-only registry lookup. Canonicalizes `repoPath` once. Does not throw. */
|
||||
export const findRegistryEntryByRepoPath = (
|
||||
entries: readonly RegistryEntry[],
|
||||
repoPath: string,
|
||||
): RegistryEntry | undefined => {
|
||||
const repoKey = canonicalizePath(repoPath);
|
||||
return entries.find((entry) => registryPathEquals(canonicalizePath(entry.path), repoKey));
|
||||
};
|
||||
|
||||
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
|
||||
|
||||
// ─── Local Storage Helpers ─────────────────────────────────────────────
|
||||
|
|
@ -838,6 +847,13 @@ export interface RegisterRepoOptions {
|
|||
* re-analyses of the same path without `--name` preserve the alias.
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Best-effort notification after an explicit alias change has been committed
|
||||
* to the registry. Invoked after the registry lock is released. Callback
|
||||
* failures are ignored: reporting must not turn a successful registry write
|
||||
* into an apparent transaction failure.
|
||||
*/
|
||||
onRename?: (previousName: string, nextName: string) => void | Promise<void>;
|
||||
/**
|
||||
* Allow two DIFFERENT repo paths to register under the same alias
|
||||
* (#829). Mapped from the `--allow-duplicate-name` CLI flag.
|
||||
|
|
@ -926,6 +942,11 @@ const hasCustomAlias = (entry: RegistryEntry, inferredName: string | null): bool
|
|||
return true;
|
||||
};
|
||||
|
||||
type RegisterRepoUnlockedResult = {
|
||||
name: string;
|
||||
rename?: { previousName: string; nextName: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Register (add or update) a repo in the global registry.
|
||||
* Called after `gitnexus analyze` completes.
|
||||
|
|
@ -954,7 +975,7 @@ const registerRepoUnlocked = async (
|
|||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
): Promise<string> => {
|
||||
): Promise<RegisterRepoUnlockedResult> => {
|
||||
// Preserve the caller's chosen path form in the registry — don't
|
||||
// canonicalise at write time. This matters for two reasons:
|
||||
// 1. `list` and error messages show the path the user actually
|
||||
|
|
@ -1151,14 +1172,28 @@ const registerRepoUnlocked = async (
|
|||
}
|
||||
|
||||
await writeRegistry(fresh);
|
||||
return name;
|
||||
const rename =
|
||||
opts?.name !== undefined && freshExisting && freshExisting.name !== name
|
||||
? { previousName: freshExisting.name, nextName: name }
|
||||
: undefined;
|
||||
return { name, ...(rename ? { rename } : {}) };
|
||||
};
|
||||
|
||||
export const registerRepo = async (
|
||||
repoPath: string,
|
||||
meta: RepoMeta,
|
||||
opts?: RegisterRepoOptions,
|
||||
): Promise<string> => withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
|
||||
): Promise<string> => {
|
||||
const { name, rename } = await withRegistryLock(() => registerRepoUnlocked(repoPath, meta, opts));
|
||||
if (rename) {
|
||||
try {
|
||||
await opts?.onRename?.(rename.previousName, rename.nextName);
|
||||
} catch {
|
||||
// The rename is already durable; observer failures cannot roll it back.
|
||||
}
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a repo from the global registry.
|
||||
|
|
|
|||
384
gitnexus/src/storage/stale-branch-slots.ts
Normal file
384
gitnexus/src/storage/stale-branch-slots.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
/**
|
||||
* Classify and reclaim leftover per-branch index slots (#3331).
|
||||
*
|
||||
* Live means a name in local `refs/heads`. Classification never reverses
|
||||
* `branchSlug`; registry rows join through the same forward slug path
|
||||
* `clean --branch` already computes. Directory delete happens before the
|
||||
* registry drop; a failed rm keeps the summary so a later clean can retry.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { BRANCHES_DIR } from './branch-index.js';
|
||||
import { listLocalHeads } from './git.js';
|
||||
import { isMissingFilesystemError, loadMeta } from './repo-meta.js';
|
||||
import { getStoragePaths, removeBranchIndex } from './repo-manager.js';
|
||||
|
||||
export type StaleBranchReason =
|
||||
| 'ref-missing'
|
||||
| 'registry-only'
|
||||
| 'disk-only'
|
||||
| 'heads-unavailable'
|
||||
| 'probe-failed'
|
||||
| 'listing-failed';
|
||||
|
||||
export interface StaleBranchSlot {
|
||||
branch: string;
|
||||
dir: string | null;
|
||||
sizeBytes: number;
|
||||
reason: StaleBranchReason;
|
||||
}
|
||||
|
||||
export interface ListStaleBranchSlotsInput {
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
branches?: readonly { branch: string }[];
|
||||
/** Injected in tests. When omitted, listed from `repoPath`. */
|
||||
heads?: string[] | null;
|
||||
/** Default true. `--stale --force` skips the size walk; it never prints sizes. */
|
||||
includeSize?: boolean;
|
||||
}
|
||||
|
||||
const slotDirForBranch = (repoPath: string, storagePath: string, branch: string): string =>
|
||||
path.dirname(getStoragePaths(repoPath, branch, storagePath).metaPath);
|
||||
|
||||
/** Same bound as `mapPool` in repo-manager: cap concurrent slot I/O. */
|
||||
const STALE_SLOT_IO_CONCURRENCY = 8;
|
||||
|
||||
const mapPool = async <T, R>(
|
||||
items: readonly T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
): Promise<R[]> => {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let next = 0;
|
||||
const workerCount = Math.max(1, Math.min(STALE_SLOT_IO_CONCURRENCY, items.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index] as T);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
|
||||
/** Proven directory, proven absence, or a probe error that is not ENOENT/ENOTDIR. */
|
||||
type DirectoryProbe = 'dir' | 'missing' | 'unreadable';
|
||||
|
||||
const probeDirectory = async (dir: string): Promise<DirectoryProbe> => {
|
||||
try {
|
||||
return (await fs.stat(dir)).isDirectory() ? 'dir' : 'unreadable';
|
||||
} catch (err) {
|
||||
return isMissingFilesystemError(err) ? 'missing' : 'unreadable';
|
||||
}
|
||||
};
|
||||
|
||||
const directorySizeBytes = async (root: string): Promise<number> => {
|
||||
let total = 0;
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (current === undefined) break;
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const files = entries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => path.join(current, entry.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) stack.push(path.join(current, entry.name));
|
||||
}
|
||||
for (const file of files) {
|
||||
try {
|
||||
total += (await fs.stat(file)).size;
|
||||
} catch {
|
||||
// Skip files that disappear or become unreadable mid-walk.
|
||||
}
|
||||
}
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const metadataBranch = async (dir: string): Promise<string | null> => {
|
||||
const meta = await loadMeta(dir);
|
||||
return typeof meta?.branch === 'string' && meta.branch.length > 0 ? meta.branch : null;
|
||||
};
|
||||
|
||||
export const listStaleBranchSlots = async (
|
||||
input: ListStaleBranchSlotsInput,
|
||||
): Promise<StaleBranchSlot[]> => {
|
||||
const recorded = input.branches ?? [];
|
||||
const branchesRoot = path.join(input.storagePath, BRANCHES_DIR);
|
||||
|
||||
const registryByDir = new Map<string, string>();
|
||||
for (const row of recorded) {
|
||||
registryByDir.set(
|
||||
path.resolve(slotDirForBranch(input.repoPath, input.storagePath, row.branch)),
|
||||
row.branch,
|
||||
);
|
||||
}
|
||||
|
||||
let diskDirs: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(branchesRoot, { withFileTypes: true });
|
||||
diskDirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(branchesRoot, entry.name));
|
||||
} catch (err) {
|
||||
if (!isMissingFilesystemError(err)) {
|
||||
return [{ branch: '', dir: null, sizeBytes: 0, reason: 'listing-failed' }];
|
||||
}
|
||||
diskDirs = [];
|
||||
}
|
||||
|
||||
if (recorded.length === 0 && diskDirs.length === 0) return [];
|
||||
|
||||
const heads = input.heads !== undefined ? input.heads : listLocalHeads(input.repoPath);
|
||||
const live = heads === null ? null : new Set(heads);
|
||||
|
||||
const pending: Array<Omit<StaleBranchSlot, 'sizeBytes'>> = [];
|
||||
|
||||
const registryProbes = await mapPool([...registryByDir], async ([resolvedDir, branch]) => ({
|
||||
resolvedDir,
|
||||
branch,
|
||||
probe: await probeDirectory(resolvedDir),
|
||||
}));
|
||||
for (const { resolvedDir, branch, probe } of registryProbes) {
|
||||
if (probe === 'unreadable') {
|
||||
pending.push({ branch, dir: resolvedDir, reason: 'probe-failed' });
|
||||
continue;
|
||||
}
|
||||
const exists = probe === 'dir';
|
||||
const dir = exists ? resolvedDir : null;
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!exists) {
|
||||
pending.push({ branch, dir: null, reason: 'registry-only' });
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir, reason: 'ref-missing' });
|
||||
}
|
||||
}
|
||||
|
||||
const leftoverDirs = diskDirs.filter((dir) => !registryByDir.has(path.resolve(dir)));
|
||||
const leftoverMeta = await mapPool(leftoverDirs, async (dir) => ({
|
||||
dir,
|
||||
branch: await metadataBranch(dir),
|
||||
}));
|
||||
for (const { dir, branch } of leftoverMeta) {
|
||||
if (branch === null) continue;
|
||||
const resolved = path.resolve(dir);
|
||||
if (live === null) {
|
||||
pending.push({ branch, dir: resolved, reason: 'heads-unavailable' });
|
||||
continue;
|
||||
}
|
||||
if (!live.has(branch)) {
|
||||
pending.push({ branch, dir: resolved, reason: 'disk-only' });
|
||||
}
|
||||
}
|
||||
|
||||
const includeSize = input.includeSize !== false;
|
||||
return mapPool(pending, async (row) => ({
|
||||
...row,
|
||||
sizeBytes: includeSize && row.dir ? await directorySizeBytes(row.dir) : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
/** Lexical / realpath containment: `child` is a proper descendant of `parent`. */
|
||||
const isProperChildPath = (parent: string, child: string): boolean => {
|
||||
const root = path.resolve(parent);
|
||||
const resolved = path.resolve(child);
|
||||
const relative = path.relative(root, resolved);
|
||||
return (
|
||||
relative !== '' &&
|
||||
relative !== '..' &&
|
||||
!relative.startsWith(`..${path.sep}`) &&
|
||||
!path.isAbsolute(relative)
|
||||
);
|
||||
};
|
||||
|
||||
const isSameNormalizedPath = (left: string, right: string): boolean =>
|
||||
path.relative(path.resolve(path.normalize(left)), path.resolve(path.normalize(right))) === '';
|
||||
|
||||
export const isContainedBranchDir = (storagePath: string, dir: string): boolean =>
|
||||
isProperChildPath(path.resolve(storagePath, BRANCHES_DIR), dir);
|
||||
|
||||
const toError = (err: unknown): Error => (err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
const keepRegistryFailure = (error: Error): RemoveBranchSlotResult => ({
|
||||
ok: false,
|
||||
emptiedBranchesDir: false,
|
||||
keptRegistry: true,
|
||||
error,
|
||||
});
|
||||
|
||||
const refuseOutsideSlot = (dir: string): RemoveBranchSlotResult =>
|
||||
keepRegistryFailure(
|
||||
new Error(`Refusing to clean branch index outside the validated storage slot: ${dir}`),
|
||||
);
|
||||
|
||||
const slotPathExists = async (slotDir: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.lstat(slotDir);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return !isMissingFilesystemError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a lexically contained slot. Returns a failure result, or `null` when
|
||||
* the slot path is gone and the registry row may drop.
|
||||
*/
|
||||
const removeValidatedSlotDir = async (
|
||||
storagePath: string,
|
||||
dir: string,
|
||||
): Promise<RemoveBranchSlotResult | null> => {
|
||||
if (!isContainedBranchDir(storagePath, dir)) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
const branchesRoot = path.resolve(storagePath, BRANCHES_DIR);
|
||||
|
||||
let branchesStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
branchesStat = await fs.lstat(branchesRoot);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
|
||||
// Never walk a branches/ symlink (rm of a child would delete the target).
|
||||
if (branchesStat.isSymbolicLink()) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
let realStorage: string;
|
||||
let realBranches: string;
|
||||
try {
|
||||
realStorage = await fs.realpath(storagePath);
|
||||
realBranches = await fs.realpath(branchesRoot);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
|
||||
// Junctions may not report as symlinks from lstat; realpath must still land
|
||||
// on storagePath/branches, not an outside tree.
|
||||
const expectedBranches = path.normalize(path.join(realStorage, BRANCHES_DIR));
|
||||
if (!isSameNormalizedPath(realBranches, expectedBranches)) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
|
||||
let slotStat: Awaited<ReturnType<typeof fs.lstat>>;
|
||||
try {
|
||||
slotStat = await fs.lstat(dir);
|
||||
} catch (err) {
|
||||
if (isMissingFilesystemError(err)) return null;
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
|
||||
let deleteError: Error | undefined;
|
||||
try {
|
||||
// A symlink, or a Windows junction that lstat reports as a directory,
|
||||
// must be unlinked at the lexical path. Never fs.rm through a target
|
||||
// that realpath places outside branches/.
|
||||
const realDir = slotStat.isSymbolicLink() ? null : await fs.realpath(dir);
|
||||
const unlinkOnly =
|
||||
slotStat.isSymbolicLink() || (realDir !== null && !isProperChildPath(realBranches, realDir));
|
||||
|
||||
// Revalidate immediately before the destructive op. Another process can
|
||||
// replace branches/ or the slot after the earlier lstat/realpath awaits.
|
||||
const lastBranches = await fs.lstat(branchesRoot);
|
||||
if (lastBranches.isSymbolicLink()) {
|
||||
return refuseOutsideSlot(dir);
|
||||
}
|
||||
const lastSlot = await fs.lstat(dir);
|
||||
const lastUnlinkOnly = lastSlot.isSymbolicLink() || unlinkOnly;
|
||||
if (lastUnlinkOnly) {
|
||||
await fs.unlink(dir);
|
||||
} else {
|
||||
const lastReal = await fs.realpath(dir);
|
||||
if (!isProperChildPath(realBranches, lastReal)) {
|
||||
await fs.unlink(dir);
|
||||
} else {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
deleteError = toError(err);
|
||||
}
|
||||
|
||||
if (await slotPathExists(dir)) {
|
||||
return keepRegistryFailure(deleteError ?? new Error(`Could not remove branch index: ${dir}`));
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isDeleteCandidate = (slot: StaleBranchSlot): boolean =>
|
||||
slot.reason === 'ref-missing' || slot.reason === 'registry-only' || slot.reason === 'disk-only';
|
||||
|
||||
export type StaleListingBlock = 'heads-unavailable' | 'listing-failed';
|
||||
|
||||
/** Git-list or branches/ listing failed; clean and doctor must not reclaim. */
|
||||
export const staleListingBlock = (slots: readonly StaleBranchSlot[]): StaleListingBlock | null => {
|
||||
if (slots.some((slot) => slot.reason === 'heads-unavailable')) return 'heads-unavailable';
|
||||
if (slots.some((slot) => slot.reason === 'listing-failed')) return 'listing-failed';
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface RemoveBranchSlotInput {
|
||||
repoPath: string;
|
||||
storagePath: string;
|
||||
branch: string;
|
||||
/** Slot directory to remove, or `null` for a registry-only row. */
|
||||
dir: string | null;
|
||||
}
|
||||
|
||||
export interface RemoveBranchSlotResult {
|
||||
ok: boolean;
|
||||
emptiedBranchesDir: boolean;
|
||||
keptRegistry: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const rmdirEmptyBranches = async (storagePath: string): Promise<boolean> => {
|
||||
try {
|
||||
await fs.rmdir(path.join(storagePath, BRANCHES_DIR));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeBranchSlot = async (
|
||||
input: RemoveBranchSlotInput,
|
||||
): Promise<RemoveBranchSlotResult> => {
|
||||
const { repoPath, storagePath, branch, dir } = input;
|
||||
if (dir !== null) {
|
||||
const slotDirError = await removeValidatedSlotDir(storagePath, dir);
|
||||
if (slotDirError) return slotDirError;
|
||||
}
|
||||
|
||||
const dropRegistry =
|
||||
dir === null || isSameNormalizedPath(dir, slotDirForBranch(repoPath, storagePath, branch));
|
||||
if (dropRegistry) {
|
||||
try {
|
||||
await removeBranchIndex(repoPath, branch);
|
||||
} catch (err) {
|
||||
return keepRegistryFailure(toError(err));
|
||||
}
|
||||
}
|
||||
const emptiedBranchesDir = await rmdirEmptyBranches(storagePath);
|
||||
return { ok: true, emptiedBranchesDir, keptRegistry: !dropRegistry };
|
||||
};
|
||||
144
gitnexus/test/integration/clean-stale-branch-slots.test.ts
Normal file
144
gitnexus/test/integration/clean-stale-branch-slots.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanCommand } from '../../src/cli/clean.js';
|
||||
import { branchSlug } from '../../src/storage/branch-index.js';
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
|
||||
/**
|
||||
* Issue #3331 repro: a pinned per-branch slot survives deleting the git
|
||||
* branch; `clean --stale --force` reclaims it without naming the branch.
|
||||
*/
|
||||
describe('clean --stale leftover branch slots (#3331)', () => {
|
||||
let home: TestDBHandle;
|
||||
let fixture: TestDBHandle;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await createTempDir('gitnexus-clean-stale-home-');
|
||||
fixture = await createTempDir('gitnexus-clean-stale-repo-');
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = home.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await fixture.cleanup();
|
||||
await home.cleanup();
|
||||
});
|
||||
|
||||
it('reclaims a slot after the git branch is deleted', async () => {
|
||||
const repo = fixture.dbPath;
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
commitAll(repo, 'init');
|
||||
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
|
||||
const storagePath = path.join(repo, '.gitnexus');
|
||||
const meta = (branch: string): RepoMeta => ({
|
||||
repoPath: repo,
|
||||
lastCommit: 'aaa',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
await saveMeta(storagePath, meta('main'));
|
||||
await registerRepo(repo, meta('main'));
|
||||
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, meta('feature/x'));
|
||||
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
|
||||
|
||||
execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe(
|
||||
'{}',
|
||||
);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a live slot when a tag shares the branch name', async () => {
|
||||
const repo = fixture.dbPath;
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
commitAll(repo, 'init');
|
||||
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
|
||||
const storagePath = path.join(repo, '.gitnexus');
|
||||
const meta = (branch: string): RepoMeta => ({
|
||||
repoPath: repo,
|
||||
lastCommit: 'aaa',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
await saveMeta(storagePath, meta('main'));
|
||||
await registerRepo(repo, meta('main'));
|
||||
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, meta('feature/x'));
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toContain('feature/x');
|
||||
});
|
||||
|
||||
it('reclaims a slot after the branch is deleted even if a tag keeps the name', async () => {
|
||||
const repo = fixture.dbPath;
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'a.ts'), 'export const a = 1;\n');
|
||||
commitAll(repo, 'init');
|
||||
execSync('git branch -M main', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
execSync('git branch feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
execSync('git tag feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
|
||||
const storagePath = path.join(repo, '.gitnexus');
|
||||
const meta = (branch: string): RepoMeta => ({
|
||||
repoPath: repo,
|
||||
lastCommit: 'aaa',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
await saveMeta(storagePath, meta('main'));
|
||||
await registerRepo(repo, meta('main'));
|
||||
await registerRepo(repo, meta('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, meta('feature/x'));
|
||||
|
||||
execSync('git branch -D feature/x', { cwd: repo, stdio: 'ignore', windowsHide: true });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -212,7 +212,7 @@ describe('persisted XAML declarations (#3202)', () => {
|
|||
expect(
|
||||
queries.mock.calls.some(
|
||||
([, statement]) =>
|
||||
statement.includes(predicate) && statement.includes('labels(n) AS type'),
|
||||
statement.includes(predicate) && statement.includes('labels(n) AS nodeLabels'),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -29,8 +29,22 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import TS from 'tree-sitter-typescript';
|
||||
import { TYPESCRIPT_QUERIES } from '../../src/core/ingestion/tree-sitter-queries.js';
|
||||
import JS from 'tree-sitter-javascript';
|
||||
import {
|
||||
JAVASCRIPT_QUERIES,
|
||||
TYPESCRIPT_QUERIES,
|
||||
} from '../../src/core/ingestion/tree-sitter-queries.js';
|
||||
import { typescriptProvider } from '../../src/core/ingestion/languages/typescript.js';
|
||||
import {
|
||||
getJsParser,
|
||||
getJsScopeQuery,
|
||||
} from '../../src/core/ingestion/languages/javascript/query.js';
|
||||
import {
|
||||
getTsParser,
|
||||
getTsScopeQuery,
|
||||
} from '../../src/core/ingestion/languages/typescript/query.js';
|
||||
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/captures.js';
|
||||
import { emitJsScopeCaptures } from '../../src/core/ingestion/languages/javascript/captures.js';
|
||||
import {
|
||||
FUNCTION_NODE_TYPES,
|
||||
genericFuncName,
|
||||
|
|
@ -274,6 +288,23 @@ describe('issue #1166 — Bug B: object-property arrows are named by pair.key',
|
|||
const getUser = findCall(sites, 'getUser');
|
||||
expect(getUser?.attributedTo).toBe('queryFn');
|
||||
});
|
||||
|
||||
it('does not attribute then / setTimeout pair callbacks to the property key', () => {
|
||||
const sites = collectCallAttributions(`
|
||||
const o = {
|
||||
result: promise.then(() => workThen()),
|
||||
timer: setTimeout(() => workTimer(), 1),
|
||||
handler: wrap(() => workWrap()),
|
||||
};
|
||||
`);
|
||||
const workThen = findCall(sites, 'workThen');
|
||||
const workTimer = findCall(sites, 'workTimer');
|
||||
expect(workThen, 'workThen call should be captured').toBeDefined();
|
||||
expect(workTimer, 'workTimer call should be captured').toBeDefined();
|
||||
expect(workThen!.attributedTo).not.toBe('result');
|
||||
expect(workTimer!.attributedTo).not.toBe('timer');
|
||||
expect(findCall(sites, 'workWrap')?.attributedTo).toBe('handler');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Definition-phase consistency ───────────────────────────────────────────
|
||||
|
|
@ -327,6 +358,136 @@ describe('issue #1166 — definition-phase consistency', () => {
|
|||
expect(names).toContain('add-item');
|
||||
});
|
||||
|
||||
it("captures quoted-key identifier HOC pairs (`'handler': wrap(() => ...)`)", () => {
|
||||
const names = definedFunctionNames(`
|
||||
export const store = {
|
||||
'handler': wrap(() => doSomething()),
|
||||
};
|
||||
`);
|
||||
expect(names).toContain('handler');
|
||||
});
|
||||
|
||||
it('captures tRPC pair HOC create: publicProcedure.mutation(...)', () => {
|
||||
const names = definedFunctionNames(`
|
||||
const r = { create: publicProcedure.mutation(async () => {}) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('captures curried tRPC pair HOC create: publicProcedure.mutation(withAuth(...))', () => {
|
||||
const names = definedFunctionNames(`
|
||||
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('captures quoted-key curried pair HOC create: mutation(withAuth(function () {}))', () => {
|
||||
const names = definedFunctionNames(`
|
||||
const r = { 'create': mutation(withAuth(function () { return null; })) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('does not name value-returning pair callbacks then / setTimeout / Array.from', () => {
|
||||
const names = definedFunctionNames(`
|
||||
const o = {
|
||||
result: promise.then(() => work()),
|
||||
timer: setTimeout(() => work(), 1),
|
||||
visible: Array.from(items, x => x),
|
||||
handler: wrap(() => work()),
|
||||
create: procedure.mutation(async () => work()),
|
||||
};
|
||||
`);
|
||||
expect(names).not.toContain('result');
|
||||
expect(names).not.toContain('timer');
|
||||
expect(names).not.toContain('visible');
|
||||
expect(names).toContain('handler');
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
// The emitters also enforce the built-in blocklists emit-side so the
|
||||
// guarantee holds regardless of query-predicate sharing behavior in
|
||||
// node-tree-sitter 0.21 (implicit-global stringValues across compiled
|
||||
// queries). This pins the emitter-level contract independently of the
|
||||
// query-level gates above.
|
||||
it('does not invent Function declarations for built-in callback registrations (emit-side)', () => {
|
||||
const declaredPairNames = (src: string, filePath: string): (string | undefined)[] => {
|
||||
const matches = filePath.endsWith('.js')
|
||||
? emitJsScopeCaptures(src, filePath)
|
||||
: emitTsScopeCaptures(src, filePath);
|
||||
return matches
|
||||
.filter((m) => m['@declaration.function'] !== undefined)
|
||||
.map((m) => m['@declaration.name']?.text);
|
||||
};
|
||||
const src = `
|
||||
export const timers = {
|
||||
timer: setTimeout(() => doSomething(), 100),
|
||||
later: Promise.resolve().then(() => doSomething()),
|
||||
ids: Array.from([1, 2], (n) => doSomething(n)),
|
||||
handler: wrapIt(() => doSomething()),
|
||||
};
|
||||
`;
|
||||
const tsNames = declaredPairNames(src, 'test.ts');
|
||||
expect(tsNames).not.toContain('timer');
|
||||
expect(tsNames).not.toContain('later');
|
||||
expect(tsNames).not.toContain('ids');
|
||||
expect(tsNames).toContain('handler');
|
||||
|
||||
const jsNames = declaredPairNames(src, 'test.js');
|
||||
expect(jsNames).not.toContain('timer');
|
||||
expect(jsNames).not.toContain('later');
|
||||
expect(jsNames).not.toContain('ids');
|
||||
expect(jsNames).toContain('handler');
|
||||
});
|
||||
|
||||
it('TYPESCRIPT_SCOPE_QUERY names curried tRPC pair HOC mutation(withAuth(arrow))', () => {
|
||||
const parser = getTsParser('router.ts');
|
||||
const query = getTsScopeQuery('router.ts');
|
||||
const tree = parser.parse(`
|
||||
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
|
||||
`);
|
||||
const names: string[] = [];
|
||||
for (const match of query.matches(tree.rootNode)) {
|
||||
let isFn = false;
|
||||
let name: string | undefined;
|
||||
for (const c of match.captures) {
|
||||
if (c.name === 'declaration.function') isFn = true;
|
||||
if (c.name === 'declaration.name') name = c.node.text;
|
||||
}
|
||||
if (isFn && name) names.push(name);
|
||||
}
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('TYPESCRIPT_SCOPE_QUERY does not name then / setTimeout / Array.from pair values', () => {
|
||||
const parser = getTsParser('router.ts');
|
||||
const query = getTsScopeQuery('router.ts');
|
||||
const tree = parser.parse(`
|
||||
const o = {
|
||||
result: promise.then(() => work()),
|
||||
timer: setTimeout(() => work(), 1),
|
||||
visible: Array.from(items, x => x),
|
||||
handler: wrap(() => work()),
|
||||
create: procedure.mutation(async () => work()),
|
||||
};
|
||||
`);
|
||||
const names: string[] = [];
|
||||
for (const match of query.matches(tree.rootNode)) {
|
||||
let isFn = false;
|
||||
let name: string | undefined;
|
||||
for (const c of match.captures) {
|
||||
if (c.name === 'declaration.function') isFn = true;
|
||||
if (c.name === 'declaration.name') name = c.node.text;
|
||||
}
|
||||
if (isFn && name) names.push(name);
|
||||
}
|
||||
expect(names).not.toContain('result');
|
||||
expect(names).not.toContain('timer');
|
||||
expect(names).not.toContain('visible');
|
||||
expect(names).toContain('handler');
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('does not invent names for computed-key pairs (`[K]: () => ...`)', () => {
|
||||
const names = definedFunctionNames(`
|
||||
export const store = {
|
||||
|
|
@ -570,3 +731,91 @@ describe('issue #1166 follow-up — HOC-wrapped variable declarations', () => {
|
|||
expect(findCall(sites, 'second')?.attributedTo).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── JavaScript HOC-wrapped pair values (tRPC / Express-style) ──────────────
|
||||
|
||||
describe('issue #1166 — JavaScript HOC-wrapped pair values (tRPC)', () => {
|
||||
const JS_GRAMMAR = JS as Parameters<Parser['setLanguage']>[0];
|
||||
|
||||
function definedJsFunctionNames(code: string): string[] {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(JS_GRAMMAR);
|
||||
const query = new Parser.Query(JS_GRAMMAR, JAVASCRIPT_QUERIES);
|
||||
const tree = parser.parse(code);
|
||||
const out: string[] = [];
|
||||
for (const match of query.matches(tree.rootNode)) {
|
||||
let isFn = false;
|
||||
let name: string | undefined;
|
||||
for (const c of match.captures) {
|
||||
if (c.name === 'definition.function') isFn = true;
|
||||
if (c.name === 'name') name = c.node.text;
|
||||
}
|
||||
if (isFn && name) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function definedJsScopeFunctionNames(code: string): string[] {
|
||||
const parser = getJsParser('router.js');
|
||||
const query = getJsScopeQuery('router.js');
|
||||
const tree = parser.parse(code);
|
||||
const out: string[] = [];
|
||||
for (const match of query.matches(tree.rootNode)) {
|
||||
let isFn = false;
|
||||
let name: string | undefined;
|
||||
for (const c of match.captures) {
|
||||
if (c.name === 'declaration.function') isFn = true;
|
||||
if (c.name === 'declaration.name') name = c.node.text;
|
||||
}
|
||||
if (isFn && name) out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it('captures create: publicProcedure.mutation as @definition.function in JAVASCRIPT_QUERIES', () => {
|
||||
const names = definedJsFunctionNames(`
|
||||
const r = { create: publicProcedure.mutation(async () => {}) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('captures create: publicProcedure.mutation(withAuth(...)) in JAVASCRIPT_QUERIES', () => {
|
||||
const names = definedJsFunctionNames(`
|
||||
const r = { create: publicProcedure.mutation(withAuth(async () => {})) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('captures create: procedure.mutation in JAVASCRIPT_SCOPE_QUERY', () => {
|
||||
const names = definedJsScopeFunctionNames(`
|
||||
const r = { create: procedure.mutation(async () => {}) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('captures create: procedure.mutation(withAuth(...)) in JAVASCRIPT_SCOPE_QUERY', () => {
|
||||
const names = definedJsScopeFunctionNames(`
|
||||
const r = { create: procedure.mutation(withAuth(async () => {})) };
|
||||
`);
|
||||
expect(names).toContain('create');
|
||||
});
|
||||
|
||||
it('does not name then / setTimeout / Array.from pair values in JS queries', () => {
|
||||
const src = `
|
||||
const o = {
|
||||
result: promise.then(() => work()),
|
||||
timer: setTimeout(() => work(), 1),
|
||||
visible: Array.from(items, x => x),
|
||||
handler: wrap(() => work()),
|
||||
create: procedure.mutation(async () => work()),
|
||||
};
|
||||
`;
|
||||
for (const names of [definedJsFunctionNames(src), definedJsScopeFunctionNames(src)]) {
|
||||
expect(names).not.toContain('result');
|
||||
expect(names).not.toContain('timer');
|
||||
expect(names).not.toContain('visible');
|
||||
expect(names).toContain('handler');
|
||||
expect(names).toContain('create');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { createSemanticModel } from '../../src/core/ingestion/model/index.js';
|
|||
import { processRoutesFromExtracted } from '../../src/core/ingestion/call-processor.js';
|
||||
import { generateId } from '../../src/lib/utils.js';
|
||||
import type { ExtractedRoute } from '../../src/core/ingestion/route-extractors/laravel.js';
|
||||
import { extractTrpcRoutes } from '../../src/core/ingestion/route-extractors/trpc.js';
|
||||
import type { KnowledgeGraph } from '../../src/core/graph/types.js';
|
||||
|
||||
const ROUTES_FILE = 'routes/web.php';
|
||||
|
|
@ -63,6 +64,26 @@ function routeCallsEdges(graph: KnowledgeGraph) {
|
|||
return graph.relationships.filter((r) => r.type === 'CALLS' && r.reason === 'laravel-route');
|
||||
}
|
||||
|
||||
function trpcCallsEdges(graph: KnowledgeGraph) {
|
||||
return graph.relationships.filter((r) => r.type === 'CALLS' && r.reason === 'trpc-route');
|
||||
}
|
||||
|
||||
const TRPC_FILE = 'src/server/trpc/routers/user.ts';
|
||||
|
||||
function addFunctionNode(
|
||||
graph: KnowledgeGraph,
|
||||
id: string,
|
||||
name: string,
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
) {
|
||||
graph.addNode({
|
||||
id,
|
||||
label: 'Function',
|
||||
properties: { name, filePath, startLine },
|
||||
});
|
||||
}
|
||||
|
||||
describe('processRoutesFromExtracted — Laravel route → controller CALLS edges', () => {
|
||||
it('resolvable controller + same-file method → one CALLS edge to the method node', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
|
@ -128,17 +149,14 @@ describe('processRoutesFromExtracted — Laravel route → controller CALLS edge
|
|||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('route missing controllerName or methodName → skipped', async () => {
|
||||
it('route missing methodName → skipped', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = modelWithController(['index']);
|
||||
|
||||
await processRoutesFromExtracted(
|
||||
graph,
|
||||
[makeRoute({ controllerName: null }), makeRoute({ methodName: null })],
|
||||
model,
|
||||
);
|
||||
await processRoutesFromExtracted(graph, [makeRoute({ methodName: null })], model);
|
||||
|
||||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
expect(trpcCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('multiple routes to the same controller → one edge per route, distinct targets', async () => {
|
||||
|
|
@ -295,3 +313,282 @@ describe('processRoutesFromExtracted — Laravel route → controller CALLS edge
|
|||
expect(edges[0].targetId).toBe('method:OrderController.index');
|
||||
});
|
||||
});
|
||||
|
||||
describe('processRoutesFromExtracted — tRPC same-file handler CALLS edges', () => {
|
||||
it('unique same-file Function + controllerName null + methodName match → one CALLS edge reason trpc-route', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list', 'Function');
|
||||
addFunctionNode(graph, 'fn:user.list', 'list', TRPC_FILE, 10);
|
||||
|
||||
await processRoutesFromExtracted(
|
||||
graph,
|
||||
[
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
routePath: '/trpc/user.list',
|
||||
lineNumber: 11,
|
||||
}),
|
||||
],
|
||||
model,
|
||||
);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].sourceId).toBe(generateId('File', TRPC_FILE));
|
||||
expect(edges[0].targetId).toBe('fn:user.list');
|
||||
expect(edges[0].reason).toBe('trpc-route');
|
||||
expect(edges[0].confidence).toBeCloseTo(0.5, 5);
|
||||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('two multiline sibling list procedures bind both handlers when arrow starts after .mutation(', async () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' list: publicProcedure.mutation(',
|
||||
' async () => {',
|
||||
' return null;',
|
||||
' },',
|
||||
' ),',
|
||||
' }),',
|
||||
' billing: t.router({',
|
||||
' list: publicProcedure.mutation(',
|
||||
' async () => {',
|
||||
' return null;',
|
||||
' },',
|
||||
' ),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(TRPC_FILE, source);
|
||||
expect(extracted.map((r) => `${r.httpMethod} ${r.routePath}`).sort()).toEqual([
|
||||
'POST /trpc/admin.list',
|
||||
'POST /trpc/billing.list',
|
||||
]);
|
||||
|
||||
const admin = extracted.find((r) => r.routePath === '/trpc/admin.list');
|
||||
const billing = extracted.find((r) => r.routePath === '/trpc/billing.list');
|
||||
expect(admin?.lineNumber).toBe(6);
|
||||
expect(billing?.lineNumber).toBe(13);
|
||||
|
||||
// Graph Function startLine is 0-based on the arrow, which begins the
|
||||
// line AFTER `.mutation(` — so it cannot equal toZeroBasedLine(terminal).
|
||||
const adminStartLine = 6; // 1-based line 7
|
||||
const billingStartLine = 13; // 1-based line 14
|
||||
expect(adminStartLine).not.toBe((admin?.lineNumber ?? 0) - 1);
|
||||
expect(billingStartLine).not.toBe((billing?.lineNumber ?? 0) - 1);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:admin.list', 'Function');
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:billing.list', 'Function');
|
||||
addFunctionNode(graph, 'fn:admin.list', 'list', TRPC_FILE, adminStartLine);
|
||||
addFunctionNode(graph, 'fn:billing.list', 'list', TRPC_FILE, billingStartLine);
|
||||
|
||||
await processRoutesFromExtracted(graph, extracted, model);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.map((e) => e.targetId).sort()).toEqual(['fn:admin.list', 'fn:billing.list']);
|
||||
expect(edges.every((e) => e.reason === 'trpc-route')).toBe(true);
|
||||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('two same-name Functions + matching lineNumbers → two trpc-route edges to the two node ids', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list#admin', 'Function');
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list#billing', 'Function');
|
||||
addFunctionNode(graph, 'fn:user.list#admin', 'list', TRPC_FILE, 10);
|
||||
addFunctionNode(graph, 'fn:user.list#billing', 'list', TRPC_FILE, 40);
|
||||
|
||||
await processRoutesFromExtracted(
|
||||
graph,
|
||||
[
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
httpMethod: 'get',
|
||||
routePath: '/trpc/admin.list',
|
||||
lineNumber: 11,
|
||||
}),
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
httpMethod: 'get',
|
||||
routePath: '/trpc/billing.list',
|
||||
lineNumber: 41,
|
||||
}),
|
||||
],
|
||||
model,
|
||||
);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(2);
|
||||
expect(edges.map((e) => e.targetId).sort()).toEqual([
|
||||
'fn:user.list#admin',
|
||||
'fn:user.list#billing',
|
||||
]);
|
||||
expect(edges.every((e) => e.reason === 'trpc-route')).toBe(true);
|
||||
});
|
||||
|
||||
it('same-name Property nearer the route line does not beat the Function handler', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'list', 'prop:user.list', 'Property');
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list', 'Function');
|
||||
graph.addNode({
|
||||
id: 'prop:user.list',
|
||||
label: 'Property',
|
||||
properties: { name: 'list', filePath: TRPC_FILE, startLine: 10 },
|
||||
});
|
||||
addFunctionNode(graph, 'fn:user.list', 'list', TRPC_FILE, 12);
|
||||
|
||||
await processRoutesFromExtracted(
|
||||
graph,
|
||||
[
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
routePath: '/trpc/user.list',
|
||||
lineNumber: 11,
|
||||
}),
|
||||
],
|
||||
model,
|
||||
);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].targetId).toBe('fn:user.list');
|
||||
});
|
||||
|
||||
it('two same-name Functions + routes without matching lineNumbers → zero trpc-route edges (fail-open)', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list#admin', 'Function');
|
||||
model.symbols.add(TRPC_FILE, 'list', 'fn:user.list#billing', 'Function');
|
||||
addFunctionNode(graph, 'fn:user.list#admin', 'list', TRPC_FILE, 10);
|
||||
addFunctionNode(graph, 'fn:user.list#billing', 'list', TRPC_FILE, 40);
|
||||
|
||||
await processRoutesFromExtracted(
|
||||
graph,
|
||||
[
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
routePath: '/trpc/admin.list',
|
||||
lineNumber: 99,
|
||||
}),
|
||||
makeRoute({
|
||||
filePath: TRPC_FILE,
|
||||
controllerName: null,
|
||||
methodName: 'list',
|
||||
routePath: '/trpc/billing.list',
|
||||
lineNumber: 100,
|
||||
}),
|
||||
],
|
||||
model,
|
||||
);
|
||||
|
||||
expect(trpcCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('db.query inside create .input still binds POST create as trpc-route', async () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.input(z.custom(async v => db.query(v))).mutation(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(TRPC_FILE, source);
|
||||
expect(extracted.map((r) => `${r.httpMethod} ${r.routePath}`)).toEqual(['POST /trpc/create']);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'create', 'fn:user.create', 'Function');
|
||||
addFunctionNode(
|
||||
graph,
|
||||
'fn:user.create',
|
||||
'create',
|
||||
TRPC_FILE,
|
||||
(extracted[0]?.lineNumber ?? 1) - 1,
|
||||
);
|
||||
|
||||
await processRoutesFromExtracted(graph, extracted, model);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].targetId).toBe('fn:user.create');
|
||||
expect(edges[0].reason).toBe('trpc-route');
|
||||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('identifier callback methodName binds the handler Function, not the procedure key', async () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.mutation(handler),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(TRPC_FILE, source);
|
||||
expect(extracted[0]?.methodName).toBe('handler');
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'handler', 'fn:user.handler', 'Function');
|
||||
addFunctionNode(graph, 'fn:user.handler', 'handler', TRPC_FILE, 2);
|
||||
|
||||
await processRoutesFromExtracted(graph, extracted, model);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].targetId).toBe('fn:user.handler');
|
||||
expect(edges[0].reason).toBe('trpc-route');
|
||||
});
|
||||
|
||||
it('db.transaction().query inside create .input still binds POST create as trpc-route', async () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.input(v => db.transaction().query(v)).mutation(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(TRPC_FILE, source);
|
||||
expect(extracted.map((r) => `${r.httpMethod} ${r.routePath}`)).toEqual(['POST /trpc/create']);
|
||||
|
||||
const graph = createKnowledgeGraph();
|
||||
const model = createSemanticModel();
|
||||
model.symbols.add(TRPC_FILE, 'create', 'fn:user.create', 'Function');
|
||||
addFunctionNode(
|
||||
graph,
|
||||
'fn:user.create',
|
||||
'create',
|
||||
TRPC_FILE,
|
||||
(extracted[0]?.lineNumber ?? 1) - 1,
|
||||
);
|
||||
|
||||
await processRoutesFromExtracted(graph, extracted, model);
|
||||
|
||||
const edges = trpcCallsEdges(graph);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(edges[0].targetId).toBe('fn:user.create');
|
||||
expect(edges[0].reason).toBe('trpc-route');
|
||||
expect(routeCallsEdges(graph)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,3 +117,60 @@ describe('cleanCommand external storage ownership', () => {
|
|||
expect(remainingEntry).toMatchObject({ path: repoA, storagePath: storageB });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanCommand named branch empty-dir cleanup (#3331)', () => {
|
||||
let fixture: TestDBHandle;
|
||||
let previousGitNexusHome: string | undefined;
|
||||
let repo: string;
|
||||
let storagePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await createTempDir();
|
||||
previousGitNexusHome = process.env.GITNEXUS_HOME;
|
||||
const home = path.join(fixture.dbPath, 'home');
|
||||
repo = path.join(fixture.dbPath, 'repo');
|
||||
storagePath = path.join(repo, '.gitnexus');
|
||||
await fs.mkdir(home, { recursive: true });
|
||||
await fs.mkdir(repo, { recursive: true });
|
||||
initGitRepo(repo);
|
||||
process.env.GITNEXUS_HOME = home;
|
||||
|
||||
const { registerRepo, getStoragePaths, saveMeta } =
|
||||
await import('../../src/storage/repo-manager.js');
|
||||
const meta = {
|
||||
repoPath: repo,
|
||||
lastCommit: 'aaa',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch: 'main',
|
||||
stats: { files: 1, nodes: 1 },
|
||||
};
|
||||
await saveMeta(storagePath, meta);
|
||||
await registerRepo(repo, meta);
|
||||
await registerRepo(
|
||||
repo,
|
||||
{ ...meta, branch: 'feature/x', lastCommit: 'bbb' },
|
||||
{
|
||||
branch: 'feature/x',
|
||||
},
|
||||
);
|
||||
const branchDir = path.dirname(getStoragePaths(repo, 'feature/x', storagePath).metaPath);
|
||||
await saveMeta(branchDir, { ...meta, branch: 'feature/x' });
|
||||
expect(path.basename(path.dirname(branchDir))).toBe('branches');
|
||||
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (previousGitNexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = previousGitNexusHome;
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it('named --branch --force rmdirs empty branches/ and keeps the workspace slot', async () => {
|
||||
await cleanCommand({ branch: 'feature/x', force: true });
|
||||
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toBeTruthy();
|
||||
await expect(fs.access(path.join(storagePath, 'gitnexus.json'))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
353
gitnexus/test/unit/clean-stale.test.ts
Normal file
353
gitnexus/test/unit/clean-stale.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanCommand } from '../../src/cli/clean.js';
|
||||
import { t } from '../../src/cli/i18n/index.js';
|
||||
import { branchSlug } from '../../src/storage/branch-index.js';
|
||||
import * as git from '../../src/storage/git.js';
|
||||
import {
|
||||
getStoragePaths,
|
||||
listRegisteredRepos,
|
||||
registerRepo,
|
||||
saveMeta,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { INDEX_METADATA_FILE } from '../../src/storage/storage-constants.js';
|
||||
import { initGitRepo, commitAll } from '../helpers/temp-git-repo.js';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
|
||||
describe('cleanCommand --stale (#3331)', () => {
|
||||
let home: TestDBHandle;
|
||||
let fixture: TestDBHandle;
|
||||
let repo: string;
|
||||
let storagePath: string;
|
||||
let savedHome: string | undefined;
|
||||
let logs: string[];
|
||||
|
||||
const metaFor = (branch: string, repoPath: string): RepoMeta => ({
|
||||
repoPath,
|
||||
lastCommit: 'aaa',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
|
||||
async function writeOwnedFlat(repoPath: string, dest: string): Promise<void> {
|
||||
await saveMeta(dest, metaFor('main', repoPath));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await createTempDir();
|
||||
fixture = await createTempDir();
|
||||
repo = path.join(fixture.dbPath, 'repo');
|
||||
storagePath = path.join(repo, '.gitnexus');
|
||||
await fs.mkdir(repo, { recursive: true });
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = home.dbPath;
|
||||
logs = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
|
||||
logs.push(args.map(String).join(' '));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await fixture.cleanup();
|
||||
await home.cleanup();
|
||||
});
|
||||
|
||||
it('previews leftover slots with reason and does not delete without --force', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true });
|
||||
|
||||
const output = logs.join('\n');
|
||||
expect(output).toContain(t('clean.stale.reason.refMissing'));
|
||||
expect(output).toContain('feature/x');
|
||||
expect(output).toContain(t('common.runForceConfirm'));
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes a leftover slot and empty branches/ with --force', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(storagePath, INDEX_METADATA_FILE))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips a leftover slot that is a local head again on force re-check', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
const realListLocalHeads = git.listLocalHeads;
|
||||
let calls = 0;
|
||||
vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => {
|
||||
calls += 1;
|
||||
if (calls === 1) return realListLocalHeads(repoPath);
|
||||
return ['main', 'feature/x'];
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
const output = logs.join('\n');
|
||||
expect(output).toContain(t('clean.stale.skippedLive', { branch: 'feature/x' }));
|
||||
expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('still deletes when force re-check returns an empty head list', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
const realListLocalHeads = git.listLocalHeads;
|
||||
let calls = 0;
|
||||
vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => {
|
||||
calls += 1;
|
||||
if (calls === 1) return realListLocalHeads(repoPath);
|
||||
return [];
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not delete when force re-check cannot list local heads', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
const realListLocalHeads = git.listLocalHeads;
|
||||
let calls = 0;
|
||||
vi.spyOn(git, 'listLocalHeads').mockImplementation((repoPath: string) => {
|
||||
calls += 1;
|
||||
if (calls === 1) return realListLocalHeads(repoPath);
|
||||
return null;
|
||||
});
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
const output = logs.join('\n');
|
||||
expect(output).toContain(t('clean.stale.headsUnavailable'));
|
||||
expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not treat an obstructed slot path as none or delete it', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await fs.mkdir(path.dirname(dir), { recursive: true });
|
||||
await fs.writeFile(dir, 'not-a-directory');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
const output = logs.join('\n');
|
||||
expect(output).toContain(t('clean.stale.probeFailed'));
|
||||
expect(output).not.toContain(t('clean.stale.none'));
|
||||
expect(output).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
await expect(fs.readFile(dir, 'utf8')).resolves.toBe('not-a-directory');
|
||||
});
|
||||
|
||||
it('does not delete when leftover directories cannot be listed', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
const realReaddir = fs.readdir.bind(fs);
|
||||
vi.spyOn(fs, 'readdir').mockImplementation((async (target: unknown, options?: unknown) => {
|
||||
if (path.resolve(String(target)) === path.resolve(branchesRoot)) {
|
||||
const err = new Error('EACCES') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return realReaddir(
|
||||
target as Parameters<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[1],
|
||||
);
|
||||
}) as typeof fs.readdir);
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.listingFailed'));
|
||||
expect(logs.join('\n')).not.toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
expect(logs.join('\n')).not.toContain(t('clean.stale.none'));
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not delete when local heads cannot be listed', async () => {
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.headsUnavailable'));
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes a disk-only leftover directory', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(dir, metaFor('feature/x', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does not drop a recorded branch when a stray disk-only dir claims its name', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.mkdir(branchesRoot, { recursive: true });
|
||||
const canonical = path.join(branchesRoot, branchSlug('feature/x'));
|
||||
await fs.writeFile(canonical, 'not-a-directory');
|
||||
const stray = path.join(branchesRoot, 'mystery-deadbeef');
|
||||
await saveMeta(stray, metaFor('feature/x', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.probeFailed'));
|
||||
await expect(fs.access(stray)).rejects.toThrow();
|
||||
await expect(fs.readFile(canonical, 'utf8')).resolves.toBe('not-a-directory');
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']);
|
||||
});
|
||||
|
||||
it('drops a registry-only row without requiring a directory', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
await registerRepo(repo, metaFor('feature/x', repo), { branch: 'feature/x' });
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.deleted', { branch: 'feature/x' }));
|
||||
});
|
||||
|
||||
it('prints a none-found message when there are no leftover slots', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.none'));
|
||||
});
|
||||
|
||||
it('prefers the --stale arm when --branch is also set', async () => {
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await writeOwnedFlat(repo, storagePath);
|
||||
await registerRepo(repo, metaFor('main', repo));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, branch: 'feature/x' });
|
||||
|
||||
expect(logs.join('\n')).toContain(t('clean.stale.none'));
|
||||
expect(logs.join('\n')).not.toContain('feature/x');
|
||||
});
|
||||
|
||||
it('refuses --stale on a non-owned external storage path', async () => {
|
||||
const foreign = path.join(fixture.dbPath, 'foreign-index');
|
||||
const other = path.join(fixture.dbPath, 'other');
|
||||
await fs.mkdir(foreign, { recursive: true });
|
||||
await fs.mkdir(other, { recursive: true });
|
||||
initGitRepo(repo);
|
||||
await fs.writeFile(path.join(repo, 'README.md'), 'hi\n');
|
||||
commitAll(repo, 'init');
|
||||
await saveMeta(foreign, {
|
||||
...metaFor('main', other),
|
||||
storagePath: foreign,
|
||||
});
|
||||
await registerRepo(
|
||||
repo,
|
||||
{ ...metaFor('main', repo), storagePath: foreign },
|
||||
{
|
||||
storagePath: foreign,
|
||||
},
|
||||
);
|
||||
const leftover = path.join(foreign, 'branches', branchSlug('feature/x'));
|
||||
await saveMeta(leftover, metaFor('feature/x', other));
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(repo);
|
||||
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
|
||||
await expect(fs.access(leftover)).resolves.toBeUndefined();
|
||||
expect(getStoragePaths(repo, undefined, foreign).storagePath).toBe(foreign);
|
||||
expect(logs.join('\n')).toMatch(/No indexed repository|Refusing to clean leftover/);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,13 +3,16 @@ import {
|
|||
displayWidth,
|
||||
doctorCommand,
|
||||
localEmbeddingDoctorStatus,
|
||||
orphanedBranchSlotDoctorLines,
|
||||
padDisplayEnd,
|
||||
nativeStatusLine,
|
||||
pageSizeDoctorLines,
|
||||
poolSizeDoctorLine,
|
||||
} from '../../src/cli/doctor.js';
|
||||
import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import type { StaleBranchSlot } from '../../src/storage/stale-branch-slots.js';
|
||||
import { setCliLanguage, t, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
|
||||
import type { NativeCheckResult } from '../../src/core/lbug/native-check.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
const nativeProbeState = vi.hoisted(() => ({
|
||||
vectorLoaded: true,
|
||||
|
|
@ -376,3 +379,81 @@ describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#2385)', () => {
|
|||
await expect(doctorCommand()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphanedBranchSlotDoctorLines (#3331)', () => {
|
||||
const slot = (overrides: Partial<StaleBranchSlot>): StaleBranchSlot => ({
|
||||
branch: 'feature/x',
|
||||
dir: '/tmp/branches/feature_x',
|
||||
sizeBytes: 1024,
|
||||
reason: 'ref-missing',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns no lines when there are no leftover slots', () => {
|
||||
expect(orphanedBranchSlotDoctorLines([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('prints branch, reason, size, total, and the clean --stale reclaim line', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]);
|
||||
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).toContain('feature/x');
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing'));
|
||||
expect(lines.join('\n')).toContain('4.6 MB');
|
||||
expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim'));
|
||||
expect(lines.join('\n')).toContain('gitnexus clean --stale');
|
||||
});
|
||||
|
||||
it('prints retry-git copy instead of reclaim when heads cannot be listed (#3337)', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
slot({ reason: 'heads-unavailable', sizeBytes: 2048 }),
|
||||
slot({ branch: 'other', reason: 'ref-missing', sizeBytes: 4096 }),
|
||||
]);
|
||||
expect(lines).toEqual([t('clean.stale.headsUnavailable')]);
|
||||
expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim'));
|
||||
});
|
||||
|
||||
it('prints only listingFailed when listing-failed is mixed with ref-missing', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
slot({ reason: 'listing-failed', branch: '', dir: null, sizeBytes: 0 }),
|
||||
slot({ reason: 'ref-missing' }),
|
||||
]);
|
||||
expect(lines).toEqual([t('clean.stale.listingFailed')]);
|
||||
expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim'));
|
||||
});
|
||||
|
||||
it('prints heading and probe-failed row without reclaim', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]);
|
||||
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).toContain('feature/x');
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed'));
|
||||
expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim'));
|
||||
});
|
||||
|
||||
it('includes reclaim when probe-failed is mixed with ref-missing', () => {
|
||||
const lines = orphanedBranchSlotDoctorLines([
|
||||
slot({ reason: 'probe-failed' }),
|
||||
slot({ branch: 'other', reason: 'ref-missing' }),
|
||||
]);
|
||||
expect(lines[0]).toBe(t('doctor.orphanedBranches'));
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed'));
|
||||
expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing'));
|
||||
expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim'));
|
||||
});
|
||||
|
||||
it('does not print leftover slots when cwd is not an indexed repo', async () => {
|
||||
const tmp = await createTempDir();
|
||||
try {
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(tmp.dbPath);
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
await doctorCommand();
|
||||
const output = log.mock.calls.map((args) => args.map(String).join(' ')).join('\n');
|
||||
expect(output).toContain(t('doctor.runtime'));
|
||||
expect(output).not.toContain(t('doctor.orphanedBranches'));
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await tmp.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,6 +66,50 @@ describe('detectFrameworkFromPath', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tRPC', () => {
|
||||
it('detects /trpc/routers/ files at 3.0x', () => {
|
||||
const result = detectFrameworkFromPath('src/server/trpc/routers/user.ts');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.framework).toBe('trpc');
|
||||
expect(result!.entryPointMultiplier).toBe(3.0);
|
||||
expect(result!.reason).toBe('trpc-router');
|
||||
});
|
||||
|
||||
it('detects T3 /api/routers/ files at 3.0x', () => {
|
||||
const result = detectFrameworkFromPath('src/server/api/routers/settings.ts');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.framework).toBe('trpc');
|
||||
expect(result!.entryPointMultiplier).toBe(3.0);
|
||||
});
|
||||
|
||||
it('detects T3 /api/routers/ JavaScript files at 3.0x', () => {
|
||||
const result = detectFrameworkFromPath('src/server/api/routers/settings.js');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.framework).toBe('trpc');
|
||||
expect(result!.entryPointMultiplier).toBe(3.0);
|
||||
});
|
||||
|
||||
it('classifies /app/trpc/routers/ as tRPC, not expo-router', () => {
|
||||
const result = detectFrameworkFromPath('src/app/trpc/routers/user.ts');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.framework).toBe('trpc');
|
||||
expect(result!.framework).not.toBe('expo-router');
|
||||
expect(result!.entryPointMultiplier).toBe(3.0);
|
||||
});
|
||||
|
||||
it('classifies /app/trpc/routers/ JavaScript as tRPC, not expo-router', () => {
|
||||
const result = detectFrameworkFromPath('src/app/trpc/routers/user.js');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.framework).toBe('trpc');
|
||||
expect(result!.framework).not.toBe('expo-router');
|
||||
expect(result!.entryPointMultiplier).toBe(3.0);
|
||||
});
|
||||
|
||||
it('does not treat a generic /routers/ folder as tRPC', () => {
|
||||
expect(detectFrameworkFromPath('src/routers/vue.ts')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Express / Node.js', () => {
|
||||
it('detects route files', () => {
|
||||
const result = detectFrameworkFromPath('routes/auth.ts');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
sanitizeRepoName,
|
||||
getDefaultBranch,
|
||||
getCurrentBranch,
|
||||
listLocalHeads,
|
||||
getGitInfoExcludePath,
|
||||
getCoreExcludesFilePath,
|
||||
} from '../../src/storage/git.js';
|
||||
|
|
@ -19,9 +20,11 @@ import {
|
|||
// Mock child_process.execSync
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
const mockSpawnSync = vi.mocked(spawnSync);
|
||||
|
||||
describe('git utilities', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -136,6 +139,68 @@ describe('git utilities', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('listLocalHeads (#3331)', () => {
|
||||
it('returns local head names including a slashed branch', () => {
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: 'refs/heads/main\nrefs/heads/feature/x\n',
|
||||
stderr: '',
|
||||
error: undefined,
|
||||
} as ReturnType<typeof spawnSync>);
|
||||
expect(listLocalHeads('/project')).toEqual(['main', 'feature/x']);
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['for-each-ref', '--format=%(refname)', 'refs/heads'],
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
windowsHide: true,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores lines that do not start with refs/heads/', () => {
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: 'refs/heads/main\nheads/feature/x\nrefs/tags/feature/x\n',
|
||||
stderr: '',
|
||||
error: undefined,
|
||||
} as ReturnType<typeof spawnSync>);
|
||||
expect(listLocalHeads('/project')).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('returns an empty list when the repo has no local heads', () => {
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: 0,
|
||||
stdout: '\n',
|
||||
stderr: '',
|
||||
error: undefined,
|
||||
} as ReturnType<typeof spawnSync>);
|
||||
expect(listLocalHeads('/project')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null when git exits non-zero', () => {
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'fatal: not a git repository',
|
||||
error: undefined,
|
||||
} as ReturnType<typeof spawnSync>);
|
||||
expect(listLocalHeads('/not-a-repo')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when git cannot run', () => {
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }),
|
||||
} as ReturnType<typeof spawnSync>);
|
||||
expect(listLocalHeads('/missing-git')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRoot', () => {
|
||||
it('returns resolved path on success', () => {
|
||||
mockExecSync.mockReturnValueOnce(Buffer.from('/d/Projects/MyRepo\n'));
|
||||
|
|
|
|||
|
|
@ -295,6 +295,123 @@ describe('GroupService', () => {
|
|||
expect(result.error).toContain('name and query are required');
|
||||
});
|
||||
|
||||
it('test_groupQuery_uses_advertised_query_defaults_when_omitted', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const query = vi.fn(async () => ({ processes: [] }));
|
||||
const svc = new GroupService(makePort({ query }));
|
||||
await svc.groupQuery({ name: 'test-group', query: 'auth flow' });
|
||||
expect(query).toHaveBeenCalled();
|
||||
for (const call of query.mock.calls) {
|
||||
expect(call[1]).toMatchObject({ limit: 10, max_symbols: 25 });
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupQuery_forwards_explicit_limit_and_max_symbols', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const query = vi.fn(async () => ({ processes: [] }));
|
||||
const svc = new GroupService(makePort({ query }));
|
||||
await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
limit: 3,
|
||||
max_symbols: 7,
|
||||
});
|
||||
for (const call of query.mock.calls) {
|
||||
expect(call[1]).toMatchObject({ limit: 3, max_symbols: 7 });
|
||||
}
|
||||
expect(query).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupQuery_rejects_infinite_and_oversized_limit_and_max_symbols', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const query = vi.fn(async () => ({ processes: [] }));
|
||||
const svc = new GroupService(makePort({ query }));
|
||||
|
||||
const infiniteLimit = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
limit: Infinity,
|
||||
});
|
||||
expect(infiniteLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
||||
|
||||
const oversizedLimit = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
limit: 101,
|
||||
});
|
||||
expect(oversizedLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
||||
|
||||
const infiniteSymbols = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
max_symbols: Infinity,
|
||||
});
|
||||
expect(infiniteSymbols).toMatchObject({
|
||||
error: expect.stringMatching(/Invalid "max_symbols"/),
|
||||
});
|
||||
|
||||
const oversizedSymbols = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
max_symbols: 201,
|
||||
});
|
||||
expect(oversizedSymbols).toMatchObject({
|
||||
error: expect.stringMatching(/Invalid "max_symbols"/),
|
||||
});
|
||||
|
||||
const cyclic = { self: null as unknown };
|
||||
cyclic.self = cyclic;
|
||||
const cyclicLimit = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
limit: cyclic,
|
||||
});
|
||||
expect(cyclicLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
||||
|
||||
const badDepth = await svc.groupQuery({
|
||||
name: 'test-group',
|
||||
query: 'auth flow',
|
||||
chain_depth: 0.1,
|
||||
});
|
||||
expect(badDepth).toMatchObject({ error: expect.stringMatching(/Invalid "chain_depth"/) });
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupQuery_forwards_chain_depth', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const query = vi.fn(async () => ({ processes: [] }));
|
||||
const svc = new GroupService(makePort({ query }));
|
||||
await svc.groupQuery({ name: 'test-group', query: 'auth flow', chain_depth: 2 });
|
||||
expect(query).toHaveBeenCalled();
|
||||
for (const call of query.mock.calls) {
|
||||
expect(call[1]).toMatchObject({ chain_depth: 2 });
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupQuery_merges_results_across_repos', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
|
|
@ -450,6 +567,45 @@ repos:
|
|||
}
|
||||
});
|
||||
|
||||
it('test_groupContext_rejects_non_integer_chain_depth', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const context = vi.fn(async () => ({ status: 'found' }));
|
||||
const svc = new GroupService(makePort({ context }));
|
||||
const r = await svc.groupContext({
|
||||
name: 'test-group',
|
||||
target: 'MySym',
|
||||
chain_depth: '2',
|
||||
});
|
||||
expect(r).toMatchObject({ error: expect.stringMatching(/Invalid "chain_depth"/) });
|
||||
expect(context).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContext_forwards_chain_depth', async () => {
|
||||
const { cleanup, tmpDir } = makeTmpGroup();
|
||||
try {
|
||||
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
||||
const context = vi.fn(async () => ({
|
||||
status: 'found',
|
||||
symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' },
|
||||
}));
|
||||
const svc = new GroupService(makePort({ context }));
|
||||
await svc.groupContext({ name: 'test-group', target: 'MySym', chain_depth: 2 });
|
||||
expect(context).toHaveBeenCalled();
|
||||
for (const call of context.mock.calls) {
|
||||
expect(call[1]).toMatchObject({ chain_depth: 2 });
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it('test_groupContext_subgroupExact_skips_descendant_member_paths', async () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `gitnexus-ctx-nest-${Date.now()}`);
|
||||
const groupDir = path.join(tmpDir, 'groups', 'nest-group');
|
||||
|
|
|
|||
|
|
@ -281,8 +281,15 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// restricted pub(...) imports from unrestricted reexports.
|
||||
// Moved 101 -> 102 for #3273: preserve exact call-result assignment facts
|
||||
// required by post-resolution Swift return-type replay.
|
||||
it('pins SCHEMA_BUMP to 102 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(102);
|
||||
// Moved 102 -> 103 for #3339 review: tRPC route extraction changed —
|
||||
// nested-router paths, a tightened entry gate, and controller-less routes
|
||||
// now bind handlers via a same-file CALLS edge. Warm caches replay the flat
|
||||
// pre-fix capture set verbatim, so both stores re-extract.
|
||||
// Moved 103 -> 104 for #3339 review: pair-HOC queries name
|
||||
// `mutation(withAuth(arrow))` object-pair handlers. Warm caches replay
|
||||
// anonymous arrows, so both stores re-extract.
|
||||
it('pins SCHEMA_BUMP to 104 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(104);
|
||||
expect(PARSE_CACHE_BUCKET_COUNT).toBe(128);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
|
|
@ -290,7 +297,7 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Every nearby historical or in-flight value is rejected.
|
||||
for (const taken of [
|
||||
59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101,
|
||||
82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103,
|
||||
]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
|
|
|
|||
482
gitnexus/test/unit/mcp-context-route-chain.test.ts
Normal file
482
gitnexus/test/unit/mcp-context-route-chain.test.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* Unit tests: context/query route + chain enrichment (#4, #9, #28).
|
||||
*
|
||||
* Drives LocalBackend with a mocked executeParameterized (same seam as
|
||||
* test/unit/trace-bfs.test.ts) so HANDLES_ROUTE / BFS rows can be injected
|
||||
* without a LadybugDB index.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { lbugMocks } = vi.hoisted(() => ({
|
||||
lbugMocks: {
|
||||
initLbug: vi.fn().mockResolvedValue(undefined),
|
||||
executeQuery: vi.fn().mockResolvedValue([]),
|
||||
executeParameterized: vi.fn().mockResolvedValue([]),
|
||||
closeLbug: vi.fn().mockResolvedValue(undefined),
|
||||
isLbugReady: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, ...lbugMocks };
|
||||
});
|
||||
|
||||
vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { ...actual, ...lbugMocks };
|
||||
});
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
|
||||
return {
|
||||
...actual,
|
||||
listRegisteredRepos: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'test-project',
|
||||
path: '/tmp/test-project',
|
||||
storagePath: '/tmp/.gitnexus/test-project',
|
||||
indexedAt: '2024-06-01T12:00:00Z',
|
||||
lastCommit: 'abc123',
|
||||
stats: { files: 10, nodes: 50, edges: 100, communities: 3, processes: 5 },
|
||||
},
|
||||
]),
|
||||
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
|
||||
findSiblingClones: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../src/core/git-staleness.js', () => ({
|
||||
checkStaleness: vi.fn().mockReturnValue({ isStale: false, commitsBehind: 0 }),
|
||||
checkStalenessAsync: vi.fn().mockResolvedValue({ isStale: false, commitsBehind: 0 }),
|
||||
checkCwdMatch: vi.fn().mockResolvedValue({ match: 'none' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/git.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/storage/git.js')>();
|
||||
return { ...actual, getGitRoot: vi.fn().mockReturnValue(null) };
|
||||
});
|
||||
|
||||
vi.mock('../../src/core/search/bm25-index.js', () => ({
|
||||
searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/mcp/core/embedder.js', () => ({
|
||||
embedQuery: vi.fn().mockResolvedValue([]),
|
||||
getEmbeddingDims: vi.fn().mockReturnValue(384),
|
||||
}));
|
||||
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
|
||||
import { executeParameterized } from '../../src/mcp/core/lbug-adapter.js';
|
||||
|
||||
const HANDLER = {
|
||||
id: 'Function:src/handlers/login.ts:loginHandler',
|
||||
name: 'loginHandler',
|
||||
type: 'Function',
|
||||
filePath: 'src/handlers/login.ts',
|
||||
startLine: 10,
|
||||
endLine: 40,
|
||||
};
|
||||
|
||||
const ROUTE = { url: '/api/login', method: 'POST' };
|
||||
|
||||
const CALLER = {
|
||||
uid: 'Function:src/a.ts:callerFn',
|
||||
name: 'callerFn',
|
||||
filePath: 'src/a.ts',
|
||||
kind: 'Function',
|
||||
isTest: 0,
|
||||
};
|
||||
|
||||
const CALLEE = {
|
||||
uid: 'Function:src/b.ts:calleeFn',
|
||||
name: 'calleeFn',
|
||||
filePath: 'src/b.ts',
|
||||
kind: 'Function',
|
||||
isTest: 0,
|
||||
};
|
||||
|
||||
/** Reciprocal peer of HANDLER: CALLS HANDLER→PEER and PEER→HANDLER. */
|
||||
const PEER = {
|
||||
uid: 'Function:src/peer.ts:peerFn',
|
||||
name: 'peerFn',
|
||||
filePath: 'src/peer.ts',
|
||||
kind: 'Function',
|
||||
isTest: 0,
|
||||
};
|
||||
|
||||
async function makeBackend(): Promise<LocalBackend> {
|
||||
const b = new LocalBackend();
|
||||
await b.init();
|
||||
(b as any).ensureInitialized = vi.fn().mockResolvedValue(undefined);
|
||||
return b;
|
||||
}
|
||||
|
||||
function isUidLookup(params: any): boolean {
|
||||
return params?.uid === HANDLER.id;
|
||||
}
|
||||
|
||||
function isNameLookup(params: any): boolean {
|
||||
return params?.symName === HANDLER.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `async` matters: the real executeParameterized returns a promise, and
|
||||
* callers chain `.catch()` on it directly (see trace-bfs.test.ts).
|
||||
*/
|
||||
function mockGraph(options: {
|
||||
handlesRoute?: boolean;
|
||||
processRows?: any[];
|
||||
chain?: boolean;
|
||||
queryProcess?: boolean;
|
||||
/** Same peer for both CALLS directions; omit if already in $visited. */
|
||||
reciprocalPeer?: typeof PEER;
|
||||
}) {
|
||||
return async (_db: string, query: string, params: any = {}) => {
|
||||
if (isUidLookup(params) || isNameLookup(params)) return [HANDLER];
|
||||
|
||||
if (params?.frontier) {
|
||||
const visited = new Set(params.visited ?? []);
|
||||
if (options.reciprocalPeer) {
|
||||
if (visited.has(options.reciprocalPeer.uid)) return [];
|
||||
return [options.reciprocalPeer];
|
||||
}
|
||||
if (!options.chain) return [];
|
||||
if (query.includes('MATCH (caller)')) return [CALLER];
|
||||
if (query.includes('MATCH (n)-[r:CodeRelation]->(target)')) return [CALLEE];
|
||||
return [];
|
||||
}
|
||||
|
||||
if (query.includes('STEP_IN_PROCESS')) {
|
||||
return options.processRows ?? [];
|
||||
}
|
||||
|
||||
// Combined ENTRY_POINT_OF ∪ HANDLES_ROUTE query — only the HANDLES_ROUTE
|
||||
// arm is populated in these fixtures (no Process / ENTRY_POINT_OF rows).
|
||||
if (query.includes('HANDLES_ROUTE')) {
|
||||
if (!options.handlesRoute) return [];
|
||||
if (options.queryProcess) {
|
||||
return [{ pid: HANDLER.id, url: ROUTE.url, method: ROUTE.method }];
|
||||
}
|
||||
return [{ url: ROUTE.url, method: ROUTE.method }];
|
||||
}
|
||||
|
||||
if (query.includes('ENTRY_POINT_OF')) return [];
|
||||
return [];
|
||||
};
|
||||
}
|
||||
|
||||
describe('context/query route + chain enrichment', () => {
|
||||
let backend: LocalBackend;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
backend = await makeBackend();
|
||||
});
|
||||
|
||||
it('context() returns routes from HANDLES_ROUTE even with no Process/ENTRY_POINT_OF', async () => {
|
||||
(executeParameterized as any).mockImplementation(
|
||||
mockGraph({ handlesRoute: true, processRows: [] }),
|
||||
);
|
||||
|
||||
const result = await backend.callTool('context', { uid: HANDLER.id });
|
||||
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.processes).toEqual([]);
|
||||
expect(result.is_entry_point).toBeUndefined();
|
||||
expect(result.routes).toEqual([{ url: ROUTE.url, method: ROUTE.method }]);
|
||||
});
|
||||
|
||||
it.each([0.1, 1.5, 4] as const)(
|
||||
'context rejects non-integer or oversized chain_depth=%s',
|
||||
async (value) => {
|
||||
const result = await backend.callTool('context', { uid: HANDLER.id, chain_depth: value });
|
||||
expect(result).toMatchObject({
|
||||
error: expect.stringMatching(/Invalid "chain_depth".*\[0,/),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('context({chain_depth:1}) returns chain when BFS rows exist', async () => {
|
||||
(executeParameterized as any).mockImplementation(
|
||||
mockGraph({ handlesRoute: true, processRows: [], chain: true }),
|
||||
);
|
||||
|
||||
const result = await backend.callTool('context', { uid: HANDLER.id, chain_depth: 1 });
|
||||
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.chain).toEqual([
|
||||
{
|
||||
depth: 1,
|
||||
upstream: [
|
||||
{ uid: CALLER.uid, name: CALLER.name, filePath: CALLER.filePath, kind: CALLER.kind },
|
||||
],
|
||||
downstream: [
|
||||
{ uid: CALLEE.uid, name: CALLEE.name, filePath: CALLEE.filePath, kind: CALLEE.kind },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('context({chain_depth:1}) de-duplicates a node reached through two frontier edges', async () => {
|
||||
(executeParameterized as any).mockImplementation(
|
||||
async (_db: string, query: string, params: any = {}) => {
|
||||
if (isUidLookup(params) || isNameLookup(params)) return [HANDLER];
|
||||
if (params?.frontier) {
|
||||
if (query.includes('MATCH (caller)')) {
|
||||
expect(query).toContain('WITH DISTINCT caller');
|
||||
return [CALLER, { ...CALLER }];
|
||||
}
|
||||
if (query.includes('MATCH (n)-[r:CodeRelation]->(target)')) {
|
||||
expect(query).toContain('WITH DISTINCT target');
|
||||
return [CALLEE, { ...CALLEE }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('HANDLES_ROUTE')) return [{ url: ROUTE.url, method: ROUTE.method }];
|
||||
if (query.includes('ENTRY_POINT_OF')) return [];
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const result = await backend.callTool('context', { uid: HANDLER.id, chain_depth: 1 });
|
||||
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.chain).toEqual([
|
||||
{
|
||||
depth: 1,
|
||||
upstream: [
|
||||
{ uid: CALLER.uid, name: CALLER.name, filePath: CALLER.filePath, kind: CALLER.kind },
|
||||
],
|
||||
downstream: [
|
||||
{ uid: CALLEE.uid, name: CALLEE.name, filePath: CALLEE.filePath, kind: CALLEE.kind },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('context({chain_depth:1}) lists a reciprocal peer in both layer-1 directions', async () => {
|
||||
(executeParameterized as any).mockImplementation(
|
||||
mockGraph({ handlesRoute: true, processRows: [], reciprocalPeer: PEER }),
|
||||
);
|
||||
|
||||
const result = await backend.callTool('context', { uid: HANDLER.id, chain_depth: 1 });
|
||||
|
||||
const peerNode = { uid: PEER.uid, name: PEER.name, filePath: PEER.filePath, kind: PEER.kind };
|
||||
expect(result.status).toBe('found');
|
||||
expect(result.chain).toEqual([
|
||||
{
|
||||
depth: 1,
|
||||
upstream: [peerNode],
|
||||
downstream: [peerNode],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('query process includes route/method/routes and is_entry_point when enrichment rows exist', async () => {
|
||||
(backend as any).bm25Search = vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
nodeId: HANDLER.id,
|
||||
name: HANDLER.name,
|
||||
type: HANDLER.type,
|
||||
filePath: HANDLER.filePath,
|
||||
startLine: HANDLER.startLine,
|
||||
endLine: HANDLER.endLine,
|
||||
},
|
||||
],
|
||||
ftsUsed: true,
|
||||
});
|
||||
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
|
||||
(executeParameterized as any).mockImplementation(
|
||||
mockGraph({
|
||||
handlesRoute: true,
|
||||
queryProcess: true,
|
||||
processRows: [
|
||||
{
|
||||
nodeId: HANDLER.id,
|
||||
pid: 'Process:login',
|
||||
label: 'LoginFlow',
|
||||
heuristicLabel: 'User login',
|
||||
processType: 'request',
|
||||
stepCount: 3,
|
||||
step: 0,
|
||||
entryPointId: HANDLER.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await backend.callTool('query', { search_query: 'login' });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.processes).toHaveLength(1);
|
||||
expect(result.processes[0]).toMatchObject({
|
||||
id: 'Process:login',
|
||||
route: ROUTE.url,
|
||||
method: ROUTE.method,
|
||||
routes: [{ url: ROUTE.url, method: ROUTE.method }],
|
||||
});
|
||||
const entry = result.process_symbols.find((s: { id: string }) => s.id === HANDLER.id);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.is_entry_point).toBe(true);
|
||||
});
|
||||
|
||||
it('query({chain_depth:1}) attaches per-process chain from the entry symbol', async () => {
|
||||
(backend as any).bm25Search = vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
nodeId: HANDLER.id,
|
||||
name: HANDLER.name,
|
||||
type: HANDLER.type,
|
||||
filePath: HANDLER.filePath,
|
||||
startLine: HANDLER.startLine,
|
||||
endLine: HANDLER.endLine,
|
||||
},
|
||||
],
|
||||
ftsUsed: true,
|
||||
});
|
||||
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
|
||||
(executeParameterized as any).mockImplementation(
|
||||
mockGraph({
|
||||
handlesRoute: true,
|
||||
queryProcess: true,
|
||||
chain: true,
|
||||
processRows: [
|
||||
{
|
||||
nodeId: HANDLER.id,
|
||||
pid: 'Process:login',
|
||||
label: 'LoginFlow',
|
||||
heuristicLabel: 'User login',
|
||||
processType: 'request',
|
||||
stepCount: 3,
|
||||
step: 0,
|
||||
entryPointId: HANDLER.id,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await backend.callTool('query', { search_query: 'login', chain_depth: 1 });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.processes[0].chain).toEqual([
|
||||
{
|
||||
depth: 1,
|
||||
upstream: [
|
||||
{ uid: CALLER.uid, name: CALLER.name, filePath: CALLER.filePath, kind: CALLER.kind },
|
||||
],
|
||||
downstream: [
|
||||
{ uid: CALLEE.uid, name: CALLEE.name, filePath: CALLEE.filePath, kind: CALLEE.kind },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['limit', -1],
|
||||
['limit', 0],
|
||||
['limit', 1.5],
|
||||
['limit', 101],
|
||||
['limit', Infinity],
|
||||
['max_symbols', 0],
|
||||
['max_symbols', 201],
|
||||
['chain_depth', -1],
|
||||
['chain_depth', 0.1],
|
||||
['chain_depth', 1.5],
|
||||
['chain_depth', 4],
|
||||
] as const)('query rejects out-of-range %s=%s before search', async (field, value) => {
|
||||
const bm25Search = vi.fn();
|
||||
(backend as any).bm25Search = bm25Search;
|
||||
(backend as any).semanticSearch = vi.fn();
|
||||
|
||||
const result = await backend.callTool('query', { search_query: 'login', [field]: value });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
error: expect.stringMatching(new RegExp(`Invalid "${field}"`)),
|
||||
});
|
||||
expect(bm25Search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('query Route FTS hit without a process attaches HANDLES_ROUTE handler + route_map hint', async () => {
|
||||
const routeId = 'Route:/trpc/admin.setSettings';
|
||||
const routeUrl = '/trpc/admin.setSettings';
|
||||
(backend as any).bm25Search = vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
nodeId: routeId,
|
||||
name: routeUrl,
|
||||
type: 'Route',
|
||||
filePath: 'src/server/trpc/routers/app.ts',
|
||||
},
|
||||
],
|
||||
ftsUsed: true,
|
||||
});
|
||||
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
|
||||
(executeParameterized as any).mockImplementation(async (_db: string, query: string) => {
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
if (query.includes('HANDLES_ROUTE') && query.includes('route.id')) {
|
||||
return [
|
||||
{
|
||||
routeId,
|
||||
handlerId: HANDLER.id,
|
||||
handlerName: HANDLER.name,
|
||||
handlerFilePath: HANDLER.filePath,
|
||||
url: routeUrl,
|
||||
method: 'POST',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const result = await backend.callTool('query', { search_query: '/trpc/admin.setSettings' });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.processes).toEqual([]);
|
||||
expect(result.definitions).toHaveLength(1);
|
||||
expect(result.definitions[0]).toMatchObject({
|
||||
id: routeId,
|
||||
name: routeUrl,
|
||||
type: 'Route',
|
||||
handlerSymbolId: HANDLER.id,
|
||||
handlerName: HANDLER.name,
|
||||
routes: [{ url: routeUrl, method: 'POST' }],
|
||||
follow_up: { tool: 'route_map', route: routeUrl },
|
||||
});
|
||||
});
|
||||
|
||||
it('query Route FTS hit stays a definition when HANDLES_ROUTE is empty', async () => {
|
||||
const routeId = 'Route:/trpc/orphan';
|
||||
const routeUrl = '/trpc/orphan';
|
||||
(backend as any).bm25Search = vi.fn().mockResolvedValue({
|
||||
results: [
|
||||
{
|
||||
nodeId: routeId,
|
||||
name: routeUrl,
|
||||
type: 'Route',
|
||||
},
|
||||
],
|
||||
ftsUsed: true,
|
||||
});
|
||||
(backend as any).semanticSearch = vi.fn().mockResolvedValue([]);
|
||||
(executeParameterized as any).mockImplementation(async (_db: string, query: string) => {
|
||||
if (query.includes('STEP_IN_PROCESS')) return [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const result = await backend.callTool('query', { search_query: '/trpc/orphan' });
|
||||
|
||||
expect(result).not.toHaveProperty('error');
|
||||
expect(result.definitions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: routeId,
|
||||
name: routeUrl,
|
||||
type: 'Route',
|
||||
follow_up: { tool: 'route_map', route: routeUrl },
|
||||
}),
|
||||
]);
|
||||
expect(result.definitions[0]).not.toHaveProperty('handlerSymbolId');
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,10 @@ import {
|
|||
saveMeta,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import { cleanCommand } from '../../src/cli/clean.js';
|
||||
import { removeBranchSlot } from '../../src/storage/stale-branch-slots.js';
|
||||
import { branchSlug } from '../../src/storage/branch-index.js';
|
||||
import { initGitRepo, commitAll } from '../helpers/temp-git-repo.js';
|
||||
import { _captureLogger } from '../../src/core/logger.js';
|
||||
import { createTempDir } from '../helpers/test-db.js';
|
||||
|
||||
|
|
@ -132,3 +136,91 @@ describe('adoptFlatBranchLabel — rm failure keeps the branch summary (#2364 F4
|
|||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBranchSlot — rm failure keeps the branch summary (#3331)', () => {
|
||||
let tmpHome: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let tmpRepo: Awaited<ReturnType<typeof createTempDir>>;
|
||||
let savedGitnexusHome: string | undefined;
|
||||
|
||||
const metaFor = (branch: string, lastCommit: string): RepoMeta => ({
|
||||
repoPath: '',
|
||||
lastCommit,
|
||||
indexedAt: '2026-07-03T12:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpHome = await createTempDir('gitnexus-stale-rm-failure-home-');
|
||||
tmpRepo = await createTempDir('gitnexus-stale-rm-failure-repo-');
|
||||
savedGitnexusHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = tmpHome.dbPath;
|
||||
fsCtx.rmMock.mockClear();
|
||||
fsCtx.rmMock.mockImplementation((...args) => fsCtx.realRm!(...args));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedGitnexusHome;
|
||||
await tmpHome.cleanup();
|
||||
await tmpRepo.cleanup();
|
||||
});
|
||||
|
||||
it('keeps the registry row and does not rmdir branches/ on EBUSY', async () => {
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('main', 'aaa1111'));
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
const { storagePath, metaPath } = getStoragePaths(tmpRepo.dbPath, 'feature/x');
|
||||
const dir = path.dirname(metaPath);
|
||||
await saveMeta(dir, metaFor('feature/x', 'bbb2222'));
|
||||
|
||||
fsCtx.rmMock.mockRejectedValueOnce(Object.assign(new Error('mock busy'), { code: 'EBUSY' }));
|
||||
const result = await removeBranchSlot({
|
||||
repoPath: tmpRepo.dbPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.keptRegistry).toBe(true);
|
||||
expect(result.emptiedBranchesDir).toBe(false);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
||||
await expect(fs.access(dir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('continues remaining --stale candidates after one rm failure', async () => {
|
||||
initGitRepo(tmpRepo.dbPath);
|
||||
await fs.writeFile(path.join(tmpRepo.dbPath, 'README.md'), 'hi\n');
|
||||
commitAll(tmpRepo.dbPath, 'init');
|
||||
const storagePath = path.join(tmpRepo.dbPath, '.gitnexus');
|
||||
await saveMeta(storagePath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath });
|
||||
await registerRepo(tmpRepo.dbPath, { ...metaFor('main', 'aaa1111'), repoPath: tmpRepo.dbPath });
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/x', 'bbb2222'), { branch: 'feature/x' });
|
||||
await registerRepo(tmpRepo.dbPath, metaFor('feature/y', 'ccc3333'), { branch: 'feature/y' });
|
||||
const dirX = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const dirY = path.join(storagePath, 'branches', branchSlug('feature/y'));
|
||||
await saveMeta(dirX, metaFor('feature/x', 'bbb2222'));
|
||||
await saveMeta(dirY, metaFor('feature/y', 'ccc3333'));
|
||||
|
||||
fsCtx.rmMock.mockImplementation(async (target, options) => {
|
||||
if (String(target) === dirX) {
|
||||
throw Object.assign(new Error('mock busy'), { code: 'EBUSY' });
|
||||
}
|
||||
return fsCtx.realRm!(target, options);
|
||||
});
|
||||
|
||||
const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpRepo.dbPath);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await cleanCommand({ stale: true, force: true });
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((b) => b.branch)).toEqual(['feature/x']);
|
||||
await expect(fs.access(dirX)).resolves.toBeUndefined();
|
||||
await expect(fs.access(dirY)).rejects.toThrow();
|
||||
} finally {
|
||||
cwdSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -963,6 +963,43 @@ describe('registerRepo name override + collision guard (#829)', () => {
|
|||
expect(entries[0].name).toBe('new-alias');
|
||||
});
|
||||
|
||||
it('keeps an alias rename committed when an async observer rejects', async () => {
|
||||
await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' });
|
||||
|
||||
const onRename = vi.fn(async () => {
|
||||
throw new Error('observer failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
registerRepo(tmpRepoA.dbPath, meta, {
|
||||
name: 'new-alias',
|
||||
onRename,
|
||||
}),
|
||||
).resolves.toBe('new-alias');
|
||||
|
||||
expect(onRename).toHaveBeenCalledTimes(1);
|
||||
expect(onRename).toHaveBeenCalledWith('old-alias', 'new-alias');
|
||||
expect(await listRegisteredRepos()).toMatchObject([{ name: 'new-alias' }]);
|
||||
});
|
||||
|
||||
it('releases the registry lock before invoking onRename', async () => {
|
||||
await registerRepo(tmpRepoA.dbPath, meta, { name: 'old-alias' });
|
||||
|
||||
await registerRepo(tmpRepoA.dbPath, meta, {
|
||||
name: 'new-alias',
|
||||
onRename: async () => {
|
||||
await registerRepo(tmpRepoB.dbPath, meta, { name: 'observer-reentry' });
|
||||
},
|
||||
});
|
||||
|
||||
expect(await listRegisteredRepos()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'new-alias' }),
|
||||
expect.objectContaining({ name: 'observer-reentry' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('registerRepo throws RegistryNameCollisionError when another path uses the name', async () => {
|
||||
await registerRepo(tmpRepoA.dbPath, meta, { name: 'shared' });
|
||||
|
||||
|
|
|
|||
119
gitnexus/test/unit/route-constant-baseline-gate.test.ts
Normal file
119
gitnexus/test/unit/route-constant-baseline-gate.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Bench --check GATE scorer for shared route-constant baselines
|
||||
// (missing/non-numeric budgets must fail --check).
|
||||
//
|
||||
// Asserts the PURE helper (`collectBaselineErrors`) that `runBaselineCheck`
|
||||
// / tRPC `measure.mjs` use — on SYNTHETIC report + baseline shapes ONLY, no
|
||||
// 800-router extract, no process.exit. The helper lives in the build-free
|
||||
// `.mjs` harness, imported directly here, so this test is deterministic and
|
||||
// stays OUT of the full bench lane (mirroring
|
||||
// impact-pdg-id-bridge-gate.test.ts). Live timing arms run only via
|
||||
// `node --import tsx bench/*/measure.mjs --check`, never in `npm test`.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
// @ts-expect-error — .mjs pure-JS harness module, no types (intentional; build-free).
|
||||
import * as G from '../../bench/lib/route-constant-guard.mjs';
|
||||
|
||||
const collectBaselineErrors = G.collectBaselineErrors as (
|
||||
report: Record<string, unknown>,
|
||||
baseline: Record<string, unknown>,
|
||||
) => string[];
|
||||
|
||||
const FINGERPRINT = 'gate-fingerprint';
|
||||
|
||||
const reportAllThree = {
|
||||
fingerprint: FINGERPRINT,
|
||||
scaling_ratio: 1.2,
|
||||
absolute_ms: 40,
|
||||
widening_overhead: 2.0,
|
||||
};
|
||||
|
||||
const baselineAllThree = {
|
||||
fingerprint: FINGERPRINT,
|
||||
scaling_budget: 1.8,
|
||||
absolute_ms_budget: 160,
|
||||
widening_overhead_budget: 3.5,
|
||||
};
|
||||
|
||||
describe('route-constant baseline gate — collectBaselineErrors()', () => {
|
||||
it('fails closed when scaling_budget is missing — DELETED GATE', () => {
|
||||
const errors = collectBaselineErrors(reportAllThree, { fingerprint: FINGERPRINT });
|
||||
expect(errors.join(' ')).toContain('DELETED GATE');
|
||||
expect(errors.join(' ')).toContain('no numeric scaling_budget');
|
||||
});
|
||||
|
||||
it('fails closed when scaling_budget is NaN or a string', () => {
|
||||
const nanErrors = collectBaselineErrors(reportAllThree, {
|
||||
fingerprint: FINGERPRINT,
|
||||
scaling_budget: Number.NaN,
|
||||
});
|
||||
const stringErrors = collectBaselineErrors(reportAllThree, {
|
||||
fingerprint: FINGERPRINT,
|
||||
scaling_budget: '1.8',
|
||||
});
|
||||
expect(nanErrors.join(' ')).toContain('no numeric scaling_budget');
|
||||
expect(stringErrors.join(' ')).toContain('no numeric scaling_budget');
|
||||
});
|
||||
|
||||
it('fails closed when report.absolute_ms is present but absolute_ms_budget is missing', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{ fingerprint: FINGERPRINT, scaling_ratio: 1.0, absolute_ms: 10 },
|
||||
{ fingerprint: FINGERPRINT, scaling_budget: 1.6 },
|
||||
);
|
||||
expect(errors.join(' ')).toMatch(/no numeric absolute_ms_budget|DELETED GATE/);
|
||||
});
|
||||
|
||||
it('fails closed when report.widening_overhead is present but widening_overhead_budget is missing', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{ fingerprint: FINGERPRINT, scaling_ratio: 1.0, widening_overhead: 1.2 },
|
||||
{ fingerprint: FINGERPRINT, scaling_budget: 1.6 },
|
||||
);
|
||||
expect(errors.join(' ')).toMatch(/no numeric widening_overhead_budget|DELETED GATE/);
|
||||
});
|
||||
|
||||
it('does not require absolute/widening budgets when those report fields are absent (java-wildcard shape)', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{ fingerprint: FINGERPRINT, scaling_ratio: 1.1 },
|
||||
{ fingerprint: FINGERPRINT, scaling_budget: 1.6 },
|
||||
);
|
||||
expect(
|
||||
errors.filter((e) => e.includes('absolute_ms') || e.includes('widening_overhead')),
|
||||
).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports comparison errors when present budgets are overrun', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{
|
||||
fingerprint: FINGERPRINT,
|
||||
scaling_ratio: 2.0,
|
||||
absolute_ms: 200,
|
||||
widening_overhead: 5,
|
||||
},
|
||||
baselineAllThree,
|
||||
);
|
||||
expect(errors.join(' ')).toContain('scaling_ratio 2 > 1.8');
|
||||
expect(errors.join(' ')).toContain('absolute_ms 200 > 160');
|
||||
expect(errors.join(' ')).toContain('widening_overhead 5 > 3.5');
|
||||
});
|
||||
|
||||
it('returns no errors on the happy path with all three budgets', () => {
|
||||
expect(collectBaselineErrors(reportAllThree, baselineAllThree)).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails closed when report.chain_scaling_ratio is present but chain_scaling_budget is missing', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{ fingerprint: FINGERPRINT, scaling_ratio: 1.0, chain_scaling_ratio: 1.5 },
|
||||
{ fingerprint: FINGERPRINT, scaling_budget: 1.6 },
|
||||
);
|
||||
expect(errors.join(' ')).toContain('DELETED GATE');
|
||||
expect(errors.join(' ')).toContain('no numeric chain_scaling_budget');
|
||||
});
|
||||
|
||||
it('reports overrun against a numeric chain_scaling_budget', () => {
|
||||
const errors = collectBaselineErrors(
|
||||
{ fingerprint: FINGERPRINT, scaling_ratio: 1.0, chain_scaling_ratio: 3 },
|
||||
{ fingerprint: FINGERPRINT, scaling_budget: 1.6, chain_scaling_budget: 2.8 },
|
||||
);
|
||||
expect(errors.join(' ')).toContain('chain_scaling_ratio 3 > 2.8');
|
||||
});
|
||||
});
|
||||
|
|
@ -188,10 +188,11 @@ describe('run-analyze module', () => {
|
|||
await registerRepo(tmpRepo.dbPath, meta, { name: 'old' });
|
||||
|
||||
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
||||
const logs: string[] = [];
|
||||
const result = await runFullAnalysis(
|
||||
tmpRepo.dbPath,
|
||||
{ registryName: 'new' },
|
||||
{ onProgress: () => {} },
|
||||
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
||||
);
|
||||
|
||||
expect(result.alreadyUpToDate).toBe(true);
|
||||
|
|
@ -199,6 +200,7 @@ describe('run-analyze module', () => {
|
|||
const entries = await readRegistry();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].name).toBe('new');
|
||||
expect(logs).toContain('Registry name changed: "old" -> "new".');
|
||||
const agents = await fs.readFile(path.join(tmpRepo.dbPath, 'AGENTS.md'), 'utf-8');
|
||||
expect(agents).toContain('**new**');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -157,6 +157,17 @@ describe('emitJsScopeCaptures — #1876 array-method-callback narrowing', () =>
|
|||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('names create: publicProcedure.mutation(withAuth(async () => {}))', () => {
|
||||
const src = 'const r = { create: publicProcedure.mutation(withAuth(async () => {})) };';
|
||||
expect(hasDecl(src, '@declaration.function', 'create')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not name variable-level nested HOC memo(forwardRef(...)) as Function', () => {
|
||||
const src = 'const Wrapped = memo(forwardRef(() => null));';
|
||||
expect(hasDecl(src, '@declaration.function', 'Wrapped')).toBe(false);
|
||||
expect(hasDecl(src, '@declaration.const', 'Wrapped')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -709,6 +709,34 @@ describe('emitTsScopeCaptures — #1876 array-method-callback narrowing', () =>
|
|||
});
|
||||
});
|
||||
|
||||
describe('emitTsScopeCaptures — curried pair HOC wrappers (tRPC withAuth)', () => {
|
||||
const declWithName = (src: string, tag: string, name: string): boolean =>
|
||||
emitTsScopeCaptures(src, 'test.ts').some(
|
||||
(m) => m[tag] !== undefined && m['@declaration.name']?.text === name,
|
||||
);
|
||||
|
||||
it('names create: publicProcedure.mutation(withAuth(async () => {}))', () => {
|
||||
const src = 'const r = { create: publicProcedure.mutation(withAuth(async () => {})) };';
|
||||
expect(declWithName(src, '@declaration.function', 'create')).toBe(true);
|
||||
});
|
||||
|
||||
it('names identifier-callee create: mutation(withAuth(function () {}))', () => {
|
||||
const src = 'const r = { create: mutation(withAuth(function () { return null; })) };';
|
||||
expect(declWithName(src, '@declaration.function', 'create')).toBe(true);
|
||||
});
|
||||
|
||||
it('names quoted-key create: publicProcedure.query(withAuth(async () => {}))', () => {
|
||||
const src = "const r = { 'create': publicProcedure.query(withAuth(async () => {})) };";
|
||||
expect(declWithName(src, '@declaration.function', 'create')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not name variable-level nested HOC memo(forwardRef(...)) as Function', () => {
|
||||
const src = 'const Wrapped = memo(forwardRef(() => null));';
|
||||
expect(declWithName(src, '@declaration.function', 'Wrapped')).toBe(false);
|
||||
expect(declWithName(src, '@declaration.variable', 'Wrapped')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitTsScopeCaptures — value-position references (#2437)', () => {
|
||||
it('captures a longhand pair value identifier as @reference.value-ref with its key', () => {
|
||||
const match = findMatch(
|
||||
|
|
|
|||
590
gitnexus/test/unit/stale-branch-slots.test.ts
Normal file
590
gitnexus/test/unit/stale-branch-slots.test.ts
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { branchSlug } from '../../src/storage/branch-index.js';
|
||||
import { INDEX_METADATA_FILE } from '../../src/storage/storage-constants.js';
|
||||
import {
|
||||
listRegisteredRepos,
|
||||
registerRepo,
|
||||
type RepoMeta,
|
||||
} from '../../src/storage/repo-manager.js';
|
||||
import * as repoManager from '../../src/storage/repo-manager.js';
|
||||
import {
|
||||
isDeleteCandidate,
|
||||
listStaleBranchSlots,
|
||||
removeBranchSlot,
|
||||
type StaleBranchSlot,
|
||||
} from '../../src/storage/stale-branch-slots.js';
|
||||
import { createTempDir, type TestDBHandle } from '../helpers/test-db.js';
|
||||
|
||||
async function writeSlotMeta(dir: string, branch: string): Promise<void> {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, INDEX_METADATA_FILE),
|
||||
JSON.stringify({ branch, lastCommit: 'abc', indexedAt: '2026-09-20T00:00:00.000Z' }),
|
||||
);
|
||||
}
|
||||
|
||||
describe('listStaleBranchSlots (#3331)', () => {
|
||||
let fixture: TestDBHandle;
|
||||
let repoPath: string;
|
||||
let storagePath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixture = await createTempDir();
|
||||
repoPath = path.join(fixture.dbPath, 'repo');
|
||||
storagePath = path.join(repoPath, '.gitnexus');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
it('classifies a recorded branch with no local head as ref-missing', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
await fs.writeFile(path.join(dir, 'blob.bin'), 'x');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'ref-missing',
|
||||
}),
|
||||
]);
|
||||
expect(rows[0]?.sizeBytes).toBeGreaterThan(0);
|
||||
expect(isDeleteCandidate(rows[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not classify a recorded branch that is still a local head', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main', 'feature/x'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('classifies a leftover directory with no registry row as disk-only', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'disk-only',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not classify a registry row when the slug path is unreadable', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
const realStat = fs.stat.bind(fs);
|
||||
const statSpy = vi.spyOn(fs, 'stat').mockImplementation(async (target, options) => {
|
||||
if (path.resolve(String(target)) === path.resolve(dir)) {
|
||||
const err = new Error('EACCES') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return realStat(target, options);
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'probe-failed',
|
||||
}),
|
||||
]);
|
||||
expect(isDeleteCandidate(rows[0]!)).toBe(false);
|
||||
} finally {
|
||||
statSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies a regular file at the canonical slug path as probe-failed', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await fs.mkdir(path.dirname(dir), { recursive: true });
|
||||
await fs.writeFile(dir, 'not a directory');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'probe-failed',
|
||||
}),
|
||||
]);
|
||||
expect(isDeleteCandidate(rows[0]!)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns listing-failed when branches/ readdir fails with a non-missing error', async () => {
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
const realReaddir = fs.readdir.bind(fs);
|
||||
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: unknown,
|
||||
options?: unknown,
|
||||
) => {
|
||||
if (path.resolve(String(target)) === path.resolve(branchesRoot)) {
|
||||
const err = new Error('EACCES') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return realReaddir(
|
||||
target as Parameters<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[1],
|
||||
);
|
||||
}) as typeof fs.readdir);
|
||||
|
||||
try {
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [],
|
||||
heads: ['main'],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
branch: '',
|
||||
dir: null,
|
||||
sizeBytes: 0,
|
||||
reason: 'listing-failed',
|
||||
},
|
||||
]);
|
||||
expect(isDeleteCandidate(rows[0]!)).toBe(false);
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not classify registry rows when branches/ listing fails', async () => {
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
const realReaddir = fs.readdir.bind(fs);
|
||||
const readdirSpy = vi.spyOn(fs, 'readdir').mockImplementation((async (
|
||||
target: unknown,
|
||||
options?: unknown,
|
||||
) => {
|
||||
if (path.resolve(String(target)) === path.resolve(branchesRoot)) {
|
||||
const err = new Error('EACCES') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return realReaddir(
|
||||
target as Parameters<typeof realReaddir>[0],
|
||||
options as Parameters<typeof realReaddir>[1],
|
||||
);
|
||||
}) as typeof fs.readdir);
|
||||
|
||||
try {
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
branch: '',
|
||||
dir: null,
|
||||
sizeBytes: 0,
|
||||
reason: 'listing-failed',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
readdirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies a registry row whose directory is gone as registry-only', async () => {
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir: null,
|
||||
sizeBytes: 0,
|
||||
reason: 'registry-only',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not classify a leftover directory with unreadable metadata and no registry row', async () => {
|
||||
const dir = path.join(storagePath, 'branches', 'mystery-deadbeef');
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, INDEX_METADATA_FILE), '{not-json');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('classifies from the recorded name when the slug path exists even if metadata is unreadable', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, INDEX_METADATA_FILE), '{not-json');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'ref-missing',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('tags every known slot heads-unavailable when heads cannot be listed', async () => {
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [{ branch: 'feature/x' }],
|
||||
heads: null,
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
reason: 'heads-unavailable',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat the workspace/flat slot as a branch directory', async () => {
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(storagePath, INDEX_METADATA_FILE),
|
||||
JSON.stringify({ branch: 'main' }),
|
||||
);
|
||||
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
|
||||
|
||||
const rows = await listStaleBranchSlots({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branches: [],
|
||||
heads: ['main'],
|
||||
});
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDeleteCandidate', () => {
|
||||
const slot = (reason: StaleBranchSlot['reason']): StaleBranchSlot => ({
|
||||
branch: 'feature/x',
|
||||
dir: '/tmp/x',
|
||||
sizeBytes: 0,
|
||||
reason,
|
||||
});
|
||||
|
||||
it('is true only for reclaimable leftover reasons', () => {
|
||||
expect(isDeleteCandidate(slot('ref-missing'))).toBe(true);
|
||||
expect(isDeleteCandidate(slot('registry-only'))).toBe(true);
|
||||
expect(isDeleteCandidate(slot('disk-only'))).toBe(true);
|
||||
expect(isDeleteCandidate(slot('heads-unavailable'))).toBe(false);
|
||||
expect(isDeleteCandidate(slot('probe-failed'))).toBe(false);
|
||||
expect(isDeleteCandidate(slot('listing-failed'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBranchSlot (#3331)', () => {
|
||||
let home: TestDBHandle;
|
||||
let fixture: TestDBHandle;
|
||||
let repoPath: string;
|
||||
let storagePath: string;
|
||||
let savedHome: string | undefined;
|
||||
|
||||
const metaFor = (branch: string): RepoMeta => ({
|
||||
repoPath: '',
|
||||
lastCommit: 'abc',
|
||||
indexedAt: '2026-09-20T00:00:00.000Z',
|
||||
branch,
|
||||
stats: { files: 1, nodes: 1 },
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await createTempDir();
|
||||
fixture = await createTempDir();
|
||||
repoPath = path.join(fixture.dbPath, 'repo');
|
||||
storagePath = path.join(repoPath, '.gitnexus');
|
||||
await fs.mkdir(repoPath, { recursive: true });
|
||||
savedHome = process.env.GITNEXUS_HOME;
|
||||
process.env.GITNEXUS_HOME = home.dbPath;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
|
||||
else process.env.GITNEXUS_HOME = savedHome;
|
||||
await fixture.cleanup();
|
||||
await home.cleanup();
|
||||
});
|
||||
|
||||
it('removes the last slot, drops the registry row, and rmdirs empty branches/', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false });
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow();
|
||||
await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe(
|
||||
'{}',
|
||||
);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the other slot and branches/ when two slots exist', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
await registerRepo(repoPath, metaFor('feature/y'), { branch: 'feature/y' });
|
||||
const dirX = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
const dirY = path.join(storagePath, 'branches', branchSlug('feature/y'));
|
||||
await writeSlotMeta(dirX, 'feature/x');
|
||||
await writeSlotMeta(dirY, 'feature/y');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: dirX,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.emptiedBranchesDir).toBe(false);
|
||||
await expect(fs.access(dirY)).resolves.toBeUndefined();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/y']);
|
||||
});
|
||||
|
||||
it('refuses a target outside branches/', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
await fs.writeFile(path.join(storagePath, 'parse-cache.json'), '{}');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: storagePath,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.keptRegistry).toBe(true);
|
||||
expect(result.emptiedBranchesDir).toBe(false);
|
||||
await expect(fs.readFile(path.join(storagePath, 'parse-cache.json'), 'utf8')).resolves.toBe(
|
||||
'{}',
|
||||
);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']);
|
||||
});
|
||||
|
||||
it('drops a registry-only row without requiring a directory', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: null,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses when branches/ is a symlink or junction pointing outside', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const outside = path.join(fixture.dbPath, 'outside-tree');
|
||||
const slug = branchSlug('feature/x');
|
||||
const outsideSlot = path.join(outside, slug);
|
||||
await writeSlotMeta(outsideSlot, 'feature/x');
|
||||
await fs.writeFile(path.join(outsideSlot, 'payload.bin'), 'secret');
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.symlink(outside, branchesRoot, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const dir = path.join(branchesRoot, slug);
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.keptRegistry).toBe(true);
|
||||
expect(result.emptiedBranchesDir).toBe(false);
|
||||
await expect(fs.access(outsideSlot)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(outsideSlot, 'payload.bin'), 'utf8')).resolves.toBe(
|
||||
'secret',
|
||||
);
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']);
|
||||
});
|
||||
|
||||
it('unlinks a slot symlink or junction and leaves the outside target', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const outside = path.join(fixture.dbPath, 'outside-slot');
|
||||
await writeSlotMeta(outside, 'feature/x');
|
||||
await fs.writeFile(path.join(outside, 'payload.bin'), 'secret');
|
||||
const branchesRoot = path.join(storagePath, 'branches');
|
||||
await fs.mkdir(branchesRoot, { recursive: true });
|
||||
const dir = path.join(branchesRoot, branchSlug('feature/x'));
|
||||
await fs.symlink(outside, dir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false });
|
||||
await expect(fs.lstat(dir)).rejects.toThrow();
|
||||
await expect(fs.access(outside)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(outside, 'payload.bin'), 'utf8')).resolves.toBe('secret');
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes a normal leftover slot directory', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
await fs.writeFile(path.join(dir, 'payload.bin'), 'stale');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: true, keptRegistry: false });
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not drop a registry row when removing a non-canonical disk-only dir', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const canonical = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(canonical, 'feature/x');
|
||||
const stray = path.join(storagePath, 'branches', 'mystery-deadbeef');
|
||||
await writeSlotMeta(stray, 'feature/x');
|
||||
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir: stray,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, emptiedBranchesDir: false, keptRegistry: true });
|
||||
await expect(fs.access(stray)).rejects.toThrow();
|
||||
await expect(fs.access(canonical)).resolves.toBeUndefined();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']);
|
||||
});
|
||||
|
||||
it('keeps the registry row when removeBranchIndex rejects after a successful rm', async () => {
|
||||
await registerRepo(repoPath, metaFor('main'));
|
||||
await registerRepo(repoPath, metaFor('feature/x'), { branch: 'feature/x' });
|
||||
const dir = path.join(storagePath, 'branches', branchSlug('feature/x'));
|
||||
await writeSlotMeta(dir, 'feature/x');
|
||||
const spy = vi
|
||||
.spyOn(repoManager, 'removeBranchIndex')
|
||||
.mockRejectedValueOnce(new Error('lock timeout'));
|
||||
|
||||
try {
|
||||
const result = await removeBranchSlot({
|
||||
repoPath,
|
||||
storagePath,
|
||||
branch: 'feature/x',
|
||||
dir,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.keptRegistry).toBe(true);
|
||||
expect(result.emptiedBranchesDir).toBe(false);
|
||||
expect(result.error?.message).toBe('lock timeout');
|
||||
await expect(fs.access(dir)).rejects.toThrow();
|
||||
const [entry] = await listRegisteredRepos();
|
||||
expect(entry.branches?.map((row) => row.branch)).toEqual(['feature/x']);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
74
gitnexus/test/unit/trpc-entry-point-scoring.test.ts
Normal file
74
gitnexus/test/unit/trpc-entry-point-scoring.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { calculateEntryPointScore } from '../../src/core/ingestion/entry-point-scoring.js';
|
||||
|
||||
describe('tRPC entry-point scoring', () => {
|
||||
it('scores a T3 router procedure higher than the same name on a utility file', () => {
|
||||
const t3 = calculateEntryPointScore(
|
||||
'setSettings',
|
||||
'typescript',
|
||||
true,
|
||||
0,
|
||||
3,
|
||||
'src/server/api/routers/settings.ts',
|
||||
);
|
||||
const util = calculateEntryPointScore(
|
||||
'setSettings',
|
||||
'typescript',
|
||||
true,
|
||||
0,
|
||||
3,
|
||||
'src/lib/utils.ts',
|
||||
);
|
||||
expect(t3.score).toBeGreaterThan(util.score);
|
||||
expect(t3.reasons).toContain('framework:trpc-router');
|
||||
expect(t3.reasons).not.toContain('utility-pattern');
|
||||
expect(util.reasons).toContain('utility-pattern');
|
||||
});
|
||||
|
||||
it('still applies utility-pattern to non-accessor helpers in a T3 router file', () => {
|
||||
const routerPath = 'src/server/api/routers/settings.ts';
|
||||
for (const name of ['formatDate', '_internal', 'parseInput'] as const) {
|
||||
const result = calculateEntryPointScore(name, 'typescript', true, 0, 3, routerPath);
|
||||
expect(result.reasons).toContain('utility-pattern');
|
||||
expect(result.reasons).toContain('framework:trpc-router');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips utility-pattern for accessor procedures on a T3 JavaScript router', () => {
|
||||
const result = calculateEntryPointScore(
|
||||
'setSettings',
|
||||
'javascript',
|
||||
true,
|
||||
0,
|
||||
3,
|
||||
'src/server/api/routers/settings.js',
|
||||
);
|
||||
expect(result.reasons).toContain('framework:trpc-router');
|
||||
expect(result.reasons).not.toContain('utility-pattern');
|
||||
});
|
||||
|
||||
it('still applies utility-pattern to non-accessor helpers on a T3 JavaScript router', () => {
|
||||
const result = calculateEntryPointScore(
|
||||
'formatDate',
|
||||
'javascript',
|
||||
true,
|
||||
0,
|
||||
3,
|
||||
'src/server/api/routers/settings.js',
|
||||
);
|
||||
expect(result.reasons).toContain('utility-pattern');
|
||||
expect(result.reasons).toContain('framework:trpc-router');
|
||||
});
|
||||
|
||||
it('does not crash on a .js router path whose framework detection returns null', () => {
|
||||
const jsResult = calculateEntryPointScore(
|
||||
'settingsRouter',
|
||||
'javascript',
|
||||
true,
|
||||
0,
|
||||
3,
|
||||
'src/server/routers/settings.js',
|
||||
);
|
||||
expect(Number.isFinite(jsResult.score)).toBe(true);
|
||||
});
|
||||
});
|
||||
800
gitnexus/test/unit/trpc-route-extractor.test.ts
Normal file
800
gitnexus/test/unit/trpc-route-extractor.test.ts
Normal file
|
|
@ -0,0 +1,800 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractTrpcRoutes,
|
||||
shouldScanForTrpcRoutes,
|
||||
} from '../../src/core/ingestion/route-extractors/trpc.js';
|
||||
|
||||
const FILE = 'src/server/trpc/routers/user.ts';
|
||||
|
||||
const paths = (source: string) =>
|
||||
extractTrpcRoutes(FILE, source).map((r) => r.httpMethod + ' ' + r.routePath);
|
||||
|
||||
describe('extractTrpcRoutes', () => {
|
||||
it('same-named procedures in sibling nested routers keep distinct full paths', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' users: t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
' }),',
|
||||
' billing: t.router({',
|
||||
' users: t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.users.list', 'GET /trpc/billing.users.list']);
|
||||
});
|
||||
|
||||
it('t.merge of a named same-file router keeps the merge prefix', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const postRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
"export const appRouter = t.merge('post.', postRouter);",
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/post.list']);
|
||||
});
|
||||
|
||||
it('a merge prefix keeps exactly one dot boundary (post. -> post.list)', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
"export const appRouter = t.merge('post.', t.router({",
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'}));',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/post.list']);
|
||||
});
|
||||
|
||||
it('a preceding t.merge does not prefix a later t.router appRouter', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
"t.merge('post.', postRouter);",
|
||||
'export const appRouter = t.router({',
|
||||
' health: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('bare router() import style still nests sibling routers', () => {
|
||||
const source = [
|
||||
"import { router, publicProcedure } from '../trpc';",
|
||||
'',
|
||||
'export const appRouter = router({',
|
||||
' admin: router({',
|
||||
' users: router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
' }),',
|
||||
' billing: router({',
|
||||
' users: router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.users.list', 'GET /trpc/billing.users.list']);
|
||||
});
|
||||
|
||||
it('an all-dot merge prefix is treated as no prefix', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
"export const appRouter = t.merge('..', t.router({",
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'}));',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/list']);
|
||||
});
|
||||
|
||||
it('a file whose only procedure-ish name is unrelated emits nothing', () => {
|
||||
// Pre-fix, /\b\w*Procedure\w*\b matched myProcedure/ProcedureBuilder and
|
||||
// this file emitted a phantom route; the allowlist rejects it.
|
||||
const source = [
|
||||
"import { ProcedureBuilder } from './internals';",
|
||||
'const myProcedure = (fn: () => unknown) => fn;',
|
||||
'',
|
||||
'export const routes = {',
|
||||
' list: myProcedure.query(() => null),',
|
||||
'};',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a comment or string tRPC marker plus fooProcedure emits nothing', () => {
|
||||
const source = [
|
||||
'// leftover: import { initTRPC } from "@trpc/server"',
|
||||
'const handlers = { ping: fooProcedure.query(() => null) }',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, source)).toEqual([]);
|
||||
});
|
||||
|
||||
it('chained key/terminal across lines still emit, and the key resets after its object closes', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const protectedProcedure = t.procedure;',
|
||||
'',
|
||||
'export const userRouter = t.router({',
|
||||
' list: protectedProcedure',
|
||||
' .input((v) => v)',
|
||||
' .query(() => null),',
|
||||
' create: protectedProcedure.mutation(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const emitted = extractTrpcRoutes(FILE, source);
|
||||
expect(emitted.map((r) => r.httpMethod + ' ' + r.routePath)).toEqual([
|
||||
'GET /trpc/user.list',
|
||||
'POST /trpc/user.create',
|
||||
]);
|
||||
// list: key on line 6, `.query(` terminal on line 8. create: key+terminal
|
||||
// share line 9, so the number is unchanged.
|
||||
expect(emitted.map((r) => r.lineNumber)).toEqual([8, 9]);
|
||||
});
|
||||
|
||||
it('compact one-line routers still emit a procedure key', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({ health: publicProcedure.query(() => null) });',
|
||||
].join('\n');
|
||||
const compact = extractTrpcRoutes(FILE, source);
|
||||
expect(compact.map((r) => r.httpMethod + ' ' + r.routePath)).toEqual(['GET /trpc/health']);
|
||||
// key and `.query(` share the export line — same number as before.
|
||||
expect(compact.map((r) => r.lineNumber)).toEqual([4]);
|
||||
});
|
||||
|
||||
it('does not treat .query( inside a comment as the procedure terminal', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' health: publicProcedure',
|
||||
' // leftover note: .query(',
|
||||
' .query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const emitted = extractTrpcRoutes(FILE, source);
|
||||
expect(emitted.map((r) => r.httpMethod + ' ' + r.routePath)).toEqual(['GET /trpc/health']);
|
||||
expect(emitted[0]?.lineNumber).toBe(8);
|
||||
});
|
||||
|
||||
it('gates and extracts terminals with whitespace before the opening paren', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' health: publicProcedure.query (() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('braces inside strings and comments do not skew the nesting stack', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' // a lone closing brace in a comment: }',
|
||||
' error: publicProcedure.mutation(() => {',
|
||||
" throw new Error('brace } inside string');",
|
||||
' }),',
|
||||
' }),',
|
||||
' health: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
// health must be /trpc/health (top level), NOT /trpc/admin.health — a skewed
|
||||
// depth counter from the string/comment braces would mis-nest it.
|
||||
expect(paths(source)).toEqual(['POST /trpc/admin.error', 'GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('prettier multiline z.object input still emits the list route', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const protectedProcedure = t.procedure;',
|
||||
'',
|
||||
'export const userRouter = t.router({',
|
||||
' list: protectedProcedure',
|
||||
' .input(',
|
||||
' z.object({',
|
||||
' id: z.string(),',
|
||||
' }),',
|
||||
' )',
|
||||
' .query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const emitted = extractTrpcRoutes(FILE, source);
|
||||
expect(emitted.map((r) => r.httpMethod + ' ' + r.routePath)).toEqual(['GET /trpc/user.list']);
|
||||
// key on line 6; `.query(` after the prettier-broken `.input(z.object)` is 12.
|
||||
expect(emitted[0]?.lineNumber).toBe(12);
|
||||
});
|
||||
|
||||
it('compact nested admin.list one-liner emits the nested path', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({ admin: t.router({ list: publicProcedure.query(() => null) }) });',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.list']);
|
||||
});
|
||||
|
||||
it('quoted kebab-case router and procedure keys emit the dotted path', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
" 'admin-panel': t.router({",
|
||||
" 'list-users': publicProcedure.query(() => null),",
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin-panel.list-users']);
|
||||
});
|
||||
|
||||
it("quoted 'create' key emits a POST create route", () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
" 'create': publicProcedure.mutation(() => null),",
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['POST /trpc/create']);
|
||||
});
|
||||
|
||||
it('does not treat a commented-out create key as the current procedure', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' list: publicProcedure',
|
||||
' // leftover: create: publicProcedure.query(',
|
||||
' .query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/list']);
|
||||
});
|
||||
|
||||
it('db.query inside .input is not a GET terminal; create stays POST', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.input(z.custom(async v => db.query(v))).mutation(handler),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['POST /trpc/create']);
|
||||
});
|
||||
|
||||
it('duplicate object-literal keys keep the later procedure', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' list: publicProcedure.query(firstHandler),',
|
||||
' list: publicProcedure.query(secondHandler),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(FILE, source);
|
||||
expect(extracted).toHaveLength(1);
|
||||
expect(extracted[0]).toMatchObject({
|
||||
routePath: '/trpc/list',
|
||||
methodName: 'secondHandler',
|
||||
lineNumber: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercase procedure builder still emits the create route', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const procedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: procedure.mutation(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['POST /trpc/create']);
|
||||
expect(extractTrpcRoutes(FILE, source)[0]?.methodName).toBe('create');
|
||||
});
|
||||
|
||||
it('identifier callback is the handler name; inline arrows keep the procedure key', () => {
|
||||
const ident = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.mutation(handler),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, ident)).toEqual([
|
||||
expect.objectContaining({
|
||||
routePath: '/trpc/create',
|
||||
methodName: 'handler',
|
||||
httpMethod: 'POST',
|
||||
}),
|
||||
]);
|
||||
|
||||
const multiline = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.mutation(',
|
||||
' handler',
|
||||
' ),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, multiline)[0]?.methodName).toBe('handler');
|
||||
|
||||
const arrow = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.mutation(async () => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, arrow)[0]?.methodName).toBe('create');
|
||||
|
||||
const member = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.mutation(handlers.create),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(extractTrpcRoutes(FILE, member)[0]?.methodName).toBe('create');
|
||||
});
|
||||
|
||||
it('nested db.transaction().query inside .input is not a GET terminal; create stays POST', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' create: publicProcedure.input(v => db.transaction().query(v)).mutation(handler),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['POST /trpc/create']);
|
||||
});
|
||||
|
||||
it('defaults.merge is not a tRPC router prefix', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
"const options = defaults.merge('internal', overrides);",
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const emitted = paths(source);
|
||||
expect(emitted).not.toContain('GET /trpc/internal.list');
|
||||
// appRouter is the root binding (no prefix); must not pick up 'internal'.
|
||||
expect(emitted).toEqual(['GET /trpc/list']);
|
||||
});
|
||||
|
||||
it('regex literal braces do not pop a nested admin router', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' foo: publicProcedure.query(() => /}/.test(value)),',
|
||||
' bar: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.foo', 'GET /trpc/admin.bar']);
|
||||
});
|
||||
|
||||
it('regex after if (ok) does not pop a nested admin router', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' foo: publicProcedure.query(() => { if (ok) /}/.test(value) }),',
|
||||
' bar: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.foo', 'GET /trpc/admin.bar']);
|
||||
});
|
||||
|
||||
it('grouping and call parens still treat / as division', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' foo: publicProcedure.query(() => (a + b) / c),',
|
||||
' bar: publicProcedure.query(() => foo(ok) / x),',
|
||||
' baz: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual([
|
||||
'GET /trpc/admin.foo',
|
||||
'GET /trpc/admin.bar',
|
||||
'GET /trpc/admin.baz',
|
||||
]);
|
||||
});
|
||||
|
||||
it('regex after return does not pop a nested admin router', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' foo: publicProcedure.query(() => { return /}/.test(value) }),',
|
||||
' bar: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.foo', 'GET /trpc/admin.bar']);
|
||||
});
|
||||
|
||||
it('regex after return persists across a newline', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' foo: publicProcedure.query(() => {',
|
||||
' return',
|
||||
' /}/.test(value)',
|
||||
' }),',
|
||||
' bar: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.foo', 'GET /trpc/admin.bar']);
|
||||
});
|
||||
|
||||
it('bare router() userRouter binding still prefixes user, not the filename', () => {
|
||||
const source = [
|
||||
"import { router, publicProcedure } from '../trpc';",
|
||||
'',
|
||||
'export const userRouter = router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(
|
||||
extractTrpcRoutes('src/server/trpc/routers/account.ts', source).map(
|
||||
(r) => r.httpMethod + ' ' + r.routePath,
|
||||
),
|
||||
).toEqual(['GET /trpc/user.list']);
|
||||
});
|
||||
|
||||
it('createTRPCRouter appRouter binding in root.ts does not prefix with app', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = createTRPCRouter({',
|
||||
' health: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(
|
||||
extractTrpcRoutes('src/server/trpc/root.ts', source).map(
|
||||
(r) => r.httpMethod + ' ' + r.routePath,
|
||||
),
|
||||
).toEqual(['GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('identifier-mounted same-file subrouter uses the live mount path', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter,',
|
||||
' health: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.list', 'GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('type-annotated router bindings still compose identifier mounts', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter: AppRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({ admin: adminRouter });',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.list']);
|
||||
});
|
||||
|
||||
it('identifier mount still composes with an inline nest inside the subrouter', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' users: t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' }),',
|
||||
'});',
|
||||
'export const appRouter = t.router({ admin: adminRouter });',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.users.list']);
|
||||
});
|
||||
|
||||
it('transitive identifier mounts compose admin.users.list', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const usersRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'const adminRouter = t.router({',
|
||||
' users: usersRouter,',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.users.list']);
|
||||
});
|
||||
|
||||
it('userRouter prefix plus identifier mount is user.admin.list', () => {
|
||||
const source = [
|
||||
"import { router, publicProcedure } from '../trpc';",
|
||||
'const adminRouter = router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const userRouter = router({',
|
||||
' admin: adminRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/user.admin.list']);
|
||||
});
|
||||
|
||||
it('identifier mount declared after appRouter still prefixes the live path', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter,',
|
||||
'});',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.list']);
|
||||
});
|
||||
|
||||
it('quoted identifier mount key is kept', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
" 'admin-panel': adminRouter,",
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin-panel.list']);
|
||||
});
|
||||
|
||||
it('the same subrouter mounted twice emits both live paths', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter,',
|
||||
' billing: adminRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.list', 'GET /trpc/billing.list']);
|
||||
});
|
||||
|
||||
it('unmounted sibling routers do not emit phantom root routes', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const unusedRouter = t.router({',
|
||||
' secret: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' health: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/health']);
|
||||
});
|
||||
|
||||
it('identifier mount inside an inline nest keeps the nest prefix', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const usersRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: t.router({',
|
||||
' users: usersRouter,',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/admin.users.list']);
|
||||
});
|
||||
|
||||
it('inline nest plus a top-level same-key mount keep distinct live paths', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const aRouter = t.router({',
|
||||
' list: publicProcedure.query(handlerA),',
|
||||
'});',
|
||||
'const bRouter = t.router({',
|
||||
' list: publicProcedure.query(handlerB),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' users: aRouter,',
|
||||
' extra: t.router({',
|
||||
' users: bRouter,',
|
||||
' }),',
|
||||
'});',
|
||||
].join('\n');
|
||||
const extracted = extractTrpcRoutes(FILE, source);
|
||||
expect(extracted.map((r) => r.httpMethod + ' ' + r.routePath)).toEqual([
|
||||
'GET /trpc/users.list',
|
||||
'GET /trpc/extra.users.list',
|
||||
]);
|
||||
expect(extracted.map((r) => r.methodName)).toEqual(['handlerA', 'handlerB']);
|
||||
});
|
||||
|
||||
it('type assertion on an identifier mount still uses the mount key', () => {
|
||||
const asConst = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter as const,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(asConst)).toEqual(['GET /trpc/admin.list']);
|
||||
|
||||
const satisfies = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const adminRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' admin: adminRouter satisfies AdminRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(satisfies)).toEqual(['GET /trpc/admin.list']);
|
||||
});
|
||||
|
||||
it('exported appRouter remount key wins over an earlier exported *Router prefix', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const postRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' blog: postRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/blog.list']);
|
||||
});
|
||||
|
||||
it('cyclic identifier mounts do not fabricate reverse-root paths', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'const aRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' b: bRouter,',
|
||||
'});',
|
||||
'const bRouter = t.router({',
|
||||
' list: publicProcedure.query(() => null),',
|
||||
' a: aRouter,',
|
||||
'});',
|
||||
'export const appRouter = t.router({',
|
||||
' a: aRouter,',
|
||||
'});',
|
||||
].join('\n');
|
||||
const emitted = paths(source);
|
||||
expect(emitted).toEqual(['GET /trpc/a.list', 'GET /trpc/a.b.list']);
|
||||
expect(emitted).not.toContain('GET /trpc/b.list');
|
||||
expect(emitted).not.toContain('GET /trpc/b.a.list');
|
||||
});
|
||||
|
||||
it('router refs inside a procedure callback are not identifier mounts', () => {
|
||||
const source = [
|
||||
"import { initTRPC } from '@trpc/server';",
|
||||
'const t = initTRPC.create();',
|
||||
'const publicProcedure = t.procedure;',
|
||||
'export const appRouter = t.router({',
|
||||
' health: publicProcedure.query(() => ({ nested: childRouter })),',
|
||||
'});',
|
||||
'const childRouter = t.router({',
|
||||
' hidden: publicProcedure.query(() => null),',
|
||||
'});',
|
||||
].join('\n');
|
||||
expect(paths(source)).toEqual(['GET /trpc/health']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldScanForTrpcRoutes', () => {
|
||||
it('matches a repo-root routers/ file after slash-normalizing', () => {
|
||||
expect(shouldScanForTrpcRoutes('routers/foo.ts')).toBe(true);
|
||||
expect(shouldScanForTrpcRoutes('src/server/api/routers/user.ts')).toBe(true);
|
||||
expect(shouldScanForTrpcRoutes('src/lib/utils.ts')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -66,6 +66,17 @@ describe('XAML document declarations (#3202)', () => {
|
|||
expect(extractXamlDeclarations('<Button x:Name="Undeclared" />')).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not mistake DOCTYPE text inside CDATA, comments, or processing instructions for a DTD', () => {
|
||||
const declarations = extractXamlDeclarations(`<Grid xmlns:x="${NS}" x:Name="ActualView">
|
||||
<![CDATA[<!DOCTYPE example>]]>
|
||||
<!-- <!DOCTYPE example> -->
|
||||
<?example <!DOCTYPE example>?>
|
||||
</Grid>`);
|
||||
expect(declarations).toEqual([
|
||||
expect.objectContaining({ name: 'ActualView', description: 'Grid x:Name declaration' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['{}escaped', 'escaped'],
|
||||
['{}{x:Type Button}', '{x:Type Button}'],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue