@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
@@ -95,30 +95,48 @@ export function parseRequirementBlock(key, blockContent) {
95
95
  }
96
96
  const criterion = parseCriterionLine(line);
97
97
  if (!criterion) {
98
- // Check if this looks like another requirement key (KEY: content format)
98
+ // Only a line whose prefix is shaped like a requirement KEY (uppercase
99
+ // prefix + "-<digits>", per REQUIREMENT_KEY_PATTERN) suggests a second
100
+ // requirement in the block; a stray "https://..." must not be
101
+ // misdiagnosed as one.
99
102
  const trimmedLine = line.trim();
100
- if (/^[\w-]+:\s*.+/.test(trimmedLine)) {
103
+ if (/^[A-Z][A-Z-]*-\d+:\s*.+/.test(trimmedLine)) {
101
104
  throw new ValidationError(`Multiple requirements in single block. Found "${trimmedLine.split(":")[0]}" at line ${i + 1}, but each requirement must have its own \`\`\`dotrequirements code block.`, key);
102
105
  }
103
106
  throw new ValidationError(`Invalid criterion format at line ${i + 1}: "${trimmedLine}". Expected format: "N. Label → content" (e.g., "0. Given → user is logged in")`, key);
104
107
  }
108
+ // SYNC-FORMAT-2.1: a duplicate position is structural ambiguity — reject
109
+ // it instead of silently dropping the earlier criterion.
110
+ if (criteriaByPosition.has(criterion.position)) {
111
+ throw new ValidationError(`Duplicate criterion position "${criterion.position}" at line ${i + 1} — each position may appear only once per requirement`, key);
112
+ }
105
113
  criteriaByPosition.set(criterion.position, criterion);
106
114
  }
107
115
  // Build tree from flat position paths
108
- buildTreeFromPositions(root, key, criteriaByPosition);
116
+ const consumed = new Set();
117
+ buildTreeFromPositions(root, key, criteriaByPosition, consumed);
118
+ // SYNC-FORMAT-2.2: any criterion not attached to the tree references a
119
+ // missing parent position — reject it instead of silently dropping it.
120
+ for (const position of criteriaByPosition.keys()) {
121
+ if (!consumed.has(position)) {
122
+ const parentPosition = position.split(".").slice(0, -1).join(".");
123
+ throw new ValidationError(`Orphaned criterion position "${position}" — parent position "${parentPosition}" does not exist`, key);
124
+ }
125
+ }
109
126
  return root;
110
127
  }
111
128
  /**
112
129
  * Build a tree structure from flat position paths.
113
130
  * Positions like "0", "1", "1.0", "1.1", "1.1.0" define the hierarchy.
114
131
  */
115
- function buildTreeFromPositions(root, rootKey, criteriaByPosition) {
132
+ function buildTreeFromPositions(root, rootKey, criteriaByPosition, consumed) {
116
133
  // Get all top-level children (single digit positions: "0", "1", "2")
117
134
  const topLevelPositions = Array.from(criteriaByPosition.keys())
118
135
  .filter((pos) => !pos.includes("."))
119
136
  .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
120
137
  for (const position of topLevelPositions) {
121
138
  const criterion = criteriaByPosition.get(position);
139
+ consumed.add(position);
122
140
  const childNode = {
123
141
  id: `${rootKey}.${position}`,
124
142
  label: criterion.label,
@@ -126,14 +144,14 @@ function buildTreeFromPositions(root, rootKey, criteriaByPosition) {
126
144
  children: [],
127
145
  };
128
146
  // Recursively build children
129
- buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition);
147
+ buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition, consumed);
130
148
  root.children.push(childNode);
131
149
  }
132
150
  }
133
151
  /**
134
152
  * Recursively build children for a given position.
135
153
  */
