shopify-upgrade-guard 0.2.6 → 0.3.0

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.6",
3
+ "version": "0.3.0",
4
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": {
package/src/action.js CHANGED
@@ -9,7 +9,7 @@ const target = input('target') || undefined; const threshold = input('fail-on')
9
9
  try {
10
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
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);
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);
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('resolved-finding-count', String(result.resolvedFindings.length)); setOutput('error-count', String(active.filter((item) => item.severity === 'error').length)); setOutput('current-versions', JSON.stringify(result.inventory)); setOutput('result-json', json);
13
13
  if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## Shopify Upgrade Guard\n\n${formatHuman(result)}\n`);
14
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}`);
15
15
  if (shouldFail(result, threshold, failOnNew)) { console.error(`::error::Upgrade findings exceeded --fail-on ${threshold}${failOnNew ? ' for NEW findings' : ''}`); process.exitCode = 1; }
package/src/git.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
- export async function changedLines(root, baseRef) {
2
+ import { lstat, mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+
6
+ function validateBaseRef(baseRef) {
3
7
  if (!baseRef || baseRef.startsWith('-') || baseRef.includes('..') || !/^[A-Za-z0-9_./-]+$/.test(baseRef)) throw new Error('Invalid Git base ref');
8
+ }
9
+
10
+ export async function changedLines(root, baseRef) {
11
+ validateBaseRef(baseRef);
4
12
  const output = await runGit(root, ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', `${baseRef}...HEAD`, '--']);
5
13
  const ranges = new Map(); let file = null;
6
14
  for (const line of output.split('\n')) {
@@ -10,4 +18,74 @@ export async function changedLines(root, baseRef) {
10
18
  }
11
19
  return ranges;
12
20
  }
13
- export async function runGit(root, args) { return new Promise((resolve, reject) => { const child = spawn('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve(stdout) : reject(new Error(`git ${args[0]} failed: ${stderr.trim()}`))); }); }
21
+
22
+ export async function withBaseTree(root, baseRef, analyze) {
23
+ validateBaseRef(baseRef);
24
+ const scanRoot = await realpath(resolve(root));
25
+ const repositoryRoot = (await runGit(scanRoot, ['rev-parse', '--show-toplevel'])).trim();
26
+ const scanPath = relative(repositoryRoot, scanRoot);
27
+ if (scanPath === '..' || scanPath.startsWith(`..${sep}`) || isAbsolute(scanPath)) throw new Error('Scan root must be inside its Git repository');
28
+ const base = (await runGit(repositoryRoot, ['rev-parse', '--verify', '--end-of-options', `${baseRef}^{commit}`])).trim();
29
+ const baseCommit = (await runGit(repositoryRoot, ['merge-base', base, 'HEAD'])).trim();
30
+ if (!/^[0-9a-f]{40,64}$/i.test(baseCommit)) throw new Error('Git did not return a valid base commit');
31
+ const parent = await mkdtemp(join(tmpdir(), 'upgradeguard-base-'));
32
+ const worktree = join(parent, 'tree');
33
+ const hooksPath = join(parent, 'empty-hooks');
34
+ const emptyConfig = join(parent, 'empty-config');
35
+ let added = false;
36
+ try {
37
+ await mkdir(hooksPath);
38
+ await writeFile(emptyConfig, '');
39
+ const filterConfig = await runGit(repositoryRoot, ['config', '--local', '--name-only', '--get-regexp', '^filter\\.'], { allowFailure: true });
40
+ if (filterConfig.code !== 0 && filterConfig.code !== 1) throw new Error('Could not inspect Git filter configuration safely');
41
+ const filterNames = new Set(filterConfig.stdout.split('\n').map((key) => key.trim()).filter(Boolean).map((key) => key.slice('filter.'.length, key.lastIndexOf('.'))).filter(Boolean));
42
+ const checkoutOptions = ['-c', `core.hooksPath=${hooksPath}`, '-c', 'core.fsmonitor=false', '-c', `core.attributesFile=${emptyConfig}`, '-c', 'core.autocrlf=false'];
43
+ for (const name of filterNames) checkoutOptions.push('-c', `filter.${name}.smudge=`, '-c', `filter.${name}.process=`, '-c', `filter.${name}.clean=`, '-c', `filter.${name}.required=false`);
44
+ const env = isolatedGitEnvironment(emptyConfig);
45
+ await runGit(repositoryRoot, [...checkoutOptions, 'worktree', 'add', '--detach', worktree, baseCommit], { env });
46
+ added = true;
47
+ let baseRoot = resolve(worktree, scanPath);
48
+ try { const stat = await lstat(baseRoot); if (!stat.isDirectory()) throw new Error('Base scan root must be a regular directory'); }
49
+ catch (error) { if (error.code !== 'ENOENT') throw error; baseRoot = join(parent, 'empty-scan-root'); await mkdir(baseRoot); }
50
+ const renames = await renamedPaths(repositoryRoot, baseCommit, scanPath);
51
+ return await analyze(baseRoot, { baseCommit, renames });
52
+ } finally {
53
+ let cleanupError;
54
+ if (added) try { await runGit(repositoryRoot, ['worktree', 'remove', '--force', worktree]); } catch (error) { cleanupError = error; }
55
+ await rm(parent, { recursive: true, force: true });
56
+ if (cleanupError) throw cleanupError;
57
+ }
58
+ }
59
+
60
+ async function renamedPaths(repositoryRoot, baseCommit, scanPath) {
61
+ const pathspec = scanPath ? `:(literal)${scanPath.replaceAll('\\', '/')}` : '.';
62
+ const output = await runGit(repositoryRoot, ['-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--find-renames', '--name-status', '-z', baseCommit, 'HEAD', '--', pathspec]);
63
+ const fields = output.split('\0'); const renames = new Map();
64
+ const toScanPath = (file) => {
65
+ const normalized = file.replaceAll('\\', '/');
66
+ if (!scanPath) return normalized;
67
+ const prefix = `${scanPath.replaceAll('\\', '/')}/`;
68
+ return normalized.startsWith(prefix) ? normalized.slice(prefix.length) : null;
69
+ };
70
+ for (let index = 0; index < fields.length;) {
71
+ const status = fields[index++];
72
+ if (!status) continue;
73
+ const count = /^[RC]/.test(status) ? 2 : 1;
74
+ const before = count === 2 ? fields[index++] : null;
75
+ const after = fields[index++];
76
+ if (status.startsWith('R')) {
77
+ const oldPath = toScanPath(before); const newPath = toScanPath(after);
78
+ if (oldPath && newPath) renames.set(oldPath, newPath);
79
+ }
80
+ }
81
+ return renames;
82
+ }
83
+
84
+ function isolatedGitEnvironment(emptyConfig) {
85
+ const env = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_SYSTEM: emptyConfig };
86
+ for (const key of Object.keys(env)) if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) delete env[key];
87
+ for (const key of ['GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS', 'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']) delete env[key];
88
+ return env;
89
+ }
90
+
91
+ export async function runGit(root, args, options = {}) { return new Promise((resolve, reject) => { const child = spawn('git', args, { cwd: root, env: options.env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve(options.allowFailure ? { code, stdout, stderr } : stdout) : options.allowFailure ? resolve({ code, stdout, stderr }) : reject(new Error(`git ${args[0]} failed: ${stderr.trim()}`))); }); }
package/src/graphql.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parse, Kind, visit } from 'graphql';
2
2
  // Parse only literal GraphQL documents. Dynamic templates remain unsupported.
3
- export function rootFieldMatches(file, name, guidance) {
3
+ export function rootFieldMatches(file, name, guidance, operation = 'query') {
4
4
  const documents = [];
5
5
  if (/\.(graphql|gql)$/.test(file.relativePath)) documents.push({ text: file.text, offset: 0 });
6
6
  else for (const match of file.text.matchAll(/([`"'])([\s\S]*?)\1/g)) {
