@sdods/core 0.2.1 → 0.3.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 (55) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/propose.js +32 -10
  4. package/dist/analyze/scan.js +19 -1
  5. package/dist/api/client.js +7 -1
  6. package/dist/auth/capture.js +21 -7
  7. package/dist/auth/index.js +71 -12
  8. package/dist/config/resolve.d.ts +13 -0
  9. package/dist/config/resolve.js +1 -0
  10. package/dist/config/runner.js +3 -0
  11. package/dist/config/tags.d.ts +29 -1
  12. package/dist/config/tags.js +46 -0
  13. package/dist/data/provider.js +5 -1
  14. package/dist/data/user-pool.js +35 -3
  15. package/dist/fixtures/api-context.d.ts +14 -1
  16. package/dist/fixtures/api-context.js +13 -0
  17. package/dist/fixtures/auth.d.ts +9 -1
  18. package/dist/fixtures/auth.js +13 -5
  19. package/dist/fixtures/test.js +42 -1
  20. package/dist/fixtures/types.d.ts +2 -0
  21. package/dist/har/api-har.d.ts +1 -0
  22. package/dist/har/api-har.js +1 -1
  23. package/dist/har/index.d.ts +1 -0
  24. package/dist/har/index.js +1 -0
  25. package/dist/har/scrub.d.ts +17 -0
  26. package/dist/har/scrub.js +60 -0
  27. package/dist/reporters/dashboard.d.ts +86 -0
  28. package/dist/reporters/dashboard.js +319 -61
  29. package/dist/steps/a11y.steps.d.ts +180 -0
  30. package/dist/steps/a11y.steps.js +598 -0
  31. package/dist/steps/api.steps.js +5 -1
  32. package/dist/steps/browser.steps.d.ts +27 -0
  33. package/dist/steps/browser.steps.js +653 -0
  34. package/dist/steps/clock.steps.d.ts +4 -0
  35. package/dist/steps/clock.steps.js +73 -0
  36. package/dist/steps/data.steps.js +50 -2
  37. package/dist/steps/db.steps.d.ts +5 -0
  38. package/dist/steps/db.steps.js +105 -0
  39. package/dist/steps/dom.steps.d.ts +2 -0
  40. package/dist/steps/dom.steps.js +583 -0
  41. package/dist/steps/iframe.steps.d.ts +2 -0
  42. package/dist/steps/iframe.steps.js +93 -0
  43. package/dist/steps/index.d.ts +10 -0
  44. package/dist/steps/index.js +10 -0
  45. package/dist/steps/net.steps.d.ts +63 -0
  46. package/dist/steps/net.steps.js +728 -0
  47. package/dist/steps/perf.steps.d.ts +248 -0
  48. package/dist/steps/perf.steps.js +514 -0
  49. package/dist/steps/tabs.steps.d.ts +5 -0
  50. package/dist/steps/tabs.steps.js +109 -0
  51. package/dist/steps/webhook.steps.d.ts +46 -0
  52. package/dist/steps/webhook.steps.js +129 -0
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +3 -4
@@ -1,10 +1,54 @@
1
- import { basename, dirname } from 'node:path';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, dirname, join, parse as parsePath } from 'node:path';
2
3
  import { parse as parseYaml } from 'yaml';
3
4
  const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte']);
4
5
  const TEMPLATE_EXT = new Set(['.tsx', '.jsx', '.vue', '.svelte', '.html', '.htm', '.ts', '.js']);
5
6
  function ev(file, line, snippet) {
6
7
  return { file, line, snippet: snippet?.trim().slice(0, 160) };
7
8
  }
