specweave 0.16.5 → 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.
@@ -0,0 +1,510 @@
1
+ /**
2
+ * Azure DevOps Project Detector
3
+ *
4
+ * Intelligently detects which Azure DevOps project a spec or increment belongs to
5
+ * based on content analysis, folder structure, and configuration.
6
+ */
7
+
8
+ import * as fs from 'fs-extra';
9
+ import * as path from 'path';
10
+ import { AzureDevOpsStrategy } from '../../../src/cli/helpers/issue-tracker/types';
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export interface ProjectConfidence {
17
+ project: string;
18
+ confidence: number;
19
+ reasons: string[];
20
+ }
21
+
22
+ export interface ProjectDetectionResult {
23
+ primary: string;
24
+ secondary?: string[];
25
+ confidence: number;
26
+ strategy: AzureDevOpsStrategy;
27
+ }
28
+
29
+ export interface ProjectKeywords {
30
+ [project: string]: string[];
31
+ }
32
+
33
+ export interface ProjectPatterns {
34
+ [project: string]: RegExp[];
35
+ }
36
+
37
+ // ============================================================================
38
+ // Project Detection Keywords and Patterns
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Keywords that indicate a spec belongs to a specific project
43
+ */
44
+ export const PROJECT_KEYWORDS: ProjectKeywords = {
45
+ 'AuthService': [
46
+ 'authentication', 'auth', 'login', 'logout', 'oauth',
47
+ 'jwt', 'token', 'session', 'password', 'credential',
48
+ 'sso', 'saml', 'ldap', 'mfa', '2fa', 'totp'
49
+ ],
50
+ 'UserService': [
51
+ 'user', 'profile', 'account', 'registration', 'preferences',
52
+ 'settings', 'avatar', 'username', 'email', 'verification',
53
+ 'onboarding', 'demographics', 'personalization'
54
+ ],
55
+ 'PaymentService': [
56
+ 'payment', 'billing', 'stripe', 'paypal', 'invoice',
57
+ 'subscription', 'charge', 'refund', 'credit card', 'transaction',
58
+ 'checkout', 'cart', 'pricing', 'plan', 'tier'
59
+ ],
60
+ 'NotificationService': [
61
+ 'notification', 'email', 'sms', 'push', 'alert',
62
+ 'message', 'webhook', 'queue', 'sendgrid', 'twilio',
63
+ 'template', 'broadcast', 'digest', 'reminder'
64
+ ],
65
+ 'Platform': [
66
+ 'infrastructure', 'deployment', 'monitoring', 'logging',
67
+ 'metrics', 'kubernetes', 'docker', 'ci/cd', 'pipeline',
68
+ 'terraform', 'ansible', 'helm', 'grafana', 'prometheus'
69
+ ],
70
+ 'DataService': [
71
+ 'database', 'data', 'analytics', 'etl', 'warehouse',
72
+ 'pipeline', 'kafka', 'spark', 'hadoop', 'bigquery',
73
+ 'redshift', 'snowflake', 'datalake', 'streaming'
74
+ ],
75
+ 'ApiGateway': [
76
+ 'gateway', 'api', 'proxy', 'routing', 'load balancer',
77
+ 'rate limiting', 'throttling', 'circuit breaker', 'cors',
78
+ 'authentication proxy', 'service mesh', 'envoy', 'kong'
79
+ ],
80
+ 'WebApp': [
81
+ 'frontend', 'ui', 'react', 'angular', 'vue', 'component',
82
+ 'responsive', 'mobile-first', 'spa', 'ssr', 'next.js',
83
+ 'gatsby', 'webpack', 'css', 'sass', 'styled-components'
84
+ ],
85
+ 'MobileApp': [
86
+ 'ios', 'android', 'react native', 'flutter', 'swift',
87
+ 'kotlin', 'objective-c', 'java', 'push notification',
88
+ 'app store', 'play store', 'mobile', 'tablet', 'responsive'
89
+ ]
90
+ };
91
+
92
+ /**
93
+ * File path patterns that indicate project ownership
94
+ */
95
+ export const FILE_PATTERNS: ProjectPatterns = {
96
+ 'AuthService': [
97
+ /auth\//i,
98
+ /login\//i,
99
+ /security\//i,
100
+ /oauth\//i,
101
+ /jwt\//i
102
+ ],
103
+ 'UserService': [
104
+ /users?\//i,
105
+ /profiles?\//i,
106
+ /accounts?\//i,
107
+ /members?\//i
108
+ ],
109
+ 'PaymentService': [
110
+ /payment\//i,
111
+ /billing\//i,
112
+ /checkout\//i,
113
+ /stripe\//i,
114
+ /subscription\//i
115
+ ],
116
+ 'NotificationService': [
117
+ /notification\//i,
118
+ /email\//i,
119
+ /messaging\//i,
120
+ /templates?\//i
121
+ ],
122
+ 'Platform': [
123
+ /infrastructure\//i,
124
+ /terraform\//i,
125
+ /kubernetes\//i,
126
+ /k8s\//i,
127
+ /\.github\/workflows\//i
128
+ ],
129
+ 'WebApp': [
130
+ /frontend\//i,
131
+ /src\/components\//i,
132
+ /src\/pages\//i,
133
+ /public\//i,
134
+ /styles?\//i
135
+ ],
136
+ 'MobileApp': [
137
+ /ios\//i,
138
+ /android\//i,
139
+ /mobile\//i,
140
+ /app\//i
141
+ ]
142
+ };
143
+
144
+ // ============================================================================
145
+ // Project Detector Class
146
+ // ============================================================================
147
+
148
+ export class AdoProjectDetector {
149
+ private strategy: AzureDevOpsStrategy;
150
+ private availableProjects: string[];
151
+ private projectKeywords: ProjectKeywords;
152
+ private filePatterns: ProjectPatterns;
153
+
154
+ constructor(
155
+ strategy: AzureDevOpsStrategy,
156
+ availableProjects: string[],
157
+ customKeywords?: ProjectKeywords,
158
+ customPatterns?: ProjectPatterns
159
+ ) {
160
+ this.strategy = strategy;
161
+ this.availableProjects = availableProjects;
162
+ this.projectKeywords = { ...PROJECT_KEYWORDS, ...customKeywords };
163
+ this.filePatterns = { ...FILE_PATTERNS, ...customPatterns };
164
+ }
165
+
166
+ /**
167
+ * Detect project from spec file path
168
+ */
169
+ async detectFromSpecPath(specPath: string): Promise<ProjectDetectionResult> {
170
+ // For project-per-team strategy, use folder structure
171
+ if (this.strategy === 'project-per-team') {
172
+ const pathParts = specPath.split(path.sep);
173
+ const specsIndex = pathParts.indexOf('specs');
174
+
175
+ if (specsIndex !== -1 && specsIndex < pathParts.length - 1) {
176
+ const projectFolder = pathParts[specsIndex + 1];
177
+
178
+ // Check if it matches an available project
179
+ const matchedProject = this.availableProjects.find(
180
+ p => p.toLowerCase() === projectFolder.toLowerCase()
181
+ );
182
+
183
+ if (matchedProject) {
184
+ return {
185
+ primary: matchedProject,
186
+ confidence: 1.0,
187
+ strategy: this.strategy
188
+ };
189
+ }
190
+ }
191
+ }
192
+
193
+ // Fall back to content detection
194
+ const content = await fs.readFile(specPath, 'utf-8');
195
+ return this.detectFromContent(content);
196
+ }
197
+
198
+ /**
199
+ * Detect project from spec content
200
+ */
201
+ async detectFromContent(content: string): Promise<ProjectDetectionResult> {
202
+ const candidates = this.analyzeContent(content);
203
+
204
+ // High confidence: Auto-select
205
+ if (candidates[0]?.confidence > 0.7) {
206
+ return {
207
+ primary: candidates[0].project,
208
+ confidence: candidates[0].confidence,
209
+ strategy: this.strategy
210
+ };
211
+ }
212
+
213
+ // Medium confidence: Primary with secondary projects
214
+ if (candidates[0]?.confidence > 0.4) {
215
+ const secondary = candidates
216
+ .slice(1)
217
+ .filter(c => c.confidence > 0.3)
218
+ .map(c => c.project);
219
+
220
+ return {
221
+ primary: candidates[0].project,
222
+ secondary: secondary.length > 0 ? secondary : undefined,
223
+ confidence: candidates[0].confidence,
224
+ strategy: this.strategy
225
+ };
226
+ }
227
+
228
+ // Low confidence: Default to first available project
229
+ return {
230
+ primary: this.availableProjects[0] || 'Unknown',
231
+ confidence: 0,
232
+ strategy: this.strategy
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Analyze content and return project candidates with confidence scores
238
+ */
239
+ private analyzeContent(content: string): ProjectConfidence[] {
240
+ const results: ProjectConfidence[] = [];
241
+ const lowerContent = content.toLowerCase();
242
+
243
+ for (const project of this.availableProjects) {
244
+ let confidence = 0;
245
+ const reasons: string[] = [];
246
+
247
+ // Check if project name is in title or frontmatter
248
+ if (lowerContent.includes(project.toLowerCase())) {
249
+ confidence += 0.5;
250
+ reasons.push(`Project name "${project}" found in content`);
251
+ }
252
+
253
+ // Check keywords
254
+ const keywords = this.projectKeywords[project] || [];
255
+ let keywordMatches = 0;
256
+ for (const keyword of keywords) {
257
+ if (lowerContent.includes(keyword.toLowerCase())) {
258
+ keywordMatches++;
259
+ }
260
+ }
261
+ if (keywordMatches > 0) {
262
+ const keywordScore = Math.min(keywordMatches * 0.1, 0.4);
263
+ confidence += keywordScore;
264
+ reasons.push(`Found ${keywordMatches} keyword matches`);
265
+ }
266
+
267
+ // Check file references
268
+ const patterns = this.filePatterns[project] || [];
269
+ let patternMatches = 0;
270
+ for (const pattern of patterns) {
271
+ if (pattern.test(content)) {
272
+ patternMatches++;
273
+ }
274
+ }
275
+ if (patternMatches > 0) {
276
+ const patternScore = Math.min(patternMatches * 0.15, 0.3);
277
+ confidence += patternScore;
278
+ reasons.push(`Found ${patternMatches} file pattern matches`);
279
+ }
280
+
281
+ // Check for explicit project assignment
282
+ const projectAssignment = new RegExp(`project:\\s*${project}`, 'i');
283
+ if (projectAssignment.test(content)) {
284
+ confidence = 1.0; // Override with full confidence
285
+ reasons.push('Explicit project assignment in frontmatter');
286
+ }
287
+
288
+ results.push({ project, confidence, reasons });
289
+ }
290
+
291
+ // Sort by confidence (highest first)
292
+ return results.sort((a, b) => b.confidence - a.confidence);
293
+ }
294
+
295
+ /**
296
+ * Detect projects for multi-project spec
297
+ */
298
+ async detectMultiProject(content: string): Promise<ProjectDetectionResult> {
299
+ const candidates = this.analyzeContent(content);
300
+
301
+ // Get all projects with meaningful confidence
302
+ const significantProjects = candidates.filter(c => c.confidence > 0.3);
303
+
304
+ if (significantProjects.length === 0) {
305
+ return {
306
+ primary: this.availableProjects[0] || 'Unknown',
307
+ confidence: 0,
308
+ strategy: this.strategy
309
+ };
310
+ }
311
+
312
+ // Primary is highest confidence
313
+ const primary = significantProjects[0];
314
+
315
+ // Secondary are other significant projects
316
+ const secondary = significantProjects
317
+ .slice(1)
318
+ .map(c => c.project);
319
+
320
+ return {
321
+ primary: primary.project,
322
+ secondary: secondary.length > 0 ? secondary : undefined,
323
+ confidence: primary.confidence,
324
+ strategy: this.strategy
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Map spec to area path (for area-path-based strategy)
330
+ */
331
+ mapToAreaPath(content: string, project: string): string {
332
+ const areaPaths = process.env.AZURE_DEVOPS_AREA_PATHS?.split(',').map(a => a.trim()) || [];
333
+
334
+ for (const areaPath of areaPaths) {
335
+ if (content.toLowerCase().includes(areaPath.toLowerCase())) {
336
+ return `${project}\\${areaPath}`;
337
+ }
338
+ }
339
+
340
+ // Default area path
341
+ return project;
342
+ }
343
+
344
+ /**
345
+ * Assign to team (for team-based strategy)
346
+ */
347
+ assignToTeam(content: string): string {
348
+ const teams = process.env.AZURE_DEVOPS_TEAMS?.split(',').map(t => t.trim()) || [];
349
+
350
+ // Check for explicit team assignment
351
+ const teamMatch = content.match(/team:\s*([^\n]+)/i);
352
+ if (teamMatch) {
353
+ const assignedTeam = teamMatch[1].trim();
354
+ if (teams.includes(assignedTeam)) {
355
+ return assignedTeam;
356
+ }
357
+ }
358
+
359
+ // Auto-detect based on keywords
360
+ const teamKeywords: { [team: string]: string[] } = {
361
+ 'Frontend': ['ui', 'react', 'component', 'css', 'design'],
362
+ 'Backend': ['api', 'database', 'server', 'endpoint', 'query'],
363
+ 'Mobile': ['ios', 'android', 'app', 'native', 'push'],
364
+ 'DevOps': ['deploy', 'ci/cd', 'kubernetes', 'docker', 'pipeline'],
365
+ 'Data': ['analytics', 'etl', 'warehouse', 'bigquery', 'spark']
366
+ };
367
+
368
+ for (const team of teams) {
369
+ const keywords = teamKeywords[team] || [];
370
+ for (const keyword of keywords) {
371
+ if (content.toLowerCase().includes(keyword)) {
372
+ return team;
373
+ }
374
+ }
375
+ }
376
+
377
+ // Default to first team
378
+ return teams[0] || 'Default Team';
379
+ }
380
+ }
381
+
382
+ // ============================================================================
383
+ // Utility Functions
384
+ // ============================================================================
385
+
386
+ /**
387
+ * Get project detector from environment
388
+ */
389
+ export function getProjectDetectorFromEnv(): AdoProjectDetector {
390
+ const strategy = process.env.AZURE_DEVOPS_STRATEGY as AzureDevOpsStrategy || 'team-based';
391
+
392
+ let projects: string[] = [];
393
+
394
+ switch (strategy) {
395
+ case 'project-per-team':
396
+ projects = process.env.AZURE_DEVOPS_PROJECTS?.split(',').map(p => p.trim()) || [];
397
+ break;
398
+ case 'area-path-based':
399
+ case 'team-based':
400
+ const project = process.env.AZURE_DEVOPS_PROJECT;
401
+ if (project) {
402
+ projects = [project];
403
+ }
404
+ break;
405
+ }
406
+
407
+ return new AdoProjectDetector(strategy, projects);
408
+ }
409
+
410
+ /**
411
+ * Create project folders based on strategy
412
+ */
413
+ export async function createProjectFolders(
414
+ baseDir: string,
415
+ strategy: AzureDevOpsStrategy,
416
+ projects: string[]
417
+ ): Promise<void> {
418
+ const specsPath = path.join(baseDir, '.specweave', 'docs', 'internal', 'specs');
419
+
420
+ switch (strategy) {
421
+ case 'project-per-team':
422
+ // Create folder for each project
423
+ for (const project of projects) {
424
+ const projectPath = path.join(specsPath, project);
425
+ await fs.ensureDir(projectPath);
426
+ await createProjectReadme(projectPath, project);
427
+ }
428
+ break;
429
+
430
+ case 'area-path-based':
431
+ // Create folders for area paths
432
+ const areaPaths = process.env.AZURE_DEVOPS_AREA_PATHS?.split(',').map(a => a.trim()) || [];
433
+ const project = projects[0];
434
+ if (project) {
435
+ const projectPath = path.join(specsPath, project);
436
+ await fs.ensureDir(projectPath);
437
+
438
+ for (const area of areaPaths) {
439
+ const areaPath = path.join(projectPath, area);
440
+ await fs.ensureDir(areaPath);
441
+ }
442
+ }
443
+ break;
444
+
445
+ case 'team-based':
446
+ // Create folders for teams
447
+ const teams = process.env.AZURE_DEVOPS_TEAMS?.split(',').map(t => t.trim()) || [];
448
+ const proj = projects[0];
449
+ if (proj) {
450
+ const projectPath = path.join(specsPath, proj);
451
+ await fs.ensureDir(projectPath);
452
+
453
+ for (const team of teams) {
454
+ const teamPath = path.join(projectPath, team);
455
+ await fs.ensureDir(teamPath);
456
+ }
457
+ }
458
+ break;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Create README for project folder
464
+ */
465
+ async function createProjectReadme(projectPath: string, projectName: string): Promise<void> {
466
+ const readmePath = path.join(projectPath, 'README.md');
467
+
468
+ // Don't overwrite existing README
469
+ if (await fs.pathExists(readmePath)) {
470
+ return;
471
+ }
472
+
473
+ const content = `# ${projectName} Specifications
474
+
475
+ ## Overview
476
+
477
+ This folder contains specifications for the ${projectName} project.
478
+
479
+ ## Azure DevOps
480
+
481
+ - Organization: ${process.env.AZURE_DEVOPS_ORG || 'TBD'}
482
+ - Project: ${projectName}
483
+ - URL: https://dev.azure.com/${process.env.AZURE_DEVOPS_ORG || 'org'}/${projectName}
484
+
485
+ ## Specifications
486
+
487
+ _No specifications yet. Specs will appear here as they are created._
488
+
489
+ ## Team
490
+
491
+ - Lead: TBD
492
+ - Members: TBD
493
+
494
+ ## Keywords
495
+
496
+ ${PROJECT_KEYWORDS[projectName]?.join(', ') || 'TBD'}
497
+
498
+ ## Getting Started
499
+
500
+ 1. Create a new spec: \`/specweave:increment "feature-name"\`
501
+ 2. Specs will be organized here automatically
502
+ 3. Sync to Azure DevOps: \`/specweave-ado:sync-spec spec-001\`
503
+
504
+ ---
505
+
506
+ _Generated by SpecWeave_
507
+ `;
508
+
509
+ await fs.writeFile(readmePath, content);
510
+ }