@popoverai/dotrequirements 0.26.1 → 0.26.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.
Files changed (52) hide show
  1. package/dist/codebase-to-spec/present.js +4 -5
  2. package/dist/codebase-to-spec/validate.js +3 -2
  3. package/dist/commands/acceptance-test.js +4 -2
  4. package/dist/commands/ai-setup.js +97 -59
  5. package/dist/commands/get.js +6 -2
  6. package/dist/commands/init.js +7 -5
  7. package/dist/commands/link-resolution.d.ts +3 -1
  8. package/dist/commands/link-resolution.js +4 -2
  9. package/dist/commands/pull.js +36 -3
  10. package/dist/commands/push.js +54 -16
  11. package/dist/commands/report.js +18 -3
  12. package/dist/commands/review-test.js +16 -8
  13. package/dist/commands/tests-for.js +13 -13
  14. package/dist/commands/validate.js +14 -14
  15. package/dist/harness/cache.d.ts +19 -3
  16. package/dist/harness/cache.js +38 -12
  17. package/dist/harness/finalize.js +33 -1
  18. package/dist/harness/index.js +16 -9
  19. package/dist/harness/requirementsLoader.js +12 -0
  20. package/dist/harness/tracking.d.ts +17 -2
  21. package/dist/harness/tracking.js +83 -9
  22. package/dist/mcp/handlers/authoring.js +13 -4
  23. package/dist/mcp/handlers/get.js +7 -3
  24. package/dist/mcp/handlers/push.js +59 -13
  25. package/dist/mcp/handlers/review.d.ts +1 -0
  26. package/dist/mcp/handlers/review.js +58 -15
  27. package/dist/mcp/handlers/test-mapping.js +47 -12
  28. package/dist/mcp/handlers/types.d.ts +14 -0
  29. package/dist/mcp/handlers/types.js +27 -0
  30. package/dist/mcp/index.js +4 -0
  31. package/dist/push/core.d.ts +50 -0
  32. package/dist/push/core.js +149 -11
  33. package/dist/push/index.d.ts +1 -1
  34. package/dist/push/index.js +1 -1
  35. package/dist/requirements/cloud-coverage.d.ts +12 -2
  36. package/dist/requirements/cloud-coverage.js +30 -3
  37. package/dist/requirements/grep.d.ts +7 -2
  38. package/dist/requirements/grep.js +75 -47
  39. package/dist/schema/builder.d.ts +1 -1
  40. package/dist/schema/builder.js +13 -0
  41. package/dist/schema/conversions.d.ts +7 -2
  42. package/dist/schema/conversions.js +13 -4
  43. package/dist/schema/parser-core.d.ts +28 -0
  44. package/dist/schema/parser-core.js +80 -9
  45. package/dist/schema/parser.d.ts +8 -26
  46. package/dist/schema/parser.js +23 -251
  47. package/dist/schema/resolver.js +18 -8
  48. package/dist/utils/env.js +17 -1
  49. package/dist/utils/oauth-flow.js +8 -0
  50. package/dist/utils/project-settings.d.ts +4 -0
  51. package/dist/utils/project-settings.js +14 -1
  52. package/package.json +1 -1
@@ -16,6 +16,7 @@
16
16
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
18
  import { parseRequirementsFile } from "../schema/parser.js";
19
+ import { stripFrontmatterBlock } from "../schema/parser-core.js";
19
20
  import { generateRunMarker } from "../schema/run-marker.js";
20
21
  import { sanitizeAreaName } from "./area-name.js";
21
22
  import { promptOverwriteChoice } from "./interactive.js";
