@popoverai/dotrequirements 0.26.1 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/codebase-to-spec/present.js +4 -5
  2. package/dist/codebase-to-spec/validate.js +3 -2
  3. package/dist/commands/acceptance-test.js +4 -2
  4. package/dist/commands/ai-setup.js +97 -59
  5. package/dist/commands/get.js +6 -2
  6. package/dist/commands/init.js +7 -5
  7. package/dist/commands/link-resolution.d.ts +3 -1
  8. package/dist/commands/link-resolution.js +4 -2
  9. package/dist/commands/pull.js +36 -3
  10. package/dist/commands/push.js +54 -16
  11. package/dist/commands/report.js +18 -3
  12. package/dist/commands/review-test.js +16 -8
  13. package/dist/commands/tests-for.js +13 -13
  14. package/dist/commands/validate.js +14 -14
  15. package/dist/harness/cache.d.ts +19 -3
  16. package/dist/harness/cache.js +38 -12
  17. package/dist/harness/finalize.js +33 -1
  18. package/dist/harness/index.js +16 -9
  19. package/dist/harness/requirementsLoader.js +12 -0
  20. package/dist/harness/tracking.d.ts +17 -2
  21. package/dist/harness/tracking.js +83 -9
  22. package/dist/mcp/handlers/authoring.js +13 -4
  23. package/dist/mcp/handlers/get.js +7 -3
  24. package/dist/mcp/handlers/push.js +59 -13
  25. package/dist/mcp/handlers/review.d.ts +1 -0
  26. package/dist/mcp/handlers/review.js +58 -15
  27. package/dist/mcp/handlers/test-mapping.js +47 -12
  28. package/dist/mcp/handlers/types.d.ts +14 -0
  29. package/dist/mcp/handlers/types.js +27 -0
  30. package/dist/mcp/index.js +4 -0
  31. package/dist/push/core.d.ts +50 -0
  32. package/dist/push/core.js +149 -11
  33. package/dist/push/index.d.ts +1 -1
  34. package/dist/push/index.js +1 -1
  35. package/dist/requirements/cloud-coverage.d.ts +12 -2
  36. package/dist/requirements/cloud-coverage.js +30 -3
  37. package/dist/requirements/grep.d.ts +7 -2
  38. package/dist/requirements/grep.js +75 -47
  39. package/dist/schema/builder.d.ts +1 -1
  40. package/dist/schema/builder.js +13 -0
  41. package/dist/schema/conversions.d.ts +7 -2
  42. package/dist/schema/conversions.js +13 -4
  43. package/dist/schema/parser-core.d.ts +28 -0
  44. package/dist/schema/parser-core.js +80 -9
  45. package/dist/schema/parser.d.ts +8 -26
  46. package/dist/schema/parser.js +23 -251
  47. package/dist/schema/resolver.js +18 -8
  48. package/dist/utils/env.js +17 -1
  49. package/dist/utils/oauth-flow.js +8 -0
  50. package/dist/utils/project-settings.d.ts +4 -0
  51. package/dist/utils/project-settings.js +14 -1
  52. package/package.json +1 -1