@@ -25,12 +25,49 @@ export function rootFieldMatches(file, name, guidance) {
25
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
26
  }
27
27
  };
28
- for (const operation of ast.definitions.filter(d=>d.kind===Kind.OPERATION_DEFINITION && d.operation === 'query')) visitRoot(operation.selectionSet.selections);
28
+ for (const definition of ast.definitions.filter(d=>d.kind===Kind.OPERATION_DEFINITION && d.operation === operation)) visitRoot(definition.selectionSet.selections);
29
29
  } catch { /* Unsupported or malformed documents are not evidence of this removal. */ }
30
30
  }
31
31
  return matches;
32
32
  }
33
33
 
34
+ // Match enum values only when they are literal inputs to the named mutation.
35
+ // Runtime variables and dynamically assembled documents remain outside this evidence.
36
+ export function mutationEnumMatches(file, mutationNames, argumentName, objectPath, enumValues, guidance) {
37
+ const documents = literalDocuments(file);
38
+ const matches = [];
39
+ for (const doc of documents) try {
40
+ const ast = parse(doc.text, { maxTokens: 50000 });
41
+ const fragments = new Map(ast.definitions.filter(d => d.kind === Kind.FRAGMENT_DEFINITION).map(d => [d.name.value, d]));
42
+ let visits = 0;
43
+ const findValue = (value) => {
44
+ let current = value;
45
+ for (const fieldName of objectPath) {
46
+ if (current?.kind !== Kind.OBJECT) return null;
47
+ current = current.fields.find(field => field.name.value === fieldName)?.value;
48
+ }
49
+ return current;
50
+ };
51
+ const visitRoot = (selections, stack = []) => {
52
+ for (const node of selections ?? []) {
53
+ if (++visits > 50000) throw new Error('GraphQL expansion limit');
54
+ if (node.kind === Kind.FIELD) {
55
+ if (!mutationNames.includes(node.name.value)) continue;
56
+ const argument = node.arguments?.find(item => item.name.value === argumentName);
57
+ const value = findValue(argument?.value);
58
+ if (value?.kind === Kind.ENUM && enumValues.includes(value.value)) {
59
+ const index = doc.offset + value.loc.start, before = file.text.slice(0, index);
60
+ matches.push({ file: file.relativePath, line: before.split('\n').length, column: index - before.lastIndexOf('\n'), snippet: value.value, guidance });
61
+ }
62
+ } else if (node.kind === Kind.INLINE_FRAGMENT) visitRoot(node.selectionSet.selections, stack);
63
+ 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]);
64
+ }
65
+ };
66
+ for (const definition of ast.definitions.filter(d => d.kind === Kind.OPERATION_DEFINITION && d.operation === 'mutation')) visitRoot(definition.selectionSet.selections);
67
+ } catch { /* Malformed or dynamic documents cannot establish a literal mutation input. */ }
68
+ return matches;
69
+ }
70
+
34
71
  // Existing field/type removals require a parseable literal document too.