136
- function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByPosition) {
154
+ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByPosition, consumed) {
137
155
  // Find direct children (e.g., if parent is "1", find "1.0", "1.1", etc.)
138
156
  const childPositions = Array.from(criteriaByPosition.keys())
139
157
  .filter((pos) => {
@@ -158,6 +176,7 @@ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByP
158
176
  });
159
177
  for (const position of childPositions) {
160
178
  const criterion = criteriaByPosition.get(position);
179
+ consumed.add(position);
161
180
  const childNode = {
162
181
  id: `${rootKey}.${position}`,
163
182
  label: criterion.label,
@@ -165,7 +184,7 @@ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByP
165
184
  children: [],
166
185
  };
167
186
  // Recursively build grandchildren
168
- buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition);
187
+ buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition, consumed);
169
188
  parent.children.push(childNode);
170
189
  }
171
190
  }
@@ -175,6 +194,7 @@ function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByP
175
194
  */
176
195
  export function extractRequirementBlocks(body) {
177
196
  const blocks = [];
197
+ const seenKeys = new Set();
178
198
  // Find all ```dotrequirements code blocks
179
199
  const blockRegex = /```dotrequirements\n([\s\S]*?)```/gm;
180
200
  let match = blockRegex.exec(body);
@@ -190,17 +210,65 @@ export function extractRequirementBlocks(body) {
190
210
  // locally instead of erroring server-side. Validation only — the original
191
211
  // key is preserved (normalization happens downstream).
192
212
  validateKey(key);
213
+ // SYNC-FORMAT-2.0: a duplicate key is structural ambiguity — reject it
214
+ // instead of producing two requirements with the same key.
215
+ if (seenKeys.has(key)) {
216
+ throw new ValidationError(`Duplicate requirement key "${key}" — each requirement key may appear in only one block`, key);
217
+ }
218
+ seenKeys.add(key);
193
219
  blocks.push({ key, blockContent: blockContent.trim() });
194
220
  match = blockRegex.exec(body);
195
221
  }
196
222
  return blocks;
197
223
  }
224
+ /**
225
+ * Split raw file content into its leading YAML frontmatter and body — both
226
+ * derived from ONE fence match, so the two halves are complementary by
227
+ * construction and can never disagree about where the fence closes.
228
+ *
229
+ * Normalizes CRLF first (SYNC-FORMAT-1): frontmatter isolation must accept
230
+ * the same line-ending styles the parser does. Callers must use this (or the
231
+ * wrappers below) instead of hand-rolled regexes so every consumer agrees on
232
+ * what "has frontmatter" means.
233
+ *
234
+ * Returns undefined when the content has no leading frontmatter fence.
235
+ */
236
+ export function splitFrontmatter(content) {
237
+ const normalized = content.replace(/\r\n/g, "\n");
238
+ const match = normalized.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
239
+ if (!match) {
240
+ return undefined;
241
+ }
242
+ return { yaml: match[1], body: normalized.slice(match[0].length) };
243
+ }
244
+ /**
245
+ * The frontmatter half of splitFrontmatter: the inner YAML text, or
246
+ * undefined when the file has no leading fence.
247
+ */
248
+ export function extractFrontmatterBlock(content) {
249
+ return splitFrontmatter(content)?.yaml;
250
+ }
251
+ /**
252
+ * The body half of splitFrontmatter: the (CRLF-normalized) content with the
253
+ * leading frontmatter fence removed, or the whole normalized content when
254
+ * there is no fence. Blank lines directly after the fence are consumed so
255
+ * the body starts at its first real line.
256
+ */
257
+ export function stripFrontmatterBlock(content) {
258
+ const split = splitFrontmatter(content);
259
+ if (split === undefined) {
260
+ return content.replace(/\r\n/g, "\n");
261
+ }
262
+ return split.body.replace(/^\n+/, "");
263
+ }
198
264
  /**
199
265
  * Parse requirement blocks from markdown content (without frontmatter).
200
266
  * Use this for web editor content that doesn't have YAML frontmatter.
201
267
  */