9
+ const PM_LOCKFILES = [
10
+ ['bun.lock', 'bun'],
11
+ ['bun.lockb', 'bun'],
12
+ ['pnpm-lock.yaml', 'pnpm'],
13
+ ['yarn.lock', 'yarn'],
14
+ ['package-lock.json', 'npm'],
15
+ ];
16
+ /**
17
+ * Nearest ancestor of `from` that looks like a JS workspace root: it holds a lockfile, or a
18
+ * package.json declaring `workspaces`, or a pnpm-workspace.yaml.
19
+ *
20
+ * Scanning a workspace MEMBER (`analyze apps/sat`) is the normal case on a monorepo, and the
21
+ * member has none of those files — the root above it does. Without this walk the whole scan
22
+ * reports `packageManager: unknown` and `monorepo: false` for a repo that is plainly neither.
23
+ * Returns undefined rather than guessing when nothing is found before the filesystem root.
24
+ */
25
+ function findWorkspaceRootAbove(from) {
26
+ const stopAt = parsePath(from).root;
27
+ let dir = dirname(from);
28
+ // Bounded so a pathological path cannot spin; 12 is far beyond any real nesting depth.
29
+ for (let i = 0; i < 12 && dir && dir !== stopAt; i++) {
30
+ if (PM_LOCKFILES.some(([f]) => existsSync(join(dir, f))))
31
+ return dir;
32
+ if (existsSync(join(dir, 'pnpm-workspace.yaml')))
33
+ return dir;
34
+ const pkgPath = join(dir, 'package.json');
35
+ if (existsSync(pkgPath)) {
36
+ try {
37
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
38
+ if (pkg.workspaces)
39
+ return dir;
40
+ }
41
+ catch {
42
+ /* an unparseable package.json is not a workspace root */
43
+ }
44
+ }
45
+ const next = dirname(dir);
46
+ if (next === dir)
47
+ break;
48
+ dir = next;
49
+ }
50
+ return undefined;
51
+ }
8
52
  export function detectPackageManager(scan) {
9
53
  const evidence = [];
10
54
  let name = 'unknown';
@@ -17,13 +61,7 @@ export function detectPackageManager(scan) {
17
61
  evidence.push(ev('package.json', undefined, `packageManager: ${pm}`));
18
62
  }
19
63
  }