35
72
  export function fieldMatches(file, fields, types, guidance) {
36
73
  const documents = [];
package/src/output.js CHANGED
@@ -6,8 +6,8 @@ export function formatHuman(result) {
6
6
  lines.push(...sectionLines(result.currentProblems.filter((finding) => !finding.baselined), 'Current debt'));
7
7
  lines.push('', 'Upgrade blockers');
8
8
  lines.push(...sectionLines(result.upgradeBlockers.filter((finding) => !finding.baselined), 'Target migration'));
9
- const introduced = active.filter((finding) => finding.changeStatus === 'introduced'); const existing = active.filter((finding) => finding.changeStatus === 'pre-existing');
10
- if (introduced.length || existing.length) lines.push('', `Introduced by this PR: ${introduced.length}`, `Pre-existing: ${existing.length}`);
9
+ const introduced = active.filter((finding) => finding.changeStatus === 'introduced'); const existing = active.filter((finding) => finding.changeStatus === 'pre-existing'); const resolved = result.resolvedFindings ?? [];
10
+ if (result.comparisonAvailable) lines.push('', `Introduced by this PR: ${introduced.length}`, `Pre-existing: ${existing.length}`, `Resolved by this PR: ${resolved.length}`);
11
11
  if (!active.length) lines.push('No upgrade risks found.', '', 'Result: scan passed'); else lines.push('', `${active.length} active risk${active.length === 1 ? '' : 's'} found`, `Baselined: ${result.baselinedCount}`, '', 'Result: migration recommended');
12
12
  return lines.join('\n');
13
13
  }
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, productVariantBarcodeMatches, segmentQueryMatches } from './graphql.js';
3
+ import { rootFieldMatches, fieldMatches, mutationEnumMatches, productVariantBarcodeMatches, segmentQueryMatches } from './graphql.js';
4
4
 
