@platformos/platformos-graph 0.0.20 → 0.2.0

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