@platformos/platformos-graph 0.0.19 → 0.0.20
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 +13 -0
- package/bin/platformos-graph +21 -81
- package/dist/cli.d.ts +46 -0
- package/dist/cli.js +126 -0
- package/dist/cli.js.map +1 -0
- package/dist/graph/augment.js +0 -1
- package/dist/graph/augment.js.map +1 -1
- package/dist/graph/module.d.ts +16 -1
- package/dist/graph/module.js +39 -0
- package/dist/graph/module.js.map +1 -1
- package/dist/graph/test-helpers.d.ts +2 -3
- package/dist/graph/test-helpers.js +2 -6
- package/dist/graph/test-helpers.js.map +1 -1
- package/dist/graph/traverse.d.ts +31 -3
- package/dist/graph/traverse.js +146 -41
- package/dist/graph/traverse.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +14 -19
- package/dist/types.js.map +1 -1
- package/fixtures/background-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/background-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/background-edges/app/views/partials/jobs/notify.liquid +3 -0
- package/fixtures/function-edges/app/lib/queries/list.liquid +3 -0
- package/fixtures/function-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/function-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/graphql-edges/app/graphql/blog_posts/find.graphql +10 -0
- package/fixtures/graphql-edges/app/views/pages/broken.liquid +3 -0
- package/fixtures/graphql-edges/app/views/pages/index.liquid +3 -0
- package/fixtures/include-edges/app/views/pages/index.liquid +1 -0
- package/fixtures/include-edges/app/views/partials/shared/header.liquid +1 -0
- package/fixtures/module-edges/app/views/pages/index.liquid +4 -0
- package/fixtures/module-edges/modules/my_module/public/lib/queries/get.liquid +3 -0
- package/fixtures/module-edges/modules/my_module/public/views/partials/card.liquid +1 -0
- package/fixtures/skeleton/app/views/partials/child.liquid +2 -4
- package/fixtures/skeleton/app/views/partials/parent.liquid +2 -4
- package/fixtures/skeleton/assets/app.js +1 -7
- package/package.json +5 -5
- package/src/cli.spec.ts +265 -0
- package/src/cli.ts +151 -0
- package/src/graph/augment.ts +0 -2
- package/src/graph/build.spec.ts +19 -5
- package/src/graph/extract.spec.ts +155 -0
- package/src/graph/module.ts +40 -0
- package/src/graph/test-helpers.ts +2 -9
- package/src/graph/traverse-edges.spec.ts +278 -0
- package/src/graph/traverse.ts +177 -49
- package/src/index.ts +1 -1
- package/src/types.ts +16 -18
- package/bin/jsconfig.json +0 -18
- package/src/getWebComponentMap.ts +0 -81
package/src/cli.spec.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import nodePath from 'node:path';
|
|
2
|
+
import { path } from '@platformos/platformos-check-common';
|
|
3
|
+
import { AbstractFileSystem, FileType } from '@platformos/platformos-common';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { URI } from 'vscode-uri';
|
|
6
|
+
import { buildSerializedFileDependencies, buildSerializedGraph, nodeFileSystem } from './cli';
|
|
7
|
+
import { skeleton } from './graph/test-helpers';
|
|
8
|
+
import { LiquidModuleKind, ModuleType } from './types';
|
|
9
|
+
|
|
10
|
+
const skeletonPath = path.fsPath(skeleton);
|
|
11
|
+
const p = (rel: string) => path.join(skeleton, ...rel.split('/'));
|
|
12
|
+
const fsPathOf = (rel: string) => nodePath.join(skeletonPath, ...rel.split('/'));
|
|
13
|
+
|
|
14
|
+
describe('platformos-graph CLI: buildSerializedGraph', () => {
|
|
15
|
+
it('serializes the full node and edge set for a project path', async () => {
|
|
16
|
+
const graph = await buildSerializedGraph(skeletonPath);
|
|
17
|
+
|
|
18
|
+
// Root is the project path re-expressed as a file URI.
|
|
19
|
+
expect(graph.rootUri).toBe(URI.file(skeletonPath).toString(true));
|
|
20
|
+
|
|
21
|
+
// Whole node set, sorted by URI so the assertion is order-independent.
|
|
22
|
+
expect([...graph.nodes].sort((a, b) => a.uri.localeCompare(b.uri))).toEqual([
|
|
23
|
+
{
|
|
24
|
+
uri: p('app/views/layouts/application.liquid'),
|
|
25
|
+
type: ModuleType.Liquid,
|
|
26
|
+
kind: LiquidModuleKind.Layout,
|
|
27
|
+
exists: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
uri: p('app/views/pages/index.liquid'),
|
|
31
|
+
type: ModuleType.Liquid,
|
|
32
|
+
kind: LiquidModuleKind.Page,
|
|
33
|
+
exists: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
uri: p('app/views/partials/child.liquid'),
|
|
37
|
+
type: ModuleType.Liquid,
|
|
38
|
+
kind: LiquidModuleKind.Partial,
|
|
39
|
+
exists: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
uri: p('app/views/partials/header.liquid'),
|
|
43
|
+
type: ModuleType.Liquid,
|
|
44
|
+
kind: LiquidModuleKind.Partial,
|
|
45
|
+
exists: true,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
uri: p('app/views/partials/parent.liquid'),
|
|
49
|
+
type: ModuleType.Liquid,
|
|
50
|
+
kind: LiquidModuleKind.Partial,
|
|
51
|
+
exists: true,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
uri: p('assets/app.css'),
|
|
55
|
+
type: ModuleType.Asset,
|
|
56
|
+
kind: 'unused',
|
|
57
|
+
exists: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
uri: p('assets/app.js'),
|
|
61
|
+
type: ModuleType.Asset,
|
|
62
|
+
kind: 'unused',
|
|
63
|
+
exists: true,
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
// Whole edge set, reduced to (source, target, type, kind) and sorted, so an
|
|
68
|
+
// extra, missing, or mis-kinded edge fails the assertion.
|
|
69
|
+
const edges = graph.edges
|
|
70
|
+
.map((edge) => ({
|
|
71
|
+
source: edge.source.uri,
|
|
72
|
+
target: edge.target.uri,
|
|
73
|
+
type: edge.type,
|
|
74
|
+
kind: edge.kind,
|
|
75
|
+
}))
|
|
76
|
+
.sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target));
|
|
77
|
+
|
|
78
|
+
expect(edges).toEqual([
|
|
79
|
+
{
|
|
80
|
+
source: p('app/views/layouts/application.liquid'),
|
|
81
|
+
target: p('app/views/partials/header.liquid'),
|
|
82
|
+
type: 'direct',
|
|
83
|
+
kind: 'render',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
source: p('app/views/layouts/application.liquid'),
|
|
87
|
+
target: p('assets/app.css'),
|
|
88
|
+
type: 'direct',
|
|
89
|
+
kind: 'asset',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
source: p('app/views/layouts/application.liquid'),
|
|
93
|
+
target: p('assets/app.js'),
|
|
94
|
+
type: 'direct',
|
|
95
|
+
kind: 'asset',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
source: p('app/views/pages/index.liquid'),
|
|
99
|
+
target: p('app/views/partials/parent.liquid'),
|
|
100
|
+
type: 'direct',
|
|
101
|
+
kind: 'render',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
source: p('app/views/partials/header.liquid'),
|
|
105
|
+
target: p('app/views/partials/child.liquid'),
|
|
106
|
+
type: 'direct',
|
|
107
|
+
kind: 'render',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
source: p('app/views/partials/parent.liquid'),
|
|
111
|
+
target: p('app/views/partials/child.liquid'),
|
|
112
|
+
type: 'direct',
|
|
113
|
+
kind: 'render',
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('platformos-graph CLI: nodeFileSystem', () => {
|
|
120
|
+
it('reads a file by URI', async () => {
|
|
121
|
+
const uri = path.join(skeleton, 'assets', 'app.js');
|
|
122
|
+
expect(await nodeFileSystem.readFile(uri)).toBe(
|
|
123
|
+
'// Skeleton app bundle (asset reference target).\n',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('stats a directory and a file', async () => {
|
|
128
|
+
expect(await nodeFileSystem.stat(path.join(skeleton, 'assets'))).toEqual({
|
|
129
|
+
type: FileType.Directory,
|
|
130
|
+
size: expect.any(Number),
|
|
131
|
+
});
|
|
132
|
+
expect(await nodeFileSystem.stat(path.join(skeleton, 'assets', 'app.css'))).toEqual({
|
|
133
|
+
type: FileType.File,
|
|
134
|
+
size: expect.any(Number),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('lists a directory as [childUri, FileType] tuples', async () => {
|
|
139
|
+
const entries = await nodeFileSystem.readDirectory(path.join(skeleton, 'assets'));
|
|
140
|
+
expect([...entries].sort((a, b) => a[0].localeCompare(b[0]))).toEqual([
|
|
141
|
+
[path.join(skeleton, 'assets', 'app.css'), FileType.File],
|
|
142
|
+
[path.join(skeleton, 'assets', 'app.js'), FileType.File],
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('platformos-graph CLI: buildSerializedFileDependencies', () => {
|
|
148
|
+
const edge = (ref: {
|
|
149
|
+
source: { uri: string };
|
|
150
|
+
target: { uri: string };
|
|
151
|
+
type: string;
|
|
152
|
+
kind?: string;
|
|
153
|
+
}) => ({
|
|
154
|
+
source: ref.source.uri,
|
|
155
|
+
target: ref.target.uri,
|
|
156
|
+
type: ref.type,
|
|
157
|
+
kind: ref.kind,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns the outgoing and incoming edges of a single file', async () => {
|
|
161
|
+
const result = await buildSerializedFileDependencies(
|
|
162
|
+
skeletonPath,
|
|
163
|
+
fsPathOf('app/views/partials/parent.liquid'),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(result.uri).toBe(p('app/views/partials/parent.liquid'));
|
|
167
|
+
expect(result.dependencies.map(edge)).toEqual([
|
|
168
|
+
{
|
|
169
|
+
source: p('app/views/partials/parent.liquid'),
|
|
170
|
+
target: p('app/views/partials/child.liquid'),
|
|
171
|
+
type: 'direct',
|
|
172
|
+
kind: 'render',
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
expect(result.references.map(edge)).toEqual([
|
|
176
|
+
{
|
|
177
|
+
source: p('app/views/pages/index.liquid'),
|
|
178
|
+
target: p('app/views/partials/parent.liquid'),
|
|
179
|
+
type: 'direct',
|
|
180
|
+
kind: 'render',
|
|
181
|
+
},
|
|
182
|
+
]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns no dependencies for a leaf partial and every incoming reference', async () => {
|
|
186
|
+
const result = await buildSerializedFileDependencies(
|
|
187
|
+
skeletonPath,
|
|
188
|
+
fsPathOf('app/views/partials/child.liquid'),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result.uri).toBe(p('app/views/partials/child.liquid'));
|
|
192
|
+
expect(result.dependencies).toEqual([]);
|
|
193
|
+
expect(result.references.map(edge).sort((a, b) => a.source.localeCompare(b.source))).toEqual([
|
|
194
|
+
{
|
|
195
|
+
source: p('app/views/partials/header.liquid'),
|
|
196
|
+
target: p('app/views/partials/child.liquid'),
|
|
197
|
+
type: 'direct',
|
|
198
|
+
kind: 'render',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
source: p('app/views/partials/parent.liquid'),
|
|
202
|
+
target: p('app/views/partials/child.liquid'),
|
|
203
|
+
type: 'direct',
|
|
204
|
+
kind: 'render',
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('resolves a relative file argument against the project root, not the cwd', async () => {
|
|
210
|
+
const result = await buildSerializedFileDependencies(
|
|
211
|
+
skeletonPath,
|
|
212
|
+
'app/views/partials/parent.liquid',
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(result.uri).toBe(p('app/views/partials/parent.liquid'));
|
|
216
|
+
expect(result.dependencies.map(edge)).toEqual([
|
|
217
|
+
{
|
|
218
|
+
source: p('app/views/partials/parent.liquid'),
|
|
219
|
+
target: p('app/views/partials/child.liquid'),
|
|
220
|
+
type: 'direct',
|
|
221
|
+
kind: 'render',
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('throws for a file that is not part of the app graph', async () => {
|
|
227
|
+
const missing = fsPathOf('app/views/partials/nonexistent.liquid');
|
|
228
|
+
const missingUri = path.normalize(URI.file(missing));
|
|
229
|
+
const rootUri = path.normalize(URI.file(skeletonPath));
|
|
230
|
+
|
|
231
|
+
const error = await buildSerializedFileDependencies(skeletonPath, missing).catch((e) => e);
|
|
232
|
+
expect(error).toBeInstanceOf(Error);
|
|
233
|
+
expect((error as Error).message).toBe(
|
|
234
|
+
`File is not part of the app graph: ${missingUri}\n` +
|
|
235
|
+
`It must exist and be reachable from a layout or page entry point. ` +
|
|
236
|
+
`Check the path is correct and inside the project root (${rootUri}).`,
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('platformos-graph CLI: project-root validation', () => {
|
|
242
|
+
/**
|
|
243
|
+
* A filesystem where nothing exists. Injected so `findRoot` walks up from the
|
|
244
|
+
* given path entirely in memory — no real disk, no dependency on what sits
|
|
245
|
+
* above the OS temp dir — and deterministically finds no project marker.
|
|
246
|
+
*/
|
|
247
|
+
const emptyFs: AbstractFileSystem = {
|
|
248
|
+
stat: () => Promise.reject(new Error('ENOENT')),
|
|
249
|
+
readFile: () => Promise.reject(new Error('ENOENT')),
|
|
250
|
+
readDirectory: () => Promise.resolve([]),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
it('throws instead of emitting an empty graph for a non-platformOS directory', async () => {
|
|
254
|
+
const dir = nodePath.resolve('no-such-project', 'sub');
|
|
255
|
+
const startUri = path.normalize(URI.file(dir));
|
|
256
|
+
|
|
257
|
+
const error = await buildSerializedGraph(dir, emptyFs).catch((e) => e);
|
|
258
|
+
expect(error).toBeInstanceOf(Error);
|
|
259
|
+
expect((error as Error).message).toBe(
|
|
260
|
+
`Not a platformOS project: ${startUri}\n` +
|
|
261
|
+
`No app/, modules/, .pos, or .platformos-check.yml found at or above this path. ` +
|
|
262
|
+
`Pass the path to a platformOS app directory.`,
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import nodePath from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { findRoot, makeFileExists, path } from '@platformos/platformos-check-common';
|
|
4
|
+
import { AbstractFileSystem, FileStat, FileTuple, FileType } from '@platformos/platformos-common';
|
|
5
|
+
import { URI } from 'vscode-uri';
|
|
6
|
+
import { buildAppGraph } from './graph/build';
|
|
7
|
+
import { serializeAppGraph } from './graph/serialize';
|
|
8
|
+
import { Reference, SerializableGraph } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A self-contained {@link AbstractFileSystem} backed by `node:fs`.
|
|
12
|
+
*
|
|
13
|
+
* The graph driver speaks file URIs, so every method converts the incoming URI
|
|
14
|
+
* to a native path with `path.fsPath` and re-emits child URIs with `path.join`
|
|
15
|
+
* (which preserves the `file://` scheme). This mirrors `NodeFileSystem` from
|
|
16
|
+
* `@platformos/platformos-check-node` exactly, but is inlined here so the CLI
|
|
17
|
+
* runs with only this package's own dependencies — `platformos-check-node` is a
|
|
18
|
+
* dev-only dependency and must not be required at runtime.
|
|
19
|
+
*/
|
|
20
|
+
export const nodeFileSystem: AbstractFileSystem = {
|
|
21
|
+
async readFile(uri: string): Promise<string> {
|
|
22
|
+
return fs.readFile(path.fsPath(uri), 'utf8');
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async readDirectory(uri: string): Promise<FileTuple[]> {
|
|
26
|
+
const entries = await fs.readdir(path.fsPath(uri), { withFileTypes: true });
|
|
27
|
+
return entries.map((entry) => [
|
|
28
|
+
path.join(uri, entry.name),
|
|
29
|
+
entry.isDirectory() ? FileType.Directory : FileType.File,
|
|
30
|
+
]);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
async stat(uri: string): Promise<FileStat> {
|
|
34
|
+
try {
|
|
35
|
+
const stats = await fs.stat(path.fsPath(uri));
|
|
36
|
+
return {
|
|
37
|
+
type: stats.isDirectory() ? FileType.Directory : FileType.File,
|
|
38
|
+
size: stats.size,
|
|
39
|
+
};
|
|
40
|
+
} catch (e) {
|
|
41
|
+
throw new Error(`Failed to get file stat: ${e}`);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The dependency and reference edges of a single module, as produced for the
|
|
48
|
+
* CLI's single-file mode.
|
|
49
|
+
*/
|
|
50
|
+
export interface SerializableFileDependencies {
|
|
51
|
+
/** The resolved file URI, as keyed in the graph. */
|
|
52
|
+
uri: string;
|
|
53
|
+
/** Outgoing edges — the modules this file renders/includes/runs/queries. */
|
|
54
|
+
dependencies: Reference[];
|
|
55
|
+
/** Incoming edges — the modules that render/include/run/query this file. */
|
|
56
|
+
references: Reference[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolves the given root path (absolute, or relative to `process.cwd()`) to the
|
|
61
|
+
* forward-slash-normalized `file://` URI of the enclosing platformOS project,
|
|
62
|
+
* using the same `findRoot` heuristic as the language server (an `app/`,
|
|
63
|
+
* `modules/`, `.pos`, or `.platformos-check.yml` marker at or above the path).
|
|
64
|
+
*
|
|
65
|
+
* Throws when no project is found, so a typo'd or non-platformOS directory fails
|
|
66
|
+
* loudly instead of silently producing an empty graph. Resolving via `findRoot`
|
|
67
|
+
* also lets the CLI be pointed anywhere inside a project, not just at its root.
|
|
68
|
+
*
|
|
69
|
+
* Normalization is load-bearing: the graph keys every module via `path.join` /
|
|
70
|
+
* `path.normalize` (forward slashes), so any lookup must key the same way or it
|
|
71
|
+
* silently misses on Windows, where raw URIs keep backslashes.
|
|
72
|
+
*/
|
|
73
|
+
async function resolveProjectRoot(root: string, fs: AbstractFileSystem): Promise<string> {
|
|
74
|
+
const absolute = nodePath.isAbsolute(root) ? root : nodePath.resolve(process.cwd(), root);
|
|
75
|
+
const startUri = path.normalize(URI.file(absolute));
|
|
76
|
+
|
|
77
|
+
const rootUri = await findRoot(startUri, makeFileExists(fs));
|
|
78
|
+
if (!rootUri) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Not a platformOS project: ${startUri}\n` +
|
|
81
|
+
`No app/, modules/, .pos, or .platformos-check.yml found at or above this path. ` +
|
|
82
|
+
`Pass the path to a platformOS app directory.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
return rootUri;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves the single-file argument to a graph module key. An absolute path is
|
|
90
|
+
* taken as-is; a relative path is resolved against the project **root** (not
|
|
91
|
+
* `process.cwd()`), matching the natural "a file within this project" mental
|
|
92
|
+
* model — only files under the root can appear in the graph anyway.
|
|
93
|
+
*/
|
|
94
|
+
function resolveFileUri(rootUri: string, file: string): string {
|
|
95
|
+
if (nodePath.isAbsolute(file)) {
|
|
96
|
+
return path.normalize(URI.file(file));
|
|
97
|
+
}
|
|
98
|
+
return path.join(rootUri, ...file.split(/[\\/]+/).filter(Boolean));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Builds the platformOS app graph for the project rooted at `root` (a native
|
|
103
|
+
* filesystem path, absolute or relative to `process.cwd()`) and returns it in
|
|
104
|
+
* serializable JSON form.
|
|
105
|
+
*
|
|
106
|
+
* `fs` is injectable for testing; it defaults to the node-backed filesystem.
|
|
107
|
+
*/
|
|
108
|
+
export async function buildSerializedGraph(
|
|
109
|
+
root: string,
|
|
110
|
+
fs: AbstractFileSystem = nodeFileSystem,
|
|
111
|
+
): Promise<SerializableGraph> {
|
|
112
|
+
const graph = await buildAppGraph(await resolveProjectRoot(root, fs), { fs });
|
|
113
|
+
return serializeAppGraph(graph);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Builds the full app graph for `root` and returns the dependency and reference
|
|
118
|
+
* edges of the single module at `file`.
|
|
119
|
+
*
|
|
120
|
+
* The graph is built from its real entry points (every layout and page), not
|
|
121
|
+
* seeded from `file`, so that incoming `references` are complete — the same
|
|
122
|
+
* approach the language server's `AppGraphManager` takes. A file that is not
|
|
123
|
+
* reachable from any entry point therefore has no node in the graph; rather
|
|
124
|
+
* than reporting a misleading empty edge set, this throws.
|
|
125
|
+
*
|
|
126
|
+
* `fs` is injectable for testing; it defaults to the node-backed filesystem.
|
|
127
|
+
*/
|
|
128
|
+
export async function buildSerializedFileDependencies(
|
|
129
|
+
root: string,
|
|
130
|
+
file: string,
|
|
131
|
+
fs: AbstractFileSystem = nodeFileSystem,
|
|
132
|
+
): Promise<SerializableFileDependencies> {
|
|
133
|
+
const rootUri = await resolveProjectRoot(root, fs);
|
|
134
|
+
const fileUri = resolveFileUri(rootUri, file);
|
|
135
|
+
const graph = await buildAppGraph(rootUri, { fs });
|
|
136
|
+
|
|
137
|
+
const module = graph.modules[fileUri];
|
|
138
|
+
if (!module) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`File is not part of the app graph: ${fileUri}\n` +
|
|
141
|
+
`It must exist and be reachable from a layout or page entry point. ` +
|
|
142
|
+
`Check the path is correct and inside the project root (${rootUri}).`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
uri: module.uri,
|
|
148
|
+
dependencies: module.dependencies,
|
|
149
|
+
references: module.references,
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/graph/augment.ts
CHANGED
package/src/graph/build.spec.ts
CHANGED
|
@@ -11,7 +11,7 @@ describe('Module: index', () => {
|
|
|
11
11
|
let dependencies: Dependencies;
|
|
12
12
|
|
|
13
13
|
beforeAll(async () => {
|
|
14
|
-
dependencies =
|
|
14
|
+
dependencies = getDependencies();
|
|
15
15
|
}, 15000);
|
|
16
16
|
|
|
17
17
|
describe('Unit: buildAppGraph', { timeout: 10000 }, () => {
|
|
@@ -64,12 +64,10 @@ describe('Module: index', () => {
|
|
|
64
64
|
assert(parentPartial.type === ModuleType.Liquid);
|
|
65
65
|
assert(parentPartial.kind === LiquidModuleKind.Partial);
|
|
66
66
|
|
|
67
|
-
// outgoing links
|
|
67
|
+
// outgoing links — parent renders exactly one partial (child)
|
|
68
68
|
const deps = parentPartial.dependencies;
|
|
69
69
|
assert(deps.map((x) => x.source.uri).every((x) => x === parentPartial.uri));
|
|
70
|
-
expect(deps.map((x) => x.target.uri)).toEqual(
|
|
71
|
-
expect.arrayContaining([p('app/views/partials/child.liquid'), p('assets/app.js')]),
|
|
72
|
-
);
|
|
70
|
+
expect(deps.map((x) => x.target.uri)).toEqual([p('app/views/partials/child.liquid')]);
|
|
73
71
|
|
|
74
72
|
// {% render 'child' %} dependency
|
|
75
73
|
const parentSource = await dependencies.getSourceCode(
|
|
@@ -103,6 +101,22 @@ describe('Module: index', () => {
|
|
|
103
101
|
]),
|
|
104
102
|
);
|
|
105
103
|
});
|
|
104
|
+
|
|
105
|
+
it('tags every layout edge with its kind (asset, asset, render)', () => {
|
|
106
|
+
const layout = graph.modules[p('app/views/layouts/application.liquid')];
|
|
107
|
+
assert(layout);
|
|
108
|
+
|
|
109
|
+
// The complete, ordered edge set — not a membership probe — so an extra,
|
|
110
|
+
// missing, or mis-kinded edge fails. (Source ranges are pinned by the
|
|
111
|
+
// dedicated render-dependency test above.)
|
|
112
|
+
expect(
|
|
113
|
+
layout.dependencies.map((d) => ({ target: d.target.uri, type: d.type, kind: d.kind })),
|
|
114
|
+
).toEqual([
|
|
115
|
+
{ target: p('assets/app.js'), type: 'direct', kind: 'asset' },
|
|
116
|
+
{ target: p('assets/app.css'), type: 'direct', kind: 'asset' },
|
|
117
|
+
{ target: p('app/views/partials/header.liquid'), type: 'direct', kind: 'render' },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
106
120
|
});
|
|
107
121
|
});
|
|
108
122
|
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { path as pathUtils } from '@platformos/platformos-check-common';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { extractFileReferences } from '../index';
|
|
4
|
+
import { toSourceCode } from '../toSourceCode';
|
|
5
|
+
import { Reference } from '../types';
|
|
6
|
+
import { fixturesRoot, getDependencies } from './test-helpers';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `extractFileReferences` is the per-file primitive a `validate_code`-style
|
|
10
|
+
* consumer uses: parse an in-flight buffer with `toSourceCode`, then resolve its
|
|
11
|
+
* outgoing dependency edges against the on-disk project — WITHOUT building the
|
|
12
|
+
* whole app graph. The buffer is never read from disk, so these specs pass the
|
|
13
|
+
* content inline (and even use a `sourceUri` for a file that does not exist).
|
|
14
|
+
*/
|
|
15
|
+
const { fs } = getDependencies();
|
|
16
|
+
|
|
17
|
+
/** The exact source range of `snippet` within `source`. */
|
|
18
|
+
function rangeOf(source: string, snippet: string): [number, number] {
|
|
19
|
+
const start = source.indexOf(snippet);
|
|
20
|
+
if (start < 0) throw new Error(`snippet not found: ${snippet}`);
|
|
21
|
+
return [start, start + snippet.length];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function directRef(
|
|
25
|
+
sourceUri: string,
|
|
26
|
+
sourceRange: [number, number],
|
|
27
|
+
targetUri: string,
|
|
28
|
+
kind: Reference['kind'],
|
|
29
|
+
): Reference {
|
|
30
|
+
return {
|
|
31
|
+
source: { uri: sourceUri, range: sourceRange },
|
|
32
|
+
target: { uri: targetUri },
|
|
33
|
+
type: 'direct',
|
|
34
|
+
kind,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function extract(rootUri: string, sourceUri: string, content: string) {
|
|
39
|
+
return extractFileReferences(rootUri, sourceUri, await toSourceCode(sourceUri, content), { fs });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('extractFileReferences: resolves a single buffer against the project', () => {
|
|
43
|
+
const rootUri = pathUtils.join(fixturesRoot, 'function-edges');
|
|
44
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
45
|
+
|
|
46
|
+
it('resolves a {% function %} edge to the canonical lib URI', async () => {
|
|
47
|
+
const sourceUri = p('app/views/pages/draft.liquid'); // not on disk — in-flight buffer
|
|
48
|
+
const content = `{% liquid
|
|
49
|
+
function items = 'queries/list'
|
|
50
|
+
%}`;
|
|
51
|
+
|
|
52
|
+
expect(await extract(rootUri, sourceUri, content)).toEqual([
|
|
53
|
+
directRef(
|
|
54
|
+
sourceUri,
|
|
55
|
+
rangeOf(content, "function items = 'queries/list'"),
|
|
56
|
+
p('app/lib/queries/list.liquid'),
|
|
57
|
+
'function',
|
|
58
|
+
),
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('resolves a target that does not exist on disk (path-based resolution)', async () => {
|
|
63
|
+
const sourceUri = p('app/views/pages/draft.liquid');
|
|
64
|
+
const content = `{% liquid
|
|
65
|
+
function ghost = 'queries/missing'
|
|
66
|
+
%}`;
|
|
67
|
+
|
|
68
|
+
expect(await extract(rootUri, sourceUri, content)).toEqual([
|
|
69
|
+
directRef(
|
|
70
|
+
sourceUri,
|
|
71
|
+
rangeOf(content, "function ghost = 'queries/missing'"),
|
|
72
|
+
p('app/lib/queries/missing.liquid'),
|
|
73
|
+
'function',
|
|
74
|
+
),
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('extractFileReferences: kinds and resolution match the full graph build', () => {
|
|
80
|
+
it('tags include edges with kind "include"', async () => {
|
|
81
|
+
const rootUri = pathUtils.join(fixturesRoot, 'include-edges');
|
|
82
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
83
|
+
const sourceUri = p('app/views/pages/draft.liquid');
|
|
84
|
+
const content = "{% include 'shared/header' %}";
|
|
85
|
+
|
|
86
|
+
expect(await extract(rootUri, sourceUri, content)).toEqual([
|
|
87
|
+
directRef(
|
|
88
|
+
sourceUri,
|
|
89
|
+
rangeOf(content, "{% include 'shared/header' %}"),
|
|
90
|
+
p('app/views/partials/shared/header.liquid'),
|
|
91
|
+
'include',
|
|
92
|
+
),
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('resolves a {% graphql %} edge to the .graphql operation file', async () => {
|
|
97
|
+
const rootUri = pathUtils.join(fixturesRoot, 'graphql-edges');
|
|
98
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
99
|
+
const sourceUri = p('app/views/pages/draft.liquid');
|
|
100
|
+
const content = `{% liquid
|
|
101
|
+
graphql posts = 'blog_posts/find', id: '1'
|
|
102
|
+
%}`;
|
|
103
|
+
|
|
104
|
+
expect(await extract(rootUri, sourceUri, content)).toEqual([
|
|
105
|
+
directRef(
|
|
106
|
+
sourceUri,
|
|
107
|
+
rangeOf(content, "graphql posts = 'blog_posts/find', id: '1'"),
|
|
108
|
+
p('app/graphql/blog_posts/find.graphql'),
|
|
109
|
+
'graphql',
|
|
110
|
+
),
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('resolves module-namespaced render + function targets in declaration order', async () => {
|
|
115
|
+
const rootUri = pathUtils.join(fixturesRoot, 'module-edges');
|
|
116
|
+
const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
|
|
117
|
+
const sourceUri = p('app/views/pages/draft.liquid');
|
|
118
|
+
const content = `{% liquid
|
|
119
|
+
function items = 'modules/my_module/queries/get'
|
|
120
|
+
%}
|
|
121
|
+
{% render 'modules/my_module/card' %}`;
|
|
122
|
+
|
|
123
|
+
expect(await extract(rootUri, sourceUri, content)).toEqual([
|
|
124
|
+
directRef(
|
|
125
|
+
sourceUri,
|
|
126
|
+
rangeOf(content, "function items = 'modules/my_module/queries/get'"),
|
|
127
|
+
p('modules/my_module/public/lib/queries/get.liquid'),
|
|
128
|
+
'function',
|
|
129
|
+
),
|
|
130
|
+
directRef(
|
|
131
|
+
sourceUri,
|
|
132
|
+
rangeOf(content, "{% render 'modules/my_module/card' %}"),
|
|
133
|
+
p('modules/my_module/public/views/partials/card.liquid'),
|
|
134
|
+
'render',
|
|
135
|
+
),
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('extractFileReferences: only statically resolvable edges', () => {
|
|
141
|
+
const rootUri = pathUtils.join(fixturesRoot, 'function-edges');
|
|
142
|
+
const sourceUri = pathUtils.join(rootUri, 'app/views/pages/draft.liquid');
|
|
143
|
+
|
|
144
|
+
it('skips dynamic render targets', async () => {
|
|
145
|
+
expect(await extract(rootUri, sourceUri, '{% render partial_name %}')).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns nothing for an unparseable buffer instead of throwing', async () => {
|
|
149
|
+
expect(await extract(rootUri, sourceUri, '{% render %}{% endif %}')).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('returns nothing for a buffer with no references', async () => {
|
|
153
|
+
expect(await extract(rootUri, sourceUri, '<h1>{{ page.title }}</h1>')).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/graph/module.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
AssetModule,
|
|
4
4
|
AppGraph,
|
|
5
5
|
AppModule,
|
|
6
|
+
GraphQLModule,
|
|
6
7
|
LiquidModule,
|
|
7
8
|
LiquidModuleKind,
|
|
8
9
|
ModuleType,
|
|
@@ -88,6 +89,45 @@ export function getPartialModule(appGraph: AppGraph, partial: string): LiquidMod
|
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Create (or fetch the cached) Liquid Partial module for an ALREADY-RESOLVED
|
|
94
|
+
* full URI — used for `{% function %}` / `{% include %}` targets whose URI is
|
|
95
|
+
* resolved canonically by `DocumentsLocator` (which handles lib paths, module
|
|
96
|
+
* prefixes, and extensions). Unlike {@link getPartialModule}, it does not
|
|
97
|
+
* reconstruct the path from a name. Commands/queries/lib helpers are all
|
|
98
|
+
* `Partial` kind, consistent with check-common's file-type classification.
|
|
99
|
+
*/
|
|
100
|
+
export function getPartialModuleByUri(appGraph: AppGraph, uri: string): LiquidModule {
|
|
101
|
+
return module(appGraph, {
|
|
102
|
+
type: ModuleType.Liquid,
|
|
103
|
+
kind: LiquidModuleKind.Partial,
|
|
104
|
+
// Normalize to forward slashes so module keys match the rest of the graph
|
|
105
|
+
// (getPartialModule/getAssetModule build URIs via path.join, which
|
|
106
|
+
// normalizes). DocumentsLocator returns `Utils.joinPath(...).toString()`
|
|
107
|
+
// unnormalized, which on Windows keeps backslashes and breaks key/edge
|
|
108
|
+
// matching against the normalized URIs everywhere else.
|
|
109
|
+
uri: path.normalize(uri),
|
|
110
|
+
dependencies: [],
|
|
111
|
+
references: [],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create (or fetch the cached) GraphQL module for an already-resolved
|
|
117
|
+
* `.graphql` URI — used for `{% graphql op = 'name' %}` targets resolved by
|
|
118
|
+
* `DocumentsLocator`. A leaf node (no outgoing edges).
|
|
119
|
+
*/
|
|
120
|
+
export function getGraphQLModuleByUri(appGraph: AppGraph, uri: string): GraphQLModule {
|
|
121
|
+
return module(appGraph, {
|
|
122
|
+
type: ModuleType.GraphQL,
|
|
123
|
+
kind: 'graphql',
|
|
124
|
+
// Normalize to forward slashes — see getPartialModuleByUri.
|
|
125
|
+
uri: path.normalize(uri),
|
|
126
|
+
dependencies: [],
|
|
127
|
+
references: [],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
91
131
|
export function getLayoutModule(
|
|
92
132
|
appGraph: AppGraph,
|
|
93
133
|
layoutUri: string | false | undefined,
|