@platformos/platformos-graph 0.0.20 → 0.2.0

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 (122) hide show
  1. package/CHANGELOG.md +622 -8
  2. package/dist/cache-probes.spec.d.ts +1 -0
  3. package/dist/cache-probes.spec.js +62 -0
  4. package/dist/cache-probes.spec.js.map +1 -0
  5. package/dist/cli.js +18 -13
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cli.spec.d.ts +1 -0
  8. package/dist/cli.spec.js +235 -0
  9. package/dist/cli.spec.js.map +1 -0
  10. package/dist/graph/app-index-resolution.spec.d.ts +1 -0
  11. package/dist/graph/app-index-resolution.spec.js +167 -0
  12. package/dist/graph/app-index-resolution.spec.js.map +1 -0
  13. package/dist/graph/asset-resolution.spec.d.ts +1 -0
  14. package/dist/graph/asset-resolution.spec.js +64 -0
  15. package/dist/graph/asset-resolution.spec.js.map +1 -0
  16. package/dist/graph/augment.js +3 -0
  17. package/dist/graph/augment.js.map +1 -1
  18. package/dist/graph/build.d.ts +15 -2
  19. package/dist/graph/build.js +54 -12
  20. package/dist/graph/build.js.map +1 -1
  21. package/dist/graph/build.spec.d.ts +1 -0
  22. package/dist/graph/build.spec.js +138 -0
  23. package/dist/graph/build.spec.js.map +1 -0
  24. package/dist/graph/edge-sources.d.ts +28 -0
  25. package/dist/graph/edge-sources.js +43 -0
  26. package/dist/graph/edge-sources.js.map +1 -0
  27. package/dist/graph/edge-sources.spec.d.ts +1 -0
  28. package/dist/graph/edge-sources.spec.js +125 -0
  29. package/dist/graph/edge-sources.spec.js.map +1 -0
  30. package/dist/graph/extract.spec.d.ts +1 -0
  31. package/dist/graph/extract.spec.js +163 -0
  32. package/dist/graph/extract.spec.js.map +1 -0
  33. package/dist/graph/module.d.ts +60 -7
  34. package/dist/graph/module.js +137 -41
  35. package/dist/graph/module.js.map +1 -1
  36. package/dist/graph/module.spec.d.ts +1 -0
  37. package/dist/graph/module.spec.js +74 -0
  38. package/dist/graph/module.spec.js.map +1 -0
  39. package/dist/graph/query.d.ts +72 -0
  40. package/dist/graph/query.js +140 -0
  41. package/dist/graph/query.js.map +1 -0
  42. package/dist/graph/query.spec.d.ts +1 -0
  43. package/dist/graph/query.spec.js +209 -0
  44. package/dist/graph/query.spec.js.map +1 -0
  45. package/dist/graph/serialize.spec.d.ts +1 -0
  46. package/dist/graph/serialize.spec.js +50 -0
  47. package/dist/graph/serialize.spec.js.map +1 -0
  48. package/dist/graph/structural.spec.d.ts +1 -0
  49. package/dist/graph/structural.spec.js +146 -0
  50. package/dist/graph/structural.spec.js.map +1 -0
  51. package/dist/graph/test-helpers.js +3 -3
  52. package/dist/graph/test-helpers.js.map +1 -1
  53. package/dist/graph/traverse-edges.spec.d.ts +1 -0
  54. package/dist/graph/traverse-edges.spec.js +337 -0
  55. package/dist/graph/traverse-edges.spec.js.map +1 -0
  56. package/dist/graph/traverse.d.ts +51 -20
  57. package/dist/graph/traverse.js +239 -29
  58. package/dist/graph/traverse.js.map +1 -1
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +19 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/parsers.d.ts +35 -0
  63. package/dist/parsers.js +72 -0
  64. package/dist/parsers.js.map +1 -0
  65. package/dist/parsers.spec.d.ts +1 -0
  66. package/dist/parsers.spec.js +70 -0
  67. package/dist/parsers.spec.js.map +1 -0
  68. package/dist/toSourceCode.d.ts +14 -2
  69. package/dist/toSourceCode.js +23 -19
  70. package/dist/toSourceCode.js.map +1 -1
  71. package/dist/tsconfig.tsbuildinfo +1 -1
  72. package/dist/types.d.ts +107 -3
  73. package/dist/types.js.map +1 -1
  74. package/dist/utils/index.d.ts +1 -1
  75. package/fixtures/asset-edges/app/assets/logo.css +1 -0
  76. package/fixtures/asset-edges/app/assets/site/app.js +1 -0
  77. package/fixtures/asset-edges/app/views/pages/index.liquid +3 -0
  78. package/fixtures/graphql-table/app/graphql/remote_table.graphql +9 -0
  79. package/fixtures/graphql-table/app/graphql/table_as_argument.graphql +5 -0
  80. package/fixtures/graphql-table/app/graphql/with_table.graphql +5 -0
  81. package/fixtures/graphql-table/app/graphql/without_table.graphql +3 -0
  82. package/fixtures/graphql-table/app/views/pages/index.liquid +6 -0
  83. package/fixtures/layout-edges/app/views/layouts/theme.liquid +4 -0
  84. package/fixtures/layout-edges/app/views/pages/broken.liquid +5 -0
  85. package/fixtures/layout-edges/app/views/pages/index.liquid +5 -0
  86. package/fixtures/schema-nodes/app/schema/blog_post.yml +6 -0
  87. package/fixtures/schema-nodes/app/schema/no_name.yml +3 -0
  88. package/fixtures/schema-nodes/app/views/pages/index.liquid +1 -0
  89. package/fixtures/structural/app/views/pages/about.liquid +1 -0
  90. package/fixtures/structural/app/views/pages/blog/show.liquid +4 -0
  91. package/fixtures/structural/app/views/pages/index.liquid +6 -0
  92. package/fixtures/structural/app/views/pages/rich.liquid +8 -0
  93. package/fixtures/structural/app/views/partials/card.liquid +1 -0
  94. package/fixtures/structural/app/views/partials/documented.liquid +5 -0
  95. package/package.json +7 -5
  96. package/src/cli.spec.ts +37 -31
  97. package/src/cli.ts +31 -15
  98. package/src/graph/augment.ts +4 -0
  99. package/src/graph/build.spec.ts +891 -6
  100. package/src/graph/build.ts +68 -17
  101. package/src/graph/edge-sources.spec.ts +125 -0
  102. package/src/graph/edge-sources.ts +49 -0
  103. package/src/graph/module.spec.ts +168 -0
  104. package/src/graph/module.ts +139 -45
  105. package/src/graph/query.spec.ts +260 -0
  106. package/src/graph/query.ts +154 -0
  107. package/src/graph/serialize.spec.ts +4 -4
  108. package/src/graph/test-helpers.ts +3 -3
  109. package/src/graph/{extract.spec.ts → traverse.spec.ts} +125 -2
  110. package/src/graph/traverse.ts +278 -37
  111. package/src/index.ts +16 -1
  112. package/src/parsers.spec.ts +85 -0
  113. package/src/parsers.ts +75 -0
  114. package/src/toSourceCode.ts +26 -26
  115. package/src/types.ts +111 -7
  116. package/src/utils/index.ts +1 -1
  117. package/dist/getWebComponentMap.d.ts +0 -10
  118. package/dist/getWebComponentMap.js +0 -66
  119. package/dist/getWebComponentMap.js.map +0 -1
  120. package/src/graph/traverse-edges.spec.ts +0 -278
  121. /package/fixtures/skeleton/{assets → app/assets}/app.css +0 -0
  122. /package/fixtures/skeleton/{assets → app/assets}/app.js +0 -0
