mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
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
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(analyze): --memory-budget flag with heap-limit override and worker-pool degradation (#3137) Adds an explicit `--memory-budget <mb>` CLI flag that overrides the RAM/cgroup auto-sized main-thread heap ceiling for the parse phase: - CLI validation (integer >= 200 MB) before bar.start(), matching the --workers pattern - Threaded CLI → runFullAnalysis → PipelineOptions → parse-impl as memoryBudgetBytes - parse-impl resolves the heap limit as budget ?? v8.heap_size_limit, so both the preflight projection warning and the #2649 mid-loop abort probe honor the budget - Graceful degradation: when the projected heap need exceeds the budget at the computed pool size, the pool shrinks (never below 1, never above the operator's --workers) before sub-batch math and pool construction, so all downstream consumers see the degraded size Omitting the flag keeps the auto-sizer path byte-identical. Refs #3137 * feat(analyze): collapse rebuild-gate log into one summary + persist needsFullRebuild verdict (#3137) The nine meta-mismatch rebuild gates (pdg mode, content retention, schema fingerprint, graph-write collapse, analysis features, Spring vendor prefixes, runner identity, FTS CJK mode, embedding dims) each logged individually and set force:true independently. An upgrade that trips several at once printed a scattered wall of near-identical warnings. - Gates now collect into rebuildReasons[]; a single summary block prints them (inline for one, numbered for many) and sets force once. Per-gate Tip text is preserved verbatim inside the entries. - The verdict persists to meta (needsFullRebuild: {reasons, recordedAt}) BEFORE the rebuild starts. If the rebuild is interrupted, the next run announces the recorded reasons up front instead of quietly attempting an incremental write on a half-rebuilt index — the gates may not all re-fire against a wiped DB. - The verdict is cleared on the next successful completion (the final meta does not carry the field forward). Semantics unchanged: every gate was already evaluated (none early-returns), force is idempotent, and a rebuild happens iff at least one reason fired. Refs #3137 * refactor(cli): share one integer flag parser across analyze, watch, and wiki Replace the duplicated Number.isInteger checks for --workers, --embeddings, the positive env-backed analyze flags, the watch interval flags, and wiki's --timeout/--retries with parseIntegerOption (per-flag minimum, optional scale for the safe-integer bound). User-facing messages are unchanged. * fix(analyze): make --memory-budget set the real V8 heap through the respawn The budget now drives ensureHeap's existing respawn instead of a parse-phase override, so the #2649 preflight, mid-loop abort, remedy text, and GC pacing all see one heap limit. The respawn sizes old space plus three semi-spaces to equal the budget, the child resolves as already at the budget (no second respawn), and GITNEXUS_HEAP_LIMIT_SOURCE drives budget-aware OOM advice. Budget validation moves to the preAction hook so analyze and watch reject a bad value before any respawn. Removes the pool-shrink block and the memoryBudgetBytes plumbing through PipelineOptions and run-analyze. * docs(analyze): describe --memory-budget accurately and translate its help The help text claimed graceful worker-pool degradation, which no longer exists; it now says the flag sets the main-thread V8 heap and that parse workers keep their own caps. Wires the option through the help i18n map with en and zh-CN strings, and documents it in both READMEs and the out-of-memory troubleshooting section. * feat(analyze): add a pure rebuild-reason collector One collector per run holds keyed rebuild reasons, merges by key, flattens reasons stored by an interrupted rebuild into one recovery entry, validates stored reasons on read, and formats the single up-front summary plus one follow-up line for reasons added after the pipeline. * fix(analyze): route every forced rebuild through one reason collector Every path that forces a full rebuild (the nine meta gates, --force, --skills, --no-parse-cache, --drop-embeddings, --repair-fts retention, Spring Actuator, AsyncAPI, shared-store graph gaps, dirty-flag recovery, the post-pipeline capability gate, and the #2409 escalation) now adds a keyed reason to one collector. The rebuild decision is applied from the collector at fixed checkpoints, one summary prints right before the pipeline, and late reasons print one follow-up line. The escalation stays non-forcing. runFullAnalysis returns the collected keys, which replaces the runner-identity source-regex test with a behavior test. Removes the separate needsFullRebuild field and its announcement, and stops folding --skills and --no-parse-cache into --force. * fix(analyze): persist rebuild reasons on the existing crash marker Every incrementalInProgress writer (the full-rebuild stamp before the wipe, the incremental pre-write, saveIncrementalDirtyState including the #2409 escalation, and buildFtsDirtyStamp) now carries the collected reasons into the active slot's metaDir, so an interrupted rebuild explains itself on the next run through one merged recovery entry. A successful run still clears the marker and its reasons; the FTS-park recovery clears them without forcing. * test(analyze): cover every rebuild-reason key through runFullAnalysis Add a coverage table that the typechecker keeps complete: every RebuildReasonKey maps to a test file that drives it through runFullAnalysis and asserts the returned key. Adds the missing graph-write-collapse and drop-embeddings drivers, asserts the key in the existing pdg-mode, spring-vendor-prefixes, cjk-segmentation, and embedding-dims tests, and removes plan-local IDs from test names and comments. * fix(review): apply review findings - A --max-old-space-size pin equal to --memory-budget no longer counts as the exact budget heap (V8 adds the young generation on top); only the budget-respawned child skips the respawn, so the limit really equals the budget. - Snapshot the analyze env before ensureHeap and restore GITNEXUS_HEAP_LIMIT_SOURCE, so a kept process does not leak its heap source into a later programmatic analyzeCommand call. - --skills and --no-parse-cache keep the forced storage requirements they had before force stopped being folded from them. - Merge the duplicated follow-up announcement into one helper and fix a stale --drop-embeddings comment. - The rebuild-reason coverage table no longer greps driver files for the key string; add tests for a programmatic invalid budget and the multi-cause interrupted-rebuild text. * fix(review): don't announce the escalated write as a full rebuild The #2409 escalation is a non-forcing reason, but its follow-up line used the 'Full rebuild also required' lead. A follow-up that carries only non-forcing reasons now leads with 'Write plan changed'. * docs(analyze): document GITNEXUS_HEAP_LIMIT_SOURCE in the env table CONTRIBUTING requires every new GITNEXUS_* variable to have a row; this one is internal (set by analyze itself) and exists so OOM advice points at --memory-budget. * fix(review): address GitNexus review threads on #3386 - heapPressureRemedy measures pressure against the real auto-sized cap (heapCapMbFor) instead of a flat 0.75 x RAM, and no longer tells a GITNEXUS_MEMORY=off run with no pin to drop a pin that does not exist. - toStored() persists the interrupted rebuild's reasons first, as documented. - ensureHeap's doc names which paths leave GITNEXUS_HEAP_LIMIT_SOURCE unset. - The heap-respawn suite restores the caller's GITNEXUS_MEMORY. - The non-forcing follow-up test rejects any 'full rebuild' wording. * fix(review): require both budget flags and check key coverage at runtime - A budget-respawned child is recognized only when the inherited heap-source marker comes with both the budget's old-space and semi-space flags; the marker alone is an inherited env var, not proof. The old-space parser is generalized to any V8 size flag instead of copying its regex. - REBUILD_REASON_KEYS is exported and RebuildReasonKey derives from it, so the coverage table is checked at runtime (CI does not type-check test files). * fix(review): don't claim a full rebuild in a non-forcing summary formatSummary and formatFollowUp now share one leadFor helper, so a block of only non-forcing reasons reads 'Write plan changed' in both. --------- Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com> Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
2317 lines
98 KiB
TypeScript
2317 lines
98 KiB
TypeScript
/**
|
|
* Integration coverage for the `runFullAnalysis` incremental-orchestration
|
|
* wiring (Claude PR-review Finding 2).
|
|
*
|
|
* These tests exercise the *real runtime path* — they call
|
|
* `runFullAnalysis` against a real on-disk git repo backed by a real
|
|
* LadybugDB at `<repo>/.gitnexus/`, and assert behaviours that pure
|
|
* unit tests on `diffFileHashes` / `extractChangedSubgraph` cannot
|
|
* catch:
|
|
*
|
|
* - the `isIncremental` decision (post-pipeline eligibility check)
|
|
* - `incrementalInProgress` dirty-flag set-before-mutation and
|
|
* clear-on-success
|
|
* - the importer-closure expansion (1-hop reached via the writable
|
|
* set, transitive reachable via bounded BFS)
|
|
* - the "forced full rebuild on dirty-flag-from-prior-crash" path
|
|
*
|
|
* Each test creates a temporary git repo, runs the analyzer, and asserts
|
|
* on the resulting `meta.json` and graph state. Cleanup is best-effort
|
|
* (Windows LadybugDB handle release can lag; `cleanupTempDir` retries).
|
|
*/
|
|
|
|
import { execSync } from 'child_process';
|
|
import { writeFile, readFile, mkdir, rm } from 'fs/promises';
|
|
import path from 'path';
|
|
import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
getStoragePaths,
|
|
saveMeta,
|
|
loadMeta,
|
|
type RepoMeta,
|
|
} from '../../src/storage/repo-manager.js';
|
|
import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js';
|
|
import { createTempDir } from '../helpers/test-db.js';
|
|
// Shared embedding-seed trio (this shipping review, FIX 8) — the KTD9
|
|
// zero-vector seeding pattern lives in one helper module now instead of two
|
|
// divergent copies here and in incremental-dirty-recovery.test.ts.
|
|
import {
|
|
readEmbeddingNodeIds,
|
|
seedEmbeddingForNodeId,
|
|
seedEmbeddingsForFiles,
|
|
stampEmbeddingCount,
|
|
} from '../helpers/embedding-seed.js';
|
|
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js';
|
|
import { RebuildReasonCollector } from '../../src/core/rebuild-reasons.js';
|
|
import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js';
|
|
import {
|
|
isLanguageAvailable,
|
|
loadParser,
|
|
loadLanguage,
|
|
} from '../../src/core/tree-sitter/parser-loader.js';
|
|
import { SupportedLanguages } from '../../src/config/supported-languages.js';
|
|
import { DART_PACKAGE_IDENTITY_REASON } from '../../src/core/ingestion/languages/dart/package-dependencies.js';
|
|
import {
|
|
SPRING_AOP_FEATURE,
|
|
SPRING_BEAN_INVENTORY_FEATURE,
|
|
SPRING_CONDITIONALS_FEATURE,
|
|
SPRING_NON_HTTP_HANDLERS_FEATURE,
|
|
SPRING_ROUTE_BINDINGS_FEATURE,
|
|
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
|
import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js';
|
|
import {
|
|
decodeSpringAopReason,
|
|
SPRING_AOP_EVIDENCE_ID_PREFIX,
|
|
} from '../../src/core/ingestion/frameworks/spring/aop.js';
|
|
import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js';
|
|
import {
|
|
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
|
SPRING_CONFIG_BINDINGS_FEATURE,
|
|
} from '../../src/core/ingestion/languages/java/analysis-features.js';
|
|
|
|
const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-');
|
|
|
|
/** Stage + commit everything in the temp repo (mirrors mini-repo.ts's git calls). */
|
|
const gitCommitAll = (cwd: string, message: string): void => {
|
|
execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', {
|
|
cwd,
|
|
stdio: 'pipe',
|
|
});
|
|
execSync(
|
|
`git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`,
|
|
{ cwd, stdio: 'pipe' },
|
|
);
|
|
};
|
|
|
|
const SPRING_SERVICE = 'org.springframework.stereotype.Service';
|
|
|
|
/**
|
|
* The collector's own output shapes, taken from the real formatter rather than
|
|
* re-typed: a single-reason summary, a numbered summary's header for `count`
|
|
* reasons, and the post-pipeline follow-up line. A reason with empty text
|
|
* reduces each to its fixed part.
|
|
*/
|
|
const rebuildLineShapes = (count: number) => {
|
|
const single = new RebuildReasonCollector();
|
|
single.add({ key: 'user-force', text: '' });
|
|
const numbered = new RebuildReasonCollector();
|
|
const keys = ['user-force', 'skills', 'parse-cache-bypass', 'drop-embeddings'] as const;
|
|
for (const key of keys.slice(0, count)) numbered.add({ key, text: '' });
|
|
const late = new RebuildReasonCollector();
|
|
late.formatSummary();
|
|
late.add({ key: 'user-force', text: '' });
|
|
const lateNonForcing = new RebuildReasonCollector();
|
|
lateNonForcing.formatSummary();
|
|
lateNonForcing.add({ key: 'escalated-full-write', text: '', forcing: false });
|
|
return {
|
|
singleSummary: single.formatSummary() ?? '',
|
|
numberedHeader: (numbered.formatSummary() ?? '').split('\n')[0],
|
|
followUp: late.formatFollowUp() ?? '',
|
|
nonForcingFollowUp: lateNonForcing.formatFollowUp() ?? '',
|
|
};
|
|
};
|
|
|
|
function withoutAnalysisFeature(meta: RepoMeta, featureId: string): RepoMeta {
|
|
return {
|
|
...meta,
|
|
analysisFeatures: Object.fromEntries(
|
|
Object.entries(meta.analysisFeatures ?? {}).filter(([id]) => id !== featureId),
|
|
),
|
|
};
|
|
}
|
|
|
|
async function setupSpringBeanIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-spring-bean-');
|
|
const src = path.join(repo.dbPath, 'src', 'com', 'other');
|
|
await mkdir(src, { recursive: true });
|
|
await writeFile(
|
|
path.join(src, 'WildcardService.java'),
|
|
'package com.other;\n' +
|
|
'import org.springframework.stereotype.*;\n\n' +
|
|
'@Service public class WildcardService {}\n',
|
|
'utf-8',
|
|
);
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial spring bean candidate');
|
|
return repo;
|
|
}
|
|
|
|
async function setupJavaEnumHeritageIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-java-enum-heritage-');
|
|
const src = path.join(repo.dbPath, 'src');
|
|
await mkdir(src, { recursive: true });
|
|
await writeFile(
|
|
path.join(src, 'Status.java'),
|
|
'interface Named { String label(); }\n' +
|
|
'enum Status implements Named {\n' +
|
|
' ACTIVE;\n' +
|
|
' public String label() { return "active"; }\n' +
|
|
'}\n',
|
|
'utf-8',
|
|
);
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial Java enum heritage');
|
|
return repo;
|
|
}
|
|
|
|
async function setupKotlinSpringBeanIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-spring-bean-kotlin-');
|
|
const src = path.join(repo.dbPath, 'src', 'com', 'other');
|
|
await mkdir(src, { recursive: true });
|
|
await writeFile(
|
|
path.join(src, 'WildcardService.kt'),
|
|
'package com.other\n' +
|
|
'import org.springframework.stereotype.*\n\n' +
|
|
'@Service class WildcardService\n',
|
|
'utf-8',
|
|
);
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial Kotlin spring bean candidate');
|
|
return repo;
|
|
}
|
|
|
|
const springAopAspectSource = (pointcut: string): string =>
|
|
`package com.example;\n` +
|
|
`import org.aspectj.lang.annotation.Aspect;\n` +
|
|
`import org.aspectj.lang.annotation.Before;\n\n` +
|
|
`@Aspect public class TraceAspect {\n` +
|
|
` @Before("${pointcut}") public void trace() {}\n` +
|
|
`}\n`;
|
|
|
|
async function setupSpringAopIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-spring-aop-');
|
|
const javaSrc = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example');
|
|
const kotlinSrc = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example');
|
|
const resources = path.join(repo.dbPath, 'src', 'main', 'resources');
|
|
await Promise.all([
|
|
mkdir(javaSrc, { recursive: true }),
|
|
mkdir(kotlinSrc, { recursive: true }),
|
|
mkdir(resources, { recursive: true }),
|
|
]);
|
|
await Promise.all([
|
|
writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n', 'utf-8'),
|
|
writeFile(
|
|
path.join(javaSrc, 'FirstService.java'),
|
|
'package com.example;\n\n' +
|
|
'public class FirstService {\n' +
|
|
' public void first() {}\n' +
|
|
'}\n',
|
|
'utf-8',
|
|
),
|
|
writeFile(
|
|
path.join(javaSrc, 'TraceAspect.java'),
|
|
springAopAspectSource('within(com.example.FirstService)'),
|
|
'utf-8',
|
|
),
|
|
writeFile(
|
|
path.join(kotlinSrc, 'KotlinService.kt'),
|
|
'package com.example\n\n' +
|
|
'import org.springframework.transaction.annotation.Transactional as Tx\n\n' +
|
|
'class KotlinService {\n' +
|
|
' @Tx fun kotlinTx() {}\n' +
|
|
'}\n',
|
|
'utf-8',
|
|
),
|
|
writeFile(path.join(resources, 'application.properties'), 'feature.enabled=true\n', 'utf-8'),
|
|
]);
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial spring aop fixture');
|
|
return repo;
|
|
}
|
|
|
|
async function setupSpringBeanFactoryIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-spring-bean-factory-');
|
|
const src = path.join(repo.dbPath, 'src', 'com', 'other');
|
|
await mkdir(src, { recursive: true });
|
|
await writeFile(
|
|
path.join(src, 'WildcardConfiguration.java'),
|
|
'package com.other;\n' +
|
|
'import org.springframework.context.annotation.*;\n\n' +
|
|
'@Configuration class WildcardConfiguration {\n' +
|
|
' @Bean Gateway gateway() { return new DefaultGateway(); }\n' +
|
|
'}\n' +
|
|
'interface Gateway {}\n' +
|
|
'class DefaultGateway implements Gateway {}\n',
|
|
'utf-8',
|
|
);
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial spring bean factory');
|
|
return repo;
|
|
}
|
|
|
|
async function setupSpringConfigIncrementalRepo() {
|
|
const repo = await createTempDir('gitnexus-incr-spring-config-');
|
|
const resources = path.join(repo.dbPath, 'src', 'main', 'resources');
|
|
await mkdir(resources, { recursive: true });
|
|
await writeFile(path.join(resources, 'application.properties'), 'service.timeout=30\n', 'utf-8');
|
|
execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'initial spring configuration');
|
|
return repo;
|
|
}
|
|
|
|
async function setupKotlinSpringConfigConsumerIncrementalRepo() {
|
|
const repo = await setupSpringConfigIncrementalRepo();
|
|
const kotlin = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example');
|
|
await mkdir(kotlin, { recursive: true });
|
|
await writeFile(
|
|
path.join(kotlin, 'ConfigConsumer.kt'),
|
|
'package com.example\n' +
|
|
'import org.springframework.beans.factory.annotation.Value\n\n' +
|
|
'class ConfigConsumer {\n' +
|
|
' @Value("\\${service.timeout}")\n' +
|
|
' var timeout: Int = 0\n' +
|
|
'}\n',
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add Kotlin Spring config consumer');
|
|
return repo;
|
|
}
|
|
|
|
async function readWildcardServiceAnnotations(repoPath: string): Promise<string[]> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
"MATCH (c:Class) WHERE c.name = 'WildcardService' " +
|
|
'RETURN c.frameworkAnnotations AS frameworkAnnotations LIMIT 1',
|
|
)) as Array<{ frameworkAnnotations?: unknown }>;
|
|
const value = rows[0]?.frameworkAnnotations;
|
|
return Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === 'string')
|
|
: [];
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function readSpringConfigPropertyNames(repoPath: string): Promise<string[]> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
"MATCH (p:Property) WHERE p.filePath = 'src/main/resources/application.properties' " +
|
|
'RETURN p.name AS name ORDER BY p.name',
|
|
)) as Array<{ name?: unknown }>;
|
|
return rows.map((row) => String(row.name));
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function readKotlinConfigConsumerState(repoPath: string): Promise<{
|
|
description: string;
|
|
bindingCount: number;
|
|
}> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
"MATCH (p:Property) WHERE p.name = 'timeout' " +
|
|
'RETURN p.description AS description LIMIT 1',
|
|
)) as Array<{ description?: unknown }>;
|
|
const bindings = (await adapter.executeQuery(
|
|
"MATCH (p:Property {name: 'timeout'})-[r:CodeRelation]->(c:Property) " +
|
|
"WHERE r.type = 'USES' AND r.reason STARTS WITH 'spring-config:' " +
|
|
'RETURN count(r) AS count',
|
|
)) as Array<{ count?: number | bigint }>;
|
|
return {
|
|
description: String(rows[0]?.description ?? ''),
|
|
bindingCount: Number(bindings[0]?.count ?? 0),
|
|
};
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function readActuatorSnapshotLeakRows(
|
|
repoPath: string,
|
|
snapshotPath: string,
|
|
secretValue: string,
|
|
): Promise<Array<{ filePath?: unknown; content?: unknown }>> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH (f:File) RETURN f.filePath AS filePath, f.content AS content`,
|
|
)) as Array<{ filePath?: unknown; content?: unknown }>;
|
|
return rows.filter(
|
|
(row) =>
|
|
row.filePath === snapshotPath ||
|
|
(typeof row.content === 'string' && row.content.includes(secretValue)),
|
|
);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function readRuntimePropertyEvidence(repoPath: string): Promise<{
|
|
description: string;
|
|
reasons: string[];
|
|
}> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const propertyRows = (await adapter.executeQuery(
|
|
`MATCH (p:Property) WHERE p.name = 'runtime.secret' ` +
|
|
`RETURN p.description AS description LIMIT 1`,
|
|
)) as Array<{ description?: unknown }>;
|
|
const relationshipRows = (await adapter.executeQuery(
|
|
`MATCH (:File)-[r:CodeRelation]->(p:Property) WHERE p.name = 'runtime.secret' ` +
|
|
`AND r.type = 'DECLARES' RETURN r.reason AS reason ORDER BY reason`,
|
|
)) as Array<{ reason?: unknown }>;
|
|
return {
|
|
description: String(propertyRows[0]?.description ?? ''),
|
|
reasons: relationshipRows.map((row) => String(row.reason ?? '')),
|
|
};
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function countStatusImplementsNamed(repoPath: string): Promise<number> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
"MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " +
|
|
"WHERE r.type = 'IMPLEMENTS' RETURN count(r) AS c",
|
|
)) as Array<{ c: number | bigint }>;
|
|
return Number(rows[0]?.c ?? 0);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function deleteStatusImplementsNamed(repoPath: string): Promise<void> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
await adapter.executeQuery(
|
|
"MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " +
|
|
"WHERE r.type = 'IMPLEMENTS' DELETE r",
|
|
);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's
|
|
* countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes
|
|
* the singleton on completion, so each count owns its own open/close).
|
|
*/
|
|
async function countInjects(repoPath: string): Promise<number> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS c`,
|
|
)) as Array<{ c: number | bigint }>;
|
|
return Number(rows[0]?.c ?? 0);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function countSpringAutoConfigurationDeclarations(repoPath: string): Promise<number> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` +
|
|
`AND (r.reason = 'spring-auto-configuration-import' ` +
|
|
`OR r.reason = 'spring-auto-configuration-factory') RETURN count(r) AS c`,
|
|
)) as Array<{ c: number | bigint }>;
|
|
return Number(rows[0]?.c ?? 0);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function countSpringBeanFactoryDeclarations(repoPath: string): Promise<number> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` +
|
|
`AND r.reason STARTS WITH 'spring-bean-factory:' RETURN count(r) AS c`,
|
|
)) as Array<{ c: number | bigint }>;
|
|
return Number(rows[0]?.c ?? 0);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): Promise<number> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH (n:Class) WHERE n.id STARTS WITH ` +
|
|
`'${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` +
|
|
`RETURN count(n) AS c`,
|
|
)) as Array<{ c: number | bigint }>;
|
|
return Number(rows[0]?.c ?? 0);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
interface SpringAopPersistedRelationship {
|
|
readonly relType: string;
|
|
readonly sourceId: string;
|
|
readonly sourceName: string;
|
|
readonly targetId: string;
|
|
readonly targetName: string;
|
|
readonly reason: string;
|
|
}
|
|
|
|
interface SpringAopPersistedEvidence {
|
|
readonly id: string;
|
|
readonly description: string;
|
|
}
|
|
|
|
interface SpringAopPersistedSnapshot {
|
|
readonly relationships: readonly SpringAopPersistedRelationship[];
|
|
readonly evidence: readonly SpringAopPersistedEvidence[];
|
|
}
|
|
|
|
async function readSpringAopSnapshot(repoPath: string): Promise<SpringAopPersistedSnapshot> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const relationships = (await adapter.executeQuery(
|
|
`MATCH (s)-[r:CodeRelation]->(t) ` +
|
|
`WHERE (r.type = 'ADVISED_BY' OR r.type = 'DECLARES') ` +
|
|
`AND r.reason STARTS WITH 'spring-aop:v1:' ` +
|
|
`RETURN r.type AS relType, s.id AS sourceId, s.name AS sourceName, ` +
|
|
`t.id AS targetId, t.name AS targetName, r.reason AS reason ` +
|
|
`ORDER BY relType, sourceId, targetId, reason`,
|
|
)) as SpringAopPersistedRelationship[];
|
|
const evidence = (await adapter.executeQuery(
|
|
`MATCH (n:CodeElement) WHERE n.id STARTS WITH '${SPRING_AOP_EVIDENCE_ID_PREFIX}' ` +
|
|
`RETURN n.id AS id, n.description AS description ORDER BY id`,
|
|
)) as SpringAopPersistedEvidence[];
|
|
return { relationships, evidence };
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
function assertSpringAopSnapshotShape(
|
|
snapshot: SpringAopPersistedSnapshot,
|
|
expectedAdviceSource: string,
|
|
): void {
|
|
expect(snapshot.relationships).toHaveLength(4);
|
|
expect(snapshot.evidence).toHaveLength(3);
|
|
const decoded = snapshot.relationships.map((relationship) => ({
|
|
relationship,
|
|
reason: decodeSpringAopReason(relationship.reason),
|
|
}));
|
|
expect(decoded.map(({ reason }) => reason?.kind).sort()).toEqual([
|
|
'advice',
|
|
'aspect',
|
|
'behavior',
|
|
'pointcut',
|
|
]);
|
|
expect(decoded.find(({ reason }) => reason?.kind === 'behavior')?.relationship.sourceName).toBe(
|
|
'kotlinTx',
|
|
);
|
|
const advice = decoded.find(({ reason }) => reason?.kind === 'advice')?.relationship;
|
|
expect(advice?.sourceName).toBe(expectedAdviceSource);
|
|
expect(advice?.targetName).toBe('trace');
|
|
expect(
|
|
new Set(
|
|
snapshot.relationships.map(
|
|
(relationship) =>
|
|
`${relationship.relType}\0${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`,
|
|
),
|
|
).size,
|
|
).toBe(snapshot.relationships.length);
|
|
expect(new Set(snapshot.evidence.map(({ id }) => id)).size).toBe(snapshot.evidence.length);
|
|
}
|
|
|
|
/** Java DI fixture (#2200): `@Autowired List<IFoo>` + 2 implementers ⇒ exactly
|
|
* 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the
|
|
* spring-di-pipeline integration fixture. */
|
|
const JAVA_DI_FIXTURE: ReadonlyArray<readonly [string, string]> = [
|
|
['IFoo.java', 'package com.example;\n\npublic interface IFoo {}\n'],
|
|
['FooA.java', 'package com.example;\n\npublic class FooA implements IFoo {}\n'],
|
|
['FooB.java', 'package com.example;\n\npublic class FooB implements IFoo {}\n'],
|
|
[
|
|
'Consumer.java',
|
|
'package com.example;\n' +
|
|
'import java.util.List;\n' +
|
|
'import org.springframework.beans.factory.annotation.Autowired;\n' +
|
|
'\n' +
|
|
'public class Consumer {\n' +
|
|
' @Autowired private List<IFoo> foos;\n' +
|
|
'}\n',
|
|
],
|
|
];
|
|
|
|
describe('runFullAnalysis — incremental orchestration', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it('first run populates fileHashes + schemaVersion and clears incrementalInProgress on success', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta).not.toBeNull();
|
|
expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT);
|
|
expect(meta!.fileHashes).toBeDefined();
|
|
expect(Object.keys(meta!.fileHashes ?? {}).length).toBeGreaterThan(0);
|
|
expect(meta!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
});
|
|
// Dirty flag MUST be cleared after a successful run.
|
|
expect(meta!.incrementalInProgress).toBeUndefined();
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 180_000);
|
|
|
|
it('second run on unchanged state takes the alreadyUpToDate fast path', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
const first = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(first.alreadyUpToDate).toBeUndefined();
|
|
|
|
const second = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
// lastCommit==HEAD && working tree clean (mod GitNexus output) →
|
|
// early-return fast path.
|
|
expect(second.alreadyUpToDate).toBe(true);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('useParseCache:false bypasses the alreadyUpToDate fast path without --force', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
const logs: string[] = [];
|
|
const cold = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, useParseCache: false },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(cold.alreadyUpToDate).toBeUndefined();
|
|
expect(cold.pipelineResult?.parseCacheHitFileCount ?? 0).toBe(0);
|
|
expect(cold.pipelineResult?.reparsedFileCount).toBe(7);
|
|
// Its own reason, not a borrowed --force.
|
|
expect(cold.rebuildReasons).toEqual(['parse-cache-bypass']);
|
|
const { singleSummary } = rebuildLineShapes(1);
|
|
expect(logs.filter((m) => m.startsWith(singleSummary))).toHaveLength(1);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => {
|
|
const repo = await setupMiniRepo();
|
|
const runtimeInput = 'runtime-actuator';
|
|
const runtimeInputDir = path.join(repo.dbPath, runtimeInput);
|
|
const secretValue = 'ACTUATOR_META_SECRET_2418';
|
|
try {
|
|
await mkdir(runtimeInputDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(runtimeInputDir, 'env.json'),
|
|
JSON.stringify({
|
|
propertySources: [
|
|
{
|
|
name: 'systemEnvironment',
|
|
properties: { 'runtime.secret': { value: secretValue, origin: 'env' } },
|
|
},
|
|
],
|
|
}),
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add actuator runtime snapshot');
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
const enabled = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, springActuatorPath: runtimeInput },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(enabled.alreadyUpToDate).toBeUndefined();
|
|
expect(enabled.rebuildReasons).toEqual(['spring-actuator']);
|
|
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const enabledMeta = await loadMeta(storagePath);
|
|
if (enabledMeta === null)
|
|
throw new Error('Expected Actuator metadata after enabled analysis');
|
|
expect(enabledMeta.springActuator).toEqual({
|
|
enabled: true,
|
|
repoRelativeInputs: [runtimeInput],
|
|
});
|
|
expect(JSON.stringify(enabledMeta)).not.toContain(secretValue);
|
|
expect(Object.keys(enabledMeta.fileHashes ?? {})).not.toContain(`${runtimeInput}/env.json`);
|
|
expect(await readRuntimePropertyEvidence(repo.dbPath)).toEqual({
|
|
description: expect.stringContaining('Spring Actuator env runtime-confirmed'),
|
|
reasons: ['spring-actuator:env:runtime-confirmed'],
|
|
});
|
|
expect(
|
|
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
|
|
).toEqual([]);
|
|
|
|
await saveMeta(storagePath, {
|
|
...enabledMeta,
|
|
springActuator: {
|
|
enabled: true,
|
|
repoRelativeInputs: [runtimeInput, 42],
|
|
},
|
|
} as RepoMeta);
|
|
await expect(
|
|
runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }),
|
|
).rejects.toThrow('Cannot safely disable Spring Actuator runtime enrichment');
|
|
await saveMeta(storagePath, enabledMeta);
|
|
|
|
const disableLogs: string[] = [];
|
|
const disabled = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => disableLogs.push(message) },
|
|
);
|
|
expect(disabled.alreadyUpToDate).toBeUndefined();
|
|
expect(disabled.rebuildReasons).toEqual(['spring-actuator']);
|
|
expect(
|
|
disableLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)),
|
|
).toHaveLength(1);
|
|
expect((await loadMeta(storagePath))?.springActuator).toEqual({
|
|
enabled: false,
|
|
repoRelativeInputs: [runtimeInput],
|
|
});
|
|
expect(
|
|
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
|
|
).toEqual([]);
|
|
|
|
const steady = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(steady.alreadyUpToDate).toBe(true);
|
|
|
|
const forceLogs: string[] = [];
|
|
const forcedSteady = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, force: true },
|
|
{ onProgress: () => {}, onLog: (message) => forceLogs.push(message) },
|
|
);
|
|
expect(forcedSteady.alreadyUpToDate).toBeUndefined();
|
|
expect(forcedSteady.rebuildReasons).toEqual(['user-force']);
|
|
expect(forceLogs.join('\n')).toContain(
|
|
'Rebuilt the graph and FTS while reusing cached parser output',
|
|
);
|
|
expect(forceLogs.join('\n')).toContain('increment SCHEMA_BUMP');
|
|
expect(
|
|
await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue),
|
|
).toEqual([]);
|
|
expect((await loadMeta(storagePath))?.springActuator).toEqual({
|
|
enabled: false,
|
|
repoRelativeInputs: [runtimeInput],
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('a same-commit v8 index missing the global Class capability rebuilds before the fast path', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT);
|
|
|
|
await saveMeta(
|
|
storagePath,
|
|
withoutAnalysisFeature(meta!, CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id),
|
|
);
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(logs.join('\n')).toContain(`missing:${CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id}`);
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('a Java index missing enum heritage evidence rebuilds before the fast path (#2918)', async () => {
|
|
const repo = await setupJavaEnumHeritageIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta!.analysisFeatures).toMatchObject({
|
|
[JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version,
|
|
});
|
|
expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1);
|
|
|
|
await deleteStatusImplementsNamed(repo.dbPath);
|
|
expect(await countStatusImplementsNamed(repo.dbPath)).toBe(0);
|
|
|
|
await saveMeta(
|
|
storagePath,
|
|
withoutAnalysisFeature(meta!, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id),
|
|
);
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(logs.join('\n')).toContain(`missing:${JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id}`);
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toMatchObject({
|
|
[JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version,
|
|
});
|
|
expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('a same-commit index with NO fingerprint (pre-#2798) rebuilds once, not grandfathered', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
|
|
// Every index built before the field existed. Grandfathering absence
|
|
// would stamp a fresh fingerprint onto a database whose DDL was never
|
|
// verified — permanently certifying the very index this guard catches.
|
|
await saveMeta(storagePath, { ...meta!, schemaFingerprint: undefined });
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
// An absent stamp is unattributable — this build cannot tell a pre-#2798
|
|
// index from a hand-cleared one — so the notice names no version.
|
|
expect(logs.join('\n')).toContain(
|
|
'index schema changed (built by an unidentified GitNexus build,',
|
|
);
|
|
// The extra "non-git repositories never record a schema fingerprint"
|
|
// sentence is conditional on the repo having no git dir. setupMiniRepo
|
|
// builds a real git repo, so appending it here would be a false
|
|
// explanation for an absence this build is genuinely responsible for.
|
|
expect(logs.join('\n')).not.toContain('Non-git repositories never record');
|
|
// One-time: the rebuild restamps it, so the next run is eligible again.
|
|
expect(await loadMeta(storagePath)).toMatchObject({
|
|
schemaFingerprint: SCHEMA_FINGERPRINT,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('a JVM index missing Bean inventory evidence rebuilds and restores the scoped stamp', async () => {
|
|
const repo = await setupKotlinSpringBeanIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
|
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
|
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
|
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
|
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
|
});
|
|
|
|
await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id));
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(logs.join('\n')).toContain(`missing:${SPRING_BEAN_INVENTORY_FEATURE.id}`);
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
|
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
|
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
|
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
|
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('rebuilds a JVM index when the registered Spring vendor prefixes change', async () => {
|
|
const repo = await setupSpringBeanIncrementalRepo();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey());
|
|
|
|
vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win');
|
|
const logs: string[] = [];
|
|
const rebuilt = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(rebuilt.alreadyUpToDate).toBeUndefined();
|
|
expect(rebuilt.rebuildReasons).toContain('spring-vendor-prefixes');
|
|
expect(logs.join('\n')).toContain('Spring vendor mapping prefixes changed');
|
|
expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey());
|
|
|
|
const steady = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(steady.alreadyUpToDate).toBe(true);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('a config-only index missing Spring config evidence rebuilds and restores the scoped stamp', async () => {
|
|
const repo = await setupSpringConfigIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
|
});
|
|
|
|
await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_CONFIG_BINDINGS_FEATURE.id));
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(logs.join('\n')).toContain(`missing:${SPRING_CONFIG_BINDINGS_FEATURE.id}`);
|
|
expect(await readSpringConfigPropertyNames(repo.dbPath)).toEqual(['service.timeout']);
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('persists Kotlin unresolved markers when a Spring config key is deleted incrementally', async () => {
|
|
const repo = await setupKotlinSpringConfigConsumerIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({
|
|
description: '',
|
|
bindingCount: 1,
|
|
});
|
|
|
|
const configPath = path.join(
|
|
repo.dbPath,
|
|
'src',
|
|
'main',
|
|
'resources',
|
|
'application.properties',
|
|
);
|
|
await writeFile(configPath, '', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'delete Spring config key');
|
|
|
|
const logs: string[] = [];
|
|
await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(logs.join('\n')).toContain('Spring config consumer property drift');
|
|
expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({
|
|
description: 'Spring config unresolved: service.timeout',
|
|
bindingCount: 0,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('adding the first JVM file re-evaluates capabilities after the pipeline and avoids a top-up', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
});
|
|
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'src', 'FirstBean.kt'),
|
|
'import org.springframework.stereotype.Service\n\n@Service class FirstBean\n',
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add first JVM source file');
|
|
|
|
const logs: string[] = [];
|
|
const result = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
// Found only after the pipeline: no summary, exactly one follow-up line.
|
|
expect(result.rebuildReasons).toEqual(['analysis-features']);
|
|
const { singleSummary, followUp } = rebuildLineShapes(1);
|
|
const followUps = logs.filter((m) => m.startsWith(followUp));
|
|
expect(followUps).toHaveLength(1);
|
|
expect(followUps[0]).toContain(`missing:${SPRING_BEAN_INVENTORY_FEATURE.id}`);
|
|
expect(logs.filter((m) => m.startsWith(singleSummary))).toEqual([]);
|
|
expect(logs.join('\n')).not.toContain('Incremental:');
|
|
expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({
|
|
[CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version,
|
|
[SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version,
|
|
[SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version,
|
|
[SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version,
|
|
[SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version,
|
|
[SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version,
|
|
[SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version,
|
|
});
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('replaces Spring AOP evidence across real incremental runs without duplicates', async () => {
|
|
const repo = await setupSpringAopIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const firstSnapshot = await readSpringAopSnapshot(repo.dbPath);
|
|
assertSpringAopSnapshotShape(firstSnapshot, 'first');
|
|
const firstPointcutEvidenceId = firstSnapshot.relationships.find(
|
|
({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut',
|
|
)?.targetId;
|
|
expect(firstPointcutEvidenceId).toBeDefined();
|
|
|
|
const aspectPath = path.join(
|
|
repo.dbPath,
|
|
'src',
|
|
'main',
|
|
'java',
|
|
'com',
|
|
'example',
|
|
'TraceAspect.java',
|
|
);
|
|
await writeFile(
|
|
aspectPath,
|
|
springAopAspectSource(
|
|
'@annotation(org.springframework.transaction.annotation.Transactional)',
|
|
),
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'retarget spring advice');
|
|
const retargetLogs: string[] = [];
|
|
const retargeted = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => retargetLogs.push(message) },
|
|
);
|
|
expect(retargeted.alreadyUpToDate).toBeUndefined();
|
|
expect(retargetLogs.join('\n')).toContain('Incremental: changed=1');
|
|
expect(retargetLogs.join('\n')).not.toContain('switching to a full DB write');
|
|
|
|
const secondSnapshot = await readSpringAopSnapshot(repo.dbPath);
|
|
assertSpringAopSnapshotShape(secondSnapshot, 'kotlinTx');
|
|
const secondPointcutEvidenceId = secondSnapshot.relationships.find(
|
|
({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut',
|
|
)?.targetId;
|
|
expect(secondPointcutEvidenceId).toBeDefined();
|
|
expect(secondPointcutEvidenceId).not.toBe(firstPointcutEvidenceId);
|
|
expect(secondSnapshot.evidence.map(({ id }) => id)).not.toContain(firstPointcutEvidenceId);
|
|
|
|
const propertyPath = path.join(
|
|
repo.dbPath,
|
|
'src',
|
|
'main',
|
|
'resources',
|
|
'application.properties',
|
|
);
|
|
await writeFile(propertyPath, 'feature.enabled=false\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'change unrelated resource');
|
|
const replayLogs: string[] = [];
|
|
const replayed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{
|
|
onLog: (message) => replayLogs.push(message),
|
|
onProgress: () => {},
|
|
},
|
|
);
|
|
expect(replayed.alreadyUpToDate).toBeUndefined();
|
|
expect(replayLogs.join('\n')).toContain('Incremental: changed=1');
|
|
expect(replayLogs.join('\n')).not.toContain('switching to a full DB write');
|
|
expect(await readSpringAopSnapshot(repo.dbPath)).toEqual(secondSnapshot);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const firstMeta = await loadMeta(storagePath);
|
|
|
|
// Modify a source file with a COMMENT-ONLY edit — by construction
|
|
// this changes the content hash (driving the incremental code path)
|
|
// without changing any symbol, scope binding, call edge, import,
|
|
// or community membership. Therefore every graph-stat invariant
|
|
// (files / nodes / edges / communities / processes) MUST be
|
|
// bit-identical to the first run. Anything else is a regression.
|
|
const target = path.join(repo.dbPath, 'src', 'logger.ts');
|
|
const before = await readFile(target, 'utf-8');
|
|
await writeFile(target, before + '\n// touched by test\n', 'utf-8');
|
|
|
|
const second = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
// The early-return alreadyUpToDate path must NOT fire (the dirty
|
|
// tree should kick the run through to incremental writeback).
|
|
expect(second.alreadyUpToDate).toBeUndefined();
|
|
|
|
const secondMeta = await loadMeta(storagePath);
|
|
expect(secondMeta).not.toBeNull();
|
|
// Dirty flag must be cleared on success.
|
|
expect(secondMeta!.incrementalInProgress).toBeUndefined();
|
|
// fileHashes[logger.ts] must have rotated to the new content.
|
|
expect(secondMeta!.fileHashes?.['src/logger.ts']).toBeDefined();
|
|
expect(secondMeta!.fileHashes?.['src/logger.ts']).not.toBe(
|
|
firstMeta!.fileHashes?.['src/logger.ts'],
|
|
);
|
|
// Exact-equality stats invariant. DoD §2.7: avoid bounds-only
|
|
// assertions that would mask a regression dropping half the graph.
|
|
expect(secondMeta!.stats?.files).toBe(firstMeta!.stats?.files);
|
|
expect(secondMeta!.stats?.nodes).toBe(firstMeta!.stats?.nodes);
|
|
expect(secondMeta!.stats?.edges).toBe(firstMeta!.stats?.edges);
|
|
expect(secondMeta!.stats?.communities).toBe(firstMeta!.stats?.communities);
|
|
expect(secondMeta!.stats?.processes).toBe(firstMeta!.stats?.processes);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('skips the framework annotation drift query when no Bean source changed', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
const target = path.join(repo.dbPath, 'src', 'logger.ts');
|
|
const before = await readFile(target, 'utf-8');
|
|
await writeFile(target, before + '\n// non-bean-source incremental touch\n', 'utf-8');
|
|
|
|
const querySpy = vi.spyOn(adapter, 'executeQuery');
|
|
try {
|
|
const incremental = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(incremental.alreadyUpToDate).toBeUndefined();
|
|
expect(incremental.incrementalStats).toMatchObject({
|
|
changedFiles: 1,
|
|
affectedDependents: 2,
|
|
deletedFiles: 0,
|
|
writeMode: 'incremental',
|
|
});
|
|
expect(incremental.incrementalStats?.reparsedFiles).toBe(1);
|
|
expect(
|
|
querySpy.mock.calls.some(
|
|
([query]) =>
|
|
typeof query === 'string' &&
|
|
query.includes('RETURN c.id AS id, c.frameworkAnnotations AS frameworkAnnotations'),
|
|
),
|
|
).toBe(false);
|
|
} finally {
|
|
querySpy.mockRestore();
|
|
}
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('incremental output is byte-equivalent to a full rebuild (incremental ≡ --force on the same repo state)', async () => {
|
|
// The central correctness contract of this PR: an incremental run
|
|
// and a full rebuild from the same repo state must produce identical
|
|
// graph stats. We exercise it end-to-end:
|
|
//
|
|
// 1. setup mini-repo + run analyze (populates the index)
|
|
// 2. edit one source file (comment-only — same graph)
|
|
// 3. run incremental analyze → record secondMeta
|
|
// 4. run analyze --force from the same state → record forceMeta
|
|
// 5. assert every stats invariant is exactly equal.
|
|
//
|
|
// Steps 3 and 4 share the same on-disk file contents, so any
|
|
// divergence is purely an artifact of the writeback strategy. If
|
|
// any invariant differs, the PR's load-bearing claim is violated.
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
|
|
// Step 1: initial index.
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
// Step 2: comment-only edit, same as the test above.
|
|
const target = path.join(repo.dbPath, 'src', 'logger.ts');
|
|
const original = await readFile(target, 'utf-8');
|
|
await writeFile(target, original + '\n// equivalence test touch\n', 'utf-8');
|
|
|
|
// Step 3: incremental writeback for the edited file.
|
|
const incremental = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(incremental.alreadyUpToDate).toBeUndefined();
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const secondMeta = await loadMeta(storagePath);
|
|
expect(secondMeta).not.toBeNull();
|
|
|
|
// Step 4: force a full rebuild from the SAME on-disk file state.
|
|
const forced = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, force: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(forced.alreadyUpToDate).toBeUndefined();
|
|
const forceMeta = await loadMeta(storagePath);
|
|
expect(forceMeta).not.toBeNull();
|
|
|
|
// Step 5: exact-equality across every stat. `toEqual` would also
|
|
// work but `toBe` per-field makes a failure pinpoint the field.
|
|
expect(secondMeta!.stats?.files).toBe(forceMeta!.stats?.files);
|
|
expect(secondMeta!.stats?.nodes).toBe(forceMeta!.stats?.nodes);
|
|
expect(secondMeta!.stats?.edges).toBe(forceMeta!.stats?.edges);
|
|
expect(secondMeta!.stats?.communities).toBe(forceMeta!.stats?.communities);
|
|
expect(secondMeta!.stats?.processes).toBe(forceMeta!.stats?.processes);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
it('rewrites unchanged Spring bean metadata when same-package shadowing changes', async () => {
|
|
const repo = await setupSpringBeanIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
|
|
|
|
const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Service.java');
|
|
await writeFile(shadow, 'package com.other;\npublic @interface Service {}\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'add same-package annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([]);
|
|
|
|
await rm(shadow);
|
|
gitCommitAll(repo.dbPath, 'remove same-package annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
it('rewrites unchanged Kotlin Spring bean metadata when same-package shadowing changes', async () => {
|
|
const repo = await setupKotlinSpringBeanIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
|
|
|
|
const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Service.kt');
|
|
await writeFile(shadow, 'package com.other\nannotation class Service\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'add same-package Kotlin annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([]);
|
|
|
|
await rm(shadow);
|
|
gitCommitAll(repo.dbPath, 'remove same-package Kotlin annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
it('rewrites unchanged Bean factory declarations when same-package shadowing changes', async () => {
|
|
const repo = await setupSpringBeanFactoryIncrementalRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1);
|
|
|
|
const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Bean.java');
|
|
await writeFile(shadow, 'package com.other;\npublic @interface Bean {}\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'add same-package Bean annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(0);
|
|
|
|
await rm(shadow);
|
|
gitCommitAll(repo.dbPath, 'remove same-package Bean annotation shadow');
|
|
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
// #2409: a large-fraction effective write set must escalate to the full DB
|
|
// write plan (wipe + bulk COPY of the already-built graph) instead of the
|
|
// surgical per-file writeback — at that size the surgical plan measured
|
|
// SLOWER than a full load and its delete storm is the write pattern behind
|
|
// the reported native mid-writeback deaths. The escalated result must be
|
|
// indistinguishable from a --force rebuild of the same state.
|
|
it('a hub edit whose write set covers most of a large repo escalates to the full DB write plan (#2409)', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
// Grow the repo past INCREMENTAL_ESCALATION_MIN_FILES (50) with a hub
|
|
// imported by every generated file: touching the hub pulls the whole
|
|
// family into the importer closure → write fraction ≈ 100% > 50%.
|
|
const src = path.join(repo.dbPath, 'src');
|
|
await writeFile(
|
|
path.join(src, 'hub.ts'),
|
|
'export function hubValue(x: number): number {\n return x + 1;\n}\n',
|
|
'utf-8',
|
|
);
|
|
for (let i = 0; i < 60; i++) {
|
|
await writeFile(
|
|
path.join(src, `spoke-${String(i).padStart(3, '0')}.ts`),
|
|
`import { hubValue } from './hub';\n\nexport function spoke${i}(): number {\n return hubValue(${i});\n}\n`,
|
|
'utf-8',
|
|
);
|
|
}
|
|
gitCommitAll(repo.dbPath, 'add hub + spokes');
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
// Touch the hub — comment-only, so graph stats must be preserved.
|
|
const hub = path.join(src, 'hub.ts');
|
|
await writeFile(hub, (await readFile(hub, 'utf-8')) + '// escalation touch\n', 'utf-8');
|
|
|
|
const logs: string[] = [];
|
|
const incremental = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
|
|
);
|
|
expect(incremental.alreadyUpToDate).toBeUndefined();
|
|
const joined = logs.join('\n');
|
|
// The importer expansion fired AND the valve rerouted the write plan.
|
|
expect(joined).toContain('importer(s) added to writable set');
|
|
expect(joined).toContain('switching to a full DB write');
|
|
// Announced as the one follow-up line, carrying the escalation's own
|
|
// text, and non-forcing: the run stayed on the incremental branch
|
|
// (`incrementalStats` exists only there) and wrote the full plan.
|
|
expect(incremental.rebuildReasons).toEqual(['escalated-full-write']);
|
|
const { singleSummary, followUp, nonForcingFollowUp } = rebuildLineShapes(1);
|
|
const followUps = logs.filter((m) => m.startsWith(nonForcingFollowUp));
|
|
expect(followUps).toHaveLength(1);
|
|
expect(followUps[0]).toContain('switching to a full DB write');
|
|
expect(logs.filter((m) => m.startsWith(followUp))).toEqual([]);
|
|
expect(logs.filter((m) => m.startsWith(singleSummary))).toEqual([]);
|
|
expect(incremental.incrementalStats?.writeMode).toBe('full');
|
|
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const escalatedMeta = await loadMeta(storagePath);
|
|
expect(escalatedMeta).not.toBeNull();
|
|
// Dirty flag cleared on success — the escalated plan converges on the
|
|
// same meta-save as every other successful run.
|
|
expect(escalatedMeta!.incrementalInProgress).toBeUndefined();
|
|
|
|
// The escalated write must be indistinguishable from --force on the
|
|
// same state: any stale surviving row would show up as a stats delta.
|
|
await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, force: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
const forcedMeta = await loadMeta(storagePath);
|
|
expect(escalatedMeta!.stats?.files).toBe(forcedMeta!.stats?.files);
|
|
expect(escalatedMeta!.stats?.nodes).toBe(forcedMeta!.stats?.nodes);
|
|
expect(escalatedMeta!.stats?.edges).toBe(forcedMeta!.stats?.edges);
|
|
expect(escalatedMeta!.stats?.communities).toBe(forcedMeta!.stats?.communities);
|
|
expect(escalatedMeta!.stats?.processes).toBe(forcedMeta!.stats?.processes);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
// U4 / KTD10 (tri-review 4669518496): a SURGICAL preserve-mode run (below
|
|
// both valve gates) must keep embedding rows in lockstep with their files
|
|
// now that deleteNodesForFiles really deletes embedding rows via the
|
|
// nodeId join:
|
|
// - changed-file rows are deleted with their nodes and RESTORED from the
|
|
// cache (the old insert-all restore lost them when a surviving row's
|
|
// PK conflict aborted the rest of the batch),
|
|
// - deleted-file rows are gone (join-delete) and NOT resurrected by the
|
|
// restore (live-graph filter),
|
|
// - unchanged rows are untouched (restore-scope filter, no conflicts),
|
|
// - a LEGACY ORPHAN row — stranded while the embedding delete was a
|
|
// no-op, unreachable by the node join forever — is swept by exact id
|
|
// (this shipping review, FIX 3).
|
|
it('surgical incremental run keeps embedding rows in lockstep: changed restored, deleted gone, unchanged intact, legacy orphan swept (tri-review 4669518496 KTD10 + FIX 3)', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const CHANGED_FILE = 'src/logger.ts';
|
|
const UNCHANGED_FILE = 'src/db.ts';
|
|
const DELETED_FILE = 'src/formatter.ts';
|
|
// A fabricated nodeId no graph will ever contain: the P2-1-era no-op
|
|
// delete left rows like this stranded in real DBs (schema version
|
|
// stays 6, so they are still out there).
|
|
const LEGACY_ORPHAN_NODE_ID = 'Function:src/ghost.ts:ghost:1';
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const idsByFile = await seedEmbeddingsForFiles(
|
|
repo.dbPath,
|
|
[CHANGED_FILE, UNCHANGED_FILE, DELETED_FILE],
|
|
3,
|
|
);
|
|
for (const fp of [CHANGED_FILE, UNCHANGED_FILE, DELETED_FILE]) {
|
|
expect((idsByFile.get(fp) ?? []).length).toBeGreaterThan(0);
|
|
}
|
|
await seedEmbeddingForNodeId(repo.dbPath, LEGACY_ORPHAN_NODE_ID);
|
|
const seededTotal = [...idsByFile.values()].flat().length + 1;
|
|
await stampEmbeddingCount(storagePath, seededTotal);
|
|
|
|
// One file modified (comment-only, appended at EOF so node ids keep
|
|
// their line numbers), one file deleted — committed so lastCommit moves.
|
|
const target = path.join(repo.dbPath, CHANGED_FILE);
|
|
await writeFile(
|
|
target,
|
|
(await readFile(target, 'utf-8')) + '\n// embeddings parity touch\n',
|
|
'utf-8',
|
|
);
|
|
await rm(path.join(repo.dbPath, DELETED_FILE));
|
|
gitCommitAll(repo.dbPath, 'modify logger + delete formatter');
|
|
|
|
const logs: string[] = [];
|
|
const run = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
|
|
);
|
|
expect(run.alreadyUpToDate).toBeUndefined();
|
|
// 7-file repo — far below the 50-file valve floor: this MUST have been
|
|
// the surgical write plan, or every assertion below is vacuously about
|
|
// the escalated path instead.
|
|
expect(logs.join('\n')).not.toContain('switching to a full DB write');
|
|
|
|
// The surgical run swept the fabricated legacy orphan by exact id
|
|
// (FIX 3). The logged count also includes DELETED_FILE's cached rows —
|
|
// live-graph rejects whose DB rows were already join-deleted with the
|
|
// file, so their exact-id DELETEs match nothing (documented no-op).
|
|
const expectedSweepCount = 1 + (idsByFile.get(DELETED_FILE) ?? []).length;
|
|
expect(logs.join('\n')).toContain(
|
|
`Swept ${expectedSweepCount} cached embedding row(s) with no live owning node`,
|
|
);
|
|
|
|
const after = await loadMeta(storagePath);
|
|
expect(after!.incrementalInProgress).toBeUndefined();
|
|
const expectedSurvivors = [
|
|
...(idsByFile.get(CHANGED_FILE) ?? []),
|
|
...(idsByFile.get(UNCHANGED_FILE) ?? []),
|
|
];
|
|
// stats.embeddings excludes both the deleted-file rows AND the swept
|
|
// legacy orphan.
|
|
expect(after!.stats?.embeddings).toBe(expectedSurvivors.length);
|
|
// Exact surviving nodeId set — pins all four behaviors at once (a
|
|
// batch-abort loss, a leaked deleted-file row, a dropped unchanged
|
|
// row, or a lingering legacy orphan each break set equality).
|
|
expect((await readEmbeddingNodeIds(repo.dbPath)).sort()).toEqual(
|
|
[...expectedSurvivors].sort(),
|
|
);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
// #2409 defect 2 (dirty-flag recovery parks WAL/shadow sidecars before any
|
|
// open) is covered in incremental-dirty-recovery.test.ts — its own file so
|
|
// the cross-platform CI matrix runs it on windows-latest without pulling in
|
|
// this whole suite.
|
|
|
|
it('a stale incrementalInProgress flag at startup forces a full rebuild that clears it', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
// First run lays down a normal index.
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
// Manually corrupt meta.json with a stale dirty flag — simulates
|
|
// a crashed previous incremental run.
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta).not.toBeNull();
|
|
const tampered: RepoMeta = {
|
|
...meta!,
|
|
incrementalInProgress: {
|
|
startedAt: Date.now() - 60_000,
|
|
toWriteCount: 3,
|
|
phase: 'load-graph',
|
|
importerExpansion: 153,
|
|
effectiveWriteCount: 167,
|
|
deleteCount: 169,
|
|
},
|
|
};
|
|
await saveMeta(storagePath, tampered);
|
|
const logs: string[] = [];
|
|
|
|
// Next run must detect the flag, force a full rebuild (which
|
|
// overwrites meta), and clear the flag.
|
|
const recovered = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
// A full rebuild was taken — the alreadyUpToDate fast path
|
|
// explicitly cannot fire because the dirty-flag check rewrote
|
|
// `options.force` to true.
|
|
expect(recovered.alreadyUpToDate).toBeUndefined();
|
|
expect(recovered.rebuildReasons).toEqual(['interrupted-rebuild']);
|
|
|
|
const after = await loadMeta(storagePath);
|
|
expect(after!.incrementalInProgress).toBeUndefined();
|
|
expect(logs.join('\n')).toContain(
|
|
'last dirty state: phase=load-graph, toWrite=3, importerExpansion=153, effectiveWrite=167, deleteCount=169',
|
|
);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// #2798/#3041: the invariant the INCREMENTAL_SCHEMA_VERSION ladder used to
|
|
// backstop. It is implicit nowhere else: no other gate observes analyzer code
|
|
// that emits no DDL, so dropping the runner-identity reason silently re-opens
|
|
// same-commit top-ups across an analyzer that changed how the graph is
|
|
// shaped. Pinned on the returned reasons (it used to be a source regex over
|
|
// run-analyze.ts). The predicate's OWN behaviour — a moved build digest with
|
|
// unmoved DDL, an absent/null/legacy/malformed receipt, an alternate
|
|
// diagnostic entrypoint — is asserted in analyzer-identity.test.ts.
|
|
it('a stamped runner identity that differs forces a full rebuild with the runner-identity reason', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
if (meta?.runnerIdentity === undefined) throw new Error('first run stamped no identity');
|
|
// Same commit, clean tree, same DDL: only the analyzer build digest moved.
|
|
const { invokedArtifact } = meta.runnerIdentity;
|
|
await saveMeta(storagePath, {
|
|
...meta,
|
|
runnerIdentity: {
|
|
...meta.runnerIdentity,
|
|
invokedArtifact: { ...invokedArtifact, digest: '0'.repeat(64) },
|
|
},
|
|
});
|
|
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(reanalyzed.rebuildReasons).toEqual(['runner-identity']);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// an upgrade that trips several reasons at once names them in ONE
|
|
// numbered block and prints no other rebuild line.
|
|
it('schema, runner identity, and a new Actuator request print one numbered block of three', async () => {
|
|
const repo = await setupMiniRepo();
|
|
const runtimeInput = 'runtime-actuator';
|
|
try {
|
|
await mkdir(path.join(repo.dbPath, runtimeInput), { recursive: true });
|
|
await writeFile(
|
|
path.join(repo.dbPath, runtimeInput, 'env.json'),
|
|
JSON.stringify({ propertySources: [] }),
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add actuator runtime snapshot');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
if (meta?.runnerIdentity === undefined) throw new Error('first run stamped no identity');
|
|
const { invokedArtifact } = meta.runnerIdentity;
|
|
await saveMeta(storagePath, {
|
|
...meta,
|
|
schemaFingerprint: 'b1c2d3e4f5a6',
|
|
runnerIdentity: {
|
|
...meta.runnerIdentity,
|
|
invokedArtifact: { ...invokedArtifact, digest: '0'.repeat(64) },
|
|
},
|
|
});
|
|
|
|
const logs: string[] = [];
|
|
const upgraded = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, springActuatorPath: runtimeInput },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(upgraded.rebuildReasons).toEqual([
|
|
'schema-fingerprint',
|
|
'runner-identity',
|
|
'spring-actuator',
|
|
]);
|
|
const { singleSummary, numberedHeader, followUp } = rebuildLineShapes(3);
|
|
const blocks = logs.filter((m) => m.startsWith(numberedHeader));
|
|
expect(blocks).toHaveLength(1);
|
|
expect(blocks[0].split('\n')).toHaveLength(4);
|
|
expect(logs.filter((m) => m.startsWith(singleSummary) || m.startsWith(followUp))).toEqual([]);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// An index carrying a schema stamp that is not this build's must not take the
|
|
// alreadyUpToDate fast path. The schema mismatch guard runs before lastCommit
|
|
// equality can short-circuit the pipeline, so node-identity migrations receive
|
|
// a full rebuild. Pinned on the RESULT (no fast path, restamped meta) rather
|
|
// than the log line, so the ordering invariant survives a reworded notice.
|
|
it('a foreign schema fingerprint forces a full rebuild on an unchanged-commit re-analyze', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
// First run stamps the digest of the DDL this build creates.
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta).not.toBeNull();
|
|
expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT);
|
|
|
|
// Simulate an index whose tables were created from a different DDL, at
|
|
// the same commit with a clean tree. Well-formed (12 lowercase hex, so it
|
|
// clears the echo-shape gate) but not this build's — every other fast-path
|
|
// condition holds, so only the schema guard can stop the early return.
|
|
const downgraded: RepoMeta = { ...meta!, schemaFingerprint: 'b1c2d3e4f5a6' };
|
|
await saveMeta(storagePath, downgraded);
|
|
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
// Pipeline actually ran (schemaFingerprint mismatch → force=true), and the
|
|
// notice names the stamp it rejected rather than a generic placeholder.
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(reanalyzed.rebuildReasons).toEqual(['schema-fingerprint']);
|
|
expect(logs.join('\n')).toContain('index schema changed (built by b1c2d3e4f5a6,');
|
|
// And the rebuild restamped this build's digest (that path runs saveMeta).
|
|
const restamped = await loadMeta(storagePath);
|
|
expect(restamped!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// A recorded graph-write collapse at an unchanged commit must not take the
|
|
// alreadyUpToDate fast path: the gate forces a full rebuild and names it.
|
|
it('a recorded graphWriteCollapsed stamp forces a full rebuild on an unchanged-commit re-analyze', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta?.graphWriteCollapsed).toBeUndefined();
|
|
expect(meta).toBeTruthy();
|
|
|
|
// Same commit, clean tree, same DDL: only the collapse stamp differs.
|
|
const collapsed: RepoMeta = {
|
|
...(meta as RepoMeta),
|
|
graphWriteCollapsed: { expected: 500, persisted: 3 },
|
|
};
|
|
await saveMeta(storagePath, collapsed);
|
|
|
|
const logs: string[] = [];
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => logs.push(message) },
|
|
);
|
|
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(reanalyzed.rebuildReasons).toContain('graph-write-collapse');
|
|
expect(logs.join('\n')).toContain('previous run persisted 3 of 500 expected relationships');
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// #2331/#2339: mirrors the schema-fingerprint mismatch test above, but for
|
|
// the CJK segmentation mode stamp. Uses a non-default mode ('bigram') rather
|
|
// than 'none' — with the default, (undefined ?? 'none') !== 'none' is
|
|
// false regardless of whether the stamp was ever actually written, so a
|
|
// dropped-stamp bug would pass this test vacuously. 'bigram' makes an
|
|
// omitted stamp manifest as a real comparator mismatch instead.
|
|
it('a stale cjkSegmentation stamp forces a full rebuild on an unchanged-commit re-analyze', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta).not.toBeNull();
|
|
expect(meta!.cjkSegmentation).toBe('bigram');
|
|
|
|
// Simulate a repo indexed under 'none' (or a pre-#2339 build with no
|
|
// stamp at all) that's now being served/re-analyzed with bigram mode.
|
|
const downgraded: RepoMeta = { ...meta!, cjkSegmentation: 'none' };
|
|
await saveMeta(storagePath, downgraded);
|
|
|
|
const reanalyzed = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
// Pipeline actually ran (cjkSegmentation mismatch → force=true).
|
|
expect(reanalyzed.alreadyUpToDate).toBeUndefined();
|
|
expect(reanalyzed.rebuildReasons).toContain('cjk-segmentation');
|
|
// And the meta is restamped to the live resolved mode.
|
|
const restamped = await loadMeta(storagePath);
|
|
expect(restamped!.cjkSegmentation).toBe('bigram');
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('first-ever analyze of a brand-new repo proceeds without a spurious CJK mode force-rebuild', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
// No meta.json exists yet — existingMeta is falsy, so the
|
|
// cjkSegmentationModeMismatch guard is skipped entirely (never calls
|
|
// the comparator), same as the pdg/schemaVersion guards above it.
|
|
expect(await loadMeta(storagePath)).toBeNull();
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
const result = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(result.alreadyUpToDate).toBeUndefined();
|
|
|
|
const meta = await loadMeta(storagePath);
|
|
expect(meta!.cjkSegmentation).toBe('none');
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
// U7 (#2200): the INJECTS delete-before-writeback must be UNCONDITIONAL.
|
|
// extractChangedSubgraph re-includes ALL INJECTS edges from the fresh graph
|
|
// on every incremental run (isGraphWideRelType), and CodeRelation has no PK
|
|
// and no read-side dedup — so a pdg-gated delete (literal TAINT_PATH
|
|
// mirroring) would append without deleting on every non-pdg incremental
|
|
// run: N runs = N copies of every INJECTS row. This test is the assertion
|
|
// that catches exactly that mistake.
|
|
it('incremental runs neither strand nor duplicate INJECTS edges (delete-all is not pdg-gated) (#2200)', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const src = path.join(repo.dbPath, 'src');
|
|
for (const [name, content] of JAVA_DI_FIXTURE) {
|
|
await writeFile(path.join(src, name), content, 'utf-8');
|
|
}
|
|
gitCommitAll(repo.dbPath, 'add java di fixture');
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
|
|
// Full index: Consumer.foos fans out to the two IFoo implementers.
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countInjects(repo.dbPath)).toBe(2);
|
|
|
|
// Incremental run 1: comment-only touch of an UNRELATED file (none of
|
|
// the Java DI files change), committed so lastCommit moves.
|
|
const target = path.join(src, 'logger.ts');
|
|
const beforeFirstTouch = await readFile(target, 'utf-8');
|
|
await writeFile(target, beforeFirstTouch + '\n// di idempotency touch 1\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'unrelated touch 1');
|
|
const run1 = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(run1.alreadyUpToDate).toBeUndefined();
|
|
expect(await countInjects(repo.dbPath)).toBe(2);
|
|
|
|
// Incremental run 2: second unrelated touch. A gated delete would have
|
|
// appended two more rows per writeback (4 by now) — must still be 2.
|
|
const beforeSecondTouch = await readFile(target, 'utf-8');
|
|
await writeFile(target, beforeSecondTouch + '\n// di idempotency touch 2\n', 'utf-8');
|
|
gitCommitAll(repo.dbPath, 'unrelated touch 2');
|
|
const run2 = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(run2.alreadyUpToDate).toBeUndefined();
|
|
expect(await countInjects(repo.dbPath)).toBe(2);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
|
|
it('incremental runs do not duplicate repository-wide Spring DECLARES edges (#2415)', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
const sourceDir = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example');
|
|
const metadataDir = path.join(repo.dbPath, 'src', 'main', 'resources', 'META-INF', 'spring');
|
|
await mkdir(sourceDir, { recursive: true });
|
|
await mkdir(metadataDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(metadataDir, 'org.springframework.boot.autoconfigure.AutoConfiguration.imports'),
|
|
'com.example.ExampleAutoConfiguration\n',
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add auto configuration metadata');
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
|
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1);
|
|
|
|
const target = path.join(repo.dbPath, 'src', 'logger.ts');
|
|
for (const run of [1, 2]) {
|
|
const before = await readFile(target, 'utf-8');
|
|
await writeFile(target, `${before}\n// auto-register idempotency touch ${run}\n`, 'utf-8');
|
|
gitCommitAll(repo.dbPath, `unrelated auto-register touch ${run}`);
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
|
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1);
|
|
}
|
|
|
|
await writeFile(
|
|
path.join(sourceDir, 'ExampleAutoConfiguration.java'),
|
|
'package com.example;\npublic class ExampleAutoConfiguration {}\n',
|
|
'utf-8',
|
|
);
|
|
gitCommitAll(repo.dbPath, 'add source for metadata-only auto configuration');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1);
|
|
expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(0);
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
});
|
|
|
|
/**
|
|
* U3 (tri-review 4669518496 P1): the #2409 escalation valve wipes the DB
|
|
* files — HNSW vector index included. The Phase 3.5 restore brought the
|
|
* embedding ROWS back, but nothing recreated the index and meta still
|
|
* stamped `vector-index`: semantic search on a >10k-embedding repo silently
|
|
* lost its vector lane while meta certified otherwise. This suite pins the
|
|
* fix end-to-end: escalated preserve-mode run → index recreated → meta honest.
|
|
*
|
|
* SEPARATE from the `--force` escalation parity test above (KTD9): seeding
|
|
* embeddings there would make its force leg derive forceRegenerateEmbeddings
|
|
* and boot a real embedder in CI. This run stays preserve-only (no force).
|
|
*
|
|
* Skip-gated on VECTOR availability (the lbug-vector-extension.test.ts
|
|
* pattern): skipped only where the extension genuinely cannot load —
|
|
* no platform is categorically excluded any more (#2623 follow-up) — and
|
|
* where it cannot, the honest stamp is
|
|
* 'exact-scan', which the unit-level wiring pin in
|
|
* run-analyze-fts-repair.test.ts covers platform-independently.
|
|
*/
|
|
let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart);
|
|
if (dartAvailable) {
|
|
try {
|
|
await loadParser();
|
|
await loadLanguage(SupportedLanguages.Dart);
|
|
} catch {
|
|
dartAvailable = false;
|
|
}
|
|
}
|
|
|
|
describe.skipIf(!dartAvailable)('Dart pubspec-only incremental persistence (#2963)', () => {
|
|
it.each([
|
|
{
|
|
name: 'rename',
|
|
initial: 'name: app',
|
|
next: 'name: renamed',
|
|
file: 'pubspec.yaml',
|
|
nextResolves: false,
|
|
},
|
|
{
|
|
name: 'addition',
|
|
initial: null,
|
|
next: 'name: app',
|
|
file: 'pubspec.yaml',
|
|
nextResolves: true,
|
|
},
|
|
{
|
|
name: 'duplicate',
|
|
initial: 'name: app',
|
|
next: 'name: app',
|
|
file: 'nested/pubspec.yaml',
|
|
nextResolves: false,
|
|
},
|
|
{
|
|
name: 'repair',
|
|
initial: 'name: [invalid',
|
|
next: 'name: app',
|
|
file: 'pubspec.yaml',
|
|
nextResolves: true,
|
|
},
|
|
])(
|
|
'persists $name and its reversal with forced-rebuild parity',
|
|
async ({ initial, next, file, nextResolves }) => {
|
|
const repo = await createTempDir();
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
const options = { skipAgentsMd: true, skipFts: true };
|
|
const progress = { onProgress: () => {} };
|
|
const readEdges = async () => {
|
|
await adapter.initLbug(getStoragePaths(repo.dbPath).lbugPath, { skipFts: true });
|
|
try {
|
|
return await adapter.executeQuery(
|
|
`MATCH (a)-[r:CodeRelation]->(b) WHERE r.type IN ['IMPORTS', 'CALLS'] ` +
|
|
`RETURN a.id AS source, b.id AS target, r.type AS type, r.reason AS reason, ` +
|
|
`a.filePath AS sourceFile, b.filePath AS targetFile, b.name AS targetName ` +
|
|
`ORDER BY source, target, type, reason`,
|
|
);
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
};
|
|
try {
|
|
await mkdir(path.join(repo.dbPath, 'lib'), { recursive: true });
|
|
await mkdir(path.join(repo.dbPath, 'nested'), { recursive: true });
|
|
await writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n');
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'lib/model.dart'),
|
|
'class Model { void save() {} }\n',
|
|
);
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'lib/bridge.dart'),
|
|
"import 'package:app/model.dart';\nModel buildModel() => Model();\n",
|
|
);
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'lib/main.dart'),
|
|
"import './bridge.dart';\nvoid run() { buildModel().save(); }\n",
|
|
);
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'nested/decoy.dart'),
|
|
'class Model { void save() {} }\n',
|
|
);
|
|
if (initial !== null) await writeFile(path.join(repo.dbPath, 'pubspec.yaml'), initial);
|
|
execSync('git init -q', { cwd: repo.dbPath, stdio: 'pipe' });
|
|
gitCommitAll(repo.dbPath, 'Dart package fixture');
|
|
await runFullAnalysis(repo.dbPath, options, progress);
|
|
const initialEdges = await readEdges();
|
|
expect(
|
|
initialEdges.filter(
|
|
(edge) =>
|
|
edge.type === 'IMPORTS' &&
|
|
edge.source === 'File:lib/bridge.dart' &&
|
|
edge.target === 'File:lib/model.dart',
|
|
),
|
|
).toHaveLength(initial === 'name: app' ? 1 : 0);
|
|
|
|
for (const [content, resolves] of [
|
|
[next, nextResolves],
|
|
[file === 'pubspec.yaml' ? initial : null, initial === 'name: app'],
|
|
] as const) {
|
|
const target = path.join(repo.dbPath, file);
|
|
if (content === null) await rm(target);
|
|
else await writeFile(target, content);
|
|
gitCommitAll(repo.dbPath, 'Update package identity');
|
|
const incremental = await runFullAnalysis(repo.dbPath, options, progress);
|
|
expect(incremental.incrementalStats?.writeMode).toBe('incremental');
|
|
const incrementalEdges = await readEdges();
|
|
const imports = incrementalEdges.filter(
|
|
(edge) =>
|
|
edge.type === 'IMPORTS' &&
|
|
edge.source === 'File:lib/bridge.dart' &&
|
|
edge.target === 'File:lib/model.dart',
|
|
);
|
|
expect(imports).toHaveLength(resolves ? 1 : 0);
|
|
expect(
|
|
incrementalEdges.filter(
|
|
(edge) =>
|
|
edge.type === 'CALLS' &&
|
|
edge.sourceFile === 'lib/main.dart' &&
|
|
edge.targetFile === 'lib/model.dart' &&
|
|
edge.targetName === 'save',
|
|
),
|
|
).toHaveLength(resolves ? 1 : 0);
|
|
const meta = await loadMeta(getStoragePaths(repo.dbPath).storagePath);
|
|
expect(Object.hasOwn(meta?.fileHashes ?? {}, file)).toBe(content !== null);
|
|
const identityToFile = incrementalEdges.filter(
|
|
(edge) =>
|
|
edge.type === 'IMPORTS' &&
|
|
edge.reason === DART_PACKAGE_IDENTITY_REASON &&
|
|
edge.source === 'File:lib/main.dart' &&
|
|
edge.target === `File:${file}`,
|
|
);
|
|
if (content === 'name: app') {
|
|
expect(identityToFile).toEqual([
|
|
{
|
|
source: 'File:lib/main.dart',
|
|
target: `File:${file}`,
|
|
type: 'IMPORTS',
|
|
reason: DART_PACKAGE_IDENTITY_REASON,
|
|
sourceFile: 'lib/main.dart',
|
|
targetFile: file,
|
|
targetName: 'pubspec.yaml',
|
|
},
|
|
]);
|
|
} else {
|
|
// Rename and deletion must drop the identity edge. A stale edge
|
|
// would keep rewriting importers after the package name is gone.
|
|
expect(identityToFile).toEqual([]);
|
|
}
|
|
await runFullAnalysis(repo.dbPath, { ...options, force: true }, progress);
|
|
expect(await readEdges()).toEqual(incrementalEdges);
|
|
}
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
await repo.cleanup();
|
|
}
|
|
},
|
|
300_000,
|
|
);
|
|
});
|
|
|
|
describe('runFullAnalysis — escalated wipe recreates the vector index (#2409, tri-review 4669518496 P1)', () => {
|
|
let vectorAvailable = false;
|
|
let skipWarned = false;
|
|
beforeAll(async () => {
|
|
// Probe VECTOR the way the analyze write path loads it. loadVectorExtension
|
|
// needs an open connection, and this suite (unlike the withTestLbugDB
|
|
// vector suites) has no ambient DB — probe against a scratch one.
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { resolveAnalyzeInstallPolicy } = await import('../../src/core/lbug/extension-loader.js');
|
|
const tmp = await createTempDir('gitnexus-incr-orch-vector-probe-');
|
|
try {
|
|
await adapter.initLbug(path.join(tmp.dbPath, 'probe-lbug'));
|
|
vectorAvailable = await adapter.loadVectorExtension(undefined, {
|
|
policy: resolveAnalyzeInstallPolicy(),
|
|
});
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
await tmp.cleanup();
|
|
}
|
|
}, 120_000);
|
|
beforeEach((ctx) => {
|
|
if (!vectorAvailable) {
|
|
if (!skipWarned) {
|
|
skipWarned = true;
|
|
console.warn(
|
|
'[incremental-orchestration] Skipping vector-index recreation test — the ' +
|
|
'LadybugDB VECTOR extension is unavailable (unsupported platform or ' +
|
|
'could not be installed).',
|
|
);
|
|
}
|
|
ctx.skip();
|
|
}
|
|
});
|
|
|
|
it('recreates the HNSW index after an escalated wipe-and-restore and stamps meta honestly (tri-review 4669518496 P1)', async () => {
|
|
const repo = await setupMiniRepo();
|
|
try {
|
|
// Hub+spokes repo shape VERBATIM from the escalation parity test above:
|
|
// the escalated run must clear BOTH valve gates (deleteCount ≥ 50 AND
|
|
// fraction > 0.5). A smaller fixture would silently take the surgical
|
|
// path, whose surviving index makes every assertion below pass
|
|
// vacuously.
|
|
const src = path.join(repo.dbPath, 'src');
|
|
await writeFile(
|
|
path.join(src, 'hub.ts'),
|
|
'export function hubValue(x: number): number {\n return x + 1;\n}\n',
|
|
'utf-8',
|
|
);
|
|
for (let i = 0; i < 60; i++) {
|
|
await writeFile(
|
|
path.join(src, `spoke-${String(i).padStart(3, '0')}.ts`),
|
|
`import { hubValue } from './hub';\n\nexport function spoke${i}(): number {\n return hubValue(${i});\n}\n`,
|
|
'utf-8',
|
|
);
|
|
}
|
|
gitCommitAll(repo.dbPath, 'add hub + spokes');
|
|
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} });
|
|
|
|
// 20 zero-vector embeddings on real Function nodes (one per spoke —
|
|
// fabricated ids would be dropped by the Phase 3.5 live-graph filter)
|
|
// + a stats stamp so deriveEmbeddingMode sees an embedded repo
|
|
// (preserve mode — the run below passes NO force flag, so no embedder
|
|
// ever fires; KTD9).
|
|
const SEED_COUNT = 20;
|
|
const { storagePath, lbugPath } = getStoragePaths(repo.dbPath);
|
|
const seedFiles: string[] = [];
|
|
for (let i = 0; i < SEED_COUNT; i++) {
|
|
seedFiles.push(`src/spoke-${String(i).padStart(3, '0')}.ts`);
|
|
}
|
|
const idsByFile = await seedEmbeddingsForFiles(repo.dbPath, seedFiles, 1);
|
|
expect([...idsByFile.values()].flat().length).toBe(SEED_COUNT);
|
|
await stampEmbeddingCount(storagePath, SEED_COUNT);
|
|
|
|
// Touch the hub — the importer closure covers the whole family, the
|
|
// valve fires, and the DB (index included) is wiped mid-run.
|
|
const hub = path.join(src, 'hub.ts');
|
|
await writeFile(hub, (await readFile(hub, 'utf-8')) + '// index recreation touch\n', 'utf-8');
|
|
|
|
const logs: string[] = [];
|
|
const escalated = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (m) => logs.push(m) },
|
|
);
|
|
expect(escalated.alreadyUpToDate).toBeUndefined();
|
|
// The valve rerouted the write plan — without this the index assertions
|
|
// below test the surgical path's surviving index, not the recreation.
|
|
expect(logs.join('\n')).toContain('switching to a full DB write');
|
|
|
|
// Every cached row was restored across the wipe…
|
|
const after = await loadMeta(storagePath);
|
|
expect(after!.incrementalInProgress).toBeUndefined();
|
|
expect(after!.stats?.embeddings).toBe(SEED_COUNT);
|
|
// …meta stamps what the DB actually holds…
|
|
expect(after!.capabilities?.vectorSearch.status).toBe('vector-index');
|
|
// …and the DB really does hold a recreated HNSW index (SHOW_INDEXES
|
|
// straight off the reopened store — the assertion that fails when the
|
|
// wipe destroys the index and nothing rebuilds it).
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const idxRows = (await adapter.executeQuery('CALL SHOW_INDEXES() RETURN *')) as Array<{
|
|
index_name?: string;
|
|
index_type?: string;
|
|
}>;
|
|
const idx = idxRows.find((r) => r.index_name === 'code_embedding_idx');
|
|
expect(idx).toBeDefined();
|
|
expect(idx!.index_type).toBe('HNSW');
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
} finally {
|
|
await repo.cleanup();
|
|
}
|
|
}, 600_000);
|
|
});
|
|
|
|
/** Document-derived destinations currently in the graph, address + provenance. */
|
|
async function readDocumentDestinations(
|
|
repoPath: string,
|
|
): Promise<Array<{ address: string; broker: string; resolution: string }>> {
|
|
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
|
|
const { lbugPath } = getStoragePaths(repoPath);
|
|
await adapter.initLbug(lbugPath);
|
|
try {
|
|
const rows = (await adapter.executeQuery(
|
|
`MATCH (d:Destination) WHERE d.resolution = 'asyncapi-document' ` +
|
|
`RETURN d.address AS address, d.broker AS broker, d.resolution AS resolution ` +
|
|
`ORDER BY address`,
|
|
)) as Array<{ address?: unknown; broker?: unknown; resolution?: unknown }>;
|
|
return rows.map((row) => ({
|
|
address: String(row.address ?? ''),
|
|
broker: String(row.broker ?? ''),
|
|
resolution: String(row.resolution ?? ''),
|
|
}));
|
|
} finally {
|
|
await adapter.closeLbug();
|
|
}
|
|
}
|
|
|
|
describe('runFullAnalysis — AsyncAPI document reading', () => {
|
|
/**
|
|
* Drives the REAL `runFullAnalysis` with `asyncApiSpecPath` set.
|
|
*
|
|
* Three separate things were individually deletable with the whole suite
|
|
* green before this existed: the forward from `run-analyze` into
|
|
* `PipelineOptions`, the forced rebuild while the option is enabled, and the
|
|
* cleanup rebuild when it is dropped. Each of them makes the feature
|
|
* partially or wholly inert, and none of them is visible one layer up, where
|
|
* the CLI test asserts on a mock's arguments.
|
|
*
|
|
* The document lives OUTSIDE the repository on purpose. That is the workflow
|
|
* the option is documented for — a cache written by other tooling — and it is
|
|
* the only one where the hazard is real: editing a tracked file dirties the
|
|
* tree and would force a rebuild anyway, so an in-repo fixture would pass
|
|
* even with the freshness fix reverted.
|
|
*/
|
|
it('forces a rebuild while enabled, re-reads a changed document, and cleans up once on disable', async () => {
|
|
const repo = await setupMiniRepo();
|
|
// A SIBLING of the repository, not a child: the document must be outside
|
|
// the working tree, or editing it would dirty the tree and force a rebuild
|
|
// on its own, and the test would pass with the freshness fix reverted.
|
|
const specDir = path.join(repo.dbPath, '..', 'gnx-asyncapi-spec');
|
|
await mkdir(specDir, { recursive: true });
|
|
const specFile = path.join(specDir, 'orders.yaml');
|
|
const documentFor = (address: string): string =>
|
|
[
|
|
'asyncapi: 3.0.0',
|
|
'info: { title: Order Service, version: 1.0.0 }',
|
|
'servers: { broker: { host: "example:9092", protocol: kafka } }',
|
|
`channels: { c: { address: ${address}, servers: [{ $ref: "#/servers/broker" }] } }`,
|
|
'operations: { publishOrder: { action: send, channel: { $ref: "#/channels/c" } } }',
|
|
'',
|
|
].join('\n');
|
|
|
|
try {
|
|
await writeFile(specFile, documentFor('orders.v1'), 'utf-8');
|
|
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
|
|
const { storagePath } = getStoragePaths(repo.dbPath);
|
|
|
|
const enabledLogs: string[] = [];
|
|
const enabled = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, asyncApiSpecPath: specDir },
|
|
{ onProgress: () => {}, onLog: (message) => enabledLogs.push(message) },
|
|
);
|
|
expect(enabled.alreadyUpToDate).toBeUndefined();
|
|
expect(enabled.rebuildReasons).toEqual(['asyncapi']);
|
|
expect(
|
|
enabledLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)),
|
|
).toHaveLength(1);
|
|
// The forward into PipelineOptions is what puts this node in the graph;
|
|
// without it the flag parses and nothing else happens.
|
|
expect(await readDocumentDestinations(repo.dbPath)).toEqual([
|
|
{ address: 'orders.v1', broker: 'kafka', resolution: 'asyncapi-document' },
|
|
]);
|
|
expect((await loadMeta(storagePath))?.asyncApiSpec).toEqual({ enabled: true });
|
|
|
|
// Same commit, clean tree, only the out-of-tree document changed. Git
|
|
// freshness cannot see this, so without the forced rebuild the run is a
|
|
// no-op that reports success and serves the previous address.
|
|
await writeFile(specFile, documentFor('orders.v2'), 'utf-8');
|
|
const rereadRun = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true, asyncApiSpecPath: specDir },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(rereadRun.alreadyUpToDate).toBeUndefined();
|
|
expect(await readDocumentDestinations(repo.dbPath)).toEqual([
|
|
{ address: 'orders.v2', broker: 'kafka', resolution: 'asyncapi-document' },
|
|
]);
|
|
|
|
const disableLogs: string[] = [];
|
|
const disabled = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {}, onLog: (message) => disableLogs.push(message) },
|
|
);
|
|
expect(disabled.alreadyUpToDate).toBeUndefined();
|
|
expect(disabled.rebuildReasons).toEqual(['asyncapi']);
|
|
expect(
|
|
disableLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)),
|
|
).toHaveLength(1);
|
|
expect(await readDocumentDestinations(repo.dbPath)).toEqual([]);
|
|
expect((await loadMeta(storagePath))?.asyncApiSpec).toBeUndefined();
|
|
|
|
// Exactly ONE cleanup rebuild: the metadata write drops the flag, so the
|
|
// next run has nothing to react to. A merge-over-previous write here
|
|
// would rebuild forever with nothing failing.
|
|
const steady = await runFullAnalysis(
|
|
repo.dbPath,
|
|
{ skipAgentsMd: true },
|
|
{ onProgress: () => {} },
|
|
);
|
|
expect(steady.alreadyUpToDate).toBe(true);
|
|
} finally {
|
|
// The document directory is a sibling of the repository, so the repo's
|
|
// own cleanup does not reach it — both owners have to be called here.
|
|
await rm(specDir, { recursive: true, force: true });
|
|
await repo.cleanup();
|
|
}
|
|
}, 180_000);
|
|
});
|