@platformos/platformos-graph 0.0.19 → 0.1.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 (141) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/bin/platformos-graph +21 -81
  3. package/dist/cache-probes.spec.d.ts +1 -0
  4. package/dist/cache-probes.spec.js +62 -0
  5. package/dist/cache-probes.spec.js.map +1 -0
  6. package/dist/cli.d.ts +46 -0
  7. package/dist/cli.js +131 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/cli.spec.d.ts +1 -0
  10. package/dist/cli.spec.js +235 -0
  11. package/dist/cli.spec.js.map +1 -0
  12. package/dist/graph/app-index-resolution.spec.d.ts +1 -0
  13. package/dist/graph/app-index-resolution.spec.js +167 -0
  14. package/dist/graph/app-index-resolution.spec.js.map +1 -0
  15. package/dist/graph/asset-resolution.spec.d.ts +1 -0
  16. package/dist/graph/asset-resolution.spec.js +64 -0
  17. package/dist/graph/asset-resolution.spec.js.map +1 -0
  18. package/dist/graph/augment.js +3 -1
  19. package/dist/graph/augment.js.map +1 -1
  20. package/dist/graph/build.d.ts +15 -2
  21. package/dist/graph/build.js +54 -12
  22. package/dist/graph/build.js.map +1 -1
  23. package/dist/graph/build.spec.d.ts +1 -0
  24. package/dist/graph/build.spec.js +138 -0
  25. package/dist/graph/build.spec.js.map +1 -0
  26. package/dist/graph/edge-sources.d.ts +28 -0
  27. package/dist/graph/edge-sources.js +43 -0
  28. package/dist/graph/edge-sources.js.map +1 -0
  29. package/dist/graph/edge-sources.spec.d.ts +1 -0
  30. package/dist/graph/edge-sources.spec.js +125 -0
  31. package/dist/graph/edge-sources.spec.js.map +1 -0
  32. package/dist/graph/extract.spec.d.ts +1 -0
  33. package/dist/graph/extract.spec.js +163 -0
  34. package/dist/graph/extract.spec.js.map +1 -0
  35. package/dist/graph/module.d.ts +70 -2
  36. package/dist/graph/module.js +164 -29
  37. package/dist/graph/module.js.map +1 -1
  38. package/dist/graph/module.spec.d.ts +1 -0
  39. package/dist/graph/module.spec.js +74 -0
  40. package/dist/graph/module.spec.js.map +1 -0
  41. package/dist/graph/query.d.ts +72 -0
  42. package/dist/graph/query.js +140 -0
  43. package/dist/graph/query.js.map +1 -0
  44. package/dist/graph/query.spec.d.ts +1 -0
  45. package/dist/graph/query.spec.js +209 -0
  46. package/dist/graph/query.spec.js.map +1 -0
  47. package/dist/graph/serialize.spec.d.ts +1 -0
  48. package/dist/graph/serialize.spec.js +50 -0
  49. package/dist/graph/serialize.spec.js.map +1 -0
  50. package/dist/graph/structural.spec.d.ts +1 -0
  51. package/dist/graph/structural.spec.js +146 -0
  52. package/dist/graph/structural.spec.js.map +1 -0
  53. package/dist/graph/test-helpers.d.ts +2 -3
  54. package/dist/graph/test-helpers.js +5 -9
  55. package/dist/graph/test-helpers.js.map +1 -1
  56. package/dist/graph/traverse-edges.spec.d.ts +1 -0
  57. package/dist/graph/traverse-edges.spec.js +337 -0
  58. package/dist/graph/traverse-edges.spec.js.map +1 -0
  59. package/dist/graph/traverse.d.ts +63 -4
  60. package/dist/graph/traverse.js +364 -49
  61. package/dist/graph/traverse.js.map +1 -1
  62. package/dist/index.d.ts +5 -1
  63. package/dist/index.js +21 -4
  64. package/dist/index.js.map +1 -1
  65. package/dist/parsers.d.ts +35 -0
  66. package/dist/parsers.js +72 -0
  67. package/dist/parsers.js.map +1 -0
  68. package/dist/parsers.spec.d.ts +1 -0
  69. package/dist/parsers.spec.js +70 -0
  70. package/dist/parsers.spec.js.map +1 -0
  71. package/dist/toSourceCode.d.ts +14 -2
  72. package/dist/toSourceCode.js +23 -19
  73. package/dist/toSourceCode.js.map +1 -1
  74. package/dist/tsconfig.tsbuildinfo +1 -1
  75. package/dist/types.d.ts +114 -20
  76. package/dist/types.js.map +1 -1
  77. package/dist/utils/index.d.ts +1 -1
  78. package/fixtures/asset-edges/app/assets/logo.css +1 -0
  79. package/fixtures/asset-edges/app/assets/site/app.js +1 -0
  80. package/fixtures/asset-edges/app/views/pages/index.liquid +3 -0
  81. package/fixtures/background-edges/app/views/pages/broken.liquid +3 -0
  82. package/fixtures/background-edges/app/views/pages/index.liquid +3 -0
  83. package/fixtures/background-edges/app/views/partials/jobs/notify.liquid +3 -0
  84. package/fixtures/function-edges/app/lib/queries/list.liquid +3 -0
  85. package/fixtures/function-edges/app/views/pages/broken.liquid +3 -0
  86. package/fixtures/function-edges/app/views/pages/index.liquid +3 -0
  87. package/fixtures/graphql-edges/app/graphql/blog_posts/find.graphql +10 -0
  88. package/fixtures/graphql-edges/app/views/pages/broken.liquid +3 -0
  89. package/fixtures/graphql-edges/app/views/pages/index.liquid +3 -0
  90. package/fixtures/graphql-table/app/graphql/with_table.graphql +5 -0
  91. package/fixtures/graphql-table/app/graphql/without_table.graphql +3 -0
  92. package/fixtures/graphql-table/app/views/pages/index.liquid +4 -0
  93. package/fixtures/include-edges/app/views/pages/index.liquid +1 -0
  94. package/fixtures/include-edges/app/views/partials/shared/header.liquid +1 -0
  95. package/fixtures/layout-edges/app/views/layouts/theme.liquid +4 -0
  96. package/fixtures/layout-edges/app/views/pages/broken.liquid +5 -0
  97. package/fixtures/layout-edges/app/views/pages/index.liquid +5 -0
  98. package/fixtures/module-edges/app/views/pages/index.liquid +4 -0
  99. package/fixtures/module-edges/modules/my_module/public/lib/queries/get.liquid +3 -0
  100. package/fixtures/module-edges/modules/my_module/public/views/partials/card.liquid +1 -0
  101. package/fixtures/schema-nodes/app/schema/blog_post.yml +6 -0
  102. package/fixtures/schema-nodes/app/schema/no_name.yml +3 -0
  103. package/fixtures/schema-nodes/app/views/pages/index.liquid +1 -0
  104. package/fixtures/skeleton/app/assets/app.js +1 -0
  105. package/fixtures/skeleton/app/views/partials/child.liquid +2 -4
  106. package/fixtures/skeleton/app/views/partials/parent.liquid +2 -4
  107. package/fixtures/structural/app/views/pages/about.liquid +1 -0
  108. package/fixtures/structural/app/views/pages/blog/show.liquid +4 -0
  109. package/fixtures/structural/app/views/pages/index.liquid +6 -0
  110. package/fixtures/structural/app/views/pages/rich.liquid +8 -0
  111. package/fixtures/structural/app/views/partials/card.liquid +1 -0
  112. package/fixtures/structural/app/views/partials/documented.liquid +5 -0
  113. package/package.json +7 -5
  114. package/src/cli.spec.ts +271 -0
  115. package/src/cli.ts +167 -0
  116. package/src/graph/augment.ts +4 -2
  117. package/src/graph/build.spec.ts +888 -9
  118. package/src/graph/build.ts +68 -17
  119. package/src/graph/edge-sources.spec.ts +125 -0
  120. package/src/graph/edge-sources.ts +49 -0
  121. package/src/graph/module.spec.ts +168 -0
  122. package/src/graph/module.ts +167 -33
  123. package/src/graph/query.spec.ts +260 -0
  124. package/src/graph/query.ts +154 -0
  125. package/src/graph/serialize.spec.ts +4 -4
  126. package/src/graph/test-helpers.ts +5 -12
  127. package/src/graph/traverse.spec.ts +278 -0
  128. package/src/graph/traverse.ts +423 -54
  129. package/src/index.ts +16 -1
  130. package/src/parsers.spec.ts +85 -0
  131. package/src/parsers.ts +75 -0
  132. package/src/toSourceCode.ts +26 -26
  133. package/src/types.ts +120 -23
  134. package/src/utils/index.ts +1 -1
  135. package/bin/jsconfig.json +0 -18
  136. package/dist/getWebComponentMap.d.ts +0 -10
  137. package/dist/getWebComponentMap.js +0 -66
  138. package/dist/getWebComponentMap.js.map +0 -1
  139. package/fixtures/skeleton/assets/app.js +0 -7
  140. package/src/getWebComponentMap.ts +0 -81
  141. /package/fixtures/skeleton/{assets → app/assets}/app.css +0 -0
