shopify-upgrade-guard 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Efegokdemir
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Shopify Upgrade Guard
2
+
3
+ Shopify Upgrade Guard detects documented Shopify platform/API upgrade risks in your repository before they become production migrations.
4
+
5
+ > Unofficial open-source developer tooling. Not affiliated with, endorsed by, or certified by Shopify.
6
+
7
+ This repository is a `0.1.0` candidate. npm publication and a GitHub Release are intentionally pending until the core product passes its release gates.
8
+
9
+ ## Try it locally
10
+
11
+ ```bash
12
+ git clone https://github.com/efegokdemir/shopify-upgrade-guard.git
13
+ cd shopify-upgrade-guard
14
+ npm ci
15
+ npm run build:action
16
+ node src/cli.js scan --path /path/to/shopify-app --target 2026-10
17
+ ```
18
+
19
+ The scan is offline, read-only, deterministic, and uses bundled Shopify release evidence. `--target` changes rule evaluation: target-only risks are reported as upgrade blockers, while existing debt is reported separately.
20
+
21
+ ## GitHub Action
22
+
23
+ Pin the Action to a reviewed commit SHA in production:
24
+
25
+ ```yaml
26
+ permissions:
27
+ contents: read
28
+
29
+ steps:
30
+ - uses: actions/checkout@<immutable-sha>
31
+ - uses: efegokdemir/shopify-upgrade-guard@<immutable-sha>
32
+ with:
33
+ target: 2026-10
34
+ fail-on: warning
35
+ ```
36
+
37
+ Inputs: `target`, `fail-on` (`never`, `error`, `warning`, or `info`), and `path`.
38
+
39
+ Outputs: `outcome`, `finding-count`, `error-count`, `current-versions` (the structured inventory), and `result-json`.
40
+
41
+ The Action uses the current GitHub `node24` JavaScript Action runtime. Consumers do not install dependencies.
42
+
43
+ ## CLI
44
+
45
+ ```text
46
+ node src/cli.js scan [--target YYYY-MM] [--format human|json|sarif] [--fail-on ...]
47
+ node src/cli.js baseline [--path DIR]
48
+ node src/cli.js rules
49
+ node src/cli.js versions
50
+ node src/cli.js explain UG-CHECKOUT-001
51
+ ```
52
+
53
+ Exit codes: `0` means the scan completed and the policy passed; `1` means active findings exceeded the policy; `2` means the scanner could not complete.
54
+
55
+ ## Configuration
56
+
57
+ Commit `.upgradeguard.json` to set repository defaults:
58
+
59
+ ```json
60
+ {
61
+ "targetVersion": "2026-10",
62
+ "failOn": "warning",
63
+ "exclude": ["fixtures/**"]
64
+ }
65
+ ```
66
+
67
+ `exclude` accepts repository-relative glob patterns. Absolute paths and traversal are rejected. `baseline` writes `.upgradeguard-baseline.json`; baselined findings remain visible in JSON but do not fail policy, and new fingerprints remain actionable.
68
+
69
+ ## Rules and evidence
70
+
71
+ The initial pack is deliberately small and high-precision. Every finding includes an official source, affected version metadata where applicable, location, confidence, and migration guidance.
72
+
73
+ | ID | Detection | Official evidence |
74
+ | --- | --- | --- |
75
+ | UG-CHECKOUT-001 | `useBuyerJourneyIntercept` / `buyerJourney.intercept` in JS/TS | [Shopify changelog](https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions) |
76
+ | UG-CHECKOUT-002 | `block_progress` in an extension TOML | [Shopify changelog](https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions) |
77
+ | UG-REST-001 | Versioned REST Admin URL or explicit REST client marker | [REST Admin API](https://shopify.dev/docs/api/admin-rest) |
78
+ | UG-VERSION-001 | Unsupported or target-retiring version in the inventory | [API versioning](https://shopify.dev/docs/api/usage/versioning) |
79
+ | UG-VERSION-002 | Supported version older than latest stable | [API versioning](https://shopify.dev/docs/api/usage/versioning) |
80
+
81
+ Supported inventory surfaces currently include Admin REST, Admin GraphQL, Checkout UI extensions, Customer Account UI extensions, POS UI extensions, Functions, Shopify app TOML, and recognized Shopify client configuration.
82
+
83
+ ## Security and privacy
84
+
85
+ No telemetry, Shopify credentials, store access, or network access is required for ordinary scans. The tool reads bounded repository files, skips generated/vendor directories, does not execute repository code, and does not upload source. See [the threat model](docs/threat-model.md).
86
+
87
+ ## Roadmap and limitations
88
+
89
+ GraphQL schema deprecation validation, richer Functions/Customer Account/POS rules, and exact base-tree PR resolution require additional maintained evidence and are not claimed as implemented. See [ROADMAP.md](ROADMAP.md).
package/action.yml ADDED
@@ -0,0 +1,32 @@
1
+ name: Shopify Upgrade Guard
2
+ description: Detect documented Shopify API and platform upgrade risks in your repository
3
+ author: Efegokdemir
4
+ branding:
5
+ icon: shield
6
+ color: purple
7
+ inputs:
8
+ target:
9
+ description: Shopify target version, for example 2026-10
10
+ required: false
11
+ fail-on:
12
+ description: never, error, warning, or info
13
+ required: false
14
+ default: never
15
+ path:
16
+ description: Repository path to scan
17
+ required: false
18
+ default: .
19
+ runs:
20
+ using: node24
21
+ main: dist/index.js
22
+ outputs:
23
+ outcome:
24
+ description: Whether the configured failure policy passed
25
+ finding-count:
26
+ description: Number of active findings
27
+ error-count:
28
+ description: Number of active error findings
29
+ current-versions:
30
+ description: JSON repository version inventory
31
+ result-json:
32
+ description: Complete JSON scan result
@@ -0,0 +1,14 @@
1
+ {
2
+ "verifiedAt": "2026-09-27",
3
+ "source": "https://shopify.dev/docs/api/usage/versioning",
4
+ "versions": [
5
+ { "version": "2025-01", "releaseDate": "2025-01-01", "accessibleUntil": "2026-01-16T15:00:00Z", "status": "unsupported" },
6
+ { "version": "2025-04", "releaseDate": "2025-04-01", "accessibleUntil": "2026-04-16T15:00:00Z", "status": "unsupported" },
7
+ { "version": "2025-07", "releaseDate": "2025-07-01", "accessibleUntil": "2026-07-16T15:00:00Z", "status": "unsupported" },
8
+ { "version": "2025-10", "releaseDate": "2025-10-01", "accessibleUntil": "2026-10-16T15:00:00Z", "status": "stable" },
9
+ { "version": "2026-01", "releaseDate": "2026-01-01", "accessibleUntil": "2027-01-16T15:00:00Z", "status": "stable" },
10
+ { "version": "2026-04", "releaseDate": "2026-04-01", "accessibleUntil": "2027-04-16T15:00:00Z", "status": "stable" },
11
+ { "version": "2026-07", "releaseDate": "2026-07-01", "accessibleUntil": "2027-07-16T15:00:00Z", "status": "latest-stable" },
12
+ { "version": "2026-10", "releaseDate": "2026-10-01", "accessibleUntil": "2027-10-16T15:00:00Z", "status": "release-candidate" }
13
+ ]
14
+ }
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ import{createRequire as e}from"module";var n={24:n=>{n.exports=e(import.meta.url)("node:fs")},708:n=>{n.exports=e(import.meta.url)("node:process")},242:(e,n,t)=>{t.a(e,(async(e,n)=>{try{var r=t(24);var i=t(708);var s=t(787);var o=t(529);const input=e=>i.env[`INPUT_${e.toUpperCase().replaceAll("-","_")}`]||"";const setOutput=(e,n)=>{const t=`ghadelimiter_${Date.now()}`;r.appendFileSync(i.env.GITHUB_OUTPUT||"/dev/null",`${e}<<${t}\n${n}\n${t}\n`)};const e=input("target")||undefined;const a=input("fail-on")||"never";const c=input("path")||i.env.GITHUB_WORKSPACE||i.cwd();try{const n=await(0,s.S)(c,{target:e});const t=JSON.stringify(n);const u=n.findings.filter((e=>!e.baselined));setOutput("outcome",(0,o.W9)(n,a)?"failure":"success");setOutput("finding-count",String(u.length));setOutput("error-count",String(u.filter((e=>e.severity==="error")).length));setOutput("current-versions",JSON.stringify(n.inventory));setOutput("result-json",t);if(i.env.GITHUB_STEP_SUMMARY)r.appendFileSync(i.env.GITHUB_STEP_SUMMARY,`## Shopify Upgrade Guard\n\n${(0,o.L2)(n)}\n`);for(const e of u)if(e.file&&e.line)console.error(`::warning file=${e.file},line=${e.line},col=${e.column}::${e.title}`);if((0,o.W9)(n,a)){console.error(`::error::Upgrade findings exceeded --fail-on ${a}`);i.exitCode=1}}catch(e){console.error(`::error::${e.message}`);i.exitCode=2}n()}catch(e){n(e)}}),1)},529:(e,n,t)=>{t.d(n,{L2:()=>formatHuman,W9:()=>shouldFail});const r={error:0,warning:1,info:2};function activeFindings(e){return e.findings.filter((e=>!e.baselined))}function formatHuman(e){const n=activeFindings(e);const t=["Shopify Upgrade Guard","",`Target version: ${e.targetVersion}`,`Files scanned: ${e.filesScanned}`,""];t.push("Current repository");t.push(...sectionLines(e.currentProblems.filter((e=>!e.baselined)),"Current debt"));t.push("","Upgrade blockers");t.push(...sectionLines(e.upgradeBlockers.filter((e=>!e.baselined)),"Target migration"));const r=n.filter((e=>e.changeStatus==="introduced"));const i=n.filter((e=>e.changeStatus==="pre-existing"));if(r.length||i.length)t.push("",`Introduced by this PR: ${r.length}`,`Pre-existing: ${i.length}`);if(!n.length)t.push("No upgrade risks found.","","Result: scan passed");else t.push("",`${n.length} active risk${n.length===1?"":"s"} found`,`Baselined: ${e.baselinedCount}`,"","Result: migration recommended");return t.join("\n")}function sectionLines(e,n){if(!e.length)return[`No ${n.toLowerCase()}.`];const t=[];for(const n of[...e].sort(((e,n)=>r[e.severity]-r[n.severity])))t.push(`${n.severity.toUpperCase()} ${n.ruleId}`,n.title,` ${n.file}:${n.line}`,` ${n.reason??n.currentState}`,` Migration: ${n.migration}`,` Source: ${n.source}`,"");return t}function shouldFail(e,n="never"){if(n==="never")return false;return activeFindings(e).some((e=>n==="info"||n==="warning"&&e.severity!=="info"||n==="error"&&e.severity==="error"))}function toSarif(e){const n=new Map;for(const t of activeFindings(e))if(!n.has(t.ruleId))n.set(t.ruleId,{id:t.ruleId,shortDescription:{text:t.title},helpUri:t.source,properties:{severity:t.severity,confidence:t.confidence}});const t=[...n.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:t}},results:activeFindings(e).map((e=>({ruleId:e.ruleId,ruleIndex:t.findIndex((n=>n.id===e.ruleId)),level:e.severity==="error"?"error":e.severity==="warning"?"warning":"note",message:{text:`${e.title}. ${e.reason??e.migration}`},locations:[{physicalLocation:{artifactLocation:{uri:e.file},region:{startLine:e.line,startColumn:e.column}}}]})))}]}}},787:(n,t,r)=>{r.d(t,{S:()=>scan});const i=e(import.meta.url)("node:fs/promises");const s=e(import.meta.url)("node:path");const o=JSON.parse('{"sP":"https://shopify.dev/docs/api/usage/versioning","e4":[{"version":"2025-01","releaseDate":"2025-01-01","accessibleUntil":"2026-01-16T15:00:00Z","status":"unsupported"},{"version":"2025-04","releaseDate":"2025-04-01","accessibleUntil":"2026-04-16T15:00:00Z","status":"unsupported"},{"version":"2025-07","releaseDate":"2025-07-01","accessibleUntil":"2026-07-16T15:00:00Z","status":"unsupported"},{"version":"2025-10","releaseDate":"2025-10-01","accessibleUntil":"2026-10-16T15:00:00Z","status":"stable"},{"version":"2026-01","releaseDate":"2026-01-01","accessibleUntil":"2027-01-16T15:00:00Z","status":"stable"},{"version":"2026-04","releaseDate":"2026-04-01","accessibleUntil":"2027-04-16T15:00:00Z","status":"stable"},{"version":"2026-07","releaseDate":"2026-07-01","accessibleUntil":"2027-07-16T15:00:00Z","status":"latest-stable"},{"version":"2026-10","releaseDate":"2026-10-01","accessibleUntil":"2027-10-16T15:00:00Z","status":"release-candidate"}]}');const a=o.sP;const c=Object.freeze(o.e4);const u=c.find((e=>e.status==="latest-stable"));const l=/^(\d{4})-(01|04|07|10)$/;function parseVersion(e){if(typeof e!=="string")return null;const n=e.match(l);return n?`${n[1]}-${n[2]}`:null}function compareVersions(e,n){const t=parseVersion(e);const r=parseVersion(n);if(!t||!r)throw new Error(`Cannot compare invalid Shopify versions: ${e}, ${n}`);return t===r?0:t<r?-1:1}function isBefore(e,n){return compareVersions(e,n)<0}function isAtOrAfter(e,n){return compareVersions(e,n)>=0}function versionRecord(e){const n=parseVersion(e);return n?c.find((e=>e.version===n))??null:null}function statusFor(e){const n=parseVersion(e);if(!n)return"unknown";return versionRecord(n)?.status??"unknown/future"}function isKnownVersion(e){return Boolean(versionRecord(e))}function targetRecord(e){const n=parseVersion(e);if(!n)throw new Error(`Invalid target version: ${e}. Expected a quarterly YYYY-MM version (01, 04, 07, or 10).`);return versionRecord(n)??{version:n,status:"unknown/future",accessibleUntil:null}}function becomesUnsupportedBefore(e,n){const t=versionRecord(e);const r=targetRecord(n);return Boolean(t?.accessibleUntil&&t.accessibleUntil.slice(0,7)<=r.version)}function versionSummary(){return c}const d=new Set([".git","node_modules","dist","build","coverage","vendor",".next",".cache"]);const p=new Set([".js",".jsx",".ts",".tsx",".graphql",".gql",".toml",".json"]);async function discover(e,n={}){const t=[];const r=[];const o=n.exclude??[];async function walk(n){let a;try{a=await(0,i.readdir)(n,{withFileTypes:true})}catch(e){if(e.code==="ENOENT")return;throw e}for(const c of a){if(c.name.startsWith(".")&&c.name!==".upgradeguard.json")continue;const a=(0,s.join)(n,c.name);const u=(0,s.relative)(e,a);if(c.isDirectory()){if(!d.has(c.name)&&!isExcluded(u,o))await walk(a);continue}if(c.isSymbolicLink())continue;const l=c.name.endsWith(".toml")?".toml":(0,s.extname)(c.name);if(!p.has(l)||c.name.endsWith(".min.js")||isExcluded(u,o))continue;const f=await(0,i.lstat)(a);if(f.size>1024*1024)continue;const g=await(0,i.readFile)(a,"utf8");const h={path:a,relativePath:u,text:g};t.push(h);r.push(...inventoryFor(h))}}await walk(e);t.sort(((e,n)=>e.relativePath.localeCompare(n.relativePath)));r.sort(compareInventory);return{files:t,inventory:r}}function inventoryFor(e){const n=[];const t=e.relativePath.endsWith(".toml");const r=/(?:^|\/)shopify\.(?:app|extension)\.toml$/.test(e.relativePath);const i=r?matchVersion(e.text,/(?:^|\n)\s*api_version\s*=\s*["'](\d{4}-(?:0[147]|10))["']/g):[];for(const t of i)n.push({surface:surfaceForToml(e.relativePath,e.text),version:t.version,file:e.relativePath,line:t.line,source:"api_version"});if(!t){for(const t of matchVersion(e.text,/\/admin\/api\/(\d{4}-(?:0[147]|10))\/(?!graphql(?:\.json)?\b)/g))n.push({surface:"admin_rest_api",version:t.version,file:e.relativePath,line:t.line,source:"admin_api_url"});for(const t of matchVersion(e.text,/\/admin\/api\/(\d{4}-(?:0[147]|10))\/graphql(?:\.json)?\b/g))n.push({surface:"admin_graphql_api",version:t.version,file:e.relativePath,line:t.line,source:"admin_graphql_url"});if(/shopify/i.test(e.text))for(const t of matchVersion(e.text,/\b(?:apiVersion|api_version)\s*[:=]\s*["'](\d{4}-(?:0[147]|10))["']/g))n.push({surface:"shopify_client",version:t.version,file:e.relativePath,line:t.line,source:"client_configuration"})}return[...new Map(n.map((e=>[`${e.surface}|${e.version}|${e.file}`,e]))).values()]}function matchVersion(e,n){return[...e.matchAll(n)].map((n=>{const t=e.slice(0,n.index+n[0].indexOf(n[1]));return{version:parseVersion(n[1]),line:t.split("\n").length}}))}function surfaceForToml(e,n){const t=e.toLowerCase();if(t.includes("checkout")||/\bblock_progress\b/.test(n))return"checkout_ui_extension";if(t.includes("customer"))return"customer_account_ui_extension";if(t.includes("pos"))return"pos_ui_extension";if(t.includes("function"))return"shopify_function";return t.endsWith("shopify.app.toml")?"shopify_app":"extension"}function compareInventory(e,n){return`${e.file}:${e.line}:${e.surface}`.localeCompare(`${n.file}:${n.line}:${n.surface}`)}function isExcluded(e,n){return n.some((n=>globToRegExp(n).test(e)))}function validateExcludePattern(e){globToRegExp(e);return e}function globToRegExp(e){if(typeof e!=="string"||e.length>200||e.startsWith("/")||e.includes(".."))throw new Error(`Invalid exclude pattern: ${e}`);const n=e.replace(/[.+^${}()|[\]\\]/g,"\\$&").replace(/\*\*/g,"§").replace(/\*/g,"[^/]*").replace(/§/g,".*").replace(/\?/g,"[^/]");return new RegExp(`^${n}$`)}const f="2026-07";const g=[{id:"UG-CHECKOUT-001",surface:"checkout_ui_extension",severity:"warning",title:"Buyer journey intercept is deprecated",deprecatedIn:f,confidence:"high",documentationUrl:"https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions",migrationUrl:"https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions",description:"useBuyerJourneyIntercept and buyerJourney.intercept are deprecated from Checkout UI extension version 2026-07. Migrate validation to a cart and checkout validation Function.",detect(e){if(!/\.(?:[jt]sx?|graphql|gql)$/.test(e.relativePath))return[];return matches(maskCommentsAndStrings(e.text),/\b(?:useBuyerJourneyIntercept|buyerJourney\.intercept)\b/g,"Replace client-side blocking with a cart and checkout validation Function.")},evaluate(e,n){return versionAware(e,this,n)}},{id:"UG-CHECKOUT-002",surface:"checkout_ui_extension",severity:"warning",title:"Checkout block_progress capability is deprecated",deprecatedIn:f,confidence:"high",documentationUrl:"https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions",migrationUrl:"https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions",description:"The block_progress capability is deprecated from Checkout UI extension version 2026-07 and supports the deprecated buyer journey intercept API.",detect(e){if(!e.relativePath.endsWith("shopify.extension.toml"))return[];return matches(maskTomlComments(e.text),/^\s*block_progress\s*=\s*(?:true|false)\s*$/gm,"Move validation logic to a cart and checkout validation Function.")},evaluate(e,n){return versionAware(e,this,n)}},{id:"UG-REST-001",surface:"admin_rest_api",severity:"warning",title:"REST Admin API usage is legacy",confidence:"high",documentationUrl:"https://shopify.dev/docs/api/admin-rest",migrationUrl:"https://shopify.dev/docs/api/admin-graphql",description:"The REST Admin API is legacy. New public apps must use the GraphQL Admin API; existing integrations should plan migration where applicable.",detect(e){if(!/\.(?:[jt]sx?|graphql|gql)$/.test(e.relativePath))return[];const n=[/\/admin\/api\/(?:\d{4}-(?:0[147]|10)|latest|unstable)\/(?!graphql(?:\.json)?\b)/g];if(/shopify/i.test(e.text))n.push(/\b(?:restResources|Rest\s*Admin|adminRest)\b/g);const t=n.flatMap((n=>matches(e.text,n,"Prefer the GraphQL Admin API for new work and plan migration for this REST integration.")));return t.length?[t[0]]:[]},evaluate(e){return{...e,classification:"current",reason:this.description}}},{id:"UG-VERSION-001",surface:"versioned_api",severity:"error",title:"Shopify API version is unsupported",confidence:"high",documentationUrl:"https://shopify.dev/docs/api/usage/versioning",description:"Shopify may fall forward when a request targets an inaccessible version. Pin to a supported stable version and test the migration.",detect(){return[]},evaluate(e,n){const t=statusFor(e.version);const r=becomesUnsupportedBefore(e.version,n.targetVersion);if(t!=="unsupported"&&!r)return null;return{...e,classification:t==="unsupported"?"current":"target",reason:t==="unsupported"?this.description:`This version becomes inaccessible by target ${n.targetVersion}. ${this.description}`}}},{id:"UG-VERSION-002",surface:"versioned_api",severity:"info",title:"Shopify API version is not the latest stable",confidence:"high",documentationUrl:"https://shopify.dev/docs/api/usage/versioning",description:`The latest bundled stable version is ${u.version}. Older supported versions remain usable, but quarterly upgrades reduce migration risk.`,detect(){return[]},evaluate(e){if(statusFor(e.version)==="stable"&&e.version!==u.version)return{...e,classification:"current",reason:this.description};return null}}];function ruleById(e){return g.find((n=>n.id===e))}function versionAware(e,n,t){const r=t.inventory.find((e=>e.surface==="checkout_ui_extension"))?.version;const i=r&&isAtOrAfter(r,n.deprecatedIn);const s=isAtOrAfter(t.targetVersion,n.deprecatedIn);if(!i&&!s)return null;return{...e,classification:i?"current":"target",reason:i?n.description:`This API becomes deprecated before target ${t.targetVersion}. ${n.description}`}}function matches(e,n,t){return[...e.matchAll(n)].map((n=>{const r=e.slice(0,n.index);return{file:null,line:r.split("\n").length,column:n.index-r.lastIndexOf("\n"),snippet:n[0],guidance:t}}))}function attachFile(e,n){return e.map((e=>({...e,file:n.relativePath})))}function maskCommentsAndStrings(e){return e.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|(['"`])(?:\\.|(?!\1)[^\\])*\1/g,(e=>e.replace(/[^\n]/g," ")))}function maskTomlComments(e){return e.replace(/#[^\n]*/g,(e=>e.replace(/[^\n]/g," ")))}const h=e(import.meta.url)("node:crypto");const v=".upgradeguard-baseline.json";function fingerprint(e){return(0,h.createHash)("sha256").update(`${e.ruleId}|${e.surface}|${e.file}|${e.snippet.replace(/\s+/g," ").trim()}`).digest("hex").slice(0,16)}async function loadBaseline(e){try{const n=JSON.parse(await(0,i.readFile)((0,s.join)(e,v),"utf8"));if(!Array.isArray(n.findings))throw new Error("findings must be an array");return{fingerprints:new Set(n.findings.map((e=>e.fingerprint))),entries:n.findings}}catch(e){if(e.code==="ENOENT")return{fingerprints:new Set,entries:[]};throw new Error(`Invalid ${v}: ${e.message}`)}}async function writeBaseline(e,n){const t={schemaVersion:1,generatedAt:(new Date).toISOString(),findings:n.map((e=>({fingerprint:fingerprint(e),ruleId:e.ruleId,surface:e.surface,file:e.file}))).sort(((e,n)=>e.fingerprint.localeCompare(n.fingerprint)))};await writeFile(join(e,v),`${JSON.stringify(t,null,2)}\n`,"utf8");return t}function applyBaseline(e,n){return e.map((e=>({...e,fingerprint:fingerprint(e),baselined:n.fingerprints.has(fingerprint(e))})))}const m=e(import.meta.url)("node:child_process");async function changedLines(e,n){if(!n||n.includes("..")||!/^[A-Za-z0-9_./-]+$/.test(n))return null;const t=await runGit(e,["diff","--no-ext-diff","--unified=0",`${n}...HEAD`,"--"]);const r=new Map;let i=null;for(const e of t.split("\n")){if(e.startsWith("+++ b/")){i=e.slice(6);continue}const n=e.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);if(n&&i){const e=Number(n[1]);const t=Number(n[2]??1);r.set(i,[...r.get(i)??[],{start:e,end:e+Math.max(t,1)-1}])}}return r}async function runGit(e,n){return new Promise(((t,r)=>{const i=(0,m.spawn)("git",n,{cwd:e,stdio:["ignore","pipe","pipe"]});let s="";let o="";i.stdout.on("data",(e=>{s+=e}));i.stderr.on("data",(e=>{o+=e}));i.on("error",r);i.on("close",(e=>e===0?t(s):r(new Error(`git ${n[0]} failed: ${o.trim()}`))))}))}async function loadConfig(e){try{const n=JSON.parse(await(0,i.readFile)((0,s.join)(e,".upgradeguard.json"),"utf8"));validateConfig(n);return n}catch(e){if(e.code==="ENOENT")return{};throw new Error(`Invalid .upgradeguard.json: ${e.message}`)}}function validateConfig(e){if(e.targetVersion!==undefined&&!parseVersion(e.targetVersion))throw new Error("targetVersion must be a quarterly YYYY-MM version.");if(e.failOn!==undefined&&!["never","error","warning","info"].includes(e.failOn))throw new Error("failOn must be never, error, warning, or info.");if(e.exclude!==undefined&&(!Array.isArray(e.exclude)||e.exclude.some((e=>validateExcludePattern(e)===undefined))))throw new Error("exclude must be an array of repository-relative glob patterns.")}async function scan(e,n={}){const t=await loadConfig(e);const r=n.target??t.targetVersion??u.version;const i=n.failOn??t.failOn??"never";targetRecord(r);const s=n.exclude??t.exclude??[];s.forEach(validateExcludePattern);const{files:o,inventory:a}=await discover(e,{exclude:s});const c={root:e,targetVersion:r,inventory:a};const l=[];for(const e of o)for(const n of g){const t=n.detect(e);const i=t.length?attachFile(t,e):inventoryMatches(n,a,e);for(const e of i){const t=n.evaluate(e,c);if(t)l.push(toFinding(n,t,r))}}const d=await loadBaseline(e);let p=applyBaseline(dedupe(l),d);const f=n.baseRef??process.env.GITHUB_BASE_REF;if(f){try{const n=await changedLines(e,f);p=p.map((e=>({...e,changeStatus:isChanged(e,n)?"introduced":"pre-existing"})))}catch{}}const h=p.filter((e=>!e.baselined));const v=h.filter((e=>e.classification==="current"));const m=h.filter((e=>e.classification==="target"));return{schemaVersion:1,tool:"shopify-upgrade-guard",targetVersion:r,failOn:i,latestStable:u.version,filesScanned:o.length,inventory:a,findings:p,currentProblems:v,upgradeBlockers:m,baselinedCount:p.filter((e=>e.baselined)).length,resolvedBaselineCount:[...d.fingerprints].filter((e=>!p.some((n=>n.fingerprint===e)))).length}}function inventoryMatches(e,n,t){if(!e.id.startsWith("UG-VERSION"))return[];return n.filter((e=>e.file===t.relativePath)).map((e=>({file:e.file,line:e.line,column:1,snippet:`${e.source}: ${e.version}`,version:e.version,guidance:"Update this Shopify version to a supported stable release and test the migration."})))}function toFinding(e,n,t){return{ruleId:e.id,severity:e.severity,title:e.title,surface:e.surface,file:n.file,line:n.line,column:n.column,snippet:n.snippet,targetVersion:t,currentState:e.description,migration:n.guidance,source:e.documentationUrl,migrationUrl:e.migrationUrl??null,confidence:e.confidence,classification:n.classification,reason:n.reason,deprecatedIn:e.deprecatedIn??null,removedIn:e.removedIn??null}}function dedupe(e){return[...new Map(e.map((e=>[`${e.ruleId}|${e.file}|${e.line}|${e.snippet}`,e]))).values()]}function isChanged(e,n){return Boolean(n?.get(e.file)?.some((n=>e.line>=n.start&&e.line<=n.end)))}}};var t={};function __nccwpck_require__(e){var r=t[e];if(r!==undefined){return r.exports}var i=t[e]={exports:{}};var s=true;try{n[e](i,i.exports,__nccwpck_require__);s=false}finally{if(s)delete t[e]}return i.exports}(()=>{var e=typeof Symbol==="function"?Symbol("webpack queues"):"__webpack_queues__";var n=typeof Symbol==="function"?Symbol("webpack exports"):"__webpack_exports__";var t=typeof Symbol==="function"?Symbol("webpack error"):"__webpack_error__";var resolveQueue=e=>{if(e&&e.d<1){e.d=1;e.forEach((e=>e.r--));e.forEach((e=>e.r--?e.r++:e()))}};var wrapDeps=r=>r.map((r=>{if(r!==null&&typeof r==="object"){if(r[e])return r;if(r.then){var i=[];i.d=0;r.then((e=>{s[n]=e;resolveQueue(i)}),(e=>{s[t]=e;resolveQueue(i)}));var s={};s[e]=e=>e(i);return s}}var o={};o[e]=e=>{};o[n]=r;return o}));__nccwpck_require__.a=(r,i,s)=>{var o;s&&((o=[]).d=-1);var a=new Set;var c=r.exports;var u;var l;var d;var p=new Promise(((e,n)=>{d=n;l=e}));p[n]=c;p[e]=e=>(o&&e(o),a.forEach(e),p["catch"]((e=>{})));r.exports=p;i((r=>{u=wrapDeps(r);var i;var getResult=()=>u.map((e=>{if(e[t])throw e[t];return e[n]}));var s=new Promise((n=>{i=()=>n(getResult);i.r=0;var fnQueue=e=>e!==o&&!a.has(e)&&(a.add(e),e&&!e.d&&(i.r++,e.push(i)));u.map((n=>n[e](fnQueue)))}));return i.r?s:getResult()}),(e=>(e?d(p[t]=e):l(c),resolveQueue(o))));o&&o.d<0&&(o.d=0)}})();(()=>{__nccwpck_require__.d=(e,n)=>{for(var t in n){if(__nccwpck_require__.o(n,t)&&!__nccwpck_require__.o(e,t)){Object.defineProperty(e,t,{enumerable:true,get:n[t]})}}}})();(()=>{__nccwpck_require__.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n)})();if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=new URL(".",import.meta.url).pathname.slice(import.meta.url.match(/^file:\/\/\/\w:/)?1:0,-1)+"/";var r=__nccwpck_require__(242);r=await r;
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "shopify-upgrade-guard",
3
+ "version": "0.1.0",
4
+ "description": "Offline static analysis for Shopify platform and API upgrade risks.",
5
+ "type": "module",
6
+ "bin": {
7
+ "shopify-upgrade-guard": "src/cli.js",
8
+ "upgradeguard": "src/cli.js"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "data",
13
+ "action.yml",
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
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",
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",
24
+ "pack:check": "npm pack --dry-run"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/efegokdemir/shopify-upgrade-guard.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/efegokdemir/shopify-upgrade-guard/issues"
36
+ },
37
+ "keywords": [
38
+ "shopify",
39
+ "shopify-api",
40
+ "github-actions",
41
+ "developer-tools",
42
+ "migration",
43
+ "deprecation"
44
+ ],
45
+ "publishConfig": {
46
+ "provenance": true
47
+ },
48
+ "devDependencies": {
49
+ "@vercel/ncc": "^0.38.3",
50
+ "c8": "^10.1.3"
51
+ }
52
+ }
package/src/action.js ADDED
@@ -0,0 +1,14 @@
1
+ import * as fs from 'node:fs';
2
+ import process from 'node:process';
3
+ import { scan } from './scan.js';
4
+ import { formatHuman, shouldFail } from './output.js';
5
+ 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 target = input('target') || undefined; const threshold = input('fail-on') || 'never'; const root = input('path') || process.env.GITHUB_WORKSPACE || process.cwd();
8
+ try {
9
+ const result = await scan(root, { target }); const json = JSON.stringify(result); const active = result.findings.filter((finding) => !finding.baselined);
10
+ setOutput('outcome', shouldFail(result, threshold) ? 'failure' : 'success'); setOutput('finding-count', String(active.length)); setOutput('error-count', String(active.filter((item) => item.severity === 'error').length)); setOutput('current-versions', JSON.stringify(result.inventory)); setOutput('result-json', json);
11
+ 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}`);
13
+ if (shouldFail(result, threshold)) { console.error(`::error::Upgrade findings exceeded --fail-on ${threshold}`); process.exitCode = 1; }
14
+ } catch (error) { console.error(`::error::${error.message}`); process.exitCode = 2; }
@@ -0,0 +1,12 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+
5
+ export const BASELINE_FILE = '.upgradeguard-baseline.json';
6
+ 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
+ export async function loadBaseline(root) {
8
+ try { const parsed = JSON.parse(await readFile(join(root, BASELINE_FILE), 'utf8')); if (!Array.isArray(parsed.findings)) throw new Error('findings must be an array'); return { fingerprints: new Set(parsed.findings.map((item) => item.fingerprint)), entries: parsed.findings }; }
9
+ catch (error) { if (error.code === 'ENOENT') return { fingerprints: new Set(), entries: [] }; throw new Error(`Invalid ${BASELINE_FILE}: ${error.message}`); }
10
+ }
11
+ export async function writeBaseline(root, findings) { 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, BASELINE_FILE), `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); return payload; }
12
+ export function applyBaseline(findings, baseline) { return findings.map((finding) => ({ ...finding, fingerprint: fingerprint(finding), baselined: baseline.fingerprints.has(fingerprint(finding)) })); }
package/src/cli.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { scan } from './scan.js';
3
+ import { formatHuman, shouldFail, toSarif } from './output.js';
4
+ import { rules } from './rules.js';
5
+ import { versionSummary } from './versions.js';
6
+ import { writeBaseline } from './baseline.js';
7
+ import process from 'node:process';
8
+ const args = process.argv.slice(2); const command = args[0] ?? 'scan';
9
+ const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
10
+ 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 --exclude GLOB ignore a repository-relative path pattern\n --path DIR repository to scan'); process.exit(0); }
11
+ if (args.includes('--version')) { console.log('0.1.0'); process.exit(0); }
12
+ try {
13
+ 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); }
14
+ if (command === 'versions') { for (const item of versionSummary()) console.log(`${item.version} ${item.status} — accessible until ${item.accessibleUntil}`); process.exit(0); }
15
+ 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); }
16
+ if (command === 'baseline') { const root = value('--path') ?? process.cwd(); const result = await scan(root, { target: value('--target') }); const payload = await writeBaseline(root, result.findings); console.log(`Wrote ${payload.findings.length} findings to .upgradeguard-baseline.json`); process.exit(0); }
17
+ if (command !== 'scan') throw new Error(`Unknown command: ${command}`);
18
+ 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'), exclude: excludes.length ? excludes : undefined }); const format = value('--format') ?? 'human';
19
+ console.log(format === 'json' ? JSON.stringify(result, null, 2) : format === 'sarif' ? JSON.stringify(toSarif(result), null, 2) : formatHuman(result));
20
+ process.exitCode = shouldFail(result, result.failOn) ? 1 : 0;
21
+ } catch (error) { console.error(`shopify-upgrade-guard: ${error.message}`); process.exitCode = 2; }
@@ -0,0 +1,42 @@
1
+ import { readdir, readFile, lstat } from 'node:fs/promises';
2
+ import { join, relative, extname } from 'node:path';
3
+ import { parseVersion } from './versions.js';
4
+
5
+ export const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'vendor', '.next', '.cache']);
6
+ const supported = new Set(['.js', '.jsx', '.ts', '.tsx', '.graphql', '.gql', '.toml', '.json']);
7
+ export async function discover(root, options = {}) {
8
+ const files = []; const inventory = []; const excludes = options.exclude ?? [];
9
+ async function walk(directory) {
10
+ 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) {
13
+ if (entry.name.startsWith('.') && entry.name !== '.upgradeguard.json') continue;
14
+ const path = join(directory, entry.name); const relativePath = relative(root, path);
15
+ if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name) && !isExcluded(relativePath, excludes)) await walk(path); continue; }
16
+ if (entry.isSymbolicLink()) continue;
17
+ const extension = entry.name.endsWith('.toml') ? '.toml' : extname(entry.name);
18
+ 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;
20
+ const text = await readFile(path, 'utf8'); const file = { path, relativePath, text };
21
+ files.push(file); inventory.push(...inventoryFor(file));
22
+ }
23
+ }
24
+ await walk(root); files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); inventory.sort(compareInventory); return { files, inventory };
25
+ }
26
+ function inventoryFor(file) {
27
+ const entries = []; const toml = file.relativePath.endsWith('.toml'); const shopifyToml = /(?:^|\/)shopify\.(?:app|extension)\.toml$/.test(file.relativePath);
28
+ const tomlVersion = shopifyToml ? matchVersion(file.text, /(?:^|\n)\s*api_version\s*=\s*["'](\d{4}-(?:0[147]|10))["']/g) : [];
29
+ 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
+ if (!toml) {
31
+ for (const match of matchVersion(file.text, /\/admin\/api\/(\d{4}-(?:0[147]|10))\/(?!graphql(?:\.json)?\b)/g)) entries.push({ surface: 'admin_rest_api', version: match.version, file: file.relativePath, line: match.line, source: 'admin_api_url' });
32
+ for (const match of matchVersion(file.text, /\/admin\/api\/(\d{4}-(?:0[147]|10))\/graphql(?:\.json)?\b/g)) entries.push({ surface: 'admin_graphql_api', version: match.version, file: file.relativePath, line: match.line, source: 'admin_graphql_url' });
33
+ if (/shopify/i.test(file.text)) for (const match of matchVersion(file.text, /\b(?:apiVersion|api_version)\s*[:=]\s*["'](\d{4}-(?:0[147]|10))["']/g)) entries.push({ surface: 'shopify_client', version: match.version, file: file.relativePath, line: match.line, source: 'client_configuration' });
34
+ }
35
+ return [...new Map(entries.map((item) => [`${item.surface}|${item.version}|${item.file}`, item])).values()];
36
+ }
37
+ 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'; }
39
+ function compareInventory(a, b) { return `${a.file}:${a.line}:${a.surface}`.localeCompare(`${b.file}:${b.line}:${b.surface}`); }
40
+ function isExcluded(path, patterns) { return patterns.some((pattern) => globToRegExp(pattern).test(path)); }
41
+ export function validateExcludePattern(pattern) { globToRegExp(pattern); return pattern; }
42
+ function globToRegExp(pattern) { if (typeof pattern !== 'string' || pattern.length > 200 || pattern.startsWith('/') || pattern.includes('..')) throw new Error(`Invalid exclude pattern: ${pattern}`); const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*/g, '§').replace(/\*/g, '[^/]*').replace(/§/g, '.*').replace(/\?/g, '[^/]'); return new RegExp(`^${escaped}$`); }
package/src/git.js ADDED
@@ -0,0 +1,13 @@
1
+ import { spawn } from 'node:child_process';
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`, '--']);
5
+ const ranges = new Map(); let file = null;
6
+ for (const line of output.split('\n')) {
7
+ if (line.startsWith('+++ b/')) { file = line.slice(6); continue; }
8
+ const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
9
+ if (match && file) { const start = Number(match[1]); const count = Number(match[2] ?? 1); ranges.set(file, [...(ranges.get(file) ?? []), { start, end: start + Math.max(count, 1) - 1 }]); }
10
+ }
11
+ return ranges;
12
+ }
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()}`))); }); }
package/src/output.js ADDED
@@ -0,0 +1,18 @@
1
+ const order = { error: 0, warning: 1, info: 2 };
2
+ export function activeFindings(result) { return result.findings.filter((finding) => !finding.baselined); }
3
+ export function formatHuman(result) {
4
+ const active = activeFindings(result); const lines = ['Shopify Upgrade Guard', '', `Target version: ${result.targetVersion}`, `Files scanned: ${result.filesScanned}`, ''];
5
+ lines.push('Current repository');
6
+ lines.push(...sectionLines(result.currentProblems.filter((finding) => !finding.baselined), 'Current debt'));
7
+ lines.push('', 'Upgrade blockers');
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}`);
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
+ return lines.join('\n');
13
+ }
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') { if (threshold === 'never') return false; return activeFindings(result).some((finding) => threshold === 'info' || (threshold === 'warning' && finding.severity !== 'info') || (threshold === 'error' && finding.severity === 'error')); }
16
+ export function toSarif(result) {
17
+ 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}` }, locations: [{ physicalLocation: { artifactLocation: { uri: finding.file }, region: { startLine: finding.line, startColumn: finding.column } } }] })) }] }; }
package/src/rules.js ADDED
@@ -0,0 +1,51 @@
1
+ import { isAtOrAfter, statusFor, latestStable, becomesUnsupportedBefore } from './versions.js';
2
+
3
+ const checkoutDeprecation = '2026-07';
4
+ export const rules = [
5
+ {
6
+ id: 'UG-CHECKOUT-001', surface: 'checkout_ui_extension', severity: 'warning', title: 'Buyer journey intercept is deprecated', deprecatedIn: checkoutDeprecation, confidence: 'high',
7
+ documentationUrl: 'https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions', migrationUrl: 'https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions',
8
+ description: 'useBuyerJourneyIntercept and buyerJourney.intercept are deprecated from Checkout UI extension version 2026-07. Migrate validation to a cart and checkout validation Function.',
9
+ detect(file) { if (!/\.(?:[jt]sx?|graphql|gql)$/.test(file.relativePath)) return []; return matches(maskCommentsAndStrings(file.text), /\b(?:useBuyerJourneyIntercept|buyerJourney\.intercept)\b/g, 'Replace client-side blocking with a cart and checkout validation Function.'); },
10
+ evaluate(match, context) { return versionAware(match, this, context); }
11
+ },
12
+ {
13
+ id: 'UG-CHECKOUT-002', surface: 'checkout_ui_extension', severity: 'warning', title: 'Checkout block_progress capability is deprecated', deprecatedIn: checkoutDeprecation, confidence: 'high',
14
+ documentationUrl: 'https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions', migrationUrl: 'https://shopify.dev/changelog/deprecating-the-usebuyerjourneyintercept-api-on-checkout-ui-extensions',
15
+ description: 'The block_progress capability is deprecated from Checkout UI extension version 2026-07 and supports the deprecated buyer journey intercept API.',
16
+ detect(file) { if (!file.relativePath.endsWith('shopify.extension.toml')) return []; return matches(maskTomlComments(file.text), /^\s*block_progress\s*=\s*(?:true|false)\s*$/gm, 'Move validation logic to a cart and checkout validation Function.'); },
17
+ evaluate(match, context) { return versionAware(match, this, context); }
18
+ },
19
+ {
20
+ id: 'UG-REST-001', surface: 'admin_rest_api', severity: 'warning', title: 'REST Admin API usage is legacy', confidence: 'high',
21
+ documentationUrl: 'https://shopify.dev/docs/api/admin-rest', migrationUrl: 'https://shopify.dev/docs/api/admin-graphql',
22
+ description: 'The REST Admin API is legacy. New public apps must use the GraphQL Admin API; existing integrations should plan migration where applicable.',
23
+ detect(file) { if (!/\.(?:[jt]sx?|graphql|gql)$/.test(file.relativePath)) return []; const patterns = [/\/admin\/api\/(?:\d{4}-(?:0[147]|10)|latest|unstable)\/(?!graphql(?:\.json)?\b)/g]; if (/shopify/i.test(file.text)) patterns.push(/\b(?:restResources|Rest\s*Admin|adminRest)\b/g); const found = patterns.flatMap((pattern) => matches(file.text, pattern, 'Prefer the GraphQL Admin API for new work and plan migration for this REST integration.')); return found.length ? [found[0]] : []; },
24
+ evaluate(match) { return { ...match, classification: 'current', reason: this.description }; }
25
+ },
26
+ {
27
+ id: 'UG-VERSION-001', surface: 'versioned_api', severity: 'error', title: 'Shopify API version is unsupported', confidence: 'high',
28
+ documentationUrl: 'https://shopify.dev/docs/api/usage/versioning', description: 'Shopify may fall forward when a request targets an inaccessible version. Pin to a supported stable version and test the migration.',
29
+ detect() { return []; },
30
+ evaluate(match, context) { const currentStatus = statusFor(match.version); const targetRisk = becomesUnsupportedBefore(match.version, context.targetVersion); if (currentStatus !== 'unsupported' && !targetRisk) return null; return { ...match, classification: currentStatus === 'unsupported' ? 'current' : 'target', reason: currentStatus === 'unsupported' ? this.description : `This version becomes inaccessible by target ${context.targetVersion}. ${this.description}` }; }
31
+ },
32
+ {
33
+ id: 'UG-VERSION-002', surface: 'versioned_api', severity: 'info', title: 'Shopify API version is not the latest stable', confidence: 'high',
34
+ documentationUrl: 'https://shopify.dev/docs/api/usage/versioning', description: `The latest bundled stable version is ${latestStable.version}. Older supported versions remain usable, but quarterly upgrades reduce migration risk.`,
35
+ detect() { return []; },
36
+ evaluate(match) { if (statusFor(match.version) === 'stable' && match.version !== latestStable.version) return { ...match, classification: 'current', reason: this.description }; return null; }
37
+ }
38
+ ];
39
+
40
+ export function ruleById(id) { return rules.find((rule) => rule.id === id); }
41
+ function versionAware(match, rule, context) {
42
+ const current = context.inventory.find((item) => item.surface === 'checkout_ui_extension')?.version;
43
+ const currentAffected = current && isAtOrAfter(current, rule.deprecatedIn);
44
+ const targetAffected = isAtOrAfter(context.targetVersion, rule.deprecatedIn);
45
+ if (!currentAffected && !targetAffected) return null;
46
+ return { ...match, classification: currentAffected ? 'current' : 'target', reason: currentAffected ? rule.description : `This API becomes deprecated before target ${context.targetVersion}. ${rule.description}` };
47
+ }
48
+ 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 }; }); }
49
+ export function attachFile(matchesForFile, file) { return matchesForFile.map((match) => ({ ...match, file: file.relativePath })); }
50
+ function maskCommentsAndStrings(text) { return text.replace(/\/\/[^\n]*|\/\*[\s\S]*?\*\/|(['"`])(?:\\.|(?!\1)[^\\])*\1/g, (value) => value.replace(/[^\n]/g, ' ')); }
51
+ function maskTomlComments(text) { return text.replace(/#[^\n]*/g, (value) => value.replace(/[^\n]/g, ' ')); }
package/src/scan.js ADDED
@@ -0,0 +1,32 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { discover, validateExcludePattern } from './discovery.js';
4
+ import { rules, attachFile } from './rules.js';
5
+ import { latestStable, parseVersion, targetRecord } from './versions.js';
6
+ import { applyBaseline, loadBaseline } from './baseline.js';
7
+ import { changedLines } from './git.js';
8
+
9
+ 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
+ }
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.'); }
14
+ 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);
16
+ const exclude = options.exclude ?? config.exclude ?? []; exclude.forEach(validateExcludePattern);
17
+ const { files, inventory } = await discover(root, { exclude }); const context = { root, targetVersion, inventory };
18
+ const findings = [];
19
+ for (const file of files) for (const rule of rules) {
20
+ const detected = rule.detect(file); const matches = detected.length ? attachFile(detected, file) : inventoryMatches(rule, inventory, file);
21
+ for (const match of matches) { const evaluated = rule.evaluate(match, context); if (evaluated) findings.push(toFinding(rule, evaluated, targetVersion)); }
22
+ }
23
+ const baseline = await loadBaseline(root); 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' })); } catch { /* Local scans may not have the PR base available. */ } }
26
+ const active = resultFindings.filter((finding) => !finding.baselined); const currentProblems = active.filter((finding) => finding.classification === 'current'); const upgradeBlockers = active.filter((finding) => finding.classification === 'target');
27
+ 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 };
28
+ }
29
+ 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.' })); }
30
+ 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 }; }
31
+ function dedupe(findings) { return [...new Map(findings.map((finding) => [`${finding.ruleId}|${finding.file}|${finding.line}|${finding.snippet}`, finding])).values()]; }
32
+ function isChanged(finding, ranges) { return Boolean(ranges?.get(finding.file)?.some((range) => finding.line >= range.start && finding.line <= range.end)); }
@@ -0,0 +1,21 @@
1
+ import versionData from '../data/versions.json' with { type: 'json' };
2
+
3
+ export const VERSION_SOURCE = versionData.source;
4
+ export const versions = Object.freeze(versionData.versions);
5
+ export const latestStable = versions.find((item) => item.status === 'latest-stable');
6
+ const quarterly = /^(\d{4})-(01|04|07|10)$/;
7
+
8
+ export function parseVersion(value) {
9
+ if (typeof value !== 'string') return null;
10
+ const match = value.match(quarterly);
11
+ return match ? `${match[1]}-${match[2]}` : null;
12
+ }
13
+ export function compareVersions(left, right) { const a = parseVersion(left); const b = parseVersion(right); if (!a || !b) throw new Error(`Cannot compare invalid Shopify versions: ${left}, ${right}`); return a === b ? 0 : a < b ? -1 : 1; }
14
+ export function isBefore(left, right) { return compareVersions(left, right) < 0; }
15
+ export function isAtOrAfter(left, right) { return compareVersions(left, right) >= 0; }
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'; }
18
+ 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 }; }
20
+ 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; }