@platformos/platformos-check-common 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/CLAUDE.md +150 -0
  3. package/dist/AugmentedPlatformOSDocset.js +1 -0
  4. package/dist/AugmentedPlatformOSDocset.js.map +1 -1
  5. package/dist/checks/deprecated-filter/index.js +15 -0
  6. package/dist/checks/deprecated-filter/index.js.map +1 -1
  7. package/dist/checks/graphql/index.d.ts +1 -0
  8. package/dist/checks/graphql/index.js +20 -7
  9. package/dist/checks/graphql/index.js.map +1 -1
  10. package/dist/checks/invalid-hash-assign-target/index.js +4 -3
  11. package/dist/checks/invalid-hash-assign-target/index.js.map +1 -1
  12. package/dist/checks/undefined-object/index.js +14 -13
  13. package/dist/checks/undefined-object/index.js.map +1 -1
  14. package/dist/checks/unknown-property/index.js +75 -10
  15. package/dist/checks/unknown-property/index.js.map +1 -1
  16. package/dist/checks/unknown-property/property-shape.js +14 -1
  17. package/dist/checks/unknown-property/property-shape.js.map +1 -1
  18. package/dist/checks/unused-assign/index.js +23 -1
  19. package/dist/checks/unused-assign/index.js.map +1 -1
  20. package/dist/checks/variable-name/index.js +4 -0
  21. package/dist/checks/variable-name/index.js.map +1 -1
  22. package/dist/frontmatter/index.d.ts +59 -0
  23. package/dist/frontmatter/index.js +301 -0
  24. package/dist/frontmatter/index.js.map +1 -0
  25. package/dist/index.d.ts +2 -1
  26. package/dist/index.js +4 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/liquid-doc/arguments.js +5 -0
  29. package/dist/liquid-doc/arguments.js.map +1 -1
  30. package/dist/path.d.ts +1 -1
  31. package/dist/path.js +3 -1
  32. package/dist/path.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +2 -2
  35. package/src/AugmentedPlatformOSDocset.ts +1 -0
  36. package/src/checks/deprecated-filter/index.spec.ts +41 -1
  37. package/src/checks/deprecated-filter/index.ts +17 -0
  38. package/src/checks/graphql/index.spec.ts +173 -0
  39. package/src/checks/graphql/index.ts +21 -10
  40. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -0
  41. package/src/checks/invalid-hash-assign-target/index.ts +6 -4
  42. package/src/checks/undefined-object/index.spec.ts +123 -19
  43. package/src/checks/undefined-object/index.ts +16 -18
  44. package/src/checks/unknown-property/index.spec.ts +133 -0
  45. package/src/checks/unknown-property/index.ts +84 -10
  46. package/src/checks/unknown-property/property-shape.ts +15 -1
  47. package/src/checks/unused-assign/index.spec.ts +74 -0
  48. package/src/checks/unused-assign/index.ts +26 -1
  49. package/src/checks/variable-name/index.spec.ts +9 -0
  50. package/src/checks/variable-name/index.ts +5 -0
  51. package/src/frontmatter/index.ts +344 -0
  52. package/src/index.ts +3 -0
  53. package/src/liquid-doc/arguments.ts +3 -0
  54. package/src/path.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformos/platformos-check-common",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,7 +26,7 @@
26
26
  "type-check": "tsc --noEmit"
27
27
  },
