teamspec 3.2.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/LICENSE +21 -0
- package/README.md +252 -0
- package/bin/teamspec-init.js +10 -0
- package/extensions/teamspec-0.1.0.vsix +0 -0
- package/lib/cli.js +1174 -0
- package/lib/extension-installer.js +236 -0
- package/lib/linter.js +1184 -0
- package/lib/prompt-generator.js +409 -0
- package/package.json +51 -0
- package/teamspec-core/agents/AGENT_BA.md +486 -0
- package/teamspec-core/agents/AGENT_BOOTSTRAP.md +447 -0
- package/teamspec-core/agents/AGENT_DES.md +623 -0
- package/teamspec-core/agents/AGENT_DEV.md +611 -0
- package/teamspec-core/agents/AGENT_FA.md +736 -0
- package/teamspec-core/agents/AGENT_FEEDBACK.md +202 -0
- package/teamspec-core/agents/AGENT_FIX.md +380 -0
- package/teamspec-core/agents/AGENT_QA.md +756 -0
- package/teamspec-core/agents/AGENT_SA.md +581 -0
- package/teamspec-core/agents/AGENT_SM.md +771 -0
- package/teamspec-core/agents/README.md +383 -0
- package/teamspec-core/context/_schema.yml +222 -0
- package/teamspec-core/copilot-instructions.md +356 -0
- package/teamspec-core/definitions/definition-of-done.md +129 -0
- package/teamspec-core/definitions/definition-of-ready.md +104 -0
- package/teamspec-core/profiles/enterprise.yml +127 -0
- package/teamspec-core/profiles/platform-team.yml +104 -0
- package/teamspec-core/profiles/regulated.yml +97 -0
- package/teamspec-core/profiles/startup.yml +85 -0
- package/teamspec-core/teamspec.yml +69 -0
- package/teamspec-core/templates/README.md +211 -0
- package/teamspec-core/templates/active-sprint-template.md +98 -0
- package/teamspec-core/templates/adr-template.md +194 -0
- package/teamspec-core/templates/bug-report-template.md +188 -0
- package/teamspec-core/templates/business-analysis-template.md +164 -0
- package/teamspec-core/templates/decision-log-template.md +216 -0
- package/teamspec-core/templates/feature-template.md +269 -0
- package/teamspec-core/templates/functional-spec-template.md +161 -0
- package/teamspec-core/templates/refinement-notes-template.md +133 -0
- package/teamspec-core/templates/sprint-goal-template.md +129 -0
- package/teamspec-core/templates/sprint-template.md +175 -0
- package/teamspec-core/templates/sprints-index-template.md +67 -0
- package/teamspec-core/templates/story-template.md +244 -0
- package/teamspec-core/templates/storymap-template.md +204 -0
- package/teamspec-core/templates/testcases-template.md +147 -0
- package/teamspec-core/templates/uat-pack-template.md +161 -0
package/lib/linter.js
ADDED
|
@@ -0,0 +1,1184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeamSpec Linter
|
|
3
|
+
* Enforces TeamSpec Feature Canon operating model rules
|
|
4
|
+
*
|
|
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)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Severity Levels
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
const SEVERITY = {
|
|
23
|
+
ERROR: 'error',
|
|
24
|
+
BLOCKER: 'blocker',
|
|
25
|
+
WARNING: 'warning',
|
|
26
|
+
INFO: 'info',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Naming Patterns (from PROJECT_STRUCTURE.yml)
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
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
|
+
};
|
|
42
|
+
|
|
43
|
+
// =============================================================================
|
|
44
|
+
// Required Sections
|
|
45
|
+
// =============================================================================
|
|
46
|
+
|
|
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
|
+
];
|
|
73
|
+
|
|
74
|
+
// =============================================================================
|
|
75
|
+
// Helper Functions
|
|
76
|
+
// =============================================================================
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse YAML-like frontmatter from markdown
|
|
80
|
+
*/
|
|
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();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return yaml;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse simple YAML file
|
|
98
|
+
*/
|
|
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;
|
|
115
|
+
}
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract headings from markdown
|
|
140
|
+
*/
|
|
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
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return headings;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if content contains a pattern
|
|
158
|
+
*/
|
|
159
|
+
function containsPattern(content, pattern) {
|
|
160
|
+
if (typeof pattern === 'string') {
|
|
161
|
+
return content.includes(pattern);
|
|
162
|
+
}
|
|
163
|
+
return pattern.test(content);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Extract checkboxes from markdown
|
|
168
|
+
*/
|
|
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 [];
|
|
179
|
+
}
|
|
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
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extract feature ID from story content
|
|
198
|
+
*/
|
|
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]}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return [...new Set(links)];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Extract story ID from filename or content
|
|
218
|
+
*/
|
|
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;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get metadata from markdown (bold fields like **Status:** value)
|
|
233
|
+
*/
|
|
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
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return metadata;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Recursively find files matching a pattern
|
|
261
|
+
*/
|
|
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);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Find all projects in workspace
|
|
282
|
+
*/
|
|
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);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// =============================================================================
|
|
294
|
+
// Rule Definitions
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
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;
|
|
355
|
+
}
|
|
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
|
+
}
|
|
370
|
+
}
|
|
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
|
+
});
|
|
414
|
+
}
|
|
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
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
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, []);
|
|
486
|
+
}
|
|
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
|
+
}
|
|
589
|
+
}
|
|
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;
|
|
638
|
+
}
|
|
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
|
+
});
|
|
684
|
+
}
|
|
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
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
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
|
+
});
|
|
772
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
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
|
+
}
|
|
911
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
1017
|
+
}
|
|
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
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
return results;
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
// =============================================================================
|
|
1059
|
+
// Linter Class
|
|
1060
|
+
// =============================================================================
|
|
1061
|
+
|
|
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
|
+
}
|
|
1095
|
+
}
|
|
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);
|
|
1132
|
+
}
|
|
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.';
|
|
1143
|
+
}
|
|
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
|
+
}
|
|
1161
|
+
}
|
|
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
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// =============================================================================
|
|
1176
|
+
// Exports
|
|
1177
|
+
// =============================================================================
|
|
1178
|
+
|
|
1179
|
+
module.exports = {
|
|
1180
|
+
Linter,
|
|
1181
|
+
rules,
|
|
1182
|
+
SEVERITY,
|
|
1183
|
+
NAMING_PATTERNS,
|
|
1184
|
+
};
|