@platformos/platformos-mcp-supervisor 0.1.0 → 0.2.1

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,1076 @@
1
+ # @platformos/platformos-mcp-supervisor
2
+
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 4b8d35d: Add `ConflictingSchemaPropertyType`: one property name declared with two different types
8
+ across the project's schemas, which the platform resolves as a single ambiguous name.
9
+
10
+ ```yaml
11
+ # app/schema/access_token.yml
12
+ properties:
13
+ - name: expires_at
14
+ type: string
15
+
16
+ # app/schema/order.yml — a different table, and that is the point
17
+ properties:
18
+ - name: expires_at
19
+ type: integer
20
+ ```
21
+
22
+ **Why this is not two local facts.** The platform's property-type lookup takes a NAME and no
23
+ owning schema, so every declaration of that name answers together — in any table, profile,
24
+ transactable or `user.yml`, app or module, instance-wide. Neither file above is wrong on its
25
+ own, which is why no per-document rule can see it and why this check reads every
26
+ property-bearing schema.
27
+
28
+ **What it costs, measured on the platform.** A `range` filter compiles to
29
+ `properties->>'name'` — a text compare — only while every declared type is string-ish
30
+ (`string`, `date`, `datetime`); one declaration outside that set switches it to
31
+ `properties->'name'`, a jsonb compare, and the bound value is then coerced to JSON:
32
+
33
+ ```
34
+ range: { gt: "2026-09-15T12:03:07+0000" } → invalid input syntax for type json. Token "-09" is invalid.
35
+ range: { gt: "2026" } → parses as a JSON number, matches nothing, reports no error
36
+ ```
37
+
38
+ So adding an unrelated `expires_at: integer` to a second schema breaks a working query
39
+ against the FIRST one, with nothing about that schema having changed — and the value that
40
+ does not error is the worse outcome of the two, because it silently returns no rows. The
41
+ `property_type:` argument does not rescue it: the range filter ignores it.
42
+
43
+ Reported at each declaration, since neither is the wrong one and the reader may be at either.
44
+ Deliberately a warning, not an error: a conflict deploys successfully, and `string` beside
45
+ `date` is ambiguous without breaking any query today.
46
+
47
+ Scoping. A type the platform rejects outright is left to `InvalidSchemaPropertyType` — a
48
+ schema that cannot deploy never becomes one of the declarations a query resolves against. A
49
+ `modules/<name>/…` file that an `app/modules/<name>/…` copy shadows is not counted against
50
+ its own overwrite, which is the normal way to retype an installed module's property.
51
+
52
+ - f1b6d3d: Move to zod 4 (`^4.6.7`) and `@modelcontextprotocol/sdk` `^1.32.2`, which fixes
53
+ GHSA-mrhx-c7xc-m3w5 for every consumer: zod 3 has no patched release. The published
54
+ `validate_code` schema no longer claims `additionalProperties: false` — unknown keys were
55
+ always stripped rather than refused, and zod 4 converts that faithfully.
56
+ - Updated dependencies [4b8d35d]
57
+ - @platformos/platformos-check-common@1.2.0
58
+ - @platformos/platformos-check-node@1.2.0
59
+ - @platformos/platformos-graph@0.2.1
60
+ - @platformos/platformos-language-server-common@0.1.2
61
+
62
+ ## 0.2.0
63
+
64
+ ### Minor Changes
65
+
66
+ - 01f9acc: `impact` now reports what your change BREAKS in files you are not editing, instead of counting
67
+ who depends on the one you are.
68
+
69
+ The count is gone, and it had to be. A file's dependant set is not decidable: `{% render var %}`,
70
+ `{% include var %}` and `{% function r = var %}` all parse and all resolve their target at
71
+ runtime, so one variable anywhere makes "nothing references this" unprovable — and a caller
72
+ whose file does not parse contributes nothing either. Measured on a real application, a partial
73
+ called once by name and once through an assigned variable was reported as having exactly one
74
+ dependant, with nothing to say the second call existed. Every number that field published was a
75
+ lower bound presented as a total, to an audience that read `total: 0` as "safe to change".
76
+
77
+ WHAT REPLACES IT. Lint is per-file and forward-looking: it visits only the buffers you send, so
78
+ a page the edited partial has just broken is never looked at. Impact now looks at it — by
79
+ linting the edited file's dependants twice, once with your changeset applied and once without,
80
+ and reporting only the findings the change INTRODUCED:
81
+
82
+ ```json
83
+ "impact": { "status": "computed", "breaks": [{
84
+ "file": "app/views/pages/home.liquid",
85
+ "diagnostics": [{
86
+ "check": "MissingRenderPartialArguments", "severity": "error",
87
+ "message": "Missing required argument 'title' in render tag for partial 'card'.",
88
+ "line": 1, "column": 11,
89
+ "suggestions": [{ "description": "Add required argument 'title'",
90
+ "edits": [{ "start_index": 16, "end_index": 16, "new_text": ", title: ''" }] }],
91
+ "see_also": "https://documentation.platformos.com/…/missing-render-partial-arguments"
92
+ }]}]}
93
+ ```
94
+
95
+ Those are the check engine's own findings, so they carry its message, severity, documentation
96
+ and fixes rather than a second opinion computed here.
97
+
98
+ RELEVANCE IS CAUSAL, NOT CATEGORICAL. There is no allowlist of "cross-file" check codes — an
99
+ allowlist rots the first time a check is added, and it asks the wrong question anyway. A finding
100
+ a dependant already had is excluded for having been there BEFORE, not for its code, so a check
101
+ added upstream is covered on the day it ships. It also reaches edits a `{% doc %}`-shaped design
102
+ could not: a renamed GraphQL variable now reports `GraphQLVariablesCheck` on every caller, and a
103
+ `.graphql` file can carry no doc block at all.
104
+
105
+ A break in someone else's file does NOT set `must_fix_before_write`. That flag answers "will
106
+ THIS file be broken if I write it", and your buffer may be perfectly correct.
107
+
108
+ NOTHING IT RETURNS IS A CLEARANCE. An empty impact means no break was found among the dependants
109
+ that are VISIBLE. This server does not answer "who depends on this file" and no longer publishes
110
+ a number in place of one.
111
+
112
+ TWO MEASURED BOUNDS, because the deadline cannot be one: a lint is synchronous CPU work and no
113
+ timer preempts it, so bounding the input is the only defence. `MAX_CANDIDATE_BYTES` (64 KiB)
114
+ caps the text discovery will parse — the most-referenced file on a real 2,615-file application
115
+ cost 4.7 s and then returned `unavailable` anyway; it now reaches the same answer in 230 ms.
116
+ `MAX_DEPENDANTS_LINTED` (100) caps how many dependants are linted, covering 99.4% of real
117
+ targets. Hitting either is REPORTED — `unchecked_dependants`, or `status: unavailable` — rather
118
+ than silently shortening the analysis.
119
+
120
+ Impact costs ~240 ms per request on that application, almost entirely the project read, which
121
+ overlaps the primary lint. Start the server with `--no-impact` (or `POS_SUPERVISOR_NO_IMPACT=1`)
122
+ to switch the stage off entirely; results then carry `impact.status: "disabled"`, distinct from
123
+ `unavailable` because retrying cannot change it. It is a server setting rather than a tool
124
+ parameter deliberately: an agent that does not know it is editing a shared partial is exactly
125
+ the one that would not ask for the check.
126
+
127
+ Breaking for anything reading `impact`: `dependents`, `signature_risk` and `scope` are gone,
128
+ replaced by `breaks`. `status` gains `disabled`.
129
+
130
+ - 10a43c9: Split `ValidFrontmatter` into five per-shape checks, so the frontmatter mistakes that reject
131
+ a deploy can block a write.
132
+
133
+ **Migration:** `ValidFrontmatter` no longer exists. A `.platformos-check.yml` naming it must
134
+ name the replacements instead:
135
+
136
+ | Was `ValidFrontmatter` | Now | Severity |
137
+ | ----------------------------------------------------------- | ------------------------------- | -------- |
138
+ | an unrecognised key | `UnknownFrontmatterField` | error |
139
+ | a value outside the accepted set, and `layout: false` | `InvalidFrontmatterValue` | error |
140
+ | a `layout:` naming a layout that does not exist | `MissingLayout` | error |
141
+ | an authorization policy or notification that does not exist | `MissingFrontmatterAssociation` | error |
142
+ | a superseded key, and the deprecated `home.liquid` filename | `DeprecatedFrontmatterField` | warning |
143
+
144
+ **Why.** One code reported seven distinct rules at `Severity.WARNING`. Six of them are
145
+ converter rejections measured against a live instance with `pos-cli deploy --dry-run`, and a
146
+ rejection fails the ENTIRE changeset rather than the offending file — so `validate_code`
147
+ answered `must_fix_before_write: false` for files that could not deploy. The supervisor's gate
148
+ reads a check CODE, so it could not admit the fatal shapes without also admitting the
149
+ advisory ones.
150
+
151
+ The four fatal codes are now in the supervisor's `BLOCKING_CHECKS`, each with the converter
152
+ error that justifies it. `DeprecatedFrontmatterField` is deliberately absent: a deprecated
153
+ key and a `home.liquid` page are measured to deploy cleanly.
154
+
155
+ `MissingFrontmatterAssociation` is the one that `--dry-run` cannot answer. The dry run
156
+ ACCEPTS a page naming a policy that does not exist, because `base_converter.rb` returns
157
+ before `bulk_write_associations_from_snapshot!` — the code that raises. A real deploy
158
+ rejects it (`<page> tries to assign authorization_policies which do not exist: <name>`), so
159
+ it blocks. It was classified `warning` first, on the dry run's silence; that silence was a
160
+ gap in the oracle rather than evidence, and the same trap applies to anything else measured
161
+ that way.
162
+
163
+ This is the discriminator TASK-26 was waiting for. Its recorded blocker — that blocking the
164
+ code would fix two false approvals and create one false block — rested on two wrong facts:
165
+ there were seven reachable shapes rather than three, and `layout: false`, named there as the
166
+ harmless one, is itself a converter rejection (`undefined method 'sub' for false`, because
167
+ YAML reads it as the boolean and `page_converter.rb`'s `set_layout` guards `nil` rather than
168
+ `false`). Its diagnostic said the opposite — "falls back to the default layout" — and now
169
+ says the deploy is rejected. The `layout: ''` suggestion is unchanged and still correct.
170
+
171
+ All five checks share one parsed block through a new memoised extractor. Measured, because
172
+ five checks re-parsing one block looked cheap and is not: `parseDocument` costs ~80 µs on a
173
+ representative block, so the four redundant parses would have cost ~640 ms over a 2 000-page
174
+ project.
175
+
176
+ The dead `Missing required frontmatter field` rule is removed rather than carried across: no
177
+ schema sets `required: true`, so it could not fire, and a check code that can never report
178
+ would need a permanent exemption from the supervisor's "every blocking check can actually
179
+ block" fixtures.
180
+
181
+ All five codes need a documentation page under
182
+ `app/views/pages/developer-guide/platformos-check/checks/` in `platformos-documentation`, plus
183
+ their overview rows and nav entries; `valid-frontmatter`'s page is retired.
184
+
185
+ ### Patch Changes
186
+
187
+ - 10a43c9: Add `InvalidFrontmatterSyntax`: malformed YAML inside a frontmatter block rejects the deploy
188
+ and was reported by nothing.
189
+
190
+ ```liquid
191
+ ---
192
+ slug: probe
193
+ layout: application ← a tab
194
+ ---
195
+ ```
196
+
197
+ Measured: `Body contains invalid YAML: found a tab character that violates indentation`,
198
+ exit 1 — and a rejection fails the whole changeset. An unclosed flow sequence
199
+ (`layout: [unclosed`) does the same. `validate_code` answered `status: ok` for both.
200
+
201
+ The machinery existed and worked — the identical YAML in a standalone `.yml` file reports
202
+ `YAMLSyntaxError` and blocks — but that check declares `SourceCodeType.YAML`, and the engine
203
+ runs a check only against files of its own type, so a `.liquid` file never reached it. The
204
+ frontmatter block was already being parsed; its `errors` were discarded.
205
+
206
+ This settles the tab-indentation question left open in the upstream audit: the converter
207
+ rejects it and the linter said nothing.
208
+
209
+ **One mistake, one diagnostic.** `parseDocument` recovers and returns a partial map, so the
210
+ field-level rules would otherwise report on whichever half of a broken block survived — an
211
+ `unknown_key` that is only unknown because the parse fell apart beside it. Those rules now
212
+ read the block through `wellFormedFrontmatterBlock` and stand down when it does not parse;
213
+ a control in the same test proves they still fire once it does.
214
+
215
+ Messages come from our parser rather than being written to match the platform's. The linter
216
+ reads YAML 1.2 (npm `yaml`) and the platform reads YAML 1.1 (Ruby Psych); both refuse a tab
217
+ and an unclosed flow collection, but they are not the same parser and the tests pin the
218
+ range rather than the wording.
219
+
220
+ Also corrected: `YAMLSyntaxError`'s docblock and `blocking.ts` both recorded that "the
221
+ converter accepts unknown property types". A real deploy rejects them (`Attribute type `x`
222
+ is not allowed`); `--dry-run` accepts only because it returns before the nested converter
223
+ that validates them. The syntax-only scoping stands, but on "no shape check exists yet"
224
+ rather than on platform permissiveness. That gap is now tracked separately.
225
+
226
+ - 2ca48d4: Add `InvalidSchemaPropertyType`: a schema property type the platform rejects was reported by
227
+ nothing, and recorded in three places as something the platform accepts.
228
+
229
+ ```yaml
230
+ # app/schema/thing.yml
231
+ properties:
232
+ - name: bogus
233
+ type: not_a_real_type
234
+ ```
235
+
236
+ A real deploy rejects it — `Attribute type \`not_a_real_type\` is not allowed. Valid attribute
237
+ types: string, integer, float, …` — and a rejection fails the whole changeset.
238
+
239
+ **Why it was believed otherwise.** Every prior measurement used `pos-cli deploy --dry-run`,
240
+ which accepts the file. The dry run returns before `persist_slice!`, so the nested
241
+ `CustomAttributeConverter` that validates the type never runs. That silence was written down
242
+ as evidence in `blocking.ts`, in `YAMLSyntaxError`'s docblock and in the MCP server's own
243
+ instructions to agents; all three are corrected. What survives is the narrower, true claim:
244
+ schema-SHAPE validation is scoped out because no check covers it, not because the platform
245
+ is permissive.
246
+
247
+ Measured against the live instance, by real deploy rather than dry run:
248
+
249
+ | Case | Deploy |
250
+ | ------------------------------------------------- | ----------------------------------------------------------------- |
251
+ | `type: not_a_real_type` | rejected |
252
+ | `type: String` — a valid type, wrong case | rejected (the model's `inclusion:` is literal) |
253
+ | unknown top-level key | rejected — `Available properties are: metadata, name, properties` |
254
+ | unknown property-level key | rejected |
255
+ | duplicate property names | **accepted** |
256
+ | `properties:` as a mapping rather than a sequence | rejected |
257
+
258
+ The check covers the property `type` across all four file types whose `properties:` are
259
+ converted by `CustomAttributeConverter` — `schema/`, `transactable_types/`,
260
+ `instance_profile_types/` and `user.yml` — confirmed on the instance for a table and for
261
+ `user.yml`. It is an error and blocks the write.
262
+
263
+ `SCHEMA_PROPERTY_TYPES` and `PROPERTY_BEARING_FILE_TYPES` live in `platformos-common` beside
264
+ the other converter-derived facts, and the type list is pinned by a literal rather than
265
+ derived, because it is our transcription of a platform constant and a corrupted list would
266
+ otherwise move every test with it.
267
+
268
+ The unknown top-level key is measured and still unreported; it is tracked separately rather
269
+ than folded in here. `schema-table.ts`'s docblock example, which showed the mapping form, is
270
+ corrected — that form does not deploy.
271
+
272
+ Also new: a `deploy` provenance oracle in the supervisor's silence fixtures. Labelling this
273
+ evidence `dry-run` would have been false, since the dry run accepts the very shapes at issue.
274
+
275
+ - 2034fce: Stop refusing three tag spellings that platformOS parses as intended
276
+
277
+ `{% capture 'name' %}`, `{% case x: %}` and `{% parse_json v %%}` were reported by
278
+ `InvalidTagSyntax`, which lands under `LiquidHTMLSyntaxError` — `Severity.ERROR` and a member
279
+ of the MCP supervisor's blocking set — so an agent was told not to write the file at all. On a
280
+ 2,768-file production application these accounted for **34 of the 122** `LiquidHTMLSyntaxError`
281
+ offenses, 32 of them `{% capture 'name' %}`, the most frequently refused construct in real
282
+ code. Every one was rendered on a live instance and produces the author's intended result.
283
+
284
+ They now report as `UnconventionalTagSyntax` at `warning`, which is outside the blocking set:
285
+ still advised against, no longer fatal. Corpus totals are otherwise identical — 13,065 offenses
286
+ across 1,950 files before and after, with `LiquidHTMLSyntaxError` 122 → 88 and the 34 moving to
287
+ the new check.
288
+
289
+ The admitted set is a deliberate allowlist, not a relaxation of `InvalidTagSyntax`. The
290
+ platform matches tag markup with an unanchored regex, so it also accepts spellings that then do
291
+ the wrong thing **silently** — a mistyped `{% cache: k %}` collapses the cache key to a
292
+ constant, and because the full key carries no user component, distinct keys share one entry
293
+ across the instance and one user's rendered fragment is served to another. Those keep blocking,
294
+ and are asserted alongside the demoted ones so the two halves cannot drift apart.
295
+
296
+ - Updated dependencies [7505f4a]
297
+ - Updated dependencies [cc01002]
298
+ - Updated dependencies [10a43c9]
299
+ - Updated dependencies [10a43c9]
300
+ - Updated dependencies [10a43c9]
301
+ - Updated dependencies [f3ccef1]
302
+ - Updated dependencies [2ca48d4]
303
+ - Updated dependencies [10a43c9]
304
+ - Updated dependencies [f3ccef1]
305
+ - Updated dependencies [10a43c9]
306
+ - Updated dependencies [6571780]
307
+ - Updated dependencies [10a43c9]
308
+ - Updated dependencies [2ca48d4]
309
+ - Updated dependencies [b70f159]
310
+ - Updated dependencies [10a43c9]
311
+ - Updated dependencies [10a43c9]
312
+ - Updated dependencies [c41ab09]
313
+ - Updated dependencies [2034fce]
314
+ - Updated dependencies [16fcd5d]
315
+ - @platformos/platformos-check-common@1.1.0
316
+ - @platformos/platformos-check-node@1.1.0
317
+ - @platformos/platformos-common@0.2.0
318
+ - @platformos/liquid-html-parser@0.2.0
319
+ - @platformos/platformos-graph@0.2.0
320
+ - @platformos/platformos-language-server-common@0.1.1
321
+
322
+ ## 0.1.0
323
+
324
+ ### Minor Changes
325
+
326
+ - a8f4da9: `validate_code` becomes a write gate an agent can act on, rather than a lint that returns a
327
+ list.
328
+
329
+ The tool's whole surface is one call, and its answer is consumed by a machine that will
330
+ either write the file or not. That reframes what the result has to say, and three things
331
+ follow from it.
332
+
333
+ **"Not checked" is a status, not an empty list.** An empty `errors[]` for a file nothing
334
+ looked at is indistinguishable on the wire from a clean file, and an agent reads it as
335
+ approval. `not_applicable` is now a terminal status distinct from `ok`, carrying a
336
+ machine-readable `not_applicable_reason` — `outside_project`, `unsupported_type`,
337
+ `misplaced_source`, `too_large`, `timed_out`, `ignored`, `internal_error` — so the caller
338
+ branches on the cause without parsing prose. `must_fix_before_write` is always `false` for
339
+ it: declining to judge must not block a legitimate write either. Before this, `/etc/passwd`
340
+ came back as `ValidJSON: Expected a JSON object, array or literal` with
341
+ `must_fix_before_write: true`, and `/etc/shadow` containing `{}` came back `status: 'ok'` —
342
+ wrong in both directions, and the second is the dangerous one.
343
+
344
+ **Blocking is not severity.** `must_fix_before_write` requires severity `error` AND
345
+ membership of an explicit `BLOCKING_CHECKS` set, so a new check is non-blocking by default.
346
+ The set is four: `LiquidHTMLSyntaxError`, `YAMLSyntaxError`, `MissingPartial`,
347
+ `UnknownFilter` — each entry carrying, in the source, the measurement that put it there.
348
+ Several checks were REMOVED from the set after `pos-cli deploy --dry-run` showed the platform
349
+ accepts what they report; a false block on a file that deploys is the most expensive thing
350
+ this server can do, because a gate that refuses legitimate work gets switched off.
351
+
352
+ **A request is a LIST of buffers, and one file is a list of length one.** That is what lets a
353
+ coordinated change across several files be validated as the changeset it actually is, which
354
+ removes a whole class of false positive rather than tuning it — see the separate multi-file
355
+ changeset for the contract and why it is a correctness fix. It also means there is one
356
+ orchestrator rather than one per tool surface: there were briefly two, and they immediately
357
+ drifted into two `UNAVAILABLE_IMPACT` constants, two differently-worded timeout messages, and
358
+ lint/impact running concurrently in one and sequentially in the other.
359
+
360
+ Also in this release:
361
+
362
+ - **Cross-file blast radius** (`impact`), graph-derived: who depends on the file being
363
+ edited, which a per-file lint cannot see. Never stale — a changed project reports
364
+ `computing` rather than an out-of-date answer — and `not_applicable` for file types with
365
+ no resolvable incoming edge, so a zeroed `dependents` can never be misread as "safe to
366
+ change".
367
+ - **A never-stale project graph cache**, persisted across restarts and reconciled
368
+ incrementally per file, built in a worker so it never blocks the request path.
369
+ - **Bounded work and bounded responses**: a byte-derived lint deadline, a per-buffer and
370
+ per-batch size cap, and a response budget applied LAST to finished results — after
371
+ `status` and `must_fix_before_write` are computed from the complete finding set, so the cap
372
+ can only shorten lists and never soften a verdict.
373
+ - **Deterministic `next_step` prose** on every declined call, so a refusal explains itself
374
+ instead of looking like a silent pass.
375
+
376
+ - a8f4da9: `validate_code` validates a whole changeset in one call — and that is a correctness fix, not
377
+ a batching convenience.
378
+
379
+ ```jsonc
380
+ // One file — the original contract, unchanged.
381
+ { "file_path": "app/views/partials/card.liquid",
382
+ "content": "<div>{{ title }}</div>" }
383
+
384
+ // Several files, validated TOGETHER.
385
+ { "files": [
386
+ { "file_path": "app/views/pages/home.liquid",
387
+ "content": "{% render 'promo' %}" },
388
+ { "file_path": "app/views/partials/promo.liquid",
389
+ "content": "<div>Promo</div>" }
390
+ ] }
391
+ ```
392
+
393
+ **Why this is about correctness.** Real changes span files: you add a partial and the page
394
+ that renders it, rename a snippet and update its callers, add a query and the page that runs
395
+ it. Validated one file at a time, the page above is reported as rendering a partial that does
396
+ not exist — because at the moment it is checked, it doesn't. The partial is in the agent's
397
+ other buffer, unwritten. `MissingPartial` is one of only four checks that BLOCK a write, so
398
+ that false positive doesn't just mislead: it stops the agent making a change that is
399
+ correct. The only escapes were to write the files unvalidated, or to write them in an order
400
+ that happens to keep every intermediate state valid — which for a mutual reference does not
401
+ exist.
402
+
403
+ With every buffer overlaid at once, in the `App` and in the filesystem view reference checks
404
+ resolve through, the partial resolves. The false positive is gone by construction rather than
405
+ by tuning.
406
+
407
+ **The batch is not atomic.** Every requested file gets its own entry in `files[]`, in the
408
+ order requested and keyed by the caller's own `file_path` string — so a caller mixing
409
+ relative and absolute spellings finds its own results without reproducing our normalization.
410
+ One declined or failing file never sinks the others.
411
+
412
+ **One gate to read.** The response carries a request-level `must_fix_before_write`: an agent
413
+ about to write a multi-file change needs a single answer to "may I write this changeset?",
414
+ and a coordinated edit is only as safe as its worst file. It is deliberately an OR over the
415
+ files' own gates, so a file that was merely not checked (`not_applicable`) never blocks the
416
+ set.
417
+
418
+ **Bounded, and self-contradiction refused.** Up to 50 files per call, with a byte cap derived
419
+ from the same cost model as the lint deadline, so a batch cannot be admitted that could not
420
+ finish inside it. Two entries resolving to the SAME file are refused rather than merged:
421
+ buffers are overlaid by normalized URI with the last winning, but results are keyed by the
422
+ caller's string, so `card.liquid` and `./card.liquid` would return two entries where only one
423
+ buffer was ever checked — one of them a verdict on content that was never looked at.
424
+
425
+ It is also faster, which is the least interesting part: everything expensive in a lint is
426
+ per-project rather than per-buffer — resolving config, walking and reconciling the app,
427
+ reconciling the route table — so N files in one call pay for the project once instead of N
428
+ times.
429
+
430
+ ### Patch Changes
431
+
432
+ - a8f4da9: An asset is served, never rendered — so nothing reads one, anywhere in the toolchain.
433
+
434
+ `app/assets/x.liquid` was linted like a page. A bare `.liquid` has no response format, so
435
+ `sourceCodeTypeOf` falls back to the `html.liquid` key — which HAS a parser row — and the
436
+ file went into the app with the Liquid+HTML parser. Measured: a broken one drew
437
+ `LiquidHTMLSyntaxError` from `check()`, and through the MCP supervisor a
438
+ `must_fix_before_write: true` — a **false block** on a file the platform hands back
439
+ byte-for-byte, for the syntax of a language nothing at that path evaluates. Backwards
440
+ besides: `theme.css.liquid`, the asset form the platform genuinely does process, was exempt
441
+ all along, because `css` IS a format and has no row.
442
+
443
+ **The rule is a TYPE question, which is why an extension table could never answer it.**
444
+ `isParsedFileType` (new, exported from `platformos-common`) is false for
445
+ `PlatformOSFileType.Asset` and true for everything else. `App.findOrLocate` had already
446
+ written the principle down — _"Nothing reads an asset, so the only question about one is
447
+ whether it exists"_ — this makes it enforceable.
448
+
449
+ Applied in exactly two places, and that is the whole design: `AppFile`'s constructor (so a
450
+ file's `type` is `undefined`, which is already the toolchain's canonical "do not parse
451
+ this") and `isSupportedSourceFile`. Every consumer follows from one of those two without
452
+ knowing the rule exists — the linter, because `check()` iterates source types; the language
453
+ server, because `App.sourceCodes()` filters on `type !== undefined`; the MCP supervisor,
454
+ whose pre-lint gate now asks the shared predicate instead of comparing to `Asset` itself.
455
+
456
+ An asset is still HELD by the app, and the distinction matters: not linted is not absent.
457
+ Dropping assets from the model would produce the same zero offenses while silently breaking
458
+ every `asset_url` resolution and the graph's asset nodes.
459
+
460
+ **Why an explicit exclusion of one type rather than a whitelist of the other eighteen.**
461
+ A whitelist gives a NEW `PlatformOSFileType` the default "not read", which is silent and
462
+ wrong in the expensive direction — a newly added YAML type would simply stop being linted,
463
+ the exact regression `file-type-coverage.spec.ts` exists to catch. Defaulting a new type to
464
+ "read" fails loudly instead.
465
+
466
+ This is also not the ignore-list that `isSupportedSourceFile` is documented to refuse. That
467
+ one was a regex inside a single predicate, so the language server honoured it while the lint
468
+ did not; this is a shared exported rule consulted by both deciders, so they cannot hold
469
+ different opinions.
470
+
471
+ Closes the write-gate half shipped earlier as a supervisor-only fix, which corrected
472
+ `must_fix_before_write` while the CLI and editor still reported on assets.
473
+
474
+ - a8f4da9: `hash_assign` is not the only tag that writes into a Hash, and it is the deprecated one.
475
+
476
+ `{% assign h['k'] = v %}` and `{% assign h.k = v %}` reach the same runtime setter as
477
+ `{% hash_assign %}`. `InvalidWriteTarget` knew only the old spelling, which cost two
478
+ defects in opposite directions — and both are settled by measurement against
479
+ `/api/app_builder/liquid_exec`, every row reading the container back so "accepted" means the
480
+ write happened rather than that the tag merely parsed.
481
+
482
+ **A FALSE BLOCK, in a check that gates the write.** A subscript write was treated as a plain
483
+ assignment, so `h` took the VALUE's type: after `{% assign h['k'] = 'V' %}`, the next write to
484
+ the same hash was refused as a write onto a _string_. Both spellings, one file apart:
485
+
486
+ ```liquid
487
+ {% assign h = '{}' | parse_json %}
488
+ {% assign h['k'] = 'V' %}
489
+ {% hash_assign h['j'] = 'W' %} <- "h ... is a string" — and the platform renders it
490
+ ```
491
+
492
+ A write INTO a container does not replace it. The container's type is now preserved, and
493
+ NARROWED where the write itself proves it: reaching the runtime at all means the container was
494
+ of the right kind.
495
+
496
+ **A MISSED DETECTION.** `{% assign x['k'] = v %}` onto a String, Number, Boolean, nil or an
497
+ unset variable raises `"x is …, expected Hash or Array"`, and an Array subscripted with a
498
+ string key raises `"expected index"` — identical to `hash_assign` in all fourteen container ×
499
+ subscript combinations. None of it was reported. It is now, under the tag the author actually
500
+ wrote.
501
+
502
+ **`{% assign x << v %}` is a separate rule and was wrong in both directions too.** It requires
503
+ an Array — a **Hash raises**, which is the falsifier proving it is not the subscript-write rule
504
+ wearing a different operator — and it does not replace the target either, so appending a number
505
+ to an array no longer makes every later write to it look like a write onto a number.
506
+
507
+ **The dot rule does NOT generalise, and that is the measured half people get wrong.**
508
+ `{% hash_assign h.k = v %}` raises a PARSE-time `Syntax Error in 'hash_assign'`;
509
+ `{% assign h.k = v %}` writes the key `k`. So `InvalidHashAssignTargetSyntax` stays
510
+ `hash_assign`-only — extending it would refuse working code on a blocking check — and a dot
511
+ lookup counts as a plain KEY accessor everywhere else, exactly as the runtime treats it.
512
+
513
+ The MCP server's instructions now describe the rules under `assign` rather than only under
514
+ `hash_assign`, since telling an agent about a deprecated tag's constraints teaches it a rule
515
+ that does not apply to the tag it should be writing.
516
+
517
+ **And the formatter was destroying these targets, which is worse than any of the above.**
518
+ `prettier-plugin-liquid` normalised a subscript away on every format:
519
+
520
+ ```liquid
521
+ {% assign h['k'] = 'V' %} -> {% assign h.'k' = 'V' %} ✗ no parser accepts this
522
+ {% function h['k'] = 'p' %} -> {% function h.k = 'p' %} ✗ target silently rewritten
523
+ ```
524
+
525
+ The `assign` output is not even the dot form — the printer emitted the string NODE after the
526
+ dot, quotes included. Measured: `Liquid syntax error: Syntax Error in 'assign' - Valid syntax:
527
+ assign [var] = [value]`, at PARSE time. So format-on-save turned a working file into one that
528
+ can neither be deployed nor rendered, with no error at any layer, and a converter rejection
529
+ takes the whole changeset. This is the same defect that was fixed for `hash_assign` earlier,
530
+ in the two tags it was not fixed for.
531
+
532
+ Both targets are now bracketed throughout — `h.a['b']` becomes `h['a']['b']`, and an author's
533
+ `h.k` becomes `h['k']`, which is behaviour-preserving and was measured pair by pair against the
534
+ runtime rather than reasoned about. Dot access is still preferred everywhere that is not a
535
+ write target. The invariant is asserted against LIVE printer output, not against the committed
536
+ `fixed.liquid`, because a fixture regenerated from a broken printer records the breakage as the
537
+ expectation — which is exactly what `liquid-tag-function/fixed.liquid` had done.
538
+
539
+ - e3a7fb0: A stalled documentation host could hang a lint indefinitely.
540
+
541
+ `PlatformOSLiquidDocsManager.setup()` compares the local docs revision against the published one, and
542
+ it runs on the first lint of every process. That request had no timeout:
543
+
544
+ ```
545
+ lintBuffers -> setup() -> remoteRevision() -> download() -> await fetch(path)
546
+ ```
547
+
548
+ A host that accepts the connection and never answers holds the caller for as long as it holds the
549
+ socket — so `pos-cli check`, the language server and the MCP supervisor could all hang on a network
550
+ condition, with no diagnostic and nothing to retry.
551
+
552
+ It surfaced as a CI failure: `Integration: lintBuffers > materialises fixes while the app is still
553
+ live` timing out at 5000 ms. Reproduced exactly by stalling that one URL and nothing else, and cured
554
+ by bounding it — the same test then passes while the host is still stalled, because `setup()` already
555
+ treats a failed refresh as staleness rather than breakage and keeps the docset on disk.
556
+
557
+ **One bound for every request, chosen from measurement rather than from the shape of the data.** The
558
+ first version of this change had two — a tight bound for the revision check and a generous one for
559
+ bulk downloads, on the assumption that a large file needs longer. Measured against the live host, the
560
+ largest resource (the 363 KB GraphQL schema) fetches in ~230 ms and the tiny revision check in
561
+ ~450 ms: latency dominates, size does not, and the second constant was justifying itself with a
562
+ guess. So there is one `DOWNLOAD_TIMEOUT_MS`, generous against ~450 ms and under the budget a caller
563
+ gives a single lint.
564
+
565
+ Not a per-platform workaround: the request is bounded everywhere, for every consumer. The CI job that
566
+ failed was ubuntu/node24, but the tests matrix has no `fail-fast: false`, so the other three jobs were
567
+ cancelled rather than passing — the failure depends on whether a runner's request stalls, which any of
568
+ them can hit.
569
+
570
+ What this does NOT fix is that a lint reaches the network at all. The docset ships with the package and
571
+ is refreshed at build time by `postbuild`, so for a one-shot lint the revision check is redundant; the
572
+ refresh exists for a long-running language server, which is what `DOCS_MANAGER_MAX_AGE_MS` is for. With
573
+ `fileParallelism: false` and `isolate: true` every spec file forks fresh and pays its own request, so
574
+ the suite depends on a third-party host being reachable. Deciding who should refresh needs a seam the
575
+ manager does not have today — it takes only a `Logger` — and is filed rather than patched around here.
576
+
577
+ - 4567a07: `FilterWithoutEffect` now matches what the runtime actually does with a filter — in both
578
+ directions. Found by running the check over a real project (130 warnings in
579
+ `pos-module-community` alone) and settled by probing a live instance, reading the affected value
580
+ back rather than checking that the page rendered.
581
+
582
+ **The discriminator is which Ruby parser receives the value**, not which tag it belongs to:
583
+
584
+ | parser | filters | positions |
585
+ | ------------------------------------------ | ------- | ---------------------------------------------------------------------- |
586
+ | `Liquid::Variable` | APPLY | `{{ }}`, `assign`, `hash_assign`, `session`, `echo`, `print`, `return` |
587
+ | `Liquid::JsonLiteralVariable` | APPLY | an argument value that IS a JSON literal |
588
+ | `TAG_ATTRIBUTES` scan | DISCARD | every other operand and argument value |
589
+ | `Expression.parse` over a `QuotedFragment` | DISCARD | tag operands |
590
+
591
+ Four fixes fall out of that table.
592
+
593
+ **`hash_assign` applies its filters** and was missing from the applying allowlist, so every
594
+ `{% hash_assign post['edited_at'] = 'now' | to_time | json %}` was reported as dead code. It is
595
+ `assign`'s deprecated twin and shares its RHS handling, so only the mechanism predicts it — no
596
+ per-tag probe would have.
597
+
598
+ ```
599
+ {% assign h = {} %}{% hash_assign h['k'] = 'a' | upcase %}{{ h['k'] }} -> A
600
+ ```
601
+
602
+ **A JSON-literal argument value applies its filters**, so `{% log 'm', data: {"a": 1} | json %}`
603
+ was a false positive — and an increasingly common one now that `parse_json` is deprecated in
604
+ favour of hash literals. Measured with a partial that reads the argument back: it was handed the
605
+ JSON _string_, and `| json | upcase` arrived as `{"A":1}`, both filters in order. The value
606
+ shape decides this, not the tag, which is why it cannot be another allowlist row. A filter
607
+ NESTED inside the literal (`data: {"a": 'z' | upcase}`) is a converter syntax error, so nothing
608
+ there is exempt.
609
+
610
+ **A trailing filter is a result filter on `{% graphql res = 'file' %}` and on nothing else.**
611
+ This was a false NEGATIVE — the check was silent on genuinely dead code, which is the direction
612
+ that ships a file doing something other than what its author wrote:
613
+
614
+ ```
615
+ {% function r = 'p', a: 1 | dig: 'x' %} dig is scanned as one more ARGUMENT; r unfiltered
616
+ {% background j = 'p' | dig: 'x' %} job id comes back unfiltered
617
+ {% graphql g, a: 1 | dig: 'x' %}… INLINE form drops it
618
+ {% graphql g = 'q', a: 1 | dig: 'x' %} the ONE that filters the result
619
+ ```
620
+
621
+ All four share one grammar rule and one plausible Ruby story, and every "renders clean" probe
622
+ says the same thing about all four — only reading the assigned value back separates them. The
623
+ trailing filter binds to the LAST argument, so it survives exactly when that argument is a JSON
624
+ literal (`{% function r = 'p', items: [1, 2] | reverse %}` really does reverse `items`).
625
+
626
+ **`background`'s trailing filter was also a grammar gap**, independent of the above:
627
+ `{% background j = 'p' | upcase %}` did not parse at all — a `LiquidHTMLSyntaxError`, which is
628
+ `error` severity and in `BLOCKING_CHECKS`, so it blocked writes on markup the platform accepts
629
+ and runs. `BackgroundMarkup` now carries `filters` and the printer emits them; without that last
630
+ part the next format would have silently deleted the filter.
631
+
632
+ The AST parks a trailing filter on the markup node (`FunctionMarkup.filters` and friends) even
633
+ where the runtime binds it to the last argument. That is a parsing choice that keeps the
634
+ author's text round-trippable, and it must not be read as "this is a result filter" — the check,
635
+ not the AST shape, carries the runtime meaning. The MCP server's instructions previously told
636
+ agents that `function`/`graphql` trailing filters filter the result; that claim is corrected and
637
+ now pinned.
638
+
639
+ - f15573d: **BREAKING**: `InvalidHashAssignTarget` is renamed to `InvalidWriteTarget`. A
640
+ `.platformos-check.yml` that configures it by the old name must be updated; the CLI reports
641
+ an unknown check otherwise.
642
+
643
+ The old name described one of the five constructs the check judges. Its subject is a write
644
+ that goes INTO a container — a subscript write or an append — and three tags spell one:
645
+
646
+ ```liquid
647
+ {% assign x['k'] = v %} {% assign x << v %}
648
+ {% hash_assign x['k'] = v %} {% function x << 'p' %}
649
+ {% function x['k'] = 'p' %}
650
+ ```
651
+
652
+ `{% function x['k'] = 'p' %}` is newly judged. Its silence was documented as "the write
653
+ semantics are unmeasured — it needs a partial that exists, and the oracle instance has
654
+ none", which was wrong: measured against `/api/app_builder/liquid_exec` with the container
655
+ read back, it obeys the rule identically to the other two spellings, error text included.
656
+
657
+ | container | `x['k'] = …` | `x[0] = …` |
658
+ | ------------------------------------------------------- | -------------------------------------------------------- | ------------------ |
659
+ | Hash | writes | writes (key `"0"`) |
660
+ | Array | raises _"x is an Array, expected index, k was provided"_ | writes |
661
+ | String / Number / Boolean / Range / Date / Time / unset | raises _"x is …, expected Hash or Array"_ | same |
662
+
663
+ Also measured and now covered: `date`, `time` and `range` targets for `<<`, and
664
+ `{% function x.k = 'p' %}`, which writes the key `k` exactly as `{% assign %}` does.
665
+
666
+ The messages no longer name the tag, because the rule is the write's and not the tag's —
667
+ `assign expects a Hash or an Array` was a false statement about `assign`:
668
+
669
+ - `Cannot write into 'x', which is a number. A subscript write needs a Hash or an Array.`
670
+ - `Cannot write into 'x' with a string key, because it is an Array. Use a numeric index instead.`
671
+ - `Cannot use '<<' on 'x', which is a Hash. '<<' appends to an Array.`
672
+
673
+ An append offense no longer highlights the whitespace before the closing `%}`.
674
+
675
+ Separately, `LiquidHTMLSyntaxError` now reports a `hash_assign` with no subscript at all.
676
+ `{% hash_assign h = 'v' %}` parses in this repository — the markup rule is a
677
+ `liquidVariableLookup`, which matches a plain name — and raises `Liquid::SyntaxError` on the
678
+ platform whatever the target holds, so a Hash target was a silent false approval on a
679
+ blocking check. It shares the dot form's message, since both have the same repair: rename
680
+ the tag to `{% assign %}`, which accepts every target `hash_assign` refuses.
681
+
682
+ - cf80cfa: Lazy `App` object model, one anchored project walk, and a `lintBuffer` that says
683
+ whether it checked the file.
684
+
685
+ `@platformos/platformos-common` now owns an `App` / `AppFile` model: it classifies a
686
+ project's paths and reads or parses nothing until a consumer asks for a specific
687
+ file. Parsers are injected, so the package stays below the parser stack and every
688
+ consumer can share one set of file objects. Names (`{% render 'ui/card' %}`) resolve
689
+ through an O(1) per-type index whose precedence is `getAppPaths`/`getModulePaths`
690
+ order, i.e. the same rule the candidate-path walk uses.
691
+
692
+ `check-node`'s `getApp` builds that model instead of reading and eagerly parsing
693
+ every file on every call, so a `validate_code`-style single-file lint parses the file
694
+ it visits plus the handful of render targets that file resolves — not the project.
695
+ `check-node` also now holds one `RouteTable` per process, reconciled per run against
696
+ each page's `mtime`/`size`, so an unchanged project costs zero page reads while an
697
+ added, changed or deleted page is still reflected.
698
+
699
+ That table is now also resolved LAZILY. `Dependencies.routeTable` accepts a provider as
700
+ well as a table, `MissingPage` asks for it at the first URL it actually has to resolve
701
+ rather than in `onCodePathStart`, and check-node passes its reconciler as the provider.
702
+ Knowing a route means reading every page in the project, while 87-97% of the Liquid in a
703
+ real project contains no `<a href>`/`<form action>` pointing at an internal route — so a
704
+ lint of one of those files now touches no page at all, where before it fingerprinted
705
+ every one of them. Warm `lintBuffer` on such a file, on a 3.1k-file project: 405 → 8
706
+ `stat`s and 71-79 → 54-77 ms; a file that does resolve a route pays exactly what it did.
707
+ Whole-project offenses are identical, field for field, on three real projects (9623,
708
+ 256 and 43 offenses).
709
+
710
+ `getApp` also stops classifying. It walks the subtrees an app file can live in —
711
+ `APP_SOURCE_SUBTREES` (`app/`, `marketplace_builder/`, `modules/*/public`,
712
+ `modules/*/private`) with the `SOURCE_FILE_EXTENSIONS` extensions, both derived in
713
+ `platformos-common` from the file-type model — and lets `App.fromPaths` decide what
714
+ the app contains. One answer to "is this a platformOS file", in the package that owns
715
+ the question: whether a file belongs to the app is its position relative to the
716
+ project ROOT, so `tmp/app/views/partials/x.liquid` is not a partial and
717
+ `app/views/pages/vendor/x.liquid` is a page.
718
+
719
+ The walk therefore never enters `node_modules` and the rest of the repository at all.
720
+ Walk time on two real projects: 903 → 33 ms and 177 → 30 ms; whole `getApp` on the same
721
+ two, 1289 → 152 ms and 381 → 154 ms, with a file-by-file identical app on four real
722
+ projects. `getAppFilesPathPattern` is REMOVED: it had no consumer left once the lint
723
+ stopped globbing, and a watcher that wants patterns can build them from
724
+ `APP_SOURCE_SUBTREES` and `SOURCE_FILE_GLOB`, which is all it did.
725
+
726
+ `check-node` also holds one `App` per project per process, reconciled per call rather
727
+ than rebuilt. The project walk is NOT cached — a process that gets no filesystem
728
+ events has no honest way to invalidate one, and an agent editing files out of band is
729
+ exactly the case this has to be right for — so the candidate paths are walked on
730
+ every call and the app is brought in line with them: files the walk no longer sees are
731
+ dropped, files it did not know are added, and files whose source is in memory are
732
+ `stat`ed and dropped if they changed. Everything else — classification, both name
733
+ indexes, and the handful of sources and ASTs the previous calls loaded — is reused.
734
+ Warm `lintBuffer`, shared vs rebuilt per call, on two real projects: 104-116 ms vs
735
+ 177-195 ms and 77-107 ms vs 123-160 ms, with diagnostics identical file by file over 40
736
+ files per project. At most 200 files keep their source between calls, so a long-lived
737
+ process stops accumulating the project (300 calls on the larger: 574 MB and climbing →
738
+ 497 MB, flat). `resetSharedApp()` discards it. `lintBuffer` now reverts its buffer
739
+ overlay when the call ends, since the app outlives it.
740
+
741
+ `isIgnored` compiles each `ignore` pattern once per config instead of rewriting and
742
+ recompiling a `Minimatch` on every path it is asked about — it is asked once per
743
+ candidate path in `getApp` and again per file per check in `check()`. Same patterns,
744
+ same answers, file-by-file identical on three real projects; the filter itself is
745
+ 5-6× faster (on one of them, 1511 candidates against 13 patterns: 76-98 → 14-16
746
+ ms), and `getApp` there is 207-267 → 45-69 ms.
747
+
748
+ **The dependency graph and the language server see the whole app now.** Both walked
749
+ the project by starting at the root and skipping any directory whose name ended in
750
+ `.git`, `node_modules`, `dist`, `build`, `tmp` or `vendor`. The last four match at any
751
+ depth, so `app/views/pages/vendor/**` — an entire section of a live site — was invisible
752
+ to both: every reference to those pages looked orphaned in the graph, and the language
753
+ server managed none of them (no diagnostics, completions or rename), with nothing to
754
+ indicate why. Measured over the projects on hand, one loses 137 app files that way and
755
+ another 3 (`app/lib/commands/v2/projects/update/build/*`).
756
+
757
+ They now share `walkAppSourceFiles` in `platformos-common`, which walks
758
+ `APP_SOURCE_SUBTREES` — the same anchoring `getApp` adopted, and the same rule
759
+ `parseAppPath` has always enforced. `recursiveReadDirectory` and its directory-name
760
+ blacklist are gone. `getApp` walks with it too, instead of globbing the equivalent
761
+ patterns: same paths file for file on four real projects, and 9-15% faster (median
762
+ 39 → 33 ms on the largest, 33 → 28 on another), because
763
+ a walk filters by extension as it enumerates instead of matching a pattern per path.
764
+ Two edge cases the glob decided and the walk now decides
765
+ deliberately: hidden entries (`.#card.liquid`, `._card.liquid`, `.old/`) are still
766
+ skipped, and an unreadable directory now FAILS the run rather than being skipped in
767
+ silence.
768
+
769
+ That failure is an `UnreadableDirectoryError`, which names the directory relative to
770
+ the project root and says what to do about it, rather than a bare `EACCES … scandir`.
771
+ `pos-cli check` prints it as a message and exits 1; an unexpected error still gets its
772
+ stack. In the language server it also fixed two things that were never specific to
773
+ permissions, and broke a session for any cause — a dropped network mount, `EMFILE` on
774
+ a large project, a directory another process has locked. `preload` is `memoize`d, and
775
+ `memoize` caches the REJECTED promise, so one failure replayed for every later preload
776
+ of that root, including after the cause was gone; and `progress.end()` was on the
777
+ success path only, so "Initializing Liquid LSP" stayed on screen for the rest of the
778
+ session. `preload` now ends its progress, drops the memo so a retry can succeed, shows
779
+ the user the reason (once per distinct failure per root — the graph rebuild preloads on
780
+ every file event) and logs the error with its stack. `AppGraphManager` likewise stops
781
+ caching a rejected graph build.
782
+
783
+ `NodeFileSystem.readDirectory` builds each entry's URI by appending to the directory's
784
+ (`path.childUri`) instead of parsing and re-serializing it (`path.join`). It runs once
785
+ per entry of every directory any walk opens — tens of thousands of times per project,
786
+ mostly for entries the caller discards — and the round trip was about a third of the
787
+ walk. `childUri` is pinned against `join` itself for every name shape a listing can
788
+ produce, including `#`, `?` and Windows separators.
789
+
790
+ Anchoring is also faster than either alternative, because the walk
791
+ never opens the directories in question: on a project with 20 000 files under
792
+ root-level `dist`/`build`/`vendor`/`coverage`, the walk is 6 ms, against 19-23 ms for
793
+ the blacklist as it was (which loses the vendor page) and 63-69 ms for a blacklist
794
+ shortened to the safe names (which keeps it). On four real projects: 71-78 → 31-34 ms,
795
+ 35-38 → 30-34 ms, 20-21 → 18-19 ms, and one unchanged at 23-28 ms.
796
+
797
+ One consequence: files outside the app subtrees — `seed/post_import/**`,
798
+ `tests/post_import/**` — are no longer preloaded by the language server, matching what
799
+ the linter's app has contained since `getApp` was anchored. Opening one still manages
800
+ it, as opening any supported file always has.
801
+
802
+ **`lintBuffer` now says whether it checked the file at all.** It returns
803
+ `{ status, offenses }` instead of `Offense[]`. Three kinds of path are never linted —
804
+ one the config's `ignore` list covers, one outside `app/`/`marketplace_builder/`/
805
+ `modules/<name>/(public|private)/`, and an asset with no parser or checks — and each
806
+ used to come back as an empty `Offense[]`, which is exactly what a clean file returns.
807
+ For `pos-cli check` that is harmless; for an agent asking "is this file OK before I
808
+ write it?" it is the wrong answer given confidently. `status` is `checked`,
809
+ `excluded-by-config`, `not-an-app-file` or `not-a-source-file`, and `offenses` is empty
810
+ for all but the first. `pos-cli check`'s own behaviour is unchanged — it goes through
811
+ `appCheckRun`, not this seam. The MCP supervisor carries the reason into
812
+ `next_step` until its result contract grows a status of its own.
813
+
814
+ **VS Code now sends YAML buffers to the language server.** `documentSelectors` had no
815
+ `yaml` entry, so translations, tables, user profile types and transactable types got no
816
+ diagnostics, completions or go-to-definition — the server has handled them all along.
817
+ The selectors are now derived from `SOURCE_FILE_EXTENSIONS`, with the `.yml` pattern
818
+ anchored to `app/`, `marketplace_builder/` and `modules/` so that a
819
+ `.github/workflows/ci.yml` is not handed to the language server. The dead `json`/`jsonc`
820
+ selectors are gone: they matched Shopify's `{config,locales,sections,templates}` layout,
821
+ and `JSONLanguageService` has nothing to serve, since platformOS publishes no JSON
822
+ schemas.
823
+
824
+ **Classification is anchored at the project root, and the extension is part of it.**
825
+ Three changes that together make `platformos-common` the single answer to "is this a
826
+ platformOS file, and if so what kind".
827
+
828
+ `getFileType(uri, rootUri)` now REQUIRES a root, as does every `isPage` / `isPartial` /
829
+ `isLayout` / `isAsset` / `isKnown*File` / `isSupportedSourceFile` predicate. A platformOS
830
+ file is one whose position RELATIVE TO THE PROJECT ROOT matches the directory structure,
831
+ so a classifier without a root cannot answer the question — it can only test whether a
832
+ known directory name appears somewhere in the string. That is how
833
+ `seed/post_import/app/migrations/20220517145452_index_rebuild.liquid` came to be a
834
+ Migration to the language server, the graph and the VS Code extension while being
835
+ correctly absent from the lint's app: it contains `app/migrations/` and is not deployed,
836
+ so nothing it renders or queries exists to resolve, and every diagnostic on it was noise
837
+ about a file the platform will never run. Callers get their root from what they already
838
+ hold — checks from a new `context.fileType(uri?)` (which reads `AppFile.fileType` off the
839
+ run's App, so the common path re-derives nothing), the graph from `AppGraph.rootUri`, the
840
+ language server from `findAppRootURI`. **This is a breaking API change** for anything
841
+ calling those exports directly.
842
+
843
+ Classification also consults the EXTENSION now, mirroring each backend model's
844
+ `PHYSICAL_PATH`: `app/graphql/x.yml` and `app/translations/en.json` are no longer a
845
+ GraphQL file and a Translation. Page, Layout, Partial and Asset stay permissive, because
846
+ `page.rb` and `instance_view.rb` are `(.+)` with no extension anchor — `app/views/pages/home.html`
847
+ is a Page the platform deploys and the linter cannot read. **`.yaml` is no longer a
848
+ platformOS extension**: every YAML model anchors `\.yml\z`, so `SOURCE_FILE_EXTENSIONS`
849
+ is now `.liquid`, `.yml`, `.graphql`, and a project with `app/translations/en.yaml` stops
850
+ getting diagnostics for it — that file was never deployed. `ActivityStreamsHandler` and
851
+ `ActivityStreamsGroupingHandler` are new file types.
852
+
853
+ **There is no ignore list left.** `isSupportedSourceFile` is the intersection of two
854
+ whitelists — the platform deploys it, and we have a parser for it — and nothing else. It
855
+ used to open with a `/\.(s?css|js)\.liquid$/` test, and an ignore list is only consulted
856
+ by the callers of whoever holds it: the language server refused `theme.css.liquid` while
857
+ the lint put it in the app with the Liquid+HTML parser and reported
858
+ `LiquidHTMLSyntaxError` on it. A file we cannot parse is now one with no row in the
859
+ parser table, which is keyed on the RESPONSE FORMAT for `.liquid` files, because the
860
+ format is the body language. `users.json.liquid` is parsed; `theme.css.liquid` and
861
+ `run.js.liquid` are not, by either tool. `.scss.liquid` changes the other way: `scss` is
862
+ not in the platform's FORMAT_ENUM, so that file is a partial named `x.scss` and is now
863
+ linted.
864
+
865
+ App file sets and per-check offense totals are identical before and after on four real
866
+ projects (946 files / 43 offenses, 3139 / 9623, 2789 and 2895).
867
+
868
+ Behaviour changes worth knowing about:
869
+
870
+ - **`OrphanedPartial` is REMOVED**, and with it the `singleFileOnly` check partition
871
+ it was the only member of. It asks "is any file rendering this partial?", which no
872
+ index answers without every Liquid file parsed — and, once wired up and measured, it
873
+ answered wrongly too often to ship: 231 hits on a module project, every one of
874
+ them a module's `public/` API whose callers live in other repositories; and on a
875
+ large site a large share of the 465 hits were partials invoked BY NAME, either through
876
+ a dispatcher (`mutation_name: 'authentications/delete'`) or as a callback
877
+ (`access_callback: 'lib/can/theme_manage'`), which static analysis cannot see. A
878
+ warning that is wrong that often is one nobody reads.
879
+
880
+ Removing it removes the reason for the partition: every remaining check answers for
881
+ one file, resolving against the project through indexes that are already cached
882
+ (`MissingPage` through the route table, `MissingPartial` through the name index).
883
+ So `CheckOptions.singleFileOnly`, `meta.singleFile` and `Dependencies.getReferences`
884
+ are gone, and the editor, `pos-cli check` and `validate_code` now run exactly the
885
+ same set of checks. `validate_code`'s `mode: full | quick` input is REMOVED with it:
886
+ the partition was the only thing it could have selected. Unknown arguments are
887
+ dropped by the MCP SDK, so a caller that still sends `mode` gets the same result it
888
+ always did.
889
+
890
+ - Translation lookups now treat "an open editor buffer" as a file with a defined
891
+ `version`, rather than any file present in the app object. Contents are otherwise
892
+ read from the filesystem.
893
+ - Six packages that imported `@platformos/*` siblings without declaring them now do
894
+ (they had resolved only through workspace hoisting).
895
+
896
+ **The language server holds the same `App`, so opening a workspace no longer parses
897
+ it.** `DocumentManager` was a second, LSP-shaped store of source codes beside the
898
+ model — its own `Map<uri, AugmentedSourceCode>`, and a `preload` that read AND eagerly
899
+ parsed every file in the project. It now holds one `App` per project root and delegates:
900
+ `open`/`change` are `setSource`, `delete`/`rename` go through the App's own index (so
901
+ deleting an `app/modules/X` overwrite promotes the `modules/X` original back), and
902
+ `app(root, includeFilesFromDisk)` is `App.sourceCodes()` — the same intersection
903
+ `isSupportedSourceFile` names, asked of a file that classified its own path once
904
+ instead of re-derived per call per predicate. `preload` classifies the walk's paths
905
+ (no I/O), reads the ones with a parser, and parses nothing; an `AppFile` parses on the
906
+ first `ast`.
907
+
908
+ Measured through the real language server on a 2735-liquid-file project —
909
+ `initialize` → `didOpen` → the first `publishDiagnostics` — median of five runs: first
910
+ diagnostic 17,742 → **771 ms**, first completion 191 → 187 ms, RSS 705-720 → **333-347
911
+ MB**, with byte-identical diagnostics. One cost MOVES rather than disappearing: a
912
+ whole-project graph build now pays for the parses `preload` used to
913
+ (`appGraph/dependencies` 198 ms → 11.5 s there, one-time — the ASTs stay on the
914
+ files, so the next request is 1 ms). Total time to a graph still falls, 18.0 → 12.4 s,
915
+ and it is off the startup path.
916
+
917
+ `set()` also classifies WITH a root now, like every other consumer: the app a URI falls
918
+ under supplies one, so "is this part of an app" is asked the way `getFileType`, the
919
+ checks and the graph ask it. A readable file the platform does not deploy
920
+ (`scripts/build.liquid`) is still an editor document — formatting, hover, completions —
921
+ and is in no `App`, so it gets no diagnostics and no graph node.
922
+
923
+ **The graph and the checks now parse each file once, not once each.** `AppGraphManager`
924
+ builds its `getSourceCode` with `appBackedGetSourceCode(app, fallback)`, and the app is
925
+ built with check-common's `sourceParsers` merged with the graph's `.js`/image entries,
926
+ so both halves of the process hold the SAME `AppFile` instances. `sourceParsers` is new
927
+ and is the single definition of how a file becomes an AST; check-node's `nodeParsers` is
928
+ now an alias for it. The graph's `toSourceCode` remains as the path for a caller holding
929
+ a buffer and no app — an in-flight `validate_code` buffer, a URI outside the project —
930
+ which by definition has no `AppFile`.
931
+
932
+ Two bugs fixed on the way, both of which cost cross-file diagnostics silently:
933
+
934
+ - `runChecks` never waited for `preload`, so the first check of a session could run
935
+ against a project that had not been read and quietly miss every diagnostic that
936
+ depends on another file's `{% doc %}`. It got away with it only because `preload` was
937
+ slow enough to monopolise the event loop.
938
+ - A file the workspace could not READ stayed in the app with no contents, and
939
+ `AppFile.source` throws rather than returning `''`. One unreadable file therefore cost
940
+ the whole run. A file is a document here exactly when its contents are in memory,
941
+ which is the set the old `Map` held.
942
+
943
+ **A malformed translation file no longer costs a project its document links.**
944
+ `TranslationProvider` let `yaml.load` throw, and a duplicated mapping key — two people
945
+ adding the same key, which real projects have — made `textDocument/documentLink` reject
946
+ for the whole file: the `render` links disappeared along with the translation ones, while
947
+ hover and go-to-definition kept working because they are separate requests. Parse
948
+ failures are values here now, the same contract `AppFile.ast` and `toYAMLAST` keep; an
949
+ unparseable translation file contributes no translations, exactly as a missing one does,
950
+ and the linter still reports the syntax error against the file that has it.
951
+
952
+ - a8f4da9: `lintBuffers` says whether it checked the file, and the supervisor turns that into advice an
953
+ author can act on.
954
+
955
+ An empty `Offense[]` means "no problems found" only when something looked. For a path the
956
+ config excludes, an asset, or a file outside every deployed subtree it means "never
957
+ examined", and the two are indistinguishable at the call site. `lintBuffer`/`lintBuffers` now
958
+ return `{ status, offenses }` with a five-value `LintBufferStatus`: `checked`,
959
+ `excluded-by-config`, `misplaced-source`, `not-a-platformos-file`, `not-a-source-file`.
960
+
961
+ The two out-of-app cases are split HERE, where classification happens, so an embedder never
962
+ re-derives the distinction from a raw path — and it matters because the remedies are
963
+ opposite. A `.liquid` outside every deployed subtree is a platformOS source the platform will
964
+ never load: dead code, and the author needs to hear that. A `.jsx` component in `src/` is a
965
+ file that was never meant to be platformOS code, and telling its author to "move it under
966
+ `app/`" is wrong advice. The supervisor maps these to `misplaced_source` and
967
+ `unsupported_type` through one total table, so a status added upstream fails the BUILD at the
968
+ point where someone has to decide what the agent should hear, rather than falling into a
969
+ catch-all and reporting a plausible wrong reason.
970
+
971
+ Neither blocks a write. A misplaced source is very likely a mistake, but "likely" is a guess
972
+ about intent — a fixture or a generator template lives there legitimately — and a gate that
973
+ vetoes legitimate work on a guess gets switched off.
974
+
975
+ **An asset is never judged, decided by TYPE rather than by whether a parser accepts the
976
+ extension.** The gate asks `platformos-common`'s `isParsedFileType`, so it and the lint
977
+ cannot disagree about a path — see the separate changeset for the rule and the false block
978
+ it closes.
979
+
980
+ Also: `fingerprintOf` and `isKnownFingerprint` are exported for an embedder running its own
981
+ never-stale cache over the same project. The sentinel itself stays private — it equals
982
+ itself, so a cache that STORES it for an unreadable file would compare equal on the next scan
983
+ and call the file unchanged forever.
984
+
985
+ - e3a7fb0: A space between a variable and its key path is a PARSE error on the platform, in a write target only.
986
+
987
+ `{% assign h ['k'] = 9 %}` parsed here and is refused by platformOS. Measured against
988
+ `/api/app_builder/liquid_exec` and, because a syntax claim is only settled by the converter,
989
+ against `pos-cli deploy --dry-run` — which REJECTS it 2/2 with its space-free control accepted.
990
+ A converter rejection fails the WHOLE changeset, so this was a false approval with the same
991
+ blast radius as `{% layout %}`: the gate said `must_fix_before_write: false` and the deploy took
992
+ every other file down with it.
993
+
994
+ The platform's own rule is a single regex (`LHS_PATTERN`, in its hash-assignable tag support):
995
+ a variable name followed by an OPTIONAL key path, where the key path alternation covers `.foo`
996
+ as well as `[…]`, and nothing between the two permits whitespace. So the constraint is **no whitespace between a variable and the start of its key path**, both
997
+ accessors — not "no space before a subscript", which is how it was filed. Three tags share that
998
+ pattern, and all three were affected, each with its own error text:
999
+
1000
+ ```liquid
1001
+ {% assign h ['k'] = 9 %} Syntax Error in 'assign'
1002
+ {% assign h .k = 9 %} Syntax Error in 'assign'
1003
+ {% hash_assign h ['k'] = 9 %} Syntax Error in 'hash_assign'
1004
+ {% function r ['k'] = 'p' %} Invalid syntax for function tag
1005
+ {% assign a ['z'] << 'x' %} Syntax Error in 'assign' <- the append operator too
1006
+ ```
1007
+
1008
+ **Scoped to write targets, and that is the whole design.** The identical spelling in a READ resolves
1009
+ correctly on the platform, so narrowing the shared `lookup` rule would have traded one false approval
1010
+ for six false blocks — strictly the worse bug, since a false block cannot be overridden:
1011
+
1012
+ ```liquid
1013
+ {{ h ['k'] }} {{ h.a [0] }} {{ h[ 'k' ] }}
1014
+ {% assign v = h ['k'] %} {% if h ['k'] %} {% echo h ['k'] %}
1015
+ ```
1016
+
1017
+ `lookup` and `liquidVariableLookup` are therefore untouched; `assignTarget` and the `hash_assign` /
1018
+ `function` markups take a new target-only rule. `{% liquid %}` bodies inherit it, because that
1019
+ grammar only redefines `space`. The AST shape is unchanged, so stage 2, the printer and the language
1020
+ server needed no change — the two stage-1 additions are passthrough mappings.
1021
+
1022
+ **A space INSIDE the brackets stays legal**, because `\[.+?\]` matches it, and so does a spaced
1023
+ bracket that follows another bracket — `h['a'] ['b']` assigns. Both were measured rather than
1024
+ assumed, and the first version of this change refused the second one: a false block, caught only by
1025
+ re-checking every spelling against the instance instead of trusting the unit tests.
1026
+
1027
+ The printer now emits a refused target VERBATIM. It used to repair the spacing by accident, which hid
1028
+ the error from anyone who formatted and left it in place for everyone who did not.
1029
+
1030
+ Known gap, pinned rather than left implicit: `{% assign h.a ['b'] = 9 %}` — a spaced bracket after a
1031
+ DOT — is still accepted and the platform refuses it. Expressing "spaced bracket only after a bracket"
1032
+ needs a recursive chain that would replace the flat `lookups` iteration the stage-1 mapping indexes,
1033
+ so it stays as it was, with a test asserting today's behaviour so it cannot be mistaken for covered.
1034
+
1035
+ Verified on a real 2 768-file application: `pos-cli check` reports 13 225 offenses over 2 002 files,
1036
+ byte-identical to before, and `LiquidHTMLSyntaxError` still exactly 122 — no new offense on code that
1037
+ ships.
1038
+
1039
+ - Updated dependencies [a8f4da9]
1040
+ - Updated dependencies [a8f4da9]
1041
+ - Updated dependencies [cf80cfa]
1042
+ - Updated dependencies [8f1beea]
1043
+ - Updated dependencies [cf80cfa]
1044
+ - Updated dependencies [cf80cfa]
1045
+ - Updated dependencies [cf80cfa]
1046
+ - Updated dependencies [e3a7fb0]
1047
+ - Updated dependencies [7e7f1cd]
1048
+ - Updated dependencies [a8f4da9]
1049
+ - Updated dependencies [4567a07]
1050
+ - Updated dependencies [a8f4da9]
1051
+ - Updated dependencies [f644a30]
1052
+ - Updated dependencies [a8f4da9]
1053
+ - Updated dependencies [cf80cfa]
1054
+ - Updated dependencies [f15573d]
1055
+ - Updated dependencies [cf80cfa]
1056
+ - Updated dependencies [a8f4da9]
1057
+ - Updated dependencies [d7374a8]
1058
+ - Updated dependencies [cf80cfa]
1059
+ - Updated dependencies [cf80cfa]
1060
+ - Updated dependencies [280a66f]
1061
+ - Updated dependencies [e3a7fb0]
1062
+ - Updated dependencies [cf80cfa]
1063
+ - Updated dependencies [a8f4da9]
1064
+ - Updated dependencies [280a66f]
1065
+ - Updated dependencies [4b6e0aa]
1066
+ - Updated dependencies [c0907ab]
1067
+ - Updated dependencies [f15573d]
1068
+ - Updated dependencies [cf80cfa]
1069
+ - Updated dependencies [cf80cfa]
1070
+ - Updated dependencies [cf80cfa]
1071
+ - @platformos/platformos-common@0.1.0
1072
+ - @platformos/platformos-check-common@1.0.0
1073
+ - @platformos/platformos-check-node@1.0.0
1074
+ - @platformos/platformos-language-server-common@0.1.0
1075
+ - @platformos/platformos-graph@0.1.0
1076
+ - @platformos/liquid-html-parser@0.1.0