@popoverai/dotrequirements 0.29.3 → 0.30.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.
@@ -7,7 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import * as readline from "node:readline";
9
9
  import { executePlan } from "../sync/execute.js";
10
- import { acquireCloudSnapshot, acquireLocalSnapshot, compareSnapshots, duplicateLocalDocumentIds, filterByScope, resolutionHint, } from "../sync/index.js";
10
+ import { acquireCloudSnapshot, acquireLocalSnapshot, compareSnapshots, duplicateIdAbortMessage, filterByScope, resolutionHint, } from "../sync/index.js";
11
11
  import { buildPlan, resolveMode, } from "../sync/plan.js";
12
12
  import { brand } from "../utils/brand.js";
13
13
  import { resolveCloudAuth } from "./sync-common.js";
@@ -60,13 +60,11 @@ export async function syncCommand(scope, options) {
60
60
  console.log(`Project: "${cloud.projectName}"`);
61
61
  }
62
62
  // SYNC-FAIL-3: two files claiming one document id would both write it — abort
63
- // before any write, naming both files and the shared id.
64
- const dupes = duplicateLocalDocumentIds(local);
65
- if (dupes.length > 0) {
66
- const d = dupes[0];
67
- throw new Error(`Two files claim the same document ID "${d.documentId}":\n` +
68
- d.filePaths.map((p) => ` ${p}`).join("\n") +
69
- `\nEach file must map to its own cloud document. Remove the "id:" line from the copy's frontmatter, then sync again.`);
63
+ // before any write, naming both files and the shared id, plus any invalid
64
+ // files (SYNC-FAIL-3.2) so one run reports every problem.
65
+ const abortMessage = duplicateIdAbortMessage(local);
66
+ if (abortMessage) {
67
+ throw new Error(abortMessage);
70
68
  }
71
69
  const comparison = compareSnapshots(local, cloud);
72
70
  const { documents, unmatched } = filterByScope(comparison, scope, workspaceRoot);
@@ -134,6 +132,22 @@ export async function syncCommand(scope, options) {
134
132
  console.log(` ⚠ ${name}: this file's import marker was not recognized — your CLI may need updating. Its requirements were saved as natively authored (they count toward your plan's requirement limit).`);
135
133
  }
136
134
  }
135
+ // The publish landed but something after it did not. The server's own words,
136
+ // because it knows what was left undone and whether it repairs itself.
137
+ for (const { name, warning } of outcome.publishWarnings) {
138
+ console.log(` ⚠ ${name}: ${warning}`);
139
+ }
140
+ // SYNC-CLI-EDIT-3.1: the cloud stores the web editor's notation, and files
141
+ // written another way converge to it on push. Each file NAMED, not counted —
142
+ // a count leaves Jaime to find out which files changed from `git diff`,
143
+ // which is exactly what this report exists to prevent.
144
+ if (outcome.normalized.length > 0) {
145
+ const n = outcome.normalized.length;
146
+ console.log(` ${n} document${n === 1 ? "" : "s"} normalized to web-compatible notation:`);
147
+ for (const { name } of outcome.normalized) {
148
+ console.log(` ${name}`);
149
+ }
150
+ }
137
151
  // SYNC-FAIL-2.1: saved to cloud, but the local file couldn't be updated —
138
152
  // give the re-link recovery step so a retry doesn't mint a duplicate.
