@platformos/platformos-mcp-supervisor 0.2.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 +1076 -0
- package/dist/transport/instructions.js +53 -104
- package/dist/transport/validate-code.d.ts +9 -5
- package/dist/transport/validate-code.js +0 -3
- package/package.json +13 -12
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
|