@proteinjs/reflection-build 1.5.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 (80) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/src/BuildContract.d.ts +69 -0
  3. package/dist/src/BuildContract.js +393 -0
  4. package/dist/src/BuildContract.js.map +1 -0
  5. package/dist/src/ReflectionDoctor.d.ts +95 -0
  6. package/dist/src/ReflectionDoctor.js +588 -0
  7. package/dist/src/ReflectionDoctor.js.map +1 -0
  8. package/dist/src/build.d.ts +5 -1
  9. package/dist/src/build.js +13 -77
  10. package/dist/src/build.js.map +1 -1
  11. package/dist/src/codegen/writeGeneratedIndex.d.ts +18 -0
  12. package/dist/src/codegen/writeGeneratedIndex.js +58 -15
  13. package/dist/src/codegen/writeGeneratedIndex.js.map +1 -1
  14. package/dist/src/parser/createGraphBuilder.js +30 -5
  15. package/dist/src/parser/createGraphBuilder.js.map +1 -1
  16. package/dist/src/runBuild.js +12 -2
  17. package/dist/src/runBuild.js.map +1 -1
  18. package/dist/src/runDoctor.d.ts +2 -0
  19. package/dist/src/runDoctor.js +83 -0
  20. package/dist/src/runDoctor.js.map +1 -0
  21. package/dist/test/GraphEmitRelativePaths.test.d.ts +1 -0
  22. package/dist/test/GraphEmitRelativePaths.test.js +162 -0
  23. package/dist/test/GraphEmitRelativePaths.test.js.map +1 -0
  24. package/dist/test/ReflectionDoctor.test.d.ts +1 -0
  25. package/dist/test/ReflectionDoctor.test.js +316 -0
  26. package/dist/test/ReflectionDoctor.test.js.map +1 -0
  27. package/dist/test/TestSubpathContract.test.d.ts +1 -0
  28. package/dist/test/TestSubpathContract.test.js +260 -0
  29. package/dist/test/TestSubpathContract.test.js.map +1 -0
  30. package/dist/test/ValidateDontMutate.test.d.ts +1 -0
  31. package/dist/test/ValidateDontMutate.test.js +238 -0
  32. package/dist/test/ValidateDontMutate.test.js.map +1 -0
  33. package/dist/test/examples/source-repository/a/generated/index.js +1 -1
  34. package/dist/test/examples/source-repository/a/generated/index.js.map +1 -1
  35. package/dist/test/examples/source-repository/b/generated/index.js +1 -1
  36. package/dist/test/examples/source-repository/b/generated/index.js.map +1 -1
  37. package/package.json +5 -4
  38. package/src/BuildContract.ts +384 -0
  39. package/src/ReflectionDoctor.ts +589 -0
  40. package/src/build.ts +19 -56
  41. package/src/codegen/writeGeneratedIndex.ts +51 -13
  42. package/src/parser/createGraphBuilder.ts +9 -4
  43. package/src/runBuild.ts +7 -1
  44. package/src/runDoctor.ts +34 -0
  45. package/test/GraphEmitRelativePaths.test.ts +95 -0
  46. package/test/ReflectionDoctor.test.ts +193 -0
  47. package/test/TestSubpathContract.test.ts +148 -0
  48. package/test/ValidateDontMutate.test.ts +126 -0
  49. package/test/examples/source-repository/a/dist/BuildContract.d.ts +69 -0
  50. package/test/examples/source-repository/a/dist/BuildContract.js +393 -0
  51. package/test/examples/source-repository/a/dist/BuildContract.js.map +1 -0
  52. package/test/examples/source-repository/a/dist/build.d.ts +5 -1
  53. package/test/examples/source-repository/a/dist/build.js +13 -77
  54. package/test/examples/source-repository/a/dist/build.js.map +1 -1
  55. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.d.ts +18 -0
  56. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js +58 -15
  57. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js.map +1 -1
  58. package/test/examples/source-repository/a/dist/generated/index.js +1 -1
  59. package/test/examples/source-repository/a/dist/generated/index.js.map +1 -1
  60. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js +30 -5
  61. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js.map +1 -1
  62. package/test/examples/source-repository/a/dist/runBuild.js +12 -2
  63. package/test/examples/source-repository/a/dist/runBuild.js.map +1 -1
  64. package/test/examples/source-repository/a/generated/index.ts +1 -1
  65. package/test/examples/source-repository/b/dist/BuildContract.d.ts +69 -0
  66. package/test/examples/source-repository/b/dist/BuildContract.js +393 -0
  67. package/test/examples/source-repository/b/dist/BuildContract.js.map +1 -0
  68. package/test/examples/source-repository/b/dist/build.d.ts +5 -1
  69. package/test/examples/source-repository/b/dist/build.js +13 -77
  70. package/test/examples/source-repository/b/dist/build.js.map +1 -1
  71. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.d.ts +18 -0
  72. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js +58 -15
  73. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js.map +1 -1
  74. package/test/examples/source-repository/b/dist/generated/index.js +1 -1
  75. package/test/examples/source-repository/b/dist/generated/index.js.map +1 -1
  76. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js +30 -5
  77. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js.map +1 -1
  78. package/test/examples/source-repository/b/dist/runBuild.js +12 -2
  79. package/test/examples/source-repository/b/dist/runBuild.js.map +1 -1
  80. package/test/examples/source-repository/b/generated/index.ts +1 -1