@@ -1,22 +1,63 @@
1
- import { NodeTypes } from '@platformos/liquid-html-parser';
2
- import { SourceCodeType, visit, Visitor } from '@platformos/platformos-check-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';
20
+ import { URI } from 'vscode-uri';
3
21
  import {
4
22
  AugmentedDependencies,
5
23
  AppGraph,
6
24
  AppModule,
25
+ FileSourceCode,
26
+ GraphBuildOptions,
7
27
  LiquidModule,
28
+ ModuleStructural,
8
29
  ModuleType,
9
30
  Range,
10
31
  Reference,
32
+ ReferenceKind,
11
33
  Void,
12
34
  } from '../types';
13
- import { assertNever, exists, isString } from '../utils';
14
- import { getAssetModule, getLayoutModule, getPartialModule } from './module';
35
+ import { assertNever, exists, isString, unique } from '../utils';
36
+ import {
37
+ getAssetModuleByUri,
38
+ getGraphQLModuleByUri,
39
+ getLayoutModuleByUri,
40
+ getPartialModuleByUri,
41
+ isSupportedAsset,
42
+ } from './module';
43
+
44
+ /** A resolved outgoing reference: the target graph node + its call-site range + kind (+ named-arg names). */
45
+ export interface ResolvedReference {
46
+ target: AppModule;
47
+ sourceRange: Range;
48
+ kind: ReferenceKind;
49
+ /** Names of the named arguments at the call site, in source order; omitted when none. */
50
+ args?: string[];
51
+ }
52
+
53
+ /** The dependency surface the reference resolver needs: what `DocumentsLocator` takes. */
54
+ type ResolverDependencies = Pick<AugmentedDependencies, 'fs' | 'app'>;
15
55
 
