@platformos/platformos-graph 0.0.18 → 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.
Files changed (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/platformos-graph +21 -81
  3. package/dist/cli.d.ts +46 -0
  4. package/dist/cli.js +126 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/graph/augment.js +0 -1
  7. package/dist/graph/augment.js.map +1 -1
  8. package/dist/graph/module.d.ts +16 -1
  9. package/dist/graph/module.js +39 -0
  10. package/dist/graph/module.js.map +1 -1
  11. package/dist/graph/test-helpers.d.ts +2 -3
  12. package/dist/graph/test-helpers.js +2 -6
  13. package/dist/graph/test-helpers.js.map +1 -1
  14. package/dist/graph/traverse.d.ts +31 -3
  15. package/dist/graph/traverse.js +146 -41
  16. package/dist/graph/traverse.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +3 -4
  19. package/dist/index.js.map +1 -1
  20. package/dist/tsconfig.tsbuildinfo +1 -1
  21. package/dist/types.d.ts +14 -19
  22. package/dist/types.js.map +1 -1
  23. package/fixtures/background-edges/app/views/pages/broken.liquid +3 -0
  24. package/fixtures/background-edges/app/views/pages/index.liquid +3 -0
  25. package/fixtures/background-edges/app/views/partials/jobs/notify.liquid +3 -0
  26. package/fixtures/function-edges/app/lib/queries/list.liquid +3 -0
  27. package/fixtures/function-edges/app/views/pages/broken.liquid +3 -0
  28. package/fixtures/function-edges/app/views/pages/index.liquid +3 -0
  29. package/fixtures/graphql-edges/app/graphql/blog_posts/find.graphql +10 -0
  30. package/fixtures/graphql-edges/app/views/pages/broken.liquid +3 -0
  31. package/fixtures/graphql-edges/app/views/pages/index.liquid +3 -0
  32. package/fixtures/include-edges/app/views/pages/index.liquid +1 -0
  33. package/fixtures/include-edges/app/views/partials/shared/header.liquid +1 -0
  34. package/fixtures/module-edges/app/views/pages/index.liquid +4 -0
  35. package/fixtures/module-edges/modules/my_module/public/lib/queries/get.liquid +3 -0
  36. package/fixtures/module-edges/modules/my_module/public/views/partials/card.liquid +1 -0
  37. package/fixtures/skeleton/app/views/partials/child.liquid +2 -4
  38. package/fixtures/skeleton/app/views/partials/parent.liquid +2 -4
  39. package/fixtures/skeleton/assets/app.js +1 -7
  40. package/package.json +5 -5
  41. package/src/cli.spec.ts +265 -0
  42. package/src/cli.ts +151 -0
  43. package/src/graph/augment.ts +0 -2
  44. package/src/graph/build.spec.ts +19 -5
  45. package/src/graph/extract.spec.ts +155 -0
  46. package/src/graph/module.ts +40 -0
  47. package/src/graph/test-helpers.ts +2 -9
  48. package/src/graph/traverse-edges.spec.ts +278 -0
  49. package/src/graph/traverse.ts +177 -49
  50. package/src/index.ts +1 -1
  51. package/src/types.ts +16 -18
  52. package/bin/jsconfig.json +0 -18
  53. package/src/getWebComponentMap.ts +0 -81
@@ -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
+ }
@@ -16,7 +16,5 @@ export function augmentDependencies(rootUri: string, ideps: IDependencies): Augm
16
16
  },
17
17
  identity,
18
18
  ),
19
-
20
- getWebComponentDefinitionReference: ideps.getWebComponentDefinitionReference,
21
19
  };
22
20
  }
@@ -11,7 +11,7 @@ describe('Module: index', () => {
11
11
  let dependencies: Dependencies;
12
12
 
13
13
  beforeAll(async () => {
14
- dependencies = await getDependencies(rootUri);
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
+ });
@@ -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,