@@ -77,10 +77,10 @@ async function sourceRepositoryLoader(
77
77
 
78
78
  // Allow multiple roots (e.g., ['test','src']) to keep ancestry intact.
79
79
  const roots = Array.isArray(sourceRootsRel) ? sourceRootsRel : [sourceRootsRel];
80
- const sourceGraph: Graph = await createSourceGraph(packageDir, [], roots);
80
+ const sourceGraph: Graph = await createEmittedSourceGraph(packageDir, roots);
81
81
 
82
- code += generateSourceGraph(sourceGraph, packageJson.name);
83
- code += generateSourceLinks(sourceGraph, packageJson, generatedIndexPath);
82
+ code += generateSourceGraph(sourceGraph);
83
+ code += generateSourceLinks(sourceGraph, packageJson, packageDir, generatedIndexPath);
84
84
  code += mergeSourceGraph();
85
85
  return code;
86
86
  }
@@ -138,19 +138,39 @@ function loadDependencySourceGraphs(packageDir: string, packageJson: any): strin
138
138
  * and correct for workspace symlinks (the symlinked package root carries its own dist).
139
139
  */
140
140
  export function dependencyHasSourceGraph(packageDir: string, packageName: string): boolean {
141
+ return !!findDependencySourceGraph(packageDir, packageName);
142
+ }
143
+
144
+ /**
145
+ * Full path to the dependency's source-graph artifact, or undefined when the dependency is
146
+ * not installed or not reflection-built. Same walk as `dependencyHasSourceGraph`; exposed for
147
+ * static graph harvesting (reflection-doctor).
148
+ */
149
+ export function findDependencySourceGraph(packageDir: string, packageName: string): string | undefined {
150
+ const dependencyDir = findDependencyDir(packageDir, packageName);
151
+ if (!dependencyDir) {
152
+ return undefined;
153
+ }
154
+
155
+ const artifactPath = path.join(dependencyDir, 'dist', 'generated', 'index.js');
156
+ return fs.existsSync(artifactPath) ? artifactPath : undefined;
157
+ }
158
+
159
+ /**
160
+ * The installed location of a dependency, resolved by walking `node_modules` up from the
161
+ * consuming package — npm-layout resolution: the nearest installed copy shadows hoisted ones.
162
+ */
163
+ export function findDependencyDir(packageDir: string, packageName: string): string | undefined {
141
164
  let currentDir = packageDir;
142
- // Upward fs walk; exits are internal (found / package root / fs root).
165
+ // Upward fs walk; exits are internal (found / fs root).
143
166
  for (;;) {
144
167
  const candidate = path.join(currentDir, 'node_modules', packageName);
145
- if (fs.existsSync(path.join(candidate, 'dist', 'generated', 'index.js'))) {
146
- return true;
147
- }
148
168
  if (fs.existsSync(candidate)) {
149
- return false;
169
+ return candidate;
150
170
  }
151
171
  const parent = path.dirname(currentDir);
152
172
  if (parent === currentDir) {
153
- return false;
173
+ return undefined;
154
174
  }
155
175
  currentDir = parent;
156
176
  }
@@ -210,8 +230,19 @@ function getDependencyImportSpecifier(packageDir: string, packageName: string):
210
230
  }
211
231
  }
212
232
 