28
28
  "dependencies": {
29
- "@platformos/liquid-html-parser": "0.0.10",
29
+ "@platformos/liquid-html-parser": "0.0.11",
30
30
  "graphql": "^16.12.0",
31
31
  "js-yaml": "^4.1.1",
32
32
  "jsonc-parser": "^3.3.1",
@@ -35,6 +35,7 @@ const undocumentedFilters = [
35
35
  'handle_from',
36
36
  'pad_spaces',
37
37
  'paragraphize',
38
+ 'push',
38
39
  'sentence',
39
40
  'unit',
40
41
  'weight',
@@ -1,5 +1,5 @@
1
1
  import { expect, describe, it } from 'vitest';
2
- import { highlightedOffenses, runLiquidCheck } from '../../test';
2
+ import { applySuggestions, highlightedOffenses, runLiquidCheck } from '../../test';
3
3
  import { DeprecatedFilter } from './index';
4
4
 
5
5
  const mockDependencies = {
@@ -99,4 +99,44 @@ describe('Module: DeprecatedFilter', () => {
99
99
  );
100
100
  expect(offenses).toHaveLength(2);
101
101
  });
102
+
103
+ it('should provide a suggestion to replace deprecated filter with recommended alternative', async () => {
104
+ const sourceCode = `{{ value | old_filter }}`;
105
+
106
+ const offenses = await runLiquidCheck(
107
+ DeprecatedFilter,
108
+ sourceCode,
109
+ 'file.liquid',
110
+ mockDependencies,
111
+ );
112
+ expect(offenses).toHaveLength(1);
113
+ expect(offenses[0].suggest).toHaveLength(1);
114
+ expect(offenses[0].suggest![0].message).toEqual("Replace 'old_filter' with 'new_filter'");
115
+ });
116
+
117
+ it('should apply the suggestion to replace the filter name', async () => {
118
+ const sourceCode = `{{ value | old_filter }}`;
119
+
120
+ const offenses = await runLiquidCheck(
121
+ DeprecatedFilter,
122
+ sourceCode,
123
+ 'file.liquid',
124
+ mockDependencies,
125
+ );
126
+ const suggestions = applySuggestions(sourceCode, offenses[0]);
127
+ expect(suggestions).toContain('{{ value | new_filter }}');
128
+ });
129
+
130
+ it('should not provide a suggestion when no replacement exists', async () => {
131
+ const sourceCode = `{{ value | deprecated_no_replacement }}`;
132
+
133
+ const offenses = await runLiquidCheck(
134
+ DeprecatedFilter,
135
+ sourceCode,
136
+ 'file.liquid',
137
+ mockDependencies,
138
+ );
139
+ expect(offenses).toHaveLength(1);
140
+ expect(offenses[0].suggest).toBeUndefined();
141
+ });
102
142
  });
@@ -39,10 +39,27 @@ export const DeprecatedFilter: LiquidCheckDefinition = {
39
39
 
40
40
  const message = deprecatedFilterMessage(deprecatedFilter, recommendedFilter);
41
41
 
42
+ const filterText = node.source.slice(node.position.start, node.position.end);
43
+ const afterPipeIdx = filterText.indexOf('|') + 1;
44
+ const nameIdx =
45
+ afterPipeIdx + filterText.slice(afterPipeIdx).indexOf(deprecatedFilter.name);
46
+ const filterNameStart = node.position.start + nameIdx;
47
+ const filterNameEnd = filterNameStart + deprecatedFilter.name.length;
48
+
42
49
  context.report({
43
50
  message,
44
51
  startIndex: node.position.start + 1,
45
52
  endIndex: node.position.end,
53
+ suggest: recommendedFilter
54
+ ? [
55
+ {
56
+ message: `Replace '${deprecatedFilter.name}' with '${recommendedFilter.name}'`,
57
+ fix: (corrector) => {
58
+ corrector.replace(filterNameStart, filterNameEnd, recommendedFilter.name);
59
+ },
60
+ },
61
+ ]
62
+ : undefined,
46
63
  });
47
64
  },
48
65
  };
