@taodigital/app-brain 0.0.0-stage → 1.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.
Files changed (47) hide show
  1. package/README.md +13 -2
  2. package/dist/archetype-classification.d.ts +54 -0
  3. package/dist/archetype-classification.js +95 -0
  4. package/dist/cloud-storage.d.ts +106 -0
  5. package/dist/cloud-storage.js +539 -0
  6. package/dist/email.d.ts +21 -0
  7. package/dist/email.js +234 -0
  8. package/dist/grounding.d.ts +30 -0
  9. package/dist/grounding.js +138 -0
  10. package/dist/index.d.ts +13 -0
  11. package/dist/index.js +37 -0
  12. package/dist/nav-graph.d.ts +42 -0
  13. package/dist/nav-graph.js +248 -0
  14. package/dist/notifications.d.ts +26 -0
  15. package/dist/notifications.js +105 -0
  16. package/dist/orphan-classification.d.ts +32 -0
  17. package/dist/orphan-classification.js +40 -0
  18. package/dist/repository.d.ts +3185 -0
  19. package/dist/repository.js +5927 -0
  20. package/dist/run-notifications.d.ts +16 -0
  21. package/dist/run-notifications.js +244 -0
  22. package/dist/skill-file-kinds.d.ts +5 -0
  23. package/dist/skill-file-kinds.js +50 -0
  24. package/dist/token-ledger.d.ts +23 -0
  25. package/dist/token-ledger.js +178 -0
  26. package/dist/traceability.d.ts +213 -0
  27. package/dist/traceability.js +339 -0
  28. package/package.json +24 -4
  29. package/src/archetype-classification.test.ts +91 -0
  30. package/src/archetype-classification.ts +132 -0
  31. package/src/cloud-storage.test.ts +83 -0
  32. package/src/cloud-storage.ts +627 -0
  33. package/src/email.ts +268 -0
  34. package/src/grounding.ts +172 -0
  35. package/src/index.ts +18 -0
  36. package/src/nav-graph.ts +295 -0
  37. package/src/notifications.ts +156 -0
  38. package/src/orphan-classification.test.ts +61 -0
  39. package/src/orphan-classification.ts +65 -0
  40. package/src/orphan-screens.test.ts +88 -0
  41. package/src/repository.ts +7397 -0
  42. package/src/run-notifications.ts +281 -0
  43. package/src/skill-file-kinds.ts +51 -0
  44. package/src/token-ledger.ts +209 -0
  45. package/src/traceability.test.ts +75 -0
  46. package/src/traceability.ts +477 -0
  47. package/tsconfig.json +9 -0
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Traceability matrix + alignment report.
3
+ *
4
+ * Chain: Jira story / requirement → acceptance criteria → test cases (primary + additional links)
5
+ * → scripts (annotation present?) → alignment (AI score + deterministic step coverage).
6
+ *
7
+ * Builders are pure (testable); loaders fetch tenant-scoped rows and call them.
8
+ */
9
+ import { withTenant } from '@taodigital/database';
10
+
11
+ // ─── Link helpers ────────────────────────────────────────────────────────────
12
+
13
+ const UNLINKED = new Set(['', 'REQ-UNKNOWN', 'UNKNOWN', 'N/A']);
14
+
15
+ export function isRealReqId(v: unknown): v is string {
16
+ return typeof v === 'string' && !UNLINKED.has(v.trim().toUpperCase());
17
+ }
18
+
19
+ /** Primary traceTo followed by additional links, de-duplicated, placeholders removed. */
20
+ export function linkedReqIds(tc: { traceTo?: string | null; traceToExtra?: unknown }): string[] {
21
+ const extra = Array.isArray(tc.traceToExtra) ? (tc.traceToExtra as unknown[]) : [];
22
+ const all = [tc.traceTo, ...extra].filter(isRealReqId).map((s) => s.trim());
23
+ return [...new Set(all)];
24
+ }
25
+
26
+ export function caseTracesTo(tc: { traceTo?: string | null; traceToExtra?: unknown }, reqId: string): boolean {
27
+ return linkedReqIds(tc).includes(reqId);
28
+ }
29
+
30
+ // ─── Acceptance-criterion matching ───────────────────────────────────────────
31
+
32
+ const AC_STOP = new Set([
33
+ 'the', 'a', 'an', 'to', 'of', 'on', 'in', 'into', 'and', 'or', 'with', 'for', 'from', 'then', 'that', 'is', 'are', 'be',
34
+ 'should', 'will', 'shall', 'can', 'it', 'its', 'this', 'these', 'those', 'at', 'by', 'as', 'user', 'given', 'when', 'but',
35
+ 'so', 'now', 'has', 'have', 'had', 'does', 'not', 'all', 'any', 'able', 'must', 'page', 'screen', 'system', 'application',
36
+ ]);
37
+
38
+ function acTokenize(text: string): Set<string> {
39
+ const out = new Set<string>();
40
+ for (const raw of text.toLowerCase().replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^a-z0-9]+/)) {
41
+ let w = raw;
42
+ if (w.length < 3 || AC_STOP.has(w)) continue;
43
+ if (w.length > 5 && w.endsWith('ing')) w = w.slice(0, -3);
44
+ else if (w.length > 4 && w.endsWith('ed')) w = w.slice(0, -2);
45
+ else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss')) w = w.slice(0, -1);
46
+ if (!AC_STOP.has(w)) out.add(w);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function stepText(s: unknown): string {
52
+ if (typeof s === 'string') return s;
53
+ if (s && typeof s === 'object') {
54
+ const o = s as Record<string, unknown>;
55
+ return [o.action, o.data, o.expected, o.text].filter((v) => v != null && typeof v !== 'object').join(' ');
56
+ }
57
+ return '';
58
+ }
59
+
60
+ function caseCorpus(tc: { title?: string; steps?: unknown; expectedResult?: string | null; preconditions?: string | null }): string {
61
+ const steps = Array.isArray(tc.steps) ? tc.steps.map(stepText) : [];
62
+ return [tc.title, tc.preconditions, tc.expectedResult, ...steps].filter(Boolean).join(' ');
63
+ }
64
+
65
+ /**
66
+ * Deterministic AC → test-case match: a case addresses an AC when it shares ≥ 40% of the AC's
67
+ * content tokens (and ≥ 2 tokens, or ≥ 1 when the AC itself has ≤ 2 tokens).
68
+ */
69
+ export function matchAcceptanceCriterion(
70
+ ac: string,
71
+ cases: Array<{ id: string; ref?: string | null; title?: string; steps?: unknown; expectedResult?: string | null; preconditions?: string | null }>,
72
+ ): { covered: boolean; confidence: number; caseRefs: string[] } {
73
+ const tokens = acTokenize(ac);
74
+ if (tokens.size === 0) return { covered: false, confidence: 0, caseRefs: [] };
75
+ const needed = tokens.size <= 2 ? 1 : 2;
76
+ const hits: Array<{ ref: string; ratio: number }> = [];
77
+ for (const tc of cases) {
78
+ const corpus = acTokenize(caseCorpus(tc));
79
+ let n = 0;
80
+ for (const t of tokens) if (corpus.has(t)) n++;
81
+ const ratio = n / tokens.size;
82
+ if (n >= needed && ratio >= 0.4) hits.push({ ref: tc.ref ?? tc.id, ratio });
83
+ }
84
+ hits.sort((a, b) => b.ratio - a.ratio);
85
+ return {
86
+ covered: hits.length > 0,
87
+ confidence: hits.length ? Math.round(hits[0].ratio * 100) / 100 : 0,
88
+ caseRefs: hits.map((h) => h.ref),
89
+ };
90
+ }
91
+
92
+ // ─── Types ───────────────────────────────────────────────────────────────────
93
+
94
+ export interface TraceScriptInfo {
95
+ language: string;
96
+ /** null when the script body is not available (e.g. stored in object storage). */
97
+ annotated: boolean | null;
98
+ aiScore: number | null;
99
+ aligned: boolean | null;
100
+ stepCoveragePct: number | null;
101
+ verifiedAt: string | null;
102
+ }
103
+
104
+ export interface TraceCaseInput {
105
+ id: string;
106
+ ref: string | null;
107
+ title: string;
108
+ kind: string;
109
+ status: string;
110
+ suiteId: string;
111
+ steps: unknown;
112
+ expectedResult: string | null;
113
+ preconditions: string | null;
114
+ traceTo: string | null;
115
+ traceToExtra: unknown;
116
+ scenarioId: string | null;
117
+ suspect: boolean;
118
+ suspectReason: string | null;
119
+ scripts: TraceScriptInfo[];
120
+ }
121
+
122
+ export interface TraceRequirementInput {
123
+ id: string;
124
+ reqId: string;
125
+ title: string;
126
+ priority: string;
127
+ status: string;
128
+ source: string;
129
+ acceptanceCriteria: unknown;
130
+ externalMeta: any;
131
+ updatedAt?: Date | string | null;
132
+ }
133
+
134
+ export interface TraceScenarioInput { id: string; requirementId: string; title: string; status: string }
135
+
136
+ export interface TraceabilityMatrix {
137
+ generatedAt: string;
138
+ summary: {
139
+ requirements: number;
140
+ requirementsCovered: number;
141
+ requirementsPartial: number;
142
+ requirementsUntested: number;
143
+ requirementsWithScripts: number;
144
+ acTotal: number;
145
+ acCovered: number;
146
+ acCoveragePct: number | null;
147
+ testCases: number;
148
+ testCasesLinked: number;
149
+ orphanTestCases: number;
150
+ brokenLinks: number;
151
+ suspectTestCases: number;
152
+ scripts: number;
153
+ scriptsMissingAnnotation: number;
154
+ scriptsAligned: number;
155
+ };
156
+ requirements: Array<{
157
+ reqId: string;
158
+ title: string;
159
+ priority: string;
160
+ status: string;
161
+ source: string;
162
+ jira: { url?: string; status?: string; epic?: string; labels: string[]; links: string[] } | null;
163
+ coverageState: 'covered' | 'partial' | 'untested';
164
+ acCoveragePct: number | null;
165
+ hasSuspect: boolean;
166
+ acceptanceCriteria: Array<{ index: number; text: string; covered: boolean; confidence: number; testCases: string[] }>;
167
+ scenarios: Array<{ id: string; title: string; status: string }>;
168
+ testCases: Array<{
169
+ id: string; ref: string | null; title: string; kind: string; status: string;
170
+ linkType: 'primary' | 'additional'; scenarioId: string | null;
171
+ suspect: boolean; suspectReason: string | null; scripts: TraceScriptInfo[];
172
+ }>;
173
+ }>;
174
+ orphans: Array<{ id: string; ref: string | null; title: string; traceTo: string | null; reason: 'no-link' | 'placeholder' }>;
175
+ brokenLinks: Array<{ id: string; ref: string | null; title: string; reqId: string }>;
176
+ }
177
+
178
+ const ALIGN_THRESHOLD = 75;
179
+ export const EXPORT_GATE_MIN = 60;
180
+
181
+ function acList(raw: unknown): string[] {
182
+ return Array.isArray(raw) ? raw.map((v) => String(v)).filter((s) => s.trim()) : [];
183
+ }
184
+
185
+ function jiraInfo(meta: any, source: string) {
186
+ if (!meta || typeof meta !== 'object') return source === 'JIRA_IMPORT' ? { labels: [], links: [] } : null;
187
+ return {
188
+ url: meta.url ?? undefined,
189
+ status: meta.status ?? undefined,
190
+ epic: meta.epic?.key ? `${meta.epic.key}${meta.epic.summary ? ` — ${meta.epic.summary}` : ''}` : (typeof meta.epic === 'string' ? meta.epic : undefined),
191
+ labels: Array.isArray(meta.labels) ? meta.labels.map(String) : [],
192
+ links: Array.isArray(meta.links) ? meta.links.map((l: any) => `${l.type ?? 'relates'}: ${l.key ?? l}`) : [],
193
+ };
194
+ }
195
+
196
+ // ─── Matrix builder ──────────────────────────────────────────────────────────
197
+
198
+ export function buildTraceabilityMatrix(input: {
199
+ requirements: TraceRequirementInput[];
200
+ testCases: TraceCaseInput[];
201
+ scenarios: TraceScenarioInput[];
202
+ }): TraceabilityMatrix {
203
+ const active = input.testCases.filter((t) => t.status !== 'RETIRED');
204
+ const reqIds = new Set(input.requirements.map((r) => r.reqId));
205
+ const reqDbIdToReqId = new Map(input.requirements.map((r) => [r.id, r.reqId]));
206
+
207
+ const rows: TraceabilityMatrix['requirements'] = input.requirements.map((r) => {
208
+ const linked = active.filter((t) => caseTracesTo(t, r.reqId));
209
+ const acs = acList(r.acceptanceCriteria).map((text, i) => {
210
+ const m = matchAcceptanceCriterion(text, linked);
211
+ return { index: i + 1, text, covered: m.covered, confidence: m.confidence, testCases: m.caseRefs };
212
+ });
213
+ const acCovered = acs.filter((a) => a.covered).length;
214
+ const acCoveragePct = acs.length ? Math.round((acCovered / acs.length) * 1000) / 10 : null;
215
+ const coverageState: 'covered' | 'partial' | 'untested' =
216
+ linked.length === 0 ? 'untested' : acs.length === 0 || acCovered === acs.length ? 'covered' : 'partial';
217
+ return {
218
+ reqId: r.reqId,
219
+ title: r.title,
220
+ priority: r.priority,
221
+ status: r.status,
222
+ source: r.source,
223
+ jira: jiraInfo(r.externalMeta, r.source),
224
+ coverageState,
225
+ acCoveragePct,
226
+ hasSuspect: linked.some((t) => t.suspect),
227
+ acceptanceCriteria: acs,
228
+ scenarios: input.scenarios
229
+ .filter((s) => reqDbIdToReqId.get(s.requirementId) === r.reqId)
230
+ .map((s) => ({ id: s.id, title: s.title, status: s.status })),
231
+ testCases: linked.map((t) => ({
232
+ id: t.id, ref: t.ref, title: t.title, kind: t.kind, status: t.status,
233
+ linkType: (t.traceTo === r.reqId ? 'primary' : 'additional') as 'primary' | 'additional',
234
+ scenarioId: t.scenarioId, suspect: t.suspect, suspectReason: t.suspectReason, scripts: t.scripts,
235
+ })),
236
+ };
237
+ });
238
+
239
+ const orphans: TraceabilityMatrix['orphans'] = [];
240
+ const brokenLinks: TraceabilityMatrix['brokenLinks'] = [];
241
+ for (const t of active) {
242
+ const ids = linkedReqIds(t);
243
+ if (ids.length === 0) {
244
+ orphans.push({ id: t.id, ref: t.ref, title: t.title, traceTo: t.traceTo, reason: t.traceTo ? 'placeholder' : 'no-link' });
245
+ continue;
246
+ }
247
+ for (const id of ids) if (!reqIds.has(id)) brokenLinks.push({ id: t.id, ref: t.ref, title: t.title, reqId: id });
248
+ }
249
+
250
+ const allAc = rows.flatMap((r) => r.acceptanceCriteria);
251
+ const acCovered = allAc.filter((a) => a.covered).length;
252
+ const scriptsAll = active.flatMap((t) => t.scripts);
253
+ const linkedCases = active.filter((t) => linkedReqIds(t).some((id) => reqIds.has(id)));
254
+
255
+ return {
256
+ generatedAt: new Date().toISOString(),
257
+ summary: {
258
+ requirements: rows.length,
259
+ requirementsCovered: rows.filter((r) => r.coverageState === 'covered').length,
260
+ requirementsPartial: rows.filter((r) => r.coverageState === 'partial').length,
261
+ requirementsUntested: rows.filter((r) => r.coverageState === 'untested').length,
262
+ requirementsWithScripts: rows.filter((r) => r.testCases.some((t) => t.scripts.length > 0)).length,
263
+ acTotal: allAc.length,
264
+ acCovered,
265
+ acCoveragePct: allAc.length ? Math.round((acCovered / allAc.length) * 1000) / 10 : null,
266
+ testCases: active.length,
267
+ testCasesLinked: linkedCases.length,
268
+ orphanTestCases: orphans.length,
269
+ brokenLinks: brokenLinks.length,
270
+ suspectTestCases: active.filter((t) => t.suspect).length,
271
+ scripts: scriptsAll.length,
272
+ scriptsMissingAnnotation: scriptsAll.filter((s) => s.annotated === false).length,
273
+ scriptsAligned: scriptsAll.filter((s) => (s.aiScore ?? -1) >= ALIGN_THRESHOLD).length,
274
+ },
275
+ requirements: rows,
276
+ orphans,
277
+ brokenLinks,
278
+ };
279
+ }
280
+
281
+ // ─── Alignment report ────────────────────────────────────────────────────────
282
+
283
+ export interface AlignmentRowInput {
284
+ testCaseId: string;
285
+ ref: string | null;
286
+ title: string;
287
+ traceTo: string | null;
288
+ language: string;
289
+ score: number;
290
+ aligned: boolean;
291
+ issues: unknown;
292
+ stepCoveragePct: number | null;
293
+ stepCoverage: any;
294
+ verifiedAt: Date | string | null;
295
+ }
296
+
297
+ export interface AlignmentReport {
298
+ generatedAt: string;
299
+ methodology: { aiScore: string; stepCoverage: string; alignedThreshold: number; exportGate: number };
300
+ summary: {
301
+ scripts: number;
302
+ avgAiScore: number | null;
303
+ avgStepCoveragePct: number | null;
304
+ alignedCount: number;
305
+ passRatePct: number | null;
306
+ belowExportGate: number;
307
+ distribution: Record<'0-39' | '40-59' | '60-74' | '75-89' | '90-100', number>;
308
+ };
309
+ rows: Array<{
310
+ testCaseId: string; ref: string | null; title: string; traceTo: string | null; language: string;
311
+ aiScore: number; aligned: boolean; stepCoveragePct: number | null;
312
+ totalSteps: number | null; coveredSteps: number | null;
313
+ assertionCoveragePct: number | null;
314
+ uncoveredSteps: Array<{ index: number; step: string }>;
315
+ perStep: Array<{ index: number; step: string; covered: boolean; confidence: number; matchedLine?: number; matchedStatement?: string }>;
316
+ issues: string[]; verifiedAt: string | null;
317
+ }>;
318
+ }
319
+
320
+ function avg(nums: number[]): number | null {
321
+ return nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 : null;
322
+ }
323
+
324
+ export function buildAlignmentReport(rows: AlignmentRowInput[]): AlignmentReport {
325
+ const outRows: AlignmentReport['rows'] = rows.map((r) => {
326
+ const sc = r.stepCoverage && typeof r.stepCoverage === 'object' ? r.stepCoverage : null;
327
+ const perStep = Array.isArray(sc?.perStep) ? sc.perStep : [];
328
+ return {
329
+ testCaseId: r.testCaseId, ref: r.ref, title: r.title, traceTo: r.traceTo, language: r.language,
330
+ aiScore: r.score, aligned: r.score >= ALIGN_THRESHOLD, stepCoveragePct: r.stepCoveragePct,
331
+ totalSteps: sc?.totalSteps ?? null, coveredSteps: sc?.coveredSteps ?? null,
332
+ assertionCoveragePct: sc?.assertionCoverage?.pct ?? null,
333
+ uncoveredSteps: perStep.filter((p: any) => !p.covered).map((p: any) => ({ index: p.index, step: p.step })),
334
+ perStep,
335
+ issues: Array.isArray(r.issues) ? (r.issues as unknown[]).map(String) : [],
336
+ verifiedAt: r.verifiedAt ? new Date(r.verifiedAt).toISOString() : null,
337
+ };
338
+ }).sort((a, b) => a.aiScore - b.aiScore);
339
+
340
+ const dist = { '0-39': 0, '40-59': 0, '60-74': 0, '75-89': 0, '90-100': 0 } as AlignmentReport['summary']['distribution'];
341
+ for (const r of outRows) {
342
+ const s = r.aiScore;
343
+ dist[s < 40 ? '0-39' : s < 60 ? '40-59' : s < 75 ? '60-74' : s < 90 ? '75-89' : '90-100']++;
344
+ }
345
+ const aligned = outRows.filter((r) => r.aligned).length;
346
+ return {
347
+ generatedAt: new Date().toISOString(),
348
+ methodology: {
349
+ aiScore: 'LLM auditor rubric per script: step coverage 40, assertion correctness 30, locator quality 20, no violations 10, minus penalties for anti-patterns. 0–100.',
350
+ stepCoverage: 'Deterministic: test-case steps with a matching script statement ÷ total steps (token-overlap-v1). Same input always gives the same result.',
351
+ alignedThreshold: ALIGN_THRESHOLD,
352
+ exportGate: EXPORT_GATE_MIN,
353
+ },
354
+ summary: {
355
+ scripts: outRows.length,
356
+ avgAiScore: avg(outRows.map((r) => r.aiScore)),
357
+ avgStepCoveragePct: avg(outRows.map((r) => r.stepCoveragePct).filter((v): v is number => typeof v === 'number')),
358
+ alignedCount: aligned,
359
+ passRatePct: outRows.length ? Math.round((aligned / outRows.length) * 1000) / 10 : null,
360
+ belowExportGate: outRows.filter((r) => r.aiScore < EXPORT_GATE_MIN).length,
361
+ distribution: dist,
362
+ },
363
+ rows: outRows,
364
+ };
365
+ }
366
+
367
+ // ─── CSV ─────────────────────────────────────────────────────────────────────
368
+
369
+ function csvCell(v: unknown): string {
370
+ const s = v == null ? '' : String(v);
371
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
372
+ }
373
+
374
+ export function traceabilityToCsv(m: TraceabilityMatrix): string {
375
+ const header = ['Requirement', 'Requirement Title', 'Priority', 'Source', 'Jira Status', 'Coverage', 'AC #', 'Acceptance Criterion', 'AC Covered', 'AC Test Cases',
376
+ 'Test Case', 'Test Case Title', 'Link', 'TC Status', 'Suspect', 'Script Language', 'Script Annotated', 'AI Score', 'Step Coverage %'];
377
+ const lines: string[] = [header.join(',')];
378
+ for (const r of m.requirements) {
379
+ const base = [r.reqId, r.title, r.priority, r.source, r.jira?.status ?? '', r.coverageState];
380
+ const acs = r.acceptanceCriteria.length ? r.acceptanceCriteria : [null];
381
+ const tcs = r.testCases.length ? r.testCases : [null];
382
+ const n = Math.max(acs.length, tcs.length);
383
+ for (let i = 0; i < n; i++) {
384
+ const ac = acs[i] ?? null;
385
+ const tc = tcs[i] ?? null;
386
+ const sc = tc?.scripts[0];
387
+ lines.push([
388
+ ...base,
389
+ ac?.index ?? '', ac?.text ?? '', ac ? (ac.covered ? 'yes' : 'NO') : '', ac?.testCases.join('; ') ?? '',
390
+ tc?.ref ?? tc?.id ?? '', tc?.title ?? '', tc?.linkType ?? '', tc?.status ?? '', tc ? (tc.suspect ? 'YES' : 'no') : '',
391
+ sc?.language ?? '', sc ? (sc.annotated === null ? 'n/a' : sc.annotated ? 'yes' : 'NO') : '', sc?.aiScore ?? '', sc?.stepCoveragePct ?? '',
392
+ ].map(csvCell).join(','));
393
+ }
394
+ }
395
+ for (const o of m.orphans) lines.push(['(orphan)', '', '', '', '', 'unlinked', '', '', '', '', o.ref ?? o.id, o.title, o.reason, '', '', '', '', '', ''].map(csvCell).join(','));
396
+ return lines.join('\n');
397
+ }
398
+
399
+ export function alignmentReportToCsv(rep: AlignmentReport): string {
400
+ const header = ['Test Case', 'Title', 'Requirement', 'Language', 'AI Score', 'Aligned (>=75)', 'Step Coverage %', 'Steps Covered', 'Total Steps', 'Assertion Coverage %', 'Uncovered Steps', 'Issues', 'Verified At'];
401
+ const lines = [header.join(',')];
402
+ for (const r of rep.rows) {
403
+ lines.push([
404
+ r.ref ?? r.testCaseId, r.title, r.traceTo ?? '', r.language, r.aiScore, r.aligned ? 'yes' : 'no', r.stepCoveragePct ?? '',
405
+ r.coveredSteps ?? '', r.totalSteps ?? '', r.assertionCoveragePct ?? '',
406
+ r.uncoveredSteps.map((u) => `#${u.index} ${u.step}`).join(' | '), r.issues.join(' | '), r.verifiedAt ?? '',
407
+ ].map(csvCell).join(','));
408
+ }
409
+ return lines.join('\n');
410
+ }
411
+
412
+ // ─── Loaders ─────────────────────────────────────────────────────────────────
413
+
414
+ export async function loadTraceabilityMatrix(tenantId: string, projectId: string): Promise<TraceabilityMatrix> {
415
+ const data = await withTenant(tenantId, async (tx: any) => {
416
+ const [requirements, testCases, scenarios, scripts, alignments] = await Promise.all([
417
+ tx.requirement.findMany({ where: { tenantId, projectId }, orderBy: { reqId: 'asc' } }),
418
+ tx.testCase.findMany({
419
+ where: { tenantId, projectId },
420
+ select: { id: true, ref: true, title: true, kind: true, status: true, suiteId: true, steps: true, expectedResult: true, preconditions: true, traceTo: true, traceToExtra: true, scenarioId: true, suspect: true, suspectReason: true },
421
+ }),
422
+ tx.scenario.findMany({ where: { tenantId, projectId }, select: { id: true, requirementId: true, title: true, status: true } }),
423
+ tx.testScript.findMany({ where: { tenantId, projectId }, select: { testCaseId: true, language: true, code: true, codeKey: true } }),
424
+ tx.alignmentResult.findMany({ where: { tenantId, projectId }, select: { testCaseId: true, language: true, score: true, aligned: true, stepCoveragePct: true, verifiedAt: true } }),
425
+ ]);
426
+ return { requirements, testCases, scenarios, scripts, alignments };
427
+ });
428
+
429
+ const alignKey = (id: string, lang: string) => `${id}::${lang}`;
430
+ const alignMap = new Map<string, any>(data.alignments.map((a: any) => [alignKey(a.testCaseId, a.language), a]));
431
+ const scriptsByCase = new Map<string, any[]>();
432
+ for (const s of data.scripts) {
433
+ if (!scriptsByCase.has(s.testCaseId)) scriptsByCase.set(s.testCaseId, []);
434
+ scriptsByCase.get(s.testCaseId)!.push(s);
435
+ }
436
+
437
+ const testCases: TraceCaseInput[] = data.testCases.map((t: any) => {
438
+ const ids = linkedReqIds(t);
439
+ const scripts: TraceScriptInfo[] = (scriptsByCase.get(t.id) ?? []).map((s: any) => {
440
+ const a = alignMap.get(alignKey(t.id, s.language));
441
+ const hasBody = typeof s.code === 'string' && s.code.length > 0;
442
+ return {
443
+ language: s.language,
444
+ annotated: hasBody ? ids.some((id) => s.code.toLowerCase().includes(id.toLowerCase())) : (s.codeKey ? null : false),
445
+ aiScore: a?.score ?? null,
446
+ aligned: a ? a.score >= ALIGN_THRESHOLD : null,
447
+ stepCoveragePct: a?.stepCoveragePct ?? null,
448
+ verifiedAt: a?.verifiedAt ? new Date(a.verifiedAt).toISOString() : null,
449
+ };
450
+ });
451
+ return { ...t, traceToExtra: t.traceToExtra ?? [], scripts };
452
+ });
453
+
454
+ return buildTraceabilityMatrix({ requirements: data.requirements, testCases, scenarios: data.scenarios });
455
+ }
456
+
457
+ export async function loadAlignmentReport(tenantId: string, projectId: string): Promise<AlignmentReport> {
458
+ const rows = await withTenant(tenantId, async (tx: any) => {
459
+ const alignments = await tx.alignmentResult.findMany({ where: { tenantId, projectId } });
460
+ const ids = [...new Set<string>(alignments.map((a: any) => a.testCaseId))];
461
+ const cases = ids.length
462
+ ? await tx.testCase.findMany({ where: { tenantId, id: { in: ids } }, select: { id: true, ref: true, title: true, traceTo: true } })
463
+ : [];
464
+ const byId = new Map<string, any>(cases.map((c: any) => [c.id, c]));
465
+ return alignments
466
+ .filter((a: any) => byId.has(a.testCaseId))
467
+ .map((a: any): AlignmentRowInput => {
468
+ const c = byId.get(a.testCaseId);
469
+ return {
470
+ testCaseId: a.testCaseId, ref: c.ref, title: c.title, traceTo: c.traceTo, language: a.language,
471
+ score: a.score, aligned: a.aligned, issues: a.issues, stepCoveragePct: a.stepCoveragePct ?? null,
472
+ stepCoverage: a.stepCoverage ?? null, verifiedAt: a.verifiedAt,
473
+ };
474
+ });
475
+ });
476
+ return buildAlignmentReport(rows);
477
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "exclude": ["src/**/*.test.ts"]
9
+ }