@platformos/platformos-graph 0.0.20 → 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 (120) hide show
  1. package/CHANGELOG.md +511 -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 +102 -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/with_table.graphql +5 -0
  79. package/fixtures/graphql-table/app/graphql/without_table.graphql +3 -0
  80. package/fixtures/graphql-table/app/views/pages/index.liquid +4 -0
  81. package/fixtures/layout-edges/app/views/layouts/theme.liquid +4 -0
  82. package/fixtures/layout-edges/app/views/pages/broken.liquid +5 -0
  83. package/fixtures/layout-edges/app/views/pages/index.liquid +5 -0
  84. package/fixtures/schema-nodes/app/schema/blog_post.yml +6 -0
  85. package/fixtures/schema-nodes/app/schema/no_name.yml +3 -0
  86. package/fixtures/schema-nodes/app/views/pages/index.liquid +1 -0
  87. package/fixtures/structural/app/views/pages/about.liquid +1 -0
  88. package/fixtures/structural/app/views/pages/blog/show.liquid +4 -0
  89. package/fixtures/structural/app/views/pages/index.liquid +6 -0
  90. package/fixtures/structural/app/views/pages/rich.liquid +8 -0
  91. package/fixtures/structural/app/views/partials/card.liquid +1 -0
  92. package/fixtures/structural/app/views/partials/documented.liquid +5 -0
  93. package/package.json +7 -5
  94. package/src/cli.spec.ts +37 -31
  95. package/src/cli.ts +31 -15
  96. package/src/graph/augment.ts +4 -0
  97. package/src/graph/build.spec.ts +871 -6
  98. package/src/graph/build.ts +68 -17
  99. package/src/graph/edge-sources.spec.ts +125 -0
  100. package/src/graph/edge-sources.ts +49 -0
  101. package/src/graph/module.spec.ts +168 -0
  102. package/src/graph/module.ts +139 -45
  103. package/src/graph/query.spec.ts +260 -0
  104. package/src/graph/query.ts +154 -0
  105. package/src/graph/serialize.spec.ts +4 -4
  106. package/src/graph/test-helpers.ts +3 -3
  107. package/src/graph/{extract.spec.ts → traverse.spec.ts} +125 -2
  108. package/src/graph/traverse.ts +278 -37
  109. package/src/index.ts +16 -1
  110. package/src/parsers.spec.ts +85 -0
  111. package/src/parsers.ts +75 -0
  112. package/src/toSourceCode.ts +26 -26
  113. package/src/types.ts +106 -7
  114. package/src/utils/index.ts +1 -1
  115. package/dist/getWebComponentMap.d.ts +0 -10
  116. package/dist/getWebComponentMap.js +0 -66
  117. package/dist/getWebComponentMap.js.map +0 -1
  118. package/src/graph/traverse-edges.spec.ts +0 -278
  119. /package/fixtures/skeleton/{assets → app/assets}/app.css +0 -0
  120. /package/fixtures/skeleton/{assets → app/assets}/app.js +0 -0
@@ -1,8 +1,30 @@
1
1
  import { path as pathUtils, SourceCodeType } from '@platformos/platformos-check-common';
2
+ import { MockFileSystem } from '@platformos/platformos-check-common/src/test';
3
+ import { NodeFileSystem } from '@platformos/platformos-check-node';
4
+ import {
5
+ AbstractFileSystem,
6
+ App,
7
+ UriString,
8
+ walkAppSourceFiles,
9
+ } from '@platformos/platformos-common';
2
10
  import { assert, beforeAll, beforeEach, describe, expect, it } from 'vitest';
11
+ import { URI } from 'vscode-uri';
3
12
  import { buildAppGraph } from '../index';
4
- import { Dependencies, LiquidModuleKind, ModuleType, AppGraph } from '../types';
5
- import { getDependencies, skeleton } from './test-helpers';
13
+ import { toSourceCode } from '../toSourceCode';
14
+ import {
15
+ AppGraph,
16
+ AppModule,
17
+ AssetModule,
18
+ Dependencies,
19
+ LiquidModule,
20
+ LiquidModuleKind,
21
+ ModuleStructural,
22
+ ModuleType,
23
+ Reference,
24
+ } from '../types';
25
+ import { unique } from '../utils';
26
+ import { getGraphQLModuleByUri, getPartialModuleByUri } from './module';
27
+ import { fixturesRoot, getDependencies, makeGetSourceCode, skeleton } from './test-helpers';
6
28
 
