@popoverai/dotrequirements 0.26.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -69
- package/dist/cli.js +19 -7
- package/dist/codebase-to-spec/present.js +4 -5
- package/dist/codebase-to-spec/validate.js +3 -2
- package/dist/commands/acceptance-test.js +4 -2
- package/dist/commands/ai-setup.d.ts +8 -2
- package/dist/commands/ai-setup.js +154 -310
- package/dist/commands/get.js +6 -2
- package/dist/commands/init.js +8 -6
- package/dist/commands/link-resolution.d.ts +3 -1
- package/dist/commands/link-resolution.js +4 -2
- package/dist/commands/mcp.d.ts +8 -2
- package/dist/commands/mcp.js +17 -6
- 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.d.ts +5 -1
- package/dist/commands/review-test.js +117 -15
- package/dist/commands/style-check.d.ts +1 -0
- package/dist/commands/style-check.js +137 -13
- package/dist/commands/tests-for.js +13 -13
- package/dist/commands/validate.js +14 -14
- package/dist/convex.d.ts +1 -3
- package/dist/convex.js +3 -3
- 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/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-ai.d.ts +21 -8
- package/dist/requirements/cloud-ai.js +10 -8
- 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 +41 -0
- package/dist/schema/parser-core.js +113 -18
- 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/context-file-section.md +25 -22
- package/dist/utils/context-file.d.ts +7 -3
- package/dist/utils/context-file.js +10 -7
- package/dist/utils/env.js +17 -1
- package/dist/utils/oauth-flow.js +8 -0
- package/dist/utils/project-settings.d.ts +5 -0
- package/dist/utils/project-settings.js +36 -1
- package/package.json +3 -5
- package/dist/mcp/convexClient.d.ts +0 -19
- package/dist/mcp/convexClient.js +0 -24
- package/dist/mcp/handlers/authoring.d.ts +0 -41
- package/dist/mcp/handlers/authoring.js +0 -104
- package/dist/mcp/handlers/debug.d.ts +0 -16
- package/dist/mcp/handlers/debug.js +0 -37
- package/dist/mcp/handlers/get.d.ts +0 -24
- package/dist/mcp/handlers/get.js +0 -65
- package/dist/mcp/handlers/index.d.ts +0 -28
- package/dist/mcp/handlers/index.js +0 -19
- package/dist/mcp/handlers/list.d.ts +0 -7
- package/dist/mcp/handlers/list.js +0 -43
- package/dist/mcp/handlers/push.d.ts +0 -26
- package/dist/mcp/handlers/push.js +0 -186
- package/dist/mcp/handlers/report.d.ts +0 -16
- package/dist/mcp/handlers/report.js +0 -134
- package/dist/mcp/handlers/review.d.ts +0 -51
- package/dist/mcp/handlers/review.js +0 -200
- package/dist/mcp/handlers/search.d.ts +0 -30
- package/dist/mcp/handlers/search.js +0 -58
- package/dist/mcp/handlers/test-mapping.d.ts +0 -39
- package/dist/mcp/handlers/test-mapping.js +0 -133
- package/dist/mcp/handlers/types.d.ts +0 -75
- package/dist/mcp/handlers/types.js +0 -25
- package/dist/mcp/index.d.ts +0 -45
- package/dist/mcp/index.js +0 -634
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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({
|
|
361
|
+
errors.push({
|
|
362
|
+
fileName,
|
|
363
|
+
filePath: file.filePath,
|
|
364
|
+
error: errorDisplayMessage(err),
|
|
365
|
+
});
|
|
235
366
|
}
|
|
236
367
|
}
|
|
237
|
-
return {
|
|
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.
|
package/dist/push/index.d.ts
CHANGED
|
@@ -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
|
package/dist/push/index.js
CHANGED
|
@@ -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
|
|
@@ -22,11 +22,23 @@ export interface StyleCheckParams {
|
|
|
22
22
|
model?: string;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* Feedback plus the optional LIMITS-5.2 quota warning ("You've used X% of
|
|
26
|
+
* your AI quota this billing cycle."). The warning is present when the
|
|
27
|
+
* team is at 80–99% of its AI allowance; callers print it alongside the
|
|
28
|
+
* feedback so the eventual quota wall never arrives unannounced. Older
|
|
29
|
+
* servers simply omit the field.
|
|
28
30
|
*/
|
|
29
|
-
export
|
|
31
|
+
export interface CloudAIFeedback {
|
|
32
|
+
feedback: string;
|
|
33
|
+
quotaWarning: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* POST to `/api/style-check`. Returns the feedback (and any quota warning)
|
|
37
|
+
* on success; throws an Error with the underlying failure reason on a
|
|
38
|
+
* non-OK response or a network error — including the LIMITS-5.1
|
|
39
|
+
* quota-exceeded error, whose message carries the billing-page upgrade link.
|
|
40
|
+
*/
|
|
41
|
+
export declare function fetchStyleCheckFeedback(params: StyleCheckParams): Promise<CloudAIFeedback>;
|
|
30
42
|
export interface ReviewTestParams {
|
|
31
43
|
apiBaseUrl: string;
|
|
32
44
|
projectId: string;
|
|
@@ -39,11 +51,12 @@ export interface ReviewTestParams {
|
|
|
39
51
|
}>;
|
|
40
52
|
}
|
|
41
53
|
/**
|
|
42
|
-
* POST to `/api/review-test`. Returns the feedback
|
|
43
|
-
* throws an Error with the underlying failure reason on a
|
|
44
|
-
* response or a network error.
|
|
54
|
+
* POST to `/api/review-test`. Returns the feedback (and any quota warning)
|
|
55
|
+
* on success; throws an Error with the underlying failure reason on a
|
|
56
|
+
* non-OK response or a network error — including the LIMITS-5.1
|
|
57
|
+
* quota-exceeded error, whose message carries the billing-page upgrade link.
|
|
45
58
|
*/
|
|
46
|
-
export declare function fetchReviewTestFeedback(params: ReviewTestParams): Promise<
|
|
59
|
+
export declare function fetchReviewTestFeedback(params: ReviewTestParams): Promise<CloudAIFeedback>;
|
|
47
60
|
export interface ProjectContext {
|
|
48
61
|
projectContext: string | null;
|
|
49
62
|
requirementsStyleContext: string | null;
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
*/
|
|
15
15
|
export const DEFAULT_API_BASE_URL = "https://app.dotrequirements.io";
|
|
16
16
|
/**
|
|
17
|
-
* POST to `/api/style-check`. Returns the feedback
|
|
18
|
-
* throws an Error with the underlying failure reason on a
|
|
19
|
-
* response or a network error.
|
|
17
|
+
* POST to `/api/style-check`. Returns the feedback (and any quota warning)
|
|
18
|
+
* on success; throws an Error with the underlying failure reason on a
|
|
19
|
+
* non-OK response or a network error — including the LIMITS-5.1
|
|
20
|
+
* quota-exceeded error, whose message carries the billing-page upgrade link.
|
|
20
21
|
*/
|
|
21
22
|
export async function fetchStyleCheckFeedback(params) {
|
|
22
23
|
const response = await fetch(`${params.apiBaseUrl}/api/style-check`, {
|
|
@@ -35,12 +36,13 @@ export async function fetchStyleCheckFeedback(params) {
|
|
|
35
36
|
throw new Error(errorData.error || response.statusText);
|
|
36
37
|
}
|
|
37
38
|
const data = (await response.json());
|
|
38
|
-
return data.feedback;
|
|
39
|
+
return { feedback: data.feedback, quotaWarning: data.quotaWarning ?? null };
|
|
39
40
|
}
|
|
40
41
|
/**
|
|
41
|
-
* POST to `/api/review-test`. Returns the feedback
|
|
42
|
-
* throws an Error with the underlying failure reason on a
|
|
43
|
-
* response or a network error.
|
|
42
|
+
* POST to `/api/review-test`. Returns the feedback (and any quota warning)
|
|
43
|
+
* on success; throws an Error with the underlying failure reason on a
|
|
44
|
+
* non-OK response or a network error — including the LIMITS-5.1
|
|
45
|
+
* quota-exceeded error, whose message carries the billing-page upgrade link.
|
|
44
46
|
*/
|
|
45
47
|
export async function fetchReviewTestFeedback(params) {
|
|
46
48
|
const response = await fetch(`${params.apiBaseUrl}/api/review-test`, {
|
|
@@ -58,7 +60,7 @@ export async function fetchReviewTestFeedback(params) {
|
|
|
58
60
|
throw new Error(errorData.error || response.statusText);
|
|
59
61
|
}
|
|
60
62
|
const data = (await response.json());
|
|
61
|
-
return data.feedback;
|
|
63
|
+
return { feedback: data.feedback, quotaWarning: data.quotaWarning ?? null };
|
|
62
64
|
}
|
|
63
65
|
/**
|
|
64
66
|
* Query Convex for project-level AI context (project description and
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"
|
|
163
|
-
"jsx",
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
package/dist/schema/builder.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build Markdown requirements files from structured data.
|
|
3
3
|
*/
|
|
4
|
-
import type
|
|
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.
|
package/dist/schema/builder.js
CHANGED
|
@@ -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`;
|