@proteinjs/reflection-build 1.5.0 → 2.0.1

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 (97) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/modules/typescript-parser/declarations/ClassDeclaration.d.ts +1 -0
  3. package/dist/modules/typescript-parser/declarations/ClassDeclaration.js +1 -0
  4. package/dist/modules/typescript-parser/node-parser/class-parser.js +6 -0
  5. package/dist/src/BuildContract.d.ts +69 -0
  6. package/dist/src/BuildContract.js +393 -0
  7. package/dist/src/BuildContract.js.map +1 -0
  8. package/dist/src/ReflectionDoctor.d.ts +95 -0
  9. package/dist/src/ReflectionDoctor.js +588 -0
  10. package/dist/src/ReflectionDoctor.js.map +1 -0
  11. package/dist/src/build.d.ts +5 -1
  12. package/dist/src/build.js +13 -77
  13. package/dist/src/build.js.map +1 -1
  14. package/dist/src/codegen/writeGeneratedIndex.d.ts +18 -0
  15. package/dist/src/codegen/writeGeneratedIndex.js +58 -15
  16. package/dist/src/codegen/writeGeneratedIndex.js.map +1 -1
  17. package/dist/src/parser/createGraphBuilder.js +30 -5
  18. package/dist/src/parser/createGraphBuilder.js.map +1 -1
  19. package/dist/src/parser/types/createClassDeclaration.js +1 -4
  20. package/dist/src/parser/types/createClassDeclaration.js.map +1 -1
  21. package/dist/src/runBuild.js +12 -2
  22. package/dist/src/runBuild.js.map +1 -1
  23. package/dist/src/runDoctor.d.ts +2 -0
  24. package/dist/src/runDoctor.js +83 -0
  25. package/dist/src/runDoctor.js.map +1 -0
  26. package/dist/test/AbstractClassDeclaration.test.d.ts +1 -0
  27. package/dist/test/AbstractClassDeclaration.test.js +121 -0
  28. package/dist/test/AbstractClassDeclaration.test.js.map +1 -0
  29. package/dist/test/GraphEmitRelativePaths.test.d.ts +1 -0
  30. package/dist/test/GraphEmitRelativePaths.test.js +162 -0
  31. package/dist/test/GraphEmitRelativePaths.test.js.map +1 -0
  32. package/dist/test/ReflectionDoctor.test.d.ts +1 -0
  33. package/dist/test/ReflectionDoctor.test.js +316 -0
  34. package/dist/test/ReflectionDoctor.test.js.map +1 -0
  35. package/dist/test/TestSubpathContract.test.d.ts +1 -0
  36. package/dist/test/TestSubpathContract.test.js +260 -0
  37. package/dist/test/TestSubpathContract.test.js.map +1 -0
  38. package/dist/test/ValidateDontMutate.test.d.ts +1 -0
  39. package/dist/test/ValidateDontMutate.test.js +238 -0
  40. package/dist/test/ValidateDontMutate.test.js.map +1 -0
  41. package/dist/test/examples/source-repository/a/generated/index.js +1 -1
  42. package/dist/test/examples/source-repository/a/generated/index.js.map +1 -1
  43. package/dist/test/examples/source-repository/b/generated/index.js +1 -1
  44. package/dist/test/examples/source-repository/b/generated/index.js.map +1 -1
  45. package/modules/typescript-parser/declarations/ClassDeclaration.d.ts +1 -0
  46. package/modules/typescript-parser/declarations/ClassDeclaration.js +1 -0
  47. package/modules/typescript-parser/node-parser/class-parser.js +6 -0
  48. package/package.json +6 -5
  49. package/src/BuildContract.ts +384 -0
  50. package/src/ReflectionDoctor.ts +589 -0
  51. package/src/build.ts +19 -56
  52. package/src/codegen/writeGeneratedIndex.ts +51 -13
  53. package/src/parser/createGraphBuilder.ts +9 -4
  54. package/src/parser/types/createClassDeclaration.ts +5 -5
  55. package/src/runBuild.ts +7 -1
  56. package/src/runDoctor.ts +34 -0
  57. package/test/AbstractClassDeclaration.test.ts +97 -0
  58. package/test/GraphEmitRelativePaths.test.ts +95 -0
  59. package/test/ReflectionDoctor.test.ts +193 -0
  60. package/test/TestSubpathContract.test.ts +148 -0
  61. package/test/ValidateDontMutate.test.ts +126 -0
  62. package/test/examples/source-repository/a/dist/BuildContract.d.ts +69 -0
  63. package/test/examples/source-repository/a/dist/BuildContract.js +393 -0
  64. package/test/examples/source-repository/a/dist/BuildContract.js.map +1 -0
  65. package/test/examples/source-repository/a/dist/build.d.ts +5 -1
  66. package/test/examples/source-repository/a/dist/build.js +13 -77
  67. package/test/examples/source-repository/a/dist/build.js.map +1 -1
  68. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.d.ts +18 -0
  69. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js +58 -15
  70. package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js.map +1 -1
  71. package/test/examples/source-repository/a/dist/generated/index.js +1 -1
  72. package/test/examples/source-repository/a/dist/generated/index.js.map +1 -1
  73. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js +30 -5
  74. package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js.map +1 -1
  75. package/test/examples/source-repository/a/dist/parser/types/createClassDeclaration.js +1 -4
  76. package/test/examples/source-repository/a/dist/parser/types/createClassDeclaration.js.map +1 -1
  77. package/test/examples/source-repository/a/dist/runBuild.js +12 -2
  78. package/test/examples/source-repository/a/dist/runBuild.js.map +1 -1
  79. package/test/examples/source-repository/a/generated/index.ts +1 -1
  80. package/test/examples/source-repository/b/dist/BuildContract.d.ts +69 -0
  81. package/test/examples/source-repository/b/dist/BuildContract.js +393 -0
  82. package/test/examples/source-repository/b/dist/BuildContract.js.map +1 -0
  83. package/test/examples/source-repository/b/dist/build.d.ts +5 -1
  84. package/test/examples/source-repository/b/dist/build.js +13 -77
  85. package/test/examples/source-repository/b/dist/build.js.map +1 -1
  86. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.d.ts +18 -0
  87. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js +58 -15
  88. package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js.map +1 -1
  89. package/test/examples/source-repository/b/dist/generated/index.js +1 -1
  90. package/test/examples/source-repository/b/dist/generated/index.js.map +1 -1
  91. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js +30 -5
  92. package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js.map +1 -1
  93. package/test/examples/source-repository/b/dist/parser/types/createClassDeclaration.js +1 -4
  94. package/test/examples/source-repository/b/dist/parser/types/createClassDeclaration.js.map +1 -1
  95. package/test/examples/source-repository/b/dist/runBuild.js +12 -2
  96. package/test/examples/source-repository/b/dist/runBuild.js.map +1 -1
  97. package/test/examples/source-repository/b/generated/index.ts +1 -1