202
268
  export function parseRequirementBlocksFromMarkdown(markdownContent) {
203
- const blocks = extractRequirementBlocks(markdownContent);
269
+ // SYNC-FORMAT-1: normalize Windows (CRLF) line endings so content parses
270
+ // identically regardless of line-ending style.
271
+ const blocks = extractRequirementBlocks(markdownContent.replace(/\r\n/g, "\n"));
204
272
  const requirements = [];
205
273
  for (const { key, blockContent } of blocks) {
206
274
  try {
@@ -208,7 +276,10 @@ export function parseRequirementBlocksFromMarkdown(markdownContent) {
208
276
  requirements.push(reqNode);
209
277
  }
210
278
  catch (error) {
211
- throw new ValidationError(`Failed to parse requirement ${key}`, key, error);
279
+ // Include the inner message so structural errors (duplicate position,
280
+ // orphaned position, ...) stay identifiable at the top level.
281
+ const detail = error instanceof Error && error.message ? `: ${error.message}` : "";
282
+ throw new ValidationError(`Failed to parse requirement ${key}${detail}`, key, error);
212
283
  }
213
284
  }
214
285
  return requirements;
@@ -1,19 +1,14 @@
1
1
  /**
2
2
  * Parse Markdown requirements files into typed structures.
3
+ *
4
+ * File-level concerns only: frontmatter isolation, metadata validation, and
5
+ * disk IO. All block and criterion parsing delegates to the
6
+ * environment-agnostic parser-core module, which the web editor and Convex
7
+ * also consume — one parser, so the CLI and the cloud cannot drift on what a
8
+ * requirements file means (SYNC-FORMAT-2.3).
3
9
  */
4
- import { type ParsedCriterion, type RequirementNode, type RequirementsFile } from "./schemas.js";
5
- /**
6
- * Parse a criterion line in "position. Label → content" format.
7
- * Example: "0. Given → user has valid credentials"
8
- * Also supports optional label: "0. → user has valid credentials"
9
- */
10
- export declare function parseCriterionLine(line: string, delimiter?: string): ParsedCriterion | null;
11
- /**
12
- * Parse a dotrequirements fenced block into a RequirementNode tree.
13
- * First line is the requirement content (with optional arrow format).
14
- * Subsequent lines are criteria with position paths.
15
- */
16
- export declare function parseRequirementBlock(key: string, blockContent: string): RequirementNode;
10
+ import { type RequirementNode, type RequirementsFile } from "./schemas.js";
11
+ export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, } from "./parser-core.js";
17
12
  /**
18
13
  * Parse a complete requirements Markdown file.
19
14
  */
@@ -28,17 +23,4 @@ export declare function parseRequirementsFromFile(filePath: string): {
28
23
  metadata: RequirementsFile["_meta"];
29
24
  requirements: RequirementNode[];
30
25
  };
31
- /**
32
- * Flatten a requirement tree into a list of all nodes.
33
- * Useful for searching or displaying all requirements.
34
- */
35
- export declare function flattenRequirementTree(node: RequirementNode): RequirementNode[];
36
- /**
37
- * Find a requirement by its ID in a tree.
38
- */
39
- export declare function findRequirementById(nodes: RequirementNode[], id: string): RequirementNode | undefined;
40
- /**
41
- * Get all requirements from multiple files as a flat list.
42
- */
43
- export declare function getAllRequirements(requirements: RequirementNode[]): RequirementNode[];
44
26
  //# sourceMappingURL=parser.d.ts.map
@@ -1,245 +1,53 @@
1
1
  /**
2
2
  * Parse Markdown requirements files into typed structures.
3
+ *
4
+ * File-level concerns only: frontmatter isolation, metadata validation, and
5
+ * disk IO. All block and criterion parsing delegates to the
6
+ * environment-agnostic parser-core module, which the web editor and Convex
7
+ * also consume — one parser, so the CLI and the cloud cannot drift on what a
8
+ * requirements file means (SYNC-FORMAT-2.3).
3
9
  */
4
10
  import * as fs from "node:fs";
5
11
  import YAML from "yaml";
6
- import { ValidationError, validateKey, validateMetadata, } from "./schemas.js";
7
- const DELIMITER_PATTERN = "(?:→|->)"; // Non-capturing group for both Unicode and ASCII
8
- /**
9
- * Parse a criterion line in "position. Label → content" format.
10
- * Example: "0. Given → user has valid credentials"
11
- * Also supports optional label: "0. → user has valid credentials"
12
- */
13
- export function parseCriterionLine(line, delimiter = DELIMITER_PATTERN) {
14
- // Match: position + period + space + optional(label + space) + delimiter + space + content
15
- // Position can be: 0, 1.0, 2.3.1, etc.
16
- // Label can contain any characters except the delimiter
17
- const pattern = new RegExp(`^\\s*(\\d+(?:\\.\\d+)*)\\.\\s*(?:(.+?)\\s+)?${delimiter}\\s*(.+)$`);
18
- const match = line.match(pattern);
19
- if (!match) {
20
- return null;
21
- }
22
- return {
23
- position: match[1],
24
- label: match[2] ? match[2].trim() : "", // Preserve original case, empty if no label
25
- content: match[3].trim(),
26
- };
27
- }
28
- /**
29
- * Parse the first line of a dotrequirements block (the root requirement).
30
- * Format: "KEY: content" or "Label → content" or "→ content"
31
- */
32
- function parseRootLine(line, delimiter = DELIMITER_PATTERN) {
33
- const trimmed = line.trim();
34
- // Try to match: KEY + colon + content (explicit key format)
35
- // KEY must be followed by colon (not arrow) to distinguish from "Label → content"
36
- const keyMatch = trimmed.match(/^([\w-]+):\s*(.+)$/);
37
- if (keyMatch) {
38
- return {
39
- label: "", // Keys use empty string for unlabeled requirements
40
- content: keyMatch[2].trim(),
41
- };
42
- }
43
- // Match: optional(label + space) + delimiter + space + content
44
- const pattern = new RegExp(`^(?:(.+?)\\s+)?${delimiter}\\s*(.+)$`);
45
- const match = trimmed.match(pattern);
46
- if (!match) {
47
- // If no delimiter found, treat entire line as content with empty label
48
- return {
49
- label: "",
50
- content: trimmed,
51
- };
52
- }
53
- return {
54
- label: match[1] ? match[1].trim() : "",
55
- content: match[2].trim(),
56
- };
57
- }
58
- /**
59
- * Parse a dotrequirements fenced block into a RequirementNode tree.
60
- * First line is the requirement content (with optional arrow format).
61
- * Subsequent lines are criteria with position paths.
62
- */
63
- export function parseRequirementBlock(key, blockContent) {
64
- const lines = blockContent.trim().split("\n");
65
- if (lines.length === 0) {
66
- throw new ValidationError("Empty requirement block", key);
67
- }
68
- // First line is the requirement content
69
- const rootParsed = parseRootLine(lines[0]);
70
- if (!rootParsed.content) {
71
- throw new ValidationError("Requirement content (first line) cannot be empty", key);
72
- }
73
- const root = {
74
- id: key,
75
- // Root requirements always use "requirementHeader" label for indexing/querying
76
- // The markdown format doesn't preserve root labels, but we standardize on this value
77
- label: "requirementHeader",
78
- content: rootParsed.content,
79
- children: [],
80
- };
81
- // Parse criteria lines
82
- const criteriaByPosition = new Map();
83
- for (let i = 1; i < lines.length; i++) {
84
- const line = lines[i];
85
- // Skip empty lines and comments
86
- if (!line.trim() || line.trim().startsWith("#")) {
87
- continue;
88
- }
89
- const criterion = parseCriterionLine(line);
90
- if (!criterion) {
91
- throw new ValidationError(`Invalid criterion format at line ${i + 1}: "${line.trim()}"`, key);
92
- }
93
- criteriaByPosition.set(criterion.position, criterion);
94
- }
95
- // Build tree from flat position paths
96
- buildTreeFromPositions(root, key, criteriaByPosition);
97
- return root;
98
- }
99
- /**
100
- * Build a tree structure from flat position paths.
101
- * Positions like "0", "1", "1.0", "1.1", "1.1.0" define the hierarchy.
102
- */
103
- function buildTreeFromPositions(root, rootKey, criteriaByPosition) {
104
- // Get all top-level children (single digit positions: "0", "1", "2")
105
- const topLevelPositions = Array.from(criteriaByPosition.keys())
106
- .filter((pos) => !pos.includes("."))
107
- .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
108
- for (const position of topLevelPositions) {
109
- const criterion = criteriaByPosition.get(position);
110
- const childNode = {
111
- id: `${rootKey}.${position}`,
112
- label: criterion.label,
113
- content: criterion.content,
114
- children: [],
115
- };
116
- // Recursively build children
117
- buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition);
118
- root.children.push(childNode);
119
- }
120
- }
121
- /**
122
- * Recursively build children for a given position.
123
- */
124
- function buildChildrenFromPositions(parent, parentPosition, rootKey, criteriaByPosition) {
125
- // Find direct children (e.g., if parent is "1", find "1.0", "1.1", etc.)
126
- const childPositions = Array.from(criteriaByPosition.keys())
127
- .filter((pos) => {
128
- const parts = pos.split(".");
129
- const parentParts = parentPosition.split(".");
130
- // Must be exactly one level deeper
131
- if (parts.length !== parentParts.length + 1) {
132
- return false;
133
- }
134
- // All parent parts must match
135
- for (let i = 0; i < parentParts.length; i++) {
136
- if (parts[i] !== parentParts[i]) {
137
- return false;
138
- }
139
- }
140
- return true;
141
- })
142
- .sort((a, b) => {
143
- const aLast = parseInt(a.split(".").pop(), 10);
144
- const bLast = parseInt(b.split(".").pop(), 10);
145
- return aLast - bLast;
146
- });
147
- for (const position of childPositions) {
148
- const criterion = criteriaByPosition.get(position);
149
- const childNode = {
150
- id: `${rootKey}.${position}`,
151
- label: criterion.label,
152
- content: criterion.content,
153
- children: [],
154
- };
155
- // Recursively build grandchildren
156
- buildChildrenFromPositions(childNode, position, rootKey, criteriaByPosition);
157
- parent.children.push(childNode);
158
- }
159
- }
12
+ import { parseRequirementBlocksFromMarkdown, splitFrontmatter, } from "./parser-core.js";
13
+ import { ValidationError, validateMetadata, } from "./schemas.js";
14
+ // Block/criterion parsing and tree utilities live in parser-core; re-exported
15
+ // here so the schema module's public surface is unchanged.
16
+ export { findRequirementById, flattenRequirementTree, getAllRequirements, parseCriterionLine, parseRequirementBlock, } from "./parser-core.js";
160
17
  /**
161
18
  * Extract YAML frontmatter from Markdown content.
162
19
  * Returns { frontmatter, body } where frontmatter is the parsed YAML object.
163
20
  */
