@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,521 @@
1
1
  # shopify/theme-graph
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a8f4da9: The graph answers questions, updates in place, and survives a restart.
8
+
9
+ **Query API** (`query.ts`) — `dependentsOf` / `dependenciesOf`, `reachableFrom`, `orphans` /
10
+ `isOrphan`, `isEntryPoint`, `exists`, `missingDependencies` / `missingTargets`, and
11
+ `nearestModules` (edit-distance "did you mean" over real module names). `dependentsOf` is
12
+ what the MCP supervisor's blast radius reads: the incoming references a per-file lint cannot
13
+ see, because lint is forward-looking and per-file.
14
+
15
+ **Incremental update** (`incremental.ts`) — `applyFileChange(graph, uri, kind, fs)` applies
16
+ one file's add/modify/delete in time proportional to that file rather than to the project.
17
+ A consumer that reconciles per change no longer pays a whole-project parse to learn that one
18
+ partial gained a caller.
19
+
20
+ **Persistence** (`deserialize.ts`) — a graph can be serialized and reloaded, so a fresh
21
+ process starts from a persisted graph plus the on-disk delta instead of a full build.
22
+
23
+ **`enumerateEdgeSources`** (`edge-sources.ts`) — the graph now owns the definition of which
24
+ files are edge sources, beside the classifier that decides it. It was previously re-derived
25
+ by the supervisor's cache, which is two answers to one question that must agree; the consumer
26
+ is now pure. The enumeration is ANCHORED on the platformOS source subtrees rather than
27
+ walking the whole tree and skipping directories by name — a bundled `react-app/` is never
28
+ descended into, and `app/views/pages/vendor/**` is a real site section that any `vendor`
29
+ blacklist would have dropped.
30
+
31
+ - f644a30: The graph resolves reference names through the `App` index its caller already holds.
32
+
33
+ `IDependencies` gains `app`, the companion to `getSourceCode`: a caller that has an
34
+ `App` hands it over for BOTH, so a graph build reuses the project model's parses AND its
35
+ name index. `traverse.ts` passes it to `DocumentsLocator`, and the language server's
36
+ `AppGraphManager` supplies the same `App` it already reads sources through.
37
+
38
+ It cannot change the ANSWER, only its cost. `App.findOrLocate` is index-first and falls
39
+ through to the very candidate walk the walk-only stand-in performs, in
40
+ `getAppPaths`/`getModulePaths` order either way — so an index that is empty, partial or
41
+ stale resolves exactly as before. What it removes is the I/O: without an app, every
42
+ `{% render %}`, `{% include %}`, `{% function %}`, `{% background %}`, `{% graphql %}`
43
+ and `layout:` in the project costs one `readDirectory` per candidate DIRECTORY, on a
44
+ project the language server had just walked and indexed. Measured over a whole-project
45
+ build on three real projects — directory listings, then median wall clock of three runs,
46
+ with the graph compared node-for-node:
47
+
48
+ | project | files | listings | build |
49
+ | -------------------- | ----- | --------------- | ------------- |
50
+ | project-a | 3450 | 6993 → **590** | 12.1 → 11.7 s |
51
+ | project-b | 3623 | 9911 → **185** | 7.3 → 7.1 s |
52
+ | pos-module-community | 1622 | 11787 → **762** | 4.8 → 4.0 s |
53
+
54
+ Identical graphs on all three. **The wall-clock win is the small half** (3-15%): a full
55
+ build is dominated by parsing every reachable file, and no index changes that. What goes
56
+ away is 92-98% of the I/O, which is the part that scales with how many references a
57
+ project has rather than how much source it has. The listings left are the two that must
58
+ remain: an asset lookup (never indexed — nothing reads an asset, so the only question is
59
+ whether it still exists on disk) and a name the index genuinely cannot answer.
60
+
61
+ Graphs are also pinned identical across three arms in the suite — a whole app, no app,
62
+ and an app holding only the entry points, so that every target is an index miss — on both
63
+ the plain and the module-prefixed fixtures.
64
+
65
+ Two comments corrected while measuring this, both of which pointed the next reader the
66
+ wrong way:
67
+
68
+ - `graphParsers` claimed the MCP supervisor was a "consumer-to-be" of
69
+ `appBackedGetSourceCode`. It is not, and the reason is now written down: its full
70
+ builds run on a worker thread (a second heap on purpose) which cannot share `AppFile`
71
+ objects at all, and its incremental apply must not read through check-node's shared
72
+ `App`, which carries unsaved editor buffers and is mutated by concurrent lints while
73
+ the graph cache is an authority on DISK state. There is also nothing to win —
74
+ `lintBuffers` parses the content it was handed and drops the app's entry for that file
75
+ on the way out, so the file a reconcile parses is precisely the one whose parse no lint
76
+ would have reused.
77
+ - The same doc implied a graph build needs the `.js`/image parsers. It does not:
78
+ `traverseModule` returns immediately for an Asset node and the only fact the graph
79
+ wants about an asset is whether it exists (`fs.stat`). Every file a build READS is
80
+ Liquid, GraphQL or YAML, so `sourceParsers` alone is enough to back one. The entries
81
+ exist for `toSourceCode`'s total contract and for an `App` that an asset URI is put
82
+ into. Pinned by a test asserting the build asks for no asset source code, with the
83
+ Liquid files it does read as the control.
84
+
85
+ - cf80cfa: Lazy `App` object model, one anchored project walk, and a `lintBuffer` that says
86
+ whether it checked the file.
87
+
88
+ `@platformos/platformos-common` now owns an `App` / `AppFile` model: it classifies a
89
+ project's paths and reads or parses nothing until a consumer asks for a specific
90
+ file. Parsers are injected, so the package stays below the parser stack and every
91
+ consumer can share one set of file objects. Names (`{% render 'ui/card' %}`) resolve
92
+ through an O(1) per-type index whose precedence is `getAppPaths`/`getModulePaths`
93
+ order, i.e. the same rule the candidate-path walk uses.
94
+
95
+ `check-node`'s `getApp` builds that model instead of reading and eagerly parsing
96
+ every file on every call, so a `validate_code`-style single-file lint parses the file
97
+ it visits plus the handful of render targets that file resolves — not the project.
98
+ `check-node` also now holds one `RouteTable` per process, reconciled per run against
99
+ each page's `mtime`/`size`, so an unchanged project costs zero page reads while an
100
+ added, changed or deleted page is still reflected.
101
+
102
+ That table is now also resolved LAZILY. `Dependencies.routeTable` accepts a provider as
103
+ well as a table, `MissingPage` asks for it at the first URL it actually has to resolve
104
+ rather than in `onCodePathStart`, and check-node passes its reconciler as the provider.
105
+ Knowing a route means reading every page in the project, while 87-97% of the Liquid in a
106
+ real project contains no `<a href>`/`<form action>` pointing at an internal route — so a
107
+ lint of one of those files now touches no page at all, where before it fingerprinted
108
+ every one of them. Warm `lintBuffer` on such a file, on a 3.1k-file project: 405 → 8
109
+ `stat`s and 71-79 → 54-77 ms; a file that does resolve a route pays exactly what it did.
110
+ Whole-project offenses are identical, field for field, on three real projects (9623,
111
+ 256 and 43 offenses).
112
+
113
+ `getApp` also stops classifying. It walks the subtrees an app file can live in —
114
+ `APP_SOURCE_SUBTREES` (`app/`, `marketplace_builder/`, `modules/*/public`,
115
+ `modules/*/private`) with the `SOURCE_FILE_EXTENSIONS` extensions, both derived in
116
+ `platformos-common` from the file-type model — and lets `App.fromPaths` decide what
117
+ the app contains. One answer to "is this a platformOS file", in the package that owns
118
+ the question: whether a file belongs to the app is its position relative to the
119
+ project ROOT, so `tmp/app/views/partials/x.liquid` is not a partial and
120
+ `app/views/pages/vendor/x.liquid` is a page.
121
+
122
+ The walk therefore never enters `node_modules` and the rest of the repository at all.
123
+ Walk time on two real projects: 903 → 33 ms and 177 → 30 ms; whole `getApp` on the same
124
+ two, 1289 → 152 ms and 381 → 154 ms, with a file-by-file identical app on four real
125
+ projects. `getAppFilesPathPattern` is REMOVED: it had no consumer left once the lint
126
+ stopped globbing, and a watcher that wants patterns can build them from
127
+ `APP_SOURCE_SUBTREES` and `SOURCE_FILE_GLOB`, which is all it did.
128
+
129
+ `check-node` also holds one `App` per project per process, reconciled per call rather
130
+ than rebuilt. The project walk is NOT cached — a process that gets no filesystem
131
+ events has no honest way to invalidate one, and an agent editing files out of band is
132
+ exactly the case this has to be right for — so the candidate paths are walked on
133
+ every call and the app is brought in line with them: files the walk no longer sees are
134
+ dropped, files it did not know are added, and files whose source is in memory are
135
+ `stat`ed and dropped if they changed. Everything else — classification, both name
136
+ indexes, and the handful of sources and ASTs the previous calls loaded — is reused.
137
+ Warm `lintBuffer`, shared vs rebuilt per call, on two real projects: 104-116 ms vs
138
+ 177-195 ms and 77-107 ms vs 123-160 ms, with diagnostics identical file by file over 40
139
+ files per project. At most 200 files keep their source between calls, so a long-lived
140
+ process stops accumulating the project (300 calls on the larger: 574 MB and climbing →
141
+ 497 MB, flat). `resetSharedApp()` discards it. `lintBuffer` now reverts its buffer
142
+ overlay when the call ends, since the app outlives it.
143
+
144
+ `isIgnored` compiles each `ignore` pattern once per config instead of rewriting and
145
+ recompiling a `Minimatch` on every path it is asked about — it is asked once per
146
+ candidate path in `getApp` and again per file per check in `check()`. Same patterns,
147
+ same answers, file-by-file identical on three real projects; the filter itself is
148
+ 5-6× faster (on one of them, 1511 candidates against 13 patterns: 76-98 → 14-16
149
+ ms), and `getApp` there is 207-267 → 45-69 ms.
150
+
151
+ **The dependency graph and the language server see the whole app now.** Both walked
152
+ the project by starting at the root and skipping any directory whose name ended in
153
+ `.git`, `node_modules`, `dist`, `build`, `tmp` or `vendor`. The last four match at any
154
+ depth, so `app/views/pages/vendor/**` — an entire section of a live site — was invisible
155
+ to both: every reference to those pages looked orphaned in the graph, and the language
156
+ server managed none of them (no diagnostics, completions or rename), with nothing to
157
+ indicate why. Measured over the projects on hand, one loses 137 app files that way and
158
+ another 3 (`app/lib/commands/v2/projects/update/build/*`).
159
+
160
+ They now share `walkAppSourceFiles` in `platformos-common`, which walks
161
+ `APP_SOURCE_SUBTREES` — the same anchoring `getApp` adopted, and the same rule
162
+ `parseAppPath` has always enforced. `recursiveReadDirectory` and its directory-name
163
+ blacklist are gone. `getApp` walks with it too, instead of globbing the equivalent
164
+ patterns: same paths file for file on four real projects, and 9-15% faster (median
165
+ 39 → 33 ms on the largest, 33 → 28 on another), because
166
+ a walk filters by extension as it enumerates instead of matching a pattern per path.
167
+ Two edge cases the glob decided and the walk now decides
168
+ deliberately: hidden entries (`.#card.liquid`, `._card.liquid`, `.old/`) are still
169
+ skipped, and an unreadable directory now FAILS the run rather than being skipped in
170
+ silence.
171
+
172
+ That failure is an `UnreadableDirectoryError`, which names the directory relative to
173
+ the project root and says what to do about it, rather than a bare `EACCES … scandir`.
174
+ `pos-cli check` prints it as a message and exits 1; an unexpected error still gets its
175
+ stack. In the language server it also fixed two things that were never specific to
176
+ permissions, and broke a session for any cause — a dropped network mount, `EMFILE` on
177
+ a large project, a directory another process has locked. `preload` is `memoize`d, and
178
+ `memoize` caches the REJECTED promise, so one failure replayed for every later preload
179
+ of that root, including after the cause was gone; and `progress.end()` was on the
180
+ success path only, so "Initializing Liquid LSP" stayed on screen for the rest of the
181
+ session. `preload` now ends its progress, drops the memo so a retry can succeed, shows
182
+ the user the reason (once per distinct failure per root — the graph rebuild preloads on
183
+ every file event) and logs the error with its stack. `AppGraphManager` likewise stops
184
+ caching a rejected graph build.
185
+
186
+ `NodeFileSystem.readDirectory` builds each entry's URI by appending to the directory's
187
+ (`path.childUri`) instead of parsing and re-serializing it (`path.join`). It runs once
188
+ per entry of every directory any walk opens — tens of thousands of times per project,
189
+ mostly for entries the caller discards — and the round trip was about a third of the
190
+ walk. `childUri` is pinned against `join` itself for every name shape a listing can
191
+ produce, including `#`, `?` and Windows separators.
192
+
193
+ Anchoring is also faster than either alternative, because the walk
194
+ never opens the directories in question: on a project with 20 000 files under
195
+ root-level `dist`/`build`/`vendor`/`coverage`, the walk is 6 ms, against 19-23 ms for
196
+ the blacklist as it was (which loses the vendor page) and 63-69 ms for a blacklist
197
+ shortened to the safe names (which keeps it). On four real projects: 71-78 → 31-34 ms,
198
+ 35-38 → 30-34 ms, 20-21 → 18-19 ms, and one unchanged at 23-28 ms.
199
+
200
+ One consequence: files outside the app subtrees — `seed/post_import/**`,
201
+ `tests/post_import/**` — are no longer preloaded by the language server, matching what
202
+ the linter's app has contained since `getApp` was anchored. Opening one still manages
203
+ it, as opening any supported file always has.
204
+
205
+ **`lintBuffer` now says whether it checked the file at all.** It returns
206
+ `{ status, offenses }` instead of `Offense[]`. Three kinds of path are never linted —
207
+ one the config's `ignore` list covers, one outside `app/`/`marketplace_builder/`/
208
+ `modules/<name>/(public|private)/`, and an asset with no parser or checks — and each
209
+ used to come back as an empty `Offense[]`, which is exactly what a clean file returns.
210
+ For `pos-cli check` that is harmless; for an agent asking "is this file OK before I
211
+ write it?" it is the wrong answer given confidently. `status` is `checked`,
212
+ `excluded-by-config`, `not-an-app-file` or `not-a-source-file`, and `offenses` is empty
213
+ for all but the first. `pos-cli check`'s own behaviour is unchanged — it goes through
214
+ `appCheckRun`, not this seam. The MCP supervisor carries the reason into
215
+ `next_step` until its result contract grows a status of its own.
216
+
217
+ **VS Code now sends YAML buffers to the language server.** `documentSelectors` had no
218
+ `yaml` entry, so translations, tables, user profile types and transactable types got no
219
+ diagnostics, completions or go-to-definition — the server has handled them all along.
220
+ The selectors are now derived from `SOURCE_FILE_EXTENSIONS`, with the `.yml` pattern
221
+ anchored to `app/`, `marketplace_builder/` and `modules/` so that a
222
+ `.github/workflows/ci.yml` is not handed to the language server. The dead `json`/`jsonc`
223
+ selectors are gone: they matched Shopify's `{config,locales,sections,templates}` layout,
224
+ and `JSONLanguageService` has nothing to serve, since platformOS publishes no JSON
225
+ schemas.
226
+
227
+ **Classification is anchored at the project root, and the extension is part of it.**
228
+ Three changes that together make `platformos-common` the single answer to "is this a
229
+ platformOS file, and if so what kind".
230
+
231
+ `getFileType(uri, rootUri)` now REQUIRES a root, as does every `isPage` / `isPartial` /
232
+ `isLayout` / `isAsset` / `isKnown*File` / `isSupportedSourceFile` predicate. A platformOS
233
+ file is one whose position RELATIVE TO THE PROJECT ROOT matches the directory structure,
234
+ so a classifier without a root cannot answer the question — it can only test whether a
235
+ known directory name appears somewhere in the string. That is how
236
+ `seed/post_import/app/migrations/20220517145452_index_rebuild.liquid` came to be a
237
+ Migration to the language server, the graph and the VS Code extension while being
238
+ correctly absent from the lint's app: it contains `app/migrations/` and is not deployed,
239
+ so nothing it renders or queries exists to resolve, and every diagnostic on it was noise
240
+ about a file the platform will never run. Callers get their root from what they already
241
+ hold — checks from a new `context.fileType(uri?)` (which reads `AppFile.fileType` off the
242
+ run's App, so the common path re-derives nothing), the graph from `AppGraph.rootUri`, the
243
+ language server from `findAppRootURI`. **This is a breaking API change** for anything
244
+ calling those exports directly.
245
+
246
+ Classification also consults the EXTENSION now, mirroring each backend model's
247
+ `PHYSICAL_PATH`: `app/graphql/x.yml` and `app/translations/en.json` are no longer a
248
+ GraphQL file and a Translation. Page, Layout, Partial and Asset stay permissive, because
249
+ `page.rb` and `instance_view.rb` are `(.+)` with no extension anchor — `app/views/pages/home.html`
250
+ is a Page the platform deploys and the linter cannot read. **`.yaml` is no longer a
251
+ platformOS extension**: every YAML model anchors `\.yml\z`, so `SOURCE_FILE_EXTENSIONS`
252
+ is now `.liquid`, `.yml`, `.graphql`, and a project with `app/translations/en.yaml` stops
253
+ getting diagnostics for it — that file was never deployed. `ActivityStreamsHandler` and
254
+ `ActivityStreamsGroupingHandler` are new file types.
255
+
256
+ **There is no ignore list left.** `isSupportedSourceFile` is the intersection of two
257
+ whitelists — the platform deploys it, and we have a parser for it — and nothing else. It
258
+ used to open with a `/\.(s?css|js)\.liquid$/` test, and an ignore list is only consulted
259
+ by the callers of whoever holds it: the language server refused `theme.css.liquid` while
260
+ the lint put it in the app with the Liquid+HTML parser and reported
261
+ `LiquidHTMLSyntaxError` on it. A file we cannot parse is now one with no row in the
262
+ parser table, which is keyed on the RESPONSE FORMAT for `.liquid` files, because the
263
+ format is the body language. `users.json.liquid` is parsed; `theme.css.liquid` and
264
+ `run.js.liquid` are not, by either tool. `.scss.liquid` changes the other way: `scss` is
265
+ not in the platform's FORMAT_ENUM, so that file is a partial named `x.scss` and is now
266
+ linted.
267
+
268
+ App file sets and per-check offense totals are identical before and after on four real
269
+ projects (946 files / 43 offenses, 3139 / 9623, 2789 and 2895).
270
+
271
+ Behaviour changes worth knowing about:
272
+
273
+ - **`OrphanedPartial` is REMOVED**, and with it the `singleFileOnly` check partition
274
+ it was the only member of. It asks "is any file rendering this partial?", which no
275
+ index answers without every Liquid file parsed — and, once wired up and measured, it
276
+ answered wrongly too often to ship: 231 hits on a module project, every one of
277
+ them a module's `public/` API whose callers live in other repositories; and on a
278
+ large site a large share of the 465 hits were partials invoked BY NAME, either through
279
+ a dispatcher (`mutation_name: 'authentications/delete'`) or as a callback
280
+ (`access_callback: 'lib/can/theme_manage'`), which static analysis cannot see. A
281
+ warning that is wrong that often is one nobody reads.
282
+
283
+ Removing it removes the reason for the partition: every remaining check answers for
284
+ one file, resolving against the project through indexes that are already cached
285
+ (`MissingPage` through the route table, `MissingPartial` through the name index).
286
+ So `CheckOptions.singleFileOnly`, `meta.singleFile` and `Dependencies.getReferences`
287
+ are gone, and the editor, `pos-cli check` and `validate_code` now run exactly the
288
+ same set of checks. `validate_code`'s `mode: full | quick` input is REMOVED with it:
289
+ the partition was the only thing it could have selected. Unknown arguments are
290
+ dropped by the MCP SDK, so a caller that still sends `mode` gets the same result it
291
+ always did.
292
+
293
+ - Translation lookups now treat "an open editor buffer" as a file with a defined
294
+ `version`, rather than any file present in the app object. Contents are otherwise
295
+ read from the filesystem.
296
+ - Six packages that imported `@platformos/*` siblings without declaring them now do
297
+ (they had resolved only through workspace hoisting).
298
+
299
+ **The language server holds the same `App`, so opening a workspace no longer parses
300
+ it.** `DocumentManager` was a second, LSP-shaped store of source codes beside the
301
+ model — its own `Map<uri, AugmentedSourceCode>`, and a `preload` that read AND eagerly
302
+ parsed every file in the project. It now holds one `App` per project root and delegates:
303
+ `open`/`change` are `setSource`, `delete`/`rename` go through the App's own index (so
304
+ deleting an `app/modules/X` overwrite promotes the `modules/X` original back), and
305
+ `app(root, includeFilesFromDisk)` is `App.sourceCodes()` — the same intersection
306
+ `isSupportedSourceFile` names, asked of a file that classified its own path once
307
+ instead of re-derived per call per predicate. `preload` classifies the walk's paths
308
+ (no I/O), reads the ones with a parser, and parses nothing; an `AppFile` parses on the
309
+ first `ast`.
310
+
311
+ Measured through the real language server on a 2735-liquid-file project —
312
+ `initialize` → `didOpen` → the first `publishDiagnostics` — median of five runs: first
313
+ diagnostic 17,742 → **771 ms**, first completion 191 → 187 ms, RSS 705-720 → **333-347
314
+ MB**, with byte-identical diagnostics. One cost MOVES rather than disappearing: a
315
+ whole-project graph build now pays for the parses `preload` used to
316
+ (`appGraph/dependencies` 198 ms → 11.5 s there, one-time — the ASTs stay on the
317
+ files, so the next request is 1 ms). Total time to a graph still falls, 18.0 → 12.4 s,
318
+ and it is off the startup path.
319
+
320
+ `set()` also classifies WITH a root now, like every other consumer: the app a URI falls
321
+ under supplies one, so "is this part of an app" is asked the way `getFileType`, the
322
+ checks and the graph ask it. A readable file the platform does not deploy
323
+ (`scripts/build.liquid`) is still an editor document — formatting, hover, completions —
324
+ and is in no `App`, so it gets no diagnostics and no graph node.
325
+
326
+ **The graph and the checks now parse each file once, not once each.** `AppGraphManager`
327
+ builds its `getSourceCode` with `appBackedGetSourceCode(app, fallback)`, and the app is
328
+ built with check-common's `sourceParsers` merged with the graph's `.js`/image entries,
329
+ so both halves of the process hold the SAME `AppFile` instances. `sourceParsers` is new
330
+ and is the single definition of how a file becomes an AST; check-node's `nodeParsers` is
331
+ now an alias for it. The graph's `toSourceCode` remains as the path for a caller holding
332
+ a buffer and no app — an in-flight `validate_code` buffer, a URI outside the project —
333
+ which by definition has no `AppFile`.
334
+
335
+ Two bugs fixed on the way, both of which cost cross-file diagnostics silently:
336
+
337
+ - `runChecks` never waited for `preload`, so the first check of a session could run
338
+ against a project that had not been read and quietly miss every diagnostic that
339
+ depends on another file's `{% doc %}`. It got away with it only because `preload` was
340
+ slow enough to monopolise the event loop.
341
+ - A file the workspace could not READ stayed in the app with no contents, and
342
+ `AppFile.source` throws rather than returning `''`. One unreadable file therefore cost
343
+ the whole run. A file is a document here exactly when its contents are in memory,
344
+ which is the set the old `Map` held.
345
+
346
+ **A malformed translation file no longer costs a project its document links.**
347
+ `TranslationProvider` let `yaml.load` throw, and a duplicated mapping key — two people
348
+ adding the same key, which real projects have — made `textDocument/documentLink` reject
349
+ for the whole file: the `render` links disappeared along with the translation ones, while
350
+ hover and go-to-definition kept working because they are separate requests. Parse
351
+ failures are values here now, the same contract `AppFile.ast` and `toYAMLAST` keep; an
352
+ unparseable translation file contributes no translations, exactly as a missing one does,
353
+ and the linter still reports the syntax error against the file that has it.
354
+
355
+ ### Patch Changes
356
+
357
+ - 8f1beea: `check` refuses a path that is not a project root, instead of reporting it clean.
358
+
359
+ `appCheckRun` handed its argument to `getAppAndConfig` as the project root without checking that
360
+ it was one. A directory carrying no marker — true of `app/`, and of any single module directory —
361
+ loaded zero files, so the run returned zero offenses and every caller printed "No offenses found".
362
+ That is indistinguishable from a clean project, and it is the dangerous direction: a developer, a
363
+ CI job or an agent gating on that message concludes the code is clean when nothing was inspected.
364
+
365
+ Measured on a real app: `pos-cli check run` reported 1036 offenses across 191 files, while
366
+ `pos-cli check run app` on the same project reported none — with a partial containing an unclosed
367
+ `{% if %}`, an unclosed `<div>` and an undefined filter sitting inside `app/`.
368
+
369
+ It now names what happened and where the root is — and says only as much as the evidence supports.
370
+
371
+ A root DECLARED by a `.pos` or `.platformos-check.yml` file is asserted:
372
+
373
+ ```
374
+ Nothing was checked: /project/app is not the root of a platformOS project.
375
+ Re-run the check against the project root: /project
376
+ ```
377
+
378
+ A root INFERRED from a directory NAME is not. `app`, `modules` and `marketplace_builder` are
379
+ ordinary names — a checkout of module repositories under `~/Work/modules` makes `~/Work` resolve as
380
+ a project root, and a Windows machine shipping `C:\Modules` makes the drive root resolve as one —
381
+ so stating it as fact sends people to re-run against a tree that is not their project:
382
+
383
+ ```
384
+ Nothing was checked: /home/me/Work/multiproj is not a platformOS project root.
385
+ A project root contains one of: app/, marketplace_builder/, modules/, .pos, .platformos-check.yml.
386
+ The nearest above it is /home/me/Work, matched on modules/ alone — that may not be your project.
387
+ Re-run the check against your project root.
388
+ ```
389
+
390
+ `resolveProjectRoot` reports which marker matched, and `isDeclaredRoot` distinguishes the two.
391
+ The message names no tool: the same text reaches pos-cli, the editor through the VS Code extension,
392
+ and any embedder, so naming one of them would be wrong for the other two.
393
+
394
+ The refusal is a typed `ProjectRootError` carrying a stable `PROJECT_ROOT_ERROR_CODE`, so a consumer
395
+ can present it as a message rather than a crash without matching on prose or on `instanceof` — the
396
+ latter throws outright when the loaded copy of this package predates the class.
397
+
398
+ **It reports rather than resolving.** Widening the run to the enclosing root would check MORE than
399
+ was asked — `check run app` would pull in `modules/`, so a run meant for one app reports offenses
400
+ from vendored code its caller does not own, and a CI job scoped to `app/` starts failing on its
401
+ dependencies. `platformos-graph` can resolve-and-proceed because the graph of a project is the same
402
+ answer wherever you point at it inside the project; "check this directory" is not. Linting an
403
+ arbitrary subtree remains unsupported and is a separate feature: it would have to load the whole
404
+ project anyway, since partials, pages and config all resolve project-wide, and then filter what it
405
+ reports.
406
+
407
+ `findRoot`, `makeFileExists` and the new `resolveProjectRoot` / `PROJECT_ROOT_MARKERS` moved from
408
+ `platformos-check-common` to `platformos-common`. They are project-LAYOUT knowledge with nothing
409
+ linter-specific in them, and every constant they run on — `APP_ROOTS`, `STANDALONE_MODULE_ROOTS`,
410
+ `APP_SOURCE_SUBTREES` — already lived in `platformos-common`, alongside `AbstractFileSystem` and
411
+ the URI helpers. They are re-exported from `platformos-check-common`, so existing imports keep
412
+ working. `platformos-graph` drops its own copy of the resolution and calls the shared one; its
413
+ error message is unchanged, which its existing assertion on that exact string proves.
414
+
415
+ - a8f4da9: How a platformOS GraphQL document is READ now lives in `platformos-common`, and a
416
+ `.graphql` file is parsed exactly once until its source changes.
417
+
418
+ `parseGraphql`, `extractGraphqlTables` and `extractGraphqlVariables` are platformOS
419
+ domain knowledge — what a table filter looks like, what a `{% graphql %}` call site may
420
+ and must pass — so they sit beside `extractSchemaTable`, the schema `name:` those tables
421
+ join to, rather than inside the linter. `platformos-check-common` no longer defines
422
+ `graphql-table.ts` and re-exports none of them; it re-exports only the
423
+ `GraphQLDocumentNode` TYPE, exactly as it re-exports `SourceCodeType`.
424
+
425
+ **The placement is what makes the parse shareable.** The GraphQL "AST" an `AppFile` held
426
+ was `{ type: 'Document', content }` — the source string, unparsed — so every consumer
427
+ parsed it again for itself:
428
+
429
+ - `GraphQLCheck`, once per file per run;
430
+ - `GraphQLVariablesCheck`, one `fs.readFile` **plus** one parse per `{% graphql %}` call
431
+ site — which also meant it read the disk while the editor held an unsaved buffer;
432
+ - `UnknownProperty` and the language server's type system, one parse per call site;
433
+ - the graph build, one more.
434
+
435
+ `sourceParsers` now injects `parseGraphql`, so the `AppFile` memoizes the real document
436
+ and all four read that one. Measured on a real project (`project-c`, 453 `.graphql` files)
437
+ by counting every call into `graphql`'s `parse` during a full CLI run: **2442 parses
438
+ before, 505 after**, for the same 15511 offenses. Wall clock is unchanged within noise
439
+ (115.9 s → 114.1 s) — a full lint of that project is dominated by Liquid parsing, so this
440
+ is a cost removed rather than a speed-up to advertise. It matters most where the same
441
+ document is read over and over against a warm app: the language server's per-keystroke
442
+ type inference, and the MCP supervisor's per-write `validate_code`.
443
+
444
+ `GraphQLDocumentNode` gains `document?` and `syntaxError?` alongside `content`, which is
445
+ unchanged — a document that does not compile is a normal state `GraphQLCheck` reports
446
+ on, so the error is a value on the node rather than the `Error` a parser may return,
447
+ which would take the file out of the very pipeline that reports it.
448
+
449
+ **`GraphQLCheck` now reports a syntax error even when the docset has no schema.** It used
450
+ to gate the whole check on `platformosDocset.graphQL()`, so a document that did not
451
+ compile drew nothing — and the test pinning that silence used a syntactically VALID
452
+ fixture, so nothing distinguished "no schema to compare against" from "says nothing at
453
+ all". A syntax error needs no schema; only `validate()` does. This changes no Node run:
454
+ the docs manager downloads the schema to its cache and falls back to the copy committed
455
+ in `platformos-check-docs-updater/data/graphql.graphql`, so `null` reaches the check only
456
+ from a caller that injects its own docset — a browser embedder, or a test. The silence
457
+ that remains (an unknown FIELD, without a schema) now has that syntax test beside it as
458
+ its control.
459
+
460
+ Callers with a buffer and no file — an inline `{% graphql res %}…{% endgraphql %}` body
461
+ — call `parseGraphql` directly. There is no cache inside it: the `AppFile` is the cache,
462
+ and a second content-keyed one behind it would be a different answer to what a file's
463
+ parse is.
464
+
465
+ `inferShapeFromGraphQL(node, schema?, args?)` and `ShapeAnalyzerDeps.readGraphQL` now
466
+ take and return the parsed node instead of a string.
467
+
468
+ The language server's `TypeSystem` takes an optional App resolver (`CompletionsProvider`
469
+ passes `DocumentManager.appModel`) and reads through the `AppFile` its own diagnostics
470
+ already parsed — for `.graphql` documents and, by the same `readLiquidFile`, for the
471
+ LIQUID PARTIALS behind `{% function %}`, which were read and re-parsed by BOTH the shape
472
+ analyzer's `readPartial` and `inferFunctionReturnType`, once per call site, per request.
473
+ So completion and hover cost a lookup instead of a read and a parse, and cannot disagree
474
+ with the diagnostic beside them about what a partial says. A host with no app (the hover
475
+ provider builds a type system without even a filesystem) still reads and parses, but once
476
+ per symbols table now rather than once per call site.
477
+
478
+ Pinned by `graphql-parse-once.spec.ts`, whose fixture makes the app's parse
479
+ unreproducible — the source carries a token that is not GraphQL and the injected parser
480
+ strips it — so a consumer that parses the file, the buffer or `ast.content` again is
481
+ visible in the offenses, not merely in a counter. The language server's path is pinned the
482
+ same way: its fixture puts a document in the App that the filesystem does not have, so a
483
+ read that went to disk infers the wrong shape. `platformos-common`'s
484
+ `package-boundaries.spec.ts` now states the invariant it always meant: no workspace
485
+ parser package and no Node import, rather than "no parser" — `js-yaml` was already there
486
+ for the same reason `graphql` is now.
487
+
488
+ - Updated dependencies [a8f4da9]
489
+ - Updated dependencies [a8f4da9]
490
+ - Updated dependencies [cf80cfa]
491
+ - Updated dependencies [8f1beea]
492
+ - Updated dependencies [cf80cfa]
493
+ - Updated dependencies [cf80cfa]
494
+ - Updated dependencies [7e7f1cd]
495
+ - Updated dependencies [a8f4da9]
496
+ - Updated dependencies [4567a07]
497
+ - Updated dependencies [a8f4da9]
498
+ - Updated dependencies [cf80cfa]
499
+ - Updated dependencies [f15573d]
500
+ - Updated dependencies [cf80cfa]
501
+ - Updated dependencies [d7374a8]
502
+ - Updated dependencies [cf80cfa]
503
+ - Updated dependencies [cf80cfa]
504
+ - Updated dependencies [280a66f]
505
+ - Updated dependencies [e3a7fb0]
506
+ - Updated dependencies [cf80cfa]
507
+ - Updated dependencies [a8f4da9]
508
+ - Updated dependencies [280a66f]
509
+ - Updated dependencies [4b6e0aa]
510
+ - Updated dependencies [c0907ab]
511
+ - Updated dependencies [f15573d]
512
+ - Updated dependencies [cf80cfa]
513
+ - Updated dependencies [cf80cfa]
514
+ - Updated dependencies [cf80cfa]
515
+ - @platformos/platformos-common@0.1.0
516
+ - @platformos/platformos-check-common@1.0.0
517
+ - @platformos/liquid-html-parser@0.1.0
518
+
3
519
  ## 0.0.19
