codecartographer-pi 0.17.0 → 0.17.1

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.
Files changed (56) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/findings/architecture/SKILL.md +1 -0
  3. package/.codecarto/findings/contracts/SKILL.md +1 -0
  4. package/.codecarto/findings/defect-scan/SKILL.md +15 -1
  5. package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
  6. package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
  7. package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
  8. package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
  9. package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
  10. package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
  11. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
  12. package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
  13. package/.codecarto/findings/porting/SKILL.md +2 -1
  14. package/.codecarto/findings/protocols/SKILL.md +1 -0
  15. package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
  16. package/.codecarto/templates/architecture-map.md +1 -1
  17. package/.codecarto/templates/defect-report.md +23 -0
  18. package/.codecarto/templates/mechanical-defects.md +22 -0
  19. package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
  20. package/.codecarto/templates/semantic-defects.md +26 -0
  21. package/.codecarto/workflow/VALIDATE.md +1 -1
  22. package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
  23. package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
  24. package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
  25. package/.codecarto/workflow/pipeline-scout-first.yaml +5 -1
  26. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  27. package/README.md +4 -4
  28. package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
  29. package/agent-skill/codecartographer/references/library.md +1 -1
  30. package/agent-skill/codecartographer/references/orchestration.md +1 -1
  31. package/dist/core/amendment.js +2 -2
  32. package/dist/core/completion.d.ts +5 -0
  33. package/dist/core/completion.js +18 -2
  34. package/dist/core/dashboard.js +5 -3
  35. package/dist/core/findings.d.ts +59 -0
  36. package/dist/core/findings.js +145 -0
  37. package/dist/core/index.d.ts +1 -0
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/library.d.ts +66 -1
  40. package/dist/core/library.js +161 -8
  41. package/dist/core/pipeline.js +15 -0
  42. package/dist/core/prompts.js +1 -1
  43. package/dist/core/status.js +14 -6
  44. package/dist/core/types.d.ts +6 -0
  45. package/dist/core/utils.d.ts +14 -0
  46. package/dist/core/utils.js +30 -0
  47. package/dist/core/workspace.js +11 -18
  48. package/dist/core/yaml.js +19 -4
  49. package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
  50. package/dist/extensions/codecarto/broadside-flags.d.ts +7 -2
  51. package/dist/extensions/codecarto/broadside-flags.js +22 -9
  52. package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
  53. package/dist/extensions/codecarto/index.js +40 -16
  54. package/dist/extensions/codecarto/phase-compaction.js +4 -0
  55. package/dist/mcp-server/server.js +65 -7
  56. package/package.json +2 -2
@@ -2,7 +2,7 @@ import { appendFile, copyFile, mkdir, readFile, readdir, writeFile } from "node:
2
2
  import { join } from "node:path";
3
3
  import { getNextEligiblePhase, resolvePhase, validatePhaseOutput } from "./pipeline.js";
4
4
  import { applyHandoff, autoAssignIds, buildTerminalNextActions, loadHandoffFile, normalizeStatus } from "./status.js";
5
- import { dateOnly, pathExists, uniqueStrings } from "./utils.js";
5
+ import { dateOnly, newlineIfUnterminated, pathExists, uniqueStrings } from "./utils.js";
6
6
  import { getWorkspaceState, updateStatusAtomically } from "./workspace.js";