5
5
  const checkoutDeprecation = '2026-07';
6
6
  const removalVersion = '2026-10';
@@ -84,6 +84,20 @@ export const rules = [
84
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
85
  evaluate(match, context) { return removalAware(match, this, context); }
86
86
  },
87
+ {
88
+ id: 'UG-ADMIN-006', surface: 'admin_graphql_api', severity: 'error', title: 'marketCurrencySettingsUpdate mutation is removed', removedIn: '2027-01', confidence: 'high',
89
+ documentationUrl: 'https://shopify.dev/changelog/posts/removing-marketcurrencysettingsupdate-mutation', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql/2027-01/mutations/marketUpdate',
90
+ description: 'The Admin GraphQL marketCurrencySettingsUpdate mutation is removed in API version 2027-01. Set currency settings through marketCreate or marketUpdate instead.',
91
+ detect(file) { return rootFieldMatches(file, 'marketCurrencySettingsUpdate', 'Move currency settings into marketCreate or marketUpdate.', 'mutation'); },
92
+ evaluate(match, context) { return removalAware(match, this, context); }
93
+ },
94
+ {
95
+ id: 'UG-ADMIN-007', surface: 'admin_graphql_api', severity: 'error', title: 'Metaobject admin access enum value is removed', removedIn: '2027-01', confidence: 'high',
96
+ documentationUrl: 'https://shopify.dev/changelog/posts/removing-deprecated-private-and-public-read-enums-on-metaobject-definitions', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql/2027-01/input-objects/metaobjectaccessinput',
97
+ description: 'The MetaobjectAdminAccess values PRIVATE and PUBLIC_READ are removed in API version 2027-01. Review the intended access and use a currently supported value.',
98
+ detect(file) { return mutationEnumMatches(file, ['metaobjectDefinitionCreate', 'metaobjectDefinitionUpdate'], 'definition', ['access', 'admin'], ['PRIVATE', 'PUBLIC_READ'], 'Remove this obsolete access value and choose an intended value supported by the current MetaobjectAdminAccess input.'); },
99
+ evaluate(match, context) { return removalAware(match, this, context); }
100
+ },
87
101
  {
88
102
  id: 'UG-REST-001', surface: 'admin_rest_api', severity: 'warning', title: 'REST Admin API usage is legacy', confidence: 'high',
89
103
  documentationUrl: 'https://shopify.dev/docs/api/admin-rest', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql',
package/src/scan.js CHANGED
@@ -4,7 +4,7 @@ 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
- import { changedLines } from './git.js';
7
+ import { withBaseTree } from './git.js';
8
8
  import { safeRead } from './safe-file.js';
9
9
  import { validatePolicy } from './output.js';
10
10
 
@@ -14,6 +14,29 @@ export async function loadConfig(root) {
14
14
  }
15
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.'); }
16
16
  export async function scan(root, options = {}) {
17
+ root = resolve(root);
18
+ const head = await analyze(root, options);
19
+ const baseline = options.noBaseline ? { fingerprints: new Set(), entries: [], file: null } : await loadBaseline(root, options.baseline ?? head.baselinePath);
20
+ let findings = head.findings;
21
+ let resolvedFindings = [];
22
+ let comparisonAvailable = false;
23
+ if (options.baseRef) {
24
+ const comparison = await withBaseTree(root, options.baseRef, async (baseRoot, metadata) => ({
25
+ base: await analyze(baseRoot, { ...options, baseRef: undefined, noBaseline: true }),
26
+ renames: metadata.renames
27
+ }));
28
+ const classified = classifyFindings(comparison.base.findings, head.findings, comparison.renames);
29
+ findings = classified.findings;
30
+ resolvedFindings = classified.resolved;
31
+ comparisonAvailable = true;
32
+ }
33
+ let resultFindings = applyBaseline(findings, baseline);
34
+ if (!comparisonAvailable) resultFindings = resultFindings.map((finding) => ({ ...finding, lifecycle: finding.baselined ? 'EXISTING' : 'UNCHANGED' }));
35
+ const active = resultFindings.filter((finding) => !finding.baselined); const currentProblems = active.filter((finding) => finding.classification === 'current'); const upgradeBlockers = active.filter((finding) => finding.classification === 'target');
36
+ return { schemaVersion: 1, comparisonAvailable, skipped: head.skipped, tool: 'shopify-upgrade-guard', targetVersion: head.targetVersion, failOn: head.failOn, latestStable: latestStable.version, filesScanned: head.filesScanned, inventory: head.inventory, findings: resultFindings, resolvedFindings, currentProblems, upgradeBlockers, baselinedCount: resultFindings.filter((finding) => finding.baselined).length, resolvedBaselineCount: [...baseline.fingerprints].filter((fingerprint) => !resultFindings.some((finding) => finding.fingerprint === fingerprint)).length, baselineFile: baseline.file };
37
+ }
38
+
39
+ async function analyze(root, options = {}) {
17
40
  root = resolve(root);
18
41
  if (!(await lstat(root)).isDirectory()) throw new Error('Scan root must be a regular directory');
19
42
  const config = await loadConfig(root); const targetVersion = options.target ?? config.targetVersion ?? latestStable.version; const failOn = options.failOn ?? config.failOn ?? 'never'; targetRecord(targetVersion); validatePolicy(failOn);
@@ -24,14 +47,34 @@ export async function scan(root, options = {}) {
24
47
  const detected = rule.detect(file); const matches = detected.length ? attachFile(detected, file) : inventoryMatches(rule, inventory, file);
25
48
  for (const match of matches) { const evaluated = rule.evaluate(match, context); if (evaluated) findings.push(toFinding(rule, evaluated, targetVersion)); }
26
49
  }
27
- const baseline = options.noBaseline ? { fingerprints: new Set(), entries: [], file: null } : await loadBaseline(root, options.baseline ?? config.baseline); let resultFindings = applyBaseline(dedupe(findings), baseline);
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' })); }
30
- else resultFindings = resultFindings.map((finding) => ({ ...finding, lifecycle: finding.baselined ? 'EXISTING' : 'UNCHANGED' }));
31
- const active = resultFindings.filter((finding) => !finding.baselined); const currentProblems = active.filter((finding) => finding.classification === 'current'); const upgradeBlockers = active.filter((finding) => finding.classification === 'target');
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 };
50
+ return { targetVersion, failOn, skipped, filesScanned: files.length, inventory, findings: dedupe(findings), baselinePath: options.baseline ?? config.baseline };
33
51
  }
