@uxf/scripts 11.123.0 → 11.124.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/src/http.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Minimalistická náhrada axiosu nad globálním `fetch` (Node >= 18).
3
+ *
4
+ * Existuje proto, aby notifiery běžely v CI bez `npm install` — viz
5
+ * `docs/decisions/package-manager.md`. Drží se chování axiosu ve třech věcech,
6
+ * na kterých volající stojí: vyhazuje na non-2xx, parsuje JSON odpověď
7
+ * a zahazuje `undefined` query parametry.
8
+ */
9
+
10
+ /**
11
+ * Chyba HTTP požadavku. `message` má stejný tvar jako u axiosu, protože
12
+ * `GitLab.findMigrationFiles` ho loguje.
13
+ */
14
+ class HttpError extends Error {
15
+ /**
16
+ * @param {number} status
17
+ * @param {string} url
18
+ * @param {any} body
19
+ */
20
+ constructor(status, url, body) {
21
+ super(`Request failed with status code ${status}`);
22
+ this.name = "HttpError";
23
+ this.status = status;
24
+ this.url = url;
25
+ this.body = body;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Poskládá query string a zahodí `undefined`/`null` hodnoty — axios se choval stejně.
31
+ *
32
+ * @param {string} url
33
+ * @param {Record<string, any> | undefined} params
34
+ */
35
+ function withQuery(url, params) {
36
+ const search = new URLSearchParams();
37
+
38
+ for (const [key, value] of Object.entries(params ?? {})) {
39
+ if (value !== undefined && value !== null) {
40
+ search.append(key, String(value));
41
+ }
42
+ }
43
+
44
+ const query = search.toString();
45
+
46
+ if (!query) {
47
+ return url;
48
+ }
49
+
50
+ return `${url}${url.includes("?") ? "&" : "?"}${query}`;
51
+ }
52
+
53
+ /**
54
+ * @param {string} url
55
+ * @param {{baseUrl?: string, method?: string, headers?: Record<string, string>, params?: Record<string, any>, body?: any}} options
56
+ * @returns {Promise<{data: any, headers: Headers, status: number}>}
57
+ */
58
+ async function request(url, options = {}) {
59
+ const { baseUrl = "", method = "GET", headers = {}, params, body } = options;
60
+ const target = withQuery(`${baseUrl}${url}`, params);
61
+
62
+ /** @type {RequestInit} */
63
+ const init = { method, headers: { ...headers } };
64
+
65
+ if (body !== undefined) {
66
+ init.body = JSON.stringify(body);
67
+ init.headers["Content-Type"] = init.headers["Content-Type"] ?? "application/json";
68
+ }
69
+
70
+ const response = await fetch(target, init);
71
+ const text = await response.text();
72
+ const isJson = (response.headers.get("content-type") ?? "").includes("application/json");
73
+ const data = isJson && text ? JSON.parse(text) : text;
74
+
75
+ if (!response.ok) {
76
+ throw new HttpError(response.status, target, data);
77
+ }
78
+
79
+ return { data, headers: response.headers, status: response.status };
80
+ }
81
+
82
+ module.exports = {
83
+ HttpError,
84
+ request,
85
+ withQuery,
86
+ };
@@ -0,0 +1,155 @@
1
+ const { request, withQuery, HttpError } = require("./http");
2
+
3
+ /**
4
+ * Minimální náhrada `Response` — testy nesmí záviset na tom, jestli ji testovací
5
+ * prostředí (jsdom) vystavuje.
6
+ */
7
+ function fakeResponse({ status = 200, body = "", contentType = "application/json" } = {}) {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ headers: { get: (name) => (name.toLowerCase() === "content-type" ? contentType : null) },
12
+ text: async () => body,
13
+ };
14
+ }
15
+
16
+ function stubFetch(response) {
17
+ const calls = [];
18
+ globalThis.fetch = async (url, init) => {
19
+ calls.push({ url, init });
20
+ return typeof response === "function" ? response(url, init) : response;
21
+ };
22
+ return calls;
23
+ }
24
+
25
+ const originalFetch = globalThis.fetch;
26
+
27
+ afterEach(() => {
28
+ globalThis.fetch = originalFetch;
29
+ });
30
+
31
+ describe("withQuery", () => {
32
+ it("returns the url untouched when there are no params", () => {
33
+ expect(withQuery("/projects", undefined)).toBe("/projects");
34
+ expect(withQuery("/projects", {})).toBe("/projects");
35
+ });
36
+
37
+ it("drops undefined and null values", () => {
38
+ expect(withQuery("/mr", { a: "1", b: undefined, c: null, d: "2" })).toBe("/mr?a=1&d=2");
39
+ });
40
+
41
+ it("keeps falsy values that are not undefined or null", () => {
42
+ expect(withQuery("/mr", { page: 0, simple: false, empty: "" })).toBe("/mr?page=0&simple=false&empty=");
43
+ });
44
+
45
+ it("appends with & when the url already has a query string", () => {
46
+ expect(withQuery("/mr?state=open", { page: "2" })).toBe("/mr?state=open&page=2");
47
+ });
48
+
49
+ it("encodes values", () => {
50
+ expect(withQuery("/tags", { search: "^release-" })).toBe("/tags?search=%5Erelease-");
51
+ });
52
+ });
53
+
54
+ describe("request", () => {
55
+ it("joins baseUrl, path and params", async () => {
56
+ const calls = stubFetch(fakeResponse({ body: "{}" }));
57
+
58
+ await request("/projects", { baseUrl: "https://git.test/api/v4", params: { per_page: "100" } });
59
+
60
+ expect(calls[0].url).toBe("https://git.test/api/v4/projects?per_page=100");
61
+ });
62
+
63
+ it("defaults to GET and sends no body", async () => {
64
+ const calls = stubFetch(fakeResponse({ body: "{}" }));
65
+
66
+ await request("/x");
67
+
68
+ expect(calls[0].init.method).toBe("GET");
69
+ expect(calls[0].init.body).toBeUndefined();
70
+ });
71
+
72
+ it("serializes the body as JSON and sets the content type", async () => {
73
+ const calls = stubFetch(fakeResponse({ body: "{}" }));
74
+
75
+ await request("/x", { method: "POST", body: { text: "ahoj" } });
76
+
77
+ expect(calls[0].init.body).toBe('{"text":"ahoj"}');
78
+ expect(calls[0].init.headers["Content-Type"]).toBe("application/json");
79
+ });
80
+
81
+ it("does not override an explicitly provided content type", async () => {
82
+ const calls = stubFetch(fakeResponse({ body: "{}" }));
83
+
84
+ await request("/x", {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json; charset=UTF-8" },
87
+ body: { a: 1 },
88
+ });
89
+
90
+ expect(calls[0].init.headers["Content-Type"]).toBe("application/json; charset=UTF-8");
91
+ });
92
+
93
+ it("passes custom headers through", async () => {
94
+ const calls = stubFetch(fakeResponse({ body: "{}" }));
95
+
96
+ await request("/x", { headers: { Authorization: "Bearer t" } });
97
+
98
+ expect(calls[0].init.headers.Authorization).toBe("Bearer t");
99
+ });
100
+
101
+ it("parses a JSON response", async () => {
102
+ stubFetch(fakeResponse({ body: '{"id":1}' }));
103
+
104
+ const response = await request("/x");
105
+
106
+ expect(response.data).toEqual({ id: 1 });
107
+ expect(response.status).toBe(200);
108
+ });
109
+
110
+ it("returns raw text when the response is not JSON", async () => {
111
+ stubFetch(fakeResponse({ body: "<html></html>", contentType: "text/html" }));
112
+
113
+ const response = await request("/x");
114
+
115
+ expect(response.data).toBe("<html></html>");
116
+ });
117
+
118
+ it("returns an empty string for an empty JSON response instead of throwing", async () => {
119
+ stubFetch(fakeResponse({ body: "" }));
120
+
121
+ const response = await request("/x");
122
+
123
+ expect(response.data).toBe("");
124
+ });
125
+
126
+ it("exposes response headers so callers can paginate", async () => {
127
+ globalThis.fetch = async () => ({
128
+ ok: true,
129
+ status: 200,
130
+ headers: { get: (name) => (name === "x-next-page" ? "2" : "application/json") },
131
+ text: async () => "[]",
132
+ });
133
+
134
+ const response = await request("/x");
135
+
136
+ expect(response.headers.get("x-next-page")).toBe("2");
137
+ });
138
+
139
+ it("throws on a non-2xx status, the way axios did", async () => {
140
+ stubFetch(fakeResponse({ status: 404, body: '{"message":"404 Not found"}' }));
141
+
142
+ await expect(request("/missing")).rejects.toThrow("Request failed with status code 404");
143
+ });
144
+
145
+ it("carries status, url and parsed body on the error", async () => {
146
+ stubFetch(fakeResponse({ status: 500, body: '{"message":"boom"}' }));
147
+
148
+ const error = await request("/x", { baseUrl: "https://git.test" }).catch((e) => e);
149
+
150
+ expect(error).toBeInstanceOf(HttpError);
151
+ expect(error.status).toBe(500);
152
+ expect(error.url).toBe("https://git.test/x");
153
+ expect(error.body).toEqual({ message: "boom" });
154
+ });
155
+ });
package/src/sitemap.js ADDED
@@ -0,0 +1,80 @@
1
+ const cheerio = require("cheerio");
2
+
3
+ const { HTTP_USERNAME, HTTP_PASSWORD } = process.env;
4
+
5
+ // got v15 je ESM-only, proto dynamický import — stejný vzorec jako v uxf-sitemap-check.
6
+ const got = (url, init) => import("got").then((mod) => mod.default(url, init));
7
+
8
+ const REQUEST_TIMEOUT_MS = 20_000;
9
+
10
+ const HEADERS = {
11
+ "User-Agent":
12
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36",
13
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
14
+ "Accept-Language": "en-US,en;q=0.9,cs-CZ;q=0.8,cs;q=0.7,de;q=0.6",
15
+ "Cache-Control": "no-cache",
16
+ Connection: "keep-alive",
17
+ Pragma: "no-cache",
18
+ "Sec-Ch-Ua": '"Google Chrome";v="111", "Not(A:Brand";v="8", "Chromium";v="111"',
19
+ "Sec-Ch-Ua-Arch": '"x86"',
20
+ "Sec-Ch-Ua-Mobile": "?0",
21
+ "Sec-Ch-Ua-Platform": '"Windows"',
22
+ "Sec-Fetch-Dest": "document",
23
+ "Sec-Fetch-Mode": "navigate",
24
+ "Sec-Fetch-Site": "cross-site",
25
+ "Sec-Fetch-User": "?1",
26
+ "Sec-Fetch-User-Agent": "?1",
27
+ };
28
+
29
+ /**
30
+ * Stáhne stránku se stejnou konfigurací, jakou měla původní axios instance:
31
+ * basic auth z env, žádné následování redirectů, 20s timeout a vypnuté ověřování
32
+ * TLS certifikátu (kvůli staging webům se self-signed certy).
33
+ *
34
+ * `https.rejectUnauthorized` je důvod, proč tenhle modul jede na `got` a ne na
35
+ * globálním `fetch` — undici žádnou per-request TLS volbu nenabízí.
36
+ *
37
+ * @param {string} url
38
+ * @returns {Promise<string>} tělo odpovědi
39
+ */
40
+ async function fetchPage(url) {
41
+ const response = await got(url, {
42
+ headers: HEADERS,
43
+ username: HTTP_USERNAME || undefined,
44
+ password: HTTP_PASSWORD || undefined,
45
+ followRedirect: false,
46
+ // BEZPEČNOST: vypnuté ověřování TLS certifikátu. Zděděno z axios verze
47
+ // (`httpsAgent` s `rejectUnauthorized: false`), zachováno kvůli staging webům
48
+ // se self-signed certy. Platí to ale na VŠECHNY URL, včetně produkčních —
49
+ // takže crawler nepozná podvržený certifikát a je náchylný na MITM.
50
+ // Čistší by bylo podmínit to env proměnnou nebo přidat CA do trust storu.
51
+ https: { rejectUnauthorized: false },
52
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
53
+ });
54
+
55
+ // got s `followRedirect: false` vrací 3xx jako úspěch s prázdným tělem, kdežto
56
+ // axios s `maxRedirects: 0` na něj vyhazoval. Bez tohohle by přesměrované URL
57
+ // tiše propadly do exportu jako řádek s prázdným titulkem.
58
+ if (response.statusCode >= 300) {
59
+ throw new Error(`Request failed with status code ${response.statusCode}`);
60
+ }
61
+
62
+ return response.body;
63
+ }
64
+
65
+ async function getSitemap(xml) {
66
+ const $ = cheerio.load(await fetchPage(xml), { xmlMode: true });
67
+
68
+ const urls = [];
69
+
70
+ $("loc").each(function () {
71
+ urls.push($(this).text());
72
+ });
73
+
74
+ return urls;
75
+ }
76
+
77
+ module.exports = {
78
+ getSitemap,
79
+ fetchPage,
80
+ };
@@ -0,0 +1,64 @@
1
+ const mockGot = jest.fn();
2
+
3
+ jest.mock("got", () => ({ __esModule: true, default: (...args) => mockGot(...args) }));
4
+ // cheerio je ESM-only a jest ho neprochází (viz transformIgnorePatterns v jest.config.js),
5
+ // proto je mockované — stejně jako v uxf-sitemap-check/index.test.js. Parsování sitemapy
6
+ // v `getSitemap` tím pádem testovat nejde; testy tu pokrývají `fetchPage`, tedy to,
7
+ // co se převodem z axiosu na got mohlo rozejít.
8
+ jest.mock("cheerio", () => ({ load: jest.fn() }));
9
+
10
+ const Sitemap = require("./sitemap");
11
+
12
+ function response({ statusCode = 200, body = "" } = {}) {
13
+ return { statusCode, body, headers: {} };
14
+ }
15
+
16
+ afterEach(() => {
17
+ mockGot.mockReset();
18
+ });
19
+
20
+ describe("fetchPage", () => {
21
+ it("returns the response body", async () => {
22
+ mockGot.mockResolvedValue(response({ body: "<html>ok</html>" }));
23
+
24
+ await expect(Sitemap.fetchPage("https://x.test/a")).resolves.toBe("<html>ok</html>");
25
+ });
26
+
27
+ it("does not follow redirects and skips TLS verification", async () => {
28
+ mockGot.mockResolvedValue(response({ body: "" }));
29
+
30
+ await Sitemap.fetchPage("https://x.test/a");
31
+
32
+ const [, options] = mockGot.mock.calls[0];
33
+ expect(options.followRedirect).toBe(false);
34
+ expect(options.https).toEqual({ rejectUnauthorized: false });
35
+ expect(options.signal).toBeInstanceOf(AbortSignal);
36
+ });
37
+
38
+ // got s `followRedirect: false` vrací 3xx jako úspěch s prázdným tělem, zatímco
39
+ // axios s `maxRedirects: 0` vyhazoval. Bez tohohle by přesměrované URL propadly
40
+ // do meta exportu jako řádek s prázdným titulkem.
41
+ it("throws on a redirect instead of returning an empty body", async () => {
42
+ mockGot.mockResolvedValue(response({ statusCode: 302, body: "" }));
43
+
44
+ await expect(Sitemap.fetchPage("https://x.test/redirect")).rejects.toThrow(
45
+ "Request failed with status code 302",
46
+ );
47
+ });
48
+
49
+ it("passes the requested url through", async () => {
50
+ mockGot.mockResolvedValue(response());
51
+
52
+ await Sitemap.fetchPage("https://x.test/a");
53
+
54
+ expect(mockGot.mock.calls[0][0]).toBe("https://x.test/a");
55
+ });
56
+
57
+ it("sends a browser-like user agent", async () => {
58
+ mockGot.mockResolvedValue(response());
59
+
60
+ await Sitemap.fetchPage("https://x.test/a");
61
+
62
+ expect(mockGot.mock.calls[0][1].headers["User-Agent"]).toContain("Mozilla/5.0");
63
+ });
64
+ });
@@ -1,12 +1,7 @@
1
1
  const { env } = require("process");
2
- const { create } = require("axios");
2
+ const { request } = require("./http");
3
3
 
4
- const axios = create({
5
- baseURL: "https://slack.com/api",
6
- headers: {
7
- Authorization: `Bearer ${env.SLACK_TOKEN}`,
8
- },
9
- });
4
+ const BASE_URL = "https://slack.com/api";
10
5
 
11
6
  /**
12
7
  * @see https://api.slack.com/methods/chat.postMessage
@@ -14,7 +9,13 @@ const axios = create({
14
9
  */
15
10
  async function chatPostMessage(channel, data, dryRun = false) {
16
11
  if (env.SLACK_TOKEN && !dryRun) {
17
- const res = await axios.post("/chat.postMessage", { ...data, channel });
12
+ // Slack vrací HTTP 200 i pro chyby API, rozlišuje je až polem `ok`.
13
+ const res = await request("/chat.postMessage", {
14
+ baseUrl: BASE_URL,
15
+ method: "POST",
16
+ headers: { Authorization: `Bearer ${env.SLACK_TOKEN}` },
17
+ body: { ...data, channel },
18
+ });
18
19
  if (res.data.ok === false) {
19
20
  process.stdout.write("SLACK: chat.postMessage error - " + JSON.stringify(res.data));
20
21
  return;
@@ -0,0 +1,81 @@
1
+ const Slack = require("./slack");
2
+
3
+ const originalFetch = globalThis.fetch;
4
+ const { SLACK_TOKEN } = process.env;
5
+
6
+ function stubFetch(payload = { ok: true }) {
7
+ const calls = [];
8
+ globalThis.fetch = async (url, init) => {
9
+ calls.push({ url, init });
10
+ return {
11
+ ok: true,
12
+ status: 200,
13
+ headers: { get: () => "application/json" },
14
+ text: async () => JSON.stringify(payload),
15
+ };
16
+ };
17
+ return calls;
18
+ }
19
+
20
+ beforeEach(() => {
21
+ process.env.SLACK_TOKEN = "xoxb-test";
22
+ jest.spyOn(process.stdout, "write").mockImplementation(() => true);
23
+ });
24
+
25
+ afterEach(() => {
26
+ globalThis.fetch = originalFetch;
27
+ process.env.SLACK_TOKEN = SLACK_TOKEN;
28
+ jest.restoreAllMocks();
29
+ });
30
+
31
+ describe("chatPostMessage", () => {
32
+ it("posts to the Slack API with the bearer token and the channel merged in", async () => {
33
+ const calls = stubFetch();
34
+
35
+ await Slack.chatPostMessage("#releases", { text: "ahoj" });
36
+
37
+ expect(calls[0].url).toBe("https://slack.com/api/chat.postMessage");
38
+ expect(calls[0].init.method).toBe("POST");
39
+ expect(calls[0].init.headers.Authorization).toBe("Bearer xoxb-test");
40
+ expect(JSON.parse(calls[0].init.body)).toEqual({ text: "ahoj", channel: "#releases" });
41
+ });
42
+
43
+ it("reports a done message on success", async () => {
44
+ stubFetch({ ok: true });
45
+
46
+ await Slack.chatPostMessage("#releases", { text: "ahoj" });
47
+
48
+ expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - done\n");
49
+ });
50
+
51
+ // Slack vrací HTTP 200 i pro chyby API, rozlišuje je až polem `ok` — proto se
52
+ // na to nedá spolehnout na status kód.
53
+ it("reports an error when Slack answers ok:false despite HTTP 200", async () => {
54
+ stubFetch({ ok: false, error: "channel_not_found" });
55
+
56
+ await Slack.chatPostMessage("#nope", { text: "ahoj" });
57
+
58
+ expect(process.stdout.write).toHaveBeenCalledWith(
59
+ 'SLACK: chat.postMessage error - {"ok":false,"error":"channel_not_found"}',
60
+ );
61
+ });
62
+
63
+ it("skips when there is no token", async () => {
64
+ delete process.env.SLACK_TOKEN;
65
+ const calls = stubFetch();
66
+
67
+ await Slack.chatPostMessage("#releases", { text: "ahoj" });
68
+
69
+ expect(calls).toHaveLength(0);
70
+ expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - skipped\n");
71
+ });
72
+
73
+ it("skips in dry run mode", async () => {
74
+ const calls = stubFetch();
75
+
76
+ await Slack.chatPostMessage("#releases", { text: "ahoj" }, true);
77
+
78
+ expect(calls).toHaveLength(0);
79
+ expect(process.stdout.write).toHaveBeenCalledWith("SLACK: chat.postMessage - skipped\n");
80
+ });
81
+ });
@@ -0,0 +1,102 @@
1
+ const { execFileSync } = require("child_process");
2
+ const path = require("path");
3
+
4
+ // 256 MB. `rev-dep`'s own `bin.js` wrapper shells out with `execSync` and no `maxBuffer`, so it
5
+ // silently truncates at Node's 1 MB default and still exits 0 — we resolve the platform binary
6
+ // ourselves to avoid that.
7
+ const MAX_BUFFER = 256 * 1024 * 1024;
8
+
9
+ function resolveRevDepBinary() {
10
+ const suffix = process.platform === "win32" ? ".exe" : "";
11
+ const request = `@rev-dep/${process.platform}-${process.arch}/bin/rev-dep${suffix}`;
12
+
13
+ try {
14
+ return require.resolve(request, { paths: [__dirname] });
15
+ } catch {
16
+ throw new Error(
17
+ `Could not locate the rev-dep binary for your platform (${request}). ` +
18
+ `It ships as an optionalDependency of rev-dep — reinstall without --omit=optional.`,
19
+ );
20
+ }
21
+ }
22
+
23
+ function runRevDep(cwd, tsConfig) {
24
+ const args = ["debug", "get-tree-for-cwd", "--cwd", cwd];
25
+
26
+ if (tsConfig) {
27
+ args.push("--tsconfig-json", tsConfig);
28
+ }
29
+
30
+ try {
31
+ return execFileSync(resolveRevDepBinary(), args, { encoding: "utf8", maxBuffer: MAX_BUFFER });
32
+ } catch (error) {
33
+ const details = error.stderr ? `\n${error.stderr}` : "";
34
+ throw new Error(`rev-dep failed to build the dependency tree for ${cwd}.${details}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Turns rev-dep's tree into the shape madge's `res.obj()` returned, so the callers' traversal
40
+ * keeps working unchanged:
41
+ *
42
+ * { "<cwd-relative file>": ["<cwd-relative dep>" | "<bare specifier>", ...] }
43
+ *
44
+ * Imports that rev-dep could not resolve to a project file (node modules, unresolved requests)
45
+ * are kept as their raw specifier — callers resolve those themselves via `require.resolve`,
46
+ * which is how `node_modules/@uxf/**` gets picked up.
47
+ *
48
+ * @param {string} cwd
49
+ * @param {{ tsConfig?: string, fileExtensions?: string[], include?: string[] }} options
50
+ * @returns {Record<string, string[]>}
51
+ */
52
+ function buildDependencyTree(cwd, options = {}) {
53
+ const { tsConfig, fileExtensions, include } = options;
54
+
55
+ const graph = JSON.parse(runRevDep(cwd, tsConfig));
56
+
57
+ const toRelative = (filePath) => (path.isAbsolute(filePath) ? path.relative(cwd, filePath) : filePath);
58
+
59
+ const extensions = fileExtensions?.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
60
+
61
+ // `node_modules` is deliberately left to the callers' own resolution — rev-dep does not
62
+ // traverse into it, and it is the one place where the raw specifiers above are needed.
63
+ const isOutOfScope = (relativePath) =>
64
+ relativePath.startsWith("node_modules") || relativePath.startsWith("..") || path.isAbsolute(relativePath);
65
+
66
+ // `include` used to be madge's `excludeRegExp`: keep only files under the given prefixes.
67
+ const isIncluded = (relativePath) => !include?.length || include.some((prefix) => relativePath.startsWith(prefix));
68
+
69
+ const hasAllowedExtension = (relativePath) =>
70
+ !extensions || extensions.some((extension) => relativePath.endsWith(extension));
71
+
72
+ const isProjectFile = (relativePath) =>
73
+ !isOutOfScope(relativePath) && isIncluded(relativePath) && hasAllowedExtension(relativePath);
74
+
75
+ const tree = {};
76
+
77
+ for (const [file, dependencies] of Object.entries(graph)) {
78
+ const relativeFile = toRelative(file);
79
+
80
+ if (!isProjectFile(relativeFile)) {
81
+ continue;
82
+ }
83
+
84
+ tree[relativeFile] = (dependencies ?? [])
85
+ .map((dependency) => {
86
+ if (dependency.resolvedTypeLabel !== "UserModule" || !dependency.id) {
87
+ // node module or unresolved — hand the specifier over untouched
88
+ return dependency.request;
89
+ }
90
+
91
+ const relativeDependency = toRelative(dependency.id);
92
+
93
+ return isProjectFile(relativeDependency) ? relativeDependency : undefined;
94
+ })
95
+ .filter((dependency) => typeof dependency === "string" && dependency.length > 0);
96
+ }
97
+
98
+ return tree;
99
+ }
100
+
101
+ module.exports = buildDependencyTree;
102
+ module.exports.resolveRevDepBinary = resolveRevDepBinary;
@@ -0,0 +1,46 @@
1
+ /** @jest-environment node */
2
+ const path = require("path");
3
+ const buildDependencyTree = require("./dependency-tree");
4
+
5
+ const FIXTURE_CWD = path.join(__dirname, "tests");
6
+
7
+ describe("buildDependencyTree", () => {
8
+ const tree = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts", "tsx"] });
9
+
10
+ it("keys the tree by cwd-relative paths", () => {
11
+ expect(Object.keys(tree)).toEqual(expect.arrayContaining(["pages/page-a.tsx", "utils.tsx"]));
12
+ });
13
+
14
+ it("resolves relative imports to project files", () => {
15
+ expect(tree["pages/page-a.tsx"]).toEqual(
16
+ expect.arrayContaining(["components/no-index-file.tsx", "components/with-index-file/index.ts"]),
17
+ );
18
+ });
19
+
20
+ it("resolves a directory import to its index file", () => {
21
+ expect(tree["components/with-index-file/index.ts"]).toEqual(["components/with-index-file/with-index-file.tsx"]);
22
+ });
23
+
24
+ it("keeps unresolved node module imports as raw specifiers", () => {
25
+ // callers resolve these themselves — that is how node_modules/@uxf/** gets picked up
26
+ expect(tree["components/with-index-file/with-index-file.tsx"]).toEqual(
27
+ expect.arrayContaining(["@uxf/ui/chip", "@uxf/core/utils/noop"]),
28
+ );
29
+ });
30
+
31
+ it("does not emit node_modules files as tree keys", () => {
32
+ expect(Object.keys(tree).filter((file) => file.startsWith("node_modules"))).toEqual([]);
33
+ });
34
+
35
+ it("honours the include filter", () => {
36
+ const filtered = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts", "tsx"], include: ["pages"] });
37
+
38
+ expect(Object.keys(filtered).every((file) => file.startsWith("pages"))).toBe(true);
39
+ });
40
+
41
+ it("honours the fileExtensions filter", () => {
42
+ const filtered = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts"] });
43
+
44
+ expect(Object.keys(filtered).every((file) => file.endsWith(".ts"))).toBe(true);
45
+ });
46
+ });