@platformos/platformos-check-common 0.0.8 → 0.0.9

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 (77) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/dist/checks/graphql-variables/index.js +4 -0
  4. package/dist/checks/graphql-variables/index.js.map +1 -1
  5. package/dist/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.js +1 -1
  6. package/dist/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.js.map +1 -1
  7. package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.d.ts +19 -0
  8. package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js +79 -0
  9. package/dist/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.js.map +1 -0
  10. package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.d.ts +3 -0
  11. package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.js +32 -0
  12. package/dist/checks/liquid-html-syntax-error/checks/UnknownTag.js.map +1 -0
  13. package/dist/checks/liquid-html-syntax-error/index.js +21 -3
  14. package/dist/checks/liquid-html-syntax-error/index.js.map +1 -1
  15. package/dist/checks/matching-translations/index.js +103 -68
  16. package/dist/checks/matching-translations/index.js.map +1 -1
  17. package/dist/checks/undefined-object/index.js +6 -1
  18. package/dist/checks/undefined-object/index.js.map +1 -1
  19. package/dist/context-utils.d.ts +16 -0
  20. package/dist/context-utils.js +30 -1
  21. package/dist/context-utils.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +17 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/liquid-doc/arguments.js +8 -1
  26. package/dist/liquid-doc/arguments.js.map +1 -1
  27. package/dist/liquid-doc/utils.d.ts +2 -2
  28. package/dist/liquid-doc/utils.js +10 -0
  29. package/dist/liquid-doc/utils.js.map +1 -1
  30. package/dist/path.d.ts +1 -1
  31. package/dist/path.js +12 -1
  32. package/dist/path.js.map +1 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/dist/types.d.ts +8 -0
  35. package/dist/types.js.map +1 -1
  36. package/package.json +2 -2
  37. package/src/checks/duplicate-render-partial-arguments/index.spec.ts +12 -12
  38. package/src/checks/graphql-variables/index.spec.ts +95 -0
  39. package/src/checks/graphql-variables/index.ts +4 -0
  40. package/src/checks/img-width-and-height/index.ts +1 -1
  41. package/src/checks/invalid-hash-assign-target/index.spec.ts +26 -26
  42. package/src/checks/json-syntax-error/index.ts +1 -1
  43. package/src/checks/liquid-html-syntax-error/checks/InvalidLoopArguments.ts +2 -2
  44. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.spec.ts +259 -0
  45. package/src/checks/liquid-html-syntax-error/checks/InvalidTagSyntax.ts +89 -0
  46. package/src/checks/liquid-html-syntax-error/checks/UnknownTag.spec.ts +293 -0
  47. package/src/checks/liquid-html-syntax-error/checks/UnknownTag.ts +43 -0
  48. package/src/checks/liquid-html-syntax-error/index.ts +24 -3
  49. package/src/checks/matching-translations/index.spec.ts +114 -24
  50. package/src/checks/matching-translations/index.ts +102 -81
  51. package/src/checks/metadata-params/index.ts +1 -1
  52. package/src/checks/missing-partial/index.ts +6 -6
  53. package/src/checks/undefined-object/index.spec.ts +29 -2
  54. package/src/checks/undefined-object/index.ts +7 -1
  55. package/src/checks/unused-assign/index.ts +1 -1
  56. package/src/checks/valid-json/index.ts +1 -1
  57. package/src/checks/valid-render-partial-argument-types/index.spec.ts +13 -13
  58. package/src/context-utils.ts +42 -1
  59. package/src/disabled-checks/index.spec.ts +26 -61
  60. package/src/disabled-checks/index.ts +2 -4
  61. package/src/disabled-checks/test-checks.ts +4 -4
  62. package/src/ignore.spec.ts +4 -4
  63. package/src/index.ts +18 -0
  64. package/src/liquid-doc/arguments.ts +9 -3
  65. package/src/liquid-doc/liquidDoc.spec.ts +1 -1
  66. package/src/liquid-doc/utils.ts +13 -5
  67. package/src/path.ts +16 -1
  68. package/src/test/MockApp.ts +2 -2
  69. package/src/test/MockFileSystem.spec.ts +10 -11
  70. package/src/test/contain-offense.spec.ts +11 -3
  71. package/src/test/test-helper.ts +24 -28
  72. package/src/types.ts +8 -0
  73. package/src/visitor.spec.ts +2 -2
  74. package/src/types/schemas/index.ts +0 -3
  75. package/src/types/schemas/preset.ts +0 -52
  76. package/src/types/schemas/setting.ts +0 -320
  77. package/src/types/schemas/template.ts +0 -34
