@proteinjs/reflection-build 1.4.12 → 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 (88) hide show
  1. package/CHANGELOG.md +45 -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 +25 -0
  12. package/dist/src/codegen/writeGeneratedIndex.js +89 -13
  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/DependencySourceGraphGate.test.d.ts +1 -0
  22. package/dist/test/DependencySourceGraphGate.test.js +78 -0
  23. package/dist/test/DependencySourceGraphGate.test.js.map +1 -0
  24. package/dist/test/GraphEmitRelativePaths.test.d.ts +1 -0
  25. package/dist/test/GraphEmitRelativePaths.test.js +162 -0
  26. package/dist/test/GraphEmitRelativePaths.test.js.map +1 -0
  27. package/dist/test/ReflectionDoctor.test.d.ts +1 -0
  28. package/dist/test/ReflectionDoctor.test.js +316 -0
  29. package/dist/test/ReflectionDoctor.test.js.map +1 -0
  30. package/dist/test/TestSubpathContract.test.d.ts +1 -0
  31. package/dist/test/TestSubpathContract.test.js +260 -0
  32. package/dist/test/TestSubpathContract.test.js.map +1 -0
  33. package/dist/test/ValidateDontMutate.test.d.ts +1 -0
  34. package/dist/test/ValidateDontMutate.test.js +238 -0
  35. package/dist/test/ValidateDontMutate.test.js.map +1 -0
  36. package/dist/test/examples/source-repository/a/generated/index.d.ts +0 -8
  37. package/dist/test/examples/source-repository/a/generated/index.js +1 -9
  38. package/dist/test/examples/source-repository/a/generated/index.js.map +1 -1
  39. package/dist/test/examples/source-repository/b/generated/index.d.ts +0 -8
  40. package/dist/test/examples/source-repository/b/generated/index.js +1 -9
  41. package/dist/test/examples/source-repository/b/generated/index.js.map +1 -1
  42. package/package.json +5 -4
  43. package/src/BuildContract.ts +384 -0
  44. package/src/ReflectionDoctor.ts +589 -0
  45. package/src/build.ts +19 -56
  46. package/src/codegen/writeGeneratedIndex.ts +80 -9
  47. package/src/parser/createGraphBuilder.ts +9 -4
  48. package/src/runBuild.ts +7 -1
  49. package/src/runDoctor.ts +34 -0
  50. package/test/DependencySourceGraphGate.test.ts +61 -0
  51. package/test/GraphEmitRelativePaths.test.ts +95 -0
  52. package/test/ReflectionDoctor.test.ts +193 -0
  53. package/test/TestSubpathContract.test.ts +148 -0
  54. package/test/ValidateDontMutate.test.ts +126 -0
  55. package/test/examples/source-repository/a/dist/BuildContract.d.ts +69 -0
  56. package/test/examples/source-repository/a/dist/BuildContract.js +393 -0
  57. package/test/examples/source-repository/a/dist/BuildContract.js.map +1 -0
  58. package/test/examples/source-repository/a/dist/build.d.ts +5 -1
  59. package/test/examples/source-repository/a/dist/build.js +13 -77
  60. package/test/examples/source-repository/a/dist/build.js.map +1 -1
  61. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.d.ts +25 -0
  62. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js +89 -13
  63. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js.map +1 -1
  64. package/test/examples/source-repository/a/dist/generated/index.d.ts +0 -8
  65. package/test/examples/source-repository/a/dist/generated/index.js +1 -9
  66. package/test/examples/source-repository/a/dist/generated/index.js.map +1 -1
  67. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js +30 -5
  68. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js.map +1 -1
  69. package/test/examples/source-repository/a/dist/runBuild.js +12 -2
  70. package/test/examples/source-repository/a/dist/runBuild.js.map +1 -1
  71. package/test/examples/source-repository/a/generated/index.ts +1 -9
  72. package/test/examples/source-repository/b/dist/BuildContract.d.ts +69 -0
  73. package/test/examples/source-repository/b/dist/BuildContract.js +393 -0
  74. package/test/examples/source-repository/b/dist/BuildContract.js.map +1 -0
  75. package/test/examples/source-repository/b/dist/build.d.ts +5 -1
  76. package/test/examples/source-repository/b/dist/build.js +13 -77
  77. package/test/examples/source-repository/b/dist/build.js.map +1 -1
  78. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.d.ts +25 -0
  79. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js +89 -13
  80. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js.map +1 -1
  81. package/test/examples/source-repository/b/dist/generated/index.d.ts +0 -8
  82. package/test/examples/source-repository/b/dist/generated/index.js +1 -9
  83. package/test/examples/source-repository/b/dist/generated/index.js.map +1 -1
  84. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js +30 -5
  85. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js.map +1 -1
  86. package/test/examples/source-repository/b/dist/runBuild.js +12 -2
  87. package/test/examples/source-repository/b/dist/runBuild.js.map +1 -1
  88. package/test/examples/source-repository/b/generated/index.ts +1 -10