@@ -187,11 +188,9 @@ async function resolveOverwriteAction(policy, path) {
187
188
  */
188
189
  function mergeContent(existingPath, newContent) {
189
190
  const existing = readFileSync(existingPath, "utf-8").trimEnd();
190
- // Strip frontmatter from newContent (it would conflict with existing frontmatter)
191
- const fmMatch = newContent.match(/^---\n[\s\S]+?\n---\n+/);
192
- const body = fmMatch
193
- ? newContent.slice(fmMatch[0].length).trimStart()
194
- : newContent;
191
+ // Strip frontmatter from newContent (it would conflict with existing
192
+ // frontmatter) via the shared CRLF-normalizing helper.
193
+ const body = stripFrontmatterBlock(newContent).trimStart();
195
194
  return [
196
195
  existing,
197
196
  "",
@@ -87,8 +87,9 @@ function describeValidationError(err) {
87
87
  const parts = [err.message];
88
88
  let cursor = err.cause;
89
89
  while (cursor instanceof Error) {
90
- // Don't repeat the same message twice if the wrapper just rethrew.
91
- if (cursor.message && cursor.message !== parts[parts.length - 1]) {
90
+ // Don't repeat the same message twice if the wrapper just rethrew
91
+ // (or already embedded the inner message in its own).
92
+ if (cursor.message && !parts[parts.length - 1].includes(cursor.message)) {
92
93
  parts.push(cursor.message);
93
94
  }
94
95
  cursor = cursor.cause;
@@ -127,8 +127,10 @@ export async function acceptanceTestCommand(requirementKey, url, options) {
127
127
  else {
128
128
  displayResults(requirementTree, results);
129
129
  }
130
- // 11. Exit with appropriate code
131
- const failedCount = results.filter((r) => r.status !== "passed").length;
130
+ // 11. Exit with appropriate code — computed over the requirement tree so
131
+ // a requirement with a missing result counts as not passing, agreeing
132
+ // with the displayed counts (ACCEPTANCE-1.0/.1/.2)
133
+ const failedCount = requirementTree.filter((_, i) => results[i]?.status !== "passed").length;
132
134
  if (failedCount > 0) {
133
135
  process.exit(1);
134
136
  }
@@ -1,10 +1,11 @@
1
- import { execSync } from "node:child_process";
1
+ import { execFileSync, execSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import prompts from "prompts";
6
6
  import { brand } from "../utils/brand.js";
7
7
  import { appendOrUpdateSection, buildNoGitRepoMessage, findDotrequirementsSection, findGitRoot, getContextFileName, } from "../utils/context-file.js";
8
+ import { findProjectRoot, readProjectSettings, } from "../utils/project-settings.js";
8
9
  import { loadTemplate } from "../utils/templates.js";
9
10
  /**
10
11
  * Load the context file section template
@@ -12,6 +13,56 @@ import { loadTemplate } from "../utils/templates.js";
12
13
  function loadContextFileSection() {
13
14
  return loadTemplate("context-file-section.md");
14
15
  }
16
+ /**
17
+ * Read an assistant's existing MCP config file (AISETUP-2).
18
+ * Returns {} when the file is absent or empty. Returns null when the file
19
+ * exists but is unusable — unreadable (AISETUP-2.2), invalid JSON
20
+ * (AISETUP-2.1), or a non-object root (AISETUP-2.3). In every null case the
21
+ * path has already been shown to the user and the caller must stop without
22
+ * writing; rewriting would destroy every other configured server.
23
+ */
24
+ function readExistingMcpConfig(configPath) {
25
+ const stopWithoutWriting = (reason) => {
26
+ console.error(`❌ ${reason}`);
27
+ console.log(" Nothing was changed. Fix (or remove) the file, then re-run ai-setup:");
28
+ console.log(` ${configPath}\n`);
29
+ return null;
30
+ };
31
+ if (!existsSync(configPath)) {
32
+ return {};
33
+ }
34
+ let existingContent;
35
+ try {
36
+ existingContent = readFileSync(configPath, "utf-8");
37
+ }
38
+ catch {
39
+ return stopWithoutWriting("Existing config file could not be read.");
40
+ }
41
+ if (!existingContent.trim()) {
42
+ return {};
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(existingContent);
47
+ }
48
+ catch {
49
+ return stopWithoutWriting("Existing config file contains invalid JSON.");
50
+ }
51
+ // AISETUP-2.3: a non-object root (e.g. a top-level array) would swallow the
52
+ // mcpServers assignment and serialize back without it — success would be
53
+ // reported while nothing was configured
54
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
55
+ return stopWithoutWriting("Existing config file is not a JSON object.");
56
+ }
57
+ // AISETUP-2.3: same trap one level down — an array-valued mcpServers takes
58
+ // the server entry as an expando property that JSON.stringify drops
59
+ const mcpServers = parsed.mcpServers;
60
+ if (mcpServers != null &&
61
+ (typeof mcpServers !== "object" || Array.isArray(mcpServers))) {
62
+ return stopWithoutWriting('Existing config file has an "mcpServers" field that is not a JSON object.');
63
+ }
64
+ return parsed;
65
+ }
15
66
  /**
16
67
  * Install workflow guidance to the appropriate context file for a platform.
17
68
  * With `yes` (the non-interactive --assistant path, AISETUP-1.0), both
@@ -166,10 +217,18 @@ async function dispatchSetup(client, yes = false) {
166
217
  }
167
218
  async function setupClaudeCode(yes = false) {
168
219
  console.log("\n📦 Configuring MCP for Claude Code...\n");
169
- const command = `claude mcp add-json dotrequirements '{"type":"stdio","command":"npx","args":["-y","@popoverai/dotrequirements","mcp"]}'`;
220
+ // Register via an args array so no shell quoting is involved: cmd.exe does
221
+ // not honor single quotes, so a shell-quoted JSON string breaks on Windows.
222
+ const mcpServerJson = JSON.stringify({
223
+ type: "stdio",
224
+ command: "npx",
225
+ args: ["-y", "@popoverai/dotrequirements", "mcp"],
226
+ });
227
+ // Shell-safe on every platform (no quoting needed anywhere)
228
+ const manualCommand = "claude mcp add dotrequirements -- npx -y @popoverai/dotrequirements mcp";
170
229
  let mcpConfigured = false;
171
230
  try {
172
- execSync(command, { stdio: "pipe" });
231
+ execFileSync("claude", ["mcp", "add-json", "dotrequirements", mcpServerJson], { stdio: "pipe" });
173
232
  console.log("✅ MCP configured successfully for Claude Code!");
174
233
  mcpConfigured = true;
175
234
  }
@@ -185,7 +244,7 @@ async function setupClaudeCode(yes = false) {
185
244
  else {
186
245
  console.error("❌ Failed to configure MCP automatically.");
187
246
  console.log("\nYou can manually run:");
188
- console.log(` ${command}`);
247
+ console.log(` ${manualCommand}`);
189
248
  if (stderr) {
190
249
  console.log(`\nError: ${stderr}`);
191
250
  }
@@ -224,24 +283,17 @@ async function setupClaudeDesktop() {
224
283
  REQUIREMENTS_DIR: process.cwd(),
225
284
  },
226
285
  };
286
+ // AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
287
+ const config = readExistingMcpConfig(configPath);
288
+ if (config === null) {
289
+ return;
290
+ }
227
291
  try {
228
292
  if (!existsSync(configDir)) {
229
293
  console.log("Creating Claude Desktop config directory...");
230
294
  mkdirSync(configDir, { recursive: true });
231
295
  }
232
- let config = {};
233
- if (existsSync(configPath)) {
234
- try {
235
- const existingContent = readFileSync(configPath, "utf-8");
236
- if (existingContent.trim()) {
237
- config = JSON.parse(existingContent);
238
- }
239
- }
240
- catch {
241
- console.log("⚠️ Existing config file is invalid, creating fresh config...");
242
- config = {};
243
- }
244
- }
296
+ // AISETUP-2.0: other configured servers survive; only ours is (re)set
245
297
  if (!config.mcpServers) {
246
298
  config.mcpServers = {};
247
299
  }
@@ -271,30 +323,26 @@ async function setupClaudeDesktop() {
271
323
  }
272
324
  async function setupCursor(yes = false) {
273
325
  console.log("\n📦 Configuring MCP for Cursor (project-specific)...\n");
274
- const configPath = join(process.cwd(), ".cursor", "mcp.json");
275
- const configDir = join(process.cwd(), ".cursor");
326
+ // Cursor loads .cursor/mcp.json from the project (git) root — match the
327
+ // context-file placement, falling back to cwd outside a repository
328
+ const projectDir = (await findGitRoot()) ?? process.cwd();
329
+ const configDir = join(projectDir, ".cursor");
330
+ const configPath = join(configDir, "mcp.json");
276
331
  const mcpServerConfig = {
277
332
  command: "npx",
278
333
  args: ["-y", "@popoverai/dotrequirements", "mcp"],
279
334
  };
335
+ // AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
336
+ const config = readExistingMcpConfig(configPath);
337
+ if (config === null) {
338
+ return;
339
+ }
280
340
  try {
281
341
  if (!existsSync(configDir)) {
282
342
  console.log("Creating Cursor config directory...");
283
343
  mkdirSync(configDir, { recursive: true });
284
344
  }
285
- let config = {};
286
- if (existsSync(configPath)) {
287
- try {
288
- const existingContent = readFileSync(configPath, "utf-8");
289
- if (existingContent.trim()) {
290
- config = JSON.parse(existingContent);
291
- }
292
- }
293
- catch {
294
- console.log("⚠️ Existing config file is invalid, creating fresh config...");
295
- config = {};
296
- }
297
- }
345
+ // AISETUP-2.0: other configured servers survive; only ours is (re)set
298
346
  if (!config.mcpServers) {
299
347
  config.mcpServers = {};
300
348
  }
@@ -324,45 +372,33 @@ async function setupAntigravity(yes = false) {
324
372
  console.log("\n📦 Configuring MCP for Google Antigravity...\n");
325
373
  const configPath = join(homedir(), ".gemini", "antigravity", "mcp_config.json");
326
374
  const configDir = join(homedir(), ".gemini", "antigravity");
327
- // Check if we're in a dotrequirements project directory
328
- const envLocalPath = join(process.cwd(), ".env.local");
375
+ // AISETUP-3.0: detect the project the way the CLI defines projects —
376
+ // .requirements/project-settings.json at the project root
329
377
  let projectId = null;
330
378
  let projectPath = null;
331
- if (existsSync(envLocalPath)) {
379
+ const projectRoot = findProjectRoot();
380
+ if (projectRoot) {
332
381
  try {
333
- const envContent = readFileSync(envLocalPath, "utf-8");
334
- const lines = envContent.split("\n");
335
- for (const line of lines) {
336
- const trimmed = line.trim();
337
- if (trimmed.startsWith("DOTREQUIREMENTS_PROJECT_ID=")) {
338
- projectId = trimmed.split("=")[1]?.trim().replace(/['"]/g, "");
339
- projectPath = process.cwd();
340
- break;
341
- }
382
+ const settings = readProjectSettings(projectRoot);
383
+ if (settings) {
384
+ projectId = settings.projectId;
385
+ projectPath = projectRoot;
342
386
  }
343
387
  }
344
388
  catch {
345
- // .env.local exists but couldn't read it - continue with global setup
389
+ // Settings file unreadable/invalid — continue with global setup
346
390
  }
347
391
  }
392
+ // AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
393
+ const config = readExistingMcpConfig(configPath);
394
+ if (config === null) {
395
+ return;
396
+ }
348
397
  try {
349
398
  if (!existsSync(configDir)) {
350
399
  console.log("Creating Antigravity config directory...");
351
400
  mkdirSync(configDir, { recursive: true });
352
401
  }
353
- let config = {};
354
- if (existsSync(configPath)) {
355
- try {
356
- const existingContent = readFileSync(configPath, "utf-8");
357
- if (existingContent.trim()) {
358
- config = JSON.parse(existingContent);
359
- }
360
- }
361
- catch {
362
- console.log("⚠️ Existing config file is invalid, creating fresh config...");
363
- config = {};
364
- }
365
- }
366
402
  if (!config.mcpServers) {
367
403
  config.mcpServers = {};
368
404
  }
@@ -385,7 +421,9 @@ async function setupAntigravity(yes = false) {
385
421
  console.log(` Path: ${projectPath}\n`);
386
422
  }
387
423
  else {
388
- console.log(`ℹ️ Not in a ${brand} project directory (no .env.local with DOTREQUIREMENTS_PROJECT_ID found)`);
424
+ // AISETUP-3.1: outside any initialized project — skip the per-project
425
+ // step and say so
426
+ console.log("ℹ️ No project found (no .requirements/project-settings.json in this directory or its parents)");
389
427
  console.log(" Setting up global MCP server only.\n");
390
428
  }
391
429
  writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
@@ -1,10 +1,14 @@
1
1
  import { glob } from "glob";
2
- import { formatRequirementTree, getRequirementTree, loadAllRequirements, } from "../requirements/index.js";
2
+ import { formatRequirementTree, loadAllRequirements, } from "../requirements/index.js";
3
3
  import { findFilesWithRequirement, findTestCodeForRequirement, } from "../requirements/testCodeExtractor.js";
4
4
  export async function getCommand(id) {
5
5
  const workspaceRoot = process.cwd();
6
6
  const { flattened } = await loadAllRequirements(workspaceRoot);
7
- const tree = getRequirementTree(flattened, id);
7
+ // Match the node itself plus all of its descendants by id prefix (mirrors
8
+ // the #46 MCP get fix). The shared getRequirementTree only matches on
9
+ // rootId/id equality, which drops grandchildren (e.g. REQ-123.0.0) when a
10
+ // child id (REQ-123.0) is requested.
11
+ const tree = flattened.filter((r) => r.id === id || r.id.startsWith(`${id}.`));
8
12
  // CLI-FIND-GET-1.2: missing ID → error + non-zero exit
9
13
  if (tree.length === 0) {
10
14
  throw new Error(`Requirement "${id}" not found`);
@@ -147,11 +147,10 @@ async function authenticatedFlow(cwd, options) {
147
147
  }
148
148
  if (projectChoice === "create") {
149
149
  await createNewProjectFlow(cwd, client, selectedTeam, options);
150
+ return true;
150
151
  }
151
- else {
152
- await connectExistingProjectFlow(cwd, client, selectedTeam);
153
- }
154
- return true;
152
+ // INIT-12: a cancelled project selection means init did not complete
153
+ return await connectExistingProjectFlow(cwd, client, selectedTeam);
155
154
  }
156
155
  /**
157
156
  * INIT-18: Prompt user to select a team after OAuth
@@ -268,13 +267,15 @@ async function createNewProjectFlow(cwd, client, team, options) {
268
267
  }
269
268
  /**
270
269
  * INIT-6, INIT-16: Connect to an existing project
270
+ * Returns true if connected successfully, false if the user cancelled
271
+ * (INIT-12: cancellation must not read as a successful init)
271
272
  */
272
273
  async function connectExistingProjectFlow(cwd, client, team) {
273
274
  // INIT-6.0, INIT-16.0: Select project from team
274
275
  const selectedProject = await selectProject(client, team.teamId);
275
276
  if (!selectedProject) {
276
277
  console.log("Initialization cancelled.");
277
- return;
278
+ return false;
278
279
  }
279
280
  // AUTHZ-3: Prompt for secret expiry (paid tier only, if creating new secret)
280
281
  const expiryDays = await promptExpiryDays(team.tier);
@@ -306,6 +307,7 @@ async function connectExistingProjectFlow(cwd, client, team) {
306
307
  console.log("\nNext steps:");
307
308
  console.log(" 1. Review the requirements in .requirements/");
308
309
  console.log(" 2. Add the test harness to your test setup (see README)");
310
+ return true;
309
311
  }
310
312
  /**
311
313
  * MEMBERSHIP-18: Invite flow - join a team using an invite token
@@ -29,7 +29,9 @@ export interface LinkFlags {
29
29
  json?: boolean;
30
30
  }
31
31
  /**
32
- * LINK-12.3: any deviation flag implies non-interactive mode.
32
+ * LINK-12.3: any deviation flag implies non-interactive mode. The JSON
33
+ * output flag does too — machine-readable output only exists on the
34
+ * non-interactive path, so `link --json` alone must not run interactively.
33
35
  */
34
36
  export declare function isNonInteractive(flags: LinkFlags): boolean;
35
37
  export declare function hasCapacity(team: TeamOption): boolean;
@@ -6,10 +6,12 @@
6
6
  * assistant needs to ask the user and retry with a deviation flag.
7
7
  */
8
8
  /**
9
- * LINK-12.3: any deviation flag implies non-interactive mode.
9
+ * LINK-12.3: any deviation flag implies non-interactive mode. The JSON
10
+ * output flag does too — machine-readable output only exists on the
11
+ * non-interactive path, so `link --json` alone must not run interactively.
10
12
  */
11
13
  export function isNonInteractive(flags) {
12
- return Boolean(flags.yes || flags.team || flags.connect || flags.create);
14
+ return Boolean(flags.yes || flags.team || flags.connect || flags.create || flags.json);
13
15
  }
14
16
  export function hasCapacity(team) {
15
17
  return team.projectLimit === -1 || team.projectCount < team.projectLimit;
@@ -5,6 +5,7 @@ import { getConvexUrl } from "../config.js";
5
5
  import { api } from "../convex.js";
6
6
  import { findRequirementsFiles } from "../requirements/index.js";
7
7
  import { buildRequirementsFile } from "../schema/index.js";
8
+ import { extractFrontmatterBlock } from "../schema/parser-core.js";
8
9
  import { brand } from "../utils/brand.js";
9
10
  import { getProjectCredentials } from "../utils/project-settings.js";
10
11
  export async function pullCommand(options) {
@@ -94,12 +95,33 @@ export async function pullCommand(options) {
94
95
  }
95
96
  // Build index of existing files by document ID (search entire workspace)
96
97
  const existingFilesByDocId = await buildDocumentIdIndex(process.cwd());
98
+ // SYNC-WEB-CREATE-2.0: track paths claimed during this pull so colliding
99
+ // titles don't silently overwrite each other within one operation
100
+ const usedPaths = new Set(existingFilesByDocId.values());
97
101
  // Write each document as a Markdown file
98
102
  for (const doc of documents) {
99
103
  // Check if an existing file has this document ID
100
104
  const existingFilePath = existingFilesByDocId.get(doc.documentId);
101
- const filePath = existingFilePath ??
102
- path.join(requirementsDir, `${sanitizeFileName(doc.title)}.requirements.md`);
105
+ let filePath;
106
+ if (existingFilePath) {
107
+ filePath = existingFilePath;
108
+ }
109
+ else {
110
+ // SYNC-WEB-CREATE-2.1: an all-symbols title sanitizes to nothing —
111
+ // fall back to the document ID rather than a hidden ".requirements.md"
112
+ const baseName = sanitizeFileName(doc.title) || doc.documentId;
113
+ let candidate = path.join(requirementsDir, `${baseName}.requirements.md`);
114
+ // SYNC-WEB-CREATE-2.0: disambiguate later collisions with a numeric
115
+ // suffix; also avoid clobbering an on-disk file that belongs to a
116
+ // different (or no) document
117
+ let suffix = 2;
118
+ while (usedPaths.has(candidate) || fs.existsSync(candidate)) {
119
+ candidate = path.join(requirementsDir, `${baseName}-${suffix}.requirements.md`);
120
+ suffix++;
121
+ }
122
+ filePath = candidate;
123
+ }
124
+ usedPaths.add(filePath);
103
125
  const fileName = path.basename(filePath);
104
126
  // IMPORT-1: carry the CTS run marker forward from the existing local
105
127
  // file — pull rebuilds frontmatter from cloud data, and silently dropping
@@ -168,10 +190,21 @@ async function buildDocumentIdIndex(workspaceRoot) {
168
190
  /**
169
191
  * Extract document.id from a requirements file's frontmatter.
170
192
  * Returns undefined if the file can't be read or doesn't have a document ID.
193
+ * SYNC-DISCOVERY-3: only the leading YAML frontmatter block is consulted —
194
+ * a document id quoted in body prose or a fenced example must never mark
195
+ * the file as owning that document.
171
196
  */
172
197
  function extractDocumentIdFromFile(filePath) {
173
198
  try {
174
- const content = fs.readFileSync(filePath, "utf-8");
199
+ const fileContent = fs.readFileSync(filePath, "utf-8");
200
+ // Isolate the leading ---...--- frontmatter block; no frontmatter means
201
+ // the file is unlinked to any cloud document (SYNC-DISCOVERY-3.2).
202
+ // Shared helper normalizes CRLF (SYNC-FORMAT-1) so a Windows-saved file
203
+ // keeps matching its document and is updated in place (SYNC-DISCOVERY-2.1).
204
+ const content = extractFrontmatterBlock(fileContent);
205
+ if (content === undefined) {
206
+ return undefined;
207
+ }
175
208
  // Match document.id in YAML frontmatter - handles both inline and nested formats
176
209
  // Inline: document: { id: "abc123", ... }
177
210
  // Nested (id: can appear at any position within the indented document block):
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as readline from "node:readline";
4
4
  import { getConvexUrl } from "../config.js";
5
- import { dryRunPush, executePush, parseFilesForPush, } from "../push/index.js";
5
+ import { dryRunPush, executePush, parseFilesForPushIndividually, } from "../push/index.js";
6
6
  import { findRequirementsFiles } from "../requirements/index.js";
7
7
  import { brand } from "../utils/brand.js";
8
8
  import { getProjectCredentials } from "../utils/project-settings.js";
@@ -30,20 +30,30 @@ export async function pushCommand(file, options) {
30
30
  return;
31
31
  }
32
32
  }
33
- // Parse all local files
33
+ // Parse local files. SYNC-FAIL-4: one invalid file is skipped while the
34
+ // rest of the batch still pushes — same helper as the MCP push handler.
34
35
  console.log("Parsing local requirements...");
35
- const { parsedFiles, totalRequirements } = parseFilesForPush(filesToPush);
36
+ const { parsedFiles, totalRequirements, parseFailures } = parseFilesForPushIndividually(filesToPush);
36
37
  // Log file parsing progress
37
38
  for (const file of parsedFiles) {
38
39
  const fileName = path.basename(file.filePath);
39
40
  const doc = file.metadata.document;
40
- if (doc?.defaultPrefix && !file.metadata.document?.defaultPrefix) {
41
- console.log(` Reading ${fileName}...`);
41
+ console.log(` Reading ${fileName}...`);
42
+ // DOC-HEADER-11.4: parseFilesForPush mutates the metadata when it infers
43
+ // the prefix, so the flag it returns is the only record of the inference
44
+ if (doc?.defaultPrefix && file.inferredDefaultPrefix) {
42
45
  console.log(` Inferred defaultPrefix "${doc.defaultPrefix}" from first requirement`);
43
46
  }
44
- else {
45
- console.log(` Reading ${fileName}...`);
47
+ }
48
+ // SYNC-FAIL-4.1: every file in the push failed to parse — report each
49
+ // skipped file, then fail honestly instead of dry-running nothing
50
+ if (parsedFiles.length === 0) {
51
+ for (const failure of parseFailures) {
52
+ console.log(` ✗ Skipped ${path.basename(failure.filePath)}: ${failure.error}`);
46
53
  }
54
+ console.log("\nNo valid documents to push.");
55
+ process.exitCode = 1;
56
+ return;
47
57
  }
48
58
  console.log(`\nFound ${totalRequirements} requirement(s) in ${parsedFiles.length} document(s).\n`);
49
59
  // Build credentials
@@ -56,13 +66,19 @@ export async function pushCommand(file, options) {
56
66
  console.log("Validating documents...");
57
67
  const dryRunResult = await dryRunPush(parsedFiles, credentials);
58
68
  // Display unified summary
59
- displayDryRunSummary(dryRunResult);
69
+ displayDryRunSummary(dryRunResult, parseFailures);
60
70
  // Check if there's anything to push
61
71
  const pushableCount = dryRunResult.updates.length +
62
72
  dryRunResult.creates.length +
63
73
  dryRunResult.notFound.length;
64
74
  if (pushableCount === 0) {
65
75
  console.log("No valid documents to push.");
76
+ // SYNC-FAIL-4.1: every file in the push was invalid — whether it failed
77
+ // local parse or dry-run validation — so the command must fail honestly,
78
+ // matching the MCP handler's merged rule
79
+ if (parseFailures.length > 0 || dryRunResult.invalid.length > 0) {
80
+ process.exitCode = 1;
81
+ }
66
82
  return;
67
83
  }
68
84
  // Single confirmation prompt
@@ -88,8 +104,9 @@ export async function pushCommand(file, options) {
88
104
  ]) {
89
105
  const fileName = path.basename(file.filePath);
90
106
  const isCreate = dryResult.action === "create" || dryResult.action === "not_found";
91
- // Check if this file had an error
92
- const error = result.errors.find((e) => e.fileName === fileName);
107
+ // Check if this file had an error — match by full path, since two pushed
108
+ // files can share a basename
109
+ const error = result.errors.find((e) => e.filePath === file.filePath);
93
110
  if (error) {
94
111
  console.log(` ✗ Failed: ${fileName} - ${error.error}`);
95
112
  }
@@ -100,6 +117,16 @@ export async function pushCommand(file, options) {
100
117
  console.log(` ✓ Updated: ${fileName}`);
101
118
  }
102
119
  }
120
+ // SYNC-FAIL-2.1: the cloud save succeeded but the local file couldn't be
121
+ // updated — name the file, say the cloud is fine, and give the recovery
122
+ // step that re-links the file instead of minting a duplicate on retry
123
+ if (result.writeBackWarnings?.length > 0) {
124
+ console.log();
125
+ for (const warning of result.writeBackWarnings) {
126
+ console.log(`⚠ ${warning.fileName}: saved to the cloud, but the local file could not be updated (${warning.error}). ` +
127
+ `To avoid creating a duplicate, add "id: ${warning.documentId}" under "document:" in the frontmatter of ${warning.filePath}, then push again.`);
128
+ }
129
+ }
103
130
  // IMPORT-3: marker failures are loud but never fatal
104
131
  if (result.importWarnings.length > 0) {
105
132
  console.log();
@@ -148,7 +175,7 @@ export async function pushCommand(file, options) {
148
175
  /**
149
176
  * Display the dry run summary.
150
177
  */
151
- function displayDryRunSummary(dryRunResult) {
178
+ function displayDryRunSummary(dryRunResult, parseFailures = []) {
152
179
  const { updates, creates, notFound, invalid, conflicts } = dryRunResult;
153
180
  console.log("\n=== Push Summary ===\n");
154
181
  if (updates.length > 0) {
@@ -179,11 +206,22 @@ function displayDryRunSummary(dryRunResult) {
179
206
  }
180
207
  console.log();
181
208
  }
182
- if (invalid.length > 0) {
183
- console.log(`Skipped - invalid files (${invalid.length}):`);
184
- for (const { file, result } of invalid) {
185
- const fileName = path.basename(file.filePath);
186
- console.log(` ✗ ${fileName}: ${result.error}`);
209
+ // SYNC-FAIL-4.0: local parse failures surface alongside dry-run invalids,
210
+ // each named by file, while the rest of the batch proceeds
211
+ const skipped = [
212
+ ...parseFailures.map((failure) => ({
213
+ fileName: path.basename(failure.filePath),
214
+ error: failure.error,
215
+ })),
216
+ ...invalid.map(({ file, result }) => ({
217
+ fileName: path.basename(file.filePath),
218
+ error: result.error,
219
+ })),
220
+ ];
221
+ if (skipped.length > 0) {
222
+ console.log(`Skipped - invalid files (${skipped.length}):`);
223
+ for (const { fileName, error } of skipped) {
224
+ console.log(` ✗ ${fileName}: ${error}`);
187
225
  }
188
226
  console.log();
189
227
  }
@@ -36,8 +36,16 @@ export async function reportCommand(options) {
36
36
  `(${error instanceof Error ? error.message : String(error)})`);
37
37
  }
38
38
  if (options.requirement) {
39
- const record = await getRequirementCoverage(options.requirement, projectId, projectSecret, CONVEX_URL);
40
- console.log(printCloudRequirement(record, format));
39
+ // REPORT-CLOUD-1.7: --branch/--since apply to the requirement-scoped
40
+ // output too, not just the project-wide report
41
+ const record = await getRequirementCoverage(options.requirement, projectId, projectSecret, CONVEX_URL, {
42
+ branch: options.branch,
43
+ sinceTimestamp: options.since,
44
+ });
45
+ console.log(printCloudRequirement(record, format, {
46
+ branch: options.branch,
47
+ since: options.since,
48
+ }));
41
49
  return;
42
50
  }
43
51
  const record = await getProjectCoverage(projectId, projectSecret, CONVEX_URL, {
@@ -100,11 +108,18 @@ function formatLocalMarkdown(report) {
100
108
  }
101
109
  return out;
102
110
  }
103
- function printCloudRequirement(record, format) {
111
+ function printCloudRequirement(record, format, filters) {
104
112
  if (format === "json") {
105
113
  return JSON.stringify({ source: "cloud", requirement: record }, null, 2);
106
114
  }
107
115
  if (!record.lastTestedAt) {
116
+ // REPORT-CLOUD-1.7: with filters in play, an empty record means nothing
117
+ // matched them — say so rather than implying the requirement was never
118
+ // tested at all
119
+ const filterNote = formatCloudFilters(filters);
120
+ if (filterNote) {
121
+ return `\n${record.requirementKey}: no coverage records match the requested filters (${filterNote.replace(/^Filters: /, "")}).\n`;
122
+ }
108
123
  return `\n${record.requirementKey}: never tested on the cloud-persisted record.\n`;
109
124
  }
110
125
  const lastTested = new Date(record.lastTestedAt).toISOString();