@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.
Files changed (50) hide show
  1. package/README.md +24 -20
  2. package/dist/cli.js +34 -12
  3. package/dist/commands/aliases.d.ts +26 -0
  4. package/dist/commands/aliases.js +31 -0
  5. package/dist/commands/diff.d.ts +14 -0
  6. package/dist/commands/diff.js +62 -0
  7. package/dist/commands/init.js +5 -5
  8. package/dist/commands/link.d.ts +1 -1
  9. package/dist/commands/link.js +10 -7
  10. package/dist/commands/sync-common.d.ts +21 -0
  11. package/dist/commands/sync-common.js +24 -0
  12. package/dist/commands/sync.d.ts +26 -0
  13. package/dist/commands/sync.js +182 -0
  14. package/dist/convex.d.ts +1 -1
  15. package/dist/convex.js +2 -2
  16. package/dist/push/core.d.ts +18 -116
  17. package/dist/push/core.js +16 -267
  18. package/dist/push/index.d.ts +3 -3
  19. package/dist/push/index.js +4 -4
  20. package/dist/requirements/style-guide.js +3 -3
  21. package/dist/schema/schemas.js +1 -1
  22. package/dist/sync/compare.d.ts +21 -0
  23. package/dist/sync/compare.js +285 -0
  24. package/dist/sync/execute.d.ts +48 -0
  25. package/dist/sync/execute.js +186 -0
  26. package/dist/sync/index.d.ts +21 -0
  27. package/dist/sync/index.js +52 -0
  28. package/dist/sync/local-files.d.ts +26 -0
  29. package/dist/sync/local-files.js +91 -0
  30. package/dist/sync/plan.d.ts +39 -0
  31. package/dist/sync/plan.js +90 -0
  32. package/dist/sync/render.d.ts +17 -0
  33. package/dist/sync/render.js +46 -0
  34. package/dist/sync/segment.d.ts +40 -0
  35. package/dist/sync/segment.js +76 -0
  36. package/dist/sync/snapshot.d.ts +30 -0
  37. package/dist/sync/snapshot.js +118 -0
  38. package/dist/sync/types.d.ts +82 -0
  39. package/dist/sync/types.js +12 -0
  40. package/dist/templates/context-file-section.md +2 -1
  41. package/dist/templates/requirements-readme.js +3 -4
  42. package/dist/templates/requirements-readme.ts +3 -4
  43. package/dist/templates/skills/codebase-to-spec/SKILL.md +2 -2
  44. package/dist/utils/project-settings.d.ts +8 -2
  45. package/dist/utils/project-settings.js +47 -24
  46. package/package.json +1 -1
  47. package/dist/commands/pull.d.ts +0 -8
  48. package/dist/commands/pull.js +0 -230
  49. package/dist/commands/push.d.ts +0 -6
  50. 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