mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-12 23:02:45 +00:00
* feat(ingestion): resolve FastAPI include_router(prefix=...) cross-file routes
FastAPI sub-route files declare paths via @router.<verb> while the entry
file mounts the router with app.include_router(<router>, prefix='/x').
Previously both the ingestion-layer Route graph nodes and the group-layer
ExtractedContract URLs lost the cross-file prefix, breaking provider <->
consumer matching.
Ingestion layer:
- parse-worker emits routerIncludes / routerImports + decoratorReceiver
- parsing-processor / parse-impl thread the new fields and aggregate
prefixesByModule across chunks; decorator routes whose receiver is
'router' are duplicated once per matching prefix
- routes.ts joins prefix via normalizeExtractedRoutePath
Group layer:
- HttpLanguagePlugin gains an optional prepareRepo() pre-pass and a
repoContext arg to scan(); python.ts builds prefixesByModule and
falls back to the bare path when no entry matches
- http-route-extractor caches one repoContext per plugin
Tests:
- 3 new http-route-extractor cases (attr / named-import / no-prefix)
- ParseWorkerResult literals in 3 test files updated to the new shape
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(ingestion,group): address PR #1877 review — relative imports, cross-package collisions, host names, ingestion tests
Follow-ups to the FastAPI `include_router(prefix=...)` cross-file fix
based on PR #1877's automated production-readiness review. Three
correctness gaps and one test coverage gap addressed:
1. Relative-import support in the worker regex (FINDING 2)
`FROM_IMPORT_ROUTER_RE` now accepts module paths starting with a
`.` (e.g. `from .calls import router as calls_router`). The
previous `[A-Za-z_][\w.]*` rejected leading dots and silently
dropped every relative-import Shape-B include — a real pattern
from the PR description's own motivating example. The matching
helpers now strip leading dots before keying so absolute and
relative imports collapse to the same module key.
2. Cross-package same-name module collisions (FINDING 3)
Two-tier module keying replaces the previous basename-only key:
• short key — `users` (file basename without `.py`)
• long key — `api/users` (parent dir + stem)
`prefixesByLongKey` is consulted first and only falls back to
`prefixesByShortKey` when no long-key match is available. Both
the ingestion pipeline (parse-impl.ts) and the group extractor
(http-patterns/python.ts) carry the same scheme so the graph
nodes and HTTP contracts agree on which prefix applies.
New protocol field `ExtractedRouterModuleAlias` (parse-worker →
parsing-processor → parse-impl) lets Shape-A
`<host>.include_router(<mod>.router, prefix='/x')` calls promote
to a long key when the same file imports `<mod>` via
`from <pkg> import <mod>`. Without this, `api/users.py` and
`admin/users.py` collided on the basename `users` and the admin
file's routes inherited the `/users` prefix that was only meant
for `api/users.py`.
3. Non-`app` host variable names (FINDING 4)
The group-layer `INCLUDE_ROUTER_*_PATTERNS` queries pinned the
host identifier to the literal `"app"` and dropped every
`application = FastAPI()` / `api = FastAPI()` pattern — the
constraint was redundant given that the call shape
(`include_router` invoked with a router argument and a
`prefix=` keyword) is already specific enough. The pin is
removed; the ingestion regex was already unrestricted.
4. Ingestion-layer regression tests (FINDING 1)
The previous PR added group-layer tests
(`http-route-extractor.test.ts`) but zero in-tree tests for the
ingestion path. Two new suites pin the
worker → parse-impl → routes flow:
- `test/unit/fastapi-router-bindings.test.ts` (23 cases):
`extractFastAPIRouterBindings()` is split into a stand-alone
module so it can be unit-tested without booting a worker
thread, then pinned for regex shape, two-tier key emission,
relative-import support, and negative cases.
- `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases)
plus `test/fixtures/fastapi-prefix-app/` — runs the full
`runPipelineFromRepo()` against a realistic multi-package
fixture (containing both `api/users.py` and `admin/users.py`)
and inspects the resulting `Route` graph nodes for cross-file
prefix joining and absence of cross-package bleed.
Verification
- `npx tsc --noEmit`: pass
- PR-touched test suites (6 files / 117 cases): all green
- `npx prettier --check`: pass on touched files
- `npx eslint`: 0 errors on touched files
Cache / compatibility
The new `routerModuleAliases?` field on `ParseWorkerResult` and
`routerModuleAliases` on `WorkerExtractedData` are optional /
guarded with `?? []`, so historical parse-cache entries continue
to load without forced re-scan.
Refs PR #1877.
* refactor(ingestion): move fastapi-router-bindings out of workers/ — pure module, not a worker
Addresses @magyargergo's `CHANGES_REQUESTED` review on PR #1877:
> Sorry I just found that we are introducing a new worker in the PR.
`gitnexus/src/core/ingestion/workers/fastapi-router-bindings.ts` was a
**pure-function module** — it never imported `worker_threads` or
`parentPort`, never spawned a worker, and was never registered as a
worker entry. It was placed in `workers/` purely because it was split
out of `workers/parse-worker.ts` to make its functions unit-testable
without booting a worker thread (parse-worker is itself the worker
entry and cannot be loaded from the main thread).
To remove the misleading directory placement:
• The implementation moves to
`gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts`,
alongside the other framework-specific route extractors (`expo`,
`nextjs`, `php`, `laravel`, `middleware`, `response-shapes`).
• `workers/parse-worker.ts` keeps a thin re-export so the worker
entry can keep using `extractFastAPIRouterBindings` directly. The
re-export now carries an explicit comment stating that the imported
file is **not** a worker and that the `workers/` directory
deliberately hosts only true worker entries (`parse-worker.ts`,
`worker-pool.ts`, `quarantine.ts`).
• The new file's leading docstring opens with "NOT A WORKER" and
explains why it exists where it does.
• The unit test (`test/unit/fastapi-router-bindings.test.ts`) is
updated to import from the new path.
No behaviour change. The function body, signatures, and exported types
are identical.
Verification
• `npx tsc --noEmit`: pass
• `npx tsc` (dist rebuild): pass
• `test/unit/fastapi-router-bindings.test.ts` (23 cases): all green
• `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases): all green
• `test/unit/group/http-route-extractor.test.ts` (63 cases): all green
• `npx prettier --check` on touched files: pass
• `npx eslint` on touched files: 0 errors
Refs PR #1877.
* refactor(ingestion): drop parse-worker re-exports; consumers import router types directly from route-extractors
Addresses @magyargergo's two remaining review comments on PR #1877:
1. **`gitnexus/src/core/ingestion/workers/parse-worker.ts:247`** —
"Can you please remove them and update the call sites?"
The `export type { ExtractedRouterInclude, ExtractedRouterImport,
ExtractedRouterModuleAlias } from '../route-extractors/...'` block
in parse-worker.ts is gone. The remaining `import type {…}` is
purely local — used only to type the corresponding fields on
`ParseWorkerResult` below — and the leading comment now says so
explicitly ("this file does NOT re-export them"). The
`extractFastAPIRouterBindings` symbol is also no longer re-exported
from parse-worker.ts; it's still imported here so the worker entry
can call it per file, but downstream consumers must reach it via
`route-extractors/fastapi-router-bindings` directly.
Call sites updated:
- `gitnexus/src/core/ingestion/parsing-processor.ts`
- `gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts`
Both files now `import type { ExtractedRouterInclude,
ExtractedRouterImport, ExtractedRouterModuleAlias }` directly from
`route-extractors/fastapi-router-bindings.js`. The worker types
they still need (`ParseWorkerResult`, `ExtractedToolDef`, etc.)
keep coming from `workers/parse-worker.js`.
The unit + integration tests already imported from the new path,
so no test changes were required.
2. **`gitnexus/src/core/ingestion/parsing-processor.ts:168`** —
suggested simplification:
for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item);
for (const item of result.routerImports ?? []) allRouterImports.push(item);
for (const item of result.routerModuleAliases ?? []) allRouterModuleAliases.push(item);
Applied verbatim. Replaces the previous `if (result.…) for …`
guards. The cache-compat semantics are unchanged — historical
parse-cache entries that lack these fields still load cleanly,
the new form just spells the fallback inline.
No behavior change, no tests touched, no public API change.
Verification
• `npx tsc --noEmit`: pass
• `npx tsc` (dist rebuild): pass
• PR-touched test suites (6 files / 117 cases): all green
• `npx prettier --check` on touched files: pass
• `npx eslint` on touched files: 0 errors
Refs PR #1877.
* refactor(ingestion): hoist fastapi-router-bindings type imports to top of parse-worker.ts
Move the `import type { ExtractedRouterInclude, ExtractedRouterImport,
ExtractedRouterModuleAlias }` block to the top of the file with the
other type imports, and drop the comment that previously sat next to
ExtractedDecoratorRoute.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { mkdtemp, rm } from 'fs/promises';
|
|
import { tmpdir } from 'os';
|
|
import path from 'path';
|
|
import {
|
|
PARSE_CACHE_VERSION,
|
|
computeChunkHash,
|
|
fileContentHash,
|
|
loadParseCache,
|
|
saveParseCache,
|
|
pruneCache,
|
|
type ParseCache,
|
|
} from '../../src/storage/parse-cache.js';
|
|
import type { ParseWorkerResult } from '../../src/core/ingestion/workers/parse-worker.js';
|
|
|
|
const minimalResult = (overrides: Partial<ParseWorkerResult> = {}): ParseWorkerResult => ({
|
|
nodes: [],
|
|
relationships: [],
|
|
symbols: [],
|
|
imports: [],
|
|
calls: [],
|
|
assignments: [],
|
|
heritage: [],
|
|
routes: [],
|
|
fetchCalls: [],
|
|
fetchWrapperDefs: [],
|
|
decoratorRoutes: [],
|
|
routerIncludes: [],
|
|
routerImports: [],
|
|
toolDefs: [],
|
|
ormQueries: [],
|
|
constructorBindings: [],
|
|
fileScopeBindings: [],
|
|
parsedFiles: [],
|
|
skippedLanguages: {},
|
|
fileCount: 0,
|
|
...overrides,
|
|
});
|
|
|
|
describe('computeChunkHash', () => {
|
|
it('produces a stable hex hash for a fixed set of (filePath, contentHash) entries', () => {
|
|
const entries = [
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
{ filePath: 'b.ts', contentHash: 'h-b' },
|
|
{ filePath: 'c.ts', contentHash: 'h-c' },
|
|
];
|
|
const h1 = computeChunkHash(entries);
|
|
const h2 = computeChunkHash(entries);
|
|
expect(h1).toBe(h2);
|
|
expect(h1).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('is order-independent (same files in different order → same hash)', () => {
|
|
const order1 = [
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
{ filePath: 'b.ts', contentHash: 'h-b' },
|
|
];
|
|
const order2 = [
|
|
{ filePath: 'b.ts', contentHash: 'h-b' },
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
];
|
|
expect(computeChunkHash(order1)).toBe(computeChunkHash(order2));
|
|
});
|
|
|
|
it('changes when any file content changes', () => {
|
|
const before = [
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
{ filePath: 'b.ts', contentHash: 'h-b' },
|
|
];
|
|
const after = [
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
{ filePath: 'b.ts', contentHash: 'h-b-NEW' }, // b.ts content changed
|
|
];
|
|
expect(computeChunkHash(before)).not.toBe(computeChunkHash(after));
|
|
});
|
|
|
|
it('changes when chunk membership changes (file added or removed)', () => {
|
|
const small = [
|
|
{ filePath: 'a.ts', contentHash: 'h-a' },
|
|
{ filePath: 'b.ts', contentHash: 'h-b' },
|
|
];
|
|
const bigger = [...small, { filePath: 'c.ts', contentHash: 'h-c' }];
|
|
expect(computeChunkHash(small)).not.toBe(computeChunkHash(bigger));
|
|
});
|
|
});
|
|
|
|
describe('fileContentHash', () => {
|
|
it('hashes a string deterministically', () => {
|
|
expect(fileContentHash('hello')).toBe(fileContentHash('hello'));
|
|
expect(fileContentHash('hello')).not.toBe(fileContentHash('hello!'));
|
|
expect(fileContentHash('hello')).toMatch(/^[a-f0-9]{64}$/);
|
|
});
|
|
|
|
it('handles Buffer input identical to its string form', () => {
|
|
const s = 'sentinel';
|
|
expect(fileContentHash(Buffer.from(s))).toBe(fileContentHash(s));
|
|
});
|
|
});
|
|
|
|
describe('PARSE_CACHE_VERSION', () => {
|
|
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
|
|
// Looks like "1+1.6.4" — schema bump prefix + actual gitnexus version
|
|
expect(PARSE_CACHE_VERSION).toMatch(/^\d+\+\d+\.\d+\.\d+/);
|
|
});
|
|
});
|
|
|
|
describe('pruneCache', () => {
|
|
it('drops entries whose hashes are not in the used-set', () => {
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map<string, ParseWorkerResult[]>([
|
|
['hash-A', [minimalResult()]],
|
|
['hash-B', [minimalResult()]],
|
|
['hash-C', [minimalResult()]],
|
|
]),
|
|
usedKeys: new Set<string>(['hash-A']),
|
|
};
|
|
const removed = pruneCache(cache, cache.usedKeys);
|
|
expect(removed).toBe(2);
|
|
expect([...cache.entries.keys()].sort()).toEqual(['hash-A']);
|
|
});
|
|
|
|
it('returns 0 when every entry is in use', () => {
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map<string, ParseWorkerResult[]>([
|
|
['hash-A', [minimalResult()]],
|
|
['hash-B', [minimalResult()]],
|
|
]),
|
|
usedKeys: new Set<string>(['hash-A', 'hash-B']),
|
|
};
|
|
expect(pruneCache(cache, cache.usedKeys)).toBe(0);
|
|
expect(cache.entries.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('loadParseCache / saveParseCache (round-trip)', () => {
|
|
it('round-trips an empty cache', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map(),
|
|
usedKeys: new Set(),
|
|
};
|
|
await saveParseCache(dir, cache);
|
|
await expect(fs.access(path.join(dir, 'parse-cache', 'index.json'))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.version).toBe(PARSE_CACHE_VERSION);
|
|
expect(loaded.entries.size).toBe(0);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns an empty cache when the file is missing', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(0);
|
|
expect(loaded.usedKeys.size).toBe(0);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns an empty cache on version mismatch (next-run regen)', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
// Write a cache file with a different version directly
|
|
const fs = await import('fs/promises');
|
|
await fs.writeFile(
|
|
path.join(dir, 'parse-cache.json'),
|
|
JSON.stringify({ version: 'foreign-99', entries: { h: [] } }),
|
|
'utf-8',
|
|
);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(0); // mismatch → empty
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns an empty cache on corrupt JSON', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
await fs.writeFile(path.join(dir, 'parse-cache.json'), '{not-json', 'utf-8');
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(0);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('loads a legacy single-file cache for backwards compatibility', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
await fs.writeFile(
|
|
path.join(dir, 'parse-cache.json'),
|
|
JSON.stringify({
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: {
|
|
legacyChunk: [minimalResult({ fileCount: 7 })],
|
|
},
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(1);
|
|
expect(loaded.entries.get('legacyChunk')?.[0]?.fileCount).toBe(7);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips corrupt or missing shards while loading the sharded cache', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const cacheDir = path.join(dir, 'parse-cache');
|
|
const goodKey = 'a'.repeat(64);
|
|
const missingKey = 'b'.repeat(64);
|
|
const badKey = 'c'.repeat(64);
|
|
await fs.mkdir(cacheDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cacheDir, 'index.json'),
|
|
JSON.stringify({
|
|
version: PARSE_CACHE_VERSION,
|
|
keys: [goodKey, missingKey, badKey],
|
|
}),
|
|
'utf-8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(cacheDir, `${goodKey}.json`),
|
|
JSON.stringify([minimalResult({ fileCount: 3 })]),
|
|
'utf-8',
|
|
);
|
|
await fs.writeFile(path.join(cacheDir, `${badKey}.json`), '{not-json', 'utf-8');
|
|
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(1);
|
|
expect(loaded.entries.get(goodKey)?.[0]?.fileCount).toBe(3);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('round-trips Map and Set values through the JSON replacer/reviver', async () => {
|
|
// ParsedFile.scopes[*].typeBindings is a ReadonlyMap<string, TypeRef>.
|
|
// Without the replacer/reviver pair, JSON.stringify collapses Maps to
|
|
// {} and downstream code that does .get() / iterates entries crashes
|
|
// with "is not iterable". This test pins the round-trip behaviour.
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const innerMap = new Map<string, string>([
|
|
['k1', 'v1'],
|
|
['k2', 'v2'],
|
|
]);
|
|
const innerSet = new Set<string>(['s1', 's2']);
|
|
// Stash the live Map/Set inside a synthetic ParseWorkerResult — we
|
|
// only need the serializer to traverse them. Casting to bypass the
|
|
// strict shape isn't a problem here: this test is about JSON
|
|
// round-tripping of arbitrary nested Map/Set values, not full
|
|
// ParseWorkerResult contents.
|
|
const fake = minimalResult({
|
|
parsedFiles: [
|
|
{
|
|
filePath: 't.ts',
|
|
// Cast through unknown to satisfy the readonly Scope shape
|
|
// while still smuggling a live Map into the serializer's
|
|
// traversal path — see comment block above.
|
|
scopes: [{ id: 's1', typeBindings: innerMap, extras: innerSet }],
|
|
} as unknown as ParseWorkerResult['parsedFiles'][number],
|
|
],
|
|
});
|
|
|
|
const chunkKey = 'd'.repeat(64);
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map<string, ParseWorkerResult[]>([[chunkKey, [fake]]]),
|
|
usedKeys: new Set([chunkKey]),
|
|
};
|
|
await saveParseCache(dir, cache);
|
|
const persisted = await fs.readdir(path.join(dir, 'parse-cache'));
|
|
expect(persisted).toContain('index.json');
|
|
expect(persisted).toContain(`${chunkKey}.json`);
|
|
const loaded = await loadParseCache(dir);
|
|
const reloaded = loaded.entries.get(chunkKey)?.[0];
|
|
expect(reloaded).toBeDefined();
|
|
const scope = (reloaded as ParseWorkerResult).parsedFiles[0]?.scopes[0] as unknown as {
|
|
typeBindings?: unknown;
|
|
extras?: unknown;
|
|
};
|
|
expect(scope.typeBindings).toBeInstanceOf(Map);
|
|
expect((scope.typeBindings as Map<string, string>).get('k1')).toBe('v1');
|
|
expect((scope.typeBindings as Map<string, string>).size).toBe(2);
|
|
expect(scope.extras).toBeInstanceOf(Set);
|
|
expect((scope.extras as Set<string>).has('s2')).toBe(true);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('ignores traversal-like and non-hex keys in sharded index.json', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const cacheDir = path.join(dir, 'parse-cache');
|
|
await fs.mkdir(cacheDir, { recursive: true });
|
|
const safeKey = 'e'.repeat(64);
|
|
await fs.writeFile(
|
|
path.join(cacheDir, 'index.json'),
|
|
JSON.stringify({
|
|
version: PARSE_CACHE_VERSION,
|
|
keys: ['../evil', '/absolute', 'G'.repeat(64), safeKey],
|
|
}),
|
|
'utf-8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(cacheDir, `${safeKey}.json`),
|
|
JSON.stringify([minimalResult({ fileCount: 9 })]),
|
|
'utf-8',
|
|
);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(1);
|
|
expect(loaded.entries.get(safeKey)?.[0]?.fileCount).toBe(9);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('writes one shard file per cache entry (three distinct keys)', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const k1 = '1'.repeat(64);
|
|
const k2 = '2'.repeat(64);
|
|
const k3 = '3'.repeat(64);
|
|
const cache: ParseCache = {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map<string, ParseWorkerResult[]>([
|
|
[k1, [minimalResult({ fileCount: 1 })]],
|
|
[k2, [minimalResult({ fileCount: 2 })]],
|
|
[k3, [minimalResult({ fileCount: 3 })]],
|
|
]),
|
|
usedKeys: new Set([k1, k2, k3]),
|
|
};
|
|
await saveParseCache(dir, cache);
|
|
const cacheDir = path.join(dir, 'parse-cache');
|
|
const names = await fs.readdir(cacheDir);
|
|
expect(names).toContain('index.json');
|
|
expect(names.filter((n) => n.endsWith('.json') && n !== 'index.json').length).toBe(3);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(3);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('returns empty when sharded index version mismatches even if legacy parse-cache.json is valid', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const cacheDir = path.join(dir, 'parse-cache');
|
|
await fs.mkdir(cacheDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(cacheDir, 'index.json'),
|
|
JSON.stringify({ version: 'foreign-sharded-1', keys: [] }),
|
|
'utf-8',
|
|
);
|
|
await fs.writeFile(
|
|
path.join(dir, 'parse-cache.json'),
|
|
JSON.stringify({
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: { legacyChunk: [minimalResult({ fileCount: 42 })] },
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(0);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('second saveParseCache replaces the first sharded cache', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
const k1 = '4'.repeat(64);
|
|
const k2 = '5'.repeat(64);
|
|
await saveParseCache(dir, {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map([[k1, [minimalResult()]]]),
|
|
usedKeys: new Set([k1]),
|
|
});
|
|
await saveParseCache(dir, {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map([[k2, [minimalResult({ fileCount: 99 })]]]),
|
|
usedKeys: new Set([k2]),
|
|
});
|
|
const names = await fs.readdir(path.join(dir, 'parse-cache'));
|
|
expect(names).not.toContain(`${k1}.json`);
|
|
expect(names).toContain(`${k2}.json`);
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.size).toBe(1);
|
|
expect(loaded.entries.get(k2)?.[0]?.fileCount).toBe(99);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('removes legacy parse-cache.json after a successful sharded save', async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'gnx-pc-'));
|
|
try {
|
|
const fs = await import('fs/promises');
|
|
await fs.writeFile(
|
|
path.join(dir, 'parse-cache.json'),
|
|
JSON.stringify({
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: { oldLegacy: [minimalResult({ fileCount: 5 })] },
|
|
}),
|
|
'utf-8',
|
|
);
|
|
const k = '6'.repeat(64);
|
|
await saveParseCache(dir, {
|
|
version: PARSE_CACHE_VERSION,
|
|
entries: new Map([[k, [minimalResult({ fileCount: 6 })]]]),
|
|
usedKeys: new Set([k]),
|
|
});
|
|
await expect(fs.access(path.join(dir, 'parse-cache.json'))).rejects.toThrow();
|
|
const loaded = await loadParseCache(dir);
|
|
expect(loaded.entries.get(k)?.[0]?.fileCount).toBe(6);
|
|
expect(loaded.entries.has('oldLegacy')).toBe(false);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|