@platformos/platformos-check-common 0.0.13 → 0.0.16
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 +28 -0
- package/dist/checks/metadata-params/extract-undefined-variables.d.ts +8 -0
- package/dist/checks/metadata-params/extract-undefined-variables.js +213 -0
- package/dist/checks/metadata-params/extract-undefined-variables.js.map +1 -0
- package/dist/checks/metadata-params/index.js +48 -33
- package/dist/checks/metadata-params/index.js.map +1 -1
- package/dist/checks/translation-utils.d.ts +3 -7
- package/dist/checks/translation-utils.js +2 -2
- package/dist/checks/translation-utils.js.map +1 -1
- package/dist/checks/undefined-object/index.js +11 -0
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/checks/unknown-property/index.js +64 -2
- package/dist/checks/unknown-property/index.js.map +1 -1
- package/dist/to-source-code.d.ts +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/checks/metadata-params/extract-undefined-variables.spec.ts +115 -0
- package/src/checks/metadata-params/extract-undefined-variables.ts +286 -0
- package/src/checks/metadata-params/index.spec.ts +180 -26
- package/src/checks/metadata-params/index.ts +51 -34
- package/src/checks/translation-utils.ts +5 -5
- package/src/checks/undefined-object/index.spec.ts +165 -36
- package/src/checks/undefined-object/index.ts +13 -0
- package/src/checks/unknown-property/index.spec.ts +62 -0
- package/src/checks/unknown-property/index.ts +73 -2
- package/src/disabled-checks/index.spec.ts +4 -4
- package/src/url-helpers.spec.ts +145 -0
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
GraphQLMarkup,
|
|
13
13
|
GraphQLInlineMarkup,
|
|
14
14
|
HashAssignMarkup,
|
|
15
|
+
JsonHashLiteral,
|
|
16
|
+
JsonArrayLiteral,
|
|
15
17
|
} from '@platformos/liquid-html-parser';
|
|
16
18
|
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
17
19
|
import { isError } from '../../utils';
|
|
@@ -85,8 +87,10 @@ export const UnknownProperty: LiquidCheckDefinition = {
|
|
|
85
87
|
if (isLiquidTagAssign(node)) {
|
|
86
88
|
const markup = node.markup;
|
|
87
89
|
|
|
88
|
-
// Close any previous shape for this variable (reassignment)
|
|
89
|
-
|
|
90
|
+
// Close any previous shape for this variable (reassignment).
|
|
91
|
+
// Use the value expression's end so the RHS can still see the old shape
|
|
92
|
+
// (e.g. {% assign c = c.d %} — c.d must resolve against the previous shape of c).
|
|
93
|
+
closeShapeRange(markup.name, markup.value.position.end);
|
|
90
94
|
|
|
91
95
|
const hasParseJsonFilter =
|
|
92
96
|
markup.value.filters &&
|
|
@@ -127,6 +131,19 @@ export const UnknownProperty: LiquidCheckDefinition = {
|
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
// {% assign x = {a: 5, b: {c: 1}} %} or {% assign x = [1, 2, 3] %}
|
|
135
|
+
const exprType = markup.value.expression.type;
|
|
136
|
+
if (exprType === NodeTypes.JsonHashLiteral || exprType === NodeTypes.JsonArrayLiteral) {
|
|
137
|
+
const shape = inferShapeFromLiteralNode(markup.value.expression);
|
|
138
|
+
if (shape) {
|
|
139
|
+
variableShapes.push({
|
|
140
|
+
name: markup.name,
|
|
141
|
+
shape,
|
|
142
|
+
range: [node.position.end],
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
130
147
|
// {% assign x = y | dig: "key1" | dig: "key2" %}
|
|
131
148
|
// Follow the dig path through the source variable's known shape.
|
|
132
149
|
const digFilters =
|
|
@@ -540,3 +557,57 @@ function isGraphQLInlineMarkup(
|
|
|
540
557
|
function isLiquidString(expr: LiquidString | LiquidVariableLookup): expr is LiquidString {
|
|
541
558
|
return expr.type === NodeTypes.String;
|
|
542
559
|
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Infer a PropertyShape from a JsonHashLiteral or JsonArrayLiteral AST node.
|
|
563
|
+
*/
|
|
564
|
+
function inferShapeFromLiteralNode(
|
|
565
|
+
node: JsonHashLiteral | JsonArrayLiteral,
|
|
566
|
+
): PropertyShape | undefined {
|
|
567
|
+
if (node.type === NodeTypes.JsonArrayLiteral) {
|
|
568
|
+
let itemShape: PropertyShape | undefined;
|
|
569
|
+
for (const element of node.elements) {
|
|
570
|
+
const elShape = inferShapeFromExpressionNode(element);
|
|
571
|
+
if (elShape) {
|
|
572
|
+
itemShape = itemShape ? itemShape : elShape;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return { kind: 'array', itemShape };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (node.type === NodeTypes.JsonHashLiteral) {
|
|
579
|
+
const properties = new Map<string, PropertyShape>();
|
|
580
|
+
for (const entry of node.entries) {
|
|
581
|
+
// Keys are VariableLookup nodes where the name is the key string
|
|
582
|
+
if (entry.key.type === NodeTypes.VariableLookup && entry.key.name) {
|
|
583
|
+
const valueShape = inferShapeFromExpressionNode(entry.value);
|
|
584
|
+
properties.set(entry.key.name, valueShape ?? { kind: 'primitive' });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { kind: 'object', properties };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Infer a PropertyShape from a Liquid expression or variable node.
|
|
595
|
+
*/
|
|
596
|
+
function inferShapeFromExpressionNode(node: LiquidExpression | LiquidVariable): PropertyShape {
|
|
597
|
+
if (node.type === NodeTypes.JsonHashLiteral) {
|
|
598
|
+
return inferShapeFromLiteralNode(node as JsonHashLiteral) ?? { kind: 'primitive' };
|
|
599
|
+
}
|
|
600
|
+
if (node.type === NodeTypes.JsonArrayLiteral) {
|
|
601
|
+
return inferShapeFromLiteralNode(node as JsonArrayLiteral) ?? { kind: 'primitive' };
|
|
602
|
+
}
|
|
603
|
+
if (node.type === NodeTypes.String) {
|
|
604
|
+
return { kind: 'primitive', primitiveType: 'string' };
|
|
605
|
+
}
|
|
606
|
+
if (node.type === NodeTypes.Number) {
|
|
607
|
+
return { kind: 'primitive', primitiveType: 'number' };
|
|
608
|
+
}
|
|
609
|
+
if (node.type === NodeTypes.LiquidLiteral) {
|
|
610
|
+
return { kind: 'primitive' };
|
|
611
|
+
}
|
|
612
|
+
return { kind: 'primitive' };
|
|
613
|
+
}
|
|
@@ -194,7 +194,7 @@ ${buildComment('platformos-check-enable')}
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
it("should disable the parent node's next node if platformos-check is disabled as the last child node", async () => {
|
|
197
|
-
const file = `{% liquid
|
|
197
|
+
const file = `{% doc %}{% enddoc %}{% liquid
|
|
198
198
|
if condition
|
|
199
199
|
# platformos-check-disable-next-line
|
|
200
200
|
elsif other_condition
|
|
@@ -211,7 +211,7 @@ ${buildComment('platformos-check-enable')}
|
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
it('should not disable any checks if platformos-check is disabled at the end', async () => {
|
|
214
|
-
const file = `{% liquid
|
|
214
|
+
const file = `{% doc %}{% enddoc %}{% liquid
|
|
215
215
|
echo hello
|
|
216
216
|
echo everyone
|
|
217
217
|
# platformos-check-disable-next-line
|
|
@@ -232,7 +232,7 @@ ${buildComment('platformos-check-enable')}
|
|
|
232
232
|
});
|
|
233
233
|
|
|
234
234
|
it('should disable the next line if the content is an HTML tag with liquid', async () => {
|
|
235
|
-
const file = `{% # platformos-check-disable-next-line %}
|
|
235
|
+
const file = `{% doc %}{% enddoc %}{% # platformos-check-disable-next-line %}
|
|
236
236
|
<div class="{{ foo }}"></div>
|
|
237
237
|
<div class="{{ bar }}"></div>`;
|
|
238
238
|
|
|
@@ -246,7 +246,7 @@ ${buildComment('platformos-check-enable')}
|
|
|
246
246
|
});
|
|
247
247
|
|
|
248
248
|
it('should not disable the next line if the specified rule does not exist', async () => {
|
|
249
|
-
const file = `{% # platformos-check-disable-next-line FAKE_RULE %}
|
|
249
|
+
const file = `{% doc %}{% enddoc %}{% # platformos-check-disable-next-line FAKE_RULE %}
|
|
250
250
|
<div class="{{ foo }}"></div>`;
|
|
251
251
|
|
|
252
252
|
const offenses = await check({ 'code.liquid': file }, [UndefinedObject]);
|
package/src/url-helpers.spec.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
extractUrlPattern,
|
|
14
14
|
isValuedAttrNode,
|
|
15
15
|
getAttrName,
|
|
16
|
+
buildVariableMap,
|
|
17
|
+
tryExtractAssignUrl,
|
|
16
18
|
ValuedAttrNode,
|
|
17
19
|
} from './url-helpers';
|
|
18
20
|
|
|
@@ -239,3 +241,146 @@ describe('extractUrlPattern with variableMap', () => {
|
|
|
239
241
|
expect(extractUrlPattern(attr, variableMap)).toBe('/about');
|
|
240
242
|
});
|
|
241
243
|
});
|
|
244
|
+
|
|
245
|
+
describe('tryExtractAssignUrl', () => {
|
|
246
|
+
function firstChild(source: string): LiquidHtmlNode {
|
|
247
|
+
return toLiquidHtmlAST(source).children[0];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
it('returns null for a non-assign liquid tag', () => {
|
|
251
|
+
expect(tryExtractAssignUrl(firstChild('{% if true %}{% endif %}'))).toBe(null);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('returns null for an HTML element', () => {
|
|
255
|
+
expect(tryExtractAssignUrl(firstChild('<a href="/about">link</a>'))).toBe(null);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('extracts name and urlPattern from a simple string assign', () => {
|
|
259
|
+
const result = tryExtractAssignUrl(firstChild('{% assign url = "/about" %}'));
|
|
260
|
+
expect(result).toEqual({ name: 'url', urlPattern: '/about' });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('extracts urlPattern from an assign with append filter', () => {
|
|
264
|
+
const result = tryExtractAssignUrl(
|
|
265
|
+
firstChild('{% assign url = "/users/" | append: user.id %}'),
|
|
266
|
+
);
|
|
267
|
+
expect(result).toEqual({ name: 'url', urlPattern: '/users/:_liquid_' });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('returns null when the assign RHS is not a URL pattern (no leading /)', () => {
|
|
271
|
+
expect(tryExtractAssignUrl(firstChild('{% assign url = "about" %}'))).toBe(null);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('returns null when the assign RHS uses an unsupported filter', () => {
|
|
275
|
+
expect(tryExtractAssignUrl(firstChild('{% assign url = "/ABOUT" | downcase %}'))).toBe(null);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('returns null when assigning to a target with lookups (e.g. obj.field = ...)', () => {
|
|
279
|
+
// {% assign hash["key"] = "/about" %} — has lookups, not a plain variable
|
|
280
|
+
const ast = toLiquidHtmlAST('{% assign url = "/about" %}');
|
|
281
|
+
const node = ast.children[0] as LiquidTagAssign;
|
|
282
|
+
// Simulate lookups by checking the real code path: lookups.length > 0 returns null
|
|
283
|
+
const markup = node.markup as AssignMarkup;
|
|
284
|
+
// Normal assign has no lookups — just verify it returns non-null here
|
|
285
|
+
expect(markup.lookups.length).toBe(0);
|
|
286
|
+
expect(tryExtractAssignUrl(node)).not.toBe(null);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('buildVariableMap', () => {
|
|
291
|
+
function parseChildren(source: string): LiquidHtmlNode[] {
|
|
292
|
+
return toLiquidHtmlAST(source).children;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
it('collects top-level assigns', () => {
|
|
296
|
+
const map = buildVariableMap(parseChildren('{% assign url = "/about" %}'));
|
|
297
|
+
expect(map.get('url')).toBe('/about');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('collects multiple top-level assigns', () => {
|
|
301
|
+
const map = buildVariableMap(
|
|
302
|
+
parseChildren('{% assign a = "/first" %}{% assign b = "/second" %}'),
|
|
303
|
+
);
|
|
304
|
+
expect(map.get('a')).toBe('/first');
|
|
305
|
+
expect(map.get('b')).toBe('/second');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('later assign overwrites earlier one', () => {
|
|
309
|
+
const map = buildVariableMap(
|
|
310
|
+
parseChildren('{% assign url = "/first" %}{% assign url = "/second" %}'),
|
|
311
|
+
);
|
|
312
|
+
expect(map.get('url')).toBe('/second');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('recurses into {% if %} block children', () => {
|
|
316
|
+
const map = buildVariableMap(
|
|
317
|
+
parseChildren('{% if true %}{% assign url = "/about" %}{% endif %}'),
|
|
318
|
+
);
|
|
319
|
+
expect(map.get('url')).toBe('/about');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('recurses into {% for %} block children', () => {
|
|
323
|
+
const map = buildVariableMap(
|
|
324
|
+
parseChildren('{% for i in list %}{% assign url = "/about" %}{% endfor %}'),
|
|
325
|
+
);
|
|
326
|
+
expect(map.get('url')).toBe('/about');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('recurses into {% liquid %} block markup', () => {
|
|
330
|
+
const map = buildVariableMap(parseChildren('{% liquid\n assign url = "/about"\n%}'));
|
|
331
|
+
expect(map.get('url')).toBe('/about');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('beforeOffset', () => {
|
|
335
|
+
it('excludes assigns that end after beforeOffset', () => {
|
|
336
|
+
// "{% assign url = "/about" %}" is 27 chars (positions 0-26, end=27)
|
|
337
|
+
const source = '{% assign url = "/about" %}';
|
|
338
|
+
const map = buildVariableMap(parseChildren(source), 26);
|
|
339
|
+
// assign.position.end === 27 > 26, so it should be excluded
|
|
340
|
+
expect(map.has('url')).toBe(false);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('includes assigns that end at or before beforeOffset', () => {
|
|
344
|
+
const source = '{% assign url = "/about" %}';
|
|
345
|
+
// assign ends at 27; beforeOffset=27 means end <= offset → included
|
|
346
|
+
const map = buildVariableMap(parseChildren(source), 27);
|
|
347
|
+
expect(map.get('url')).toBe('/about');
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('includes assign and excludes later reassignment based on cursor position', () => {
|
|
351
|
+
// assign1 ends at 27, assign2 ends at 54; cursor between them
|
|
352
|
+
const source = '{% assign url = "/first" %}{% assign url = "/second" %}';
|
|
353
|
+
const map = buildVariableMap(parseChildren(source), 28);
|
|
354
|
+
expect(map.get('url')).toBe('/first');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Regression test for bug where the top-level `continue` skipped recursion into
|
|
358
|
+
// block containers. A block that starts before the cursor but ends after it must
|
|
359
|
+
// still be recursed into so that assigns before the cursor within it are found.
|
|
360
|
+
it('includes assign inside a block that ends after beforeOffset', () => {
|
|
361
|
+
// {% if %}...{% assign url = "/about" %}...<a href>...{% endif %}
|
|
362
|
+
// The if block ends after <a>.position.start, but the assign ends before it.
|
|
363
|
+
const source =
|
|
364
|
+
'{% if true %}{% assign url = "/about" %}<a href="{{ url }}">About</a>{% endif %}';
|
|
365
|
+
const aStart = source.indexOf('<a href');
|
|
366
|
+
const map = buildVariableMap(parseChildren(source), aStart);
|
|
367
|
+
expect(map.get('url')).toBe('/about');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('includes assign inside {% liquid %} block when block ends after beforeOffset', () => {
|
|
371
|
+
const source =
|
|
372
|
+
'{% if true %}{% liquid\n assign url = "/about"\n%}<a href="{{ url }}">About</a>{% endif %}';
|
|
373
|
+
const aStart = source.indexOf('<a href');
|
|
374
|
+
const map = buildVariableMap(parseChildren(source), aStart);
|
|
375
|
+
expect(map.get('url')).toBe('/about');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('excludes assign inside block that starts after beforeOffset', () => {
|
|
379
|
+
const source =
|
|
380
|
+
'<a href="{{ url }}">About</a>{% if true %}{% assign url = "/about" %}{% endif %}';
|
|
381
|
+
const aStart = source.indexOf('<a href');
|
|
382
|
+
const map = buildVariableMap(parseChildren(source), aStart);
|
|
383
|
+
expect(map.has('url')).toBe(false);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
});
|