@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.
Files changed (104) hide show
  1. package/dist/codebase-to-spec/area-name.d.ts +13 -0
  2. package/dist/codebase-to-spec/area-name.js +18 -0
  3. package/dist/codebase-to-spec/cache.d.ts +31 -0
  4. package/dist/codebase-to-spec/cache.js +29 -1
  5. package/dist/codebase-to-spec/compose.d.ts +7 -4
  6. package/dist/codebase-to-spec/compose.js +10 -21
  7. package/dist/codebase-to-spec/dispatch.js +1 -1
  8. package/dist/codebase-to-spec/present.js +11 -7
  9. package/dist/codebase-to-spec/prompts/planner-initial.d.ts +3 -2
  10. package/dist/codebase-to-spec/prompts/planner-initial.js +3 -2
  11. package/dist/codebase-to-spec/renumber.d.ts +52 -0
  12. package/dist/codebase-to-spec/renumber.js +105 -0
  13. package/dist/codebase-to-spec/schemas.d.ts +35 -240
  14. package/dist/codebase-to-spec/schemas.js +5 -173
  15. package/dist/codebase-to-spec/validate.js +3 -2
  16. package/dist/commands/acceptance-test.js +4 -2
  17. package/dist/commands/ai-setup.js +97 -59
  18. package/dist/commands/codebase-to-spec/dispatch-editor.js +1 -1
  19. package/dist/commands/codebase-to-spec/dispatch-spec.js +1 -1
  20. package/dist/commands/codebase-to-spec/index.js +7 -103
  21. package/dist/commands/codebase-to-spec/pack.js +8 -1
  22. package/dist/commands/codebase-to-spec/present-orchestrator.d.ts +3 -1
  23. package/dist/commands/codebase-to-spec/present-orchestrator.js +11 -4
  24. package/dist/commands/get.js +6 -2
  25. package/dist/commands/init.js +7 -5
  26. package/dist/commands/link-resolution.d.ts +3 -1
  27. package/dist/commands/link-resolution.js +4 -2
  28. package/dist/commands/pull.js +36 -3
  29. package/dist/commands/push.js +54 -16
  30. package/dist/commands/report.js +18 -3
  31. package/dist/commands/review-test.js +16 -8
  32. package/dist/commands/tests-for.js +13 -13
  33. package/dist/commands/validate.js +14 -14
  34. package/dist/harness/cache.d.ts +19 -3
  35. package/dist/harness/cache.js +38 -12
  36. package/dist/harness/finalize.js +33 -1
  37. package/dist/harness/index.js +16 -9
  38. package/dist/harness/requirementsLoader.js +12 -0
  39. package/dist/harness/tracking.d.ts +17 -2
  40. package/dist/harness/tracking.js +83 -9
  41. package/dist/mcp/handlers/authoring.js +13 -4
  42. package/dist/mcp/handlers/get.js +7 -3
  43. package/dist/mcp/handlers/push.js +59 -13
  44. package/dist/mcp/handlers/review.d.ts +1 -0
  45. package/dist/mcp/handlers/review.js +58 -15
  46. package/dist/mcp/handlers/test-mapping.js +47 -12
  47. package/dist/mcp/handlers/types.d.ts +14 -0
  48. package/dist/mcp/handlers/types.js +27 -0
  49. package/dist/mcp/index.js +4 -0
  50. package/dist/push/core.d.ts +50 -0
  51. package/dist/push/core.js +149 -11
  52. package/dist/push/index.d.ts +1 -1
  53. package/dist/push/index.js +1 -1
  54. package/dist/requirements/cloud-coverage.d.ts +12 -2
  55. package/dist/requirements/cloud-coverage.js +30 -3
  56. package/dist/requirements/grep.d.ts +7 -2
  57. package/dist/requirements/grep.js +75 -47
  58. package/dist/schema/builder.d.ts +1 -1
  59. package/dist/schema/builder.js +13 -0
  60. package/dist/schema/conversions.d.ts +7 -2
  61. package/dist/schema/conversions.js +13 -4
  62. package/dist/schema/parser-core.d.ts +28 -0
  63. package/dist/schema/parser-core.js +80 -9
  64. package/dist/schema/parser.d.ts +8 -26
  65. package/dist/schema/parser.js +23 -251
  66. package/dist/schema/resolver.js +18 -8
  67. package/dist/templates/skills/codebase-to-spec/SKILL.md +10 -4
  68. package/dist/utils/env.js +17 -1
  69. package/dist/utils/oauth-flow.js +8 -0
  70. package/dist/utils/project-settings.d.ts +4 -0
  71. package/dist/utils/project-settings.js +14 -1
  72. package/package.json +1 -1
  73. package/dist/codebase-to-spec/edit-loop.d.ts +0 -54
  74. package/dist/codebase-to-spec/edit-loop.js +0 -195
  75. package/dist/codebase-to-spec/editor.d.ts +0 -54
  76. package/dist/codebase-to-spec/editor.js +0 -74
  77. package/dist/codebase-to-spec/fan-out.d.ts +0 -63
  78. package/dist/codebase-to-spec/fan-out.js +0 -215
  79. package/dist/codebase-to-spec/outline-review-loop.d.ts +0 -51
  80. package/dist/codebase-to-spec/outline-review-loop.js +0 -187
  81. package/dist/codebase-to-spec/planner.d.ts +0 -41
  82. package/dist/codebase-to-spec/planner.js +0 -76
  83. package/dist/codebase-to-spec/prompts/outline-reviewer.d.ts +0 -12
  84. package/dist/codebase-to-spec/prompts/outline-reviewer.js +0 -89
  85. package/dist/codebase-to-spec/slice.d.ts +0 -49
  86. package/dist/codebase-to-spec/slice.js +0 -111
  87. package/dist/codebase-to-spec/specifier.d.ts +0 -60
  88. package/dist/codebase-to-spec/specifier.js +0 -85
  89. package/dist/codebase-to-spec/summary.d.ts +0 -51
  90. package/dist/codebase-to-spec/summary.js +0 -183
  91. package/dist/commands/codebase-to-spec/compose.d.ts +0 -14
  92. package/dist/commands/codebase-to-spec/compose.js +0 -57
  93. package/dist/commands/codebase-to-spec/edit-loop.d.ts +0 -16
  94. package/dist/commands/codebase-to-spec/edit-loop.js +0 -83
  95. package/dist/commands/codebase-to-spec/fan-out.d.ts +0 -19
  96. package/dist/commands/codebase-to-spec/fan-out.js +0 -77
  97. package/dist/commands/codebase-to-spec/plan-loop.d.ts +0 -26
  98. package/dist/commands/codebase-to-spec/plan-loop.js +0 -105
  99. package/dist/commands/codebase-to-spec/present.d.ts +0 -26
  100. package/dist/commands/codebase-to-spec/present.js +0 -97
  101. package/dist/commands/codebase-to-spec/run.d.ts +0 -20
  102. package/dist/commands/codebase-to-spec/run.js +0 -86
  103. package/dist/commands/codebase-to-spec/specify-area.d.ts +0 -18
  104. package/dist/commands/codebase-to-spec/specify-area.js +0 -82
