fix(MULTIVERSION-0001): eliminate NULL-row contamination in getRules

When a versioned query is made, getRules() now returns only the
version-specific repository_configs row. The NULL (HEAD/repo-wide)
row is no longer merged in, preventing v4 rules from bleeding into
v1/v2/v3 versioned context responses.

Tests updated to assert the isolation: versioned queries return only
their own rules row; a new test verifies that a version with no
config row returns an empty rules array even when a NULL row exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-29 11:47:31 +02:00
parent bbc67f8064
commit 09c6f9f7c1
2 changed files with 50 additions and 30 deletions

View File

@@ -446,7 +446,11 @@ describe('API contract integration', () => {
const repositoryId = seedRepo(db); const repositoryId = seedRepo(db);
const versionId = seedVersion(db, repositoryId, 'v18.3.0'); const versionId = seedVersion(db, repositoryId, 'v18.3.0');
const documentId = seedDocument(db, repositoryId, versionId); const documentId = seedDocument(db, repositoryId, versionId);
seedRules(db, repositoryId, ['Prefer hooks over classes']); // Insert version-specific rules (versioned queries no longer inherit the NULL row).
db.prepare(
`INSERT INTO repository_configs (repository_id, version_id, rules, updated_at)
VALUES (?, ?, ?, ?)`
).run(repositoryId, versionId, JSON.stringify(['Prefer hooks over classes']), NOW_S);
seedSnippet(db, { seedSnippet(db, {
documentId, documentId,
repositoryId, repositoryId,
@@ -497,12 +501,12 @@ describe('API contract integration', () => {
}); });
}); });
it('GET /api/v1/context returns merged repo-wide and version-specific rules', async () => { it('GET /api/v1/context returns only version-specific rules for versioned queries (no NULL row contamination)', async () => {
const repositoryId = seedRepo(db); const repositoryId = seedRepo(db);
const versionId = seedVersion(db, repositoryId, 'v2.0.0'); const versionId = seedVersion(db, repositoryId, 'v2.0.0');
const documentId = seedDocument(db, repositoryId, versionId); const documentId = seedDocument(db, repositoryId, versionId);
// Insert repo-wide rules (version_id IS NULL). // Insert repo-wide rules (version_id IS NULL) — these must NOT appear in versioned queries.
db.prepare( db.prepare(
`INSERT INTO repository_configs (repository_id, version_id, rules, updated_at) `INSERT INTO repository_configs (repository_id, version_id, rules, updated_at)
VALUES (?, NULL, ?, ?)` VALUES (?, NULL, ?, ?)`
@@ -529,8 +533,8 @@ describe('API contract integration', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
// Both repo-wide and version-specific rules should appear (deduped). // Only the version-specific rule should appear — NULL row must not contaminate.
expect(body.rules).toEqual(['Repo-wide rule', 'Version-specific rule']); expect(body.rules).toEqual(['Version-specific rule']);
}); });
it('GET /api/v1/context returns only repo-wide rules when no version is requested', async () => { it('GET /api/v1/context returns only repo-wide rules when no version is requested', async () => {
@@ -556,12 +560,13 @@ describe('API contract integration', () => {
expect(body.rules).toEqual(['Repo-wide rule only']); expect(body.rules).toEqual(['Repo-wide rule only']);
}); });
it('GET /api/v1/context deduplicates rules that appear in both repo-wide and version config', async () => { it('GET /api/v1/context versioned query returns only the version-specific rules row', async () => {
const repositoryId = seedRepo(db); const repositoryId = seedRepo(db);
const versionId = seedVersion(db, repositoryId, 'v3.0.0'); const versionId = seedVersion(db, repositoryId, 'v3.0.0');
const documentId = seedDocument(db, repositoryId, versionId); const documentId = seedDocument(db, repositoryId, versionId);
const sharedRule = 'Use TypeScript strict mode'; const sharedRule = 'Use TypeScript strict mode';
// Insert repo-wide NULL row — must NOT bleed into versioned query results.
db.prepare( db.prepare(
`INSERT INTO repository_configs (repository_id, version_id, rules, updated_at) `INSERT INTO repository_configs (repository_id, version_id, rules, updated_at)
VALUES (?, NULL, ?, ?)` VALUES (?, NULL, ?, ?)`
@@ -582,10 +587,35 @@ describe('API contract integration', () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
const body = await response.json(); const body = await response.json();
// sharedRule appears once, version-only rule appended. // Returns only the version-specific row as stored — no NULL row merge.
expect(body.rules).toEqual([sharedRule, 'Version-only rule']); expect(body.rules).toEqual([sharedRule, 'Version-only rule']);
}); });
it('GET /api/v1/context versioned query returns empty rules when only NULL row exists (no NULL contamination)', async () => {
const repositoryId = seedRepo(db);
const versionId = seedVersion(db, repositoryId, 'v1.0.0');
const documentId = seedDocument(db, repositoryId, versionId);
// Only a repo-wide NULL row exists — no version-specific config.
db.prepare(
`INSERT INTO repository_configs (repository_id, version_id, rules, updated_at)
VALUES (?, NULL, ?, ?)`
).run(repositoryId, JSON.stringify(['HEAD rules that must not contaminate v1']), NOW_S);
seedSnippet(db, { documentId, repositoryId, versionId, content: 'v1 content' });
const response = await getContext({
url: new URL(
`http://test/api/v1/context?libraryId=${encodeURIComponent(`${repositoryId}/v1.0.0`)}&query=${encodeURIComponent('v1 content')}`
)
} as never);
expect(response.status).toBe(200);
const body = await response.json();
// No version-specific config row → empty rules. NULL row must not bleed in.
expect(body.rules).toEqual([]);
});
it('GET /api/v1/context returns 404 with VERSION_NOT_FOUND when version does not exist', async () => { it('GET /api/v1/context returns 404 with VERSION_NOT_FOUND when version does not exist', async () => {
const repositoryId = seedRepo(db); const repositoryId = seedRepo(db);

View File

@@ -69,35 +69,25 @@ function getRules(
repositoryId: string, repositoryId: string,
versionId?: string versionId?: string
): string[] { ): string[] {
// Repo-wide rules (version_id IS NULL). if (!versionId) {
const repoRow = db // Unversioned query: return repo-wide (HEAD) rules only.
const row = db
.prepare< .prepare<
[string], [string],
RawRepoConfig RawRepoConfig
>(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`) >(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`)
.get(repositoryId); .get(repositoryId);
return parseRulesJson(row?.rules);
}
const repoRules = parseRulesJson(repoRow?.rules); // Versioned query: return only version-specific rules (no NULL row merge).
const row = db
if (!versionId) return repoRules;
// Version-specific rules.
const versionRow = db
.prepare< .prepare<
[string, string], [string, string],
RawRepoConfig RawRepoConfig
>(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id = ?`) >(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id = ?`)
.get(repositoryId, versionId); .get(repositoryId, versionId);
return parseRulesJson(row?.rules);
const versionRules = parseRulesJson(versionRow?.rules);
// Merge: repo-wide first, then version-specific (deduped by content).
const seen = new Set(repoRules);
const merged = [...repoRules];
for (const r of versionRules) {
if (!seen.has(r)) merged.push(r);
}
return merged;
} }
interface RawRepoState { interface RawRepoState {