diff --git a/src/routes/api/v1/api-contract.integration.test.ts b/src/routes/api/v1/api-contract.integration.test.ts index 82ae634..342bed2 100644 --- a/src/routes/api/v1/api-contract.integration.test.ts +++ b/src/routes/api/v1/api-contract.integration.test.ts @@ -446,7 +446,11 @@ describe('API contract integration', () => { const repositoryId = seedRepo(db); const versionId = seedVersion(db, repositoryId, 'v18.3.0'); 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, { documentId, 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 versionId = seedVersion(db, repositoryId, 'v2.0.0'); 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( `INSERT INTO repository_configs (repository_id, version_id, rules, updated_at) VALUES (?, NULL, ?, ?)` @@ -529,8 +533,8 @@ describe('API contract integration', () => { expect(response.status).toBe(200); const body = await response.json(); - // Both repo-wide and version-specific rules should appear (deduped). - expect(body.rules).toEqual(['Repo-wide rule', 'Version-specific rule']); + // Only the version-specific rule should appear — NULL row must not contaminate. + expect(body.rules).toEqual(['Version-specific rule']); }); 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']); }); - 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 versionId = seedVersion(db, repositoryId, 'v3.0.0'); const documentId = seedDocument(db, repositoryId, versionId); const sharedRule = 'Use TypeScript strict mode'; + // Insert repo-wide NULL row — must NOT bleed into versioned query results. db.prepare( `INSERT INTO repository_configs (repository_id, version_id, rules, updated_at) VALUES (?, NULL, ?, ?)` @@ -582,10 +587,35 @@ describe('API contract integration', () => { expect(response.status).toBe(200); 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']); }); + 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 () => { const repositoryId = seedRepo(db); diff --git a/src/routes/api/v1/context/+server.ts b/src/routes/api/v1/context/+server.ts index ed711dc..214713c 100644 --- a/src/routes/api/v1/context/+server.ts +++ b/src/routes/api/v1/context/+server.ts @@ -69,35 +69,25 @@ function getRules( repositoryId: string, versionId?: string ): string[] { - // Repo-wide rules (version_id IS NULL). - const repoRow = db - .prepare< - [string], - RawRepoConfig - >(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`) - .get(repositoryId); + if (!versionId) { + // Unversioned query: return repo-wide (HEAD) rules only. + const row = db + .prepare< + [string], + RawRepoConfig + >(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id IS NULL`) + .get(repositoryId); + return parseRulesJson(row?.rules); + } - const repoRules = parseRulesJson(repoRow?.rules); - - if (!versionId) return repoRules; - - // Version-specific rules. - const versionRow = db + // Versioned query: return only version-specific rules (no NULL row merge). + const row = db .prepare< [string, string], RawRepoConfig >(`SELECT rules FROM repository_configs WHERE repository_id = ? AND version_id = ?`) .get(repositoryId, versionId); - - 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; + return parseRulesJson(row?.rules); } interface RawRepoState {