@popoverai/dotrequirements 0.26.0 → 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.
- package/dist/codebase-to-spec/area-name.d.ts +13 -0
- package/dist/codebase-to-spec/area-name.js +18 -0
- package/dist/codebase-to-spec/cache.d.ts +31 -0
- package/dist/codebase-to-spec/cache.js +29 -1
- package/dist/codebase-to-spec/compose.d.ts +7 -4
- package/dist/codebase-to-spec/compose.js +10 -21
- package/dist/codebase-to-spec/dispatch.js +1 -1
- package/dist/codebase-to-spec/present.js +11 -7
- package/dist/codebase-to-spec/prompts/planner-initial.d.ts +3 -2
- package/dist/codebase-to-spec/prompts/planner-initial.js +3 -2
- package/dist/codebase-to-spec/renumber.d.ts +52 -0
- package/dist/codebase-to-spec/renumber.js +105 -0
- package/dist/codebase-to-spec/schemas.d.ts +35 -240
- package/dist/codebase-to-spec/schemas.js +5 -173
- package/dist/codebase-to-spec/validate.js +3 -2
- package/dist/commands/acceptance-test.js +4 -2
- package/dist/commands/ai-setup.js +97 -59
- package/dist/commands/codebase-to-spec/dispatch-editor.js +1 -1
- package/dist/commands/codebase-to-spec/dispatch-spec.js +1 -1
- package/dist/commands/codebase-to-spec/index.js +7 -103
- package/dist/commands/codebase-to-spec/pack.js +8 -1
- package/dist/commands/codebase-to-spec/present-orchestrator.d.ts +3 -1
- package/dist/commands/codebase-to-spec/present-orchestrator.js +11 -4
- package/dist/commands/get.js +6 -2
- package/dist/commands/init.js +7 -5
- package/dist/commands/link-resolution.d.ts +3 -1
- package/dist/commands/link-resolution.js +4 -2
- package/dist/commands/pull.js +36 -3
- package/dist/commands/push.js +54 -16
- package/dist/commands/report.js +18 -3
- package/dist/commands/review-test.js +16 -8
- package/dist/commands/tests-for.js +13 -13
- package/dist/commands/validate.js +14 -14
- package/dist/harness/cache.d.ts +19 -3
- package/dist/harness/cache.js +38 -12
- package/dist/harness/finalize.js +33 -1
- package/dist/harness/index.js +16 -9
- package/dist/harness/requirementsLoader.js +12 -0
- package/dist/harness/tracking.d.ts +17 -2
- package/dist/harness/tracking.js +83 -9
- package/dist/mcp/handlers/authoring.js +13 -4
- package/dist/mcp/handlers/get.js +7 -3
- package/dist/mcp/handlers/push.js +59 -13
- package/dist/mcp/handlers/review.d.ts +1 -0
- package/dist/mcp/handlers/review.js +58 -15
- package/dist/mcp/handlers/test-mapping.js +47 -12
- package/dist/mcp/handlers/types.d.ts +14 -0
- package/dist/mcp/handlers/types.js +27 -0
- package/dist/mcp/index.js +4 -0
- package/dist/push/core.d.ts +50 -0
- package/dist/push/core.js +149 -11
- package/dist/push/index.d.ts +1 -1
- package/dist/push/index.js +1 -1
- package/dist/requirements/cloud-coverage.d.ts +12 -2
- package/dist/requirements/cloud-coverage.js +30 -3
- package/dist/requirements/grep.d.ts +7 -2
- package/dist/requirements/grep.js +75 -47
- package/dist/schema/builder.d.ts +1 -1
- package/dist/schema/builder.js +13 -0
- package/dist/schema/conversions.d.ts +7 -2
- package/dist/schema/conversions.js +13 -4
- package/dist/schema/parser-core.d.ts +28 -0
- package/dist/schema/parser-core.js +80 -9
- package/dist/schema/parser.d.ts +8 -26
- package/dist/schema/parser.js +23 -251
- package/dist/schema/resolver.js +18 -8
- package/dist/templates/skills/codebase-to-spec/SKILL.md +10 -4
- package/dist/utils/env.js +17 -1
- package/dist/utils/oauth-flow.js +8 -0
- package/dist/utils/project-settings.d.ts +4 -0
- package/dist/utils/project-settings.js +14 -1
- package/package.json +1 -1
- package/dist/codebase-to-spec/edit-loop.d.ts +0 -54
- package/dist/codebase-to-spec/edit-loop.js +0 -195
- package/dist/codebase-to-spec/editor.d.ts +0 -54
- package/dist/codebase-to-spec/editor.js +0 -74
- package/dist/codebase-to-spec/fan-out.d.ts +0 -63
- package/dist/codebase-to-spec/fan-out.js +0 -215
- package/dist/codebase-to-spec/outline-review-loop.d.ts +0 -51
- package/dist/codebase-to-spec/outline-review-loop.js +0 -187
- package/dist/codebase-to-spec/planner.d.ts +0 -41
- package/dist/codebase-to-spec/planner.js +0 -76
- package/dist/codebase-to-spec/prompts/outline-reviewer.d.ts +0 -12
- package/dist/codebase-to-spec/prompts/outline-reviewer.js +0 -89
- package/dist/codebase-to-spec/slice.d.ts +0 -49
- package/dist/codebase-to-spec/slice.js +0 -111
- package/dist/codebase-to-spec/specifier.d.ts +0 -60
- package/dist/codebase-to-spec/specifier.js +0 -85
- package/dist/codebase-to-spec/summary.d.ts +0 -51
- package/dist/codebase-to-spec/summary.js +0 -183
- package/dist/commands/codebase-to-spec/compose.d.ts +0 -14
- package/dist/commands/codebase-to-spec/compose.js +0 -57
- package/dist/commands/codebase-to-spec/edit-loop.d.ts +0 -16
- package/dist/commands/codebase-to-spec/edit-loop.js +0 -83
- package/dist/commands/codebase-to-spec/fan-out.d.ts +0 -19
- package/dist/commands/codebase-to-spec/fan-out.js +0 -77
- package/dist/commands/codebase-to-spec/plan-loop.d.ts +0 -26
- package/dist/commands/codebase-to-spec/plan-loop.js +0 -105
- package/dist/commands/codebase-to-spec/present.d.ts +0 -26
- package/dist/commands/codebase-to-spec/present.js +0 -97
- package/dist/commands/codebase-to-spec/run.d.ts +0 -20
- package/dist/commands/codebase-to-spec/run.js +0 -86
- package/dist/commands/codebase-to-spec/specify-area.d.ts +0 -18
- package/dist/commands/codebase-to-spec/specify-area.js +0 -82
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Convert between Convex flat representation and hierarchical RequirementNode trees.
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
18
|
-
if (!match)
|
|
23
|
+
if (!REQUIREMENT_KEY_PATTERN.test(key))
|
|
19
24
|
return null;
|
|
20
|
-
|
|
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.
|
|
@@ -95,30 +95,48 @@ export function parseRequirementBlock(key, blockContent) {
|
|
|
95
95
|
}
|
|
96
96
|
const criterion = parseCriterionLine(line);
|
|
97
97
|
if (!criterion) {
|
|
98
|
-
//
|
|
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 (/^[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/schema/parser.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/schema/parser.js
CHANGED
|
@@ -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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
package/dist/schema/resolver.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 =
|
|
53
|
-
|
|
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
|
|
164
|
-
if (
|
|
173
|
+
const resolved = resolvePathSegment(currentNode, segment);
|
|
174
|
+
if (!resolved) {
|
|
165
175
|
return 0; // Invalid path
|
|
166
176
|
}
|
|
167
|
-
currentNode =
|
|
177
|
+
currentNode = resolved.child;
|
|
168
178
|
continue;
|
|
169
179
|
}
|
|
170
180
|
// Check for label disambiguation (#N)
|