52
+
53
+ function classifyFindings(baseFindings, headFindings, renames) {
54
+ const identity = (finding, baseSide = false) => {
55
+ const file = baseSide ? renames.get(finding.file) ?? finding.file : finding.file;
56
+ const snippet = String(finding.snippet ?? '').replace(/\s+/g, ' ').trim();
57
+ return `${finding.ruleId}|${finding.surface}|${file}|${snippet}`;
58
+ };
59
+ const count = (items, baseSide) => {
60
+ const counts = new Map();
61
+ for (const item of items) { const key = identity(item, baseSide); counts.set(key, (counts.get(key) ?? 0) + 1); }
62
+ return counts;
63
+ };
64
+ const baseCounts = count(baseFindings, true); const headCounts = count(headFindings, false); const seenHead = new Map();
65
+ const findings = headFindings.map((finding) => {
66
+ const key = identity(finding); const occurrence = seenHead.get(key) ?? 0; seenHead.set(key, occurrence + 1);
67
+ const existing = occurrence < (baseCounts.get(key) ?? 0);
68
+ return { ...finding, changeStatus: existing ? 'pre-existing' : 'introduced', lifecycle: existing ? 'EXISTING' : 'NEW' };
69
+ });
70
+ const seenBase = new Map(); const resolved = [];
71
+ for (const finding of baseFindings) {
72
+ const key = identity(finding, true); const occurrence = seenBase.get(key) ?? 0; seenBase.set(key, occurrence + 1);
73
+ if (occurrence >= (headCounts.get(key) ?? 0)) resolved.push({ ...finding, changeStatus: 'resolved', lifecycle: 'RESOLVED', baselined: false });
74
+ }
75
+ return { findings, resolved };
76
+ }
77
+
34
78
  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.' })); }
