shopify-upgrade-guard 0.2.2 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shopify-upgrade-guard",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Offline static analysis for Shopify platform and API upgrade risks.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,11 +17,14 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "test": "node --test",
20
- "coverage": "c8 --exclude 'dist/**' --exclude 'node_modules/**' --check-coverage --lines 90 --statements 90 --functions 85 --branches 85 --reporter=text --reporter=text-summary node --test",
21
- "lint": "node --check src/cli.js && node --check src/scan.js && node --check src/action.js",
20
+ "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
+ "lint": "node scripts/check-source.mjs",
22
22
  "build:action": "ncc build src/action.js -o dist --minify",
23
23
  "check:dist": "npm run build:action && git diff --exit-code -- dist/index.js",
24
- "pack:check": "npm pack --dry-run"
24
+ "pack:check": "npm pack --dry-run",
25
+ "package:smoke": "node scripts/package-smoke.mjs",
26
+ "action:check": "node scripts/check-action.mjs",
27
+ "prebuild:action": "node scripts/write-version.mjs"
25
28
  },
26
29
  "engines": {
27
30
  "node": ">=20"
@@ -29,10 +32,10 @@
29
32
  "license": "MIT",
30
33
  "repository": {
31
34
  "type": "git",
32
- "url": "https://github.com/efegokdemir/shopify-upgrade-guard.git"
35
+ "url": "https://github.com/RexCode-Digital/shopify-upgrade-guard.git"
33
36
  },
34
37
  "bugs": {
35
- "url": "https://github.com/efegokdemir/shopify-upgrade-guard/issues"
38
+ "url": "https://github.com/RexCode-Digital/shopify-upgrade-guard/issues"
36
39
  },
37
40
  "keywords": [
38
41
  "shopify",
@@ -48,5 +51,9 @@
48
51
  "devDependencies": {
49
52
  "@vercel/ncc": "^0.38.3",
50
53
  "c8": "^10.1.3"
51
- }
54
+ },
55
+ "dependencies": {
56
+ "graphql": "16.14.2"
57
+ },
58
+ "homepage": "https://github.com/RexCode-Digital/shopify-upgrade-guard#readme"
52
59
  }
package/src/action.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import * as fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import process from 'node:process';
3
4
  import { scan } from './scan.js';
4
5
  import { formatHuman, shouldFail } from './output.js';
5
6
  const input = (name) => process.env[`INPUT_${name.toUpperCase().replaceAll('-', '_')}`] || '';
6
- const setOutput = (name, value) => { const delimiter = `ghadelimiter_${Date.now()}`; fs.appendFileSync(process.env.GITHUB_OUTPUT || '/dev/null', `${name}<<${delimiter}\n${value}\n${delimiter}\n`); };
7
+ const setOutput = (name, value) => { const delimiter = `ghadelimiter_${randomUUID()}`; if (!process.env.GITHUB_OUTPUT) return; fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}<<${delimiter}\n${value}\n${delimiter}\n`); };
7
8
  const target = input('target') || undefined; const threshold = input('fail-on') || 'never'; const failOnNew = input('fail-on-new') === 'true'; const root = input('path') || process.env.GITHUB_WORKSPACE || process.cwd();
8
9
  try {
9
- const result = await scan(root, { target, failOn: threshold, baseRef: process.env.GITHUB_BASE_REF || undefined }); const json = JSON.stringify(result); const active = result.findings.filter((finding) => !finding.baselined);
10
+ if (input('fail-on-new') && !['true','false'].includes(input('fail-on-new'))) throw new Error('fail-on-new must be true or false');
11
+ const result = await scan(root, { target, failOn: threshold, baseRef: input('base-ref') || (failOnNew && process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : undefined) }); const json = JSON.stringify(result); const active = result.findings.filter((finding) => !finding.baselined);
10
12
  setOutput('outcome', shouldFail(result, threshold, failOnNew) ? 'failure' : 'success'); setOutput('finding-count', String(active.length)); setOutput('new-finding-count', String(active.filter((item) => item.lifecycle === 'NEW').length)); setOutput('error-count', String(active.filter((item) => item.severity === 'error').length)); setOutput('current-versions', JSON.stringify(result.inventory)); setOutput('result-json', json);
11
13
  if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Shopify Upgrade Guard\n\n${formatHuman(result)}\n`);
12
- for (const finding of active) if (finding.file && finding.line) console.error(`::warning file=${finding.file},line=${finding.line},col=${finding.column}::${finding.title}`);
14
+ for (const finding of active) if (finding.file && finding.line) console.error(`::warning file=${finding.file.replaceAll('%','%25').replaceAll('\r','%0D').replaceAll('\n','%0A').replaceAll(':','%3A').replaceAll(',','%2C')},line=${finding.line},col=${finding.column}::${finding.title}`);
13
15
  if (shouldFail(result, threshold, failOnNew)) { console.error(`::error::Upgrade findings exceeded --fail-on ${threshold}${failOnNew ? ' for NEW findings' : ''}`); process.exitCode = 1; }
14
- } catch (error) { console.error(`::error::${error.message}`); process.exitCode = 2; }
16
+ } catch (error) { console.error(`Upgrade Guard scanner error: ${String(error.message).replace(/[\r\n]/g, ' ')}`); process.exitCode = 2; }
package/src/baseline.js CHANGED
@@ -2,11 +2,13 @@ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { join } from 'node:path';
4
4
 
5
+ import { safeRead, safePath } from './safe-file.js';
6
+
5
7
  export const BASELINE_FILE = '.upgradeguard-baseline.json';
