@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
@@ -3,7 +3,6 @@ import { AbstractFileSystem } from '@platformos/platformos-common';
3
3
  import { NodeFileSystem } from '@platformos/platformos-check-node';
4
4
  import { vi } from 'vitest';
5
5
  import { URI } from 'vscode-uri';
6
- import { getWebComponentMap } from '../getWebComponentMap';
7
6
  import { toSourceCode } from '../toSourceCode';
8
7
  import { identity } from '../utils';
9
8
 
@@ -17,18 +16,12 @@ export function makeGetSourceCode(fs: AbstractFileSystem) {
17
16
  export const fixturesRoot = pathUtils.join(URI.file(__dirname), ...'../../fixtures'.split('/'));
18
17
  export const skeleton = pathUtils.join(fixturesRoot, 'skeleton');
19
18
 
20
- export async function getDependencies(rootUri: string, fs: AbstractFileSystem = NodeFileSystem) {
19
+ export function getDependencies(fs: AbstractFileSystem = NodeFileSystem) {
21
20
  const getSourceCode = makeGetSourceCode(fs);
22
- const deps = {
21
+ return {
23
22
  fs,
24
23
  getSourceCode,
25
- getWebComponentDefinitionReference: (customElementName: string) =>
26
- webComponentDefs.get(customElementName),
27
24
  };
28
-
29
- const webComponentDefs = await getWebComponentMap(rootUri, deps);
30
-
31
- return deps;
32
25
  }
33
26
 
34
27
  // This thing is way too hard to type.
@@ -0,0 +1,278 @@
1
+ import { path as pathUtils } from '@platformos/platformos-check-common';
2
+ import { beforeAll, describe, expect, it } from 'vitest';
3
+ import { buildAppGraph } from '../index';
4
+ import {
5
+ AppGraph,
6
+ Dependencies,
7
+ LiquidModule,
8
+ LiquidModuleKind,
9
+ ModuleType,
10
+ Reference,
11
+ } from '../types';
12
+ import { getGraphQLModuleByUri, getPartialModuleByUri } from './module';
13
+ import { fixturesRoot, getDependencies } from './test-helpers';
14
+
15
+ /**
16
+ * The exact source range of `snippet` within `source`. Derived from the fixture
17
+ * text (rather than hard-coded offsets) so the assertion is self-documenting
18
+ * and survives edits to unrelated lines.
19
+ */
20
+ function rangeOf(source: string, snippet: string): [number, number] {
21
+ const start = source.indexOf(snippet);
22
+ if (start < 0) throw new Error(`snippet not found in fixture: ${snippet}`);
23
+ return [start, start + snippet.length];
24
+ }
25
+
26
+ /** A `direct` dependency Reference with no target range (the common case here). */
27
+ function directRef(
28
+ sourceUri: string,
29
+ sourceRange: [number, number],
30
+ targetUri: string,
31
+ kind: Reference['kind'],
32
+ ): Reference {
33
+ return {
34
+ source: { uri: sourceUri, range: sourceRange },
35
+ target: { uri: targetUri },
36
+ type: 'direct',
37
+ kind,
38
+ };
39
+ }
40
+
41
+ const partialNode = (uri: string, exists: boolean, references: Reference[]): LiquidModule => ({
42
+ type: ModuleType.Liquid,
43
+ kind: LiquidModuleKind.Partial,
44
+ uri,
45
+ exists,
46
+ dependencies: [],
47
+ references,
48
+ });
49
+
50
+ describe('URI normalization in graph node factories', () => {
51
+ const emptyGraph = (): AppGraph => ({
52
+ rootUri: 'file:///app',
53
+ entryPoints: [],
54
+ modules: {},
55
+ });
56
+
57
+ it('getPartialModuleByUri returns a Partial node with a forward-slash URI', () => {
58
+ const mod = getPartialModuleByUri(
59
+ emptyGraph(),
60
+ 'file:///d:/a/repo\\app\\lib\\queries\\list.liquid',
61
+ );
62
+ expect(mod).toEqual({
63
+ type: ModuleType.Liquid,
64
+ kind: LiquidModuleKind.Partial,
65
+ uri: 'file:///d:/a/repo/app/lib/queries/list.liquid',
66
+ dependencies: [],
67
+ references: [],
68
+ });
69
+ });
70
+
71
+ it('getGraphQLModuleByUri returns a GraphQL node with a forward-slash URI', () => {
72
+ const mod = getGraphQLModuleByUri(
73
+ emptyGraph(),
74
+ 'file:///d:/a/repo\\app\\graphql\\find.graphql',
75
+ );
76
+ expect(mod).toEqual({
77
+ type: ModuleType.GraphQL,
78
+ kind: 'graphql',
79
+ uri: 'file:///d:/a/repo/app/graphql/find.graphql',
80
+ dependencies: [],
81
+ references: [],
82
+ });
83
+ });
84
+ });
85
+
86
+ describe('Graph traversal: {% function %} edges', () => {
87
+ const rootUri = pathUtils.join(fixturesRoot, 'function-edges');
88
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
89
+ let graph: AppGraph;
90
+ let indexSource: string;
91
+ let brokenSource: string;
92
+
93
+ beforeAll(async () => {
94
+ const dependencies: Dependencies = getDependencies();
95
+ graph = await buildAppGraph(rootUri, dependencies);
96
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
97
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
98
+ }, 15000);
99
+
100
+ it('links a page to the resolved lib query via a single function edge', () => {
101
+ const edge = directRef(
102
+ p('app/views/pages/index.liquid'),
103
+ rangeOf(indexSource, "function items = 'queries/list'"),
104
+ p('app/lib/queries/list.liquid'),
105
+ 'function',
106
+ );
107
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
108
+ expect(graph.modules[p('app/lib/queries/list.liquid')]).toEqual(
109
+ partialNode(p('app/lib/queries/list.liquid'), true, [edge]),
110
+ );
111
+ });
112
+
113
+ it('records a missing function target as an exists:false node', () => {
114
+ const edge = directRef(
115
+ p('app/views/pages/broken.liquid'),
116
+ rangeOf(brokenSource, "function ghost = 'queries/missing'"),
117
+ p('app/lib/queries/missing.liquid'),
118
+ 'function',
119
+ );
120
+ expect(graph.modules[p('app/lib/queries/missing.liquid')]).toEqual(
121
+ partialNode(p('app/lib/queries/missing.liquid'), false, [edge]),
122
+ );
123
+ });
124
+ });
125
+
126
+ describe('Graph traversal: {% graphql %} edges', () => {
127
+ const rootUri = pathUtils.join(fixturesRoot, 'graphql-edges');
128
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
129
+ let graph: AppGraph;
130
+ let indexSource: string;
131
+ let brokenSource: string;
132
+
133
+ const graphqlNode = (uri: string, exists: boolean, references: Reference[]) => ({
134
+ type: ModuleType.GraphQL,
135
+ kind: 'graphql' as const,
136
+ uri,
137
+ exists,
138
+ dependencies: [],
139
+ references,
140
+ });
141
+
142
+ beforeAll(async () => {
143
+ const dependencies: Dependencies = getDependencies();
144
+ graph = await buildAppGraph(rootUri, dependencies);
145
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
146
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
147
+ }, 15000);
148
+
149
+ it('links a page to the resolved .graphql operation via a single graphql edge', () => {
150
+ const edge = directRef(
151
+ p('app/views/pages/index.liquid'),
152
+ rangeOf(indexSource, "graphql posts = 'blog_posts/find', id: '1'"),
153
+ p('app/graphql/blog_posts/find.graphql'),
154
+ 'graphql',
155
+ );
156
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
157
+ expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual(
158
+ graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge]),
159
+ );
160
+ });
161
+
162
+ it('records a missing graphql target as an exists:false GraphQL node', () => {
163
+ const edge = directRef(
164
+ p('app/views/pages/broken.liquid'),
165
+ rangeOf(brokenSource, "graphql ghost = 'blog_posts/missing'"),
166
+ p('app/graphql/blog_posts/missing.graphql'),
167
+ 'graphql',
168
+ );
169
+ expect(graph.modules[p('app/graphql/blog_posts/missing.graphql')]).toEqual(
170
+ graphqlNode(p('app/graphql/blog_posts/missing.graphql'), false, [edge]),
171
+ );
172
+ });
173
+ });
174
+
175
+ describe('Graph traversal: {% include %} edges', () => {
176
+ const rootUri = pathUtils.join(fixturesRoot, 'include-edges');
177
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
178
+ let graph: AppGraph;
179
+ let indexSource: string;
180
+
181
+ beforeAll(async () => {
182
+ const dependencies: Dependencies = getDependencies();
183
+ graph = await buildAppGraph(rootUri, dependencies);
184
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
185
+ }, 15000);
186
+
187
+ it('tags an include edge with kind "include" (distinct from render)', () => {
188
+ const edge = directRef(
189
+ p('app/views/pages/index.liquid'),
190
+ rangeOf(indexSource, "{% include 'shared/header' %}"),
191
+ p('app/views/partials/shared/header.liquid'),
192
+ 'include',
193
+ );
194
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
195
+ expect(graph.modules[p('app/views/partials/shared/header.liquid')]).toEqual(
196
+ partialNode(p('app/views/partials/shared/header.liquid'), true, [edge]),
197
+ );
198
+ });
199
+ });
200
+
201
+ describe('Graph traversal: {% background %} edges', () => {
202
+ const rootUri = pathUtils.join(fixturesRoot, 'background-edges');
203
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
204
+ let graph: AppGraph;
205
+ let indexSource: string;
206
+ let brokenSource: string;
207
+
208
+ beforeAll(async () => {
209
+ const dependencies: Dependencies = getDependencies();
210
+ graph = await buildAppGraph(rootUri, dependencies);
211
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
212
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
213
+ }, 15000);
214
+
215
+ it('links a page to the background partial via a single background edge', () => {
216
+ const edge = directRef(
217
+ p('app/views/pages/index.liquid'),
218
+ rangeOf(indexSource, "background job_id = 'jobs/notify', data: 'x'"),
219
+ p('app/views/partials/jobs/notify.liquid'),
220
+ 'background',
221
+ );
222
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
223
+ expect(graph.modules[p('app/views/partials/jobs/notify.liquid')]).toEqual(
224
+ partialNode(p('app/views/partials/jobs/notify.liquid'), true, [edge]),
225
+ );
226
+ });
227
+
228
+ it('records a missing background target as an exists:false node', () => {
229
+ const edge = directRef(
230
+ p('app/views/pages/broken.liquid'),
231
+ rangeOf(brokenSource, "background job_id = 'jobs/missing'"),
232
+ p('app/lib/jobs/missing.liquid'),
233
+ 'background',
234
+ );
235
+ expect(graph.modules[p('app/lib/jobs/missing.liquid')]).toEqual(
236
+ partialNode(p('app/lib/jobs/missing.liquid'), false, [edge]),
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('Graph traversal: module-namespaced targets (modules/<name>/public/...)', () => {
242
+ const rootUri = pathUtils.join(fixturesRoot, 'module-edges');
243
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
244
+ let graph: AppGraph;
245
+ let indexSource: string;
246
+
247
+ beforeAll(async () => {
248
+ const dependencies: Dependencies = getDependencies();
249
+ graph = await buildAppGraph(rootUri, dependencies);
250
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
251
+ }, 15000);
252
+
253
+ it('resolves function + render targets into modules/<name>/public/{lib,views/partials}', () => {
254
+ const functionEdge = directRef(
255
+ p('app/views/pages/index.liquid'),
256
+ rangeOf(indexSource, "function items = 'modules/my_module/queries/get'"),
257
+ p('modules/my_module/public/lib/queries/get.liquid'),
258
+ 'function',
259
+ );
260
+ const renderEdge = directRef(
261
+ p('app/views/pages/index.liquid'),
262
+ rangeOf(indexSource, "{% render 'modules/my_module/card' %}"),
263
+ p('modules/my_module/public/views/partials/card.liquid'),
264
+ 'render',
265
+ );
266
+
267
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([
268
+ functionEdge,
269
+ renderEdge,
270
+ ]);
271
+ expect(graph.modules[p('modules/my_module/public/lib/queries/get.liquid')]).toEqual(
272
+ partialNode(p('modules/my_module/public/lib/queries/get.liquid'), true, [functionEdge]),
273
+ );
274
+ expect(graph.modules[p('modules/my_module/public/views/partials/card.liquid')]).toEqual(
275
+ partialNode(p('modules/my_module/public/views/partials/card.liquid'), true, [renderEdge]),
276
+ );
277
+ });
278
+ });
@@ -1,17 +1,31 @@
1
- import { NodeTypes } from '@platformos/liquid-html-parser';
2
- import { SourceCodeType, visit, Visitor } from '@platformos/platformos-check-common';
1
+ import { NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
2
+ import { SourceCodeType, UriString, visit, Visitor } from '@platformos/platformos-check-common';
3
+ import { DocumentsLocator } from '@platformos/platformos-common';
4
+ import { URI } from 'vscode-uri';
3
5
  import {
4
6
  AugmentedDependencies,
5
7
  AppGraph,
6
8
  AppModule,
9
+ FileSourceCode,
7
10
  LiquidModule,
8
11
  ModuleType,
9
12
  Range,
10
13
  Reference,
14
+ ReferenceKind,
11
15
  Void,
12
16
  } from '../types';
13
- import { assertNever, exists, isString } from '../utils';
14
- import { getAssetModule, getLayoutModule, getPartialModule } from './module';
17
+ import { assertNever, exists, isString, unique } from '../utils';
18
+ import { getAssetModule, getGraphQLModuleByUri, getPartialModuleByUri } from './module';
19
+
20
+ /** A resolved outgoing reference: the target graph node + its call-site range + kind. */
21
+ interface ResolvedReference {
22
+ target: AppModule;
23
+ sourceRange: Range;
24
+ kind: ReferenceKind;
25
+ }
26
+
27
+ /** The dependency surface the reference resolver needs: just a filesystem (for DocumentsLocator). */
28
+ type ResolverDependencies = Pick<AugmentedDependencies, 'fs'>;
15
29
 
16
30
  export async function traverseModule(
17
31
  module: AppModule,
@@ -44,6 +58,10 @@ export async function traverseModule(
44
58
  return; // Nothing to traverse in assets
45
59
  }
46
60
 
61
+ case ModuleType.GraphQL: {
62
+ return; // Leaf node — GraphQL documents have no platformOS dependencies
63
+ }
64
+
47
65
  default: {
48
66
  return assertNever(module);
49
67
  }
@@ -56,13 +74,47 @@ async function traverseLiquidModule(
56
74
  deps: AugmentedDependencies,
57
75
  ) {
58
76
  const sourceCode = await deps.getSourceCode(module.uri);
77
+ const references = await resolveLiquidReferences(appGraph, sourceCode, deps);
59
78
 
60
- if (sourceCode.ast instanceof Error) return; // can't visit what you can't parse
79
+ for (const reference of references) {
80
+ bind(module, reference.target, {
81
+ sourceRange: reference.sourceRange,
82
+ kind: reference.kind,
83
+ });
84
+ }
61
85
 
62
- const visitor: Visitor<
63
- SourceCodeType.LiquidHtml,
64
- { target: AppModule; sourceRange: Range; targetRange?: Range }
65
- > = {
86
+ const modules = unique(references.map((ref) => ref.target));
87
+ const promises = modules.map((mod) => traverseModule(mod, appGraph, deps));
88
+
89
+ return Promise.all(promises);
90
+ }
91
+
92
+ /**
93
+ * Resolve a single parsed Liquid file's outgoing references — the one place that
94
+ * knows how each Liquid construct (`render`/`include`, `function`, `background`,
95
+ * `graphql`, asset filters) maps to a target module + {@link ReferenceKind}.
96
+ *
97
+ * Both the full-project traversal ({@link traverseLiquidModule}) and the
98
+ * standalone per-file primitive ({@link extractFileReferences}) go through this,
99
+ * so resolution can never drift between "graph build" and "validate one buffer".
100
+ *
101
+ * Targets are produced via the module factories (which normalize URIs), so keys
102
+ * match the rest of the graph on every platform. Unparseable input yields no
103
+ * references rather than throwing.
104
+ */
105
+ async function resolveLiquidReferences(
106
+ appGraph: AppGraph,
107
+ sourceCode: FileSourceCode,
108
+ deps: ResolverDependencies,
109
+ ): Promise<ResolvedReference[]> {
110
+ if (sourceCode.ast instanceof Error) return []; // can't visit what you can't parse
111
+
112
+ // Canonical target resolution (lib paths, module prefixes, extensions) is
113
+ // owned by check-common's DocumentsLocator — never re-derived here.
114
+ const documentsLocator = new DocumentsLocator(deps.fs);
115
+ const rootUri = URI.parse(appGraph.rootUri);
116
+
117
+ const visitor: Visitor<SourceCodeType.LiquidHtml, ResolvedReference> = {
66
118
  // {{ 'app.js' | asset_url }}
67
119
  // {{ 'image.png' | asset_img_url }}
68
120
  // {{ 'icon.svg' | inline_asset_content }}
@@ -78,61 +130,136 @@ async function traverseLiquidModule(
78
130
  return {
79
131
  target: assetModule,
80
132
  sourceRange: [parentNode.position.start, parentNode.position.end],
133
+ kind: 'asset',
81
134
  };
82
135
  }
83
136
  },
84
137
 
85
- // <custom-element></custom-element>
86
- HtmlElement: async (node) => {
87
- if (node.name.length !== 1) return;
88
- if (node.name[0].type !== NodeTypes.TextNode) return;
89
- const nodeNameNode = node.name[0];
90
- const nodeName = nodeNameNode.value;
91
- if (!nodeName.includes('-')) return; // skip non-custom-elements
138
+ // {% render 'partial' %} / {% include 'partial' %}
139
+ // Both resolve through DocumentsLocator (like function/graphql), so module
140
+ // prefixes (`modules/<m>/...`), the lib search path, and `.liquid` /
141
+ // `.html.liquid` extensions are handled uniformly — not hard-coded to
142
+ // `app/views/partials`.
143
+ RenderMarkup: async (node, ancestors) => {
144
+ const partial = node.partial;
145
+ const tag = ancestors.at(-1)!;
146
+ if (!isStringLiteral(partial)) return; // dynamic target — skip
147
+ const isInclude = tag.type === NodeTypes.LiquidTag && tag.name === NamedTags.include;
148
+ const uri = await documentsLocator.locateOrDefault(
149
+ rootUri,
150
+ isInclude ? 'include' : 'render',
151
+ partial.value,
152
+ );
153
+ if (!uri) return;
154
+ return {
155
+ target: getPartialModuleByUri(appGraph, uri),
156
+ sourceRange: [tag.position.start, tag.position.end],
157
+ kind: isInclude ? 'include' : 'render',
158
+ };
159
+ },
92
160
 
93
- const result = deps.getWebComponentDefinitionReference(nodeName);
94
- if (!result) return;
95
- const { assetName, range } = result;
96
- const assetModule = getAssetModule(appGraph, assetName);
97
- if (!assetModule) return;
161
+ // {% function result = 'queries/...' %} / {% function res = 'commands/...' %}
162
+ FunctionMarkup: async (node, ancestors) => {
163
+ const target = node.partial;
164
+ const tag = ancestors.at(-1)!;
165
+ if (!isStringLiteral(target)) return; // dynamic target — skip
166
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
167
+ if (!uri) return;
168
+ return {
169
+ target: getPartialModuleByUri(appGraph, uri),
170
+ sourceRange: [tag.position.start, tag.position.end],
171
+ kind: 'function',
172
+ };
173
+ },
98
174
 
175
+ // {% background job_id = 'partial', ... %} (file-based form)
176
+ // Runs a partial asynchronously; `node.partial` is the partial reference,
177
+ // resolved against the same search paths as {% function %}. The inline form
178
+ // ({% background %}...{% endbackground %}) parses to BackgroundInlineMarkup,
179
+ // has no file target, and is intentionally not matched here.
180
+ BackgroundMarkup: async (node, ancestors) => {
181
+ const target = node.partial;
182
+ const tag = ancestors.at(-1)!;
183
+ if (!isStringLiteral(target)) return; // dynamic target — skip
184
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
185
+ if (!uri) return;
99
186
  return {
100
- target: assetModule,
101
- sourceRange: [node.blockStartPosition.start, nodeNameNode.position.end],
102
- targetRange: range,
187
+ target: getPartialModuleByUri(appGraph, uri),
188
+ sourceRange: [tag.position.start, tag.position.end],
189
+ kind: 'background',
103
190
  };
104
191
  },
105
192
 
106
- // {% render 'partial' %}
107
- RenderMarkup: async (node, ancestors) => {
108
- const partial = node.partial;
193
+ // {% graphql result = 'path/to/operation' %}
194
+ // `node.name` is the RESULT variable; the operation-file path is `node.graphql`.
195
+ GraphQLMarkup: async (node, ancestors) => {
196
+ const op = node.graphql;
109
197
  const tag = ancestors.at(-1)!;
110
- if (!isString(partial) && partial.type === NodeTypes.String) {
111
- return {
112
- target: getPartialModule(appGraph, partial.value),
113
- sourceRange: [tag.position.start, tag.position.end],
114
- };
115
- }
198
+ if (!isStringLiteral(op)) return; // dynamic/inline no static file
199
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'graphql', op.value);
200
+ if (!uri) return;
201
+ return {
202
+ target: getGraphQLModuleByUri(appGraph, uri),
203
+ sourceRange: [tag.position.start, tag.position.end],
204
+ kind: 'graphql',
205
+ };
116
206
  },
117
207
  };
118
208
 
119
- const references = await visit(sourceCode.ast, visitor);
120
-
121
- for (const reference of references) {
122
- bind(module, reference.target, {
123
- sourceRange: reference.sourceRange,
124
- targetRange: reference.targetRange,
125
- });
126
- }
209
+ return visit(sourceCode.ast, visitor);
210
+ }
127
211
 
128
- const modules = unique(references.map((ref) => ref.target));
129
- const promises = modules.map((mod) => traverseModule(mod, appGraph, deps));
212
+ /**
213
+ * Extract one Liquid file's outgoing dependency references, resolved against the
214
+ * project at `rootUri`, WITHOUT building the whole app graph.
215
+ *
216
+ * This is the per-file primitive for consumers that hold a single (possibly
217
+ * in-flight, not-yet-on-disk) buffer — e.g. a `validate_code`-style tool that
218
+ * parses the buffer with {@link toSourceCode} and wants the file's resolved
219
+ * `render`/`include`/`function`/`background`/`graphql`/asset edges with their
220
+ * canonical target URIs and {@link ReferenceKind}. Resolution uses the same
221
+ * `DocumentsLocator`-backed logic as the full graph build, so a target's URI is
222
+ * identical to the key it would have as a graph node.
223
+ *
224
+ * Notes for consumers:
225
+ * - Targets are returned whether or not they exist on disk (resolution is
226
+ * path-based). To distinguish missing targets, `stat` `target.uri` via the
227
+ * same `fs`; unresolved/missing partials are also surfaced by the linter's
228
+ * `MissingPartial` check, so prefer that for diagnostics.
229
+ * - Only statically resolvable references are returned; dynamic targets
230
+ * (`{% render some_var %}`) and inline forms are skipped.
231
+ * - `sourceCode` is parsed by the caller (from the buffer, not disk); only `fs`
232
+ * is touched here, for target resolution.
233
+ */
234
+ export async function extractFileReferences(
235
+ rootUri: UriString,
236
+ sourceUri: UriString,
237
+ sourceCode: FileSourceCode,
238
+ deps: ResolverDependencies,
239
+ ): Promise<Reference[]> {
240
+ // A throwaway graph so the URI-normalizing module factories can be reused; it
241
+ // is never traversed and is discarded with this call.
242
+ const scratchGraph: AppGraph = { rootUri, entryPoints: [], modules: {} };
243
+ const references = await resolveLiquidReferences(scratchGraph, sourceCode, deps);
130
244
 
131
- return Promise.all(promises);
245
+ return references.map((reference) => ({
246
+ source: { uri: sourceUri, range: reference.sourceRange },
247
+ target: { uri: reference.target.uri },
248
+ type: 'direct',
249
+ kind: reference.kind,
250
+ }));
132
251
  }
133
252
 
134
- function unique<T>(arr: T[]): T[] {
135
- return [...new Set(arr)];
253
+ /**
254
+ * A render/function/graphql target that is a static string literal (e.g.
255
+ * `{% render 'partial' %}`), narrowed to its `NodeTypes.String` member so the
256
+ * `.value` is accessible. Dynamic targets (`{% render var %}`) return false and
257
+ * are skipped — the graph only records statically resolvable edges.
258
+ */
259
+ function isStringLiteral<T extends { type: NodeTypes }>(
260
+ node: T,
261
+ ): node is Extract<T, { type: NodeTypes.String }> {
262
+ return !isString(node) && node.type === NodeTypes.String;
136
263
  }
137
264
 
138
265
  /**
@@ -147,18 +274,19 @@ export function bind(
147
274
  target: AppModule,
148
275
  {
149
276
  sourceRange,
150
- targetRange,
151
277
  type = 'direct', // the type of dependency, can be 'direct' or 'indirect'
278
+ kind, // the semantic Liquid construct that created the edge
152
279
  }: {
153
280
  sourceRange?: Range; // a range in the source module that references the child
154
- targetRange?: Range; // a range in the child module that is being referenced
155
281
  type?: Reference['type']; // the type of dependency
282
+ kind?: ReferenceKind; // render | include | function | background | graphql | asset | layout
156
283
  } = {},
157
284
  ): void {
158
285
  const dependency: Reference = {
159
286
  source: { uri: source.uri, range: sourceRange },
160
- target: { uri: target.uri, range: targetRange },
287
+ target: { uri: target.uri },
161
288
  type: type,
289
+ kind: kind,
162
290
  };
163
291
 
164
292
  source.dependencies.push(dependency);
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { buildAppGraph } from './graph/build';
2
+ export { extractFileReferences } from './graph/traverse';
2
3
  export { serializeAppGraph } from './graph/serialize';
3
- export { getWebComponentMap, findWebComponentReferences } from './getWebComponentMap';
4
4
  export { parseJs, toSourceCode } from './toSourceCode';
5
5
  export * from './types';