@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.
Files changed (65) hide show
  1. package/README.md +24 -20
  2. package/dist/cli.js +34 -12
  3. package/dist/codebase-to-spec/compose.d.ts +3 -2
  4. package/dist/codebase-to-spec/compose.js +3 -3
  5. package/dist/commands/aliases.d.ts +26 -0
  6. package/dist/commands/aliases.js +31 -0
  7. package/dist/commands/diff.d.ts +14 -0
  8. package/dist/commands/diff.js +62 -0
  9. package/dist/commands/init.js +5 -5
  10. package/dist/commands/link.d.ts +1 -1
  11. package/dist/commands/link.js +10 -7
  12. package/dist/commands/sync-common.d.ts +21 -0
  13. package/dist/commands/sync-common.js +24 -0
  14. package/dist/commands/sync.d.ts +26 -0
  15. package/dist/commands/sync.js +187 -0
  16. package/dist/convex.d.ts +1 -1
  17. package/dist/convex.js +2 -2
  18. package/dist/push/core.d.ts +18 -116
  19. package/dist/push/core.js +23 -267
  20. package/dist/push/index.d.ts +3 -3
  21. package/dist/push/index.js +4 -4
  22. package/dist/requirements/index.d.ts +2 -0
  23. package/dist/requirements/index.js +6 -1
  24. package/dist/requirements/style-guide.js +4 -4
  25. package/dist/schema/browser.d.ts +1 -0
  26. package/dist/schema/browser.js +3 -0
  27. package/dist/schema/index.d.ts +1 -0
  28. package/dist/schema/index.js +1 -0
  29. package/dist/schema/parser.d.ts +3 -0
  30. package/dist/schema/parser.js +1 -1
  31. package/dist/schema/schemas.d.ts +24 -22
  32. package/dist/schema/schemas.js +9 -12
  33. package/dist/schema/title-markdown.d.ts +30 -0
  34. package/dist/schema/title-markdown.js +38 -0
  35. package/dist/sync/compare.d.ts +21 -0
  36. package/dist/sync/compare.js +285 -0
  37. package/dist/sync/execute.d.ts +53 -0
  38. package/dist/sync/execute.js +225 -0
  39. package/dist/sync/index.d.ts +21 -0
  40. package/dist/sync/index.js +52 -0
  41. package/dist/sync/local-files.d.ts +26 -0
  42. package/dist/sync/local-files.js +93 -0
  43. package/dist/sync/plan.d.ts +39 -0
  44. package/dist/sync/plan.js +90 -0
  45. package/dist/sync/render.d.ts +17 -0
  46. package/dist/sync/render.js +46 -0
  47. package/dist/sync/segment.d.ts +40 -0
  48. package/dist/sync/segment.js +76 -0
  49. package/dist/sync/snapshot.d.ts +30 -0
  50. package/dist/sync/snapshot.js +122 -0
  51. package/dist/sync/types.d.ts +82 -0
  52. package/dist/sync/types.js +12 -0
  53. package/dist/templates/context-file-section.md +2 -1
  54. package/dist/templates/example-requirements.js +0 -2
  55. package/dist/templates/example-requirements.ts +0 -2
  56. package/dist/templates/requirements-readme.js +3 -4
  57. package/dist/templates/requirements-readme.ts +3 -4
  58. package/dist/templates/skills/codebase-to-spec/SKILL.md +2 -2
  59. package/dist/utils/project-settings.d.ts +8 -2
  60. package/dist/utils/project-settings.js +47 -24
  61. package/package.json +1 -1
  62. package/dist/commands/pull.d.ts +0 -8
  63. package/dist/commands/pull.js +0 -230
  64. package/dist/commands/push.d.ts +0 -6
  65. package/dist/commands/push.js +0 -244
