teamspec 3.2.0 → 4.0.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.
Files changed (51) hide show
  1. package/README.md +24 -12
  2. package/bin/teamspec-init.js +2 -2
  3. package/lib/cli.js +653 -99
  4. package/lib/linter.js +823 -1076
  5. package/lib/prompt-generator.js +312 -330
  6. package/lib/structure-loader.js +400 -0
  7. package/package.json +14 -6
  8. package/teamspec-core/FOLDER_STRUCTURE.yml +131 -0
  9. package/teamspec-core/agents/AGENT_BA.md +188 -293
  10. package/teamspec-core/agents/AGENT_BOOTSTRAP.md +197 -102
  11. package/teamspec-core/agents/AGENT_DES.md +9 -8
  12. package/teamspec-core/agents/AGENT_DEV.md +68 -67
  13. package/teamspec-core/agents/AGENT_FA.md +437 -245
  14. package/teamspec-core/agents/AGENT_FIX.md +344 -74
  15. package/teamspec-core/agents/AGENT_PO.md +487 -0
  16. package/teamspec-core/agents/AGENT_QA.md +124 -98
  17. package/teamspec-core/agents/AGENT_SA.md +143 -84
  18. package/teamspec-core/agents/AGENT_SM.md +106 -83
  19. package/teamspec-core/agents/README.md +143 -93
  20. package/teamspec-core/copilot-instructions.md +281 -205
  21. package/teamspec-core/definitions/definition-of-done.md +47 -84
  22. package/teamspec-core/definitions/definition-of-ready.md +35 -60
  23. package/teamspec-core/registry.yml +898 -0
  24. package/teamspec-core/teamspec.yml +44 -28
  25. package/teamspec-core/templates/README.md +5 -5
  26. package/teamspec-core/templates/adr-template.md +19 -17
  27. package/teamspec-core/templates/bai-template.md +125 -0
  28. package/teamspec-core/templates/bug-report-template.md +21 -15
  29. package/teamspec-core/templates/business-analysis-template.md +16 -13
  30. package/teamspec-core/templates/decision-log-template.md +26 -22
  31. package/teamspec-core/templates/dev-plan-template.md +168 -0
  32. package/teamspec-core/templates/epic-template.md +204 -0
  33. package/teamspec-core/templates/feature-increment-template.md +84 -0
  34. package/teamspec-core/templates/feature-template.md +45 -32
  35. package/teamspec-core/templates/increments-index-template.md +53 -0
  36. package/teamspec-core/templates/product-template.yml +44 -0
  37. package/teamspec-core/templates/products-index-template.md +46 -0
  38. package/teamspec-core/templates/project-template.yml +70 -0
  39. package/teamspec-core/templates/ri-template.md +225 -0
  40. package/teamspec-core/templates/rt-template.md +104 -0
  41. package/teamspec-core/templates/sd-template.md +132 -0
  42. package/teamspec-core/templates/sdi-template.md +119 -0
  43. package/teamspec-core/templates/sprint-template.md +17 -15
  44. package/teamspec-core/templates/story-template-v4.md +202 -0
  45. package/teamspec-core/templates/story-template.md +48 -90
  46. package/teamspec-core/templates/ta-template.md +198 -0
  47. package/teamspec-core/templates/tai-template.md +131 -0
  48. package/teamspec-core/templates/tc-template.md +145 -0
  49. package/teamspec-core/templates/testcases-template.md +20 -17
  50. package/extensions/teamspec-0.1.0.vsix +0 -0
  51. package/lib/extension-installer.js +0 -236
package/lib/linter.js CHANGED
@@ -1,1184 +1,931 @@
1
1
  /**
2
2
  * TeamSpec Linter
3
- * Enforces TeamSpec Feature Canon operating model rules
4
3
  *
5
- * Rule Categories:
6
- * - TS-PROJ: Project structure and registration
7
- * - TS-FEAT: Feature Canon integrity
8
- * - TS-STORY: Story format and delta compliance
9
- * - TS-ADR: Architecture decisions
10
- * - TS-DEVPLAN: Development planning
11
- * - TS-DOD: Definition of Done gates
12
- * - TS-NAMING: Naming conventions (from PROJECT_STRUCTURE.yml)
4
+ * Validates project artifacts against TeamSpec 4.0 rules.
5
+ * Rules are defined in spec/4.0/lint-rules.md and registry.yml
6
+ *
7
+ * Version: 4.0
13
8
  */
14
9
 
15
10
  const fs = require('fs');
16
11
  const path = require('path');
12
+ const { loadRegistry, loadFolderStructure, getArtifactPatterns, getArtifactNamingRegex } = require('./structure-loader');
17
13
 
18
14
  // =============================================================================
19
- // Severity Levels
15
+ // SEVERITY LEVELS
20
16
  // =============================================================================
21
17
 
22
18
  const SEVERITY = {
23
- ERROR: 'error',
24
- BLOCKER: 'blocker',
25
- WARNING: 'warning',
26
- INFO: 'info',
19
+ BLOCKER: 'blocker',
20
+ ERROR: 'error',
21
+ WARNING: 'warning',
22
+ INFO: 'info'
27
23
  };
28
24
 
29
25
  // =============================================================================
30
- // Naming Patterns (from PROJECT_STRUCTURE.yml)
26
+ // LINT RESULT
31
27
  // =============================================================================
32
28
 
33
- const NAMING_PATTERNS = {
34
- feature: /^F-\d{3,}-[a-z][a-z0-9-]*\.md$/,
35
- story: /^S-\d{3,}-[a-z][a-z0-9-]*\.md$/,
36
- adr: /^ADR-\d{3,}-[a-z][a-z0-9-]*\.md$/,
37
- decision: /^DECISION-\d{3,}-[a-z][a-z0-9-]*\.md$/,
38
- epic: /^EPIC-\d{3,}-[a-z][a-z0-9-]*\.md$/,
39
- devPlan: /^story-\d{3,}-tasks\.md$/,
40
- sprint: /^sprint-\d+$/,
41
- };
29
+ class LintResult {
30
+ constructor() {
31
+ this.errors = [];
32
+ this.warnings = [];
33
+ this.info = [];
34
+ }
35
+
36
+ add(rule, message, file, severity = SEVERITY.ERROR) {
37
+ const result = { rule, message, file, severity };
38
+
39
+ switch (severity) {
40
+ case SEVERITY.BLOCKER:
41
+ case SEVERITY.ERROR:
42
+ this.errors.push(result);
43
+ break;
44
+ case SEVERITY.WARNING:
45
+ this.warnings.push(result);
46
+ break;
47
+ case SEVERITY.INFO:
48
+ this.info.push(result);
49
+ break;
50
+ }
51
+ }
52
+
53
+ hasErrors() {
54
+ return this.errors.length > 0;
55
+ }
56
+
57
+ hasBlockers() {
58
+ return this.errors.some(e => e.severity === SEVERITY.BLOCKER);
59
+ }
60
+
61
+ getAll() {
62
+ return [...this.errors, ...this.warnings, ...this.info];
63
+ }
64
+
65
+ getSummary() {
66
+ return {
67
+ errors: this.errors.length,
68
+ warnings: this.warnings.length,
69
+ info: this.info.length,
70
+ total: this.getAll().length,
71
+ passed: !this.hasErrors()
72
+ };
73
+ }
74
+ }
42
75
 
43
76
  // =============================================================================
44
- // Required Sections
77
+ // YAML PARSER (Simple)
45
78
  // =============================================================================
46
79
 