164
21
  function extractFrontmatter(content) {
165
- // Match YAML frontmatter: ---\n...\n---
166
- const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
167
- const match = content.match(frontmatterRegex);
168
- if (!match) {
22
+ // Single fence match: yaml and body come from the same split, so they can
23
+ // never disagree about where the frontmatter closes.
24
+ const split = splitFrontmatter(content);
25
+ if (split === undefined) {
169
26
  throw new ValidationError("Missing YAML frontmatter (should start with ---)");
170
27
  }
171
- const [, frontmatterYaml, body] = match;
172
28
  let frontmatter;
173
29
  try {
174
- frontmatter = YAML.parse(frontmatterYaml);
30
+ frontmatter = YAML.parse(split.yaml);
175
31
  }
176
32
  catch (error) {
177
33
  throw new ValidationError("Failed to parse YAML frontmatter", undefined, error);
178
34
  }
179
- return { frontmatter, body: body.trim() };
180
- }
181
- /**
182
- * Extract requirement blocks from Markdown body.
183
- * Returns a map of requirement key -> { title, blockContent }
184
- *
185
- * The KEY is extracted from the first line inside the code block.
186
- * Headings are optional and only used for human readability.
187
- */
188
- function extractRequirementBlocks(body) {
189
- const blocks = new Map();
190
- // Find all ```dotrequirements code blocks
191
- const blockRegex = /```dotrequirements\n([\s\S]*?)```/gm;
192
- let match = blockRegex.exec(body);
193
- while (match !== null) {
194
- const blockContent = match[1];
195
- // Extract key from first line of block (format: "KEY: content")
196
- const firstLineMatch = blockContent.match(/^([\w-]+):\s*(.+)/);
197
- if (!firstLineMatch) {
198
- throw new ValidationError(`Invalid requirement block format - first line must be "KEY: content"`, blockContent.substring(0, 50));
199
- }
200
- const key = firstLineMatch[1];
201
- // SYNC-KEY-1: reject malformed keys at parse time so push/validate fail
202
- // locally instead of erroring server-side. Validation only — the original
203
- // key is preserved (normalization happens downstream).
204
- validateKey(key);
205
- // Try to find a heading immediately before this block for the title
206
- // Look backwards from the match position to find the nearest heading
207
- const textBeforeBlock = body.substring(0, match.index);
208
- const headingMatch = textBeforeBlock.match(/^#{2,6}\s+([^\n]+)\s*$/m);
209
- const title = headingMatch ? headingMatch[1].trim() : key;
210
- blocks.set(key, { title, blockContent: blockContent.trim() });
211
- match = blockRegex.exec(body);
212
- }
213
- return blocks;
35
+ return { frontmatter, body: split.body.trim() };
214
36
  }
215
37
  /**
216
38
  * Parse a complete requirements Markdown file.
217
39
  */
218
40
  export function parseRequirementsFile(markdownContent) {
219
- // Extract frontmatter and body
41
+ // CRLF normalization (SYNC-FORMAT-1) happens inside the shared parser-core
42
+ // helpers (splitFrontmatter / parseRequirementBlocksFromMarkdown), so every
43
+ // entry point agrees.
220
44
  const { frontmatter, body } = extractFrontmatter(markdownContent);
221
- // Validate metadata from frontmatter
222
45
  const metadata = validateMetadata(frontmatter);
223
- // Extract requirement blocks
224
- const blocks = extractRequirementBlocks(body);
225
- if (blocks.size === 0) {
46
+ const requirements = parseRequirementBlocksFromMarkdown(body);
47
+ if (requirements.length === 0) {
226
48
  throw new ValidationError("No requirement blocks found in document");
227
49
  }
228
- // Parse each requirement block
229
- const requirements = [];
230
- for (const [key, { blockContent }] of blocks) {
231
- try {
232
- const reqNode = parseRequirementBlock(key, blockContent);
233
- requirements.push(reqNode);
234
- }
235
- catch (error) {
236
- throw new ValidationError(`Failed to parse requirement ${key}`, key, error);
237
- }
238
- }
239
- return {
240
- metadata,
241
- requirements,
242
- };
50
+ return { metadata, requirements };
243
51
  }
244
52
  /**
245
53
  * Parse requirements file from disk.
@@ -256,40 +64,4 @@ export function parseRequirementsFromFile(filePath) {
256
64
  throw new ValidationError(`Failed to read file: ${filePath}`, undefined, error);
257
65
  }
258
66
  }
259
- /**
260
- * Flatten a requirement tree into a list of all nodes.
261
- * Useful for searching or displaying all requirements.
262
- */
263
- export function flattenRequirementTree(node) {
264
- const result = [node];
265
- for (const child of node.children) {
266
- result.push(...flattenRequirementTree(child));
267
- }
268
- return result;
269
- }
270
- /**
271
- * Find a requirement by its ID in a tree.
272
- */
273
- export function findRequirementById(nodes, id) {
274
- for (const node of nodes) {
275
- if (node.id === id) {
276
- return node;
277
- }
278
- const found = findRequirementById(node.children, id);
279
- if (found) {
280
- return found;
281
- }
282
- }
283
- return undefined;
284
- }
285
- /**
286
- * Get all requirements from multiple files as a flat list.
287
- */
288
- export function getAllRequirements(requirements) {
289
- const all = [];
290
- for (const req of requirements) {
291
- all.push(...flattenRequirementTree(req));
292
- }
293
- return all;
294
- }
295
67
  //# sourceMappingURL=parser.js.map
@@ -16,7 +16,9 @@ export function parseRequirementPath(path) {
16
16
  * E.g., "given#2" → { label: "given", index: 2 }
17
17
  */
18
18
  export function parsePathSegment(segment) {
19
- const match = segment.match(/^(\w+)(?:#(\d+))?$/);
19
+ // HARNESS-RESOLVE-2.0: multi-word labels are addressed by their kebab-case
20
+ // form (e.g. "Edge Case" → "edge-case"), so segments may contain hyphens.
21
+ const match = segment.match(/^([\w-]+)(?:#(\d+))?$/);
20
22
  if (!match) {
21
23
  throw new Error(`Invalid path segment: ${segment}`);
22
24
  }
@@ -47,10 +49,18 @@ export function findChildrenByLabel(node, label) {
47
49
  * Returns the child node and its numeric index.
48
50
  */
49
51
  export function resolvePathSegment(node, segment) {
50
- // Check if segment is numeric (direct index)
52
+ // Check if segment is numeric (written position)
53
+ // HARNESS-RESOLVE-1: a numeric segment means the position written in the
54
+ // file, not the array index. Node ids preserve written positions (which may
55
+ // have gaps), so match the child whose id's last position segment equals
56
+ // the requested position. When no child was written at that position,
57
+ // resolution fails rather than binding to a neighboring criterion.
51
58
  if (/^\d+$/.test(segment)) {
52
- const index = parseInt(segment, 10);
53
- if (index < node.children.length) {
59
+ const index = node.children.findIndex((child) => {
60
+ const lastDot = child.id.lastIndexOf(".");
61
+ return child.id.slice(lastDot + 1) === segment;
62
+ });
63
+ if (index !== -1) {
54
64
  return {
55
65
  child: node.children[index],
56
66
  index,
@@ -158,13 +168,13 @@ export function checkPathAmbiguity(requirements, path) {
158
168
  // Check each segment for ambiguity
159
169
  for (let i = 1; i < segments.length; i++) {
160
170
  const segment = segments[i];
161
- // Numeric segments are never ambiguous
171
+ // Numeric segments are never ambiguous (resolved by written position)
162
172
  if (/^\d+$/.test(segment)) {
163
- const index = parseInt(segment, 10);
164
- if (index >= currentNode.children.length) {
173
+ const resolved = resolvePathSegment(currentNode, segment);
174
+ if (!resolved) {
165
175
  return 0; // Invalid path
166
176
  }
167
- currentNode = currentNode.children[index];
177
+ currentNode = resolved.child;
168
178
  continue;
169
179
  }
170
180
  // Check for label disambiguation (#N)
package/dist/utils/env.js CHANGED
@@ -23,7 +23,7 @@ export function loadEnvFile(cwd = process.cwd(), force = false) {
23
23
  const match = line.match(/^([^=]+)=(.*)$/);
24
24
  if (match) {
25
25
  const key = match[1].trim();
26
- const value = match[2].trim();
26
+ const value = stripSurroundingQuotes(match[2].trim());
27
27
  // Set if force=true, or if not already in process.env
28
28
  if (force || !process.env[key]) {
29
29
  process.env[key] = value;
@@ -36,4 +36,20 @@ export function loadEnvFile(cwd = process.cwd(), force = false) {
36
36
  // The commands will error appropriately if env vars are missing
37
37
  }
38
38
  }
39
+ /**
40
+ * Strip a single matching pair of surrounding single or double quotes from a
41
+ * value, matching dotenv behavior. Credentials written in standard
42
+ * dotenv/Next.js style (KEY="value") should not carry the quote characters
43
+ * into process.env. Mismatched or interior quotes are left untouched.
44
+ */
45
+ function stripSurroundingQuotes(value) {
46
+ if (value.length >= 2) {
47
+ const first = value[0];
48
+ const last = value[value.length - 1];
49
+ if ((first === '"' || first === "'") && first === last) {
50
+ return value.slice(1, -1);
51
+ }
52
+ }
53
+ return value;
54
+ }
39
55
  //# sourceMappingURL=env.js.map
@@ -32,6 +32,14 @@ export async function executeOAuthFlow() {
32
32
  port: 3010,
33
33
  timeoutMs: 120000,
34
34
  });
35
+ // Attach a no-op rejection handler immediately. startCallbackServer can
36
+ // reject synchronously-adjacent to creation (e.g. listen EADDRINUSE when the
37
+ // callback port is busy), but the real handler isn't awaited until after the
38
+ // browser launch below. Without this, an early rejection has no handler and
39
+ // Node escalates it to a fatal unhandledRejection, bypassing wrapCommand's
40
+ // clean CLI error formatting. The awaited `callbackPromise` below still sees
41
+ // the rejection and surfaces it as a normal thrown error.
42
+ callbackPromise.catch(() => { });
35
43
  // Build authorization URL
36
44
  const authUrl = buildAuthorizationUrl({
37
45
  clientId: WORKOS_CLIENT_ID,
@@ -54,6 +54,10 @@ export declare function readProjectSettings(projectRoot: string): ProjectSetting
54
54
  /**
55
55
  * Write project settings to .requirements/project-settings.json
56
56
  * Creates .requirements/ directory if it doesn't exist
57
+ *
58
+ * SETTINGS-2.3/2.4: fields being written replace the stored ones, but any
59
+ * other existing settings (defaultURL, browserTest, ...) survive — re-linking
60
+ * with fresh credentials must not erase the rest of the file.
57
61
  */
58
62
  export declare function writeProjectSettings(projectRoot: string, settings: ProjectSettings): void;
59
63
  /**
@@ -106,6 +106,10 @@ export function readProjectSettings(projectRoot) {
106
106
  /**
107
107
  * Write project settings to .requirements/project-settings.json
108
108
  * Creates .requirements/ directory if it doesn't exist
109
+ *
110
+ * SETTINGS-2.3/2.4: fields being written replace the stored ones, but any
111
+ * other existing settings (defaultURL, browserTest, ...) survive — re-linking
112
+ * with fresh credentials must not erase the rest of the file.
109
113
  */
110
114
  export function writeProjectSettings(projectRoot, settings) {
111
115
  const requirementsDir = path.join(projectRoot, REQUIREMENTS_DIR);
@@ -114,7 +118,16 @@ export function writeProjectSettings(projectRoot, settings) {
114
118
  if (!fs.existsSync(requirementsDir)) {
115
119
  fs.mkdirSync(requirementsDir, { recursive: true });
116
120
  }
117
- const content = `${JSON.stringify(settings, null, 2)}\n`;
121
+ let existing;
122
+ try {
123
+ existing = readProjectSettings(projectRoot);
124
+ }
125
+ catch {
126
+ // Unreadable or invalid existing file: nothing preservable, write fresh
127
+ existing = undefined;
128
+ }
129
+ const merged = existing ? { ...existing, ...settings } : settings;
130
+ const content = `${JSON.stringify(merged, null, 2)}\n`;
118
131
  fs.writeFileSync(settingsPath, content, "utf-8");
119
132
  }
120
133
  /**