@sdods/core 0.2.1 → 0.2.2

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.
@@ -3,5 +3,6 @@ export { analyzeProject, buildChecklist, type AnalyzeOptions } from './analyze.j
3
3
  export { proposeProject, slugify, type ProposeOptions } from './propose.js';
4
4
  export { applyProposal, importPlaywrightSpecs, type ApplyOptions, type ApplyResult, } from './apply.js';
5
5
  export { computeCoverage, parseFeatures, pomRouteSteps, type CoverageOptions } from './coverage.js';
6
+ export { detectModules, moduleOfFile, moduleOfPath, canonicalAlias, MODULE_ALIASES, SIGNAL_WEIGHTS, type ModuleSignal, type ModuleVote, type DetectedModule, type DetectModulesOptions, type DetectModulesResult, } from './modules.js';
6
7
  export * from './detectors.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,6 @@ export { analyzeProject, buildChecklist } from './analyze.js';
3
3
  export { proposeProject, slugify } from './propose.js';
4
4
  export { applyProposal, importPlaywrightSpecs, } from './apply.js';
5
5
  export { computeCoverage, parseFeatures, pomRouteSteps } from './coverage.js';
6
+ export { detectModules, moduleOfFile, moduleOfPath, canonicalAlias, MODULE_ALIASES, SIGNAL_WEIGHTS, } from './modules.js';
6
7
  export * from './detectors.js';