@@ -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`);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync } from "node:fs";
8
8
  import { basename, resolve } from "node:path";
9
- import { dryRunPush, executePush, parseFilesForPush, } from "../../push/index.js";
9
+ import { dryRunPush, executePush, parseFilesForPushIndividually, } from "../../push/index.js";
10
10
  import { findRequirementsFiles } from "../../requirements/index.js";
11
11
  import { CONVEX_URL } from "../convexClient.js";
12
12
  import { errorResponse, textResponse } from "./types.js";
@@ -46,24 +46,60 @@ export async function handlePushRequirements(args, context) {
46
46
  return textResponse("No *.requirements.md files found. Nothing to push.");
47
47
  }
48
48
  }
49
- // Parse files
50
- const { parsedFiles, totalRequirements } = parseFilesForPush(filesToPush);
49
+ // Parse files. #45/SYNC-FAIL-4: one invalid file does not abort the whole
50
+ // push — its failure is surfaced in the preview's "Skipped - Invalid"
51
+ // section while valid files still push. Same helper as the CLI command.
52
+ const { parsedFiles, totalRequirements, parseFailures } = parseFilesForPushIndividually(filesToPush);
51
53
  // Build credentials
52
54
  const credentials = {
53
55
  projectId: project.projectId,
54
56
  projectSecret: project.projectSecret,
55
57
  convexUrl: CONVEX_URL,
56
58
  };
57
- // Run dry run (always, even when confirmed - ensures fresh state)
58
- const dryRunResult = await dryRunPush(parsedFiles, credentials);
59
+ // Run dry run (always, even when confirmed - ensures fresh state).
60
+ // SYNC-FAIL-3: two files claiming the same document ID abort the push
61
+ // before any cloud write — surface that as a clean error result naming
62
+ // both files rather than an unhandled throw.
63
+ let dryRunResult;
64
+ try {
65
+ dryRunResult = await dryRunPush(parsedFiles, credentials);
66
+ }
67
+ catch (error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ if (message.includes("claim the same document ID")) {
70
+ return {
71
+ content: [
72
+ {
73
+ type: "text",
74
+ text: `✗ Push aborted: ${message}`,
75
+ },
76
+ ],
77
+ isError: true,
78
+ };
79
+ }
80
+ throw error;
81
+ }
82
+ // #45: Merge dry-run invalids (missing document section, cloud validation)
83
+ // with local parse failures so both are surfaced together, each named by
84
+ // filename.
85
+ const allInvalid = [
86
+ ...dryRunResult.invalid.map(({ file, result }) => ({
87
+ fileName: basename(file.filePath),
88
+ error: result.error ?? "invalid",
89
+ })),
90
+ ...parseFailures.map(({ filePath, error }) => ({
91
+ fileName: basename(filePath),
92
+ error,
93
+ })),
94
+ ];
59
95
  // Check if there's anything to push
60
96
  const pushableCount = dryRunResult.updates.length +
61
97
  dryRunResult.creates.length +
62
98
  dryRunResult.notFound.length;
63
- if (pushableCount === 0 && dryRunResult.invalid.length > 0) {
99
+ if (pushableCount === 0 && allInvalid.length > 0) {
64
100
  // Only invalid files
65
- const invalidList = dryRunResult.invalid
66
- .map(({ file, result }) => `- ${basename(file.filePath)}: ${result.error}`)
101
+ const invalidList = allInvalid
102
+ .map(({ fileName, error }) => `- ${fileName}: ${error}`)
67
103
  .join("\n");
68
104
  return {
69
105
  content: [
@@ -106,11 +142,10 @@ export async function handlePushRequirements(args, context) {
106
142
  }
107
143
  summary += "\n";
108
144
  }
109
- if (dryRunResult.invalid.length > 0) {
110
- summary += `## Skipped - Invalid (${dryRunResult.invalid.length})\n`;
111
- for (const { file, result } of dryRunResult.invalid) {
112
- const fileName = basename(file.filePath);
113
- summary += `- ✗ ${fileName}: ${result.error}\n`;
145
+ if (allInvalid.length > 0) {
146
+ summary += `## Skipped - Invalid (${allInvalid.length})\n`;
147
+ for (const { fileName, error } of allInvalid) {
148
+ summary += `- ✗ ${fileName}: ${error}\n`;
114
149
  }
115
150
  summary += "\n";
116
151
  }
@@ -158,6 +193,17 @@ export async function handlePushRequirements(args, context) {
158
193
  output += `- ${fileName}: ${error}\n`;
159
194
  }
160
195
  }
