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.
- package/README.md +24 -12
- package/bin/teamspec-init.js +2 -2
- package/lib/cli.js +653 -99
- package/lib/linter.js +823 -1076
- package/lib/prompt-generator.js +312 -330
- package/lib/structure-loader.js +400 -0
- package/package.json +14 -6
- package/teamspec-core/FOLDER_STRUCTURE.yml +131 -0
- package/teamspec-core/agents/AGENT_BA.md +188 -293
- package/teamspec-core/agents/AGENT_BOOTSTRAP.md +197 -102
- package/teamspec-core/agents/AGENT_DES.md +9 -8
- package/teamspec-core/agents/AGENT_DEV.md +68 -67
- package/teamspec-core/agents/AGENT_FA.md +437 -245
- package/teamspec-core/agents/AGENT_FIX.md +344 -74
- package/teamspec-core/agents/AGENT_PO.md +487 -0
- package/teamspec-core/agents/AGENT_QA.md +124 -98
- package/teamspec-core/agents/AGENT_SA.md +143 -84
- package/teamspec-core/agents/AGENT_SM.md +106 -83
- package/teamspec-core/agents/README.md +143 -93
- package/teamspec-core/copilot-instructions.md +281 -205
- package/teamspec-core/definitions/definition-of-done.md +47 -84
- package/teamspec-core/definitions/definition-of-ready.md +35 -60
- package/teamspec-core/registry.yml +898 -0
- package/teamspec-core/teamspec.yml +44 -28
- package/teamspec-core/templates/README.md +5 -5
- package/teamspec-core/templates/adr-template.md +19 -17
- package/teamspec-core/templates/bai-template.md +125 -0
- package/teamspec-core/templates/bug-report-template.md +21 -15
- package/teamspec-core/templates/business-analysis-template.md +16 -13
- package/teamspec-core/templates/decision-log-template.md +26 -22
- package/teamspec-core/templates/dev-plan-template.md +168 -0
- package/teamspec-core/templates/epic-template.md +204 -0
- package/teamspec-core/templates/feature-increment-template.md +84 -0
- package/teamspec-core/templates/feature-template.md +45 -32
- package/teamspec-core/templates/increments-index-template.md +53 -0
- package/teamspec-core/templates/product-template.yml +44 -0
- package/teamspec-core/templates/products-index-template.md +46 -0
- package/teamspec-core/templates/project-template.yml +70 -0
- package/teamspec-core/templates/ri-template.md +225 -0
- package/teamspec-core/templates/rt-template.md +104 -0
- package/teamspec-core/templates/sd-template.md +132 -0
- package/teamspec-core/templates/sdi-template.md +119 -0
- package/teamspec-core/templates/sprint-template.md +17 -15
- package/teamspec-core/templates/story-template-v4.md +202 -0
- package/teamspec-core/templates/story-template.md +48 -90
- package/teamspec-core/templates/ta-template.md +198 -0
- package/teamspec-core/templates/tai-template.md +131 -0
- package/teamspec-core/templates/tc-template.md +145 -0
- package/teamspec-core/templates/testcases-template.md +20 -17
- package/extensions/teamspec-0.1.0.vsix +0 -0
- 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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
//
|
|
15
|
+
// SEVERITY LEVELS
|
|
20
16
|
// =============================================================================
|
|
21
17
|
|
|
22
18
|
const SEVERITY = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
BLOCKER: 'blocker',
|
|
20
|
+
ERROR: 'error',
|
|
21
|
+
WARNING: 'warning',
|
|
22
|
+
INFO: 'info'
|
|
27
23
|
};
|
|
28
24
|
|
|
29
25
|
// =============================================================================
|
|
30
|
-
//
|
|
26
|
+
// LINT RESULT
|
|
31
27
|
// =============================================================================
|
|
32
28
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
//
|
|
77
|
+
// YAML PARSER (Simple)
|
|
45
78
|
// =============================================================================
|
|
46
79
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
//
|
|
250
|
+
// RULE IMPLEMENTATIONS
|
|
76
251
|
// =============================================================================
|
|
77
252
|
|
|
78
253
|
/**
|
|
79
|
-
*
|
|
254
|
+
* TS-PROD-001: Product folder must be registered
|
|
80
255
|
*/
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
*
|
|
271
|
+
* TS-PROD-002: product.yml required with PRX
|
|
98
272
|
*/
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
304
|
+
* TS-PROJ-001: Project folder must be registered
|
|
140
305
|
*/
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
*
|
|
321
|
+
* TS-PROJ-002: project.yml required with minimum metadata
|
|
158
322
|
*/
|
|
159
|
-
function
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*
|
|
349
|
+
* TS-FI-001: Feature-Increment must have AS-IS and TO-BE sections
|
|
168
350
|
*/
|
|
169
|
-
function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
*
|
|
474
|
+
* TS-NAMING-*: Artifact naming conventions
|
|
198
475
|
*/
|
|
199
|
-
function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
*
|
|
488
|
+
* TS-DOD-001: Story must have all AC verified
|
|
218
489
|
*/
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
*
|
|
503
|
+
* TS-DOD-003: Product sync after deployment
|
|
233
504
|
*/
|
|
234
|
-
function
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
*
|
|
512
|
+
* TS-QA-001: Deployed Feature-Increment must have test coverage
|
|
261
513
|
*/
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
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
|
-
*
|
|
535
|
+
* TS-QA-003: Regression impact must be recorded for each FI
|
|
282
536
|
*/
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
//
|
|
610
|
+
// MAIN LINTER
|
|
295
611
|
// =============================================================================
|
|
296
612
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
};
|