@@ -1,3 +1,4 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
2
3
  import { promisifiedFs } from '@proteinjs/util-node';
3
4
  import { graphSerializer, isInstanceOf } from '@proteinjs/util';
@@ -76,10 +77,10 @@ async function sourceRepositoryLoader(
76
77
 
77
78
  // Allow multiple roots (e.g., ['test','src']) to keep ancestry intact.
78
79
  const roots = Array.isArray(sourceRootsRel) ? sourceRootsRel : [sourceRootsRel];
79
- const sourceGraph: Graph = await createSourceGraph(packageDir, [], roots);
80
+ const sourceGraph: Graph = await createEmittedSourceGraph(packageDir, roots);
80
81
 
81
- code += generateSourceGraph(sourceGraph, packageJson.name);
82
- code += generateSourceLinks(sourceGraph, packageJson, generatedIndexPath);
82
+ code += generateSourceGraph(sourceGraph);
83
+ code += generateSourceLinks(sourceGraph, packageJson, packageDir, generatedIndexPath);
83
84
  code += mergeSourceGraph();
84
85
  return code;
85
86
  }
@@ -103,12 +104,19 @@ function loadDependencySourceGraphs(packageDir: string, packageJson: any): strin
103
104
 
104
105
  if (packageJson.dependencies) {
105
106
  for (const packageName in packageJson.dependencies) {
106
- // Skip any packages listed in SKIP_REFLECTION_LOAD
107
+ // Skip any packages listed in SKIP_REFLECTION_LOAD (deliberate opt-out for deps that
108
+ // DO carry a graph but should not load in this package's context)
107
109
  if (skipList.includes(packageName)) {
108
110
  continue;
109
111
  }
110
112
 
111
- if (packageName.startsWith('@material-ui')) {
113
+ // Only reflection-built dependencies belong here: the import exists solely to run the
114
+ // dependency's generated index (SourceRepository.merge of its source graph). A dep is
115
+ // reflection-built iff it ships the artifact this same codegen emits —
116
+ // dist/generated/index.js. Everything else (icon sets, ui libs, utilities) contributed
117
+ // nothing at runtime while pinning its entire dependency root into every consumer's
118
+ // bundle (observed: ~19 duplicate FontAwesome set copies across the app bundle).
119
+ if (!dependencyHasSourceGraph(packageDir, packageName)) {
112
120
  continue;
113
121
  }
114
122
 
@@ -123,6 +131,51 @@ function loadDependencySourceGraphs(packageDir: string, packageJson: any): strin
123
131
  return code;
124
132
  }
125
133
 
134
+ /**
135
+ * True iff the dependency ships a reflection source graph (`dist/generated/index.js`, the
136
+ * artifact `writeGeneratedIndex` itself emits). Located by walking `node_modules` up from the
137
+ * consuming package — the npm layout lookup, immune to `exports`-map resolution restrictions
138
+ * and correct for workspace symlinks (the symlinked package root carries its own dist).
139
+ */
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 {
164
+ let currentDir = packageDir;
165
+ // Upward fs walk; exits are internal (found / fs root).
166
+ for (;;) {
167
+ const candidate = path.join(currentDir, 'node_modules', packageName);
168
+ if (fs.existsSync(candidate)) {
169
+ return candidate;
170
+ }
171
+ const parent = path.dirname(currentDir);
172
+ if (parent === currentDir) {
173
+ return undefined;
174
+ }
175
+ currentDir = parent;
176
+ }
177
+ }
178
+
126
179
  /**
127
180
  * Determine an import specifier for a dependency that respects its package.json exports or main/module fields.
128
181
  */
@@ -177,8 +230,19 @@ function getDependencyImportSpecifier(packageDir: string, packageName: string):
177
230
  }
178
231
  }
