@shipfox/client-shell 0.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/dist/tsconfig.test.tsbuildinfo +1 -1
  4. package/dist/vite/evaluate-features.d.ts +0 -1
  5. package/dist/vite/evaluate-features.d.ts.map +1 -1
  6. package/dist/vite/evaluate-features.js +60 -47
  7. package/dist/vite/evaluate-features.js.map +1 -1
  8. package/dist/vite/generate.js +2 -2
  9. package/dist/vite/generate.js.map +1 -1
  10. package/dist/vite/plugin.d.ts.map +1 -1
  11. package/dist/vite/plugin.js +3 -12
  12. package/dist/vite/plugin.js.map +1 -1
  13. package/package.json +28 -49
  14. package/src/vite/evaluate-features.test.ts +58 -3
  15. package/src/vite/evaluate-features.ts +71 -50
  16. package/src/vite/generate.ts +2 -2
  17. package/src/vite/plugin.test.ts +59 -9
  18. package/src/vite/plugin.ts +3 -16
  19. package/test/external/FINDINGS.md +33 -45
  20. package/test/external/README.md +21 -23
  21. package/test/external/fixture/package.json +1 -2
  22. package/test/external/fixture/src/app.fixture.tsx +78 -46
  23. package/test/external/fixture/src/features/external-settings.tsx +19 -0
  24. package/test/external/fixture/src/features/login-override.tsx +9 -0
  25. package/test/external/fixture/src/features.collision.ts +5 -5
  26. package/test/external/fixture/src/features.ts +49 -3
  27. package/test/external/fixture/src/link-typecheck.tsx +5 -5
  28. package/test/external/fixture/src/main.tsx +83 -1
  29. package/test/external/fixture/src/provider.ts +45 -0
  30. package/test/external/fixture/tsconfig.json +1 -1
  31. package/test/external/fixture/vite.config.ts +13 -2
  32. package/test/external/fixture/vitest.config.ts +3 -1
  33. package/test/external/verify.mjs +217 -47
  34. package/test/typecheck/shipfox-app.gen.ts +6 -6
  35. package/test/typecheck/types.tsx +6 -1
  36. package/tsconfig.build.tsbuildinfo +1 -1
  37. package/test/external/fixture/src/features/override-impl.tsx +0 -12
  38. package/test/external/fixture/src/features/override.tsx +0 -20
  39. package/test/external/toy-feature/CHANGELOG.md +0 -15
  40. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +0 -43
  41. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +0 -43
  42. package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +0 -43
  43. package/test/external/toy-feature/node_modules/.bin/shipfox-swc +0 -43
  44. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +0 -43
  45. package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +0 -43
  46. package/test/external/toy-feature/package.json +0 -57
  47. package/test/external/toy-feature/src/config.ts +0 -5
  48. package/test/external/toy-feature/src/index.ts +0 -64
  49. package/test/external/toy-feature/src/provider.tsx +0 -33
  50. package/test/external/toy-feature/src/routes/insights.tsx +0 -7
  51. package/test/external/toy-feature/src/routes/settings.tsx +0 -7
  52. package/test/external/toy-feature/tsconfig.build.json +0 -5
  53. package/test/external/toy-feature/tsconfig.json +0 -1
  54. package/test/not-route-impl.ts +0 -1
@@ -1,13 +1,11 @@
1
- import {readFile, realpath} from 'node:fs/promises';
1
+ import {randomUUID} from 'node:crypto';
2
+ import {realpath} from 'node:fs/promises';
2
3
  import {createRequire} from 'node:module';
3
4
  import {resolve} from 'node:path';
4
- import {fileURLToPath, pathToFileURL} from 'node:url';
5
- import {createJiti} from 'jiti';
5
+ import {register} from 'tsx/cjs/api';
6
6
  import type {ClientFeature} from '#contract.js';
7
7
 
8
8
  const require = createRequire(import.meta.url);