6
8
  export function fingerprint(finding) { return createHash('sha256').update(`${finding.ruleId}|${finding.surface}|${finding.file}|${finding.snippet.replace(/\s+/g, ' ').trim()}`).digest('hex').slice(0, 16); }
7
9
  export async function loadBaseline(root, file = BASELINE_FILE) {
8
- try { const parsed = JSON.parse(await readFile(join(root, file), 'utf8')); if (!Array.isArray(parsed.findings) || parsed.findings.some((item) => typeof item.fingerprint !== 'string')) throw new Error('findings must be an array of fingerprinted findings'); return { fingerprints: new Set(parsed.findings.map((item) => item.fingerprint)), entries: parsed.findings, file }; }
9
- catch (error) { if (error.code === 'ENOENT') return { fingerprints: new Set(), entries: [], file }; throw new Error(`Invalid ${file}: ${error.message}`); }
10
+ try { const parsed = JSON.parse(await safeRead(root, file)); if (!Array.isArray(parsed.findings) || parsed.findings.some((item) => typeof item.fingerprint !== 'string')) throw new Error('findings must be an array of fingerprinted findings'); return { fingerprints: new Set(parsed.findings.map((item) => item.fingerprint)), entries: parsed.findings, file }; }
11
+ catch (error) { if (error.code === 'ENOENT') return { fingerprints: new Set(), entries: [], file }; throw new Error(`Invalid ${file}: ${error instanceof SyntaxError ? 'malformed JSON' : error.message}`); }
10
12
  }
11
- export async function writeBaseline(root, findings, file = BASELINE_FILE) { const payload = { schemaVersion: 1, generatedAt: new Date().toISOString(), findings: findings.map((finding) => ({ fingerprint: fingerprint(finding), ruleId: finding.ruleId, surface: finding.surface, file: finding.file })).sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) }; await writeFile(join(root, file), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); return payload; }
13
+ export async function writeBaseline(root, findings, file = BASELINE_FILE) { const payload = { schemaVersion: 1, generatedAt: new Date().toISOString(), findings: findings.map((finding) => ({ fingerprint: fingerprint(finding), ruleId: finding.ruleId, surface: finding.surface, file: finding.file })).sort((a, b) => a.fingerprint.localeCompare(b.fingerprint)) }; await writeFile(await safePath(root, file, true), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); return payload; }
12
14
  export function applyBaseline(findings, baseline) { return findings.map((finding) => ({ ...finding, fingerprint: fingerprint(finding), baselined: baseline.fingerprints.has(fingerprint(finding)) })); }
package/src/cli.js CHANGED
@@ -1,20 +1,28 @@
1
1
  #!/usr/bin/env node
2
+ import { TOOL_VERSION } from './version.js';
2
3
  import { scan } from './scan.js';
3
4
  import { formatHuman, shouldFail, toSarif } from './output.js';
4
5
  import { rules } from './rules.js';
5
6
  import { versionSummary } from './versions.js';
6
7
  import { writeBaseline } from './baseline.js';
7
- import packageJson from '../package.json' with { type: 'json' };
8
8
  import process from 'node:process';
9
- const args = process.argv.slice(2); const command = args[0] ?? 'scan';
9
+ const args = process.argv.slice(2); const command = args[0] && !args[0].startsWith('-') ? args[0] : 'scan';
10
10
  const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
11
11
  if (args.includes('--help') || command === 'help') { console.log('Usage: shopify-upgrade-guard <scan|baseline|rules|versions|explain> [options]\n\nscan options:\n --target YYYY-MM compare against a target version\n --format human|json|sarif\n --fail-on never|error|warning|info\n --fail-on-new apply threshold only to NEW findings\n --base-ref REF classify findings against a Git base ref\n --exclude GLOB ignore a repository-relative path pattern\n --path DIR repository to scan\n\nbaseline create|check writes or evaluates .upgradeguard-baseline.json'); process.exit(0); }