20
- const lockfiles = [
21
- ['bun.lock', 'bun'],
22
- ['bun.lockb', 'bun'],
23
- ['pnpm-lock.yaml', 'pnpm'],
24
- ['yarn.lock', 'yarn'],
25
- ['package-lock.json', 'npm'],
26
- ];
64
+ const lockfiles = PM_LOCKFILES;
27
65
  for (const [file, pmName] of lockfiles) {
28
66
  if (scan.has(file)) {
29
67
  if (name === 'unknown')
@@ -65,10 +103,41 @@ export function detectPackageManager(scan) {
65
103
  if (scan.has(f))
66
104
  evidence.push(ev(f, undefined, 'monorepo tooling'));
67
105
  }
68
- const monorepo = workspaces.length > 0 ||
106
+ let monorepo = workspaces.length > 0 ||
69
107
  scan.has('lerna.json') ||
70
108
  scan.has('nx.json') ||
71
109
  scan.has('turbo.json');
110
+ // A workspace member has no lockfile and no `workspaces` key of its own — those live in the
111
+ // root above it. Scanning a member directly is the normal monorepo case, so resolve upwards
112
+ // rather than reporting `unknown` / `monorepo: false` for a repo that is neither.
113
+ if (name === 'unknown' || !monorepo) {
114
+ const wsRoot = findWorkspaceRootAbove(scan.root);
115
+ if (wsRoot) {
116
+ monorepo = true;
117
+ if (name === 'unknown') {
118
+ for (const [file, pmName] of PM_LOCKFILES) {
119
+ if (existsSync(join(wsRoot, file))) {
120
+ name = pmName;
121
+ evidence.push(ev(file, undefined, `workspace root ${wsRoot}`));
122
+ break;
123
+ }
124
+ }
125
+ if (name === 'unknown') {
126
+ try {
127
+ const pkg = JSON.parse(readFileSync(join(wsRoot, 'package.json'), 'utf8'));
128
+ const m = /^(npm|pnpm|yarn|bun)@/.exec(pkg.packageManager ?? '');
129
+ if (m) {
130
+ name = m[1];
131
+ evidence.push(ev('package.json', undefined, `workspace root ${wsRoot}: ${pkg.packageManager}`));
132
+ }
133
+ }
134
+ catch {
135
+ /* no readable manifest at the workspace root */
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
72
141
  return { name, monorepo, workspaces: [...new Set(workspaces)], evidence };
73
142
  }
74
143
  // ── frameworks ────────────────────────────────────────────────────────────────
@@ -202,6 +271,8 @@ export function detectRoutes(scan, frameworks) {
202
271
  if (names.has('Next.js') ||
203
272
  scan.files.some((f) => /(^|\/)app\/.*page\.(tsx|jsx|ts|js)$/.test(f.rel))) {
204
273
  for (const f of scan.files) {
274
+ if (isNotShippedCode(f.rel))
275
+ continue;
205
276
  const m = /(?:^|\/)(?:src\/)?app\/(.*?)(?:^|\/)?(page|route)\.(tsx|jsx|ts|js)$/.exec(f.rel);
206
277
  if (!m)
207
278
  continue;
@@ -237,9 +308,19 @@ export function detectRoutes(scan, frameworks) {
237
308
  }
238
309
  }
239
310
  for (const f of scan.files) {
311
+ if (isNotShippedCode(f.rel))
312
+ continue;
240
313
  const m = /(?:^|\/)(?:src\/)?pages\/(.+)\.(tsx|jsx|ts|js)$/.exec(f.rel);
241
314
  if (!m)
242
315
  continue;
316
+ // A directory called `pages` is not automatically the Pages Router.
317
+ // `app/api/intranet/pages/[id]/route.ts` is an App Router ROUTE HANDLER
318
+ // inside a resource that happens to be named "pages" — and it was being
319
+ // read as a Pages Router PAGE at `/{id}/route`: wrong kind, wrong source,
320
+ // and a path that had lost every parent segment. Anything under an `app/`
321
+ // segment belongs to the App Router loop above, which already handled it.
322
+ if (/(?:^|\/)(?:src\/)?app\//.test(f.rel))
323
+ continue;
243
324
  const rel = m[1];
244
325
  if (/^_app$|^_document$|^_error$|^404$|^500$/.test(rel))
245
326
  continue;
@@ -256,6 +337,15 @@ export function detectRoutes(scan, frameworks) {
256
337
  });
257
338
  }
258
339
  }
340
+ /**
341
+ * Colocated tests, stories and type declarations sit next to the code they
342
+ * cover and match every route filename pattern. `route.test.ts` was being
343
+ * reported as a route named `id-publish-route-test`, which then became a key in
344
+ * the generated `routes:` map — an address that resolves to nothing.
345
+ */
346
+ function isNotShippedCode(rel) {
347
+ return /\.(test|spec|stories|story|bench|d)\.(tsx|jsx|ts|js|mts|cts)$/.test(rel);
348
+ }
259
349
  // React Router
260
350
  if (names.has('React Router')) {
261
351
  for (const f of scan.byExt('.tsx', '.jsx', '.ts', '.js')) {
@@ -664,13 +754,35 @@ export function detectAuth(scan, routes) {
664
754
  }
665
755
  if (pages.length)
666
756
  votes.form += 0.5;
667
- let strategyGuess = 'none';
668
- let best = 0;
669
- for (const [k, v] of Object.entries(votes)) {
670
- if (v > best) {
671
- best = v;
672
- strategyGuess = k;
673
- }
757
+ // Ranked, not object-key order. `if (v > best)` over `Object.entries` resolves a TIE by
758
+ // whichever key happens to be declared first — so a repo scoring form 1.6 and token 1.6 got
759
+ // `form` for no reason a reader could see, and adding a library could silently flip it.
760
+ // Ties are now broken by this explicit precedence and, more importantly, are REPORTED: an
761
+ // ambiguous guess a human must confirm is not the same result as a confident one.
762
+ const TIE_ORDER = [
763
+ 'sso',
764
+ 'oauth-client-credentials',
765
+ 'token',
766
+ 'form',
767
+ 'none',
768
+ ];
769
+ // Epsilon, not `===`. These scores are sums of decimal weights, so a "tie" is almost never
770
+ // exact: passport-local + express-session is 0.8 + 0.5 = 1.3000000000000000, while
771
+ // jsonwebtoken + @nestjs/jwt is 0.6 + 0.7 = 1.2999999999999998. Exact equality would report
772
+ // those as a clear win for `form` by 2e-16 — which is precisely the invisible, weight-order
773
+ // -dependent decision this fix exists to remove.
774
+ const TIE_EPSILON = 1e-9;
775
+ const ranked = Object.entries(votes).sort((a, b) => (Math.abs(b[1] - a[1]) < TIE_EPSILON ? 0 : b[1] - a[1]) ||
776
+ TIE_ORDER.indexOf(a[0]) - TIE_ORDER.indexOf(b[0]));
777
+ const [top, second] = ranked;
778
+ const best = top?.[1] ?? 0;
779
+ const strategyGuess = best > 0 ? top[0] : 'none';
780
+ if (second && best > 0 && Math.abs(second[1] - best) < TIE_EPSILON) {
781
+ const tied = ranked
782
+ .filter(([, v]) => Math.abs(v - best) < TIE_EPSILON)
783
+ .map(([k]) => k)
784
+ .join(', ');
785
+ evidence.push(ev('package.json', undefined, `ambiguous auth: ${tied} all scored ${best.toFixed(2)} — picked ${strategyGuess}, confirm before relying on it`));
674
786
  }
675
787
  return {
676
788
  pages: [...new Set(pages)],
@@ -682,30 +794,115 @@ export function detectAuth(scan, routes) {
682
794
  }
683
795
  // ── envs and base urls ────────────────────────────────────────────────────────
684
796
  const URL_VAR = /(BASE_?URL|API_?URL|APP_?URL|PUBLIC_URL|SITE_URL|HOST|ORIGIN|ENDPOINT)/i;
797
+ /** Framework prefixes that expose a variable to the browser but say nothing about its meaning. */
798
+ const PUBLIC_PREFIX = /^(NEXT_PUBLIC_|VITE_|REACT_APP_|NUXT_PUBLIC_|PUBLIC_|GATSBY_|EXPO_PUBLIC_)/;
799
+ const CANONICAL_URL_KEYS = new Set([
800
+ 'API_URL',
801
+ 'API_BASE_URL',
802
+ 'BASE_URL',
803
+ 'APP_URL',
804
+ 'APP_BASE_URL',
805
+ 'SITE_URL',
806
+ 'PUBLIC_URL',
807
+ 'ORIGIN',
808
+ 'HOST',
809
+ ]);
810
+ /**
811
+ * How strongly a variable NAME claims to be *the* base URL of its kind.
812
+ *
813
+ * The old rule was `apiBaseUrl ??= raw` — first match in file order wins. On a real repo that
814
+ * means `SIM_AGENT_API_URL`, declared 39 lines above `NEXT_PUBLIC_API_URL`, becomes the API base
815
+ * URL for the whole generated project, and every generated API scenario then talks to a service
816
+ * that is not the application. A vendor- or service-prefixed name is the LEAST likely to be the
817
+ * app's own base URL, so specificity has to beat position.
818
+ */
819
+ function urlKeyScore(key) {
820
+ const bare = key.toUpperCase().replace(PUBLIC_PREFIX, '');
821
+ if (CANONICAL_URL_KEYS.has(bare))
822
+ return 3;
823
+ if (/^[A-Z0-9]+_(API_URL|BASE_URL|APP_URL)$/.test(bare))
824
+ return 1;
825
+ return 2;
826
+ }
827
+ /**
828
+ * A deliberately small `.gitignore` reader, scoped to the `.env` family only.
829
+ *
830
+ * Full gitignore semantics (negation, directory-only, nested files, precedence) are not needed
831
+ * to answer the one question asked here — "is this .env file deliberately untracked?" — and a
832
+ * partial implementation of the full spec would be worse than an honest narrow one. Patterns
833
+ * that do not concern `.env*` are ignored outright.
834
+ */
835
+ function gitIgnoredEnvMatcher(scan) {
836
+ const patterns = [];
837
+ for (const candidate of ['.gitignore', '.git/info/exclude']) {
838
+ const text = scan.read(candidate);
839
+ if (!text)
840
+ continue;
841
+ for (const raw of text.split(/\r?\n/)) {
842
+ const line = raw.trim();
843
+ if (!line || line.startsWith('#') || line.startsWith('!'))
844
+ continue;
845
+ const body = line.replace(/^\/+/, '').replace(/\/+$/, '');
846
+ if (!body.includes('.env'))
847
+ continue;
848
+ patterns.push(new RegExp(`^${body
849
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
850
+ .replace(/\*/g, '[^/]*')
851
+ .replace(/\?/g, '[^/]')}$`));
852
+ }
853
+ }
854
+ if (!patterns.length)
855
+ return () => false;
856
+ return (rel) => {
857
+ const base = basename(rel);
858
+ return patterns.some((re) => re.test(base) || re.test(rel));
859
+ };
860
+ }
685
861
  export function detectEnvs(scan) {
686
862
  const envs = [];
687
863
  const evidence = [];
688
864
  let ui;
689
865
  let api;
866
+ const ignored = gitIgnoredEnvMatcher(scan);
690
867
  for (const f of scan.files) {
691
868
  const name = basename(f.rel);
692
869
  if (!name.startsWith('.env'))
693
870
  continue;
694
871
  if (f.rel.split('/').length > 2)
695
872
  continue;
873
+ // A gitignored .env holds real credentials and real internal hostnames. Reading it puts
874
+ // those values into the report, and `analyze --json` / the generated yaml are things people
875
+ // paste into issues and commit. The file is deliberately not in the repo; the analyzer has
876
+ // no business republishing it.
877
+ if (ignored(f.rel)) {
878
+ evidence.push(ev(f.rel, undefined, 'gitignored — not read'));
879
+ continue;
880
+ }
696
881
  const envName = name === '.env' ? 'local' : name.replace(/^\.env\.?/, '').replace(/\.local$/, '') || 'local';
697
882
  const vars = [];
698
883
  let uiBaseUrl;
699
884
  let apiBaseUrl;
885
+ let uiScore = -1;
886
+ let apiScore = -1;
700
887
  for (const hit of scan.grep(f.rel, /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)) {
701
888
  const key = hit.match[1];
702
889
  const raw = hit.match[2].trim().replace(/^["']|["']$/g, '');
703
890
  vars.push(key);
704
891
  if (URL_VAR.test(key) && /^https?:\/\//.test(raw)) {
705
- if (/API|ENDPOINT/i.test(key))
706
- apiBaseUrl ??= raw;
707
- else
708
- uiBaseUrl ??= raw;
892
+ const score = urlKeyScore(key);
893
+ const isApi = /API|ENDPOINT/i.test(key);
894
+ // Strictly greater: the first key at a given specificity still wins, so a file with two
895
+ // equally-canonical names keeps its existing, file-order-stable answer.
896
+ if (isApi ? score > apiScore : score > uiScore) {
897
+ if (isApi) {
898
+ apiBaseUrl = raw;
899
+ apiScore = score;
900
+ }
901
+ else {
902
+ uiBaseUrl = raw;
903
+ uiScore = score;
904
+ }
905
+ }
709
906
  evidence.push(ev(f.rel, hit.line, `${key}=${/example|sample/i.test(name) ? raw : '…'}`));
710
907
  }
711
908
  }
@@ -757,7 +954,12 @@ export function detectEnvs(scan) {
757
954
  }
758
955
  }
759
956
  }
760
- for (const e of envs) {
957
+ // Real environments first, `.env.example` last. `propose` drops `example` from the env list
958
+ // (it is a template, not an environment) while base URLs were folded in file order — so a repo
959
+ // whose .env.example sorted first handed its placeholder URLs to every generated env, and the
960
+ // proposal named environments that had supplied none of its own values.
961
+ const isTemplate = (n) => n === 'example' || n === 'sample';
962
+ for (const e of [...envs].sort((a, b) => Number(isTemplate(a.name)) - Number(isTemplate(b.name)))) {
761
963
  ui ??= e.uiBaseUrl;
762
964
  api ??= e.apiBaseUrl;
763
965
  }
@@ -766,9 +968,19 @@ export function detectEnvs(scan) {
766
968
  const portHit = scan.files
767
969
  .filter((f) => SOURCE_EXT.has(f.ext))
768
970
  .flatMap((f) => scan.grep(f.rel, /\.listen\(\s*(?:\{[^}]*port:\s*)?(\d{4,5})/).map((h) => ({ f, h })))[0];
769
- api = portHit ? `http://localhost:${portHit.h.match[1]}` : 'http://localhost:3000';
770
- if (portHit)
771
- evidence.push(ev(portHit.f.rel, portHit.h.line, portHit.h.text));
971
+ // Only when it names a DIFFERENT origin than the UI. A full-stack app (Next, Nuxt) serves
972
+ // its API from the same port under /api, and an express dependency there is usually a socket
973
+ // or worker server — not a second API origin. The old code set `api` unconditionally, which
974
+ // both invented `http://localhost:3000` out of nothing when no `.listen()` existed AND
975
+ // suppressed the `api ??= ui + '/api'` fallback below, so every generated API scenario was
976
+ // pointed at the UI origin with no /api suffix.
977
+ if (portHit) {
978
+ const candidate = `http://localhost:${portHit.h.match[1]}`;
979
+ if (candidate !== ui) {
980
+ api = candidate;
981
+ evidence.push(ev(portHit.f.rel, portHit.h.line, portHit.h.text));
982
+ }
983
+ }
772
984
  }
773
985
  api ??= ui ? `${ui}/api` : undefined;
774
986
  return { envs, baseUrls: { ui, api, evidence } };
@@ -10,19 +10,41 @@ export function slugify(input) {
10
10
  .replace(/^-+|-+$/g, '');
11
11
  return s || 'app';
12
12
  }
13
+ /**
14
+ * A route key is a feature-file identifier a person has to read, type and grep for — it names
15
+ * `features/<module>/<key>.feature`. The old version cleaned every segment and joined all of
16
+ * them, so a deep route produced a long key whose leading segments were pure transport
17
+ * (`api-v1-…`) and whose parameter segments lost the fact that they were parameters.
18
+ *
19
+ * Now: transport prefixes are dropped, parameters read as `by-<param>`, and the key is capped at
20
+ * the segments that actually distinguish it. Collisions are still resolved by the `-2`, `-3`
21
+ * suffix loop in the caller, so shortening cannot silently merge two routes.
22
+ */
23
+ const TRANSPORT_SEGMENTS = new Set(['api', 'rest', 'v1', 'v2', 'v3']);
13
24
  function routeName(path) {
14
25
  if (path === '/' || path === '')
15
26
  return 'home';
16
- const segs = path
17
- .split('/')
18
- .filter(Boolean)
19
- .map((s) => s.replace(/[{}*]/g, '').replace(/[^a-zA-Z0-9]+/g, '-'));
20
- const name = segs
21
- .filter(Boolean)
22
- .map((s, i) => (i > 0 && path.includes(`{${s}}`) ? `by-${s}` : s))
23
- .join('-')
24
- .toLowerCase();
25
- return name || 'home';
27
+ const raw = path.split('/').filter(Boolean);
28
+ const isParam = (s) => /^[{:*]/.test(s) || /^\[.*\]$/.test(s);
29
+ const clean = (s) => s
30
+ .replace(/[{}[\]:*]/g, '')
31
+ .replace(/^\.\.\./, '')
32
+ .replace(/[^a-zA-Z0-9]+/g, '-')
33
+ .replace(/^-+|-+$/g, '');
34
+ const parts = [];
35
+ for (const [i, seg] of raw.entries()) {
36
+ const name = clean(seg);
37
+ if (!name)
38
+ continue;
39
+ // Only a LEADING transport segment is dropped: a resource genuinely called `v2` deeper in
40
+ // the path is part of the identity of the route.
41
+ if (i === 0 && TRANSPORT_SEGMENTS.has(name.toLowerCase()))
42
+ continue;
43
+ parts.push(isParam(seg) ? `by-${name}` : name);
44
+ }
45
+ // Keep the head (what it is) and the tail (what it does); the middle rarely distinguishes.
46
+ const trimmed = parts.length > 4 ? [parts[0], ...parts.slice(-3)] : parts;
47
+ return trimmed.join('-').toLowerCase() || 'home';
26
48
  }
27
49
  function moduleOfPath(path) {
28
50
  const first = path.split('/').filter(Boolean)[0];
@@ -34,6 +34,10 @@ export const IGNORED_DIRS = new Set([
34
34
  'bin',
35
35
  'obj',
36
36
  ]);
37
+ /** A directory that contains `.git` (a dir for a clone, a file for a worktree). */
38
+ function isRepositoryRoot(dir) {
39
+ return existsSync(join(dir, '.git'));
40
+ }
37
41
  export const TEXT_EXTENSIONS = new Set([
38
42
  '.ts',
39
43
  '.tsx',
@@ -77,7 +81,12 @@ export class Scan {
77
81
  byRel = new Map();
78
82
  constructor(root, opts = {}) {
79
83
  this.root = resolvePath(root);
80
- const maxFiles = opts.maxFiles ?? 8000;
84
+ // 8,000 was below the size of a single real monorepo app: `apps/sat` alone is ~6,400 files,
85
+ // so the walk truncated before reaching `.github/`, and every absence-based finding
86
+ // ("no CI", "no OpenAPI", "no test ids") was then reported as fact rather than as
87
+ // "not seen". Raising the default is half the fix; `--max-files` / `--max-depth` on the CLI
88
+ // is the other half, because no default is right for every repo.
89
+ const maxFiles = opts.maxFiles ?? 25000;
81
90
  const maxDepth = opts.maxDepth ?? 12;
82
91
  let truncated = false;
83
92
  const walk = (dir, depth) => {
@@ -96,6 +105,15 @@ export class Scan {
96
105
  return;
97
106
  }
98
107
  const abs = join(dir, name);
108
+ // A directory holding its own `.git` is a different repository — a
109
+ // submodule, a vendored checkout, or a git worktree. Descending into
110
+ // one attributes another project's routes to this one, and a worktree
111
+ // is usually a STALE copy of this very repo, so every route gets
112
+ // reported twice with the wrong path. Observed on a real monorepo: all
113
+ // 157 routes were cited inside `.claude/worktrees/<branch>/`, a path
114
+ // that repo's own .gitignore excludes.
115
+ if (dir !== this.root && isRepositoryRoot(dir))
116
+ return;
99
117
  let st;
100
118
  try {
101
119
  st = statSync(abs);
@@ -53,7 +53,13 @@ export class ApiClient {
53
53
  };
54
54
  if (opts.body !== undefined && !headers['content-type'] && !opts.form)
55
55
  headers['content-type'] = 'application/json';
56
- const auth = opts.auth === null ? undefined : (opts.auth ?? this.deps.ctx.auth ?? envAuth(env.auth));
56
+ // `null` at either level means "send this unauthenticated". Only `undefined`
57
+ // falls through to the next layer, so a scenario can opt out of the
58
+ // environment credential without the environment having to know.
59
+ const ctxAuth = this.deps.ctx.auth;
60
+ const auth = opts.auth === null
61
+ ? undefined
62
+ : (opts.auth ?? (ctxAuth === null ? undefined : (ctxAuth ?? envAuth(env.auth))));
57
63
  if (auth) {
58
64
  if (auth.type === 'bearer')
59
65
  headers.authorization = `Bearer ${auth.token}`;
@@ -111,14 +111,22 @@ export async function captureAuth(opts) {
111
111
  results.push(result);
112
112
  continue;
113
113
  }
114
- if (!opts.force && cache.isFresh(user)) {
114
+ // A fresh storageState means the LOGIN can be skipped. It says nothing about
115
+ // the token: the two are separate artefacts with separate lifetimes, and the
116
+ // token file is what the API layer reads (steps/data.steps.ts). Skipping the
117
+ // whole user here left roles with a browser session and no token, so every
118
+ // `@user:<role>` API scenario fell through to a live mint -- one Firebase
119
+ // sign-in per scenario, which is how a suite earns QUOTA_EXCEEDED. So the
120
+ // freshness check now gates the login only, and the token block below still
121
+ // runs -- and it is not a wasted mint either, because it now mints only when
122
+ // there is no token file yet, or when --force asked for a fresh one.
123
+ const loginIsFresh = !opts.force && cache.isFresh(user);
124
+ const interactive = opts.interactive || strategy.strategy === 'sso';
125
+ if (loginIsFresh) {
115
126
  result.file = cache.fileFor(user);
116
127
  result.skipped = 'fresh state exists (use force to recapture)';
117
- results.push(result);
118
- continue;
119
128
  }
120
- const interactive = opts.interactive || strategy.strategy === 'sso';
121
- if (interactive) {
129
+ else if (interactive) {
122
130
  const file = cache.fileFor(user);
123
131
  const loginUrl = new URL(opts.loginUrl ?? config.project.auth.form?.loginPath ?? '/', config.env.ui.baseUrl).toString();
124
132
  const args = ['playwright', 'codegen', '--save-storage', file];
@@ -144,14 +152,20 @@ export async function captureAuth(opts) {
144
152
  else if (!strategy.token)
145
153
  result.skipped = 'strategy returned no storage state';
146
154
  }
147
- if (strategy.token) {
155
+ if (strategy.token && (opts.force || !existsSync(tokenFileFor(config, user)))) {
148
156
  const token = await strategy.token({ config, user });
149
157
  if (token) {
150
158
  const file = tokenFileFor(config, user);
151
- writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
159
+ // Owner-only: this holds a usable API token for the application under test.
160
+ writeFileSync(file, JSON.stringify({ token, capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
152
161
  result.tokenFile = file;
153
162
  }
154
163
  }
164
+ else if (strategy.token) {
165
+ // Reused, not absent. Reporting nothing here reads as "this role has no
166
+ // token", which is the opposite of the truth.
167
+ result.tokenFile = tokenFileFor(config, user);
168
+ }
155
169
  results.push(result);
156
170
  }
157
171
  }
@@ -1,3 +1,6 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { SdodsError } from '../errors.js';
1
4
  /**
2
5
  * Define the project's auth strategy. `form` uses `auth.form` selectors from the project yaml
3
6
  * unless a custom `login` is provided.
@@ -29,7 +32,16 @@ export function defineAuth(def) {
29
32
  case 'token':
30
33
  return {
31
34
  strategy: 'token',
32
- login: async () => undefined,
35
+ // A `token` project can call the API and has NO browser session. Returning `undefined`
36
+ // here let a @ui scenario run signed-out and then fail on an assertion about the page,
37
+ // which reads as a product defect rather than as a misconfigured project. Fail where the
38
+ // cause is, and say what to do instead.
39
+ login: async () => {
40
+ throw new SdodsError('NOT_SUPPORTED', 'The `token` auth strategy provides an API credential, not a browser session.', {
41
+ hint: 'Use `strategy: "custom"` with a `login` that performs the sign-in and returns a storageState, or restrict these scenarios to the @api layer.',
42
+ exitCode: 2,
43
+ });
44
+ },
33
45
  token: def.token,
34
46
  };
35
47
  case 'oauth-client-credentials':
@@ -61,7 +73,18 @@ export function defineAuth(def) {
61
73
  },
62
74
  };
63
75
  case 'sso':
64
- return { strategy: 'sso', login: async () => undefined };
76
+ return {
77
+ strategy: 'sso',
78
+ // `sso` was a stub: it returned no session and threw nothing, so every scenario under it
79
+ // ran unauthenticated and silently. There is no generic SSO sign-in to implement — the
80
+ // flow is provider-specific — so the honest behaviour is to refuse and name the seam.
81
+ login: async () => {
82
+ throw new SdodsError('NOT_SUPPORTED', 'The `sso` auth strategy is a placeholder: it performs no sign-in.', {
83
+ hint: 'SSO flows are provider-specific. Use `strategy: "custom"` with a `login` that drives your identity provider, or capture a session once with `sdods auth capture --interactive`.',
84
+ exitCode: 2,
85
+ });
86
+ },
87
+ };
65
88
  case 'custom':
66
89
  return { strategy: 'custom', login: def.login, token: def.token };
67
90
  }
@@ -71,16 +94,52 @@ export async function formLogin(page, config, user) {
71
94
  if (!form) {
72
95
  throw new Error('auth.strategy is "form" but auth.form selectors are missing in sdods.project.yaml.');
73
96
  }
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');
97
+ try {
98
+ await page.goto(form.loginPath);
99
+ await page.locator(form.usernameSelector).fill(user.username);
100
+ await page.locator(form.passwordSelector).fill(user.password);
101
+ await page.locator(form.submitSelector).click();
102
+ if (form.readySelector)
103
+ await page.locator(form.readySelector).first().waitFor({ state: 'visible' });
104
+ else if (form.readyUrl)
105
+ await page.waitForURL(`**${form.readyUrl}*`);
106
+ else
107
+ await page.waitForLoadState('domcontentloaded');
108
+ }
109
+ catch (cause) {
110
+ throw await describeLoginFailure(page, config, user, cause);
111
+ }
112
+ }
113
+ /**
114
+ * The form login runs in its own context (see `defineAuth`), so Playwright's video/trace/screenshot
115
+ * settings never attach to it — a failure here would otherwise surface as a bare locator timeout
116
+ * with no artifact. Name the page we actually landed on, and leave a screenshot behind.
117
+ */
118
+ async function describeLoginFailure(page, config, user, cause) {
119
+ let where = '';
120
+ let shot = '';
121
+ try {
122
+ const url = page.url();
123
+ const title = await page.title();
124
+ where = ` at ${url}${title ? ` (title: ${JSON.stringify(title)})` : ''}`;
125
+ }
126
+ catch {
127
+ // page already closed or crashed — the original error is still worth reporting
128
+ }
129
+ try {
130
+ const dir = join(config.runtime.runDir, 'auth');
131
+ mkdirSync(dir, { recursive: true });
132
+ const file = join(dir, `login-failed-${user.role}-${user.username}.png`.replace(/\s+/g, '_'));
133
+ await page.screenshot({ path: file, fullPage: true });
134
+ shot = `\nScreenshot: ${file}`;
135
+ }
136
+ catch {
137
+ // screenshotting is best effort
138
+ }
139
+ const reason = cause instanceof Error ? cause.message : String(cause);
140
+ return new Error(`Form login for ${user.username} (${user.role}) failed${where}.\n${reason}` +
141
+ `\nIs the application under test running at ${config.env.ui.baseUrl}, and does that page ` +
142
+ `use the auth.form selectors in sdods.project.yaml?${shot}`, { cause });
84
143
  }
85
144
  export const noopAuth = defineAuth({ strategy: 'none' });
86
145
  //# sourceMappingURL=index.js.map
@@ -50,6 +50,19 @@ export interface ResolvedConfig {
50
50
  sources: {
51
51
  dotenvFiles: string[];
52
52
  };
53
+ /**
54
+ * The resolved `${VAR}` scope: the dotenv layer merged under `process.env`.
55
+ *
56
+ * Exposed because `loadDotEnvLayer` deliberately does NOT mutate `process.env`
57
+ * (doing so would silently outrank the process layer), so anything that
58
+ * interpolates `${VAR}` outside the config tree — dataset rows, most of all —
59
+ * cannot reach the values in `.env.<env>` unless it is handed this. Without
60
+ * it, the documented pattern of keeping passwords in `.env.<env>` and
61
+ * referencing them from `data/**\/users.csv` resolves to the literal string
62
+ * `${TEST_MEMBER_PASSWORD}`, and the failure surfaces far away as a provider
63
+ * rejecting the credential.
64
+ */
65
+ vars: Record<string, string | undefined>;
53
66
  }
54
67
  export interface ResolveOptions {
55
68
  rootDir: string;
@@ -162,6 +162,7 @@ export function resolveConfig(opts) {
162
162
  runtime: rt,
163
163
  provenance: Object.fromEntries(prov.byPath),
164
164
  sources: { dotenvFiles: dotenv.files },
165
+ vars,
165
166
  };
166
167
  }
167
168
  function markAll(obj, prov, layer, prefix) {