@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
package/dist/commands/pull.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getConvexUrl } from "../config.js";
|
|
|
5
5
|
import { api } from "../convex.js";
|
|
6
6
|
import { findRequirementsFiles } from "../requirements/index.js";
|
|
7
7
|
import { buildRequirementsFile } from "../schema/index.js";
|
|
8
|
+
import { extractFrontmatterBlock } from "../schema/parser-core.js";
|
|
8
9
|
import { brand } from "../utils/brand.js";
|
|
9
10
|
import { getProjectCredentials } from "../utils/project-settings.js";
|
|
10
11
|
export async function pullCommand(options) {
|
|
@@ -94,12 +95,33 @@ export async function pullCommand(options) {
|
|
|
94
95
|
}
|
|
95
96
|
// Build index of existing files by document ID (search entire workspace)
|
|
96
97
|
const existingFilesByDocId = await buildDocumentIdIndex(process.cwd());
|
|
98
|
+
// SYNC-WEB-CREATE-2.0: track paths claimed during this pull so colliding
|
|
99
|
+
// titles don't silently overwrite each other within one operation
|
|
100
|
+
const usedPaths = new Set(existingFilesByDocId.values());
|
|
97
101
|
// Write each document as a Markdown file
|
|
98
102
|
for (const doc of documents) {
|
|
99
103
|
// Check if an existing file has this document ID
|
|
100
104
|
const existingFilePath = existingFilesByDocId.get(doc.documentId);
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
let filePath;
|
|
106
|
+
if (existingFilePath) {
|
|
107
|
+
filePath = existingFilePath;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// SYNC-WEB-CREATE-2.1: an all-symbols title sanitizes to nothing —
|
|
111
|
+
// fall back to the document ID rather than a hidden ".requirements.md"
|
|
112
|
+
const baseName = sanitizeFileName(doc.title) || doc.documentId;
|
|
113
|
+
let candidate = path.join(requirementsDir, `${baseName}.requirements.md`);
|
|
114
|
+
// SYNC-WEB-CREATE-2.0: disambiguate later collisions with a numeric
|
|
115
|
+
// suffix; also avoid clobbering an on-disk file that belongs to a
|
|
116
|
+
// different (or no) document
|
|
117
|
+
let suffix = 2;
|
|
118
|
+
while (usedPaths.has(candidate) || fs.existsSync(candidate)) {
|
|
119
|
+
candidate = path.join(requirementsDir, `${baseName}-${suffix}.requirements.md`);
|
|
120
|
+
suffix++;
|
|
121
|
+
}
|
|
122
|
+
filePath = candidate;
|
|
123
|
+
}
|
|
124
|
+
usedPaths.add(filePath);
|
|
103
125
|
const fileName = path.basename(filePath);
|
|
104
126
|
// IMPORT-1: carry the CTS run marker forward from the existing local
|
|
105
127
|
// file — pull rebuilds frontmatter from cloud data, and silently dropping
|
|
@@ -168,10 +190,21 @@ async function buildDocumentIdIndex(workspaceRoot) {
|
|
|
168
190
|
/**
|
|
169
191
|
* Extract document.id from a requirements file's frontmatter.
|
|
170
192
|
* Returns undefined if the file can't be read or doesn't have a document ID.
|
|
193
|
+
* SYNC-DISCOVERY-3: only the leading YAML frontmatter block is consulted —
|
|
194
|
+
* a document id quoted in body prose or a fenced example must never mark
|
|
195
|
+
* the file as owning that document.
|
|
171
196
|
*/
|
|
172
197
|
function extractDocumentIdFromFile(filePath) {
|
|
173
198
|
try {
|
|
174
|
-
const
|
|
199
|
+
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
200
|
+
// Isolate the leading ---...--- frontmatter block; no frontmatter means
|
|
201
|
+
// the file is unlinked to any cloud document (SYNC-DISCOVERY-3.2).
|
|
202
|
+
// Shared helper normalizes CRLF (SYNC-FORMAT-1) so a Windows-saved file
|
|
203
|
+
// keeps matching its document and is updated in place (SYNC-DISCOVERY-2.1).
|
|
204
|
+
const content = extractFrontmatterBlock(fileContent);
|
|
205
|
+
if (content === undefined) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
175
208
|
// Match document.id in YAML frontmatter - handles both inline and nested formats
|
|
176
209
|
// Inline: document: { id: "abc123", ... }
|
|
177
210
|
// Nested (id: can appear at any position within the indented document block):
|
package/dist/commands/push.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import { getConvexUrl } from "../config.js";
|
|
5
|
-
import { dryRunPush, executePush,
|
|
5
|
+
import { dryRunPush, executePush, parseFilesForPushIndividually, } from "../push/index.js";
|
|
6
6
|
import { findRequirementsFiles } from "../requirements/index.js";
|
|
7
7
|
import { brand } from "../utils/brand.js";
|
|
8
8
|
import { getProjectCredentials } from "../utils/project-settings.js";
|
|
@@ -30,20 +30,30 @@ export async function pushCommand(file, options) {
|
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
// Parse
|
|
33
|
+
// Parse local files. SYNC-FAIL-4: one invalid file is skipped while the
|
|
34
|
+
// rest of the batch still pushes — same helper as the MCP push handler.
|
|
34
35
|
console.log("Parsing local requirements...");
|
|
35
|
-
const { parsedFiles, totalRequirements } =
|
|
36
|
+
const { parsedFiles, totalRequirements, parseFailures } = parseFilesForPushIndividually(filesToPush);
|
|
36
37
|
// Log file parsing progress
|
|
37
38
|
for (const file of parsedFiles) {
|
|
38
39
|
const fileName = path.basename(file.filePath);
|
|
39
40
|
const doc = file.metadata.document;
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
console.log(` Reading ${fileName}...`);
|
|
42
|
+
// DOC-HEADER-11.4: parseFilesForPush mutates the metadata when it infers
|
|
43
|
+
// the prefix, so the flag it returns is the only record of the inference
|
|
44
|
+
if (doc?.defaultPrefix && file.inferredDefaultPrefix) {
|
|
42
45
|
console.log(` Inferred defaultPrefix "${doc.defaultPrefix}" from first requirement`);
|
|
43
46
|
}
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
}
|
|
48
|
+
// SYNC-FAIL-4.1: every file in the push failed to parse — report each
|
|
49
|
+
// skipped file, then fail honestly instead of dry-running nothing
|
|
50
|
+
if (parsedFiles.length === 0) {
|
|
51
|
+
for (const failure of parseFailures) {
|
|
52
|
+
console.log(` ✗ Skipped ${path.basename(failure.filePath)}: ${failure.error}`);
|
|
46
53
|
}
|
|
54
|
+
console.log("\nNo valid documents to push.");
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
47
57
|
}
|
|
48
58
|
console.log(`\nFound ${totalRequirements} requirement(s) in ${parsedFiles.length} document(s).\n`);
|
|
49
59
|
// Build credentials
|
|
@@ -56,13 +66,19 @@ export async function pushCommand(file, options) {
|
|
|
56
66
|
console.log("Validating documents...");
|
|
57
67
|
const dryRunResult = await dryRunPush(parsedFiles, credentials);
|
|
58
68
|
// Display unified summary
|
|
59
|
-
displayDryRunSummary(dryRunResult);
|
|
69
|
+
displayDryRunSummary(dryRunResult, parseFailures);
|
|
60
70
|
// Check if there's anything to push
|
|
61
71
|
const pushableCount = dryRunResult.updates.length +
|
|
62
72
|
dryRunResult.creates.length +
|
|
63
73
|
dryRunResult.notFound.length;
|
|
64
74
|
if (pushableCount === 0) {
|
|
65
75
|
console.log("No valid documents to push.");
|
|
76
|
+
// SYNC-FAIL-4.1: every file in the push was invalid — whether it failed
|
|
77
|
+
// local parse or dry-run validation — so the command must fail honestly,
|
|
78
|
+
// matching the MCP handler's merged rule
|
|
79
|
+
if (parseFailures.length > 0 || dryRunResult.invalid.length > 0) {
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
|
66
82
|
return;
|
|
67
83
|
}
|
|
68
84
|
// Single confirmation prompt
|
|
@@ -88,8 +104,9 @@ export async function pushCommand(file, options) {
|
|
|
88
104
|
]) {
|
|
89
105
|
const fileName = path.basename(file.filePath);
|
|
90
106
|
const isCreate = dryResult.action === "create" || dryResult.action === "not_found";
|
|
91
|
-
// Check if this file had an error
|
|
92
|
-
|
|
107
|
+
// Check if this file had an error — match by full path, since two pushed
|
|
108
|
+
// files can share a basename
|
|
109
|
+
const error = result.errors.find((e) => e.filePath === file.filePath);
|
|
93
110
|
if (error) {
|
|
94
111
|
console.log(` ✗ Failed: ${fileName} - ${error.error}`);
|
|
95
112
|
}
|
|
@@ -100,6 +117,16 @@ export async function pushCommand(file, options) {
|
|
|
100
117
|
console.log(` ✓ Updated: ${fileName}`);
|
|
101
118
|
}
|
|
102
119
|
}
|
|
120
|
+
// SYNC-FAIL-2.1: the cloud save succeeded but the local file couldn't be
|
|
121
|
+
// updated — name the file, say the cloud is fine, and give the recovery
|
|
122
|
+
// step that re-links the file instead of minting a duplicate on retry
|
|
123
|
+
if (result.writeBackWarnings?.length > 0) {
|
|
124
|
+
console.log();
|
|
125
|
+
for (const warning of result.writeBackWarnings) {
|
|
126
|
+
console.log(`⚠ ${warning.fileName}: saved to the cloud, but the local file could not be updated (${warning.error}). ` +
|
|
127
|
+
`To avoid creating a duplicate, add "id: ${warning.documentId}" under "document:" in the frontmatter of ${warning.filePath}, then push again.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
103
130
|
// IMPORT-3: marker failures are loud but never fatal
|
|
104
131
|
if (result.importWarnings.length > 0) {
|
|
105
132
|
console.log();
|
|
@@ -148,7 +175,7 @@ export async function pushCommand(file, options) {
|
|
|
148
175
|
/**
|
|
149
176
|
* Display the dry run summary.
|
|
150
177
|
*/
|
|
151
|
-
function displayDryRunSummary(dryRunResult) {
|
|
178
|
+
function displayDryRunSummary(dryRunResult, parseFailures = []) {
|
|
152
179
|
const { updates, creates, notFound, invalid, conflicts } = dryRunResult;
|
|
153
180
|
console.log("\n=== Push Summary ===\n");
|
|
154
181
|
if (updates.length > 0) {
|
|
@@ -179,11 +206,22 @@ function displayDryRunSummary(dryRunResult) {
|
|
|
179
206
|
}
|
|
180
207
|
console.log();
|
|
181
208
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
// SYNC-FAIL-4.0: local parse failures surface alongside dry-run invalids,
|
|
210
|
+
// each named by file, while the rest of the batch proceeds
|
|
211
|
+
const skipped = [
|
|
212
|
+
...parseFailures.map((failure) => ({
|
|
213
|
+
fileName: path.basename(failure.filePath),
|
|
214
|
+
error: failure.error,
|
|
215
|
+
})),
|
|
216
|
+
...invalid.map(({ file, result }) => ({
|
|
217
|
+
fileName: path.basename(file.filePath),
|
|
218
|
+
error: result.error,
|
|
219
|
+
})),
|
|
220
|
+
];
|
|
221
|
+
if (skipped.length > 0) {
|
|
222
|
+
console.log(`Skipped - invalid files (${skipped.length}):`);
|
|
223
|
+
for (const { fileName, error } of skipped) {
|
|
224
|
+
console.log(` ✗ ${fileName}: ${error}`);
|
|
187
225
|
}
|
|
188
226
|
console.log();
|
|
189
227
|
}
|
package/dist/commands/report.js
CHANGED
|
@@ -36,8 +36,16 @@ export async function reportCommand(options) {
|
|
|
36
36
|
`(${error instanceof Error ? error.message : String(error)})`);
|
|
37
37
|
}
|
|
38
38
|
if (options.requirement) {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// REPORT-CLOUD-1.7: --branch/--since apply to the requirement-scoped
|
|
40
|
+
// output too, not just the project-wide report
|
|
41
|
+
const record = await getRequirementCoverage(options.requirement, projectId, projectSecret, CONVEX_URL, {
|
|
42
|
+
branch: options.branch,
|
|
43
|
+
sinceTimestamp: options.since,
|
|
44
|
+
});
|
|
45
|
+
console.log(printCloudRequirement(record, format, {
|
|
46
|
+
branch: options.branch,
|
|
47
|
+
since: options.since,
|
|
48
|
+
}));
|
|
41
49
|
return;
|
|
42
50
|
}
|
|
43
51
|
const record = await getProjectCoverage(projectId, projectSecret, CONVEX_URL, {
|
|
@@ -100,11 +108,18 @@ function formatLocalMarkdown(report) {
|
|
|
100
108
|
}
|
|
101
109
|
return out;
|
|
102
110
|
}
|
|
103
|
-
function printCloudRequirement(record, format) {
|
|
111
|
+
function printCloudRequirement(record, format, filters) {
|
|
104
112
|
if (format === "json") {
|
|
105
113
|
return JSON.stringify({ source: "cloud", requirement: record }, null, 2);
|
|
106
114
|
}
|
|
107
115
|
if (!record.lastTestedAt) {
|
|
116
|
+
// REPORT-CLOUD-1.7: with filters in play, an empty record means nothing
|
|
117
|
+
// matched them — say so rather than implying the requirement was never
|
|
118
|
+
// tested at all
|
|
119
|
+
const filterNote = formatCloudFilters(filters);
|
|
120
|
+
if (filterNote) {
|
|
121
|
+
return `\n${record.requirementKey}: no coverage records match the requested filters (${filterNote.replace(/^Filters: /, "")}).\n`;
|
|
122
|
+
}
|
|
108
123
|
return `\n${record.requirementKey}: never tested on the cloud-persisted record.\n`;
|
|
109
124
|
}
|
|
110
125
|
const lastTested = new Date(record.lastTestedAt).toISOString();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { DEFAULT_API_BASE_URL, fetchReviewTestFeedback, } from "../requirements/cloud-ai.js";
|
|
4
|
+
import { findRequirementsInFile } from "../requirements/grep.js";
|
|
4
5
|
import { formatRequirementTree, getRequirementTree, loadAllRequirements, } from "../requirements/index.js";
|
|
5
6
|
import { getProjectCredentials } from "../utils/project-settings.js";
|
|
6
7
|
export async function reviewTestCommand(testFilePath) {
|
|
@@ -15,17 +16,24 @@ export async function reviewTestCommand(testFilePath) {
|
|
|
15
16
|
throw new Error(`File must be a test file: *.{test,spec}.{js,jsx,ts,tsx} — got ${testFilePath}`);
|
|
16
17
|
}
|
|
17
18
|
const testFileContents = readFileSync(fullPath, "utf-8");
|
|
18
|
-
// CLI-REVIEW-1.0: collect every requirement(...) call site
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
// CLI-REVIEW-1.0: collect every requirement(...) call site via the shared
|
|
20
|
+
// AST-based extractor (as the MCP review handler does post-#43) so that
|
|
21
|
+
// multi-arg calls (`requirement("A", "B")`) and options-bearing calls
|
|
22
|
+
// (`requirement("A", { ... })`) are captured — the old single-string regex
|
|
23
|
+
// matched neither.
|
|
24
|
+
const references = await findRequirementsInFile(fullPath);
|
|
25
|
+
const requirementIds = new Set(references.map((ref) => ref.requirementId));
|
|
26
|
+
// CLI-REVIEW-1.1: resolve references against the workspace and bundle trees.
|
|
27
|
+
// A reference may be a bare root (`AUTH-1`), an index child (`AUTH-1.0`), or
|
|
28
|
+
// a label path (`AUTH-1.given`). Normalize each reference to its root id
|
|
29
|
+
// (the substring before the first dot) before matching so label-path refs
|
|
30
|
+
// resolve instead of being dropped.
|
|
25
31
|
const { flattened } = await loadAllRequirements(workspaceRoot);
|
|
26
32
|
const rootToTestedIds = new Map();
|
|
27
33
|
for (const reqId of requirementIds) {
|
|
28
|
-
const
|
|
34
|
+
const dotIndex = reqId.indexOf(".");
|
|
35
|
+
const refRootId = dotIndex > 0 ? reqId.substring(0, dotIndex) : reqId;
|
|
36
|
+
const req = flattened.find((r) => r.rootId === refRootId);
|
|
29
37
|
if (req) {
|
|
30
38
|
if (!rootToTestedIds.has(req.rootId)) {
|
|
31
39
|
rootToTestedIds.set(req.rootId, new Set());
|
|
@@ -20,27 +20,27 @@ export async function testsForCommand(requirementsFile) {
|
|
|
20
20
|
}
|
|
21
21
|
const rootIds = new Set(fileRequirements.map((req) => req.rootId));
|
|
22
22
|
const allTestRefs = await findAllTestReferences(workspaceRoot);
|
|
23
|
-
// Build coverage map
|
|
23
|
+
// Build coverage map keyed by ROOT id. A reference may be a bare root
|
|
24
|
+
// (`AUTH-1`), an index child (`AUTH-1.0`), or a label path (`AUTH-1.given`) —
|
|
25
|
+
// and every string arg of a multi-arg `requirement("A", "B")` call is its own
|
|
26
|
+
// ref. Normalizing each ref to its root id (the first dotted segment) lets a
|
|
27
|
+
// covered descendant count toward its root requirement.
|
|
24
28
|
const coverageMap = new Map();
|
|
25
29
|
for (const ref of allTestRefs) {
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
const dotIndex = ref.requirementId.indexOf(".");
|
|
31
|
+
const refRootId = dotIndex > 0
|
|
32
|
+
? ref.requirementId.substring(0, dotIndex)
|
|
33
|
+
: ref.requirementId;
|
|
34
|
+
if (!coverageMap.has(refRootId)) {
|
|
35
|
+
coverageMap.set(refRootId, []);
|
|
28
36
|
}
|
|
29
|
-
coverageMap.get(
|
|
37
|
+
coverageMap.get(refRootId).push(ref);
|
|
30
38
|
}
|
|
31
39
|
// CLI-FIND-MAP-2.0: group by coverage status
|
|
32
40
|
const covered = [];
|
|
33
41
|
const notCovered = [];
|
|
34
42
|
for (const rootId of rootIds) {
|
|
35
|
-
const
|
|
36
|
-
const allIds = reqAndChildren.map((r) => r.id);
|
|
37
|
-
const testsForThisReq = [];
|
|
38
|
-
for (const id of allIds) {
|
|
39
|
-
const refs = coverageMap.get(id);
|
|
40
|
-
if (refs) {
|
|
41
|
-
testsForThisReq.push(...refs);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
43
|
+
const testsForThisReq = coverageMap.get(rootId) ?? [];
|
|
44
44
|
if (testsForThisReq.length > 0) {
|
|
45
45
|
covered.push({ id: rootId, tests: testsForThisReq });
|
|
46
46
|
}
|
|
@@ -1,24 +1,18 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
1
|
import * as path from "node:path";
|
|
2
|
+
import { findRequirementsFiles } from "../requirements/index.js";
|
|
3
3
|
import { parseRequirementsFromFile, } from "../schema/index.js";
|
|
4
4
|
export async function validateCommand(options) {
|
|
5
5
|
console.log("Parsing and validating requirements...");
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
process.exit(1);
|
|
11
|
-
}
|
|
12
|
-
// Get files to test
|
|
6
|
+
// Validation parses the file in front of it — it never requires a
|
|
7
|
+
// .requirements/ directory (VALIDATE-1.0/.1). The no-flag path uses the
|
|
8
|
+
// shared recursive discovery so colocated files at any level and files in
|
|
9
|
+
// subdirectories of .requirements/ are all found (VALIDATE-2.0).
|
|
13
10
|
let files;
|
|
14
11
|
if (options.file) {
|
|
15
12
|
files = [options.file];
|
|
16
13
|
}
|
|
17
14
|
else {
|
|
18
|
-
files =
|
|
19
|
-
.readdirSync(requirementsDir)
|
|
20
|
-
.filter((f) => f.endsWith(".requirements.md"))
|
|
21
|
-
.map((f) => path.join(requirementsDir, f));
|
|
15
|
+
files = await findRequirementsFiles(process.cwd());
|
|
22
16
|
}
|
|
23
17
|
if (files.length === 0) {
|
|
24
18
|
console.log("No Markdown requirements files found to test.");
|
|
@@ -40,8 +34,14 @@ export async function validateCommand(options) {
|
|
|
40
34
|
}
|
|
41
35
|
totalRequirements += count;
|
|
42
36
|
console.log(` ✓ Valid structure (${count} requirement(s))`);
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
// Version/sync metadata is optional (hand-authored files have neither);
|
|
38
|
+
// only report fields that are present (VALIDATE-3.0/.1)
|
|
39
|
+
if (metadata.version !== undefined) {
|
|
40
|
+
console.log(` ✓ Version: ${metadata.version}`);
|
|
41
|
+
}
|
|
42
|
+
if (metadata.pulledAt !== undefined) {
|
|
43
|
+
console.log(` ✓ Last sync: ${metadata.pulledAt}`);
|
|
44
|
+
}
|
|
45
45
|
passCount++;
|
|
46
46
|
}
|
|
47
47
|
catch (error) {
|
package/dist/harness/cache.d.ts
CHANGED
|
@@ -36,6 +36,18 @@ export interface LookupCache {
|
|
|
36
36
|
isAlias?: boolean;
|
|
37
37
|
}>;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Staleness metadata for the lookup cache, stored in a sibling file.
|
|
41
|
+
*
|
|
42
|
+
* lookup.json's shape is a cross-language contract: the shipped Python/Java
|
|
43
|
+
* helpers parse it with minimal JSON parsers (MLANG-6/7), so new fields —
|
|
44
|
+
* especially arrays — must not be added to it. Deletion detection
|
|
45
|
+
* (HARNESS-REQUIREMENT-4) needs the source-file set, so that lives here.
|
|
46
|
+
*/
|
|
47
|
+
export interface LookupCacheMeta {
|
|
48
|
+
/** Project-relative paths of the source files the cache was built from. */
|
|
49
|
+
sourceFiles: string[];
|
|
50
|
+
}
|
|
39
51
|
/**
|
|
40
52
|
* Structure of the coverage.json cache (for cloud deduplication)
|
|
41
53
|
*/
|
|
@@ -73,10 +85,14 @@ export declare function getCacheDir(requirementsDir: string, create?: boolean):
|
|
|
73
85
|
*/
|
|
74
86
|
export declare function writeLookupCache(requirementsDir: string, requirements: RequirementNode[]): void;
|
|
75
87
|
/**
|
|
76
|
-
* Check if
|
|
77
|
-
*
|
|
88
|
+
* Check if the lookup cache is stale and should be invalidated.
|
|
89
|
+
*
|
|
90
|
+
* A cache is stale when any source file is newer than the cache file, or —
|
|
91
|
+
* when the cache's recorded source-file set is provided — when the current
|
|
92
|
+
* set of source files differs from the recorded one (HARNESS-REQUIREMENT-4:
|
|
93
|
+
* deletions don't touch surviving files' mtimes, so the set must be compared).
|
|
78
94
|
*/
|
|
79
|
-
export declare function isCacheStale(requirementsDir: string, cacheMtime: number): boolean;
|
|
95
|
+
export declare function isCacheStale(requirementsDir: string, cacheMtime: number, cachedSourceFiles?: string[]): boolean;
|
|
80
96
|
/**
|
|
81
97
|
* Read the lookup cache, returning null if not found, invalid, or stale.
|
|
82
98
|
* Cache is considered stale if any .requirements.md file is newer than the cache.
|
package/dist/harness/cache.js
CHANGED
|
@@ -12,6 +12,7 @@ import { findRequirementsFilesSync } from "../requirements/index.js";
|
|
|
12
12
|
// Cache directory structure
|
|
13
13
|
const CACHE_DIR = ".cache";
|
|
14
14
|
const LOOKUP_FILE = "lookup.json";
|
|
15
|
+
const LOOKUP_META_FILE = "lookup-meta.json";
|
|
15
16
|
const TRACKING_FILE = "tracking.jsonl";
|
|
16
17
|
const COVERAGE_FILE = "coverage.json";
|
|
17
18
|
// Test run ID file (outside cache, in .requirements/)
|
|
@@ -58,6 +59,10 @@ export function getCacheDir(requirementsDir, create = false) {
|
|
|
58
59
|
export function writeLookupCache(requirementsDir, requirements) {
|
|
59
60
|
const cacheDir = getCacheDir(requirementsDir, true);
|
|
60
61
|
const lookupPath = path.join(cacheDir, LOOKUP_FILE);
|
|
62
|
+
const projectRoot = path.dirname(requirementsDir);
|
|
63
|
+
const sourceFiles = findRequirementsFilesSync(projectRoot)
|
|
64
|
+
.map((file) => path.relative(projectRoot, file))
|
|
65
|
+
.sort();
|
|
61
66
|
const lookup = {
|
|
62
67
|
generatedAt: new Date().toISOString(),
|
|
63
68
|
requirements: {},
|
|
@@ -96,14 +101,30 @@ export function writeLookupCache(requirementsDir, requirements) {
|
|
|
96
101
|
flatten(req, null);
|
|
97
102
|
}
|
|
98
103
|
fs.writeFileSync(lookupPath, JSON.stringify(lookup, null, 2));
|
|
104
|
+
const meta = { sourceFiles };
|
|
105
|
+
fs.writeFileSync(path.join(cacheDir, LOOKUP_META_FILE), JSON.stringify(meta, null, 2));
|
|
99
106
|
}
|
|
100
107
|
/**
|
|
101
|
-
* Check if
|
|
102
|
-
*
|
|
108
|
+
* Check if the lookup cache is stale and should be invalidated.
|
|
109
|
+
*
|
|
110
|
+
* A cache is stale when any source file is newer than the cache file, or —
|
|
111
|
+
* when the cache's recorded source-file set is provided — when the current
|
|
112
|
+
* set of source files differs from the recorded one (HARNESS-REQUIREMENT-4:
|
|
113
|
+
* deletions don't touch surviving files' mtimes, so the set must be compared).
|
|
103
114
|
*/
|
|
104
|
-
export function isCacheStale(requirementsDir, cacheMtime) {
|
|
115
|
+
export function isCacheStale(requirementsDir, cacheMtime, cachedSourceFiles) {
|
|
105
116
|
const projectRoot = path.dirname(requirementsDir);
|
|
106
117
|
const sourceFiles = findRequirementsFilesSync(projectRoot);
|
|
118
|
+
if (cachedSourceFiles) {
|
|
119
|
+
const current = sourceFiles
|
|
120
|
+
.map((file) => path.relative(projectRoot, file))
|
|
121
|
+
.sort();
|
|
122
|
+
const cached = [...cachedSourceFiles].sort();
|
|
123
|
+
if (current.length !== cached.length ||
|
|
124
|
+
current.some((file, i) => file !== cached[i])) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
107
128
|
for (const file of sourceFiles) {
|
|
108
129
|
try {
|
|
109
130
|
const stat = fs.statSync(file);
|
|
@@ -127,23 +148,28 @@ export function readLookupCache(requirementsDir) {
|
|
|
127
148
|
if (!fs.existsSync(lookupPath)) {
|
|
128
149
|
return null;
|
|
129
150
|
}
|
|
130
|
-
|
|
151
|
+
let cache;
|
|
152
|
+
let meta;
|
|
153
|
+
let cacheMtime;
|
|
131
154
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
155
|
+
cacheMtime = fs.statSync(lookupPath).mtimeMs;
|
|
156
|
+
cache = JSON.parse(fs.readFileSync(lookupPath, "utf-8"));
|
|
157
|
+
meta = JSON.parse(fs.readFileSync(path.join(cacheDir, LOOKUP_META_FILE), "utf-8"));
|
|
136
158
|
}
|
|
137
159
|
catch {
|
|
160
|
+
// A cache without its meta file (legacy layout) can't detect deletions —
|
|
161
|
+
// treat it as stale and let prepare() rebuild both files.
|
|
138
162
|
return null;
|
|
139
163
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return JSON.parse(content);
|
|
164
|
+
if (!Array.isArray(meta.sourceFiles)) {
|
|
165
|
+
return null;
|
|
143
166
|
}
|
|
144
|
-
|
|
167
|
+
// Stale if the source-file set changed (deletions included) or any source
|
|
168
|
+
// file is newer than the cache
|
|
169
|
+
if (isCacheStale(requirementsDir, cacheMtime, meta.sourceFiles)) {
|
|
145
170
|
return null;
|
|
146
171
|
}
|
|
172
|
+
return cache;
|
|
147
173
|
}
|
|
148
174
|
/**
|
|
149
175
|
* Generate and store a unique test run ID
|
package/dist/harness/finalize.js
CHANGED
|
@@ -13,6 +13,7 @@ import { execSync } from "node:child_process";
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import { getProjectInfo } from "../utils/project-settings.js";
|
|
15
15
|
import { cleanupTestRunId, deleteTrackingFile, findProjectRoot, findRequirementsDir, getTestRunId, needsReporting, readCoverageCache, readLookupCache, readTrackingEntries, updateCoverageCache, } from "./cache.js";
|
|
16
|
+
import { toProjectRelativePath } from "./tracking.js";
|
|
16
17
|
/**
|
|
17
18
|
* Aggregate tracking entries by requirement key
|
|
18
19
|
*/
|
|
@@ -181,7 +182,7 @@ async function reportToConvex(projectRoot, testRunId, aggregated, showCloudStatu
|
|
|
181
182
|
// Use the first access location
|
|
182
183
|
const firstEntry = entries[0];
|
|
183
184
|
const location = firstEntry.callerLocation;
|
|
184
|
-
// Parse "
|
|
185
|
+
// Parse "path/to/file.ts:42" format
|
|
185
186
|
let testFile;
|
|
186
187
|
let testLine;
|
|
187
188
|
if (location !== "unknown") {
|
|
@@ -193,6 +194,12 @@ async function reportToConvex(projectRoot, testRunId, aggregated, showCloudStatu
|
|
|
193
194
|
else {
|
|
194
195
|
testFile = location;
|
|
195
196
|
}
|
|
197
|
+
// COVERAGE-CONTEXT-2: locations inside the project are recorded
|
|
198
|
+
// project-relative; normalize any absolute path that falls inside
|
|
199
|
+
// the project so records and git blame use a stable relative path.
|
|
200
|
+
if (testFile) {
|
|
201
|
+
testFile = toProjectRelativePath(testFile, projectRoot);
|
|
202
|
+
}
|
|
196
203
|
}
|
|
197
204
|
// COVERAGE-CONTEXT-2: git blame on the requirement() call line
|
|
198
205
|
let user;
|
|
@@ -337,6 +344,23 @@ export async function finalize(options = {}) {
|
|
|
337
344
|
// We resolve those to their canonical numeric key (e.g. "AUTH-LOGIN-1.0") so that
|
|
338
345
|
// coverage counting, display, and cloud reporting all use consistent keys.
|
|
339
346
|
if (lookup) {
|
|
347
|
+
// HARNESS-RESOLVE-2.1: label paths resolve case-insensitively, but
|
|
348
|
+
// tracking preserves the test's original casing while the cache stores
|
|
349
|
+
// alias keys lowercased. Fold unmatched keys onto their lowercased-label
|
|
350
|
+
// alias first, so a ref like "REQ-1.Given" isn't dropped as foreign below.
|
|
351
|
+
for (const [key, trackingEntries] of Array.from(aggregated.entries())) {
|
|
352
|
+
if (lookup.requirements[key])
|
|
353
|
+
continue;
|
|
354
|
+
const m = /^([A-Z][A-Z0-9_-]*-\d+)(\..+)$/.exec(key);
|
|
355
|
+
if (!m)
|
|
356
|
+
continue;
|
|
357
|
+
const folded = m[1] + m[2].toLowerCase();
|
|
358
|
+
if (folded !== key && lookup.requirements[folded]) {
|
|
359
|
+
const existing = aggregated.get(folded) || [];
|
|
360
|
+
aggregated.set(folded, [...existing, ...trackingEntries]);
|
|
361
|
+
aggregated.delete(key);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
340
364
|
for (const [key, trackingEntries] of Array.from(aggregated.entries())) {
|
|
341
365
|
const entry = lookup.requirements[key];
|
|
342
366
|
if (entry?.isAlias) {
|
|
@@ -347,6 +371,14 @@ export async function finalize(options = {}) {
|
|
|
347
371
|
aggregated.delete(key);
|
|
348
372
|
}
|
|
349
373
|
}
|
|
374
|
+
// HARNESS-REQUIREMENT-6.1: a project's coverage report contains only
|
|
375
|
+
// requirement keys that belong to that project. Tracking entries for
|
|
376
|
+
// foreign keys (e.g. resolved against another project) are dropped.
|
|
377
|
+
for (const key of Array.from(aggregated.keys())) {
|
|
378
|
+
if (!lookup.requirements[key]) {
|
|
379
|
+
aggregated.delete(key);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
350
382
|
}
|
|
351
383
|
const testedKeys = Array.from(aggregated.keys());
|
|
352
384
|
const totalRequirements = lookup
|
package/dist/harness/index.js
CHANGED
|
@@ -70,21 +70,28 @@ export function requirement(...args) {
|
|
|
70
70
|
if (options.projectRoot) {
|
|
71
71
|
loadRequirements({ projectRoot: options.projectRoot });
|
|
72
72
|
}
|
|
73
|
-
//
|
|
74
|
-
|
|
73
|
+
// HARNESS-REQUIREMENT-2: Invalid requirement paths fail the individual test.
|
|
74
|
+
// Every ref is validated — a typo in any ref throws, naming the missing path.
|
|
75
|
+
// Validation happens BEFORE tracking so that refs that fail lookup are never
|
|
76
|
+
// recorded as covered (HARNESS-REQUIREMENT-3).
|
|
77
|
+
const resolved = requirementRefs.map((ref) => {
|
|
78
|
+
const found = getRequirement(ref);
|
|
79
|
+
if (!found) {
|
|
80
|
+
throw new Error(`Requirement ${ref} not found`);
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
});
|
|
84
|
+
// Ensure test run is initialized against the resolving project
|
|
85
|
+
// (HARNESS-REQUIREMENT-6: explicit projectRoot wins over the enclosing one)
|
|
86
|
+
ensureTestRun(options.projectRoot);
|
|
75
87
|
// Track all requirements for coverage at the full path level
|
|
76
88
|
// e.g., 'REQ-123.given' is tracked as 'REQ-123.given', not just 'REQ-123'
|
|
77
89
|
for (const ref of requirementRefs) {
|
|
78
|
-
trackRequirement(ref);
|
|
90
|
+
trackRequirement(ref, options.projectRoot);
|
|
79
91
|
}
|
|
80
92
|
// Save after tracking all requirements (no-op in new JSONL approach)
|
|
81
93
|
saveTrackingData();
|
|
82
|
-
|
|
83
|
-
const firstRef = requirementRefs[0];
|
|
84
|
-
const req = getRequirement(firstRef);
|
|
85
|
-
if (!req) {
|
|
86
|
-
throw new Error(`Requirement ${firstRef} not found`);
|
|
87
|
-
}
|
|
94
|
+
const req = resolved[0];
|
|
88
95
|
// HARNESS-REQUIREMENT-1: Format output as human-readable string
|
|
89
96
|
// Root requirements use 'requirementHeader' label which should not be shown in output
|
|
90
97
|
if (!req.label || req.label === "requirementHeader") {
|
|
@@ -151,6 +151,18 @@ export function getRequirement(reqId) {
|
|
|
151
151
|
if (direct) {
|
|
152
152
|
return direct;
|
|
153
153
|
}
|
|
154
|
+
// HARNESS-RESOLVE-2.1: label matching is case-insensitive. The lookup
|
|
155
|
+
// cache stores label-path aliases lowercased (e.g. "REQ-123.given"), and
|
|
156
|
+
// in cache mode nodes carry no children for the fallback resolver to walk,
|
|
157
|
+
// so retry with the label segments lowercased (root key stays as written).
|
|
158
|
+
const firstDot = reqId.indexOf(".");
|
|
159
|
+
if (firstDot !== -1) {
|
|
160
|
+
const normalized = reqId.slice(0, firstDot) + reqId.slice(firstDot).toLowerCase();
|
|
161
|
+
const aliased = loadedRequirements.get(normalized);
|
|
162
|
+
if (aliased) {
|
|
163
|
+
return aliased;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
154
166
|
// If not found, try resolving label-based paths
|
|
155
167
|
// resolveRequirementPath expects an array of root requirements and the full path
|
|
156
168
|
const allTrees = Array.from(requirementTrees.values());
|