gswd 0.1.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/agents/gswd/architecture-drafter.md +70 -0
- package/agents/gswd/brainstorm-alternatives.md +60 -0
- package/agents/gswd/devils-advocate.md +57 -0
- package/agents/gswd/icp-persona.md +58 -0
- package/agents/gswd/integrations-checker.md +68 -0
- package/agents/gswd/journey-mapper.md +69 -0
- package/agents/gswd/market-researcher.md +54 -0
- package/agents/gswd/positioning.md +54 -0
- package/bin/gswd-tools.cjs +716 -0
- package/lib/audit.ts +959 -0
- package/lib/bootstrap.ts +617 -0
- package/lib/compile.ts +940 -0
- package/lib/config.ts +164 -0
- package/lib/imagine-agents.ts +154 -0
- package/lib/imagine-gate.ts +156 -0
- package/lib/imagine-input.ts +242 -0
- package/lib/imagine-synthesis.ts +402 -0
- package/lib/imagine.ts +433 -0
- package/lib/parse.ts +196 -0
- package/lib/render.ts +200 -0
- package/lib/specify-agents.ts +332 -0
- package/lib/specify-journeys.ts +410 -0
- package/lib/specify-nfr.ts +208 -0
- package/lib/specify-roles.ts +122 -0
- package/lib/specify.ts +773 -0
- package/lib/state.ts +305 -0
- package/package.json +26 -0
- package/templates/gswd/ARCHITECTURE.template.md +17 -0
- package/templates/gswd/AUDIT.template.md +31 -0
- package/templates/gswd/COMPETITION.template.md +18 -0
- package/templates/gswd/DECISIONS.template.md +18 -0
- package/templates/gswd/GTM.template.md +18 -0
- package/templates/gswd/ICP.template.md +18 -0
- package/templates/gswd/IMAGINE.template.md +24 -0
- package/templates/gswd/INTEGRATIONS.template.md +7 -0
- package/templates/gswd/JOURNEYS.template.md +7 -0
- package/templates/gswd/NFR.template.md +7 -0
- package/templates/gswd/PROJECT.template.md +21 -0
- package/templates/gswd/REQUIREMENTS.template.md +31 -0
- package/templates/gswd/ROADMAP.template.md +21 -0
- package/templates/gswd/SPEC.template.md +19 -0
- package/templates/gswd/STATE.template.md +15 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Specify Journeys Module — Journey generation, FR extraction, cross-linking
|
|
3
|
+
*
|
|
4
|
+
* Produces journey structures and extracts functional requirements from journey steps.
|
|
5
|
+
* Bidirectional cross-linking: journeys reference FR IDs, FRs reference source journeys.
|
|
6
|
+
*
|
|
7
|
+
* Schema: GSWD_SPEC.md Section 6.1 (ID formats), 6.2 (scope tagging), 8.3 (specify workflow)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { normalizeId } from './parse.js';
|
|
11
|
+
|
|
12
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type JourneyType =
|
|
15
|
+
| 'onboarding'
|
|
16
|
+
| 'core_action'
|
|
17
|
+
| 'view_results'
|
|
18
|
+
| 'settings'
|
|
19
|
+
| 'error_states'
|
|
20
|
+
| 'empty_states';
|
|
21
|
+
|
|
22
|
+
export interface FailureMode {
|
|
23
|
+
/** Concise scenario description (1-2 sentences max) */
|
|
24
|
+
scenario: string;
|
|
25
|
+
/** How the system handles this failure */
|
|
26
|
+
handling: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface JourneyStep {
|
|
30
|
+
/** Step number (1-based) */
|
|
31
|
+
number: number;
|
|
32
|
+
/** User action description */
|
|
33
|
+
action: string;
|
|
34
|
+
/** FR-XXX IDs derived from this step (populated during extraction) */
|
|
35
|
+
frIds: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Journey {
|
|
39
|
+
/** Journey ID in J-001 format */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Human-readable journey name */
|
|
42
|
+
name: string;
|
|
43
|
+
/** Journey type category */
|
|
44
|
+
type: JourneyType;
|
|
45
|
+
/** Conditions that must be true before journey starts */
|
|
46
|
+
preconditions: string[];
|
|
47
|
+
/** Ordered user action steps (5-8 for main, 3+ for error/empty) */
|
|
48
|
+
steps: JourneyStep[];
|
|
49
|
+
/** Successful completion outcome */
|
|
50
|
+
success: string;
|
|
51
|
+
/** Failure scenarios (>= 2 required) */
|
|
52
|
+
failureModes: FailureMode[];
|
|
53
|
+
/** Single assertable test statements (>= 1 required) */
|
|
54
|
+
acceptanceTests: string[];
|
|
55
|
+
/** FR-XXX IDs linked to this journey (populated after extraction) */
|
|
56
|
+
linkedFRs: string[];
|
|
57
|
+
/** NFR-XXX IDs linked to this journey (populated later) */
|
|
58
|
+
linkedNFRs: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface FunctionalRequirement {
|
|
62
|
+
/** FR ID in FR-001 format */
|
|
63
|
+
id: string;
|
|
64
|
+
/** What this requirement describes */
|
|
65
|
+
description: string;
|
|
66
|
+
/** Scope tag: v1/v2/out */
|
|
67
|
+
scope: 'v1' | 'v2' | 'out';
|
|
68
|
+
/** Priority: P0 (blocks core), P1 (required v1), P2 (enhances) */
|
|
69
|
+
priority: 'P0' | 'P1' | 'P2';
|
|
70
|
+
/** J-XXX IDs of source journeys */
|
|
71
|
+
sourceJourneys: string[];
|
|
72
|
+
/** "J-XXX step N" format references */
|
|
73
|
+
sourceSteps: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TraceabilityEntry {
|
|
77
|
+
/** FR ID */
|
|
78
|
+
frId: string;
|
|
79
|
+
/** FR description */
|
|
80
|
+
description: string;
|
|
81
|
+
/** Formatted journey references: "J-001 (step 3), J-002 (step 1)" */
|
|
82
|
+
journeyRefs: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* All 6 required journey types from GSWD_SPEC Section 8.3.
|
|
89
|
+
*/
|
|
90
|
+
export const JOURNEY_TYPES: { type: JourneyType; displayName: string }[] = [
|
|
91
|
+
{ type: 'onboarding', displayName: 'Onboarding' },
|
|
92
|
+
{ type: 'core_action', displayName: 'Core Action' },
|
|
93
|
+
{ type: 'view_results', displayName: 'View Results/History' },
|
|
94
|
+
{ type: 'settings', displayName: 'Settings/Preferences' },
|
|
95
|
+
{ type: 'error_states', displayName: 'Error States' },
|
|
96
|
+
{ type: 'empty_states', displayName: 'Empty States' },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Journey types that allow fewer steps (minimum 3 instead of 5) */
|
|
100
|
+
const REDUCED_STEP_TYPES: JourneyType[] = ['error_states', 'empty_states'];
|
|
101
|
+
|
|
102
|
+
// ─── Scope & Priority Heuristics ────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Assign scope based on journey type.
|
|
106
|
+
* Heuristic from CONTEXT.md locked decisions:
|
|
107
|
+
* - core_action/onboarding → v1
|
|
108
|
+
* - error_states/empty_states → v1 (required for polish)
|
|
109
|
+
* - view_results → v1
|
|
110
|
+
* - settings → v1 (default; caller can override to v2 for non-essential)
|
|
111
|
+
*/
|
|
112
|
+
export function assignScope(journeyType: JourneyType): 'v1' | 'v2' | 'out' {
|
|
113
|
+
switch (journeyType) {
|
|
114
|
+
case 'onboarding':
|
|
115
|
+
case 'core_action':
|
|
116
|
+
case 'view_results':
|
|
117
|
+
case 'error_states':
|
|
118
|
+
case 'empty_states':
|
|
119
|
+
case 'settings':
|
|
120
|
+
return 'v1';
|
|
121
|
+
default:
|
|
122
|
+
return 'v1';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Assign priority based on journey type.
|
|
128
|
+
* Heuristic from CONTEXT.md locked decisions:
|
|
129
|
+
* - core_action/onboarding → P0 (blocks core journey)
|
|
130
|
+
* - error_states/empty_states/view_results/settings → P1 (required for complete v1)
|
|
131
|
+
*/
|
|
132
|
+
export function assignPriority(journeyType: JourneyType): 'P0' | 'P1' | 'P2' {
|
|
133
|
+
switch (journeyType) {
|
|
134
|
+
case 'core_action':
|
|
135
|
+
case 'onboarding':
|
|
136
|
+
return 'P0';
|
|
137
|
+
case 'error_states':
|
|
138
|
+
case 'empty_states':
|
|
139
|
+
case 'view_results':
|
|
140
|
+
case 'settings':
|
|
141
|
+
return 'P1';
|
|
142
|
+
default:
|
|
143
|
+
return 'P1';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─── Journey Structure Generation ───────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Format a journey ID: J-001, J-002, etc.
|
|
151
|
+
*/
|
|
152
|
+
function formatJourneyId(journeyNumber: number): string {
|
|
153
|
+
return normalizeId(`J-${journeyNumber}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a journey skeleton with proper ID and empty arrays.
|
|
158
|
+
*/
|
|
159
|
+
export function generateJourneyStructure(
|
|
160
|
+
type: JourneyType,
|
|
161
|
+
journeyNumber: number
|
|
162
|
+
): Journey {
|
|
163
|
+
const typeInfo = JOURNEY_TYPES.find((t) => t.type === type);
|
|
164
|
+
const displayName = typeInfo ? typeInfo.displayName : type;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: formatJourneyId(journeyNumber),
|
|
168
|
+
name: displayName,
|
|
169
|
+
type,
|
|
170
|
+
preconditions: [],
|
|
171
|
+
steps: [],
|
|
172
|
+
success: '',
|
|
173
|
+
failureModes: [],
|
|
174
|
+
acceptanceTests: [],
|
|
175
|
+
linkedFRs: [],
|
|
176
|
+
linkedNFRs: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Journey Validation ─────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Validate a journey meets all structural requirements.
|
|
184
|
+
*/
|
|
185
|
+
export function validateJourney(
|
|
186
|
+
journey: Journey
|
|
187
|
+
): { valid: boolean; errors: string[] } {
|
|
188
|
+
const errors: string[] = [];
|
|
189
|
+
|
|
190
|
+
// ID format check
|
|
191
|
+
if (!/^J-\d{3,}$/.test(journey.id)) {
|
|
192
|
+
errors.push(`Invalid journey ID format: ${journey.id} (expected J-XXX)`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Minimum step count: 3 for error/empty, 5 for main journeys
|
|
196
|
+
const minSteps = REDUCED_STEP_TYPES.includes(journey.type) ? 3 : 5;
|
|
197
|
+
if (journey.steps.length < minSteps) {
|
|
198
|
+
errors.push(
|
|
199
|
+
`Journey ${journey.id} has ${journey.steps.length} steps, minimum is ${minSteps} for type '${journey.type}'`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Maximum step count: 8 recommended
|
|
204
|
+
if (journey.steps.length > 8) {
|
|
205
|
+
errors.push(
|
|
206
|
+
`Journey ${journey.id} has ${journey.steps.length} steps, recommended maximum is 8`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Failure modes >= 2
|
|
211
|
+
if (journey.failureModes.length < 2) {
|
|
212
|
+
errors.push(
|
|
213
|
+
`Journey ${journey.id} has ${journey.failureModes.length} failure modes, minimum is 2`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Acceptance tests >= 1
|
|
218
|
+
if (journey.acceptanceTests.length < 1) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`Journey ${journey.id} has ${journey.acceptanceTests.length} acceptance tests, minimum is 1`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Failure mode conciseness (max 200 chars per scenario)
|
|
225
|
+
for (const fm of journey.failureModes) {
|
|
226
|
+
if (fm.scenario.length > 200) {
|
|
227
|
+
errors.push(
|
|
228
|
+
`Journey ${journey.id} failure mode too long (${fm.scenario.length} chars, max 200): "${fm.scenario.substring(0, 50)}..."`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Acceptance test conciseness (max 200 chars, single statement)
|
|
234
|
+
for (const at of journey.acceptanceTests) {
|
|
235
|
+
if (at.length > 200) {
|
|
236
|
+
errors.push(
|
|
237
|
+
`Journey ${journey.id} acceptance test too long (${at.length} chars, max 200): "${at.substring(0, 50)}..."`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Preconditions non-empty
|
|
243
|
+
if (journey.preconditions.length === 0) {
|
|
244
|
+
errors.push(`Journey ${journey.id} has empty preconditions`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Success non-empty
|
|
248
|
+
if (!journey.success || journey.success.trim() === '') {
|
|
249
|
+
errors.push(`Journey ${journey.id} has empty success outcome`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return { valid: errors.length === 0, errors };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── FR Extraction ──────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Format an FR ID: FR-001, FR-002, etc.
|
|
259
|
+
*/
|
|
260
|
+
function formatFRId(frNumber: number): string {
|
|
261
|
+
return normalizeId(`FR-${frNumber}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extract FRs from journeys. Each journey step maps to at least 1 FR.
|
|
266
|
+
* Populates bidirectional cross-links:
|
|
267
|
+
* - Each step.frIds gets the generated FR IDs
|
|
268
|
+
* - Each journey.linkedFRs gets all FR IDs from its steps
|
|
269
|
+
* - Each FR.sourceJourneys references the parent journey
|
|
270
|
+
* - Each FR.sourceSteps references "J-XXX step N"
|
|
271
|
+
*
|
|
272
|
+
* Duplicate step descriptions merge into a single FR with multiple sources.
|
|
273
|
+
*/
|
|
274
|
+
export function extractFRsFromJourneys(
|
|
275
|
+
journeys: Journey[]
|
|
276
|
+
): FunctionalRequirement[] {
|
|
277
|
+
let frCounter = 1;
|
|
278
|
+
const frMap = new Map<string, FunctionalRequirement>();
|
|
279
|
+
/** Map from normalized description to FR ID for deduplication */
|
|
280
|
+
const descriptionToFrId = new Map<string, string>();
|
|
281
|
+
|
|
282
|
+
for (const journey of journeys) {
|
|
283
|
+
for (const step of journey.steps) {
|
|
284
|
+
const normalizedDesc = step.action.trim().toLowerCase();
|
|
285
|
+
|
|
286
|
+
if (descriptionToFrId.has(normalizedDesc)) {
|
|
287
|
+
// Merge: add this journey/step as additional source
|
|
288
|
+
const existingFrId = descriptionToFrId.get(normalizedDesc)!;
|
|
289
|
+
const existingFr = frMap.get(existingFrId)!;
|
|
290
|
+
|
|
291
|
+
if (!existingFr.sourceJourneys.includes(journey.id)) {
|
|
292
|
+
existingFr.sourceJourneys.push(journey.id);
|
|
293
|
+
}
|
|
294
|
+
existingFr.sourceSteps.push(`${journey.id} step ${step.number}`);
|
|
295
|
+
step.frIds.push(existingFrId);
|
|
296
|
+
} else {
|
|
297
|
+
// New FR
|
|
298
|
+
const frId = formatFRId(frCounter);
|
|
299
|
+
frCounter++;
|
|
300
|
+
|
|
301
|
+
const fr: FunctionalRequirement = {
|
|
302
|
+
id: frId,
|
|
303
|
+
description: step.action,
|
|
304
|
+
scope: assignScope(journey.type),
|
|
305
|
+
priority: assignPriority(journey.type),
|
|
306
|
+
sourceJourneys: [journey.id],
|
|
307
|
+
sourceSteps: [`${journey.id} step ${step.number}`],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
frMap.set(frId, fr);
|
|
311
|
+
descriptionToFrId.set(normalizedDesc, frId);
|
|
312
|
+
step.frIds.push(frId);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Populate journey.linkedFRs from step frIds
|
|
317
|
+
const journeyFrIds = new Set<string>();
|
|
318
|
+
for (const step of journey.steps) {
|
|
319
|
+
for (const frId of step.frIds) {
|
|
320
|
+
journeyFrIds.add(frId);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
journey.linkedFRs = Array.from(journeyFrIds).sort();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Return sorted by FR ID
|
|
327
|
+
return Array.from(frMap.values()).sort((a, b) => {
|
|
328
|
+
const aNum = parseInt(a.id.split('-')[1], 10);
|
|
329
|
+
const bNum = parseInt(b.id.split('-')[1], 10);
|
|
330
|
+
return aNum - bNum;
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── Traceability ───────────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Build traceability map for SPEC.md traceability table.
|
|
338
|
+
* Each entry maps an FR to its source journeys.
|
|
339
|
+
*/
|
|
340
|
+
export function buildTraceabilityMap(
|
|
341
|
+
frs: FunctionalRequirement[]
|
|
342
|
+
): TraceabilityEntry[] {
|
|
343
|
+
return frs.map((fr) => ({
|
|
344
|
+
frId: fr.id,
|
|
345
|
+
description: fr.description,
|
|
346
|
+
journeyRefs: fr.sourceSteps.map((stepRef) => {
|
|
347
|
+
// Convert "J-001 step 3" to "J-001 (step 3)"
|
|
348
|
+
const match = stepRef.match(/^(J-\d+)\s+step\s+(\d+)$/);
|
|
349
|
+
if (match) {
|
|
350
|
+
return `${match[1]} (step ${match[2]})`;
|
|
351
|
+
}
|
|
352
|
+
return stepRef;
|
|
353
|
+
}),
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── Coverage Validation ────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Validate FR coverage: every journey step has FRs, every FR has sources.
|
|
361
|
+
*/
|
|
362
|
+
export function validateFRCoverage(
|
|
363
|
+
journeys: Journey[],
|
|
364
|
+
frs: FunctionalRequirement[]
|
|
365
|
+
): { valid: boolean; errors: string[] } {
|
|
366
|
+
const errors: string[] = [];
|
|
367
|
+
|
|
368
|
+
// Every journey step must have at least 1 FR
|
|
369
|
+
for (const journey of journeys) {
|
|
370
|
+
for (const step of journey.steps) {
|
|
371
|
+
if (step.frIds.length === 0) {
|
|
372
|
+
errors.push(
|
|
373
|
+
`Journey ${journey.id} step ${step.number} has no FR coverage`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Every FR must have at least 1 source journey
|
|
380
|
+
for (const fr of frs) {
|
|
381
|
+
if (fr.sourceJourneys.length === 0) {
|
|
382
|
+
errors.push(`FR ${fr.id} has no source journey (orphan FR)`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// All FR IDs use correct format
|
|
387
|
+
for (const fr of frs) {
|
|
388
|
+
if (!/^FR-\d{3,}$/.test(fr.id)) {
|
|
389
|
+
errors.push(`Invalid FR ID format: ${fr.id} (expected FR-XXX)`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// All scope tags are valid
|
|
394
|
+
const validScopes = ['v1', 'v2', 'out'];
|
|
395
|
+
for (const fr of frs) {
|
|
396
|
+
if (!validScopes.includes(fr.scope)) {
|
|
397
|
+
errors.push(`FR ${fr.id} has invalid scope: ${fr.scope}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// All priority tags are valid
|
|
402
|
+
const validPriorities = ['P0', 'P1', 'P2'];
|
|
403
|
+
for (const fr of frs) {
|
|
404
|
+
if (!validPriorities.includes(fr.priority)) {
|
|
405
|
+
errors.push(`FR ${fr.id} has invalid priority: ${fr.priority}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { valid: errors.length === 0, errors };
|
|
410
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSWD Specify NFR Module — Rule-based non-functional requirement generation
|
|
3
|
+
*
|
|
4
|
+
* Generates NFRs across 4 required categories: security, privacy, performance, observability.
|
|
5
|
+
* Thresholds calibrated for v1 MVP — conservative and achievable.
|
|
6
|
+
*
|
|
7
|
+
* Schema: GSWD_SPEC.md Section 6.1 (ID formats), 8.3 (specify workflow step 4)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { normalizeId } from './parse.js';
|
|
11
|
+
import type { FunctionalRequirement } from './specify-journeys.js';
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type NFRCategory = 'security' | 'privacy' | 'performance' | 'observability';
|
|
16
|
+
|
|
17
|
+
export interface NonFunctionalRequirement {
|
|
18
|
+
/** NFR ID in NFR-001 format */
|
|
19
|
+
id: string;
|
|
20
|
+
/** Category: security, privacy, performance, or observability */
|
|
21
|
+
category: NFRCategory;
|
|
22
|
+
/** What this NFR requires */
|
|
23
|
+
description: string;
|
|
24
|
+
/** Measurable threshold or acceptance criteria */
|
|
25
|
+
threshold: string;
|
|
26
|
+
/** FR-XXX IDs this NFR applies to */
|
|
27
|
+
linkedFRs: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The 4 required NFR categories from GSWD_SPEC Section 8.3.
|
|
34
|
+
*/
|
|
35
|
+
export const NFR_CATEGORIES: { category: NFRCategory; description: string }[] = [
|
|
36
|
+
{ category: 'security', description: 'Input validation, authentication, authorization' },
|
|
37
|
+
{ category: 'privacy', description: 'Data handling, user consent, PII protection' },
|
|
38
|
+
{ category: 'performance', description: 'Response time, throughput, resource usage' },
|
|
39
|
+
{ category: 'observability', description: 'Logging, error tracking, health checks' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ─── NFR Generation ─────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format an NFR ID: NFR-001, NFR-002, etc.
|
|
46
|
+
*/
|
|
47
|
+
function formatNFRId(nfrNumber: number): string {
|
|
48
|
+
return normalizeId(`NFR-${nfrNumber}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Generate NFRs from FR list across all 4 required categories.
|
|
53
|
+
* Rule-based: each category produces a standard set of NFRs.
|
|
54
|
+
* Thresholds calibrated for v1 MVP.
|
|
55
|
+
*
|
|
56
|
+
* @param frs - Functional requirements to link NFRs to
|
|
57
|
+
* @param decisionsContext - Optional DECISIONS.md content for calibration
|
|
58
|
+
*/
|
|
59
|
+
export function generateNFRs(
|
|
60
|
+
frs: FunctionalRequirement[],
|
|
61
|
+
decisionsContext?: string
|
|
62
|
+
): NonFunctionalRequirement[] {
|
|
63
|
+
const nfrs: NonFunctionalRequirement[] = [];
|
|
64
|
+
let counter = 1;
|
|
65
|
+
|
|
66
|
+
const v1FrIds = frs.filter((fr) => fr.scope === 'v1').map((fr) => fr.id);
|
|
67
|
+
const p0FrIds = frs.filter((fr) => fr.priority === 'P0').map((fr) => fr.id);
|
|
68
|
+
|
|
69
|
+
// ─── Security NFRs ───────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
nfrs.push({
|
|
72
|
+
id: formatNFRId(counter++),
|
|
73
|
+
category: 'security',
|
|
74
|
+
description: 'All user-facing input fields must be validated before processing',
|
|
75
|
+
threshold: 'No unvalidated user input reaches data store or business logic',
|
|
76
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
nfrs.push({
|
|
80
|
+
id: formatNFRId(counter++),
|
|
81
|
+
category: 'security',
|
|
82
|
+
description: 'All authenticated operations must verify session validity',
|
|
83
|
+
threshold: 'Unauthorized access returns 401/403 within 100ms',
|
|
84
|
+
linkedFRs: p0FrIds.length > 0 ? p0FrIds : [],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
nfrs.push({
|
|
88
|
+
id: formatNFRId(counter++),
|
|
89
|
+
category: 'security',
|
|
90
|
+
description: 'Operations must enforce role-based access when roles are defined',
|
|
91
|
+
threshold: 'Users can only access resources they own or are authorized to view',
|
|
92
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── Privacy NFRs ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
nfrs.push({
|
|
98
|
+
id: formatNFRId(counter++),
|
|
99
|
+
category: 'privacy',
|
|
100
|
+
description: 'User data must not be logged in plain text or exposed in error messages',
|
|
101
|
+
threshold: 'No PII appears in application logs or stack traces',
|
|
102
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
nfrs.push({
|
|
106
|
+
id: formatNFRId(counter++),
|
|
107
|
+
category: 'privacy',
|
|
108
|
+
description: 'User data collection requires explicit consent where applicable',
|
|
109
|
+
threshold: 'No data stored without user-initiated action',
|
|
110
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── Performance NFRs ────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
nfrs.push({
|
|
116
|
+
id: formatNFRId(counter++),
|
|
117
|
+
category: 'performance',
|
|
118
|
+
description: 'Core user actions must complete within acceptable time',
|
|
119
|
+
threshold: 'P95 response time < 2s for core actions, < 5s for complex operations',
|
|
120
|
+
linkedFRs: p0FrIds.length > 0 ? p0FrIds : [],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
nfrs.push({
|
|
124
|
+
id: formatNFRId(counter++),
|
|
125
|
+
category: 'performance',
|
|
126
|
+
description: 'System must handle expected v1 concurrent load',
|
|
127
|
+
threshold: 'Supports 100 concurrent users without degradation',
|
|
128
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ─── Observability NFRs ──────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
nfrs.push({
|
|
134
|
+
id: formatNFRId(counter++),
|
|
135
|
+
category: 'observability',
|
|
136
|
+
description: 'All errors must be captured with context for debugging',
|
|
137
|
+
threshold: 'Errors include timestamp, user context (anonymized), stack trace, and request ID',
|
|
138
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
nfrs.push({
|
|
142
|
+
id: formatNFRId(counter++),
|
|
143
|
+
category: 'observability',
|
|
144
|
+
description: 'System health must be verifiable',
|
|
145
|
+
threshold: 'Health endpoint returns status within 1s',
|
|
146
|
+
linkedFRs: [],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
nfrs.push({
|
|
150
|
+
id: formatNFRId(counter++),
|
|
151
|
+
category: 'observability',
|
|
152
|
+
description: 'Key operations must produce structured logs',
|
|
153
|
+
threshold: 'All state-changing operations produce at least one log entry',
|
|
154
|
+
linkedFRs: v1FrIds.length > 0 ? v1FrIds : [],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return nfrs;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── NFR Validation ─────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate NFRs: all 4 categories represented, IDs correct, fields non-empty.
|
|
164
|
+
*/
|
|
165
|
+
export function validateNFRs(
|
|
166
|
+
nfrs: NonFunctionalRequirement[]
|
|
167
|
+
): { valid: boolean; errors: string[] } {
|
|
168
|
+
const errors: string[] = [];
|
|
169
|
+
const categoriesPresent = new Set<NFRCategory>();
|
|
170
|
+
|
|
171
|
+
for (const nfr of nfrs) {
|
|
172
|
+
categoriesPresent.add(nfr.category);
|
|
173
|
+
|
|
174
|
+
// ID format check
|
|
175
|
+
if (!/^NFR-\d{3,}$/.test(nfr.id)) {
|
|
176
|
+
errors.push(`Invalid NFR ID format: ${nfr.id} (expected NFR-XXX)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Non-empty description
|
|
180
|
+
if (!nfr.description || nfr.description.trim() === '') {
|
|
181
|
+
errors.push(`NFR ${nfr.id} has empty description`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Non-empty threshold
|
|
185
|
+
if (!nfr.threshold || nfr.threshold.trim() === '') {
|
|
186
|
+
errors.push(`NFR ${nfr.id} has empty threshold`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// All 4 categories represented
|
|
191
|
+
const requiredCategories: NFRCategory[] = ['security', 'privacy', 'performance', 'observability'];
|
|
192
|
+
for (const category of requiredCategories) {
|
|
193
|
+
if (!categoriesPresent.has(category)) {
|
|
194
|
+
errors.push(`Missing required NFR category: ${category}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Sequential IDs (warning-level, but include in errors for strict validation)
|
|
199
|
+
for (let i = 0; i < nfrs.length; i++) {
|
|
200
|
+
const expectedId = formatNFRId(i + 1);
|
|
201
|
+
if (nfrs[i].id !== expectedId) {
|
|
202
|
+
errors.push(`Non-sequential NFR ID: expected ${expectedId}, got ${nfrs[i].id}`);
|
|
203
|
+
break; // Only report first gap
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { valid: errors.length === 0, errors };
|
|
208
|
+
}
|