@@ -0,0 +1,173 @@
1
+ import { expect, describe, it } from 'vitest';
2
+ import { GraphQLCheck, lineToRange } from './index';
3
+ import { check } from '../../test';
4
+
5
+ const SCHEMA = `
6
+ type Query {
7
+ hello: String
8
+ users: [User]
9
+ }
10
+
11
+ type User {
12
+ id: ID
13
+ name: String
14
+ }
15
+ `;
16
+
17
+ const mockDependencies = {
18
+ platformosDocset: {
19
+ async graphQL() {
20
+ return SCHEMA;
21
+ },
22
+ async filters() {
23
+ return [];
24
+ },
25
+ async objects() {
26
+ return [];
27
+ },
28
+ async liquidDrops() {
29
+ return [];
30
+ },
31
+ async tags() {
32
+ return [];
33
+ },
34
+ },
35
+ };
36
+
37
+ const noDeps = {
38
+ platformosDocset: {
39
+ async graphQL() {
40
+ return null;
41
+ },
42
+ async filters() {
43
+ return [];
44
+ },
45
+ async objects() {
46
+ return [];
47
+ },
48
+ async liquidDrops() {
49
+ return [];
50
+ },
51
+ async tags() {
52
+ return [];
53
+ },
54
+ },
55
+ };
56
+
57
+ describe('Module: GraphQLCheck', () => {
58
+ it('reports no offenses for a valid query', async () => {
59
+ const files = {
60
+ 'app/graphql/my_query.graphql': '{ hello }',
61
+ };
62
+
63
+ const offenses = await check(files, [GraphQLCheck], mockDependencies);
64
+ expect(offenses).to.be.empty;
65
+ });
66
+
67
+ it('reports an offense for an unknown field', async () => {
68
+ const files = {
69
+ 'app/graphql/my_query.graphql': '{ unknownField }',
70
+ };
71
+
72
+ const offenses = await check(files, [GraphQLCheck], mockDependencies);
73
+ expect(offenses).to.have.length(1);
74
+ expect(offenses[0].message).to.equal('Cannot query field "unknownField" on type "Query".');
75
+ });
76
+
77
+ it('offense for unknown field spans only the affected line, not the entire file', async () => {
78
+ const query = `{
79
+ unknownField
80
+ }`;
81
+ const files = {
82
+ 'app/graphql/my_query.graphql': query,
83
+ };
84
+
85
+ const offenses = await check(files, [GraphQLCheck], mockDependencies);
86
+ expect(offenses).to.have.length(1);
87
+
88
+ // Should point to line 2 (1-based), which is " unknownField"
89
+ // and NOT span to the end of the file
90
+ expect(offenses[0].start.line).to.equal(1); // 0-based: line index 1 = " unknownField"
91
+ expect(offenses[0].end.line).to.equal(1);
92
+ });
93
+
94
+ it('reports a syntax error offense instead of swallowing it', async () => {
95
+ const files = {
96
+ 'app/graphql/my_query.graphql': '{ unclosed {',
97
+ };
98
+
99
+ const offenses = await check(files, [GraphQLCheck], mockDependencies);
100
+ expect(offenses).to.have.length(1);
101
+ expect(offenses[0].message).to.include('Syntax Error');
102
+ });
103
+
104
+ it('syntax error offense points to the actual error line, not the whole file', async () => {
105
+ // unclosed brace on line 3 causes a parse error — graphql-js will report the exact location
106
+ const query = `{
107
+ hello
108
+ unclosed {
109
+ `;
110
+ const files = {
111
+ 'app/graphql/my_query.graphql': query,
112
+ };
113
+
114
+ const offenses = await check(files, [GraphQLCheck], mockDependencies);
115
+ expect(offenses).to.have.length(1);
116
+ expect(offenses[0].message).to.include('Syntax Error');
117
+ // Offense spans exactly one line (the error line), NOT the whole file
118
+ expect(offenses[0].start.line).to.equal(offenses[0].end.line);
119
+ // And that line is not the last line of the file (i.e. not spanning to the end)
120
+ expect(offenses[0].end.line).to.be.lessThan(3); // file has 4 lines (0-indexed: 0-3)
121
+ });
122
+
123
+ it('reports no offenses when platformosDocset.graphQL returns null', async () => {
124
+ const files = {
125
+ 'app/graphql/my_query.graphql': '{ unknownField }',
126
+ };
127
+
128
+ const offenses = await check(files, [GraphQLCheck], noDeps);
129
+ expect(offenses).to.be.empty;
130
+ });
131
+ });
132
+
133
+ describe('Unit: lineToRange', () => {
134
+ const TEXT = 'line1\nline2\nline3';
135
+
136
+ it('returns correct range for line 1', () => {
137
+ expect(lineToRange(TEXT, 1)).to.eql([0, 5]); // "line1"
138
+ });
139
+
140
+ it('returns correct range for line 2', () => {
141
+ expect(lineToRange(TEXT, 2)).to.eql([6, 11]); // "line2"
142
+ });
143
+
144
+ it('returns correct range for line 3', () => {
145
+ expect(lineToRange(TEXT, 3)).to.eql([12, 17]); // "line3"
146
+ });
147
+
148
+ it('clamps line 0 to first line instead of spanning the whole file', () => {
149
+ const [start, end] = lineToRange(TEXT, 0);
150
+ expect(start).to.equal(0);
151
+ expect(end).to.equal(5); // "line1" length = 5, not TEXT.length (17)
152
+ });
153
+
154
+ it('clamps line beyond last to last line instead of spanning the whole file', () => {
155
+ const [start, end] = lineToRange(TEXT, 999);
156
+ expect(start).to.equal(12);
157
+ expect(end).to.equal(17); // "line3"
158
+ });
159
+
160
+ it('handles single-line text with line 0', () => {
161
+ const [start, end] = lineToRange('hello', 0);
162
+ expect(start).to.equal(0);
163
+ expect(end).to.equal(5); // entire single line, NOT text.length (which happens to be the same here)
164
+ });
165
+
166
+ it('does not return the whole file when line is 0', () => {
167
+ const longText = 'first line\nsecond line\nthird line';
168
+ const [, end] = lineToRange(longText, 0);
169
+ // Should be end of first line (10), not end of whole text (33)
170
+ expect(end).to.equal(10);
171
+ expect(end).to.not.equal(longText.length);
172
+ });
173
+ });
@@ -1,20 +1,17 @@
1
1
  import { GraphQLCheckDefinition, Severity, SourceCodeType } from '../../types';