9
- const importPattern =
10
- /(?:\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
11
9
  const nodeModulesPathPattern = /[\\/]node_modules[\\/]/;
12
10
 
13
11
  export interface EvaluatedFeatures {
@@ -15,71 +13,91 @@ export interface EvaluatedFeatures {
15
13
  loadedFiles: readonly string[];
16
14
  }
17
15
 
18
- export function invalidateFeatures(loadedFiles: Iterable<string>): void {
19
- for (const file of loadedFiles) delete require.cache[file];
20
- }
21
-
22
- function loadedModuleGraph(entry: string): string[] {
23
- const files = new Set<string>();
16
+ function loadedModuleGraph(namespaceQuery: string): {
17
+ cacheKeys: Set<string>;
18
+ loadedPaths: Set<string>;
19
+ } {
20
+ const cacheKeys = new Set<string>();
21
+ const loadedPaths = new Set<string>();
22
+ const keysByModule = new Map<NodeJS.Module, string[]>();
24
23
 
25
- function visit(file: string): void {
26
- if (files.has(file)) return;
27
- files.add(file);
28
- for (const child of require.cache[file]?.children ?? []) visit(child.filename);
24
+ for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
25
+ if (!cachedModule) continue;
26
+ const keys = keysByModule.get(cachedModule) ?? [];
27
+ keys.push(cacheKey);
28
+ keysByModule.set(cachedModule, keys);
29
29
  }
30
30
 
31
- visit(entry);
32
- return [...files];
33
- }
31
+ const visited = new Set<NodeJS.Module>();
32
+ function visit(cachedModule: NodeJS.Module): void {
33
+ if (visited.has(cachedModule)) return;
34
+ visited.add(cachedModule);
34
35
 
35
- async function staticallyImportedModules(
36
- entry: string,
37
- jiti: ReturnType<typeof createJiti>,
38
- ): Promise<string[]> {
39
- const files = new Set<string>();
36
+ for (const cacheKey of keysByModule.get(cachedModule) ?? []) {
37
+ const path = cacheKey.endsWith(namespaceQuery)
38
+ ? cacheKey.slice(0, -namespaceQuery.length)
39
+ : cacheKey;
40
+ const isLocal = !nodeModulesPathPattern.test(path);
41
+ if (isLocal) loadedPaths.add(path);
42
+ if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
43
+ }
40
44
 
41
- async function visit(file: string): Promise<void> {
42
- const path = file.startsWith('file:') ? fileURLToPath(file) : file;
43
- if (path.startsWith('node:') || nodeModulesPathPattern.test(path) || files.has(path)) return;
44
- const resolvedPath = await realpath(path).catch(() => path);
45
- files.add(resolvedPath);
46
- const source = await readFile(resolvedPath, 'utf8').catch(() => undefined);
47
- if (!source) return;
45
+ for (const child of cachedModule.children) visit(child);
46
+ }
48
47
 
49
- for (const match of source.matchAll(importPattern)) {
50
- const specifier = match[1];
51
- if (!specifier) continue;
52
- const dependency = jiti.esmResolve(specifier, {
53
- parentURL: pathToFileURL(resolvedPath),
54
- try: true,
55
- });
56
- if (dependency) await visit(dependency);
57
- }
48
+ for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
49
+ if (cachedModule && cacheKey.endsWith(namespaceQuery)) visit(cachedModule);
58
50
  }
59
51
 
60
- await visit(entry);
52
+ return {cacheKeys, loadedPaths};
53
+ }
54
+
55
+ async function localFiles(paths: Iterable<string>): Promise<string[]> {
56
+ const files = new Set<string>();
57
+ for (const path of paths) {
58
+ if (nodeModulesPathPattern.test(path)) continue;
59
+ files.add(await realpath(path).catch(() => path));
60
+ }
61
61
  return [...files];
62
62
  }
63
63
 
64
64
  export async function evaluateFeatures(featuresModule: string): Promise<EvaluatedFeatures> {
65
65
  const resolvedFeaturesModule = await realpath(resolve(featuresModule));
66
- const jiti = createJiti(resolvedFeaturesModule, {interopDefault: false, tsconfigPaths: true});
66
+ const namespace = randomUUID();
67
+ const namespaceQuery = `?namespace=${namespace}`;
68
+ const loader = register({namespace});
69
+ const loadedPaths = new Set<string>([resolvedFeaturesModule]);
70
+ const cacheKeys = new Set<string>();
71
+ const initialCacheKeys = new Set(Object.keys(require.cache));
67
72
  let module: {default?: unknown; features?: unknown};
68
- let loadedFiles: string[];
69
73
  try {
70
- module = jiti(resolvedFeaturesModule) as {default?: unknown; features?: unknown};
71
- loadedFiles = [
72
- ...new Set([
73
- ...loadedModuleGraph(resolvedFeaturesModule),
74
- ...(await staticallyImportedModules(resolvedFeaturesModule, jiti)),
75
- ]),
76
- ];
74
+ module = loader.require(resolvedFeaturesModule, import.meta.url) as {
75
+ default?: unknown;
76
+ features?: unknown;
77
+ };
77
78
  } catch (error) {
78
79
  const message = error instanceof Error ? error.message : String(error);
79
80
  throw new Error(
80
81
  `Failed to evaluate features module "${resolvedFeaturesModule}". Features modules must be Node-safe: ${message}`,
81
82
  {cause: error},
82
83
  );
84
+ } finally {
85
+ const graph = loadedModuleGraph(namespaceQuery);
86
+ for (const path of graph.loadedPaths) loadedPaths.add(path);
87
+ for (const cacheKey of graph.cacheKeys) cacheKeys.add(cacheKey);
88
+ for (const cacheKey of Object.keys(require.cache)) {
89
+ if (initialCacheKeys.has(cacheKey)) continue;
90
+ const path = cacheKey.endsWith(namespaceQuery)
91
+ ? cacheKey.slice(0, -namespaceQuery.length)
92
+ : cacheKey;
93
+ const isLocal = !nodeModulesPathPattern.test(path);
94
+ if (isLocal) loadedPaths.add(path);
95
+ if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
96
+ }
97
+ loader.unregister();
98
+ for (const cacheKey of cacheKeys) delete require.cache[cacheKey];
99
+ for (const cacheKey of Object.keys(require.cache))
100
+ if (cacheKey.endsWith(namespaceQuery)) delete require.cache[cacheKey];
83
101
  }
84
102
 
85
103
  const features = module.features ?? module.default;
@@ -89,5 +107,8 @@ export async function evaluateFeatures(featuresModule: string): Promise<Evaluate
89
107
  );
90
108
  }
91
109
 
92
- return {features: features as readonly ClientFeature[], loadedFiles};
110
+ return {
111
+ features: features as readonly ClientFeature[],
112
+ loadedFiles: await localFiles(loadedPaths),
113
+ };
93
114
  }
@@ -34,14 +34,14 @@ export function generateAppModule({
34
34
  routePath: routePathForAnchor(route.parent, route.path),
35
35
  }));
