@sdods/core 0.2.2 → 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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/analyze/detectors.js +236 -24
- package/dist/analyze/index.d.ts +0 -1
- package/dist/analyze/index.js +0 -1
- package/dist/analyze/propose.js +80 -38
- package/dist/analyze/scan.js +19 -1
- package/dist/api/client.js +7 -1
- package/dist/auth/capture.js +19 -6
- package/dist/auth/index.js +23 -2
- package/dist/config/resolve.d.ts +13 -0
- package/dist/config/resolve.js +1 -0
- package/dist/config/tags.d.ts +29 -1
- package/dist/config/tags.js +46 -0
- package/dist/data/provider.js +5 -1
- package/dist/data/user-pool.js +35 -3
- package/dist/fixtures/api-context.d.ts +14 -1
- package/dist/fixtures/api-context.js +13 -0
- package/dist/fixtures/scenario.js +1 -4
- package/dist/fixtures/test.js +42 -1
- package/dist/fixtures/types.d.ts +2 -0
- package/dist/reporters/dashboard.d.ts +86 -0
- package/dist/reporters/dashboard.js +319 -61
- package/dist/shots/hooks.js +0 -10
- package/dist/steps/a11y.steps.d.ts +180 -0
- package/dist/steps/a11y.steps.js +598 -0
- package/dist/steps/api.steps.js +5 -1
- package/dist/steps/browser.steps.d.ts +27 -0
- package/dist/steps/browser.steps.js +653 -0
- package/dist/steps/clock.steps.d.ts +4 -0
- package/dist/steps/clock.steps.js +73 -0
- package/dist/steps/data.steps.js +50 -2
- package/dist/steps/db.steps.d.ts +5 -0
- package/dist/steps/db.steps.js +105 -0
- package/dist/steps/dom.steps.d.ts +2 -0
- package/dist/steps/dom.steps.js +583 -0
- package/dist/steps/iframe.steps.d.ts +2 -0
- package/dist/steps/iframe.steps.js +93 -0
- package/dist/steps/index.d.ts +10 -0
- package/dist/steps/index.js +10 -0
- package/dist/steps/net.steps.d.ts +63 -0
- package/dist/steps/net.steps.js +728 -0
- package/dist/steps/perf.steps.d.ts +248 -0
- package/dist/steps/perf.steps.js +514 -0
- package/dist/steps/tabs.steps.d.ts +5 -0
- package/dist/steps/tabs.steps.js +109 -0
- package/dist/steps/webhook.steps.d.ts +46 -0
- package/dist/steps/webhook.steps.js +129 -0
- package/package.json +3 -4
- package/dist/analyze/modules.d.ts +0 -74
- package/dist/analyze/modules.js +0 -353
- package/dist/config/playwright.d.ts +0 -37
- package/dist/config/playwright.js +0 -262
|
@@ -1,10 +1,54 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
for
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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 } };
|
package/dist/analyze/index.d.ts
CHANGED
|
@@ -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
|
package/dist/analyze/index.js
CHANGED
|
@@ -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
|
package/dist/analyze/propose.js
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
.
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
routes: [
|
|
86
|
-
endpoints:
|
|
87
|
-
testingTypes:
|
|
88
|
-
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
|
|
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) {
|
package/dist/analyze/scan.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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}`;
|
package/dist/auth/capture.js
CHANGED
|
@@ -111,14 +111,22 @@ export async function captureAuth(opts) {
|
|
|
111
111
|
results.push(result);
|
|
112
112
|
continue;
|
|
113
113
|
}
|
|
114
|
-
|
|
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
|
-
|
|
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
|
}
|