shopify-upgrade-guard 0.2.4 → 0.2.6
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/LICENSE +1 -1
- package/README.md +7 -3
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/action.yml +1 -1
- package/dist/index.js +1 -1
- package/dist/licenses.txt +23 -0
- package/package.json +13 -10
- package/src/graphql.js +89 -0
- package/src/rules.js +33 -1
- package/src/version.js +1 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
graphql
|
|
2
|
+
MIT
|
|
3
|
+
MIT License
|
|
4
|
+
|
|
5
|
+
Copyright (c) GraphQL Contributors
|
|
6
|
+
|
|
7
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
8
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
9
|
+
in the Software without restriction, including without limitation the rights
|
|
10
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
11
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
12
|
+
furnished to do so, subject to the following conditions:
|
|
13
|
+
|
|
14
|
+
The above copyright notice and this permission notice shall be included in all
|
|
15
|
+
copies or substantial portions of the Software.
|
|
16
|
+
|
|
17
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
18
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
19
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
20
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
21
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
22
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
23
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shopify-upgrade-guard",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.6",
|
|
4
|
+
"description": "Check documented Shopify API and platform upgrade compatibility risks with an offline CLI and GitHub Action.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"shopify-upgrade-guard": "src/cli.js",
|
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
"action.yml",
|
|
14
14
|
"dist",
|
|
15
15
|
"README.md",
|
|
16
|
-
"LICENSE"
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"THIRD_PARTY_NOTICES.md"
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
|
19
20
|
"test": "node --test",
|
|
20
21
|
"coverage": "c8 --include 'src/**' --check-coverage --lines 90 --statements 90 --functions 85 --branches 85 --reporter=text --reporter=text-summary --reporter=json-summary node --test",
|
|
21
22
|
"lint": "node scripts/check-source.mjs",
|
|
22
|
-
"build:action": "ncc build src/action.js -o dist --minify",
|
|
23
|
-
"check:dist": "npm run build:action && git diff --exit-code -- dist/index.js",
|
|
23
|
+
"build:action": "ncc build src/action.js -o dist --minify --license licenses.txt",
|
|
24
|
+
"check:dist": "npm run build:action && git diff --exit-code -- dist/index.js dist/licenses.txt",
|
|
24
25
|
"pack:check": "npm pack --dry-run",
|
|
25
26
|
"package:smoke": "node scripts/package-smoke.mjs",
|
|
26
27
|
"action:check": "node scripts/check-action.mjs",
|
|
@@ -39,18 +40,20 @@
|
|
|
39
40
|
},
|
|
40
41
|
"keywords": [
|
|
41
42
|
"shopify",
|
|
43
|
+
"shopify-app",
|
|
42
44
|
"shopify-api",
|
|
43
|
-
"
|
|
44
|
-
"developer-tools",
|
|
45
|
+
"upgrade",
|
|
45
46
|
"migration",
|
|
46
|
-
"
|
|
47
|
+
"compatibility",
|
|
48
|
+
"github-actions",
|
|
49
|
+
"ci"
|
|
47
50
|
],
|
|
48
51
|
"publishConfig": {
|
|
49
52
|
"provenance": true
|
|
50
53
|
},
|
|
51
54
|
"devDependencies": {
|
|
52
|
-
"@vercel/ncc": "
|
|
53
|
-
"c8": "
|
|
55
|
+
"@vercel/ncc": "0.45.0",
|
|
56
|
+
"c8": "12.0.0"
|
|
54
57
|
},
|
|
55
58
|
"dependencies": {
|
|
56
59
|
"graphql": "16.14.2"
|
package/src/graphql.js
CHANGED
|
@@ -50,3 +50,92 @@ export function fieldMatches(file, fields, types, guidance) {
|
|
|
50
50
|
} catch { /* Malformed/dynamic documents cannot establish schema usage. */ }
|
|
51
51
|
return matches;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
export function productVariantBarcodeMatches(file, guidance) {
|
|
55
|
+
const documents = literalDocuments(file);
|
|
56
|
+
const matches = [];
|
|
57
|
+
for (const doc of documents) try {
|
|
58
|
+
const ast = parse(doc.text, { maxTokens: 50000 });
|
|
59
|
+
const fragments = new Map(ast.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(d => [d.name.value, d]));
|
|
60
|
+
const add = (node) => {
|
|
61
|
+
const index = doc.offset + node.name.loc.start, before = file.text.slice(0, index);
|
|
62
|
+
matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: node.name.value, guidance });
|
|
63
|
+
};
|
|
64
|
+
const walkVariant = (selections, stack = []) => {
|
|
65
|
+
for (const node of selections ?? []) {
|
|
66
|
+
if (node.kind === Kind.FIELD && node.name.value === 'barcode') add(node);
|
|
67
|
+
else if (node.kind === Kind.INLINE_FRAGMENT) walkVariant(node.selectionSet.selections, stack);
|
|
68
|
+
else if (node.kind === Kind.FRAGMENT_SPREAD && fragments.has(node.name.value) && !stack.includes(node.name.value)) {
|
|
69
|
+
const fragment = fragments.get(node.name.value);
|
|
70
|
+
if (fragment.typeCondition.name.value === 'ProductVariant') walkVariant(fragment.selectionSet.selections, [...stack, node.name.value]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const walkRoot = (selections, stack = []) => {
|
|
75
|
+
for (const node of selections ?? []) {
|
|
76
|
+
if (node.kind === Kind.FIELD && node.name.value === 'productVariant') walkVariant(node.selectionSet?.selections);
|
|
77
|
+
else if (node.kind === Kind.FIELD && node.name.value === 'productVariants') {
|
|
78
|
+
for (const connection of node.selectionSet?.selections ?? []) {
|
|
79
|
+
if (connection.kind !== Kind.FIELD || !['nodes', 'edges'].includes(connection.name.value)) continue;
|
|
80
|
+
const variants = connection.name.value === 'nodes' ? connection.selectionSet?.selections : connection.selectionSet?.selections?.filter(edge => edge.kind === Kind.FIELD && edge.name.value === 'node').flatMap(edge => edge.selectionSet?.selections ?? []);
|
|
81
|
+
walkVariant(variants);
|
|
82
|
+
}
|
|
83
|
+
} else if (node.kind === Kind.INLINE_FRAGMENT) walkRoot(node.selectionSet.selections, stack);
|
|
84
|
+
else if (node.kind === Kind.FRAGMENT_SPREAD && fragments.has(node.name.value) && !stack.includes(node.name.value)) {
|
|
85
|
+
const fragment = fragments.get(node.name.value);
|
|
86
|
+
if (fragment.typeCondition.name.value === 'QueryRoot') walkRoot(fragment.selectionSet.selections, [...stack, node.name.value]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const definition of ast.definitions) {
|
|
91
|
+
if (definition.kind === Kind.OPERATION_DEFINITION && definition.operation === 'query') walkRoot(definition.selectionSet.selections);
|
|
92
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION && definition.typeCondition.name.value === 'ProductVariant') walkVariant(definition.selectionSet.selections, [definition.name.value]);
|
|
93
|
+
}
|
|
94
|
+
} catch { /* Malformed or dynamic documents cannot establish a ProductVariant field use. */ }
|
|
95
|
+
return matches;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function segmentQueryMatches(file, guidance) {
|
|
99
|
+
const pattern = /(?:\b[a-z][\w.]*(?:\([^)]*\))?\s*=\s*(?:true|false)\b|\b(?:12_months_ago|90_days_ago|30_days_ago|7_days_ago)\b)/gi;
|
|
100
|
+
const documents = literalDocuments(file);
|
|
101
|
+
const matches = [];
|
|
102
|
+
for (const doc of documents) try {
|
|
103
|
+
const ast = parse(doc.text, { maxTokens: 50000 });
|
|
104
|
+
visit(ast, { Field(node) {
|
|
105
|
+
if (!/^segments?(?:Create|Update|Upsert|Delete)?$/i.test(node.name.value)) return;
|
|
106
|
+
const inspect = (value) => {
|
|
107
|
+
if (!value) return;
|
|
108
|
+
if (value.kind === Kind.OBJECT) for (const field of value.fields) {
|
|
109
|
+
if (field.name.value === 'query' && field.value.kind === Kind.STRING) {
|
|
110
|
+
const legacy = [...field.value.value.matchAll(pattern)];
|
|
111
|
+
for (const match of legacy) {
|
|
112
|
+
const index = doc.offset + field.value.loc.start + match.index;
|
|
113
|
+
const before = file.text.slice(0, index);
|
|
114
|
+
matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: match[0], guidance });
|
|
115
|
+
}
|
|
116
|
+
} else inspect(field.value);
|
|
117
|
+
}
|
|
118
|
+
else if (value.kind === Kind.LIST) value.values.forEach(inspect);
|
|
119
|
+
};
|
|
120
|
+
for (const arg of node.arguments ?? []) {
|
|
121
|
+
if (arg.name.value === 'query' && arg.value.kind === Kind.STRING) {
|
|
122
|
+
const legacy = [...arg.value.value.matchAll(pattern)];
|
|
123
|
+
for (const match of legacy) {
|
|
124
|
+
const index = doc.offset + arg.value.loc.start + match.index, before = file.text.slice(0, index);
|
|
125
|
+
matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: match[0], guidance });
|
|
126
|
+
}
|
|
127
|
+
} else inspect(arg.value);
|
|
128
|
+
}
|
|
129
|
+
} });
|
|
130
|
+
} catch { /* Malformed or dynamic documents are not evidence. */ }
|
|
131
|
+
return matches;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function literalDocuments(file) {
|
|
135
|
+
if (/\.(graphql|gql)$/.test(file.relativePath)) return [{ text: file.text, offset: 0 }];
|
|
136
|
+
if (!/\.(?:[jt]sx?)$/.test(file.relativePath)) return [];
|
|
137
|
+
return [...file.text.matchAll(/([`"'])([\s\S]*?)\1/g)].flatMap(match => {
|
|
138
|
+
const text = match[2];
|
|
139
|
+
return !text.includes('${') && /(?:\bquery\b|\bmutation\b|#graphql|^\s*\{)/.test(text) ? [{ text, offset: match.index + 1 }] : [];
|
|
140
|
+
});
|
|
141
|
+
}
|
package/src/rules.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isAtOrAfter, statusFor, latestStable, becomesUnsupportedBefore } from './versions.js';
|
|
2
2
|
|
|
3
|
-
import { rootFieldMatches, fieldMatches } from './graphql.js';
|
|
3
|
+
import { rootFieldMatches, fieldMatches, productVariantBarcodeMatches, segmentQueryMatches } from './graphql.js';
|
|
4
4
|
|
|
5
5
|
const checkoutDeprecation = '2026-07';
|
|
6
6
|
const removalVersion = '2026-10';
|
|
@@ -56,6 +56,34 @@ export const rules = [
|
|
|
56
56
|
detect(file) { return rootFieldMatches(file, 'automaticDiscounts', 'Use discountNodes(first: ..., query: "method:automatic") and move discount fragments under DiscountNode.discount.'); },
|
|
57
57
|
evaluate(match, context) { return removalAware(match, this, context); }
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
id: 'UG-ADMIN-003', surface: 'admin_graphql_api', severity: 'error', title: 'Integer metafield collection condition is removed', removedIn: '2027-01', confidence: 'high',
|
|
61
|
+
documentationUrl: 'https://shopify.dev/changelog/posts/metafieldinteger-collection-condition-removed-in-api-version-2027-01', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql/2027-01/input-objects/CollectionSourceInclusionConditionInput',
|
|
62
|
+
description: 'Admin GraphQL collection condition fields and types named metafieldInteger are removed in API version 2027-01. Migrate to metafieldInt and send integer condition values as strings.',
|
|
63
|
+
detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return fieldMatches(file, ['metafieldInteger'], ['CollectionSourceInclusionConditionMetafieldInteger', 'CollectionSourceInclusionConditionMetafieldIntegerRelation'], 'Replace metafieldInteger with metafieldInt and serialize the integer condition value as a string.'); },
|
|
64
|
+
evaluate(match, context) { return removalAware(match, this, context); }
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'UG-ADMIN-004', surface: 'admin_graphql_api', severity: 'warning', title: 'ProductVariant.barcode is deprecated', deprecatedIn: '2026-10', confidence: 'high',
|
|
68
|
+
documentationUrl: 'https://shopify.dev/changelog/posts/product-variant-barcode-is-being-replaced-by-barcodes', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql/2026-10/objects/ProductVariant',
|
|
69
|
+
description: 'ProductVariant.barcode is deprecated from API version 2026-10 and returns only the first entry after a variant has multiple barcodes. Migrate reads to the barcodes connection when every identifier matters; Shopify has not announced a removal date.',
|
|
70
|
+
detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return productVariantBarcodeMatches(file, 'Review this ProductVariant.barcode read. Use the barcodes connection if the integration must handle every barcode.'); },
|
|
71
|
+
evaluate(match, context) { return deprecationAware(match, this, context); }
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'UG-SEGMENT-001', surface: 'admin_graphql_api', severity: 'warning', title: 'Legacy segment query syntax is deprecated', deprecatedIn: '2026-10', confidence: 'high',
|
|
75
|
+
documentationUrl: 'https://shopify.dev/changelog/posts/updated-function-syntax-on-the-segment-query-language', migrationUrl: 'https://shopify.dev/docs/apps/build/shopifyql/segment-query-language-reference',
|
|
76
|
+
description: 'In API version 2026-10, segment functions use MATCHES or NOT MATCHES instead of = true or = false; named date tokens 12_months_ago, 90_days_ago, 30_days_ago, and 7_days_ago are deprecated. Migrate to the documented operators and date offsets.',
|
|
77
|
+
detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return segmentQueryMatches(file, 'Update this literal Shopify segment query to MATCHES/NOT MATCHES and replace deprecated named dates with supported date offsets.'); },
|
|
78
|
+
evaluate(match, context) { return deprecationAware(match, this, context); }
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: 'UG-ADMIN-005', surface: 'admin_graphql_api', severity: 'error', title: 'ITEM_NOT_STOCKED_AT_LOCATION handling is obsolete', removedIn: '2026-10', confidence: 'high',
|
|
82
|
+
documentationUrl: 'https://shopify.dev/changelog/posts/removal-of-itemnotstockedatlocation-error', migrationUrl: 'https://shopify.dev/changelog/posts/removal-of-itemnotstockedatlocation-error',
|
|
83
|
+
description: 'Shopify removed ITEM_NOT_STOCKED_AT_LOCATION from the relevant inventory mutation error codes in API version 2026-10. Remove logic that depends on this specific error; inventory quantities can now be adjusted at any location.',
|
|
84
|
+
detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return matches(maskComments(file.text), /(?:(?:===|!==|==|!=)\s*(['"])ITEM_NOT_STOCKED_AT_LOCATION\1|\bcase\s+(['"]?)ITEM_NOT_STOCKED_AT_LOCATION\2|\b[A-Z][A-Za-z0-9_]*\.ITEM_NOT_STOCKED_AT_LOCATION\b)/g, 'Remove handling for ITEM_NOT_STOCKED_AT_LOCATION; Shopify no longer emits this error code from API version 2026-10.'); },
|
|
85
|
+
evaluate(match, context) { return removalAware(match, this, context); }
|
|
86
|
+
},
|
|
59
87
|
{
|
|
60
88
|
id: 'UG-REST-001', surface: 'admin_rest_api', severity: 'warning', title: 'REST Admin API usage is legacy', confidence: 'high',
|
|
61
89
|
documentationUrl: 'https://shopify.dev/docs/api/admin-rest', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql',
|
|
@@ -89,6 +117,10 @@ function removalAware(match, rule, context) {
|
|
|
89
117
|
if (!isAtOrAfter(context.targetVersion, rule.removedIn ?? rule.deprecatedIn)) return null;
|
|
90
118
|
return { ...match, classification: 'target', reason: `This usage is affected by target ${context.targetVersion}. ${rule.description}` };
|
|
91
119
|
}
|
|
120
|
+
function deprecationAware(match, rule, context) {
|
|
121
|
+
if (!isAtOrAfter(context.targetVersion, rule.deprecatedIn)) return null;
|
|
122
|
+
return { ...match, classification: 'target', reason: `This usage is deprecated for target ${context.targetVersion}. ${rule.description}` };
|
|
123
|
+
}
|
|
92
124
|
function matches(text, pattern, guidance) { return [...text.matchAll(pattern)].map((match) => { const before = text.slice(0, match.index); return { file: null, line: before.split('\n').length, column: match.index - before.lastIndexOf('\n'), snippet: match[0], guidance }; }); }
|
|
93
125
|
export function attachFile(matchesForFile, file) { return matchesForFile.map((match) => ({ ...match, file: file.relativePath })); }
|
|
94
126
|
function maskCommentsAndStrings(text) { return text.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (value) => value.replace(/[^\n]/g, ' ')); }
|
package/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated from package.json.
|
|
2
|
-
export const TOOL_VERSION = "0.2.
|
|
2
|
+
export const TOOL_VERSION = "0.2.6";
|