12
- if (args.includes('--version')) { console.log(packageJson.version); process.exit(0); }
12
+ if (args.includes('--version')) { console.log(TOOL_VERSION); process.exit(0); }
13
13
  try {
14
+ const valued = ['--target','--format','--fail-on','--base-ref','--exclude','--path'];
15
+ for (let i = command === args[0] ? 1 : 0; i < args.length; i++) {
16
+ const arg = args[i];
17
+ if (valued.includes(arg)) { if (!args[i+1] || args[i+1].startsWith('-')) throw new Error(`Missing value for ${arg}`); i++; }
18
+ else if (arg.startsWith('-') && arg !== '--fail-on-new') throw new Error(`Unknown argument: ${arg}`);
19
+ else if (!arg.startsWith('-') && !['baseline','explain'].includes(command)) throw new Error(`Unexpected argument: ${arg}`);
20
+ }
21
+ if (value('--format') && !['human','json','sarif'].includes(value('--format'))) throw new Error('format must be human, json, or sarif');
14
22
  if (command === 'rules') { for (const rule of rules) console.log(`${rule.id}\t${rule.surface}\t${rule.severity}\t${rule.title}\n ${rule.documentationUrl}`); process.exit(0); }
15
23
  if (command === 'versions') { for (const item of versionSummary()) console.log(`${item.version} ${item.status} — accessible until ${item.accessibleUntil}`); process.exit(0); }
16
24
  if (command === 'explain') { const rule = rules.find((item) => item.id === args[1]); if (!rule) throw new Error(`Unknown rule: ${args[1]}`); console.log(`${rule.id}: ${rule.title}\n\n${rule.description}\n\nSource: ${rule.documentationUrl}${rule.migrationUrl ? `\nMigration: ${rule.migrationUrl}` : ''}`); process.exit(0); }
17
- if (command === 'baseline') { const root = value('--path') ?? process.cwd(); const result = await scan(root, { target: value('--target'), noBaseline: true }); if ((args[1] ?? 'create') === 'check') { console.log(formatHuman(result)); process.exitCode = shouldFail(result, value('--fail-on') ?? 'never', args.includes('--fail-on-new')) ? 1 : 0; } else { const payload = await writeBaseline(root, result.findings); console.log(`Wrote ${payload.findings.length} findings to .upgradeguard-baseline.json`); process.exitCode = 0; } process.exit(process.exitCode ?? 0); }
25
+ if (command === 'baseline') { if (!['create','check'].includes(args[1] ?? 'create')) throw new Error('baseline command must be create or check'); const root = value('--path') ?? process.cwd(); const result = await scan(root, { target: value('--target'), noBaseline: (args[1] ?? 'create') === 'create', failOn: value('--fail-on'), baseRef: value('--base-ref') }); if ((args[1] ?? 'create') === 'check') { console.log(formatHuman(result)); process.exitCode = shouldFail(result, value('--fail-on') ?? result.failOn, args.includes('--fail-on-new')) ? 1 : 0; } else { const payload = await writeBaseline(root, result.findings); console.log(`Wrote ${payload.findings.length} findings to .upgradeguard-baseline.json`); process.exitCode = 0; } process.exit(process.exitCode ?? 0); }
18
26
  if (command !== 'scan') throw new Error(`Unknown command: ${command}`);
19
27
  const root = value('--path') ?? process.cwd(); const excludes = args.flatMap((arg, index) => arg === '--exclude' ? [args[index + 1]] : []).filter(Boolean); const result = await scan(root, { target: value('--target'), failOn: value('--fail-on'), baseRef: value('--base-ref'), exclude: excludes.length ? excludes : undefined }); const format = value('--format') ?? 'human';
20
28
  console.log(format === 'json' ? JSON.stringify(result, null, 2) : format === 'sarif' ? JSON.stringify(toSarif(result), null, 2) : formatHuman(result));
package/src/discovery.js CHANGED
@@ -5,26 +5,28 @@ import { parseVersion } from './versions.js';
5
5
  export const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', '.next', '.cache']);
6
6
  const supported = new Set(['.js', '.jsx', '.ts', '.tsx', '.graphql', '.gql', '.toml', '.json']);
7
7
  export async function discover(root, options = {}) {
8
- const files = []; const inventory = []; const excludes = options.exclude ?? [];
8
+ if (!(await lstat(root)).isDirectory()) throw new Error('Scan root must be a regular directory');
9
+ const files = []; const inventory = []; const skipped = []; const excludes = options.exclude ?? [];
9
10
  async function walk(directory) {
10
11
  let entries;
11
- try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return; throw error; }
12
- for (const entry of entries) {
12
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch (error) { throw error; }
13
+ for (const entry of entries.sort((a,b) => a.name.localeCompare(b.name))) {
13
14
  if (entry.name.startsWith('.') && entry.name !== '.upgradeguard.json') continue;
14
15
  const path = join(directory, entry.name); const relativePath = relative(root, path).replaceAll('\\', '/');
15
16
  if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name) && !isExcluded(relativePath, excludes)) await walk(path); continue; }
16
- if (entry.isSymbolicLink()) continue;
17
+ if (entry.isSymbolicLink()) { skipped.push({ file: relativePath, reason: 'Symbolic link' }); continue; }
18
+ if (!entry.isFile()) continue;
17
19
  const extension = entry.name.endsWith('.toml') ? '.toml' : extname(entry.name);
18
20
  if (!supported.has(extension) || entry.name.endsWith('.min.js') || isExcluded(relativePath, excludes)) continue;
19
- const stats = await lstat(path); if (stats.size > 1024 * 1024) continue;
21
+ const stats = await lstat(path); if (stats.size > 1024 * 1024 || files.length >= 2000) { skipped.push({ file: relativePath, reason: 'Analysis limit' }); continue; }
20
22
  const text = await readFile(path, 'utf8'); const file = { path, relativePath, text };
21
23
  files.push(file); inventory.push(...inventoryFor(file));
22
24
  }
23
25
  }
24
- await walk(root); files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); inventory.sort(compareInventory); return { files, inventory };
26
+ await walk(root); files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); inventory.sort(compareInventory); return { files, inventory, skipped };
25
27
  }
