@popoverai/dotrequirements 0.29.0 → 0.29.2
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/dist/codebase-to-spec/compose.js +0 -4
- package/dist/codebase-to-spec/renumber.d.ts +7 -5
- package/dist/codebase-to-spec/renumber.js +17 -15
- package/dist/commands/report.js +15 -1
- package/dist/commands/sync.js +7 -1
- package/dist/harness/cache.d.ts +83 -2
- package/dist/harness/cache.js +94 -8
- package/dist/harness/finalize.js +238 -78
- package/dist/harness/reportingStatus.d.ts +44 -0
- package/dist/harness/reportingStatus.js +123 -0
- package/dist/push/core.d.ts +0 -12
- package/dist/push/core.js +8 -58
- package/dist/push/index.d.ts +1 -1
- package/dist/push/index.js +1 -1
- package/dist/requirements/cloud-coverage.js +7 -2
- package/dist/schema/browser.d.ts +2 -2
- package/dist/schema/browser.js +2 -2
- package/dist/schema/builder.d.ts +6 -1
- package/dist/schema/builder.js +6 -1
- package/dist/schema/conversions.js +12 -3
- package/dist/schema/file-writer.d.ts +49 -0
- package/dist/schema/file-writer.js +138 -0
- package/dist/schema/index.d.ts +5 -2
- package/dist/schema/index.js +3 -2
- package/dist/schema/parser-core.d.ts +32 -5
- package/dist/schema/parser-core.js +136 -31
- package/dist/schema/parser.d.ts +2 -1
- package/dist/schema/parser.js +1 -1
- package/dist/schema/schemas.d.ts +11 -0
- package/dist/schema/schemas.js +14 -0
- package/dist/sync/compare.js +16 -1
- package/dist/sync/execute.d.ts +10 -2
- package/dist/sync/execute.js +44 -18
- package/dist/sync/local-files.d.ts +2 -12
- package/dist/sync/local-files.js +2 -62
- package/dist/sync/segment.d.ts +2 -2
- package/dist/sync/segment.js +45 -11
- package/package.json +2 -2
- package/dist/harness/convexReporting.d.ts +0 -15
- package/dist/harness/convexReporting.js +0 -131
- package/dist/harness/coverageCache.d.ts +0 -30
- package/dist/harness/coverageCache.js +0 -70
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* This module contains pure parsing functions with no Node.js dependencies,
|
|
5
5
|
* making it safe to import in Convex runtime or browser environments.
|
|
6
6
|
*/
|
|
7
|
-
import { ValidationError, validateKey, } from "./schemas.js";
|
|
7
|
+
import { REQUIREMENT_KEY_PATTERN, ValidationError, validateKey, } from "./schemas.js";
|
|
8
8
|
/**
|
|
9
9
|
* The label stamped on every requirement's root, for indexing and querying.
|
|
10
10
|
*
|
|
@@ -78,7 +78,7 @@ export function parseRootLine(line, delimiter = DELIMITER_PATTERN) {
|
|
|
78
78
|
const trimmed = line.trim();
|
|
79
79
|
// Try to match: KEY + colon + content (explicit key format)
|
|
80
80
|
// KEY must be followed by colon (not arrow) to distinguish from "Label → content"
|
|
81
|
-
const keyMatch = trimmed.match(/^([\w-]+):\s*(
|
|
81
|
+
const keyMatch = trimmed.match(/^([\w-]+):\s*(.*)$/);
|
|
82
82
|
if (keyMatch) {
|
|
83
83
|
return {
|
|
84
84
|
key: keyMatch[1],
|
|
@@ -106,7 +106,13 @@ export function parseRootLine(line, delimiter = DELIMITER_PATTERN) {
|
|
|
106
106
|
* First line is the requirement content (with optional arrow format).
|
|
107
107
|
* Subsequent lines are criteria with position paths.
|
|
108
108
|
*/
|
|
109
|
-
export function parseRequirementBlock(key, blockContent
|
|
109
|
+
export function parseRequirementBlock(key, blockContent,
|
|
110
|
+
/**
|
|
111
|
+
* Zero-based line index of this block within its fence. A fence may group
|
|
112
|
+
* several roots, so without it every root's diagnostics would count from its
|
|
113
|
+
* own first line and "at line 3" would occur once per root.
|
|
114
|
+
*/
|
|
115
|
+
lineOffset = 0) {
|
|
110
116
|
const lines = blockContent.trim().split("\n");
|
|
111
117
|
if (lines.length === 0) {
|
|
112
118
|
throw new ValidationError("Empty requirement block", key);
|
|
@@ -134,20 +140,27 @@ export function parseRequirementBlock(key, blockContent) {
|
|
|
134
140
|
}
|
|
135
141
|
const criterion = parseCriterionLine(line);
|
|
136
142
|
if (!criterion) {
|
|
137
|
-
// Only a line whose prefix is shaped like a requirement KEY (uppercase
|
|
138
|
-
// prefix + "-<digits>", per REQUIREMENT_KEY_PATTERN) suggests a second
|
|
139
|
-
// requirement in the block; a stray "https://..." must not be
|
|
140
|
-
// misdiagnosed as one.
|
|
141
143
|
const trimmedLine = line.trim();
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
// SYNC-KEY-1.5: a line written as "KEY: content" is someone starting
|
|
145
|
+
// another requirement, not writing a criterion. Blame the key so the
|
|
146
|
+
// error teaches key format rather than criterion format.
|
|
147
|
+
const rootAttempt = trimmedLine.match(ROOT_ATTEMPT_PATTERN);
|
|
148
|
+
if (rootAttempt) {
|
|
149
|
+
try {
|
|
150
|
+
validateKey(rootAttempt[1]);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
// validateKey has no line context, and in a grouped fence naming the
|
|
154
|
+
// key without naming where it is makes the author hunt for it.
|
|
155
|
+
throw new ValidationError(`${err instanceof Error ? err.message : String(err)} (at line ${i + 1 + lineOffset})`, key, err);
|
|
156
|
+
}
|
|
144
157
|
}
|
|
145
|
-
throw new ValidationError(`Invalid criterion format at line ${i + 1}: "${trimmedLine}". Expected format: "N. Label → content" (e.g., "0. Given → user is logged in")`, key);
|
|
158
|
+
throw new ValidationError(`Invalid criterion format at line ${i + 1 + lineOffset}: "${trimmedLine}". Expected format: "N. Label → content" (e.g., "0. Given → user is logged in")`, key);
|
|
146
159
|
}
|
|
147
160
|
// SYNC-FORMAT-2.1: a duplicate position is structural ambiguity — reject
|
|
148
161
|
// it instead of silently dropping the earlier criterion.
|
|
149
162
|
if (criteriaByPosition.has(criterion.position)) {
|
|
150
|
-
throw new ValidationError(`Duplicate criterion position "${criterion.position}" at line ${i + 1} — each position may appear only once per requirement`, key);
|
|
163
|
+
throw new ValidationError(`Duplicate criterion position "${criterion.position}" at line ${i + 1 + lineOffset} — each position may appear only once per requirement`, key);
|
|
151
164
|
}
|
|
152
165
|
criteriaByPosition.set(criterion.position, criterion);
|
|
153
166
|
}
|
|
@@ -227,9 +240,104 @@ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByP
|
|
|
227
240
|
parent.children.push(childNode);
|
|
228
241
|
}
|
|
229
242
|
}
|
|
243
|
+
const EXPLICIT_ROOT_LINE_PATTERN = /^([\w-]+):\s*(.*)$/;
|
|
244
|
+
/**
|
|
245
|
+
* A line someone plainly meant as a root requirement: "KEY: content", where the
|
|
246
|
+
* candidate was reaching for the `PREFIX-<number>` shape — it starts with a
|
|
247
|
+
* letter and either contains a digit or trails off at the hyphen where the
|
|
248
|
+
* number belongs.
|
|
249
|
+
*
|
|
250
|
+
* Used only to pick the right diagnostic (SYNC-KEY-1.5), so it is deliberately
|
|
251
|
+
* narrower than the root grammar. Every exclusion earns its keep by keeping a
|
|
252
|
+
* line that is really a fumbled *criterion* on criterion advice:
|
|
253
|
+
* - leading letter — a mistyped position ("0:" for "0.")
|
|
254
|
+
* - space-or-EOL — a bare "https://…" line, not a key named "https"
|
|
255
|
+
* - digit-or-trailing "-" — "Given:", "Note:", "TODO:", and this project's
|
|
256
|
+
* hyphenated labels ("edit-mode:", "view-mode:").
|
|
257
|
+
* "Given:" for "Given →" is the likeliest fumble of
|
|
258
|
+
* all, since the format itself teaches that word.
|
|
259
|
+
* It catches "LOGIN_2:", "LOGIN2:", "OAUTH2-2:", and "LOGIN-:".
|
|
260
|
+
*/
|
|
261
|
+
const ROOT_ATTEMPT_PATTERN = /^([A-Za-z][\w-]*(?:\d[\w-]*|-)):(?:\s|$)/;
|
|
262
|
+
/**
|
|
263
|
+
* Strip the indentation the whole fence shares, so a fence nested inside a
|
|
264
|
+
* Markdown list item still has its roots at column zero. Trimming the body as a
|
|
265
|
+
* whole would de-indent only the first line, leaving every later root indented
|
|
266
|
+
* and therefore invisible as a root.
|
|
267
|
+
*/
|
|
268
|
+
function dedentFenceBody(content) {
|
|
269
|
+
const all = content.replace(/\r\n/g, "\n").split("\n");
|
|
270
|
+
let start = 0;
|
|
271
|
+
let end = all.length;
|
|
272
|
+
while (start < end && !all[start].trim())
|
|
273
|
+
start++;
|
|
274
|
+
while (end > start && !all[end - 1].trim())
|
|
275
|
+
end--;
|
|
276
|
+
const lines = all.slice(start, end);
|
|
277
|
+
const indents = lines
|
|
278
|
+
.filter((l) => l.trim())
|
|
279
|
+
.map((l) => l.match(/^[ \t]*/)?.[0].length ?? 0);
|
|
280
|
+
const common = indents.length ? Math.min(...indents) : 0;
|
|
281
|
+
return {
|
|
282
|
+
lines: lines.map((l) => (l.trim() ? l.slice(common) : l)),
|
|
283
|
+
offset: start,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Split one dotrequirements fence into its independent root requirements.
|
|
288
|
+
*
|
|
289
|
+
* A fence marks a contiguous structured region; each unindented, well-formed
|
|
290
|
+
* KEY: content line starts a new root. The shared key pattern is tested against
|
|
291
|
+
* an uppercase candidate because key validation is case-insensitive
|
|
292
|
+
* (SYNC-KEY-1.3). Authored casing remains untouched in the returned block.
|
|
293
|
+
*
|
|
294
|
+
* Splitting is structural, not a grammar check: it rejects content that is not
|
|
295
|
+
* shaped like a requirement block at all, but a *malformed key* is passed
|
|
296
|
+
* through for the caller to judge. Validating keys here made every reader as
|
|
297
|
+
* strict as the authoring path — a legacy "LOGIN_1" in the cloud took down a
|
|
298
|
+
* whole `dotreq diff` run and rendered as raw markdown in the chat document
|
|
299
|
+
* view. Key grammar is enforced in extractRequirementBlocks (SYNC-KEY-1),
|
|
300
|
+
* on the authoring and save paths.
|
|
301
|
+
*/
|
|
302
|
+
export function splitRequirementFenceContent(content) {
|
|
303
|
+
const { lines, offset } = dedentFenceBody(content);
|
|
304
|
+
const firstRoot = lines[0]?.match(EXPLICIT_ROOT_LINE_PATTERN);
|
|
305
|
+
if (!firstRoot) {
|
|
306
|
+
throw new ValidationError(`Invalid requirement block format - first line must be "KEY: content"`, content.substring(0, 50));
|
|
307
|
+
}
|
|
308
|
+
const blocks = [];
|
|
309
|
+
let currentKey = firstRoot[1];
|
|
310
|
+
let currentLines = [lines[0]];
|
|
311
|
+
let currentStart = 0;
|
|
312
|
+
const flushCurrent = () => {
|
|
313
|
+
blocks.push({
|
|
314
|
+
key: currentKey,
|
|
315
|
+
blockContent: currentLines.join("\n").trim(),
|
|
316
|
+
startLine: currentStart + offset,
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
for (let i = 1; i < lines.length; i++) {
|
|
320
|
+
const rootMatch = lines[i].match(EXPLICIT_ROOT_LINE_PATTERN);
|
|
321
|
+
const nextKey = rootMatch && REQUIREMENT_KEY_PATTERN.test(rootMatch[1].toUpperCase())
|
|
322
|
+
? rootMatch[1]
|
|
323
|
+
: undefined;
|
|
324
|
+
if (nextKey !== undefined) {
|
|
325
|
+
flushCurrent();
|
|
326
|
+
currentKey = nextKey;
|
|
327
|
+
currentLines = [lines[i]];
|
|
328
|
+
currentStart = i;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
currentLines.push(lines[i]);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
flushCurrent();
|
|
335
|
+
return blocks;
|
|
336
|
+
}
|
|
230
337
|
/**
|
|
231
338
|
* Extract requirement blocks from Markdown body (no frontmatter).
|
|
232
|
-
*
|
|
339
|
+
* A fence may contain multiple adjacent root requirements; each root is
|
|
340
|
+
* returned as its own logical block.
|
|
233
341
|
*/
|
|
234
342
|
export function extractRequirementBlocks(body) {
|
|
235
343
|
const blocks = [];
|
|
@@ -238,24 +346,21 @@ export function extractRequirementBlocks(body) {
|
|
|
238
346
|
const blockRegex = /```dotrequirements\n([\s\S]*?)```/gm;
|
|
239
347
|
let match = blockRegex.exec(body);
|
|
240
348
|
while (match !== null) {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
throw new ValidationError(`Duplicate requirement key "${key}" — each requirement key may appear in only one block`, key);
|
|
349
|
+
const fenceBlocks = splitRequirementFenceContent(match[1]);
|
|
350
|
+
for (const block of fenceBlocks) {
|
|
351
|
+
// SYNC-KEY-1: reject malformed keys at parse time so push/validate fail
|
|
352
|
+
// locally instead of erroring server-side. The original key is preserved
|
|
353
|
+
// in parsed content; normalizedKey is identity-only.
|
|
354
|
+
const normalizedKey = validateKey(block.key);
|
|
355
|
+
// SYNC-FORMAT-2.0: duplicate canonical keys are structural ambiguity,
|
|
356
|
+
// whether they share a fence, use separate fences, or differ by casing
|
|
357
|
+
// or leading zeros.
|
|
358
|
+
if (seenKeys.has(normalizedKey)) {
|
|
359
|
+
throw new ValidationError(`Duplicate requirement key "${block.key}" — each requirement key may appear only once in a document`, block.key);
|
|
360
|
+
}
|
|
361
|
+
seenKeys.add(normalizedKey);
|
|
362
|
+
blocks.push(block);
|
|
256
363
|
}
|
|
257
|
-
seenKeys.add(key);
|
|
258
|
-
blocks.push({ key, blockContent: blockContent.trim() });
|
|
259
364
|
match = blockRegex.exec(body);
|
|
260
365
|
}
|
|
261
366
|
return blocks;
|
|
@@ -309,9 +414,9 @@ export function parseRequirementBlocksFromMarkdown(markdownContent) {
|
|
|
309
414
|
// identically regardless of line-ending style.
|
|
310
415
|
const blocks = extractRequirementBlocks(markdownContent.replace(/\r\n/g, "\n"));
|
|
311
416
|
const requirements = [];
|
|
312
|
-
for (const { key, blockContent } of blocks) {
|
|
417
|
+
for (const { key, blockContent, startLine } of blocks) {
|
|
313
418
|
try {
|
|
314
|
-
const reqNode = parseRequirementBlock(key, blockContent);
|
|
419
|
+
const reqNode = parseRequirementBlock(key, blockContent, startLine);
|
|
315
420
|
requirements.push(reqNode);
|
|
316
421
|
}
|
|
317
422
|
catch (error) {
|
package/dist/schema/parser.d.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* requirements file means (SYNC-FORMAT-2.3).
|
|
9
9
|
*/
|
|
10
10
|
import { type RequirementNode, type RequirementsFile } from "./schemas.js";
|
|
11
|
-
export {
|
|
11
|
+
export type { ExtractedRequirementBlock } from "./parser-core.js";
|
|
12
|
+
export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, splitRequirementFenceContent, } from "./parser-core.js";
|
|
12
13
|
/**
|
|
13
14
|
* Parse a complete requirements Markdown file.
|
|
14
15
|
*/
|
package/dist/schema/parser.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parseRequirementBlocksFromMarkdown, splitFrontmatter, } from "./parser-
|
|
|
13
13
|
import { ValidationError, validateMetadata, } from "./schemas.js";
|
|
14
14
|
// Block/criterion parsing and tree utilities live in parser-core; re-exported
|
|
15
15
|
// here so the schema module's public surface is unchanged.
|
|
16
|
-
export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, } from "./parser-core.js";
|
|
16
|
+
export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRootLine, splitRequirementFenceContent, } from "./parser-core.js";
|
|
17
17
|
/**
|
|
18
18
|
* Extract YAML frontmatter from Markdown content.
|
|
19
19
|
* Returns { frontmatter, body } where frontmatter is the parsed YAML object.
|
package/dist/schema/schemas.d.ts
CHANGED
|
@@ -49,6 +49,17 @@ export declare function parseRequirementKey(key: string): {
|
|
|
49
49
|
prefix: string;
|
|
50
50
|
number: number;
|
|
51
51
|
} | null;
|
|
52
|
+
/**
|
|
53
|
+
* The canonical spelling of a key, for deciding whether two keys name the same
|
|
54
|
+
* requirement. Casing and leading zeros are authoring latitude (SYNC-KEY-1.3)
|
|
55
|
+
* and the cloud stores the canonical form, so "login-02" and "LOGIN-2" must
|
|
56
|
+
* compare equal (DIFF-3.3).
|
|
57
|
+
*
|
|
58
|
+
* A key that doesn't parse has no canonical form; it uppercases instead, so
|
|
59
|
+
* callers that must stay total (sync segmentation) keep a usable identity for a
|
|
60
|
+
* legacy malformed key rather than losing it.
|
|
61
|
+
*/
|
|
62
|
+
export declare function canonicalRequirementKey(key: string): string;
|
|
52
63
|
/**
|
|
53
64
|
* Build a requirement key from prefix and number.
|
|
54
65
|
*/
|
package/dist/schema/schemas.js
CHANGED
|
@@ -89,6 +89,20 @@ export function parseRequirementKey(key) {
|
|
|
89
89
|
return null;
|
|
90
90
|
return { prefix, number: parseInt(numStr, 10) };
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The canonical spelling of a key, for deciding whether two keys name the same
|
|
94
|
+
* requirement. Casing and leading zeros are authoring latitude (SYNC-KEY-1.3)
|
|
95
|
+
* and the cloud stores the canonical form, so "login-02" and "LOGIN-2" must
|
|
96
|
+
* compare equal (DIFF-3.3).
|
|
97
|
+
*
|
|
98
|
+
* A key that doesn't parse has no canonical form; it uppercases instead, so
|
|
99
|
+
* callers that must stay total (sync segmentation) keep a usable identity for a
|
|
100
|
+
* legacy malformed key rather than losing it.
|
|
101
|
+
*/
|
|
102
|
+
export function canonicalRequirementKey(key) {
|
|
103
|
+
const parsed = parseRequirementKey(key);
|
|
104
|
+
return parsed ? `${parsed.prefix}-${parsed.number}` : key.toUpperCase();
|
|
105
|
+
}
|
|
92
106
|
/**
|
|
93
107
|
* Build a requirement key from prefix and number.
|
|
94
108
|
*/
|
package/dist/sync/compare.js
CHANGED
|
@@ -198,7 +198,22 @@ function compareDocument(local, cloud) {
|
|
|
198
198
|
if (!diverges && normalizeBody(body) === normalizeBody(cloud.body)) {
|
|
199
199
|
return { ...base, verdict: "in_sync" };
|
|
200
200
|
}
|
|
201
|
-
|
|
201
|
+
// SYNC-FAIL-4.2: cloud bodies reach the snapshot as stored markdown, never
|
|
202
|
+
// re-parsed, so a document saved before today's grammar can still be
|
|
203
|
+
// unreadable. Segmentation is total, so this is a backstop — and it resolves
|
|
204
|
+
// to "conflict", never "invalid_file": invalid_file maps to "skip" in every
|
|
205
|
+
// sync mode including --repo-wins, which would leave the document permanently
|
|
206
|
+
// unrepairable, and it names the local file for a problem in the cloud copy.
|
|
207
|
+
// Conflict is both honest (we cannot show the change is additive) and
|
|
208
|
+
// actionable (`--repo-wins` overwrites the bad cloud body).
|
|
209
|
+
let classified;
|
|
210
|
+
try {
|
|
211
|
+
classified = classifyBodies(body, cloud.body);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return { ...base, verdict: "conflict" };
|
|
215
|
+
}
|
|
216
|
+
const { verdict: bodyVerdict, details } = classified;
|
|
202
217
|
let verdict;
|
|
203
218
|
if (diverges) {
|
|
204
219
|
// A changed single-valued attribute is never additive → conflict.
|
package/dist/sync/execute.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* frontmatter write-back) so document-header/import-provenance fidelity is
|
|
6
6
|
* identical to a repo→cloud push. Local writes reuse local-files.ts.
|
|
7
7
|
*/
|
|
8
|
-
import type { PlannedAction } from "./plan.js";
|
|
8
|
+
import type { PlannedAction, SyncMode } from "./plan.js";
|
|
9
9
|
import type { CloudAuth } from "./snapshot.js";
|
|
10
10
|
import type { CloudSnapshot, DocumentComparison } from "./types.js";
|
|
11
11
|
export interface SyncOutcome {
|
|
@@ -43,11 +43,19 @@ export interface SyncOutcome {
|
|
|
43
43
|
from: string;
|
|
44
44
|
to: string;
|
|
45
45
|
}>;
|
|
46
|
+
/** DOC-HEADER-11.4: documents whose prefix was inferred from their first
|
|
47
|
+
* requirement key rather than read from frontmatter. The sync writes that
|
|
48
|
+
* prefix into the file and sends it to the cloud, so it is never a value
|
|
49
|
+
* Jaime has to discover by diffing afterwards. */
|
|
50
|
+
inferredPrefixes: Array<{
|
|
51
|
+
name: string;
|
|
52
|
+
prefix: string;
|
|
53
|
+
}>;
|
|
46
54
|
}
|
|
47
55
|
/**
|
|
48
56
|
* Execute a plan. `cloud` provides bodies for local writes (keyed by id). Cloud
|
|
49
57
|
* writes require a project slug in `auth`; a read-only share run has none and
|
|
50
58
|
* its plan carries no cloud-writing actions.
|
|
51
59
|
*/
|
|
52
|
-
export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string): Promise<SyncOutcome>;
|
|
60
|
+
export declare function executePlan(plan: PlannedAction[], cloud: CloudSnapshot, auth: CloudAuth, workspaceRoot: string, mode: SyncMode): Promise<SyncOutcome>;
|
|
53
61
|
//# sourceMappingURL=execute.d.ts.map
|
package/dist/sync/execute.js
CHANGED
|
@@ -10,9 +10,9 @@ import * as path from "node:path";
|
|
|
10
10
|
import { ConvexHttpClient } from "convex/browser";
|
|
11
11
|
import { getConvexUrl } from "../config.js";
|
|
12
12
|
import { api } from "../convex.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { resolveLocalPath
|
|
13
|
+
import { parseFilesForPushIndividually, WEB_APP_URL } from "../push/core.js";
|
|
14
|
+
import { composeMarkdownWithTitle, splitLeadingH1, writeRequirementsFile, } from "../schema/index.js";
|
|
15
|
+
import { resolveLocalPath } from "./local-files.js";
|
|
16
16
|
function emptyOutcome() {
|
|
17
17
|
return {
|
|
18
18
|
cloudCreated: 0,
|
|
@@ -27,6 +27,7 @@ function emptyOutcome() {
|
|
|
27
27
|
writeBackWarnings: [],
|
|
28
28
|
importWarnings: [],
|
|
29
29
|
renames: [],
|
|
30
|
+
inferredPrefixes: [],
|
|
30
31
|
};
|
|
31
32
|
}
|
|
32
33
|
/**
|
|
@@ -34,7 +35,7 @@ function emptyOutcome() {
|
|
|
34
35
|
* writes require a project slug in `auth`; a read-only share run has none and
|
|
35
36
|
* its plan carries no cloud-writing actions.
|
|
36
37
|
*/
|
|
37
|
-
export async function executePlan(plan, cloud, auth, workspaceRoot) {
|
|
38
|
+
export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
|
|
38
39
|
const outcome = emptyOutcome();
|
|
39
40
|
const client = new ConvexHttpClient(getConvexUrl());
|
|
40
41
|
const cloudById = new Map(cloud.documents.map((d) => [d.documentId, d]));
|
|
@@ -59,7 +60,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot) {
|
|
|
59
60
|
await cloudDelete(client, auth, doc, outcome);
|
|
60
61
|
break;
|
|
61
62
|
case "local_write":
|
|
62
|
-
localWrite(doc, cloudById, usedPaths, requirementsDir, outcome);
|
|
63
|
+
localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode);
|
|
63
64
|
break;
|
|
64
65
|
case "local_delete":
|
|
65
66
|
localDelete(doc, outcome);
|
|
@@ -141,6 +142,16 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
|
|
|
141
142
|
outcome.cloudCreated++;
|
|
142
143
|
else
|
|
143
144
|
outcome.cloudUpdated++;
|
|
145
|
+
// DOC-HEADER-11.4: the prefix that just went to the cloud was read off the
|
|
146
|
+
// first requirement key, not authored — say which one, so it is never a
|
|
147
|
+
// value Jaime finds out about by diffing. Reported only now that the save
|
|
148
|
+
// has succeeded: a prefix from a push that failed was never used.
|
|
149
|
+
if (parsed.inferredDefaultPrefix && meta?.defaultPrefix) {
|
|
150
|
+
outcome.inferredPrefixes.push({
|
|
151
|
+
name: path.basename(doc.filePath),
|
|
152
|
+
prefix: meta.defaultPrefix,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
144
155
|
// SYNC-TITLE-1.3: a push that changes the title is a rename — announce it,
|
|
145
156
|
// so an unintended H1 edit is visible and costs one heading edit to undo.
|
|
146
157
|
// The new title is derived from the pushed markdown exactly as the server
|
|
@@ -167,17 +178,19 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
|
|
|
167
178
|
// failure is a warning on a synced document, never a sync failure (which
|
|
168
179
|
// would invite a retry that mints a duplicate).
|
|
169
180
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
// The legacy frontmatter title is not carried over (SYNC-TITLE-1.2) — the
|
|
182
|
+
// body already carries the name as its leading H1 (materialized above,
|
|
183
|
+
// before the push), so the rewritten file loses the field without losing
|
|
184
|
+
// the title.
|
|
185
|
+
writeRequirementsFile(doc.filePath, {
|
|
186
|
+
documentId,
|
|
187
|
+
// The prefix just pushed is the file's own (or the one inferred from
|
|
188
|
+
// its first key, DOC-HEADER-11.4, which the write-back materializes).
|
|
189
|
+
defaultPrefix: meta?.defaultPrefix
|
|
190
|
+
? { set: meta.defaultPrefix }
|
|
191
|
+
: "keep",
|
|
192
|
+
version: (parsed.metadata.version ?? 0) + 1,
|
|
193
|
+
}, parsed.markdownContent);
|
|
181
194
|
}
|
|
182
195
|
catch (writeErr) {
|
|
183
196
|
outcome.writeBackWarnings.push({
|
|
@@ -202,7 +215,7 @@ async function cloudDelete(client, auth, doc, outcome) {
|
|
|
202
215
|
});
|
|
203
216
|
outcome.cloudDeleted++;
|
|
204
217
|
}
|
|
205
|
-
function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
|
|
218
|
+
function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode) {
|
|
206
219
|
const cloudDoc = doc.documentId ? cloudById.get(doc.documentId) : undefined;
|
|
207
220
|
if (!cloudDoc)
|
|
208
221
|
throw new Error(`No cloud content for ${doc.title}`);
|
|
@@ -210,7 +223,20 @@ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome) {
|
|
|
210
223
|
fs.mkdirSync(requirementsDir, { recursive: true });
|
|
211
224
|
const filePath = resolveLocalPath(cloudDoc, doc.filePath, usedPaths, requirementsDir);
|
|
212
225
|
usedPaths.add(filePath);
|
|
213
|
-
|
|
226
|
+
// DOC-HEADER-11.6/11.7: a cloud document with no prefix means "nothing to
|
|
227
|
+
// impose" under an additive sync and "no prefix" under cloud-wins authority
|
|
228
|
+
// — so a prefix Jaime wrote is only ever removed by the mode they ran to
|
|
229
|
+
// make the repo match the cloud.
|
|
230
|
+
const defaultPrefix = cloudDoc.defaultPrefix
|
|
231
|
+
? { set: cloudDoc.defaultPrefix }
|
|
232
|
+
: mode === "cloud_wins"
|
|
233
|
+
? "clear"
|
|
234
|
+
: "keep";
|
|
235
|
+
const changed = writeRequirementsFile(filePath, {
|
|
236
|
+
documentId: cloudDoc.documentId,
|
|
237
|
+
defaultPrefix,
|
|
238
|
+
version: cloudDoc.version,
|
|
239
|
+
}, cloudDoc.body);
|
|
214
240
|
if (changed)
|
|
215
241
|
outcome.localWritten++;
|
|
216
242
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* sync.
|
|
4
|
-
* build (SYNC-WRITE-1) live here so pull and sync agree.
|
|
2
|
+
* Choosing where cloud content lands in `.requirements/` — the local half of
|
|
3
|
+
* sync. The bytes themselves are written by `writeRequirementsFile`.
|
|
5
4
|
*/
|
|
6
5
|
import type { CloudDocument } from "./types.js";
|
|
7
6
|
/** Sanitize a document title into a filename stem. */
|
|
@@ -14,13 +13,4 @@ export declare function sanitizeFileName(title: string): string;
|
|
|
14
13
|
* (SYNC-WEB-CREATE-2).
|
|
15
14
|
*/
|
|
16
15
|
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
16
|
//# sourceMappingURL=local-files.d.ts.map
|
package/dist/sync/local-files.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* sync.
|
|
4
|
-
* build (SYNC-WRITE-1) live here so pull and sync agree.
|
|
2
|
+
* Choosing where cloud content lands in `.requirements/` — the local half of
|
|
3
|
+
* sync. The bytes themselves are written by `writeRequirementsFile`.
|
|
5
4
|
*/
|
|
6
5
|
import * as fs from "node:fs";
|
|
7
6
|
import * as path from "node:path";
|
|
8
|
-
import { buildRequirementsFile } from "../schema/index.js";
|
|
9
|
-
import { extractFrontmatterBlock } from "../schema/parser-core.js";
|
|
10
7
|
/** Sanitize a document title into a filename stem. */
|
|
11
8
|
export function sanitizeFileName(title) {
|
|
12
9
|
return title
|
|
@@ -33,61 +30,4 @@ export function resolveLocalPath(cloud, existingPath, usedPaths, requirementsDir
|
|
|
33
30
|
}
|
|
34
31
|
return candidate;
|
|
35
32
|
}
|
|
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
33
|
//# sourceMappingURL=local-files.js.map
|
package/dist/sync/segment.d.ts
CHANGED
|
@@ -23,8 +23,8 @@ export type Unit = {
|
|
|
23
23
|
export declare function normalizeProse(text: string): string;
|
|
24
24
|
/**
|
|
25
25
|
* Split a body into its ordered units. Prose between (and around) fences
|
|
26
|
-
* becomes prose units when non-empty after normalization;
|
|
27
|
-
* requirement unit keyed by its
|
|
26
|
+
* becomes prose units when non-empty after normalization; every root inside a
|
|
27
|
+
* fence becomes a requirement unit keyed by its KEY.
|
|
28
28
|
*/
|
|
29
29
|
export declare function segmentBody(body: string): Unit[];
|
|
30
30
|
/**
|