@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
|
@@ -77,10 +77,10 @@ async function sourceRepositoryLoader(
|
|
|
77
77
|
|
|
78
78
|
// Allow multiple roots (e.g., ['test','src']) to keep ancestry intact.
|
|
79
79
|
const roots = Array.isArray(sourceRootsRel) ? sourceRootsRel : [sourceRootsRel];
|
|
80
|
-
const sourceGraph: Graph = await
|
|
80
|
+
const sourceGraph: Graph = await createEmittedSourceGraph(packageDir, roots);
|
|
81
81
|
|
|
82
|
-
code += generateSourceGraph(sourceGraph
|
|
83
|
-
code += generateSourceLinks(sourceGraph, packageJson, generatedIndexPath);
|
|
82
|
+
code += generateSourceGraph(sourceGraph);
|
|
83
|
+
code += generateSourceLinks(sourceGraph, packageJson, packageDir, generatedIndexPath);
|
|
84
84
|
code += mergeSourceGraph();
|
|
85
85
|
return code;
|
|
86
86
|
}
|
|
@@ -138,19 +138,39 @@ function loadDependencySourceGraphs(packageDir: string, packageJson: any): strin
|
|
|
138
138
|
* and correct for workspace symlinks (the symlinked package root carries its own dist).
|
|
139
139
|
*/
|
|
140
140
|
export function dependencyHasSourceGraph(packageDir: string, packageName: string): boolean {
|
|
141
|
+
return !!findDependencySourceGraph(packageDir, packageName);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Full path to the dependency's source-graph artifact, or undefined when the dependency is
|
|
146
|
+
* not installed or not reflection-built. Same walk as `dependencyHasSourceGraph`; exposed for
|
|
147
|
+
* static graph harvesting (reflection-doctor).
|
|
148
|
+
*/
|
|
149
|
+
export function findDependencySourceGraph(packageDir: string, packageName: string): string | undefined {
|
|
150
|
+
const dependencyDir = findDependencyDir(packageDir, packageName);
|
|
151
|
+
if (!dependencyDir) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const artifactPath = path.join(dependencyDir, 'dist', 'generated', 'index.js');
|
|
156
|
+
return fs.existsSync(artifactPath) ? artifactPath : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The installed location of a dependency, resolved by walking `node_modules` up from the
|
|
161
|
+
* consuming package — npm-layout resolution: the nearest installed copy shadows hoisted ones.
|
|
162
|
+
*/
|
|
163
|
+
export function findDependencyDir(packageDir: string, packageName: string): string | undefined {
|
|
141
164
|
let currentDir = packageDir;
|
|
142
|
-
// Upward fs walk; exits are internal (found /
|
|
165
|
+
// Upward fs walk; exits are internal (found / fs root).
|
|
143
166
|
for (;;) {
|
|
144
167
|
const candidate = path.join(currentDir, 'node_modules', packageName);
|
|
145
|
-
if (fs.existsSync(path.join(candidate, 'dist', 'generated', 'index.js'))) {
|
|
146
|
-
return true;
|
|
147
|
-
}
|
|
148
168
|
if (fs.existsSync(candidate)) {
|
|
149
|
-
return
|
|
169
|
+
return candidate;
|
|
150
170
|
}
|
|
151
171
|
const parent = path.dirname(currentDir);
|
|
152
172
|
if (parent === currentDir) {
|
|
153
|
-
return
|
|
173
|
+
return undefined;
|
|
154
174
|
}
|
|
155
175
|
currentDir = parent;
|
|
156
176
|
}
|
|
@@ -210,8 +230,19 @@ function getDependencyImportSpecifier(packageDir: string, packageName: string):
|
|
|
210
230
|
}
|
|
211
231
|
}
|
|
212
232
|
|
|
213
|
-
|
|
214
|
-
|
|
233
|
+
/**
|
|
234
|
+
* The graph exactly as a build emits it: parsed from the package's source roots, with
|
|
235
|
+
* build-time-non-loadable declarations pruned. Owned here so build emit and diagnostic
|
|
236
|
+
* tooling (reflection-doctor drift checks) share one pipeline.
|
|
237
|
+
*/
|
|
238
|
+
export async function createEmittedSourceGraph(packageDir: string, sourceRootsRel: string[]): Promise<Graph> {
|
|
239
|
+
const packageJson = await getPackageJson(packageDir);
|
|
240
|
+
const sourceGraph = await createSourceGraph(packageDir, [], sourceRootsRel);
|
|
241
|
+
removeNonLoadables(sourceGraph, packageJson.name);
|
|
242
|
+
return sourceGraph;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function generateSourceGraph(sourceGraph: Graph): string {
|
|
215
246
|
let code = `\n\n/** Generate Source Graph */\n\n`;
|
|
216
247
|
const serializedSourceGraph = graphSerializer.serialize(sourceGraph);
|
|
217
248
|
const doubleEscapedSerializedSourceGraph = jsesc(serializedSourceGraph, { json: true }); // since we write to file, need to escape a second time
|
|
@@ -288,7 +319,12 @@ function removeNonLoadableNode(
|
|
|
288
319
|
return shouldRemove;
|
|
289
320
|
}
|
|
290
321
|
|
|
291
|
-
function generateSourceLinks(
|
|
322
|
+
function generateSourceLinks(
|
|
323
|
+
sourceGraph: Graph,
|
|
324
|
+
packageJson: any,
|
|
325
|
+
packageDir: string,
|
|
326
|
+
generatedIndexPath: string
|
|
327
|
+
): string {
|
|
292
328
|
let code = `\n\n/** Generate Source Links */\n\n`;
|
|
293
329
|
const linkableNodes: PackageScope[] = [];
|
|
294
330
|
for (const nodeName of sourceGraph.nodes()) {
|
|
@@ -309,7 +345,9 @@ function generateSourceLinks(sourceGraph: Graph, packageJson: any, generatedInde
|
|
|
309
345
|
continue;
|
|
310
346
|
}
|
|
311
347
|
|
|
312
|
-
|
|
348
|
+
// node.filePath is package-relative (never serialized absolute); resolve against the
|
|
349
|
+
// package dir to compute the real import location for the generated index.
|
|
350
|
+
const relativeImportPath = path.relative(path.dirname(generatedIndexPath), path.join(packageDir, node.filePath));
|
|
313
351
|
code += `import { ${node.name} } from '${relativeImportPath.replace(/\.[^/.]+$/, '')}';\n`;
|
|
314
352
|
linkableNodes.push(node);
|
|
315
353
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
1
2
|
import * as graphlib from '@dagrejs/graphlib';
|
|
2
3
|
import {
|
|
3
4
|
VariableDeclaration as ParserVariableDeclaration,
|
|
@@ -17,6 +18,10 @@ import { createInterfaceDeclaration } from './types/createInterfaceDeclaration';
|
|
|
17
18
|
export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, packageJsonDir: string) {
|
|
18
19
|
const packageName = packageJson.name;
|
|
19
20
|
return async (parsedFile: File): Promise<void> => {
|
|
21
|
+
// Package-relative: the graph is a serialized, shipped artifact — absolute build-machine
|
|
22
|
+
// paths (CI runner paths, local checkout paths) must never enter it. Build-time consumers
|
|
23
|
+
// that need a real location (sourceLink imports) join this with the package dir.
|
|
24
|
+
const filePath = path.relative(packageJsonDir, parsedFile.filePath);
|
|
20
25
|
for (const declaration of parsedFile.declarations) {
|
|
21
26
|
if (!(declaration as any)['isExported']) {
|
|
22
27
|
continue;
|
|
@@ -31,7 +36,7 @@ export function createGraphBuilder(graph: graphlib.Graph, packageJson: any, pack
|
|
|
31
36
|
const variableDeclaration = await createVariableDeclaration(
|
|
32
37
|
declaration as ParserVariableDeclaration,
|
|
33
38
|
packageNameFinder,
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
filePath
|
|
104
109
|
);
|
|
105
110
|
graph.setNode(
|
|
106
111
|
interfaceDeclaration.qualifiedName,
|
|
@@ -9,14 +9,14 @@ export async function createClassDeclaration(
|
|
|
9
9
|
packageNameFinder: PackageNameFinder,
|
|
10
10
|
filePath: string
|
|
11
11
|
): Promise<ClassDeclaration> {
|
|
12
|
-
|
|
12
|
+
// Class-level truth, recorded by the parser from the class node's own `abstract` modifier.
|
|
13
|
+
// Never derive this from members: an abstract class need not declare any abstract member
|
|
14
|
+
// (the 2026-08 MachineAccount mis-stamp — abstract properties, concrete accessors — was
|
|
15
|
+
// emitted `isAbstract: false` by an accessor scan, and the runtime instantiated it).
|
|
16
|
+
const isAbstract = parserClassDeclaration.isAbstract;
|
|
13
17
|
let isStatic = false;
|
|
14
18
|
let visibility: Visibility = 'public';
|
|
15
19
|
for (const accessor of parserClassDeclaration.accessors) {
|
|
16
|
-
if (accessor.isAbstract) {
|
|
17
|
-
isAbstract = true;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
20
|
if (accessor.isStatic) {
|
|
21
21
|
isStatic = true;
|
|
22
22
|
}
|
package/src/runBuild.ts
CHANGED
|
@@ -3,5 +3,11 @@
|
|
|
3
3
|
import { build } from './build';
|
|
4
4
|
|
|
5
5
|
(async function () {
|
|
6
|
-
|
|
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
|
})();
|
package/src/runDoctor.ts
ADDED
|
@@ -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,97 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { createSourceGraph } from '../src/parser/createSourceGraph';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The parser must stamp `isAbstract: true` on a class declared `abstract` — the runtime's
|
|
8
|
+
* only defense against instantiating an abstract class (`objects()` filters on it) is this flag.
|
|
9
|
+
*
|
|
10
|
+
* The real-world mis-stamp (2026-08-21): `MachineAccount`, an `export abstract class` with
|
|
11
|
+
* generics implementing an interface, was emitted with `isAbstract: false` into a generated
|
|
12
|
+
* source graph. Root cause: `createClassDeclaration` derived class abstractness by scanning the
|
|
13
|
+
* class's get/set ACCESSORS for the `abstract` modifier — the vendored typescript-parser never
|
|
14
|
+
* recorded the class-level `abstract` keyword at all. Any abstract class without an abstract
|
|
15
|
+
* accessor member (i.e. nearly all of them, MachineAccount included: abstract PROPERTIES plus
|
|
16
|
+
* concrete getters) was stamped concrete.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PACKAGE_NAME = '@test/machine-account-shape';
|
|
20
|
+
|
|
21
|
+
// Mirrors the real MachineAccount shape: export abstract class, generic interface implemented,
|
|
22
|
+
// abstract properties, concrete get accessors — and NO abstract accessors.
|
|
23
|
+
const MACHINE_ACCOUNT_SHAPE = `
|
|
24
|
+
import { SourceRecordLoader } from '@proteinjs/db';
|
|
25
|
+
import { User } from './User';
|
|
26
|
+
|
|
27
|
+
export abstract class MachineAccount implements SourceRecordLoader<User> {
|
|
28
|
+
abstract id: string;
|
|
29
|
+
abstract email: string;
|
|
30
|
+
abstract accountName: string;
|
|
31
|
+
abstract roles: string[];
|
|
32
|
+
abstract secretName: string;
|
|
33
|
+
|
|
34
|
+
get table(): string {
|
|
35
|
+
return 'user';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get record(): SourceRecordLoader<User>['record'] {
|
|
39
|
+
return {} as SourceRecordLoader<User>['record'];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class ConcreteMachineAccount extends MachineAccount {
|
|
44
|
+
id = '1';
|
|
45
|
+
email = 'machine@example.com';
|
|
46
|
+
accountName = 'machine';
|
|
47
|
+
roles: string[] = [];
|
|
48
|
+
secretName = 'machine-secret';
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
|
|
52
|
+
const OTHER_ABSTRACT_SHAPES = `
|
|
53
|
+
export abstract class PlainAbstract {
|
|
54
|
+
abstract run(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
abstract class UnexportedAbstract {
|
|
58
|
+
abstract run(): void;
|
|
59
|
+
}
|
|
60
|
+
export { UnexportedAbstract };
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
describe('parser stamps class-level isAbstract', () => {
|
|
64
|
+
let dir: string;
|
|
65
|
+
let graph: any;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'abstract-class-declaration-'));
|
|
69
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: PACKAGE_NAME }));
|
|
70
|
+
fs.mkdirSync(path.join(dir, 'src'));
|
|
71
|
+
fs.writeFileSync(path.join(dir, 'src', 'MachineAccount.ts'), MACHINE_ACCOUNT_SHAPE);
|
|
72
|
+
fs.writeFileSync(path.join(dir, 'src', 'OtherAbstractShapes.ts'), OTHER_ABSTRACT_SHAPES);
|
|
73
|
+
graph = await createSourceGraph(dir);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
fs.rmdirSync(dir, { recursive: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('abstract class with generics implementing an interface (the MachineAccount shape) is stamped isAbstract: true', () => {
|
|
81
|
+
const machineAccount = graph.node(`${PACKAGE_NAME}/MachineAccount`);
|
|
82
|
+
expect(machineAccount).toBeTruthy();
|
|
83
|
+
expect(machineAccount.isAbstract).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('plain export abstract class is stamped isAbstract: true', () => {
|
|
87
|
+
const plainAbstract = graph.node(`${PACKAGE_NAME}/PlainAbstract`);
|
|
88
|
+
expect(plainAbstract).toBeTruthy();
|
|
89
|
+
expect(plainAbstract.isAbstract).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('concrete subclass is stamped isAbstract: false', () => {
|
|
93
|
+
const concrete = graph.node(`${PACKAGE_NAME}/ConcreteMachineAccount`);
|
|
94
|
+
expect(concrete).toBeTruthy();
|
|
95
|
+
expect(concrete.isAbstract).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -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
|
+
});
|