mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
CodeQL / Analyze (python) (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
* fix(web): drop TypeScript 7-incompatible tsconfig paths Remove baseUrl and the dead ../shared include so web project references typecheck under TypeScript 7. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): parse TypeScript with a TypeScript 6 API package Keep AST guards working after the named typescript package becomes 7, which no longer ships the Compiler API. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(lint): pin root TypeScript to the 6 API package Give typescript-eslint a TypeScript 6 peer so syntax-only lint still installs after CLI and web move to TypeScript 7. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(deps): compile first-party packages with TypeScript 7.0.2 Unify CLI and web on the same native compiler line as gitnexus-shared so typecheck and emit no longer split 5.x versus 7.x. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(ci): describe parent TypeScript 7 as the shared compiler Stop saying web compiles shared with TypeScript 5 now that the parent lockfile is 7. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): compile shared from parent TypeScript on Vercel and skill-evolution Stop isolated npm installs in gitnexus-shared so those paths do not pull a second TypeScript 7 optional-platform tree. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: record TypeScript 7 typecheck and Dependabot major-split policy Keep contributor typecheck commands, and stop Dependabot from bumping shared onto a different TypeScript major than CLI and web. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(lint): pin root TypeScript to 5.9 so npm ci satisfies eslint peers typescript-eslint 8 peers typescript below 6.0.0, so the typescript6 alias made quality lint npm ci fail with ERESOLVE. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): drop the TypeScript 6 Compiler API package TypeScript 7.0 has no classic createProgram surface, so parse-only guards now use Babel and Mode 4 uses the TypeScript 7 Checker. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: align contributor setup with parent TypeScript 7 compile Stop telling clones to npm-install gitnexus-shared; CI and Vercel already emit that package from a parent lib/tsc.js shim. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): typecheck React JSX on TypeScript 7 with explicit DOM libs TypeScript 7 no longer implies DOM or auto-includes @types, so the web app must declare React/JSX settings while Vite keeps plugin-react. Co-authored-by: Cursor <cursoragent@cursor.com> * test: pin Vercel --include=dev and share parse-only string helpers Production npm ci omits the web TypeScript unless --include=dev is on that install. Move staticStringValue next to the other Babel walk helpers so CLI help and contract tests share one source. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
596 lines
24 KiB
TypeScript
596 lines
24 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
import { _captureLogger } from '../../../src/core/logger.js';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import * as os from 'node:os';
|
||
import { fileURLToPath } from 'node:url';
|
||
import * as t from '@babel/types';
|
||
import {
|
||
type AstNode,
|
||
collectDescendants,
|
||
lineAt,
|
||
nodeStart,
|
||
nodeText,
|
||
parseTypeScript,
|
||
staticMemberName,
|
||
} from '../../helpers/parse-typescript-source.js';
|
||
import type {
|
||
ContractRegistry,
|
||
ExtractedContract,
|
||
GroupConfig,
|
||
GroupManifestLink,
|
||
RepoHandle,
|
||
} from '../../../src/core/group/types.js';
|
||
|
||
/**
|
||
* Per-repo extraction is all-or-nothing.
|
||
*
|
||
* `syncGroup` runs each enabled extractor for a repo in sequence and any one of
|
||
* them can throw. Appending results to the shared `autoContracts` as they were
|
||
* produced meant a repo whose HTTP extractor succeeded and whose gRPC extractor
|
||
* then failed contributed a partial set to contracts.json — while the catch that
|
||
* caught the failure told the operator that repo's "contracts are omitted from
|
||
* this sync", and `group sync` printed the same. The persisted registry held an
|
||
* undocumented partial view of a repo that the diagnostics described as absent.
|
||
*
|
||
* Nothing about the earlier extractor's output is wrong in isolation. What makes
|
||
* it unusable is that no reader can tell which repos are complete: a contract
|
||
* that is silently absent reads exactly like a contract that does not exist.
|
||
*/
|
||
|
||
const PARTIAL_CONTRACT: ExtractedContract = {
|
||
contractId: 'http::GET::/api/users',
|
||
type: 'http',
|
||
role: 'provider',
|
||
symbolUid: 'Function:src/users.ts:listUsers',
|
||
symbolRef: { filePath: 'src/users.ts', name: 'listUsers' },
|
||
symbolName: 'listUsers',
|
||
confidence: 1,
|
||
meta: {},
|
||
};
|
||
|
||
const httpExtract = vi.fn();
|
||
const grpcExtract = vi.fn();
|
||
// Bound through an arrow so the test body can read its calls: which repos the
|
||
// deferred manifest phase re-opens is the observable side of dropping a failed
|
||
// repo's handle, and a `vi.fn()` created inside the factory is unreachable here.
|
||
const initLbugMock = vi.fn(async () => {});
|
||
|
||
vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({
|
||
initLbug: (...args: unknown[]) => initLbugMock(...args),
|
||
executeParameterized: vi.fn(async () => []),
|
||
pinRepo: vi.fn(() => () => {}),
|
||
getMaxResidentRepos: vi.fn(() => 5),
|
||
}));
|
||
|
||
vi.mock('../../../src/storage/repo-manager.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../../../src/storage/repo-manager.js')>();
|
||
return {
|
||
...actual,
|
||
readRegistry: vi.fn(async () => []),
|
||
readRegistryStrict: vi.fn(async () => []),
|
||
};
|
||
});
|
||
|
||
vi.mock('../../../src/core/group/extractors/http-route-extractor.js', () => ({
|
||
HttpRouteExtractor: class {
|
||
extract = (...args: unknown[]) => httpExtract(...args);
|
||
},
|
||
}));
|
||
|
||
vi.mock('../../../src/core/group/extractors/grpc-extractor.js', () => ({
|
||
GrpcExtractor: class {
|
||
extract = (...args: unknown[]) => grpcExtract(...args);
|
||
},
|
||
}));
|
||
|
||
const { syncGroup } = await import('../../../src/core/group/sync.js');
|
||
|
||
const handle: RepoHandle = {
|
||
id: 'pool-backend',
|
||
path: '/repos/backend',
|
||
repoPath: '/repos/backend',
|
||
storagePath: '/repos/backend/.gitnexus',
|
||
};
|
||
|
||
const config = (): GroupConfig => ({
|
||
version: 1,
|
||
name: 'test',
|
||
description: '',
|
||
repos: { 'app/backend': 'backend-repo' },
|
||
links: [],
|
||
packages: {},
|
||
detect: {
|
||
http: true,
|
||
grpc: true,
|
||
thrift: false,
|
||
topics: false,
|
||
includes: false,
|
||
workspace_deps: false,
|
||
},
|
||
matching: {},
|
||
});
|
||
|
||
describe('syncGroup when one extractor fails partway through a repo', () => {
|
||
let groupDir: string;
|
||
|
||
beforeEach(() => {
|
||
httpExtract.mockReset();
|
||
grpcExtract.mockReset();
|
||
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-partial-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(groupDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('keeps none of that repo’s contracts, matching what the diagnostics say', async () => {
|
||
httpExtract.mockResolvedValue([PARTIAL_CONTRACT]);
|
||
grpcExtract.mockRejectedValue(new Error('gRPC extraction failed'));
|
||
|
||
const result = await syncGroup(config(), {
|
||
groupDir,
|
||
resolveRepoHandle: async () => handle,
|
||
});
|
||
|
||
expect(httpExtract).toHaveBeenCalledTimes(1);
|
||
expect(result.unreadableRepos).toEqual(['app/backend']);
|
||
// The contract the HTTP extractor produced is discarded with the rest of
|
||
// the repo. Anything else contradicts the warning the same run emits.
|
||
expect(result.contracts).toEqual([]);
|
||
});
|
||
|
||
it('keeps every contract when all enabled extractors succeed', async () => {
|
||
// The control: the all-or-nothing rule must not cost the happy path its
|
||
// output, which a guard that simply dropped `repoContracts` would.
|
||
httpExtract.mockResolvedValue([PARTIAL_CONTRACT]);
|
||
grpcExtract.mockResolvedValue([]);
|
||
|
||
const result = await syncGroup(config(), {
|
||
groupDir,
|
||
resolveRepoHandle: async () => handle,
|
||
});
|
||
|
||
expect(result.unreadableRepos).toEqual([]);
|
||
expect(result.contracts).toHaveLength(1);
|
||
expect(result.contracts[0].contractId).toBe('http::GET::/api/users');
|
||
expect(result.contracts[0].repo).toBe('app/backend');
|
||
});
|
||
});
|
||
|
||
/**
|
||
* The staged contracts must be appended by a BOUNDED construct.
|
||
*
|
||
* Staging (above) is what made the append dangerous. Before it, each extractor's
|
||
* output was appended as it came back, so `autoContracts.push(...)` only ever
|
||
* spread one extractor's contracts; staging makes it spread the whole repo's.
|
||
* A spread call passes every element as a separate ARGUMENT, and the engine caps
|
||
* how many arguments a call can take — so a repo that stages enough contracts
|
||
* kills the sync with `RangeError: Maximum call stack size exceeded` on the one
|
||
* line whose job is to commit the work that just succeeded.
|
||
*
|
||
* This gate is structural rather than size-based ON PURPOSE. The argument limit
|
||
* is a function of the host's available stack: this machine accepts a 125k-element
|
||
* spread and dies at 150k, and a larger-stack host sails past both. A "make the
|
||
* fixture big enough to crash" test therefore passes against unfixed code on some
|
||
* hosts — which is precisely the guarantee a regression gate cannot give up. The
|
||
* size test below is a completeness/ordering check, not the guard.
|
||
*
|
||
* Scope: the per-repo extractor `try` block ONLY. `sync.ts` also spreads in the
|
||
* windowed manifest loop (`autoContracts.push(...windowResult.contracts)` and its
|
||
* cross-link twin). Those predate this change, are bounded by the window size,
|
||
* and are not what this gate is about — a text scan keyed on `autoContracts.push(...`
|
||
* would match them too and fail on code this change never touches. So the region
|
||
* is located by AST and by ROLE, not by name: the `const … : StoredContract[] = []`
|
||
* staging buffer declared per repo (the function-scoped `let autoContracts` is
|
||
* excluded by the `const`), then the one `try` whose block references it. Renaming
|
||
* either identifier keeps the gate pointed at the same code.
|
||
*
|
||
* `.apply(` is rejected alongside the spread: `push.apply(dest, staged)` is the
|
||
* same argument-limit hazard wearing different syntax.
|
||
*/
|
||
const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url));
|
||
|
||
/** `const <name>: StoredContract[] = []` — the per-repo staging buffer. */
|
||
function isStagingBufferDeclaration(node: t.Node): node is t.VariableDeclarator {
|
||
if (!t.isVariableDeclarator(node) || !t.isIdentifier(node.id)) return false;
|
||
const annotation = node.id.typeAnnotation;
|
||
if (
|
||
!annotation ||
|
||
!t.isTSTypeAnnotation(annotation) ||
|
||
!t.isTSArrayType(annotation.typeAnnotation)
|
||
) {
|
||
return false;
|
||
}
|
||
const elementType = annotation.typeAnnotation.elementType;
|
||
return (
|
||
t.isTSTypeReference(elementType) &&
|
||
t.isIdentifier(elementType.typeName) &&
|
||
elementType.typeName.name === 'StoredContract' &&
|
||
node.init !== undefined &&
|
||
node.init !== null &&
|
||
t.isArrayExpression(node.init) &&
|
||
node.init.elements.length === 0 &&
|
||
t.isVariableDeclaration((node as AstNode).parent) &&
|
||
((node as AstNode).parent as t.VariableDeclaration).kind === 'const'
|
||
);
|
||
}
|
||
|
||
/** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */
|
||
function isApplyCall(call: t.CallExpression): boolean {
|
||
return (
|
||
(t.isMemberExpression(call.callee) || t.isOptionalMemberExpression(call.callee)) &&
|
||
staticMemberName(call.callee) === 'apply'
|
||
);
|
||
}
|
||
|
||
function describeCall(source: string, call: t.CallExpression): string {
|
||
const line = lineAt(source, nodeStart(call));
|
||
return `${line}: ${nodeText(source, call).replace(/\s+/g, ' ')}`;
|
||
}
|
||
|
||
describe('the per-repo staging append in sync.ts', () => {
|
||
it('appends the staged contracts without spreading them into a call', () => {
|
||
const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8');
|
||
const { ast } = parseTypeScript(SYNC_SOURCE_PATH, source);
|
||
|
||
const allNodes = collectDescendants(ast);
|
||
const stagingBuffers = allNodes.filter(isStagingBufferDeclaration);
|
||
// One staging buffer, or this gate no longer knows which code it guards.
|
||
const stagingNames = stagingBuffers.map((d) =>
|
||
t.isIdentifier(d.id) ? d.id.name : nodeText(source, d.id),
|
||
);
|
||
expect(stagingNames).toHaveLength(1);
|
||
|
||
// The block the buffer is declared in — the per-repo loop body.
|
||
// VariableDeclarator → VariableDeclaration → BlockStatement (Babel has no
|
||
// extra VariableStatement wrapper).
|
||
const declaringBlocks = stagingBuffers
|
||
.map((d) => (d as AstNode).parent?.parent)
|
||
.filter((node): node is t.BlockStatement => t.isBlockStatement(node));
|
||
expect(declaringBlocks).toHaveLength(1);
|
||
|
||
// The extractor try-block: a DIRECT statement of that block whose `try` reads
|
||
// the staging buffer. Direct statements only, deliberately — `syncGroup` wraps
|
||
// this whole section in its own try/finally (the lease sweep), and that
|
||
// ancestor reads the buffer too. Widening to "any try that mentions it" pulls
|
||
// in the entire function body, manifest-window spreads and all.
|
||
const extractorTryBlocks = declaringBlocks.flatMap((block) =>
|
||
block.body
|
||
.filter((statement): statement is t.TryStatement => t.isTryStatement(statement))
|
||
.filter((statement) =>
|
||
collectDescendants(statement.block).some(
|
||
(n) => t.isIdentifier(n) && stagingNames.includes(n.name),
|
||
),
|
||
)
|
||
.map((statement) => statement.block),
|
||
);
|
||
expect(extractorTryBlocks).toHaveLength(1);
|
||
|
||
const unboundedAppends = extractorTryBlocks.flatMap((block) =>
|
||
collectDescendants(block)
|
||
.filter((node): node is t.CallExpression => t.isCallExpression(node))
|
||
.filter((call) => call.arguments.some((arg) => t.isSpreadElement(arg)) || isApplyCall(call))
|
||
.map((call) => describeCall(source, call)),
|
||
);
|
||
|
||
// Every staged contract must reach `autoContracts` through a bounded loop:
|
||
// the count a repo can stage is then bounded by memory, not by how much
|
||
// stack the host happened to give this process.
|
||
expect(unboundedAppends).toEqual([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A repo can stage more contracts than a call is allowed to take as arguments.
|
||
* 200_000 is over this host's measured spread ceiling (~125k) and under nothing
|
||
* in particular — the point is that the count is bounded by memory now, so the
|
||
* assertion is that all of them arrive, in the order the extractors produced them.
|
||
*/
|
||
const LARGE_CONTRACT_COUNT = 200_000;
|
||
|
||
describe('syncGroup appending a repo that staged a large contract count', () => {
|
||
let groupDir: string;
|
||
|
||
beforeEach(() => {
|
||
httpExtract.mockReset();
|
||
grpcExtract.mockReset();
|
||
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bulk-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(groupDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('keeps every staged contract, in order', async () => {
|
||
const staged: ExtractedContract[] = Array.from({ length: LARGE_CONTRACT_COUNT }, (_, i) => ({
|
||
...PARTIAL_CONTRACT,
|
||
contractId: `http::GET::/api/item/${i}`,
|
||
symbolUid: `Function:src/items.ts:item${i}`,
|
||
}));
|
||
httpExtract.mockResolvedValue(staged);
|
||
grpcExtract.mockResolvedValue([]);
|
||
|
||
const result = await syncGroup(config(), {
|
||
groupDir,
|
||
// Nothing here is about persistence; writing a 200k-contract registry and
|
||
// bridge would only make the test slow.
|
||
skipWrite: true,
|
||
resolveRepoHandle: async () => handle,
|
||
});
|
||
|
||
// An argument-limit RangeError lands in the per-repo catch, so an unbounded
|
||
// append shows up here as an "unreadable" repo with zero contracts — the
|
||
// extraction that actually succeeded, reported as an unreadable index.
|
||
expect(result.unreadableRepos).toEqual([]);
|
||
expect(result.contracts).toHaveLength(LARGE_CONTRACT_COUNT);
|
||
const firstOutOfOrder = result.contracts.findIndex(
|
||
(c, i) => c.contractId !== `http::GET::/api/item/${i}`,
|
||
);
|
||
expect(firstOutOfOrder).toBe(-1);
|
||
}, 30_000);
|
||
|
||
it('appends an ordinary repo’s contracts in the order the extractors produced them', async () => {
|
||
// The control. Ordering across extractors is observable in contracts.json
|
||
// and in every consumer of it, so the bounded append has to reproduce the
|
||
// sequence the spread produced: HTTP contracts first, then gRPC, each in
|
||
// the extractor's own order.
|
||
const httpContracts: ExtractedContract[] = ['a', 'b', 'c'].map((suffix) => ({
|
||
...PARTIAL_CONTRACT,
|
||
contractId: `http::GET::/api/${suffix}`,
|
||
}));
|
||
const grpcContracts: ExtractedContract[] = ['x', 'y'].map((suffix) => ({
|
||
...PARTIAL_CONTRACT,
|
||
type: 'grpc',
|
||
contractId: `grpc::svc.Service/${suffix}`,
|
||
}));
|
||
httpExtract.mockResolvedValue(httpContracts);
|
||
grpcExtract.mockResolvedValue(grpcContracts);
|
||
|
||
const result = await syncGroup(config(), {
|
||
groupDir,
|
||
resolveRepoHandle: async () => handle,
|
||
});
|
||
|
||
expect(result.unreadableRepos).toEqual([]);
|
||
expect(result.contracts.map((c) => c.contractId)).toEqual([
|
||
'http::GET::/api/a',
|
||
'http::GET::/api/b',
|
||
'http::GET::/api/c',
|
||
'grpc::svc.Service/x',
|
||
'grpc::svc.Service/y',
|
||
]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A repo the sync reported unreadable contributes NO contracts to the persisted
|
||
* registry — including through deferred manifest resolution.
|
||
*
|
||
* Per-repo staging (above) closes the extractor door only. It leaves the
|
||
* manifest one open: `repoHandles` kept the failed repo's pool identity, so the
|
||
* windowed manifest phase still counted it among the known repos, re-opened it,
|
||
* and `ManifestExtractor` emitted a contract for BOTH endpoints of every link
|
||
* naming it. contracts.json therefore listed a repo that the very same run's
|
||
* `unreadableRepos` said it could not read — the contradiction the staging
|
||
* change exists to remove, reproduced one phase later.
|
||
*
|
||
* The narrow part is what must NOT be dropped. `ManifestExtractor` resolves both
|
||
* endpoints of a link and emits one contract per endpoint, so dropping the whole
|
||
* link would also delete the HEALTHY partner's contract. A link is not the unit
|
||
* of ownership; the endpoint is. Hence the filter is by endpoint repo, and the
|
||
* all-healthy control below is what pins the healthy partner's output so an
|
||
* over-broad "drop the link" fix cannot pass.
|
||
*
|
||
* Every assertion here reads the WRITTEN contracts.json, not the in-memory
|
||
* `SyncResult`: the file is what `group status`, the bridge builder and the next
|
||
* sync consume, so an in-memory-only assertion would not describe the artifact
|
||
* the requirement is about.
|
||
*/
|
||
|
||
const GRPC_LINK: GroupManifestLink = {
|
||
from: 'app/gateway',
|
||
to: 'app/backend',
|
||
type: 'grpc',
|
||
// `role` describes `from`: the gateway CONSUMES what the backend provides, so
|
||
// the provider endpoint is the repo whose extractor fails below.
|
||
role: 'consumer',
|
||
contract: 'orders.Orders/List',
|
||
};
|
||
|
||
const LINK_CONTRACT_ID = 'grpc::orders.Orders/List';
|
||
|
||
const linkedConfig = (): GroupConfig => ({
|
||
...config(),
|
||
repos: { 'app/gateway': 'gateway-repo', 'app/backend': 'backend-repo' },
|
||
links: [GRPC_LINK],
|
||
});
|
||
|
||
/**
|
||
* Resolve handles from a table keyed on the GROUP path, so a two-repo case needs
|
||
* no branching in the test body. Distinct `repoPath`s are what let the extractor
|
||
* outcome below be keyed per repo.
|
||
*/
|
||
const LINKED_HANDLES = new Map<string, RepoHandle>([
|
||
[
|
||
'app/gateway',
|
||
{
|
||
id: 'pool-gateway',
|
||
path: '/repos/gateway',
|
||
repoPath: '/repos/gateway',
|
||
storagePath: '/repos/gateway/.gitnexus',
|
||
},
|
||
],
|
||
[
|
||
'app/backend',
|
||
{
|
||
id: 'pool-backend',
|
||
path: '/repos/backend',
|
||
repoPath: '/repos/backend',
|
||
storagePath: '/repos/backend/.gitnexus',
|
||
},
|
||
],
|
||
]);
|
||
|
||
const resolveLinkedHandle = async (
|
||
_registryName: string,
|
||
groupPath: string,
|
||
): Promise<RepoHandle | null> => LINKED_HANDLES.get(groupPath) ?? null;
|
||
|
||
/**
|
||
* `extract(executor, repoPath, handle)` — key the outcome on the repo path so
|
||
* which repo fails is data, not a branch in a test body. A repo outside the
|
||
* failing set extracts cleanly.
|
||
*/
|
||
const grpcFailingIn =
|
||
(failing: ReadonlySet<string>) =>
|
||
async (_executor: unknown, repoPath: unknown): Promise<ExtractedContract[]> => {
|
||
if (failing.has(String(repoPath))) throw new Error('gRPC extraction failed');
|
||
return [];
|
||
};
|
||
|
||
const readPersistedRegistry = (dir: string): ContractRegistry =>
|
||
JSON.parse(fs.readFileSync(path.join(dir, 'contracts.json'), 'utf8')) as ContractRegistry;
|
||
|
||
/** `<repo>|<contractId>|<role>` — the identity a registry reader cares about. */
|
||
const contractIdentities = (registry: ContractRegistry): string[] =>
|
||
registry.contracts.map((c) => `${c.repo}|${c.contractId}|${c.role}`);
|
||
|
||
describe('syncGroup persisting a manifest link with an unreadable endpoint', () => {
|
||
let groupDir: string;
|
||
|
||
beforeEach(() => {
|
||
httpExtract.mockReset();
|
||
grpcExtract.mockReset();
|
||
// `mockClear`, not `mockReset` — the resolving implementation is what makes
|
||
// `await initLbug(...)` a no-op for every other case in this file.
|
||
initLbugMock.mockClear();
|
||
// The manifest link is the only contract source in these cases, so the
|
||
// per-repo extractors contribute nothing and the registry contains exactly
|
||
// what deferred manifest resolution emitted.
|
||
httpExtract.mockResolvedValue([]);
|
||
groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-manifest-'));
|
||
});
|
||
|
||
afterEach(() => {
|
||
fs.rmSync(groupDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('names no contract for the repo the same run reported unreadable', async () => {
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
|
||
|
||
const result = await syncGroup(linkedConfig(), {
|
||
groupDir,
|
||
resolveRepoHandle: resolveLinkedHandle,
|
||
});
|
||
|
||
expect(result.unreadableRepos).toEqual(['app/backend']);
|
||
expect(result.registryOutcome).toBe('written');
|
||
|
||
const onDisk = readPersistedRegistry(groupDir);
|
||
expect(onDisk.unreadableRepos).toEqual(['app/backend']);
|
||
expect(onDisk.contracts.filter((c) => c.repo === 'app/backend')).toEqual([]);
|
||
// Not just the `repo` tag: the manifest fallback uid is `manifest::<repo>::…`,
|
||
// so a contract can still carry the unreadable repo's name after a filter
|
||
// that only looked at one field.
|
||
expect(onDisk.contracts.filter((c) => JSON.stringify(c).includes('app/backend'))).toEqual([]);
|
||
});
|
||
|
||
it('keeps the healthy endpoint’s own contract from that same link', async () => {
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
|
||
|
||
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
|
||
|
||
// Byte-identical to the healthy endpoint's line in the all-healthy control
|
||
// below — that equality IS the requirement: one endpoint failing costs the
|
||
// other nothing. A fix that drops the whole link empties this array.
|
||
expect(contractIdentities(readPersistedRegistry(groupDir))).toEqual([
|
||
`app/gateway|${LINK_CONTRACT_ID}|consumer`,
|
||
]);
|
||
});
|
||
|
||
it('emits no cross-link for a pair whose other endpoint failed', async () => {
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
|
||
|
||
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
|
||
|
||
// A cross-link asserts a relationship between two repos. With one of them
|
||
// absent from this sync there is nothing to assert it against, and a
|
||
// half-anchored link is exactly the "confident about something it could not
|
||
// read" answer the registry must not give.
|
||
expect(readPersistedRegistry(groupDir).crossLinks).toEqual([]);
|
||
});
|
||
|
||
it('emits both contracts and the cross-link when both endpoints are healthy', async () => {
|
||
// The control. Without it, "drop everything the link touches" passes every
|
||
// case above while deleting a healthy repo's contracts.
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set()));
|
||
|
||
const result = await syncGroup(linkedConfig(), {
|
||
groupDir,
|
||
resolveRepoHandle: resolveLinkedHandle,
|
||
});
|
||
|
||
expect(result.unreadableRepos).toEqual([]);
|
||
expect(result.registryOutcome).toBe('written');
|
||
|
||
const onDisk = readPersistedRegistry(groupDir);
|
||
expect(contractIdentities(onDisk)).toEqual([
|
||
`app/backend|${LINK_CONTRACT_ID}|provider`,
|
||
`app/gateway|${LINK_CONTRACT_ID}|consumer`,
|
||
]);
|
||
expect(onDisk.crossLinks).toHaveLength(1);
|
||
expect(onDisk.crossLinks[0]).toMatchObject({
|
||
from: { repo: 'app/gateway' },
|
||
to: { repo: 'app/backend' },
|
||
type: 'grpc',
|
||
contractId: LINK_CONTRACT_ID,
|
||
matchType: 'manifest',
|
||
});
|
||
});
|
||
|
||
it('does not re-open the index it just reported unreadable', async () => {
|
||
// The other half of the fix, and the one a contract-level assertion cannot
|
||
// see: the manifest phase derives its known-repo set from `repoHandles`, so
|
||
// a failed repo left in that map is re-initialized and queried a second
|
||
// time. Filtering the OUTPUT would still hide the contracts while the sync
|
||
// went on reading an index it had already told the operator it could not
|
||
// read — and, for a window at its residency cap, spending a slot on it.
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
|
||
|
||
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
|
||
|
||
const openedPools = initLbugMock.mock.calls.map((call) => String(call[0]));
|
||
// The gateway is opened twice: once to extract, once for its manifest
|
||
// window. The backend is opened once — the extraction attempt that failed —
|
||
// and never again.
|
||
expect(openedPools).toEqual(['pool-gateway', 'pool-backend', 'pool-gateway']);
|
||
});
|
||
|
||
it('tells the operator the endpoint was unreadable, not that it is unconfigured', async () => {
|
||
// The two diagnoses need different actions: an unconfigured repo means edit
|
||
// group.yaml, an unreadable one means re-index. Reusing the "not in
|
||
// config.repos" line for a repo that IS configured sends the operator to
|
||
// change a file that is already correct — and its "cross-links will use
|
||
// synthetic UIDs" tail describes an outcome that no longer happens, since
|
||
// this link's cross-link is dropped outright.
|
||
grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend'])));
|
||
const cap = _captureLogger();
|
||
|
||
try {
|
||
await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle });
|
||
} finally {
|
||
cap.restore();
|
||
}
|
||
|
||
const linkWarnings = cap
|
||
.records()
|
||
.filter((r) => r.level === 40)
|
||
.map((r) => String(r.msg ?? ''))
|
||
.filter((msg) => msg.includes('[group/sync] manifest link'));
|
||
|
||
expect(linkWarnings).toHaveLength(1);
|
||
expect(linkWarnings[0]).toContain('could not read: app/backend');
|
||
expect(linkWarnings[0]).not.toContain('not in config.repos');
|
||
});
|
||
});
|