7
29
  describe('Module: index', () => {
8
30
  const rootUri = skeleton;
@@ -51,8 +73,8 @@ describe('Module: index', () => {
51
73
  const deps = layout.dependencies;
52
74
  expect(deps.map((x) => x.target.uri)).toEqual(
53
75
  expect.arrayContaining([
54
- p('assets/app.js'),
55
- p('assets/app.css'),
76
+ p('app/assets/app.js'),
77
+ p('app/assets/app.css'),
56
78
  p('app/views/partials/header.liquid'),
57
79
  ]),
58
80
  );
@@ -112,11 +134,854 @@ describe('Module: index', () => {
112
134
  expect(
113
135
  layout.dependencies.map((d) => ({ target: d.target.uri, type: d.type, kind: d.kind })),
114
136
  ).toEqual([
115
- { target: p('assets/app.js'), type: 'direct', kind: 'asset' },
116
- { target: p('assets/app.css'), type: 'direct', kind: 'asset' },
137
+ { target: p('app/assets/app.js'), type: 'direct', kind: 'asset' },
138
+ { target: p('app/assets/app.css'), type: 'direct', kind: 'asset' },
117
139
  { target: p('app/views/partials/header.liquid'), type: 'direct', kind: 'render' },
118
140
  ]);
119
141
  });
120
142
  });
143
+
144
+ /**
145
+ * Entry points come from an ANCHORED walk of the app subtrees, so what a
146
+ * directory is CALLED never decides whether the graph can see it. The walk this
147
+ * replaced skipped any directory ending in `vendor`, `build`, `tmp` or `dist`,
148
+ * which lost every page under `app/views/pages/vendor/**` — a real section of a
149
+ * real site — while still descending into `tmp/app/views/pages/`, which the
150
+ * platform does not deploy at all.
151
+ */
152
+ describe('entry points on a project with app directories named like build output', () => {
153
+ const projectRoot = 'file:///project';
154
+ const u = (part: string) => `${projectRoot}/${part}`;
155
+
156
+ let graph: AppGraph;
157
+
158
+ beforeEach(async () => {
159
+ const fs = new MockFileSystem(
160
+ {
161
+ 'app/views/pages/vendor/index.liquid': `{% render 'vendor/card' %}`,
162
+ 'app/views/pages/build/status.liquid': `ok`,
163
+ 'app/views/partials/vendor/card.liquid': `a card`,
164
+ 'tmp/app/views/pages/scratch.liquid': `not deployed`,
165
+ 'node_modules/some-pkg/app/views/pages/decoy.liquid': `not ours`,
166
+ 'dist/app/views/pages/bundled.liquid': `build output`,
167
+ },
168
+ projectRoot,
169
+ );
170
+
171
+ graph = await buildAppGraph(projectRoot, {
172
+ fs,
173
+ getSourceCode: async (uri: string) => toSourceCode(uri, await fs.readFile(uri)),
174
+ });
175
+ });
176
+
177
+ it('finds the pages under vendor/ and build/, and nothing outside the app subtrees', () => {
178
+ expect(graph.entryPoints.map((entry) => entry.uri).sort()).toEqual([
179
+ u('app/views/pages/build/status.liquid'),
180
+ u('app/views/pages/vendor/index.liquid'),
181
+ ]);
182
+ });
183
+
184
+ it('traverses them, so a partial under vendor/ is a graph node with a reference', () => {
185
+ const card = graph.modules[u('app/views/partials/vendor/card.liquid')];
186
+ assert(card);
187
+ expect(card.references.map((reference) => reference.source.uri)).toEqual([
188
+ u('app/views/pages/vendor/index.liquid'),
189
+ ]);
190
+ });
191
+ });
192
+ });
193
+ });
194
+
195
+ /**
196
+ * a Liquid file's own structural declarations.
197
+ */
198
+ describe('Self-structural: page routing + AST usage facts', () => {
199
+ const rootUri = pathUtils.join(fixturesRoot, 'structural');
200
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
201
+ let graph: AppGraph;
202
+
203
+ const structuralOf = (uri: string): ModuleStructural | undefined => {
204
+ const module = graph.modules[uri];
205
+ assert(module);
206
+ assert(module.type === ModuleType.Liquid);
207
+ return module.structural;
208
+ };
209
+
210
+ /** The empty usage arrays — spread into expectations so each asserts the whole object. */
211
+ const NO_USAGE = {
212
+ renders_used: [],
213
+ graphql_queries_used: [],
214
+ filters_used: [],
215
+ tags_used: [],
216
+ translation_keys: [],
217
+ doc_params: [],
218
+ };
219
+
220
+ beforeAll(async () => {
221
+ graph = await buildAppGraph(rootUri, getDependencies(), undefined, { includeStructural: true });
222
+ }, 15000);
223
+
224
+ it('derives slug from the page path and carries declared layout + method + its renders', () => {
225
+ expect(structuralOf(p('app/views/pages/index.liquid'))).toEqual({
226
+ ...NO_USAGE,
227
+ renders_used: ['card', 'documented'],
228
+ tags_used: ['render'],
229
+ slug: '/',
230
+ layout: 'application',
231
+ method: 'get',
232
+ });
233
+ });
234
+
235
+ it('collects `{% doc %}` @param names in declaration order', () => {
236
+ // `{% doc %}` is a raw tag (not a LiquidTag), so it does not appear in
237
+ // tags_used; its @param names are surfaced via doc_params, in source order.
238
+ expect(structuralOf(p('app/views/partials/documented.liquid'))).toEqual({
239
+ ...NO_USAGE,
240
+ doc_params: ['title', 'count'],
241
+ });
242
+ });
243
+
244
+ it('derives the slug from the path for a page with no frontmatter and no usage', () => {
245
+ expect(structuralOf(p('app/views/pages/about.liquid'))).toEqual({
246
+ ...NO_USAGE,
247
+ slug: 'about',
248
+ });
249
+ });
250
+
251
+ it('uses the frontmatter slug override verbatim (not the path)', () => {
252
+ expect(structuralOf(p('app/views/pages/blog/show.liquid'))).toEqual({
253
+ ...NO_USAGE,
254
+ slug: 'blog/custom',
255
+ });
256
+ });
257
+
258
+ it('gives a partial all-empty usage arrays and no routing facts', () => {
259
+ expect(structuralOf(p('app/views/partials/card.liquid'))).toEqual({ ...NO_USAGE });
260
+ });
261
+
262
+ it('collects every AST usage fact (renders/graphql/filters/tags/translation) sorted + de-duplicated', () => {
263
+ expect(structuralOf(p('app/views/pages/rich.liquid'))).toEqual({
264
+ renders_used: ['card'],
265
+ graphql_queries_used: ['blog/find'],
266
+ filters_used: ['t', 'upcase'],
267
+ tags_used: ['assign', 'graphql', 'if', 'render'],
268
+ translation_keys: ['greeting.hello'],
269
+ doc_params: [],
270
+ slug: 'rich',
271
+ layout: 'application',
272
+ });
273
+ });
274
+ });
275
+
276
+ describe('Self-structural: the includeStructural opt-in', () => {
277
+ const rootUri = pathUtils.join(fixturesRoot, 'structural');
278
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
279
+
280
+ const liquidModule = (graph: AppGraph, uri: string): LiquidModule => {
281
+ const module = graph.modules[uri];
282
+ assert(module);
283
+ assert(module.type === ModuleType.Liquid);
284
+ return module;
285
+ };
286
+
287
+ it('does NOT populate `structural` on a default build (the LSP opt-out)', async () => {
288
+ const graph = await buildAppGraph(rootUri, getDependencies());
289
+ expect(liquidModule(graph, p('app/views/pages/index.liquid')).structural).toBeUndefined();
290
+ });
291
+
292
+ it('populates `structural` only when opted in', async () => {
293
+ const graph = await buildAppGraph(rootUri, getDependencies(), undefined, {
294
+ includeStructural: true,
295
+ });
296
+ expect(liquidModule(graph, p('app/views/pages/index.liquid')).structural).toEqual({
297
+ renders_used: ['card', 'documented'],
298
+ graphql_queries_used: [],
299
+ filters_used: [],
300
+ tags_used: ['render'],
301
+ translation_keys: [],
302
+ doc_params: [],
303
+ slug: '/',
304
+ layout: 'application',
305
+ method: 'get',
306
+ });
307
+ });
308
+ });
309
+
310
+ /**
311
+ * A module compared for its EDGE identity. Self-structural (`LiquidModule.structural`) is a
312
+ * separate concern, pinned exhaustively in `traverse.spec.ts`;
313
+ * stripping it here keeps these edge tests focused and stable as structural grows.
314
+ */
315
+ function edgeIdentity(module: AppModule | undefined): AppModule | undefined {
316
+ if (module && module.type === ModuleType.Liquid) {
317
+ const copy = { ...module };
318
+ delete copy.structural;
319
+ return copy;
320
+ }
321
+ return module;
322
+ }
323
+
324
+ /**
325
+ * The exact source range of `snippet` within `source`. Derived from the fixture
326
+ * text (rather than hard-coded offsets) so the assertion is self-documenting
327
+ * and survives edits to unrelated lines.
328
+ */
329
+ function rangeOf(source: string, snippet: string): [number, number] {
330
+ const start = source.indexOf(snippet);
331
+ if (start < 0) throw new Error(`snippet not found in fixture: ${snippet}`);
332
+ return [start, start + snippet.length];
333
+ }
334
+
335
+ /** A `direct` dependency Reference with no target range (the common case here). */
336
+ function directRef(
337
+ sourceUri: string,
338
+ sourceRange: [number, number],
339
+ targetUri: string,
340
+ kind: Reference['kind'],
341
+ args?: string[],
342
+ ): Reference {
343
+ return {
344
+ source: { uri: sourceUri, range: sourceRange },
345
+ target: { uri: targetUri },
346
+ type: 'direct',
347
+ kind,
348
+ ...(args ? { args } : {}),
349
+ };
350
+ }
351
+
352
+ const partialNode = (uri: string, exists: boolean, references: Reference[]): LiquidModule => ({
353
+ type: ModuleType.Liquid,
354
+ kind: LiquidModuleKind.Partial,
355
+ uri,
356
+ exists,
357
+ dependencies: [],
358
+ references,
359
+ });
360
+
361
+ describe('URI normalization in graph node factories', () => {
362
+ const emptyGraph = (): AppGraph => ({
363
+ rootUri: 'file:///app',
364
+ entryPoints: [],
365
+ modules: {},
366
+ });
367
+
368
+ it('getPartialModuleByUri returns a Partial node with a forward-slash URI', () => {
369
+ const mod = getPartialModuleByUri(
370
+ emptyGraph(),
371
+ 'file:///d:/a/repo\\app\\lib\\queries\\list.liquid',
372
+ );
373
+ expect(mod).toEqual({
374
+ type: ModuleType.Liquid,
375
+ kind: LiquidModuleKind.Partial,
376
+ uri: 'file:///d:/a/repo/app/lib/queries/list.liquid',
377
+ dependencies: [],
378
+ references: [],
379
+ });
380
+ });
381
+
382
+ it('getGraphQLModuleByUri returns a GraphQL node with a forward-slash URI', () => {
383
+ const mod = getGraphQLModuleByUri(
384
+ emptyGraph(),
385
+ 'file:///d:/a/repo\\app\\graphql\\find.graphql',
386
+ );
387
+ expect(mod).toEqual({
388
+ type: ModuleType.GraphQL,
389
+ kind: 'graphql',
390
+ uri: 'file:///d:/a/repo/app/graphql/find.graphql',
391
+ dependencies: [],
392
+ references: [],
393
+ tables: [],
394
+ });
395
+ });
396
+ });
397
+
398
+ describe('Graph traversal: {% function %} edges', () => {
399
+ const rootUri = pathUtils.join(fixturesRoot, 'function-edges');
400
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
401
+ let graph: AppGraph;
402
+ let indexSource: string;
403
+ let brokenSource: string;
404
+
405
+ beforeAll(async () => {
406
+ const dependencies: Dependencies = getDependencies();
407
+ graph = await buildAppGraph(rootUri, dependencies);
408
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
409
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
410
+ }, 15000);
411
+
412
+ it('links a page to the resolved lib query via a single function edge', () => {
413
+ const edge = directRef(
414
+ p('app/views/pages/index.liquid'),
415
+ rangeOf(indexSource, "function items = 'queries/list'"),
416
+ p('app/lib/queries/list.liquid'),
417
+ 'function',
418
+ );
419
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
420
+ expect(edgeIdentity(graph.modules[p('app/lib/queries/list.liquid')])).toEqual(
421
+ partialNode(p('app/lib/queries/list.liquid'), true, [edge]),
422
+ );
423
+ });
424
+
425
+ it('records a missing function target as an exists:false node', () => {
426
+ const edge = directRef(
427
+ p('app/views/pages/broken.liquid'),
428
+ rangeOf(brokenSource, "function ghost = 'queries/missing'"),
429
+ p('app/lib/queries/missing.liquid'),
430
+ 'function',
431
+ );
432
+ expect(graph.modules[p('app/lib/queries/missing.liquid')]).toEqual(
433
+ partialNode(p('app/lib/queries/missing.liquid'), false, [edge]),
434
+ );
435
+ });
436
+ });
437
+
438
+ describe('Graph traversal: {% graphql %} edges', () => {
439
+ const rootUri = pathUtils.join(fixturesRoot, 'graphql-edges');
440
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
441
+ let graph: AppGraph;
442
+ let indexSource: string;
443
+ let brokenSource: string;
444
+
445
+ const graphqlNode = (
446
+ uri: string,
447
+ exists: boolean,
448
+ references: Reference[],
449
+ tables: string[] = [],
450
+ ) => ({
451
+ type: ModuleType.GraphQL,
452
+ kind: 'graphql' as const,
453
+ uri,
454
+ exists,
455
+ dependencies: [],
456
+ references,
457
+ tables,
458
+ });
459
+
460
+ beforeAll(async () => {
461
+ const dependencies: Dependencies = getDependencies();
462
+ graph = await buildAppGraph(rootUri, dependencies);
463
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
464
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
465
+ }, 15000);
466
+
467
+ it('links a page to the resolved .graphql operation via a single graphql edge', () => {
468
+ const edge = directRef(
469
+ p('app/views/pages/index.liquid'),
470
+ rangeOf(indexSource, "graphql posts = 'blog_posts/find', id: '1'"),
471
+ p('app/graphql/blog_posts/find.graphql'),
472
+ 'graphql',
473
+ ['id'],
474
+ );
475
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
476
+ // The resolved GraphQL node carries the model `table` it targets (the fixture
477
+ // operation filters on `table: { value: "blog_post" }`).
478
+ expect(graph.modules[p('app/graphql/blog_posts/find.graphql')]).toEqual(
479
+ graphqlNode(p('app/graphql/blog_posts/find.graphql'), true, [edge], ['blog_post']),
480
+ );
481
+ });
482
+
483
+ it('records a missing graphql target as an exists:false GraphQL node', () => {
484
+ const edge = directRef(
485
+ p('app/views/pages/broken.liquid'),
486
+ rangeOf(brokenSource, "graphql ghost = 'blog_posts/missing'"),
487
+ p('app/graphql/blog_posts/missing.graphql'),
488
+ 'graphql',
489
+ );
490
+ expect(graph.modules[p('app/graphql/blog_posts/missing.graphql')]).toEqual(
491
+ graphqlNode(p('app/graphql/blog_posts/missing.graphql'), false, [edge]),
492
+ );
493
+ });
494
+ });
495
+
496
+ describe('Graph traversal: GraphQL node `tables` (build-time, both shapes)', () => {
497
+ const rootUri = pathUtils.join(fixturesRoot, 'graphql-table');
498
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
499
+ let graph: AppGraph;
500
+
501
+ beforeAll(async () => {
502
+ graph = await buildAppGraph(rootUri, getDependencies());
503
+ }, 15000);
504
+
505
+ it('records the tables for an operation that filters on one', () => {
506
+ const node = graph.modules[p('app/graphql/with_table.graphql')];
507
+ assert(node);
508
+ assert(node.type === ModuleType.GraphQL);
509
+ expect(node.tables).toEqual(['blog_post']);
510
+ });
511
+
512
+ it('leaves tables empty for an operation with no table filter', () => {
513
+ const node = graph.modules[p('app/graphql/without_table.graphql')];
514
+ assert(node);
515
+ assert(node.type === ModuleType.GraphQL);
516
+ expect(node.tables).toEqual([]);
517
+ });
518
+ });
519
+
520
+ describe('Graph traversal: {% include %} edges', () => {
521
+ const rootUri = pathUtils.join(fixturesRoot, 'include-edges');
522
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
523
+ let graph: AppGraph;
524
+ let indexSource: string;
525
+
526
+ beforeAll(async () => {
527
+ const dependencies: Dependencies = getDependencies();
528
+ graph = await buildAppGraph(rootUri, dependencies);
529
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
530
+ }, 15000);
531
+
532
+ it('tags an include edge with kind "include" (distinct from render)', () => {
533
+ const edge = directRef(
534
+ p('app/views/pages/index.liquid'),
535
+ rangeOf(indexSource, "{% include 'shared/header' %}"),
536
+ p('app/views/partials/shared/header.liquid'),
537
+ 'include',
538
+ );
539
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
540
+ expect(edgeIdentity(graph.modules[p('app/views/partials/shared/header.liquid')])).toEqual(
541
+ partialNode(p('app/views/partials/shared/header.liquid'), true, [edge]),
542
+ );
543
+ });
544
+ });
545
+
546
+ describe('Graph traversal: {% background %} edges', () => {
547
+ const rootUri = pathUtils.join(fixturesRoot, 'background-edges');
548
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
549
+ let graph: AppGraph;
550
+ let indexSource: string;
551
+ let brokenSource: string;
552
+
553
+ beforeAll(async () => {
554
+ const dependencies: Dependencies = getDependencies();
555
+ graph = await buildAppGraph(rootUri, dependencies);
556
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
557
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
558
+ }, 15000);
559
+
560
+ it('links a page to the background partial via a single background edge', () => {
561
+ const edge = directRef(
562
+ p('app/views/pages/index.liquid'),
563
+ rangeOf(indexSource, "background job_id = 'jobs/notify', data: 'x'"),
564
+ p('app/views/partials/jobs/notify.liquid'),
565
+ 'background',
566
+ ['data'],
567
+ );
568
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
569
+ expect(edgeIdentity(graph.modules[p('app/views/partials/jobs/notify.liquid')])).toEqual(
570
+ partialNode(p('app/views/partials/jobs/notify.liquid'), true, [edge]),
571
+ );
572
+ });
573
+
574
+ it('records a missing background target as an exists:false node', () => {
575
+ const edge = directRef(
576
+ p('app/views/pages/broken.liquid'),
577
+ rangeOf(brokenSource, "background job_id = 'jobs/missing'"),
578
+ p('app/lib/jobs/missing.liquid'),
579
+ 'background',
580
+ );
581
+ expect(graph.modules[p('app/lib/jobs/missing.liquid')]).toEqual(
582
+ partialNode(p('app/lib/jobs/missing.liquid'), false, [edge]),
583
+ );
584
+ });
585
+ });
586
+
587
+ describe('Graph traversal: module-namespaced targets (modules/<name>/public/...)', () => {
588
+ const rootUri = pathUtils.join(fixturesRoot, 'module-edges');
589
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
590
+ let graph: AppGraph;
591
+ let indexSource: string;
592
+
593
+ beforeAll(async () => {
594
+ const dependencies: Dependencies = getDependencies();
595
+ graph = await buildAppGraph(rootUri, dependencies);
596
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
597
+ }, 15000);
598
+
599
+ it('resolves function + render targets into modules/<name>/public/{lib,views/partials}', () => {
600
+ const functionEdge = directRef(
601
+ p('app/views/pages/index.liquid'),
602
+ rangeOf(indexSource, "function items = 'modules/my_module/queries/get'"),
603
+ p('modules/my_module/public/lib/queries/get.liquid'),
604
+ 'function',
605
+ );
606
+ const renderEdge = directRef(
607
+ p('app/views/pages/index.liquid'),
608
+ rangeOf(indexSource, "{% render 'modules/my_module/card' %}"),
609
+ p('modules/my_module/public/views/partials/card.liquid'),
610
+ 'render',
611
+ );
612
+
613
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([
614
+ functionEdge,
615
+ renderEdge,
616
+ ]);
617
+ expect(
618
+ edgeIdentity(graph.modules[p('modules/my_module/public/lib/queries/get.liquid')]),
619
+ ).toEqual(
620
+ partialNode(p('modules/my_module/public/lib/queries/get.liquid'), true, [functionEdge]),
621
+ );
622
+ expect(
623
+ edgeIdentity(graph.modules[p('modules/my_module/public/views/partials/card.liquid')]),
624
+ ).toEqual(
625
+ partialNode(p('modules/my_module/public/views/partials/card.liquid'), true, [renderEdge]),
626
+ );
627
+ });
628
+ });
629
+
630
+ describe('Graph traversal: layout-association edges (frontmatter `layout:`)', () => {
631
+ const rootUri = pathUtils.join(fixturesRoot, 'layout-edges');
632
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
633
+ let graph: AppGraph;
634
+ let indexSource: string;
635
+ let brokenSource: string;
636
+
637
+ const layoutNode = (uri: string, exists: boolean, references: Reference[]): LiquidModule => ({
638
+ type: ModuleType.Liquid,
639
+ kind: LiquidModuleKind.Layout,
640
+ uri,
641
+ exists,
642
+ dependencies: [],
643
+ references,
644
+ });
645
+
646
+ /**
647
+ * The source range the layout edge carries — the whole `YAMLFrontmatter`
648
+ * block, from the opening fence through the closing `---` line (incl. its
649
+ * trailing newline). Derived from the fixture text so it survives edits.
650
+ */
651
+ const frontmatterRange = (source: string): [number, number] => [
652
+ 0,
653
+ source.indexOf('\n', source.indexOf('---', 3)) + 1,
654
+ ];
655
+
656
+ beforeAll(async () => {
657
+ const dependencies: Dependencies = getDependencies();
658
+ graph = await buildAppGraph(rootUri, dependencies);
659
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
660
+ brokenSource = (await dependencies.getSourceCode(p('app/views/pages/broken.liquid'))).source;
661
+ }, 15000);
662
+
663
+ it('links a page to its resolved layout via a single layout edge', () => {
664
+ const edge = directRef(
665
+ p('app/views/pages/index.liquid'),
666
+ frontmatterRange(indexSource),
667
+ p('app/views/layouts/theme.liquid'),
668
+ 'layout',
669
+ );
670
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toEqual([edge]);
671
+ expect(edgeIdentity(graph.modules[p('app/views/layouts/theme.liquid')])).toEqual(
672
+ layoutNode(p('app/views/layouts/theme.liquid'), true, [edge]),
673
+ );
674
+ });
675
+
676
+ it('records a missing layout target as an exists:false Layout node', () => {
677
+ const edge = directRef(
678
+ p('app/views/pages/broken.liquid'),
679
+ frontmatterRange(brokenSource),
680
+ p('app/views/layouts/ghost.liquid'),
681
+ 'layout',
682
+ );
683
+ expect(graph.modules[p('app/views/pages/broken.liquid')].dependencies).toEqual([edge]);
684
+ expect(graph.modules[p('app/views/layouts/ghost.liquid')]).toEqual(
685
+ layoutNode(p('app/views/layouts/ghost.liquid'), false, [edge]),
686
+ );
687
+ });
688
+ });
689
+
690
+ describe('Graph traversal: asset edges (asset_url resolves under app/assets)', () => {
691
+ const rootUri = pathUtils.join(fixturesRoot, 'asset-edges');
692
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
693
+ let graph: AppGraph;
694
+ let indexSource: string;
695
+
696
+ const assetNode = (uri: string, exists: boolean, references: Reference[]): AssetModule => ({
697
+ type: ModuleType.Asset,
698
+ kind: 'unused',
699
+ uri,
700
+ exists,
701
+ dependencies: [],
702
+ references,
703
+ });
704
+
705
+ // The asset edge carries the `{{ … }}` output's LiquidVariable range: from the
706
+ // first char after `{{ ` up to the `}}` (the parser includes the trailing
707
+ // space). Derived from the fixture text around the given literal so it stays
708
+ // correct as the fixture changes.
709
+ const outputRange = (literal: string): [number, number] => {
710
+ const at = indexSource.indexOf(literal);
711
+ if (at < 0) throw new Error(`literal not found in fixture: ${literal}`);
712
+ return [indexSource.lastIndexOf('{{', at) + '{{ '.length, indexSource.indexOf('}}', at)];
713
+ };
714
+
715
+ const assetEdge = (literal: string, targetPart: string): Reference =>
716
+ directRef(p('app/views/pages/index.liquid'), outputRange(literal), p(targetPart), 'asset');
717
+
718
+ beforeAll(async () => {
719
+ const dependencies: Dependencies = getDependencies();
720
+ graph = await buildAppGraph(rootUri, dependencies);
721
+ indexSource = (await dependencies.getSourceCode(p('app/views/pages/index.liquid'))).source;
722
+ }, 15000);
723
+
724
+ it('resolves an asset (relative to app/assets) to an exists:true Asset node', () => {
725
+ const edge = assetEdge("'site/app.js'", 'app/assets/site/app.js');
726
+ expect(graph.modules[p('app/views/pages/index.liquid')].dependencies).toContainEqual(edge);
727
+ expect(graph.modules[p('app/assets/site/app.js')]).toEqual(
728
+ assetNode(p('app/assets/site/app.js'), true, [edge]),
729
+ );
730
+ });
731
+
732
+ it('resolves a top-level asset under app/assets', () => {
733
+ const edge = assetEdge("'logo.css'", 'app/assets/logo.css');
734
+ expect(graph.modules[p('app/assets/logo.css')]).toEqual(
735
+ assetNode(p('app/assets/logo.css'), true, [edge]),
736
+ );
737
+ });
738
+
739
+ it('records a missing asset as an exists:false Asset node at its canonical app/assets path', () => {
740
+ const edge = assetEdge("'images/missing.png'", 'app/assets/images/missing.png');
741
+ expect(graph.modules[p('app/assets/images/missing.png')]).toEqual(
742
+ assetNode(p('app/assets/images/missing.png'), false, [edge]),
743
+ );
744
+ });
745
+
746
+ it('emits exactly the three asset edges for the page, in source order', () => {
747
+ expect(
748
+ graph.modules[p('app/views/pages/index.liquid')].dependencies.map((d) => ({
749
+ kind: d.kind,
750
+ target: d.target.uri,
751
+ })),
752
+ ).toEqual([
753
+ { kind: 'asset', target: p('app/assets/site/app.js') },
754
+ { kind: 'asset', target: p('app/assets/logo.css') },
755
+ { kind: 'asset', target: p('app/assets/images/missing.png') },
756
+ ]);
757
+ });
758
+ });
759
+
760
+ describe('Graph traversal: schema/Table nodes (full-build discovery)', () => {
761
+ const rootUri = pathUtils.join(fixturesRoot, 'schema-nodes');
762
+ const p = (part: string) => pathUtils.join(rootUri, ...part.split('/'));
763
+ let graph: AppGraph;
764
+
765
+ beforeAll(async () => {
766
+ graph = await buildAppGraph(rootUri, getDependencies());
767
+ }, 15000);
768
+
769
+ it('discovers a schema file as a leaf Schema node carrying its table (the YAML name)', () => {
770
+ expect(graph.modules[p('app/schema/blog_post.yml')]).toEqual({
771
+ type: ModuleType.Schema,
772
+ kind: 'schema',
773
+ uri: p('app/schema/blog_post.yml'),
774
+ exists: true,
775
+ dependencies: [],
776
+ references: [],
777
+ table: 'blog_post',
778
+ });
779
+ });
780
+
781
+ it('leaves table undefined for a schema file with no `name:`', () => {
782
+ expect(graph.modules[p('app/schema/no_name.yml')]).toEqual({
783
+ type: ModuleType.Schema,
784
+ kind: 'schema',
785
+ uri: p('app/schema/no_name.yml'),
786
+ exists: true,
787
+ dependencies: [],
788
+ references: [],
789
+ });
790
+ });
791
+
792
+ it('does not make schema files entry points', () => {
793
+ expect(graph.entryPoints.map((m) => m.uri)).toEqual([p('app/views/pages/index.liquid')]);
794
+ });
795
+ });
796
+
797
+ /**
798
+ * The graph resolves `{% render %}` / `{% function %}` / `{% graphql %}` / `layout:`
799
+ * targets through `IDependencies.app`'s index when it is given one.
800
+ */
801
+ const moduleEdges = pathUtils.join(fixturesRoot, 'module-edges');
802
+
803
+ /** The real filesystem, with every directory listing recorded. */
804
+ function recordingFs(): { fs: AbstractFileSystem; listedDirectories: UriString[] } {
805
+ const listedDirectories: UriString[] = [];
806
+ const fs: AbstractFileSystem = {
807
+ stat: (uri) => NodeFileSystem.stat(uri),
808
+ readFile: (uri) => NodeFileSystem.readFile(uri),
809
+ readDirectory: (uri) => {
810
+ listedDirectories.push(uri);
811
+ return NodeFileSystem.readDirectory(uri);
812
+ },
813
+ };
814
+ return { fs, listedDirectories };
815
+ }
816
+
817
+ /** Every source file under the root, classified — the app the language server holds. */
818
+ async function wholeApp(rootUri: UriString, fs: AbstractFileSystem): Promise<App> {
819
+ return App.fromPaths(rootUri, await walkAppSourceFiles(fs, rootUri), fs);
820
+ }
821
+
822
+ /** An app that knows the entry points and nothing else, so every target is an index MISS. */
823
+ async function pagesOnlyApp(rootUri: UriString, fs: AbstractFileSystem): Promise<App> {
824
+ const whole = await wholeApp(rootUri, fs);
825
+ return App.fromPaths(
826
+ rootUri,
827
+ whole.pages().map((file) => file.uri),
828
+ fs,
829
+ );
830
+ }
831
+
832
+ interface Build {
833
+ graph: AppGraph;
834
+ /** Distinct directories listed while RESOLVING (the walks that precede it are discarded). */
835
+ listedDirectories: UriString[];
836
+ /** Distinct URIs the build asked for a source code, sorted. */
837
+ sourcesRead: UriString[];
838
+ }
839
+
840
+ /**
841
+ * Build the fixture's graph from its pages and layouts, recording what it touched.
842
+ *
843
+ * `entryPoints` are passed explicitly — a discovering build walks the project first, and
844
+ * those listings would swamp the ones under test. The scope is the same one
845
+ * `buildAppGraph` would have discovered for these fixtures.
846
+ */
847
+ async function buildRecording(
848
+ rootUri: UriString,
849
+ makeApp?: (rootUri: UriString, fs: AbstractFileSystem) => Promise<App>,
850
+ ): Promise<Build> {
851
+ const { fs, listedDirectories } = recordingFs();
852
+ const entryPointsApp = await wholeApp(rootUri, fs);
853
+ const entryPoints = [...entryPointsApp.pages(), ...entryPointsApp.layouts()].map(
854
+ (file) => file.uri,
855
+ );
856
+ const app = makeApp ? await makeApp(rootUri, fs) : undefined;
857
+
858
+ const sourcesRead: UriString[] = [];
859
+ const getSourceCode = makeGetSourceCode(fs);
860
+
861
+ // Everything above is fixture setup — the walks it performed are not what is measured.
862
+ listedDirectories.length = 0;
863
+
864
+ const graph = await buildAppGraph(
865
+ rootUri,
866
+ {
867
+ fs,
868
+ app,
869
+ getSourceCode: (uri) => {
870
+ sourcesRead.push(uri);
871
+ return getSourceCode(uri);
872
+ },
873
+ },
874
+ entryPoints,
875
+ );
876
+
877
+ return {
878
+ graph,
879
+ // SORTED, not in call order: entry points are traversed concurrently, so which
880
+ // lookup reaches the filesystem first is a scheduling detail. WHICH directories a
881
+ // build has to list is the claim.
882
+ listedDirectories: unique(listedDirectories).sort(),
883
+ sourcesRead: unique(sourcesRead).sort(),
884
+ };
885
+ }
886
+
887
+ const dir = (rootUri: UriString, relative: string): UriString =>
888
+ pathUtils.join(rootUri, ...relative.split('/'));
889
+
890
+ const referenceKey = (reference: Reference): string =>
891
+ [
892
+ reference.source.uri,
893
+ reference.source.range?.join(':') ?? '',
894
+ reference.kind,
895
+ reference.type,
896
+ ].join('|');
897
+
898
+ /**
899
+ * The graph with every node's REVERSE index (`references`) in a stable order.
900
+ */
901
+ function canonical(graph: AppGraph): AppGraph {
902
+ const sortReferences = (module: AppModule): AppModule => ({
903
+ ...module,
904
+ references: [...module.references].sort((a, b) =>
905
+ referenceKey(a).localeCompare(referenceKey(b)),
906
+ ),
907
+ });
908
+
909
+ return {
910
+ ...graph,
911
+ entryPoints: graph.entryPoints.map(sortReferences),
912
+ modules: Object.fromEntries(
913
+ Object.entries(graph.modules).map(([uri, module]) => [uri, sortReferences(module)]),
914
+ ),
915
+ };
916
+ }
917
+
918
+ describe('a graph build over a project with an App index', () => {
919
+ let indexed: Build;
920
+ let unindexed: Build;
921
+ let partialIndex: Build;
922
+
923
+ beforeAll(async () => {
924
+ indexed = await buildRecording(skeleton, wholeApp);
925
+ unindexed = await buildRecording(skeleton);
926
+ partialIndex = await buildRecording(skeleton, pagesOnlyApp);
927
+ });
928
+
929
+ it('lists no directory but the asset one, because the index answers every name', () => {
930
+ // `app/assets` is here on purpose: `findOrLocate` never indexes an asset, since
931
+ // nothing reads one and the only question is whether it still exists on disk.
932
+ expect(indexed.listedDirectories).toEqual([dir(skeleton, 'app/assets')]);
933
+ });
934
+
935
+ it('lists a directory per candidate when there is no index to answer from', () => {
936
+ expect(unindexed.listedDirectories).toEqual([
937
+ dir(skeleton, 'app/assets'),
938
+ dir(skeleton, 'app/views/layouts'),
939
+ dir(skeleton, 'app/views/partials'),
940
+ ]);
941
+ });
942
+
943
+ it('builds the same graph with the index as without it', () => {
944
+ expect(canonical(indexed.graph)).toEqual(canonical(unindexed.graph));
945
+ });
946
+
947
+ it('falls back to the filesystem for a name the index does not hold', () => {
948
+ expect(canonical(partialIndex.graph)).toEqual(canonical(unindexed.graph));
949
+ // And the miss really was a miss: the partial and layout directories were listed,
950
+ // so the equality above is not an app that quietly answered everything.
951
+ expect(partialIndex.listedDirectories).toEqual(unindexed.listedDirectories);
952
+ });
953
+
954
+ it('never asks for an asset source code, only for the files it traverses', () => {
955
+ // An Asset node is a leaf whose only fact is existence (an `fs.stat`), so `app.js`
956
+ // and `app.css` are absent here — which is why an App backing a graph needs no
957
+ // `.js`/image parsers. Every Liquid file in the fixture IS read: the absence above
958
+ // is about assets, not about a build that read nothing.
959
+ expect(indexed.sourcesRead).toEqual(
960
+ [
961
+ pathUtils.join(skeleton, 'app', 'views', 'layouts', 'application.liquid'),
962
+ pathUtils.join(skeleton, 'app', 'views', 'pages', 'index.liquid'),
963
+ pathUtils.join(skeleton, 'app', 'views', 'partials', 'child.liquid'),
964
+ pathUtils.join(skeleton, 'app', 'views', 'partials', 'header.liquid'),
965
+ pathUtils.join(skeleton, 'app', 'views', 'partials', 'parent.liquid'),
966
+ ].sort(),
967
+ );
968
+ });
969
+ });
970
+
971
+ describe('a module-prefixed name resolved through an App index', () => {
972
+ let indexed: Build;
973
+ let unindexed: Build;
974
+
975
+ beforeAll(async () => {
976
+ indexed = await buildRecording(moduleEdges, wholeApp);
977
+ unindexed = await buildRecording(moduleEdges);
978
+ });
979
+
980
+ it('costs no listing at all — this fixture has no assets to except', () => {
981
+ expect(indexed.listedDirectories).toEqual([]);
982
+ });
983
+
984
+ it('resolves `modules/my_module/…` to the same files the candidate walk finds', () => {
985
+ expect(canonical(indexed.graph)).toEqual(canonical(unindexed.graph));
121
986
  });
122
987
  });