26
28
  function inventoryFor(file) {
27
- const entries = []; const toml = file.relativePath.endsWith('.toml'); const shopifyToml = /(?:^|\/)shopify\.(?:app|extension)\.toml$/.test(file.relativePath);
29
+ const entries = []; const toml = file.relativePath.endsWith('.toml'); const shopifyToml = /(?:^|\/)shopify\.(?:app(?:\.[^/]+)?|extension)\.toml$/.test(file.relativePath);
28
30
  const tomlVersion = shopifyToml ? matchVersion(file.text, /(?:^|\n)\s*api_version\s*=\s*["'](\d{4}-(?:0[147]|10))["']/g) : [];
29
31
  for (const match of tomlVersion) entries.push({ surface: surfaceForToml(file.relativePath, file.text), version: match.version, file: file.relativePath, line: match.line, source: 'api_version' });
30
32
  if (!toml) {
@@ -35,7 +37,7 @@ function inventoryFor(file) {
35
37
  return [...new Map(entries.map((item) => [`${item.surface}|${item.version}|${item.file}`, item])).values()];
36
38
  }
37
39
  function matchVersion(text, pattern) { return [...text.matchAll(pattern)].map((match) => { const before = text.slice(0, match.index + match[0].indexOf(match[1])); return { version: parseVersion(match[1]), line: before.split('\n').length }; }); }
38
- function surfaceForToml(path, text) { const normalized = path.toLowerCase(); if (normalized.includes('checkout') || /\bblock_progress\b/.test(text)) return 'checkout_ui_extension'; if (normalized.includes('customer')) return 'customer_account_ui_extension'; if (normalized.includes('pos')) return 'pos_ui_extension'; if (normalized.includes('function')) return 'shopify_function'; return normalized.endsWith('shopify.app.toml') ? 'shopify_app' : 'extension'; }
40
+ function surfaceForToml(path, text) { const normalized = path.toLowerCase(); if (normalized.includes('checkout') || /\bblock_progress\b/.test(text)) return 'checkout_ui_extension'; if (normalized.includes('customer')) return 'customer_account_ui_extension'; if (normalized.includes('pos')) return 'pos_ui_extension'; if (normalized.includes('function')) return 'shopify_function'; return /(?:^|\/)shopify\.app(?:\.[^/]+)?\.toml$/.test(normalized) ? 'shopify_app' : 'extension'; }
39
41
  function compareInventory(a, b) { return `${a.file}:${a.line}:${a.surface}`.localeCompare(`${b.file}:${b.line}:${b.surface}`); }
40
42
  function isExcluded(path, patterns) { return patterns.some((pattern) => globToRegExp(pattern).test(path)); }
41
43
  export function validateExcludePattern(pattern) { globToRegExp(pattern); return pattern; }
package/src/git.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  export async function changedLines(root, baseRef) {
3
- if (!baseRef || baseRef.includes('..') || !/^[A-Za-z0-9_./-]+$/.test(baseRef)) return null;
4
- const output = await runGit(root, ['diff', '--no-ext-diff', '--unified=0', `${baseRef}...HEAD`, '--']);
3
+ if (!baseRef || baseRef.startsWith('-') || baseRef.includes('..') || !/^[A-Za-z0-9_./-]+$/.test(baseRef)) throw new Error('Invalid Git base ref');
4
+ const output = await runGit(root, ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', `${baseRef}...HEAD`, '--']);
5
5
  const ranges = new Map(); let file = null;
6
6
  for (const line of output.split('\n')) {
7
7
  if (line.startsWith('+++ b/')) { file = line.slice(6); continue; }
package/src/graphql.js ADDED
@@ -0,0 +1,52 @@
1
+ import { parse, Kind, visit } from 'graphql';
2
+ // Parse only literal GraphQL documents. Dynamic templates remain unsupported.
3
+ export function rootFieldMatches(file, name, guidance) {
4
+ const documents = [];
5
+ if (/\.(graphql|gql)$/.test(file.relativePath)) documents.push({ text: file.text, offset: 0 });
6
+ else for (const match of file.text.matchAll(/([`"'])([\s\S]*?)\1/g)) {
7
+ const text = match[2];
8
+ if (text.includes('${') || !/(?:\bquery\b|\bmutation\b|#graphql|^\s*\{)/.test(text)) continue;
9
+ documents.push({ text, offset: match.index + 1 });
10
+ }
11
+ const matches = [];
12
+ for (const doc of documents) {
13
+ try {
14
+ const ast = parse(doc.text, { maxTokens: 50000 });
15
+ const fragments = new Map(ast.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(d => [d.name.value,d]));
16
+ let visits = 0;
17
+ const visitRoot = (selections, stack = []) => {
18
+ for (const node of selections ?? []) {
19
+ if (++visits > 50000) throw new Error('GraphQL expansion limit');
20
+ if (node.kind === Kind.FIELD && node.name.value === name) {
21
+ const index = doc.offset + node.name.loc.start;
22
+ const before = file.text.slice(0,index);
23
+ matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: name, guidance });
24
+ } else if (node.kind === Kind.INLINE_FRAGMENT) visitRoot(node.selectionSet.selections, stack);
25
+ else if (node.kind === Kind.FRAGMENT_SPREAD && fragments.has(node.name.value) && !stack.includes(node.name.value)) visitRoot(fragments.get(node.name.value).selectionSet.selections,[...stack,node.name.value]);
26
+ }
27
+ };
28
+ for (const operation of ast.definitions.filter(d=>d.kind===Kind.OPERATION_DEFINITION && d.operation === 'query')) visitRoot(operation.selectionSet.selections);
29
+ } catch { /* Unsupported or malformed documents are not evidence of this removal. */ }
30
+ }
31
+ return matches;
32
+ }
33
+
34
+ // Existing field/type removals require a parseable literal document too.
35
+ export function fieldMatches(file, fields, types, guidance) {
36
+ const documents = [];
37
+ if (/\.(graphql|gql)$/.test(file.relativePath)) documents.push({ text: file.text, offset: 0 });
38
+ else for (const match of file.text.matchAll(/([`"'])([\s\S]*?)\1/g)) {
39
+ if (!match[2].includes('${') && /(?:\bquery\b|\bmutation\b|#graphql|^\s*\{)/.test(match[2])) documents.push({ text: match[2], offset: match.index + 1 });
40
+ }
41
+ const matches = [];
42
+ for (const doc of documents) try {
43
+ const ast = parse(doc.text, { maxTokens: 50000 });
44
+ visit(ast, { enter(node) {
45
+ const name = node.kind === Kind.FIELD && fields.includes(node.name.value) ? node.name : ([Kind.INLINE_FRAGMENT, Kind.FRAGMENT_DEFINITION].includes(node.kind) && types.includes(node.typeCondition?.name.value) ? node.typeCondition.name : null);
46
+ if (!name) return;
47
+ const index = doc.offset + name.loc.start, before = file.text.slice(0,index);
48
+ matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: name.value, guidance });
49
+ } });
50
+ } catch { /* Malformed/dynamic documents cannot establish schema usage. */ }
51
+ return matches;
52
+ }
package/src/output.js CHANGED
@@ -12,7 +12,8 @@ export function formatHuman(result) {
12
12
  return lines.join('\n');
13
13
  }
14
14
  function sectionLines(findings, fallback) { if (!findings.length) return [`No ${fallback.toLowerCase()}.`]; const lines = []; for (const finding of [...findings].sort((a, b) => order[a.severity] - order[b.severity])) lines.push(`${finding.severity.toUpperCase()} ${finding.ruleId}`, finding.title, ` ${finding.file}:${finding.line}`, ` ${finding.reason ?? finding.currentState}`, ` Migration: ${finding.migration}`, ` Source: ${finding.source}`, ''); return lines; }
15
- export function shouldFail(result, threshold = 'never', newOnly = false) { if (threshold === 'never') return false; return activeFindings(result).filter((finding) => !newOnly || finding.lifecycle === 'NEW').some((finding) => threshold === 'info' || (threshold === 'warning' && finding.severity !== 'info') || (threshold === 'error' && finding.severity === 'error')); }
15
+ export function validatePolicy(threshold) { if (!['never','error','warning','info'].includes(threshold)) throw new Error('fail-on must be never, error, warning, or info'); }
16
+ export function shouldFail(result, threshold = 'never', newOnly = false) { validatePolicy(threshold); if (newOnly && !result.comparisonAvailable) throw new Error('fail-on-new requires an available Git base comparison; use --base-ref or a pull request checkout with full history'); if (threshold === 'never') return false; return activeFindings(result).filter((finding) => !newOnly || finding.lifecycle === 'NEW').some((finding) => threshold === 'info' || (threshold === 'warning' && finding.severity !== 'info') || (threshold === 'error' && finding.severity === 'error')); }
16
17
  export function toSarif(result) {
17
18
  const ruleMap = new Map(); for (const finding of activeFindings(result)) if (!ruleMap.has(finding.ruleId)) ruleMap.set(finding.ruleId, { id: finding.ruleId, shortDescription: { text: finding.title }, helpUri: finding.source, properties: { severity: finding.severity, confidence: finding.confidence } });
18
- const rules = [...ruleMap.values()]; return { version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json', runs: [{ tool: { driver: { name: 'Shopify Upgrade Guard', informationUri: 'https://github.com/efegokdemir/shopify-upgrade-guard', rules } }, results: activeFindings(result).map((finding) => ({ ruleId: finding.ruleId, ruleIndex: rules.findIndex((rule) => rule.id === finding.ruleId), level: finding.severity === 'error' ? 'error' : finding.severity === 'warning' ? 'warning' : 'note', message: { text: `${finding.title}. ${finding.reason ?? finding.migration}` }, partialFingerprints: { primaryLocationLineHash: finding.fingerprint }, locations: [{ physicalLocation: { artifactLocation: { uri: finding.file.replaceAll('\\', '/') }, region: { startLine: finding.line, startColumn: finding.column } } }] })) }] }; }
19
+ const rules = [...ruleMap.values()]; return { version: '2.1.0', $schema: 'https://json.schemastore.org/sarif-2.1.0.json', runs: [{ tool: { driver: { name: 'Shopify Upgrade Guard', informationUri: 'https://github.com/RexCode-Digital/shopify-upgrade-guard', rules } }, results: activeFindings(result).map((finding) => ({ ruleId: finding.ruleId, ruleIndex: rules.findIndex((rule) => rule.id === finding.ruleId), level: finding.severity === 'error' ? 'error' : finding.severity === 'warning' ? 'warning' : 'note', message: { text: `${finding.title}. ${finding.reason ?? finding.migration}` }, partialFingerprints: { primaryLocationLineHash: finding.fingerprint }, locations: [{ physicalLocation: { artifactLocation: { uri: finding.file.replaceAll('\\', '/').split('/').map(encodeURIComponent).join('/') }, region: { startLine: finding.line, startColumn: finding.column } } }] })) }] }; }
package/src/rules.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { isAtOrAfter, statusFor, latestStable, becomesUnsupportedBefore } from './versions.js';
2
2
 
3
+ import { rootFieldMatches, fieldMatches } from './graphql.js';
4
+
3
5
  const checkoutDeprecation = '2026-07';
4
6
  const removalVersion = '2026-10';
5
7
  const sourceExtensions = /\.(?:[jt]sx?|graphql|gql)$/;
@@ -22,7 +24,7 @@ export const rules = [
22
24
  id: 'UG-CUSTOMER-001', surface: 'customer_account_api', severity: 'error', title: 'Customer Account checkout types are removed', removedIn: removalVersion, confidence: 'high',
23
25
  documentationUrl: 'https://shopify.dev/changelog/customer-account-api-last-incomplete-checkout-and-checkout-types-removed', migrationUrl: 'https://shopify.dev/changelog/customer-account-api-last-incomplete-checkout-and-checkout-types-removed',
24
26
  description: 'Customer.lastIncompleteCheckout and the Checkout type subtree are removed in API version 2026-10 with no replacement. Use Storefront cart flows or Customer.orders as appropriate.',
25
- detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return matches(maskComments(file.text), /\blastIncompleteCheckout\b|\bCheckout\s*\{/g, 'Remove the Customer Account checkout field/type usage and migrate to Storefront cart flows or Customer.orders.'); },
27
+ detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return fieldMatches(file, ['lastIncompleteCheckout'], ['Checkout'], 'Remove the Customer Account checkout field/type usage and migrate to Storefront cart flows or Customer.orders.'); },
26
28
  evaluate(match, context) { return removalAware(match, this, context); }
27
29
  },
28
30
  {
@@ -34,16 +36,24 @@ export const rules = [
34
36
  },
35
37
  {
36
38
  id: 'UG-ADMIN-001', surface: 'admin_graphql_api', severity: 'warning', title: 'Legacy GraphQL priceRule field is removed', removedIn: removalVersion, confidence: 'medium',
37
- documentationUrl: 'https://shopify.dev/release-notes/2026-10', migrationUrl: 'https://shopify.dev/release-notes/2026-10',
39
+ documentationUrl: 'https://shopify.dev/changelog/release-notes/2026-10', migrationUrl: 'https://shopify.dev/changelog/release-notes/2026-10',
38
40
  description: 'Legacy priceRule fields and types are removed from the Admin GraphQL API in 2026-10. Use discountTitle or discountCode where applicable.',
39
- detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return matches(maskComments(file.text), /\bpriceRule\b/g, 'Replace priceRule usage with the supported discountTitle or discountCode fields.'); },
41
+ detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return fieldMatches(file, ['priceRule'], ['PriceRule'], 'For DraftOrderDiscountNotAppliedWarning, select discountTitle and discountCode instead; review other legacy PriceRule selections against the removed public types.'); },
40
42
  evaluate(match, context) { return removalAware(match, this, context); }
41
43
  },
42
44
  {
43
45
  id: 'UG-SCRIPT-001', surface: 'online_store_script_tags', severity: 'error', title: 'Script Tag creation and updates are deprecated', deprecatedIn: removalVersion, confidence: 'high',
44
46
  documentationUrl: 'https://shopify.dev/changelog/online-store-script-tags-deprecation', migrationUrl: 'https://shopify.dev/docs/apps/build/online-store/script-tag-deprecation',
45
- description: 'ScriptTag create/update operations error from 2026-10 and storefront injection stops in 2027-03. Migrate to a theme app extension/app embed block or web pixel.',
47
+ description: 'ScriptTag create/update operations are restricted on all API versions from October 1, 2026 and storefront injection stops on March 1, 2027. Migrate to a theme app extension/app embed block or web pixel.',
46
48
  detect(file) { if (!sourceExtensions.test(file.relativePath)) return []; return matches(maskComments(file.text), /\b(?:scriptTagCreate|scriptTagUpdate|script_tags)\b/g, 'Migrate Script Tag injection to a theme app extension/app embed block or web pixel.'); },
49
+ evaluate(match) { return { ...match, classification: 'current', reason: this.description, ...(match.snippet === 'script_tags' ? { severity: 'warning', confidence: 'medium', title: 'Script Tag REST usage requires migration review', reason: 'Script Tag POST/PUT are restricted across all API versions; reads and deletes remain available. Review the HTTP method and plan storefront injection migration.' } : {}) }; }
50
+ },
51
+ {
52
+ id: 'UG-ADMIN-002', surface: 'admin_graphql_api', severity: 'error', title: 'automaticDiscounts query is removed', removedIn: '2027-01', confidence: 'high',
53
+ documentationUrl: 'https://shopify.dev/changelog/posts/automaticdiscounts-query-is-removed-in-api-version-2027-01',
54
+ migrationUrl: 'https://shopify.dev/docs/api/admin-graphql/2027-01/queries/discountNodes',
55
+ description: 'The Admin GraphQL QueryRoot.automaticDiscounts field is removed in 2027-01. Migrate to discountNodes with a method:automatic filter.',
56
+ detect(file) { return rootFieldMatches(file, 'automaticDiscounts', 'Use discountNodes(first: ..., query: "method:automatic") and move discount fragments under DiscountNode.discount.'); },
47
57
  evaluate(match, context) { return removalAware(match, this, context); }
48
58
  },
49
59
  {
@@ -0,0 +1,16 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { resolve, relative, isAbsolute, sep, join } from 'node:path';
3
+ export async function safePath(root, file, allowMissing = false) {
4
+ const absolute = resolve(root, file), rel = relative(resolve(root), absolute);
5
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error('Input must be a repository-relative file path');
6
+ let current = resolve(root);
7
+ for (const part of rel.split(sep)) {
8
+ current = join(current, part);
9
+ try { if ((await lstat(current)).isSymbolicLink()) throw new Error('Symbolic link inputs are not supported'); }
10
+ catch (error) { if (allowMissing && error.code === 'ENOENT' && current === absolute) return absolute; throw error; }
11
+ }
12
+ const stat = await lstat(absolute);
13
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('Input must be a regular file up to 1 MiB');
14
+ return absolute;
15
+ }
16
+ export async function safeRead(root, file) { return readFile(await safePath(root, file), 'utf8'); }
package/src/scan.js CHANGED
@@ -1,33 +1,37 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
1
+ import { readFile, lstat } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
3
  import { discover, validateExcludePattern } from './discovery.js';
4
4
  import { rules, attachFile } from './rules.js';
5
5
  import { latestStable, parseVersion, targetRecord } from './versions.js';
6
6
  import { applyBaseline, loadBaseline } from './baseline.js';
7
7
  import { changedLines } from './git.js';
8
+ import { safeRead } from './safe-file.js';
9
+ import { validatePolicy } from './output.js';
8
10
 
9
11
  export async function loadConfig(root) {
10
- try { const config = JSON.parse(await readFile(join(root, '.upgradeguard.json'), 'utf8')); validateConfig(config); return config; }
11
- catch (error) { if (error.code === 'ENOENT') return {}; throw new Error(`Invalid .upgradeguard.json: ${error.message}`); }
12
+ try { const config = JSON.parse(await safeRead(root, '.upgradeguard.json')); validateConfig(config); return config; }
13
+ catch (error) { if (error.code === 'ENOENT') return {}; throw new Error(`Invalid .upgradeguard.json: ${error instanceof SyntaxError ? 'malformed JSON' : error.message}`); }
12
14
  }
13
- function validateConfig(config) { if (config.targetVersion !== undefined && !parseVersion(config.targetVersion)) throw new Error('targetVersion must be a quarterly YYYY-MM version.'); if (config.failOn !== undefined && !['never', 'error', 'warning', 'info'].includes(config.failOn)) throw new Error('failOn must be never, error, warning, or info.'); if (config.exclude !== undefined && (!Array.isArray(config.exclude) || config.exclude.some((pattern) => validateExcludePattern(pattern) === undefined))) throw new Error('exclude must be an array of repository-relative glob patterns.'); if (config.baseline !== undefined && (typeof config.baseline !== 'string' || config.baseline.startsWith('/') || config.baseline.includes('..'))) throw new Error('baseline must be a repository-relative file path.'); }
15
+ function validateConfig(config) { if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Configuration must be an object.'); if (config.targetVersion !== undefined && !parseVersion(config.targetVersion)) throw new Error('targetVersion must be a quarterly YYYY-MM version.'); if (config.failOn !== undefined && !['never', 'error', 'warning', 'info'].includes(config.failOn)) throw new Error('failOn must be never, error, warning, or info.'); if (config.exclude !== undefined && (!Array.isArray(config.exclude) || config.exclude.some((pattern) => validateExcludePattern(pattern) === undefined))) throw new Error('exclude must be an array of repository-relative glob patterns.'); if (config.baseline !== undefined && (typeof config.baseline !== 'string' || config.baseline.startsWith('/') || config.baseline.includes('..'))) throw new Error('baseline must be a repository-relative file path.'); }
14
16
  export async function scan(root, options = {}) {
15
- const config = await loadConfig(root); const targetVersion = options.target ?? config.targetVersion ?? latestStable.version; const failOn = options.failOn ?? config.failOn ?? 'never'; targetRecord(targetVersion);
17
+ root = resolve(root);
18
+ if (!(await lstat(root)).isDirectory()) throw new Error('Scan root must be a regular directory');
19
+ const config = await loadConfig(root); const targetVersion = options.target ?? config.targetVersion ?? latestStable.version; const failOn = options.failOn ?? config.failOn ?? 'never'; targetRecord(targetVersion); validatePolicy(failOn);
16
20
  const exclude = options.exclude ?? config.exclude ?? []; exclude.forEach(validateExcludePattern);
17
- const { files, inventory } = await discover(root, { exclude }); const context = { root, targetVersion, inventory };
21
+ const { files, inventory, skipped } = await discover(root, { exclude }); const context = { root, targetVersion, inventory };
18
22
  const findings = [];
19
23
  for (const file of files) for (const rule of rules) {
20
24
  const detected = rule.detect(file); const matches = detected.length ? attachFile(detected, file) : inventoryMatches(rule, inventory, file);
21
25
  for (const match of matches) { const evaluated = rule.evaluate(match, context); if (evaluated) findings.push(toFinding(rule, evaluated, targetVersion)); }
22
26
  }
23
27
  const baseline = options.noBaseline ? { fingerprints: new Set(), entries: [], file: null } : await loadBaseline(root, options.baseline ?? config.baseline); let resultFindings = applyBaseline(dedupe(findings), baseline);
24
- const baseRef = options.baseRef ?? process.env.GITHUB_BASE_REF;
25
- if (baseRef) { try { const ranges = await changedLines(root, baseRef); resultFindings = resultFindings.map((finding) => ({ ...finding, changeStatus: isChanged(finding, ranges) ? 'introduced' : 'pre-existing', lifecycle: isChanged(finding, ranges) ? 'NEW' : 'EXISTING' })); } catch { resultFindings = resultFindings.map((finding) => ({ ...finding, lifecycle: finding.baselined ? 'EXISTING' : 'UNCHANGED' })); } }
28
+ const baseRef = options.baseRef;
29
+ if (baseRef) { const ranges = await changedLines(root, baseRef); resultFindings = resultFindings.map(finding => ({ ...finding, changeStatus: isChanged(finding, ranges) ? 'introduced' : 'pre-existing', lifecycle: isChanged(finding, ranges) ? 'NEW' : 'EXISTING' })); }
26
30
  else resultFindings = resultFindings.map((finding) => ({ ...finding, lifecycle: finding.baselined ? 'EXISTING' : 'UNCHANGED' }));
27
31
  const active = resultFindings.filter((finding) => !finding.baselined); const currentProblems = active.filter((finding) => finding.classification === 'current'); const upgradeBlockers = active.filter((finding) => finding.classification === 'target');
28
- return { schemaVersion: 1, tool: 'shopify-upgrade-guard', targetVersion, failOn, latestStable: latestStable.version, filesScanned: files.length, inventory, findings: resultFindings, currentProblems, upgradeBlockers, baselinedCount: resultFindings.filter((finding) => finding.baselined).length, resolvedBaselineCount: [...baseline.fingerprints].filter((fingerprint) => !resultFindings.some((finding) => finding.fingerprint === fingerprint)).length, baselineFile: baseline.file };
32
+ return { schemaVersion: 1, comparisonAvailable: Boolean(baseRef), skipped, tool: 'shopify-upgrade-guard', targetVersion, failOn, latestStable: latestStable.version, filesScanned: files.length, inventory, findings: resultFindings, currentProblems, upgradeBlockers, baselinedCount: resultFindings.filter((finding) => finding.baselined).length, resolvedBaselineCount: [...baseline.fingerprints].filter((fingerprint) => !resultFindings.some((finding) => finding.fingerprint === fingerprint)).length, baselineFile: baseline.file };
29
33
  }
30
34
  function inventoryMatches(rule, inventory, file) { if (!rule.id.startsWith('UG-VERSION')) return []; return inventory.filter((item) => item.file === file.relativePath).map((item) => ({ file: item.file, line: item.line, column: 1, snippet: `${item.source}: ${item.version}`, version: item.version, guidance: 'Update this Shopify version to a supported stable release and test the migration.' })); }
31
- function toFinding(rule, match, targetVersion) { return { ruleId: rule.id, severity: rule.severity, title: rule.title, surface: rule.surface, file: match.file, line: match.line, column: match.column, snippet: match.snippet, targetVersion, currentState: rule.description, migration: match.guidance, source: rule.documentationUrl, migrationUrl: rule.migrationUrl ?? null, confidence: rule.confidence, classification: match.classification, reason: match.reason, deprecatedIn: rule.deprecatedIn ?? null, removedIn: rule.removedIn ?? null }; }
35
+ function toFinding(rule, match, targetVersion) { return { ruleId: rule.id, severity: match.severity ?? rule.severity, title: match.title ?? rule.title, surface: rule.surface, file: match.file, line: match.line, column: match.column, snippet: match.snippet, targetVersion, currentState: rule.description, migration: match.guidance, source: rule.documentationUrl, migrationUrl: rule.migrationUrl ?? null, confidence: match.confidence ?? rule.confidence, classification: match.classification, reason: match.reason, deprecatedIn: rule.deprecatedIn ?? null, removedIn: rule.removedIn ?? null }; }
32
36
  function dedupe(findings) { return [...new Map(findings.map((finding) => [`${finding.ruleId}|${finding.file}|${finding.line}|${finding.snippet}`, finding])).values()]; }
33
37
  function isChanged(finding, ranges) { return Boolean(ranges?.get(finding.file)?.some((range) => finding.line >= range.start && finding.line <= range.end)); }
@@ -0,0 +1,61 @@
1
+ // Generated from data/versions.json.
2
+ export default {
3
+ "verifiedAt": "2026-10-04",
4
+ "source": "https://shopify.dev/docs/api/usage/versioning",
5
+ "versions": [
6
+ {
7
+ "version": "2025-01",
8
+ "releaseDate": "2025-01-01",
9
+ "accessibleUntil": "2026-01-16T15:00:00Z",
10
+ "status": "unsupported"
11
+ },
12
+ {
13
+ "version": "2025-04",
14
+ "releaseDate": "2025-04-01",
15
+ "accessibleUntil": "2026-04-16T15:00:00Z",
16
+ "status": "unsupported"
17
+ },
18
+ {
19
+ "version": "2025-07",
20
+ "releaseDate": "2025-07-01",
21
+ "accessibleUntil": "2026-07-16T15:00:00Z",
22
+ "status": "unsupported"
23
+ },
24
+ {
25
+ "version": "2025-10",
26
+ "releaseDate": "2025-10-01",
27
+ "accessibleUntil": "2026-10-16T15:00:00Z",
28
+ "status": "unsupported"
29
+ },
30
+ {
31
+ "version": "2026-01",
32
+ "releaseDate": "2026-01-01",
33
+ "accessibleUntil": "2027-01-16T15:00:00Z",
34
+ "status": "stable"
35
+ },
36
+ {
37
+ "version": "2026-04",
38
+ "releaseDate": "2026-04-01",
39
+ "accessibleUntil": "2027-04-16T15:00:00Z",
40
+ "status": "stable"
41
+ },
42
+ {
43
+ "version": "2026-07",
44
+ "releaseDate": "2026-07-01",
45
+ "accessibleUntil": "2027-07-16T15:00:00Z",
46
+ "status": "stable"
47
+ },
48
+ {
49
+ "version": "2026-10",
50
+ "releaseDate": "2026-10-01",
51
+ "accessibleUntil": "2027-10-16T15:00:00Z",
52
+ "status": "latest-stable"
53
+ },
54
+ {
55
+ "version": "2027-01",
56
+ "releaseDate": "2027-01-01",
57
+ "accessibleUntil": "2028-01-16T15:00:00Z",
58
+ "status": "release-candidate"
59
+ }
60
+ ]
61
+ };
package/src/version.js ADDED
@@ -0,0 +1,2 @@
1
+ // Generated from package.json.
2
+ export const TOOL_VERSION = "0.2.4";
package/src/versions.js CHANGED
@@ -1,4 +1,4 @@
1
- import versionData from '../data/versions.json' with { type: 'json' };
1
+ import versionData from './version-data.js';
2
2
 
3
3
  export const VERSION_SOURCE = versionData.source;
4
4
  export const versions = Object.freeze(versionData.versions);