From 55739c18c70b8a71e959e48252c7d9667f376b28 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Sat, 26 Sep 2026 14:14:15 +0800 Subject: [PATCH] [CTX-0026] fix(registry): bind cached metadata to repository identity + validate recorded gitlinks Fixes #50 (PLUG-REG-003): bind cached metadata to repository identity - Add repository_source field to IndexMetadata to track the source repository - Invalidate cached metadata when repository URL changes (normalized comparison) - Preserve metadata for equivalent URL spellings (e.g., .git suffix variations) - Update sync-metadata.ts to store repository_source and check it for equality Fixes #52 (PLUG-REG-005): validate recorded gitlinks instead of checkout revisions - Change readSubmodulePins() to use 'git ls-tree HEAD plugins' instead of 'git submodule status' - This reads the gitlinks recorded in the parent commit rather than checkout status - Prevents local checkout drift from replacing the reviewed SHA in validation Tests: - Add test for metadata invalidation when repository changes - Add test for metadata preservation with equivalent URL spellings - Update all existing tests to include repository_source field --- scripts/registry-lib.ts | 17 +++++++-- scripts/sync-metadata.ts | 7 +++- scripts/validate-registry.ts | 12 +++--- tests/registry.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/scripts/registry-lib.ts b/scripts/registry-lib.ts index b2232e9..afc7fd0 100644 --- a/scripts/registry-lib.ts +++ b/scripts/registry-lib.ts @@ -95,6 +95,7 @@ export interface IndexMetadata { license?: string; source?: string; fetched_at?: string; + repository_source?: string; } export interface IndexPlugin { @@ -1659,9 +1660,19 @@ export function buildIndex( previousById.set(plugin.id, plugin); } const plugins = entries - .map(({ entry, official }) => - toIndexPlugin(entry, official, previousById.get(entry.id)?.metadata), - ) + .map(({ entry, official }) => { + const previousPlugin = previousById.get(entry.id); + let metadata = previousPlugin?.metadata; + // Invalidate metadata if repository has changed + if ( + metadata?.repository_source !== undefined && + normalizeRepositoryUrl(metadata.repository_source) !== + normalizeRepositoryUrl(entry.repository) + ) { + metadata = undefined; + } + return toIndexPlugin(entry, official, metadata); + }) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const generatedAt = diff --git a/scripts/sync-metadata.ts b/scripts/sync-metadata.ts index df93883..225d375 100644 --- a/scripts/sync-metadata.ts +++ b/scripts/sync-metadata.ts @@ -168,6 +168,7 @@ export function manifestMetadata( text: string, source: string, expectedId: string, + repositoryUrl: string, previous: IndexMetadata | undefined, fetchedAt: string, ): ManifestMetadataResult { @@ -193,7 +194,7 @@ export function manifestMetadata( }; } - const metadata: IndexMetadata = { source }; + const metadata: IndexMetadata = { source, repository_source: repositoryUrl }; if (typeof plugin.version === "string") metadata.version = plugin.version; if (typeof plugin.description === "string") { metadata.description = plugin.description; @@ -207,7 +208,8 @@ export function manifestMetadata( previous.version === metadata.version && previous.description === metadata.description && previous.license === metadata.license && - previous.source === metadata.source; + previous.source === metadata.source && + previous.repository_source === metadata.repository_source; metadata.fetched_at = unchanged && previous.fetched_at ? previous.fetched_at : fetchedAt; return { metadata }; @@ -281,6 +283,7 @@ async function main(): Promise { text, source, entry.id, + entry.repository, existing, nowUtcSeconds(), ); diff --git a/scripts/validate-registry.ts b/scripts/validate-registry.ts index 09f813f..c8e546d 100644 --- a/scripts/validate-registry.ts +++ b/scripts/validate-registry.ts @@ -395,13 +395,13 @@ function checkSubmoduleMapping(entries: LoadedEntry[]): Diagnostic[] { } /** - * Read recorded submodule pins (`plugins/` to commit SHA) from - * `git submodule status`, which works without initialized submodules. - * Returns null when git cannot report the pins. + * Read recorded submodule gitlinks (`plugins/` to commit SHA) from + * `git ls-tree HEAD`, which reads the gitlinks recorded in the parent commit + * rather than checkout status. Returns null when git cannot report the pins. */ function readSubmodulePins(): Map | null { const result = Bun.spawnSync({ - cmd: ["git", "submodule", "status"], + cmd: ["git", "ls-tree", "HEAD", "plugins"], cwd: REPO_ROOT, stdout: "pipe", stderr: "pipe", @@ -410,7 +410,9 @@ function readSubmodulePins(): Map | null { if (result.exitCode !== 0) return null; const pins = new Map(); for (const line of result.stdout.toString().split("\n")) { - const match = line.match(/^[ +\-U]([0-9a-f]{40}) (\S+)/); + // Format: + // Example: 160000 commit abc123... plugins/activity + const match = line.match(/^160000 commit ([0-9a-f]{40})\t(.+)$/); const sha = match?.[1]; const path = match?.[2]; if (sha !== undefined && path !== undefined) pins.set(path, sha); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index fda24b7..6398b34 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -1547,12 +1547,14 @@ license = "MIT" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", undefined, "2024-01-01T00:00:00Z", ); expect(result.metadata).toEqual({ source: "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/example/sample-plugin", version: "1.0.0", description: "A sample plugin", license: "MIT", @@ -1571,6 +1573,7 @@ version = "1.0.0" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", undefined, "2024-01-01T00:00:00Z", ); @@ -1587,6 +1590,7 @@ version = "1.0.0" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", undefined, "2024-01-01T00:00:00Z", ); @@ -1600,6 +1604,7 @@ version = "1.0.0" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", undefined, "2024-01-01T00:00:00Z", ); @@ -1618,6 +1623,7 @@ license = "MIT" const previous = { source: "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/example/sample-plugin", version: "1.0.0", description: "A sample plugin", license: "MIT", @@ -1627,6 +1633,7 @@ license = "MIT" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", previous, "2024-01-02T00:00:00Z", ); @@ -1644,6 +1651,7 @@ license = "MIT" const previous = { source: "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/example/sample-plugin", version: "1.0.0", description: "A sample plugin", license: "MIT", @@ -1653,11 +1661,75 @@ license = "MIT" text, "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", "sample.plugin", + "https://github.com/example/sample-plugin", previous, "2024-01-02T00:00:00Z", ); expect(result.metadata?.fetched_at).toBe("2024-01-02T00:00:00Z"); }); + + test("invalidates metadata when repository changes", () => { + const entries = [loaded(baseEntry)]; + const previous: RegistryIndex = { + schema_version: 1, + generated_at: "2024-01-01T00:00:00Z", + plugins: [ + { + id: "sample.plugin", + name: "Sample Plugin", + kind: "plugin", + repository: "https://github.com/example/sample-plugin", + official: false, + signature_status: "unsigned", + metadata: { + version: "1.0.0", + description: "A sample plugin", + source: + "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/old-owner/sample-plugin", + fetched_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + const index = buildIndex(entries, previous); + expect(index.plugins[0]?.metadata).toBeUndefined(); + }); + + test("preserves metadata when repository URL has equivalent spelling", () => { + const entries = [loaded(baseEntry)]; + const previous: RegistryIndex = { + schema_version: 1, + generated_at: "2024-01-01T00:00:00Z", + plugins: [ + { + id: "sample.plugin", + name: "Sample Plugin", + kind: "plugin", + repository: "https://github.com/example/sample-plugin", + official: false, + signature_status: "unsigned", + metadata: { + version: "1.0.0", + description: "A sample plugin", + source: + "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/example/sample-plugin.git", + fetched_at: "2024-01-01T00:00:00Z", + }, + }, + ], + }; + const index = buildIndex(entries, previous); + expect(index.plugins[0]?.metadata).toEqual({ + version: "1.0.0", + description: "A sample plugin", + source: + "https://raw.githubusercontent.com/example/sample-plugin/HEAD/bitty-plugin.toml", + repository_source: "https://github.com/example/sample-plugin.git", + fetched_at: "2024-01-01T00:00:00Z", + }); + }); }); describe("bounded response reading", () => {