codecartographer-pi 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,675 @@
1
+ // CodeCartographer library: on-disk store for reimplementation-spec
2
+ // artifacts produced by analysis runs. Versioned, optionally namespaced,
3
+ // detectable via a marker file at the library root.
4
+ //
5
+ // The schemas in this module match the public read-side contract in
6
+ // docs/library-format.md. Treat that document as authoritative — any
7
+ // breaking change here must also update the spec and bump the marker
8
+ // `schema_version`.
9
+ //
10
+ // Design notes:
11
+ // - The `latest` pointer is always a regular file (containing the
12
+ // version directory name as a single line), never a symlink. This
13
+ // is deterministic across platforms and avoids the elevation
14
+ // requirement for symlink creation on Windows.
15
+ // - publishEntry is content-hash idempotent: re-publishing the same
16
+ // spec bytes does not create a new version. Metadata-only changes
17
+ // (headline, tags, capabilities) update the existing latest
18
+ // metadata.yaml in place.
19
+ // - reindex regenerates index.yaml and INDEX.md from filesystem state.
20
+ // Treat both as derived artifacts; never hand-edit. Resolution
21
+ // recipe for git merge conflicts is documented in
22
+ // docs/library-format.md.
23
+ // - Git operations (`commitPublish`) shell out to the `git` binary.
24
+ // Failures are non-fatal — the caller decides how to surface them.
25
+ import { createHash } from "node:crypto";
26
+ import { spawn } from "node:child_process";
27
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
28
+ import { join, resolve } from "node:path";
29
+ import { isPlainObject, pathExists } from "./utils.js";
30
+ import { parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
31
+ // ─── Constants ──────────────────────────────────────────────────────────────
32
+ export const LIBRARY_MARKER_FILE = ".codecarto-library";
33
+ export const LIBRARY_INDEX_FILE = "index.yaml";
34
+ export const LIBRARY_INDEX_MD_FILE = "INDEX.md";
35
+ export const ENTRIES_DIR = "entries";
36
+ export const SPEC_FILE = "reimplementation-spec.md";
37
+ export const METADATA_FILE = "metadata.yaml";
38
+ export const LATEST_POINTER_FILE = "latest";
39
+ export const MARKER_SCHEMA_VERSION = 1;
40
+ export const INDEX_SCHEMA_VERSION = 1;
41
+ const SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
42
+ const RESERVED_SLUGS = new Set(["latest", "index", "entries"]);
43
+ const VERSION_DIR_RE = /^v(\d+)$/;
44
+ // ─── Marker / discovery ─────────────────────────────────────────────────────
45
+ export async function discoverLibrary(libraryPath) {
46
+ const markerPath = join(libraryPath, LIBRARY_MARKER_FILE);
47
+ if (!(await pathExists(markerPath)))
48
+ return null;
49
+ return readMarker(libraryPath);
50
+ }
51
+ export async function readMarker(libraryRoot) {
52
+ const markerPath = join(libraryRoot, LIBRARY_MARKER_FILE);
53
+ if (!(await pathExists(markerPath)))
54
+ return null;
55
+ try {
56
+ const raw = await readFile(markerPath, "utf8");
57
+ const parsed = JSON.parse(raw);
58
+ if (!isPlainObject(parsed))
59
+ return null;
60
+ return normalizeMarker(parsed);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ export async function writeMarker(libraryRoot, marker) {
67
+ await mkdir(libraryRoot, { recursive: true });
68
+ const markerPath = join(libraryRoot, LIBRARY_MARKER_FILE);
69
+ const normalized = normalizeMarker(marker);
70
+ const tempPath = `${markerPath}.${process.pid}.${Date.now()}.tmp`;
71
+ await writeFile(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
72
+ await rename(tempPath, markerPath);
73
+ }
74
+ function normalizeMarker(raw) {
75
+ const r = raw;
76
+ const schemaVersion = typeof r.schema_version === "number" ? r.schema_version : MARKER_SCHEMA_VERSION;
77
+ const name = typeof r.name === "string" && r.name.trim() !== "" ? r.name.trim() : "codecarto-library";
78
+ const namespaced = typeof r.namespaced === "boolean" ? r.namespaced : false;
79
+ const out = { schema_version: schemaVersion, name, namespaced };
80
+ if (typeof r.visibility === "string" && isVisibility(r.visibility))
81
+ out.visibility = r.visibility;
82
+ if (typeof r.created_at === "string")
83
+ out.created_at = r.created_at;
84
+ return out;
85
+ }
86
+ function isVisibility(v) {
87
+ return v === "internal" || v === "shared" || v === "public";
88
+ }
89
+ // ─── Slug helpers ───────────────────────────────────────────────────────────
90
+ export function isValidSlug(slug) {
91
+ if (typeof slug !== "string")
92
+ return false;
93
+ if (RESERVED_SLUGS.has(slug))
94
+ return false;
95
+ return SLUG_RE.test(slug);
96
+ }
97
+ /**
98
+ * Derive a slug from a source repo URL or path. The last meaningful path
99
+ * component is lowercased and non-`[a-z0-9-]` characters are coerced to `-`.
100
+ * Caller is responsible for collision handling — derived slugs may already
101
+ * exist in the library and the calling UX (Pi or MCP) is the right place
102
+ * to ask the user about it.
103
+ */
104
+ export function deriveSlug(sourceRepo) {
105
+ const cleaned = sourceRepo.replace(/\.git$/i, "").replace(/\\/g, "/");
106
+ const parts = cleaned.split("/").filter((p) => p.length > 0);
107
+ const last = parts[parts.length - 1] ?? "entry";
108
+ const slug = last
109
+ .toLowerCase()
110
+ .replace(/[^a-z0-9-]+/g, "-")
111
+ .replace(/^-+|-+$/g, "")
112
+ .replace(/-+/g, "-")
113
+ .slice(0, 64);
114
+ const safe = slug.length === 0 || !/^[a-z]/.test(slug) ? `entry-${slug}`.slice(0, 64) : slug;
115
+ return RESERVED_SLUGS.has(safe) ? `${safe}-entry` : safe;
116
+ }
117
+ // ─── Path helpers ───────────────────────────────────────────────────────────
118
+ function entryRoot(libraryRoot, namespace, slug) {
119
+ return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
120
+ }
121
+ function versionDir(libraryRoot, namespace, slug, version) {
122
+ return join(entryRoot(libraryRoot, namespace, slug), `v${version}`);
123
+ }
124
+ async function listVersionDirs(entryDir) {
125
+ if (!(await pathExists(entryDir)))
126
+ return [];
127
+ const entries = await readdir(entryDir, { withFileTypes: true });
128
+ const versions = [];
129
+ for (const e of entries) {
130
+ if (!e.isDirectory())
131
+ continue;
132
+ const m = VERSION_DIR_RE.exec(e.name);
133
+ if (m)
134
+ versions.push(Number.parseInt(m[1], 10));
135
+ }
136
+ return versions.sort((a, b) => a - b);
137
+ }
138
+ async function writeLatestPointer(entryDir, versionDirName) {
139
+ const latestPath = join(entryDir, LATEST_POINTER_FILE);
140
+ const tempPath = `${latestPath}.${process.pid}.${Date.now()}.tmp`;
141
+ await writeFile(tempPath, `${versionDirName}\n`, "utf8");
142
+ await rename(tempPath, latestPath);
143
+ }
144
+ async function readLatestPointer(entryDir) {
145
+ const latestPath = join(entryDir, LATEST_POINTER_FILE);
146
+ if (!(await pathExists(latestPath)))
147
+ return null;
148
+ try {
149
+ const raw = await readFile(latestPath, "utf8");
150
+ const trimmed = raw.trim();
151
+ return trimmed === "" ? null : trimmed;
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
158
+ const marker = await readMarker(libraryRoot);
159
+ if (!marker) {
160
+ throw new Error(`Not a CodeCartographer library: ${LIBRARY_MARKER_FILE} missing at ${libraryRoot}`);
161
+ }
162
+ if (!isValidSlug(input.slug)) {
163
+ throw new Error(`Invalid slug: "${input.slug}" (must match ${SLUG_RE.source}, not in ${[...RESERVED_SLUGS].join(",")})`);
164
+ }
165
+ if (marker.namespaced && (!input.namespace || input.namespace.trim() === "")) {
166
+ throw new Error(`Library is namespaced — input.namespace is required`);
167
+ }
168
+ if (!marker.namespaced && input.namespace) {
169
+ throw new Error(`Library is not namespaced — input.namespace must be omitted (got "${input.namespace}")`);
170
+ }
171
+ if (input.namespace !== undefined && !isValidSlug(input.namespace)) {
172
+ throw new Error(`Invalid namespace: "${input.namespace}" (same rules as slug)`);
173
+ }
174
+ const namespace = input.namespace;
175
+ const entryDir = entryRoot(libraryRoot, namespace, input.slug);
176
+ const existingVersions = await listVersionDirs(entryDir);
177
+ const latestVersion = existingVersions.length === 0 ? 0 : existingVersions[existingVersions.length - 1];
178
+ const newSpecHash = sha256(spec);
179
+ // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
180
+ // update metadata in place and return without bumping the version.
181
+ if (latestVersion > 0 && !opts.forceNewVersion) {
182
+ const latestVersionDir = versionDir(libraryRoot, namespace, input.slug, latestVersion);
183
+ const latestSpecPath = join(latestVersionDir, SPEC_FILE);
184
+ if (await pathExists(latestSpecPath)) {
185
+ const existingSpec = await readFile(latestSpecPath, "utf8");
186
+ if (sha256(existingSpec) === newSpecHash) {
187
+ const metadata = buildMetadata(input, latestVersion);
188
+ await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
189
+ if (!opts.skipReindex)
190
+ await reindex(libraryRoot);
191
+ return {
192
+ slug: input.slug,
193
+ namespace,
194
+ version: latestVersion,
195
+ isNewVersion: false,
196
+ entryDir,
197
+ versionDir: latestVersionDir,
198
+ };
199
+ }
200
+ }
201
+ }
202
+ const nextVersion = latestVersion + 1;
203
+ const finalVersionDir = versionDir(libraryRoot, namespace, input.slug, nextVersion);
204
+ const stagingDir = `${entryDir}.publish.${process.pid}.${Date.now()}`;
205
+ // Stage all files under a sibling directory, then atomically rename it
206
+ // into place as v<N>. If the rename fails partway, the staging dir is
207
+ // left for the user to inspect or remove.
208
+ await mkdir(stagingDir, { recursive: true });
209
+ try {
210
+ const metadata = buildMetadata({ ...input, provenance: input.provenance ?? { prior_version: latestVersion === 0 ? null : latestVersion, mutation_source: null } }, nextVersion);
211
+ await writeFile(join(stagingDir, SPEC_FILE), spec, "utf8");
212
+ await atomicWriteYaml(join(stagingDir, METADATA_FILE), metadata);
213
+ await mkdir(entryDir, { recursive: true });
214
+ await rename(stagingDir, finalVersionDir);
215
+ }
216
+ catch (err) {
217
+ // Best-effort cleanup of the staging directory.
218
+ try {
219
+ await rm(stagingDir, { recursive: true, force: true });
220
+ }
221
+ catch {
222
+ // swallow — leave the staging dir for diagnostics
223
+ }
224
+ throw err;
225
+ }
226
+ await writeLatestPointer(entryDir, `v${nextVersion}`);
227
+ if (!opts.skipReindex)
228
+ await reindex(libraryRoot);
229
+ return {
230
+ slug: input.slug,
231
+ namespace,
232
+ version: nextVersion,
233
+ isNewVersion: true,
234
+ entryDir,
235
+ versionDir: finalVersionDir,
236
+ };
237
+ }
238
+ function buildMetadata(input, version) {
239
+ const out = {
240
+ slug: input.slug,
241
+ version,
242
+ source_repo: input.source_repo,
243
+ analyzed_at: input.analyzed_at,
244
+ pipeline: input.pipeline,
245
+ codecarto_version: input.codecarto_version,
246
+ headline: input.headline,
247
+ tags: [...input.tags],
248
+ capabilities: [...input.capabilities],
249
+ generation: { ...input.generation },
250
+ };
251
+ if (input.namespace)
252
+ out.namespace = input.namespace;
253
+ if (input.source_commit)
254
+ out.source_commit = input.source_commit;
255
+ if (input.source_branch)
256
+ out.source_branch = input.source_branch;
257
+ if (typeof input.source_dirty === "boolean")
258
+ out.source_dirty = input.source_dirty;
259
+ if (input.scope_tier_counts)
260
+ out.scope_tier_counts = { ...input.scope_tier_counts };
261
+ if (input.confidentiality)
262
+ out.confidentiality = input.confidentiality;
263
+ if (input.provenance)
264
+ out.provenance = { ...input.provenance };
265
+ return out;
266
+ }
267
+ export async function readEntry(libraryRoot, ref) {
268
+ const marker = await readMarker(libraryRoot);
269
+ if (!marker) {
270
+ throw new Error(`Not a CodeCartographer library: ${LIBRARY_MARKER_FILE} missing at ${libraryRoot}`);
271
+ }
272
+ const entryDir = entryRoot(libraryRoot, ref.namespace, ref.slug);
273
+ if (!(await pathExists(entryDir))) {
274
+ throw new Error(`Entry not found: ${describeRef(ref)}`);
275
+ }
276
+ let version = ref.version;
277
+ if (version === undefined) {
278
+ const pointed = await readLatestPointer(entryDir);
279
+ if (pointed && VERSION_DIR_RE.test(pointed)) {
280
+ version = Number.parseInt(VERSION_DIR_RE.exec(pointed)[1], 10);
281
+ }
282
+ else {
283
+ const versions = await listVersionDirs(entryDir);
284
+ if (versions.length === 0) {
285
+ throw new Error(`No versions for ${describeRef(ref)}`);
286
+ }
287
+ version = versions[versions.length - 1];
288
+ }
289
+ }
290
+ const vDir = versionDir(libraryRoot, ref.namespace, ref.slug, version);
291
+ const specPath = join(vDir, SPEC_FILE);
292
+ const metaPath = join(vDir, METADATA_FILE);
293
+ if (!(await pathExists(specPath)) || !(await pathExists(metaPath))) {
294
+ throw new Error(`Incomplete entry: ${describeRef({ ...ref, version })}`);
295
+ }
296
+ const spec = await readFile(specPath, "utf8");
297
+ const rawMeta = parseSimpleYaml(await readFile(metaPath, "utf8"));
298
+ const metadata = normalizeMetadata(rawMeta, { slug: ref.slug, namespace: ref.namespace, version });
299
+ return { metadata, spec, versionDir: vDir };
300
+ }
301
+ function describeRef(ref) {
302
+ const nsPart = ref.namespace ? `${ref.namespace}/` : "";
303
+ const verPart = ref.version === undefined ? "latest" : `v${ref.version}`;
304
+ return `${nsPart}${ref.slug}@${verPart}`;
305
+ }
306
+ function normalizeMetadata(raw, fallback) {
307
+ if (!isPlainObject(raw)) {
308
+ throw new Error(`Malformed metadata for ${fallback.slug}`);
309
+ }
310
+ const r = raw;
311
+ // A real metadata.yaml must have at least one of these string fields. If
312
+ // not even one is present, the parsed object is structurally degenerate
313
+ // (e.g. `:::not valid yaml:::` parses to `{"": "..."}`) and we should
314
+ // reject rather than silently producing an empty-fields entry.
315
+ const requiredOneOf = ["slug", "source_repo", "headline", "pipeline"];
316
+ const hasAny = requiredOneOf.some((key) => typeof r[key] === "string" && r[key].trim() !== "");
317
+ if (!hasAny) {
318
+ throw new Error(`Malformed metadata for ${fallback.slug}: no recognizable fields`);
319
+ }
320
+ const generation = normalizeGeneration(r.generation);
321
+ const out = {
322
+ slug: typeof r.slug === "string" ? r.slug : fallback.slug,
323
+ version: typeof r.version === "number" ? r.version : fallback.version,
324
+ source_repo: typeof r.source_repo === "string" ? r.source_repo : "",
325
+ analyzed_at: typeof r.analyzed_at === "string" ? r.analyzed_at : "",
326
+ pipeline: typeof r.pipeline === "string" ? r.pipeline : "",
327
+ codecarto_version: typeof r.codecarto_version === "string" ? r.codecarto_version : "0.0.0",
328
+ headline: typeof r.headline === "string" ? r.headline : "",
329
+ tags: Array.isArray(r.tags) ? r.tags.filter((t) => typeof t === "string") : [],
330
+ capabilities: Array.isArray(r.capabilities) ? r.capabilities.filter((c) => typeof c === "string") : [],
331
+ generation,
332
+ };
333
+ if (typeof r.namespace === "string")
334
+ out.namespace = r.namespace;
335
+ else if (fallback.namespace)
336
+ out.namespace = fallback.namespace;
337
+ if (typeof r.source_commit === "string")
338
+ out.source_commit = r.source_commit;
339
+ if (typeof r.source_branch === "string")
340
+ out.source_branch = r.source_branch;
341
+ if (typeof r.source_dirty === "boolean")
342
+ out.source_dirty = r.source_dirty;
343
+ if (isPlainObject(r.scope_tier_counts)) {
344
+ const stc = r.scope_tier_counts;
345
+ const counts = {};
346
+ if (typeof stc.p0 === "number")
347
+ counts.p0 = stc.p0;
348
+ if (typeof stc.p1 === "number")
349
+ counts.p1 = stc.p1;
350
+ if (typeof stc.p2 === "number")
351
+ counts.p2 = stc.p2;
352
+ out.scope_tier_counts = counts;
353
+ }
354
+ if (typeof r.confidentiality === "string" && isVisibility(r.confidentiality)) {
355
+ out.confidentiality = r.confidentiality;
356
+ }
357
+ if (isPlainObject(r.provenance)) {
358
+ const p = r.provenance;
359
+ out.provenance = {
360
+ prior_version: typeof p.prior_version === "number" ? p.prior_version : null,
361
+ mutation_source: typeof p.mutation_source === "string" ? p.mutation_source : null,
362
+ };
363
+ }
364
+ return out;
365
+ }
366
+ function normalizeGeneration(raw) {
367
+ const defaults = {
368
+ surface: "drop-in",
369
+ agent: "unknown",
370
+ agent_version: "unknown",
371
+ model: "unknown",
372
+ model_vendor: "unknown",
373
+ reasoning: "unknown",
374
+ notes: "",
375
+ };
376
+ if (!isPlainObject(raw))
377
+ return defaults;
378
+ const r = raw;
379
+ const surface = isGenerationSurface(r.surface) ? r.surface : defaults.surface;
380
+ const reasoning = isReasoning(r.reasoning) ? r.reasoning : defaults.reasoning;
381
+ return {
382
+ surface,
383
+ agent: typeof r.agent === "string" ? r.agent : defaults.agent,
384
+ agent_version: typeof r.agent_version === "string" ? r.agent_version : defaults.agent_version,
385
+ model: typeof r.model === "string" ? r.model : defaults.model,
386
+ model_vendor: typeof r.model_vendor === "string" ? r.model_vendor : defaults.model_vendor,
387
+ reasoning,
388
+ notes: typeof r.notes === "string" ? r.notes : defaults.notes,
389
+ };
390
+ }
391
+ function isGenerationSurface(v) {
392
+ return v === "pi-extension" || v === "mcp-server" || v === "drop-in";
393
+ }
394
+ function isReasoning(v) {
395
+ return v === "high" || v === "medium" || v === "low" || v === "default" || v === "unknown";
396
+ }
397
+ export async function listEntries(libraryRoot, filter = {}) {
398
+ const marker = await readMarker(libraryRoot);
399
+ if (!marker)
400
+ return [];
401
+ // Prefer the index if it's present; fall back to a fresh reindex if not.
402
+ const indexPath = join(libraryRoot, LIBRARY_INDEX_FILE);
403
+ let index;
404
+ if (await pathExists(indexPath)) {
405
+ try {
406
+ const raw = await readFile(indexPath, "utf8");
407
+ index = normalizeIndex(parseSimpleYaml(raw), marker);
408
+ }
409
+ catch {
410
+ index = await reindex(libraryRoot);
411
+ }
412
+ }
413
+ else {
414
+ index = await reindex(libraryRoot);
415
+ }
416
+ return index.entries.filter((e) => {
417
+ if (filter.namespace !== undefined && e.namespace !== filter.namespace)
418
+ return false;
419
+ if (filter.slug !== undefined && e.slug !== filter.slug)
420
+ return false;
421
+ if (filter.source_repo !== undefined && e.source_repo !== filter.source_repo)
422
+ return false;
423
+ if (filter.tag !== undefined && !e.tags.includes(filter.tag))
424
+ return false;
425
+ return true;
426
+ });
427
+ }
428
+ // ─── Reindex ────────────────────────────────────────────────────────────────
429
+ export async function reindex(libraryRoot) {
430
+ const marker = await readMarker(libraryRoot);
431
+ if (!marker) {
432
+ throw new Error(`Not a CodeCartographer library: ${LIBRARY_MARKER_FILE} missing at ${libraryRoot}`);
433
+ }
434
+ const entries = [];
435
+ const namespacesSeen = new Set();
436
+ const entriesRoot = join(libraryRoot, ENTRIES_DIR);
437
+ if (await pathExists(entriesRoot)) {
438
+ if (marker.namespaced) {
439
+ const namespaceDirs = await readdir(entriesRoot, { withFileTypes: true });
440
+ for (const nsEntry of namespaceDirs) {
441
+ if (!nsEntry.isDirectory())
442
+ continue;
443
+ if (!isValidSlug(nsEntry.name))
444
+ continue;
445
+ namespacesSeen.add(nsEntry.name);
446
+ const nsDir = join(entriesRoot, nsEntry.name);
447
+ const slugDirs = await readdir(nsDir, { withFileTypes: true });
448
+ for (const slugEntry of slugDirs) {
449
+ if (!slugEntry.isDirectory())
450
+ continue;
451
+ if (!isValidSlug(slugEntry.name))
452
+ continue;
453
+ const built = await buildIndexEntry(libraryRoot, nsEntry.name, slugEntry.name);
454
+ if (built)
455
+ entries.push(built);
456
+ }
457
+ }
458
+ }
459
+ else {
460
+ const slugDirs = await readdir(entriesRoot, { withFileTypes: true });
461
+ for (const slugEntry of slugDirs) {
462
+ if (!slugEntry.isDirectory())
463
+ continue;
464
+ if (!isValidSlug(slugEntry.name))
465
+ continue;
466
+ const built = await buildIndexEntry(libraryRoot, undefined, slugEntry.name);
467
+ if (built)
468
+ entries.push(built);
469
+ }
470
+ }
471
+ }
472
+ entries.sort((a, b) => {
473
+ const nsA = a.namespace ?? "";
474
+ const nsB = b.namespace ?? "";
475
+ if (nsA !== nsB)
476
+ return nsA < nsB ? -1 : 1;
477
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
478
+ });
479
+ const index = {
480
+ schema_version: INDEX_SCHEMA_VERSION,
481
+ library_name: marker.name,
482
+ generated_at: new Date().toISOString(),
483
+ entry_count: entries.length,
484
+ namespaces: [...namespacesSeen].sort(),
485
+ entries,
486
+ };
487
+ await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
488
+ await writeIndexMarkdown(libraryRoot, index, marker);
489
+ return index;
490
+ }
491
+ async function buildIndexEntry(libraryRoot, namespace, slug) {
492
+ const entryDir = entryRoot(libraryRoot, namespace, slug);
493
+ const versions = await listVersionDirs(entryDir);
494
+ if (versions.length === 0)
495
+ return null;
496
+ const latest = versions[versions.length - 1];
497
+ const latestMetaPath = join(entryDir, `v${latest}`, METADATA_FILE);
498
+ if (!(await pathExists(latestMetaPath)))
499
+ return null;
500
+ let metadata;
501
+ try {
502
+ const rawMeta = parseSimpleYaml(await readFile(latestMetaPath, "utf8"));
503
+ metadata = normalizeMetadata(rawMeta, { slug, namespace, version: latest });
504
+ }
505
+ catch {
506
+ return null;
507
+ }
508
+ const entry = {
509
+ slug,
510
+ latest_version: latest,
511
+ versions: [...versions],
512
+ source_repo: metadata.source_repo,
513
+ headline: metadata.headline,
514
+ tags: [...metadata.tags],
515
+ capabilities: [...metadata.capabilities],
516
+ last_analyzed_at: metadata.analyzed_at,
517
+ last_codecarto_version: metadata.codecarto_version,
518
+ };
519
+ if (namespace)
520
+ entry.namespace = namespace;
521
+ if (metadata.confidentiality)
522
+ entry.confidentiality = metadata.confidentiality;
523
+ return entry;
524
+ }
525
+ function normalizeIndex(raw, marker) {
526
+ const fallback = {
527
+ schema_version: INDEX_SCHEMA_VERSION,
528
+ library_name: marker.name,
529
+ generated_at: new Date().toISOString(),
530
+ entry_count: 0,
531
+ namespaces: [],
532
+ entries: [],
533
+ };
534
+ if (!isPlainObject(raw))
535
+ return fallback;
536
+ const r = raw;
537
+ const entries = Array.isArray(r.entries) ? r.entries.filter(isPlainObject).map((e) => normalizeIndexEntry(e)) : [];
538
+ return {
539
+ schema_version: typeof r.schema_version === "number" ? r.schema_version : INDEX_SCHEMA_VERSION,
540
+ library_name: typeof r.library_name === "string" ? r.library_name : marker.name,
541
+ generated_at: typeof r.generated_at === "string" ? r.generated_at : fallback.generated_at,
542
+ entry_count: typeof r.entry_count === "number" ? r.entry_count : entries.length,
543
+ namespaces: Array.isArray(r.namespaces) ? r.namespaces.filter((n) => typeof n === "string") : [],
544
+ entries,
545
+ };
546
+ }
547
+ function normalizeIndexEntry(raw) {
548
+ const entry = {
549
+ slug: typeof raw.slug === "string" ? raw.slug : "",
550
+ latest_version: typeof raw.latest_version === "number" ? raw.latest_version : 1,
551
+ versions: Array.isArray(raw.versions) ? raw.versions.filter((v) => typeof v === "number") : [],
552
+ source_repo: typeof raw.source_repo === "string" ? raw.source_repo : "",
553
+ headline: typeof raw.headline === "string" ? raw.headline : "",
554
+ tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string") : [],
555
+ capabilities: Array.isArray(raw.capabilities) ? raw.capabilities.filter((c) => typeof c === "string") : [],
556
+ last_analyzed_at: typeof raw.last_analyzed_at === "string" ? raw.last_analyzed_at : "",
557
+ last_codecarto_version: typeof raw.last_codecarto_version === "string" ? raw.last_codecarto_version : "0.0.0",
558
+ };
559
+ if (typeof raw.namespace === "string")
560
+ entry.namespace = raw.namespace;
561
+ if (typeof raw.confidentiality === "string" && isVisibility(raw.confidentiality))
562
+ entry.confidentiality = raw.confidentiality;
563
+ return entry;
564
+ }
565
+ async function writeIndexMarkdown(libraryRoot, index, marker) {
566
+ const lines = [];
567
+ lines.push(`# ${escapeMd(marker.name)} — Library Index`);
568
+ lines.push("");
569
+ lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
570
+ lines.push("");
571
+ lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${index.namespaces.length || 1} ${index.namespaces.length === 1 ? "namespace" : "namespaces"}.`);
572
+ lines.push("");
573
+ if (marker.namespaced) {
574
+ const grouped = new Map();
575
+ for (const e of index.entries) {
576
+ const ns = e.namespace ?? "(unnamespaced)";
577
+ const bucket = grouped.get(ns) ?? [];
578
+ bucket.push(e);
579
+ grouped.set(ns, bucket);
580
+ }
581
+ const namespaces = [...grouped.keys()].sort();
582
+ for (const ns of namespaces) {
583
+ const bucket = grouped.get(ns);
584
+ lines.push(`## ${escapeMd(ns)} (${bucket.length} ${bucket.length === 1 ? "entry" : "entries"})`);
585
+ lines.push("");
586
+ lines.push("| Slug | Latest | Headline | Tags |");
587
+ lines.push("|---|---|---|---|");
588
+ for (const e of bucket) {
589
+ lines.push(formatIndexRow(e, marker.namespaced));
590
+ }
591
+ lines.push("");
592
+ }
593
+ }
594
+ else {
595
+ lines.push("| Slug | Latest | Headline | Tags |");
596
+ lines.push("|---|---|---|---|");
597
+ for (const e of index.entries) {
598
+ lines.push(formatIndexRow(e, marker.namespaced));
599
+ }
600
+ lines.push("");
601
+ }
602
+ const content = lines.join("\n");
603
+ const path = join(libraryRoot, LIBRARY_INDEX_MD_FILE);
604
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
605
+ await writeFile(tempPath, content, "utf8");
606
+ await rename(tempPath, path);
607
+ }
608
+ function formatIndexRow(e, namespaced) {
609
+ const pathPart = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}/latest/` : `${ENTRIES_DIR}/${e.slug}/latest/`;
610
+ const slugLink = `[${escapeMd(e.slug)}](${pathPart})`;
611
+ const headline = escapeMd(e.headline).replace(/\n+/g, " ");
612
+ const tags = e.tags.length === 0 ? "" : e.tags.map(escapeMd).join(", ");
613
+ return `| ${slugLink} | v${e.latest_version} | ${headline} | ${tags} |`;
614
+ }
615
+ function escapeMd(value) {
616
+ return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
617
+ }
618
+ // ─── Atomic YAML write ──────────────────────────────────────────────────────
619
+ async function atomicWriteYaml(path, value) {
620
+ const serialized = `${stringifySimpleYaml(value)}\n`;
621
+ const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
622
+ await writeFile(tempPath, serialized, "utf8");
623
+ await rename(tempPath, path);
624
+ }
625
+ // ─── Hash ───────────────────────────────────────────────────────────────────
626
+ function sha256(content) {
627
+ return createHash("sha256").update(content, "utf8").digest("hex");
628
+ }
629
+ /**
630
+ * Optional convenience: stage and commit publish output. Never pushes.
631
+ * On any failure, returns `{ ok: false, skipped: <reason> }` rather than
632
+ * throwing — the publish itself has already succeeded, and the caller
633
+ * decides whether to surface the commit failure to the user.
634
+ */
635
+ export async function commitPublish(libraryRoot, message, opts = {}) {
636
+ const cwd = resolve(libraryRoot);
637
+ if (!(await pathExists(join(cwd, ".git")))) {
638
+ return { ok: false, skipped: "not-a-git-repo" };
639
+ }
640
+ try {
641
+ if (opts.addAll !== false) {
642
+ const add = await runGit(cwd, ["add", "--", "."]);
643
+ if (!add.ok)
644
+ return { ok: false, skipped: "error", message: add.stderr };
645
+ }
646
+ const status = await runGit(cwd, ["status", "--porcelain"]);
647
+ if (!status.ok)
648
+ return { ok: false, skipped: "error", message: status.stderr };
649
+ if (status.stdout.trim() === "") {
650
+ return { ok: false, skipped: "nothing-to-commit" };
651
+ }
652
+ const commit = await runGit(cwd, ["commit", "-m", message]);
653
+ if (!commit.ok)
654
+ return { ok: false, skipped: "error", message: commit.stderr };
655
+ return { ok: true };
656
+ }
657
+ catch {
658
+ return { ok: false, skipped: "git-missing" };
659
+ }
660
+ }
661
+ function runGit(cwd, args) {
662
+ return new Promise((resolvePromise) => {
663
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
664
+ let stdout = "";
665
+ let stderr = "";
666
+ child.stdout.on("data", (b) => {
667
+ stdout += b.toString("utf8");
668
+ });
669
+ child.stderr.on("data", (b) => {
670
+ stderr += b.toString("utf8");
671
+ });
672
+ child.on("error", () => resolvePromise({ ok: false, stdout, stderr }));
673
+ child.on("close", (code) => resolvePromise({ ok: code === 0, stdout, stderr }));
674
+ });
675
+ }