7
8
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,74 @@
1
+ import type { AnalysisReport, Evidence, Layer } from '@sdods/contracts';
2
+ /**
3
+ * Module detection by signal fusion.
4
+ *
5
+ * Every detector casts weighted votes for "which module owns this route/endpoint"; the
6
+ * resolver keeps the strongest vote per target, then drops noise, merges aliases and splits
7
+ * oversized modules. Replaces the two independent first-URL-segment groupers that used to
8
+ * live in propose.ts and mcp/tools/analyze.ts.
9
+ *
10
+ * The ordering matters more than any individual rule. Measured against
11
+ * cypress-realworld-app (17 routes): first-URL-segment yields 14 groups, the source-file
12
+ * signal alone yields 8, and `backend/auth.ts` collapses /checkAuth + /login + /logout into
13
+ * one module without consulting the alias table at all.
14
+ */
15
+ export type ModuleSignal = 'openapi-tag' | 'workspace' | 'framework-module' | 'domain-dir' | 'source-file' | 'har' | 'route-segment' | 'alias';
16
+ export declare const SIGNAL_WEIGHTS: Record<ModuleSignal, number>;
17
+ export interface ModuleVote {
18
+ /** the thing being assigned: a route or endpoint path */
19
+ target: string;
20
+ kind: 'route' | 'endpoint';
21
+ module: string;
22
+ weight: number;
23
+ signal: ModuleSignal;
24
+ layer: Layer;
25
+ evidence: Evidence;
26
+ }
27
+ export interface DetectedModule {
28
+ name: string;
29
+ layers: Layer[];
30
+ routes: string[];
31
+ endpoints: string[];
32
+ testingTypes: string[];
33
+ tags: string[];
34
+ /** mean weight of the winning votes; 0..1 */
35
+ confidence: number;
36
+ signals: ModuleSignal[];
37
+ evidence: Evidence[];
38
+ /** populated by the split pass; recorded in the report, not yet emitted to yaml */
39
+ children?: DetectedModule[];
40
+ }
41
+ export interface DetectModulesOptions {
42
+ /** a module with more targets than this splits into children (default 12) */
43
+ splitAfter?: number;
44
+ /** a module with this many targets or fewer merges into an alias sibling (default 1) */
45
+ mergeBelow?: number;
46
+ maxModules?: number;
47
+ }
48
+ export interface DetectModulesResult {
49
+ modules: DetectedModule[];
50
+ votes: ModuleVote[];
51
+ /** targets deliberately not turned into modules, with the reason */
52
+ ignored: Array<{
53
+ target: string;
54
+ reason: string;
55
+ }>;
56
+ }
57
+ /**
58
+ * Synonyms folded onto a canonical module name. Merges only; an alias never creates a module,
59
+ * which is why the weight is the lowest in the table.
60
+ */
61
+ export declare const MODULE_ALIASES: Record<string, string[]>;
62
+ export declare function canonicalAlias(name: string): string | undefined;
63
+ /** Today's rule, kept: demoted to a weak signal, and reused as the sub-module split rule. */
64
+ export declare function moduleOfPath(path: string): string;
65
+ /**
66
+ * Module name from the file that defines a route. Returns undefined when the file names a role
67
+ * rather than a domain (`backend/app.ts`), so a weaker signal can take over.
68
+ *
69
+ * `backend/auth.ts` -> auth; `bankaccount-routes.ts` -> bankaccount;
70
+ * `TransactionsContainer.tsx` -> transactions; `backend/app.ts` -> undefined.
71
+ */
72
+ export declare function moduleOfFile(file: string): string | undefined;
73
+ export declare function detectModules(report: AnalysisReport, opts?: DetectModulesOptions): DetectModulesResult;
74
+ //# sourceMappingURL=modules.d.ts.map
@@ -0,0 +1,353 @@
1
+ import { slugify } from './propose.js';
2
+ export const SIGNAL_WEIGHTS = {
3
+ 'openapi-tag': 1.0,
4
+ workspace: 0.9,
5
+ 'framework-module': 0.85,
6
+ 'domain-dir': 0.8,
7
+ 'source-file': 0.7,
8
+ har: 0.5,
9
+ 'route-segment': 0.3,
10
+ alias: 0.2,
11
+ };
12
+ /** Path segments that never name a domain. */
13
+ const RESERVED_SEGMENTS = new Set(['api', 'app', 'src', 'pages']);
14
+ /** Basenames that name a file's role, not its domain. */
15
+ const GENERIC_BASENAMES = new Set(['index', 'app', 'main', 'server', 'routes', 'route', 'router']);
16
+ /** Directories that group by layer, not by domain. */
17
+ const GENERIC_DIRS = new Set([
18
+ 'src',
19
+ 'backend',
20
+ 'frontend',
21
+ 'api',
22
+ 'lib',
23
+ 'app',
24
+ 'pages',
25
+ 'containers',
26
+ 'views',
27
+ 'components',
28
+ 'routes',
29
+ 'server',
30
+ ]);
31
+ /** Directories that conventionally hold one subdirectory per domain. */
32
+ const DOMAIN_DIR_RE = /(?:^|\/)(?:src\/)?(?:modules|features|domains|containers|views|pages|apps|services)\/([^/]+)\//;
33
+ /** Route-param and route-group directories: `[id]`, `[...slug]`, `{id}`, `:id`, `(marketing)`. */
34
+ const DYNAMIC_DIR_RE = /^(?:\[.*\]|\{.*\}|:.+|\(.*\))$/;
35
+ /** Suffixes that describe a file's role and should be stripped before naming a module. */
36
+ const ROLE_SUFFIX_RE = /[-_.]?(routes?|router|controller|container|service|module|handler|page|view|screen|api)$/i;
37
+ /**
38
+ * Synonyms folded onto a canonical module name. Merges only; an alias never creates a module,
39
+ * which is why the weight is the lowest in the table.
40
+ */
41
+ export const MODULE_ALIASES = {
42
+ auth: [
43
+ 'login',
44
+ 'signin',
45
+ 'sign-in',
46
+ 'logout',
47
+ 'signout',
48
+ 'sign-out',
49
+ 'signup',
50
+ 'sign-up',
51
+ 'register',
52
+ 'session',
53
+ 'checkauth',
54
+ 'check-auth',
55
+ 'oauth',
56
+ 'sso',
57
+ 'identity',
58
+ ],
59
+ user: ['users', 'profile', 'profiles', 'me', 'account', 'accounts'],
60
+ admin: ['administration', 'backoffice', 'back-office'],
61
+ search: ['searches', 'query'],
62
+ };
63
+ const ALIAS_TO_CANONICAL = (() => {
64
+ const out = {};
65
+ for (const [canonical, names] of Object.entries(MODULE_ALIASES)) {
66
+ for (const n of names)
67
+ out[n] = canonical;
68
+ }
69
+ return out;
70
+ })();
71
+ export function canonicalAlias(name) {
72
+ return ALIAS_TO_CANONICAL[name];
73
+ }
74
+ /** Today's rule, kept: demoted to a weak signal, and reused as the sub-module split rule. */
75
+ export function moduleOfPath(path) {
76
+ const first = path.split('/').filter(Boolean)[0];
77
+ if (!first || first.startsWith('{'))
78
+ return 'home';
79
+ const clean = first.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
80
+ return RESERVED_SEGMENTS.has(clean) ? 'core' : clean;
81
+ }
82
+ /**
83
+ * Module name from the file that defines a route. Returns undefined when the file names a role
84
+ * rather than a domain (`backend/app.ts`), so a weaker signal can take over.
85
+ *
86
+ * `backend/auth.ts` -> auth; `bankaccount-routes.ts` -> bankaccount;
87
+ * `TransactionsContainer.tsx` -> transactions; `backend/app.ts` -> undefined.
88
+ */
89
+ export function moduleOfFile(file) {
90
+ const parts = file.replace(/\\/g, '/').split('/').filter(Boolean);
91
+ const base = (parts.pop() ?? '').replace(/\.[^.]+$/, '');
92
+ let name = base.replace(ROLE_SUFFIX_RE, '');
93
+ if (!name || GENERIC_BASENAMES.has(name.toLowerCase())) {
94
+ // File-router frameworks put the domain further up: `app/products/[id]/page.tsx` is the
95
+ // `products` module, not `id`. Walk past dynamic segments and route groups.
96
+ name = '';
97
+ while (parts.length) {
98
+ const dir = parts.pop();
99
+ if (DYNAMIC_DIR_RE.test(dir))
100
+ continue;
101
+ if (GENERIC_DIRS.has(dir.toLowerCase()))
102
+ return undefined;
103
+ name = dir;
104
+ break;
105
+ }
106
+ if (!name)
107
+ return undefined;
108
+ }
109
+ // split camelCase/PascalCase before slugify so TransactionsContainer -> transactions
110
+ const slug = slugify(name.replace(/([a-z0-9])([A-Z])/g, '$1-$2'));
111
+ if (!slug || slug === 'app' || GENERIC_DIRS.has(slug))
112
+ return undefined;
113
+ return slug;
114
+ }
115
+ /**
116
+ * Routes that carry no domain information and must not become modules.
117
+ *
118
+ * A path that is uninformative on its own is still fine when its defining file names a domain:
119
+ * `GET /{username}` in `backend/contact-routes.ts` is the `contact` module, and dropping it
120
+ * would lose a real module. So parameter-only and root routes are noise only when the file
121
+ * cannot name them either. Catch-all and optional-group routes are never valid test targets.
122
+ */
123
+ function noiseReason(r) {
124
+ const p = r.path;
125
+ if (/^\/?\*/.test(p))
126
+ return 'catch-all route';
127
+ if (/\(.*\)\?/.test(p))
128
+ return 'optional-group route';
129
+ if (moduleOfFile(r.file))
130
+ return undefined;
131
+ const segs = p.split('/').filter(Boolean);
132
+ // a UI root path is a real, nameable screen (home); an API root carries nothing
133
+ if (segs.length === 0)
134
+ return r.kind === 'page' ? undefined : 'root route in a generic file';
135
+ if (segs.length === 1 && /^[{:]/.test(segs[0]))
136
+ return 'bare parameter route in a generic file';
137
+ return undefined;
138
+ }
139
+ function layerOf(r) {
140
+ return r.kind === 'api' ? 'api' : 'ui';
141
+ }
142
+ function longestWorkspaceMatch(file, workspaces) {
143
+ let best;
144
+ for (const w of workspaces) {
145
+ const prefix = w.replace(/\/?\*+$/, '').replace(/\/$/, '');
146
+ if (!prefix)
147
+ continue;
148
+ if (file === prefix || file.startsWith(`${prefix}/`)) {
149
+ if (!best || prefix.length > best.length)
150
+ best = prefix;
151
+ }
152
+ }
153
+ if (!best)
154
+ return undefined;
155
+ // the workspace dir itself is generic (packages/, apps/) - name the package under it
156
+ const rest = file.slice(best.length).split('/').filter(Boolean)[0];
157
+ return rest ? slugify(rest) : undefined;
158
+ }
159
+ export function detectModules(report, opts = {}) {
160
+ const splitAfter = opts.splitAfter ?? 12;
161
+ const mergeBelow = opts.mergeBelow ?? 1;
162
+ const maxModules = opts.maxModules ?? 30;
163
+ const votes = [];
164
+ const ignored = [];
165
+ const cast = (v) => {
166
+ if (v.module)
167
+ votes.push(v);
168
+ };
169
+ // S1 openapi-tag (1.0) - the authoritative API grouping when a spec exists
170
+ const tagged = new Set();
171
+ for (const spec of report.openapi) {
172
+ for (const e of spec.endpoints) {
173
+ if (!e.tag)
174
+ continue;
175
+ tagged.add(e.path);
176
+ cast({
177
+ target: e.path,
178
+ kind: 'endpoint',
179
+ module: slugify(e.tag),
180
+ weight: SIGNAL_WEIGHTS['openapi-tag'],
181
+ signal: 'openapi-tag',
182
+ layer: 'api',
183
+ evidence: { file: spec.file, snippet: `tag: ${e.tag}` },
184
+ });
185
+ }
186
+ }
187
+ const workspaces = report.packageManager.workspaces ?? [];
188
+ for (const r of report.routes) {
189
+ if (tagged.has(r.path))
190
+ continue;
191
+ const reason = noiseReason(r);
192
+ if (reason) {
193
+ ignored.push({ target: r.path, reason });
194
+ continue;
195
+ }
196
+ const kind = r.kind === 'api' ? 'endpoint' : 'route';
197
+ const layer = layerOf(r);
198
+ const ev = { file: r.file, line: r.line, snippet: r.path };
199
+ // S2 workspace (0.9)
200
+ const ws = longestWorkspaceMatch(r.file, workspaces);
201
+ if (ws) {
202
+ cast({
203
+ target: r.path,
204
+ kind,
205
+ module: ws,
206
+ weight: SIGNAL_WEIGHTS.workspace,
207
+ signal: 'workspace',
208
+ layer,
209
+ evidence: ev,
210
+ });
211
+ }
212
+ // S3 domain-dir (0.8)
213
+ const dd = DOMAIN_DIR_RE.exec(r.file.replace(/\\/g, '/'));
214
+ if (dd?.[1] && !GENERIC_DIRS.has(dd[1].toLowerCase())) {
215
+ cast({
216
+ target: r.path,
217
+ kind,
218
+ module: slugify(dd[1]),
219
+ weight: SIGNAL_WEIGHTS['domain-dir'],
220
+ signal: 'domain-dir',
221
+ layer,
222
+ evidence: ev,
223
+ });
224
+ }
225
+ // S4 source-file (0.7) - the primary signal
226
+ const mf = moduleOfFile(r.file);
227
+ if (mf) {
228
+ cast({
229
+ target: r.path,
230
+ kind,
231
+ module: mf,
232
+ weight: SIGNAL_WEIGHTS['source-file'],
233
+ signal: 'source-file',
234
+ layer,
235
+ evidence: ev,
236
+ });
237
+ }
238
+ // S5 route-segment (0.3) - today's rule, kept as the floor so nothing goes unassigned
239
+ cast({
240
+ target: r.path,
241
+ kind,
242
+ module: moduleOfPath(r.path),
243
+ weight: SIGNAL_WEIGHTS['route-segment'],
244
+ signal: 'route-segment',
245
+ layer,
246
+ evidence: ev,
247
+ });
248
+ }
249
+ // resolve: strongest vote per target
250
+ const winners = new Map();
251
+ for (const v of votes) {
252
+ const cur = winners.get(v.target);
253
+ if (!cur || v.weight > cur.weight)
254
+ winners.set(v.target, v);
255
+ }
256
+ // build modules
257
+ const acc = new Map();
258
+ for (const v of winners.values()) {
259
+ const m = acc.get(v.module) ?? {
260
+ routes: new Set(),
261
+ endpoints: new Set(),
262
+ layers: new Set(),
263
+ signals: new Set(),
264
+ evidence: [],
265
+ weights: [],
266
+ contract: false,
267
+ };
268
+ if (v.kind === 'endpoint')
269
+ m.endpoints.add(v.target);
270
+ else
271
+ m.routes.add(v.target);
272
+ m.layers.add(v.layer);
273
+ m.signals.add(v.signal);
274
+ m.weights.push(v.weight);
275
+ if (v.signal === 'openapi-tag')
276
+ m.contract = true;
277
+ if (m.evidence.length < 5)
278
+ m.evidence.push(v.evidence);
279
+ acc.set(v.module, m);
280
+ }
281
+ // alias merge - folds signin/signup (generic App.tsx) into auth (backend/auth.ts)
282
+ for (const [name, m] of [...acc.entries()]) {
283
+ const canonical = canonicalAlias(name);
284
+ const size = m.routes.size + m.endpoints.size;
285
+ if (canonical && canonical !== name && acc.has(canonical)) {
286
+ const t = acc.get(canonical);
287
+ for (const r of m.routes)
288
+ t.routes.add(r);
289
+ for (const e of m.endpoints)
290
+ t.endpoints.add(e);
291
+ for (const l of m.layers)
292
+ t.layers.add(l);
293
+ t.signals.add('alias');
294
+ t.weights.push(...m.weights);
295
+ for (const e of m.evidence)
296
+ if (t.evidence.length < 5)
297
+ t.evidence.push(e);
298
+ acc.delete(name);
299
+ }
300
+ else if (size <= mergeBelow && !canonical && acc.size > 1) {
301
+ // a singleton that nothing claims stays put rather than inventing a bucket;
302
+ // it is the caller's job (detect.ignore) to drop it if it is noise.
303
+ }
304
+ }
305
+ let modules = [...acc.entries()].map(([name, m]) => {
306
+ const testingTypes = ['functional', 'smoke', 'regression'];
307
+ if (m.contract)
308
+ testingTypes.push('contract');
309
+ if (canonicalAlias(name) === 'auth' || name === 'auth')
310
+ testingTypes.push('data-driven');
311
+ const targets = [...m.routes, ...m.endpoints];
312
+ const mod = {
313
+ name,
314
+ layers: [...m.layers],
315
+ routes: [...m.routes],
316
+ endpoints: [...m.endpoints],
317
+ testingTypes,
318
+ tags: [`@${name}`],
319
+ confidence: m.weights.length ? m.weights.reduce((a, b) => a + b, 0) / m.weights.length : 0,
320
+ signals: [...m.signals],
321
+ evidence: m.evidence,
322
+ };
323
+ // split pass: reuse the old first-segment rule where it is actually correct
324
+ if (targets.length > splitAfter) {
325
+ const groups = new Map();
326
+ for (const t of targets) {
327
+ const k = moduleOfPath(t);
328
+ groups.set(k, [...(groups.get(k) ?? []), t]);
329
+ }
330
+ if (groups.size > 1) {
331
+ mod.children = [...groups.entries()].map(([childName, ts]) => ({
332
+ name: childName,
333
+ layers: mod.layers,
334
+ routes: ts.filter((t) => m.routes.has(t)),
335
+ endpoints: ts.filter((t) => m.endpoints.has(t)),
336
+ testingTypes: mod.testingTypes,
337
+ tags: [`@${name}`, `@${name}/${childName}`],
338
+ confidence: mod.confidence,
339
+ signals: ['route-segment'],
340
+ evidence: [],
341
+ }));
342
+ }
343
+ }
344
+ return mod;
345
+ });
346
+ modules.sort((a, b) => b.confidence - a.confidence ||
347
+ b.routes.length + b.endpoints.length - (a.routes.length + a.endpoints.length) ||
348
+ a.name.localeCompare(b.name));
349
+ if (modules.length > maxModules)
350
+ modules = modules.slice(0, maxModules);
351
+ return { modules, votes, ignored };
352
+ }
353
+ //# sourceMappingURL=modules.js.map
@@ -1,7 +1,7 @@
1
1
  import { basename } from 'node:path';
2
2
  import { stringify as toYaml } from 'yaml';
3
+ import { detectModules } from './modules.js';
3
4
  import { ProjectConfigSchema, } from '@sdods/contracts';
4
- const RESERVED_SEGMENTS = new Set(['api', 'app', 'src', 'pages']);
5
5
  export function slugify(input) {
6
6
  const s = input
7
7
  .toLowerCase()
@@ -24,13 +24,6 @@ function routeName(path) {
24
24
  .toLowerCase();
25
25
  return name || 'home';
26
26
  }
27
- function moduleOfPath(path) {
28
- const first = path.split('/').filter(Boolean)[0];
29
- if (!first || first.startsWith('{'))
30
- return 'home';
31
- const clean = first.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
32
- return RESERVED_SEGMENTS.has(clean) ? 'core' : clean;
33
- }
34
27
  function pathToConcrete(path) {
35
28
  return path.replace(/\{[^}]+\}/g, '1');
36
29
  }
@@ -76,48 +69,26 @@ export function proposeProject(report, opts = {}) {
76
69
  }
77
70
  if (hasUi && !routes.home)
78
71
  routes.home = '/';
79
- // modules: UI by first path segment, API by openapi tag or first segment
72
+ // modules: signal fusion over route-defining files, workspaces, domain dirs and OpenAPI tags.
73
+ // See analyze/modules.ts - first-URL-segment grouping is kept only as the weakest signal.
74
+ const detected = detectModules(report);
75
+ const nameOfPath = new Map();
76
+ for (const [key, path] of Object.entries(routes))
77
+ if (!nameOfPath.has(path))
78
+ nameOfPath.set(path, key);
80
79
  const modules = new Map();
81
- const ensure = (m, layer) => {
82
- const key = slugify(m);
83
- const mod = modules.get(key) ?? {
84
- name: key,
85
- layers: [],
86
- routes: [],
87
- endpoints: [],
88
- testingTypes: ['functional', 'smoke', 'regression'],
89
- tags: [`@${key}`],
90
- };
91
- if (!mod.layers.includes(layer))
92
- mod.layers.push(layer);
93
- modules.set(key, mod);
94
- return mod;
95
- };
96
- for (const [key, path] of Object.entries(routes)) {
97
- const mod = ensure(moduleOfPath(path), 'ui');
98
- if (!mod.routes.includes(key))
99
- mod.routes.push(key);
100
- }
101
- const endpointSet = new Set();
102
- for (const e of openapiEndpoints) {
103
- const mod = ensure(e.tag ?? moduleOfPath(e.path), 'api');
104
- if (!mod.endpoints.includes(e.path))
105
- mod.endpoints.push(e.path);
106
- if (!mod.testingTypes.includes('contract'))
107
- mod.testingTypes.push('contract');
108
- endpointSet.add(e.path);
109
- }
110
- for (const r of apiRoutes) {
111
- if (endpointSet.has(r.path))
112
- continue;
113
- const mod = ensure(moduleOfPath(r.path.replace(/^\/api\//, '/')), 'api');
114
- if (!mod.endpoints.includes(r.path))
115
- mod.endpoints.push(r.path);
116
- endpointSet.add(r.path);
80
+ for (const m of detected.modules) {
81
+ modules.set(m.name, {
82
+ name: m.name,
83
+ layers: m.layers,
84
+ // module.routes are route NAMES (keys of the project `routes` map), not paths
85
+ routes: [...new Set(m.routes.map((p) => nameOfPath.get(p) ?? routeName(p)))],
86
+ endpoints: m.endpoints,
87
+ testingTypes: m.testingTypes,
88
+ tags: m.tags,
89
+ });
117
90
  }
118
- const authModule = [...modules.values()].find((m) => /auth|login|account|session/.test(m.name));
119
- if (authModule && !authModule.testingTypes.includes('data-driven'))
120
- authModule.testingTypes.push('data-driven');
91
+ const endpointSet = new Set(detected.modules.flatMap((m) => m.endpoints));
121
92
  // envs
122
93
  const detectedEnvNames = [
123
94
  ...new Set(report.envs.map((e) => e.name).filter((n) => n !== 'example')),
@@ -283,6 +254,15 @@ export function proposeProject(report, opts = {}) {
283
254
  coverageMap,
284
255
  checklist,
285
256
  notes,
257
+ detectedModules: detected.modules.map((m) => ({
258
+ name: m.name,
259
+ confidence: m.confidence,
260
+ signals: m.signals,
261
+ routes: m.routes.length,
262
+ endpoints: m.endpoints.length,
263
+ evidence: m.evidence,
264
+ })),
265
+ ignoredTargets: detected.ignored,
286
266
  };
287
267
  }
288
268
  function titleCase(s) {
@@ -148,7 +148,8 @@ export async function captureAuth(opts) {
148
148
  const token = await strategy.token({ config, user });
149
149
  if (token) {
150
150
  const file = tokenFileFor(config, user);
151
- writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
151
+ // Owner-only: this holds a usable API token for the application under test.
152
+ writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
152
153
  result.tokenFile = file;
153
154
  }
154
155
  }
@@ -1,3 +1,5 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
1
3
  /**
2
4
  * Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
3
5
  * unless a custom `login` is provided.
@@ -71,16 +73,52 @@ export async function formLogin(page, config, user) {
71
73
  if (!form) {
72
74
  throw new Error('auth.strategy is "form" but auth.form selectors are missing in sdods.project.yaml.');
73
75
  }
74
- await page.goto(form.loginPath);
75
- await page.locator(form.usernameSelector).fill(user.username);
76
- await page.locator(form.passwordSelector).fill(user.password);
77
- await page.locator(form.submitSelector).click();
78
- if (form.readySelector)
79
- await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
80
- else if (form.readyUrl)
81
- await page.waitForURL(`**${form.readyUrl}*`);
82
- else
83
- await page.waitForLoadState('domcontentloaded');
76
+ try {
77
+ await page.goto(form.loginPath);
78
+ await page.locator(form.usernameSelector).fill(user.username);
79
+ await page.locator(form.passwordSelector).fill(user.password);
80
+ await page.locator(form.submitSelector).click();
81
+ if (form.readySelector)
82
+ await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
83
+ else if (form.readyUrl)
84
+ await page.waitForURL(`**${form.readyUrl}*`);
85
+ else
86
+ await page.waitForLoadState('domcontentloaded');
87
+ }
88
+ catch (cause) {
89
+ throw await describeLoginFailure(page, config, user, cause);
90
+ }
91
+ }
92
+ /**
93
+ * The form login runs in its own context (see `defineAuth`), so Playwright's video/trace/screenshot
94
+ * settings never attach to it — a failure here would otherwise surface as a bare locator timeout
95
+ * with no artifact. Name the page we actually landed on, and leave a screenshot behind.
96
+ */
97
+ async function describeLoginFailure(page, config, user, cause) {
98
+ let where = '';
99
+ let shot = '';
100
+ try {
101
+ const url = page.url();
102
+ const title = await page.title();
103
+ where = ` at ${url}${title ? ` (title: ${JSON.stringify(title)})` : ''}`;
104
+ }
105
+ catch {
106
+ // page already closed or crashed — the original error is still worth reporting
107
+ }
108
+ try {
109
+ const dir = join(config.runtime.runDir, 'auth');
110
+ mkdirSync(dir, { recursive: true });
111
+ const file = join(dir, `login-failed-${user.role}-${user.username}.png`.replace(/\s+/g, '_'));
112
+ await page.screenshot({ path: file, fullPage: true });
113
+ shot = `\nScreenshot: ${file}`;
114
+ }
115
+ catch {
116
+ // screenshotting is best effort
117
+ }
118
+ const reason = cause instanceof Error ? cause.message : String(cause);
119
+ return new Error(`Form login for ${user.username} (${user.role}) failed${where}.\n${reason}` +
120
+ `\nIs the application under test running at ${config.env.ui.baseUrl}, and does that page ` +
121
+ `use the auth.form selectors in sdods.project.yaml?${shot}`, { cause });
84
122
  }
85
123
  export const noopAuth = defineAuth({ strategy: 'none' });
86
124
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,37 @@
1
+ import { type PlaywrightTestConfig } from '@playwright/test';
2
+ import { type BrowserName, type Layer } from '@sdods/contracts';
3
+ import type { ProjectRegistry } from './registry.js';
4
+ export interface PlaywrightSelection {
5
+ project?: string;
6
+ env?: string;
7
+ layers?: string[];
8
+ browsers?: string[];
9
+ tags?: string;
10
+ runId?: string;
11
+ lint?: boolean;
12
+ allure?: boolean;
13
+ reporters?: string[];
14
+ reporterMode?: 'default' | 'server' | 'quiet';
15
+ }
16
+ export interface SdodsUseOption {
17
+ project: string;
18
+ layer: Layer;
19
+ browser?: BrowserName;
20
+ }
21
+ export declare const DASHBOARD_REPORTER = "@sdods/core/reporters/dashboard";
22
+ /** Convenience for `playwright.config.ts`: read the selection from SDODS_* env vars. */
23
+ export declare function selectionFromEnv(env?: NodeJS.ProcessEnv): PlaywrightSelection;
24
+ export interface GeneratedProject {
25
+ name: string;
26
+ project: string;
27
+ layer: Layer;
28
+ browser?: BrowserName;
29
+ }
30
+ /** Names (and identity) of the Playwright projects a selection would produce, without side effects. */
31
+ export declare function listGeneratedProjects(registry: ProjectRegistry, sel: PlaywrightSelection): GeneratedProject[];
32
+ /**
33
+ * Build the Playwright config for a selection of projects × layers × browsers.
34
+ * One `defineBddConfig` per project × layer; browsers reuse the generated testDir.
35
+ */
36
+ export declare function buildPlaywrightConfig(registry: ProjectRegistry, sel?: PlaywrightSelection): PlaywrightTestConfig;
37
+ //# sourceMappingURL=playwright.d.ts.map