@sdods/core 0.2.2 → 0.3.1

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 (57) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/detectors.js +236 -24
  3. package/dist/analyze/index.d.ts +0 -1
  4. package/dist/analyze/index.js +0 -1
  5. package/dist/analyze/propose.js +80 -38
  6. package/dist/analyze/scan.js +19 -1
  7. package/dist/api/client.js +7 -1
  8. package/dist/auth/capture.js +19 -6
  9. package/dist/auth/index.js +23 -2
  10. package/dist/config/resolve.d.ts +13 -0
  11. package/dist/config/resolve.js +1 -0
  12. package/dist/config/runner.js +2 -2
  13. package/dist/config/tags.d.ts +29 -1
  14. package/dist/config/tags.js +46 -0
  15. package/dist/data/provider.js +5 -1
  16. package/dist/data/user-pool.js +35 -3
  17. package/dist/fixtures/api-context.d.ts +14 -1
  18. package/dist/fixtures/api-context.js +13 -0
  19. package/dist/fixtures/scenario.js +1 -4
  20. package/dist/fixtures/test.js +42 -1
  21. package/dist/fixtures/types.d.ts +2 -0
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/reporters/dashboard.d.ts +86 -0
  25. package/dist/reporters/dashboard.js +319 -61
  26. package/dist/shots/hooks.js +0 -10
  27. package/dist/steps/a11y.steps.d.ts +180 -0
  28. package/dist/steps/a11y.steps.js +598 -0
  29. package/dist/steps/api.steps.js +5 -1
  30. package/dist/steps/browser.steps.d.ts +27 -0
  31. package/dist/steps/browser.steps.js +653 -0
  32. package/dist/steps/clock.steps.d.ts +4 -0
  33. package/dist/steps/clock.steps.js +73 -0
  34. package/dist/steps/data.steps.js +50 -2
  35. package/dist/steps/db.steps.d.ts +5 -0
  36. package/dist/steps/db.steps.js +105 -0
  37. package/dist/steps/dom.steps.d.ts +2 -0
  38. package/dist/steps/dom.steps.js +583 -0
  39. package/dist/steps/glob.d.ts +16 -1
  40. package/dist/steps/glob.js +40 -1
  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 +11 -1
  44. package/dist/steps/index.js +11 -1
  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/package.json +3 -4
  54. package/dist/analyze/modules.d.ts +0 -74
  55. package/dist/analyze/modules.js +0 -353
  56. package/dist/config/playwright.d.ts +0 -37
  57. package/dist/config/playwright.js +0 -262
@@ -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 } };
@@ -3,6 +3,5 @@ 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';
7
6
  export * from './detectors.js';
8
7
  //# sourceMappingURL=index.d.ts.map
@@ -3,6 +3,5 @@ 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';
7
6
  export * from './detectors.js';
8
7
  //# sourceMappingURL=index.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';
4
3
  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()
@@ -10,19 +10,48 @@ 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';
48
+ }
49
+ function moduleOfPath(path) {
50
+ const first = path.split('/').filter(Boolean)[0];
51
+ if (!first || first.startsWith('{'))
52
+ return 'home';
53
+ const clean = first.replace(/[^a-zA-Z0-9]+/g, '-').toLowerCase();
54
+ return RESERVED_SEGMENTS.has(clean) ? 'core' : clean;
26
55
  }
27
56
  function pathToConcrete(path) {
28
57
  return path.replace(/\{[^}]+\}/g, '1');
@@ -69,26 +98,48 @@ export function proposeProject(report, opts = {}) {
69
98
  }
70
99
  if (hasUi && !routes.home)
71
100
  routes.home = '/';
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);
101
+ // modules: UI by first path segment, API by openapi tag or first segment
79
102
  const modules = new Map();
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
- });
103
+ const ensure = (m, layer) => {
104
+ const key = slugify(m);
105
+ const mod = modules.get(key) ?? {
106
+ name: key,
107
+ layers: [],
108
+ routes: [],
109
+ endpoints: [],
110
+ testingTypes: ['functional', 'smoke', 'regression'],
111
+ tags: [`@${key}`],
112
+ };
113
+ if (!mod.layers.includes(layer))
114
+ mod.layers.push(layer);
115
+ modules.set(key, mod);
116
+ return mod;
117
+ };
118
+ for (const [key, path] of Object.entries(routes)) {
119
+ const mod = ensure(moduleOfPath(path), 'ui');
120
+ if (!mod.routes.includes(key))
121
+ mod.routes.push(key);
122
+ }
123
+ const endpointSet = new Set();
124
+ for (const e of openapiEndpoints) {
125
+ const mod = ensure(e.tag ?? moduleOfPath(e.path), 'api');
126
+ if (!mod.endpoints.includes(e.path))
127
+ mod.endpoints.push(e.path);
128
+ if (!mod.testingTypes.includes('contract'))
129
+ mod.testingTypes.push('contract');
130
+ endpointSet.add(e.path);
90
131
  }
91
- const endpointSet = new Set(detected.modules.flatMap((m) => m.endpoints));
132
+ for (const r of apiRoutes) {
133
+ if (endpointSet.has(r.path))
134
+ continue;
135
+ const mod = ensure(moduleOfPath(r.path.replace(/^\/api\//, '/')), 'api');
136
+ if (!mod.endpoints.includes(r.path))
137
+ mod.endpoints.push(r.path);
138
+ endpointSet.add(r.path);
139
+ }
140
+ const authModule = [...modules.values()].find((m) => /auth|login|account|session/.test(m.name));
141
+ if (authModule && !authModule.testingTypes.includes('data-driven'))
142
+ authModule.testingTypes.push('data-driven');
92
143
  // envs
93
144
  const detectedEnvNames = [
94
145
  ...new Set(report.envs.map((e) => e.name).filter((n) => n !== 'example')),
@@ -254,15 +305,6 @@ export function proposeProject(report, opts = {}) {
254
305
  coverageMap,
255
306
  checklist,
256
307
  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,
266
308
  };
267
309
  }
268
310
  function titleCase(s) {
@@ -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,7 +152,7 @@ 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);
@@ -153,6 +161,11 @@ export async function captureAuth(opts) {
153
161
  result.tokenFile = file;
154
162
  }
155
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
+ }
156
169
  results.push(result);
157
170
  }
158
171
  }