16
56
  export async function traverseModule(
17
57
  module: AppModule,
18
58
  appGraph: AppGraph,
19
59
  deps: AugmentedDependencies,
60
+ options: GraphBuildOptions = {},
20
61
  ): Promise<Void> {
21
62
  // If the module is already traversed, skip it
22
63
  if (appGraph.modules[module.uri]) {
@@ -37,13 +78,31 @@ export async function traverseModule(
37
78
 
38
79
  switch (module.type) {
39
80
  case ModuleType.Liquid: {
40
- return traverseLiquidModule(module, appGraph, deps);
81
+ return traverseLiquidModule(module, appGraph, deps, options);
41
82
  }
42
83
 
43
84
  case ModuleType.Asset: {
44
85
  return; // Nothing to traverse in assets
45
86
  }
46
87
 
88
+ case ModuleType.GraphQL: {
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;
104
+ }
105
+
47
106
  default: {
48
107
  return assertNever(module);
49
108
  }
@@ -54,85 +113,391 @@ async function traverseLiquidModule(
54
113
  module: LiquidModule,
55
114
  appGraph: AppGraph,
56
115
  deps: AugmentedDependencies,
116
+ options: GraphBuildOptions,
57
117
  ) {
58
118
  const sourceCode = await deps.getSourceCode(module.uri);
59
119
 
60
- if (sourceCode.ast instanceof Error) return; // can't visit what you can't parse
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
+
127
+ const references = await resolveLiquidReferences(appGraph, sourceCode, deps);
128
+
129
+ for (const reference of references) {
130
+ bind(module, reference.target, {
131
+ sourceRange: reference.sourceRange,
132
+ kind: reference.kind,
133
+ args: reference.args,
134
+ });
135
+ }
136
+
137
+ const modules = unique(references.map((ref) => ref.target));
138
+ const promises = modules.map((mod) => traverseModule(mod, appGraph, deps, options));
61
139
 
62
- const visitor: Visitor<
63
- SourceCodeType.LiquidHtml,
64
- { target: AppModule; sourceRange: Range; targetRange?: Range }
65
- > = {
140
+ return Promise.all(promises);
141
+ }
142
+
143
+ /**
144
+ * Resolve a single parsed Liquid file's outgoing references — the one place that
145
+ * knows how each Liquid construct (`render`/`include`, `function`, `background`,
146
+ * `graphql`, asset filters) maps to a target module + {@link ReferenceKind}.
147
+ *
148
+ * Both the full-project traversal ({@link traverseLiquidModule}) and the
149
+ * standalone per-file primitive ({@link extractFileReferences}) go through this,
150
+ * so resolution can never drift between "graph build" and "validate one buffer".
151
+ *
152
+ * Targets are produced via the module factories (which normalize URIs), so keys
153
+ * match the rest of the graph on every platform. Unparseable input yields no
154
+ * references rather than throwing.
155
+ */
156
+ export async function resolveLiquidReferences(
157
+ appGraph: AppGraph,
158
+ sourceCode: FileSourceCode,
159
+ deps: ResolverDependencies,
160
+ ): Promise<ResolvedReference[]> {
161
+ if (sourceCode.ast instanceof Error) return []; // can't visit what you can't parse
162
+
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);
172
+ const rootUri = URI.parse(appGraph.rootUri);
173
+
174
+ const visitor: Visitor<SourceCodeType.LiquidHtml, ResolvedReference> = {
66
175
  // {{ 'app.js' | asset_url }}
67
- // {{ 'image.png' | asset_img_url }}
68
- // {{ '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.
69
182
  LiquidFilter: async (node, ancestors) => {
70
- if (['asset_url', 'asset_img_url', 'inline_asset_content'].includes(node.name)) {
183
+ if (node.name === 'asset_url') {
71
184
  const parentNode = ancestors[ancestors.length - 1]!;
72
185
  if (parentNode.type !== NodeTypes.LiquidVariable) return;
73
186
  if (parentNode.expression.type !== NodeTypes.String) return;
74
187
  if (parentNode.filters[0] !== node) return;
75
188
  const asset = parentNode.expression.value;
76
- const assetModule = getAssetModule(appGraph, asset);
77
- 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
78
201
  return {
79
- target: assetModule,
202
+ target,
80
203
  sourceRange: [parentNode.position.start, parentNode.position.end],
204
+ kind: 'asset',
81
205
  };
82
206
  }
83
207
  },
84
208
 
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
209
+ // {% render 'partial' %} / {% include 'partial' %}
210
+ // Both resolve through DocumentsLocator (like function/graphql), so module
211
+ // prefixes (`modules/<m>/...`), the lib search path, and `.liquid` /
212
+ // `.html.liquid` extensions are handled uniformly — not hard-coded to
213
+ // `app/views/partials`.
214
+ RenderMarkup: async (node, ancestors) => {
215
+ const partial = node.partial;
216
+ const tag = ancestors.at(-1)!;
217
+ if (!isStringLiteral(partial)) return; // dynamic target — skip
218
+ const isInclude = tag.type === NodeTypes.LiquidTag && tag.name === NamedTags.include;
219
+ const uri = await documentsLocator.locateOrDefault(
220
+ rootUri,
221
+ isInclude ? 'include' : 'render',
222
+ partial.value,
223
+ );
224
+ if (!uri) return;
225
+ return {
226
+ target: getPartialModuleByUri(appGraph, uri),
227
+ sourceRange: [tag.position.start, tag.position.end],
228
+ kind: isInclude ? 'include' : 'render',
229
+ args: argNames(node.args),
230
+ };
231
+ },
92
232
 
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;
233
+ // {% function result = 'queries/...' %} / {% function res = 'commands/...' %}
234
+ FunctionMarkup: async (node, ancestors) => {
235
+ const target = node.partial;
236
+ const tag = ancestors.at(-1)!;
237
+ if (!isStringLiteral(target)) return; // dynamic target — skip
238
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
239
+ if (!uri) return;
240
+ return {
241
+ target: getPartialModuleByUri(appGraph, uri),
242
+ sourceRange: [tag.position.start, tag.position.end],
243
+ kind: 'function',
244
+ args: argNames(node.args),
245
+ };
246
+ },
98
247
 
248
+ // {% background job_id = 'partial', ... %} (file-based form)
249
+ // Runs a partial asynchronously; `node.partial` is the partial reference,
250
+ // resolved against the same search paths as {% function %}. The inline form
251
+ // ({% background %}...{% endbackground %}) parses to BackgroundInlineMarkup,
252
+ // has no file target, and is intentionally not matched here.
253
+ BackgroundMarkup: async (node, ancestors) => {
254
+ const target = node.partial;
255
+ const tag = ancestors.at(-1)!;
256
+ if (!isStringLiteral(target)) return; // dynamic target — skip
257
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'function', target.value);
258
+ if (!uri) return;
99
259
  return {
100
- target: assetModule,
101
- sourceRange: [node.blockStartPosition.start, nodeNameNode.position.end],
102
- targetRange: range,
260
+ target: getPartialModuleByUri(appGraph, uri),
261
+ sourceRange: [tag.position.start, tag.position.end],
262
+ kind: 'background',
263
+ args: argNames(node.args),
103
264
  };
104
265
  },
105
266
 
106
- // {% render 'partial' %}
107
- RenderMarkup: async (node, ancestors) => {
108
- const partial = node.partial;
267
+ // {% graphql result = 'path/to/operation' %}
268
+ // `node.name` is the RESULT variable; the operation-file path is `node.graphql`.
269
+ GraphQLMarkup: async (node, ancestors) => {
270
+ const op = node.graphql;
109
271
  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
- }
272
+ if (!isStringLiteral(op)) return; // dynamic/inline — no static file
273
+ const uri = await documentsLocator.locateOrDefault(rootUri, 'graphql', op.value);
274
+ if (!uri) return;
275
+ return {
276
+ target: getGraphQLModuleByUri(appGraph, uri),
277
+ sourceRange: [tag.position.start, tag.position.end],
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',
304
+ };
116
305
  },
117
306
  };
118
307
 
119
- const references = await visit(sourceCode.ast, visitor);
308
+ return visit(sourceCode.ast, visitor);
309
+ }
120
310
 
121
- for (const reference of references) {
122
- bind(module, reference.target, {
123
- sourceRange: reference.sourceRange,
124
- targetRange: reference.targetRange,
125
- });
311
+ /**
312
+ * Extract one Liquid file's outgoing dependency references, resolved against the project at
313
+ * `rootUri`, WITHOUT building the whole app graph.
314
+ *
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.
318
+ *
319
+ * Notes for consumers:
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`.
322
+ * - Only statically resolvable references are returned; dynamic targets
323
+ * (`{% render some_var %}`) and inline forms are skipped.
324
+ * - `sourceCode` is parsed by the caller; nothing but target resolution happens here.
325
+ */
326
+ export async function extractFileReferences(
327
+ rootUri: UriString,
328
+ sourceUri: UriString,
329
+ sourceCode: FileSourceCode,
330
+ deps: ResolverDependencies,
331
+ ): Promise<Reference[]> {
332
+ // A throwaway graph so the URI-normalizing module factories can be reused; it
333
+ // is never traversed and is discarded with this call.
334
+ const scratchGraph: AppGraph = { rootUri, entryPoints: [], modules: {} };
335
+ const references = await resolveLiquidReferences(scratchGraph, sourceCode, deps);
336
+
337
+ return references.map((reference) => ({
338
+ source: { uri: sourceUri, range: reference.sourceRange },
339
+ target: { uri: reference.target.uri },
340
+ type: 'direct' as const,
341
+ kind: reference.kind,
342
+ ...argsField(reference.args),
343
+ }));
344
+ }
345
+
346
+ /**
347
+ * A render/function/graphql target that is a static string literal (e.g.
348
+ * `{% render 'partial' %}`), narrowed to its `NodeTypes.String` member so the
349
+ * `.value` is accessible. Dynamic targets (`{% render var %}`) return false and
350
+ * are skipped — the graph only records statically resolvable edges.
351
+ */
352
+ function isStringLiteral<T extends { type: NodeTypes }>(
353
+ node: T,
354
+ ): node is Extract<T, { type: NodeTypes.String }> {
355
+ return !isString(node) && node.type === NodeTypes.String;
356
+ }
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;
126
374
  }
375
+ return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined;
376
+ }
127
377
 
128
- const modules = unique(references.map((ref) => ref.target));
129
- const promises = modules.map((mod) => traverseModule(mod, appGraph, deps));
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
+ }
130
400
 
131
- return Promise.all(promises);
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
+ };
132
454
  }
