@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,213 @@
1
+ export declare function isRealReqId(v: unknown): v is string;
2
+ /** Primary traceTo followed by additional links, de-duplicated, placeholders removed. */
3
+ export declare function linkedReqIds(tc: {
4
+ traceTo?: string | null;
5
+ traceToExtra?: unknown;
6
+ }): string[];
7
+ export declare function caseTracesTo(tc: {
8
+ traceTo?: string | null;
9
+ traceToExtra?: unknown;
10
+ }, reqId: string): boolean;
11
+ /**
12
+ * Deterministic AC → test-case match: a case addresses an AC when it shares ≥ 40% of the AC's
13
+ * content tokens (and ≥ 2 tokens, or ≥ 1 when the AC itself has ≤ 2 tokens).
14
+ */
15
+ export declare function matchAcceptanceCriterion(ac: string, cases: Array<{
16
+ id: string;
17
+ ref?: string | null;
18
+ title?: string;
19
+ steps?: unknown;
20
+ expectedResult?: string | null;
21
+ preconditions?: string | null;
22
+ }>): {
23
+ covered: boolean;
24
+ confidence: number;
25
+ caseRefs: string[];
26
+ };
27
+ export interface TraceScriptInfo {
28
+ language: string;
29
+ /** null when the script body is not available (e.g. stored in object storage). */
30
+ annotated: boolean | null;
31
+ aiScore: number | null;
32
+ aligned: boolean | null;
33
+ stepCoveragePct: number | null;
34
+ verifiedAt: string | null;
35
+ }
36
+ export interface TraceCaseInput {
37
+ id: string;
38
+ ref: string | null;
39
+ title: string;
40
+ kind: string;
41
+ status: string;
42
+ suiteId: string;
43
+ steps: unknown;
44
+ expectedResult: string | null;
45
+ preconditions: string | null;
46
+ traceTo: string | null;
47
+ traceToExtra: unknown;
48
+ scenarioId: string | null;
49
+ suspect: boolean;
50
+ suspectReason: string | null;
51
+ scripts: TraceScriptInfo[];
52
+ }
53
+ export interface TraceRequirementInput {
54
+ id: string;
55
+ reqId: string;
56
+ title: string;
57
+ priority: string;
58
+ status: string;
59
+ source: string;
60
+ acceptanceCriteria: unknown;
61
+ externalMeta: any;
62
+ updatedAt?: Date | string | null;
63
+ }
64
+ export interface TraceScenarioInput {
65
+ id: string;
66
+ requirementId: string;
67
+ title: string;
68
+ status: string;
69
+ }
70
+ export interface TraceabilityMatrix {
71
+ generatedAt: string;
72
+ summary: {
73
+ requirements: number;
74
+ requirementsCovered: number;
75
+ requirementsPartial: number;
76
+ requirementsUntested: number;
77
+ requirementsWithScripts: number;
78
+ acTotal: number;
79
+ acCovered: number;
80
+ acCoveragePct: number | null;
81
+ testCases: number;
82
+ testCasesLinked: number;
83
+ orphanTestCases: number;
84
+ brokenLinks: number;
85
+ suspectTestCases: number;
86
+ scripts: number;
87
+ scriptsMissingAnnotation: number;
88
+ scriptsAligned: number;
89
+ };
90
+ requirements: Array<{
91
+ reqId: string;
92
+ title: string;
93
+ priority: string;
94
+ status: string;
95
+ source: string;
96
+ jira: {
97
+ url?: string;
98
+ status?: string;
99
+ epic?: string;
100
+ labels: string[];
101
+ links: string[];
102
+ } | null;
103
+ coverageState: 'covered' | 'partial' | 'untested';
104
+ acCoveragePct: number | null;
105
+ hasSuspect: boolean;
106
+ acceptanceCriteria: Array<{
107
+ index: number;
108
+ text: string;
109
+ covered: boolean;
110
+ confidence: number;
111
+ testCases: string[];
112
+ }>;
113
+ scenarios: Array<{
114
+ id: string;
115
+ title: string;
116
+ status: string;
117
+ }>;
118
+ testCases: Array<{
119
+ id: string;
120
+ ref: string | null;
121
+ title: string;
122
+ kind: string;
123
+ status: string;
124
+ linkType: 'primary' | 'additional';
125
+ scenarioId: string | null;
126
+ suspect: boolean;
127
+ suspectReason: string | null;
128
+ scripts: TraceScriptInfo[];
129
+ }>;
130
+ }>;
131
+ orphans: Array<{
132
+ id: string;
133
+ ref: string | null;
134
+ title: string;
135
+ traceTo: string | null;
136
+ reason: 'no-link' | 'placeholder';
137
+ }>;
138
+ brokenLinks: Array<{
139
+ id: string;
140
+ ref: string | null;
141
+ title: string;
142
+ reqId: string;
143
+ }>;
144
+ }
145
+ export declare const EXPORT_GATE_MIN = 60;
146
+ export declare function buildTraceabilityMatrix(input: {
147
+ requirements: TraceRequirementInput[];
148
+ testCases: TraceCaseInput[];
149
+ scenarios: TraceScenarioInput[];
150
+ }): TraceabilityMatrix;
151
+ export interface AlignmentRowInput {
152
+ testCaseId: string;
153
+ ref: string | null;
154
+ title: string;
155
+ traceTo: string | null;
156
+ language: string;
157
+ score: number;
158
+ aligned: boolean;
159
+ issues: unknown;
160
+ stepCoveragePct: number | null;
161
+ stepCoverage: any;
162
+ verifiedAt: Date | string | null;
163
+ }
164
+ export interface AlignmentReport {
165
+ generatedAt: string;
166
+ methodology: {
167
+ aiScore: string;
168
+ stepCoverage: string;
169
+ alignedThreshold: number;
170
+ exportGate: number;
171
+ };
172
+ summary: {
173
+ scripts: number;
174
+ avgAiScore: number | null;
175
+ avgStepCoveragePct: number | null;
176
+ alignedCount: number;
177
+ passRatePct: number | null;
178
+ belowExportGate: number;
179
+ distribution: Record<'0-39' | '40-59' | '60-74' | '75-89' | '90-100', number>;
180
+ };
181
+ rows: Array<{
182
+ testCaseId: string;
183
+ ref: string | null;
184
+ title: string;
185
+ traceTo: string | null;
186
+ language: string;
187
+ aiScore: number;
188
+ aligned: boolean;
189
+ stepCoveragePct: number | null;
190
+ totalSteps: number | null;
191
+ coveredSteps: number | null;
192
+ assertionCoveragePct: number | null;
193
+ uncoveredSteps: Array<{
194
+ index: number;
195
+ step: string;
196
+ }>;
197
+ perStep: Array<{
198
+ index: number;
199
+ step: string;
200
+ covered: boolean;
201
+ confidence: number;
202
+ matchedLine?: number;
203
+ matchedStatement?: string;
204
+ }>;
205
+ issues: string[];
206
+ verifiedAt: string | null;
207
+ }>;
208
+ }
209
+ export declare function buildAlignmentReport(rows: AlignmentRowInput[]): AlignmentReport;
210
+ export declare function traceabilityToCsv(m: TraceabilityMatrix): string;
211
+ export declare function alignmentReportToCsv(rep: AlignmentReport): string;
212
+ export declare function loadTraceabilityMatrix(tenantId: string, projectId: string): Promise<TraceabilityMatrix>;
213
+ export declare function loadAlignmentReport(tenantId: string, projectId: string): Promise<AlignmentReport>;
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EXPORT_GATE_MIN = void 0;
4
+ exports.isRealReqId = isRealReqId;
5
+ exports.linkedReqIds = linkedReqIds;
6
+ exports.caseTracesTo = caseTracesTo;
7
+ exports.matchAcceptanceCriterion = matchAcceptanceCriterion;
8
+ exports.buildTraceabilityMatrix = buildTraceabilityMatrix;
9
+ exports.buildAlignmentReport = buildAlignmentReport;
10
+ exports.traceabilityToCsv = traceabilityToCsv;
11
+ exports.alignmentReportToCsv = alignmentReportToCsv;
12
+ exports.loadTraceabilityMatrix = loadTraceabilityMatrix;
13
+ exports.loadAlignmentReport = loadAlignmentReport;
14
+ /**
15
+ * Traceability matrix + alignment report.
16
+ *
17
+ * Chain: Jira story / requirement → acceptance criteria → test cases (primary + additional links)
18
+ * → scripts (annotation present?) → alignment (AI score + deterministic step coverage).
19
+ *
20
+ * Builders are pure (testable); loaders fetch tenant-scoped rows and call them.
21
+ */
22
+ const database_1 = require("@taodigital/database");
23
+ // ─── Link helpers ────────────────────────────────────────────────────────────
24
+ const UNLINKED = new Set(['', 'REQ-UNKNOWN', 'UNKNOWN', 'N/A']);
25
+ function isRealReqId(v) {
26
+ return typeof v === 'string' && !UNLINKED.has(v.trim().toUpperCase());
27
+ }
28
+ /** Primary traceTo followed by additional links, de-duplicated, placeholders removed. */
29
+ function linkedReqIds(tc) {
30
+ const extra = Array.isArray(tc.traceToExtra) ? tc.traceToExtra : [];
31
+ const all = [tc.traceTo, ...extra].filter(isRealReqId).map((s) => s.trim());
32
+ return [...new Set(all)];
33
+ }
34
+ function caseTracesTo(tc, reqId) {
35
+ return linkedReqIds(tc).includes(reqId);
36
+ }
37
+ // ─── Acceptance-criterion matching ───────────────────────────────────────────
38
+ const AC_STOP = new Set([
39
+ 'the', 'a', 'an', 'to', 'of', 'on', 'in', 'into', 'and', 'or', 'with', 'for', 'from', 'then', 'that', 'is', 'are', 'be',
40
+ 'should', 'will', 'shall', 'can', 'it', 'its', 'this', 'these', 'those', 'at', 'by', 'as', 'user', 'given', 'when', 'but',
41
+ 'so', 'now', 'has', 'have', 'had', 'does', 'not', 'all', 'any', 'able', 'must', 'page', 'screen', 'system', 'application',
42
+ ]);
43
+ function acTokenize(text) {
44
+ const out = new Set();
45
+ for (const raw of text.toLowerCase().replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^a-z0-9]+/)) {
46
+ let w = raw;
47
+ if (w.length < 3 || AC_STOP.has(w))
48
+ continue;
49
+ if (w.length > 5 && w.endsWith('ing'))
50
+ w = w.slice(0, -3);
51
+ else if (w.length > 4 && w.endsWith('ed'))
52
+ w = w.slice(0, -2);
53
+ else if (w.length > 3 && w.endsWith('s') && !w.endsWith('ss'))
54
+ w = w.slice(0, -1);
55
+ if (!AC_STOP.has(w))
56
+ out.add(w);
57
+ }
58
+ return out;
59
+ }
60
+ function stepText(s) {
61
+ if (typeof s === 'string')
62
+ return s;
63
+ if (s && typeof s === 'object') {
64
+ const o = s;
65
+ return [o.action, o.data, o.expected, o.text].filter((v) => v != null && typeof v !== 'object').join(' ');
66
+ }
67
+ return '';
68
+ }
69
+ function caseCorpus(tc) {
70
+ const steps = Array.isArray(tc.steps) ? tc.steps.map(stepText) : [];
71
+ return [tc.title, tc.preconditions, tc.expectedResult, ...steps].filter(Boolean).join(' ');
72
+ }
73
+ /**
74
+ * Deterministic AC → test-case match: a case addresses an AC when it shares ≥ 40% of the AC's
75
+ * content tokens (and ≥ 2 tokens, or ≥ 1 when the AC itself has ≤ 2 tokens).
76
+ */
77
+ function matchAcceptanceCriterion(ac, cases) {
78
+ const tokens = acTokenize(ac);
79
+ if (tokens.size === 0)
80
+ return { covered: false, confidence: 0, caseRefs: [] };
81
+ const needed = tokens.size <= 2 ? 1 : 2;
82
+ const hits = [];
83
+ for (const tc of cases) {
84
+ const corpus = acTokenize(caseCorpus(tc));
85
+ let n = 0;
86
+ for (const t of tokens)
87
+ if (corpus.has(t))
88
+ n++;
89
+ const ratio = n / tokens.size;
90
+ if (n >= needed && ratio >= 0.4)
91
+ hits.push({ ref: tc.ref ?? tc.id, ratio });
92
+ }
93
+ hits.sort((a, b) => b.ratio - a.ratio);
94
+ return {
95
+ covered: hits.length > 0,
96
+ confidence: hits.length ? Math.round(hits[0].ratio * 100) / 100 : 0,
97
+ caseRefs: hits.map((h) => h.ref),
98
+ };
99
+ }
100
+ const ALIGN_THRESHOLD = 75;
101
+ exports.EXPORT_GATE_MIN = 60;
102
+ function acList(raw) {
103
+ return Array.isArray(raw) ? raw.map((v) => String(v)).filter((s) => s.trim()) : [];
104
+ }
105
+ function jiraInfo(meta, source) {
106
+ if (!meta || typeof meta !== 'object')
107
+ return source === 'JIRA_IMPORT' ? { labels: [], links: [] } : null;
108
+ return {
109
+ url: meta.url ?? undefined,
110
+ status: meta.status ?? undefined,
111
+ epic: meta.epic?.key ? `${meta.epic.key}${meta.epic.summary ? ` — ${meta.epic.summary}` : ''}` : (typeof meta.epic === 'string' ? meta.epic : undefined),
112
+ labels: Array.isArray(meta.labels) ? meta.labels.map(String) : [],
113
+ links: Array.isArray(meta.links) ? meta.links.map((l) => `${l.type ?? 'relates'}: ${l.key ?? l}`) : [],
114
+ };
115
+ }
116
+ // ─── Matrix builder ──────────────────────────────────────────────────────────
117
+ function buildTraceabilityMatrix(input) {
118
+ const active = input.testCases.filter((t) => t.status !== 'RETIRED');
119
+ const reqIds = new Set(input.requirements.map((r) => r.reqId));
120
+ const reqDbIdToReqId = new Map(input.requirements.map((r) => [r.id, r.reqId]));
121
+ const rows = input.requirements.map((r) => {
122
+ const linked = active.filter((t) => caseTracesTo(t, r.reqId));
123
+ const acs = acList(r.acceptanceCriteria).map((text, i) => {
124
+ const m = matchAcceptanceCriterion(text, linked);
125
+ return { index: i + 1, text, covered: m.covered, confidence: m.confidence, testCases: m.caseRefs };
126
+ });
127
+ const acCovered = acs.filter((a) => a.covered).length;
128
+ const acCoveragePct = acs.length ? Math.round((acCovered / acs.length) * 1000) / 10 : null;
129
+ const coverageState = linked.length === 0 ? 'untested' : acs.length === 0 || acCovered === acs.length ? 'covered' : 'partial';
130
+ return {
131
+ reqId: r.reqId,
132
+ title: r.title,
133
+ priority: r.priority,
134
+ status: r.status,
135
+ source: r.source,
136
+ jira: jiraInfo(r.externalMeta, r.source),
137
+ coverageState,
138
+ acCoveragePct,
139
+ hasSuspect: linked.some((t) => t.suspect),
140
+ acceptanceCriteria: acs,
141
+ scenarios: input.scenarios
142
+ .filter((s) => reqDbIdToReqId.get(s.requirementId) === r.reqId)
143
+ .map((s) => ({ id: s.id, title: s.title, status: s.status })),
144
+ testCases: linked.map((t) => ({
145
+ id: t.id, ref: t.ref, title: t.title, kind: t.kind, status: t.status,
146
+ linkType: (t.traceTo === r.reqId ? 'primary' : 'additional'),
147
+ scenarioId: t.scenarioId, suspect: t.suspect, suspectReason: t.suspectReason, scripts: t.scripts,
148
+ })),
149
+ };
150
+ });
151
+ const orphans = [];
152
+ const brokenLinks = [];
153
+ for (const t of active) {
154
+ const ids = linkedReqIds(t);
155
+ if (ids.length === 0) {
156
+ orphans.push({ id: t.id, ref: t.ref, title: t.title, traceTo: t.traceTo, reason: t.traceTo ? 'placeholder' : 'no-link' });
157
+ continue;
158
+ }
159
+ for (const id of ids)
160
+ if (!reqIds.has(id))
161
+ brokenLinks.push({ id: t.id, ref: t.ref, title: t.title, reqId: id });
162
+ }
163
+ const allAc = rows.flatMap((r) => r.acceptanceCriteria);
164
+ const acCovered = allAc.filter((a) => a.covered).length;
165
+ const scriptsAll = active.flatMap((t) => t.scripts);
166
+ const linkedCases = active.filter((t) => linkedReqIds(t).some((id) => reqIds.has(id)));
167
+ return {
168
+ generatedAt: new Date().toISOString(),
169
+ summary: {
170
+ requirements: rows.length,
171
+ requirementsCovered: rows.filter((r) => r.coverageState === 'covered').length,
172
+ requirementsPartial: rows.filter((r) => r.coverageState === 'partial').length,
173
+ requirementsUntested: rows.filter((r) => r.coverageState === 'untested').length,
174
+ requirementsWithScripts: rows.filter((r) => r.testCases.some((t) => t.scripts.length > 0)).length,
175
+ acTotal: allAc.length,
176
+ acCovered,
177
+ acCoveragePct: allAc.length ? Math.round((acCovered / allAc.length) * 1000) / 10 : null,
178
+ testCases: active.length,
179
+ testCasesLinked: linkedCases.length,
180
+ orphanTestCases: orphans.length,
181
+ brokenLinks: brokenLinks.length,
182
+ suspectTestCases: active.filter((t) => t.suspect).length,
183
+ scripts: scriptsAll.length,
184
+ scriptsMissingAnnotation: scriptsAll.filter((s) => s.annotated === false).length,
185
+ scriptsAligned: scriptsAll.filter((s) => (s.aiScore ?? -1) >= ALIGN_THRESHOLD).length,
186
+ },
187
+ requirements: rows,
188
+ orphans,
189
+ brokenLinks,
190
+ };
191
+ }
192
+ function avg(nums) {
193
+ return nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10 : null;
194
+ }
195
+ function buildAlignmentReport(rows) {
196
+ const outRows = rows.map((r) => {
197
+ const sc = r.stepCoverage && typeof r.stepCoverage === 'object' ? r.stepCoverage : null;
198
+ const perStep = Array.isArray(sc?.perStep) ? sc.perStep : [];
199
+ return {
200
+ testCaseId: r.testCaseId, ref: r.ref, title: r.title, traceTo: r.traceTo, language: r.language,
201
+ aiScore: r.score, aligned: r.score >= ALIGN_THRESHOLD, stepCoveragePct: r.stepCoveragePct,
202
+ totalSteps: sc?.totalSteps ?? null, coveredSteps: sc?.coveredSteps ?? null,
203
+ assertionCoveragePct: sc?.assertionCoverage?.pct ?? null,
204
+ uncoveredSteps: perStep.filter((p) => !p.covered).map((p) => ({ index: p.index, step: p.step })),
205
+ perStep,
206
+ issues: Array.isArray(r.issues) ? r.issues.map(String) : [],
207
+ verifiedAt: r.verifiedAt ? new Date(r.verifiedAt).toISOString() : null,
208
+ };
209
+ }).sort((a, b) => a.aiScore - b.aiScore);
210
+ const dist = { '0-39': 0, '40-59': 0, '60-74': 0, '75-89': 0, '90-100': 0 };
211
+ for (const r of outRows) {
212
+ const s = r.aiScore;
213
+ dist[s < 40 ? '0-39' : s < 60 ? '40-59' : s < 75 ? '60-74' : s < 90 ? '75-89' : '90-100']++;
214
+ }
215
+ const aligned = outRows.filter((r) => r.aligned).length;
216
+ return {
217
+ generatedAt: new Date().toISOString(),
218
+ methodology: {
219
+ 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.',
220
+ stepCoverage: 'Deterministic: test-case steps with a matching script statement ÷ total steps (token-overlap-v1). Same input always gives the same result.',
221
+ alignedThreshold: ALIGN_THRESHOLD,
222
+ exportGate: exports.EXPORT_GATE_MIN,
223
+ },
224
+ summary: {
225
+ scripts: outRows.length,
226
+ avgAiScore: avg(outRows.map((r) => r.aiScore)),
227
+ avgStepCoveragePct: avg(outRows.map((r) => r.stepCoveragePct).filter((v) => typeof v === 'number')),
228
+ alignedCount: aligned,
229
+ passRatePct: outRows.length ? Math.round((aligned / outRows.length) * 1000) / 10 : null,
230
+ belowExportGate: outRows.filter((r) => r.aiScore < exports.EXPORT_GATE_MIN).length,
231
+ distribution: dist,
232
+ },
233
+ rows: outRows,
234
+ };
235
+ }
236
+ // ─── CSV ─────────────────────────────────────────────────────────────────────
237
+ function csvCell(v) {
238
+ const s = v == null ? '' : String(v);
239
+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
240
+ }
241
+ function traceabilityToCsv(m) {
242
+ const header = ['Requirement', 'Requirement Title', 'Priority', 'Source', 'Jira Status', 'Coverage', 'AC #', 'Acceptance Criterion', 'AC Covered', 'AC Test Cases',
243
+ 'Test Case', 'Test Case Title', 'Link', 'TC Status', 'Suspect', 'Script Language', 'Script Annotated', 'AI Score', 'Step Coverage %'];
244
+ const lines = [header.join(',')];
245
+ for (const r of m.requirements) {
246
+ const base = [r.reqId, r.title, r.priority, r.source, r.jira?.status ?? '', r.coverageState];
247
+ const acs = r.acceptanceCriteria.length ? r.acceptanceCriteria : [null];
248
+ const tcs = r.testCases.length ? r.testCases : [null];
249
+ const n = Math.max(acs.length, tcs.length);
250
+ for (let i = 0; i < n; i++) {
251
+ const ac = acs[i] ?? null;
252
+ const tc = tcs[i] ?? null;
253
+ const sc = tc?.scripts[0];
254
+ lines.push([
255
+ ...base,
256
+ ac?.index ?? '', ac?.text ?? '', ac ? (ac.covered ? 'yes' : 'NO') : '', ac?.testCases.join('; ') ?? '',
257
+ tc?.ref ?? tc?.id ?? '', tc?.title ?? '', tc?.linkType ?? '', tc?.status ?? '', tc ? (tc.suspect ? 'YES' : 'no') : '',
258
+ sc?.language ?? '', sc ? (sc.annotated === null ? 'n/a' : sc.annotated ? 'yes' : 'NO') : '', sc?.aiScore ?? '', sc?.stepCoveragePct ?? '',
259
+ ].map(csvCell).join(','));
260
+ }
261
+ }
262
+ for (const o of m.orphans)
263
+ lines.push(['(orphan)', '', '', '', '', 'unlinked', '', '', '', '', o.ref ?? o.id, o.title, o.reason, '', '', '', '', '', ''].map(csvCell).join(','));
264
+ return lines.join('\n');
265
+ }
266
+ function alignmentReportToCsv(rep) {
267
+ const header = ['Test Case', 'Title', 'Requirement', 'Language', 'AI Score', 'Aligned (>=75)', 'Step Coverage %', 'Steps Covered', 'Total Steps', 'Assertion Coverage %', 'Uncovered Steps', 'Issues', 'Verified At'];
268
+ const lines = [header.join(',')];
269
+ for (const r of rep.rows) {
270
+ lines.push([
271
+ r.ref ?? r.testCaseId, r.title, r.traceTo ?? '', r.language, r.aiScore, r.aligned ? 'yes' : 'no', r.stepCoveragePct ?? '',
272
+ r.coveredSteps ?? '', r.totalSteps ?? '', r.assertionCoveragePct ?? '',
273
+ r.uncoveredSteps.map((u) => `#${u.index} ${u.step}`).join(' | '), r.issues.join(' | '), r.verifiedAt ?? '',
274
+ ].map(csvCell).join(','));
275
+ }
276
+ return lines.join('\n');
277
+ }
278
+ // ─── Loaders ─────────────────────────────────────────────────────────────────
279
+ async function loadTraceabilityMatrix(tenantId, projectId) {
280
+ const data = await (0, database_1.withTenant)(tenantId, async (tx) => {
281
+ const [requirements, testCases, scenarios, scripts, alignments] = await Promise.all([
282
+ tx.requirement.findMany({ where: { tenantId, projectId }, orderBy: { reqId: 'asc' } }),
283
+ tx.testCase.findMany({
284
+ where: { tenantId, projectId },
285
+ 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 },
286
+ }),
287
+ tx.scenario.findMany({ where: { tenantId, projectId }, select: { id: true, requirementId: true, title: true, status: true } }),
288
+ tx.testScript.findMany({ where: { tenantId, projectId }, select: { testCaseId: true, language: true, code: true, codeKey: true } }),
289
+ tx.alignmentResult.findMany({ where: { tenantId, projectId }, select: { testCaseId: true, language: true, score: true, aligned: true, stepCoveragePct: true, verifiedAt: true } }),
290
+ ]);
291
+ return { requirements, testCases, scenarios, scripts, alignments };
292
+ });
293
+ const alignKey = (id, lang) => `${id}::${lang}`;
294
+ const alignMap = new Map(data.alignments.map((a) => [alignKey(a.testCaseId, a.language), a]));
295
+ const scriptsByCase = new Map();
296
+ for (const s of data.scripts) {
297
+ if (!scriptsByCase.has(s.testCaseId))
298
+ scriptsByCase.set(s.testCaseId, []);
299
+ scriptsByCase.get(s.testCaseId).push(s);
300
+ }
301
+ const testCases = data.testCases.map((t) => {
302
+ const ids = linkedReqIds(t);
303
+ const scripts = (scriptsByCase.get(t.id) ?? []).map((s) => {
304
+ const a = alignMap.get(alignKey(t.id, s.language));
305
+ const hasBody = typeof s.code === 'string' && s.code.length > 0;
306
+ return {
307
+ language: s.language,
308
+ annotated: hasBody ? ids.some((id) => s.code.toLowerCase().includes(id.toLowerCase())) : (s.codeKey ? null : false),
309
+ aiScore: a?.score ?? null,
310
+ aligned: a ? a.score >= ALIGN_THRESHOLD : null,
311
+ stepCoveragePct: a?.stepCoveragePct ?? null,
312
+ verifiedAt: a?.verifiedAt ? new Date(a.verifiedAt).toISOString() : null,
313
+ };
314
+ });
315
+ return { ...t, traceToExtra: t.traceToExtra ?? [], scripts };
316
+ });
317
+ return buildTraceabilityMatrix({ requirements: data.requirements, testCases, scenarios: data.scenarios });
318
+ }
319
+ async function loadAlignmentReport(tenantId, projectId) {
320
+ const rows = await (0, database_1.withTenant)(tenantId, async (tx) => {
321
+ const alignments = await tx.alignmentResult.findMany({ where: { tenantId, projectId } });
322
+ const ids = [...new Set(alignments.map((a) => a.testCaseId))];
323
+ const cases = ids.length
324
+ ? await tx.testCase.findMany({ where: { tenantId, id: { in: ids } }, select: { id: true, ref: true, title: true, traceTo: true } })
325
+ : [];
326
+ const byId = new Map(cases.map((c) => [c.id, c]));
327
+ return alignments
328
+ .filter((a) => byId.has(a.testCaseId))
329
+ .map((a) => {
330
+ const c = byId.get(a.testCaseId);
331
+ return {
332
+ testCaseId: a.testCaseId, ref: c.ref, title: c.title, traceTo: c.traceTo, language: a.language,
333
+ score: a.score, aligned: a.aligned, issues: a.issues, stepCoveragePct: a.stepCoveragePct ?? null,
334
+ stepCoverage: a.stepCoverage ?? null, verifiedAt: a.verifiedAt,
335
+ };
336
+ });
337
+ });
338
+ return buildAlignmentReport(rows);
339
+ }
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@taodigital/app-brain",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.2.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "registry": "https://registry.npmjs.org/"
8
+ },
9
+ "main": "dist/index.js",
10
+ "types": "dist/index.d.ts",
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "tsx --test src/**/*.test.ts"
14
+ },
15
+ "dependencies": {
16
+ "@taodigital/core": "*",
17
+ "@taodigital/database": "*",
18
+ "@taodigital/reranker": "*",
19
+ "@aws-sdk/client-s3": "^3.1075.0",
20
+ "@aws-sdk/s3-request-presigner": "^3.1075.0",
21
+ "nodemailer": "^9.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/nodemailer": "^8.0.1"
25
+ }
26
+ }
@@ -0,0 +1,91 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyScreenArchetype, matchInterestingJourneyPairs } from './archetype-classification.js';
4
+
5
+ test('classifyScreenArchetype: a screen with a password-role element is LOGIN', () => {
6
+ const result = classifyScreenArchetype(
7
+ { id: 's1', name: 'Account Access', url: '/account/access' },
8
+ [{ role: 'password', expectedData: null }, { role: 'text', expectedData: null }],
9
+ );
10
+ assert.equal(result.archetype, 'LOGIN');
11
+ });
12
+
13
+ test('classifyScreenArchetype: a screen with no password element but a login-like URL is LOGIN', () => {
14
+ const result = classifyScreenArchetype(
15
+ { id: 's1', name: 'Sign In', url: '/signin' },
16
+ [],
17
+ );
18
+ assert.equal(result.archetype, 'LOGIN');
19
+ });
20
+
21
+ test('classifyScreenArchetype: signup URL requires BOTH email and password fields, not just the URL', () => {
22
+ const withBoth = classifyScreenArchetype(
23
+ { id: 's1', name: 'Register', url: '/register' },
24
+ [{ role: 'email', expectedData: null }, { role: 'password', expectedData: null }],
25
+ );
26
+ assert.equal(withBoth.archetype, 'SIGNUP');
27
+
28
+ // Register URL but only an email field (no password) — falls through past
29
+ // the SIGNUP rule (needs both) and the LOGIN rule (needs a password), but
30
+ // an email input is itself a data-entry field, so this correctly lands on
31
+ // FORM rather than the less useful GENERIC.
32
+ const emailOnly = classifyScreenArchetype(
33
+ { id: 's2', name: 'Register', url: '/register' },
34
+ [{ role: 'email', expectedData: null }],
35
+ );
36
+ assert.equal(emailOnly.archetype, 'FORM');
37
+ });
38
+
39
+ test('classifyScreenArchetype: checkout/cart URL wins even over other signals', () => {
40
+ const result = classifyScreenArchetype(
41
+ { id: 's1', name: 'Your Cart', url: '/cart' },
42
+ [{ role: 'text', expectedData: null }],
43
+ );
44
+ assert.equal(result.archetype, 'CHECKOUT');
45
+ });
46
+
47
+ test('classifyScreenArchetype: a search-role element with a neutral URL is SEARCH', () => {
48
+ const result = classifyScreenArchetype(
49
+ { id: 's1', name: 'Find Products', url: '/products' },
50
+ [{ role: 'search', expectedData: null }],
51
+ );
52
+ assert.equal(result.archetype, 'SEARCH');
53
+ });
54
+
55
+ test('classifyScreenArchetype: plain data-entry fields with no other signal is FORM', () => {
56
+ const result = classifyScreenArchetype(
57
+ { id: 's1', name: 'Profile', url: '/profile' },
58
+ [{ role: 'text', expectedData: null }, { role: 'date', expectedData: null }],
59
+ );
60
+ assert.equal(result.archetype, 'FORM');
61
+ });
62
+
63
+ test('classifyScreenArchetype: a screen with no distinguishing signals is GENERIC', () => {
64
+ const result = classifyScreenArchetype(
65
+ { id: 's1', name: 'About Us', url: '/about' },
66
+ [{ role: 'a', expectedData: null }],
67
+ );
68
+ assert.equal(result.archetype, 'GENERIC');
69
+ });
70
+
71
+ test('matchInterestingJourneyPairs: pairs a LOGIN screen with every CHECKOUT screen', () => {
72
+ const screens = [
73
+ { id: 'login1', name: 'Login', archetype: 'LOGIN' as const },
74
+ { id: 'checkout1', name: 'Checkout', archetype: 'CHECKOUT' as const },
75
+ { id: 'about1', name: 'About', archetype: 'GENERIC' as const },
76
+ ];
77
+ const pairs = matchInterestingJourneyPairs(screens);
78
+ const loginToCheckout = pairs.find((p) => p.startScreenId === 'login1' && p.endScreenId === 'checkout1');
79
+ assert.ok(loginToCheckout, 'expected a Login -> Checkout candidate');
80
+ assert.equal(loginToCheckout!.label, 'Login → Checkout');
81
+ // GENERIC screens never appear as either side of a suggestion.
82
+ assert.ok(!pairs.some((p) => p.startScreenId === 'about1' || p.endScreenId === 'about1'));
83
+ });
84
+
85
+ test('matchInterestingJourneyPairs: returns no candidates when nothing matches any rule', () => {
86
+ const screens = [
87
+ { id: 's1', name: 'About', archetype: 'GENERIC' as const },
88
+ { id: 's2', name: 'Contact', archetype: 'GENERIC' as const },
89
+ ];
90
+ assert.deepEqual(matchInterestingJourneyPairs(screens), []);
91
+ });