@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
package/dist/push/core.js CHANGED
@@ -12,8 +12,10 @@
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { ConvexHttpClient } from "convex/browser";
15
+ import YAML from "yaml";
15
16
  import { api } from "../convex.js";
16
17
  import { buildRequirementsFile, getAllRequirements, parseRequirementKey, parseRequirementsFromFile, } from "../schema/index.js";
18
+ import { extractFrontmatterBlock, stripFrontmatterBlock, } from "../schema/parser-core.js";
17
19
  /** Base URL of the web app, where synced documents are reviewed. */
18
20
  export const WEB_APP_URL = "https://app.dotrequirements.io";
19
21
  // ============================================================================
@@ -23,15 +25,63 @@ export const WEB_APP_URL = "https://app.dotrequirements.io";
23
25
  * Extract markdown content from a file, stripping YAML frontmatter.
24
26
  */
25
27
  export function extractMarkdownContent(rawContent) {
26
- // Match YAML frontmatter: starts with ---, ends with ---
27
- const frontmatterMatch = rawContent.match(/^---\n[\s\S]*?\n---\n*/);
28
- if (frontmatterMatch) {
29
- return rawContent.slice(frontmatterMatch[0].length);
28
+ // Shared CRLF-normalizing helper (SYNC-FORMAT-1): the stripped body is
29
+ // pushed as the document's canonical markdownContent (SYNC-ARCH-1), so a
30
+ // CRLF-authored file must not leak its frontmatter fence into the cloud
31
+ // body.
32
+ return stripFrontmatterBlock(rawContent);
33
+ }
34
+ /**
35
+ * DOC-HEADER-14: parse the frontmatter block as the user wrote it, keeping
36
+ * keys the schema doesn't recognize (Zod strip-mode parsing discards them).
37
+ */
38
+ function extractRawFrontmatter(rawContent) {
39
+ // Shared helper normalizes CRLF (SYNC-FORMAT-1) so a Windows-saved file's
40
+ // custom keys survive the rewrite too (DOC-HEADER-14).
41
+ const frontmatterBlock = extractFrontmatterBlock(rawContent);
42
+ if (frontmatterBlock === undefined) {
43
+ return undefined;
44
+ }
45
+ try {
46
+ const parsed = YAML.parse(frontmatterBlock);
47
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
48
+ return parsed;
49
+ }
50
+ }
51
+ catch {
52
+ // Unparseable frontmatter would have failed schema parsing already;
53
+ // treat as no extra keys to preserve.
30
54
  }
31
- return rawContent;
55
+ return undefined;
56
+ }
57
+ /**
58
+ * DOC-HEADER-14: merge the validated (and push-updated) metadata over the raw
59
+ * frontmatter so unrecognized keys survive the rewrite while the fields the
60
+ * push owns (document ID, pulledAt, defaultPrefix, version) stay updated.
61
+ */
62
+ function mergeMetadataWithRawFrontmatter(metadata, rawFrontmatter) {
63
+ if (!rawFrontmatter) {
64
+ return metadata;
65
+ }
66
+ const merged = { ...rawFrontmatter, ...metadata };
67
+ const rawDocument = rawFrontmatter.document;
68
+ if (metadata.document &&
69
+ rawDocument &&
70
+ typeof rawDocument === "object" &&
71
+ !Array.isArray(rawDocument)) {
72
+ merged.document = {
73
+ ...rawDocument,
74
+ ...metadata.document,
75
+ };
76
+ }
77
+ return merged;
32
78
  }
33
79
  /**
34
80
  * Parse files for push. Returns parsed files with metadata and content.
81
+ *
82
+ * Throws (via `parseRequirementsFromFile`) if any file is syntactically
83
+ * invalid. Callers that need one bad file not to abort the batch should parse
84
+ * files individually and collect failures (see the MCP push handler, #45).
35
85
  */