36
36
  const imports = routes
37
- .map((route, index) => `import route${index}Impl from ${literal(route.impl)};`)
37
+ .map((route, index) => `import * as route${index}Module from ${literal(route.impl)};`)
38
38
  .join('\n');
39
39
  const routeDeclarations = generatedRoutes
40
40
  .map(
41
41
  (route, index) => `const route${index} = createRoute({
42
42
  getParentRoute: () => ${route.parent === 'root' ? 'skeleton.rootRoute' : `skeleton.${route.parent}`},
43
43
  path: ${literal(route.routePath)},
44
- ...routeOptions(route${index}Impl, ${literal(route.impl)}, ${literal(route.path)}),
44
+ ...routeOptions(route${index}Module.default, ${literal(route.impl)}, ${literal(route.path)}),
45
45
  });`,
46
46
  )
47
47
  .join('\n\n');
@@ -2,7 +2,7 @@ import {mkdtemp, readFile, realpath, rm, writeFile} from 'node:fs/promises';
2
2
  import {tmpdir} from 'node:os';
3
3
  import {join} from 'node:path';
4
4
  import {fileURLToPath} from 'node:url';
5
- import type {Plugin, ResolvedConfig} from 'vite';
5
+ import {type Plugin, type ResolvedConfig, build as viteBuild} from 'vite';
6
6
  import {shipfoxClientComposition} from './plugin.js';
7
7
 
8
8
  const fixtureFeatures = fileURLToPath(new URL('../../test/fixtures/features.ts', import.meta.url));