@@ -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
- const requirementIdMatches = testFileContents.matchAll(/requirement\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g);
20
- const requirementIds = new Set();
21
- for (const match of requirementIdMatches) {
22
- requirementIds.add(match[1]);
23
- }
24
- // CLI-REVIEW-1.1: resolve references against the workspace and bundle trees
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 req = flattened.find((r) => r.id === reqId || r.rootId === reqId);
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: requirement ID → test refs
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
- if (!coverageMap.has(ref.requirementId)) {
27
- coverageMap.set(ref.requirementId, []);
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(ref.requirementId).push(ref);
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 reqAndChildren = fileRequirements.filter((r) => r.rootId === rootId);
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
- const requirementsDir = path.join(process.cwd(), ".requirements");
7
- if (!fs.existsSync(requirementsDir)) {
8
- console.error("Error: .requirements/ directory not found.");
9
- console.log('Run "dotrequirements pull" first to sync requirements.');
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 = fs
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
- console.log(` ✓ Version: ${metadata.version}`);
44
- console.log(` ✓ Last sync: ${metadata.pulledAt}`);
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) {
@@ -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 any source file is newer than the cache file.
77
- * Returns true if cache is stale and should be invalidated.
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.
@@ -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 any source file is newer than the cache file.
102
- * Returns true if cache is stale and should be invalidated.
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
- // Check if cache is stale (any source file newer than cache)
151
+ let cache;
152
+ let meta;
153
+ let cacheMtime;
131
154
  try {
132
- const cacheStat = fs.statSync(lookupPath);
133
- if (isCacheStale(requirementsDir, cacheStat.mtimeMs)) {
134
- return null;
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
- try {
141
- const content = fs.readFileSync(lookupPath, "utf-8");
142
- return JSON.parse(content);
164
+ if (!Array.isArray(meta.sourceFiles)) {
165
+ return null;
143
166
  }
144
- catch {
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
@@ -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 "filename.ts:42" format
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
@@ -70,21 +70,28 @@ export function requirement(...args) {
70
70
  if (options.projectRoot) {
71
71
  loadRequirements({ projectRoot: options.projectRoot });
72
72
  }
73
- // Ensure test run is initialized
74
- ensureTestRun();
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
- // HARNESS-REQUIREMENT-2: Invalid requirement paths fail the individual test
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());
@@ -11,8 +11,21 @@ export interface TrackedRequirement {
11
11
  fieldsAccessed: string[];
12
12
  accessedAt: string[];
13
13
  }
14
- export declare function ensureTestRun(): string;
14
+ /**
15
+ * Initialize (or reuse) a test run.
16
+ *
17
+ * HARNESS-REQUIREMENT-6.0: when an explicit projectRoot is provided, the run
18
+ * is keyed to that project's .requirements/ so tracking writes land in the
19
+ * resolving project's cache, not the enclosing project's.
20
+ */
21
+ export declare function ensureTestRun(projectRoot?: string): string;
15
22
  export declare function getCallerLocation(): string;
23
+ /**
24
+ * Normalize an absolute path inside the project to a project-relative one
25
+ * (COVERAGE-CONTEXT-2: stable, blame-able from the project root). Paths that
26
+ * are already relative or fall outside the project are returned unchanged.
27
+ */
28
+ export declare function toProjectRelativePath(filePath: string, projectRoot: string): string;
16
29
  /**
17
30
  * Track a requirement access.
18
31
  *
@@ -21,8 +34,10 @@ export declare function getCallerLocation(): string;
21
34
  * HARNESS-REQUIREMENT-3.2: Works correctly when tests run in parallel processes
22
35
  *
23
36
  * @param reqId - Full requirement path (e.g., 'REQ-123.given' or 'REQ-123.0.1')
37
+ * @param projectRoot - Explicit project root; tracking is written to this
38
+ * project's cache when provided (HARNESS-REQUIREMENT-6.0)
24
39
  */
25
- export declare function trackRequirement(reqId: string): void;
40
+ export declare function trackRequirement(reqId: string, projectRoot?: string): void;
26
41
  /**
27
42
  * Save current tracking data.
28
43
  *
@@ -7,13 +7,33 @@
7
7
  * HARNESS-REQUIREMENT-3: Each requirement() call is recorded for coverage reporting
8
8
  */
9
9
  import * as path from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
  import { appendTrackingEntry, clearTrackingFile, findProjectRoot, findRequirementsDir, getCacheDir, getTestRunId, initTestRunId, } from "./cache.js";
11
12
  const trackedRequirements = new Map();
12
13
  // Test run tracking
13
14
  let testRunId = null;
14
15
  let requirementsDirCached = null;
15
- // Initialize test run
16
- export function ensureTestRun() {
16
+ // Per-project run ids for explicit projectRoot tracking (HARNESS-REQUIREMENT-6)
17
+ const runIdsByRequirementsDir = new Map();
18
+ /**
19
+ * Initialize (or reuse) a test run.
20
+ *
21
+ * HARNESS-REQUIREMENT-6.0: when an explicit projectRoot is provided, the run
22
+ * is keyed to that project's .requirements/ so tracking writes land in the
23
+ * resolving project's cache, not the enclosing project's.
24
+ */
25
+ export function ensureTestRun(projectRoot) {
26
+ if (projectRoot) {
27
+ const requirementsDir = path.join(projectRoot, ".requirements");
28
+ let runId = runIdsByRequirementsDir.get(requirementsDir);
29
+ if (!runId) {
30
+ // Reuse a run id written by prepare() in that project, else mint one
31
+ runId = getTestRunId(requirementsDir) ?? `${Date.now()}-${process.pid}`;
32
+ runIdsByRequirementsDir.set(requirementsDir, runId);
33
+ getCacheDir(requirementsDir, true);
34
+ }
35
+ return runId;
36
+ }
17
37
  if (!testRunId) {
18
38
  // Priority 1: Use environment variable (cross-process persistence from globalSetup)
19
39
  if (process.env.DOTREQUIREMENTS_PROJECT_ROOT) {
@@ -61,14 +81,62 @@ export function getCallerLocation() {
61
81
  const regex2 = /at (.+):(\d+):(\d+)/;
62
82
  const match = regex1.exec(frame) || regex2.exec(frame);
63
83
  if (match) {
64
- const filePath = match[1];
84
+ let filePath = match[1];
65
85
  const lineNumber = match[2];
66
- return `${path.basename(filePath)}:${lineNumber}`;
86
+ // ESM stack frames use file:// URLs
87
+ if (filePath.startsWith("file://")) {
88
+ try {
89
+ filePath = fileURLToPath(filePath);
90
+ }
91
+ catch {
92
+ // Keep the raw frame path if the URL can't be converted
93
+ }
94
+ }
95
+ // COVERAGE-CONTEXT-2: keep the full path (not just the basename) so
96
+ // finalize() can git-blame the call site for nested test files.
97
+ return `${filePath}:${lineNumber}`;
67
98
  }
68
99
  }
69
100
  }
70
101
  return "unknown";
71
102
  }
103
+ /**
104
+ * Convert an absolute caller location to a project-relative one when the
105
+ * caller file lives inside the project. Keeps locations stable and blame-able
106
+ * from the project root (COVERAGE-CONTEXT-2); callers outside the project
107
+ * stay absolute.
108
+ */
109
+ function toProjectRelativeLocation(location, requirementsDir) {
110
+ if (!requirementsDir) {
111
+ return location;
112
+ }
113
+ const match = /^(.+):(\d+)$/.exec(location);
114
+ if (!match) {
115
+ return location;
116
+ }
117
+ const [, filePath, lineNumber] = match;
118
+ const projectRoot = path.dirname(requirementsDir);
119
+ const relative = toProjectRelativePath(filePath, projectRoot);
120
+ if (relative === filePath) {
121
+ return location;
122
+ }
123
+ return `${relative}:${lineNumber}`;
124
+ }
125
+ /**
126
+ * Normalize an absolute path inside the project to a project-relative one
127
+ * (COVERAGE-CONTEXT-2: stable, blame-able from the project root). Paths that
128
+ * are already relative or fall outside the project are returned unchanged.
129
+ */
130
+ export function toProjectRelativePath(filePath, projectRoot) {
131
+ if (!path.isAbsolute(filePath)) {
132
+ return filePath;
133
+ }
134
+ const relative = path.relative(projectRoot, filePath);
135
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
136
+ return filePath;
137
+ }
138
+ return relative;
139
+ }
72
140
  // Get caller's directory (full path) for finding .requirements
73
141
  function getCallerDirectory() {
74
142
  const error = new Error();
@@ -100,10 +168,15 @@ function getCallerDirectory() {
100
168
  * HARNESS-REQUIREMENT-3.2: Works correctly when tests run in parallel processes
101
169
  *
102
170
  * @param reqId - Full requirement path (e.g., 'REQ-123.given' or 'REQ-123.0.1')
171
+ * @param projectRoot - Explicit project root; tracking is written to this
172
+ * project's cache when provided (HARNESS-REQUIREMENT-6.0)
103
173
  */
104
- export function trackRequirement(reqId) {
105
- const callerLocation = getCallerLocation();
106
- const runId = ensureTestRun();
174
+ export function trackRequirement(reqId, projectRoot) {
175
+ const runId = ensureTestRun(projectRoot);
176
+ const targetRequirementsDir = projectRoot
177
+ ? path.join(projectRoot, ".requirements")
178
+ : requirementsDirCached;
179
+ const callerLocation = toProjectRelativeLocation(getCallerLocation(), targetRequirementsDir);
107
180
  // Track in memory for backward compatibility
108
181
  if (!trackedRequirements.has(reqId)) {
109
182
  trackedRequirements.set(reqId, {
@@ -116,14 +189,14 @@ export function trackRequirement(reqId) {
116
189
  tracked.accessedAt.push(callerLocation);
117
190
  // Append to JSONL file for cross-process tracking
118
191
  // HARNESS-REQUIREMENT-3.2: JSONL is append-only, safe for parallel workers
119
- if (requirementsDirCached) {
192
+ if (targetRequirementsDir) {
120
193
  const entry = {
121
194
  requirementKey: reqId,
122
195
  callerLocation,
123
196
  timestamp: Date.now(),
124
197
  testRunId: runId,
125
198
  };
126
- appendTrackingEntry(requirementsDirCached, entry);
199
+ appendTrackingEntry(targetRequirementsDir, entry);
127
200
  }
128
201
  }
129
202
  /**
@@ -144,6 +217,7 @@ export function clearTracking() {
144
217
  trackedRequirements.clear();
145
218
  testRunId = null;
146
219
  requirementsDirCached = null;
220
+ runIdsByRequirementsDir.clear();
147
221
  }
148
222
  /**
149
223
  * Initialize a test run (called by globalSetup).
@@ -6,12 +6,11 @@
6
6
  * - validate_requirements: Validate requirements file syntax offline
7
7
  */
8
8
  import { existsSync } from "node:fs";
9
- import { resolve } from "node:path";
10
9
  import { getProjectContext } from "../../requirements/cloud-ai.js";
11
10
  import { generateStyleGuide, readLocalStyleGuide, } from "../../requirements/style-guide.js";
12
11
  import { parseRequirementsFromFile, validateForPush, } from "../../schema/index.js";
13
12
  import { CONVEX_URL } from "../convexClient.js";
14
- import { errorResponse, textResponse } from "./types.js";
13
+ import { errorResponse, resolveProjectFilePath, textResponse, } from "./types.js";
15
14
  /**
16
15
  * Handler for create_requirement_document tool
17
16
  *
@@ -60,8 +59,18 @@ export async function handleCreateRequirementDocument(args, context) {
60
59
  */
61
60
  export async function handleValidateRequirements(args, context) {
62
61
  const { filePath } = args;
63
- // MCP-AUTHOR-2.3: Validation works offline - just use workspaceRoot
64
- const fullPath = resolve(context.workspaceRoot, filePath);
62
+ // #49: Resolve against the discovered project.path (matching push) with a
63
+ // workspaceRoot fallback. MCP-AUTHOR-2.3: validation stays offline — if
64
+ // discovery has no credentials it simply throws and we fall back to
65
+ // workspaceRoot resolution.
66
+ let projectPath;
67
+ try {
68
+ projectPath = (await context.getProjectFromDiscovery()).path;
69
+ }
70
+ catch {
71
+ projectPath = undefined;
72
+ }
73
+ const fullPath = resolveProjectFilePath(filePath, projectPath, context.workspaceRoot);
65
74
  // MCP-AUTHOR-2.4: Check if file exists
66
75
  if (!existsSync(fullPath)) {
67
76
  return errorResponse(`File not found: ${filePath}`);
@@ -4,7 +4,7 @@
4
4
  * Retrieves a specific requirement by ID with its full tree and test coverage.
5
5
  */
6
6
  import { glob } from "glob";
7
- import { formatRequirementTree, getRequirementTree, } from "../../requirements/index.js";
7
+ import { formatRequirementTree } from "../../requirements/index.js";
8
8
  import { findFilesWithRequirement, findTestCodeForRequirement, } from "../../requirements/testCodeExtractor.js";
9
9
  import { textResponse } from "./types.js";
10
10
  /**
@@ -20,10 +20,14 @@ export async function handleGetRequirement(args, context) {
20
20
  const { id, projectId } = args;
21
21
  const project = await context.getProjectFromDiscovery(projectId);
22
22
  const requirements = await context.getRequirements(projectId);
23
- // Get the tree starting from this ID
23
+ // Get the tree starting from this ID.
24
24
  // MCP-GET-1.0: Full tree for root requirement
25
25
  // MCP-GET-1.1: Subtree for child requirement
26
- const tree = getRequirementTree(requirements, id);
26
+ //
27
+ // #46: Match the node itself plus all of its descendants by id prefix. The
28
+ // shared getRequirementTree only matches on rootId/id equality, which drops
29
+ // grandchildren (e.g. REQ-123.0.0) when a child id (REQ-123.0) is requested.
30
+ const tree = requirements.filter((r) => r.id === id || r.id.startsWith(`${id}.`));
27
31
  // MCP-GET-1.2: Return error if not found
28
32
  if (tree.length === 0) {
29
33
  return textResponse(`Requirement "${id}" not found`);