36
86
  export function parseFilesForPush(filePaths) {
37
87
  const parsedFiles = [];
@@ -46,12 +96,16 @@ export function parseFilesForPush(filePaths) {
46
96
  const markdownContent = extractMarkdownContent(rawContent);
47
97
  // DOC-HEADER-11.1/11.2: Infer prefix from first requirement if not specified
48
98
  const doc = parsed.metadata.document;
99
+ let inferredDefaultPrefix = false;
49
100
  if (doc && !doc.defaultPrefix && flatRequirements.length > 0) {
50
101
  const firstReq = flatRequirements[0];
51
102
  if (firstReq) {
52
103
  const parsedKey = parseRequirementKey(firstReq.id);
53
104
  if (parsedKey) {
54
105
  doc.defaultPrefix = parsedKey.prefix;
106
+ // DOC-HEADER-11.4: remember the prefix was inferred, not authored —
107
+ // the metadata mutation above makes the two indistinguishable later
108
+ inferredDefaultPrefix = true;
55
109
  }
56
110
  }
57
111
  }
@@ -60,11 +114,67 @@ export function parseFilesForPush(filePaths) {
60
114
  metadata: parsed.metadata,
61
115
  markdownContent,
62
116
  requirementCount: flatRequirements.length,
117
+ // DOC-HEADER-14: keep the user's frontmatter (unrecognized keys included)
118
+ rawFrontmatter: extractRawFrontmatter(rawContent),
119
+ inferredDefaultPrefix,
63
120
  });
64
121
  totalRequirements += flatRequirements.length;
65
122
  }
123
+ // SYNC-FAIL-3.0: abort before any cloud write when two files claim the
124
+ // same document
125
+ assertNoDuplicateDocumentIds(parsedFiles);
66
126
  return { parsedFiles, totalRequirements };
67
127
  }
128
+ /**
129
+ * SYNC-FAIL-4.0: parse files one at a time so a single invalid file cannot
130
+ * abort the batch — its failure is collected per file while the valid files
131
+ * still parse. Shared by the CLI push command and the MCP push handler (#45)
132
+ * so their isolation semantics cannot drift.
133
+ *
134
+ * Note: cross-file checks that need the whole batch (e.g. SYNC-FAIL-3
135
+ * duplicate document IDs) are enforced downstream in dryRunPush, not here.
136
+ */
137
+ export function parseFilesForPushIndividually(filePaths) {
138
+ const parsedFiles = [];
139
+ let totalRequirements = 0;
140
+ const parseFailures = [];
141
+ for (const filePath of filePaths) {
142
+ try {
143
+ const result = parseFilesForPush([filePath]);
144
+ parsedFiles.push(...result.parsedFiles);
145
+ totalRequirements += result.totalRequirements;
146
+ }
147
+ catch (error) {
148
+ parseFailures.push({
149
+ filePath,
150
+ error: error instanceof Error ? error.message : String(error),
151
+ });
152
+ }
153
+ }
154
+ return { parsedFiles, totalRequirements, parseFailures };
155
+ }
156
+ /**
157
+ * SYNC-FAIL-3: two ParsedFiles carrying the same document.id would both push
158
+ * as updates to one cloud document — last writer wins and the first spec is
159
+ * silently destroyed. Abort instead, naming both files and the shared id.
160
+ */
161
+ export function assertNoDuplicateDocumentIds(parsedFiles) {
162
+ const filesByDocId = new Map();
163
+ for (const file of parsedFiles) {
164
+ const docId = file.metadata.document?.id;
165
+ if (!docId)
166
+ continue;
167
+ const existingPath = filesByDocId.get(docId);
168
+ if (existingPath) {
169
+ throw new Error(`Two files claim the same document ID "${docId}":\n` +
170
+ ` ${existingPath}\n` +
171
+ ` ${file.filePath}\n` +
172
+ `Each file must map to its own cloud document. Remove the "id:" line ` +
173
+ `from the copy's frontmatter so it pushes as a new document, then push again.`);
174
+ }
175
+ filesByDocId.set(docId, file.filePath);
176
+ }
177
+ }
68
178
  // ============================================================================
69
179
  // Dry Run
70
180
  // ============================================================================