213
- function generateSourceGraph(sourceGraph: Graph, buildTargetPackageName: string): string {
214
- removeNonLoadables(sourceGraph, buildTargetPackageName);
233
+ /**
234
+ * The graph exactly as a build emits it: parsed from the package's source roots, with
235
+ * build-time-non-loadable declarations pruned. Owned here so build emit and diagnostic
236
+ * tooling (reflection-doctor drift checks) share one pipeline.
237
+ */
238
+ export async function createEmittedSourceGraph(packageDir: string, sourceRootsRel: string[]): Promise<Graph> {
239
+ const packageJson = await getPackageJson(packageDir);
240
+ const sourceGraph = await createSourceGraph(packageDir, [], sourceRootsRel);
241
+ removeNonLoadables(sourceGraph, packageJson.name);
242
+ return sourceGraph;
243
+ }
244
+
245
+ function generateSourceGraph(sourceGraph: Graph): string {
215
246
  let code = `\n\n/** Generate Source Graph */\n\n`;
216
247
  const serializedSourceGraph = graphSerializer.serialize(sourceGraph);
217
248
  const doubleEscapedSerializedSourceGraph = jsesc(serializedSourceGraph, { json: true }); // since we write to file, need to escape a second time
@@ -288,7 +319,12 @@ function removeNonLoadableNode(
288
319
  return shouldRemove;
289
320
  }
290
321
 
291
- function generateSourceLinks(sourceGraph: Graph, packageJson: any, generatedIndexPath: string): string {
322
+ function generateSourceLinks(
323
+ sourceGraph: Graph,
324
+ packageJson: any,
325
+ packageDir: string,
326
+ generatedIndexPath: string
327
+ ): string {
292
328
  let code = `\n\n/** Generate Source Links */\n\n`;
293
329
  const linkableNodes: PackageScope[] = [];
294
330
  for (const nodeName of sourceGraph.nodes()) {
@@ -309,7 +345,9 @@ function generateSourceLinks(sourceGraph: Graph, packageJson: any, generatedInde
309
345
  continue;
310
346
  }
311
347
 
312
- const relativeImportPath = path.relative(path.dirname(generatedIndexPath), node.filePath);
348
+ // node.filePath is package-relative (never serialized absolute); resolve against the
349
+ // package dir to compute the real import location for the generated index.
350
+ const relativeImportPath = path.relative(path.dirname(generatedIndexPath), path.join(packageDir, node.filePath));
313
351
  code += `import { ${node.name} } from '${relativeImportPath.replace(/\.[^/.]+$/, '')}';\n`;
314
352
  linkableNodes.push(node);
315
353
  }
@@ -1,3 +1,4 @@
1
+ import * as path from 'path';
1
2
  import * as graphlib from '@dagrejs/graphlib';
2
3
  import {
3
4
  VariableDeclaration as ParserVariableDeclaration,
@@ -17,6 +18,10 @@ import { createInterfaceDeclaration } from './types/createInterfaceDeclaration';
17
18
  export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, packageJsonDir: string) {
18
19
  const packageName = packageJson.name;
19
20
  return async (parsedFile: File): Promise<void> => {
21
+ // Package-relative: the graph is a serialized, shipped artifact — absolute build-machine
22
+ // paths (CI runner paths, local checkout paths) must never enter it. Build-time consumers
23
+ // that need a real location (sourceLink imports) join this with the package dir.
24
+ const filePath = path.relative(packageJsonDir, parsedFile.filePath);
20
25
  for (const declaration of parsedFile.declarations) {
21
26
  if (!(declaration as any)['isExported']) {
22
27
  continue;
@@ -31,7 +36,7 @@ export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, pack
31
36
  const variableDeclaration = await createVariableDeclaration(
32
37
  declaration as ParserVariableDeclaration,
33
38
  packageNameFinder,
34
- parsedFile.filePath
39
+ filePath
35
40
  );
36
41
  graph.setNode(
37
42
  variableDeclaration.qualifiedName,
@@ -47,7 +52,7 @@ export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, pack
47
52
  const typeAliasDeclaration = await createTypeAliasDeclaration(
48
53
  declaration as ParserTypeAliasDeclaration,
49
54
  packageNameFinder,
50
- parsedFile.filePath
55
+ filePath
51
56
  );
52
57
  graph.setNode(
53
58
  typeAliasDeclaration.qualifiedName,
@@ -63,7 +68,7 @@ export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, pack
63
68
  const classDeclaration = await createClassDeclaration(
64
69
  declaration as ParserClassDeclaration,
65
70
  packageNameFinder,
66
- parsedFile.filePath
71
+ filePath
67
72
  );
68
73
  graph.setNode(
69
74
  classDeclaration.qualifiedName,
@@ -100,7 +105,7 @@ export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, pack
100
105
  const interfaceDeclaration = await createInterfaceDeclaration(
101
106
  declaration as ParserInterfaceDeclaration,
102
107
  packageNameFinder,
103
- parsedFile.filePath
108
+ filePath
104
109
  );
105
110
  graph.setNode(
106
111
  interfaceDeclaration.qualifiedName,
package/src/runBuild.ts CHANGED
@@ -3,5 +3,11 @@
3
3
  import { build } from './build';
4
4
 
5
5
  (async function () {
6
- await build();
6
+ try {
7
+ await build({ fix: process.argv.includes('--fix') });
8
+ } catch (error: any) {
9
+ // Contract conflicts are a diagnosis for humans — print the message, not a stack trace.
10
+ console.error(error instanceof Error ? error.message : error);
11
+ process.exit(1);
12
+ }
7
13
  })();
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { ReflectionDoctor } from './ReflectionDoctor';
4
+
5
+ (async function () {
6
+ try {
7
+ const packageDir = process.env.INIT_CWD || process.cwd();
8
+ const skipList = (process.env.SKIP_REFLECTION_LOAD || '')
9
+ .split(',')
10
+ .map((s) => s.trim())
11
+ .filter(Boolean);
12
+ const doctor = new ReflectionDoctor(packageDir, { skipList });
13
+
14
+ const explainIndex = process.argv.indexOf('--explain');
15
+ if (explainIndex !== -1) {
16
+ const target = process.argv[explainIndex + 1];
17
+ if (!target) {
18
+ console.error(`usage: reflection-doctor [--explain <name|qualifiedName>]`);
19
+ process.exit(1);
20
+ }
21
+ const explanation = await doctor.explain(target);
22
+ console.log(explanation.text);
23
+ return;
24
+ }
25
+
26
+ const report = await doctor.diagnose();
27
+ console.log(report.text);
28
+ // Diagnostic exit code: orphans/drift fail CI-style callers.
29
+ process.exit(report.healthy ? 0 : 1);
30
+ } catch (error: any) {
31
+ console.error(error instanceof Error ? error.message : error);
32
+ process.exit(1);
33
+ }
34
+ })();
@@ -0,0 +1,95 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { execSync } from 'child_process';
5
+ import { writeGeneratedIndex } from '../src/codegen/writeGeneratedIndex';
6
+
7
+ /**
8
+ * The emitted source graph must carry package-relative filePaths (task #104 §3.4 carve-out).
9
+ * Absolute paths from the build machine leak into every consumer bundle (CI runner paths in
10
+ * registry packages, local checkout paths in dev bundles) — a path disclosure with zero
11
+ * runtime value: nothing reads filePath at runtime; it is consumed only at build time to
12
+ * emit sourceLink imports, before serialization.
13
+ *
14
+ * Red-before-green: against pre-fix codegen the emitted graph embeds absolute paths and the
15
+ * assertions below fail.
16
+ */
17
+ describe('graph emit: package-relative filePaths', () => {
18
+ const fixtureA = path.join(__dirname, 'examples', 'source-repository', 'a');
19
+ // A tmp copy of fixture a (sources only), emitted into its own ./generated — the real build
20
+ // layout, so path assertions cover exactly what a real build writes.
21
+ let pkgDir: string;
22
+ let tmpRoot: string;
23
+ let emitted: string;
24
+ let graph: any;
25
+
26
+ beforeAll(async () => {
27
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'refl-relpaths-'));
28
+ pkgDir = path.join(tmpRoot, 'a');
29
+ fs.mkdirSync(pkgDir);
30
+ for (const entry of ['package.json', 'index.ts', 'src']) {
31
+ execSync(`cp -R ${path.join(fixtureA, entry)} ${path.join(pkgDir, entry)}`);
32
+ }
33
+ const outDir = path.join(pkgDir, 'generated');
34
+ fs.mkdirSync(outDir);
35
+ const generatedIndexPath = path.join(outDir, 'index.ts');
36
+ await writeGeneratedIndex(pkgDir, outDir, generatedIndexPath, ['src']);
37
+ emitted = fs.readFileSync(generatedIndexPath, 'utf-8');
38
+
39
+ // Extract the emitted graph exactly as the runtime would read it: the JSON string literal
40
+ // assigned to `const sourceGraph`, unescaped once (JSON.parse of the literal), then parsed.
41
+ const literalMatch = emitted.match(/const sourceGraph = (".*");/);
42
+ expect(literalMatch).toBeTruthy();
43
+ const serialized = JSON.parse(literalMatch![1]).replace(/\\'/g, "'");
44
+ graph = JSON.parse(serialized);
45
+ });
46
+
47
+ afterAll(() => {
48
+ fs.rmdirSync(tmpRoot, { recursive: true });
49
+ });
50
+
51
+ it('emits no absolute filePath on any graph node', () => {
52
+ const offenders: string[] = [];
53
+ for (const node of graph.nodes) {
54
+ const filePath = node.value?.filePath;
55
+ if (typeof filePath === 'string' && path.isAbsolute(filePath)) {
56
+ offenders.push(`${node.v}: ${filePath}`);
57
+ }
58
+ }
59
+ expect(offenders).toEqual([]);
60
+ });
61
+
62
+ it('emits no build-machine path anywhere in the serialized graph string', () => {
63
+ // The strongest form of the disclosure check: the package's own absolute location
64
+ // (worktree checkout path here; /home/runner/... in CI) never appears in the artifact.
65
+ expect(emitted).not.toContain(pkgDir);
66
+ expect(emitted).not.toContain(fixtureA);
67
+ });
68
+
69
+ it('node filePaths are package-relative and resolve to real source files', () => {
70
+ const checked: string[] = [];
71
+ for (const node of graph.nodes) {
72
+ const filePath = node.value?.filePath;
73
+ if (typeof filePath !== 'string' || filePath === '') {
74
+ continue;
75
+ }
76
+ expect(fs.existsSync(path.join(pkgDir, filePath))).toBe(true);
77
+ checked.push(filePath);
78
+ }
79
+ // Fixture a declares sources under src/ — the graph must actually carry them.
80
+ expect(checked.length).toBeGreaterThan(0);
81
+ expect(checked.every((p) => p.startsWith('src' + path.sep) || p.startsWith('index.'))).toBe(true);
82
+ });
83
+
84
+ it('sourceLink imports still resolve to the linked source files (outcome: links survive)', () => {
85
+ const importLines = emitted.match(/import \{ .+ \} from '.+';/g) || [];
86
+ const linkImports = importLines.filter((line) => !line.includes('@proteinjs/reflection'));
87
+ expect(linkImports.length).toBeGreaterThan(0);
88
+ for (const line of linkImports) {
89
+ const spec = line.match(/from '(.+)';/)![1];
90
+ const resolved = path.resolve(path.join(pkgDir, 'generated'), spec);
91
+ const exists = fs.existsSync(`${resolved}.ts`) || fs.existsSync(`${resolved}.tsx`);
92
+ expect(`${spec}: ${exists}`).toBe(`${spec}: true`);
93
+ }
94
+ });
95
+ });
@@ -0,0 +1,193 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { ReflectionDoctor } from '../src/ReflectionDoctor';
5
+ import { writeGeneratedIndex } from '../src/codegen/writeGeneratedIndex';
6
+
7
+ /**
8
+ * reflection-doctor (task #104 §3.1): purely analytical CLI — registered-inventory print,
9
+ * orphan detection (the undeclared-dep/typo class, today silently pruned at runtime), and
10
+ * --explain parent-edge traces naming the broken hop. Executes no dependency code: graphs
11
+ * are harvested statically from dist/generated artifacts.
12
+ *
13
+ * Red-before-green: written against the finished doctor api/trace format; fails before
14
+ * ReflectionDoctor exists.
15
+ */
16
+ describe('ReflectionDoctor', () => {
17
+ const fixtureA = path.join(__dirname, 'examples', 'source-repository', 'a');
18
+
19
+ describe('inventory + healthy fixture (fixture a)', () => {
20
+ it('reports dependency graphs, own graph size, and no orphans', async () => {
21
+ const doctor = new ReflectionDoctor(fixtureA);
22
+ const report = await doctor.diagnose();
23
+
24
+ expect(report.dependencies.loaded).toContain('@proteinjs/reflection-build-test-b');
25
+ expect(report.dependencies.withoutGraph).toContain('@proteinjs/reflection');
26
+ expect(report.nodeCount).toBeGreaterThan(0);
27
+ expect(report.edgeCount).toBeGreaterThan(0);
28
+ expect(report.sourceLinkCount).toBeGreaterThan(0);
29
+ expect(report.orphans).toEqual([]);
30
+ expect(report.drift.missingFromDist).toEqual([]);
31
+ expect(report.drift.onlyInDist).toEqual([]);
32
+ expect(report.healthy).toBe(true);
33
+ expect(report.text).toContain('@proteinjs/reflection-build-test-b');
34
+ });
35
+
36
+ it('explains a Loadable chain through a foreign graph, naming each hop', async () => {
37
+ const doctor = new ReflectionDoctor(fixtureA);
38
+ const explanation = await doctor.explain('ImplementsLoadableForeignInterface');
39
+
40
+ expect(explanation.text).toContain('@proteinjs/reflection-build-test-a/ImplementsLoadableForeignInterface');
41
+ expect(explanation.text).toContain(
42
+ 'implements interface @proteinjs/reflection-build-test-b/LoadableForeignInterface'
43
+ );
44
+ expect(explanation.text).toContain('[edge OK]');
45
+ expect(explanation.text).toContain('graph @proteinjs/reflection-build-test-b: LOADED');
46
+ expect(explanation.verdict).toContain('Loadable');
47
+ expect(explanation.verdict).toContain(`objects('@proteinjs/reflection-build-test-b/LoadableForeignInterface')`);
48
+ });
49
+
50
+ it('explains a deliberately non-Loadable type without inventing a defect', async () => {
51
+ const doctor = new ReflectionDoctor(fixtureA);
52
+ const explanation = await doctor.explain('ImplementsNotLoadableForeignInterface');
53
+
54
+ expect(explanation.verdict).toContain('NOT Loadable');
55
+ // The parent package's graph IS loaded; the parent just is not Loadable there.
56
+ expect(explanation.text).toContain('@proteinjs/reflection-build-test-b/NotLoadableForeignInterface');
57
+ expect(explanation.text).toContain('not Loadable');
58
+ });
59
+ });
60
+
61
+ describe('broken chains (crafted fixture)', () => {
62
+ let pkgDir: string;
63
+
64
+ const emitFixture = async (pkgDirToBuild: string) => {
65
+ const generatedDir = path.join(pkgDirToBuild, 'generated');
66
+ fs.mkdirSync(generatedDir, { recursive: true });
67
+ const generatedIndexPath = path.join(generatedDir, 'index.ts');
68
+ await writeGeneratedIndex(pkgDirToBuild, generatedDir, generatedIndexPath, ['src']);
69
+ // The doctor reads the built artifact; the sourceGraph/sourceLinks lines it parses are
70
+ // identical between generated/index.ts and its compiled dist twin.
71
+ const distGeneratedDir = path.join(pkgDirToBuild, 'dist', 'generated');
72
+ fs.mkdirSync(distGeneratedDir, { recursive: true });
73
+ fs.copyFileSync(generatedIndexPath, path.join(distGeneratedDir, 'index.js'));
74
+ };
75
+
76
+ beforeEach(() => {
77
+ pkgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refl-doctor-'));
78
+ fs.writeFileSync(
79
+ path.join(pkgDir, 'package.json'),
80
+ JSON.stringify({ name: '@test/doctor-fixture', version: '1.0.0', dependencies: {} }, null, 2)
81
+ );
82
+ fs.mkdirSync(path.join(pkgDir, 'src'));
83
+ fs.writeFileSync(path.join(pkgDir, 'src', 'index.ts'), `export * from './Orphan';\n`);
84
+ fs.writeFileSync(
85
+ path.join(pkgDir, 'src', 'Orphan.ts'),
86
+ `import { ForeignBase } from '@undeclared/pkg';\n\nexport class Orphan implements ForeignBase {}\n`
87
+ );
88
+ });
89
+
90
+ afterEach(() => {
91
+ fs.rmdirSync(pkgDir, { recursive: true });
92
+ });
93
+
94
+ it('detects the orphan: a kept node whose foreign parent resolves in no loaded graph', async () => {
95
+ await emitFixture(pkgDir);
96
+ const doctor = new ReflectionDoctor(pkgDir);
97
+ const report = await doctor.diagnose();
98
+
99
+ expect(report.orphans).toHaveLength(1);
100
+ expect(report.orphans[0].qualifiedName).toBe('@test/doctor-fixture/Orphan');
101
+ expect(report.orphans[0].unresolvedParent).toBe('@undeclared/pkg/ForeignBase');
102
+ expect(report.orphans[0].cause).toContain('not a declared dependency');
103
+ expect(report.healthy).toBe(false);
104
+ expect(report.text).toContain('@undeclared/pkg/ForeignBase');
105
+ });
106
+
107
+ it('--explain names the broken hop and the cause', async () => {
108
+ await emitFixture(pkgDir);
109
+ const doctor = new ReflectionDoctor(pkgDir);
110
+ const explanation = await doctor.explain('Orphan');
111
+
112
+ expect(explanation.text).toContain('implements interface @undeclared/pkg/ForeignBase');
113
+ expect(explanation.text).toContain('graph @undeclared/pkg: NOT LOADED');
114
+ expect(explanation.verdict).toContain('NOT Loadable');
115
+ expect(explanation.verdict).toContain('@undeclared/pkg/ForeignBase');
116
+ expect(explanation.verdict).toContain('not a declared dependency');
117
+ });
118
+
119
+ it('classifies a declared-but-not-reflection-built dependency', async () => {
120
+ const packageJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
121
+ packageJson.dependencies['@undeclared/pkg'] = '1.0.0';
122
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2));
123
+ const depDir = path.join(pkgDir, 'node_modules', '@undeclared', 'pkg');
124
+ fs.mkdirSync(depDir, { recursive: true });
125
+ fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({ name: '@undeclared/pkg' }));
126
+
127
+ await emitFixture(pkgDir);
128
+ const doctor = new ReflectionDoctor(pkgDir);
129
+ const report = await doctor.diagnose();
130
+
131
+ expect(report.orphans).toHaveLength(1);
132
+ expect(report.orphans[0].cause).toContain('not reflection-built');
133
+ expect(report.dependencies.withoutGraph).toContain('@undeclared/pkg');
134
+ });
135
+
136
+ it('does not report inline type-expression parents as orphans (the ArrayMembershipOp class)', async () => {
137
+ // A union alias with a doc comment inside: the parser captures the raw expression as a
138
+ // parent name (leading '/'), dodging the '{'-name guard — a junk, never-resolvable
139
+ // parent edge. The doctor must classify it as inline-expression noise, not as an
140
+ // undeclared dependency.
141
+ fs.writeFileSync(
142
+ path.join(pkgDir, 'src', 'Ops.ts'),
143
+ `export type MembershipOp =\n /** Insert */\n | { op: 'add'; id: string }\n /** Remove */\n | { op: 'remove'; id: string };\n`
144
+ );
145
+ await emitFixture(pkgDir);
146
+
147
+ const doctor = new ReflectionDoctor(pkgDir);
148
+ const report = await doctor.diagnose();
149
+ // Only the real orphan remains; the junk parent is excluded.
150
+ expect(report.orphans).toHaveLength(1);
151
+ expect(report.orphans[0].unresolvedParent).toBe('@undeclared/pkg/ForeignBase');
152
+ });
153
+
154
+ it('detects drift: sources added after the artifact was built', async () => {
155
+ await emitFixture(pkgDir);
156
+ fs.writeFileSync(
157
+ path.join(pkgDir, 'src', 'Late.ts'),
158
+ `import { ForeignBase } from '@undeclared/pkg';\n\nexport class Late implements ForeignBase {}\n`
159
+ );
160
+
161
+ const doctor = new ReflectionDoctor(pkgDir);
162
+ const report = await doctor.diagnose();
163
+ expect(report.drift.missingFromDist).toContain('@test/doctor-fixture/Late');
164
+ expect(report.healthy).toBe(false);
165
+ });
166
+
167
+ it('--explain diagnoses a declaration that exists but is not exported', async () => {
168
+ fs.writeFileSync(
169
+ path.join(pkgDir, 'src', 'Hidden.ts'),
170
+ `import { ForeignBase } from '@undeclared/pkg';\n\nclass Hidden implements ForeignBase {}\n`
171
+ );
172
+ await emitFixture(pkgDir);
173
+
174
+ const doctor = new ReflectionDoctor(pkgDir);
175
+ const explanation = await doctor.explain('Hidden');
176
+ expect(explanation.verdict).toContain('not exported');
177
+ expect(explanation.text).toContain('Hidden.ts');
178
+ });
179
+
180
+ it('--explain diagnoses a stale dist: declaration in sources but not in the artifact', async () => {
181
+ await emitFixture(pkgDir);
182
+ fs.writeFileSync(
183
+ path.join(pkgDir, 'src', 'Late.ts'),
184
+ `import { ForeignBase } from '@undeclared/pkg';\n\nexport class Late implements ForeignBase {}\n`
185
+ );
186
+
187
+ const doctor = new ReflectionDoctor(pkgDir);
188
+ const explanation = await doctor.explain('Late');
189
+ expect(explanation.verdict.toLowerCase()).toContain('stale');
190
+ expect(explanation.verdict).toContain('rebuild');
191
+ });
192
+ });
193
+ });
@@ -0,0 +1,148 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { build } from '../src/build';
5
+
6
+ /**
7
+ * The ./test subpath contract (task #104 §3.2, the d51556f0 class): the test subpath is a
8
+ * reflection-build feature, so reflection-build owns the COMPLETE artifact contract for it —
9
+ * root stubs, exports/typesVersions mappings, files[] entries, tsconfig excludes — instead of
10
+ * every package hand-rolling the d51556f0 shims and drifting.
11
+ *
12
+ * Contract pieces, validated/emitted on every subpath build (primary root != 'src'):
13
+ * - root <root>.js / <root>.d.ts stubs re-exporting dist/generated/<root>/index — win the
14
+ * node10/paths-mapped file lookup so consumers resolve built dist, never sources (TS6059)
15
+ * - exports: "." and "./<root>" mapped to the built artifacts (node16/modern resolution)
16
+ * - typesVersions: "<root>" mapped for node10 TYPES resolution
17
+ * - files[]: stub entries added when a files allowlist exists (published tarballs keep them)
18
+ * - tsconfig exclude: the stubs never become compiler inputs
19
+ *
20
+ * Red-before-green: against pre-change code nothing emits any of this — every assertion fails.
21
+ */
22
+ describe('test-subpath contract', () => {
23
+ let pkgDir: string;
24
+ const savedEnv: { [key: string]: string | undefined } = {};
25
+
26
+ const writeFixture = (packageJson: any) => {
27
+ pkgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refl-subpath-'));
28
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify(packageJson, null, 2));
29
+ fs.mkdirSync(path.join(pkgDir, 'src'));
30
+ fs.writeFileSync(path.join(pkgDir, 'src', 'index.ts'), `export * from './Example';\n`);
31
+ fs.writeFileSync(path.join(pkgDir, 'src', 'Example.ts'), `export class Example {}\n`);
32
+ fs.mkdirSync(path.join(pkgDir, 'test'));
33
+ fs.writeFileSync(path.join(pkgDir, 'test', 'index.ts'), `export * from './TestHelper';\n`);
34
+ fs.writeFileSync(path.join(pkgDir, 'test', 'TestHelper.ts'), `export class TestHelper {}\n`);
35
+ process.env.INIT_CWD = pkgDir;
36
+ process.env.REFLECTION_SOURCE_DIRS = 'test,src';
37
+ process.env.REFLECTION_EXPORT_FROM = 'test/index.ts';
38
+ };
39
+
40
+ const readPackageJson = () => JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
41
+ const readTsconfig = () => JSON.parse(fs.readFileSync(path.join(pkgDir, 'tsconfig.json'), 'utf-8'));
42
+
43
+ beforeEach(() => {
44
+ for (const key of ['INIT_CWD', 'REFLECTION_SOURCE_DIRS', 'REFLECTION_EXPORT_FROM', 'REFLECTION_DIST_DIR']) {
45
+ savedEnv[key] = process.env[key];
46
+ delete process.env[key];
47
+ }
48
+ });
49
+
50
+ afterEach(() => {
51
+ for (const key of Object.keys(savedEnv)) {
52
+ if (typeof savedEnv[key] === 'undefined') {
53
+ delete process.env[key];
54
+ } else {
55
+ process.env[key] = savedEnv[key];
56
+ }
57
+ }
58
+ fs.rmdirSync(pkgDir, { recursive: true });
59
+ });
60
+
61
+ it('emits root stubs that resolve the subpath to built dist in every install shape', async () => {
62
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0' });
63
+ await build();
64
+
65
+ const stubJs = fs.readFileSync(path.join(pkgDir, 'test.js'), 'utf-8');
66
+ expect(stubJs).toContain(`require('./dist/generated/test/index.js')`);
67
+ const stubDts = fs.readFileSync(path.join(pkgDir, 'test.d.ts'), 'utf-8');
68
+ expect(stubDts).toContain(`export * from './dist/generated/test/index'`);
69
+ });
70
+
71
+ it('maps the subpath in exports and typesVersions', async () => {
72
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0' });
73
+ await build();
74
+
75
+ const packageJson = readPackageJson();
76
+ expect(packageJson.exports['.']).toEqual({
77
+ types: './dist/generated/index.d.ts',
78
+ default: './dist/generated/index.js',
79
+ });
80
+ expect(packageJson.exports['./test']).toEqual({
81
+ types: './dist/generated/test/index.d.ts',
82
+ default: './dist/generated/test/index.js',
83
+ });
84
+ expect(packageJson.typesVersions['*']['test']).toEqual(['dist/generated/test/index.d.ts']);
85
+ });
86
+
87
+ it('keeps extra exports subpaths a package added on its own', async () => {
88
+ writeFixture({
89
+ name: '@test/subpath-fixture',
90
+ version: '1.0.0',
91
+ exports: { './extra': './dist/extra.js' },
92
+ });
93
+ await build();
94
+
95
+ const packageJson = readPackageJson();
96
+ expect(packageJson.exports['./extra']).toBe('./dist/extra.js');
97
+ expect(packageJson.exports['./test']).toEqual({
98
+ types: './dist/generated/test/index.d.ts',
99
+ default: './dist/generated/test/index.js',
100
+ });
101
+ });
102
+
103
+ it('adds stub entries to an existing files allowlist, and leaves absent files[] absent', async () => {
104
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0', files: ['dist/**'] });
105
+ await build();
106
+ expect(readPackageJson().files).toEqual(expect.arrayContaining(['dist/**', 'test.js', 'test.d.ts']));
107
+
108
+ fs.rmdirSync(pkgDir, { recursive: true });
109
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0' });
110
+ await build();
111
+ expect(readPackageJson().files).toBeUndefined();
112
+ });
113
+
114
+ it('excludes the stubs from compilation in tsconfig without clobbering default excludes', async () => {
115
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0' });
116
+ await build();
117
+
118
+ const tsconfig = readTsconfig();
119
+ expect(tsconfig.exclude).toEqual(expect.arrayContaining(['node_modules', 'test.js', 'test.d.ts']));
120
+ });
121
+
122
+ it('fails friendly on a conflicting subpath mapping and leaves package.json untouched', async () => {
123
+ writeFixture({
124
+ name: '@test/subpath-fixture',
125
+ version: '1.0.0',
126
+ exports: { './test': './dist/somewhere-else.js' },
127
+ });
128
+ fs.mkdirSync(path.join(pkgDir, 'dist'));
129
+ fs.writeFileSync(path.join(pkgDir, 'dist', 'somewhere-else.js'), 'module.exports = {};\n');
130
+ const before = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8');
131
+
132
+ await expect(build()).rejects.toThrow(/--fix/);
133
+ expect(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).toBe(before);
134
+ });
135
+
136
+ it('is idempotent: a second build changes nothing', async () => {
137
+ writeFixture({ name: '@test/subpath-fixture', version: '1.0.0', files: ['dist/**'] });
138
+ await build();
139
+ const packageJsonAfterFirst = fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8');
140
+ const stubAfterFirst = fs.readFileSync(path.join(pkgDir, 'test.js'), 'utf-8');
141
+ const tsconfigAfterFirst = fs.readFileSync(path.join(pkgDir, 'tsconfig.json'), 'utf-8');
142
+
143
+ await build();
144
+ expect(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).toBe(packageJsonAfterFirst);
145
+ expect(fs.readFileSync(path.join(pkgDir, 'test.js'), 'utf-8')).toBe(stubAfterFirst);
146
+ expect(fs.readFileSync(path.join(pkgDir, 'tsconfig.json'), 'utf-8')).toBe(tsconfigAfterFirst);
147
+ });
148
+ });