139
153
  for (const w of outcome.writeBackWarnings) {
@@ -6,7 +6,7 @@
6
6
  * - tracking.jsonl: Append-only tracking of requirement() calls (cross-process safe)
7
7
  * - coverage.json: Deduplication cache for cloud reporting
8
8
  */
9
- import type { RequirementNode } from "../schema/index.js";
9
+ import { type RequirementNode } from "../schema/index.js";
10
10
  /**
11
11
  * Tracking entry for a single requirement() call.
12
12
  * Stored as one JSON object per line in tracking.jsonl
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { findRequirementsFilesSync } from "../requirements/index.js";
12
+ import { canonicalRequirementPath, } from "../schema/index.js";
12
13
  // Cache directory structure
13
14
  const CACHE_DIR = ".cache";
14
15
  const LOOKUP_FILE = "lookup.json";
@@ -80,6 +81,19 @@ export function writeLookupCache(requirementsDir, requirements) {
80
81
  if (labelPath && labelPath !== node.id) {
81
82
  lookup.requirements[labelPath] = { ...entry, isAlias: true };
82
83
  }
84
+ // HARNESS-RESOLVE-2.3: when the file spells the root key non-canonically
85
+ // (e.g. "login-01"), also index every path under the canonical spelling
86
+ // ("LOGIN-1"), so a lookup by the spelling other surfaces display resolves
87
+ // here too — including for the Python/Java helpers, whose only resolution
88
+ // medium is this cache. The entry's id keeps the authored spelling.
89
+ for (const authored of [node.id, labelPath]) {
90
+ if (!authored)
91
+ continue;
92
+ const canonicalPath = canonicalRequirementPath(authored);
93
+ if (canonicalPath !== authored && !lookup.requirements[canonicalPath]) {
94
+ lookup.requirements[canonicalPath] = { ...entry, isAlias: true };
95
+ }
96
+ }
83
97
  // Track label occurrences for disambiguation (e.g. given#0, given#1)
84
98
  const labelCounts = new Map();
85
99
  for (const child of node.children) {
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { execSync } from "node:child_process";
13
13
  import { randomUUID } from "node:crypto";
14
+ import { canonicalRequirementPath } from "../schema/index.js";
14
15
  import { getProjectInfo } from "../utils/project-settings.js";
15
16
  import { cleanupTestRunId, clearCoverageReportFailure, deleteTrackingFile, findProjectRoot, findRequirementsDir, getTestRunId, needsReporting, readCoverageCache, readLookupCache, readTrackingEntries, recordCoverageReplayed, recordCoverageReported, recordCoverageReportFailure, updateCoverageCache, } from "./cache.js";
16
17
  import { formatFailureNotice, formatUnrecordedNotice, refusalReason, } from "./reportingStatus.js";
@@ -478,17 +479,17 @@ export async function finalize(options = {}) {
478
479
  // We resolve those to their canonical numeric key (e.g. "AUTH-LOGIN-1.0") so that
479
480
  // coverage counting, display, and cloud reporting all use consistent keys.
480
481
  if (lookup) {
481
- // HARNESS-RESOLVE-2.1: label paths resolve case-insensitively, but
482
- // tracking preserves the test's original casing while the cache stores
483
- // alias keys lowercased. Fold unmatched keys onto their lowercased-label
484
- // alias first, so a ref like "REQ-1.Given" isn't dropped as foreign below.
482
+ // HARNESS-RESOLVE-2.1/2.3: label paths resolve case-insensitively and
483
+ // root keys resolve across spellings (casing, leading zeros), but
484
+ // tracking preserves the test's original spelling while the cache stores
485
+ // label aliases lowercased and key aliases canonically spelled. Fold
486
+ // unmatched keys onto their normalized form first, so a ref like
487
+ // "REQ-1.Given" or "LOGIN-1.0" (for a file that writes "login-01") isn't
488
+ // dropped as foreign below.
485
489
  for (const [key, trackingEntries] of Array.from(aggregated.entries())) {
486
490
  if (lookup.requirements[key])
487
491
  continue;
488
- const m = /^([A-Z][A-Z0-9_-]*-\d+)(\..+)$/.exec(key);
489
- if (!m)
490
- continue;
491
- const folded = m[1] + m[2].toLowerCase();
492
+ const folded = canonicalRequirementPath(key);
492
493
  if (folded !== key && lookup.requirements[folded]) {
493
494
  const existing = aggregated.get(folded) || [];
494
495
  aggregated.set(folded, [...existing, ...trackingEntries]);
@@ -10,7 +10,7 @@
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { findRequirementsFilesSync } from "../requirements/index.js";
13
- import { parseRequirementsFromFile, resolveRequirementPath, } from "../schema/index.js";
13
+ import { canonicalRequirementPath, parseRequirementsFromFile, resolveRequirementPath, } from "../schema/index.js";
14
14
  import { findProjectRoot as findProjectRootByWalkUp, readLookupCache, } from "./cache.js";
15
15
  // Cache: Maps full requirement IDs (e.g., "REQ-123.0.1") to RequirementNode
16
16
  export const loadedRequirements = new Map();
@@ -64,8 +64,10 @@ function tryLoadFromCache(projectRoot) {
64
64
  children: [], // Children are loaded separately by ID
65
65
  };
66
66
  loadedRequirements.set(id, node);
67
- // If this is a root node (no dot in ID), add to trees cache
68
- if (!id.includes(".")) {
67
+ // If this is a root node (no dot in ID), add to trees cache. Aliases
68
+ // (label paths, canonical key spellings) stay out: the trees map holds
69
+ // one entry per authored root.
70
+ if (!id.includes(".") && !req.isAlias) {
69
71
  requirementTrees.set(id, node);
70
72
  }
71
73
  }
@@ -163,6 +165,15 @@ export function getRequirement(reqId) {
163
165
  return aliased;
164
166
  }
165
167
  }
168
+ // HARNESS-RESOLVE-2.3: root-key spelling is authoring latitude, so retry
169
+ // under the canonical spelling. The lookup cache stores a canonical-key
170
+ // alias for every node whose authored root spelling isn't already
171
+ // canonical, so in cache mode this get lands whatever spelling the file
172
+ // used; the fallback resolver below handles the no-cache mode.
173
+ const canonical = loadedRequirements.get(canonicalRequirementPath(reqId));
174
+ if (canonical) {
175
+ return canonical;
176
+ }
166
177
  // If not found, try resolving label-based paths
167
178
  // resolveRequirementPath expects an array of root requirements and the full path
168
179
  const allTrees = Array.from(requirementTrees.values());
@@ -9,6 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { findRequirementsDir, isCacheStale, readTrackingEntries, } from "../harness/cache.js";
12
+ import { canonicalRequirementPath } from "../schema/index.js";
12
13
  export class LocalReportCacheMissingError extends Error {
13
14
  constructor(message) {
14
15
  super(message);
@@ -70,37 +71,42 @@ export function buildLocalReport(opts) {
70
71
  };
71
72
  }
72
73
  function buildCoverageEntries(lookup, entries, filterKey) {
73
- const locationsByKey = new Map();
74
+ // Assign each tracked reference to the requirement that owns it: the exact
75
+ // spelling (primary or alias key) when the project writes it, else the
76
+ // canonical fold — the same preference order getRequirement uses
77
+ // (REPORT-LOCAL-1.5). Exact first matters: duplicate detection is
78
+ // per-document, so a project can legitimately hold distinct requirements
79
+ // whose keys differ only in spelling, and a test that referenced one must
80
+ // never count toward the other (HARNESS-RESOLVE-3.0). Locations group
81
+ // under the owner's node id, which alias entries share with their primary.
82
+ const locationsById = new Map();
74
83
  for (const entry of entries) {
75
- if (!locationsByKey.has(entry.requirementKey)) {
76
- locationsByKey.set(entry.requirementKey, new Set());
84
+ const owner = lookup.requirements[entry.requirementKey] ??
85
+ lookup.requirements[canonicalRequirementPath(entry.requirementKey)];
86
+ if (!owner)
87
+ continue;
88
+ if (!locationsById.has(owner.id)) {
89
+ locationsById.set(owner.id, new Set());
77
90
  }
78
- locationsByKey.get(entry.requirementKey).add(entry.callerLocation);
91
+ locationsById.get(owner.id).add(entry.callerLocation);
92
+ }
93
+ // The filter names one requirement; resolve it with the same preference
94
+ // order, then scope by the owner's node id (REPORT-LOCAL-1.3). A filter
95
+ // that resolves to nothing matches nothing, as before.
96
+ let filterId;
97
+ if (filterKey) {
98
+ const owner = lookup.requirements[filterKey] ??
99
+ lookup.requirements[canonicalRequirementPath(filterKey)];
100
+ filterId = owner?.id ?? filterKey;
79
101
  }
80
102
  const results = [];
81
103
  for (const [key, req] of Object.entries(lookup.requirements)) {
82
104
  if (req.isAlias)
83
105
  continue;
84
- if (filterKey && key !== filterKey && !key.startsWith(`${filterKey}.`)) {
106
+ if (filterId && key !== filterId && !key.startsWith(`${filterId}.`)) {
85
107
  continue;
86
108
  }
87
- const locations = new Set();
88
- const keyLocations = locationsByKey.get(key);
89
- if (keyLocations) {
90
- keyLocations.forEach((l) => {
91
- locations.add(l);
92
- });
93
- }
94
- for (const [aliasKey, aliasReq] of Object.entries(lookup.requirements)) {
95
- if (aliasReq.isAlias && aliasReq.id === req.id) {
96
- const aliasLocations = locationsByKey.get(aliasKey);
97
- if (aliasLocations) {
98
- aliasLocations.forEach((l) => {
99
- locations.add(l);
100
- });
101
- }
102
- }
103
- }
109
+ const locations = locationsById.get(req.id) ?? new Set();
104
110
  results.push({
105
111
  key,
106
112
  label: req.label || null,
@@ -9,7 +9,7 @@ export { buildMetadata, constructKey, convexToRequirements, extractRequirementKe
9
9
  export { DELIMITER_PATTERN, type ExtractedRequirementBlock, extractRequirementBlocks, findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, parseRequirementBlocksFromMarkdown, parseRootLine, ROOT_LABEL_MARKER, splitRequirementFenceContent, } from "./parser-core.js";
10
10
  export type { Metadata, ParsedCriterion, RequirementKey, RequirementNode, RequirementPrefix, RequirementsFile, } from "./schemas.js";
11
11
  export { buildRequirementKey, canonicalRequirementKey, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
12
- export { composeMarkdownWithTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
12
+ export { composeMarkdownWithTitle, effectiveTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
13
13
  export type { BuildScenarioOptions, Scenario, ScenarioAssertionSource, ScenarioStep, } from "./scenario.js";
14
14
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
15
15
  //# sourceMappingURL=browser.d.ts.map
@@ -24,7 +24,7 @@ REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, Require
24
24
  ValidationError, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
25
25
  // Title semantics (pure TypeScript - browser-safe): the document title and
26
26
  // the markdown's leading H1 are the same thing (DOC-TITLE-3)
27
- export { composeMarkdownWithTitle, splitLeadingH1, } from "./title-markdown.js";
27
+ export { composeMarkdownWithTitle, effectiveTitle, splitLeadingH1, } from "./title-markdown.js";
28
28
  // Scenario building (pure TypeScript - browser-safe, used by Convex Node actions)
29
29
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
30
30
  //# sourceMappingURL=browser.js.map
@@ -15,6 +15,6 @@ export { checkPathAmbiguity, findChildrenByLabel, getAllLabelPaths, parsePathSeg
15
15
  export type { BuildScenarioOptions, Scenario, ScenarioAssertionSource, ScenarioStep, } from "./scenario.js";
16
16
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
17
17
  export type { Metadata, ParsedCriterion, PushValidationResult, RequirementKey, RequirementNode, RequirementPrefix, RequirementsFile, } from "./schemas.js";
18
- export { buildRequirementKey, CONVEX_ID_PATTERN, canonicalRequirementKey, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateForPush, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
19
- export { composeMarkdownWithTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
18
+ export { buildRequirementKey, CONVEX_ID_PATTERN, canonicalRequirementKey, canonicalRequirementPath, MetadataSchema, normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PATTERN, REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema, ValidationError, validateForPush, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
19
+ export { composeMarkdownWithTitle, effectiveTitle, type SplitMarkdown, splitLeadingH1, } from "./title-markdown.js";
20
20
  //# sourceMappingURL=index.d.ts.map
@@ -15,7 +15,7 @@ export { checkPathAmbiguity, findChildrenByLabel, getAllLabelPaths, parsePathSeg
15
15
  // Scenario building (for browser-automation / runScenario)
16
16
  export { buildScenarioFromRequirements, requirementTreeToScenario, } from "./scenario.js";
17
17
  // Schemas and types
18
- export { buildRequirementKey, CONVEX_ID_PATTERN, canonicalRequirementKey,
18
+ export { buildRequirementKey, CONVEX_ID_PATTERN, canonicalRequirementKey, canonicalRequirementPath,
19
19
  // Zod schemas
20
20
  MetadataSchema,
21
21
  // Prefix/key utilities
@@ -24,5 +24,5 @@ normalizePrefix, ParsedCriterionSchema, parseRequirementKey, REQUIREMENT_KEY_PAT
24
24
  REQUIREMENT_PREFIX_PATTERN, RequirementKeySchema, RequirementNodeSchema, RequirementPrefixSchema, RequirementsFileSchema,
25
25
  // Validation
26
26
  ValidationError, validateForPush, validateKey, validateMetadata, validatePrefix, validateRequirementNode, validateRequirementsFile, } from "./schemas.js";
27
- export { composeMarkdownWithTitle, splitLeadingH1, } from "./title-markdown.js";
27
+ export { composeMarkdownWithTitle, effectiveTitle, splitLeadingH1, } from "./title-markdown.js";
28
28
  //# sourceMappingURL=index.js.map
@@ -2,7 +2,7 @@
2
2
  * Resolve label-based requirement paths to numeric paths.
3
3
  * Supports: REQ123.given, REQ123.when.then, REQ123.given#1
4
4
  */
5
- import type { RequirementNode } from "./schemas.js";
5
+ import { type RequirementNode } from "./schemas.js";
6
6
  /**
7
7
  * Parse a requirement path into segments.
8
8
  * E.g., "REQ123.given.then" → ["REQ123", "given", "then"]
@@ -2,6 +2,24 @@
2
2
  * Resolve label-based requirement paths to numeric paths.
3
3
  * Supports: REQ123.given, REQ123.when.then, REQ123.given#1
4
4
  */
5
+ import { canonicalRequirementKey } from "./schemas.js";
6
+ /**
7
+ * Match a path's root segment against the loaded roots: the exact spelling
8
+ * when a root writes it, else canonically — casing and leading zeros are
9
+ * authoring latitude, so `LOGIN-1` finds a root written `login-01` and vice
10
+ * versa (HARNESS-RESOLVE-2.3). Exact first matters: duplicate detection is
11
+ * per-document, so a project can legitimately hold distinct roots whose keys
12
+ * differ only in spelling, and each path must bind to the root that writes
13
+ * it exactly (HARNESS-RESOLVE-3.0).
14
+ */
15
+ function findRoot(requirements, rootId) {
16
+ const exact = requirements.find((r) => r.id === rootId);
17
+ if (exact) {
18
+ return exact;
19
+ }
20
+ const canonical = canonicalRequirementKey(rootId);
21
+ return requirements.find((r) => canonicalRequirementKey(r.id) === canonical);
22
+ }
5
23
  /**
6
24
  * Parse a requirement path into segments.
7
25
  * E.g., "REQ123.given.then" → ["REQ123", "given", "then"]
@@ -104,7 +122,7 @@ export function resolveRequirementPath(requirements, path) {
104
122
  }
105
123
  // First segment should be the root requirement ID
106
124
  const rootId = segments[0];
107
- let currentNode = requirements.find((r) => r.id === rootId);
125
+ let currentNode = findRoot(requirements, rootId);
108
126
  if (!currentNode) {
109
127
  return null;
110
128
  }
@@ -158,7 +176,7 @@ export function checkPathAmbiguity(requirements, path) {
158
176
  return 0;
159
177
  }
160
178
  const rootId = segments[0];
161
- let currentNode = requirements.find((r) => r.id === rootId);
179
+ let currentNode = findRoot(requirements, rootId);
162
180
  if (!currentNode) {
163
181
  return 0;
164
182
  }
@@ -60,6 +60,14 @@ export declare function parseRequirementKey(key: string): {
60
60
  * legacy malformed key rather than losing it.
61
61
  */
62
62
  export declare function canonicalRequirementKey(key: string): string;
63
+ /**
64
+ * Normalize a full requirement path for comparison: canonical root key plus
65
+ * the path tail lowercased (label matching is case-insensitive,
66
+ * HARNESS-RESOLVE-2.1; root spelling is authoring latitude,
67
+ * HARNESS-RESOLVE-2.3). Comparison only — never a spelling to display or
68
+ * store in place of what the author wrote.
69
+ */
70
+ export declare function canonicalRequirementPath(path: string): string;
63
71
  /**
64
72
  * Build a requirement key from prefix and number.
65
73
  */
@@ -103,6 +103,19 @@ export function canonicalRequirementKey(key) {
103
103
  const parsed = parseRequirementKey(key);
104
104
  return parsed ? `${parsed.prefix}-${parsed.number}` : key.toUpperCase();
105
105
  }
106
+ /**
107
+ * Normalize a full requirement path for comparison: canonical root key plus
108
+ * the path tail lowercased (label matching is case-insensitive,
109
+ * HARNESS-RESOLVE-2.1; root spelling is authoring latitude,
110
+ * HARNESS-RESOLVE-2.3). Comparison only — never a spelling to display or
111
+ * store in place of what the author wrote.
112
+ */
113
+ export function canonicalRequirementPath(path) {
114
+ const dot = path.indexOf(".");
115
+ if (dot === -1)
116
+ return canonicalRequirementKey(path);
117
+ return (canonicalRequirementKey(path.slice(0, dot)) + path.slice(dot).toLowerCase());
118
+ }
106
119
  /**
107
120
  * Build a requirement key from prefix and number.
108
121
  */
@@ -27,4 +27,22 @@ export declare function splitLeadingH1(markdown: string): SplitMarkdown;
27
27
  * gets no empty heading line (DOC-TITLE-3.3).
28
28
  */
29
29
  export declare function composeMarkdownWithTitle(title: string, body: string): string;
30
+ /**
31
+ * The title a document currently answers to, from its row and published copy.
32
+ *
33
+ * One precedence, exported: the working title wins; else the published
34
+ * markdown's leading H1; else the stored title column; else untitled. The
35
+ * middle term is the one that keeps getting dropped when this rule is
36
+ * hand-copied — legacy web-authored documents carry their title only in the
37
+ * column, H1-authored ones only in the markdown, and every surface that
38
+ * composes or matches markdown (the edit path's `old_string`, the assistant's
39
+ * read, publish) must answer identically or a rename lands as a duplicate
40
+ * body heading. This was the FOURTH hand-written copy of the rule when it was
41
+ * extracted; the count is the argument for calling this instead of writing a
42
+ * fifth.
43
+ */
44
+ export declare function effectiveTitle(workingTitle: string | undefined, published: {
45
+ markdownContent: string;
46
+ title?: string;
47
+ } | null | undefined): string;
30
48
  //# sourceMappingURL=title-markdown.d.ts.map
@@ -35,4 +35,24 @@ export function composeMarkdownWithTitle(title, body) {
35
35
  return `# ${trimmedTitle}\n`;
36
36
  return `# ${trimmedTitle}\n\n${body}`;
37
37
  }
38
+ /**
39
+ * The title a document currently answers to, from its row and published copy.
40
+ *
41
+ * One precedence, exported: the working title wins; else the published
42
+ * markdown's leading H1; else the stored title column; else untitled. The
43
+ * middle term is the one that keeps getting dropped when this rule is
44
+ * hand-copied — legacy web-authored documents carry their title only in the
45
+ * column, H1-authored ones only in the markdown, and every surface that
46
+ * composes or matches markdown (the edit path's `old_string`, the assistant's
47
+ * read, publish) must answer identically or a rename lands as a duplicate
48
+ * body heading. This was the FOURTH hand-written copy of the rule when it was
49
+ * extracted; the count is the argument for calling this instead of writing a
50
+ * fifth.
51
+ */
52
+ export function effectiveTitle(workingTitle, published) {
53
+ return (workingTitle ??
54
+ splitLeadingH1(published?.markdownContent ?? "").title ??
55
+ published?.title ??
56
+ "");
57
+ }
38
58
  //# sourceMappingURL=title-markdown.js.map
@@ -14,6 +14,14 @@ export declare function duplicateLocalDocumentIds(local: LocalSnapshot): Array<{
14
14
  documentId: string;
15
15
  filePaths: string[];
16
16
  }>;
17
+ /**
18
+ * The message for the duplicate-id abort, or null when there is nothing to
19
+ * abort over. Built here, beside the detector, so the abort also names the
20
+ * snapshot's invalid files (SYNC-FAIL-3.2) — a caller that throws on the
21
+ * duplicate would otherwise sit on that knowledge until the next run, when
22
+ * the user has fixed the id and syncs again only to hit the second problem.
23
+ */
24
+ export declare function duplicateIdAbortMessage(local: LocalSnapshot): string | null;
17
25
  /**
18
26
  * Compare the whole repo against the whole cloud, pairing documents by id.
19
27
  */
@@ -247,6 +247,35 @@ export function duplicateLocalDocumentIds(local) {
247
247
  .filter(([, paths]) => paths.length > 1)
248
248
  .map(([documentId, filePaths]) => ({ documentId, filePaths }));
249
249
  }
250
+ /**
251
+ * The message for the duplicate-id abort, or null when there is nothing to
252
+ * abort over. Built here, beside the detector, so the abort also names the
253
+ * snapshot's invalid files (SYNC-FAIL-3.2) — a caller that throws on the
254
+ * duplicate would otherwise sit on that knowledge until the next run, when
255
+ * the user has fixed the id and syncs again only to hit the second problem.
256
+ */
257
+ export function duplicateIdAbortMessage(local) {
258
+ const dupes = duplicateLocalDocumentIds(local);
259
+ if (dupes.length === 0)
260
+ return null;
261
+ const lines = [];
262
+ for (const d of dupes) {
263
+ // Copying a starter spec twice leaves three claimants, so count them.
264
+ lines.push(`${d.filePaths.length} files claim the same document ID "${d.documentId}":`);
265
+ for (const p of d.filePaths)
266
+ lines.push(` ${p}`);
267
+ }
268
+ lines.push(`Each file must map to its own cloud document. Remove the "id:" line from all but one file's frontmatter, then sync again.`);
269
+ const invalid = local.documents.filter((doc) => doc.parseError);
270
+ if (invalid.length > 0) {
271
+ lines.push("");
272
+ lines.push("These files are also invalid — fix them in the same pass:");
273
+ for (const doc of invalid) {
274
+ lines.push(` ${doc.filePath} — ${doc.parseError}`);
275
+ }
276
+ }
277
+ return lines.join("\n");
278
+ }
250
279
  /**
251
280
  * Compare the whole repo against the whole cloud, pairing documents by id.
252
281
  */
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Executing a sync plan: cloud writes/deletes and local writes/deletes.
3
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.
4
+ * Cloud writes reuse the push machinery (parse → publish → frontmatter
5
+ * write-back) so document-header/import-provenance fidelity is identical to a
6
+ * repo→cloud push. Local writes reuse local-files.ts. Deletes still go straight
7
+ * to Convex: removing a document needs no conversion, so it has no reason to
8
+ * take the web-app hop that publishing does.
7
9
  */
8
10
  import type { PlannedAction, SyncMode } from "./plan.js";
9
11
  import type { CloudAuth } from "./snapshot.js";
@@ -38,6 +40,21 @@ export interface SyncOutcome {
38
40
  name: string;
39
41
  status: string;
40
42
  }>;
43
+ /** Pushes the server accepted while telling us something about them did not
44
+ * finish. It answers 200 for these on purpose — the publish landed, and
45
+ * failing the push would send Jaime to retry a thing that already happened —
46
+ * so the only way they reach anyone is by being reported here. */
47
+ publishWarnings: Array<{
48
+ name: string;
49
+ warning: string;
50
+ }>;
51
+ /** SYNC-CLI-EDIT-3.1: files whose notation the cloud changed on storing —
52
+ * list markers, emphasis characters, spacing. Counted so the sync can say
53
+ * the reformatting happened and why, instead of leaving Jaime to discover
54
+ * rewritten files in `git diff`. */
55
+ normalized: Array<{
56
+ name: string;
57
+ }>;
41
58
  /** SYNC-TITLE-1.3: pushes that changed a document's title. */
42
59
  renames: Array<{
43
60
  from: string;
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Executing a sync plan: cloud writes/deletes and local writes/deletes.
3
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.
4
+ * Cloud writes reuse the push machinery (parse → publish → frontmatter
5
+ * write-back) so document-header/import-provenance fidelity is identical to a
6
+ * repo→cloud push. Local writes reuse local-files.ts. Deletes still go straight
7
+ * to Convex: removing a document needs no conversion, so it has no reason to
8
+ * take the web-app hop that publishing does.
7
9
  */
8
10
  import * as fs from "node:fs";
9
11
  import * as path from "node:path";
@@ -13,6 +15,7 @@ import { api } from "../convex.js";
13
15
  import { parseFilesForPushIndividually, WEB_APP_URL } from "../push/core.js";
14
16
  import { composeMarkdownWithTitle, splitLeadingH1, writeRequirementsFile, } from "../schema/index.js";
15
17
  import { resolveLocalPath } from "./local-files.js";
18
+ import { publishToCloud } from "./publish.js";
16
19
  function emptyOutcome() {
17
20
  return {
18
21
  cloudCreated: 0,
@@ -26,6 +29,8 @@ function emptyOutcome() {
26
29
  synced: [],
27
30
  writeBackWarnings: [],
28
31
  importWarnings: [],
32
+ publishWarnings: [],
33
+ normalized: [],
29
34
  renames: [],
30
35
  inferredPrefixes: [],
31
36
  };
@@ -54,7 +59,7 @@ export async function executePlan(plan, cloud, auth, workspaceRoot, mode) {
54
59
  outcome.invalid.push(doc);
55
60
  break;
56
61
  case "cloud_write":
57
- await cloudWrite(client, auth, doc, cloudById, outcome);
62
+ await cloudWrite(auth, doc, cloudById, outcome);
58
63
  break;
59
64
  case "cloud_delete":
60
65
  await cloudDelete(client, auth, doc, outcome);
@@ -84,7 +89,7 @@ function errorDisplayMessage(err) {
84
89
  return data.message;
85
90
  return err instanceof Error ? err.message : String(err);
86
91
  }
87
- async function cloudWrite(client, auth, doc, cloudById, outcome) {
92
+ async function cloudWrite(auth, doc, cloudById, outcome) {
88
93
  if (!auth.projectSlug || !doc.filePath) {
89
94
  throw new Error("Cannot write to the cloud without project credentials.");
90
95
  }
@@ -104,27 +109,36 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
104
109
  if (meta?.title && splitLeadingH1(parsed.markdownContent).title === null) {
105
110
  parsed.markdownContent = composeMarkdownWithTitle(meta.title, parsed.markdownContent);
106
111
  }
112
+ // SYNC-CLI-EDIT-2: the push goes to the web app, which writes it into the
113
+ // working document Quinn may have open and publishes a copy printed from it.
114
+ // Convex cannot do that job — the conversion needs the BlockNote schema.
115
+ //
107
116
  // SYNC-TITLE-1: the server derives the title from the body's leading H1.
108
117
  // A legacy frontmatter title rides along only as the server's fallback for
109
118
  // H1-less bodies (SYNC-TITLE-1.1); the filename is never a title
110
119
  // (SYNC-TITLE-1.0).
111
- const saveResult = (await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
112
- projectAuth: {
113
- projectSlug: auth.projectSlug,
114
- projectSecret: auth.projectSecret,
115
- },
116
- target: { type: "project", slug: auth.projectSlug },
120
+ const saveResult = await publishToCloud({
121
+ projectId: auth.projectSlug,
122
+ projectSecret: auth.projectSecret,
117
123
  documentId: meta?.id,
118
124
  title: meta?.title,
119
125
  markdownContent: parsed.markdownContent,
120
126
  defaultPrefix: meta?.defaultPrefix,
121
127
  runMarker: parsed.metadata.ctsRun,
122
- dryRun: false,
123
- }));
124
- const documentId = typeof saveResult === "string" ? saveResult : saveResult.documentId;
128
+ });
129
+ const documentId = saveResult.documentId;
130
+ // The server accepted the publish and told us something after it did not
131
+ // finish. Reported rather than dropped: the push DID land, so this is not a
132
+ // failure, but "synced" on its own would claim more than happened.
133
+ if (saveResult.warning) {
134
+ outcome.publishWarnings.push({
135
+ name: path.basename(doc.filePath),
136
+ warning: saveResult.warning,
137
+ });
138
+ }
125
139
  // IMPORT-3.3: marker failure is never silent — surface the statuses the
126
140
  // server reports as warnings.
127
- if (typeof saveResult !== "string" && saveResult.importStatus) {
141
+ if (saveResult.importStatus) {
128
142
  const status = saveResult.importStatus;
129
143
  if (status === "invalid_marker" ||
130
144
  status === "unsupported_version" ||
@@ -174,6 +188,20 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
174
188
  name: path.basename(doc.filePath),
175
189
  url: `${WEB_APP_URL}/documents/${documentId}`,
176
190
  });
191
+ // SYNC-CLI-EDIT-3: the file adopts what the cloud stored — the web editor's
192
+ // notation — so the repo and the cloud agree byte-for-byte and the next
193
+ // `dotreq diff` has nothing to say. Writing back the LOCAL bytes here left
194
+ // the two permanently disagreeing about spelling, which the comparator
195
+ // reports as a conflict on a document nobody touched. An older server does
196
+ // not return its stored copy; the local bytes are then exactly what it
197
+ // stored, so the fallback is the same agreement.
198
+ //
199
+ // `.trim()` on both sides, matching the writer's own treatment of the body —
200
+ // a looser comparison here reported files as normalized that the write then
201
+ // left untouched.
202
+ const contentToWriteBack = saveResult.storedMarkdown ?? parsed.markdownContent;
203
+ const wasNormalized = saveResult.storedMarkdown !== undefined &&
204
+ saveResult.storedMarkdown.trim() !== parsed.markdownContent.trim();
177
205
  // SYNC-FAIL-2: the cloud document already exists at this point. A write-back
178
206
  // failure is a warning on a synced document, never a sync failure (which
179
207
  // would invite a retry that mints a duplicate).
@@ -190,7 +218,12 @@ async function cloudWrite(client, auth, doc, cloudById, outcome) {
190
218
  ? { set: meta.defaultPrefix }
191
219
  : "keep",
192
220
  version: (parsed.metadata.version ?? 0) + 1,
193
- }, parsed.markdownContent);
221
+ }, contentToWriteBack);
222
+ // Counted only once the rewrite actually happened: reporting it before a
223
+ // write that then failed produced two contradicting lines about one file.
224
+ if (wasNormalized) {
225
+ outcome.normalized.push({ name: path.basename(doc.filePath) });
226
+ }
194
227
  }
195
228
  catch (writeErr) {
196
229
  outcome.writeBackWarnings.push({
@@ -3,7 +3,7 @@
3
3
  * (snapshot.ts), consumed by `dotreq diff` and `dotreq sync`.
4
4
  */
5
5
  import type { ComparisonResult, DocumentComparison } from "./types.js";
6
- export { compareSnapshots, duplicateLocalDocumentIds } from "./compare.js";
6
+ export { compareSnapshots, duplicateIdAbortMessage, duplicateLocalDocumentIds, } from "./compare.js";
7
7
  export { displayName, formatUnitDetail, formatVerdictLine, resolutionHint, scopeToken, } from "./render.js";
8
8
  export { segmentBody } from "./segment.js";
9
9
  export { acquireCloudSnapshot, acquireLocalSnapshot, type CloudAuth, readLocalDocument, } from "./snapshot.js";
@@ -3,7 +3,7 @@
3
3
  * (snapshot.ts), consumed by `dotreq diff` and `dotreq sync`.
4
4
  */
5
5
  import * as path from "node:path";
6
- export { compareSnapshots, duplicateLocalDocumentIds } from "./compare.js";
6
+ export { compareSnapshots, duplicateIdAbortMessage, duplicateLocalDocumentIds, } from "./compare.js";
7
7
  export { displayName, formatUnitDetail, formatVerdictLine, resolutionHint, scopeToken, } from "./render.js";
8
8
  export { segmentBody } from "./segment.js";
9
9
  export { acquireCloudSnapshot, acquireLocalSnapshot, readLocalDocument, } from "./snapshot.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The push half of `dotreq sync`, posted to the web app rather than to Convex.
3
+ *
4
+ * Publishing prints the working document — the shared body the web editor is
5
+ * editing live — so a push has to land there first, and turning the repo's
6
+ * markdown into that body needs the BlockNote schema, which only the web app's
7
+ * runtime can build. The payload is the same one `saveWithRequirements` always
8
+ * took; only its destination moved.
9
+ *
10
+ * The cost is a web-app dependency in the push path: an outage there leaves the
11
+ * repo unable to push. Recoverable rather than lossy — see
12
+ * docs/working/publishing-and-unpublished-changes.md.
13
+ */
14
+ export interface PublishParams {
15
+ /** Project slug or id — whichever `dotreq link` wrote into settings. */
16
+ projectId: string;
17
+ projectSecret: string;
18
+ /** Absent for a file that has never been pushed; the server mints the id. */
19
+ documentId?: string;
20
+ markdownContent: string;
21
+ /** Legacy frontmatter title, used only as the server's H1-less fallback. */
22
+ title?: string;
23
+ defaultPrefix?: string;
24
+ runMarker?: string;
25
+ }
26
+ /**
27
+ * What the server reports back: the document, any run-marker complaint, and any
28
+ * partial success.
29
+ *
30
+ * `warning` is the server saying the publish landed but something after it did
31
+ * not — today, seeding a new document's working copy. It answers 200 on purpose,
32
+ * because the publish itself succeeded and failing the push would send Jaime to
33
+ * retry a thing that already happened. Dropping the warning here turned that
34
+ * deliberate partial success back into an unqualified one.
35
+ */
36
+ export type PublishResult = {
37
+ documentId: string;
38
+ importStatus?: string;
39
+ warning?: string;
40
+ /**
41
+ * The markdown the cloud actually stored — the web editor's notation
42
+ * (SYNC-CLI-EDIT-3). The write-back adopts it so the file and the cloud
43
+ * agree byte-for-byte; without it the next `dotreq diff` reports a conflict
44
+ * on a document nobody touched. Absent from older servers, in which case the
45
+ * write-back falls back to the local bytes, which is exactly the old
46
+ * behaviour.
47
+ */
48
+ storedMarkdown?: string;
49
+ };
50
+ /**
51
+ * POST to `/api/publish`. Returns the saved document; throws with the server's
52
+ * own message on failure, which is what Jaime reads in the sync output.
53
+ *
54
+ * SYNC-CLI-EDIT-2.3: a document somebody is typing in continuously is the one
55
+ * refusal that is not a mistake — the message says so, and says the sync will
56
+ * apply next time.
57
+ */
58
+ export declare function publishToCloud(params: PublishParams): Promise<PublishResult>;
59
+ //# sourceMappingURL=publish.d.ts.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The push half of `dotreq sync`, posted to the web app rather than to Convex.
3
+ *
4
+ * Publishing prints the working document — the shared body the web editor is
5
+ * editing live — so a push has to land there first, and turning the repo's
6
+ * markdown into that body needs the BlockNote schema, which only the web app's
7
+ * runtime can build. The payload is the same one `saveWithRequirements` always
8
+ * took; only its destination moved.
9
+ *
10
+ * The cost is a web-app dependency in the push path: an outage there leaves the
11
+ * repo unable to push. Recoverable rather than lossy — see
12
+ * docs/working/publishing-and-unpublished-changes.md.
13
+ */
14
+ import { DEFAULT_API_BASE_URL } from "../requirements/cloud-ai.js";
15
+ /**
16
+ * POST to `/api/publish`. Returns the saved document; throws with the server's
17
+ * own message on failure, which is what Jaime reads in the sync output.
18
+ *
19
+ * SYNC-CLI-EDIT-2.3: a document somebody is typing in continuously is the one
20
+ * refusal that is not a mistake — the message says so, and says the sync will
21
+ * apply next time.
22
+ */
23
+ export async function publishToCloud(params) {
24
+ const apiBaseUrl = process.env.DOTREQUIREMENTS_API_URL ?? DEFAULT_API_BASE_URL;
25
+ const response = await fetch(`${apiBaseUrl}/api/publish`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify(params),
29
+ });
30
+ if (!response.ok) {
31
+ const body = (await response.json().catch(() => ({})));
32
+ throw new Error(body.error || response.statusText);
33
+ }
34
+ const { result, warning, storedMarkdown } = (await response.json());
35
+ const published = typeof result === "string" ? { documentId: result } : { ...result };
36
+ // The server's warning and stored markdown sit beside `result`, not inside
37
+ // it, so they have to be carried across deliberately — the warning was once
38
+ // destructured away here.
39
+ return {
40
+ ...published,
41
+ ...(warning !== undefined ? { warning } : {}),
42
+ ...(storedMarkdown !== undefined ? { storedMarkdown } : {}),
43
+ };
44
+ }
45
+ //# sourceMappingURL=publish.js.map
@@ -6,19 +6,7 @@
6
6
  * normalized text (prose has no stable id, so a reworded passage reads as a
7
7
  * removal-plus-addition — a conflict, per DIFF-4.3).
8
8
  */
9
- import { canonicalRequirementKey, getAllRequirements, parseRequirementBlock, splitRequirementFenceContent, } from "../schema/index.js";
10
- /**
11
- * A node's comparison id: the canonical root key plus its position tail, so the
12
- * two sides agree on identity even when they spell the key differently
13
- * (DIFF-3.3). Splitting on the first "." keeps the tail untouched — position
14
- * paths are already canonical.
15
- */
16
- function canonicalNodeId(id) {
17
- const dot = id.indexOf(".");
18
- if (dot === -1)
19
- return canonicalRequirementKey(id);
20
- return `${canonicalRequirementKey(id.slice(0, dot))}${id.slice(dot)}`;
21
- }
9
+ import { canonicalRequirementKey, canonicalRequirementPath, getAllRequirements, parseRequirementBlock, splitRequirementFenceContent, } from "../schema/index.js";
22
10
  /** Matches a fenced dotrequirements block; group 1 is its inner content. */
23
11
  const FENCE = /```dotrequirements\n([\s\S]*?)```/gm;
24
12
  /**
@@ -103,7 +91,10 @@ export function requirementUnitMap(blockContent) {
103
91
  const map = new Map();
104
92
  for (const node of getAllRequirements([tree])) {
105
93
  // A NUL (\u0000) separates the label from content so a label change reads as a change.
106
- map.set(canonicalNodeId(node.id), `${node.label}\u0000${node.content}`);
94
+ // The comparison id is the canonical root key plus the numeric position
95
+ // tail, so both sides agree on identity even when they spell the key
96
+ // differently (DIFF-3.3).
97
+ map.set(canonicalRequirementPath(node.id), `${node.label}\u0000${node.content}`);
107
98
  }
108
99
  return map;
109
100
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@popoverai/dotrequirements",
3
- "version": "0.29.3",
4
- "description": "Requirements tracking CLI and test harness",
3
+ "version": "0.30.0",
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": {
7
7
  "dotrequirements": "./dist/cli.js",
@@ -30,11 +30,17 @@
30
30
  },
31
31
  "keywords": [
32
32
  "requirements",
33
+ "specifications",
34
+ "spec-driven-development",
33
35
  "testing",
34
36
  "test-coverage",
37
+ "acceptance-criteria",
35
38
  "bdd",
36
39
  "tdd",
37
- "ai-assistant"
40
+ "ai-assistant",
41
+ "ai-agents",
42
+ "claude-code",
43
+ "mcp"
38
44
  ],
39
45
  "author": "Will Raymer @Popover",
40
46
  "license": "MIT",
@@ -49,9 +55,7 @@
49
55
  "@babel/parser": "^7.28.5",
50
56
  "@babel/traverse": "^7.28.5",
51
57
  "@babel/types": "^7.28.5",
52
- "@modelcontextprotocol/sdk": "^1.0.0",
53
58
  "@types/prompts": "^2.4.9",
54
- "@workos-inc/node": "^7.77.0",
55
59
  "chalk": "^5.6.2",
56
60
  "commander": "^12.1.0",
57
61
  "convex": "^1.42.3",
@@ -70,7 +74,7 @@
70
74
  "@types/node": "^20",
71
75
  "@types/uuid": "^11.0.0",
72
76
  "typescript": "^5",
73
- "vitest": "^4.1.8"
77
+ "vitest": "^4.1.10"
74
78
  },
75
79
  "files": [
76
80
  "dist",