@akanjs/devkit 2.4.1-rc.1 → 2.4.1-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -39,6 +39,7 @@ export type * from "./getRelatedCnsts";
39
39
  export type * from "./guideline";
40
40
  export type * from "./incrementalBuilder";
41
41
  export type * from "./mobile";
42
+ export type * from "./packageExportsMap";
42
43
  export type * from "./prompter";
43
44
  export type * from "./qualityScanner";
44
45
  export type * from "./scanInfo";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1-rc.1",
3
+ "version": "2.4.1-rc.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.1-rc.1",
47
+ "akanjs": "2.4.1-rc.2",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PackageExportsMap } from "@akanjs/devkit/packageExportsMap";
5
+
6
+ // Guards the published shape of this package, which the monorepo cannot exercise on its own.
7
+ //
8
+ // Inside the monorepo Bun resolves `@akanjs/devkit/executors` through the root tsconfig `paths`
9
+ // (`@akanjs/devkit/*` -> `pkgs/@akanjs/devkit/*`), and that resolver probes extensions and directory
10
+ // indexes. A published consumer has no such mapping: it goes through this package's `exports` map,
11
+ // whose targets are matched *exactly*. So `"./*": "./*"` type-checked, built, and passed every test
12
+ // here while every subpath import failed at runtime for anyone installing the tarball:
13
+ //
14
+ // error: Cannot find module '@akanjs/devkit/executors' from
15
+ // '<consumer>/node_modules/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts'
16
+ //
17
+ // `PackageRunner.verifyDistPackage` runs the same check against the built dist tree of every
18
+ // publishable package at release time; these tests keep this package honest on every run.
19
+
20
+ const packageDir = import.meta.dir;
21
+ const exportsMap = await PackageExportsMap.from(packageDir);
22
+
23
+ /** Every facet the root barrel re-exports, as the subpath a consumer would import. */
24
+ const barrelFacets = async (): Promise<string[]> => {
25
+ const barrel = await Bun.file(path.join(packageDir, "index.ts")).text();
26
+ return [...barrel.matchAll(/^export (?:type )?\* from "\.\/([^"]+)";$/gm)].map((match) => `./${match[1]}`);
27
+ };
28
+
29
+ /** Every `@akanjs/devkit/<subpath>` specifier written anywhere in the two packages that use them. */
30
+ const importedSubpaths = async (): Promise<string[]> => {
31
+ const repoRoot = path.resolve(packageDir, "../../..");
32
+ const glob = new Bun.Glob("pkgs/@akanjs/{cli,devkit}/**/*.{ts,tsx}");
33
+ const found = new Set<string>();
34
+ for await (const relative of glob.scan({ cwd: repoRoot })) {
35
+ if (relative.includes("node_modules/") || relative.includes("/dist/")) continue;
36
+ const source = await Bun.file(path.join(repoRoot, relative)).text();
37
+ for (const match of source.matchAll(/"@akanjs\/devkit\/([a-zA-Z0-9_./-]+)"/g)) found.add(`./${match[1]}`);
38
+ }
39
+ return [...found].sort();
40
+ };
41
+
42
+ describe("published exports map", () => {
43
+ test("resolves every facet the root barrel re-exports", async () => {
44
+ const facets = await barrelFacets();
45
+ expect(facets.length).toBeGreaterThan(30);
46
+ expect(exportsMap.findUnreachable(facets)).toEqual([]);
47
+ });
48
+
49
+ test("resolves every subpath the monorepo actually imports", async () => {
50
+ const subpaths = await importedSubpaths();
51
+ expect(subpaths.length).toBeGreaterThan(20);
52
+ expect(exportsMap.findUnreachable(subpaths)).toEqual([]);
53
+ });
54
+
55
+ test("covers both facet shapes and keeps explicit extensions intact", () => {
56
+ // A single wildcard cannot serve all three: `./*` -> `./*.ts` reaches bare files, directory
57
+ // facets need their own literal entry, and `./*.ts` -> `./*.ts` keeps an already-suffixed
58
+ // specifier from becoming `./cloud/cloudApi.ts.ts`.
59
+ expect(exportsMap.resolve("./executors")).toBe("./executors.ts");
60
+ expect(exportsMap.resolve("./frontendBuild")).toBe("./frontendBuild/index.ts");
61
+ expect(exportsMap.resolve("./cloud/cloudApi.ts")).toBe("./cloud/cloudApi.ts");
62
+ expect(exportsMap.resolve("./package.json")).toBe("./package.json");
63
+ });
64
+
65
+ test("every directory facet has a literal entry, since the wildcard cannot probe index.ts", async () => {
66
+ const facets = await barrelFacets();
67
+ const missing = facets.filter(
68
+ (subpath) =>
69
+ existsSync(path.join(packageDir, subpath, "index.ts")) && exportsMap.resolve(subpath) !== `${subpath}/index.ts`,
70
+ );
71
+ expect(missing).toEqual([]);
72
+ });
73
+ });
@@ -0,0 +1,96 @@
1
+ import { statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ export interface UnreachableSubpath {
5
+ subpath: string;
6
+ /** The path the `exports` map yields, or null when no entry matches the subpath at all. */
7
+ target: string | null;
8
+ }
9
+
10
+ /**
11
+ * The subset of Node's `exports` resolution that Bun applies to a published package.
12
+ *
13
+ * Exports targets are matched **exactly**: no extension is appended and no `index.ts` is probed. That
14
+ * is invisible inside this monorepo, where `@akanjs/devkit/*` and `akanjs/*` resolve through the root
15
+ * tsconfig `paths` instead — a resolver that *does* probe both. A map of `{"./*": "./*"}` therefore
16
+ * type-checks, builds, and passes every test while every subpath import fails for anyone who installs
17
+ * the tarball. This class exists so that gap can be asserted against before publishing.
18
+ */
19
+ export class PackageExportsMap {
20
+ /** Reads `<packageDir>/package.json` and builds the map from its `exports` field. */
21
+ static async from(packageDir: string) {
22
+ const manifest = (await Bun.file(path.join(packageDir, "package.json")).json()) as { exports?: unknown };
23
+ return new PackageExportsMap(packageDir, manifest.exports);
24
+ }
25
+ /** Picks the target a runtime import would follow, walking conditional objects in Bun's order. */
26
+ static #runtimeTargetOf(value: unknown): string | null {
27
+ if (typeof value === "string") return value;
28
+ if (Array.isArray(value)) {
29
+ // Bun takes the first entry and stops; it does not fall through on a missing file.
30
+ for (const entry of value) {
31
+ const target = PackageExportsMap.#runtimeTargetOf(entry);
32
+ if (target) return target;
33
+ }
34
+ return null;
35
+ }
36
+ if (!value || typeof value !== "object") return null;
37
+ const conditions = value as Record<string, unknown>;
38
+ for (const condition of ["bun", "import", "default", "require", "types"]) {
39
+ if (!(condition in conditions)) continue;
40
+ const target = PackageExportsMap.#runtimeTargetOf(conditions[condition]);
41
+ if (target) return target;
42
+ }
43
+ return null;
44
+ }
45
+ #packageDir: string;
46
+ #literals = new Map<string, string>();
47
+ #patterns: { prefix: string; suffix: string; target: string }[] = [];
48
+ constructor(packageDir: string, exportsField: unknown) {
49
+ this.#packageDir = packageDir;
50
+ if (!exportsField || typeof exportsField !== "object") return;
51
+ for (const [key, value] of Object.entries(exportsField as Record<string, unknown>)) {
52
+ if (!key.startsWith(".")) continue; // a bare conditional map has no subpaths to check
53
+ const target = PackageExportsMap.#runtimeTargetOf(value);
54
+ if (!target) continue;
55
+ const star = key.indexOf("*");
56
+ if (star === -1) this.#literals.set(key, target);
57
+ else this.#patterns.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), target });
58
+ }
59
+ // Node picks the most specific pattern: longest prefix first, then longest suffix. So `./*.ts`
60
+ // wins over `./*`, which is what keeps an already-suffixed specifier from gaining a second `.ts`.
61
+ this.#patterns.sort((a, b) => b.prefix.length - a.prefix.length || b.suffix.length - a.suffix.length);
62
+ }
63
+ /** Returns the target an `exports` lookup yields, or null when the subpath is unexported. */
64
+ resolve(subpath: string): string | null {
65
+ const literal = this.#literals.get(subpath);
66
+ if (literal) return literal;
67
+ for (const { prefix, suffix, target } of this.#patterns) {
68
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue;
69
+ if (subpath.length < prefix.length + suffix.length) continue;
70
+ return target.replace("*", subpath.slice(prefix.length, subpath.length - suffix.length));
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Resolves a subpath and reports whether the target it yields is a readable file.
76
+ *
77
+ * A directory does not count. `{"./*": "./*"}` maps `./commandDecorators` onto the directory of
78
+ * that name, which exists but is not a module — an `existsSync` check here reports such a subpath
79
+ * as reachable while the import still fails.
80
+ */
81
+ resolveToFile(subpath: string): { target: string | null; exists: boolean } {
82
+ const target = this.resolve(subpath);
83
+ if (!target) return { target: null, exists: false };
84
+ const stat = statSync(path.join(this.#packageDir, target), { throwIfNoEntry: false });
85
+ return { target, exists: !!stat?.isFile() };
86
+ }
87
+ /** Returns the given subpaths that no consumer could import, in input order. */
88
+ findUnreachable(subpaths: Iterable<string>): UnreachableSubpath[] {
89
+ const unreachable: UnreachableSubpath[] = [];
90
+ for (const subpath of subpaths) {
91
+ const { target, exists } = this.resolveToFile(subpath);
92
+ if (!exists) unreachable.push({ subpath, target });
93
+ }
94
+ return unreachable;
95
+ }
96
+ }