179
232
 
180
- function generateSourceGraph(sourceGraph: Graph, buildTargetPackageName: string): string {
181
- 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 {
182
246
  let code = `\n\n/** Generate Source Graph */\n\n`;
183
247
  const serializedSourceGraph = graphSerializer.serialize(sourceGraph);
184
248
  const doubleEscapedSerializedSourceGraph = jsesc(serializedSourceGraph, { json: true }); // since we write to file, need to escape a second time
@@ -255,7 +319,12 @@ function removeNonLoadableNode(
255
319
  return shouldRemove;
256
320
  }
257
321
 
258
- 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 {
259
328
  let code = `\n\n/** Generate Source Links */\n\n`;
260
329
  const linkableNodes: PackageScope[] = [];
261
330
  for (const nodeName of sourceGraph.nodes()) {
@@ -276,7 +345,9 @@ function generateSourceLinks(sourceGraph: Graph, packageJson: any, generatedInde
276
345
  continue;
277
346
  }
278
347
 
279
- 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));
280
351
  code += `import { ${node.name} } from '${relativeImportPath.replace(/\.[^/.]+$/, '')}';\n`;
281
352
  linkableNodes.push(node);
282
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,61 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { dependencyHasSourceGraph } from '../src/codegen/writeGeneratedIndex';
5
+
6
+ /**
7
+ * The dependency-import gate (MOBILE_SUPPORT S11 bundle diet): generated indexes import a
8
+ * dependency iff it ships `dist/generated/index.js` — the artifact writeGeneratedIndex
9
+ * itself emits. Outcome tests over a real on-disk npm layout, including the hoisted case.
10
+ */
11
+ describe('dependencyHasSourceGraph', () => {
12
+ let root: string;
13
+
14
+ const mkpkg = (base: string, name: string, withGraph: boolean) => {
15
+ const dir = path.join(base, 'node_modules', name);
16
+ fs.mkdirSync(dir, { recursive: true });
17
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0' }));
18
+ if (withGraph) {
19
+ const gen = path.join(dir, 'dist', 'generated');
20
+ fs.mkdirSync(gen, { recursive: true });
21
+ fs.writeFileSync(path.join(gen, 'index.js'), '// sourceGraph artifact');
22
+ }
23
+ };
24
+
25
+ beforeEach(() => {
26
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'refl-gate-'));
27
+ });
28
+
29
+ afterEach(() => {
30
+ fs.rmdirSync(root, { recursive: true });
31
+ });
32
+
33
+ it('accepts a dependency that ships dist/generated/index.js', () => {
34
+ mkpkg(root, '@scope/reflected-dep', true);
35
+ expect(dependencyHasSourceGraph(root, '@scope/reflected-dep')).toBe(true);
36
+ });
37
+
38
+ it('rejects a resolvable dependency without the artifact (icon sets, ui libs)', () => {
39
+ mkpkg(root, '@fortawesome/pro-solid-svg-icons', false);
40
+ expect(dependencyHasSourceGraph(root, '@fortawesome/pro-solid-svg-icons')).toBe(false);
41
+ });
42
+
43
+ it('finds the artifact through hoisted node_modules (walks up like npm resolution)', () => {
44
+ const consumer = path.join(root, 'node_modules', '@scope', 'consumer');
45
+ fs.mkdirSync(consumer, { recursive: true });
46
+ mkpkg(root, '@scope/hoisted-reflected', true);
47
+ expect(dependencyHasSourceGraph(consumer, '@scope/hoisted-reflected')).toBe(true);
48
+ });
49
+
50
+ it('stops at the nearest resolved copy: a local artifact-less copy shadows a hoisted one', () => {
51
+ const consumer = path.join(root, 'pkg');
52
+ fs.mkdirSync(consumer, { recursive: true });
53
+ mkpkg(consumer, 'dep', false);
54
+ mkpkg(root, 'dep', true);
55
+ expect(dependencyHasSourceGraph(consumer, 'dep')).toBe(false);
56
+ });
57
+
58
+ it('rejects an unresolvable dependency', () => {
59
+ expect(dependencyHasSourceGraph(root, 'not-installed-anywhere')).toBe(false);
60
+ });
61
+ });
@@ -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
+ });