Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

### Fixed

- **Publish refuses to append one project's spec to another project's history** (#123). Slugs derive from the trailing path segment of `source_repo`, so `acme/whisper` and `openai/whisper` both produce `whisper`. Publishing the second wrote it as `v2` of the first: the entry's version history then spanned two unrelated codebases, and because `index.yaml` carries only the newest version's metadata, the index attributed the whole entry to whichever repo published last. `publishEntry` now compares the incoming `source_repo` against the one recorded on the newest version and fails before writing anything. The comparison is normalized across scheme, embedded credentials (`git@`, `user:token@`), `git@host:path` SCP syntax, a default port, a `www.` prefix, a trailing `.git`, trailing slashes, separators and casing, so re-publishing the same repository spelled differently is unaffected. A non-default port still distinguishes two services on one host. It is checked ahead of the content-hash branch, because a metadata-only update overwrote the wrong entry just as quietly. Unreadable or malformed recorded metadata skips the check rather than blocking the publish. `force_new_version` does not bypass it. A genuine repository move opts out with `allow_source_repo_change` (`allowSourceRepoChange` in `PublishOptions`). `docs/library-format.md` previously promised auto-suffixing (`-2`, `-3`) for this case, which was never implemented; it now documents the refusal instead.

## [0.16.0] — 2026-08-17

The field-test round. Immediately after 0.15.0 shipped, the same 7-phase deepseek-harness analysis was re-run on a fresh worktree through the published binary — this time with the driving chat as orchestrator — and the run's own gaps became this release (#111–#114): the very first completion appended decision rows without their promised heading, both full runs ended with no dashboard ever rendered, the analysis→publish→synthesis library loop was unreachable from any served text, and the terminal completion message named nothing actionable while skills, amendments, a publishable spec, and the usage log all sat unused.
Expand Down
99 changes: 99 additions & 0 deletions core/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,76 @@ export function deriveSlug(sourceRepo: string): string {
return RESERVED_SLUGS.has(safe) ? `${safe}-entry` : safe;
}

/**
* Reduce a repo reference to a comparable form so that spellings of the same
* repository do not read as different projects. Handles scheme, `git@host:path`
* SCP syntax, a `www.` host prefix, a trailing `.git`, trailing slashes,
* backslash separators, and case.
*
* This is deliberately conservative: it only collapses spellings that are
* unambiguously the same target. Anything it cannot prove equivalent stays
* distinct, because the caller treats "different" as a hard error.
*/
export function normalizeSourceRepo(sourceRepo: string): string {
let s = sourceRepo.trim().replace(/\\/g, "/");

// Order matters here. The scheme comes off first so that the SCP branch
// below sees only genuine `host:path` syntax, and the userinfo strip runs
// before either interpretation of a colon. Getting this order wrong makes
// `ssh://git@host/acme/tool` and `https://host/acme/tool` read as two
// different repositories, which would refuse a legitimate re-publish.
const hadScheme = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(s);
s = s.replace(/^[A-Za-z][A-Za-z0-9+.-]*:\/\//, "");

// git@, user:token@, oauth2:x-oauth-basic@ ...
s = s.replace(/^[^/@]+@/, "");

// SCP syntax (git@github.com:acme/tool) only ever appears without a scheme,
// where the colon separates host from path rather than naming a port. The
// dot requirement keeps a Windows drive letter (C:/repos/tool) out of this
// branch.
if (!hadScheme) s = s.replace(/^([^:/]+\.[^:/]+):(.+)$/, "$1/$2");

// A default port for the transports in play is not a distinguishing part of
// the address. Any other port is left alone, since two services on one host
// may genuinely differ by port.
s = s.replace(/^([^/]+):(?:22|80|443)(?=\/|$)/, "$1");

s = s.replace(/^www\./i, "");
s = s.replace(/\.git$/i, "");
s = s.replace(/\/+$/, "");
return s.toLowerCase();
}

/** True when two repo references denote the same repository. */
export function sameSourceRepo(a: string, b: string): boolean {
return normalizeSourceRepo(a) === normalizeSourceRepo(b);
}

/**
* The `source_repo` recorded on an entry's newest version, or null when it
* cannot be determined (no metadata, unreadable, or malformed). Null means
* "unknown", and callers treat unknown as permission to proceed rather than
* as a mismatch.
*/
async function readRecordedSourceRepo(
libraryRoot: string,
namespace: string | undefined,
slug: string,
version: number,
): Promise<string | null> {
const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
if (!(await pathExists(metaPath))) return null;
try {
const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
if (!isPlainObject(raw)) return null;
const recorded = raw.source_repo;
return typeof recorded === "string" && recorded.trim() !== "" ? recorded : null;
} catch {
return null;
}
}

// ─── Path helpers ───────────────────────────────────────────────────────────

function entryRoot(libraryRoot: string, namespace: string | undefined, slug: string): string {
Expand Down Expand Up @@ -309,6 +379,14 @@ export interface PublishOptions {
forceNewVersion?: boolean;
/** Skip the regen of index.yaml + INDEX.md (caller will batch). */
skipReindex?: boolean;
/**
* Permit publishing when the target entry's recorded `source_repo` differs
* from the incoming one. Off by default: a mismatch usually means two
* different projects derived the same slug, and continuing would append
* one project's spec to the other's version history. Set this only when
* the repository genuinely moved (rename, org transfer, host change).
*/
allowSourceRepoChange?: boolean;
}

export interface PublishResult {
Expand Down Expand Up @@ -349,6 +427,27 @@ export async function publishEntry(
const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1]!;
const newSpecHash = sha256(spec);

// Collision guard. Slugs derive from the trailing path segment of the source
// repo, so two unrelated projects (acme/whisper and openai/whisper) collapse
// onto one slug. Without this check the second publish would append its spec
// to the first project's version history, and the index would then report the
// newcomer's source_repo as though it owned every prior version. Checked
// before the idempotence branch below, because a metadata-only update would
// overwrite the wrong entry just as silently.
if (latestVersion > 0 && !opts.allowSourceRepoChange) {
const recorded = await readRecordedSourceRepo(libraryRoot, namespace, input.slug, latestVersion);
if (recorded !== null && !sameSourceRepo(recorded, input.source_repo)) {
const label = namespace ? `${namespace}/${input.slug}` : input.slug;
throw new Error(
`Refusing to publish: entry "${label}" v${latestVersion} records source_repo ` +
`"${recorded}", but this publish carries "${input.source_repo}". Publishing would ` +
`append this spec to a different project's version history. Pass an explicit, ` +
`distinct slug to shelve it separately, or set allowSourceRepoChange if the ` +
`repository itself moved.`,
);
}
}

// Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
// update metadata in place and return without bumping the version.
if (latestVersion > 0 && !opts.forceNewVersion) {
Expand Down
38 changes: 35 additions & 3 deletions docs/library-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,17 @@ codecarto-library/
- Must not equal any reserved name (`latest`, `index`, `entries`).

Slugs are derived from the source repo name by default (`my-cool-tool`
from `github.com/acme/my-cool-tool`), with collision-handling via a
trailing `-2`, `-3`, etc. If the namespace differs, the same slug is
permitted across namespaces.
from `github.com/acme/my-cool-tool`). If the namespace differs, the same
slug is permitted across namespaces.

Derivation uses only the trailing path segment, so two unrelated
repositories can land on one slug (`acme/whisper` and `openai/whisper`
both give `whisper`). CodeCartographer does not auto-suffix these.
Instead, publish refuses when the target entry already records a
different `source_repo`, because the alternative is appending one
project's spec to another project's version history. To shelve the
second project, pass an explicit distinct slug. See "Source repo
conflicts" below.

### Version directories

Expand Down Expand Up @@ -331,6 +339,30 @@ as a recoverable error: `codecarto library-reindex` repairs it.
rename), `latest` is repointed, and `index.yaml` + `INDEX.md` are
regenerated.

## Source repo conflicts

Before either branch above, publish compares the incoming `source_repo`
against the one recorded on the entry's newest version. If they denote
different repositories, publish fails and writes nothing.

Comparison is normalized, so these are all the same repository and none
of them trip the check: a `https://`, `http://`, `ssh://` or `git://`
scheme or none at all, `git@host:owner/name` SCP syntax, a `www.` host
prefix, a trailing `.git`, trailing slashes, backslash separators, and
any letter casing.

The check is skipped when the recorded `source_repo` cannot be read at
all (absent, unreadable, or malformed metadata), since there is nothing
to compare. It is *not* skipped by the force-new-version override, which
means "another version of this entry", not "overwrite a different
project".

A repository that genuinely moved (rename, org transfer, host change) is
the one legitimate case for changing the recorded value. Override it
with `allow_source_repo_change` on `codecarto_publish`, or
`allowSourceRepoChange` in `PublishOptions` when calling the core
directly.

## Git interaction

CodeCartographer is conservative about git operations on the library:
Expand Down
10 changes: 9 additions & 1 deletion mcp-server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,10 @@ export async function handlePublish(args: Record<string, unknown>) {
confidentiality,
generation,
},
{ forceNewVersion: args.force_new_version === true },
{
forceNewVersion: args.force_new_version === true,
allowSourceRepoChange: args.allow_source_repo_change === true,
},
);

const lines = [
Expand Down Expand Up @@ -1095,6 +1098,11 @@ const TOOLS = [
},
},
force_new_version: { type: "boolean" },
allow_source_repo_change: {
type: "boolean",
description:
"Permit publishing when the target entry already records a different source_repo. Off by default, because a mismatch usually means two projects derived the same slug and the spec would land in the wrong version history. Set only when the repository itself moved.",
},
},
required: ["source_repo", "headline"],
},
Expand Down
Loading