47
- const FEATURE_REQUIRED_SECTIONS = [
48
- 'Purpose',
49
- 'Scope|In Scope',
50
- 'Actors|Personas|Users',
51
- 'Main Flow|Current Behavior|Behavior',
52
- 'Business Rules|Rules',
53
- 'Edge Cases|Exceptions|Error Handling',
54
- 'Non-Goals|Out of Scope',
55
- 'Change Log|Story Ledger|Changelog',
56
- ];
57
-
58
- const STORY_FORBIDDEN_HEADINGS = [
59
- 'Full Specification',
60
- 'Complete Requirements',
61
- 'End-to-End Behavior',
62
- 'Full Flow',
63
- ];
64
-
65
- const PLACEHOLDER_PATTERNS = [
66
- /\{TBD\}/i,
67
- /\bTBD\b/,
68
- /\?\?\?/,
69
- /lorem ipsum/i,
70
- /to be defined/i,
71
- /\bplaceholder\b/i,
72
- ];
80
+ function parseYamlSimple(content) {
81
+ const result = {};
82
+ const lines = content.split('\n');
83
+
84
+ for (const line of lines) {
85
+ if (!line.trim() || line.trim().startsWith('#')) continue;
86
+
87
+ const match = line.match(/^\s*(\w+[\w.-]*):\s*(.*)$/);
88
+ if (match) {
89
+ let [, key, value] = match;
90
+ value = value.trim().replace(/^["']|["']$/g, '');
91
+
92
+ // Handle nested keys
93
+ if (key.includes('.')) {
94
+ const parts = key.split('.');
95
+ let obj = result;
96
+ for (let i = 0; i < parts.length - 1; i++) {
97
+ if (!obj[parts[i]]) obj[parts[i]] = {};
98
+ obj = obj[parts[i]];
99
+ }
100
+ obj[parts[parts.length - 1]] = value;
101
+ } else {
102
+ result[key] = value;
103
+ }
104
+ }
105
+ }
106
+
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Parse YAML frontmatter from markdown files
112
+ */
113
+ function parseFrontmatter(content) {
114
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
115
+ if (!match) return {};
116
+ return parseYamlSimple(match[1]);
117
+ }
118
+
119
+ /**
120
+ * Parse product.yml or project.yml
121
+ */
122
+ function parseConfigYaml(filePath) {
123
+ if (!fs.existsSync(filePath)) return null;
124
+
125
+ try {
126
+ const content = fs.readFileSync(filePath, 'utf-8');
127
+ const result = {};
128
+ const lines = content.split('\n');
129
+ let currentSection = null;
130
+ let inArray = false;
131
+ let arrayKey = null;
132
+
133
+ for (const line of lines) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed || trimmed.startsWith('#')) continue;
136
+
137
+ // Handle array items
138
+ if (trimmed.startsWith('- ')) {
139
+ if (arrayKey && result[arrayKey]) {
140
+ result[arrayKey].push(trimmed.slice(2).trim());
141
+ }
142
+ continue;
143
+ }
144
+
145
+ const colonIdx = trimmed.indexOf(':');
146
+ if (colonIdx === -1) continue;
147
+
148
+ const key = trimmed.slice(0, colonIdx).trim();
149
+ let value = trimmed.slice(colonIdx + 1).trim();
150
+
151
+ // Handle section headers (product:, project:)
152
+ if (value === '' || value === '|') {
153
+ currentSection = key;
154
+ continue;
155
+ }
156
+
157
+ // Handle arrays
158
+ if (value === '[]') {
159
+ result[key] = [];
160
+ arrayKey = key;
161
+ continue;
162
+ }
163
+
164
+ // Clean quoted values
165
+ value = value.replace(/^["']|["']$/g, '');
166
+
167
+ result[key] = value;
168
+ }
169
+
170
+ return result;
171
+ } catch (err) {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // =============================================================================
177
+ // NAMING PATTERN VALIDATION
178
+ // =============================================================================
179
+
180
+ // Cache for naming patterns loaded from registry
181
+ let _namingPatterns = null;
182
+ let _registry = null;
183
+
184
+ // Fallback hardcoded patterns (used when registry unavailable)
185
+ const FALLBACK_PATTERNS = {
186
+ 'feature': /^f-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
187
+ 'business-analysis': /^ba-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
188
+ 'solution-design': /^sd-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
189
+ 'technical-architecture': /^ta-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
190
+ 'product-decision': /^dec-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
191
+ 'product-regression-test': /^rt-f-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
192
+ 'feature-increment': /^fi-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
193
+ 'epic': /^epic-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
194
+ 'story': /^s-e\d{3}-\d{3}-[\w-]+\.md$/,
195
+ 'dev-plan': /^dp-e\d{3}-s\d{3}-[\w-]+\.md$/,
196
+ 'project-test-case': /^tc-fi-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
197
+ 'ba-increment': /^bai-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
198
+ 'sd-increment': /^sdi-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
199
+ 'ta-increment': /^tai-[A-Z]{3,4}-\d{3}-[\w-]+\.md$/,
200
+ 'bug-report': /^bug-[\w-]+-\d{3}-[\w-]+\.md$/,
201
+ 'regression-impact': /^ri-fi-[A-Z]{3,4}-\d{3}\.md$/
202
+ };
203
+
204
+ /**
205
+ * Get naming patterns from registry (cached)
206
+ * Falls back to hardcoded patterns if registry unavailable
207
+ */
208
+ function getNamingPatterns(workspaceDir) {
209
+ // Return fallback patterns if no workspace
210
+ if (!workspaceDir) {
211
+ return FALLBACK_PATTERNS;
212
+ }
213
+
214
+ if (_namingPatterns && _registry) {
215
+ return _namingPatterns;
216
+ }
217
+
218
+ // Try to load from registry
219
+ try {
220
+ const registry = loadRegistry(workspaceDir);
221
+ if (registry?.artifacts) {
222
+ _registry = registry;
223
+ _namingPatterns = getArtifactNamingRegex(registry);
224
+
225
+ // Fill in any missing patterns from fallback
226
+ for (const key of Object.keys(FALLBACK_PATTERNS)) {
227
+ if (!_namingPatterns[key]) {
228
+ _namingPatterns[key] = FALLBACK_PATTERNS[key];
229
+ }
230
+ }
231
+ return _namingPatterns;
232
+ }
233
+ } catch (err) {
234
+ // Fall through to fallback
235
+ }
236
+
237
+ // Fallback to hardcoded patterns if registry not available
238
+ return FALLBACK_PATTERNS;
239
+ }
240
+
241
+ /**
242
+ * Reset cached patterns (for testing)
243
+ */
244
+ function resetNamingPatterns() {
245
+ _namingPatterns = null;
246
+ _registry = null;
247
+ }
73
248
 
74
249
  // =============================================================================
75
- // Helper Functions
250
+ // RULE IMPLEMENTATIONS
76
251
  // =============================================================================
77
252
 
78
253
  /**
79
- * Parse YAML-like frontmatter from markdown
254
+ * TS-PROD-001: Product folder must be registered
80
255
  */
81
- function parseYamlFrontmatter(content) {
82
- const yamlMatch = content.match(/^---\n([\s\S]*?)\n---/);
83
- if (!yamlMatch) return {};
84
-
85
- const yaml = {};
86
- const lines = yamlMatch[1].split('\n');
87
- for (const line of lines) {
88
- const match = line.match(/^([^:]+):\s*(.*)$/);
89
- if (match) {
90
- yaml[match[1].trim()] = match[2].trim();
256
+ function checkProductRegistered(workspaceDir, productId, result) {
257
+ const indexPath = path.join(workspaceDir, 'products', 'products-index.md');
258
+
259
+ if (!fs.existsSync(indexPath)) {
260
+ result.add('TS-PROD-001', `products-index.md does not exist`, indexPath, SEVERITY.WARNING);
261
+ return;
262
+ }
263
+
264
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
265
+ if (!indexContent.includes(productId)) {
266
+ result.add('TS-PROD-001', `Product '${productId}' is not registered in products-index.md`, indexPath, SEVERITY.ERROR);
91
267
  }
92
- }
93
- return yaml;
94
268
  }
95
269
 
96
270
  /**
97
- * Parse simple YAML file
271
+ * TS-PROD-002: product.yml required with PRX
98
272
  */
99
- function parseSimpleYaml(content) {
100
- const result = {};
101
- const lines = content.split('\n');
102
- let currentKey = null;
103
- let currentArray = null;
104
-
105
- for (const line of lines) {
106
- const trimmed = line.trim();
107
- if (!trimmed || trimmed.startsWith('#')) continue;
108
-
109
- // Array item
110
- if (trimmed.startsWith('- ')) {
111
- if (currentArray) {
112
- currentArray.push(trimmed.slice(2).trim());
113
- }
114
- continue;
273
+ function checkProductYml(productDir, productId, result) {
274
+ const ymlPath = path.join(productDir, 'product.yml');
275
+
276
+ if (!fs.existsSync(ymlPath)) {
277
+ result.add('TS-PROD-002', `product.yml is missing for product '${productId}'`, ymlPath, SEVERITY.ERROR);
278
+ return null;
115
279
  }
116
-
117
- // Key-value pair
118
- const match = trimmed.match(/^([^:]+):\s*(.*)$/);
119
- if (match) {
120
- const key = match[1].trim();
121
- const value = match[2].trim();
122
-
123
- if (value === '' || value === '[]') {
124
- result[key] = [];
125
- currentArray = result[key];
126
- currentKey = key;
127
- } else {
128
- result[key] = value;
129
- currentArray = null;
130
- currentKey = key;
131
- }
280
+
281
+ const config = parseConfigYaml(ymlPath);
282
+ if (!config) {
283
+ result.add('TS-PROD-002', `product.yml could not be parsed for product '${productId}'`, ymlPath, SEVERITY.ERROR);
284
+ return null;
285
+ }
286
+
287
+ // Check required fields
288
+ const requiredFields = ['id', 'name', 'prefix'];
289
+ for (const field of requiredFields) {
290
+ if (!config[field]) {
291
+ result.add('TS-PROD-002', `product.yml is missing required field: '${field}'`, ymlPath, SEVERITY.ERROR);
292
+ }
132
293
  }
133
- }
134
-
135
- return result;
294
+
295
+ // Check PRX format
296
+ if (config.prefix && !/^[A-Z]{3,4}$/.test(config.prefix)) {
297
+ result.add('TS-PROD-002', `PRX '${config.prefix}' does not match pattern [A-Z]{3,4}`, ymlPath, SEVERITY.ERROR);
298
+ }
299
+
300
+ return config;
136
301
  }
137
302
 
138
303
  /**
139
- * Extract headings from markdown
304
+ * TS-PROJ-001: Project folder must be registered
140
305
  */
141
- function extractHeadings(content) {
142
- const headings = [];
143
- const lines = content.split(/\r?\n/);
144
- for (const line of lines) {
145
- const match = line.match(/^(#{1,6})\s+(.+?)[\r\s]*$/);
146
- if (match) {
147
- headings.push({
148
- level: match[1].length,
149
- text: match[2].trim(),
150
- });
306
+ function checkProjectRegistered(workspaceDir, projectId, result) {
307
+ const indexPath = path.join(workspaceDir, 'projects', 'projects-index.md');
308
+
309
+ if (!fs.existsSync(indexPath)) {
310
+ // projects-index.md is optional
311
+ return;
312
+ }
313
+
314
+ const indexContent = fs.readFileSync(indexPath, 'utf-8');
315
+ if (!indexContent.includes(projectId)) {
316
+ result.add('TS-PROJ-001', `Project '${projectId}' is not registered in projects-index.md`, indexPath, SEVERITY.WARNING);
151
317
  }
152
- }
153
- return headings;
154
318
  }
155
319
 
156
320
  /**
157
- * Check if content contains a pattern
321
+ * TS-PROJ-002: project.yml required with minimum metadata
158
322
  */
159
- function containsPattern(content, pattern) {
160
- if (typeof pattern === 'string') {
161
- return content.includes(pattern);
162
- }
163
- return pattern.test(content);
323
+ function checkProjectYml(projectDir, projectId, result) {
324
+ const ymlPath = path.join(projectDir, 'project.yml');
325
+
326
+ if (!fs.existsSync(ymlPath)) {
327
+ result.add('TS-PROJ-002', `project.yml is missing for project '${projectId}'`, ymlPath, SEVERITY.ERROR);
328
+ return null;
329
+ }
330
+
331
+ const config = parseConfigYaml(ymlPath);
332
+ if (!config) {
333
+ result.add('TS-PROJ-002', `project.yml could not be parsed for project '${projectId}'`, ymlPath, SEVERITY.ERROR);
334
+ return null;
335
+ }
336
+
337
+ // Check required fields
338
+ const requiredFields = ['id', 'name', 'status'];
339
+ for (const field of requiredFields) {
340
+ if (!config[field]) {
341
+ result.add('TS-PROJ-002', `project.yml is missing required field: '${field}'`, ymlPath, SEVERITY.ERROR);
342
+ }
343
+ }
344
+
345
+ return config;
164
346
  }
165
347
 
166
348
  /**
167
- * Extract checkboxes from markdown
349
+ * TS-FI-001: Feature-Increment must have AS-IS and TO-BE sections
168
350
  */
169
- function extractCheckboxes(content, sectionHeading = null) {
170
- let searchContent = content;
171
-
172
- if (sectionHeading) {
173
- const sectionPattern = new RegExp(`##\\s+(${sectionHeading})\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i');
174
- const match = content.match(sectionPattern);
175
- if (match) {
176
- searchContent = match[2];
177
- } else {
178
- return [];
351
+ function checkFIContent(filePath, result) {
352
+ const content = fs.readFileSync(filePath, 'utf-8');
353
+ const filename = path.basename(filePath);
354
+
355
+ // Check for AS-IS section
356
+ if (!content.includes('## AS-IS') && !content.includes('## 2. AS-IS') && !content.includes('## Current State')) {
357
+ result.add('TS-FI-001', `Feature-Increment '${filename}' is missing AS-IS section`, filePath, SEVERITY.ERROR);
358
+ }
359
+
360
+ // Check for TO-BE section
361
+ if (!content.includes('## TO-BE') && !content.includes('## 3. TO-BE') && !content.includes('## Proposed State')) {
362
+ result.add('TS-FI-001', `Feature-Increment '${filename}' is missing TO-BE section`, filePath, SEVERITY.ERROR);
363
+ }
364
+ }
365
+
366
+ /**
367
+ * TS-FI-002: Feature-Increment must link to target Feature
368
+ */
369
+ function checkFIFeatureLink(filePath, workspaceDir, result) {
370
+ const content = fs.readFileSync(filePath, 'utf-8');
371
+ const filename = path.basename(filePath);
372
+
373
+ // Look for feature reference (f-PRX-NNN pattern)
374
+ const featureRef = content.match(/f-[A-Z]{3,4}-\d{3}/);
375
+
376
+ if (!featureRef) {
377
+ result.add('TS-FI-002', `Feature-Increment '${filename.replace('.md', '')}' does not reference a target feature`, filePath, SEVERITY.ERROR);
378
+ return;
379
+ }
380
+
381
+ // Verify the referenced feature exists
382
+ const featurePattern = new RegExp(`${featureRef[0]}[\\w-]*\\.md`);
383
+ const productsDir = path.join(workspaceDir, 'products');
384
+
385
+ if (fs.existsSync(productsDir)) {
386
+ let featureFound = false;
387
+ const products = fs.readdirSync(productsDir, { withFileTypes: true })
388
+ .filter(d => d.isDirectory())
389
+ .map(d => d.name);
390
+
391
+ for (const prod of products) {
392
+ const featuresDir = path.join(productsDir, prod, 'features');
393
+ if (fs.existsSync(featuresDir)) {
394
+ const features = fs.readdirSync(featuresDir);
395
+ if (features.some(f => featurePattern.test(f))) {
396
+ featureFound = true;
397
+ break;
398
+ }
399
+ }
400
+ }
401
+
402
+ if (!featureFound) {
403
+ result.add('TS-FI-002', `Feature '${featureRef[0]}' referenced by FI does not exist`, filePath, SEVERITY.WARNING);
404
+ }
405
+ }
406
+ }
407
+
408
+ /**
409
+ * TS-EPIC-001: Epic file naming convention
410
+ */
411
+ function checkEpicNaming(filePath, result, workspaceDir = null) {
412
+ const filename = path.basename(filePath);
413
+ const patterns = getNamingPatterns(workspaceDir);
414
+ const pattern = patterns.epic || patterns['epic'];
415
+
416
+ if (pattern && !pattern.test(filename)) {
417
+ result.add('TS-EPIC-001', `Epic file '${filename}' does not match naming convention: epic-PRX-XXX-description.md`, filePath, SEVERITY.ERROR);
418
+ }
419
+ }
420
+
421
+ /**
422
+ * TS-STORY-001: Story must link to Epic via filename
423
+ */
424
+ function checkStoryEpicLink(filePath, projectDir, result, workspaceDir = null) {
425
+ const filename = path.basename(filePath);
426
+ const patterns = getNamingPatterns(workspaceDir);
427
+ const pattern = patterns.story || patterns['story'];
428
+
429
+ // Check naming pattern
430
+ if (pattern && !pattern.test(filename)) {
431
+ result.add('TS-STORY-001', `Story '${filename}' does not match naming convention: s-eXXX-YYY-description.md`, filePath, SEVERITY.ERROR);
432
+ return;
433
+ }
434
+
435
+ // Extract epic number
436
+ const epicMatch = filename.match(/s-e(\d{3})-/);
437
+ if (epicMatch) {
438
+ const epicNum = epicMatch[1];
439
+ const epicsDir = path.join(projectDir, 'epics');
440
+
441
+ if (fs.existsSync(epicsDir)) {
442
+ const epics = fs.readdirSync(epicsDir);
443
+ const epicExists = epics.some(e => e.includes(`-${epicNum}-`) || e.includes(`-${parseInt(epicNum)}-`));
444
+
445
+ if (!epicExists) {
446
+ result.add('TS-STORY-001', `Story '${filename}' references non-existent epic ${epicNum}`, filePath, SEVERITY.ERROR);
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * TS-STORY-002: Story describes delta, not full behavior
454
+ */
455
+ function checkStoryDelta(filePath, result) {
456
+ const content = fs.readFileSync(filePath, 'utf-8');
457
+ const filename = path.basename(filePath);
458
+
459
+ // Look for delta language
460
+ const deltaTerms = ['adds', 'changes', 'removes', 'modifies', 'updates', 'introduces', 'replaces'];
461
+ const hasDeltaLanguage = deltaTerms.some(term =>
462
+ content.toLowerCase().includes(term)
463
+ );
464
+
465
+ // Look for Feature-Increment reference
466
+ const hasFIRef = /fi-[A-Z]{3,4}-\d{3}/.test(content);
467
+
468
+ if (!hasDeltaLanguage && !hasFIRef) {
469
+ result.add('TS-STORY-002', `Story '${filename.replace('.md', '')}' appears to describe full behavior instead of delta`, filePath, SEVERITY.WARNING);
179
470
  }
180
- }
181
-
182
- const checkboxes = [];
183
- const regex = /- \[([ xX])\]\s*(.+)/g;
184
- let match;
185
-
186
- while ((match = regex.exec(searchContent)) !== null) {
187
- checkboxes.push({
188
- checked: match[1].toLowerCase() === 'x',
189
- text: match[2].trim(),
190
- });
191
- }
192
-
193
- return checkboxes;
194
471
  }
195
472
 
196
473
  /**
197
- * Extract feature ID from story content
474
+ * TS-NAMING-*: Artifact naming conventions
198
475
  */
199
- function extractFeatureLinks(content) {
200
- const links = [];
201
- const patterns = [
202
- /\[F-(\d{3,})/g,
203
- /F-(\d{3,})/g,
204
- ];
205
-
206
- for (const pattern of patterns) {
207
- let match;
208
- while ((match = pattern.exec(content)) !== null) {
209
- links.push(`F-${match[1]}`);
476
+ function checkArtifactNaming(filePath, artifactType, result, workspaceDir = null) {
477
+ const filename = path.basename(filePath);
478
+ const patterns = getNamingPatterns(workspaceDir);
479
+ const pattern = patterns[artifactType];
480
+
481
+ if (pattern && !pattern.test(filename)) {
482
+ const ruleId = `TS-NAMING-${artifactType.toUpperCase().replace(/-/g, '')}`;
483
+ result.add(ruleId, `File '${filename}' does not match naming convention for ${artifactType}`, filePath, SEVERITY.ERROR);
210
484
  }
211
- }
212
-
213
- return [...new Set(links)];
214
485
  }
215
486
 
216
487
  /**
217
- * Extract story ID from filename or content
488
+ * TS-DOD-001: Story must have all AC verified
218
489
  */
219
- function extractStoryId(filename, content) {
220
- // Try filename first
221
- const filenameMatch = filename.match(/S-(\d{3,})/);
222
- if (filenameMatch) return filenameMatch[1];
223
-
224
- // Try content
225
- const contentMatch = content.match(/# Story: S-(\d{3,})/);
226
- if (contentMatch) return contentMatch[1];
227
-
228
- return null;
490
+ function checkDoneStoryAC(filePath, result) {
491
+ const content = fs.readFileSync(filePath, 'utf-8');
492
+ const filename = path.basename(filePath);
493
+
494
+ // Check for unverified AC (unchecked checkboxes)
495
+ const hasUnverifiedAC = /- \[ \]/.test(content);
496
+
497
+ if (hasUnverifiedAC) {
498
+ result.add('TS-DOD-001', `Story '${filename.replace('.md', '')}' has unverified acceptance criteria`, filePath, SEVERITY.ERROR);
499
+ }
229
500
  }
230
501
 
231
502
  /**
232
- * Get metadata from markdown (bold fields like **Status:** value)
503
+ * TS-DOD-003: Product sync after deployment
233
504
  */
234
- function extractMetadata(content) {
235
- const metadata = {};
236
- const patterns = [
237
- // Pattern: **Key:** Value (colon inside bold)
238
- /\*\*([^*:]+):\*\*\s*(.+)/g,
239
- // Pattern: **Key**: Value (colon outside bold)
240
- /\*\*([^*]+)\*\*:\s*(.+)/g,
241
- // Pattern: Key: Value at line start
242
- /^([A-Za-z ]+):\s*(.+)/gm,
243
- ];
244
-
245
- for (const pattern of patterns) {
246
- let match;
247
- while ((match = pattern.exec(content)) !== null) {
248
- const key = match[1].trim().replace(/:$/, ''); // Remove trailing colon if any
249
- const value = match[2].trim();
250
- if (!metadata[key]) { // Don't overwrite existing keys
251
- metadata[key] = value;
252
- }
505
+ function checkCanonSync(projectConfig, projectDir, result) {
506
+ if (projectConfig.deployed_date && !projectConfig.canon_synced) {
507
+ result.add('TS-DOD-003', `Project deployed but Product Canon not synced. Run ts:po sync`, path.join(projectDir, 'project.yml'), SEVERITY.BLOCKER);
253
508
  }
254
- }
255
-
256
- return metadata;
257
509
  }
258
510
 
259
511
  /**
260
- * Recursively find files matching a pattern
512
+ * TS-QA-001: Deployed Feature-Increment must have test coverage
261
513
  */
262
- function findFiles(dir, pattern, results = []) {
263
- if (!fs.existsSync(dir)) return results;
264
-
265
- const entries = fs.readdirSync(dir, { withFileTypes: true });
266
-
267
- for (const entry of entries) {
268
- const fullPath = path.join(dir, entry.name);
269
-
270
- if (entry.isDirectory()) {
271
- findFiles(fullPath, pattern, results);
272
- } else if (pattern.test(entry.name)) {
273
- results.push(fullPath);
514
+ function checkFITestCoverage(fiFile, projectDir, result) {
515
+ const fiName = path.basename(fiFile, '.md');
516
+ const fiMatch = fiName.match(/^fi-([A-Z]{3,4}-\d{3})/);
517
+
518
+ if (!fiMatch) return;
519
+
520
+ const tcDir = path.join(projectDir, 'qa', 'test-cases');
521
+ if (!fs.existsSync(tcDir)) {
522
+ result.add('TS-QA-001', `No test-cases directory for FI test coverage validation`, tcDir, SEVERITY.WARNING);
523
+ return;
524
+ }
525
+
526
+ const tcFiles = fs.readdirSync(tcDir);
527
+ const hasTestCase = tcFiles.some(tc => tc.includes(fiMatch[1]));
528
+
529
+ if (!hasTestCase) {
530
+ result.add('TS-QA-001', `Deployed FI '${fiName}' has no test coverage. Expected: tc-fi-${fiMatch[1]}-*.md`, fiFile, SEVERITY.WARNING);
274
531
  }
275
- }
276
-
277
- return results;
278
532
  }
279
533
 
280
534
  /**
281
- * Find all projects in workspace
535
+ * TS-QA-003: Regression impact must be recorded for each FI
282
536
  */
283
- function findProjects(workspaceDir) {
284
- const projectsDir = path.join(workspaceDir, 'projects');
285
- if (!fs.existsSync(projectsDir)) return [];
286
-
287
- const entries = fs.readdirSync(projectsDir, { withFileTypes: true });
288
- return entries
289
- .filter(e => e.isDirectory() && e.name !== '.git')
290
- .map(e => e.name);
537
+ function checkRegressionImpact(fiFile, projectDir, workspaceDir, result) {
538
+ const fiName = path.basename(fiFile, '.md');
539
+ const fiMatch = fiName.match(/^fi-([A-Z]{3,4})-(\d{3})/);
540
+
541
+ if (!fiMatch) return;
542
+
543
+ const prx = fiMatch[1];
544
+ const num = fiMatch[2];
545
+ const riDir = path.join(projectDir, 'qa', 'regression-impact');
546
+ const riFile = path.join(riDir, `ri-fi-${prx}-${num}.md`);
547
+
548
+ if (!fs.existsSync(riFile)) {
549
+ result.add('TS-QA-003', `FI '${fiName}' has no regression impact record. Create ri-fi-${prx}-${num}.md`, fiFile, SEVERITY.ERROR);
550
+ return;
551
+ }
552
+
553
+ // Check ri file content
554
+ const riContent = fs.readFileSync(riFile, 'utf-8');
555
+
556
+ // Check for assessment field
557
+ const assessmentMatch = riContent.match(/assessment:\s*([\w-]+)/);
558
+ if (!assessmentMatch) {
559
+ result.add('TS-QA-003', `ri-fi-${prx}-${num}.md is missing 'assessment' field`, riFile, SEVERITY.ERROR);
560
+ return;
561
+ }
562
+
563
+ const assessment = assessmentMatch[1];
564
+
565
+ if (assessment === 'update-required') {
566
+ // Check for regression_tests field
567
+ if (!riContent.includes('regression_tests:')) {
568
+ result.add('TS-QA-003', `ri-fi-${prx}-${num}.md has assessment: update-required but no regression_tests listed`, riFile, SEVERITY.ERROR);
569
+ } else {
570
+ // Check that listed rt files exist
571
+ const rtMatches = riContent.match(/rt-f-[A-Z]{3,4}-\d{3}[\w-]*\.md/g) || [];
572
+ for (const rtFile of rtMatches) {
573
+ const rtFound = findRegressionTest(workspaceDir, prx, rtFile);
574
+ if (!rtFound) {
575
+ result.add('TS-QA-003', `ri-fi-${prx}-${num}.md lists ${rtFile} but file does not exist`, riFile, SEVERITY.ERROR);
576
+ }
577
+ }
578
+ }
579
+ } else if (assessment === 'no-impact') {
580
+ // Check for rationale
581
+ if (!riContent.includes('rationale:') || !/rationale:\s*\S/.test(riContent)) {
582
+ result.add('TS-QA-003', `ri-fi-${prx}-${num}.md has assessment: no-impact but no rationale provided`, riFile, SEVERITY.ERROR);
583
+ }
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Find a regression test file in products
589
+ */
590
+ function findRegressionTest(workspaceDir, prx, rtFilename) {
591
+ const productsDir = path.join(workspaceDir, 'products');
592
+ if (!fs.existsSync(productsDir)) return false;
593
+
594
+ const products = fs.readdirSync(productsDir, { withFileTypes: true })
595
+ .filter(d => d.isDirectory())
596
+ .map(d => d.name);
597
+
598
+ for (const prod of products) {
599
+ const rtDir = path.join(productsDir, prod, 'qa', 'regression-tests');
600
+ if (fs.existsSync(rtDir)) {
601
+ const rtPath = path.join(rtDir, rtFilename);
602
+ if (fs.existsSync(rtPath)) return true;
603
+ }
604
+ }
605
+
606
+ return false;
291
607
  }
292
608
 
293
609
  // =============================================================================
294
- // Rule Definitions
610
+ // MAIN LINTER
295
611
  // =============================================================================
296
612
 
297
- const rules = {
298
- // -------------------------------------------------------------------------
299
- // Project Rules (TS-PROJ)
300
- // -------------------------------------------------------------------------
301
-
302
- 'TS-PROJ-001': {
303
- id: 'TS-PROJ-001',
304
- name: 'Project folder must be registered',
305
- severity: SEVERITY.ERROR,
306
- owner: 'BA',
307
- async check(ctx) {
308
- const results = [];
309
- const indexPath = path.join(ctx.workspaceDir, 'projects', 'projects-index.md');
310
-
311
- if (!fs.existsSync(indexPath)) {
312
- // If no index exists, skip (will be caught by other rules)
313
- return results;
314
- }
315
-
316
- const indexContent = fs.readFileSync(indexPath, 'utf-8');
317
-
318
- for (const projectId of ctx.projects) {
319
- if (!indexContent.includes(projectId)) {
320
- results.push({
321
- ruleId: 'TS-PROJ-001',
322
- severity: SEVERITY.ERROR,
323
- file: path.join(ctx.workspaceDir, 'projects', projectId),
324
- message: `Project '${projectId}' is not registered in projects-index.md`,
325
- owner: 'BA',
326
- });
327
- }
328
- }
329
-
330
- return results;
331
- },
332
- },
333
-
334
- 'TS-PROJ-002': {
335
- id: 'TS-PROJ-002',
336
- name: 'project.yml required with minimum metadata',
337
- severity: SEVERITY.ERROR,
338
- owner: 'BA',
339
- requiredFields: ['project_id', 'name', 'status', 'stakeholders', 'roles'],
340
- async check(ctx) {
341
- const results = [];
342
-
343
- for (const projectId of ctx.projects) {
344
- const ymlPath = path.join(ctx.workspaceDir, 'projects', projectId, 'project.yml');
345
-
346
- if (!fs.existsSync(ymlPath)) {
347
- results.push({
348
- ruleId: 'TS-PROJ-002',
349
- severity: SEVERITY.ERROR,
350
- file: ymlPath,
351
- message: `project.yml is missing for project '${projectId}'`,
352
- owner: 'BA',
353
- });
354
- continue;
613
+ /**
614
+ * Lint a TeamSpec workspace
615
+ * @param {string} workspaceDir - Path to workspace root
616
+ * @param {Object} options - Linting options
617
+ * @param {string} options.project - Specific project to lint
618
+ * @param {string} options.rule - Specific rule to check
619
+ * @returns {LintResult}
620
+ */
621
+ function lint(workspaceDir, options = {}) {
622
+ const result = new LintResult();
623
+
624
+ // Load registry for patterns (if available)
625
+ const registry = loadRegistry(workspaceDir);
626
+
627
+ // Lint products
628
+ const productsDir = path.join(workspaceDir, 'products');
629
+ if (fs.existsSync(productsDir)) {
630
+ const products = fs.readdirSync(productsDir, { withFileTypes: true })
631
+ .filter(d => d.isDirectory())
632
+ .map(d => d.name);
633
+
634
+ for (const productId of products) {
635
+ lintProduct(workspaceDir, productId, result, options);
355
636
  }
356
-
357
- const content = fs.readFileSync(ymlPath, 'utf-8');
358
- const yaml = parseSimpleYaml(content);
359
-
360
- for (const field of this.requiredFields) {
361
- if (!(field in yaml)) {
362
- results.push({
363
- ruleId: 'TS-PROJ-002',
364
- severity: SEVERITY.ERROR,
365
- file: ymlPath,
366
- message: `project.yml is missing required field: '${field}'`,
367
- owner: 'BA',
368
- });
369
- }
637
+ }
638
+
639
+ // Lint projects
640
+ const projectsDir = path.join(workspaceDir, 'projects');
641
+ if (fs.existsSync(projectsDir)) {
642
+ const projects = fs.readdirSync(projectsDir, { withFileTypes: true })
643
+ .filter(d => d.isDirectory())
644
+ .map(d => d.name);
645
+
646
+ for (const projectId of projects) {
647
+ if (options.project && options.project !== projectId) continue;
648
+ lintProject(workspaceDir, projectId, result, options);
370
649
  }
371
- }
372
-
373
- return results;
374
- },
375
- },
376
-
377
- // -------------------------------------------------------------------------
378
- // Feature Rules (TS-FEAT)
379
- // -------------------------------------------------------------------------
380
-
381
- 'TS-FEAT-001': {
382
- id: 'TS-FEAT-001',
383
- name: 'Feature file required for any story link',
384
- severity: SEVERITY.ERROR,
385
- owner: 'BA/FA',
386
- async check(ctx) {
387
- const results = [];
388
-
389
- for (const projectId of ctx.projects) {
390
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
391
- const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
392
- const storyFiles = findFiles(storiesDir, /\.md$/);
393
-
394
- for (const storyFile of storyFiles) {
395
- if (path.basename(storyFile) === 'README.md') continue;
396
-
397
- const content = fs.readFileSync(storyFile, 'utf-8');
398
- const featureLinks = extractFeatureLinks(content);
399
-
400
- for (const featureId of featureLinks) {
401
- const featurePattern = new RegExp(`^${featureId}-.*\\.md$`);
402
- const featureFiles = fs.existsSync(featuresDir)
403
- ? fs.readdirSync(featuresDir).filter(f => featurePattern.test(f))
404
- : [];
405
-
406
- if (featureFiles.length === 0) {
407
- results.push({
408
- ruleId: 'TS-FEAT-001',
409
- severity: SEVERITY.ERROR,
410
- file: storyFile,
411
- message: `Referenced feature '${featureId}' not found in features/`,
412
- owner: 'BA/FA',
413
- });
650
+ }
651
+
652
+ return result;
653
+ }
654
+
655
+ /**
656
+ * Lint a product folder
657
+ */
658
+ function lintProduct(workspaceDir, productId, result, options) {
659
+ const productDir = path.join(workspaceDir, 'products', productId);
660
+
661
+ // TS-PROD-001: Product registered
662
+ if (!options.rule || options.rule === 'TS-PROD-001') {
663
+ checkProductRegistered(workspaceDir, productId, result);
664
+ }
665
+
666
+ // TS-PROD-002: product.yml validation
667
+ if (!options.rule || options.rule === 'TS-PROD-002') {
668
+ const productConfig = checkProductYml(productDir, productId, result);
669
+
670
+ if (productConfig) {
671
+ // Lint features naming
672
+ const featuresDir = path.join(productDir, 'features');
673
+ if (fs.existsSync(featuresDir)) {
674
+ const features = fs.readdirSync(featuresDir)
675
+ .filter(f => f.endsWith('.md') && f !== 'features-index.md' && f !== 'story-ledger.md');
676
+
677
+ for (const feature of features) {
678
+ checkArtifactNaming(path.join(featuresDir, feature), 'feature', result, workspaceDir);
679
+ }
414
680
  }
415
- }
416
- }
417
- }
418
-
419
- return results;
420
- },
421
- },
422
-
423
- 'TS-FEAT-002': {
424
- id: 'TS-FEAT-002',
425
- name: 'Feature must include canon sections',
426
- severity: SEVERITY.ERROR,
427
- owner: 'BA/FA',
428
- async check(ctx) {
429
- const results = [];
430
-
431
- for (const projectId of ctx.projects) {
432
- const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
433
- if (!fs.existsSync(featuresDir)) continue;
434
-
435
- const featureFiles = findFiles(featuresDir, /^F-\d{3,}-.*\.md$/);
436
-
437
- for (const featureFile of featureFiles) {
438
- const content = fs.readFileSync(featureFile, 'utf-8');
439
- const headings = extractHeadings(content);
440
- const headingTexts = headings.map(h => h.text);
441
-
442
- for (const required of FEATURE_REQUIRED_SECTIONS) {
443
- const patterns = required.split('|');
444
- const found = patterns.some(p =>
445
- headingTexts.some(h => h.toLowerCase().includes(p.toLowerCase()))
446
- );
447
-
448
- if (!found) {
449
- results.push({
450
- ruleId: 'TS-FEAT-002',
451
- severity: SEVERITY.ERROR,
452
- file: featureFile,
453
- message: `Feature is missing required section: '${required}'`,
454
- owner: 'BA/FA',
455
- });
681
+
682
+ // Lint regression tests naming
683
+ const rtDir = path.join(productDir, 'qa', 'regression-tests');
684
+ if (fs.existsSync(rtDir)) {
685
+ const rtFiles = fs.readdirSync(rtDir).filter(f => f.endsWith('.md'));
686
+ for (const rt of rtFiles) {
687
+ checkArtifactNaming(path.join(rtDir, rt), 'product-regression-test', result, workspaceDir);
688
+ }
456
689
  }
457
- }
458
690
  }
459
- }
460
-
461
- return results;
462
- },
463
- },
464
-
465
- 'TS-FEAT-003': {
466
- id: 'TS-FEAT-003',
467
- name: 'Feature IDs must be unique within project',
468
- severity: SEVERITY.ERROR,
469
- owner: 'BA/FA',
470
- async check(ctx) {
471
- const results = [];
472
-
473
- for (const projectId of ctx.projects) {
474
- const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
475
- if (!fs.existsSync(featuresDir)) continue;
476
-
477
- const featureFiles = findFiles(featuresDir, /^F-\d{3,}-.*\.md$/);
478
- const idToFiles = new Map();
479
-
480
- for (const featureFile of featureFiles) {
481
- const match = path.basename(featureFile).match(/^(F-\d{3,})/);
482
- if (match) {
483
- const id = match[1];
484
- if (!idToFiles.has(id)) {
485
- idToFiles.set(id, []);
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Lint a project folder
696
+ */
697
+ function lintProject(workspaceDir, projectId, result, options) {
698
+ const projectDir = path.join(workspaceDir, 'projects', projectId);
699
+
700
+ // TS-PROJ-001: Project registered
701
+ if (!options.rule || options.rule === 'TS-PROJ-001') {
702
+ checkProjectRegistered(workspaceDir, projectId, result);
703
+ }
704
+
705
+ // TS-PROJ-002: project.yml validation
706
+ let projectConfig = null;
707
+ if (!options.rule || options.rule === 'TS-PROJ-002') {
708
+ projectConfig = checkProjectYml(projectDir, projectId, result);
709
+ } else {
710
+ projectConfig = parseConfigYaml(path.join(projectDir, 'project.yml'));
711
+ }
712
+
713
+ // TS-DOD-003: Canon sync check
714
+ if (projectConfig && (!options.rule || options.rule === 'TS-DOD-003')) {
715
+ checkCanonSync(projectConfig, projectDir, result);
716
+ }
717
+
718
+ // Lint Feature-Increments
719
+ const fiDir = path.join(projectDir, 'feature-increments');
720
+ if (fs.existsSync(fiDir)) {
721
+ const fiFiles = fs.readdirSync(fiDir).filter(f => f.endsWith('.md') && f !== 'increments-index.md');
722
+
723
+ for (const fi of fiFiles) {
724
+ const fiPath = path.join(fiDir, fi);
725
+
726
+ // TS-FI-001: AS-IS/TO-BE sections
727
+ if (!options.rule || options.rule === 'TS-FI-001') {
728
+ checkFIContent(fiPath, result);
486
729
  }
487
- idToFiles.get(id).push(featureFile);
488
- }
489
- }
490
-
491
- for (const [id, files] of idToFiles) {
492
- if (files.length > 1) {
493
- results.push({
494
- ruleId: 'TS-FEAT-003',
495
- severity: SEVERITY.ERROR,
496
- file: files[1],
497
- message: `Duplicate feature ID '${id}' found in: ${files.map(f => path.basename(f)).join(', ')}`,
498
- owner: 'BA/FA',
499
- });
500
- }
501
- }
502
- }
503
-
504
- return results;
505
- },
506
- },
507
-
508
- // -------------------------------------------------------------------------
509
- // Story Rules (TS-STORY)
510
- // -------------------------------------------------------------------------
511
-
512
- 'TS-STORY-001': {
513
- id: 'TS-STORY-001',
514
- name: 'Story must link to feature',
515
- severity: SEVERITY.ERROR,
516
- owner: 'FA',
517
- async check(ctx) {
518
- const results = [];
519
-
520
- for (const projectId of ctx.projects) {
521
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
522
- const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
523
-
524
- for (const storyFile of storyFiles) {
525
- const content = fs.readFileSync(storyFile, 'utf-8');
526
- const featureLinks = extractFeatureLinks(content);
527
-
528
- // Check for Linked Features section
529
- const hasLinkedSection = /##\s*(Linked Features?|Features?)/i.test(content);
530
-
531
- if (featureLinks.length === 0 && !hasLinkedSection) {
532
- results.push({
533
- ruleId: 'TS-STORY-001',
534
- severity: SEVERITY.ERROR,
535
- file: storyFile,
536
- message: 'Story has no feature link. Stories must link to at least one feature.',
537
- owner: 'FA',
538
- });
539
- }
540
- }
541
- }
542
-
543
- return results;
544
- },
545
- },
546
-
547
- 'TS-STORY-002': {
548
- id: 'TS-STORY-002',
549
- name: 'Story must describe delta-only behavior',
550
- severity: SEVERITY.ERROR,
551
- owner: 'FA',
552
- async check(ctx) {
553
- const results = [];
554
-
555
- for (const projectId of ctx.projects) {
556
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
557
- const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
558
-
559
- for (const storyFile of storyFiles) {
560
- const content = fs.readFileSync(storyFile, 'utf-8');
561
-
562
- // Check for Before/After pattern
563
- const hasBefore = /\b(Before|Current behavior).*:/i.test(content);
564
- const hasAfter = /\b(After|New behavior).*:/i.test(content);
565
-
566
- if (!hasBefore || !hasAfter) {
567
- results.push({
568
- ruleId: 'TS-STORY-002',
569
- severity: SEVERITY.ERROR,
570
- file: storyFile,
571
- message: 'Story must have Before/After sections describing delta behavior.',
572
- owner: 'FA',
573
- });
574
- }
575
-
576
- // Check for forbidden full-spec headings
577
- const headings = extractHeadings(content);
578
- for (const heading of headings) {
579
- for (const forbidden of STORY_FORBIDDEN_HEADINGS) {
580
- if (heading.text.toLowerCase().includes(forbidden.toLowerCase())) {
581
- results.push({
582
- ruleId: 'TS-STORY-002',
583
- severity: SEVERITY.ERROR,
584
- file: storyFile,
585
- message: `Story contains forbidden heading '${heading.text}'. Stories describe deltas, not full specifications.`,
586
- owner: 'FA',
587
- });
588
- }
730
+
731
+ // TS-FI-002: Feature link
732
+ if (!options.rule || options.rule === 'TS-FI-002') {
733
+ checkFIFeatureLink(fiPath, workspaceDir, result);
589
734
  }
590
- }
591
- }
592
- }
593
-
594
- return results;
595
- },
596
- },
597
-
598
- 'TS-STORY-003': {
599
- id: 'TS-STORY-003',
600
- name: 'Acceptance Criteria must be present and testable',
601
- severity: SEVERITY.ERROR,
602
- owner: 'FA',
603
- async check(ctx) {
604
- const results = [];
605
-
606
- for (const projectId of ctx.projects) {
607
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
608
- const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
609
-
610
- for (const storyFile of storyFiles) {
611
- const content = fs.readFileSync(storyFile, 'utf-8');
612
-
613
- // Check for AC section
614
- const hasAC = /##\s*Acceptance Criteria/i.test(content);
615
-
616
- if (!hasAC) {
617
- results.push({
618
- ruleId: 'TS-STORY-003',
619
- severity: SEVERITY.ERROR,
620
- file: storyFile,
621
- message: 'Acceptance Criteria section is missing.',
622
- owner: 'FA',
623
- });
624
- continue;
625
- }
626
-
627
- // Check for placeholders
628
- for (const pattern of PLACEHOLDER_PATTERNS) {
629
- if (pattern.test(content)) {
630
- results.push({
631
- ruleId: 'TS-STORY-003',
632
- severity: SEVERITY.ERROR,
633
- file: storyFile,
634
- message: `Story contains placeholder text (${pattern.source}). All content must be complete.`,
635
- owner: 'FA',
636
- });
637
- break;
735
+
736
+ // TS-NAMING-FI
737
+ if (!options.rule || options.rule === 'TS-NAMING-FI') {
738
+ checkArtifactNaming(fiPath, 'feature-increment', result, workspaceDir);
638
739
  }
639
- }
640
- }
641
- }
642
-
643
- return results;
644
- },
645
- },
646
-
647
- 'TS-STORY-004': {
648
- id: 'TS-STORY-004',
649
- name: 'Only SM can assign sprint',
650
- severity: SEVERITY.ERROR,
651
- owner: 'SM',
652
- async check(ctx) {
653
- const results = [];
654
-
655
- for (const projectId of ctx.projects) {
656
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
657
- const storyFiles = findFiles(storiesDir, /\.md$/);
658
-
659
- for (const storyFile of storyFiles) {
660
- if (path.basename(storyFile) === 'README.md') continue;
661
-
662
- const content = fs.readFileSync(storyFile, 'utf-8');
663
- const metadata = extractMetadata(content);
664
-
665
- // Check if sprint is assigned
666
- if (metadata.Sprint && metadata.Sprint !== '-' && metadata.Sprint !== 'None') {
667
- // Check for SM role in assignment - various patterns
668
- const hasSMAssignment = /Assigned By:.*Role:\s*SM/i.test(content) ||
669
- /Role:\s*SM.*Assigned/i.test(content) ||
670
- /\*\*Assigned By:\*\*.*SM/i.test(content) ||
671
- /Assigned By:.*SM\s*$/im.test(content);
672
-
673
- // Also fail if explicitly NOT SM
674
- const hasNonSMAssignment = /\*\*Assigned By:\*\*\s*(DEV|BA|FA|ARCH|QA)\s*(\(|$)/i.test(content);
675
-
676
- if (!hasSMAssignment || hasNonSMAssignment) {
677
- results.push({
678
- ruleId: 'TS-STORY-004',
679
- severity: SEVERITY.ERROR,
680
- file: storyFile,
681
- message: 'Sprint assignment must be done by SM role. Add "Assigned By: Role: SM".',
682
- owner: 'SM',
683
- });
740
+
741
+ // TS-QA-001: Test coverage (for deployed projects)
742
+ if (projectConfig?.status === 'deployed' || projectConfig?.deployed_date) {
743
+ if (!options.rule || options.rule === 'TS-QA-001') {
744
+ checkFITestCoverage(fiPath, projectDir, result);
745
+ }
684
746
  }
685
- }
686
- }
687
- }
688
-
689
- return results;
690
- },
691
- },
692
-
693
- 'TS-STORY-005': {
694
- id: 'TS-STORY-005',
695
- name: 'Ready for Development requires DoR checklist complete',
696
- severity: SEVERITY.ERROR,
697
- owner: 'FA',
698
- async check(ctx) {
699
- const results = [];
700
-
701
- for (const projectId of ctx.projects) {
702
- const readyDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories', 'ready-for-development');
703
- if (!fs.existsSync(readyDir)) continue;
704
-
705
- const storyFiles = findFiles(readyDir, /\.md$/);
706
-
707
- for (const storyFile of storyFiles) {
708
- if (path.basename(storyFile) === 'README.md') continue;
709
-
710
- const content = fs.readFileSync(storyFile, 'utf-8');
711
-
712
- // Stories in ready-for-development folder must have complete DoR
713
- // Check for DoR section
714
- const dorCheckboxes = extractCheckboxes(content, 'DoR Checklist|Definition of Ready');
715
-
716
- if (dorCheckboxes.length > 0) {
717
- const unchecked = dorCheckboxes.filter(c => !c.checked);
718
- if (unchecked.length > 0) {
719
- results.push({
720
- ruleId: 'TS-STORY-005',
721
- severity: SEVERITY.ERROR,
722
- file: storyFile,
723
- message: `DoR Checklist incomplete. Unchecked items: ${unchecked.map(c => c.text).join(', ')}`,
724
- owner: 'FA',
725
- });
747
+
748
+ // TS-QA-003: Regression impact
749
+ if (projectConfig?.status === 'deployed' || projectConfig?.deployed_date) {
750
+ if (!options.rule || options.rule === 'TS-QA-003') {
751
+ checkRegressionImpact(fiPath, projectDir, workspaceDir, result);
752
+ }
726
753
  }
727
- }
728
754
  }
729
- }
730
-
731
- return results;
732
- },
733
- },
734
-
735
- // -------------------------------------------------------------------------
736
- // ADR Rules (TS-ADR)
737
- // -------------------------------------------------------------------------
738
-
739
- 'TS-ADR-001': {
740
- id: 'TS-ADR-001',
741
- name: 'Feature marked "Architecture Required" must have ADR',
742
- severity: SEVERITY.ERROR,
743
- owner: 'SA',
744
- async check(ctx) {
745
- const results = [];
746
-
747
- for (const projectId of ctx.projects) {
748
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
749
- const storyFiles = findFiles(storiesDir, /\.md$/);
750
-
751
- for (const storyFile of storyFiles) {
752
- if (path.basename(storyFile) === 'README.md') continue;
753
-
754
- const content = fs.readFileSync(storyFile, 'utf-8');
755
-
756
- // Check if ADR Required is checked
757
- const checkboxes = extractCheckboxes(content);
758
- const adrRequired = checkboxes.some(c => c.checked && /ADR Required/i.test(c.text));
759
-
760
- if (adrRequired) {
761
- // Check for ADR reference
762
- const hasAdrRef = /ADR-\d{3,}/i.test(content);
763
-
764
- if (!hasAdrRef) {
765
- results.push({
766
- ruleId: 'TS-ADR-001',
767
- severity: SEVERITY.ERROR,
768
- file: storyFile,
769
- message: 'Story has "ADR Required" checked but no ADR reference found.',
770
- owner: 'SA',
771
- });
755
+ }
756
+
757
+ // Lint Epics
758
+ const epicsDir = path.join(projectDir, 'epics');
759
+ if (fs.existsSync(epicsDir)) {
760
+ const epics = fs.readdirSync(epicsDir)
761
+ .filter(f => f.endsWith('.md') && f !== 'epics-index.md');
762
+
763
+ for (const epic of epics) {
764
+ const epicPath = path.join(epicsDir, epic);
765
+
766
+ // TS-EPIC-001: Naming
767
+ if (!options.rule || options.rule === 'TS-EPIC-001') {
768
+ checkEpicNaming(epicPath, result, workspaceDir);
772
769
  }
773
- }
774
- }
775
- }
776
-
777
- return results;
778
- },
779
- },
780
-
781
- 'TS-ADR-002': {
782
- id: 'TS-ADR-002',
783
- name: 'ADR must link to feature(s)',
784
- severity: SEVERITY.ERROR,
785
- owner: 'SA',
786
- async check(ctx) {
787
- const results = [];
788
-
789
- for (const projectId of ctx.projects) {
790
- const adrDir = path.join(ctx.workspaceDir, 'projects', projectId, 'adr');
791
- if (!fs.existsSync(adrDir)) continue;
792
-
793
- const adrFiles = findFiles(adrDir, /^ADR-\d{3,}-.*\.md$/);
794
-
795
- for (const adrFile of adrFiles) {
796
- const content = fs.readFileSync(adrFile, 'utf-8');
797
-
798
- // Check for feature reference
799
- const hasFeatureRef = /F-\d{3,}|Linked Feature|Related Feature/i.test(content);
800
-
801
- if (!hasFeatureRef) {
802
- results.push({
803
- ruleId: 'TS-ADR-002',
804
- severity: SEVERITY.ERROR,
805
- file: adrFile,
806
- message: 'ADR must link to at least one feature.',
807
- owner: 'SA',
808
- });
809
- }
810
770
  }
811
- }
812
-
813
- return results;
814
- },
815
- },
816
-
817
- // -------------------------------------------------------------------------
818
- // Dev Plan Rules (TS-DEVPLAN)
819
- // -------------------------------------------------------------------------
820
-
821
- 'TS-DEVPLAN-001': {
822
- id: 'TS-DEVPLAN-001',
823
- name: 'Story in sprint must have dev plan',
824
- severity: SEVERITY.ERROR,
825
- owner: 'DEV',
826
- async check(ctx) {
827
- const results = [];
828
-
829
- for (const projectId of ctx.projects) {
830
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
831
- const devPlansDir = path.join(ctx.workspaceDir, 'projects', projectId, 'dev-plans');
832
- const storyFiles = findFiles(storiesDir, /^S-\d{3,}-.*\.md$/);
833
-
834
- for (const storyFile of storyFiles) {
835
- const content = fs.readFileSync(storyFile, 'utf-8');
836
- const metadata = extractMetadata(content);
837
-
838
- // Check if story is in sprint
839
- const isInSprint = metadata.Status && /in sprint|in progress|ready for testing/i.test(metadata.Status);
840
-
841
- if (isInSprint) {
842
- const storyId = extractStoryId(path.basename(storyFile), content);
843
-
844
- if (storyId) {
845
- const devPlanPath = path.join(devPlansDir, `story-${storyId}-tasks.md`);
846
-
847
- if (!fs.existsSync(devPlanPath)) {
848
- results.push({
849
- ruleId: 'TS-DEVPLAN-001',
850
- severity: SEVERITY.ERROR,
851
- file: storyFile,
852
- message: `Story is in sprint but dev plan is missing. Expected: dev-plans/story-${storyId}-tasks.md`,
853
- owner: 'DEV',
854
- });
855
- }
771
+ }
772
+
773
+ // Lint Stories
774
+ const storiesDir = path.join(projectDir, 'stories');
775
+ if (fs.existsSync(storiesDir)) {
776
+ const storyFolders = ['backlog', 'ready-to-refine', 'ready-to-develop', 'deferred', 'out-of-scope'];
777
+
778
+ for (const folder of storyFolders) {
779
+ const folderPath = path.join(storiesDir, folder);
780
+ if (!fs.existsSync(folderPath)) continue;
781
+
782
+ const stories = fs.readdirSync(folderPath).filter(f => f.endsWith('.md') && f !== 'README.md');
783
+
784
+ for (const story of stories) {
785
+ const storyPath = path.join(folderPath, story);
786
+
787
+ // TS-STORY-001: Epic link
788
+ if (!options.rule || options.rule === 'TS-STORY-001') {
789
+ checkStoryEpicLink(storyPath, projectDir, result, workspaceDir);
790
+ }
791
+
792
+ // TS-STORY-002: Delta language
793
+ if (!options.rule || options.rule === 'TS-STORY-002') {
794
+ checkStoryDelta(storyPath, result);
795
+ }
856
796
  }
857
- }
858
797
  }
859
- }
860
-
861
- return results;
862
- },
863
- },
864
-
865
- // -------------------------------------------------------------------------
866
- // DoD Rules (TS-DOD)
867
- // -------------------------------------------------------------------------
868
-
869
- 'TS-DOD-001': {
870
- id: 'TS-DOD-001',
871
- name: 'Story cannot be Done if behavior changed and Canon not updated',
872
- severity: SEVERITY.BLOCKER,
873
- owner: 'FA',
874
- async check(ctx) {
875
- const results = [];
876
-
877
- for (const projectId of ctx.projects) {
878
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
879
- const storyFiles = findFiles(storiesDir, /\.md$/);
880
-
881
- for (const storyFile of storyFiles) {
882
- if (path.basename(storyFile) === 'README.md') continue;
883
-
884
- const content = fs.readFileSync(storyFile, 'utf-8');
885
- const metadata = extractMetadata(content);
886
-
887
- // Check if status is Done
888
- const isDone = metadata.Status && /done/i.test(metadata.Status);
889
-
890
- if (isDone) {
891
- // Check if behavior is being added/changed (anywhere in file)
892
- const allCheckboxes = extractCheckboxes(content);
893
- const addsBehavior = allCheckboxes.some(c => c.checked && /adds behavior/i.test(c.text));
894
- const changesBehavior = allCheckboxes.some(c => c.checked && /changes behavior/i.test(c.text));
895
-
896
- if (addsBehavior || changesBehavior) {
897
- // Check DoD for Canon update - look for unchecked "Feature Canon updated" item
898
- const dodCheckboxes = extractCheckboxes(content, 'DoD Checklist|Definition of Done');
899
- const canonChecked = dodCheckboxes.some(c => c.checked && /feature canon updated|canon updated/i.test(c.text));
900
- const canonUnchecked = dodCheckboxes.some(c => !c.checked && /feature canon updated|canon updated/i.test(c.text));
901
-
902
- if (canonUnchecked || (!canonChecked && dodCheckboxes.length > 0)) {
903
- results.push({
904
- ruleId: 'TS-DOD-001',
905
- severity: SEVERITY.BLOCKER,
906
- file: storyFile,
907
- message: 'Story is marked Done with behavior changes but Feature Canon not updated. This blocks release.',
908
- owner: 'FA',
909
- });
910
- }
798
+ }
799
+
800
+ // Lint stories in sprint folders
801
+ const sprintsDir = path.join(projectDir, 'sprints');
802
+ if (fs.existsSync(sprintsDir)) {
803
+ const sprints = fs.readdirSync(sprintsDir, { withFileTypes: true })
804
+ .filter(d => d.isDirectory() && d.name.startsWith('sprint-'))
805
+ .map(d => d.name);
806
+
807
+ for (const sprint of sprints) {
808
+ const sprintDir = path.join(sprintsDir, sprint);
809
+ const stories = fs.readdirSync(sprintDir)
810
+ .filter(f => f.startsWith('s-') && f.endsWith('.md'));
811
+
812
+ for (const story of stories) {
813
+ const storyPath = path.join(sprintDir, story);
814
+
815
+ // TS-STORY-001
816
+ if (!options.rule || options.rule === 'TS-STORY-001') {
817
+ checkStoryEpicLink(storyPath, projectDir, result, workspaceDir);
818
+ }
819
+
820
+ // TS-DOD-001: Done stories need verified AC
821
+ if (!options.rule || options.rule === 'TS-DOD-001') {
822
+ checkDoneStoryAC(storyPath, result);
823
+ }
911
824
  }
912
- }
913
- }
914
- }
915
-
916
- return results;
917
- },
918
- },
919
-
920
- // -------------------------------------------------------------------------
921
- // Naming Convention Rules (TS-NAMING)
922
- // -------------------------------------------------------------------------
923
-
924
- 'TS-NAMING-FEATURE': {
925
- id: 'TS-NAMING-FEATURE',
926
- name: 'Feature file naming convention',
927
- severity: SEVERITY.WARNING,
928
- owner: 'FA',
929
- async check(ctx) {
930
- const results = [];
931
-
932
- for (const projectId of ctx.projects) {
933
- const featuresDir = path.join(ctx.workspaceDir, 'projects', projectId, 'features');
934
- if (!fs.existsSync(featuresDir)) continue;
935
-
936
- const files = fs.readdirSync(featuresDir).filter(f => f.endsWith('.md'));
937
-
938
- for (const file of files) {
939
- if (['features-index.md', 'story-ledger.md', 'README.md'].includes(file)) continue;
940
-
941
- if (!NAMING_PATTERNS.feature.test(file)) {
942
- results.push({
943
- ruleId: 'TS-NAMING-FEATURE',
944
- severity: SEVERITY.WARNING,
945
- file: path.join(featuresDir, file),
946
- message: `Feature file '${file}' does not match naming convention: F-NNN-description.md`,
947
- owner: 'FA',
948
- });
949
- }
950
825
  }
951
- }
952
-
953
- return results;
954
- },
955
- },
956
-
957
- 'TS-NAMING-STORY': {
958
- id: 'TS-NAMING-STORY',
959
- name: 'Story file naming convention',
960
- severity: SEVERITY.WARNING,
961
- owner: 'FA',
962
- async check(ctx) {
963
- const results = [];
964
-
965
- for (const projectId of ctx.projects) {
966
- const storiesDir = path.join(ctx.workspaceDir, 'projects', projectId, 'stories');
967
- if (!fs.existsSync(storiesDir)) continue;
968
-
969
- const storyFiles = findFiles(storiesDir, /\.md$/);
970
-
971
- for (const storyFile of storyFiles) {
972
- const filename = path.basename(storyFile);
973
- if (filename === 'README.md') continue;
974
-
975
- if (!NAMING_PATTERNS.story.test(filename)) {
976
- results.push({
977
- ruleId: 'TS-NAMING-STORY',
978
- severity: SEVERITY.WARNING,
979
- file: storyFile,
980
- message: `Story file '${filename}' does not match naming convention: S-NNN-description.md`,
981
- owner: 'FA',
982
- });
983
- }
984
- }
985
- }
986
-
987
- return results;
988
- },
989
- },
990
-
991
- 'TS-NAMING-DEVPLAN': {
992
- id: 'TS-NAMING-DEVPLAN',
993
- name: 'Dev plan file naming convention',
994
- severity: SEVERITY.WARNING,
995
- owner: 'DEV',
996
- async check(ctx) {
997
- const results = [];
998
-
999
- for (const projectId of ctx.projects) {
1000
- const devPlansDir = path.join(ctx.workspaceDir, 'projects', projectId, 'dev-plans');
1001
- if (!fs.existsSync(devPlansDir)) continue;
1002
-
1003
- const files = fs.readdirSync(devPlansDir).filter(f => f.endsWith('.md'));
1004
-
1005
- for (const file of files) {
1006
- if (file === 'README.md') continue;
1007
-
1008
- if (!NAMING_PATTERNS.devPlan.test(file)) {
1009
- results.push({
1010
- ruleId: 'TS-NAMING-DEVPLAN',
1011
- severity: SEVERITY.WARNING,
1012
- file: path.join(devPlansDir, file),
1013
- message: `Dev plan file '${file}' does not match naming convention: story-NNN-tasks.md`,
1014
- owner: 'DEV',
1015
- });
1016
- }
826
+ }
827
+
828
+ // Lint test cases naming
829
+ const tcDir = path.join(projectDir, 'qa', 'test-cases');
830
+ if (fs.existsSync(tcDir)) {
831
+ const tcFiles = fs.readdirSync(tcDir).filter(f => f.endsWith('.md'));
832
+ for (const tc of tcFiles) {
833
+ checkArtifactNaming(path.join(tcDir, tc), 'project-test-case', result, workspaceDir);
1017
834
  }
1018
- }
1019
-
1020
- return results;
1021
- },
1022
- },
1023
-
1024
- 'TS-NAMING-ADR': {
1025
- id: 'TS-NAMING-ADR',
1026
- name: 'ADR file naming convention',
1027
- severity: SEVERITY.WARNING,
1028
- owner: 'SA',
1029
- async check(ctx) {
1030
- const results = [];
1031
-
1032
- for (const projectId of ctx.projects) {
1033
- const adrDir = path.join(ctx.workspaceDir, 'projects', projectId, 'adr');
1034
- if (!fs.existsSync(adrDir)) continue;
1035
-
1036
- const files = fs.readdirSync(adrDir).filter(f => f.endsWith('.md'));
1037
-
1038
- for (const file of files) {
1039
- if (file === 'README.md') continue;
1040
-
1041
- if (!NAMING_PATTERNS.adr.test(file)) {
1042
- results.push({
1043
- ruleId: 'TS-NAMING-ADR',
1044
- severity: SEVERITY.WARNING,
1045
- file: path.join(adrDir, file),
1046
- message: `ADR file '${file}' does not match naming convention: ADR-NNN-description.md`,
1047
- owner: 'SA',
1048
- });
1049
- }
835
+ }
836
+
837
+ // Lint bug reports naming
838
+ const bugDir = path.join(projectDir, 'qa', 'bug-reports');
839
+ if (fs.existsSync(bugDir)) {
840
+ const bugFiles = fs.readdirSync(bugDir).filter(f => f.endsWith('.md'));
841
+ for (const bug of bugFiles) {
842
+ checkArtifactNaming(path.join(bugDir, bug), 'bug-report', result, workspaceDir);
1050
843
  }
1051
- }
1052
-
1053
- return results;
1054
- },
1055
- },
1056
- };
844
+ }
845
+ }
1057
846
 
1058
- // =============================================================================
1059
- // Linter Class
1060
- // =============================================================================
847
+ /**
848
+ * Format lint results for console output
849
+ */
850
+ function formatResults(result, verbose = false) {
851
+ const output = [];
852
+ const summary = result.getSummary();
1061
853
 
1062
- class Linter {
1063
- constructor(workspaceDir) {
1064
- this.workspaceDir = workspaceDir;
1065
- }
1066
-
1067
- /**
1068
- * Run all linter rules
1069
- */
1070
- async run(options = {}) {
1071
- const projects = options.project
1072
- ? [options.project]
1073
- : findProjects(this.workspaceDir);
1074
-
1075
- const ctx = {
1076
- workspaceDir: this.workspaceDir,
1077
- projects,
1078
- };
1079
-
1080
- const results = [];
1081
-
1082
- for (const rule of Object.values(rules)) {
1083
- try {
1084
- const ruleResults = await rule.check(ctx);
1085
- results.push(...ruleResults);
1086
- } catch (err) {
1087
- results.push({
1088
- ruleId: rule.id,
1089
- severity: SEVERITY.ERROR,
1090
- file: this.workspaceDir,
1091
- message: `Rule execution failed: ${err.message}`,
1092
- owner: 'System',
1093
- });
1094
- }
854
+ if (summary.total === 0) {
855
+ output.push('✅ No lint errors found');
856
+ return output.join('\n');
1095
857
  }
1096
-
1097
- return results;
1098
- }
1099
-
1100
- /**
1101
- * Run a specific rule
1102
- */
1103
- async runRule(ruleId, options = {}) {
1104
- const rule = rules[ruleId];
1105
- if (!rule) {
1106
- throw new Error(`Unknown rule: ${ruleId}`);
1107
- }
1108
-
1109
- const projects = options.project
1110
- ? [options.project]
1111
- : findProjects(this.workspaceDir);
1112
-
1113
- const ctx = {
1114
- workspaceDir: this.workspaceDir,
1115
- projects,
1116
- };
1117
-
1118
- return rule.check(ctx);
1119
- }
1120
-
1121
- /**
1122
- * Group results by file
1123
- */
1124
- groupByFile(results) {
1125
- const grouped = {};
1126
-
1127
- for (const result of results) {
1128
- if (!grouped[result.file]) {
1129
- grouped[result.file] = [];
1130
- }
1131
- grouped[result.file].push(result);
858
+
859
+ // Group by file
860
+ const byFile = {};
861
+ for (const item of result.getAll()) {
862
+ if (!byFile[item.file]) byFile[item.file] = [];
863
+ byFile[item.file].push(item);
1132
864
  }
1133
-
1134
- return grouped;
1135
- }
1136
-
1137
- /**
1138
- * Format results for console output
1139
- */
1140
- formatResults(results) {
1141
- if (results.length === 0) {
1142
- return '✅ No issues found.';
865
+
866
+ for (const [file, items] of Object.entries(byFile)) {
867
+ output.push(`\n📄 ${file}`);
868
+ for (const item of items) {
869
+ const icon = item.severity === SEVERITY.ERROR || item.severity === SEVERITY.BLOCKER
870
+ ? '❌'
871
+ : item.severity === SEVERITY.WARNING
872
+ ? '⚠️'
873
+ : 'ℹ️';
874
+ output.push(` ${icon} [${item.rule}] ${item.message}`);
875
+ }
1143
876
  }
1144
-
1145
- const lines = [];
1146
- const grouped = this.groupByFile(results);
1147
-
1148
- for (const [file, fileResults] of Object.entries(grouped)) {
1149
- lines.push(`\n📄 ${path.relative(this.workspaceDir, file)}`);
1150
-
1151
- for (const result of fileResults) {
1152
- const icon = result.severity === SEVERITY.ERROR || result.severity === SEVERITY.BLOCKER
1153
- ? '❌'
1154
- : result.severity === SEVERITY.WARNING
1155
- ? '⚠️'
1156
- : 'ℹ️';
1157
-
1158
- lines.push(` ${icon} [${result.ruleId}] ${result.message}`);
1159
- lines.push(` Owner: ${result.owner}`);
1160
- }
877
+
878
+ output.push('\n---');
879
+ output.push(`Summary: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.info} info`);
880
+
881
+ if (result.hasBlockers()) {
882
+ output.push('🚫 BLOCKERS found - cannot proceed');
883
+ } else if (result.hasErrors()) {
884
+ output.push('❌ Lint check FAILED');
885
+ } else {
886
+ output.push('✅ Lint check passed (warnings only)');
1161
887
  }
1162
-
1163
- // Summary
1164
- const errors = results.filter(r => r.severity === SEVERITY.ERROR || r.severity === SEVERITY.BLOCKER).length;
1165
- const warnings = results.filter(r => r.severity === SEVERITY.WARNING).length;
1166
- const info = results.filter(r => r.severity === SEVERITY.INFO).length;
1167
-
1168
- lines.push('\n' + '─'.repeat(60));
1169
- lines.push(`Summary: ${errors} errors, ${warnings} warnings, ${info} info`);
1170
-
1171
- return lines.join('\n');
1172
- }
888
+
889
+ return output.join('\n');
1173
890
  }
1174
891
 
1175
892
  // =============================================================================
1176
- // Exports
893
+ // CLI INTEGRATION
1177
894
  // =============================================================================
1178
895
 
896
+ /**
897
+ * Run linter from CLI
898
+ */
899
+ function runLint(targetDir, options = {}) {
900
+ console.log(`\n🔍 Linting TeamSpec workspace: ${targetDir}\n`);
901
+
902
+ const result = lint(targetDir, options);
903
+ console.log(formatResults(result, options.verbose));
904
+
905
+ return result.hasErrors() ? 1 : 0;
906
+ }
907
+
1179
908
  module.exports = {
1180
- Linter,
1181
- rules,
1182
- SEVERITY,
1183
- NAMING_PATTERNS,
909
+ lint,
910
+ runLint,
911
+ formatResults,
912
+ LintResult,
913
+ SEVERITY,
914
+ getNamingPatterns,
915
+ resetNamingPatterns,
916
+ // Export individual checks for testing
917
+ checkProductRegistered,
918
+ checkProductYml,
919
+ checkProjectRegistered,
920
+ checkProjectYml,
921
+ checkFIContent,
922
+ checkFIFeatureLink,
923
+ checkEpicNaming,
924
+ checkStoryEpicLink,
925
+ checkStoryDelta,
926
+ checkDoneStoryAC,
927
+ checkCanonSync,
928
+ checkFITestCoverage,
929
+ checkRegressionImpact,
930
+ checkArtifactNaming
1184
931
  };