196
+ // SYNC-FAIL-2.1: cloud save succeeded but the local file couldn't be
197
+ // updated — name the file, say the cloud is fine, and give the recovery
198
+ // step so a retry doesn't mint a duplicate document.
199
+ if (result.writeBackWarnings?.length > 0) {
200
+ output += `\n**Warnings:**\n`;
201
+ for (const warning of result.writeBackWarnings) {
202
+ output +=
203
+ `- ⚠ ${warning.fileName}: saved to the cloud, but the local file could not be updated (${warning.error}). ` +
204
+ `To avoid creating a duplicate, add "id: ${warning.documentId}" under "document:" in the frontmatter of ${warning.filePath}, then push again.\n`;
205
+ }
206
+ }
161
207
  // SYNC-LAND-1: where each synced document lives in the web app
162
208
  if (result.synced.length > 0) {
163
209
  output += `\n**Review in dot•requirements:**\n`;
@@ -13,6 +13,7 @@ export interface StyleCheckArgs {
13
13
  filePath: string;
14
14
  requirementKeys?: string[];
15
15
  model?: string;
16
+ projectId?: string;
16
17
  }
17
18
  /**
18
19
  * Arguments for review_test tool
@@ -6,11 +6,12 @@
6
6
  * - review_test: Comprehensively review tests for semantic correctness
7
7
  */
8
8
  import { existsSync, readFileSync } from "node:fs";
9
- import { dirname, resolve } from "node:path";
9
+ import { dirname } from "node:path";
10
10
  import { fetchReviewTestFeedback, fetchStyleCheckFeedback, } from "../../requirements/cloud-ai.js";
11
+ import { findRequirementsInFile } from "../../requirements/grep.js";
11
12
  import { filterRequirementsByKeys, formatRequirementTree, getRequirementTree, } from "../../requirements/index.js";
12
13
  import { discoverProjects, } from "../../utils/project-discovery.js";
13
- import { errorResponse, textResponse } from "./types.js";
14
+ import { errorResponse, resolveProjectFilePath, textResponse, } from "./types.js";
14
15
  const STYLE_CHECK_GUIDANCE = `
15
16
 
16
17
  ---
@@ -32,9 +33,20 @@ const STYLE_CHECK_GUIDANCE = `
32
33
  * - MCP-REVIEW-1.3: When cloud credentials are unavailable, an error explains how to authenticate
33
34
  */
34
35
  export async function handleStyleCheck(args, context, options) {
35
- const { filePath, requirementKeys, model } = args;
36
+ const { filePath, requirementKeys, model, projectId } = args;
36
37
  const { useEnvAuth = false, apiBaseUrl = "https://app.dotrequirements.io" } = options ?? {};
37
- const fullPath = resolve(context.workspaceRoot, filePath);
38
+ // #49: Resolve against the discovered project.path (matching push) with a
39
+ // workspaceRoot fallback, so a relative path that push accepts also works
40
+ // here in PROJ_*-based setups. Discovery may throw without credentials — that
41
+ // is fine, we then fall back to workspaceRoot.
42
+ let styleProjectPath;
43
+ try {
44
+ styleProjectPath = (await context.getProjectFromDiscovery(projectId)).path;
45
+ }
46
+ catch {
47
+ styleProjectPath = undefined;
48
+ }
49
+ const fullPath = resolveProjectFilePath(filePath, styleProjectPath, context.workspaceRoot);
38
50
  if (!existsSync(fullPath)) {
39
51
  return errorResponse(`File not found: ${filePath}`);
40
52
  }
@@ -73,7 +85,14 @@ export async function handleStyleCheck(args, context, options) {
73
85
  const fileDir = dirname(fullPath);
74
86
  let project;
75
87
  try {
76
- if (useEnvAuth) {
88
+ if (projectId) {
89
+ // #48: PROJ-CONTEXT-6 — an explicit projectId disambiguates a
90
+ // multi-project workspace. Thread it straight through to discovery
91
+ // (matching review_test/push/report) instead of the file-directory
92
+ // auto-detect path, which cannot choose among multiple projects.
93
+ project = await context.getProjectFromDiscovery(projectId);
94
+ }
95
+ else if (useEnvAuth) {
77
96
  // Using env auth - go straight to context
78
97
  project = await context.getProjectFromDiscovery();
79
98
  }
@@ -88,8 +107,14 @@ export async function handleStyleCheck(args, context, options) {
88
107
  project = result.project;
89
108
  }
90
109
  else {
91
- // Multiple projects - can't auto-detect which one to use
92
- throw new Error("Multiple projects found - cannot auto-detect for this file");
110
+ // #48: Multiple projects — can't auto-detect. The remedy is to pass
111
+ // projectId (not `dotrequirements link`, which is already satisfied);
112
+ // list the available projects so the caller can choose.
113
+ const available = result.projects
114
+ .map((p) => p.projectId)
115
+ .filter(Boolean)
116
+ .join(", ");
117
+ return errorResponse(`Multiple projects found. Specify the \`projectId\` parameter to choose one${available ? ` (available: ${available})` : ""}.`);
93
118
  }
94
119
  }
95
120
  }
@@ -130,7 +155,17 @@ export async function handleStyleCheck(args, context, options) {
130
155
  export async function handleReviewTest(args, context, options) {
131
156
  const { testFilePath, projectId } = args;
132
157
  const { apiBaseUrl = "https://app.dotrequirements.io" } = options ?? {};
133
- const fullPath = resolve(context.workspaceRoot, testFilePath);
158
+ // #49: Resolve against the discovered project.path (matching push) with a
159
+ // workspaceRoot fallback. Discovery may throw without credentials — fall back
160
+ // to workspaceRoot; the credential error is reported later.
161
+ let reviewProjectPath;
162
+ try {
163
+ reviewProjectPath = (await context.getProjectFromDiscovery(projectId)).path;
164
+ }
165
+ catch {
166
+ reviewProjectPath = undefined;
167
+ }
168
+ const fullPath = resolveProjectFilePath(testFilePath, reviewProjectPath, context.workspaceRoot);
134
169
  if (!existsSync(fullPath)) {
135
170
  return errorResponse(`File not found: ${testFilePath}`);
136
171
  }
@@ -141,12 +176,12 @@ export async function handleReviewTest(args, context, options) {
141
176
  }
142
177
  // Read test file contents
143
178
  const testFileContents = readFileSync(fullPath, "utf-8");
144
- // Extract requirement IDs from the test file using regex
145
- const requirementIdMatches = testFileContents.matchAll(/requirement\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/g);
146
- const requirementIds = new Set();
147
- for (const match of requirementIdMatches) {
148
- requirementIds.add(match[1]);
149
- }
179
+ // #43: Extract requirement IDs via the shared AST-based extractor so that
180
+ // multi-arg calls (`requirement("A", "B")`) and options-bearing calls
181
+ // (`requirement("A", { ... })`) are captured — the old single-string regex
182
+ // matched neither.
183
+ const references = await findRequirementsInFile(fullPath);
184
+ const requirementIds = new Set(references.map((ref) => ref.requirementId));
150
185
  // MCP-REVIEW-2.5: Get project credentials
151
186
  let project;
152
187
  try {
@@ -159,9 +194,17 @@ export async function handleReviewTest(args, context, options) {
159
194
  const allRequirements = await context.getRequirements(projectId);
160
195
  // Group requirement IDs by their root to avoid sending duplicate trees
161
196
  // e.g., NAV-3.0, NAV-3.1, NAV-3.2 all belong to root NAV-3
197
+ //
198
+ // #42: A reference may be a bare root (`AUTH-1`), an index child (`AUTH-1.0`),
199
+ // or a label path (`AUTH-1.given`). Normalize each reference to its root id
200
+ // (the substring before the first dot) before matching — the same approach
201
+ // getReferencedRequirementIds uses — so label-path refs resolve instead of
202
+ // being reported as non-existent.
162
203
  const rootToTestedIds = new Map();
163
204
  for (const reqId of requirementIds) {
164
- const req = allRequirements.find((r) => r.id === reqId || r.rootId === reqId);
205
+ const dotIndex = reqId.indexOf(".");
206
+ const refRootId = dotIndex > 0 ? reqId.substring(0, dotIndex) : reqId;
207
+ const req = allRequirements.find((r) => r.rootId === refRootId);
165
208
  if (req) {
166
209
  if (!rootToTestedIds.has(req.rootId)) {
167
210
  rootToTestedIds.set(req.rootId, new Set());
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs";
7
7
  import { relative, resolve } from "node:path";
8
8
  import { findAllTestReferences, findRequirementsInFile, } from "../../requirements/grep.js";
9
9
  import { getRequirementById } from "../../requirements/index.js";
10
- import { errorResponse, textResponse } from "./types.js";
10
+ import { errorResponse, resolveProjectFilePath, textResponse, } from "./types.js";
11
11
  /**
12
12
  * Handler for get_requirements_by_test tool
13
13
  *
@@ -18,14 +18,26 @@ import { errorResponse, textResponse } from "./types.js";
18
18
  */
19
19
  export async function handleGetRequirementsByTest(args, context) {
20
20
  const { testFile, projectId } = args;
21
+ // #49: Resolve against the discovered project.path (matching push) with a
22
+ // workspaceRoot fallback, so a relative path works in PROJ_*-based setups.
23
+ let projectPath;
24
+ try {
25
+ projectPath = (await context.getProjectFromDiscovery(projectId)).path;
26
+ }
27
+ catch {
28
+ projectPath = undefined;
29
+ }
21
30
  // MCP-MAP-1.2: Check if file exists before processing
22
- const fullPath = resolve(context.workspaceRoot, testFile);
31
+ const fullPath = resolveProjectFilePath(testFile, projectPath, context.workspaceRoot);
23
32
  if (!existsSync(fullPath)) {
24
33
  return errorResponse(`Test file not found: ${testFile}`);
25
34
  }
26
35
  const requirements = await context.getRequirements(projectId);
27
- // MCP-MAP-1.0: Find all requirement references in the test file
28
- const refs = await findRequirementsInFile(testFile);
36
+ // MCP-MAP-1.0: Find all requirement references in the test file.
37
+ // #44: Pass the already-resolved absolute path — findRequirementsInFile
38
+ // resolves relative paths against process.cwd(), which is not the workspace
39
+ // root when the MCP server runs from a different directory.
40
+ const refs = await findRequirementsInFile(fullPath);
29
41
  if (refs.length === 0) {
30
42
  return textResponse(`No requirement references found in "${testFile}"`);
31
43
  }
@@ -50,7 +62,18 @@ export async function handleGetRequirementsByTest(args, context) {
50
62
  export async function handleGetTestsByRequirement(args, context) {
51
63
  const { requirementsFile, projectId } = args;
52
64
  const { workspaceRoot } = context;
53
- const fullPath = resolve(workspaceRoot, requirementsFile);
65
+ // #49: Resolve against the discovered project.path (matching push) with a
66
+ // workspaceRoot fallback, so a relative path works in PROJ_*-based setups.
67
+ // The same base root anchors sourceFile comparison, test scanning, and the
68
+ // relative paths in the output.
69
+ let projectPath;
70
+ try {
71
+ projectPath = (await context.getProjectFromDiscovery(projectId)).path;
72
+ }
73
+ catch {
74
+ projectPath = undefined;
75
+ }
76
+ const fullPath = resolveProjectFilePath(requirementsFile, projectPath, workspaceRoot);
54
77
  // Verify file exists
55
78
  if (!existsSync(fullPath)) {
56
79
  return errorResponse(`Requirements file not found: ${requirementsFile}`);
@@ -59,23 +82,35 @@ export async function handleGetTestsByRequirement(args, context) {
59
82
  if (!requirementsFile.endsWith(".requirements.md")) {
60
83
  return errorResponse(`File must be a requirements file: *.requirements.md`);
61
84
  }
85
+ // Anchor all subsequent path work to the root that actually contains the
86
+ // requirements file, so sourceFile comparison and test discovery agree.
87
+ const baseRoot = projectPath ?? workspaceRoot;
62
88
  // Load all requirements from this file
63
89
  const requirements = await context.getRequirements(projectId);
64
- const fileRequirements = requirements.filter((req) => resolve(workspaceRoot, req.sourceFile) === fullPath);
90
+ const fileRequirements = requirements.filter((req) => resolve(baseRoot, req.sourceFile) === fullPath);
65
91
  if (fileRequirements.length === 0) {
66
92
  return textResponse(`No requirements found in "${requirementsFile}"`);
67
93
  }
68
94
  // Get unique root requirement IDs from this file
69
95
  const rootIds = new Set(fileRequirements.map((req) => req.rootId));
70
96
  // Find all test references in the workspace
71
- const allTestRefs = await findAllTestReferences(workspaceRoot);
72
- // Build coverage map: requirement ID -> list of test references
97
+ const allTestRefs = await findAllTestReferences(baseRoot);
98
+ // Build coverage map keyed by ROOT id. A reference may be a bare root
99
+ // (`AUTH-1`), an index child (`AUTH-1.0`), or a label path (`AUTH-1.given`).
100
+ // #47: Normalize each reference to its root id (the substring before the
101
+ // first dot) before keying — the same normalization as the tests-for fix —
102
+ // so label-path refs match numeric requirement ids instead of rendering as
103
+ // "(requirement not found)".
73
104
  const coverageMap = new Map();
74
105
  for (const ref of allTestRefs) {
75
- if (!coverageMap.has(ref.requirementId)) {
76
- coverageMap.set(ref.requirementId, []);
106
+ const dotIndex = ref.requirementId.indexOf(".");
107
+ const refRootId = dotIndex > 0
108
+ ? ref.requirementId.substring(0, dotIndex)
109
+ : ref.requirementId;
110
+ if (!coverageMap.has(refRootId)) {
111
+ coverageMap.set(refRootId, []);
77
112
  }
78
- coverageMap.get(ref.requirementId).push(ref);
113
+ coverageMap.get(refRootId).push(ref);
79
114
  }
80
115
  // MCP-MAP-2.0: Categorize requirements as covered or not covered
81
116
  const covered = [];
@@ -114,7 +149,7 @@ export async function handleGetTestsByRequirement(args, context) {
114
149
  }
115
150
  output += `- **${id}**: ${tests.length} test${tests.length === 1 ? "" : "s"}\n`;
116
151
  for (const [file, lines] of testsByFile.entries()) {
117
- const relPath = relative(workspaceRoot, file);
152
+ const relPath = relative(baseRoot, file);
118
153
  const lineList = lines.sort((a, b) => a - b).join(", ");
119
154
  output += ` - \`${relPath}\` (lines ${lineList})\n`;
120
155
  }
@@ -64,6 +64,20 @@ export interface HandlerContext {
64
64
  * The args parameter contains tool-specific arguments.
65
65
  */
66
66
  export type ToolHandler<TArgs = Record<string, unknown>> = (args: TArgs, context: HandlerContext) => Promise<ToolResponse>;
67
+ /**
68
+ * Resolve a user-supplied relative file path to an absolute path.
69
+ *
70
+ * #49: The MCP server's workspaceRoot is its process cwd, but in PROJ_*-based
71
+ * (Antigravity) setups the discovered project.path is the real project root and
72
+ * differs from cwd. push_requirements resolves against project.path; the other
73
+ * file-taking handlers historically resolved against workspaceRoot, so the same
74
+ * relative path worked for push but not for validate/style/review/mapping.
75
+ *
76
+ * This resolves against project.path first (matching push) and falls back to
77
+ * workspaceRoot when the file is not present there, so both layouts work.
78
+ * Absolute paths are returned unchanged.
79
+ */
80
+ export declare function resolveProjectFilePath(filePath: string, projectPath: string | undefined, workspaceRoot: string): string;
67
81
  /**
68
82
  * Helper to create a text response
69
83
  */
@@ -5,6 +5,33 @@
5
5
  * and returns an MCP tool response. This enables unit testing without
6
6
  * the full MCP server infrastructure.
7
7
  */
8
+ import { existsSync } from "node:fs";
9
+ import { isAbsolute, resolve } from "node:path";
10
+ /**
11
+ * Resolve a user-supplied relative file path to an absolute path.
12
+ *
13
+ * #49: The MCP server's workspaceRoot is its process cwd, but in PROJ_*-based
14
+ * (Antigravity) setups the discovered project.path is the real project root and
15
+ * differs from cwd. push_requirements resolves against project.path; the other
16
+ * file-taking handlers historically resolved against workspaceRoot, so the same
17
+ * relative path worked for push but not for validate/style/review/mapping.
18
+ *
19
+ * This resolves against project.path first (matching push) and falls back to
20
+ * workspaceRoot when the file is not present there, so both layouts work.
21
+ * Absolute paths are returned unchanged.
22
+ */
23
+ export function resolveProjectFilePath(filePath, projectPath, workspaceRoot) {
24
+ if (isAbsolute(filePath)) {
25
+ return filePath;
26
+ }
27
+ if (projectPath) {
28
+ const fromProject = resolve(projectPath, filePath);
29
+ if (existsSync(fromProject)) {
30
+ return fromProject;
31
+ }
32
+ }
33
+ return resolve(workspaceRoot, filePath);
34
+ }
8
35
  /**
9
36
  * Helper to create a text response
10
37
  */