@@ -0,0 +1,589 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import globby from 'globby';
4
+ import { TypescriptParser } from '../modules/typescript-parser';
5
+ import { createEmittedSourceGraph, findDependencyDir, findDependencySourceGraph } from './codegen/writeGeneratedIndex';
6
+
7
+ const LOADABLE_QUALIFIED_NAME = '@proteinjs/reflection/Loadable';
8
+ const SOURCE_REPOSITORY_FILTER_QUALIFIED_NAME = '@proteinjs/reflection/SourceRepositoryFilter';
9
+
10
+ export interface DoctorReport {
11
+ text: string;
12
+ healthy: boolean;
13
+ dependencies: {
14
+ /** reflection-built deps whose graphs load at runtime */
15
+ loaded: string[];
16
+ /** declared deps that ship no graph (normal for non-reflection deps) */
17
+ withoutGraph: string[];
18
+ /** deps opted out via SKIP_REFLECTION_LOAD */
19
+ skipped: string[];
20
+ };
21
+ nodeCount: number;
22
+ edgeCount: number;
23
+ sourceLinkCount: number;
24
+ drift: {
25
+ /** declarations in current sources missing from the built artifact (stale dist) */
26
+ missingFromDist: string[];
27
+ /** declarations in the built artifact no longer in sources (stale dist) */
28
+ onlyInDist: string[];
29
+ };
30
+ orphans: Orphan[];
31
+ }
32
+
33
+ export interface Orphan {
34
+ qualifiedName: string;
35
+ unresolvedParent: string;
36
+ cause: string;
37
+ }
38
+
39
+ export interface Explanation {
40
+ text: string;
41
+ verdict: string;
42
+ }
43
+
44
+ interface GraphJson {
45
+ nodes: { v: string; value?: any }[];
46
+ edges: { v: string; w: string; value?: string }[];
47
+ }
48
+
49
+ interface Universe {
50
+ /** package name -> harvested graph + artifact location (own package included) */
51
+ graphs: { [packageName: string]: { graph: GraphJson; artifactPath: string } };
52
+ /** qualifiedName -> owning package, for nodes with values (placeholders excluded) */
53
+ valued: { [qualifiedName: string]: string };
54
+ }
55
+
56
+ /**
57
+ * reflection-doctor (task #104 §3.1): purely analytical diagnostics for a reflection-built
58
+ * package, run from the package dir. Executes NO dependency code — its own graph and every
59
+ * dependency graph are harvested statically from `dist/generated/index.js` artifacts (the
60
+ * same upward node_modules walk the build's import gate uses, transitively, mirroring what
61
+ * the runtime would merge).
62
+ *
63
+ * - `diagnose()` prints the registered inventory (deps with graphs / without / skip-listed),
64
+ * this package's node+edge+sourceLink counts, emitted-vs-source drift (stale dist), and
65
+ * ORPHANS: nodes the build kept because a parent is foreign, whose parent's package
66
+ * resolves to no loadable graph — the undeclared-dep / typo footgun that the runtime
67
+ * silently prunes today.
68
+ * - `explain(name)` prints the parent-edge resolution trace for one declaration — how (or
69
+ * why not) it becomes Loadable — naming the broken hop and its cause.
70
+ */
71
+ export class ReflectionDoctor {
72
+ private readonly skipList: string[];
73
+
74
+ constructor(
75
+ private readonly packageDir: string,
76
+ options: { skipList?: string[] } = {}
77
+ ) {
78
+ this.skipList = options.skipList || [];
79
+ }
80
+
81
+ async diagnose(): Promise<DoctorReport> {
82
+ const packageJson = this.readPackageJson(this.packageDir);
83
+ const universe = this.harvestUniverse(packageJson);
84
+ const own = universe.graphs[packageJson.name];
85
+ const dependencies = this.dependencyInventory(packageJson);
86
+ const drift = await this.computeDrift(packageJson, own.graph);
87
+ const orphans = this.findOrphans(packageJson, universe);
88
+ const sourceLinkCount = this.parseSourceLinkKeys(fs.readFileSync(own.artifactPath, 'utf-8')).length;
89
+
90
+ const healthy = orphans.length === 0 && drift.missingFromDist.length === 0 && drift.onlyInDist.length === 0;
91
+ const report: DoctorReport = {
92
+ text: '',
93
+ healthy,
94
+ dependencies,
95
+ nodeCount: own.graph.nodes.length,
96
+ edgeCount: own.graph.edges.length,
97
+ sourceLinkCount,
98
+ drift,
99
+ orphans,
100
+ };
101
+ report.text = this.formatReport(packageJson.name, own.artifactPath, report);
102
+ return report;
103
+ }
104
+
105
+ async explain(nameOrQualifiedName: string): Promise<Explanation> {
106
+ const packageJson = this.readPackageJson(this.packageDir);
107
+ const universe = this.harvestUniverse(packageJson);
108
+ const qualifiedName = this.resolveTarget(nameOrQualifiedName, packageJson.name, universe);
109
+ if (!qualifiedName) {
110
+ return await this.explainNotFound(nameOrQualifiedName, packageJson);
111
+ }
112
+
113
+ const node = this.findValuedNode(qualifiedName, universe);
114
+ const lines: string[] = [];
115
+ const kind = this.describeKind(node?.value);
116
+ const filePath = node?.value?.filePath ? `, ${node.value.filePath}` : '';
117
+ lines.push(`${qualifiedName} (${kind}${filePath})`);
118
+
119
+ const outcome = this.trace(qualifiedName, universe, lines, '', {});
120
+ let verdict: string;
121
+ if (outcome.loadableVia) {
122
+ verdict = `VERDICT: Loadable — returned by objects('${outcome.loadableVia}')`;
123
+ const linkWarning = this.checkSourceLink(qualifiedName, node, packageJson.name, universe);
124
+ if (linkWarning) {
125
+ verdict += `\n${linkWarning}`;
126
+ }
127
+ } else if (outcome.brokenHop) {
128
+ verdict = `VERDICT: NOT Loadable — broken hop: ${outcome.brokenHop}`;
129
+ } else {
130
+ verdict =
131
+ `VERDICT: NOT Loadable — no parent chain reaches ${LOADABLE_QUALIFIED_NAME}. ` +
132
+ `Every parent resolved; this type simply is not Loadable (the runtime prunes it, by design).`;
133
+ }
134
+
135
+ lines.push(verdict);
136
+ return { text: lines.join('\n'), verdict };
137
+ }
138
+
139
+ /**
140
+ * Recursive parent-edge trace. Returns the first Loadable-reaching direct parent (for the
141
+ * objects() verdict) and the first broken hop encountered, if any.
142
+ */
143
+ private trace(
144
+ qualifiedName: string,
145
+ universe: Universe,
146
+ lines: string[],
147
+ indent: string,
148
+ visited: { [qualifiedName: string]: boolean },
149
+ depth: number = 0
150
+ ): { loadableVia?: string; brokenHop?: string } {
151
+ if (visited[qualifiedName]) {
152
+ return {};
153
+ }
154
+ visited[qualifiedName] = true;
155
+
156
+ const owningPackage = universe.valued[qualifiedName];
157
+ const graph = universe.graphs[owningPackage]?.graph;
158
+ if (!graph) {
159
+ return {};
160
+ }
161
+
162
+ const outEdges = graph.edges.filter((edge) => edge.v === qualifiedName);
163
+ let loadableVia: string | undefined;
164
+ let brokenHop: string | undefined;
165
+ for (const edge of outEdges) {
166
+ const relationship = edge.value || 'extends';
167
+ const parent = edge.w;
168
+
169
+ if (parent === LOADABLE_QUALIFIED_NAME || parent === SOURCE_REPOSITORY_FILTER_QUALIFIED_NAME) {
170
+ lines.push(`${indent}└─ ${relationship} ${parent} [OK]`);
171
+ loadableVia = loadableVia || (depth === 0 ? parent : qualifiedName);
172
+ continue;
173
+ }
174
+
175
+ const parentPackage = parent.substring(0, parent.lastIndexOf('/'));
176
+ if (universe.valued[parent]) {
177
+ lines.push(`${indent}└─ ${relationship} ${parent} [edge OK]`);
178
+ if (parentPackage !== owningPackage) {
179
+ const artifactPath = universe.graphs[parentPackage].artifactPath;
180
+ lines.push(`${indent} └─ graph ${parentPackage}: LOADED (${path.relative(this.packageDir, artifactPath)})`);
181
+ }
182
+ const parentOutcome = this.trace(parent, universe, lines, `${indent} `, visited, depth + 1);
183
+ if (parentOutcome.loadableVia) {
184
+ loadableVia = loadableVia || (depth === 0 ? parent : qualifiedName);
185
+ }
186
+ brokenHop = brokenHop || parentOutcome.brokenHop;
187
+ continue;
188
+ }
189
+
190
+ // Unresolved parent — name the broken hop.
191
+ const cause = this.classifyUnresolvedParent(parent, parentPackage, owningPackage, universe);
192
+ lines.push(`${indent}└─ ${relationship} ${parent} [BROKEN]`);
193
+ lines.push(`${indent} └─ ${cause.detail}`);
194
+ brokenHop = brokenHop || `${parent} — ${cause.summary}`;
195
+ }
196
+
197
+ return { loadableVia, brokenHop };
198
+ }
199
+
200
+ private classifyUnresolvedParent(
201
+ parent: string,
202
+ parentPackage: string,
203
+ owningPackage: string,
204
+ universe: Universe
205
+ ): { summary: string; detail: string } {
206
+ if (!this.isResolvableQualifiedName(parent)) {
207
+ const summary =
208
+ 'inline type expression captured as a parent — never graph-resolvable (hierarchy edges to inline types carry no package); harmless unless this was meant to be a named type';
209
+ return { summary, detail: summary };
210
+ }
211
+
212
+ if (!parentPackage) {
213
+ const summary = 'global-scope parent (no package) — not resolvable in any graph';
214
+ return { summary, detail: summary };
215
+ }
216
+
217
+ if (universe.graphs[parentPackage]) {
218
+ const summary =
219
+ `${parentPackage}'s graph is loaded but does not declare it — not Loadable there ` +
220
+ `(pruned at its build), not exported, or ${parentPackage}'s dist is stale; rebuild ${parentPackage}`;
221
+ return { summary, detail: `graph ${parentPackage}: LOADED, declaration not found — ${summary}` };
222
+ }
223
+
224
+ const cause = this.classifyPackage(parentPackage, this.readPackageJson(this.packageDir));
225
+ return { summary: cause, detail: `graph ${parentPackage}: NOT LOADED — ${cause}` };
226
+ }
227
+
228
+ /** Why a package contributes no graph, from the consuming package's point of view. */
229
+ private classifyPackage(packageName: string, packageJson: any): string {
230
+ if (this.skipList.includes(packageName)) {
231
+ return `skip-listed via SKIP_REFLECTION_LOAD`;
232
+ }
233
+
234
+ const declared = !!(packageJson.dependencies && packageJson.dependencies[packageName]);
235
+ if (!declared) {
236
+ return `${packageName} is not a declared dependency of ${packageJson.name} — undeclared dep or typo (an undeclared workspace package resolves to a stale registry copy at install time)`;
237
+ }
238
+
239
+ const dependencyDir = findDependencyDir(this.packageDir, packageName);
240
+ if (!dependencyDir) {
241
+ return `${packageName} is declared but not installed`;
242
+ }
243
+
244
+ return `${packageName} is installed but not reflection-built (no dist/generated/index.js)`;
245
+ }
246
+
247
+ /**
248
+ * Orphans: nodes this package's build kept (foreign parent ⇒ benefit of the doubt), whose
249
+ * parent's package contributes NO graph at runtime — the runtime will silently prune them.
250
+ * Parents living in loaded graphs that simply aren't Loadable are not orphans (that pruning
251
+ * is by design); `explain` covers those.
252
+ */
253
+ private findOrphans(packageJson: any, universe: Universe): Orphan[] {
254
+ const own = universe.graphs[packageJson.name].graph;
255
+ const orphans: Orphan[] = [];
256
+ for (const edge of own.edges) {
257
+ const child = edge.v;
258
+ const parent = edge.w;
259
+ if (!universe.valued[child] || universe.valued[child] !== packageJson.name) {
260
+ continue;
261
+ }
262
+ if (universe.valued[parent]) {
263
+ continue;
264
+ }
265
+ if (parent === LOADABLE_QUALIFIED_NAME || parent === SOURCE_REPOSITORY_FILTER_QUALIFIED_NAME) {
266
+ continue;
267
+ }
268
+
269
+ if (!this.isResolvableQualifiedName(parent)) {
270
+ // Inline type expression captured as a parent name (e.g. a union literal with a doc
271
+ // comment). Never graph-resolvable by design — not an orphan signal.
272
+ continue;
273
+ }
274
+
275
+ const parentPackage = parent.substring(0, parent.lastIndexOf('/'));
276
+ if (!parentPackage || parentPackage === packageJson.name || universe.graphs[parentPackage]) {
277
+ continue;
278
+ }
279
+
280
+ orphans.push({
281
+ qualifiedName: child,
282
+ unresolvedParent: parent,
283
+ cause: this.classifyPackage(parentPackage, packageJson),
284
+ });
285
+ }
286
+
287
+ return orphans;
288
+ }
289
+
290
+ /** Emitted-vs-source drift: rebuild-needed detection for the prod graph. */
291
+ private async computeDrift(
292
+ packageJson: any,
293
+ emittedGraph: GraphJson
294
+ ): Promise<{ missingFromDist: string[]; onlyInDist: string[] }> {
295
+ const freshGraph = await createEmittedSourceGraph(this.packageDir, ['src']);
296
+ const freshValued: { [qualifiedName: string]: boolean } = {};
297
+ for (const nodeName of freshGraph.nodes()) {
298
+ if (freshGraph.node(nodeName)) {
299
+ freshValued[nodeName] = true;
300
+ }
301
+ }
302
+
303
+ const emittedValued: { [qualifiedName: string]: boolean } = {};
304
+ for (const node of emittedGraph.nodes) {
305
+ if (node.value) {
306
+ emittedValued[node.v] = true;
307
+ }
308
+ }
309
+
310
+ return {
311
+ missingFromDist: Object.keys(freshValued).filter((qualifiedName) => !emittedValued[qualifiedName]),
312
+ onlyInDist: Object.keys(emittedValued).filter((qualifiedName) => !freshValued[qualifiedName]),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Statically harvest this package's graph and, transitively, every reflection-built
318
+ * dependency's graph — the same set the runtime merges.
319
+ */
320
+ private harvestUniverse(packageJson: any): Universe {
321
+ const universe: Universe = { graphs: {}, valued: {} };
322
+
323
+ const ownArtifactPath = path.join(this.packageDir, 'dist', 'generated', 'index.js');
324
+ if (!fs.existsSync(ownArtifactPath)) {
325
+ throw new Error(
326
+ `reflection-doctor: no reflection artifact at ${path.relative(process.cwd(), ownArtifactPath) || '.'} — ` +
327
+ `run the package build (reflection-build && tsc) first.`
328
+ );
329
+ }
330
+ this.addToUniverse(universe, packageJson.name, ownArtifactPath);
331
+
332
+ const pending: { fromDir: string; packageName: string }[] = this.dependencyNames(packageJson).map(
333
+ (packageName) => ({ fromDir: this.packageDir, packageName })
334
+ );
335
+ const seen: { [packageName: string]: boolean } = { [packageJson.name]: true };
336
+ while (pending.length > 0) {
337
+ const { fromDir, packageName } = pending.shift() as { fromDir: string; packageName: string };
338
+ if (seen[packageName] || this.skipList.includes(packageName)) {
339
+ continue;
340
+ }
341
+ seen[packageName] = true;
342
+
343
+ const artifactPath = findDependencySourceGraph(fromDir, packageName);
344
+ if (!artifactPath) {
345
+ continue;
346
+ }
347
+
348
+ this.addToUniverse(universe, packageName, artifactPath);
349
+ const dependencyDir = path.dirname(path.dirname(path.dirname(artifactPath)));
350
+ const dependencyPackageJson = this.readPackageJson(dependencyDir);
351
+ for (const transitive of this.dependencyNames(dependencyPackageJson)) {
352
+ pending.push({ fromDir: dependencyDir, packageName: transitive });
353
+ }
354
+ }
355
+
356
+ return universe;
357
+ }
358
+
359
+ private addToUniverse(universe: Universe, packageName: string, artifactPath: string): void {
360
+ const graph = this.parseArtifactGraph(artifactPath);
361
+ universe.graphs[packageName] = { graph, artifactPath };
362
+ for (const node of graph.nodes) {
363
+ if (node.value && !universe.valued[node.v]) {
364
+ universe.valued[node.v] = packageName;
365
+ }
366
+ }
367
+ }
368
+
369
+ /** Extract the serialized graph from a generated index without executing it. */
370
+ private parseArtifactGraph(artifactPath: string): GraphJson {
371
+ const artifact = fs.readFileSync(artifactPath, 'utf-8');
372
+ const literalMatch = artifact.match(/sourceGraph = ("(?:[^"\\]|\\.)*");/);
373
+ if (!literalMatch) {
374
+ throw new Error(`reflection-doctor: ${artifactPath} does not contain a serialized sourceGraph`);
375
+ }
376
+
377
+ // Same read the runtime performs: the JSON string literal, unescaped once, then parsed.
378
+ return JSON.parse(JSON.parse(literalMatch[1]).replace(/\\'/g, "'"));
379
+ }
380
+
381
+ private parseSourceLinkKeys(artifact: string): string[] {
382
+ const blockMatch = artifact.match(/sourceLinks = \{([\s\S]*?)\};/);
383
+ if (!blockMatch) {
384
+ return [];
385
+ }
386
+
387
+ const keys: string[] = [];
388
+ const keyPattern = /'([^']+)':/g;
389
+ let match: RegExpExecArray | null;
390
+ while ((match = keyPattern.exec(blockMatch[1])) !== null) {
391
+ keys.push(match[1]);
392
+ }
393
+ return keys;
394
+ }
395
+
396
+ private dependencyInventory(packageJson: any): { loaded: string[]; withoutGraph: string[]; skipped: string[] } {
397
+ const loaded: string[] = [];
398
+ const withoutGraph: string[] = [];
399
+ const skipped: string[] = [];
400
+ for (const packageName of this.dependencyNames(packageJson)) {
401
+ if (this.skipList.includes(packageName)) {
402
+ skipped.push(packageName);
403
+ } else if (findDependencySourceGraph(this.packageDir, packageName)) {
404
+ loaded.push(packageName);
405
+ } else {
406
+ withoutGraph.push(packageName);
407
+ }
408
+ }
409
+ return { loaded, withoutGraph, skipped };
410
+ }
411
+
412
+ private resolveTarget(nameOrQualifiedName: string, ownPackageName: string, universe: Universe): string | undefined {
413
+ if (universe.valued[nameOrQualifiedName]) {
414
+ return nameOrQualifiedName;
415
+ }
416
+
417
+ // Bare name: prefer this package's declaration, then any package's.
418
+ const ownMatch = `${ownPackageName}/${nameOrQualifiedName}`;
419
+ if (universe.valued[ownMatch]) {
420
+ return ownMatch;
421
+ }
422
+
423
+ const suffix = `/${nameOrQualifiedName}`;
424
+ return Object.keys(universe.valued).find((qualifiedName) => qualifiedName.endsWith(suffix));
425
+ }
426
+
427
+ /** The declaration is in no harvested graph — diagnose why from this package's sources. */
428
+ private async explainNotFound(nameOrQualifiedName: string, packageJson: any): Promise<Explanation> {
429
+ const name = nameOrQualifiedName.includes('/')
430
+ ? nameOrQualifiedName.substring(nameOrQualifiedName.lastIndexOf('/') + 1)
431
+ : nameOrQualifiedName;
432
+
433
+ const freshGraph = await createEmittedSourceGraph(this.packageDir, ['src']);
434
+ const freshMatch = freshGraph
435
+ .nodes()
436
+ .find((qualifiedName: string) => freshGraph.node(qualifiedName) && qualifiedName.endsWith(`/${name}`));
437
+ if (freshMatch) {
438
+ const verdict =
439
+ `VERDICT: NOT in the built artifact — ${freshMatch} is in current sources but not in ` +
440
+ `dist/generated (stale dist); rebuild this package (reflection-build && tsc).`;
441
+ return { text: verdict, verdict };
442
+ }
443
+
444
+ const declaration = await this.findDeclarationInSources(name);
445
+ if (declaration && !declaration.isExported) {
446
+ const verdict =
447
+ `VERDICT: NOT in the graph — ${name} is declared in ${declaration.filePath} but not exported; ` +
448
+ `the parser only registers exported declarations. Export it to make it graph-visible.`;
449
+ return { text: verdict, verdict };
450
+ }
451
+
452
+ const verdict =
453
+ `VERDICT: NOT FOUND — no declaration named '${name}' in ${packageJson.name}'s scanned sources (src) ` +
454
+ `or any loaded dependency graph. Check the spelling, or whether it lives outside the scanned source roots.`;
455
+ return { text: verdict, verdict };
456
+ }
457
+
458
+ /** Raw parser pass over src — sees non-exported declarations the graph never contains. */
459
+ private async findDeclarationInSources(name: string): Promise<{ filePath: string; isExported: boolean } | undefined> {
460
+ const patterns = [
461
+ path.join(this.packageDir, 'src', '**/*.ts'),
462
+ path.join(this.packageDir, 'src', '**/*.tsx'),
463
+ '!**/node_modules/**',
464
+ '!**/generated/**',
465
+ ];
466
+ const sourceFilePaths: string[] = await globby(patterns);
467
+ const parser = new TypescriptParser();
468
+ for (const sourceFilePath of sourceFilePaths) {
469
+ const parsedFile = await parser.parseFile(sourceFilePath, path.dirname(sourceFilePath));
470
+ for (const declaration of parsedFile.declarations) {
471
+ if (declaration.name === name) {
472
+ return {
473
+ filePath: path.relative(this.packageDir, sourceFilePath),
474
+ isExported: !!(declaration as any).isExported,
475
+ };
476
+ }
477
+ }
478
+ }
479
+ return undefined;
480
+ }
481
+
482
+ private checkSourceLink(
483
+ qualifiedName: string,
484
+ node: { value?: any } | undefined,
485
+ ownPackageName: string,
486
+ universe: Universe
487
+ ): string | undefined {
488
+ // Only concrete classes/variables of the examined package are instantiated via links.
489
+ if (universe.valued[qualifiedName] !== ownPackageName) {
490
+ return undefined;
491
+ }
492
+
493
+ const sourceType = node?.value?.sourceType;
494
+ const isClass = sourceType === 2;
495
+ const isVariable = sourceType === 0;
496
+ if (!isClass && !isVariable) {
497
+ return undefined;
498
+ }
499
+ if (isClass && node?.value?.isAbstract) {
500
+ return undefined;
501
+ }
502
+
503
+ const artifact = fs.readFileSync(universe.graphs[ownPackageName].artifactPath, 'utf-8');
504
+ if (this.parseSourceLinkKeys(artifact).includes(qualifiedName)) {
505
+ return undefined;
506
+ }
507
+
508
+ return (
509
+ `WARNING: Loadable but has no sourceLink in the artifact — objects() cannot instantiate it. ` +
510
+ `The dist/generated artifact is stale; rebuild this package.`
511
+ );
512
+ }
513
+
514
+ private findValuedNode(qualifiedName: string, universe: Universe): { value?: any } | undefined {
515
+ const owningPackage = universe.valued[qualifiedName];
516
+ if (!owningPackage) {
517
+ return undefined;
518
+ }
519
+ return universe.graphs[owningPackage].graph.nodes.find((node) => node.v === qualifiedName);
520
+ }
521
+
522
+ private describeKind(value: any): string {
523
+ const kinds = ['variable', 'type alias', 'class', 'interface'];
524
+ return (typeof value?.sourceType === 'number' && kinds[value.sourceType]) || 'declaration';
525
+ }
526
+
527
+ private formatReport(packageName: string, artifactPath: string, report: DoctorReport): string {
528
+ const lines: string[] = [];
529
+ lines.push(`reflection-doctor: ${packageName}`);
530
+ lines.push('');
531
+ lines.push(`Dependency graphs:`);
532
+ lines.push(` loaded (${report.dependencies.loaded.length}): ${report.dependencies.loaded.join(', ') || '—'}`);
533
+ lines.push(
534
+ ` no graph (${report.dependencies.withoutGraph.length}): ${report.dependencies.withoutGraph.join(', ') || '—'} [not reflection-built — normal for non-reflection deps]`
535
+ );
536
+ if (report.dependencies.skipped.length > 0) {
537
+ lines.push(
538
+ ` skip-listed (${report.dependencies.skipped.length}): ${report.dependencies.skipped.join(', ')} [SKIP_REFLECTION_LOAD]`
539
+ );
540
+ }
541
+ lines.push('');
542
+ lines.push(
543
+ `This package's graph: ${path.relative(this.packageDir, artifactPath)} — ` +
544
+ `${report.nodeCount} nodes, ${report.edgeCount} edges, ${report.sourceLinkCount} source links`
545
+ );
546
+
547
+ if (report.drift.missingFromDist.length === 0 && report.drift.onlyInDist.length === 0) {
548
+ lines.push(`Drift vs sources: in sync`);
549
+ } else {
550
+ lines.push(`Drift vs sources: STALE dist/generated — rebuild this package (reflection-build && tsc)`);
551
+ for (const qualifiedName of report.drift.missingFromDist) {
552
+ lines.push(` in sources, not in artifact: ${qualifiedName}`);
553
+ }
554
+ for (const qualifiedName of report.drift.onlyInDist) {
555
+ lines.push(` in artifact, not in sources: ${qualifiedName}`);
556
+ }
557
+ }
558
+
559
+ lines.push('');
560
+ if (report.orphans.length === 0) {
561
+ lines.push(`Orphans: none — every foreign parent resolves in a loaded graph`);
562
+ } else {
563
+ lines.push(`Orphans (kept at build, silently pruned at runtime — parent resolves in NO loaded graph):`);
564
+ for (const orphan of report.orphans) {
565
+ lines.push(` ${orphan.qualifiedName} -> ${orphan.unresolvedParent}`);
566
+ lines.push(` ${orphan.cause}`);
567
+ }
568
+ }
569
+
570
+ return lines.join('\n');
571
+ }
572
+
573
+ /** True for shapes a qualified name can take; inline type expressions (unions, literals, comments) fail this. */
574
+ private isResolvableQualifiedName(qualifiedName: string): boolean {
575
+ return !/[\s{}|()<>&'"]/.test(qualifiedName);
576
+ }
577
+
578
+ private dependencyNames(packageJson: any): string[] {
579
+ return Object.keys(packageJson.dependencies || {});
580
+ }
581
+
582
+ private readPackageJson(dir: string): any {
583
+ const packageJsonPath = path.join(dir, 'package.json');
584
+ if (!fs.existsSync(packageJsonPath)) {
585
+ throw new Error(`Unable to find package.json in dir: ${dir}`);
586
+ }
587
+ return JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
588
+ }
589
+ }
package/src/build.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import * as path from 'path';
2
- const tsconfig = require('./tsconfigTemplate.json');
3
- import { promisifiedFs } from '@proteinjs/util-node';
4
2
  import { writeGeneratedIndex } from './codegen/writeGeneratedIndex';
3
+ import { BuildContract } from './BuildContract';
5
4
 
6
- export async function build() {
5
+ export interface BuildOptions {
6
+ /** Write conflicting package config to the contract instead of failing (reflection-build --fix) */
7
+ fix?: boolean;
8
+ }
9
+
10
+ export async function build(options: BuildOptions = {}) {
7
11
  const targetDir = process.env.INIT_CWD as string;
8
- const targetDirTsconfig = path.join(targetDir, 'tsconfig.json');
9
12
 
10
13
  // Env is ONLY read here.
11
14
  // Allow multiple comma-separated roots, e.g. "test,src" or "integration,examples,src".
@@ -35,57 +38,17 @@ export async function build() {
35
38
 
36
39
  const generatedIndex = path.join(targetDirGenerated, 'index.ts');
37
40
 
38
- await updatePackageJson();
39
- await writeTsconfig();
40
- await writeGeneratedIndex(targetDir, targetDirGenerated, generatedIndex, sourceRoots, publicEntryRelOverride);
41
-
42
- // TODO save their index location in package.json and pass it in to writeGeneratedIndex above
43
- async function updatePackageJson() {
44
- const targetDirPackageJson = path.join(targetDir, 'package.json');
45
- if (!(await promisifiedFs.exists(targetDirPackageJson))) {
46
- throw new Error(`package.json does not exist, run \`npm init -y\` to create one`);
47
- }
48
-
49
- const targetPackageJson = require(targetDirPackageJson);
50
- const originalPackageJsonString = JSON.stringify(targetPackageJson, null, 2);
51
-
52
- // Only update main/types when primary root is 'src'. Prevents infinite toggle.
53
- const isProd = primaryRoot === 'src';
54
- if (isProd) {
55
- const targetDirDist = path.join(targetDir, distDirRel);
41
+ // Validate-don't-mutate: the package-config contract (main/types, subpath exports +
42
+ // typesVersions + root stubs, tsconfig include/excludes) is validated before any parse
43
+ // work — absent config is completed, conflicting config fails the build friendly.
44
+ const contract = new BuildContract({
45
+ packageDir: targetDir,
46
+ primaryRoot,
47
+ distDirRel,
48
+ generatedIndexPath: generatedIndex,
49
+ fix: !!options.fix,
50
+ });
51
+ await contract.apply();
56
52
 
57
- // Rel path to /generated (may be './generated' or './generated/src' depending on primaryRoot)
58
- const relGenPath =
59
- primaryRoot && primaryRoot !== 'src'
60
- ? path.relative(targetDir, path.join(targetDir, 'generated', primaryRoot))
61
- : path.relative(targetDir, path.join(targetDir, 'generated'));
62
-
63
- const targetDirDistGenerated = path.join(targetDirDist, relGenPath);
64
- const generatedIndexJs = path.join(targetDirDistGenerated, 'index.js');
65
- const generatedIndexDts = path.join(targetDirDistGenerated, 'index.d.ts');
66
-
67
- targetPackageJson.main = `./${path.relative(targetDir, generatedIndexJs)}`;
68
- targetPackageJson.types = `./${path.relative(targetDir, generatedIndexDts)}`;
69
- }
70
-
71
- const updatedPackageJsonString = JSON.stringify(targetPackageJson, null, 2);
72
- if (originalPackageJsonString !== updatedPackageJsonString) {
73
- await promisifiedFs.writeFile(targetDirPackageJson, updatedPackageJsonString);
74
- }
75
- }
76
-
77
- async function writeTsconfig() {
78
- if (await promisifiedFs.exists(targetDirTsconfig)) {
79
- const existingTsconfig = require(targetDirTsconfig);
80
- const includePath = `./${path.relative(targetDir, generatedIndex)}`;
81
- if (existingTsconfig.include && !existingTsconfig.include.includes(includePath)) {
82
- existingTsconfig.include.push(includePath);
83
- await promisifiedFs.writeFile(targetDirTsconfig, JSON.stringify(existingTsconfig, null, 4));
84
- }
85
- } else {
86
- const generatedTsconfig = Object.assign({}, tsconfig);
87
- generatedTsconfig.include = [`./${path.relative(targetDir, generatedIndex)}`];
88
- await promisifiedFs.writeFile(targetDirTsconfig, JSON.stringify(generatedTsconfig, null, 4));
89
- }
90
- }
53
+ await writeGeneratedIndex(targetDir, targetDirGenerated, generatedIndex, sourceRoots, publicEntryRelOverride);
91
54
  }