@popoverai/dotrequirements 0.27.4 → 0.29.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.
- package/README.md +24 -20
- package/dist/cli.js +34 -12
- package/dist/codebase-to-spec/compose.d.ts +3 -2
- package/dist/codebase-to-spec/compose.js +3 -3
- package/dist/commands/aliases.d.ts +26 -0
- package/dist/commands/aliases.js +31 -0
- package/dist/commands/diff.d.ts +14 -0
- package/dist/commands/diff.js +62 -0
- package/dist/commands/init.js +5 -5
- package/dist/commands/link.d.ts +1 -1
- package/dist/commands/link.js +10 -7
- package/dist/commands/sync-common.d.ts +21 -0
- package/dist/commands/sync-common.js +24 -0
- package/dist/commands/sync.d.ts +26 -0
- package/dist/commands/sync.js +187 -0
- package/dist/convex.d.ts +1 -1
- package/dist/convex.js +2 -2
- package/dist/push/core.d.ts +18 -116
- package/dist/push/core.js +23 -267
- package/dist/push/index.d.ts +3 -3
- package/dist/push/index.js +4 -4
- package/dist/requirements/index.d.ts +2 -0
- package/dist/requirements/index.js +6 -1
- package/dist/requirements/style-guide.js +4 -4
- package/dist/schema/browser.d.ts +1 -0
- package/dist/schema/browser.js +3 -0
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/parser.d.ts +3 -0
- package/dist/schema/parser.js +1 -1
- package/dist/schema/schemas.d.ts +24 -22
- package/dist/schema/schemas.js +9 -12
- package/dist/schema/title-markdown.d.ts +30 -0
- package/dist/schema/title-markdown.js +38 -0
- package/dist/sync/compare.d.ts +21 -0
- package/dist/sync/compare.js +285 -0
- package/dist/sync/execute.d.ts +53 -0
- package/dist/sync/execute.js +225 -0
- package/dist/sync/index.d.ts +21 -0
- package/dist/sync/index.js +52 -0
- package/dist/sync/local-files.d.ts +26 -0
- package/dist/sync/local-files.js +93 -0
- package/dist/sync/plan.d.ts +39 -0
- package/dist/sync/plan.js +90 -0
- package/dist/sync/render.d.ts +17 -0
- package/dist/sync/render.js +46 -0
- package/dist/sync/segment.d.ts +40 -0
- package/dist/sync/segment.js +76 -0
- package/dist/sync/snapshot.d.ts +30 -0
- package/dist/sync/snapshot.js +122 -0
- package/dist/sync/types.d.ts +82 -0
- package/dist/sync/types.js +12 -0
- package/dist/templates/context-file-section.md +2 -1
- package/dist/templates/example-requirements.js +0 -2
- package/dist/templates/example-requirements.ts +0 -2
- package/dist/templates/requirements-readme.js +3 -4
- package/dist/templates/requirements-readme.ts +3 -4
- package/dist/templates/skills/codebase-to-spec/SKILL.md +2 -2
- package/dist/utils/project-settings.d.ts +8 -2
- package/dist/utils/project-settings.js +47 -24
- package/package.json +1 -1
- package/dist/commands/pull.d.ts +0 -8
- package/dist/commands/pull.js +0 -230
- package/dist/commands/push.d.ts +0 -6
- package/dist/commands/push.js +0 -244
package/dist/schema/schemas.js
CHANGED
|
@@ -106,8 +106,10 @@ export function buildRequirementKey(prefix, number) {
|
|
|
106
106
|
// Metadata Schema
|
|
107
107
|
// =============================================================================
|
|
108
108
|
/**
|
|
109
|
-
* Metadata block at the top of every requirements file.
|
|
110
|
-
*
|
|
109
|
+
* Metadata block at the top of every requirements file. Everything is
|
|
110
|
+
* optional or auto-filled; the document's title lives in the BODY as its
|
|
111
|
+
* leading H1 (SYNC-TITLE-1), with a legacy frontmatter title tolerated as a
|
|
112
|
+
* fallback for files from before that convention.
|
|
111
113
|
*/
|
|
112
114
|
export const MetadataSchema = z.object({
|
|
113
115
|
version: z.number().optional(), // Informational - incremented on push
|
|
@@ -116,7 +118,7 @@ export const MetadataSchema = z.object({
|
|
|
116
118
|
document: z
|
|
117
119
|
.object({
|
|
118
120
|
id: z.string().optional(), // Cloud document link - filled by push when creating
|
|
119
|
-
title: z.string(), //
|
|
121
|
+
title: z.string().optional(), // Legacy fallback — the body's leading H1 is the title
|
|
120
122
|
defaultPrefix: z.string().optional(), // Editor hint for new requirement keys
|
|
121
123
|
})
|
|
122
124
|
.optional(),
|
|
@@ -136,18 +138,13 @@ export const CONVEX_ID_PATTERN = /^j[a-z0-9]{31}$/;
|
|
|
136
138
|
* SYNC-CLI-EDIT-1: Files with valid document.id update existing documents
|
|
137
139
|
*/
|
|
138
140
|
export function validateForPush(metadata) {
|
|
139
|
-
// Must have document section
|
|
141
|
+
// Must have document section. (A title is NOT required — untitled
|
|
142
|
+
// documents are allowed, and the title is derived from the markdown's
|
|
143
|
+
// leading H1 anyway; see docs/working/h1-as-title.md.)
|
|
140
144
|
if (!metadata.document) {
|
|
141
145
|
return {
|
|
142
146
|
valid: false,
|
|
143
|
-
reason: "Missing document section. Add a document section
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
// Must have title
|
|
147
|
-
if (!metadata.document.title || metadata.document.title.trim() === "") {
|
|
148
|
-
return {
|
|
149
|
-
valid: false,
|
|
150
|
-
reason: "Document section missing required title.",
|
|
147
|
+
reason: "Missing document section. Add a document section to sync this file.",
|
|
151
148
|
};
|
|
152
149
|
}
|
|
153
150
|
// Check document.id
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document title and the markdown's leading H1 are the same thing
|
|
3
|
+
* (DOC-TITLE-3). These helpers hold that contract at the web app's storage
|
|
4
|
+
* boundary: markdown coming *in* from Convex is split into title + body, and
|
|
5
|
+
* markdown going *out* is recomposed — so inside the app, `Content.markdown`
|
|
6
|
+
* is always the body (no leading H1) and `Content.title` carries the title.
|
|
7
|
+
*/
|
|
8
|
+
export interface SplitMarkdown {
|
|
9
|
+
/** The leading H1's text, or null when the markdown has no leading H1. */
|
|
10
|
+
title: string | null;
|
|
11
|
+
/** Everything after the leading H1 (or the whole input when there is none). */
|
|
12
|
+
body: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Split a leading ATX H1 (`# Title`) off the markdown.
|
|
16
|
+
*
|
|
17
|
+
* "Leading" means the first non-blank line (DOC-TITLE-3.0); an H1 further
|
|
18
|
+
* down is ordinary content and stays in the body (DOC-TITLE-3.4). Markdown
|
|
19
|
+
* allows up to 3 spaces of heading indentation — 4+ is a code block, so it
|
|
20
|
+
* is not a title.
|
|
21
|
+
*/
|
|
22
|
+
export declare function splitLeadingH1(markdown: string): SplitMarkdown;
|
|
23
|
+
/**
|
|
24
|
+
* Compose full-document markdown from a title and a body.
|
|
25
|
+
*
|
|
26
|
+
* The title becomes the leading H1 (DOC-TITLE-3.1); an untitled document
|
|
27
|
+
* gets no empty heading line (DOC-TITLE-3.3).
|
|
28
|
+
*/
|
|
29
|
+
export declare function composeMarkdownWithTitle(title: string, body: string): string;
|
|
30
|
+
//# sourceMappingURL=title-markdown.d.ts.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The document title and the markdown's leading H1 are the same thing
|
|
3
|
+
* (DOC-TITLE-3). These helpers hold that contract at the web app's storage
|
|
4
|
+
* boundary: markdown coming *in* from Convex is split into title + body, and
|
|
5
|
+
* markdown going *out* is recomposed — so inside the app, `Content.markdown`
|
|
6
|
+
* is always the body (no leading H1) and `Content.title` carries the title.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Split a leading ATX H1 (`# Title`) off the markdown.
|
|
10
|
+
*
|
|
11
|
+
* "Leading" means the first non-blank line (DOC-TITLE-3.0); an H1 further
|
|
12
|
+
* down is ordinary content and stays in the body (DOC-TITLE-3.4). Markdown
|
|
13
|
+
* allows up to 3 spaces of heading indentation — 4+ is a code block, so it
|
|
14
|
+
* is not a title.
|
|
15
|
+
*/
|
|
16
|
+
export function splitLeadingH1(markdown) {
|
|
17
|
+
const match = /^(?:[ \t]*\r?\n)* {0,3}# (.*)(?:\r?\n|$)/.exec(markdown);
|
|
18
|
+
if (!match) {
|
|
19
|
+
return { title: null, body: markdown };
|
|
20
|
+
}
|
|
21
|
+
const body = markdown.slice(match[0].length).replace(/^(\s*\r?\n)+/, "");
|
|
22
|
+
return { title: match[1].trim(), body };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Compose full-document markdown from a title and a body.
|
|
26
|
+
*
|
|
27
|
+
* The title becomes the leading H1 (DOC-TITLE-3.1); an untitled document
|
|
28
|
+
* gets no empty heading line (DOC-TITLE-3.3).
|
|
29
|
+
*/
|
|
30
|
+
export function composeMarkdownWithTitle(title, body) {
|
|
31
|
+
const trimmedTitle = title.trim();
|
|
32
|
+
if (!trimmedTitle)
|
|
33
|
+
return body;
|
|
34
|
+
if (!body.trim())
|
|
35
|
+
return `# ${trimmedTitle}\n`;
|
|
36
|
+
return `# ${trimmedTitle}\n\n${body}`;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=title-markdown.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repo/cloud comparator (DIFF-*). A pure function of two snapshots: it
|
|
3
|
+
* reads no disk and makes no network calls. `dotreq diff` renders its result,
|
|
4
|
+
* `dotreq sync` acts on it, CI asserts on it.
|
|
5
|
+
*/
|
|
6
|
+
import type { CloudSnapshot, ComparisonResult, LocalSnapshot } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Two local files carrying the same document id would both pair to one cloud
|
|
9
|
+
* document and both try to write it — last writer wins, silently destroying the
|
|
10
|
+
* first (SYNC-FAIL-3). Return the offending ids with their file paths so the
|
|
11
|
+
* caller can abort before any write.
|
|
12
|
+
*/
|
|
13
|
+
export declare function duplicateLocalDocumentIds(local: LocalSnapshot): Array<{
|
|
14
|
+
documentId: string;
|
|
15
|
+
filePaths: string[];
|
|
16
|
+
}>;
|
|
17
|
+
/**
|
|
18
|
+
* Compare the whole repo against the whole cloud, pairing documents by id.
|
|
19
|
+
*/
|
|
20
|
+
export declare function compareSnapshots(local: LocalSnapshot, cloud: CloudSnapshot): ComparisonResult;
|
|
21
|
+
//# sourceMappingURL=compare.d.ts.map
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The repo/cloud comparator (DIFF-*). A pure function of two snapshots: it
|
|
3
|
+
* reads no disk and makes no network calls. `dotreq diff` renders its result,
|
|
4
|
+
* `dotreq sync` acts on it, CI asserts on it.
|
|
5
|
+
*/
|
|
6
|
+
import { normalizeProse, requirementUnitMap, segmentBody, } from "./segment.js";
|
|
7
|
+
/** Whitespace-only normalization for the fast-path body equality check. */
|
|
8
|
+
function normalizeBody(body) {
|
|
9
|
+
return normalizeProse(body);
|
|
10
|
+
}
|
|
11
|
+
/** Do the single-valued document attributes diverge? A changed title or prefix
|
|
12
|
+
* is never an "addition" (both sides always have one), so it forces a conflict.
|
|
13
|
+
* A prefix present on one side only is treated as no divergence — omission is
|
|
14
|
+
* filled by inference (DOC-HEADER-11), not a genuine disagreement. */
|
|
15
|
+
function attributesDiverge(local, cloud) {
|
|
16
|
+
if ((local.title ?? "") !== cloud.title)
|
|
17
|
+
return true;
|
|
18
|
+
if (local.defaultPrefix &&
|
|
19
|
+
cloud.defaultPrefix &&
|
|
20
|
+
local.defaultPrefix !== cloud.defaultPrefix) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/** Compare two requirement blocks by their position-path unit maps. */
|
|
26
|
+
function compareRequirementBlocks(repoBlock, cloudBlock) {
|
|
27
|
+
let repoMap;
|
|
28
|
+
let cloudMap;
|
|
29
|
+
try {
|
|
30
|
+
repoMap = requirementUnitMap(repoBlock);
|
|
31
|
+
cloudMap = requirementUnitMap(cloudBlock);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// A block that won't parse here can't be classified — treat as diverged so
|
|
35
|
+
// it flags rather than silently syncing. (Both sides are pre-validated in
|
|
36
|
+
// practice, so this is a guard, not a live path.)
|
|
37
|
+
return {
|
|
38
|
+
result: "diverged",
|
|
39
|
+
repoOnlyPositions: [],
|
|
40
|
+
cloudOnlyPositions: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const cloudSubsetOfRepo = [...cloudMap].every(([id, v]) => repoMap.get(id) === v);
|
|
44
|
+
const repoSubsetOfCloud = [...repoMap].every(([id, v]) => cloudMap.get(id) === v);
|
|
45
|
+
const toPosition = (id) => id.replace(/^[^.]*/, "").replace(/^\./, "") || "(root)";
|
|
46
|
+
const repoOnlyPositions = [...repoMap.keys()]
|
|
47
|
+
.filter((id) => repoMap.get(id) !== cloudMap.get(id))
|
|
48
|
+
.map(toPosition);
|
|
49
|
+
const cloudOnlyPositions = [...cloudMap.keys()]
|
|
50
|
+
.filter((id) => cloudMap.get(id) !== repoMap.get(id))
|
|
51
|
+
.map(toPosition);
|
|
52
|
+
let result;
|
|
53
|
+
if (cloudSubsetOfRepo && repoSubsetOfCloud)
|
|
54
|
+
result = "equal";
|
|
55
|
+
else if (cloudSubsetOfRepo)
|
|
56
|
+
result = "repo_extends";
|
|
57
|
+
else if (repoSubsetOfCloud)
|
|
58
|
+
result = "cloud_extends";
|
|
59
|
+
else
|
|
60
|
+
result = "diverged";
|
|
61
|
+
return { result, repoOnlyPositions, cloudOnlyPositions };
|
|
62
|
+
}
|
|
63
|
+
/** Classify a paired document's bodies structurally (DIFF-4). */
|
|
64
|
+
function classifyBodies(repoBody, cloudBody) {
|
|
65
|
+
const repoUnits = segmentBody(repoBody);
|
|
66
|
+
const cloudUnits = segmentBody(cloudBody);
|
|
67
|
+
const details = [];
|
|
68
|
+
// Requirements match by key; index each side.
|
|
69
|
+
const repoReqs = new Map();
|
|
70
|
+
const cloudReqs = new Map();
|
|
71
|
+
for (const u of repoUnits)
|
|
72
|
+
if (u.kind === "requirement")
|
|
73
|
+
repoReqs.set(u.key, u.blockContent);
|
|
74
|
+
for (const u of cloudUnits)
|
|
75
|
+
if (u.kind === "requirement")
|
|
76
|
+
cloudReqs.set(u.key, u.blockContent);
|
|
77
|
+
let anyRepoExtra = false; // repo has something cloud lacks
|
|
78
|
+
let anyCloudExtra = false; // cloud has something repo lacks
|
|
79
|
+
// Matched requirement pairs.
|
|
80
|
+
for (const [key, repoBlock] of repoReqs) {
|
|
81
|
+
const cloudBlock = cloudReqs.get(key);
|
|
82
|
+
if (cloudBlock === undefined) {
|
|
83
|
+
anyRepoExtra = true;
|
|
84
|
+
details.push({ unit: key, verdict: "additions_in_repo" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const { result, repoOnlyPositions, cloudOnlyPositions } = compareRequirementBlocks(repoBlock, cloudBlock);
|
|
88
|
+
if (result === "repo_extends") {
|
|
89
|
+
anyRepoExtra = true;
|
|
90
|
+
details.push({
|
|
91
|
+
unit: key,
|
|
92
|
+
verdict: "additions_in_repo",
|
|
93
|
+
positions: repoOnlyPositions,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else if (result === "cloud_extends") {
|
|
97
|
+
anyCloudExtra = true;
|
|
98
|
+
details.push({
|
|
99
|
+
unit: key,
|
|
100
|
+
verdict: "additions_in_cloud",
|
|
101
|
+
positions: cloudOnlyPositions,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
else if (result === "diverged") {
|
|
105
|
+
anyRepoExtra = true;
|
|
106
|
+
anyCloudExtra = true;
|
|
107
|
+
details.push({
|
|
108
|
+
unit: key,
|
|
109
|
+
verdict: "conflict",
|
|
110
|
+
positions: [...new Set([...repoOnlyPositions, ...cloudOnlyPositions])],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Cloud-only requirement keys.
|
|
115
|
+
for (const key of cloudReqs.keys()) {
|
|
116
|
+
if (!repoReqs.has(key)) {
|
|
117
|
+
anyCloudExtra = true;
|
|
118
|
+
details.push({ unit: key, verdict: "additions_in_cloud" });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Prose: match by exact normalized text (multiset). Unmatched prose on either
|
|
122
|
+
// side is extra material — a reworded passage is unmatched on both.
|
|
123
|
+
const cloudProse = cloudUnits.filter((u) => u.kind === "prose");
|
|
124
|
+
const cloudProseUsed = new Array(cloudProse.length).fill(false);
|
|
125
|
+
for (const u of repoUnits) {
|
|
126
|
+
if (u.kind !== "prose")
|
|
127
|
+
continue;
|
|
128
|
+
const idx = cloudProse.findIndex((c, i) => !cloudProseUsed[i] && c.text === u.text);
|
|
129
|
+
if (idx === -1) {
|
|
130
|
+
anyRepoExtra = true;
|
|
131
|
+
details.push({
|
|
132
|
+
unit: proseLabel(u.text),
|
|
133
|
+
verdict: "additions_in_repo",
|
|
134
|
+
note: "prose",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
cloudProseUsed[idx] = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
cloudProse.forEach((c, i) => {
|
|
142
|
+
if (!cloudProseUsed[i]) {
|
|
143
|
+
anyCloudExtra = true;
|
|
144
|
+
details.push({
|
|
145
|
+
unit: proseLabel(c.text),
|
|
146
|
+
verdict: "additions_in_cloud",
|
|
147
|
+
note: "prose",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Order of the units common to both sides must agree; a reorder is a conflict.
|
|
152
|
+
const orderPreserved = matchedOrderAgrees(repoUnits, cloudUnits);
|
|
153
|
+
if (!orderPreserved) {
|
|
154
|
+
anyRepoExtra = true;
|
|
155
|
+
anyCloudExtra = true;
|
|
156
|
+
}
|
|
157
|
+
let verdict;
|
|
158
|
+
if (!anyRepoExtra && !anyCloudExtra)
|
|
159
|
+
verdict = "in_sync";
|
|
160
|
+
else if (anyRepoExtra && !anyCloudExtra)
|
|
161
|
+
verdict = "additions_in_repo";
|
|
162
|
+
else if (anyCloudExtra && !anyRepoExtra)
|
|
163
|
+
verdict = "additions_in_cloud";
|
|
164
|
+
else
|
|
165
|
+
verdict = "conflict";
|
|
166
|
+
return { verdict, details };
|
|
167
|
+
}
|
|
168
|
+
/** The subsequence of unit identities present on both sides must appear in the
|
|
169
|
+
* same relative order. Identity: requirement key, or prose text. */
|
|
170
|
+
function matchedOrderAgrees(repoUnits, cloudUnits) {
|
|
171
|
+
const identity = (u) => u.kind === "requirement" ? `R:${u.key}` : `P:${u.text}`;
|
|
172
|
+
const repoIds = repoUnits.map(identity);
|
|
173
|
+
const cloudIds = cloudUnits.map(identity);
|
|
174
|
+
const cloudIdSet = new Set(cloudIds);
|
|
175
|
+
const common = new Set(repoIds.filter((id) => cloudIdSet.has(id)));
|
|
176
|
+
const repoSeq = repoIds.filter((id) => common.has(id));
|
|
177
|
+
const cloudSeq = cloudIds.filter((id) => common.has(id));
|
|
178
|
+
return (repoSeq.length === cloudSeq.length &&
|
|
179
|
+
repoSeq.every((id, i) => id === cloudSeq[i]));
|
|
180
|
+
}
|
|
181
|
+
function proseLabel(text) {
|
|
182
|
+
const firstLine = text.split("\n")[0].trim();
|
|
183
|
+
return firstLine.length > 40
|
|
184
|
+
? `${firstLine.slice(0, 40)}…`
|
|
185
|
+
: firstLine || "(prose)";
|
|
186
|
+
}
|
|
187
|
+
/** Compare one document across surfaces. */
|
|
188
|
+
function compareDocument(local, cloud) {
|
|
189
|
+
const base = {
|
|
190
|
+
filePath: local.filePath,
|
|
191
|
+
documentId: cloud.documentId,
|
|
192
|
+
title: cloud.title,
|
|
193
|
+
verdict: "in_sync",
|
|
194
|
+
};
|
|
195
|
+
const diverges = attributesDiverge(local, cloud);
|
|
196
|
+
const body = local.body ?? "";
|
|
197
|
+
// Fast path: whitespace-identical bodies with matching attributes.
|
|
198
|
+
if (!diverges && normalizeBody(body) === normalizeBody(cloud.body)) {
|
|
199
|
+
return { ...base, verdict: "in_sync" };
|
|
200
|
+
}
|
|
201
|
+
const { verdict: bodyVerdict, details } = classifyBodies(body, cloud.body);
|
|
202
|
+
let verdict;
|
|
203
|
+
if (diverges) {
|
|
204
|
+
// A changed single-valued attribute is never additive → conflict.
|
|
205
|
+
verdict = "conflict";
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
verdict = bodyVerdict;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
...base,
|
|
212
|
+
verdict,
|
|
213
|
+
details: verdict === "in_sync" ? undefined : details,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Two local files carrying the same document id would both pair to one cloud
|
|
218
|
+
* document and both try to write it — last writer wins, silently destroying the
|
|
219
|
+
* first (SYNC-FAIL-3). Return the offending ids with their file paths so the
|
|
220
|
+
* caller can abort before any write.
|
|
221
|
+
*/
|
|
222
|
+
export function duplicateLocalDocumentIds(local) {
|
|
223
|
+
const byId = new Map();
|
|
224
|
+
for (const doc of local.documents) {
|
|
225
|
+
if (!doc.documentId)
|
|
226
|
+
continue;
|
|
227
|
+
const paths = byId.get(doc.documentId) ?? [];
|
|
228
|
+
paths.push(doc.filePath);
|
|
229
|
+
byId.set(doc.documentId, paths);
|
|
230
|
+
}
|
|
231
|
+
return [...byId.entries()]
|
|
232
|
+
.filter(([, paths]) => paths.length > 1)
|
|
233
|
+
.map(([documentId, filePaths]) => ({ documentId, filePaths }));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Compare the whole repo against the whole cloud, pairing documents by id.
|
|
237
|
+
*/
|
|
238
|
+
export function compareSnapshots(local, cloud) {
|
|
239
|
+
const cloudById = new Map(cloud.documents.map((d) => [d.documentId, d]));
|
|
240
|
+
const matchedCloudIds = new Set();
|
|
241
|
+
const documents = [];
|
|
242
|
+
for (const doc of local.documents) {
|
|
243
|
+
if (doc.parseError) {
|
|
244
|
+
// An invalid file that still names its document id claims the cloud
|
|
245
|
+
// counterpart: without this, the cloud doc would re-emit as
|
|
246
|
+
// "only_in_cloud" and a sync would write a duplicate local file beside
|
|
247
|
+
// the broken one (then hard-abort every later run on the duplicate id).
|
|
248
|
+
if (doc.documentId)
|
|
249
|
+
matchedCloudIds.add(doc.documentId);
|
|
250
|
+
documents.push({
|
|
251
|
+
filePath: doc.filePath,
|
|
252
|
+
documentId: doc.documentId,
|
|
253
|
+
title: doc.title ?? doc.filePath,
|
|
254
|
+
verdict: "invalid_file",
|
|
255
|
+
error: doc.parseError,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const cloudDoc = doc.documentId ? cloudById.get(doc.documentId) : undefined;
|
|
260
|
+
if (!cloudDoc) {
|
|
261
|
+
// No id, or an id the cloud doesn't have (deleted in cloud, or a stale
|
|
262
|
+
// link) — the document lives only in the repo (SYNC-WEB-DELETE-1.0).
|
|
263
|
+
documents.push({
|
|
264
|
+
filePath: doc.filePath,
|
|
265
|
+
documentId: doc.documentId,
|
|
266
|
+
title: doc.title ?? doc.filePath,
|
|
267
|
+
verdict: "only_in_repo",
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
matchedCloudIds.add(cloudDoc.documentId);
|
|
272
|
+
documents.push(compareDocument(doc, cloudDoc));
|
|
273
|
+
}
|
|
274
|
+
for (const cloudDoc of cloud.documents) {
|
|
275
|
+
if (!matchedCloudIds.has(cloudDoc.documentId)) {
|
|
276
|
+
documents.push({
|
|
277
|
+
documentId: cloudDoc.documentId,
|
|
278
|
+
title: cloudDoc.title,
|
|
279
|
+
verdict: "only_in_cloud",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { documents };
|
|
284
|
+
}
|
|
285
|
+
//# sourceMappingURL=compare.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executing a sync plan: cloud writes/deletes and local writes/deletes.
|
|
3
|
+
*
|
|
4
|
+
* Cloud writes reuse the push machinery (parse → saveWithRequirements →
|
|
5
|
+
* frontmatter write-back) so document-header/import-provenance fidelity is
|
|
6
|
+
* identical to a repo→cloud push. Local writes reuse local-files.ts.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlannedAction } from "./plan.js";
|
|
9
|
+
import type { CloudAuth } from "./snapshot.js";
|
|
10
|
+
import type { CloudSnapshot, DocumentComparison } from "./types.js";
|
|
11
|
+
export interface SyncOutcome {
|
|
12
|
+
cloudCreated: number;
|
|
13
|
+
cloudUpdated: number;
|
|
14
|
+
cloudDeleted: number;
|
|
15
|
+
localWritten: number;
|
|
16
|
+
localDeleted: number;
|
|
17
|
+
conflicts: DocumentComparison[];
|
|
18
|
+
invalid: DocumentComparison[];
|
|
19
|
+
errors: Array<{
|
|
20
|
+
name: string;
|
|
21
|
+
error: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** Synced-to-cloud documents with their web URLs (SYNC-LAND-1). */
|
|
24
|
+
synced: Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
}>;
|
|
28
|
+
/** SYNC-FAIL-2: cloud save succeeded but the local write-back failed. The
|
|
29
|
+
* document is synced; the warning carries the re-link recovery step. */
|
|
30
|
+
writeBackWarnings: Array<{
|
|
31
|
+
name: string;
|
|
32
|
+
filePath: string;
|
|
33
|
+
documentId: string;
|
|
34
|
+
error: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** IMPORT-3: run-marker failures — loud, never fatal, never silent. */
|
|
37
|
+
importWarnings: Array<{
|
|
38
|
+
name: string;
|
|
39
|
+
status: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** SYNC-TITLE-1.3: pushes that changed a document's title. */
|
|
42
|
+
renames: Array<{
|
|
43
|
+
from: string;
|
|
44
|
+
to: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
|
|
49
|
+
* writes require a project slug in `auth`; a read-only share run has none and
|
|
50
|
+
* its plan carries no cloud-writing actions.
|
|
51
|
+
*/
|
|
52
|
+
export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string): Promise<SyncOutcome>;
|
|
53
|
+
//# sourceMappingURL=execute.d.ts.map
|