@@ -1,7 +1,7 @@
1
1
  import { path as pathUtils } from '@platformos/platformos-check-common';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import { AppGraph } from '../types';
4
- import { getLayoutModule, getPartialModule } from './module';
4
+ import { getLayoutModule, getPartialModuleByUri } from './module';
5
5
  import { serializeAppGraph } from './serialize';
6
6
  import { bind } from './traverse';
7
7
 
@@ -16,9 +16,9 @@ describe('Unit: serializeAppGraph', () => {
16
16
  };
17
17
 
18
18
  const layout = getLayoutModule(graph, p('app/views/layouts/application.liquid'))!;
19
- const headerPartial = getPartialModule(graph, 'header');
20
- const parentPartial = getPartialModule(graph, 'parent');
21
- const childPartial = getPartialModule(graph, 'child');
19
+ const headerPartial = getPartialModuleByUri(graph, p('app/views/partials/header.liquid'));
20
+ const parentPartial = getPartialModuleByUri(graph, p('app/views/partials/parent.liquid'));
21
+ const childPartial = getPartialModuleByUri(graph, p('app/views/partials/child.liquid'));
22
22
 
23
23
  bind(layout, headerPartial, { sourceRange: [0, 5] });
24
24
  bind(layout, parentPartial, { sourceRange: [10, 15] });
@@ -1,5 +1,5 @@
1
1
  import { memoize, path as pathUtils } from '@platformos/platformos-check-common';
2
- import { AbstractFileSystem } from '@platformos/platformos-common';
2
+ import { AbstractFileSystem, uriFromPath } 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';
@@ -9,11 +9,11 @@ import { identity } from '../utils';
9
9
  export function makeGetSourceCode(fs: AbstractFileSystem) {
10
10
  return memoize(async function getSourceCode(uri: string) {
11
11
  const source = await fs.readFile(uri);
12
- return toSourceCode(URI.file(uri).toString(), source);
12
+ return toSourceCode(pathUtils.toUri(uri), source);
13
13
  }, identity);
14
14
  }
15
15
 
16
- export const fixturesRoot = pathUtils.join(URI.file(__dirname), ...'../../fixtures'.split('/'));
16
+ export const fixturesRoot = pathUtils.join(uriFromPath(__dirname), ...'../../fixtures'.split('/'));
17
17
  export const skeleton = pathUtils.join(fixturesRoot, 'skeleton');
18
18
 
19
19
  export function getDependencies(fs: AbstractFileSystem = NodeFileSystem) {
@@ -1,8 +1,8 @@
1
1
  import { path as pathUtils } from '@platformos/platformos-check-common';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { extractFileReferences } from '../index';
3
+ import { extractFileReferences, extractStructural } from '../index';
4
4
  import { toSourceCode } from '../toSourceCode';
5
- import { Reference } from '../types';
5
+ import { ModuleStructural, Reference } from '../types';
6
6
  import { fixturesRoot, getDependencies } from './test-helpers';
7
7
 
8
8
  /**
@@ -26,12 +26,14 @@ function directRef(
26
26
  sourceRange: [number, number],
27
27
  targetUri: string,
28
28
  kind: Reference['kind'],
29
+ args?: string[],
29
30
  ): Reference {
30
31
  return {
31
32
  source: { uri: sourceUri, range: sourceRange },
32
33
  target: { uri: targetUri },
33
34
  type: 'direct',
34
35
  kind,
36
+ ...(args ? { args } : {}),
35
37
  };
36
38
  }
37
39
 
@@ -74,6 +76,21 @@ describe('extractFileReferences: resolves a single buffer against the project',
74
76
  ),
75
77
  ]);
76
78
  });
79
+
80
+ it('captures the named-argument names at a render call site', async () => {
81
+ const sourceUri = p('app/views/pages/draft.liquid');
82
+ const content = "{% render 'card', title: page.title, count: 3 %}";
83
+
84
+ expect(await extract(rootUri, sourceUri, content)).toEqual([
85
+ directRef(
86
+ sourceUri,
87
+ rangeOf(content, "{% render 'card', title: page.title, count: 3 %}"),
88
+ p('app/views/partials/card.liquid'),
89
+ 'render',
90
+ ['title', 'count'],
91
+ ),
92
+ ]);
93
+ });
77
94
  });
78
95
 
79
96
  describe('extractFileReferences: kinds and resolution match the full graph build', () => {
@@ -107,6 +124,7 @@ describe('extractFileReferences: kinds and resolution match the full graph build
107
124
  rangeOf(content, "graphql posts = 'blog_posts/find', id: '1'"),
108
125
  p('app/graphql/blog_posts/find.graphql'),
109
126
  'graphql',
127
+ ['id'],
110
128
  ),
111
129
  ]);
112
130
  });
@@ -153,3 +171,108 @@ describe('extractFileReferences: only statically resolvable edges', () => {
153
171
  expect(await extract(rootUri, sourceUri, '<h1>{{ page.title }}</h1>')).toEqual([]);
154
172
  });
155
173
  });
174
+
175
+ describe('extractFileReferences: layout-association edges (frontmatter `layout:`)', () => {
176
+ const rootUri = pathUtils.join(fixturesRoot, 'function-edges'); // no layouts here → default path
177
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
178
+ const sourceUri = p('app/views/pages/draft.liquid');
179
+
180
+ // The whole frontmatter block: start through the closing `---` line + newline.
181
+ const fmRange = (content: string): [number, number] => [
182
+ 0,
183
+ content.indexOf('\n', content.indexOf('---', 3)) + 1,
184
+ ];
185
+
186
+ it('emits a layout edge for an explicit frontmatter layout', async () => {
187
+ const content = `---
188
+ slug: draft
189
+ layout: theme
190
+ ---
191
+ <h1>x</h1>`;
192
+
193
+ expect(await extract(rootUri, sourceUri, content)).toEqual([
194
+ directRef(sourceUri, fmRange(content), p('app/views/layouts/theme.liquid'), 'layout'),
195
+ ]);
196
+ });
197
+
198
+ it('resolves a module-prefixed layout', async () => {
199
+ const content = `---
200
+ layout: modules/core/admin
201
+ ---
202
+ <h1>x</h1>`;
203
+
204
+ expect(await extract(rootUri, sourceUri, content)).toEqual([
205
+ directRef(
206
+ sourceUri,
207
+ fmRange(content),
208
+ p('modules/core/public/views/layouts/admin.liquid'),
209
+ 'layout',
210
+ ),
211
+ ]);
212
+ });
213
+
214
+ it('emits no edge for an explicitly empty layout', async () => {
215
+ const content = `---
216
+ slug: draft
217
+ layout: ''
218
+ ---
219
+ <h1>x</h1>`;
220
+
221
+ expect(await extract(rootUri, sourceUri, content)).toEqual([]);
222
+ });
223
+
224
+ it('emits no edge when layout is omitted (no implicit default)', async () => {
225
+ const content = `---
226
+ slug: draft
227
+ ---
228
+ <h1>x</h1>`;
229
+
230
+ expect(await extract(rootUri, sourceUri, content)).toEqual([]);
231
+ });
232
+
233
+ it('emits no edge for a dynamic layout value', async () => {
234
+ const content = `---
235
+ layout: "{{ context.constants.LAYOUT }}"
236
+ ---
237
+ <h1>x</h1>`;
238
+
239
+ expect(await extract(rootUri, sourceUri, content)).toEqual([]);
240
+ });
241
+ });
242
+
243
+ describe('extractStructural: the per-file primitive', () => {
244
+ const rootUri = pathUtils.join(fixturesRoot, 'structural');
245
+ // `rootUri` is already a URI, so joining is the whole conversion — `URI.file` on one
246
+ // yields `file:///file%3A///…`, a plausible-looking URI for nowhere.
247
+ const uriOf = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
248
+
249
+ const run = async (part: string, content: string): Promise<ModuleStructural | undefined> => {
250
+ const uri = uriOf(part);
251
+ return extractStructural(await toSourceCode(uri, content), uri);
252
+ };
253
+
254
+ it('extracts usage + routing facts from an in-flight buffer (no graph build)', async () => {
255
+ const content = `---
256
+ layout: theme
257
+ method: post
258
+ ---
259
+ {% render 'card' %}
260
+ {{ 'greeting.hi' | t }}
261
+ {{ title | upcase }}`;
262
+ expect(await run('app/views/pages/contact.liquid', content)).toEqual({
263
+ renders_used: ['card'],
264
+ graphql_queries_used: [],
265
+ filters_used: ['t', 'upcase'],
266
+ tags_used: ['render'],
267
+ translation_keys: ['greeting.hi'],
268
+ doc_params: [],
269
+ slug: 'contact',
270
+ layout: 'theme',
271
+ method: 'post',
272
+ });
273
+ });
274
+
275
+ it('returns undefined for a non-Liquid (.graphql) buffer', async () => {
276
+ expect(await run('app/graphql/find.graphql', 'query find { records { id } }')).toBeUndefined();
277
+ });
278
+ });
@@ -1,13 +1,31 @@
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';
1
+ import yaml from 'js-yaml';
2
+ import { LiquidNamedArgument, NamedTags, NodeTypes } from '@platformos/liquid-html-parser';
3
+ import {
4
+ isTranslationKeyUsage,
5
+ SourceCodeType,
6
+ UriString,
7
+ visit,
8
+ Visitor,
9
+ } from '@platformos/platformos-check-common';
10
+ import {
11
+ containsLiquid,
12
+ DocumentsLocator,
13
+ effectivePageSlug,
14
+ extractGraphqlTables,
15
+ extractRelativePagePath,
16
+ extractSchemaTable,
17
+ isGraphqlDocument,
18
+ PLATFORM_YAML_LOAD_OPTIONS,
19
+ } from '@platformos/platformos-common';
4
20
  import { URI } from 'vscode-uri';
5
21
  import {
6
22
  AugmentedDependencies,
7
23
  AppGraph,
8
24
  AppModule,
9
25
  FileSourceCode,
26
+ GraphBuildOptions,
10
27
  LiquidModule,
28
+ ModuleStructural,
11
29
  ModuleType,
12
30
  Range,
13
31
  Reference,
@@ -15,22 +33,31 @@ import {
15
33
  Void,
16
34
  } from '../types';
17
35
  import { assertNever, exists, isString, unique } from '../utils';
18
- import { getAssetModule, getGraphQLModuleByUri, getPartialModuleByUri } from './module';
36
+ import {
37
+ getAssetModuleByUri,
38
+ getGraphQLModuleByUri,
39
+ getLayoutModuleByUri,
40
+ getPartialModuleByUri,
41
+ isSupportedAsset,
42
+ } from './module';
19
43
 
20
- /** A resolved outgoing reference: the target graph node + its call-site range + kind. */
21
- interface ResolvedReference {
44
+ /** A resolved outgoing reference: the target graph node + its call-site range + kind (+ named-arg names). */
45
+ export interface ResolvedReference {
22
46
  target: AppModule;
23
47
  sourceRange: Range;
24
48
  kind: ReferenceKind;
49
+ /** Names of the named arguments at the call site, in source order; omitted when none. */
50
+ args?: string[];
25
51
  }
26
52
 
27
- /** The dependency surface the reference resolver needs: just a filesystem (for DocumentsLocator). */
28
- type ResolverDependencies = Pick<AugmentedDependencies, 'fs'>;
53
+ /** The dependency surface the reference resolver needs: what `DocumentsLocator` takes. */
54
+ type ResolverDependencies = Pick<AugmentedDependencies, 'fs' | 'app'>;
29
55
 
30
56
  export async function traverseModule(
31
57
  module: AppModule,
32
58
  appGraph: AppGraph,
33
59
  deps: AugmentedDependencies,
60
+ options: GraphBuildOptions = {},
34
61
  ): Promise<Void> {
35
62
  // If the module is already traversed, skip it
36
63
  if (appGraph.modules[module.uri]) {
@@ -51,7 +78,7 @@ export async function traverseModule(
51
78
 
52
79
  switch (module.type) {
53
80
  case ModuleType.Liquid: {
54
- return traverseLiquidModule(module, appGraph, deps);
81
+ return traverseLiquidModule(module, appGraph, deps, options);
55
82
  }
56
83
 
57
84
  case ModuleType.Asset: {
@@ -59,7 +86,21 @@ export async function traverseModule(
59
86
  }
60
87
 
61
88
  case ModuleType.GraphQL: {
62
- return; // Leaf node — GraphQL documents have no platformOS dependencies
89
+ // Leaf node — GraphQL documents have no platformOS dependencies. We do read the
90
+ // file once to record the model tables it targets (a neutral platform fact),
91
+ // from the parse the source code already carries: when the graph reads through
92
+ // an `App` that is the SAME parse the lint used, not a second one.
93
+ const sourceCode = await deps.getSourceCode(module.uri);
94
+ module.tables = isGraphqlDocument(sourceCode.ast) ? extractGraphqlTables(sourceCode.ast) : [];
95
+ return;
96
+ }
97
+
98
+ case ModuleType.Schema: {
99
+ // Leaf node — a custom model type / schema file. Read the source once to
100
+ // record its model table name (the YAML `name:`), a neutral platform fact.
101
+ const sourceCode = await deps.getSourceCode(module.uri);
102
+ module.table = extractSchemaTable(sourceCode.source);
103
+ return;
63
104
  }
64
105
 
65
106
  default: {
@@ -72,19 +113,29 @@ async function traverseLiquidModule(
72
113
  module: LiquidModule,
73
114
  appGraph: AppGraph,
74
115
  deps: AugmentedDependencies,
116
+ options: GraphBuildOptions,
75
117
  ) {
76
118
  const sourceCode = await deps.getSourceCode(module.uri);
119
+
120
+ // Surface the file's own structural declarations as a by-product of the parse
121
+ // — only when the caller opted in (see GraphBuildOptions). Absent
122
+ // otherwise, and when the file could not be parsed.
123
+ if (options.includeStructural) {
124
+ module.structural = await extractStructural(sourceCode, module.uri);
125
+ }
126
+
77
127
  const references = await resolveLiquidReferences(appGraph, sourceCode, deps);
78
128
 
79
129
  for (const reference of references) {
80
130
  bind(module, reference.target, {
81
131
  sourceRange: reference.sourceRange,
82
132
  kind: reference.kind,
133
+ args: reference.args,
83
134
  });
84
135
  }
85
136
 
86
137
  const modules = unique(references.map((ref) => ref.target));
87
- const promises = modules.map((mod) => traverseModule(mod, appGraph, deps));
138
+ const promises = modules.map((mod) => traverseModule(mod, appGraph, deps, options));
88
139
 
89
140
  return Promise.all(promises);
90
141
  }
@@ -102,33 +153,53 @@ async function traverseLiquidModule(
102
153
  * match the rest of the graph on every platform. Unparseable input yields no
103
154
  * references rather than throwing.
104
155
  */
105
- async function resolveLiquidReferences(
156
+ export async function resolveLiquidReferences(
106
157
  appGraph: AppGraph,
107
158
  sourceCode: FileSourceCode,
108
159
  deps: ResolverDependencies,
109
160
  ): Promise<ResolvedReference[]> {
110
161
  if (sourceCode.ast instanceof Error) return []; // can't visit what you can't parse
111
162
 
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);
163
+ // Canonical target resolution (lib paths, module prefixes, extensions) is owned by
164
+ // platformos-common's DocumentsLocator — never re-derived here.
165
+ //
166
+ // `deps.app` is the caller's App, so a name is answered by the index it already holds
167
+ // (O(1), no I/O) and only an index MISS walks candidate directories. Without one the
168
+ // locator builds a walk-only stand-in and every reference lists a directory per
169
+ // candidate path — same answers, thousands of extra listings on a project the language
170
+ // server had already indexed. See `IDependencies.app`.
171
+ const documentsLocator = new DocumentsLocator(deps.fs, deps.app);
115
172
  const rootUri = URI.parse(appGraph.rootUri);
116
173
 
117
174
  const visitor: Visitor<SourceCodeType.LiquidHtml, ResolvedReference> = {
118
175
  // {{ 'app.js' | asset_url }}
119
- // {{ 'image.png' | asset_img_url }}
120
- // {{ 'icon.svg' | inline_asset_content }}
176
+ //
177
+ // `asset_url` is the ONLY filter that names an asset. `asset_img_url` and
178
+ // `inline_asset_content` stood beside it here and are Shopify's, inherited with the fork:
179
+ // neither is in `filters.json`, so no platformOS template can call one, and the four sample
180
+ // projects contain zero uses against 1142 of `asset_url`. A dead name in an edge rule is not
181
+ // harmless — it is a claim about the platform that the next reader has no reason to doubt.
121
182
  LiquidFilter: async (node, ancestors) => {
122
- if (['asset_url', 'asset_img_url', 'inline_asset_content'].includes(node.name)) {
183
+ if (node.name === 'asset_url') {
123
184
  const parentNode = ancestors[ancestors.length - 1]!;
124
185
  if (parentNode.type !== NodeTypes.LiquidVariable) return;
125
186
  if (parentNode.expression.type !== NodeTypes.String) return;
126
187
  if (parentNode.filters[0] !== node) return;
127
188
  const asset = parentNode.expression.value;
128
- const assetModule = getAssetModule(appGraph, asset);
129
- if (!assetModule) return;
189
+ // Skip a non-asset value before paying for the filesystem probe below.
190
+ // Not the only gate: `getAssetModuleByUri` applies the same predicate, so
191
+ // neither layer relies on the other remembering.
192
+ if (!isSupportedAsset(asset)) return;
193
+ // Resolve through DocumentsLocator (`'asset'`: app/assets, module
194
+ // public/assets) — not a hard-coded base — so the target matches the
195
+ // real on-disk location, with the canonical `app/assets/<name>` as the
196
+ // fallback for an unresolved asset.
197
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'asset', asset);
198
+ if (!uri) return;
199
+ const target = getAssetModuleByUri(appGraph, uri);
200
+ if (!target) return; // resolution changed the extension — no asset node
130
201
  return {
131
- target: assetModule,
202
+ target,
132
203
  sourceRange: [parentNode.position.start, parentNode.position.end],
133
204
  kind: 'asset',
134
205
  };
@@ -155,6 +226,7 @@ async function resolveLiquidReferences(
155
226
  target: getPartialModuleByUri(appGraph, uri),
156
227
  sourceRange: [tag.position.start, tag.position.end],
157
228
  kind: isInclude ? 'include' : 'render',
229
+ args: argNames(node.args),
158
230
  };
159
231
  },
160
232
 
@@ -169,6 +241,7 @@ async function resolveLiquidReferences(
169
241
  target: getPartialModuleByUri(appGraph, uri),
170
242
  sourceRange: [tag.position.start, tag.position.end],
171
243
  kind: 'function',
244
+ args: argNames(node.args),
172
245
  };
173
246
  },
174
247
 
@@ -187,6 +260,7 @@ async function resolveLiquidReferences(
187
260
  target: getPartialModuleByUri(appGraph, uri),
188
261
  sourceRange: [tag.position.start, tag.position.end],
189
262
  kind: 'background',
263
+ args: argNames(node.args),
190
264
  };
191
265
  },
192
266
 
@@ -202,6 +276,31 @@ async function resolveLiquidReferences(
202
276
  target: getGraphQLModuleByUri(appGraph, uri),
203
277
  sourceRange: [tag.position.start, tag.position.end],
204
278
  kind: 'graphql',
279
+ args: argNames(node.args),
280
+ };
281
+ },
282
+
283
+ // Frontmatter `layout: name` → page/email → its wrapper layout.
284
+ // Resolved through DocumentsLocator (`'layout'`: app/views/layouts, module
285
+ // prefixes, `.html.liquid`/`.liquid`). Only an EXPLICIT, static, non-empty
286
+ // string layout produces an edge:
287
+ // - `layout: ''` → explicitly no layout (no edge)
288
+ // - layout omitted → no edge (we never synthesize the implicit default)
289
+ // - dynamic `{{ ... }}` → no edge (not statically resolvable)
290
+ // - non-string value → no edge
291
+ // The source range is the whole frontmatter block (tag-level granularity,
292
+ // like the other edges).
293
+ YAMLFrontmatter: async (node) => {
294
+ const data = loadFrontmatter(node.body);
295
+ if (!data) return; // malformed/empty frontmatter — nothing to resolve
296
+ const layout = data.layout;
297
+ if (typeof layout !== 'string' || layout === '' || containsLiquid(layout)) return;
298
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'layout', layout);
299
+ if (!uri) return;
300
+ return {
301
+ target: getLayoutModuleByUri(appGraph, uri),
302
+ sourceRange: [node.position.start, node.position.end],
303
+ kind: 'layout',
205
304
  };
206
305
  },
207
306
  };
@@ -210,26 +309,19 @@ async function resolveLiquidReferences(
210
309
  }
211
310
 
212
311
  /**
213
- * Extract one Liquid file's outgoing dependency references, resolved against the
214
- * project at `rootUri`, WITHOUT building the whole app graph.
312
+ * Extract one Liquid file's outgoing dependency references, resolved against the project at
313
+ * `rootUri`, WITHOUT building the whole app graph.
215
314
  *
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.
315
+ * The per-file primitive for consumers holding a single (possibly in-flight, not-yet-on-disk)
316
+ * buffer. Resolution uses the same `DocumentsLocator`-backed logic as the full graph build, so
317
+ * a target's URI is identical to the key it would have as a graph node.
223
318
  *
224
319
  * 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.
320
+ * - Targets are returned whether or not they exist on disk (resolution is path-based). To
321
+ * distinguish missing targets, `stat` `target.uri` via the same `fs`.
229
322
  * - Only statically resolvable references are returned; dynamic targets
230
323
  * (`{% 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.
324
+ * - `sourceCode` is parsed by the caller; nothing but target resolution happens here.
233
325
  */
234
326
  export async function extractFileReferences(
235
327
  rootUri: UriString,
@@ -245,8 +337,9 @@ export async function extractFileReferences(
245
337
  return references.map((reference) => ({
246
338
  source: { uri: sourceUri, range: reference.sourceRange },
247
339
  target: { uri: reference.target.uri },
248
- type: 'direct',
340
+ type: 'direct' as const,
249
341
  kind: reference.kind,
342
+ ...argsField(reference.args),
250
343
  }));
251
344
  }
252
345
 
@@ -262,6 +355,151 @@ function isStringLiteral<T extends { type: NodeTypes }>(
262
355
  return !isString(node) && node.type === NodeTypes.String;
263
356
  }
264
357
 
358
+ /**
359
+ * Parse a YAML block (frontmatter body or a schema file) into an object, or
360
+ * `undefined` for unparseable / non-object YAML. The single `js-yaml` entry
361
+ * point shared by the layout-edge resolver, schema-table extraction, and
362
+ * self-structural extraction — so there is one frontmatter/YAML parse path.
363
+ */
364
+ function loadFrontmatter(body: string): Record<string, unknown> | undefined {
365
+ let data: unknown;
366
+ try {
367
+ // A duplicated key must not cost this file its edges — see
368
+ // PLATFORM_YAML_LOAD_OPTIONS. js-yaml's default throws, and the `catch` below
369
+ // then reports the file as having no frontmatter and no schema at all, which is
370
+ // how one repeated key silently removes a layout or partial from the graph.
371
+ data = yaml.load(body, PLATFORM_YAML_LOAD_OPTIONS);
372
+ } catch {
373
+ return undefined;
374
+ }
375
+ return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
376
+ }
377
+
378
+ /**
379
+ * Extract a Liquid file's own structural declarations from an already-parsed source — the
380
+ * per-file primitive, sibling to {@link extractFileReferences}. Reuses the shared `js-yaml`
381
+ * frontmatter parse, platformos-common's slug helpers, and check-common's liquid-doc and
382
+ * translation detection — never a second parser.
383
+ *
384
+ * Usage facts come from one walk of the parsed AST and are always present (sorted,
385
+ * de-duplicated); `doc_params` is in source order. Routing facts come from frontmatter: `slug`
386
+ * is the frontmatter override else the path-derived slug, for page files only, and
387
+ * `layout`/`method` when declared.
388
+ *
389
+ * Returns `undefined` for a non-Liquid source or one that could not be parsed.
390
+ */
391
+ export async function extractStructural(
392
+ sourceCode: FileSourceCode,
393
+ uri: UriString,
394
+ ): Promise<ModuleStructural | undefined> {
395
+ // Only a parsed Liquid AST has the structure this analyzes; a `.graphql`/`.yml`
396
+ // buffer (or an unparseable one) yields no structural facts.
397
+ if (sourceCode.type !== SourceCodeType.LiquidHtml || sourceCode.ast instanceof Error) {
398
+ return undefined;
399
+ }
400
+
401
+ const renders = new Set<string>();
402
+ const graphqlQueries = new Set<string>();
403
+ const filters = new Set<string>();
404
+ const tags = new Set<string>();
405
+ const translationKeys = new Set<string>();
406
+ // `{% doc %}` @param names, in source (signature) order — not sorted/de-duped.
407
+ const docParams: string[] = [];
408
+
409
+ // A single walk of the already-parsed AST collects every usage fact — no
410
+ // second traversal (the doc `@param` names are read from the parser-produced
411
+ // `LiquidDocParamNode`s in this same pass, as `extractDocDefinition` would).
412
+ await visit<SourceCodeType.LiquidHtml, void>(sourceCode.ast, {
413
+ RenderMarkup: async (node) => {
414
+ if (isStringLiteral(node.partial)) renders.add(node.partial.value);
415
+ },
416
+ GraphQLMarkup: async (node) => {
417
+ if (isStringLiteral(node.graphql)) graphqlQueries.add(node.graphql.value);
418
+ },
419
+ LiquidFilter: async (node) => {
420
+ filters.add(node.name);
421
+ },
422
+ LiquidTag: async (node) => {
423
+ if (typeof node.name === 'string') tags.add(node.name);
424
+ },
425
+ // A translation-key usage is a string literal piped through `t`/`translate`,
426
+ // e.g. `{{ 'greeting.hello' | t }}` — detected via check-common's shared
427
+ // `isTranslationKeyUsage` so it cannot drift from the translation check.
428
+ LiquidVariable: async (node) => {
429
+ if (isTranslationKeyUsage(node)) translationKeys.add(node.expression.value);
430
+ },
431
+ LiquidDocParamNode: async (node) => {
432
+ docParams.push(node.paramName.value);
433
+ },
434
+ });
435
+
436
+ const frontmatter = loadFrontmatterOf(sourceCode);
437
+ const layout = typeof frontmatter?.layout === 'string' ? frontmatter.layout : undefined;
438
+ const method = typeof frontmatter?.method === 'string' ? frontmatter.method : undefined;
439
+ const slug = effectiveSlug(uri, frontmatter);
440
+
441
+ const sorted = (set: Set<string>) => [...set].sort((a, b) => a.localeCompare(b));
442
+
443
+ return {
444
+ renders_used: sorted(renders),
445
+ graphql_queries_used: sorted(graphqlQueries),
446
+ filters_used: sorted(filters),
447
+ tags_used: sorted(tags),
448
+ translation_keys: sorted(translationKeys),
449
+ doc_params: docParams,
450
+ ...(slug !== undefined ? { slug } : {}),
451
+ ...(layout !== undefined ? { layout } : {}),
452
+ ...(method !== undefined ? { method } : {}),
453
+ };
454
+ }
455
+
456
+ /** The parsed frontmatter object of a Liquid file, if it has parseable frontmatter. */
457
+ function loadFrontmatterOf(sourceCode: FileSourceCode): Record<string, unknown> | undefined {
458
+ if (sourceCode.type !== SourceCodeType.LiquidHtml) return undefined;
459
+ const ast = sourceCode.ast;
460
+ if (ast instanceof Error || ast.type !== NodeTypes.Document) return undefined;
461
+ const node = ast.children.find((child) => child.type === NodeTypes.YAMLFrontmatter);
462
+ return node?.type === NodeTypes.YAMLFrontmatter ? loadFrontmatter(node.body) : undefined;
463
+ }
464
+
465
+ /**
466
+ * The effective URL slug for `uri`, delegating to `effectivePageSlug` — the
467
+ * single slug-derivation shared with `RouteTable`, so the graph's routing fact
468
+ * can never drift from the platform's actual routing (override-wins, coerced to
469
+ * a string; else path-derived using the effective format). Returns `undefined`
470
+ * for a non-page file unless it declares an explicit `slug` override.
471
+ */
472
+ function effectiveSlug(
473
+ uri: UriString,
474
+ frontmatter: Record<string, unknown> | undefined,
475
+ ): string | undefined {
476
+ const relativePath = extractRelativePagePath(uri);
477
+ if (relativePath !== null) return effectivePageSlug(relativePath, frontmatter);
478
+ // Non-page file: only an explicit `slug` override is meaningful.
479
+ const slug = frontmatter?.slug;
480
+ return slug !== undefined && slug !== null ? String(slug) : undefined;
481
+ }
482
+
483
+ /**
484
+ * The names of a call site's named arguments, in source order, or `undefined`
485
+ * when there are none (so an argument-less edge carries no `args` field). Values
486
+ * are intentionally not captured — names are what cross-checking against a
487
+ * partial's `@param` signature needs. Every `LiquidNamedArgument` has a string
488
+ * `name` by construction, so no per-element guard is required.
489
+ */
490
+ function argNames(args: LiquidNamedArgument[]): string[] | undefined {
491
+ return args.length > 0 ? args.map((arg) => arg.name) : undefined;
492
+ }
493
+
494
+ /**
495
+ * The spreadable `args` field for an edge: present only for a non-empty
496
+ * argument-name list, absent otherwise. The single place the "omit when none"
497
+ * rule lives, so {@link bind} and {@link extractFileReferences} cannot diverge.
498
+ */
499
+ function argsField(args: string[] | undefined): { args?: string[] } {
500
+ return args && args.length > 0 ? { args } : {};
501
+ }
502
+
265
503
  /**
266
504
  * The bind method is the method that links two modules together.
267
505
  *
@@ -276,10 +514,12 @@ export function bind(
276
514
  sourceRange,
277
515
  type = 'direct', // the type of dependency, can be 'direct' or 'indirect'
278
516
  kind, // the semantic Liquid construct that created the edge
517
+ args, // names of the named arguments at the call site (omitted when none)
279
518
  }: {
280
519
  sourceRange?: Range; // a range in the source module that references the child
281
520
  type?: Reference['type']; // the type of dependency
282
521
  kind?: ReferenceKind; // render | include | function | background | graphql | asset | layout
522
+ args?: string[]; // named-argument names at the call site
283
523
  } = {},
284
524
  ): void {
285
525
  const dependency: Reference = {
@@ -287,6 +527,7 @@ export function bind(
287
527
  target: { uri: target.uri },
288
528
  type: type,
289
529
  kind: kind,
530
+ ...argsField(args),
290
531
  };
291
532
 
292
533
  source.dependencies.push(dependency);