GitNexus/gitnexus/test/unit/stream-graph-emit-config.test.ts
azizur100389 5fc518d2cb
fix(dart): resolve package imports by pubspec identity (#3369)
* fix(dart): resolve package imports by pubspec identity

* fix(dart): keep package-identity edges out of the cycle check

Pubspec identity edges invalidate importers when a manifest changes. They cannot form an init cycle, so the cycle query excludes them before the row cap. Discovery reads each manifest once, with a size bound, and resolution shares one package-URI parser with those edges.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

- Reject package URIs with an empty library path so they do not emit identity edges
- Skip the pubspec permission test where chmod cannot deny reads
- Document that the Dart heap probe is not a uniqueTarget spelling

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): list package directories through a no-follow descriptor

A directory replaced by a symlink between the parent listing and the next visit must not be traversed. The walk opens it with O_DIRECTORY|O_NOFOLLOW and lists that inode.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(mcp): keep the Dart identity reason out of MCP startup

The cycle query still excludes the same reason string. The constant now lives with the other non-initializing import reasons, so MCP startup does not load a language provider.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3369)

Open discovered pubspecs and child directories through the parent directory inode on Linux, so replacing that directory with a symlink cannot redirect the walk.

Note: pre-existing failure in test/unit/incremental-index-extension-dml-gate.test.ts (worker pool startup timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject Windows junctions during pubspec walk

* Address PR review feedback (#3369)

Refuse pubspec discovery that cannot set O_NOFOLLOW, and verify macOS child opens against the pinned directory chain instead of reopening a mutable path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): bound live pubspec descriptors and close the macOS check-then-open

A deep directory chain held one descriptor per level until open failed with EMFILE, and macOS child opens statted the path before using it. Refuse the next directory at 64 live handles, and stat only the descriptor opened with O_NOFOLLOW.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): open pubspecs non-blocking so a FIFO cannot hang discovery

A listed pubspec can be replaced by a FIFO before open. O_RDONLY alone waits inside open for a writer, so the file-type check never runs. O_NONBLOCK returns immediately and the walk rejects the non-regular file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): cap names read from each pubspec directory

readdir kept every entry before the visit budget could run, so one huge directory could allocate without bound. Read the listing one name at a time and fail closed past 100,000 entries.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dart): reject a pubspec that grows while its descriptor is read

The size cap was taken from the stat before the read, so a file that grew in that window could be parsed from a short prefix. Re-stat the same descriptor afterward and fail closed when the size no longer matches the bytes captured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): rebaseline the Dart scope-capture fingerprint for package-import fixtures

The benchmark hashes every dart-* fixture. The new package-import corpus adds six Dart files and 33 capture groups. Parking that directory restores the previous fingerprint, so this is corpus growth, not a capture change.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
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>
2026-09-26 13:21:09 +01:00

192 lines
7.8 KiB
TypeScript