@@ -72,6 +182,9 @@ export function parseFilesForPush(filePaths) {
72
182
  * Execute dry run phase: validate all files against Convex and detect conflicts.
73
183
  */
74
184
  export async function dryRunPush(parsedFiles, credentials) {
185
+ // SYNC-FAIL-3.0: callers that assemble ParsedFiles themselves (e.g. the
186
+ // MCP handler parses files one at a time) still abort before any cloud write
187
+ assertNoDuplicateDocumentIds(parsedFiles);
75
188
  const client = new ConvexHttpClient(credentials.convexUrl);
76
189
  const dryRunResults = [];
77
190
  // Phase 1: Dry run to categorize all files
@@ -174,6 +287,7 @@ export async function executePush(dryRunResult, credentials) {
174
287
  const errors = [];
175
288
  const importWarnings = [];
176
289
  const synced = [];
290
+ const writeBackWarnings = [];
177
291
  for (const { file, result } of pushableFiles) {
178
292
  const doc = file.metadata.document;
179
293
  const fileName = path.basename(file.filePath);
@@ -211,18 +325,31 @@ export async function executePush(dryRunResult, credentials) {
211
325
  doc.id = pushResult;
212
326
  file.metadata.pulledAt = new Date().toISOString();
213
327
  file.metadata.version = 1;
214
- const updatedContent = buildRequirementsFile(file.metadata, file.markdownContent);
215
- fs.writeFileSync(file.filePath, updatedContent, "utf-8");
216
328
  created++;
217
329
  }
218
330
  else {
219
331
  // Update pulledAt to reflect this push
220
332
  file.metadata.pulledAt = new Date().toISOString();
221
333
  file.metadata.version = (file.metadata.version || 0) + 1;
222
- const updatedContent = buildRequirementsFile(file.metadata, file.markdownContent);
223
- fs.writeFileSync(file.filePath, updatedContent, "utf-8");
224
334
  updated++;
225
335
  }
336
+ // SYNC-FAIL-2: the local write-back is separate from the cloud save —
337
+ // the cloud document already exists at this point, so a write-back
338
+ // failure is a warning on a synced document, never a push failure
339
+ // (which would invite a retry that mints a duplicate cloud document).
340
+ try {
341
+ // DOC-HEADER-14: unrecognized frontmatter keys survive the rewrite
342
+ const updatedContent = buildRequirementsFile(mergeMetadataWithRawFrontmatter(file.metadata, file.rawFrontmatter), file.markdownContent);
343
+ fs.writeFileSync(file.filePath, updatedContent, "utf-8");
344
+ }
345
+ catch (writeErr) {
346
+ writeBackWarnings.push({
347
+ fileName,
348
+ filePath: file.filePath,
349
+ documentId: pushResult,
350
+ error: writeErr instanceof Error ? writeErr.message : String(writeErr),
351
+ });
352
+ }
226
353
  // SYNC-LAND-1: every synced document gets its web URL in the result
227
354
  synced.push({
228
355
  fileName,
@@ -231,10 +358,21 @@ export async function executePush(dryRunResult, credentials) {
231
358
  });
232
359
  }
233
360
  catch (err) {
234
- errors.push({ fileName, error: errorDisplayMessage(err) });
361
+ errors.push({
362
+ fileName,
363
+ filePath: file.filePath,
364
+ error: errorDisplayMessage(err),
365
+ });
235
366
  }
236
367
  }
237
- return { created, updated, errors, importWarnings, synced };
368
+ return {
369
+ created,
370
+ updated,
371
+ errors,
372
+ importWarnings,
373
+ synced,
374
+ writeBackWarnings,
375
+ };
238
376
  }
239
377
  /**
240
378
  * Servers throw ConvexError({kind, message}) for expected failures (e.g.
@@ -4,5 +4,5 @@
4
4
  * Shared push logic for syncing local requirements files to the cloud.
5
5
  * Used by both CLI push command and MCP push_requirements tool.
6
6
  */
7
- export { type CloudDocumentMetadata, type ConflictInfo, type DryRunResult, type DryRunResultItem, dryRunPush, executePush, extractMarkdownContent, type FileWithDryRun, type ParsedFile, type PushCredentials, type PushResult, parseFilesForPush, WEB_APP_URL, } from "./core.js";
7
+ export { type CloudDocumentMetadata, type ConflictInfo, type DryRunResult, type DryRunResultItem, dryRunPush, executePush, extractMarkdownContent, type FileWithDryRun, type ParsedFile, type ParseFailure, type PushCredentials, type PushResult, parseFilesForPush, parseFilesForPushIndividually, WEB_APP_URL, } from "./core.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -6,5 +6,5 @@
6
6
  */
7
7
  export { dryRunPush, executePush,
8
8
  // Functions
9
- extractMarkdownContent, parseFilesForPush, WEB_APP_URL, } from "./core.js";
9
+ extractMarkdownContent, parseFilesForPush, parseFilesForPushIndividually, WEB_APP_URL, } from "./core.js";
10
10
  //# sourceMappingURL=index.js.map
@@ -27,9 +27,19 @@ export interface ProjectCoverageRecord {
27
27
  untested: string[];
28
28
  }
29
29
  /**
30
- * Query cloud for coverage on a single requirement.
30
+ * Query cloud for coverage on a single requirement, optionally filtered by
31
+ * branch or recorded-since timestamp.
32
+ *
33
+ * The hosted query takes no branch/since parameters, so the filters are
34
+ * applied client-side over the per-branch rollup (`allBranches`). When no
35
+ * entries match, the returned record has `lastTestedAt: null` and an empty
36
+ * `allBranches` so callers can report "no records match" instead of falling
37
+ * back to coverage from other branches or times (REPORT-CLOUD-1.7).
31
38
  */
32
- export declare function getRequirementCoverage(requirementKey: string, projectId: string, projectSecret: string, convexUrl: string): Promise<RequirementCoverageRecord>;
39
+ export declare function getRequirementCoverage(requirementKey: string, projectId: string, projectSecret: string, convexUrl: string, options?: {
40
+ branch?: string;
41
+ sinceTimestamp?: number;
42
+ }): Promise<RequirementCoverageRecord>;
33
43
  /**
34
44
  * Query cloud for project-wide coverage summary, optionally filtered by
35
45
  * branch or recorded-since timestamp.
@@ -4,9 +4,16 @@
4
4
  * tool and the CLI `report` verb's `--source cloud` mode.
5
5
  */
6
6
  /**
7
- * Query cloud for coverage on a single requirement.
7
+ * Query cloud for coverage on a single requirement, optionally filtered by
8
+ * branch or recorded-since timestamp.
9
+ *
10
+ * The hosted query takes no branch/since parameters, so the filters are
11
+ * applied client-side over the per-branch rollup (`allBranches`). When no
12
+ * entries match, the returned record has `lastTestedAt: null` and an empty
13
+ * `allBranches` so callers can report "no records match" instead of falling
14
+ * back to coverage from other branches or times (REPORT-CLOUD-1.7).
8
15
  */
9
- export async function getRequirementCoverage(requirementKey, projectId, projectSecret, convexUrl) {
16
+ export async function getRequirementCoverage(requirementKey, projectId, projectSecret, convexUrl, options) {
10
17
  const response = await fetch(`${convexUrl}/api/query`, {
11
18
  method: "POST",
12
19
  headers: { "Content-Type": "application/json" },
@@ -27,7 +34,27 @@ export async function getRequirementCoverage(requirementKey, projectId, projectS
27
34
  if (json.status === "error") {
28
35
  throw new Error(`Convex query failed: ${json.errorMessage}`);
29
36
  }
30
- return json.value;
37
+ const record = json.value;
38
+ const { branch, sinceTimestamp } = options ?? {};
39
+ if (branch === undefined && sinceTimestamp === undefined) {
40
+ return record;
41
+ }
42
+ const matching = record.allBranches.filter((b) => (branch === undefined || b.branch === branch) &&
43
+ (sinceTimestamp === undefined || b.lastTestedAt > sinceTimestamp));
44
+ let newest;
45
+ for (const b of matching) {
46
+ if (!newest || b.lastTestedAt > newest.lastTestedAt) {
47
+ newest = b;
48
+ }
49
+ }
50
+ return {
51
+ requirementKey: record.requirementKey,
52
+ lastTestedAt: newest?.lastTestedAt ?? null,
53
+ branch: newest?.branch ?? null,
54
+ testFile: newest?.testFile ?? null,
55
+ testLine: newest?.testLine ?? null,
56
+ allBranches: matching,
57
+ };
31
58
  }
32
59
  /**
33
60
  * Query cloud for project-wide coverage summary, optionally filtered by
@@ -19,9 +19,14 @@ export declare function findTestsForRequirement(workspaceRoot: string, requireme
19
19
  */
20
20
  export declare function findRequirementsInFile(filePath: string): Promise<TestReference[]>;
21
21
  /**
22
- * Find all requirement references in the entire workspace
22
+ * Find all requirement references in the entire workspace.
23
23
  *
24
- * Uses execFile with array arguments to avoid command injection vulnerabilities.
24
+ * Backed by AST parsing (the same extraction used by
25
+ * getReferencedRequirementIds) rather than line-oriented grep, so that:
26
+ * - commented-out `requirement(...)` calls are NOT counted as live references, and
27
+ * - `requirement(...)` calls wrapped across multiple lines are found, and
28
+ * - every string-literal argument of a multi-arg `requirement("A", "B")` call
29
+ * is captured (not just the first).
25
30
  */
26
31
  export declare function findAllTestReferences(workspaceRoot: string): Promise<TestReference[]>;
27
32
  /**
@@ -37,12 +37,16 @@ export async function findTestsForRequirement(workspaceRoot, requirementId) {
37
37
  "ts",
38
38
  "--type",
39
39
  "js",
40
- "--type",
41
- "tsx",
42
- "--type",
43
- "jsx",
44
40
  workspaceRoot,
45
- ], { maxBuffer: 10 * 1024 * 1024 }).catch(() => ({ stdout: "" })); // ripgrep returns non-zero on no matches
41
+ ], { maxBuffer: 10 * 1024 * 1024 }).catch((err) => {
42
+ // ripgrep exits non-zero on no matches (code 1). A code-2 failure is a
43
+ // real error (bad flag, unreadable path) — surface its stderr rather
44
+ // than silently treating it as "no matches".
45
+ if (err?.code && err.code !== 1 && err.stderr) {
46
+ console.error(`ripgrep error: ${err.stderr}`);
47
+ }
48
+ return { stdout: "" };
49
+ });
46
50
  output = stdout;
47
51
  }
48
52
  else {
@@ -75,6 +79,7 @@ export async function findRequirementsInFile(filePath) {
75
79
  const t = await import("@babel/types");
76
80
  const fs = await import("node:fs");
77
81
  const pathModule = await import("node:path");
82
+ const { fileURLToPath } = await import("node:url");
78
83
  const results = [];
79
84
  // Resolve relative paths from current working directory
80
85
  let resolvedPath = pathModule.isAbsolute(filePath)
@@ -82,7 +87,11 @@ export async function findRequirementsInFile(filePath) {
82
87
  : pathModule.resolve(process.cwd(), filePath);
83
88
  // If file doesn't exist and path is relative, try resolving from CLI package root
84
89
  if (!pathModule.isAbsolute(filePath) && !fs.existsSync(resolvedPath)) {
85
- const cliPackageRoot = pathModule.resolve(__dirname, "../..");
90
+ // This module is bundled as ESM ("type":"module"), where the CommonJS
91
+ // module-directory global is undefined and throws ReferenceError. Derive
92
+ // the directory from import.meta.url instead.
93
+ const moduleDir = pathModule.dirname(fileURLToPath(import.meta.url));
94
+ const cliPackageRoot = pathModule.resolve(moduleDir, "../..");
86
95
  const alternativePath = pathModule.resolve(cliPackageRoot, filePath);
87
96
  if (fs.existsSync(alternativePath)) {
88
97
  resolvedPath = alternativePath;
@@ -137,52 +146,71 @@ export async function findRequirementsInFile(filePath) {
137
146
  }
138
147
  }
139
148
  /**
140
- * Find all requirement references in the entire workspace
149
+ * Find all requirement references in the entire workspace.
141
150
  *
142
- * Uses execFile with array arguments to avoid command injection vulnerabilities.
151
+ * Backed by AST parsing (the same extraction used by
152
+ * getReferencedRequirementIds) rather than line-oriented grep, so that:
153
+ * - commented-out `requirement(...)` calls are NOT counted as live references, and
154
+ * - `requirement(...)` calls wrapped across multiple lines are found, and
155
+ * - every string-literal argument of a multi-arg `requirement("A", "B")` call
156
+ * is captured (not just the first).
143
157
  */
144
158
  export async function findAllTestReferences(workspaceRoot) {
145
- const useRg = await hasRipgrep();
146
- // Pattern to match any requirement() call
147
- const pattern = `requirement\\s*\\(\\s*['"\`][^'"\`]+`;
148
- try {
149
- let output;
150
- if (useRg) {
151
- // Using execFile with array args to avoid shell injection
152
- const { stdout } = await execFileAsync("rg", [
153
- "-n",
154
- "--column",
155
- pattern,
156
- "--type",
157
- "ts",
158
- "--type",
159
- "js",
160
- "--type",
161
- "tsx",
162
- "--type",
163
- "jsx",
164
- workspaceRoot,
165
- ], { maxBuffer: 10 * 1024 * 1024 }).catch(() => ({ stdout: "" })); // ripgrep returns non-zero on no matches
166
- output = stdout;
167
- }
168
- else {
169
- const { stdout } = await execFileAsync("grep", [
170
- "-rn",
171
- "--include=*.ts",
172
- "--include=*.tsx",
173
- "--include=*.js",
174
- "--include=*.jsx",
175
- "-E",
176
- pattern,
177
- workspaceRoot,
178
- ], { maxBuffer: 10 * 1024 * 1024 }).catch(() => ({ stdout: "" })); // grep returns non-zero on no matches
179
- output = stdout;
159
+ const { glob } = await import("glob");
160
+ const { parse } = await import("@babel/parser");
161
+ const traverse = await import("@babel/traverse");
162
+ const t = await import("@babel/types");
163
+ const fs = await import("node:fs");
164
+ // Find all test files using glob (same pattern as getReferencedRequirementIds)
165
+ const testFiles = await glob("**/*.{test,spec}.{js,jsx,ts,tsx}", {
166
+ cwd: workspaceRoot,
167
+ absolute: true,
168
+ ignore: ["**/node_modules/**", "**/dist/**", "**/build/**"],
169
+ });
170
+ const results = [];
171
+ for (const file of testFiles) {
172
+ try {
173
+ const code = fs.readFileSync(file, "utf-8");
174
+ const codeLines = code.split("\n");
175
+ const ast = parse(code, {
176
+ sourceType: "module",
177
+ plugins: ["typescript", "jsx"],
178
+ errorRecovery: true,
179
+ });
180
+ // Handle both ES module and CommonJS exports for traverse
181
+ // biome-ignore lint/suspicious/noExplicitAny: see earlier comment on babel/traverse CJS/ESM interop
182
+ let traverseFunc = traverse;
183
+ if (typeof traverseFunc !== "function") {
184
+ traverseFunc = traverseFunc.default;
185
+ }
186
+ if (typeof traverseFunc !== "function") {
187
+ traverseFunc = traverseFunc.default;
188
+ }
189
+ traverseFunc(ast, {
190
+ // biome-ignore lint/suspicious/noExplicitAny: babel-traverse visitor `path` is a deeply-generic NodePath whose precise type depends on what handler you're inside; `any` is the documented practice for plugin code.
191
+ CallExpression(path) {
192
+ const callee = path.node.callee;
193
+ if (t.isIdentifier(callee) && callee.name === "requirement") {
194
+ const loc = path.node.loc;
195
+ // Capture every string-literal argument (multi-requirement calls).
196
+ for (const arg of path.node.arguments) {
197
+ if (t.isStringLiteral(arg) && loc) {
198
+ results.push({
199
+ file,
200
+ line: loc.start.line,
201
+ column: loc.start.column + 1, // 1-indexed
202
+ requirementId: arg.value,
203
+ context: (codeLines[loc.start.line - 1] || "").trim(),
204
+ });
205
+ }
206
+ }
207
+ }
208
+ },
209
+ });
180
210
  }
181
- return parseGrepOutput(output, "", useRg);
182
- }
183
- catch {
184
- return [];
211
+ catch { }
185
212
  }
213
+ return results;
186
214
  }
187
215
  /**
188
216
  * Parse grep/ripgrep output into TestReference objects
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Build Markdown requirements files from structured data.
3
3
  */
4
- import type { Metadata, RequirementNode } from "./schemas.js";
4
+ import { type Metadata, type RequirementNode } from "./schemas.js";
5
5
  /**
6
6
  * Default delimiter for requirements.
7
7
  * Can be overridden for organization-specific preferences.
@@ -2,6 +2,7 @@
2
2
  * Build Markdown requirements files from structured data.
3
3
  */
4
4
  import YAML from "yaml";
5
+ import { ValidationError, } from "./schemas.js";
5
6
  /**
6
7
  * Default delimiter for requirements.
7
8
  * Can be overridden for organization-specific preferences.
@@ -17,6 +18,16 @@ function buildFrontmatter(metadata) {
17
18
  });
18
19
  return `---\n${yamlStr.trim()}\n---`;
19
20
  }
21
+ /**
22
+ * SYNC-FORMAT-3.0: content with an embedded newline cannot be represented on
23
+ * a single block line — the parser would reject (or misparse) the output.
24
+ * Fail the build instead of emitting markdown that breaks the round-trip.
25
+ */
26
+ function assertSingleLineContent(node) {
27
+ if (node.content.includes("\n")) {
28
+ throw new ValidationError(`Requirement content for "${node.id}" contains an embedded newline — content must be a single line`, node.id);
29
+ }
30
+ }
20
31
  /**
21
32
  * Build the children portion of a requirement block (without the root line).
22
33
  */
@@ -27,6 +38,7 @@ function buildChildrenBlock(children) {
27
38
  const position = parentPosition === "" ? `${index}` : `${parentPosition}.${index}`;
28
39
  const depth = position.split(".").length - 1;
29
40
  const indent = " ".repeat(depth + 1); // 2 spaces per level
41
+ assertSingleLineContent(child);
30
42
  // Include label if present, otherwise just delimiter
31
43
  const labelPart = child.label
32
44
  ? `${child.label.charAt(0).toUpperCase() + child.label.slice(1)} `
@@ -45,6 +57,7 @@ function buildChildrenBlock(children) {
45
57
  * Build a complete requirement section (heading + block).
46
58
  */
47
59
  function buildRequirementSection(node, title) {
60
+ assertSingleLineContent(node);
48
61
  const displayTitle = title || node.content;
49
62
  // Heading now just has the title, no key
50
63
  let section = `## ${displayTitle}\n\n`;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Convert between Convex flat representation and hierarchical RequirementNode trees.
3
3
  */
4
- import type { Metadata, RequirementNode } from "./schemas.js";
4
+ import { type Metadata, type RequirementNode } from "./schemas.js";
5
5
  /**
6
6
  * Convex requirement type (flat structure with position paths).
7
7
  * This mirrors the Convex database schema.
@@ -31,7 +31,12 @@ export declare function constructKey(prefix: string, index: number): string;
31
31
  /**
32
32
  * Parse a requirement key into prefix and index.
33
33
  * E.g., "REQ-123" -> { prefix: "REQ", index: 123 }
34
- * Returns null if key doesn't match expected format.
34
+ * E.g., "SYNC-CLI-CREATE-1" -> { prefix: "SYNC-CLI-CREATE", index: 1 }
35
+ * Returns null if key doesn't match REQUIREMENT_KEY_PATTERN, which is the
36
+ * sole grammar authority here: the split below is mechanical. (Deliberately
37
+ * NOT delegated to parseRequirementKey — it case-normalizes and enforces a
38
+ * 30-char prefix cap this pattern doesn't, and pattern-passing keys must
39
+ * never fall back to the caller's prefix=node.id path.)
35
40
  */
36
41
  export declare function parseKey(key: string): {
37
42
  prefix: string;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Convert between Convex flat representation and hierarchical RequirementNode trees.
3
3
  */
4
+ import { REQUIREMENT_KEY_PATTERN, } from "./schemas.js";
4
5
  /**
5
6
  * Construct a requirement key from prefix and index.
6
7
  * E.g., ("REQ", 123) -> "REQ-123"
@@ -11,13 +12,21 @@ export function constructKey(prefix, index) {
11
12
  /**
12
13
  * Parse a requirement key into prefix and index.
13
14
  * E.g., "REQ-123" -> { prefix: "REQ", index: 123 }
14
- * Returns null if key doesn't match expected format.
15
+ * E.g., "SYNC-CLI-CREATE-1" -> { prefix: "SYNC-CLI-CREATE", index: 1 }
16
+ * Returns null if key doesn't match REQUIREMENT_KEY_PATTERN, which is the
17
+ * sole grammar authority here: the split below is mechanical. (Deliberately
18
+ * NOT delegated to parseRequirementKey — it case-normalizes and enforces a
19
+ * 30-char prefix cap this pattern doesn't, and pattern-passing keys must
20
+ * never fall back to the caller's prefix=node.id path.)
15
21
  */
16
22
  export function parseKey(key) {
17
- const match = key.match(/^([A-Z0-9_]+)-(\d+)$/);
18
- if (!match)
23
+ if (!REQUIREMENT_KEY_PATTERN.test(key))
19
24
  return null;
20
- return { prefix: match[1], index: parseInt(match[2], 10) };
25
+ const splitAt = key.lastIndexOf("-");
26
+ return {
27
+ prefix: key.slice(0, splitAt),
28
+ index: parseInt(key.slice(splitAt + 1), 10),
29
+ };
21
30
  }
22
31
  /**
23
32
  * Build a hierarchical tree from flat Convex requirements.
@@ -40,6 +40,34 @@ export declare function extractRequirementBlocks(body: string): Array<{
40
40
  key: string;
41
41
  blockContent: string;
42
42
  }>;
43
+ /**
44
+ * Split raw file content into its leading YAML frontmatter and body — both
45
+ * derived from ONE fence match, so the two halves are complementary by
46
+ * construction and can never disagree about where the fence closes.
47
+ *
48
+ * Normalizes CRLF first (SYNC-FORMAT-1): frontmatter isolation must accept
49
+ * the same line-ending styles the parser does. Callers must use this (or the
50
+ * wrappers below) instead of hand-rolled regexes so every consumer agrees on
51
+ * what "has frontmatter" means.
52
+ *
53
+ * Returns undefined when the content has no leading frontmatter fence.
54
+ */
55
+ export declare function splitFrontmatter(content: string): {
56
+ yaml: string;
57
+ body: string;
58
+ } | undefined;
59
+ /**
60
+ * The frontmatter half of splitFrontmatter: the inner YAML text, or
61
+ * undefined when the file has no leading fence.
62
+ */
63
+ export declare function extractFrontmatterBlock(content: string): string | undefined;
64
+ /**
65
+ * The body half of splitFrontmatter: the (CRLF-normalized) content with the
66
+ * leading frontmatter fence removed, or the whole normalized content when
67
+ * there is no fence. Blank lines directly after the fence are consumed so
68
+ * the body starts at its first real line.
69
+ */
70
+ export declare function stripFrontmatterBlock(content: string): string;
43
71
  /**
44
72
  * Parse requirement blocks from markdown content (without frontmatter).
45
73
  * Use this for web editor content that doesn't have YAML frontmatter.