@popoverai/dotrequirements 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,5 @@ interface PushOptions {
2
2
  yes?: boolean;
3
3
  }
4
4
  export declare function pushCommand(file: string | undefined, options: PushOptions): Promise<void>;
5
- /**
6
- * Extract markdown content from a file, stripping YAML frontmatter.
7
- * Exported for testing.
8
- */
9
- export declare function extractMarkdownContent(rawContent: string): string;
10
5
  export {};
11
6
  //# sourceMappingURL=push.d.ts.map
@@ -1,13 +1,11 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { ConvexHttpClient } from 'convex/browser';
4
- import { getConvexUrl } from '../config.js';
5
- import { parseRequirementsFromFile, getAllRequirements, parseRequirementKey, buildRequirementsFile } from '../schema/index.js';
6
- import { api } from '../convex.js';
7
3
  import * as readline from 'readline';
8
4
  import { brand } from '../utils/brand.js';
9
5
  import { findRequirementsFiles } from '../mcp/requirements.js';
10
6
  import { getProjectCredentials } from '../utils/project-settings.js';
7
+ import { getConvexUrl } from '../config.js';
8
+ import { parseFilesForPush, dryRunPush, executePush, } from '../push/index.js';
11
9
  export async function pushCommand(file, options) {
12
10
  try {
13
11
  console.log(`Preparing to push requirements to ${brand} cloud...\n`);
@@ -35,164 +33,42 @@ export async function pushCommand(file, options) {
35
33
  }
36
34
  // Parse all local files
37
35
  console.log('Parsing local requirements...');
38
- const parsedFiles = [];
39
- let totalRequirements = 0;
40
- for (const filePath of filesToPush) {
41
- const fileName = path.basename(filePath);
42
- console.log(` Reading ${fileName}...`);
43
- // Read raw file content
44
- const rawContent = fs.readFileSync(filePath, 'utf-8');
45
- // Parse to get metadata and requirements
46
- const parsed = parseRequirementsFromFile(filePath);
47
- const flatRequirements = getAllRequirements(parsed.requirements);
48
- // Extract markdown content (everything after frontmatter)
49
- const markdownContent = extractMarkdownContent(rawContent);
50
- // DOC-HEADER-11.1/11.2: Infer prefix from first requirement if not specified
51
- const doc = parsed.metadata.document;
52
- if (doc && !doc.defaultPrefix && flatRequirements.length > 0) {
53
- const firstReq = flatRequirements[0];
54
- if (firstReq) {
55
- const parsedKey = parseRequirementKey(firstReq.id);
56
- if (parsedKey) {
57
- doc.defaultPrefix = parsedKey.prefix;
58
- console.log(` Inferred defaultPrefix "${doc.defaultPrefix}" from first requirement`);
59
- }
60
- }
61
- }
62
- parsedFiles.push({
63
- filePath,
64
- metadata: parsed.metadata,
65
- markdownContent,
66
- requirementCount: flatRequirements.length,
67
- });
68
- totalRequirements += flatRequirements.length;
69
- }
70
- console.log(`\nFound ${totalRequirements} requirement(s) in ${parsedFiles.length} document(s).\n`);
71
- // Connect to Convex
72
- const convexUrl = getConvexUrl();
73
- const client = new ConvexHttpClient(convexUrl);
74
- // Phase 1: Dry run to categorize all files
75
- console.log('Validating documents...');
76
- const dryRunResults = [];
36
+ const { parsedFiles, totalRequirements } = parseFilesForPush(filesToPush);
37
+ // Log file parsing progress
77
38
  for (const file of parsedFiles) {
78
- const doc = file.metadata.document;
79
39
  const fileName = path.basename(file.filePath);
80
- if (!doc) {
81
- // No document section - mark as invalid locally
82
- dryRunResults.push({
83
- file,
84
- result: {
85
- dryRun: true,
86
- action: 'invalid',
87
- title: fileName,
88
- error: 'Missing document section in frontmatter',
89
- },
90
- });
91
- continue;
92
- }
93
- try {
94
- const result = await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
95
- projectAuth: {
96
- projectSlug: projectId,
97
- projectSecret,
98
- },
99
- target: { type: 'project', slug: projectId },
100
- documentId: doc.id,
101
- title: doc.title,
102
- markdownContent: file.markdownContent,
103
- defaultPrefix: doc.defaultPrefix,
104
- dryRun: true,
105
- });
106
- dryRunResults.push({ file, result });
107
- }
108
- catch (err) {
109
- dryRunResults.push({
110
- file,
111
- result: {
112
- dryRun: true,
113
- action: 'invalid',
114
- title: doc.title,
115
- error: err.message,
116
- },
117
- });
40
+ const doc = file.metadata.document;
41
+ if (doc?.defaultPrefix && !file.metadata.document?.defaultPrefix) {
42
+ console.log(` Reading ${fileName}...`);
43
+ console.log(` Inferred defaultPrefix "${doc.defaultPrefix}" from first requirement`);
118
44
  }
119
- }
120
- // Categorize results
121
- const updates = dryRunResults.filter(r => r.result.action === 'update');
122
- const creates = dryRunResults.filter(r => r.result.action === 'create');
123
- const notFound = dryRunResults.filter(r => r.result.action === 'not_found');
124
- const invalid = dryRunResults.filter(r => r.result.action === 'invalid');
125
- // Check for conflicts on updates (cloud changed since last pull)
126
- const conflicts = [];
127
- if (updates.length > 0) {
128
- const docIds = updates.map(u => u.result.documentId);
129
- const cloudMetadata = await client.query(api.documents.queries.getDocumentsMetadata, {
130
- projectAuth: {
131
- projectSlug: projectId,
132
- projectSecret,
133
- },
134
- target: { type: 'project', slug: projectId },
135
- documentIds: docIds,
136
- });
137
- const cloudMetaByDocId = new Map(cloudMetadata.map((m) => [m.documentId, m]));
138
- for (const item of updates) {
139
- const docId = item.result.documentId;
140
- const cloudMeta = cloudMetaByDocId.get(docId);
141
- if (cloudMeta && item.file.metadata.pulledAt) {
142
- const pulledAtMs = new Date(item.file.metadata.pulledAt).getTime();
143
- if (cloudMeta.updatedAt > pulledAtMs) {
144
- conflicts.push({ item, cloudMeta });
145
- }
146
- }
45
+ else {
46
+ console.log(` Reading ${fileName}...`);
147
47
  }
148
48
  }
49
+ console.log(`\nFound ${totalRequirements} requirement(s) in ${parsedFiles.length} document(s).\n`);
50
+ // Build credentials
51
+ const credentials = {
52
+ projectId,
53
+ projectSecret,
54
+ convexUrl: getConvexUrl(),
55
+ };
56
+ // Phase 1: Dry run
57
+ console.log('Validating documents...');
58
+ const dryRunResult = await dryRunPush(parsedFiles, credentials);
149
59
  // Display unified summary
150
- console.log('\n=== Push Summary ===\n');
151
- if (updates.length > 0) {
152
- console.log(`Updated documents (${updates.length}):`);
153
- for (const { file, result } of updates) {
154
- const fileName = path.basename(file.filePath);
155
- const hasConflict = conflicts.some(c => c.item.file === file);
156
- const conflictNote = hasConflict ? ' ⚠️ [cloud changed since pull]' : '';
157
- const warningNote = result.warning ? ` (${result.warning})` : '';
158
- console.log(` ~ ${fileName}${conflictNote}${warningNote}`);
159
- }
160
- console.log();
161
- }
162
- if (creates.length > 0) {
163
- console.log(`New documents (${creates.length}):`);
164
- for (const { file, result } of creates) {
165
- const fileName = path.basename(file.filePath);
166
- const warningNote = result.warning ? ` (${result.warning})` : '';
167
- console.log(` + ${fileName}${warningNote}`);
168
- }
169
- console.log();
170
- }
171
- if (notFound.length > 0) {
172
- console.log(`Documents not found in cloud (${notFound.length}) - will create new:`);
173
- for (const { file, result } of notFound) {
174
- const fileName = path.basename(file.filePath);
175
- console.log(` ! ${fileName} (ID: ${result.documentId})`);
176
- }
177
- console.log();
178
- }
179
- if (invalid.length > 0) {
180
- console.log(`Skipped - invalid files (${invalid.length}):`);
181
- for (const { file, result } of invalid) {
182
- const fileName = path.basename(file.filePath);
183
- console.log(` ✗ ${fileName}: ${result.error}`);
184
- }
185
- console.log();
186
- }
60
+ displayDryRunSummary(dryRunResult);
187
61
  // Check if there's anything to push
188
- const pushableFiles = [...updates, ...creates, ...notFound];
189
- if (pushableFiles.length === 0) {
62
+ const pushableCount = dryRunResult.updates.length +
63
+ dryRunResult.creates.length +
64
+ dryRunResult.notFound.length;
65
+ if (pushableCount === 0) {
190
66
  console.log('No valid documents to push.');
191
67
  return;
192
68
  }
193
69
  // Single confirmation prompt
194
70
  if (!options.yes) {
195
- const hasConflicts = conflicts.length > 0;
71
+ const hasConflicts = dryRunResult.conflicts.length > 0;
196
72
  const prompt = hasConflicts
197
73
  ? 'Continue with push? (will overwrite cloud changes) [y/N] '
198
74
  : 'Continue with push? [y/N] ';
@@ -204,59 +80,35 @@ export async function pushCommand(file, options) {
204
80
  }
205
81
  // Phase 2: Execute push
206
82
  console.log('\nPushing documents to cloud...');
207
- let created = 0;
208
- let updated = 0;
209
- for (const { file, result } of pushableFiles) {
210
- const doc = file.metadata.document;
83
+ const result = await executePush(dryRunResult, credentials);
84
+ // Display results
85
+ for (const { file, result: dryResult } of [
86
+ ...dryRunResult.updates,
87
+ ...dryRunResult.creates,
88
+ ...dryRunResult.notFound,
89
+ ]) {
211
90
  const fileName = path.basename(file.filePath);
212
- // For not_found, clear the ID so we create a new document
213
- const effectiveDocId = result.action === 'not_found' ? undefined : doc.id;
214
- try {
215
- const pushResult = await client.mutation(api.documents.saveWithRequirements.saveWithRequirements, {
216
- projectAuth: {
217
- projectSlug: projectId,
218
- projectSecret,
219
- },
220
- target: { type: 'project', slug: projectId },
221
- documentId: effectiveDocId,
222
- title: doc.title,
223
- markdownContent: file.markdownContent,
224
- defaultPrefix: doc.defaultPrefix,
225
- dryRun: false,
226
- });
227
- const isCreate = result.action === 'create' || result.action === 'not_found';
228
- if (isCreate) {
229
- // New document - write ID back to file
230
- doc.id = pushResult;
231
- file.metadata.pulledAt = new Date().toISOString();
232
- file.metadata.version = 1;
233
- const updatedContent = buildRequirementsFile(file.metadata, file.markdownContent);
234
- fs.writeFileSync(file.filePath, updatedContent, 'utf-8');
235
- console.log(` ✓ Created: ${fileName} → ${pushResult}`);
236
- created++;
237
- }
238
- else {
239
- // Update pulledAt to reflect this push
240
- file.metadata.pulledAt = new Date().toISOString();
241
- file.metadata.version = (file.metadata.version || 0) + 1;
242
- const updatedContent = buildRequirementsFile(file.metadata, file.markdownContent);
243
- fs.writeFileSync(file.filePath, updatedContent, 'utf-8');
244
- console.log(` ✓ Updated: ${fileName}`);
245
- updated++;
246
- }
91
+ const isCreate = dryResult.action === 'create' || dryResult.action === 'not_found';
92
+ // Check if this file had an error
93
+ const error = result.errors.find((e) => e.fileName === fileName);
94
+ if (error) {
95
+ console.log(` ✗ Failed: ${fileName} - ${error.error}`);
247
96
  }
248
- catch (err) {
249
- console.error(` ✗ Failed: ${fileName} - ${err.message}`);
97
+ else if (isCreate) {
98
+ console.log(` ✓ Created: ${fileName} → ${file.metadata.document?.id}`);
99
+ }
100
+ else {
101
+ console.log(` ✓ Updated: ${fileName}`);
250
102
  }
251
103
  }
252
104
  console.log(`\n✓ Push complete!`);
253
- if (created > 0) {
254
- console.log(` Created: ${created} document(s)`);
105
+ if (result.created > 0) {
106
+ console.log(` Created: ${result.created} document(s)`);
255
107
  }
256
- if (updated > 0) {
257
- console.log(` Updated: ${updated} document(s)`);
108
+ if (result.updated > 0) {
109
+ console.log(` Updated: ${result.updated} document(s)`);
258
110
  }
259
- if (created === 0 && updated === 0) {
111
+ if (result.created === 0 && result.updated === 0) {
260
112
  console.log(' No documents were pushed.');
261
113
  }
262
114
  }
@@ -266,16 +118,47 @@ export async function pushCommand(file, options) {
266
118
  }
267
119
  }
268
120
  /**
269
- * Extract markdown content from a file, stripping YAML frontmatter.
270
- * Exported for testing.
121
+ * Display the dry run summary.
271
122
  */
272
- export function extractMarkdownContent(rawContent) {
273
- // Match YAML frontmatter: starts with ---, ends with ---
274
- const frontmatterMatch = rawContent.match(/^---\n[\s\S]*?\n---\n*/);
275
- if (frontmatterMatch) {
276
- return rawContent.slice(frontmatterMatch[0].length);
123
+ function displayDryRunSummary(dryRunResult) {
124
+ const { updates, creates, notFound, invalid, conflicts } = dryRunResult;
125
+ console.log('\n=== Push Summary ===\n');
126
+ if (updates.length > 0) {
127
+ console.log(`Updated documents (${updates.length}):`);
128
+ for (const { file, result } of updates) {
129
+ const fileName = path.basename(file.filePath);
130
+ const hasConflict = conflicts.some((c) => c.item.file === file);
131
+ const conflictNote = hasConflict ? ' ⚠️ [cloud changed since pull]' : '';
132
+ const warningNote = result.warning ? ` (${result.warning})` : '';
133
+ console.log(` ~ ${fileName}${conflictNote}${warningNote}`);
134
+ }
135
+ console.log();
136
+ }
137
+ if (creates.length > 0) {
138
+ console.log(`New documents (${creates.length}):`);
139
+ for (const { file, result } of creates) {
140
+ const fileName = path.basename(file.filePath);
141
+ const warningNote = result.warning ? ` (${result.warning})` : '';
142
+ console.log(` + ${fileName}${warningNote}`);
143
+ }
144
+ console.log();
145
+ }
146
+ if (notFound.length > 0) {
147
+ console.log(`Documents not found in cloud (${notFound.length}) - will create new:`);
148
+ for (const { file, result } of notFound) {
149
+ const fileName = path.basename(file.filePath);
150
+ console.log(` ! ${fileName} (ID: ${result.documentId})`);
151
+ }
152
+ console.log();
153
+ }
154
+ if (invalid.length > 0) {
155
+ console.log(`Skipped - invalid files (${invalid.length}):`);
156
+ for (const { file, result } of invalid) {
157
+ const fileName = path.basename(file.filePath);
158
+ console.log(` ✗ ${fileName}: ${result.error}`);
159
+ }
160
+ console.log();
277
161
  }
278
- return rawContent;
279
162
  }
280
163
  /**
281
164
  * Prompt user for confirmation
@@ -70,7 +70,8 @@ export declare function findRequirementsFiles(projectRoot: string): string[];
70
70
  */
71
71
  export declare function writeLookupCache(requirementsDir: string, requirements: RequirementNode[]): void;
72
72
  /**
73
- * Read the lookup cache, returning null if not found or invalid
73
+ * Read the lookup cache, returning null if not found, invalid, or stale.
74
+ * Cache is considered stale if any .requirements.md file is newer than the cache.
74
75
  */
75
76
  export declare function readLookupCache(requirementsDir: string): LookupCache | null;
76
77
  /**
@@ -101,7 +101,28 @@ export function writeLookupCache(requirementsDir, requirements) {
101
101
  fs.writeFileSync(lookupPath, JSON.stringify(lookup, null, 2));
102
102
  }
103
103
  /**
104
- * Read the lookup cache, returning null if not found or invalid
104
+ * Check if any source file is newer than the cache file.
105
+ * Returns true if cache is stale and should be invalidated.
106
+ */
107
+ function isCacheStale(requirementsDir, cacheMtime) {
108
+ const projectRoot = path.dirname(requirementsDir);
109
+ const sourceFiles = findRequirementsFiles(projectRoot);
110
+ for (const file of sourceFiles) {
111
+ try {
112
+ const stat = fs.statSync(file);
113
+ if (stat.mtimeMs > cacheMtime) {
114
+ return true;
115
+ }
116
+ }
117
+ catch {
118
+ // File might have been deleted, ignore
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+ /**
124
+ * Read the lookup cache, returning null if not found, invalid, or stale.
125
+ * Cache is considered stale if any .requirements.md file is newer than the cache.
105
126
  */
106
127
  export function readLookupCache(requirementsDir) {
107
128
  const cacheDir = getCacheDir(requirementsDir);
@@ -109,6 +130,16 @@ export function readLookupCache(requirementsDir) {
109
130
  if (!fs.existsSync(lookupPath)) {
110
131
  return null;
111
132
  }
133
+ // Check if cache is stale (any source file newer than cache)
134
+ try {
135
+ const cacheStat = fs.statSync(lookupPath);
136
+ if (isCacheStale(requirementsDir, cacheStat.mtimeMs)) {
137
+ return null;
138
+ }
139
+ }
140
+ catch {
141
+ return null;
142
+ }
112
143
  try {
113
144
  const content = fs.readFileSync(lookupPath, 'utf-8');
114
145
  return JSON.parse(content);
@@ -47,5 +47,12 @@ export declare function getProjectCoverage(projectId: string, projectSecret: str
47
47
  }>;
48
48
  untested: string[];
49
49
  }>;
50
+ /**
51
+ * PROJ-CONTEXT-7: Query Convex for project context (AI style guidelines)
52
+ */
53
+ export declare function getProjectContext(projectId: string, projectSecret: string, convexUrl: string): Promise<{
54
+ projectContext: string | null;
55
+ requirementsStyleContext: string | null;
56
+ } | null>;
50
57
  export {};
51
58
  //# sourceMappingURL=convexClient.d.ts.map
@@ -47,7 +47,11 @@ export async function getRequirementCoverage(requirementKey, projectId, projectS
47
47
  if (!response.ok) {
48
48
  throw new Error(`Failed to query coverage: ${response.statusText}`);
49
49
  }
50
- return await response.json();
50
+ const json = await response.json();
51
+ if (json.status === 'error') {
52
+ throw new Error(`Convex query failed: ${json.errorMessage}`);
53
+ }
54
+ return json.value;
51
55
  }
52
56
  /**
53
57
  * Query Convex for project coverage summary
@@ -76,6 +80,50 @@ export async function getProjectCoverage(projectId, projectSecret, convexUrl, op
76
80
  if (!response.ok) {
77
81
  throw new Error(`Failed to query coverage: ${response.statusText}`);
78
82
  }
79
- return await response.json();
83
+ const json = await response.json();
84
+ if (json.status === 'error') {
85
+ throw new Error(`Convex query failed: ${json.errorMessage}`);
86
+ }
87
+ return json.value;
88
+ }
89
+ /**
90
+ * PROJ-CONTEXT-7: Query Convex for project context (AI style guidelines)
91
+ */
92
+ export async function getProjectContext(projectId, projectSecret, convexUrl) {
93
+ try {
94
+ const response = await fetch(`${convexUrl}/api/query`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({
98
+ path: 'projects/queries:get',
99
+ args: {
100
+ projectAuth: {
101
+ projectSlug: projectId,
102
+ projectSecret,
103
+ },
104
+ target: {
105
+ type: 'project',
106
+ slug: projectId,
107
+ },
108
+ },
109
+ format: 'json',
110
+ }),
111
+ });
112
+ if (!response.ok) {
113
+ return null;
114
+ }
115
+ const json = await response.json();
116
+ if (json.status === 'error' || !json.value) {
117
+ return null;
118
+ }
119
+ return {
120
+ projectContext: json.value.projectContext ?? null,
121
+ requirementsStyleContext: json.value.requirementsStyleContext ?? null,
122
+ };
123
+ }
124
+ catch {
125
+ // Non-fatal: return null if query fails
126
+ return null;
127
+ }
80
128
  }
81
129
  //# sourceMappingURL=convexClient.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Authoring handlers for MCP tools
3
+ *
4
+ * Provides document creation and validation:
5
+ * - create_requirement_document: Generate requirements template with format guidance
6
+ * - validate_requirements: Validate requirements file syntax offline
7
+ */
8
+ import type { HandlerContext, ToolResponse } from './types.js';
9
+ /**
10
+ * Arguments for create_requirement_document tool
11
+ */
12
+ export interface CreateRequirementDocumentArgs {
13
+ filePath?: string;
14
+ }
15
+ /**
16
+ * Arguments for validate_requirements tool
17
+ */
18
+ export interface ValidateRequirementsArgs {
19
+ filePath: string;
20
+ }
21
+ /**
22
+ * Handler for create_requirement_document tool
23
+ *
24
+ * Requirements covered:
25
+ * - MCP-AUTHOR-1.0: The template includes format guidance with code block examples
26
+ * - MCP-AUTHOR-1.1: The template includes guidance on concrete examples, concise prose, and testable conditions
27
+ * - MCP-AUTHOR-1.2: When the project has requirementsStyleContext configured, it is included in the template
28
+ * - MCP-AUTHOR-1.3: When cloud credentials are unavailable, the template works without the custom context
29
+ */
30
+ export declare function handleCreateRequirementDocument(args: CreateRequirementDocumentArgs, context: HandlerContext): Promise<ToolResponse>;
31
+ /**
32
+ * Handler for validate_requirements tool
33
+ *
34
+ * Requirements covered:
35
+ * - MCP-AUTHOR-2.0: When the file has valid syntax, the response confirms validation passed
36
+ * - MCP-AUTHOR-2.1: When the file has syntax errors, the response lists each error with location
37
+ * - MCP-AUTHOR-2.2: Validation does not require network access or cloud credentials
38
+ * - MCP-AUTHOR-2.3: When the file does not exist, an error is returned
39
+ */
40
+ export declare function handleValidateRequirements(args: ValidateRequirementsArgs, context: HandlerContext): Promise<ToolResponse>;
41
+ //# sourceMappingURL=authoring.d.ts.map