@@ -9,6 +9,8 @@ import { detectInvalidLoopArguments } from './checks/InvalidLoopArguments';
9
9
  import { detectConditionalNodeUnsupportedParenthesis } from './checks/InvalidConditionalNodeParenthesis';
10
10
  import { detectInvalidFilterName } from './checks/InvalidFilterName';
11
11
  import { detectInvalidPipeSyntax } from './checks/InvalidPipeSyntax';
12
+ import { detectUnknownTag } from './checks/UnknownTag';
13
+ import { detectInvalidTagSyntax } from './checks/InvalidTagSyntax';
12
14
  import { isWithinRawTagThatDoesNotParseItsContents } from '../utils';
13
15
 
14
16
  type LineColPosition = {
@@ -64,11 +66,21 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
64
66
  async LiquidTag(node, ancestors) {
65
67
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
66
68
 
69
+ const tags = (await tagsPromise) ?? [];
70
+
71
+ // Unknown tags are fatal — no point in further syntax checks.
72
+ const unknownTagProblem = detectUnknownTag(node, tags);
73
+ if (unknownTagProblem) {
74
+ context.report(unknownTagProblem);
75
+ return;
76
+ }
77
+
78
+ // Run specific sub-checks first — they provide better error messages and autofixes.
67
79
  const problems = [
68
80
  detectMultipleAssignValues(node),
69
81
  detectInvalidEchoValue(node),
70
82
  detectInvalidLoopRange(node),
71
- detectInvalidLoopArguments(node, await tagsPromise),
83
+ detectInvalidLoopArguments(node, tags),
72
84
  ].filter(Boolean) as Problem<SourceCodeType.LiquidHtml>[];
73
85
 
74
86
  // Fixers for `detectConditionalNodeUnsupportedParenthesis` and `detectInvalidConditionalNode` consume
@@ -80,9 +92,18 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
80
92
  problems.push(conditionalNodeProblem);
81
93
  }
82
94
 
95
+ // InvalidTagSyntax is a catch-all for known tags with unparseable markup.
96
+ // Only fire it if no more specific sub-check already reported on this tag.
97
+ if (problems.length === 0) {
98
+ const invalidSyntaxProblem = detectInvalidTagSyntax(node, tags);
99
+ if (invalidSyntaxProblem) {
100
+ problems.push(invalidSyntaxProblem);
101
+ }
102
+ }
103
+
83
104
  problems.forEach(context.report);
84
105
 
85
- const filterProblems = await detectInvalidFilterName(node, await filtersPromise);
106
+ const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
86
107
  if (filterProblems.length > 0) {
87
108
  filterProblems.forEach((filterProblem) => context.report(filterProblem));
88
109
  }
@@ -108,7 +129,7 @@ export const LiquidHTMLSyntaxError: LiquidCheckDefinition = {
108
129
  async LiquidVariableOutput(node, ancestors) {
109
130
  if (isWithinRawTagThatDoesNotParseItsContents(ancestors)) return;
110
131
 
111
- const filterProblems = await detectInvalidFilterName(node, await filtersPromise);
132
+ const filterProblems = await detectInvalidFilterName(node, (await filtersPromise) ?? []);
112
133
  if (filterProblems.length > 0) {
113
134
  filterProblems.forEach((problem) => context.report(problem));
114
135
  }
@@ -4,36 +4,36 @@ import { MatchingTranslations } from '../../checks/matching-translations/index';
4
4
 
5
5
  describe('Module: MatchingTranslations', async () => {
6
6
  it('should report offenses when the translation file is missing a key', async () => {
7
- const theme = {
7
+ const app = {
8
8
  'app/translations/en.yml': 'en:\n hello: Hello\n world: World\n',
9
9
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
10
10
  };
11
11
 
12
- const offenses = await check(theme, [MatchingTranslations]);
12
+ const offenses = await check(app, [MatchingTranslations]);
13
13
 
14
14
  expect(offenses).to.be.of.length(1);
15
15
  expect(offenses).to.containOffense("The translation for 'world' is missing");
16
16
  });
17
17
 
18
18
  it('should report offenses when the default translation is missing a key', async () => {
19
- const theme = {
19
+ const app = {
20
20
  'app/translations/en.yml': 'en:\n hello: Hello\n',
21
21
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n world: Mundo\n',
22
22
  };
23
23
 
24
- const offenses = await check(theme, [MatchingTranslations]);
24
+ const offenses = await check(app, [MatchingTranslations]);
25
25
 
26
26
  expect(offenses).to.be.of.length(1);
27
- expect(offenses).to.containOffense("A default translation for 'world' does not exist");
27
+ expect(offenses).to.containOffense("A translation for 'world' does not exist in the en locale");
28
28
  });
29
29
 
30
30
  it('should report offenses when nested translation keys do not exist', async () => {
31
- const theme = {
31
+ const app = {
32
32
  'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
33
33
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: {}\n',
34
34
  };
35
35
 
36
- const offenses = await check(theme, [MatchingTranslations]);
36
+ const offenses = await check(app, [MatchingTranslations]);
37
37
 
38
38
  expect(offenses).to.be.of.length(1);
39
39
  expect(offenses).to.containOffense({
@@ -43,16 +43,16 @@ describe('Module: MatchingTranslations', async () => {
43
43
  });
44
44
 
45
45
  it('should report offenses when translation shapes do not match', async () => {
46
- const theme = {
46
+ const app = {
47
47
  'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
48
48
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
49
49
  };
50
50
 
51
- const offenses = await check(theme, [MatchingTranslations]);
51
+ const offenses = await check(app, [MatchingTranslations]);
52
52
 
53
53
  expect(offenses).to.be.of.length(2);
54
54
  expect(offenses).to.containOffense({
55
- message: "A default translation for 'hello' does not exist",
55
+ message: "A translation for 'hello' does not exist in the en locale",
56
56
  uri: `file:///app/translations/pt-BR.yml`,
57
57
  });
58
58
  expect(offenses).to.containOffense({
@@ -62,22 +62,22 @@ describe('Module: MatchingTranslations', async () => {
62
62
  });
63
63
 
64
64
  it('should report offenses when nested translation keys do not match', async () => {
65
- const theme = {
65
+ const app = {
66
66
  'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
67
67
  'app/translations/fr.yml': 'fr:\n hello:\n monde: Bonjour, monde\n',
68
68
  'app/translations/es-ES.yml':
69
69
  'es-ES:\n hello:\n world: Hello, world!\n mundo:\n hola: "¡Hola, mundo!"\n',
70
70
  };
71
71
 
72
- const offenses = await check(theme, [MatchingTranslations]);
72
+ const offenses = await check(app, [MatchingTranslations]);
73
73
 
74
74
  expect(offenses).to.be.of.length(3);
75
75
  expect(offenses).to.containOffense({
76
- message: "A default translation for 'hello.monde' does not exist",
76
+ message: "A translation for 'hello.monde' does not exist in the en locale",
77
77
  uri: `file:///app/translations/fr.yml`,
78
78
  });
79
79
  expect(offenses).to.containOffense({
80
- message: "A default translation for 'hello.mundo.hola' does not exist",
80
+ message: "A translation for 'hello.mundo.hola' does not exist in the en locale",
81
81
  uri: `file:///app/translations/es-ES.yml`,
82
82
  });
83
83
  expect(offenses).to.containOffense({
@@ -87,57 +87,147 @@ describe('Module: MatchingTranslations', async () => {
87
87
  });
88
88
 
89
89
  it('should not report offenses when default translations do not exist (no en.yml)', async () => {
90
- const theme = {
90
+ const app = {
91
91
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
92
92
  };
93
93
 
94
- const offenses = await check(theme, [MatchingTranslations]);
94
+ const offenses = await check(app, [MatchingTranslations]);
95
95
 
96
96
  expect(offenses).to.be.of.length(0);
97
97
  });
98
98
 
99
99
  it('should not report offenses when translations match', async () => {
100
- const theme = {
100
+ const app = {
101
101
  'app/translations/en.yml': 'en:\n hello: Hello\n world: World\n',
102
102
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n world: Mundo\n',
103
103
  };
104
104
 
105
- const offenses = await check(theme, [MatchingTranslations]);
105
+ const offenses = await check(app, [MatchingTranslations]);
106
106
 
107
107
  expect(offenses).to.be.of.length(0);
108
108
  });
109
109
 
110
110
  it('should not report offenses when nested translations match', async () => {
111
- const theme = {
111
+ const app = {
112
112
  'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
113
113
  'app/translations/pt-BR.yml': 'pt-BR:\n hello:\n world: Olá, mundo!\n',
114
114
  'app/translations/fr.yml': 'fr:\n hello:\n world: Bonjour, monde\n',
115
115
  };
116
116
 
117
- const offenses = await check(theme, [MatchingTranslations]);
117
+ const offenses = await check(app, [MatchingTranslations]);
118
118
 
119
119
  expect(offenses).to.be.of.length(0);
120
120
  });
121
121
 
122
122
  it('should not report offenses and ignore pluralization', async () => {
123
- const theme = {
123
+ const app = {
124
124
  'app/translations/en.yml': 'en:\n hello:\n one: Hello, you\n other: "Hello, y\'all"\n',
125
125
  'app/translations/pt-BR.yml':
126
126
  'pt-BR:\n hello:\n zero: Estou sozinho :(\n few: "Olá, galerinha :)"\n',
127
127
  };
128
128
 
129
- const offenses = await check(theme, [MatchingTranslations]);
129
+ const offenses = await check(app, [MatchingTranslations]);
130
130
 
131
131
  expect(offenses).to.be.of.length(0);
132
132
  });
133
133
 
134
134
  it('should not highlight anything if the file is unparseable', async () => {
135
- const theme = {
135
+ const app = {
136
136
  'app/translations/en.yml': 'en:\n hello:\n world: Hello, world!\n',
137
137
  'app/translations/pt-BR.yml': 'pt-BR:\n hello: :\n bad yaml',
138
138
  };
139
139
 
140
- const offenses = await check(theme, [MatchingTranslations]);
140
+ const offenses = await check(app, [MatchingTranslations]);
141
+ expect(offenses).to.have.length(0);
142
+ });
143
+
144
+ // --- Multi-file / multi-scope tests ---
145
+
146
+ it('should not flag keys from a different translation scope (module vs app)', async () => {
147
+ // Module translations are auto-prefixed with their module name at runtime, so each
148
+ // module is its own isolated scope. The app scope should never need keys from
149
+ // modules/common-styling/public/translations/en.yml.
150
+ const app = {
151
+ 'app/translations/en.yml': 'en:\n hello: Hello\n',
152
+ 'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
153
+ 'modules/common-styling/public/translations/en.yml':
154
+ 'en:\n password:\n toggle_visibility: Toggle\n',
155
+ };
156
+
157
+ const offenses = await check(app, [MatchingTranslations]);
158
+ expect(offenses).to.have.length(0);
159
+ });
160
+
161
+ it('should report missing keys in a module non-en file against that module own en translations', async () => {
162
+ const app = {
163
+ 'app/translations/en.yml': 'en:\n hello: Hello\n',
164
+ 'modules/common-styling/public/translations/en.yml':
165
+ 'en:\n password:\n toggle_visibility: Toggle\n',
166
+ 'modules/common-styling/public/translations/pt-BR.yml': 'pt-BR:\n other: Outro\n',
167
+ };
168
+
169
+ const offenses = await check(app, [MatchingTranslations]);
170
+ expect(offenses).to.have.length(1);
171
+ expect(offenses).to.containOffense({
172
+ message: "The translation for 'password.toggle_visibility' is missing",
173
+ uri: 'file:///modules/common-styling/public/translations/pt-BR.yml',
174
+ });
175
+ });
176
+
177
+ it('should skip files inside the en/ locale sub-directory (they are English source files)', async () => {
178
+ // Files like app/translations/en/validation.yml are English — the check must not
179
+ // lint them as if they were a "non-English" locale file to compare.
180
+ const app = {
181
+ 'app/translations/en/validation.yml': 'en:\n required: Required\n',
182
+ 'app/translations/pt-BR/validation.yml': 'pt-BR:\n required: Obrigatório\n',
183
+ };
184
+
185
+ const offenses = await check(app, [MatchingTranslations]);
186
+ expect(offenses).to.have.length(0);
187
+ });
188
+
189
+ it('should aggregate multiple en/*.yml files within one scope as the reference set', async () => {
190
+ // Within the app scope, en/auth.yml and en/checkout.yml both contribute to the
191
+ // reference; pt-BR.yml must cover all of them.
192
+ const app = {
193
+ 'app/translations/en/auth.yml': 'en:\n login: Log in\n',
194
+ 'app/translations/en/checkout.yml': 'en:\n submit: Submit\n',
195
+ 'app/translations/pt-BR.yml': 'pt-BR:\n login: Entrar\n',
196
+ };
197
+
198
+ const offenses = await check(app, [MatchingTranslations]);
199
+ expect(offenses).to.have.length(1);
200
+ expect(offenses).to.containOffense({
201
+ message: "The translation for 'submit' is missing",
202
+ uri: 'file:///app/translations/pt-BR.yml',
203
+ });
204
+ });
205
+
206
+ it('should aggregate en.yml and en/*.yml together as the scope reference set', async () => {
207
+ const app = {
208
+ 'app/translations/en.yml': 'en:\n hello: Hello\n',
209
+ 'app/translations/en/auth.yml': 'en:\n login: Log in\n',
210
+ 'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
211
+ };
212
+
213
+ const offenses = await check(app, [MatchingTranslations]);
214
+ expect(offenses).to.have.length(1);
215
+ expect(offenses).to.containOffense({
216
+ message: "The translation for 'login' is missing",
217
+ uri: 'file:///app/translations/pt-BR.yml',
218
+ });
219
+ });
220
+
221
+ it('should not report a key as missing if it is covered by another file in the same locale scope', async () => {
222
+ // pt-BR/validation.yml covers 'required' — pt-BR.yml should not be blamed for it
223
+ const app = {
224
+ 'app/translations/en.yml': 'en:\n hello: Hello\n',
225
+ 'app/translations/en/validation.yml': 'en:\n required: Required\n',
226
+ 'app/translations/pt-BR.yml': 'pt-BR:\n hello: Olá\n',
227
+ 'app/translations/pt-BR/validation.yml': 'pt-BR:\n required: Obrigatório\n',
228
+ };
229
+
230
+ const offenses = await check(app, [MatchingTranslations]);
141
231
  expect(offenses).to.have.length(0);
142
232
  });
143
233
  });
@@ -5,10 +5,39 @@ import {
5
5
  Severity,
6
6
  SourceCodeType,
7
7
  PropertyNode,
8
+ ObjectNode,
8
9
  } from '../../types';
9
10
 
10
11
  const PLURALIZATION_KEYS = new Set(['zero', 'one', 'two', 'few', 'many', 'other']);
11
12
 
13
+ /**
14
+ * Returns the locale declared in a YAML translation file by reading its first
15
+ * top-level key (e.g. `en`, `pt-BR`, `fr`). platformOS determines a file's
16
+ * locale from content, not from its path.
17
+ */
18
+ function getLocaleFromAst(ast: JSONNode | Error): string | null {
19
+ if (ast instanceof Error) return null;
20
+ if (ast.type !== 'Object') return null;
21
+ const firstProp = (ast as ObjectNode).children[0];
22
+ if (!firstProp || firstProp.type !== 'Property') return null;
23
+ return firstProp.key.value || null;
24
+ }
25
+
26
+ /**
27
+ * Extracts the translations base directory from a relative file path.
28
+ *
29
+ * e.g. `app/translations/pt-BR.yml` → `app/translations`
30
+ * `app/translations/pt-BR/validation.yml` → `app/translations`
31
+ * `modules/x/public/translations/en.yml` → `modules/x/public/translations`
32
+ *
33
+ * Returns `null` if the path doesn't contain a `/translations/` segment.
34
+ */
35
+ function getTranslationRelativeBase(relativePath: string): string | null {
36
+ const idx = relativePath.lastIndexOf('/translations/');
37
+ if (idx === -1) return null;
38
+ return relativePath.substring(0, idx + '/translations'.length);
39
+ }
40
+
12
41
  export const MatchingTranslations: YAMLCheckDefinition = {
13
42
  meta: {
14
43
  code: 'MatchingTranslations',
@@ -25,48 +54,63 @@ export const MatchingTranslations: YAMLCheckDefinition = {
25
54
  },
26
55
 
27
56
  create(context) {
28
- // State
29
- const defaultTranslations = new Set<string>();
30
- const missingTranslations = new Set<string>();
57
+ // ── State ──────────────────────────────────────────────────────────────
58
+ const enTranslations = new Set<string>(); // keys present in the en scope
59
+ const missingFromLocale = new Set<string>(); // en keys absent from the entire locale scope
31
60
  const nodesByPath = new Map<string, PropertyNode>();
61
+
32
62
  const file = context.file as YAMLSourceCode;
33
63
  const fileUri = file.uri;
34
64
  const relativePath = context.toRelativePath(fileUri);
35
65
  const ast = file.ast;
66
+
67
+ // ── Guard: only lint translation files ────────────────────────────────
36
68
  const isTranslationFile = relativePath.includes('/translations/');
37
- // In platformOS, en.yml is the reference locale; skip running the check on it
38
- const basename = fileUri.split('/').pop() ?? '';
39
- const isDefaultTranslationsFile = basename.replace(/\.ya?ml$/, '') === 'en';
40
69
 
41
- if (!isTranslationFile || isDefaultTranslationsFile || ast instanceof Error) {
42
- // No need to lint a file that isn't a non-default translation file
70
+ // The locale is always the first top-level key in the YAML file (e.g. `en`,
71
+ // `pt-BR`). platformOS resolves locale from content, not from the file path.
72
+ const locale = getLocaleFromAst(ast);
73
+
74
+ if (!isTranslationFile || !locale || locale === 'en' || ast instanceof Error) {
43
75
  return {};
44
76
  }
45
77
 
46
- // Helpers
47
- const hasDefaultTranslations = () => defaultTranslations.size > 0;
48
- const isTerminalNode = ({ type }: JSONNode) => type === 'Literal';
49
- const isPluralizationNode = (node: PropertyNode) => PLURALIZATION_KEYS.has(node.key.value);
78
+ // ── Derive scope (translation base URI) ──────────────────────────────
79
+ const relativeBase = getTranslationRelativeBase(relativePath);
80
+ if (!relativeBase) return {};
81
+
82
+ const translationBaseUri = context.toUri(relativeBase);
50
83
 
51
- const hasDefaultTranslation = (translationPath: string) =>
52
- defaultTranslations.has(translationPath) ?? false;
84
+ // A "primary" locale file is the top-level `{locale}.yml` (not inside a
85
+ // locale sub-directory like `pt-BR/`). Only the primary file reports
86
+ // missing translations to avoid duplicate offenses across split files.
87
+ const pathAfterBase = relativePath.substring(relativeBase.length + 1);
88
+ const isPrimaryLocaleFile = !pathAfterBase.includes('/');
53
89
 
90
+ // ── Helpers ───────────────────────────────────────────────────────────
91
+ const isTerminalNode = ({ type }: JSONNode) => type === 'Literal';
92
+ const isPluralizationNode = (node: PropertyNode) => PLURALIZATION_KEYS.has(node.key.value);
54
93
  const isPluralizationPath = (path: string) =>
55
94
  [...PLURALIZATION_KEYS].some((key) => path.endsWith(key));
56
95
 
57
- const jsonPaths = (json: any): string[] => {
58
- const keys = Object.keys(json);
96
+ const countCommonParts = (a: string[], b: string[]): number => {
97
+ const min = Math.min(a.length, b.length);
98
+ for (let i = 0; i < min; i++) if (a[i] !== b[i]) return i;
99
+ return min;
100
+ };
59
101
 
60
- return keys.reduce((acc: string[], key: string) => {
61
- if (typeof json[key] !== 'object') {
62
- return acc.concat(key);
102
+ const closestTranslationKey = (key: string) => {
103
+ const keyParts = key.split('.');
104
+ let closest = '';
105
+ let max = 0;
106
+ for (const path of nodesByPath.keys()) {
107
+ const common = countCommonParts(path.split('.'), keyParts);
108
+ if (common > max) {
109
+ max = common;
110
+ closest = path;
63
111
  }
64
-
65
- const childJson = json[key];
66
- const childPaths = jsonPaths(childJson);
67
-
68
- return acc.concat(childPaths.map((path) => `${key}.${path}`));
69
- }, []);
112
+ }
113
+ return nodesByPath.get(closest) ?? ast;
70
114
  };
71
115
 
72
116
  // Strip the locale prefix (first Property in the ancestors chain).
@@ -74,90 +118,67 @@ export const MatchingTranslations: YAMLCheckDefinition = {
74
118
  // We want paths like 'hello', not 'en.hello'.
75
119
  const objectPath = (nodes: JSONNode[]) => {
76
120
  const props = nodes.filter((n): n is PropertyNode => n.type === 'Property');
77
- if (props.length <= 1) return ''; // locale key itself, or empty
121
+ if (props.length <= 1) return '';
78
122
  return props
79
123
  .slice(1)
80
124
  .map((p) => p.key.value)
81
125
  .join('.');
82
126
  };
83
127
 
84
- const countCommonParts = (arrayA: string[], arrayB: string[]): number => {
85
- const minLength = Math.min(arrayA.length, arrayB.length);
86
-
87
- for (let i = 0; i < minLength; i++) {
88
- if (arrayA[i] !== arrayB[i]) {
89
- return i;
90
- }
91
- }
92
-
93
- return minLength;
94
- };
95
-
96
- const closestTranslationKey = (translationKey: string) => {
97
- const translationKeyParts = translationKey.split('.');
98
- let closestMatch = '';
99
- let maxCommonParts = 0;
100
-
101
- for (const path of nodesByPath.keys()) {
102
- const pathParts = path.split('.');
103
- const commonParts = countCommonParts(pathParts, translationKeyParts);
104
-
105
- if (commonParts > maxCommonParts) {
106
- maxCommonParts = commonParts;
107
- closestMatch = path;
108
- }
109
- }
110
-
111
- return nodesByPath.get(closestMatch) ?? ast;
112
- };
128
+ const jsonPaths = (json: any): string[] =>
129
+ Object.keys(json).reduce((acc: string[], key: string) => {
130
+ if (typeof json[key] !== 'object') return acc.concat(key);
131
+ return acc.concat(jsonPaths(json[key]).map((p) => `${key}.${p}`));
132
+ }, []);
113
133
 
114
134
  return {
115
135
  async onCodePathStart() {
116
- const defaultTranslationPaths = await context.getDefaultTranslations().then(jsonPaths);
117
- defaultTranslationPaths.forEach(Set.prototype.add, defaultTranslations);
118
-
119
- // At the `onCodePathStart`, we assume that all translations are missing,
120
- // and remove translation paths while traversing through the file.
121
- defaultTranslationPaths.forEach(Set.prototype.add, missingTranslations);
136
+ // Aggregate ALL en translations in this scope (en.yml + en/*.yml)
137
+ const en = await context.getTranslationsForBase(translationBaseUri, 'en');
138
+ jsonPaths(en).forEach(Set.prototype.add, enTranslations);
139
+
140
+ if (!isPrimaryLocaleFile) return;
141
+
142
+ // For the primary locale file: pre-compute which en keys are absent
143
+ // from the entire locale scope (locale.yml + locale/*.yml).
144
+ const localeAgg = await context.getTranslationsForBase(translationBaseUri, locale);
145
+ const localeKeys = new Set(jsonPaths(localeAgg));
146
+ for (const key of enTranslations) {
147
+ if (!localeKeys.has(key)) missingFromLocale.add(key);
148
+ }
122
149
  },
123
150
 
124
151
  async Property(node, ancestors) {
125
152
  const path = objectPath(ancestors.concat(node));
126
-
127
- if (!path) return; // skip the root locale key (e.g. 'pt-BR')
153
+ if (!path) return;
128
154
 
129
155
  nodesByPath.set(path, node);
130
156
 
131
- if (!hasDefaultTranslations()) return;
132
157
  if (isPluralizationNode(node)) return;
133
158
  if (!isTerminalNode(node.value)) return;
159
+ if (!enTranslations.size) return; // no en reference — skip
134
160
 
135
- if (hasDefaultTranslation(path)) {
136
- // As `path` is present, we remove it from the
137
- // `missingTranslationsPerFile` bucket.
138
- missingTranslations.delete(path);
139
- return;
161
+ if (!enTranslations.has(path)) {
162
+ context.report({
163
+ message: `A translation for '${path}' does not exist in the en locale`,
164
+ startIndex: node.loc!.start.offset,
165
+ endIndex: node.loc!.end.offset,
166
+ });
140
167
  }
141
-
142
- context.report({
143
- message: `A default translation for '${path}' does not exist`,
144
- startIndex: node.loc!.start.offset,
145
- endIndex: node.loc!.end.offset,
146
- });
147
168
  },
148
169
 
149
170
  async onCodePathEnd() {
150
- missingTranslations.forEach((path) => {
151
- const closest = closestTranslationKey(path);
152
-
153
- if (isPluralizationPath(path)) return;
171
+ if (!isPrimaryLocaleFile) return;
154
172
 
173
+ for (const key of missingFromLocale) {
174
+ if (isPluralizationPath(key)) continue;
175
+ const closest = closestTranslationKey(key);
155
176
  context.report({
156
- message: `The translation for '${path}' is missing`,
177
+ message: `The translation for '${key}' is missing`,
157
178
  startIndex: closest.loc!.start.offset,
158
179
  endIndex: closest.loc!.end.offset,
159
180
  });
160
- });
181
+ }
161
182
  },
162
183
  };
163
184
  },
@@ -97,7 +97,7 @@ export const MetadataParamsCheck: LiquidCheckDefinition = {
97
97
 
98
98
  return {
99
99
  async RenderMarkup(node) {
100
- const targetFile = 'value' in node.snippet ? node.snippet.value : node.snippet.name;
100
+ const targetFile = 'value' in node.partial ? node.partial.value : node.partial.name;
101
101
  if (!targetFile) {
102
102
  return;
103
103
  }
@@ -27,20 +27,20 @@ export const MissingPartial: LiquidCheckDefinition<typeof schema> = {
27
27
 
28
28
  return {
29
29
  async RenderMarkup(node) {
30
- if (node.snippet.type === NodeTypes.VariableLookup) return;
30
+ if (node.partial.type === NodeTypes.VariableLookup) return;
31
31
 
32
- const snippet = node.snippet;
32
+ const partial = node.partial;
33
33
  const location = await locator.locate(
34
34
  URI.parse(context.config.rootUri),
35
35
  'render',
36
- snippet.value,
36
+ partial.value,
37
37
  );
38
38
 
39
39
  if (!location) {
40
40
  context.report({
41
- message: `'${snippet.value}' does not exist`,
42
- startIndex: node.snippet.position.start,
43
- endIndex: node.snippet.position.end,
41
+ message: `'${partial.value}' does not exist`,
42
+ startIndex: node.partial.position.start,
43
+ endIndex: node.partial.position.end,
44
44
  });
45
45
  }
46
46
  },
@@ -242,7 +242,7 @@ describe('Module: UndefinedObject', () => {
242
242
  }
243
243
  });
244
244
 
245
- it('should report an offense when object is undefined in a "snippet" file with doc tags that are missing the associated param', async () => {
245
+ it('should report an offense when object is undefined in a "partial" file with doc tags that are missing the associated param', async () => {
246
246
  const sourceCode = `
247
247
  {% doc %}
248
248
  {% enddoc %}
@@ -259,7 +259,7 @@ describe('Module: UndefinedObject', () => {
259
259
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'my_var' used."]);
260
260
  });
261
261
 
262
- it('should not report an offense when object is defined with @param in a snippet file', async () => {
262
+ it('should not report an offense when object is defined with @param in a partial file', async () => {
263
263
  const sourceCode = `
264
264
  {% doc %}
265
265
  @param {string} text
@@ -410,4 +410,31 @@ describe('Module: UndefinedObject', () => {
410
410
  expect(offenses).toHaveLength(1);
411
411
  expect(offenses.map((e) => e.message)).toEqual(["Unknown object 'job_id' used."]);
412
412
  });
413
+
414
+ it('should not report an offense when object is defined with a parse_json tag', async () => {
415
+ const sourceCode = `
416
+ {% parse_json groups_data %}
417
+ { "hello": "world" }
418
+ {% endparse_json %}
419
+ {{ groups_data }}
420
+ `;
421
+
422
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
423
+
424
+ expect(offenses).toHaveLength(0);
425
+ });
426
+
427
+ it('should report an offense when parse_json variable is used before the tag', async () => {
428
+ const sourceCode = `
429
+ {{ groups_data }}
430
+ {% parse_json groups_data %}
431
+ { "hello": "world" }
432
+ {% endparse_json %}
433
+ `;
434
+
435
+ const offenses = await runLiquidCheck(UndefinedObject, sourceCode);
436
+
437
+ expect(offenses).toHaveLength(1);
438
+ expect(offenses[0].message).toBe("Unknown object 'groups_data' used.");
439
+ });
413
440
  });