/**
* Streamed structural graph emit — config gate and pruner integration (#2680).
*
* The gate is a soundness boundary, not a preference: streaming is only valid
* on a full rebuild, because the incremental writeback reads relationships back
* out of the in-memory graph.
*
* The pruner cases are the sharp end of the feature. `pruneLocalValueSymbols`
* decides "is this block-local symbol referenced?" from an in-memory
* relationship scan; under streaming that scan cannot see edges already on
* disk, so without the predicate a referenced symbol is deleted and its
* streamed CSV row is left pointing at a node with no row.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { resolveStreamGraphEmit } from '../../src/core/run-analyze.js';
import { buildPhaseList } from '../../src/core/ingestion/pipeline.js';
import { RETAINED_REL_TYPES } from '../../src/core/lbug/graph-emit-sink.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import type { RelationshipType } from 'gitnexus-shared';
afterEach(() => {
vi.unstubAllEnvs();
});
describe('resolveStreamGraphEmit', () => {
it('is ON by default on a full rebuild — no opt-in needed', () => {
expect(resolveStreamGraphEmit({ force: true })).toBe(true);
});
it('is turned off by an explicit falsy env value (the escape hatch)', () => {
vi.stubEnv('GITNEXUS_STREAM_GRAPH_EMIT', '0');
expect(resolveStreamGraphEmit({ force: true })).toBe(false);
});
it('is turned off by an explicit option, which beats the env', () => {
vi.stubEnv('GITNEXUS_STREAM_GRAPH_EMIT', '1');
expect(resolveStreamGraphEmit({ force: true, streamGraphEmit: false })).toBe(false);
});
it('honors the explicit option on a full rebuild', () => {
expect(resolveStreamGraphEmit({ force: true, streamGraphEmit: true })).toBe(true);
});
it('honors the env toggle on a full rebuild', () => {
vi.stubEnv('GITNEXUS_STREAM_GRAPH_EMIT', '1');
expect(resolveStreamGraphEmit({ force: true })).toBe(true);
});
it('refuses an incremental run even when explicitly requested', () => {
// The incremental writeback reads relationships back out of the in-memory
// graph; streaming has already offloaded them.
expect(resolveStreamGraphEmit({ force: false, streamGraphEmit: true })).toBe(false);
expect(resolveStreamGraphEmit({ streamGraphEmit: true })).toBe(false);
});
it('refuses an incremental run even when the env toggle is set', () => {
vi.stubEnv('GITNEXUS_STREAM_GRAPH_EMIT', '1');
expect(resolveStreamGraphEmit({ force: false })).toBe(false);
});
});
const FILE_ID = 'File:src/a.ts';
const LOCAL_ID = 'Const:src/a.ts:localValue';
const localConst = (): GraphNode => ({
id: LOCAL_ID,
label: 'Const',
properties: { name: 'localValue', filePath: 'src/a.ts', scope: 'block' },
});
/** Graph holding only the structural File->DEFINES->localConst edge, i.e. the
* shape the pruner sees when the symbol's only *semantic* reference streamed
* out to CSV. */
const graphWithOnlyStructuralEdge = () => {
const graph = createKnowledgeGraph();
graph.addNode({
id: FILE_ID,
label: 'File',
properties: { name: 'a.ts', filePath: 'src/a.ts' },
});
graph.addNode(localConst());
graph.addRelationship({
id: `DEFINES:${FILE_ID}->${LOCAL_ID}`,
sourceId: FILE_ID,
targetId: LOCAL_ID,
type: 'DEFINES',
confidence: 1,
reason: 'structural',
});
return graph;
};
describe('buildPhaseList under streamGraphEmit', () => {
const names = (o: Parameters<typeof buildPhaseList>[0]) => buildPhaseList(o).map((p) => p.name);
it('keeps every CALLS-consuming phase enabled — nothing is traded away', () => {
// The sink answers a complete relationship read, so these phases work
// unchanged. If this ever regresses to filtering them out, streaming can no
// longer be the default.
const streamed = names({ streamGraphEmit: true, pdg: true, force: true });
expect(streamed).toContain('communities');
expect(streamed).toContain('processes');
expect(streamed).toContain('taintSummaries');
expect(streamed).toContain('callSummaries');
});
it('keeps mro and di, whose reads are all in the retained set', () => {
const streamed = names({ streamGraphEmit: true, pdg: true, force: true });
expect(streamed).toContain('mro');
expect(streamed).toContain('di');
expect(streamed).toContain('parse');
expect(streamed).toContain('scopeResolution');
expect(streamed).toContain('pruneLocalSymbols');
});
it('leaves the phase list untouched when the flag is off', () => {
// Guards the default path: the gating predicates must not filter anything
// for existing (flag-off) users.
const withPdg = names({ pdg: true, force: true });
expect(withPdg).toContain('communities');
expect(withPdg).toContain('processes');
expect(withPdg).toContain('taintSummaries');
expect(withPdg).toContain('callSummaries');
});
it('still honours skipGraphPhases independently of the streaming flag', () => {
const skipped = names({ skipGraphPhases: true });
expect(skipped).not.toContain('communities');
expect(skipped).not.toContain('processes');
expect(skipped).toContain('pruneLocalSymbols');
});
});
describe('RETAINED_REL_TYPES tracks its readers', () => {
it('streams Actuator relationship types that no later phase reads', () => {
expect(RETAINED_REL_TYPES.has('CONDITIONAL_ON')).toBe(false);
expect(RETAINED_REL_TYPES.has('DECLARES')).toBe(false);
});
it('retains every relationship type any phase reads back mid-pipeline', async () => {
// The round-trip test CANNOT catch drift here: addRelationship partitions
// edges between the graph and the CSVs, and a partition's union is
// invariant under where the line falls — so it stays green for any
// partitioning, including a wrong one. Nothing else guards the invariant,
// and getting it wrong yields a silently incomplete edge set mid-pipeline
// rather than a crash. So derive the required set from the source and
// compare.
const { execFileSync } = await import('node:child_process');
const srcDir = new URL('../../src/', import.meta.url).pathname;
// Every literal `iterRelationshipsByType('X')` reachable while streaming is
// armed. `git grep -h` over src/ excluding tests; the sink itself is
// excluded because its own fast-path check reads the constant, not an edge.
const out = execFileSync(
'grep',
['-rhoE', "iterRelationshipsByType\\('[A-Z_]+'\\)", '--include=*.ts', srcDir],
{ encoding: 'utf8' },
);
const readTypes = new Set(
[...out.matchAll(/iterRelationshipsByType\('([A-Z_]+)'\)/g)].map((m) => m[1]),
);
// CALLS is read by taintSummaries, which is exactly why the sink answers a
// COMPLETE read instead of retaining it — so it is a known exemption.
readTypes.delete('CALLS');
// Dart package invalidation reads IMPORTS endpoints through the sink's
// complete typed iterator. dart-package-dependencies.test.ts exercises
// transitive closure and idempotence with actual streamed IMPORTS rows.
readTypes.delete('IMPORTS');
const missing = [...readTypes].filter((t) => !RETAINED_REL_TYPES.has(t as RelationshipType));
expect(missing).toEqual([]);
});
});
describe('streamGraphEmit without a CSV dir', () => {
it('throws instead of silently running without streaming', async () => {
// Streaming is on by default, so a programmatic host that builds its own
// PipelineOptions and forgets the directory must not get a successful run
// that quietly did no streaming.
const { runPipelineFromRepo } = await import('../../src/core/ingestion/pipeline.js');
await expect(
runPipelineFromRepo('/nonexistent-repo', () => {}, { streamGraphEmit: true }),
).rejects.toThrow(/graphEmitCsvDir is missing/);
});
});