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.
- package/.codecarto/GUIDE.md +1 -1
- package/.codecarto/findings/architecture/SKILL.md +1 -0
- package/.codecarto/findings/contracts/SKILL.md +1 -0
- package/.codecarto/findings/defect-scan/SKILL.md +15 -1
- package/.codecarto/findings/defect-scan/passes/01-logic-and-correctness.md +1 -1
- package/.codecarto/findings/defect-scan/passes/02-error-handling.md +1 -1
- package/.codecarto/findings/defect-scan/passes/03-concurrency-and-resources.md +1 -1
- package/.codecarto/findings/defect-scan/passes/04-security-and-trust.md +1 -1
- package/.codecarto/findings/defect-scan/passes/05-api-contract-violations.md +2 -1
- package/.codecarto/findings/defect-scan/passes/06-config-and-environment.md +1 -1
- package/.codecarto/findings/defect-scan-mechanical/SKILL.md +3 -2
- package/.codecarto/findings/defect-scan-semantic/SKILL.md +2 -0
- package/.codecarto/findings/porting/SKILL.md +2 -1
- package/.codecarto/findings/protocols/SKILL.md +1 -0
- package/.codecarto/findings/reimplementation-spec/SKILL.md +1 -1
- package/.codecarto/templates/architecture-map.md +1 -1
- package/.codecarto/templates/defect-report.md +23 -0
- package/.codecarto/templates/mechanical-defects.md +22 -0
- package/.codecarto/templates/reverse-engineering-bundle.md +2 -2
- package/.codecarto/templates/semantic-defects.md +26 -0
- package/.codecarto/workflow/VALIDATE.md +1 -1
- package/.codecarto/workflow/pipeline-defect-scan.yaml +2 -0
- package/.codecarto/workflow/pipeline-full-with-audit.yaml +3 -1
- package/.codecarto/workflow/pipeline-full-with-deep-audit.yaml +5 -1
- package/.codecarto/workflow/pipeline-scout-first.yaml +5 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +4 -4
- package/agent-skill/codecartographer/references/deep-audit-synthesis.md +4 -1
- package/agent-skill/codecartographer/references/library.md +1 -1
- package/agent-skill/codecartographer/references/orchestration.md +1 -1
- package/dist/core/amendment.js +2 -2
- package/dist/core/completion.d.ts +5 -0
- package/dist/core/completion.js +18 -2
- package/dist/core/dashboard.js +5 -3
- package/dist/core/findings.d.ts +59 -0
- package/dist/core/findings.js +145 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +66 -1
- package/dist/core/library.js +161 -8
- package/dist/core/pipeline.js +15 -0
- package/dist/core/prompts.js +1 -1
- package/dist/core/status.js +14 -6
- package/dist/core/types.d.ts +6 -0
- package/dist/core/utils.d.ts +14 -0
- package/dist/core/utils.js +30 -0
- package/dist/core/workspace.js +11 -18
- package/dist/core/yaml.js +19 -4
- package/dist/extensions/codecarto/auto-runner.d.ts +2 -0
- package/dist/extensions/codecarto/broadside-flags.d.ts +7 -2
- package/dist/extensions/codecarto/broadside-flags.js +22 -9
- package/dist/extensions/codecarto/dashboard-narrator.js +1 -1
- package/dist/extensions/codecarto/index.js +40 -16
- package/dist/extensions/codecarto/phase-compaction.js +4 -0
- package/dist/mcp-server/server.js +65 -7
- package/package.json +2 -2
package/dist/core/completion.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/dashboard.js
CHANGED
|
@@ -452,7 +452,9 @@ function usagePhaseNote(phaseId, status) {
|
|
|
452
452
|
function renderActivityTimeline(runs) {
|
|
453
453
|
if (runs.length === 0)
|
|
454
454
|
return "";
|
|
455
|
-
|
|
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
|
+
}
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/library.d.ts
CHANGED
|
@@ -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<
|
|
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
|
}
|
package/dist/core/library.js
CHANGED
|
@@ -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 ??
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|