@@ -0,0 +1,225 @@
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, composeMarkdownWithTitle, splitLeadingH1, } 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
+ renames: [],
30
+ };
31
+ }
32
+ /**
33
+ * Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
34
+ * writes require a project slug in `auth`; a read-only share run has none and
35
+ * its plan carries no cloud-writing actions.
36
+ */
37
+ export async function executePlan(plan, cloud, auth, workspaceRoot) {
38
+ const outcome = emptyOutcome();
39
+ const client = new ConvexHttpClient(getConvexUrl());
40
+ const cloudById = new Map(cloud.documents.map((d) => [d.documentId, d]));
41
+ const requirementsDir = path.join(workspaceRoot, ".requirements");
42
+ const usedPaths = new Set();
43
+ for (const { doc, action } of plan) {
44
+ const name = doc.filePath ? path.basename(doc.filePath) : doc.title;
45
+ try {
46
+ switch (action) {
47
+ case "skip":
48
+ break;
49
+ case "skip_conflict":
50
+ outcome.conflicts.push(doc);
51
+ break;
52
+ case "invalid":
53
+ outcome.invalid.push(doc);
54
+ break;
55
+ case "cloud_write":
56
+ await cloudWrite(client, auth, doc, cloudById, outcome);
57
+ break;
58
+ case "cloud_delete":
59
+ await cloudDelete(client, auth, doc, outcome);
60
+ break;
61
+ case "local_write":
62
+ localWrite(doc, cloudById, usedPaths, requirementsDir, outcome);
63
+ break;
64
+ case "local_delete":
65
+ localDelete(doc, outcome);
66
+ break;
67
+ }
68
+ }
69
+ catch (err) {
70
+ outcome.errors.push({ name, error: errorDisplayMessage(err) });
71
+ }
72
+ }
73
+ return outcome;
74
+ }
75
+ /**
76
+ * Servers throw ConvexError({kind, message}) for expected failures; production
77
+ * redacts the Error message to "Server Error" but preserves error.data.
78
+ * Surface the human-readable message rather than the blob.
79
+ */
80
+ function errorDisplayMessage(err) {
81
+ const data = err.data;
82
+ if (data && typeof data.message === "string")
83
+ return data.message;
84
+ return err instanceof Error ? err.message : String(err);
85
+ }
86
+ async function cloudWrite(client, auth, doc, cloudById, outcome) {
87
+ if (!auth.projectSlug || !doc.filePath) {
88
+ throw new Error("Cannot write to the cloud without project credentials.");
89
+ }
90
+ const { parsedFiles } = parseFilesForPushIndividually([doc.filePath]);
91
+ const parsed = parsedFiles[0];
92
+ if (!parsed)
93
+ throw new Error(`Could not parse ${path.basename(doc.filePath)}`);
94
+ const meta = parsed.metadata.document;
95
+ // SYNC-TITLE-1.1: a legacy file whose title lives only in frontmatter (no body
96
+ // H1) must reach the cloud self-describing. Materialize the title as the body's
97
+ // leading H1 BEFORE the push — so the cloud row carries the H1 on first write
98
+ // and never lands as an H1-less-but-titled row. Such a row would otherwise be
99
+ // reachable to a web/MCP edit that untitles it (and is out of reach of the
100
+ // one-time title backfill, since a legacy checkout mints it on any later sync).
101
+ // Only the unambiguous case fires: a known frontmatter title with no existing
102
+ // H1 — never a guess about a content H1.
103
+ if (meta?.title && splitLeadingH1(parsed.markdownContent).title === null) {
104
+ parsed.markdownContent = composeMarkdownWithTitle(meta.title, parsed.markdownContent);
105
+ }
106
+ // SYNC-TITLE-1: the server derives the title from the body's leading H1.
107
+ // A legacy frontmatter title rides along only as the server's fallback for
108
+ // H1-less bodies (SYNC-TITLE-1.1); the filename is never a title
109
+ // (SYNC-TITLE-1.0).
110
+ const saveResult = (await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
111
+ projectAuth: {
112
+ projectSlug: auth.projectSlug,
113
+ projectSecret: auth.projectSecret,
114
+ },
115
+ target: { type: "project", slug: auth.projectSlug },
116
+ documentId: meta?.id,
117
+ title: meta?.title,
118
+ markdownContent: parsed.markdownContent,
119
+ defaultPrefix: meta?.defaultPrefix,
120
+ runMarker: parsed.metadata.ctsRun,
121
+ dryRun: false,
122
+ }));
123
+ const documentId = typeof saveResult === "string" ? saveResult : saveResult.documentId;
124
+ // IMPORT-3.3: marker failure is never silent — surface the statuses the
125
+ // server reports as warnings.
126
+ if (typeof saveResult !== "string" && saveResult.importStatus) {
127
+ const status = saveResult.importStatus;
128
+ if (status === "invalid_marker" ||
129
+ status === "unsupported_version" ||
130
+ status === "already_used") {
131
+ outcome.importWarnings.push({
132
+ name: path.basename(doc.filePath),
133
+ status,
134
+ });
135
+ }
136
+ }
137
+ // Created vs updated is decided by the cloud, not the frontmatter: a stale
138
+ // id (document deleted in cloud) makes the save mint a fresh document.
139
+ const wasCreate = documentId !== meta?.id;
140
+ if (wasCreate)
141
+ outcome.cloudCreated++;
142
+ else
143
+ outcome.cloudUpdated++;
144
+ // SYNC-TITLE-1.3: a push that changes the title is a rename — announce it,
145
+ // so an unintended H1 edit is visible and costs one heading edit to undo.
146
+ // The new title is derived from the pushed markdown exactly as the server
147
+ // derives it (H1, else the legacy frontmatter title, else untitled) — NOT
148
+ // from doc.title, which the comparator pins to the OLD cloud title for any
149
+ // id-matched document, so comparing against it never detects a rename.
150
+ const cloudBefore = meta?.id ? cloudById.get(meta.id) : undefined;
151
+ if (cloudBefore) {
152
+ const newTitle = splitLeadingH1(parsed.markdownContent).title ?? meta?.title ?? "";
153
+ // NAV-TITLE-1.2: the cloud snapshot reports an untitled document's title as
154
+ // the display fallback "Untitled Document", while newTitle is "" for that
155
+ // same state. Normalize before comparing so a content-only push of an
156
+ // untitled doc doesn't report a phantom `"Untitled Document" → ""` rename.
157
+ const before = cloudBefore.title === "Untitled Document" ? "" : cloudBefore.title;
158
+ if (before !== newTitle) {
159
+ outcome.renames.push({ from: before, to: newTitle });
160
+ }
161
+ }
162
+ outcome.synced.push({
163
+ name: path.basename(doc.filePath),
164
+ url: `${WEB_APP_URL}/documents/${documentId}`,
165
+ });
166
+ // SYNC-FAIL-2: the cloud document already exists at this point. A write-back
167
+ // failure is a warning on a synced document, never a sync failure (which
168
+ // would invite a retry that mints a duplicate).
169
+ try {
170
+ if (parsed.metadata.document) {
171
+ parsed.metadata.document.id = documentId;
172
+ // SYNC-TITLE-1.2: drop the legacy frontmatter title — the body already
173
+ // carries the name as its leading H1 (materialized above, before the
174
+ // push), so the rewritten file loses the field without losing the title.
175
+ delete parsed.metadata.document.title;
176
+ }
177
+ parsed.metadata.pulledAt = new Date().toISOString();
178
+ parsed.metadata.version = (parsed.metadata.version ?? 0) + 1;
179
+ const content = buildRequirementsFile(mergeMetadataWithRawFrontmatter(parsed.metadata, parsed.rawFrontmatter), parsed.markdownContent);
180
+ fs.writeFileSync(doc.filePath, content, "utf-8");
181
+ }
182
+ catch (writeErr) {
183
+ outcome.writeBackWarnings.push({
184
+ name: path.basename(doc.filePath),
185
+ filePath: doc.filePath,
186
+ documentId,
187
+ error: writeErr instanceof Error ? writeErr.message : String(writeErr),
188
+ });
189
+ }
190
+ }
191
+ async function cloudDelete(client, auth, doc, outcome) {
192
+ if (!auth.projectSlug || !doc.documentId) {
193
+ throw new Error("Cannot delete from the cloud without project credentials.");
194
+ }
195
+ await client.mutation(api.documents.mutations.deleteForCli, {
196
+ projectAuth: {
197
+ projectSlug: auth.projectSlug,
198
+ projectSecret: auth.projectSecret,
199
+ },
200
+ target: { type: "project", slug: auth.projectSlug },
201
+ documentId: doc.documentId,
202
+ });
203
+ outcome.cloudDeleted++;
204
+ }
205
+ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
206
+ const cloudDoc = doc.documentId ? cloudById.get(doc.documentId) : undefined;
207
+ if (!cloudDoc)
208
+ throw new Error(`No cloud content for ${doc.title}`);
209
+ if (!fs.existsSync(requirementsDir))
210
+ fs.mkdirSync(requirementsDir, { recursive: true });
211
+ const filePath = resolveLocalPath(cloudDoc, doc.filePath, usedPaths, requirementsDir);
212
+ usedPaths.add(filePath);
213
+ const changed = writeLocalDocument(filePath, cloudDoc);
214
+ if (changed)
215
+ outcome.localWritten++;
216
+ }
217
+ function localDelete(doc, outcome) {
218
+ if (!doc.filePath)
219
+ return;
220
+ if (fs.existsSync(doc.filePath)) {
221
+ fs.unlinkSync(doc.filePath);
222
+ outcome.localDeleted++;
223
+ }
224
+ }
225
+ //# 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
@@ -0,0 +1,93 @@
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 * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { buildRequirementsFile } from "../schema/index.js";
9
+ import { extractFrontmatterBlock } from "../schema/parser-core.js";
10
+ /** Sanitize a document title into a filename stem. */
11
+ export function sanitizeFileName(title) {
12
+ return title
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/^-+|-+$/g, "");
16
+ }
17
+ /**
18
+ * Choose the path a cloud document is written to. An existing file linked by
19
+ * document ID keeps its path (SYNC-DISCOVERY-2.0); a new document lands in
20
+ * `.requirements/`, disambiguated by numeric suffix on collision and falling
21
+ * back to the document ID when the title sanitizes to nothing
22
+ * (SYNC-WEB-CREATE-2).
23
+ */
24
+ export function resolveLocalPath(cloud, existingPath, usedPaths, requirementsDir) {
25
+ if (existingPath)
26
+ return existingPath;
27
+ const baseName = sanitizeFileName(cloud.title) || cloud.documentId;
28
+ let candidate = path.join(requirementsDir, `${baseName}.requirements.md`);
29
+ let suffix = 2;
30
+ while (usedPaths.has(candidate) || fs.existsSync(candidate)) {
31
+ candidate = path.join(requirementsDir, `${baseName}-${suffix}.requirements.md`);
32
+ suffix++;
33
+ }
34
+ return candidate;
35
+ }
36
+ /** Read the ctsRun marker from an existing file's frontmatter, if any (IMPORT-1). */
37
+ export function readCtsRun(filePath) {
38
+ if (!fs.existsSync(filePath))
39
+ return undefined;
40
+ // Search only the frontmatter block — a body line starting "ctsRun:" must
41
+ // not be mistaken for the marker.
42
+ const yaml = extractFrontmatterBlock(fs.readFileSync(filePath, "utf-8"));
43
+ const match = yaml?.match(/^ctsRun: (.+)$/m);
44
+ return match?.[1].trim();
45
+ }
46
+ /**
47
+ * Write a cloud document to a local file. `pulledAt` records when this content
48
+ * arrived (SYNC-WRITE-1.1); an existing ctsRun marker is carried forward
49
+ * (IMPORT-1). Returns true when the file's bytes actually changed — sync only
50
+ * touches files whose content changed (SYNC-WRITE-1.0).
51
+ */
52
+ export function writeLocalDocument(filePath, cloud) {
53
+ const existingCtsRun = readCtsRun(filePath);
54
+ // SYNC-TITLE-1.2: no `document.title` in frontmatter — the title lives in
55
+ // the body as its leading H1. A legacy file that gets rewritten loses the
56
+ // field here, since the metadata is rebuilt from scratch.
57
+ const metadata = {
58
+ pulledAt: new Date().toISOString(),
59
+ version: cloud.version,
60
+ ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
61
+ document: {
62
+ id: cloud.documentId,
63
+ defaultPrefix: cloud.defaultPrefix,
64
+ },
65
+ };
66
+ const content = buildRequirementsFile(metadata, cloud.body);
67
+ // SYNC-WRITE-1.0: skip the write when the body+identity are unchanged, so a
68
+ // no-op sync leaves the working tree clean. Compare ignoring the volatile
69
+ // pulledAt line, which would otherwise force a rewrite every run.
70
+ if (fs.existsSync(filePath)) {
71
+ const current = fs.readFileSync(filePath, "utf-8");
72
+ if (withoutPulledAt(current) === withoutPulledAt(content))
73
+ return false;
74
+ }
75
+ fs.writeFileSync(filePath, content, "utf-8");
76
+ return true;
77
+ }
78
+ /**
79
+ * Normalize the volatile pulledAt line for equality comparison — within the
80
+ * frontmatter only, so a body line that happens to start "pulledAt:" can never
81
+ * mask a genuine content change (which would silently skip the write).
82
+ */
83
+ function withoutPulledAt(fileContent) {
84
+ // CRLF-normalize first: extractFrontmatterBlock returns LF-normalized yaml,
85
+ // so the replace below must operate on LF content to find it.
86
+ const normalized = fileContent.replace(/\r\n/g, "\n");
87
+ const yaml = extractFrontmatterBlock(normalized);
88
+ if (yaml === undefined)
89
+ return normalized;
90
+ const normalizedYaml = yaml.replace(/^pulledAt: .*$/m, "pulledAt: <normalized>");
91
+ return normalized.replace(yaml, normalizedYaml);
92
+ }
93
+ //# sourceMappingURL=local-files.js.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Turning verdicts into actions under a sync mode (SYNC-MODE-1..4).
3
+ *
4
+ * The mode is direction × authority; the action table below is the whole
5
+ * policy layer over the comparator. Pure and table-driven so it is exhaustively
6
+ * testable.
7
+ */
8
+ import type { DocumentComparison, Verdict } from "./types.js";
9
+ export type SyncMode = "bare" | "cloud_contributes" | "repo_contributes" | "repo_wins" | "cloud_wins";
10
+ export interface ModeFlags {
11
+ cloudContributes?: boolean;
12
+ repoContributes?: boolean;
13
+ cloudWins?: boolean;
14
+ repoWins?: boolean;
15
+ }
16
+ /**
17
+ * Resolve raw flags into a mode, rejecting incoherent combinations
18
+ * (SYNC-MODE-3.2). Passing both contribute flags is the bare default spelled
19
+ * out (SYNC-MODE-2.3).
20
+ */
21
+ export declare function resolveMode(flags: ModeFlags): SyncMode;
22
+ /**
23
+ * What a sync does to one document.
24
+ * - cloud_write: save the repo's content to the cloud (create or overwrite)
25
+ * - local_write: write the cloud's content to the local file (create or overwrite)
26
+ * - cloud_delete / local_delete: remove the document on that side (wins modes only)
27
+ * - skip_conflict: a conflict left untouched, reported with its resolution
28
+ * - invalid: an unparseable local file, reported and skipped
29
+ * - skip: nothing to do
30
+ */
31
+ export type SyncAction = "cloud_write" | "local_write" | "cloud_delete" | "local_delete" | "skip_conflict" | "invalid" | "skip";
32
+ export declare function planAction(verdict: Verdict, mode: SyncMode): SyncAction;
33
+ export interface PlannedAction {
34
+ doc: DocumentComparison;
35
+ action: SyncAction;
36
+ }
37
+ /** Assign an action to every document under the given mode. */
38
+ export declare function buildPlan(documents: DocumentComparison[], mode: SyncMode): PlannedAction[];
39
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Turning verdicts into actions under a sync mode (SYNC-MODE-1..4).
3
+ *
4
+ * The mode is direction × authority; the action table below is the whole
5
+ * policy layer over the comparator. Pure and table-driven so it is exhaustively
6
+ * testable.
7
+ */
8
+ /**
9
+ * Resolve raw flags into a mode, rejecting incoherent combinations
10
+ * (SYNC-MODE-3.2). Passing both contribute flags is the bare default spelled
11
+ * out (SYNC-MODE-2.3).
12
+ */
13
+ export function resolveMode(flags) {
14
+ const wins = [flags.repoWins, flags.cloudWins].filter(Boolean).length;
15
+ const contributes = [flags.cloudContributes, flags.repoContributes].filter(Boolean).length;
16
+ if (wins > 1 || (wins > 0 && contributes > 0)) {
17
+ throw new Error("Authority must be a single side: combine at most one of --repo-wins or --cloud-wins, and not with a --contributes flag.");
18
+ }
19
+ if (flags.repoWins)
20
+ return "repo_wins";
21
+ if (flags.cloudWins)
22
+ return "cloud_wins";
23
+ if (flags.cloudContributes && !flags.repoContributes)
24
+ return "cloud_contributes";
25
+ if (flags.repoContributes && !flags.cloudContributes)
26
+ return "repo_contributes";
27
+ return "bare";
28
+ }
29
+ const TABLE = {
30
+ only_in_repo: {
31
+ bare: "cloud_write",
32
+ cloud_contributes: "skip",
33
+ repo_contributes: "cloud_write",
34
+ repo_wins: "cloud_write",
35
+ cloud_wins: "local_delete",
36
+ },
37
+ only_in_cloud: {
38
+ bare: "local_write",
39
+ cloud_contributes: "local_write",
40
+ repo_contributes: "skip",
41
+ repo_wins: "cloud_delete",
42
+ cloud_wins: "local_write",
43
+ },
44
+ additions_in_repo: {
45
+ bare: "cloud_write",
46
+ cloud_contributes: "skip",
47
+ repo_contributes: "cloud_write",
48
+ repo_wins: "cloud_write",
49
+ cloud_wins: "local_write",
50
+ },
51
+ additions_in_cloud: {
52
+ bare: "local_write",
53
+ cloud_contributes: "local_write",
54
+ repo_contributes: "skip",
55
+ repo_wins: "cloud_write",
56
+ cloud_wins: "local_write",
57
+ },
58
+ conflict: {
59
+ bare: "skip_conflict",
60
+ cloud_contributes: "skip_conflict",
61
+ repo_contributes: "skip_conflict",
62
+ repo_wins: "cloud_write",
63
+ cloud_wins: "local_write",
64
+ },
65
+ in_sync: {
66
+ bare: "skip",
67
+ cloud_contributes: "skip",
68
+ repo_contributes: "skip",
69
+ repo_wins: "skip",
70
+ cloud_wins: "skip",
71
+ },
72
+ invalid_file: {
73
+ bare: "invalid",
74
+ cloud_contributes: "invalid",
75
+ repo_contributes: "invalid",
76
+ repo_wins: "invalid",
77
+ cloud_wins: "invalid",
78
+ },
79
+ };
80
+ export function planAction(verdict, mode) {
81
+ return TABLE[verdict][mode];
82
+ }
83
+ /** Assign an action to every document under the given mode. */
84
+ export function buildPlan(documents, mode) {
85
+ return documents.map((doc) => ({
86
+ doc,
87
+ action: planAction(doc.verdict, mode),
88
+ }));
89
+ }
90
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Presentation helpers over the comparison result — shared by `dotreq diff`
3
+ * and `dotreq sync` so their vocabulary can't drift.
4
+ */
5
+ import type { DocumentComparison, UnitDetail } from "./types.js";
6
+ /** The stable token a user would pass as scope to act on this document: its
7
+ * file basename when local, else its cloud id, else its title. */
8
+ export declare function scopeToken(doc: DocumentComparison): string;
9
+ /** A human name for the document in listings. */
10
+ export declare function displayName(doc: DocumentComparison): string;
11
+ /** The copy-pasteable resolution command for a conflict (DIFF-5, SYNC-MODE-1.2). */
12
+ export declare function resolutionHint(doc: DocumentComparison): string;
13
+ /** One line per document: glyph, name, verdict. */
14
+ export declare function formatVerdictLine(doc: DocumentComparison): string;
15
+ /** Verbose, one-level-unfolded detail for a document (DIFF-2). */
16
+ export declare function formatUnitDetail(detail: UnitDetail): string;
17
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Presentation helpers over the comparison result — shared by `dotreq diff`
3
+ * and `dotreq sync` so their vocabulary can't drift.
4
+ */
5
+ import * as path from "node:path";
6
+ import { verdictLabel } from "./types.js";
7
+ /** A short glyph per verdict for scannable output. */
8
+ const GLYPH = {
9
+ in_sync: "✓",
10
+ additions_in_repo: "→",
11
+ additions_in_cloud: "←",
12
+ conflict: "!",
13
+ only_in_repo: "+",
14
+ only_in_cloud: "+",
15
+ invalid_file: "✗",
16
+ };
17
+ /** The stable token a user would pass as scope to act on this document: its
18
+ * file basename when local, else its cloud id, else its title. */
19
+ export function scopeToken(doc) {
20
+ if (doc.filePath)
21
+ return path.basename(doc.filePath);
22
+ return doc.documentId ?? doc.title;
23
+ }
24
+ /** A human name for the document in listings. */
25
+ export function displayName(doc) {
26
+ if (doc.filePath)
27
+ return path.basename(doc.filePath);
28
+ return `${doc.title} (cloud)`;
29
+ }
30
+ /** The copy-pasteable resolution command for a conflict (DIFF-5, SYNC-MODE-1.2). */
31
+ export function resolutionHint(doc) {
32
+ return `resolve with \`dotreq sync ${scopeToken(doc)} --repo-wins\` or \`--cloud-wins\``;
33
+ }
34
+ /** One line per document: glyph, name, verdict. */
35
+ export function formatVerdictLine(doc) {
36
+ return ` ${GLYPH[doc.verdict]} ${displayName(doc).padEnd(40)} ${verdictLabel(doc.verdict)}`;
37
+ }
38
+ /** Verbose, one-level-unfolded detail for a document (DIFF-2). */
39
+ export function formatUnitDetail(detail) {
40
+ const positions = detail.positions && detail.positions.length > 0
41
+ ? ` (${detail.note === "prose" ? "" : "positions "}${detail.positions.join(", ")})`
42
+ : "";
43
+ const label = detail.note === "prose" ? "prose" : detail.unit;
44
+ return ` ${label}: ${verdictLabel(detail.verdict)}${positions}`;
45
+ }
46
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Segment a document body into an ordered sequence of units — prose passages
3
+ * and requirement blocks, interleaved (the comparator's structural input).
4
+ *
5
+ * A requirement's identity is its key; a prose passage's identity is its
6
+ * normalized text (prose has no stable id, so a reworded passage reads as a
7
+ * removal-plus-addition — a conflict, per DIFF-4.3).
8
+ */
9
+ export type Unit = {
10
+ kind: "prose";
11
+ text: string;
12
+ } | {
13
+ kind: "requirement";
14
+ key: string;
15
+ blockContent: string;
16
+ };
17
+ /**
18
+ * Normalize prose for comparison: LF line endings, trailing whitespace
19
+ * stripped per line, runs of blank lines collapsed, ends trimmed. Two prose
20
+ * passages that differ only in incidental whitespace compare equal, so a
21
+ * whitespace-only reflow does not read as a conflict.
22
+ */
23
+ export declare function normalizeProse(text: string): string;
24
+ /**
25
+ * Split a body into its ordered units. Prose between (and around) fences
26
+ * becomes prose units when non-empty after normalization; each fence becomes a
27
+ * requirement unit keyed by its first-line KEY.
28
+ */
29
+ export declare function segmentBody(body: string): Unit[];
30
+ /**
31
+ * Flatten a requirement block into a map from position-path id (e.g. "AUTH-1",
32
+ * "AUTH-1.0", "AUTH-1.2.1") to a canonical label+content string.
33
+ *
34
+ * Because the id encodes the position, a criterion inserted mid-list shifts the
35
+ * ids of everything after it — so an insertion cannot subset-match the original
36
+ * (DIFF-4.2), while an appended criterion adds a new id without disturbing the
37
+ * others (DIFF-4.1).
38
+ */
39
+ export declare function requirementUnitMap(blockContent: string): Map<string, string>;
40
+ //# sourceMappingURL=segment.d.ts.map