@popoverai/dotrequirements 0.27.4 → 0.28.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/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 +182 -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 +16 -267
- package/dist/push/index.d.ts +3 -3
- package/dist/push/index.js +4 -4
- package/dist/requirements/style-guide.js +3 -3
- package/dist/schema/schemas.js +1 -1
- package/dist/sync/compare.d.ts +21 -0
- package/dist/sync/compare.js +285 -0
- package/dist/sync/execute.d.ts +48 -0
- package/dist/sync/execute.js +186 -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 +91 -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 +118 -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/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
|
@@ -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,48 @@
|
|
|
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
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
|
|
44
|
+
* writes require a project slug in `auth`; a read-only share run has none and
|
|
45
|
+
* its plan carries no cloud-writing actions.
|
|
46
|
+
*/
|
|
47
|
+
export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string): Promise<SyncOutcome>;
|
|
48
|
+
//# sourceMappingURL=execute.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
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 * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
11
|
+
import { getConvexUrl } from "../config.js";
|
|
12
|
+
import { api } from "../convex.js";
|
|
13
|
+
import { mergeMetadataWithRawFrontmatter, parseFilesForPushIndividually, WEB_APP_URL, } from "../push/core.js";
|
|
14
|
+
import { buildRequirementsFile } from "../schema/index.js";
|
|
15
|
+
import { resolveLocalPath, writeLocalDocument } from "./local-files.js";
|
|
16
|
+
function emptyOutcome() {
|
|
17
|
+
return {
|
|
18
|
+
cloudCreated: 0,
|
|
19
|
+
cloudUpdated: 0,
|
|
20
|
+
cloudDeleted: 0,
|
|
21
|
+
localWritten: 0,
|
|
22
|
+
localDeleted: 0,
|
|
23
|
+
conflicts: [],
|
|
24
|
+
invalid: [],
|
|
25
|
+
errors: [],
|
|
26
|
+
synced: [],
|
|
27
|
+
writeBackWarnings: [],
|
|
28
|
+
importWarnings: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
|
|
33
|
+
* writes require a project slug in `auth`; a read-only share run has none and
|
|
34
|
+
* its plan carries no cloud-writing actions.
|
|
35
|
+
*/
|
|
36
|
+
export async function executePlan(plan, cloud, auth, workspaceRoot) {
|
|
37
|
+
const outcome = emptyOutcome();
|
|
38
|
+
const client = new ConvexHttpClient(getConvexUrl());
|
|
39
|
+
const cloudById = new Map(cloud.documents.map((d) => [d.documentId, d]));
|
|
40
|
+
const requirementsDir = path.join(workspaceRoot, ".requirements");
|
|
41
|
+
const usedPaths = new Set();
|
|
42
|
+
for (const { doc, action } of plan) {
|
|
43
|
+
const name = doc.filePath ? path.basename(doc.filePath) : doc.title;
|
|
44
|
+
try {
|
|
45
|
+
switch (action) {
|
|
46
|
+
case "skip":
|
|
47
|
+
break;
|
|
48
|
+
case "skip_conflict":
|
|
49
|
+
outcome.conflicts.push(doc);
|
|
50
|
+
break;
|
|
51
|
+
case "invalid":
|
|
52
|
+
outcome.invalid.push(doc);
|
|
53
|
+
break;
|
|
54
|
+
case "cloud_write":
|
|
55
|
+
await cloudWrite(client, auth, doc, outcome);
|
|
56
|
+
break;
|
|
57
|
+
case "cloud_delete":
|
|
58
|
+
await cloudDelete(client, auth, doc, outcome);
|
|
59
|
+
break;
|
|
60
|
+
case "local_write":
|
|
61
|
+
localWrite(doc, cloudById, usedPaths, requirementsDir, outcome);
|
|
62
|
+
break;
|
|
63
|
+
case "local_delete":
|
|
64
|
+
localDelete(doc, outcome);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
outcome.errors.push({ name, error: errorDisplayMessage(err) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return outcome;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Servers throw ConvexError({kind, message}) for expected failures; production
|
|
76
|
+
* redacts the Error message to "Server Error" but preserves error.data.
|
|
77
|
+
* Surface the human-readable message rather than the blob.
|
|
78
|
+
*/
|
|
79
|
+
function errorDisplayMessage(err) {
|
|
80
|
+
const data = err.data;
|
|
81
|
+
if (data && typeof data.message === "string")
|
|
82
|
+
return data.message;
|
|
83
|
+
return err instanceof Error ? err.message : String(err);
|
|
84
|
+
}
|
|
85
|
+
async function cloudWrite(client, auth, doc, outcome) {
|
|
86
|
+
if (!auth.projectSlug || !doc.filePath) {
|
|
87
|
+
throw new Error("Cannot write to the cloud without project credentials.");
|
|
88
|
+
}
|
|
89
|
+
const { parsedFiles } = parseFilesForPushIndividually([doc.filePath]);
|
|
90
|
+
const parsed = parsedFiles[0];
|
|
91
|
+
if (!parsed)
|
|
92
|
+
throw new Error(`Could not parse ${path.basename(doc.filePath)}`);
|
|
93
|
+
const meta = parsed.metadata.document;
|
|
94
|
+
const saveResult = (await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
|
|
95
|
+
projectAuth: {
|
|
96
|
+
projectSlug: auth.projectSlug,
|
|
97
|
+
projectSecret: auth.projectSecret,
|
|
98
|
+
},
|
|
99
|
+
target: { type: "project", slug: auth.projectSlug },
|
|
100
|
+
documentId: meta?.id,
|
|
101
|
+
title: meta?.title ?? path.basename(doc.filePath),
|
|
102
|
+
markdownContent: parsed.markdownContent,
|
|
103
|
+
defaultPrefix: meta?.defaultPrefix,
|
|
104
|
+
runMarker: parsed.metadata.ctsRun,
|
|
105
|
+
dryRun: false,
|
|
106
|
+
}));
|
|
107
|
+
const documentId = typeof saveResult === "string" ? saveResult : saveResult.documentId;
|
|
108
|
+
// IMPORT-3.3: marker failure is never silent — surface the statuses the
|
|
109
|
+
// server reports as warnings.
|
|
110
|
+
if (typeof saveResult !== "string" && saveResult.importStatus) {
|
|
111
|
+
const status = saveResult.importStatus;
|
|
112
|
+
if (status === "invalid_marker" ||
|
|
113
|
+
status === "unsupported_version" ||
|
|
114
|
+
status === "already_used") {
|
|
115
|
+
outcome.importWarnings.push({
|
|
116
|
+
name: path.basename(doc.filePath),
|
|
117
|
+
status,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Created vs updated is decided by the cloud, not the frontmatter: a stale
|
|
122
|
+
// id (document deleted in cloud) makes the save mint a fresh document.
|
|
123
|
+
const wasCreate = documentId !== meta?.id;
|
|
124
|
+
if (wasCreate)
|
|
125
|
+
outcome.cloudCreated++;
|
|
126
|
+
else
|
|
127
|
+
outcome.cloudUpdated++;
|
|
128
|
+
outcome.synced.push({
|
|
129
|
+
name: path.basename(doc.filePath),
|
|
130
|
+
url: `${WEB_APP_URL}/documents/${documentId}`,
|
|
131
|
+
});
|
|
132
|
+
// SYNC-FAIL-2: the cloud document already exists at this point. A write-back
|
|
133
|
+
// failure is a warning on a synced document, never a sync failure (which
|
|
134
|
+
// would invite a retry that mints a duplicate).
|
|
135
|
+
try {
|
|
136
|
+
if (parsed.metadata.document)
|
|
137
|
+
parsed.metadata.document.id = documentId;
|
|
138
|
+
parsed.metadata.pulledAt = new Date().toISOString();
|
|
139
|
+
parsed.metadata.version = (parsed.metadata.version ?? 0) + 1;
|
|
140
|
+
const content = buildRequirementsFile(mergeMetadataWithRawFrontmatter(parsed.metadata, parsed.rawFrontmatter), parsed.markdownContent);
|
|
141
|
+
fs.writeFileSync(doc.filePath, content, "utf-8");
|
|
142
|
+
}
|
|
143
|
+
catch (writeErr) {
|
|
144
|
+
outcome.writeBackWarnings.push({
|
|
145
|
+
name: path.basename(doc.filePath),
|
|
146
|
+
filePath: doc.filePath,
|
|
147
|
+
documentId,
|
|
148
|
+
error: writeErr instanceof Error ? writeErr.message : String(writeErr),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function cloudDelete(client, auth, doc, outcome) {
|
|
153
|
+
if (!auth.projectSlug || !doc.documentId) {
|
|
154
|
+
throw new Error("Cannot delete from the cloud without project credentials.");
|
|
155
|
+
}
|
|
156
|
+
await client.mutation(api.documents.mutations.deleteForCli, {
|
|
157
|
+
projectAuth: {
|
|
158
|
+
projectSlug: auth.projectSlug,
|
|
159
|
+
projectSecret: auth.projectSecret,
|
|
160
|
+
},
|
|
161
|
+
target: { type: "project", slug: auth.projectSlug },
|
|
162
|
+
documentId: doc.documentId,
|
|
163
|
+
});
|
|
164
|
+
outcome.cloudDeleted++;
|
|
165
|
+
}
|
|
166
|
+
function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
|
|
167
|
+
const cloudDoc = doc.documentId ? cloudById.get(doc.documentId) : undefined;
|
|
168
|
+
if (!cloudDoc)
|
|
169
|
+
throw new Error(`No cloud content for ${doc.title}`);
|
|
170
|
+
if (!fs.existsSync(requirementsDir))
|
|
171
|
+
fs.mkdirSync(requirementsDir, { recursive: true });
|
|
172
|
+
const filePath = resolveLocalPath(cloudDoc, doc.filePath, usedPaths, requirementsDir);
|
|
173
|
+
usedPaths.add(filePath);
|
|
174
|
+
const changed = writeLocalDocument(filePath, cloudDoc);
|
|
175
|
+
if (changed)
|
|
176
|
+
outcome.localWritten++;
|
|
177
|
+
}
|
|
178
|
+
function localDelete(doc, outcome) {
|
|
179
|
+
if (!doc.filePath)
|
|
180
|
+
return;
|
|
181
|
+
if (fs.existsSync(doc.filePath)) {
|
|
182
|
+
fs.unlinkSync(doc.filePath);
|
|
183
|
+
outcome.localDeleted++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=execute.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync module: one comparator (compare.ts) over two snapshots
|
|
3
|
+
* (snapshot.ts), consumed by `dotreq diff` and `dotreq sync`.
|
|
4
|
+
*/
|
|
5
|
+
import type { ComparisonResult, DocumentComparison } from "./types.js";
|
|
6
|
+
export { compareSnapshots, duplicateLocalDocumentIds } from "./compare.js";
|
|
7
|
+
export { displayName, formatUnitDetail, formatVerdictLine, resolutionHint, scopeToken, } from "./render.js";
|
|
8
|
+
export { segmentBody } from "./segment.js";
|
|
9
|
+
export { acquireCloudSnapshot, acquireLocalSnapshot, type CloudAuth, readLocalDocument, } from "./snapshot.js";
|
|
10
|
+
export type { CloudDocument, CloudSnapshot, ComparisonResult, DocumentComparison, LocalDocument, LocalSnapshot, UnitDetail, Verdict, } from "./types.js";
|
|
11
|
+
export { verdictLabel } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Restrict a comparison to the documents named by scope tokens. Returns the
|
|
14
|
+
* matched documents plus any tokens that matched nothing, so the caller can
|
|
15
|
+
* report an unrecognized scope rather than silently comparing zero documents.
|
|
16
|
+
*/
|
|
17
|
+
export declare function filterByScope(result: ComparisonResult, scope: string[], workspaceRoot: string): {
|
|
18
|
+
documents: DocumentComparison[];
|
|
19
|
+
unmatched: string[];
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sync module: one comparator (compare.ts) over two snapshots
|
|
3
|
+
* (snapshot.ts), consumed by `dotreq diff` and `dotreq sync`.
|
|
4
|
+
*/
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
export { compareSnapshots, duplicateLocalDocumentIds } from "./compare.js";
|
|
7
|
+
export { displayName, formatUnitDetail, formatVerdictLine, resolutionHint, scopeToken, } from "./render.js";
|
|
8
|
+
export { segmentBody } from "./segment.js";
|
|
9
|
+
export { acquireCloudSnapshot, acquireLocalSnapshot, readLocalDocument, } from "./snapshot.js";
|
|
10
|
+
export { verdictLabel } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Does a document match one scope token? A token may name a local file (path or
|
|
13
|
+
* basename), a cloud document id, or a title — scope resolves against the union
|
|
14
|
+
* of both surfaces (SYNC-SCOPE-1), since a cloud-only document has no path.
|
|
15
|
+
*/
|
|
16
|
+
function documentMatchesToken(doc, token, workspaceRoot) {
|
|
17
|
+
if (doc.documentId && doc.documentId === token)
|
|
18
|
+
return true;
|
|
19
|
+
if (doc.title.toLowerCase() === token.toLowerCase())
|
|
20
|
+
return true;
|
|
21
|
+
if (doc.filePath) {
|
|
22
|
+
if (doc.filePath === token)
|
|
23
|
+
return true;
|
|
24
|
+
if (path.resolve(workspaceRoot, token) === doc.filePath)
|
|
25
|
+
return true;
|
|
26
|
+
if (path.basename(doc.filePath) === token)
|
|
27
|
+
return true;
|
|
28
|
+
if (path.resolve(token) === doc.filePath)
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Restrict a comparison to the documents named by scope tokens. Returns the
|
|
35
|
+
* matched documents plus any tokens that matched nothing, so the caller can
|
|
36
|
+
* report an unrecognized scope rather than silently comparing zero documents.
|
|
37
|
+
*/
|
|
38
|
+
export function filterByScope(result, scope, workspaceRoot) {
|
|
39
|
+
if (scope.length === 0)
|
|
40
|
+
return { documents: result.documents, unmatched: [] };
|
|
41
|
+
const matched = new Set();
|
|
42
|
+
const unmatched = [];
|
|
43
|
+
for (const token of scope) {
|
|
44
|
+
const hits = result.documents.filter((d) => documentMatchesToken(d, token, workspaceRoot));
|
|
45
|
+
if (hits.length === 0)
|
|
46
|
+
unmatched.push(token);
|
|
47
|
+
for (const hit of hits)
|
|
48
|
+
matched.add(hit);
|
|
49
|
+
}
|
|
50
|
+
return { documents: [...matched], unmatched };
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing cloud content into local `.requirements/` files — the local half of
|
|
3
|
+
* sync. Shared filename/collision logic (SYNC-WEB-CREATE-2) and the frontmatter
|
|
4
|
+
* build (SYNC-WRITE-1) live here so pull and sync agree.
|
|
5
|
+
*/
|
|
6
|
+
import type { CloudDocument } from "./types.js";
|
|
7
|
+
/** Sanitize a document title into a filename stem. */
|
|
8
|
+
export declare function sanitizeFileName(title: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Choose the path a cloud document is written to. An existing file linked by
|
|
11
|
+
* document ID keeps its path (SYNC-DISCOVERY-2.0); a new document lands in
|
|
12
|
+
* `.requirements/`, disambiguated by numeric suffix on collision and falling
|
|
13
|
+
* back to the document ID when the title sanitizes to nothing
|
|
14
|
+
* (SYNC-WEB-CREATE-2).
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveLocalPath(cloud: CloudDocument, existingPath: string | undefined, usedPaths: Set<string>, requirementsDir: string): string;
|
|
17
|
+
/** Read the ctsRun marker from an existing file's frontmatter, if any (IMPORT-1). */
|
|
18
|
+
export declare function readCtsRun(filePath: string): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Write a cloud document to a local file. `pulledAt` records when this content
|
|
21
|
+
* arrived (SYNC-WRITE-1.1); an existing ctsRun marker is carried forward
|
|
22
|
+
* (IMPORT-1). Returns true when the file's bytes actually changed — sync only
|
|
23
|
+
* touches files whose content changed (SYNC-WRITE-1.0).
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeLocalDocument(filePath: string, cloud: CloudDocument): boolean;
|
|
26
|
+
//# sourceMappingURL=local-files.d.ts.map
|