35
79
  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 }; }
36
80
  function dedupe(findings) { return [...new Map(findings.map((finding) => [`${finding.ruleId}|${finding.file}|${finding.line}|${finding.snippet}`, finding])).values()]; }
37
- function isChanged(finding, ranges) { return Boolean(ranges?.get(finding.file)?.some((range) => finding.line >= range.start && finding.line <= range.end)); }
@@ -1,6 +1,6 @@
1
1
  // Generated from data/versions.json.
2
2
  export default {
3
- "verifiedAt": "2026-10-04",
3
+ "verifiedAt": "2026-10-14",
4
4
  "source": "https://shopify.dev/docs/api/usage/versioning",
5
5
  "versions": [
6
6
  {
@@ -25,7 +25,7 @@ export default {
25
25
  "version": "2025-10",
26
26
  "releaseDate": "2025-10-01",
27
27
  "accessibleUntil": "2026-10-16T15:00:00Z",
28
- "status": "unsupported"
28
+ "status": "stable"
29
29
  },
30
30
  {
31
31
  "version": "2026-01",
package/src/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated from package.json.
2
- export const TOOL_VERSION = "0.2.6";
2
+ export const TOOL_VERSION = "0.3.0";
package/src/versions.js CHANGED
@@ -14,8 +14,16 @@ export function compareVersions(left, right) { const a = parseVersion(left); con
14
14
  export function isBefore(left, right) { return compareVersions(left, right) < 0; }
15
15
  export function isAtOrAfter(left, right) { return compareVersions(left, right) >= 0; }
16
16
  export function versionRecord(value) { const parsed = parseVersion(value); return parsed ? versions.find((item) => item.version === parsed) ?? null : null; }
17
- export function statusFor(value) { const parsed = parseVersion(value); if (!parsed) return 'unknown'; return versionRecord(parsed)?.status ?? 'unknown/future'; }
17
+ export function statusFor(value, asOf = new Date()) {
18
+ const parsed = parseVersion(value);
19
+ if (!parsed) return 'unknown';
20
+ const record = versionRecord(parsed);
21
+ if (!record) return 'unknown/future';
22
+ const timestamp = asOf instanceof Date ? asOf.getTime() : Date.parse(asOf);
23
+ if (record.accessibleUntil && Number.isFinite(timestamp) && timestamp >= Date.parse(record.accessibleUntil)) return 'unsupported';
24
+ return record.status;
25
+ }
18
26
  export function isKnownVersion(value) { return Boolean(versionRecord(value)); }
19
- export function targetRecord(value) { const parsed = parseVersion(value); if (!parsed) throw new Error(`Invalid target version: ${value}. Expected a quarterly YYYY-MM version (01, 04, 07, or 10).`); return versionRecord(parsed) ?? { version: parsed, status: 'unknown/future', accessibleUntil: null }; }
27
+ export function targetRecord(value, asOf = new Date()) { const parsed = parseVersion(value); if (!parsed) throw new Error(`Invalid target version: ${value}. Expected a quarterly YYYY-MM version (01, 04, 07, or 10).`); const record = versionRecord(parsed); return record ? { ...record, status: statusFor(parsed, asOf) } : { version: parsed, status: 'unknown/future', accessibleUntil: null }; }
20
28
  export function becomesUnsupportedBefore(version, target) { const record = versionRecord(version); const targetVersion = targetRecord(target); return Boolean(record?.accessibleUntil && record.accessibleUntil.slice(0, 7) <= targetVersion.version); }
21
- export function versionSummary() { return versions; }
29
+ export function versionSummary(asOf = new Date()) { return versions.map((record) => ({ ...record, status: statusFor(record.version, asOf) })); }