@popoverai/dotrequirements 0.30.1 → 0.32.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 +26 -9
- package/dist/cli.js +5 -0
- package/dist/commands/create-requirement-document.js +1 -0
- package/dist/commands/diff.js +6 -1
- package/dist/commands/greenfield-discovery.d.ts +10 -0
- package/dist/commands/greenfield-discovery.js +13 -0
- package/dist/commands/sync.js +50 -6
- package/dist/convex.d.ts +1 -0
- package/dist/convex.js +2 -0
- package/dist/requirements/greenfield.d.ts +38 -0
- package/dist/requirements/greenfield.js +182 -0
- package/dist/requirements/style-guide.d.ts +10 -2
- package/dist/requirements/style-guide.js +23 -4
- package/dist/schema/attachment-lists.d.ts +21 -0
- package/dist/schema/attachment-lists.js +22 -0
- package/dist/schema/browser.d.ts +1 -0
- package/dist/schema/browser.js +5 -0
- package/dist/schema/file-writer.d.ts +21 -0
- package/dist/schema/file-writer.js +7 -0
- package/dist/schema/index.d.ts +2 -1
- package/dist/schema/index.js +1 -0
- package/dist/schema/schemas.d.ts +152 -0
- package/dist/schema/schemas.js +18 -0
- package/dist/sync/attachments.d.ts +64 -0
- package/dist/sync/attachments.js +149 -0
- package/dist/sync/compare.js +94 -10
- package/dist/sync/execute.d.ts +11 -0
- package/dist/sync/execute.js +135 -2
- package/dist/sync/publish.d.ts +10 -0
- package/dist/sync/render.js +7 -0
- package/dist/sync/snapshot.js +28 -1
- package/dist/sync/types.d.ts +23 -0
- package/dist/templates/context-file-section.md +2 -1
- package/package.json +5 -4
package/dist/sync/compare.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* reads no disk and makes no network calls. `dotreq diff` renders its result,
|
|
4
4
|
* `dotreq sync` acts on it, CI asserts on it.
|
|
5
5
|
*/
|
|
6
|
+
import { attachmentListsEqual } from "./attachments.js";
|
|
6
7
|
import { normalizeProse, requirementUnitMap, segmentBody, } from "./segment.js";
|
|
7
8
|
/** Whitespace-only normalization for the fast-path body equality check. */
|
|
8
9
|
function normalizeBody(body) {
|
|
@@ -184,6 +185,81 @@ function proseLabel(text) {
|
|
|
184
185
|
? `${firstLine.slice(0, 40)}…`
|
|
185
186
|
: firstLine || "(prose)";
|
|
186
187
|
}
|
|
188
|
+
function attachmentLabel(url) {
|
|
189
|
+
return url.length > 60 ? `${url.slice(0, 60)}…` : url;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Describe attachment differences for `dotreq diff` (ATTACH-10.2). Display
|
|
193
|
+
* only: attachments never touch the document's verdict — they resolve by
|
|
194
|
+
* their own rule in sync/attachments.ts. The directional verdicts here name
|
|
195
|
+
* which side holds the difference, for the reader.
|
|
196
|
+
*/
|
|
197
|
+
function describeAttachments(local, cloud) {
|
|
198
|
+
const details = [];
|
|
199
|
+
const localByUrl = new Map((local ?? []).map((a) => [a.url, a]));
|
|
200
|
+
const cloudByUrl = new Map((cloud ?? []).map((a) => [a.url, a]));
|
|
201
|
+
for (const [url, a] of localByUrl) {
|
|
202
|
+
const c = cloudByUrl.get(url);
|
|
203
|
+
if (!c) {
|
|
204
|
+
details.push({
|
|
205
|
+
unit: attachmentLabel(url),
|
|
206
|
+
verdict: "additions_in_repo",
|
|
207
|
+
note: "attachment",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else if ((a.title ?? "") !== (c.title ?? "")) {
|
|
211
|
+
details.push({
|
|
212
|
+
unit: attachmentLabel(url),
|
|
213
|
+
verdict: (a.title ?? "") === "" ? "additions_in_cloud" : "additions_in_repo",
|
|
214
|
+
note: "attachment name",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
for (const url of cloudByUrl.keys()) {
|
|
219
|
+
if (!localByUrl.has(url)) {
|
|
220
|
+
details.push({
|
|
221
|
+
unit: attachmentLabel(url),
|
|
222
|
+
verdict: "additions_in_cloud",
|
|
223
|
+
note: "attachment",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Duplicate entries collapse in the URL-keyed maps above, so a
|
|
228
|
+
// duplicated row would flag `differ` with no detail line beneath it —
|
|
229
|
+
// a not-in-sync header over nothing. Count per URL and say so.
|
|
230
|
+
const countBy = (list) => {
|
|
231
|
+
const counts = new Map();
|
|
232
|
+
for (const a of list ?? [])
|
|
233
|
+
counts.set(a.url, (counts.get(a.url) ?? 0) + 1);
|
|
234
|
+
return counts;
|
|
235
|
+
};
|
|
236
|
+
const localCounts = countBy(local);
|
|
237
|
+
const cloudCounts = countBy(cloud);
|
|
238
|
+
for (const [url, n] of cloudCounts) {
|
|
239
|
+
// Both-sides URLs only: a cloud-only duplicated URL already gets the
|
|
240
|
+
// first loop's addition line, and a second line with the same label
|
|
241
|
+
// double-reports one link (round 10). No local twin — the frontmatter
|
|
242
|
+
// schema rejects duplicates, so such a file is invalid_file before it
|
|
243
|
+
// reaches this comparison.
|
|
244
|
+
const localN = localCounts.get(url);
|
|
245
|
+
if (n > 1 && localN !== undefined && n !== localN) {
|
|
246
|
+
details.push({
|
|
247
|
+
unit: attachmentLabel(url),
|
|
248
|
+
verdict: "additions_in_cloud",
|
|
249
|
+
note: "duplicated entry",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// `differ` is THE predicate every consumer gates on (the sync command's
|
|
254
|
+
// gate resolves it further by mode; the executor's residual path trusts
|
|
255
|
+
// it) — so it is computed by the same set-equality the resolver uses,
|
|
256
|
+
// not from these display details: the URL-keyed maps above collapse
|
|
257
|
+
// duplicate entries that the length-aware comparison still sees.
|
|
258
|
+
return {
|
|
259
|
+
differ: !attachmentListsEqual(local ?? [], cloud ?? []),
|
|
260
|
+
details,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
187
263
|
/** Compare one document across surfaces. */
|
|
188
264
|
function compareDocument(local, cloud) {
|
|
189
265
|
const base = {
|
|
@@ -194,9 +270,17 @@ function compareDocument(local, cloud) {
|
|
|
194
270
|
};
|
|
195
271
|
const diverges = attributesDiverge(local, cloud);
|
|
196
272
|
const body = local.body ?? "";
|
|
273
|
+
const attach = describeAttachments(local.attachments, cloud.attachments);
|
|
274
|
+
const attachments = {
|
|
275
|
+
local: local.attachments,
|
|
276
|
+
cloud: cloud.attachments ?? [],
|
|
277
|
+
differ: attach.differ,
|
|
278
|
+
details: attach.details,
|
|
279
|
+
};
|
|
197
280
|
// Fast path: whitespace-identical bodies with matching attributes.
|
|
281
|
+
// Attachments deliberately don't gate this — they resolve on their own.
|
|
198
282
|
if (!diverges && normalizeBody(body) === normalizeBody(cloud.body)) {
|
|
199
|
-
return { ...base, verdict: "in_sync" };
|
|
283
|
+
return { ...base, verdict: "in_sync", attachments };
|
|
200
284
|
}
|
|
201
285
|
// SYNC-FAIL-4.2: cloud bodies reach the snapshot as stored markdown, never
|
|
202
286
|
// re-parsed, so a document saved before today's grammar can still be
|
|
@@ -211,21 +295,21 @@ function compareDocument(local, cloud) {
|
|
|
211
295
|
classified = classifyBodies(body, cloud.body);
|
|
212
296
|
}
|
|
213
297
|
catch {
|
|
214
|
-
|
|
298
|
+
// `attachments` rides along like every other return: without it the
|
|
299
|
+
// residual sync and verbose diff silently drop this document's links.
|
|
300
|
+
return { ...base, verdict: "conflict", attachments };
|
|
215
301
|
}
|
|
216
302
|
const { verdict: bodyVerdict, details } = classified;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
else {
|
|
223
|
-
verdict = bodyVerdict;
|
|
224
|
-
}
|
|
303
|
+
// ATTACH-10.2: the verdict is the BODY's verdict. Attachments never move
|
|
304
|
+
// it — a name difference could otherwise freeze the document
|
|
305
|
+
// (skip_conflict) or, worse, plan a destructive body publish over a
|
|
306
|
+
// colleague's unpublished work, from someone naming a link in frontmatter.
|
|
307
|
+
const verdict = diverges ? "conflict" : bodyVerdict;
|
|
225
308
|
return {
|
|
226
309
|
...base,
|
|
227
310
|
verdict,
|
|
228
311
|
details: verdict === "in_sync" ? undefined : details,
|
|
312
|
+
attachments,
|
|
229
313
|
};
|
|
230
314
|
}
|
|
231
315
|
/**
|
package/dist/sync/execute.d.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface SyncOutcome {
|
|
|
16
16
|
cloudDeleted: number;
|
|
17
17
|
localWritten: number;
|
|
18
18
|
localDeleted: number;
|
|
19
|
+
/** ATTACH-10.2: attachment-only operations, outside the body actions. */
|
|
20
|
+
attachmentPushes: number;
|
|
21
|
+
attachmentWrites: number;
|
|
22
|
+
/** ATTACH-10.3.1.0: cloud attachments a push removed on the file's
|
|
23
|
+
* authority. Recorded only after the push succeeded — the report's trace
|
|
24
|
+
* must never claim a removal a refused or failed push left in place. */
|
|
25
|
+
attachmentRemovals: Array<{
|
|
26
|
+
name: string;
|
|
27
|
+
url: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
}>;
|
|
19
30
|
conflicts: DocumentComparison[];
|
|
20
31
|
invalid: DocumentComparison[];
|
|
21
32
|
errors: Array<{
|
package/dist/sync/execute.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getConvexUrl } from "../config.js";
|
|
|
14
14
|
import { api } from "../convex.js";
|
|
15
15
|
import { parseFilesForPushIndividually, WEB_APP_URL } from "../push/core.js";
|
|
16
16
|
import { composeMarkdownWithTitle, splitLeadingH1, writeRequirementsFile, } from "../schema/index.js";
|
|
17
|
+
import { cloudAttachmentRemovals, resolveAttachments } from "./attachments.js";
|
|
17
18
|
import { resolveLocalPath } from "./local-files.js";
|
|
18
19
|
import { publishToCloud } from "./publish.js";
|
|
19
20
|
function emptyOutcome() {
|
|
@@ -23,6 +24,9 @@ function emptyOutcome() {
|
|
|
23
24
|
cloudDeleted: 0,
|
|
24
25
|
localWritten: 0,
|
|
25
26
|
localDeleted: 0,
|
|
27
|
+
attachmentPushes: 0,
|
|
28
|
+
attachmentWrites: 0,
|
|
29
|
+
attachmentRemovals: [],
|
|
26
30
|
conflicts: [],
|
|
27
31
|
invalid: [],
|
|
28
32
|
errors: [],
|
|
@@ -59,7 +63,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
|
|
|
59
63
|
outcome.invalid.push(doc);
|
|
60
64
|
break;
|
|
61
65
|
case "cloud_write":
|
|
62
|
-
await cloudWrite(auth, doc, cloudById, outcome);
|
|
66
|
+
await cloudWrite(auth, doc, cloudById, outcome, mode);
|
|
63
67
|
break;
|
|
64
68
|
case "cloud_delete":
|
|
65
69
|
await cloudDelete(client, auth, doc, outcome);
|
|
@@ -71,6 +75,12 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
|
|
|
71
75
|
localDelete(doc, outcome);
|
|
72
76
|
break;
|
|
73
77
|
}
|
|
78
|
+
// ATTACH-10.2: attachments resolve outside the body's verdict, so a
|
|
79
|
+
// body action may not have carried them — cover the residue. cloud_write
|
|
80
|
+
// pushed cloudFinal and wrote back localFinal; local_write wrote
|
|
81
|
+
// localFinal; deletes moot the question. Everything else (skip,
|
|
82
|
+
// skip_conflict, and the un-pushed side of local_write) lands here.
|
|
83
|
+
await syncResidualAttachments(client, auth, doc, action, outcome, mode);
|
|
74
84
|
}
|
|
75
85
|
catch (err) {
|
|
76
86
|
outcome.errors.push({ name, error: errorDisplayMessage(err) });
|
|
@@ -78,6 +88,79 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
|
|
|
78
88
|
}
|
|
79
89
|
return outcome;
|
|
80
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Attachment-only operations for whatever the body action didn't cover.
|
|
93
|
+
* The push goes through setAttachmentsForSync — a metadata patch, never the
|
|
94
|
+
* publish path, so naming a link cannot displace anyone's working document.
|
|
95
|
+
*/
|
|
96
|
+
async function syncResidualAttachments(client, auth, doc, action, outcome, mode) {
|
|
97
|
+
if (!doc.attachments?.differ || !doc.documentId)
|
|
98
|
+
return;
|
|
99
|
+
// A conflicted document is reported as left alone, with a resolution hint
|
|
100
|
+
// — rewriting its frontmatter or its cloud list would falsify that report
|
|
101
|
+
// for someone mid-resolution. Deletes and invalid files moot the question.
|
|
102
|
+
if (action === "cloud_delete" ||
|
|
103
|
+
action === "local_delete" ||
|
|
104
|
+
action === "invalid" ||
|
|
105
|
+
action === "skip_conflict") {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const res = resolveAttachments(doc.attachments.local, doc.attachments.cloud, mode);
|
|
109
|
+
if (res.pushNeeded && action !== "cloud_write") {
|
|
110
|
+
if (!auth.projectSlug) {
|
|
111
|
+
throw new Error("Cannot sync attachments without project credentials.");
|
|
112
|
+
}
|
|
113
|
+
await client.mutation(api.documents.mutations.setAttachmentsForSync, {
|
|
114
|
+
projectAuth: {
|
|
115
|
+
projectSlug: auth.projectSlug,
|
|
116
|
+
projectSecret: auth.projectSecret,
|
|
117
|
+
},
|
|
118
|
+
target: { type: "project", slug: auth.projectSlug },
|
|
119
|
+
documentId: doc.documentId,
|
|
120
|
+
attachments: res.cloudFinal,
|
|
121
|
+
// Compare-and-set: the resolution was computed from the snapshot's
|
|
122
|
+
// cloud list; if the cloud moved since (a paste in the web app while
|
|
123
|
+
// this run sat at a prompt), replaying our list would delete it. The
|
|
124
|
+
// mutation refuses instead, and the next run resolves fresh.
|
|
125
|
+
expectAttachments: doc.attachments.cloud,
|
|
126
|
+
});
|
|
127
|
+
outcome.attachmentPushes++;
|
|
128
|
+
for (const removed of cloudAttachmentRemovals(doc.attachments, mode)) {
|
|
129
|
+
outcome.attachmentRemovals.push({
|
|
130
|
+
name: doc.title,
|
|
131
|
+
url: removed.url,
|
|
132
|
+
title: removed.title,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (res.writeNeeded &&
|
|
137
|
+
res.localFinal !== undefined &&
|
|
138
|
+
action !== "local_write" &&
|
|
139
|
+
action !== "cloud_write" &&
|
|
140
|
+
doc.filePath) {
|
|
141
|
+
const { parsedFiles } = parseFilesForPushIndividually([doc.filePath]);
|
|
142
|
+
const parsed = parsedFiles[0];
|
|
143
|
+
if (!parsed)
|
|
144
|
+
return;
|
|
145
|
+
// The writer drops the legacy frontmatter `document.title`
|
|
146
|
+
// (SYNC-TITLE-1.2) — same compensation cloudWrite makes: an H1-less
|
|
147
|
+
// legacy body gets its title materialized as the leading H1, or an
|
|
148
|
+
// attachment-only write would leave the file untitled and the next
|
|
149
|
+
// compare would flag a phantom title conflict.
|
|
150
|
+
let body = parsed.markdownContent;
|
|
151
|
+
const legacyTitle = parsed.metadata.document?.title;
|
|
152
|
+
if (legacyTitle && splitLeadingH1(body).title === null) {
|
|
153
|
+
body = composeMarkdownWithTitle(legacyTitle, body);
|
|
154
|
+
}
|
|
155
|
+
writeRequirementsFile(doc.filePath, {
|
|
156
|
+
documentId: doc.documentId,
|
|
157
|
+
defaultPrefix: "keep",
|
|
158
|
+
attachments: { set: res.localFinal },
|
|
159
|
+
version: (parsed.metadata.version ?? 0) + 1,
|
|
160
|
+
}, body);
|
|
161
|
+
outcome.attachmentWrites++;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
81
164
|
/**
|
|
82
165
|
* Servers throw ConvexError({kind, message}) for expected failures; production
|
|
83
166
|
* redacts the Error message to "Server Error" but preserves error.data.
|
|
@@ -89,7 +172,7 @@ function errorDisplayMessage(err) {
|
|
|
89
172
|
return data.message;
|
|
90
173
|
return err instanceof Error ? err.message : String(err);
|
|
91
174
|
}
|
|
92
|
-
async function cloudWrite(auth, doc, cloudById, outcome) {
|
|
175
|
+
async function cloudWrite(auth, doc, cloudById, outcome, mode) {
|
|
93
176
|
if (!auth.projectSlug || !doc.filePath) {
|
|
94
177
|
throw new Error("Cannot write to the cloud without project credentials.");
|
|
95
178
|
}
|
|
@@ -109,6 +192,7 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
|
|
|
109
192
|
if (meta?.title && splitLeadingH1(parsed.markdownContent).title === null) {
|
|
110
193
|
parsed.markdownContent = composeMarkdownWithTitle(meta.title, parsed.markdownContent);
|
|
111
194
|
}
|
|
195
|
+
const attachmentsRes = resolveAttachments(meta?.attachments, meta?.id ? cloudById.get(meta.id)?.attachments : undefined, mode);
|
|
112
196
|
// SYNC-CLI-EDIT-2: the push goes to the web app, which writes it into the
|
|
113
197
|
// working document Quinn may have open and publishes a copy printed from it.
|
|
114
198
|
// Convex cannot do that job — the conversion needs the BlockNote schema.
|
|
@@ -124,9 +208,43 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
|
|
|
124
208
|
title: meta?.title,
|
|
125
209
|
markdownContent: parsed.markdownContent,
|
|
126
210
|
defaultPrefix: meta?.defaultPrefix,
|
|
211
|
+
// ATTACH-10.2: the push carries the RESOLVED list (sync/attachments.ts)
|
|
212
|
+
// — union in additive modes, authority as declared, a file with no
|
|
213
|
+
// attachments key having no opinion — or nothing at all when the
|
|
214
|
+
// cloud's list already is the resolution.
|
|
215
|
+
attachments: attachmentsRes.pushNeeded
|
|
216
|
+
? attachmentsRes.cloudFinal
|
|
217
|
+
: undefined,
|
|
218
|
+
// Compare-and-set whenever the push replaces the list: the cloud copy
|
|
219
|
+
// the resolution was computed against. A link pasted mid-sync makes the
|
|
220
|
+
// publish refuse (dry-run first, so nothing is destroyed) instead of
|
|
221
|
+
// silently replaying the stale list over it.
|
|
222
|
+
expectAttachments: attachmentsRes.pushNeeded
|
|
223
|
+
? meta?.id
|
|
224
|
+
? (cloudById.get(meta.id)?.attachments ?? [])
|
|
225
|
+
: []
|
|
226
|
+
: undefined,
|
|
127
227
|
runMarker: parsed.metadata.ctsRun,
|
|
128
228
|
});
|
|
129
229
|
const documentId = saveResult.documentId;
|
|
230
|
+
// ATTACH-10.3.1.0: the publish above is the push that removes cloud
|
|
231
|
+
// attachments under repo authority — trace them now that it has landed.
|
|
232
|
+
// Named by file, like every other per-document line here: doc.title is
|
|
233
|
+
// pinned to the OLD cloud title, so a push that renames the H1 and drops
|
|
234
|
+
// a link in one edit would print the removal against a title that no
|
|
235
|
+
// longer exists (round-2 release review).
|
|
236
|
+
if (attachmentsRes.pushNeeded) {
|
|
237
|
+
const cloudBefore = meta?.id
|
|
238
|
+
? (cloudById.get(meta.id)?.attachments ?? [])
|
|
239
|
+
: [];
|
|
240
|
+
for (const removed of cloudAttachmentRemovals({ local: meta?.attachments, cloud: cloudBefore }, mode)) {
|
|
241
|
+
outcome.attachmentRemovals.push({
|
|
242
|
+
name: path.basename(doc.filePath),
|
|
243
|
+
url: removed.url,
|
|
244
|
+
title: removed.title,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
130
248
|
// The server accepted the publish and told us something after it did not
|
|
131
249
|
// finish. Reported rather than dropped: the push DID land, so this is not a
|
|
132
250
|
// failure, but "synced" on its own would claim more than happened.
|
|
@@ -217,6 +335,11 @@ async function cloudWrite(auth, doc, cloudById, outcome) {
|
|
|
217
335
|
defaultPrefix: meta?.defaultPrefix
|
|
218
336
|
? { set: meta.defaultPrefix }
|
|
219
337
|
: "keep",
|
|
338
|
+
// The file adopts its side of the resolution — cloud-held names it
|
|
339
|
+
// lacked, for one (ATTACH-10.2).
|
|
340
|
+
attachments: attachmentsRes.writeNeeded && attachmentsRes.localFinal !== undefined
|
|
341
|
+
? { set: attachmentsRes.localFinal }
|
|
342
|
+
: "keep",
|
|
220
343
|
version: (parsed.metadata.version ?? 0) + 1,
|
|
221
344
|
}, contentToWriteBack);
|
|
222
345
|
// Counted only once the rewrite actually happened: reporting it before a
|
|
@@ -265,9 +388,19 @@ function localWrite(doc, cloudById, usedPaths, requirementsDir, outcome, mode) {
|
|
|
265
388
|
: mode === "cloud_wins"
|
|
266
389
|
? "clear"
|
|
267
390
|
: "keep";
|
|
391
|
+
// ATTACH-10.0/10.2: the file adopts its side of the attachment resolution
|
|
392
|
+
// — cloud's list plus the file's own additions in additive modes, the
|
|
393
|
+
// cloud's exactly under cloud authority. An explicit empty list is written
|
|
394
|
+
// as `attachments: []` (present key = an opinion); no write happens when
|
|
395
|
+
// the file already agrees.
|
|
396
|
+
const attachRes = resolveAttachments(doc.attachments?.local, cloudDoc.attachments, mode);
|
|
397
|
+
const attachments = attachRes.writeNeeded && attachRes.localFinal !== undefined
|
|
398
|
+
? { set: attachRes.localFinal }
|
|
399
|
+
: "keep";
|
|
268
400
|
const changed = writeRequirementsFile(filePath, {
|
|
269
401
|
documentId: cloudDoc.documentId,
|
|
270
402
|
defaultPrefix,
|
|
403
|
+
attachments,
|
|
271
404
|
version: cloudDoc.version,
|
|
272
405
|
}, cloudDoc.body);
|
|
273
406
|
if (changed)
|
package/dist/sync/publish.d.ts
CHANGED
|
@@ -21,6 +21,16 @@ export interface PublishParams {
|
|
|
21
21
|
/** Legacy frontmatter title, used only as the server's H1-less fallback. */
|
|
22
22
|
title?: string;
|
|
23
23
|
defaultPrefix?: string;
|
|
24
|
+
/** document.attachments from the file's frontmatter (ATTACH-10.1). */
|
|
25
|
+
attachments?: Array<{
|
|
26
|
+
url: string;
|
|
27
|
+
title?: string;
|
|
28
|
+
}>;
|
|
29
|
+
/** Compare-and-set: the cloud attachments the sync read them against. */
|
|
30
|
+
expectAttachments?: Array<{
|
|
31
|
+
url: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
}>;
|
|
24
34
|
runMarker?: string;
|
|
25
35
|
}
|
|
26
36
|
/**
|
package/dist/sync/render.js
CHANGED
|
@@ -33,6 +33,13 @@ export function resolutionHint(doc) {
|
|
|
33
33
|
}
|
|
34
34
|
/** One line per document: glyph, name, verdict. */
|
|
35
35
|
export function formatVerdictLine(doc) {
|
|
36
|
+
// ATTACH-10.2: attachments resolve outside the verdict, so a document can
|
|
37
|
+
// be in sync while its attachments differ — say so rather than reporting
|
|
38
|
+
// "in sync" over a real difference. (Diff is mode-independent, like the
|
|
39
|
+
// body verdicts: whether a sync would act on it depends on the mode.)
|
|
40
|
+
if (doc.verdict === "in_sync" && doc.attachments?.differ) {
|
|
41
|
+
return ` ~ ${displayName(doc).padEnd(40)} attachments differ`;
|
|
42
|
+
}
|
|
36
43
|
return ` ${GLYPH[doc.verdict]} ${displayName(doc).padEnd(40)} ${verdictLabel(doc.verdict)}`;
|
|
37
44
|
}
|
|
38
45
|
/** Verbose, one-level-unfolded detail for a document (DIFF-2). */
|
package/dist/sync/snapshot.js
CHANGED
|
@@ -32,6 +32,7 @@ export function readLocalDocument(filePath) {
|
|
|
32
32
|
let documentId;
|
|
33
33
|
let title;
|
|
34
34
|
let defaultPrefix;
|
|
35
|
+
let attachments;
|
|
35
36
|
let frontmatterError;
|
|
36
37
|
const split = splitFrontmatter(raw);
|
|
37
38
|
if (split) {
|
|
@@ -56,6 +57,24 @@ export function readLocalDocument(filePath) {
|
|
|
56
57
|
title = d.title;
|
|
57
58
|
if (typeof d.defaultPrefix === "string")
|
|
58
59
|
defaultPrefix = d.defaultPrefix;
|
|
60
|
+
// ATTACH-10.1: attachments from frontmatter. Shape-filtered here for
|
|
61
|
+
// the same reason as the fields above; a malformed entry is caught by
|
|
62
|
+
// validateMetadata below and flags the file rather than half-syncing.
|
|
63
|
+
// A present-but-empty key (`attachments:` or `attachments: []`) is an
|
|
64
|
+
// explicit "none" — the tri-state the resolver needs: only a file
|
|
65
|
+
// with NO key has no opinion (sync/attachments.ts).
|
|
66
|
+
if (d.attachments === null) {
|
|
67
|
+
attachments = [];
|
|
68
|
+
}
|
|
69
|
+
else if (Array.isArray(d.attachments)) {
|
|
70
|
+
attachments = d.attachments
|
|
71
|
+
.filter((a) => !!a && typeof a === "object" && !Array.isArray(a))
|
|
72
|
+
.filter((a) => typeof a.url === "string")
|
|
73
|
+
.map((a) => ({
|
|
74
|
+
url: a.url,
|
|
75
|
+
title: typeof a.title === "string" ? a.title : undefined,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
59
78
|
}
|
|
60
79
|
}
|
|
61
80
|
if (rawMeta !== undefined && !frontmatterError) {
|
|
@@ -83,7 +102,14 @@ export function readLocalDocument(filePath) {
|
|
|
83
102
|
// frontmatter title stands only when the body has none (SYNC-TITLE-1.1);
|
|
84
103
|
// with neither, the document is untitled (SYNC-TITLE-1.4).
|
|
85
104
|
const derivedTitle = splitLeadingH1(body).title ?? title;
|
|
86
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
filePath,
|
|
107
|
+
documentId,
|
|
108
|
+
title: derivedTitle,
|
|
109
|
+
defaultPrefix,
|
|
110
|
+
attachments,
|
|
111
|
+
body,
|
|
112
|
+
};
|
|
87
113
|
}
|
|
88
114
|
/**
|
|
89
115
|
* Build the local snapshot from the workspace, or from an explicit set of file
|
|
@@ -113,6 +139,7 @@ export async function acquireCloudSnapshot(auth) {
|
|
|
113
139
|
documentId: d.documentId,
|
|
114
140
|
title: d.title,
|
|
115
141
|
defaultPrefix: d.defaultPrefix,
|
|
142
|
+
attachments: d.attachments,
|
|
116
143
|
body: d.markdownContent,
|
|
117
144
|
version: d.version,
|
|
118
145
|
updatedAt: d.updatedAt,
|
package/dist/sync/types.d.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
export type Verdict = "in_sync" | "additions_in_repo" | "additions_in_cloud" | "conflict" | "only_in_repo" | "only_in_cloud" | "invalid_file";
|
|
12
12
|
/** The user-facing spelling of a verdict — same words, spaced. */
|
|
13
13
|
export declare function verdictLabel(verdict: Verdict): string;
|
|
14
|
+
/** One attached link (ATTACH-10): document metadata that syncs both ways. */
|
|
15
|
+
export interface Attachment {
|
|
16
|
+
url: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
14
19
|
/**
|
|
15
20
|
* One document as it exists locally: a `.requirements.md` file. When the file
|
|
16
21
|
* cannot be parsed, `parseError` is set and the other content fields are absent
|
|
@@ -22,6 +27,8 @@ export interface LocalDocument {
|
|
|
22
27
|
documentId?: string;
|
|
23
28
|
title?: string;
|
|
24
29
|
defaultPrefix?: string;
|
|
30
|
+
/** document.attachments from frontmatter (ATTACH-10). */
|
|
31
|
+
attachments?: Attachment[];
|
|
25
32
|
/** Frontmatter-stripped markdown body (the comparison unit, SYNC-ARCH-1). */
|
|
26
33
|
body?: string;
|
|
27
34
|
parseError?: string;
|
|
@@ -33,6 +40,8 @@ export interface CloudDocument {
|
|
|
33
40
|
documentId: string;
|
|
34
41
|
title: string;
|
|
35
42
|
defaultPrefix?: string;
|
|
43
|
+
/** The document's attached links (ATTACH-10). */
|
|
44
|
+
attachments?: Attachment[];
|
|
36
45
|
/** Published markdownContent (SYNC-ARCH-1). */
|
|
37
46
|
body: string;
|
|
38
47
|
version: number;
|
|
@@ -75,6 +84,20 @@ export interface DocumentComparison {
|
|
|
75
84
|
error?: string;
|
|
76
85
|
/** Unit-level breakdown, populated for verbose diff of non-in-sync documents. */
|
|
77
86
|
details?: UnitDetail[];
|
|
87
|
+
/**
|
|
88
|
+
* ATTACH-10.2: attachments never contribute to `verdict` — they resolve by
|
|
89
|
+
* their own rule (sync/attachments.ts), so a name difference can neither
|
|
90
|
+
* freeze the body's sync nor plan a body write. This carries what that
|
|
91
|
+
* resolution needs: both sides' lists (`local` undefined = the file has no
|
|
92
|
+
* attachments key = no opinion) and display details for `dotreq diff`.
|
|
93
|
+
*/
|
|
94
|
+
attachments?: {
|
|
95
|
+
local: Attachment[] | undefined;
|
|
96
|
+
cloud: Attachment[];
|
|
97
|
+
/** True when the two sides' lists differ (set semantics, names count). */
|
|
98
|
+
differ: boolean;
|
|
99
|
+
details: UnitDetail[];
|
|
100
|
+
};
|
|
78
101
|
}
|
|
79
102
|
export interface ComparisonResult {
|
|
80
103
|
documents: DocumentComparison[];
|
|
@@ -57,7 +57,8 @@ test(requirement('REQ-ID.0'), () => { /* test specific criterion */ });
|
|
|
57
57
|
|
|
58
58
|
**Authoring:**
|
|
59
59
|
- `dotreq create-requirement-document [path]` - Print the template and style guide
|
|
60
|
-
- `dotreq
|
|
60
|
+
- `dotreq greenfield-discovery` - Print the interview protocol for working out what something new should do. Run it when the user is starting something new and has not worked out what it should do — the interview beats drafting requirements from their description
|
|
61
|
+
- `dotreq validate [--file <path>]` - Check syntax (works offline)
|
|
61
62
|
- `dotreq diff [scope...]` - Show how the repo and the cloud differ (read-only)
|
|
62
63
|
- `dotreq sync [scope...]` - Reconcile the repo and the cloud (both contribute by default)
|
|
63
64
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popoverai/dotrequirements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Requirements as testable, human-readable data — CLI and test harness for spec-driven development with AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"./test": "./dist/harness/index.js",
|
|
13
13
|
"./schema": "./dist/schema/index.js",
|
|
14
14
|
"./schema/browser": "./dist/schema/browser.js",
|
|
15
|
-
"./style-guide": "./dist/requirements/style-guide.js"
|
|
15
|
+
"./style-guide": "./dist/requirements/style-guide.js",
|
|
16
|
+
"./greenfield": "./dist/requirements/greenfield.js"
|
|
16
17
|
},
|
|
17
18
|
"publishConfig": {
|
|
18
19
|
"access": "public"
|
|
@@ -49,7 +50,7 @@
|
|
|
49
50
|
"email": "support@dotrequirements.io"
|
|
50
51
|
},
|
|
51
52
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
53
|
+
"node": ">=22"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
56
|
"@babel/parser": "^7.28.5",
|
|
@@ -71,7 +72,7 @@
|
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"@types/babel__traverse": "^7.28.0",
|
|
74
|
-
"@types/node": "^
|
|
75
|
+
"@types/node": "^22",
|
|
75
76
|
"@types/uuid": "^11.0.0",
|
|
76
77
|
"typescript": "^5",
|
|
77
78
|
"vitest": "^4.1.10"
|