@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.
- package/CHANGELOG.md +40 -0
- package/dist/modules/typescript-parser/declarations/ClassDeclaration.d.ts +1 -0
- package/dist/modules/typescript-parser/declarations/ClassDeclaration.js +1 -0
- package/dist/modules/typescript-parser/node-parser/class-parser.js +6 -0
- package/dist/src/BuildContract.d.ts +69 -0
- package/dist/src/BuildContract.js +393 -0
- package/dist/src/BuildContract.js.map +1 -0
- package/dist/src/ReflectionDoctor.d.ts +95 -0
- package/dist/src/ReflectionDoctor.js +588 -0
- package/dist/src/ReflectionDoctor.js.map +1 -0
- package/dist/src/build.d.ts +5 -1
- package/dist/src/build.js +13 -77
- package/dist/src/build.js.map +1 -1
- package/dist/src/codegen/writeGeneratedIndex.d.ts +18 -0
- package/dist/src/codegen/writeGeneratedIndex.js +58 -15
- package/dist/src/codegen/writeGeneratedIndex.js.map +1 -1
- package/dist/src/parser/createGraphBuilder.js +30 -5
- package/dist/src/parser/createGraphBuilder.js.map +1 -1
- package/dist/src/parser/types/createClassDeclaration.js +1 -4
- package/dist/src/parser/types/createClassDeclaration.js.map +1 -1
- package/dist/src/runBuild.js +12 -2
- package/dist/src/runBuild.js.map +1 -1
- package/dist/src/runDoctor.d.ts +2 -0
- package/dist/src/runDoctor.js +83 -0
- package/dist/src/runDoctor.js.map +1 -0
- package/dist/test/AbstractClassDeclaration.test.d.ts +1 -0
- package/dist/test/AbstractClassDeclaration.test.js +121 -0
- package/dist/test/AbstractClassDeclaration.test.js.map +1 -0
- package/dist/test/GraphEmitRelativePaths.test.d.ts +1 -0
- package/dist/test/GraphEmitRelativePaths.test.js +162 -0
- package/dist/test/GraphEmitRelativePaths.test.js.map +1 -0
- package/dist/test/ReflectionDoctor.test.d.ts +1 -0
- package/dist/test/ReflectionDoctor.test.js +316 -0
- package/dist/test/ReflectionDoctor.test.js.map +1 -0
- package/dist/test/TestSubpathContract.test.d.ts +1 -0
- package/dist/test/TestSubpathContract.test.js +260 -0
- package/dist/test/TestSubpathContract.test.js.map +1 -0
- package/dist/test/ValidateDontMutate.test.d.ts +1 -0
- package/dist/test/ValidateDontMutate.test.js +238 -0
- package/dist/test/ValidateDontMutate.test.js.map +1 -0
- package/dist/test/examples/source-repository/a/generated/index.js +1 -1
- package/dist/test/examples/source-repository/a/generated/index.js.map +1 -1
- package/dist/test/examples/source-repository/b/generated/index.js +1 -1
- package/dist/test/examples/source-repository/b/generated/index.js.map +1 -1
- package/modules/typescript-parser/declarations/ClassDeclaration.d.ts +1 -0
- package/modules/typescript-parser/declarations/ClassDeclaration.js +1 -0
- package/modules/typescript-parser/node-parser/class-parser.js +6 -0
- package/package.json +6 -5
- package/src/BuildContract.ts +384 -0
- package/src/ReflectionDoctor.ts +589 -0
- package/src/build.ts +19 -56
- package/src/codegen/writeGeneratedIndex.ts +51 -13
- package/src/parser/createGraphBuilder.ts +9 -4
- package/src/parser/types/createClassDeclaration.ts +5 -5
- package/src/runBuild.ts +7 -1
- package/src/runDoctor.ts +34 -0
- package/test/AbstractClassDeclaration.test.ts +97 -0
- package/test/GraphEmitRelativePaths.test.ts +95 -0
- package/test/ReflectionDoctor.test.ts +193 -0
- package/test/TestSubpathContract.test.ts +148 -0
- package/test/ValidateDontMutate.test.ts +126 -0
- package/test/examples/source-repository/a/dist/BuildContract.d.ts +69 -0
- package/test/examples/source-repository/a/dist/BuildContract.js +393 -0
- package/test/examples/source-repository/a/dist/BuildContract.js.map +1 -0
- package/test/examples/source-repository/a/dist/build.d.ts +5 -1
- package/test/examples/source-repository/a/dist/build.js +13 -77
- package/test/examples/source-repository/a/dist/build.js.map +1 -1
- package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.d.ts +18 -0
- package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js +58 -15
- package/test/examples/source-repository/a/dist/codegen/writeGeneratedIndex.js.map +1 -1
- package/test/examples/source-repository/a/dist/generated/index.js +1 -1
- package/test/examples/source-repository/a/dist/generated/index.js.map +1 -1
- package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js +30 -5
- package/test/examples/source-repository/a/dist/parser/createGraphBuilder.js.map +1 -1
- package/test/examples/source-repository/a/dist/parser/types/createClassDeclaration.js +1 -4
- package/test/examples/source-repository/a/dist/parser/types/createClassDeclaration.js.map +1 -1
- package/test/examples/source-repository/a/dist/runBuild.js +12 -2
- package/test/examples/source-repository/a/dist/runBuild.js.map +1 -1
- package/test/examples/source-repository/a/generated/index.ts +1 -1
- package/test/examples/source-repository/b/dist/BuildContract.d.ts +69 -0
- package/test/examples/source-repository/b/dist/BuildContract.js +393 -0
- package/test/examples/source-repository/b/dist/BuildContract.js.map +1 -0
- package/test/examples/source-repository/b/dist/build.d.ts +5 -1
- package/test/examples/source-repository/b/dist/build.js +13 -77
- package/test/examples/source-repository/b/dist/build.js.map +1 -1
- package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.d.ts +18 -0
- package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js +58 -15
- package/test/examples/source-repository/b/dist/codegen/writeGeneratedIndex.js.map +1 -1
- package/test/examples/source-repository/b/dist/generated/index.js +1 -1
- package/test/examples/source-repository/b/dist/generated/index.js.map +1 -1
- package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js +30 -5
- package/test/examples/source-repository/b/dist/parser/createGraphBuilder.js.map +1 -1
- package/test/examples/source-repository/b/dist/parser/types/createClassDeclaration.js +1 -4
- package/test/examples/source-repository/b/dist/parser/types/createClassDeclaration.js.map +1 -1
- package/test/examples/source-repository/b/dist/runBuild.js +12 -2
- package/test/examples/source-repository/b/dist/runBuild.js.map +1 -1
- 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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
}
|