7
7
  /**
8
8
  * The Markdown a reader sees: content inside `<!-- -->` blocks removed by a
@@ -223,7 +223,7 @@ async function writeCompletionArtifacts(workspaceDir, phaseId, validation, times
223
223
  }
224
224
  const link = `[closeout](closeouts/${closeoutFile})`;
225
225
  if (!current.split(/\r?\n/).some((line) => line.includes(link))) {
226
- await appendFile(threadLogPath, `${entry}\n`, "utf8");
226
+ await appendFile(threadLogPath, `${newlineIfUnterminated(current)}${entry}\n`, "utf8");
227
227
  }
228
228
  // Mechanize the orchestrator loop's bookkeeping half (issue #98): decisions
229
229
  // reach DECISIONS.md and proposals reach CONVENTIONS.md at completion, so a
@@ -268,6 +268,21 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
268
268
  throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
269
269
  }
270
270
  }
271
+ // Closure integrity (#122, warning only): a handoff can close a carry-forward
272
+ // or open question the report never addressed — "closed in the handoff,
273
+ // resolved nowhere." The id of every claimed closure should appear somewhere
274
+ // in the primary output that claims to resolve it.
275
+ const warnings = [];
276
+ if (handoff && validation.outputPath) {
277
+ const closures = [...handoff.carry_forward_closures, ...handoff.open_question_closures].filter((id) => id?.trim());
278
+ if (closures.length > 0) {
279
+ const output = await readFile(validation.outputPath, "utf8").catch(() => "");
280
+ const unmentioned = closures.filter((id) => !output.includes(id));
281
+ if (unmentioned.length > 0) {
282
+ warnings.push(`The handoff closes ${unmentioned.join(", ")} but .codecarto/${validation.primaryOutput} never mentions ${unmentioned.length === 1 ? "that id" : "those ids"} — a closure should be visible in the report that claims to resolve it, not only in the handoff.`);
283
+ }
284
+ }
285
+ }
271
286
  const completionTimestamp = new Date().toISOString();
272
287
  let closeoutPath;
273
288
  let orchestratorCheckpoint;
@@ -343,5 +358,6 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
343
358
  updatedState,
344
359
  closeoutNotice: closeoutPath ? `Closeout: ${closeoutPath}` : undefined,
345
360
  orchestratorCheckpoint,
361
+ warnings,
346
362
  };
347
363
  }
@@ -452,7 +452,9 @@ function usagePhaseNote(phaseId, status) {
452
452
  function renderActivityTimeline(runs) {
453
453
  if (runs.length === 0)
454
454
  return "";
455
- const sorted = [...runs].sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
455
+ // Newest first. The comparator must return 0 for equal keys: returning -1
456
+ // in both directions left same-timestamp order implementation-defined (#134).
457
+ const sorted = [...runs].sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0));
456
458
  const visible = sorted.slice(0, TIMELINE_VISIBLE_COUNT);
457
459
  const overflow = sorted.slice(TIMELINE_VISIBLE_COUNT);
458
460
  const hasSessionLinks = sorted.some((run) => Boolean(run.session_file && safeRelativeHref(run.session_file)));
@@ -507,7 +509,7 @@ function renderCloseoutsList(inputs) {
507
509
  const closeouts = inputs.closeouts;
508
510
  if (closeouts.length === 0)
509
511
  return [`<section class="cc-card cc-closeouts" id="closeouts" aria-label="Closeouts" data-section>`, `<h2>Closeouts</h2>`, `<p class="cc-empty">No closeouts yet.</p>`, `</section>`].join("\n");
510
- const sorted = [...closeouts].sort((a, b) => (a.date < b.date ? 1 : -1));
512
+ const sorted = [...closeouts].sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
511
513
  const rows = sorted.map((c) => {
512
514
  const phase = getPhase(inputs.pipeline, c.phaseOrModule);
513
515
  const outputs = inputs.outputsPresent.get(c.phaseOrModule);
@@ -646,7 +648,7 @@ function getPhase(pipeline, phaseId) {
646
648
  return pipeline.phases.find((p) => p.id === phaseId);
647
649
  }
648
650
  function closeoutForPhase(closeouts, phaseId) {
649
- return [...closeouts].filter((c) => c.phaseOrModule === phaseId).sort((a, b) => (a.date < b.date ? 1 : -1))[0];
651
+ return [...closeouts].filter((c) => c.phaseOrModule === phaseId).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))[0];
650
652
  }
651
653
  function phaseAnchor(phaseId) {
652
654
  return `phase-${phaseId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * First scaffold version whose defect templates offer `verify at runtime`.
3
+ * A workspace scaffolded before it had no honest action for an unsettled
4
+ * finding, so the pairing violation is reported as a warning there instead
5
+ * of failing a phase mid-run.
6
+ */
7
+ export declare const FINDINGS_PAIRING_GATE_SCAFFOLD_VERSION = "0.17.1";
8
+ /** Evidence levels that mean "not settled by reading this source". */
9
+ export declare const UNSETTLED_EVIDENCE_LEVELS: ReadonlySet<string>;
10
+ /** Actions that assert the diagnosis is settled enough to act on. */
11
+ export declare const SETTLED_FIX_ACTIONS: ReadonlySet<string>;
12
+ /** The pre-porting action an unsettled finding takes. */
13
+ export declare const RUNTIME_VERIFY_ACTION = "verify at runtime";
14
+ export type FindingRow = {
15
+ /** The `## Pass N` heading the table sits under, when there is one. */
16
+ pass: string | null;
17
+ /** The `#` cell, verbatim. */
18
+ number: string;
19
+ /** Normalized Evidence Level cell (lowercase, markup stripped). */
20
+ evidence: string;
21
+ /** Normalized Action cell. */
22
+ action: string;
23
+ /** 1-based line of the row in the document. */
24
+ line: number;
25
+ };
26
+ export type FindingsCrossCheck = {
27
+ /** Violations that fail validation on a current scaffold. */
28
+ errors: string[];
29
+ /** Non-gating observations, rendered as NOTE lines. */
30
+ warnings: string[];
31
+ findings: FindingRow[];
32
+ };
33
+ /**
34
+ * Every data row of every table whose header carries both an Evidence Level
35
+ * and an Action column. Placeholder rows (both cells empty) are skipped so an
36
+ * untouched template section contributes nothing.
37
+ */
38
+ export declare function parseFindingsTables(content: string): FindingRow[];
39
+ /** Whether the report has an `## Open Questions` table, and how many filled rows it holds. */
40
+ export declare function parseOpenQuestionsTable(content: string): {
41
+ present: boolean;
42
+ rows: number;
43
+ };
44
+ /**
45
+ * Whether the pairing violation fails validation for this workspace. True from
46
+ * the scaffold version that introduced `verify at runtime`; an unversioned or
47
+ * older scaffold only warns.
48
+ */
49
+ export declare function findingsPairingGateActive(scaffoldVersion: string | undefined | null): boolean;
50
+ /**
51
+ * Run the cross-checks over a phase output. Returns empty results for any
52
+ * document without findings tables, so non-defect phases are untouched.
53
+ *
54
+ * @param content - The primary output's markdown.
55
+ * @param opts.gate - Whether the pairing violation is an error (current scaffold) or a warning.
56
+ */
57
+ export declare function crossCheckFindings(content: string, opts: {
58
+ gate: boolean;
59
+ }): FindingsCrossCheck;
@@ -0,0 +1,145 @@
1
+ // Mechanical cross-checks over a defect report's findings tables (issue #122).
2
+ //
3
+ // A run can register an open question saying "source alone cannot determine
4
+ // which" and, in the same run, ship one of that question's candidates as
5
+ // `strong inference` / `fix before porting`. Both artifacts validate on their
6
+ // own criteria because nothing reads what the evidence and action cells SAY.
7
+ // The checks here do: they are deterministic reads of two cells the model
8
+ // wrote itself, so the gating one cannot wedge an --auto run on a heuristic.
9
+ //
10
+ // The findings tables are header-identical across the three defect templates
11
+ // (`| # | Location | Defect | Severity | Evidence Level | Action |`, with an
12
+ // optional trailing Spec Reference), which is what makes a header-driven parse
13
+ // a parse and not a guess. Any table without both an Evidence Level and an
14
+ // Action column is left alone.
15
+ import { compareDottedVersions } from "./utils.js";
16
+ /**
17
+ * First scaffold version whose defect templates offer `verify at runtime`.
18
+ * A workspace scaffolded before it had no honest action for an unsettled
19
+ * finding, so the pairing violation is reported as a warning there instead
20
+ * of failing a phase mid-run.
21
+ */
22
+ export const FINDINGS_PAIRING_GATE_SCAFFOLD_VERSION = "0.17.1";
23
+ /** Evidence levels that mean "not settled by reading this source". */
24
+ export const UNSETTLED_EVIDENCE_LEVELS = new Set(["open question", "external-behavior claim"]);
25
+ /** Actions that assert the diagnosis is settled enough to act on. */
26
+ export const SETTLED_FIX_ACTIONS = new Set(["fix before porting", "fix now"]);
27
+ /** The pre-porting action an unsettled finding takes. */
28
+ export const RUNTIME_VERIFY_ACTION = "verify at runtime";
29
+ function normalizeCell(cell) {
30
+ return cell.replace(/[`*_]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
31
+ }
32
+ function isTableRow(line) {
33
+ return line.trim().startsWith("|");
34
+ }
35
+ function isSeparatorRow(line) {
36
+ return /^\s*\|?\s*:?-{3,}/.test(line);
37
+ }
38
+ function splitRow(line) {
39
+ const trimmed = line.trim();
40
+ const inner = trimmed.slice(1, trimmed.endsWith("|") ? -1 : undefined);
41
+ return inner.split("|").map((cell) => cell.trim());
42
+ }
43
+ /**
44
+ * Every data row of every table whose header carries both an Evidence Level
45
+ * and an Action column. Placeholder rows (both cells empty) are skipped so an
46
+ * untouched template section contributes nothing.
47
+ */
48
+ export function parseFindingsTables(content) {
49
+ const lines = content.split(/\r?\n/);
50
+ const rows = [];
51
+ let pass = null;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const heading = /^##\s+Pass\s+(\d+)\b/i.exec(lines[i]);
54
+ if (heading) {
55
+ pass = heading[1];
56
+ continue;
57
+ }
58
+ if (!isTableRow(lines[i]) || !isSeparatorRow(lines[i + 1] ?? ""))
59
+ continue;
60
+ const header = splitRow(lines[i]).map(normalizeCell);
61
+ const evidenceIdx = header.indexOf("evidence level");
62
+ const actionIdx = header.indexOf("action");
63
+ const numberIdx = header.indexOf("#");
64
+ if (evidenceIdx < 0 || actionIdx < 0)
65
+ continue;
66
+ for (let j = i + 2; j < lines.length && isTableRow(lines[j]); j++) {
67
+ const cells = splitRow(lines[j]);
68
+ const evidence = normalizeCell(cells[evidenceIdx] ?? "");
69
+ const action = normalizeCell(cells[actionIdx] ?? "");
70
+ if (!evidence && !action)
71
+ continue;
72
+ rows.push({ pass, number: numberIdx >= 0 ? (cells[numberIdx] ?? "").trim() : "", evidence, action, line: j + 1 });
73
+ i = j;
74
+ }
75
+ }
76
+ return rows;
77
+ }
78
+ /** Whether the report has an `## Open Questions` table, and how many filled rows it holds. */
79
+ export function parseOpenQuestionsTable(content) {
80
+ const lines = content.split(/\r?\n/);
81
+ const start = lines.findIndex((line) => /^##\s+Open Questions\s*$/i.test(line));
82
+ if (start < 0)
83
+ return { present: false, rows: 0 };
84
+ for (let i = start + 1; i < lines.length; i++) {
85
+ if (/^##\s/.test(lines[i]))
86
+ break; // next section, no table
87
+ if (!isTableRow(lines[i]) || !isSeparatorRow(lines[i + 1] ?? ""))
88
+ continue;
89
+ let rows = 0;
90
+ for (let j = i + 2; j < lines.length && isTableRow(lines[j]); j++) {
91
+ if (splitRow(lines[j]).some((cell) => cell.length > 0))
92
+ rows++;
93
+ }
94
+ return { present: true, rows };
95
+ }
96
+ return { present: true, rows: 0 };
97
+ }
98
+ /**
99
+ * Whether the pairing violation fails validation for this workspace. True from
100
+ * the scaffold version that introduced `verify at runtime`; an unversioned or
101
+ * older scaffold only warns.
102
+ */
103
+ export function findingsPairingGateActive(scaffoldVersion) {
104
+ if (!scaffoldVersion)
105
+ return false;
106
+ const comparison = compareDottedVersions(scaffoldVersion, FINDINGS_PAIRING_GATE_SCAFFOLD_VERSION);
107
+ return comparison !== null && comparison >= 0;
108
+ }
109
+ function describe(row) {
110
+ const where = row.pass ? `Pass ${row.pass} finding #${row.number || "?"}` : `finding #${row.number || "?"}`;
111
+ return `${where} (line ${row.line})`;
112
+ }
113
+ /**
114
+ * Run the cross-checks over a phase output. Returns empty results for any
115
+ * document without findings tables, so non-defect phases are untouched.
116
+ *
117
+ * @param content - The primary output's markdown.
118
+ * @param opts.gate - Whether the pairing violation is an error (current scaffold) or a warning.
119
+ */
120
+ export function crossCheckFindings(content, opts) {
121
+ const findings = parseFindingsTables(content);
122
+ const errors = [];
123
+ const warnings = [];
124
+ if (findings.length === 0)
125
+ return { errors, warnings, findings };
126
+ for (const row of findings) {
127
+ if (UNSETTLED_EVIDENCE_LEVELS.has(row.evidence) && SETTLED_FIX_ACTIONS.has(row.action)) {
128
+ const message = `${describe(row)}: evidence level "${row.evidence}" cannot carry the settled action "${row.action}" — ` +
129
+ `use "${RUNTIME_VERIFY_ACTION}" or "port differently" ("investigate" on maintenance pipelines), and list the finding under ## Open Questions.`;
130
+ if (opts.gate)
131
+ errors.push(message);
132
+ else
133
+ warnings.push(`${message} Warning only: this workspace's scaffold predates the verify-at-runtime vocabulary — refresh it to make this gating.`);
134
+ }
135
+ if (row.evidence === "observed fact" && row.action === RUNTIME_VERIFY_ACTION) {
136
+ warnings.push(`${describe(row)}: "observed fact" paired with "${RUNTIME_VERIFY_ACTION}" contradicts itself — a settled label with an unsettled action. Pick the one that is true.`);
137
+ }
138
+ }
139
+ const unsettled = findings.filter((row) => UNSETTLED_EVIDENCE_LEVELS.has(row.evidence) || row.action === RUNTIME_VERIFY_ACTION);
140
+ const openQuestions = parseOpenQuestionsTable(content);
141
+ if (unsettled.length > 0 && openQuestions.present && openQuestions.rows === 0) {
142
+ warnings.push(`${unsettled.length} unsettled finding(s) but the ## Open Questions table is empty — each open question / external-behavior claim finding needs a row there so the hedge travels with the finding, not only with the handoff.`);
143
+ }
144
+ return { errors, warnings, findings };
145
+ }
@@ -4,6 +4,7 @@ export * from "./yaml.ts";
4
4
  export * from "./status.ts";
5
5
  export * from "./amendment.ts";
6
6
  export * from "./pipeline.ts";
7
+ export * from "./findings.ts";
7
8
  export * from "./prompts.ts";
8
9
  export * from "./workspace.ts";
9
10
  export * from "./completion.ts";
@@ -7,6 +7,7 @@ export * from "./yaml.js";
7
7
  export * from "./status.js";
8
8
  export * from "./amendment.js";
9
9
  export * from "./pipeline.js";
10
+ export * from "./findings.js";
10
11
  export * from "./prompts.js";
11
12
  export * from "./workspace.js";
12
13
  export * from "./completion.js";
@@ -75,9 +75,39 @@ export interface LibraryIndex {
75
75
  namespaces: string[];
76
76
  entries: LibraryIndexEntry[];
77
77
  }
78
+ /** One older version whose recorded source_repo names a different repository than the newest version's. */
79
+ export interface ProvenanceConflictVersion {
80
+ version: number;
81
+ source_repo: string;
82
+ }
83
+ /** An entry whose version history spans more than one repository. */
84
+ export interface ProvenanceConflict {
85
+ slug: string;
86
+ namespace?: string;
87
+ /** The newest version — the one whose metadata the index reports for the whole entry. */
88
+ latest_version: number;
89
+ /** The source_repo recorded on the newest version, i.e. what index.yaml advertises. */
90
+ source_repo: string;
91
+ /** Older versions that disagree with it, ascending. Versions with missing or unreadable metadata are skipped. */
92
+ disagreeing_versions: ProvenanceConflictVersion[];
93
+ }
94
+ /**
95
+ * What `reindex` returns: the index it wrote, plus findings that ride on the
96
+ * return value only. `provenance_conflicts` is never serialized into
97
+ * index.yaml or INDEX.md — both shapes are ABI (docs/library-format.md).
98
+ */
99
+ export interface ReindexResult extends LibraryIndex {
100
+ provenance_conflicts: ProvenanceConflict[];
101
+ }
78
102
  export declare function discoverLibrary(libraryPath: string): Promise<LibraryMarker | null>;
79
103
  export declare function readMarker(libraryRoot: string): Promise<LibraryMarker | null>;
80
104
  export declare function writeMarker(libraryRoot: string, marker: LibraryMarker): Promise<void>;
105
+ /**
106
+ * The level a marker's `visibility` or an entry's `confidentiality` is taken
107
+ * to have when it declares none. It is the default `initLibrary` writes and
108
+ * the default docs/library-format.md gives the entry field.
109
+ */
110
+ export declare const DEFAULT_VISIBILITY: LibraryVisibility;
81
111
  export interface InitLibraryOptions {
82
112
  /** Library name (defaults to basename of the path). */
83
113
  name?: string;
@@ -153,6 +183,15 @@ export interface PublishOptions {
153
183
  * the repository genuinely moved (rename, org transfer, host change).
154
184
  */
155
185
  allowSourceRepoChange?: boolean;
186
+ /**
187
+ * Permit publishing when the entry's `confidentiality` is more restricted
188
+ * than the library's `visibility` — an `internal` entry into a `shared` or
189
+ * `public` library, a `shared` entry into a `public` one. Off by default:
190
+ * that direction exposes the spec to everyone the library reaches. Set
191
+ * this only when the exposure is intended. It does not change the
192
+ * confidentiality recorded on the entry.
193
+ */
194
+ allowConfidentialityMismatch?: boolean;
156
195
  }
157
196
  export interface PublishResult {
158
197
  slug: string;
@@ -162,6 +201,17 @@ export interface PublishResult {
162
201
  entryDir: string;
163
202
  versionDir: string;
164
203
  }
204
+ /**
205
+ * Thrown by `publishEntry` when the entry is more restricted than the library
206
+ * it is headed for. Nothing has been written when this is raised. It carries
207
+ * the two compared levels so a wrapper with a user to ask (Pi) can pose the
208
+ * question from the values rather than by matching the message.
209
+ */
210
+ export declare class ConfidentialityMismatchError extends Error {
211
+ readonly entryConfidentiality: LibraryVisibility;
212
+ readonly libraryVisibility: LibraryVisibility;
213
+ constructor(message: string, entryConfidentiality: LibraryVisibility, libraryVisibility: LibraryVisibility);
214
+ }
165
215
  export declare function publishEntry(libraryRoot: string, spec: string, input: PublishInput, opts?: PublishOptions): Promise<PublishResult>;
166
216
  export interface EntryRef {
167
217
  slug: string;
@@ -182,7 +232,22 @@ export interface ListEntriesFilter {
182
232
  source_repo?: string;
183
233
  }
184
234
  export declare function listEntries(libraryRoot: string, filter?: ListEntriesFilter): Promise<LibraryIndexEntry[]>;
185
- export declare function reindex(libraryRoot: string): Promise<LibraryIndex>;
235
+ export declare function reindex(libraryRoot: string): Promise<ReindexResult>;
236
+ /**
237
+ * Read-only check over the given entries: does every version of each entry
238
+ * record the same repository as its newest version? Comparison goes through
239
+ * `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
240
+ * syntax, casing where safe) do not count as disagreement. A version whose
241
+ * metadata is missing, unreadable, or lacks `source_repo` is skipped rather
242
+ * than reported — the stance the publish guard takes — and an entry whose
243
+ * newest version is unreadable is skipped entirely, since there is nothing to
244
+ * compare against. Never writes.
245
+ *
246
+ * A repository that genuinely moved and was re-published with
247
+ * `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
248
+ * reported the same way; the history alone cannot tell the two apart.
249
+ */
250
+ export declare function detectProvenanceConflicts(libraryRoot: string, entries: ReadonlyArray<Pick<LibraryIndexEntry, "slug" | "namespace">>): Promise<ProvenanceConflict[]>;
186
251
  export interface CommitOptions {
187
252
  addAll?: boolean;
188
253
  }
@@ -15,11 +15,16 @@
15
15
  // - publishEntry is content-hash idempotent: re-publishing the same
16
16
  // spec bytes does not create a new version. Metadata-only changes
17
17
  // (headline, tags, capabilities) update the existing latest
18
- // metadata.yaml in place.
18
+ // metadata.yaml in place; the version's recorded provenance is
19
+ // carried forward unless the publish supplies its own.
19
20
  // - reindex regenerates index.yaml and INDEX.md from filesystem state.
20
21
  // Treat both as derived artifacts; never hand-edit. Resolution
21
22
  // recipe for git merge conflicts is documented in
22
23
  // docs/library-format.md.
24
+ // - reindex also reports, on its return value and never in the index
25
+ // files, entries whose versions disagree about source_repo — the shape
26
+ // a slug collision left behind before publish refused cross-project
27
+ // appends. Repair is manual; see the "Provenance conflicts" section.
23
28
  // - Git operations (`commitPublish`) shell out to the `git` binary.
24
29
  // Failures are non-fatal — the caller decides how to surface them.
25
30
  import { createHash } from "node:crypto";
@@ -86,6 +91,16 @@ function normalizeMarker(raw) {
86
91
  function isVisibility(v) {
87
92
  return v === "internal" || v === "shared" || v === "public";
88
93
  }
94
+ /**
95
+ * The level a marker's `visibility` or an entry's `confidentiality` is taken
96
+ * to have when it declares none. It is the default `initLibrary` writes and
97
+ * the default docs/library-format.md gives the entry field.
98
+ */
99
+ export const DEFAULT_VISIBILITY = "internal";
100
+ // Ordered from most to least restricted. An entry may sit in a library at or
101
+ // below its own level; one above it would expose the entry to everyone the
102
+ // library reaches.
103
+ const VISIBILITY_RANK = { internal: 0, shared: 1, public: 2 };
89
104
  /**
90
105
  * Initialize a CodeCartographer library at the given path: create the
91
106
  * directory if needed, write the `.codecarto-library` marker if missing,
@@ -102,7 +117,7 @@ export async function initLibrary(libraryPath, options = {}) {
102
117
  schema_version: MARKER_SCHEMA_VERSION,
103
118
  name,
104
119
  namespaced: options.namespaced ?? false,
105
- visibility: options.visibility ?? "internal",
120
+ visibility: options.visibility ?? DEFAULT_VISIBILITY,
106
121
  created_at: new Date().toISOString(),
107
122
  };
108
123
  await writeMarker(libraryPath, marker);
@@ -193,10 +208,11 @@ export function sameSourceRepo(a, b) {
193
208
  return normalizeSourceRepo(a) === normalizeSourceRepo(b);
194
209
  }
195
210
  /**
196
- * The `source_repo` recorded on an entry's newest version, or null when it
211
+ * The `source_repo` recorded on one version of an entry, or null when it
197
212
  * cannot be determined (no metadata, unreadable, or malformed). Null means
198
213
  * "unknown", and callers treat unknown as permission to proceed rather than
199
- * as a mismatch.
214
+ * as a mismatch — the publish guard lets the publish through, and conflict
215
+ * detection skips the version.
200
216
  */
201
217
  async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
202
218
  const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
@@ -213,6 +229,26 @@ async function readRecordedSourceRepo(libraryRoot, namespace, slug, version) {
213
229
  return null;
214
230
  }
215
231
  }
232
+ /**
233
+ * The `provenance` block recorded on one version of an entry, or undefined
234
+ * when there is none to carry forward (no metadata, unreadable, malformed,
235
+ * or a version that never had the block — a hand-built entry, say). The
236
+ * metadata-only publish branch uses this so an identical re-publish, which
237
+ * neither surface sends `provenance` with, rewrites `metadata.yaml` without
238
+ * dropping what the version's original publish recorded.
239
+ */
240
+ async function readRecordedProvenance(libraryRoot, namespace, slug, version) {
241
+ const metaPath = join(versionDir(libraryRoot, namespace, slug, version), METADATA_FILE);
242
+ if (!(await pathExists(metaPath)))
243
+ return undefined;
244
+ try {
245
+ const raw = parseSimpleYaml(await readFile(metaPath, "utf8"));
246
+ return normalizeMetadata(raw, { slug, namespace, version }).provenance;
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }
216
252
  // ─── Path helpers ───────────────────────────────────────────────────────────
217
253
  function entryRoot(libraryRoot, namespace, slug) {
218
254
  return namespace ? join(libraryRoot, ENTRIES_DIR, namespace, slug) : join(libraryRoot, ENTRIES_DIR, slug);
@@ -253,6 +289,22 @@ async function readLatestPointer(entryDir) {
253
289
  return null;
254
290
  }
255
291
  }
292
+ /**
293
+ * Thrown by `publishEntry` when the entry is more restricted than the library
294
+ * it is headed for. Nothing has been written when this is raised. It carries
295
+ * the two compared levels so a wrapper with a user to ask (Pi) can pose the
296
+ * question from the values rather than by matching the message.
297
+ */
298
+ export class ConfidentialityMismatchError extends Error {
299
+ entryConfidentiality;
300
+ libraryVisibility;
301
+ constructor(message, entryConfidentiality, libraryVisibility) {
302
+ super(message);
303
+ this.name = "ConfidentialityMismatchError";
304
+ this.entryConfidentiality = entryConfidentiality;
305
+ this.libraryVisibility = libraryVisibility;
306
+ }
307
+ }
256
308
  export async function publishEntry(libraryRoot, spec, input, opts = {}) {
257
309
  const marker = await readMarker(libraryRoot);
258
310
  if (!marker) {
@@ -295,6 +347,29 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
295
347
  `allowSourceRepoChange in PublishOptions.`);
296
348
  }
297
349
  }
350
+ // Confidentiality guard. Levels are ordered internal < shared < public. An
351
+ // entry may sit in a library at or below its own level, but one more
352
+ // restricted than its library would be exposed to everyone the library
353
+ // reaches: an internal spec in a public library is a leak. Either side that
354
+ // declares nothing counts as internal — the marker default initLibrary
355
+ // writes, and the entry default docs/library-format.md documents — so a
356
+ // library with no visibility field accepts everything it did before. Like
357
+ // the collision guard this runs ahead of the idempotence branch, so a
358
+ // metadata-only update cannot reclassify an entry past it, and it fails
359
+ // before anything is written.
360
+ const entryConfidentiality = input.confidentiality ?? DEFAULT_VISIBILITY;
361
+ const libraryVisibility = marker.visibility ?? DEFAULT_VISIBILITY;
362
+ if (!opts.allowConfidentialityMismatch && VISIBILITY_RANK[entryConfidentiality] < VISIBILITY_RANK[libraryVisibility]) {
363
+ const label = namespace ? `${namespace}/${input.slug}` : input.slug;
364
+ const declared = input.confidentiality ? "" : " (the default when none is declared)";
365
+ throw new ConfidentialityMismatchError(`Refusing to publish: entry "${label}" has confidentiality "${entryConfidentiality}"${declared}, ` +
366
+ `but library "${marker.name}" has visibility "${libraryVisibility}". Publishing would expose a ` +
367
+ `spec classified "${entryConfidentiality}" to everyone the "${libraryVisibility}" library reaches. ` +
368
+ `Publish it to a library whose visibility is "${entryConfidentiality}" or narrower, declare a ` +
369
+ `confidentiality of "${libraryVisibility}" or wider if the spec may travel that far, or — if ` +
370
+ `this exposure is intended — re-publish with the mismatch allowed: ` +
371
+ `allow_confidentiality_mismatch on codecarto_publish, allowConfidentialityMismatch in PublishOptions.`, entryConfidentiality, libraryVisibility);
372
+ }
298
373
  // Content-hash idempotence: if the latest version's spec matches bytes-for-bytes,
299
374
  // update metadata in place and return without bumping the version.
300
375
  if (latestVersion > 0 && !opts.forceNewVersion) {
@@ -303,7 +378,11 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
303
378
  if (await pathExists(latestSpecPath)) {
304
379
  const existingSpec = await readFile(latestSpecPath, "utf8");
305
380
  if (sha256(existingSpec) === newSpecHash) {
306
- const metadata = buildMetadata(input, latestVersion);
381
+ // buildMetadata writes provenance only when the input carries it, and
382
+ // neither surface sends it on publish — so without this the rewrite
383
+ // would drop the block the version's original publish recorded.
384
+ const provenance = input.provenance ?? (await readRecordedProvenance(libraryRoot, namespace, input.slug, latestVersion));
385
+ const metadata = buildMetadata({ ...input, provenance }, latestVersion);
307
386
  await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
308
387
  if (!opts.skipReindex)
309
388
  await reindex(libraryRoot);
@@ -605,7 +684,10 @@ export async function reindex(libraryRoot) {
605
684
  };
606
685
  await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
607
686
  await writeIndexMarkdown(libraryRoot, index, marker);
608
- return index;
687
+ // Reported, not written. The index files above are ABI, so the conflict
688
+ // list travels on the return value only (see ReindexResult).
689
+ const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
690
+ return { ...index, provenance_conflicts };
609
691
  }
610
692
  async function buildIndexEntry(libraryRoot, namespace, slug) {
611
693
  const entryDir = entryRoot(libraryRoot, namespace, slug);
@@ -687,7 +769,10 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
687
769
  lines.push("");
688
770
  lines.push(`_Generated ${index.generated_at}. Do not edit by hand — regenerate with \`codecarto library-reindex\`._`);
689
771
  lines.push("");
690
- lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${index.namespaces.length || 1} ${index.namespaces.length === 1 ? "namespace" : "namespaces"}.`);
772
+ // A single-tenant library has no namespaces but is still one namespace's
773
+ // worth of entries; count once so the noun agrees with the number shown.
774
+ const namespaceCount = index.namespaces.length || 1;
775
+ lines.push(`**${index.entry_count} ${index.entry_count === 1 ? "entry" : "entries"}** across ${namespaceCount} ${namespaceCount === 1 ? "namespace" : "namespaces"}.`);
691
776
  lines.push("");
692
777
  if (marker.namespaced) {
693
778
  const grouped = new Map();
@@ -725,7 +810,11 @@ async function writeIndexMarkdown(libraryRoot, index, marker) {
725
810
  await rename(tempPath, path);
726
811
  }
727
812
  function formatIndexRow(e, namespaced) {
728
- const pathPart = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}/latest/` : `${ENTRIES_DIR}/${e.slug}/latest/`;
813
+ // Link to the newest version directory, not `latest/`: the pointer is a
814
+ // one-line regular file (see the module header), so a `latest/` link has
815
+ // nothing to land on when the library is browsed on a forge.
816
+ const entryPath = namespaced && e.namespace ? `${ENTRIES_DIR}/${e.namespace}/${e.slug}` : `${ENTRIES_DIR}/${e.slug}`;
817
+ const pathPart = `${entryPath}/v${e.latest_version}/`;
729
818
  const slugLink = `[${escapeMd(e.slug)}](${pathPart})`;
730
819
  const headline = escapeMd(e.headline).replace(/\n+/g, " ");
731
820
  const tags = e.tags.length === 0 ? "" : e.tags.map(escapeMd).join(", ");
@@ -737,6 +826,70 @@ function escapeMd(value) {
737
826
  // break out of the table cell (code scanning alert #3).
738
827
  return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
739
828
  }
829
+ // ─── Provenance conflicts ───────────────────────────────────────────────────
830
+ //
831
+ // Before publish refused cross-project appends (#123), two projects whose
832
+ // source_repo shared a trailing path segment derived the same slug, and the
833
+ // second publish landed as the next version of the first project's entry.
834
+ // Nothing rewrites those entries after the fact: the index reads only the
835
+ // newest version's metadata, so it advertises every version under whichever
836
+ // project published last, and a synthesis run reading the entry gets one
837
+ // project's spec history presented as another's (#148). Detection reads every
838
+ // version and reports the disagreement. Repair is deliberately manual —
839
+ // splitting an entry means inventing a slug, renumbering versions and
840
+ // repointing `latest`, all of which are paths docs/library-format.md calls
841
+ // ABI — so nothing here renames, renumbers, or moves anything.
842
+ /**
843
+ * Read-only check over the given entries: does every version of each entry
844
+ * record the same repository as its newest version? Comparison goes through
845
+ * `sameSourceRepo`, so spellings of one repository (scheme, `.git`, SCP
846
+ * syntax, casing where safe) do not count as disagreement. A version whose
847
+ * metadata is missing, unreadable, or lacks `source_repo` is skipped rather
848
+ * than reported — the stance the publish guard takes — and an entry whose
849
+ * newest version is unreadable is skipped entirely, since there is nothing to
850
+ * compare against. Never writes.
851
+ *
852
+ * A repository that genuinely moved and was re-published with
853
+ * `allowSourceRepoChange` leaves the same on-disk shape as a collision and is
854
+ * reported the same way; the history alone cannot tell the two apart.
855
+ */
856
+ export async function detectProvenanceConflicts(libraryRoot, entries) {
857
+ const conflicts = [];
858
+ for (const entry of entries) {
859
+ const conflict = await findProvenanceConflict(libraryRoot, entry.namespace, entry.slug);
860
+ if (conflict)
861
+ conflicts.push(conflict);
862
+ }
863
+ return conflicts;
864
+ }
865
+ async function findProvenanceConflict(libraryRoot, namespace, slug) {
866
+ const versions = await listVersionDirs(entryRoot(libraryRoot, namespace, slug));
867
+ if (versions.length < 2)
868
+ return null;
869
+ const latest = versions[versions.length - 1];
870
+ const latestRepo = await readRecordedSourceRepo(libraryRoot, namespace, slug, latest);
871
+ if (latestRepo === null)
872
+ return null;
873
+ const disagreeing = [];
874
+ for (const version of versions.slice(0, -1)) {
875
+ const recorded = await readRecordedSourceRepo(libraryRoot, namespace, slug, version);
876
+ if (recorded === null)
877
+ continue;
878
+ if (!sameSourceRepo(recorded, latestRepo))
879
+ disagreeing.push({ version, source_repo: recorded });
880
+ }
881
+ if (disagreeing.length === 0)
882
+ return null;
883
+ const conflict = {
884
+ slug,
885
+ latest_version: latest,
886
+ source_repo: latestRepo,
887
+ disagreeing_versions: disagreeing,
888
+ };
889
+ if (namespace)
890
+ conflict.namespace = namespace;
891
+ return conflict;
892
+ }
740
893
  // ─── Atomic YAML write ──────────────────────────────────────────────────────
741
894
  async function atomicWriteYaml(path, value) {
742
895
  const serialized = `${stringifySimpleYaml(value)}\n`;