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:
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user