@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
|
@@ -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
|