@@ -35,13 +35,12 @@ function resolveRouteImplementation(source: string): string | undefined {
35
35
  const files: Record<string, string> = {
36
36
  '#test/default-route-impl.js': join(testDirectory, 'default-route-impl.tsx'),
37
37
  '#test/named-route-impl.js': join(testDirectory, 'named-route-impl.tsx'),
38
- '#test/not-route-impl.js': join(testDirectory, 'not-route-impl.ts'),
39
38
  '#test/search-route-impl.js': join(testDirectory, 'search-route-impl.tsx'),
40
39
  };
41
40
  return files[source];
42
41
  }
43
42
 
44
- function pluginContext() {
43
+ function pluginContext(resolveImplementation = resolveRouteImplementation) {
45
44
  const watchedFiles: string[] = [];
46
45
  return {
47
46
  watchedFiles,
@@ -49,13 +48,13 @@ function pluginContext() {
49
48
  watchedFiles.push(file);
50
49
  },
51
50
  resolve(source: string) {
52
- const id = resolveRouteImplementation(source);
51
+ const id = resolveImplementation(source);
53
52
  return Promise.resolve(id ? {id} : null);
54
53
  },
55
54
  environment: {
56
55
  pluginContainer: {
57
56
  resolveId(source: string) {
58
- const id = resolveRouteImplementation(source);
57
+ const id = resolveImplementation(source);
59
58
  return Promise.resolve(id ? {id} : null);
60
59
  },
61
60
  },
@@ -95,7 +94,7 @@ describe('shipfoxClientComposition', () => {
95
94
  await build(plugin, context);
96
95
 
97
96
  await expect(readFile(out, 'utf8')).resolves.toContain(
98
- 'import route0Impl from "#test/search-route-impl.js";',
97
+ 'import * as route0Module from "#test/search-route-impl.js";',
99
98
  );
100
99
  expect(context.watchedFiles).toContain(fixtureFeatures);
101
100
  });
@@ -118,18 +117,69 @@ describe('shipfoxClientComposition', () => {
118
117
  );
119
118
  });
120
119
 
121
- test('rejects route implementations without a default export', async () => {
120
+ test('fails the Vite build when a route implementation cannot resolve', async () => {
122
121
  const features = join(directory, 'features.ts');
123
122
  await writeFile(
124
123
  features,
125
- `export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/not-route-impl.js'}]}];`,
124
+ `export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/missing-route-impl.js'}]}];`,
126
125
  );
127
126
  const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
128
127
  const context = pluginContext();
129
128
  configure(plugin);
130
129
 
131
130
  await expect(build(plugin, context)).rejects.toThrow(
132
- 'Route implementation "#test/not-route-impl.js" for "/projects" must export default defineRoute(...).',
131
+ 'Could not resolve route implementation "#test/missing-route-impl.js" for "/projects".',
132
+ );
133
+ });
134
+
135
+ test('generates when Vite resolves a route implementation with a version query', async () => {
136
+ const out = join(directory, 'shipfox-app.gen.ts');
137
+ const plugin = shipfoxClientComposition({features: fixtureFeatures, out});
138
+ const context = pluginContext((source) => {
139
+ if (source === '#test/search-route-impl.js') {
140
+ return `${join(testDirectory, 'search-route-impl.tsx')}?v=abc123`;
141
+ }
142
+ return resolveRouteImplementation(source);
143
+ });
144
+ configure(plugin);
145
+
146
+ await build(plugin, context);
147
+
148
+ await expect(readFile(out, 'utf8')).resolves.toContain(
149
+ 'import * as route0Module from "#test/search-route-impl.js";',
150
+ );
151
+ });
152
+
153
+ test('bundles a named-only route module so the runtime export guard can report it', async () => {
154
+ const features = join(directory, 'features.ts');
155
+ const implementation = join(directory, 'not-route-impl.ts');
156
+ const output = join(directory, 'shipfox-app.gen.ts');
157
+ const entry = join(directory, 'main.ts');
158
+ await Promise.all([
159
+ writeFile(
160
+ features,
161
+ `export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: './not-route-impl.ts'}]}];`,
162
+ ),
163
+ writeFile(implementation, 'export const Route = () => null;'),
164
+ writeFile(entry, "import './shipfox-app.gen.ts';"),
165
+ ]);
166
+
167
+ await expect(
168
+ viteBuild({
169
+ root: process.cwd(),
170
+ logLevel: 'silent',
171
+ plugins: [shipfoxClientComposition({features, out: output})],
172
+ build: {
173
+ write: false,
174
+ rolldownOptions: {
175
+ input: entry,
176
+ external: ['@shipfox/client-shell/runtime', '@tanstack/react-router'],
177
+ },
178
+ },
179
+ }),
180
+ ).resolves.toBeDefined();
181
+ await expect(readFile(output, 'utf8')).resolves.toContain(
182
+ 'routeOptions(route0Module.default, "./not-route-impl.ts", "/projects")',
133
183
  );
134
184
  });
135
185
 
@@ -6,11 +6,9 @@ import {mergeConfigShapes} from '#compose/merge-config.js';
6
6
  import {validateProviderIds} from '#compose/validate-providers.js';
7
7
  import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
8
8
  import {navigationEntries, settingsEntries} from '#runtime/registries.js';
9
- import {evaluateFeatures, invalidateFeatures} from './evaluate-features.js';
9
+ import {evaluateFeatures} from './evaluate-features.js';
10
10
  import {generateAppModule} from './generate.js';
11
11
 
12
- const defaultExportPattern = /\bexport\s+default\b|\bexport\s*\{[\s\S]*?\bas\s+default\b[\s\S]*?\}/;
13
-
14
12
  export interface ShipfoxClientCompositionOptions {
15
13
  features: string;
16
14
  out?: string;
@@ -18,10 +16,6 @@ export interface ShipfoxClientCompositionOptions {
18
16
 
19
17
  type RouteResolver = (source: string, importer?: string) => Promise<{id: string} | null>;
20
18
 
21
- function hasDefaultExport(source: string): boolean {
22
- return defaultExportPattern.test(source);
23
- }
24
-
25
19
  export function shipfoxClientComposition({
26
20
  features,
27
21
  out = './src/shipfox-app.gen.ts',
@@ -32,7 +26,7 @@ export function shipfoxClientComposition({
32
26
  const outputPath = () => resolve(config?.root ?? process.cwd(), out);
33
27
  const featuresPath = () => resolve(config?.root ?? process.cwd(), features);
34
28
 
35
- async function assertDefaultRouteExports(
29
+ async function assertRoutesResolve(
36
30
  resolveRoute: RouteResolver,
37
31
  routes: ReturnType<typeof composeRoutes>,
38
32
  ): Promise<void> {
@@ -43,12 +37,6 @@ export function shipfoxClientComposition({
43
37
  `Could not resolve route implementation "${route.impl}" for "${route.path}".`,
44
38
  );
45
39
  }
46
- const source = await readFile(resolvedRoute.id, 'utf8');
47
- if (!hasDefaultExport(source)) {
48
- throw new Error(
49
- `Route implementation "${route.impl}" for "${route.path}" must export default defineRoute(...).`,
50
- );
51
- }
52
40
  }
53
41
  }
54
42
 
@@ -59,7 +47,6 @@ export function shipfoxClientComposition({
59
47
  addWatchFile(file: string): void;
60
48
  resolveRoute: RouteResolver;
61
49
  }): Promise<void> {
62
- invalidateFeatures(watchedFiles);
63
50
  const evaluated = await evaluateFeatures(featuresPath());
64
51
  const routes = composeRoutes(evaluated.features);
65
52
  validateProviderIds(evaluated.features);
@@ -72,7 +59,7 @@ export function shipfoxClientComposition({
72
59
  routes.map((route) => route.path),
73
60
  );
74
61
  mergeConfigShapes(evaluated.features);
75
- await assertDefaultRouteExports(resolveRoute, routes);
62
+ await assertRoutesResolve(resolveRoute, routes);
76
63
 
77
64
  watchedFiles = new Set(evaluated.loadedFiles);
78
65
  for (const file of watchedFiles) addWatchFile(file);
@@ -1,53 +1,41 @@
1
1
  # External composition findings
2
2
 
3
- Both the linked iteration mode and packed-tarball exit gate passed on 2026-07-16. The verifier
4
- computed and installed a 12-package `@shipfox/*` runtime closure.
5
-
6
- ## Declaration portability
7
-
8
- The packed consumer's `tsc --noEmit` accepted a typed `Link` and `useSearch` for the added
9
- `/workspaces/$wid/insights` route. The generated router also resolved the toy package's emitted
10
- default `defineRoute(...)` declarations and the shell's anchor return types from `dist`.
11
-
12
- The packed verifier also checks that every installed `#*` import map defaults to `./dist/*` and
13
- that representative shell and toy-feature public entrypoints resolve beneath `dist`. This keeps the
14
- proof valid even while the tarballs still include source files.
15
-
16
- The proof exposed two package-boundary gaps before it passed:
17
-
18
- - Compiled client packages still mapped internal `#*` imports directly to `src`. Their package
19
- manifests now use `workspace-source` and `development` for source, and `default` for `dist`.
20
- - The inferred anchor declarations name `@tanstack/router-core`. The shell now declares that direct
21
- dependency so isolated pnpm consumers can resolve it.
22
-
23
- The fixture did not need `@storybook/react` at runtime or during its consumer type-check. The
24
- `ShellProviders` declaration's type-only Storybook import is erased at runtime; the fixture uses the
25
- same `skipLibCheck` setting as the existing external-consumer verifier.
26
-
27
- ## Development-mode coverage
28
-
29
- The gate intentionally proves production builds and default package resolution; it does not start a
30
- Vite development server. The `development` condition still resolves to the TypeScript source that
31
- the packages currently ship. Plugin watch and regeneration behavior remains covered by ENG-961's
32
- focused tests. If published packages stop including source, external development-mode support needs
33
- a separate product decision and proof rather than making this release gate longer.
34
-
35
- ## Generated-file developer experience
36
-
37
- The generated file keeps both forms of route implementation specifier readable:
38
-
39
- - App-local implementations use a path relative to `src/shipfox-app.gen.ts`, such as
40
- `./features/override-impl`.
41
- - Packaged implementations use an exported package subpath, such as
42
- `@shipfox/client-shell-fixture-feature/routes/insights`.
43
-
44
- The app's Node-evaluated feature manifest imports local TypeScript modules without a `.js` suffix so
45
- jiti resolves the source file. Route implementation modules are never evaluated by jiti.
3
+ The graduated fixture uses the production `@shipfox/client-features` default composition and the
4
+ complete recursive client runtime closure from `publication-closure.json`. The external feature is
5
+ application-local, so route implementations and generated types cross the same package boundary as
6
+ a downstream distribution.
7
+
8
+ ## Contract proof
9
+
10
+ The fixture proves that:
11
+
12
+ - every default feature contributes its production routes to the generated application module;
13
+ - an application-local settings route is added and the default login route is explicitly replaced;
14
+ - two application-local providers receive the shell query client and Jotai store, then nest in
15
+ declaration order;
16
+ - application navigation and settings data render through the shell-owned registries;
17
+ - the external config fragment is required, merged, and readable by both providers and the route;
18
+ - the generated router types an application-local `Link` and `useParams` call; and
19
+ - the unapproved login collision fails with the exact normative diagnostic.
20
+
21
+ ## Distribution isolation
22
+
23
+ Packed mode builds declarations and runtime files before creating tarballs, then productionizes a
24
+ temporary copy of each package manifest like `release:publish` so source conditions cannot leak into
25
+ the consumer artifact or mutate the worktree. The fixture declares only the documented client
26
+ composition roots, plus `@shipfox/client-config` for its own config proof, while `file:` overrides
27
+ keep every first-party runtime dependency in the full closure on its local tarball. It rejects
28
+ registry-resolved Shipfox packages and `workspace:` ranges across every installed closure package,
29
+ checks that generated package imports are direct fixture dependencies, and confirms full-closure
30
+ runtime imports resolve through `dist` under default and `development` conditions while TypeScript
31
+ checks every packed declaration graph. Linked mode keeps the
32
+ minimal-consumer, generated-route, behavior, collision, and type checks for faster local iteration;
33
+ its workspace packages intentionally resolve `development` to source.
46
34
 
47
35
  ## Collision diagnostic
48
36
 
49
- The rejected build returned this diagnostic and a non-zero status:
37
+ The rejected build must return this diagnostic and a non-zero status:
50
38
 
51
39
  ```text
52
- Route "/workspaces/$wid/insights" is contributed by both features "fixture.toy-feature" and "fixture.unapproved-collision". Set override: true to replace it explicitly.
40
+ Route "/auth/login" is contributed by both features "shipfox.auth" and "fixture.unapproved-collision". Set override: true to replace it explicitly.
53
41
  ```
@@ -1,39 +1,37 @@
1
1
  # External client runtime fixture
2
2
 
3
3
  This fixture packs the published client runtime closure into a Vite application outside the pnpm
4
- workspace. It is a manual structural gate and does not add a CI job.
4
+ workspace. The application starts from `defaultFeatures()` and adds one external feature that
5
+ replaces the login route, adds a settings route, appends providers, contributes navigation and
6
+ settings entries, and merges a config fragment.
5
7
 
6
- ## Prerequisites
8
+ ## Run the required packed gate
7
9
 
8
- Build runtime files, declarations, and the closure helper before running either mode:
10
+ The package script builds runtime files and declarations from a clean checkout before packing the
11
+ full client closure:
9
12
 
10
13
  ```sh
11
- turbo build type:emit \
12
- --filter='./libs/client/**' \
13
- --filter=@shipfox/application-release
14
+ pnpm --filter=@shipfox/client-shell test:external
14
15
  ```
15
16
 
16
- ## Run the fast linked mode
17
+ The gate installs only the nine documented client composition roots, plus
18
+ `@shipfox/client-config` used by the fixture's own config proof, as direct dependencies. It uses
19
+ release-shaped local tarballs and overrides for the full `@shipfox/*` runtime closure. It verifies
20
+ that generated package imports are declared direct dependencies, checks default and `development`
21
+ condition resolution through `dist`, type-checks every packed declaration graph, generates the
22
+ composed TanStack router, builds and type-checks the consumer, runs the behavioral fixture, and
23
+ asserts the exact rejected-collision diagnostic. CI runs this command during static verification.
17
24
 
18
- ```sh
19
- node libs/client/shell/test/external/verify.mjs --link
20
- ```
21
-
22
- This copies the Vite template to a temporary directory and links the workspace-built closure. It
23
- runs a Vite build and resolves every non-pattern declaration entry point from the consumer.
24
-
25
- ## Run the packed exit gate
25
+ ## Run the linked iteration mode
26
26
 
27
27
  ```sh
28
- node libs/client/shell/test/external/verify.mjs
28
+ pnpm --filter=@shipfox/client-shell test:external -- --link
29
29
  ```
30
30
 
31
- This computes the runtime workspace closure from the client roots in
32
- `publication-closure.json`, packs every package, and installs only those tarballs for
33
- `@shipfox/*` dependencies. It repeats the linked checks against default `dist` exports and
34
- verifies no candidate package came from the workspace or registry.
31
+ This copies the Vite template to a temporary directory and links the workspace-built closure. It
32
+ runs the same minimal-consumer, generated-route, behavior, collision, and type assertions without
33
+ packing tarballs. The packed-only `development` check is omitted because linked workspace packages
34
+ intentionally resolve that condition to source.
35
35
 
36
36
  Both modes remove their temporary directories after completion. The behavioral composition fixture
37
- and required CI gate are part of ENG-995; neither mode needs browser E2E infrastructure or adds
38
- work to the normal CI test graph. Its source files and Vitest wiring remain in the fixture template
39
- for that follow-up.
37
+ uses Vitest with JSDOM and does not need browser E2E infrastructure.
@@ -18,8 +18,7 @@
18
18
  "zod": "4.4.3"
19
19
  },
20
20
  "devDependencies": {
21
- "@testing-library/jest-dom": "6.9.1",
22
- "@testing-library/react": "16.3.2",
21
+ "@storybook/react": "10.4.6",
23
22
  "@types/node": "24.13.2",
24
23
  "@types/react": "19.2.17",
25
24
  "@types/react-dom": "19.2.3",