4
520
 
5
521
  ### Patch Changes
@@ -1,107 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { buildThemeGraph, serializeThemeGraph, toSourceCode } = require('@platformos/theme-graph');
4
- const {
5
- NodeFileSystem,
6
- memoize,
7
- toSchema,
8
- path: pathUtils,
9
- recursiveReadDirectory,
10
- } = require('@platformos/theme-check-node');
11
- const path = require('path');
12
- const { URI } = require('vscode-uri');
3
+ const { buildSerializedGraph, buildSerializedFileDependencies } = require('../dist/cli');
13
4
 
14
5
  const usage = `
15
6
  Usage:
16
- theme-graph <path-to-theme-directory>
7
+ platformos-graph <path-to-app-directory> [file]
17
8
 
18
- Example:
19
- theme-graph horizon > graph.json
20
- `;
9
+ Without [file], prints the whole app graph (nodes + edges).
10
+ With [file], prints that single file's dependencies and references.
11
+ A relative [file] is resolved against the project directory.
21
12
 
22
- const identity = (/** @type {any} */ x) => x;
13
+ Examples:
14
+ platformos-graph ./app > graph.json
15
+ platformos-graph ./app app/views/partials/header.liquid
16
+ `;
23
17
 
24
- /**
25
- * Outputs the JSON representing the theme graph for a Shopify theme directory.
26
- *
27
- * @param {string} root
28
- */
29
- async function main(root) {
18
+ async function main(root, file) {
30
19
  if (!root) {
31
20
  console.error(usage);
32
21
  process.exit(1);
33
22
  }
34
23
 
35
- if (!path.isAbsolute(root)) {
36
- root = path.resolve(process.cwd(), root);
37
- }
38
-
39
- const getSourceCode = makeGetSourceCode();
40
-
41
- // Preload files to get a sense of what is slow
42
- const fs = NodeFileSystem;
43
- const rootUri = URI.file(root).toString(true);
44
- const filesToPreload = await recursiveReadDirectory(fs, rootUri, ([uri]) =>
45
- ['.json', '.liquid'].some((ext) => uri.endsWith(ext)),
46
- );
47
- await bench('Preloading files', async () => {
48
- await Promise.all(
49
- filesToPreload.map(async (uri) => {
50
- await getSourceCode(uri);
51
- }),
52
- );
53
- });
54
-
55
- /** @type {import('@platformos/theme-graph').WebComponentMap} */
56
- const webComponentDefs = new Map();
57
-
58
- /** @type {import('@platformos/theme-graph').Dependencies} */
59
- const dependencies = {
60
- fs,
61
- // @ts-ignore
62
- getSectionSchema: memoize(async (name) => {
63
- const uri = pathUtils.join(rootUri, 'sections', `${name}.liquid`);
64
- const sourceCode = await getSourceCode(uri);
65
- return toSchema('theme', uri, /** @type {any} */ (sourceCode), async () => true);
66
- }, identity),
67
- // @ts-ignore
68
- getBlockSchema: memoize(async (name) => {
69
- const uri = pathUtils.join(rootUri, 'blocks', `${name}.liquid`);
70
- const sourceCode = await getSourceCode(uri);
71
- return toSchema('theme', uri, /** @type {any} */ (sourceCode), async () => true);
72
- }, identity),
73
- getSourceCode,
74
- getWebComponentDefinitionReference: (customElementName) =>
75
- webComponentDefs.get(customElementName),
76
- };
77
-
78
- // Build graph
79
- const graph = await bench('Build graph', () => buildThemeGraph(rootUri, dependencies));
80
-
81
- // Serialize into JSON
82
- const serializedGraph = serializeThemeGraph(graph);
83
-
84
- // Print result to STDOUT
85
- console.log(JSON.stringify(serializedGraph));
86
- }
24
+ const result = file
25
+ ? await bench('Build graph', () => buildSerializedFileDependencies(root, file))
26
+ : await bench('Build graph', () => buildSerializedGraph(root));
87
27
 
88
- function makeGetSourceCode() {
89
- return memoize(async function getSourceCode(uri) {
90
- const source = await NodeFileSystem.readFile(uri);
91
- return await toSourceCode(URI.file(uri).toString(), source);
92
- }, identity);
28
+ // Print result to STDOUT (diagnostics go to STDERR), so it can be piped.
29
+ console.log(JSON.stringify(result));
93
30
  }
94
31
 
95
32
  /**
96
33
  * @param {string} name
97
- * @param {(...args: any[]) => Promise<any>} fn
34
+ * @param {() => Promise<any>} fn
98
35
  */
99
36
  async function bench(name, fn) {
100
37
  const start = performance.now();
101
38
  const result = await fn();
102
39
  const end = performance.now();
103
- console.error(`${name} took ${end - start}ms`);
40
+ console.error(`${name} took ${(end - start).toFixed(1)}ms`);
104
41
  return result;
105
42
  }
106
43
 
107
- main(process.argv[2]);
44
+ main(process.argv[2], process.argv[3]).catch((error) => {
45
+ console.error(error);
46
+ process.exit(1);
47
+ });
@@ -0,0 +1 @@
1
+ export {};