133
455
 
134
- function unique<T>(arr: T[]): T[] {
135
- return [...new Set(arr)];
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 } : {};
136
501
  }
137
502
 
138
503
  /**
@@ -147,18 +512,22 @@ export function bind(
147
512
  target: AppModule,
148
513
  {
149
514
  sourceRange,
150
- targetRange,
151
515
  type = 'direct', // the type of dependency, can be 'direct' or 'indirect'
516
+ kind, // the semantic Liquid construct that created the edge
517
+ args, // names of the named arguments at the call site (omitted when none)
152
518
  }: {
153
519
  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
520
  type?: Reference['type']; // the type of dependency
521
+ kind?: ReferenceKind; // render | include | function | background | graphql | asset | layout
522
+ args?: string[]; // named-argument names at the call site
156
523
  } = {},
157
524
  ): void {
158
525
  const dependency: Reference = {
159
526
  source: { uri: source.uri, range: sourceRange },
160
- target: { uri: target.uri, range: targetRange },
527
+ target: { uri: target.uri },
161
528
  type: type,
529
+ kind: kind,
530
+ ...argsField(args),
162
531
  };
163
532
 
164
533
  source.dependencies.push(dependency);
package/src/index.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  export { buildAppGraph } from './graph/build';
2
+ export { enumerateEdgeSources, isEdgeSource } from './graph/edge-sources';
3
+ export { extractFileReferences, extractStructural } from './graph/traverse';
4
+ export {
5
+ dependenciesOf,
6
+ dependentsOf,
7
+ exists,
8
+ isEntryPoint,
9
+ isOrphan,
10
+ orphans,
11
+ reachableFrom,
12
+ missingDependencies,
13
+ missingTargets,
14
+ nearestModules,
15
+ } from './graph/query';
16
+ export type { NearestModulesOptions } from './graph/query';
2
17
  export { serializeAppGraph } from './graph/serialize';
3
- export { getWebComponentMap, findWebComponentReferences } from './getWebComponentMap';
4
18
  export { parseJs, toSourceCode } from './toSourceCode';
19
+ export { appBackedGetSourceCode, graphParsers } from './parsers';
5
20
  export * from './types';
@@ -0,0 +1,85 @@
1
+ import { App, Parsers, SourceCodeType } from '@platformos/platformos-common';
2
+ import { toLiquidHTMLAST } from '@platformos/platformos-check-common';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { appBackedGetSourceCode, graphParsers } from './parsers';
5
+ import { toSourceCode } from './toSourceCode';
6
+
7
+ const ROOT = 'file:///project';
8
+ const uri = (relativePath: string) => `${ROOT}/${relativePath}`;
9
+
10
+ const fsOver = (files: Record<string, string>) => ({
11
+ readFile: async (target: string) => {
12
+ const source = files[target];
13
+ if (source === undefined) throw new Error(`ENOENT: ${target}`);
14
+ return source;
15
+ },
16
+ stat: async () => {
17
+ throw new Error('not used');
18
+ },
19
+ readDirectory: async () => [],
20
+ });
21
+
22
+ describe('graphParsers', () => {
23
+ it('registers a JS parser and treats images as opaque', async () => {
24
+ const jsUri = uri('app/assets/app.js');
25
+ const pngUri = uri('app/assets/logo.png');
26
+ const files = { [jsUri]: 'export const a = 1;', [pngUri]: 'binary' };
27
+ const app = App.fromPaths(ROOT, [jsUri, pngUri], fsOver(files), graphParsers);
28
+
29
+ await app.load();
30
+
31
+ expect((app.get(jsUri)!.ast as { type: string }).type).toBe('Program');
32
+ expect(app.get(pngUri)!.ast).toEqual(new Error('File parsing not implemented'));
33
+ });
34
+
35
+ it('is the same JS parse the buffer path uses', async () => {
36
+ const source = 'export const a = 1;';
37
+ const jsUri = uri('app/assets/app.js');
38
+ const app = App.fromPaths(ROOT, [jsUri], fsOver({ [jsUri]: source }), graphParsers);
39
+ await app.load();
40
+
41
+ const fromBuffer = await toSourceCode(jsUri, source);
42
+
43
+ expect(app.get(jsUri)!.ast).toEqual(fromBuffer.ast);
44
+ });
45
+ });
46
+
47
+ describe('appBackedGetSourceCode', () => {
48
+ const parsers = (
49
+ liquid = vi.fn(toLiquidHTMLAST),
50
+ ): { parsers: Parsers; liquid: typeof liquid } => ({
51
+ parsers: { [SourceCodeType.LiquidHtml]: liquid, extensions: graphParsers.extensions },
52
+ liquid,
53
+ });
54
+
55
+ it('parses a file at most once across a graph read and a lint read', async () => {
56
+ const cardUri = uri('app/views/partials/card.liquid');
57
+ const { parsers: registered, liquid } = parsers();
58
+ const app = App.fromPaths(ROOT, [cardUri], fsOver({ [cardUri]: '<b>card</b>' }), registered);
59
+ const getSourceCode = appBackedGetSourceCode(app, async () => {
60
+ throw new Error('fallback should not be needed');
61
+ });
62
+
63
+ // The graph asks for it…
64
+ const forGraph = await getSourceCode(cardUri);
65
+ void forGraph.ast;
66
+ // …and the lint reads the same file object.
67
+ await app.get(cardUri)!.load();
68
+ void app.get(cardUri)!.ast;
69
+
70
+ expect(forGraph).toBe(app.get(cardUri) as unknown as typeof forGraph);
71
+ expect(liquid.mock.calls.length).toBe(1);
72
+ });
73
+
74
+ it('falls back for a URI the app does not contain', async () => {
75
+ const app = App.fromPaths(ROOT, [], fsOver({}), graphParsers);
76
+ const outside = 'file:///elsewhere/thing.liquid';
77
+
78
+ const result = await appBackedGetSourceCode(app, async (target) =>
79
+ toSourceCode(target, '<b>outside</b>'),
80
+ )(outside);
81
+
82
+ expect(result.uri).toBe(outside);
83
+ expect(result.source).toBe('<b>outside</b>');
84
+ });
85
+ });