2
2
  import { parse } from 'graphql/language';
3
- import { buildSchema, validate } from 'graphql';
3
+ import { buildSchema, GraphQLError, validate } from 'graphql';
4
4
 
5
- function lineToRange(text: string, line: number): [number, number] {
5
+ export function lineToRange(text: string, line: number): [number, number] {
6
6
  const lines = text.split(/\r?\n/);
7
-
8
- if (line < 1 || line > lines.length) {
9
- return [0, text.length];
10
- }
7
+ const clampedLine = Math.max(1, Math.min(line, lines.length));
11
8
 
12
9
  let start = 0;
13
- for (let i = 0; i < line - 1; i++) {
10
+ for (let i = 0; i < clampedLine - 1; i++) {
14
11
  start += lines[i].length + 1;
15
12
  }
16
13
 
17
- const end = start + lines[line - 1].length;
14
+ const end = start + lines[clampedLine - 1].length;
18
15
  return [start, end];
19
16
  }
20
17
 
@@ -42,11 +39,25 @@ export const GraphQLCheck: GraphQLCheckDefinition = {
42
39
 
43
40
  const graphQLSchema = buildSchema(graphQLSchemaString);
44
41
 
45
- const document = parse(content);
42
+ let document;
43
+ try {
44
+ document = parse(content);
45
+ } catch (e) {
46
+ if (e instanceof GraphQLError) {
47
+ const [start, end] = lineToRange(content, e.locations?.[0]?.line ?? 1);
48
+ context.report({
49
+ message: e.message,
50
+ startIndex: start,
51
+ endIndex: end,
52
+ });
53
+ }
54
+ return;
55
+ }
56
+
46
57
  const errors = validate(graphQLSchema, document);
47
58
 
48
59
  errors.forEach((error) => {
49
- const [start, end] = lineToRange(content, error.locations?.[0].line ?? 0);
60
+ const [start, end] = lineToRange(content, error.locations?.[0]?.line ?? 0);
50
61
  context.report({
51
62
  message: error.message,
52
63
  startIndex: start,
@@ -119,6 +119,32 @@ describe('Module: InvalidHashAssignTarget', () => {
119
119
  expect(offenses).toHaveLength(0);
120
120
  });
121
121
 
122
+ it('should not report an error when hash_assign is used on a function return with variable partial', async () => {
123
+ const app: MockApp = {
124
+ 'file.liquid': `
125
+ {% assign partial_name = 'lib/get_data' %}
126
+ {% function data = partial_name %}
127
+ {% hash_assign data['extra'] = 'value' %}
128
+ `,
129
+ };
130
+
131
+ const offenses = await check(app, [InvalidHashAssignTarget]);
132
+ expect(offenses).toHaveLength(0);
133
+ });
134
+
135
+ it('should not report an error when function uses hash-access result target and hash_assign follows', async () => {
136
+ const app: MockApp = {
137
+ 'file.liquid': `
138
+ {% parse_json my_hash %}{}{% endparse_json %}
139
+ {% function my_hash['result'] = 'lib/get_data' %}
140
+ {% hash_assign my_hash['extra'] = 'value' %}
141
+ `,
142
+ };
143
+
144
+ const offenses = await check(app, [InvalidHashAssignTarget]);
145
+ expect(offenses).toHaveLength(0);
146
+ });
147
+
122
148
  it('should track reassignment and report error on new type', async () => {
123
149
  const app: MockApp = {
124
150
  'file.liquid': `
@@ -7,6 +7,7 @@ import {
7
7
  HashAssignMarkup,
8
8
  GraphQLMarkup,
9
9
  GraphQLInlineMarkup,
10
+ FunctionMarkup,
10
11
  } from '@platformos/liquid-html-parser';
11
12
  import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
12
13
  import { isError } from '../../utils';
@@ -229,12 +230,13 @@ export const InvalidHashAssignTarget: LiquidCheckDefinition = {
229
230
 
230
231
  // {% function result = 'path' %}
231
232
  if (node.name === NamedTags.function && typeof node.markup !== 'string') {
232
- const markup = node.markup as { name: string };
233
- if (markup.name) {
234
- closeTypeRange(markup.name, node.position.start);
233
+ const markup = node.markup as FunctionMarkup;
234
+ const varName = markup.name.name;
235
+ if (varName) {
236
+ closeTypeRange(varName, node.position.start);
235
237
  // Function returns are untyped unless we can infer them
236
238
  variableTypes.push({
237
- name: markup.name,
239
+ name: varName,
238
240
  type: 'untyped',
239
241
  range: [node.position.end],
240
242
  });
@@ -145,6 +145,116 @@ describe('Module: UndefinedObject', () => {
145
145
  expect(offenses).toHaveLength(0);
146
146
  });
147
147
 
148
+ it('should report an offense when function result variable is used before its definition', async () => {
149
+ const sourceCode = `
150
+ {{ a }}
151
+ {% function a = 'test' %}
152
+ `;
153
+
154
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
155
+
156
+ expect(offenses).toHaveLength(1);
157
+ expect(offenses[0].message).toBe("Unknown object 'a' used.");
158
+ });
159
+
160
+ it('should not report an offense for multiple function result variables', async () => {
161
+ const sourceCode = `
162
+ {% function result1 = 'partial_one' %}
163
+ {% function result2 = 'partial_two' %}
164
+ {{ result1 }}
165
+ {{ result2 }}
166
+ `;
167
+
168
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
169
+
170
+ expect(offenses).toHaveLength(0);
171
+ });
172
+
173
+ it('should not register a scope variable when function target is a hash/array access', async () => {
174
+ const sourceCode = `
175
+ {% parse_json my_hash %}{"key": "value"}{% endparse_json %}
176
+ {% function my_hash['result'] = 'test' %}
177
+ {{ my_hash }}
178
+ `;
179
+
180
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
181
+
182
+ // my_hash is defined via parse_json; function hash-access target does not shadow it
183
+ expect(offenses).toHaveLength(0);
184
+ });
185
+
186
+ it('should report an offense when a variable partial in include is undefined', async () => {
187
+ const sourceCode = `
188
+ {% include undefined_partial %}
189
+ `;
190
+
191
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
192
+
193
+ expect(offenses).toHaveLength(1);
194
+ expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
195
+ });
196
+
197
+ it('should not report an offense when a variable partial in include is defined', async () => {
198
+ const sourceCode = `
199
+ {% assign partial_name = 'some/partial' %}
200
+ {% include partial_name %}
201
+ `;
202
+
203
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
204
+
205
+ expect(offenses).toHaveLength(0);
206
+ });
207
+
208
+ it('should report an offense when a variable partial in function is undefined', async () => {
209
+ const sourceCode = `
210
+ {% function result = undefined_partial %}
211
+ `;
212
+
213
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
214
+
215
+ expect(offenses).toHaveLength(1);
216
+ expect(offenses[0].message).toBe("Unknown object 'undefined_partial' used.");
217
+ });
218
+
219
+ it('should not report an offense for the result variable itself in function tag', async () => {
220
+ const sourceCode = `
221
+ {% function result = undefined_partial %}
222
+ `;
223
+
224
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
225
+
226
+ // only 'undefined_partial' should be reported, not 'result'
227
+ expect(offenses.every((o) => o.message !== "Unknown object 'result' used.")).toBe(true);
228
+ });
229
+
230
+ it('should report offenses for lookup key variables in function result target and partial', async () => {
231
+ const sourceCode = `
232
+ {% parse_json my_hash %}{}{% endparse_json %}
233
+ {% function my_hash[lookup_key] = my_hash[path_var] %}
234
+ `;
235
+
236
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
237
+
238
+ const messages = offenses.map((o) => o.message);
239
+ // lookup_key and path_var are undefined; my_hash is defined
240
+ expect(messages).toContain("Unknown object 'lookup_key' used.");
241
+ expect(messages).toContain("Unknown object 'path_var' used.");
242
+ expect(messages).not.toContain("Unknown object 'my_hash' used.");
243
+ });
244
+
245
+ it('should check the partial variable in function but not the hash-access result target base', async () => {
246
+ const sourceCode = `
247
+ {% parse_json my_hash %}{}{% endparse_json %}
248
+ {% function my_hash['key'] = undefined_partial %}
249
+ `;
250
+
251
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
252
+
253
+ const messages = offenses.map((o) => o.message);
254
+ expect(messages).toContain("Unknown object 'undefined_partial' used.");
255
+ expect(messages).not.toContain("Unknown object 'my_hash' used.");
256
+ });
257
+
148
258
  it('should report an offense when object is defined in a for loop but used outside of the scope (in scenarios where the same variable has multiple scopes in the file)', async () => {
149
259
  const sourceCode = `
150
260
  {% for c in collections %}
@@ -359,10 +469,10 @@ describe('Module: UndefinedObject', () => {
359
469
  expect(offenses[0].message).toBe("Unknown object 'my_var' used.");
360
470
  });
361
471
 
362
- it('should report an offense when job_id is used inside background block', async () => {
472
+ it('should report an offense when undefined variable is used inside background block', async () => {
363
473
  const sourceCode = `
364
- {% background job_id %}
365
- {{ job_id }}
474
+ {% background source_type: 'some form' %}
475
+ {{ undefined_var }}
366
476
  {% endbackground %}
367
477
  `;
368
478
 
@@ -371,12 +481,10 @@ describe('Module: UndefinedObject', () => {
371
481
  expect(offenses).toHaveLength(1);
372
482
  });
373
483
 
374
- it('should not report an offense when job_id is used after background block', async () => {
484
+ it('should not report an offense when job_id is used after background file-based tag', async () => {
375
485
  const sourceCode = `
376
- {% background job_id %}
377
- {% assign a = 5 %}
378
- {% endbackground %}
379
- {{ job_id }}
486
+ {% background my_job = 'some_partial' %}
487
+ {{ my_job }}
380
488
  `;
381
489
 
382
490
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
@@ -384,12 +492,10 @@ describe('Module: UndefinedObject', () => {
384
492
  expect(offenses).toHaveLength(0);
385
493
  });
386
494
 
387
- it('should not report an offense when job_id is used after background block with args', async () => {
495
+ it('should not report an offense when job_id is used after background file-based tag with named args', async () => {
388
496
  const sourceCode = `
389
- {% background job_id, priority: 'low' %}
390
- {% assign a = 5 %}
391
- {% endbackground %}
392
- {{ job_id }}
497
+ {% background my_job = 'some_partial', source_type: 'some form' %}
498
+ {{ my_job }}
393
499
  `;
394
500
 
395
501
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
@@ -397,18 +503,16 @@ describe('Module: UndefinedObject', () => {
397
503
  expect(offenses).toHaveLength(0);
398
504
  });
399
505
 
400
- it('should report an offense when job_id is used before background block', async () => {
506
+ it('should report an offense when job_id is used before background file-based tag', async () => {
401
507
  const sourceCode = `
402
- {{ job_id }}
403
- {% background job_id %}
404
- {% assign a = 5 %}
405
- {% endbackground %}
508
+ {{ my_job }}
509
+ {% background my_job = 'some_partial' %}
406
510
  `;
407
511
 
408
512
  const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
409
513
 
410
514
  expect(offenses).toHaveLength(1);
411
- expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'job_id' used."]);
515
+ expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_job' used."]);
412
516
  });
413
517
 
414
518
  it('should not report an offense when object is defined with a parse_json tag', async () => {
@@ -18,7 +18,7 @@ import {
18
18
  LiquidTagGraphQL,
19
19
  LiquidTagParseJson,
20
20
  LiquidTagBackground,
21
- BackgroundInlineMarkup,
21
+ BackgroundMarkup,
22
22
  } from '@platformos/liquid-html-parser';
23
23
  import { LiquidCheckDefinition, Severity, SourceCodeType, PlatformOSDocset } from '../../types';
24
24
  import { isError, last } from '../../utils';
@@ -108,9 +108,13 @@ export const UndefinedObject: LiquidCheckDefinition = {
108
108
  }
109
109
 
110
110
  if (node.name === 'function') {
111
- indexVariableScope((node.markup as FunctionMarkup).name, {
112
- start: node.position.end,
113
- });
111
+ const fnName = (node.markup as FunctionMarkup).name;
112
+ // Only register simple variable names (not hash/array mutations like hash['key'])
113
+ if (fnName.lookups.length === 0 && fnName.name !== null) {
114
+ indexVariableScope(fnName.name, {
115
+ start: node.position.end,
116
+ });
117
+ }
114
118
  }
115
119
 
116
120
  if (node.name === 'layout') {
@@ -148,8 +152,8 @@ export const UndefinedObject: LiquidCheckDefinition = {
148
152
  }
149
153
 
150
154
  if (isLiquidTagBackground(node)) {
151
- indexVariableScope(node.markup.jobId.name, {
152
- start: node.blockEndPosition?.end,
155
+ indexVariableScope(node.markup.jobId, {
156
+ start: node.position.end,
153
157
  });
154
158
  }
155
159
  },
@@ -160,9 +164,8 @@ export const UndefinedObject: LiquidCheckDefinition = {
160
164
  const parent = last(ancestors);
161
165
  if (isLiquidTag(parent) && isLiquidTagCapture(parent)) return;
162
166
  if (isLiquidTag(parent) && isLiquidTagParseJson(parent)) return;
163
-
164
- // Skip the jobId variable in background tag markup - it's being defined, not used
165
- if (isBackgroundInlineMarkup(parent) && parent.jobId === node) return;
167
+ // Skip the result variable of function tags (it's a definition, not a usage)
168
+ if (isFunctionMarkup(parent) && parent.name === node) return;
166
169
 
167
170
  variables.push(node);
168
171
  },
@@ -299,19 +302,14 @@ function isLiquidTagDecrement(node: LiquidTag): node is LiquidTagDecrement {
299
302
 
300
303
  function isLiquidTagBackground(
301
304
  node: LiquidTag,
302
- ): node is LiquidTagBackground & { markup: BackgroundInlineMarkup } {
305
+ ): node is LiquidTagBackground & { markup: BackgroundMarkup } {
303
306
  return (
304
307
  node.name === NamedTags.background &&
305
308
  typeof node.markup !== 'string' &&
306
- node.markup.type === NodeTypes.BackgroundInlineMarkup
309
+ node.markup.type === NodeTypes.BackgroundMarkup
307
310
  );
308
311
  }
309
312
 
310
- function isBackgroundInlineMarkup(node: unknown): node is BackgroundInlineMarkup {
311
- return (
312
- typeof node === 'object' &&
313
- node !== null &&
314
- 'type' in node &&
315
- node.type === NodeTypes.BackgroundInlineMarkup
316
- );
313
+ function isFunctionMarkup(node?: LiquidHtmlNode): node is FunctionMarkup {
314
+ return node?.type === NodeTypes.FunctionMarkup;
317
315
  }