@platformos/platformos-check-common 0.0.12 → 0.0.13
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 +31 -0
- package/dist/checks/circular-render/index.d.ts +2 -0
- package/dist/checks/circular-render/index.js +164 -0
- package/dist/checks/circular-render/index.js.map +1 -0
- package/dist/checks/index.d.ts +1 -1
- package/dist/checks/index.js +6 -0
- package/dist/checks/index.js.map +1 -1
- package/dist/checks/missing-page/index.d.ts +2 -0
- package/dist/checks/missing-page/index.js +73 -0
- package/dist/checks/missing-page/index.js.map +1 -0
- package/dist/checks/missing-partial/index.js +31 -31
- package/dist/checks/missing-partial/index.js.map +1 -1
- package/dist/checks/missing-render-partial-arguments/index.d.ts +2 -0
- package/dist/checks/missing-render-partial-arguments/index.js +37 -0
- package/dist/checks/missing-render-partial-arguments/index.js.map +1 -0
- package/dist/checks/nested-graphql-query/index.d.ts +2 -0
- package/dist/checks/nested-graphql-query/index.js +146 -0
- package/dist/checks/nested-graphql-query/index.js.map +1 -0
- package/dist/checks/translation-key-exists/index.js +16 -19
- package/dist/checks/translation-key-exists/index.js.map +1 -1
- package/dist/checks/translation-utils.d.ts +20 -0
- package/dist/checks/translation-utils.js +51 -0
- package/dist/checks/translation-utils.js.map +1 -0
- package/dist/checks/undefined-object/index.js +21 -0
- package/dist/checks/undefined-object/index.js.map +1 -1
- package/dist/checks/unused-translation-key/index.d.ts +4 -0
- package/dist/checks/unused-translation-key/index.js +85 -0
- package/dist/checks/unused-translation-key/index.js.map +1 -0
- package/dist/checks/valid-render-partial-argument-types/index.js +2 -1
- package/dist/checks/valid-render-partial-argument-types/index.js.map +1 -1
- package/dist/context-utils.d.ts +2 -1
- package/dist/context-utils.js +31 -1
- package/dist/context-utils.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/liquid-doc/arguments.js +4 -0
- package/dist/liquid-doc/arguments.js.map +1 -1
- package/dist/liquid-doc/utils.d.ts +10 -2
- package/dist/liquid-doc/utils.js +26 -1
- package/dist/liquid-doc/utils.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +8 -1
- package/dist/types.js.map +1 -1
- package/dist/url-helpers.d.ts +55 -0
- package/dist/url-helpers.js +334 -0
- package/dist/url-helpers.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/levenshtein.d.ts +3 -0
- package/dist/utils/levenshtein.js +39 -0
- package/dist/utils/levenshtein.js.map +1 -0
- package/package.json +2 -2
- package/src/checks/graphql/index.spec.ts +2 -2
- package/src/checks/index.ts +6 -0
- package/src/checks/missing-page/index.spec.ts +755 -0
- package/src/checks/missing-page/index.ts +89 -0
- package/src/checks/missing-partial/index.spec.ts +361 -0
- package/src/checks/missing-partial/index.ts +39 -47
- package/src/checks/missing-render-partial-arguments/index.spec.ts +74 -0
- package/src/checks/missing-render-partial-arguments/index.ts +44 -0
- package/src/checks/nested-graphql-query/index.spec.ts +175 -0
- package/src/checks/nested-graphql-query/index.ts +203 -0
- package/src/checks/parser-blocking-script/index.spec.ts +7 -3
- package/src/checks/translation-key-exists/index.spec.ts +79 -2
- package/src/checks/translation-key-exists/index.ts +18 -27
- package/src/checks/translation-utils.ts +63 -0
- package/src/checks/undefined-object/index.spec.ts +30 -0
- package/src/checks/undefined-object/index.ts +27 -1
- package/src/checks/unused-assign/index.spec.ts +1 -1
- package/src/checks/unused-doc-param/index.spec.ts +4 -2
- package/src/checks/valid-doc-param-types/index.spec.ts +1 -1
- package/src/checks/valid-render-partial-argument-types/index.spec.ts +24 -1
- package/src/checks/valid-render-partial-argument-types/index.ts +3 -2
- package/src/checks/variable-name/index.spec.ts +1 -1
- package/src/context-utils.ts +33 -1
- package/src/index.ts +3 -0
- package/src/liquid-doc/arguments.ts +6 -0
- package/src/liquid-doc/utils.ts +26 -2
- package/src/types.ts +9 -1
- package/src/url-helpers.spec.ts +241 -0
- package/src/url-helpers.ts +363 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/levenshtein.ts +41 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RenderMarkup } from '@platformos/liquid-html-parser';
|
|
2
|
+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
3
|
+
import {
|
|
4
|
+
getLiquidDocParams,
|
|
5
|
+
getPartialName,
|
|
6
|
+
reportMissingArguments,
|
|
7
|
+
} from '../../liquid-doc/arguments';
|
|
8
|
+
|
|
9
|
+
export const MissingRenderPartialArguments: LiquidCheckDefinition = {
|
|
10
|
+
meta: {
|
|
11
|
+
code: 'MissingRenderPartialArguments',
|
|
12
|
+
name: 'Missing Required Render Partial Arguments',
|
|
13
|
+
aliases: ['MissingRenderPartialParams'],
|
|
14
|
+
docs: {
|
|
15
|
+
description:
|
|
16
|
+
'This check ensures that all required @param arguments declared by a partial are provided at the call site.',
|
|
17
|
+
recommended: true,
|
|
18
|
+
url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/missing-render-partial-arguments',
|
|
19
|
+
},
|
|
20
|
+
type: SourceCodeType.LiquidHtml,
|
|
21
|
+
severity: Severity.ERROR,
|
|
22
|
+
schema: {},
|
|
23
|
+
targets: [],
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
create(context) {
|
|
27
|
+
return {
|
|
28
|
+
async RenderMarkup(node: RenderMarkup) {
|
|
29
|
+
const partialName = getPartialName(node);
|
|
30
|
+
if (!partialName) return;
|
|
31
|
+
|
|
32
|
+
const liquidDocParameters = await getLiquidDocParams(context, partialName);
|
|
33
|
+
if (!liquidDocParameters) return;
|
|
34
|
+
|
|
35
|
+
const providedNames = new Set(node.args.map((a) => a.name));
|
|
36
|
+
const missingRequired = [...liquidDocParameters.values()].filter(
|
|
37
|
+
(p) => p.required && !providedNames.has(p.name),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
reportMissingArguments(context, node, missingRequired, partialName);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { runLiquidCheck, check } from '../../test';
|
|
3
|
+
import { NestedGraphQLQuery } from '.';
|
|
4
|
+
|
|
5
|
+
describe('Module: NestedGraphQLQuery', () => {
|
|
6
|
+
it('should not report graphql outside a loop', async () => {
|
|
7
|
+
const offenses = await runLiquidCheck(
|
|
8
|
+
NestedGraphQLQuery,
|
|
9
|
+
`{% graphql result = 'products/list' %}`,
|
|
10
|
+
);
|
|
11
|
+
expect(offenses).to.have.length(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should report graphql inside a for loop', async () => {
|
|
15
|
+
const offenses = await runLiquidCheck(
|
|
16
|
+
NestedGraphQLQuery,
|
|
17
|
+
`{% for item in items %}{% graphql result = 'products/get' %}{% endfor %}`,
|
|
18
|
+
);
|
|
19
|
+
expect(offenses).to.have.length(1);
|
|
20
|
+
expect(offenses[0].message).to.equal(
|
|
21
|
+
"N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should report graphql inside a tablerow loop', async () => {
|
|
26
|
+
const offenses = await runLiquidCheck(
|
|
27
|
+
NestedGraphQLQuery,
|
|
28
|
+
`{% tablerow item in items %}{% graphql result = 'products/get' %}{% endtablerow %}`,
|
|
29
|
+
);
|
|
30
|
+
expect(offenses).to.have.length(1);
|
|
31
|
+
expect(offenses[0].message).to.equal(
|
|
32
|
+
"N+1 pattern: {% graphql result = 'result' %} is inside a {% tablerow %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should report graphql inside nested loops', async () => {
|
|
37
|
+
const offenses = await runLiquidCheck(
|
|
38
|
+
NestedGraphQLQuery,
|
|
39
|
+
`{% for a in items %}{% for b in a.children %}{% graphql result = 'foo' %}{% endfor %}{% endfor %}`,
|
|
40
|
+
);
|
|
41
|
+
expect(offenses).to.have.length(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should not report graphql inside a loop when wrapped in cache', async () => {
|
|
45
|
+
const offenses = await runLiquidCheck(
|
|
46
|
+
NestedGraphQLQuery,
|
|
47
|
+
`{% for item in items %}{% cache 'key' %}{% graphql result = 'foo' %}{% endcache %}{% endfor %}`,
|
|
48
|
+
);
|
|
49
|
+
expect(offenses).to.have.length(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should not report background tag inside a loop', async () => {
|
|
53
|
+
const offenses = await runLiquidCheck(
|
|
54
|
+
NestedGraphQLQuery,
|
|
55
|
+
`{% for item in items %}{% background %}{% graphql result = 'foo' %}{% endbackground %}{% endfor %}`,
|
|
56
|
+
);
|
|
57
|
+
expect(offenses).to.have.length(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should report graphql inline markup inside a for loop', async () => {
|
|
61
|
+
const offenses = await runLiquidCheck(
|
|
62
|
+
NestedGraphQLQuery,
|
|
63
|
+
`{% for item in items %}{% graphql result %}query { records { results { id } } }{% endgraphql %}{% endfor %}`,
|
|
64
|
+
);
|
|
65
|
+
expect(offenses).to.have.length(1);
|
|
66
|
+
expect(offenses[0].message).to.equal(
|
|
67
|
+
"N+1 pattern: {% graphql result = 'result' %} is inside a {% for %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should report multiple graphql tags inside one loop', async () => {
|
|
72
|
+
const offenses = await runLiquidCheck(
|
|
73
|
+
NestedGraphQLQuery,
|
|
74
|
+
`{% for item in items %}{% graphql a = 'foo' %}{% graphql b = 'bar' %}{% endfor %}`,
|
|
75
|
+
);
|
|
76
|
+
expect(offenses).to.have.length(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should report function call inside loop that transitively calls graphql', async () => {
|
|
80
|
+
const offenses = await check(
|
|
81
|
+
{
|
|
82
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'my_partial' %}{% endfor %}`,
|
|
83
|
+
'app/lib/my_partial.liquid': `{% graphql result = 'products/get' %}`,
|
|
84
|
+
},
|
|
85
|
+
[NestedGraphQLQuery],
|
|
86
|
+
);
|
|
87
|
+
expect(offenses).to.have.length(1);
|
|
88
|
+
expect(offenses[0].message).to.equal(
|
|
89
|
+
"N+1 pattern: {% function 'my_partial' %} inside a {% for %} loop transitively calls a GraphQL query (my_partial). Move the query before the loop and pass data as a variable.",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should report render call inside loop that transitively calls graphql', async () => {
|
|
94
|
+
const offenses = await check(
|
|
95
|
+
{
|
|
96
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% render 'my_partial' %}{% endfor %}`,
|
|
97
|
+
'app/views/partials/my_partial.liquid': `{% graphql result = 'products/get' %}`,
|
|
98
|
+
},
|
|
99
|
+
[NestedGraphQLQuery],
|
|
100
|
+
);
|
|
101
|
+
expect(offenses).to.have.length(1);
|
|
102
|
+
expect(offenses[0].message).to.equal(
|
|
103
|
+
"N+1 pattern: {% render 'my_partial' %} inside a {% for %} loop transitively calls a GraphQL query (my_partial). Move the query before the loop and pass data as a variable.",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should report function call that transitively calls graphql through another function', async () => {
|
|
108
|
+
const offenses = await check(
|
|
109
|
+
{
|
|
110
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'outer' %}{% endfor %}`,
|
|
111
|
+
'app/lib/outer.liquid': `{% function inner_res = 'inner' %}`,
|
|
112
|
+
'app/lib/inner.liquid': `{% graphql result = 'products/get' %}`,
|
|
113
|
+
},
|
|
114
|
+
[NestedGraphQLQuery],
|
|
115
|
+
);
|
|
116
|
+
expect(offenses).to.have.length(1);
|
|
117
|
+
expect(offenses[0].message).to.equal(
|
|
118
|
+
"N+1 pattern: {% function 'outer' %} inside a {% for %} loop transitively calls a GraphQL query (outer \u2192 inner). Move the query before the loop and pass data as a variable.",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should not report function call inside loop that does not call graphql', async () => {
|
|
123
|
+
const offenses = await check(
|
|
124
|
+
{
|
|
125
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'safe_partial' %}{% endfor %}`,
|
|
126
|
+
'app/lib/safe_partial.liquid': `{{ 'hello' }}`,
|
|
127
|
+
},
|
|
128
|
+
[NestedGraphQLQuery],
|
|
129
|
+
);
|
|
130
|
+
expect(offenses).to.have.length(0);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should not report function call inside loop when partial does not exist', async () => {
|
|
134
|
+
const offenses = await check(
|
|
135
|
+
{
|
|
136
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'nonexistent' %}{% endfor %}`,
|
|
137
|
+
},
|
|
138
|
+
[NestedGraphQLQuery],
|
|
139
|
+
);
|
|
140
|
+
expect(offenses).to.have.length(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should not report function call inside loop with cache wrapping', async () => {
|
|
144
|
+
const offenses = await check(
|
|
145
|
+
{
|
|
146
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% cache 'key' %}{% function res = 'my_partial' %}{% endcache %}{% endfor %}`,
|
|
147
|
+
'app/lib/my_partial.liquid': `{% graphql result = 'products/get' %}`,
|
|
148
|
+
},
|
|
149
|
+
[NestedGraphQLQuery],
|
|
150
|
+
);
|
|
151
|
+
expect(offenses).to.have.length(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle circular function calls without infinite loop', async () => {
|
|
155
|
+
const offenses = await check(
|
|
156
|
+
{
|
|
157
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = 'partial_a' %}{% endfor %}`,
|
|
158
|
+
'app/lib/partial_a.liquid': `{% function res = 'partial_b' %}`,
|
|
159
|
+
'app/lib/partial_b.liquid': `{% function res = 'partial_a' %}`,
|
|
160
|
+
},
|
|
161
|
+
[NestedGraphQLQuery],
|
|
162
|
+
);
|
|
163
|
+
expect(offenses).to.have.length(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should skip function calls with dynamic partial names', async () => {
|
|
167
|
+
const offenses = await check(
|
|
168
|
+
{
|
|
169
|
+
'app/views/pages/index.liquid': `{% for item in items %}{% function res = partial_name %}{% endfor %}`,
|
|
170
|
+
},
|
|
171
|
+
[NestedGraphQLQuery],
|
|
172
|
+
);
|
|
173
|
+
expect(offenses).to.have.length(0);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LiquidHtmlNode,
|
|
3
|
+
NamedTags,
|
|
4
|
+
NodeTypes,
|
|
5
|
+
toLiquidHtmlAST,
|
|
6
|
+
} from '@platformos/liquid-html-parser';
|
|
7
|
+
import { DocumentsLocator } from '@platformos/platformos-common';
|
|
8
|
+
import { URI } from 'vscode-uri';
|
|
9
|
+
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
10
|
+
import { isLoopLiquidTag } from '../utils';
|
|
11
|
+
|
|
12
|
+
const SKIP_IF_ANCESTOR_TAGS = [NamedTags.cache];
|
|
13
|
+
|
|
14
|
+
type GraphQLFound = {
|
|
15
|
+
type: 'graphql';
|
|
16
|
+
partialChain: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type FunctionOrRenderFound = {
|
|
20
|
+
type: 'function' | 'render';
|
|
21
|
+
partialName: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type FoundNode = GraphQLFound | FunctionOrRenderFound;
|
|
25
|
+
|
|
26
|
+
function findNodesInAST(ast: LiquidHtmlNode[]): FoundNode[] {
|
|
27
|
+
const results: FoundNode[] = [];
|
|
28
|
+
const stack: LiquidHtmlNode[] = [...ast];
|
|
29
|
+
|
|
30
|
+
while (stack.length > 0) {
|
|
31
|
+
const node = stack.pop()!;
|
|
32
|
+
|
|
33
|
+
if (node.type === NodeTypes.LiquidTag) {
|
|
34
|
+
if (node.name === NamedTags.graphql) {
|
|
35
|
+
results.push({ type: 'graphql', partialChain: [] });
|
|
36
|
+
} else if (
|
|
37
|
+
(node.name === NamedTags.function || node.name === NamedTags.render) &&
|
|
38
|
+
typeof node.markup !== 'string' &&
|
|
39
|
+
'partial' in node.markup &&
|
|
40
|
+
node.markup.partial.type !== NodeTypes.VariableLookup
|
|
41
|
+
) {
|
|
42
|
+
results.push({ type: node.name, partialName: node.markup.partial.value });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if ('children' in node && Array.isArray(node.children)) {
|
|
46
|
+
stack.push(...node.children);
|
|
47
|
+
}
|
|
48
|
+
} else if ('children' in node && Array.isArray((node as any).children)) {
|
|
49
|
+
stack.push(...(node as any).children);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function containsGraphQLTransitively(
|
|
57
|
+
locator: DocumentsLocator,
|
|
58
|
+
fs: { readFile(uri: string): Promise<string> },
|
|
59
|
+
rootUri: URI,
|
|
60
|
+
partialName: string,
|
|
61
|
+
tagType: 'function' | 'render',
|
|
62
|
+
visited: Set<string>,
|
|
63
|
+
): Promise<string[] | null> {
|
|
64
|
+
if (visited.has(partialName)) return null;
|
|
65
|
+
visited.add(partialName);
|
|
66
|
+
|
|
67
|
+
const location = await locator.locate(rootUri, tagType, partialName);
|
|
68
|
+
if (!location) return null;
|
|
69
|
+
|
|
70
|
+
let source: string;
|
|
71
|
+
try {
|
|
72
|
+
source = await fs.readFile(location);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let ast;
|
|
78
|
+
try {
|
|
79
|
+
ast = toLiquidHtmlAST(source);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const nodes = findNodesInAST(ast.children);
|
|
85
|
+
|
|
86
|
+
for (const found of nodes) {
|
|
87
|
+
if (found.type === 'graphql') {
|
|
88
|
+
return [partialName];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const found of nodes) {
|
|
93
|
+
if (found.type === 'function' || found.type === 'render') {
|
|
94
|
+
const chain = await containsGraphQLTransitively(
|
|
95
|
+
locator,
|
|
96
|
+
fs,
|
|
97
|
+
rootUri,
|
|
98
|
+
found.partialName,
|
|
99
|
+
found.type,
|
|
100
|
+
visited,
|
|
101
|
+
);
|
|
102
|
+
if (chain) {
|
|
103
|
+
return [partialName, ...chain];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const NestedGraphQLQuery: LiquidCheckDefinition = {
|
|
112
|
+
meta: {
|
|
113
|
+
code: 'NestedGraphQLQuery',
|
|
114
|
+
name: 'Prevent N+1 GraphQL queries in loops',
|
|
115
|
+
docs: {
|
|
116
|
+
description:
|
|
117
|
+
'This check detects {% graphql %} tags placed inside loop tags ({% for %}, {% tablerow %}), which causes one database request per loop iteration (N+1 pattern). It also follows {% function %} and {% render %} calls transitively to detect indirect GraphQL queries.',
|
|
118
|
+
recommended: true,
|
|
119
|
+
url: 'https://documentation.platformos.com/developer-guide/platformos-check/checks/nested-graphql-query',
|
|
120
|
+
},
|
|
121
|
+
type: SourceCodeType.LiquidHtml,
|
|
122
|
+
severity: Severity.WARNING,
|
|
123
|
+
schema: {},
|
|
124
|
+
targets: [],
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
create(context) {
|
|
128
|
+
const locator = new DocumentsLocator(context.fs);
|
|
129
|
+
const rootUri = URI.parse(context.config.rootUri);
|
|
130
|
+
|
|
131
|
+
function isInsideLoopWithoutCacheOrBackground(ancestors: LiquidHtmlNode[]) {
|
|
132
|
+
const ancestorTags = ancestors.filter((a) => a.type === NodeTypes.LiquidTag);
|
|
133
|
+
const loopAncestor = ancestorTags.find(isLoopLiquidTag);
|
|
134
|
+
if (!loopAncestor) return null;
|
|
135
|
+
|
|
136
|
+
const inBackground = ancestorTags.some((a) => a.name === NamedTags.background);
|
|
137
|
+
if (inBackground) return null;
|
|
138
|
+
|
|
139
|
+
const shouldSkip = ancestorTags.some((a) =>
|
|
140
|
+
SKIP_IF_ANCESTOR_TAGS.map((a) => a.toString()).includes(a.name),
|
|
141
|
+
);
|
|
142
|
+
if (shouldSkip) return null;
|
|
143
|
+
|
|
144
|
+
return loopAncestor;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
async LiquidTag(node, ancestors) {
|
|
149
|
+
if (node.name === NamedTags.graphql) {
|
|
150
|
+
const loopAncestor = isInsideLoopWithoutCacheOrBackground(ancestors);
|
|
151
|
+
if (!loopAncestor) return;
|
|
152
|
+
|
|
153
|
+
let resultName = '';
|
|
154
|
+
if (
|
|
155
|
+
typeof node.markup !== 'string' &&
|
|
156
|
+
(node.markup.type === NodeTypes.GraphQLMarkup ||
|
|
157
|
+
node.markup.type === NodeTypes.GraphQLInlineMarkup)
|
|
158
|
+
) {
|
|
159
|
+
resultName = node.markup.name ? ` result = '${node.markup.name}'` : '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const graphqlStr = resultName ? `{% graphql${resultName} %}` : '{% graphql %}';
|
|
163
|
+
context.report({
|
|
164
|
+
message: `N+1 pattern: ${graphqlStr} is inside a {% ${loopAncestor.name} %} loop. This executes at least one database request per iteration. Move the query before the loop and pass data as a variable.`,
|
|
165
|
+
startIndex: node.position.start,
|
|
166
|
+
endIndex: node.position.end,
|
|
167
|
+
});
|
|
168
|
+
} else if (node.name === NamedTags.function || node.name === NamedTags.render) {
|
|
169
|
+
const loopAncestor = isInsideLoopWithoutCacheOrBackground(ancestors);
|
|
170
|
+
if (!loopAncestor) return;
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
typeof node.markup === 'string' ||
|
|
174
|
+
!('partial' in node.markup) ||
|
|
175
|
+
node.markup.partial.type === NodeTypes.VariableLookup
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const partialName = node.markup.partial.value;
|
|
181
|
+
const visited = new Set<string>();
|
|
182
|
+
const chain = await containsGraphQLTransitively(
|
|
183
|
+
locator,
|
|
184
|
+
context.fs,
|
|
185
|
+
rootUri,
|
|
186
|
+
partialName,
|
|
187
|
+
node.name,
|
|
188
|
+
visited,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (chain) {
|
|
192
|
+
const chainStr = chain.join(' → ');
|
|
193
|
+
context.report({
|
|
194
|
+
message: `N+1 pattern: {% ${node.name} '${partialName}' %} inside a {% ${loopAncestor.name} %} loop transitively calls a GraphQL query (${chainStr}). Move the query before the loop and pass data as a variable.`,
|
|
195
|
+
startIndex: node.position.start,
|
|
196
|
+
endIndex: node.position.end,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
};
|
|
@@ -20,7 +20,9 @@ describe('Module: ParserBlockingScript', () => {
|
|
|
20
20
|
expect(offenses).to.have.length(1);
|
|
21
21
|
const { check, message, start, end } = offenses[0];
|
|
22
22
|
expect(check).to.equal(ParserBlockingScript.meta.code);
|
|
23
|
-
expect(message).to.
|
|
23
|
+
expect(message).to.equal(
|
|
24
|
+
'Avoid parser blocking scripts by adding `defer` or `async` on this tag',
|
|
25
|
+
);
|
|
24
26
|
expect(start.index).to.equal(startIndex);
|
|
25
27
|
expect(end.index).to.equal(endIndex);
|
|
26
28
|
});
|
|
@@ -45,8 +47,10 @@ describe('Module: ParserBlockingScript', () => {
|
|
|
45
47
|
});
|
|
46
48
|
|
|
47
49
|
const suggestions = applySuggestions(file, offense);
|
|
48
|
-
expect(suggestions).to.
|
|
49
|
-
|
|
50
|
+
expect(suggestions).to.deep.equal([
|
|
51
|
+
'<script src="a.js" defer></script>',
|
|
52
|
+
'<script src="a.js" async></script>',
|
|
53
|
+
]);
|
|
50
54
|
});
|
|
51
55
|
});
|
|
52
56
|
|
|
@@ -38,7 +38,84 @@ describe('Module: TranslationKeyExists', () => {
|
|
|
38
38
|
);
|
|
39
39
|
|
|
40
40
|
expect(offenses).to.have.length(1);
|
|
41
|
-
expect(offenses[0].message).to.
|
|
42
|
-
|
|
41
|
+
expect(offenses[0].message).to.equal(
|
|
42
|
+
"'missing.key' does not have a matching translation entry",
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should suggest nearest key when the key is a typo', async () => {
|
|
47
|
+
const offenses = await check(
|
|
48
|
+
{
|
|
49
|
+
'app/translations/en.yml': 'en:\n general:\n title: Hello',
|
|
50
|
+
'code.liquid': `{{"general.titel" | t}}`,
|
|
51
|
+
},
|
|
52
|
+
[TranslationKeyExists],
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(offenses).to.have.length(1);
|
|
56
|
+
expect(offenses[0].suggest).to.have.length(1);
|
|
57
|
+
expect(offenses[0].suggest![0].message).to.equal("Did you mean 'general.title'?");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should not add suggestions when there is no close key', async () => {
|
|
61
|
+
const offenses = await check(
|
|
62
|
+
{
|
|
63
|
+
'app/translations/en.yml': 'en:\n general:\n title: Hello',
|
|
64
|
+
'code.liquid': `{{"completely.different.xyz" | t}}`,
|
|
65
|
+
},
|
|
66
|
+
[TranslationKeyExists],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(offenses).to.have.length(1);
|
|
70
|
+
expect(offenses[0].suggest ?? []).to.have.length(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not report a module translation key that exists', async () => {
|
|
74
|
+
const offenses = await check(
|
|
75
|
+
{
|
|
76
|
+
'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello',
|
|
77
|
+
'code.liquid': '{{"modules/user/greeting" | t}}',
|
|
78
|
+
},
|
|
79
|
+
[TranslationKeyExists],
|
|
80
|
+
);
|
|
81
|
+
expect(offenses).to.have.length(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should report a module translation key that does not exist', async () => {
|
|
85
|
+
const offenses = await check(
|
|
86
|
+
{
|
|
87
|
+
'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello',
|
|
88
|
+
'code.liquid': '{{"modules/user/missing" | t}}',
|
|
89
|
+
},
|
|
90
|
+
[TranslationKeyExists],
|
|
91
|
+
);
|
|
92
|
+
expect(offenses).to.have.length(1);
|
|
93
|
+
expect(offenses[0].message).to.equal(
|
|
94
|
+
"'modules/user/missing' does not have a matching translation entry",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should suggest nearest module key for typos', async () => {
|
|
99
|
+
const offenses = await check(
|
|
100
|
+
{
|
|
101
|
+
'app/modules/user/public/translations/en.yml': 'en:\n greeting: Hello',
|
|
102
|
+
'code.liquid': '{{"modules/user/greating" | t}}',
|
|
103
|
+
},
|
|
104
|
+
[TranslationKeyExists],
|
|
105
|
+
);
|
|
106
|
+
expect(offenses).to.have.length(1);
|
|
107
|
+
expect(offenses[0].suggest).to.have.length(1);
|
|
108
|
+
expect(offenses[0].suggest![0].message).to.equal("Did you mean 'modules/user/greeting'?");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should find keys in legacy modules/ path', async () => {
|
|
112
|
+
const offenses = await check(
|
|
113
|
+
{
|
|
114
|
+
'modules/core/public/translations/en.yml': 'en:\n label: Label',
|
|
115
|
+
'code.liquid': '{{"modules/core/label" | t}}',
|
|
116
|
+
},
|
|
117
|
+
[TranslationKeyExists],
|
|
118
|
+
);
|
|
119
|
+
expect(offenses).to.have.length(0);
|
|
43
120
|
});
|
|
44
121
|
});
|
|
@@ -1,22 +1,6 @@
|
|
|
1
|
-
import { TranslationProvider } from '@platformos/platformos-common';
|
|
2
1
|
import { LiquidCheckDefinition, Severity, SourceCodeType } from '../../types';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function keyExists(key: string, pointer: any) {
|
|
6
|
-
for (const token of key.split('.')) {
|
|
7
|
-
if (typeof pointer !== 'object') {
|
|
8
|
-
return false;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
if (!pointer.hasOwnProperty(token)) {
|
|
12
|
-
return false;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
pointer = pointer[token];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return true;
|
|
19
|
-
}
|
|
2
|
+
import { findNearestKeys } from '../../utils/levenshtein';
|
|
3
|
+
import { loadAllDefinedKeys } from '../translation-utils';
|
|
20
4
|
|
|
21
5
|
export const TranslationKeyExists: LiquidCheckDefinition = {
|
|
22
6
|
meta: {
|
|
@@ -35,7 +19,6 @@ export const TranslationKeyExists: LiquidCheckDefinition = {
|
|
|
35
19
|
|
|
36
20
|
create(context) {
|
|
37
21
|
const nodes: { translationKey: string; startIndex: number; endIndex: number }[] = [];
|
|
38
|
-
const translationProvider = new TranslationProvider(context.fs);
|
|
39
22
|
|
|
40
23
|
return {
|
|
41
24
|
async LiquidVariable(node) {
|
|
@@ -55,21 +38,29 @@ export const TranslationKeyExists: LiquidCheckDefinition = {
|
|
|
55
38
|
},
|
|
56
39
|
|
|
57
40
|
async onCodePathEnd() {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
41
|
+
if (nodes.length === 0) return;
|
|
42
|
+
|
|
43
|
+
// Load all defined keys (app + modules) once per file
|
|
44
|
+
const allDefinedKeys = await loadAllDefinedKeys(context);
|
|
45
|
+
const definedKeySet = new Set(allDefinedKeys);
|
|
63
46
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
47
|
+
for (const { translationKey, startIndex, endIndex } of nodes) {
|
|
48
|
+
if (definedKeySet.has(translationKey)) continue;
|
|
67
49
|
|
|
50
|
+
const nearest = findNearestKeys(translationKey, allDefinedKeys);
|
|
68
51
|
const message = `'${translationKey}' does not have a matching translation entry`;
|
|
52
|
+
|
|
69
53
|
context.report({
|
|
70
54
|
message,
|
|
71
55
|
startIndex,
|
|
72
56
|
endIndex,
|
|
57
|
+
suggest:
|
|
58
|
+
nearest.length > 0
|
|
59
|
+
? nearest.map((key) => ({
|
|
60
|
+
message: `Did you mean '${key}'?`,
|
|
61
|
+
fix: (fixer: any) => fixer.replace(startIndex, endIndex, `'${key}'`),
|
|
62
|
+
}))
|
|
63
|
+
: undefined,
|
|
73
64
|
});
|
|
74
65
|
}
|
|
75
66
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { FileType, TranslationProvider } from '@platformos/platformos-common';
|
|
2
|
+
import { flattenTranslationKeys } from '../utils/levenshtein';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Discovers all module names by listing app/modules/ and modules/ directories.
|
|
6
|
+
* Returns a deduplicated set of module names.
|
|
7
|
+
*/
|
|
8
|
+
export async function discoverModules(
|
|
9
|
+
fs: { readDirectory(uri: string): Promise<[string, FileType][]> },
|
|
10
|
+
...moduleDirUris: string[]
|
|
11
|
+
): Promise<Set<string>> {
|
|
12
|
+
const modules = new Set<string>();
|
|
13
|
+
for (const dirUri of moduleDirUris) {
|
|
14
|
+
try {
|
|
15
|
+
const entries = await fs.readDirectory(dirUri);
|
|
16
|
+
for (const [entryUri, entryType] of entries) {
|
|
17
|
+
if (entryType === FileType.Directory) {
|
|
18
|
+
modules.add(entryUri.split('/').pop()!);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(`[translation-utils] Failed to read module directory ${dirUri}:`, error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return modules;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TranslationContext {
|
|
29
|
+
fs: { readDirectory(uri: string): Promise<[string, FileType][]> };
|
|
30
|
+
toUri(relativePath: string): string;
|
|
31
|
+
getTranslationsForBase(uri: string, locale: string): Promise<Record<string, any>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Loads all defined translation keys (app-level + module-level) and returns
|
|
36
|
+
* them as a flat string array. Module keys are prefixed with `modules/{name}/`.
|
|
37
|
+
*/
|
|
38
|
+
export async function loadAllDefinedKeys(context: TranslationContext): Promise<string[]> {
|
|
39
|
+
const definedKeys: string[] = [];
|
|
40
|
+
|
|
41
|
+
// App-level translations
|
|
42
|
+
for (const base of TranslationProvider.getSearchPaths()) {
|
|
43
|
+
const translations = await context.getTranslationsForBase(context.toUri(base), 'en');
|
|
44
|
+
definedKeys.push(...flattenTranslationKeys(translations));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Module translations
|
|
48
|
+
const modules = await discoverModules(
|
|
49
|
+
context.fs,
|
|
50
|
+
context.toUri('app/modules'),
|
|
51
|
+
context.toUri('modules'),
|
|
52
|
+
);
|
|
53
|
+
for (const moduleName of modules) {
|
|
54
|
+
for (const base of TranslationProvider.getSearchPaths(moduleName)) {
|
|
55
|
+
const translations = await context.getTranslationsForBase(context.toUri(base), 'en');
|
|
56
|
+
for (const key of flattenTranslationKeys(translations)) {
|
|
57
|
+
definedKeys.push(`modules/${moduleName}/${key}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return definedKeys;
|
|
63
|
+
}
|