diff --git a/.github/workflows/Versioning.Checks.Job.yml b/.github/workflows/Versioning.Checks.Job.yml index 9d6ecf5fe..17c34983a 100644 --- a/.github/workflows/Versioning.Checks.Job.yml +++ b/.github/workflows/Versioning.Checks.Job.yml @@ -7,8 +7,15 @@ jobs: versioning-checks: name: Versioning Checks runs-on: ubuntu-latest + env: + MXC_VERSIONING_BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || github.event_name == 'push' && github.event.before || 'HEAD^' }} steps: - uses: actions/checkout@v4 + with: + # Full history so a pull-request base ref can be resolved. For a + # pull_request event the checkout action fetches the base branch too, + # so the gates can read files at the base commit directly. + fetch-depth: 0 - uses: actions/setup-node@v4 with: @@ -24,6 +31,10 @@ jobs: working-directory: scripts/versioning run: npm ci + - name: Test versioning gate logic + working-directory: scripts/versioning + run: npm test + - name: Check schema version sync run: node scripts/versioning/check-schema-versions.js diff --git a/scripts/versioning/check-tests-present.js b/scripts/versioning/check-tests-present.js new file mode 100644 index 000000000..640fffaf9 --- /dev/null +++ b/scripts/versioning/check-tests-present.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Guards the guard: fail when there are no test files to run. +// +// `node --test tests/*.test.js` exits 0 when the pattern matches nothing, so +// renaming or moving the tests directory would leave the versioning job green +// while executing nothing at all. Every other gate in this directory is only as +// trustworthy as its tests actually running, so this runs ahead of them. +// +// node scripts/versioning/check-tests-present.js + +const { readdirSync, existsSync } = require("fs"); +const { join, resolve } = require("path"); + +const testsDir = resolve(__dirname, "tests"); + +if (!existsSync(testsDir)) { + console.error( + `Test presence check FAILED: ${join("scripts", "versioning", "tests")} does not exist.` + ); + process.exit(1); +} + +// Read the entry types, not just the names. A *directory* named `foo.test.js` +// matches the suffix but runs nothing, so a name-only check would report the +// tests are present while `node --test` still executes nothing -- exactly the +// silent pass this script exists to prevent. +const files = readdirSync(testsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".test.js")) + .map((entry) => entry.name); + +if (files.length === 0) { + console.error( + "Test presence check FAILED: no *.test.js files found; the test step " + + "would report success without executing anything." + ); + process.exit(1); +} + +console.log(`Test presence OK: ${files.length} test file(s).`); diff --git a/scripts/versioning/lib/git-base.js b/scripts/versioning/lib/git-base.js new file mode 100644 index 000000000..f50c16110 --- /dev/null +++ b/scripts/versioning/lib/git-base.js @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const { execFileSync, spawnSync } = require("child_process"); +const { isAbsolute, resolve } = require("path"); + +const GIT_MAX_BUFFER = 100 * 1024 * 1024; + +function argumentValue(argv, name) { + const index = argv.indexOf(name); + if (index < 0) return null; + if (!argv[index + 1]) throw new Error(`${name} requires a ref`); + return argv[index + 1]; +} + +function requestedBaseRef(argv = process.argv.slice(2), env = process.env) { + const fromArgs = argumentValue(argv, "--base-ref"); + if (fromArgs) return fromArgs; + if (env.MXC_VERSIONING_BASE_REF) return env.MXC_VERSIONING_BASE_REF; + if (env.GITHUB_ACTIONS) { + throw new Error( + "MXC_VERSIONING_BASE_REF is required in GitHub Actions; refusing to skip history checks" + ); + } + return null; +} + +function git(repoRoot, args, { trim = true } = {}) { + const output = execFileSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + maxBuffer: GIT_MAX_BUFFER, + stdio: ["ignore", "pipe", "pipe"], + }); + return trim ? output.trimEnd() : output; +} + +function refExists(repoRoot, ref) { + return ( + spawnSync("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + cwd: repoRoot, + stdio: "ignore", + }).status === 0 + ); +} + +function resolveBaseCommit( + repoRoot, + { argv = process.argv.slice(2), env = process.env } = {} +) { + let ref = requestedBaseRef(argv, env); + if (ref) { + if (!refExists(repoRoot, ref)) { + throw new Error(`versioning base ref "${ref}" is unavailable`); + } + } else { + ref = ["origin/main", "HEAD^"].find((candidate) => + refExists(repoRoot, candidate) + ); + if (!ref) { + throw new Error( + "could not resolve a versioning base; pass --base-ref or set MXC_VERSIONING_BASE_REF" + ); + } + } + + let commit; + try { + commit = git(repoRoot, ["merge-base", "HEAD", ref]); + } catch (error) { + throw new Error( + `could not compute merge-base between HEAD and "${ref}": ${error.message}` + ); + } + if (!commit) throw new Error(`empty merge-base for HEAD and "${ref}"`); + return { ref, commit }; +} + +// Git speaks repository-relative paths with forward slashes. Callers on Windows +// naturally produce backslashes (path.join), which would never match `ls-tree` +// output and would make an existing file look absent. +// +// Non-canonical spellings fail the same way: `.` and `..` segments and doubled +// slashes are all meaningful to the filesystem but never appear in git's own +// output, so they are collapsed here rather than compared literally. +function toGitPath(path) { + if (typeof path !== "string") { + throw new TypeError(`path must be a string, got ${typeof path}`); + } + const text = path.split("\\").join("/"); + const segments = []; + for (const segment of text.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + // A `..` with nothing to pop escapes the repository root, so it can never + // name a tracked file. Throw rather than silently normalising it away, + // which would make an out-of-tree path read as an in-tree one. + if (segments.length === 0) { + throw new Error(`path "${path}" escapes the repository root`); + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +// Reduces a caller's path to the repository-relative form git expects. The +// natural call shape is `path.join(repoRoot, ...)`, which yields an absolute +// path that matches no `ls-tree` entry, so an absolute path inside the +// repository is rebased onto its root and one outside it is refused. +// +// Refusing is the point: returning a value that simply fails to match would be +// reported as "the file is not present at that commit", and a gate reading that +// as "newly added, nothing to compare" would pass without checking anything. +function repoRelativeGitPath(repoRoot, path) { + const normalized = toGitPath(path); + const root = toGitPath(resolve(repoRoot)); + if (!isAbsolute(path.split("\\").join("/")) && !hasDriveLetter(normalized)) { + return normalized; + } + const caseInsensitive = process.platform === "win32" || hasDriveLetter(root); + const comparablePath = caseInsensitive ? normalized.toLowerCase() : normalized; + const comparableRoot = caseInsensitive ? root.toLowerCase() : root; + if (comparablePath === comparableRoot) return ""; + const prefix = `${comparableRoot}/`; + if (!comparablePath.startsWith(prefix)) { + throw new Error( + `path "${path}" is outside the repository root "${repoRoot}"` + ); + } + return normalized.slice(root.length + 1); +} + +function hasDriveLetter(path) { + return /^[A-Za-z]:\//.test(path); +} + +function listFilesAtCommit(repoRoot, commit, path) { + // `-z` gives NUL-delimited, unquoted names. Without it git C-quotes any path + // containing non-ASCII bytes, quotes, backslashes or control characters, and + // the literal comparison below would silently miss it. + const gitPath = repoRelativeGitPath(repoRoot, path); + const args = [ + "--literal-pathspecs", + "ls-tree", + "-r", + "-z", + "--name-only", + commit, + ]; + if (gitPath) args.push("--", gitPath); + const output = git( + repoRoot, + args, + { trim: false } + ); + return output ? output.split("\0").filter(Boolean) : []; +} + +/// Returns the file's content at `commit`, or `null` when the file does not +/// exist there. Throws when git itself fails, or when the path cannot name a +/// file in this repository at all, so neither a lookup error nor a malformed +/// path is ever mistaken for an absent file. +function readFileAtCommit(repoRoot, commit, path) { + const gitPath = repoRelativeGitPath(repoRoot, path); + if (!listFilesAtCommit(repoRoot, commit, gitPath).includes(gitPath)) { + return null; + } + return git(repoRoot, ["show", `${commit}:${gitPath}`], { trim: false }); +} + +module.exports = { + listFilesAtCommit, + readFileAtCommit, + repoRelativeGitPath, + requestedBaseRef, + resolveBaseCommit, + toGitPath, +}; diff --git a/scripts/versioning/lib/version.js b/scripts/versioning/lib/version.js new file mode 100644 index 000000000..e9dcad27c --- /dev/null +++ b/scripts/versioning/lib/version.js @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Numeric identifiers must not have leading zeros, per SemVer, and must stay +// exactly representable. An unbounded digit run converts to Infinity, and +// Infinity - Infinity is NaN, for which the ordinary `< 0` / `> 0` regression +// checks are all false -- a malformed version would silently pass every +// ordering gate. Reject rather than compare something we cannot order. +function parseNumericIdentifier(text) { + if (!/^(0|[1-9]\d*)$/.test(text)) return null; + const value = Number(text); + return Number.isSafeInteger(value) ? value : null; +} + +// SemVer identifier grammars. A prerelease identifier is alphanumeric-or-hyphen +// and, when purely numeric, must not carry leading zeros. Build metadata uses +// the same character set but allows leading zeros, because it never participates +// in ordering. +const PRERELEASE_IDENTIFIER = /^(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)$/; +const BUILD_IDENTIFIER = /^[0-9A-Za-z-]+$/; + +// Orders two purely numeric prerelease identifiers without converting them to +// `Number`. SemVer leaves these unbounded, so conversion loses precision well +// before the digits run out: two distinct values above 2^53 become the same +// float, and long ones become `Infinity`, whose difference is `NaN` -- falsy, +// so the comparison would report equality and an ordering gate would fail open. +// +// The grammar above has already rejected leading zeros, so both operands are +// canonical: the one with more digits is larger, and at equal length a +// lexicographic comparison is a numeric one. That is exact at any length, so +// unlike the core components these identifiers need no bound. +function compareNumericIdentifiers(left, right) { + if (left.length !== right.length) return left.length < right.length ? -1 : 1; + if (left === right) return 0; + return left < right ? -1 : 1; +} + +function validDotSeparated(text, identifier) { + const parts = text.split("."); + return parts.length > 0 && parts.every((part) => identifier.test(part)); +} + +function parseVersion(value) { + // Only a real string is parsed. `RegExp.exec` coerces its argument, so a + // single-element array, a boxed String, or any object with a `toString` would + // otherwise parse -- and `raw` would then carry the non-string, breaking + // round-tripping as well. + if (typeof value !== "string") return null; + const match = + /^(\d+)\.(\d+)\.(\d+)(?:-([^+]*))?(?:\+(.*))?$/.exec(value || ""); + if (!match) return null; + const major = parseNumericIdentifier(match[1]); + const minor = parseNumericIdentifier(match[2]); + const patch = parseNumericIdentifier(match[3]); + if (major === null || minor === null || patch === null) return null; + const prerelease = match[4]; + if ( + prerelease !== undefined && + !validDotSeparated(prerelease, PRERELEASE_IDENTIFIER) + ) { + return null; + } + const build = match[5]; + if (build !== undefined && !validDotSeparated(build, BUILD_IDENTIFIER)) { + return null; + } + return { + major, + minor, + patch, + prerelease: prerelease || "", + // Kept for round-tripping only. SemVer requires build metadata to be + // accepted and then ignored when determining precedence, so it is + // deliberately absent from every comparison below. + build: build || "", + raw: value, + }; +} + +function comparePrerelease(a, b) { + if (a === b) return 0; + if (!a) return 1; + if (!b) return -1; + + const left = a.split("."); + const right = b.split("."); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + if (left[i] === undefined) return -1; + if (right[i] === undefined) return 1; + + const leftNumeric = /^\d+$/.test(left[i]); + const rightNumeric = /^\d+$/.test(right[i]); + if (leftNumeric && rightNumeric) { + const difference = compareNumericIdentifiers(left[i], right[i]); + if (difference) return difference; + } else if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } else if (left[i] !== right[i]) { + return left[i] < right[i] ? -1 : 1; + } + } + return 0; +} + +// A component the parsers can actually produce: a non-negative safe integer. +// `Number.isSafeInteger` alone admits negatives, which no parser here emits, so +// a hand-built or mutated object would be ordered as a valid version instead of +// being rejected. +function isVersionComponent(value) { + return Number.isSafeInteger(value) && value >= 0; +} + +// Guards against being handed a raw string or a failed parse. Reading `.major` +// off either yields undefined, every comparison below is then false, and the +// caller is told the versions are equal -- the same fail-open shape as an +// unorderable numeric identifier. +// +// This is the major/minor-only check, which is all `compareMajorMinor` needs and +// so is also what a `parseMajorMinor` result satisfies. +function assertParsed(value, label) { + if ( + !value || + typeof value !== "object" || + !isVersionComponent(value.major) || + !isVersionComponent(value.minor) + ) { + throw new TypeError( + `${label} must be a parsed version object (from parseVersion / parseMajorMinor), got ${JSON.stringify(value)}` + ); + } +} + +// The stricter check for full-version ordering. A `parseMajorMinor` result +// carries no `patch` or `prerelease`, so it passes `assertParsed` and then makes +// `compareVersions` subtract `undefined` -- yielding `NaN`, for which both `< 0` +// and `> 0` are false. That is precisely the fail-open the guard exists to stop, +// so require the fields this comparison actually reads. Callers holding a +// version line rather than a full version want `compareMajorMinor`. +// +// The prerelease is checked against the same grammar `parseVersion` applies, not +// merely for being a string: `comparePrerelease` splits on "." and orders the +// identifiers, so a value the parser would have rejected -- "01", "alpha..1", a +// value with a space -- would otherwise be ordered rather than refused, and the +// guard would drift from the parser it is meant to stand in for. +function assertFullVersion(value, label) { + assertParsed(value, label); + const wellFormedPrerelease = + typeof value.prerelease === "string" && + (value.prerelease === "" || + validDotSeparated(value.prerelease, PRERELEASE_IDENTIFIER)); + if (!isVersionComponent(value.patch) || !wellFormedPrerelease) { + throw new TypeError( + `${label} must be a full parsed version (from parseVersion); use compareMajorMinor to compare version lines, got ${JSON.stringify(value)}` + ); + } +} + +function compareVersions(a, b) { + assertFullVersion(a, "a"); + assertFullVersion(b, "b"); + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + // Build metadata is intentionally not consulted: SemVer gives it no + // precedence, so 1.2.3+a and 1.2.3+b are the same version. + return comparePrerelease(a.prerelease, b.prerelease); +} + +function majorMinor(value) { + if (typeof value === "string") { + const parsed = parseVersion(value); + return parsed ? `${parsed.major}.${parsed.minor}` : null; + } + if (value === null || value === undefined) return null; + assertParsed(value, "value"); + return `${value.major}.${value.minor}`; +} + +function parseMajorMinor(value) { + // Same coercion guard as parseVersion. + if (typeof value !== "string") return null; + const match = /^(\d+)\.(\d+)$/.exec(value || ""); + if (!match) return null; + const major = parseNumericIdentifier(match[1]); + const minor = parseNumericIdentifier(match[2]); + if (major === null || minor === null) return null; + return { major, minor, raw: value }; +} + +function compareMajorMinor(a, b) { + assertParsed(a, "a"); + assertParsed(b, "b"); + if (a.major !== b.major) return a.major - b.major; + return a.minor - b.minor; +} + +module.exports = { + compareMajorMinor, + compareVersions, + majorMinor, + parseMajorMinor, + parseVersion, +}; diff --git a/scripts/versioning/package.json b/scripts/versioning/package.json index fde1cc299..36e9f0c70 100644 --- a/scripts/versioning/package.json +++ b/scripts/versioning/package.json @@ -4,6 +4,8 @@ "private": true, "description": "Repository tooling: version-sync and config schema validation gates. Not published.", "scripts": { + "pretest": "node check-tests-present.js", + "test": "node --test tests/*.test.js", "check-schema-versions": "node check-schema-versions.js", "validate-configs": "node validate-configs.js" }, diff --git a/scripts/versioning/tests/check-tests-present.test.js b/scripts/versioning/tests/check-tests-present.test.js new file mode 100644 index 000000000..808c7dadd --- /dev/null +++ b/scripts/versioning/tests/check-tests-present.test.js @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { spawnSync } = require("child_process"); +const { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } = require("fs"); +const { join, resolve } = require("path"); +const { tmpdir } = require("os"); + +const SCRIPT = resolve(__dirname, "..", "check-tests-present.js"); + +// The script resolves its tests directory relative to its own location, so it is +// copied into a scratch layout and run as a real process. That also exercises +// the exit code, which is the only thing CI actually reads. +function runAgainst(populate) { + const dir = mkdtempSync(join(tmpdir(), "check-tests-present-")); + try { + copyFileSync(SCRIPT, join(dir, "check-tests-present.js")); + populate(dir); + const result = spawnSync(process.execPath, [join(dir, "check-tests-present.js")], { + encoding: "utf8", + }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test("the presence check passes when a real test file exists", () => { + const { status, output } = runAgainst((dir) => { + mkdirSync(join(dir, "tests")); + writeFileSync(join(dir, "tests", "a.test.js"), "// test\n"); + }); + assert.equal(status, 0, output); + assert.match(output, /Test presence OK: 1 test file\(s\)\./); +}); + +test("the presence check fails when the tests directory is missing or empty", () => { + const missing = runAgainst(() => {}); + assert.equal(missing.status, 1, missing.output); + assert.match(missing.output, /does not exist/); + + const empty = runAgainst((dir) => mkdirSync(join(dir, "tests"))); + assert.equal(empty.status, 1, empty.output); + assert.match(empty.output, /no \*\.test\.js files found/); +}); + +test("a directory named like a test file does not satisfy the presence check", () => { + // `readdirSync` returns entry names, so a suffix-only check would count a + // directory called `foo.test.js` as a test -- reporting that tests are present + // while `node --test` still executes nothing, which is the exact silent pass + // this script exists to prevent. + const { status, output } = runAgainst((dir) => { + mkdirSync(join(dir, "tests", "placeholder.test.js"), { recursive: true }); + }); + assert.equal(status, 1, output); + assert.match(output, /no \*\.test\.js files found/); +}); + +test("a real test file is still found alongside such a directory", () => { + const { status, output } = runAgainst((dir) => { + mkdirSync(join(dir, "tests", "placeholder.test.js"), { recursive: true }); + writeFileSync(join(dir, "tests", "real.test.js"), "// test\n"); + }); + assert.equal(status, 0, output); + assert.match(output, /Test presence OK: 1 test file\(s\)\./); +}); diff --git a/scripts/versioning/tests/git-base-integration.test.js b/scripts/versioning/tests/git-base-integration.test.js new file mode 100644 index 000000000..1b4c132e3 --- /dev/null +++ b/scripts/versioning/tests/git-base-integration.test.js @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Integration coverage for the git helpers, against throwaway repositories. +// The interesting behaviour is fail-closed resolution and path handling, none of +// which can be exercised without a real repository. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { execFileSync } = require("child_process"); +const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require("fs"); +const { tmpdir } = require("os"); +const { join } = require("path"); +const { + listFilesAtCommit, + readFileAtCommit, + repoRelativeGitPath, + resolveBaseCommit, + toGitPath, +} = require("../lib/git-base"); + +function git(cwd, args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +// Build a repository with two commits and return its path. +function scratchRepo(setup) { + const dir = mkdtempSync(join(tmpdir(), "git-base-test-")); + git(dir, ["init", "-q", "-b", "main"]); + git(dir, ["config", "user.email", "test@example.com"]); + git(dir, ["config", "user.name", "Test"]); + setup(dir); + return dir; +} + +function commitAll(dir, message) { + git(dir, ["add", "-A"]); + git(dir, ["commit", "-q", "-m", message]); +} + +test("toGitPath normalises Windows separators", () => { + assert.equal(toGitPath("schemas\\dev\\a.json"), "schemas/dev/a.json"); + assert.equal(toGitPath("schemas/dev/a.json"), "schemas/dev/a.json"); + assert.equal(toGitPath("./a.json"), "a.json"); +}); + +test("toGitPath rejects non-string paths", () => { + for (const path of [null, undefined, 42, ["schemas", "a.json"], {}]) { + assert.throws(() => toGitPath(path), TypeError); + } +}); + +test("readFileAtCommit reads a file using either separator", () => { + const dir = scratchRepo((d) => { + mkdirSync(join(d, "schemas", "dev"), { recursive: true }); + writeFileSync(join(d, "schemas", "dev", "a.json"), '{"v":1}\n'); + commitAll(d, "add"); + }); + try { + // A Windows caller naturally produces backslashes; without normalisation the + // literal comparison against ls-tree output misses and the file reads as + // absent, which a gate cannot distinguish from "genuinely deleted". + assert.equal(readFileAtCommit(dir, "HEAD", "schemas\\dev\\a.json"), '{"v":1}\n'); + assert.equal(readFileAtCommit(dir, "HEAD", "schemas/dev/a.json"), '{"v":1}\n'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("readFileAtCommit finds a path that git would C-quote", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "sch\u00e9ma.json"), "{}\n"); + commitAll(d, "add"); + }); + try { + // Without -z, git renders this name as "sch\303\251ma.json" and the literal + // membership check fails open. + assert.equal(readFileAtCommit(dir, "HEAD", "sch\u00e9ma.json"), "{}\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("readFileAtCommit returns null for a genuinely absent file", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.json"), "{}\n"); + commitAll(d, "add"); + }); + try { + assert.equal(readFileAtCommit(dir, "HEAD", "missing.json"), null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("readFileAtCommit handles output larger than Node's default buffer", () => { + const content = "x".repeat(2 * 1024 * 1024); + const dir = scratchRepo((d) => { + writeFileSync(join(d, "large.txt"), content); + commitAll(d, "add"); + }); + try { + assert.equal(readFileAtCommit(dir, "HEAD", "large.txt"), content); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("listFilesAtCommit lists tracked files under a directory", () => { + const dir = scratchRepo((d) => { + mkdirSync(join(d, "schemas"), { recursive: true }); + writeFileSync(join(d, "schemas", "a.json"), "{}\n"); + writeFileSync(join(d, "schemas", "b.json"), "{}\n"); + commitAll(d, "add"); + }); + try { + const files = listFilesAtCommit(dir, "HEAD", "schemas").sort(); + assert.deepEqual(files, ["schemas/a.json", "schemas/b.json"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("listFilesAtCommit lists the repository root", () => { + const dir = scratchRepo((d) => { + mkdirSync(join(d, "schemas"), { recursive: true }); + writeFileSync(join(d, "root.json"), "{}\n"); + writeFileSync(join(d, "schemas", "nested.json"), "{}\n"); + commitAll(d, "add"); + }); + try { + assert.deepEqual(listFilesAtCommit(dir, "HEAD", dir).sort(), [ + "root.json", + "schemas/nested.json", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("listFilesAtCommit treats pathspec magic as a literal filename", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.txt"), "ordinary\n"); + writeFileSync(join(d, "[ab].txt"), "magic-looking\n"); + commitAll(d, "add"); + }); + try { + assert.deepEqual( + listFilesAtCommit(dir, "HEAD", "[ab].txt"), + ["[ab].txt"] + ); + assert.equal( + readFileAtCommit(dir, "HEAD", "[ab].txt"), + "magic-looking\n" + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveBaseCommit honours an explicit base ref", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.txt"), "one\n"); + commitAll(d, "first"); + // Tag the first commit under a name the resolver's fallback would never + // choose, so this asserts the injected ref was actually used rather than + // passing by coincidence when the fallback lands on the same commit. + git(d, ["branch", "injected-base"]); + writeFileSync(join(d, "a.txt"), "two\n"); + commitAll(d, "second"); + }); + try { + const first = git(dir, ["rev-parse", "injected-base"]); + const base = resolveBaseCommit(dir, { + argv: [], + env: { MXC_VERSIONING_BASE_REF: "injected-base" }, + }); + assert.equal(base.ref, "injected-base"); + assert.equal(base.commit, first); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveBaseCommit takes its options as a single object", () => { + // The options are `{ argv, env }`; passing them positionally silently falls + // back to `process.argv` / `process.env`, which reads whatever the ambient + // job happens to set and makes these tests depend on their environment. + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.txt"), "one\n"); + commitAll(d, "first"); + git(d, ["branch", "injected-base"]); + writeFileSync(join(d, "a.txt"), "two\n"); + commitAll(d, "second"); + }); + try { + // An ambient value must not win over the injected one. + const previous = process.env.MXC_VERSIONING_BASE_REF; + process.env.MXC_VERSIONING_BASE_REF = "origin/definitely-not-here"; + try { + const base = resolveBaseCommit(dir, { + argv: [], + env: { MXC_VERSIONING_BASE_REF: "injected-base" }, + }); + assert.equal(base.ref, "injected-base"); + } finally { + if (previous === undefined) delete process.env.MXC_VERSIONING_BASE_REF; + else process.env.MXC_VERSIONING_BASE_REF = previous; + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveBaseCommit fails closed in CI when the base ref is unavailable", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.txt"), "one\n"); + commitAll(d, "first"); + }); + try { + // A shallow clone or a missing fetch must be an error, not a silent skip + // that reports success while checking nothing. + assert.throws( + () => + resolveBaseCommit(dir, { + argv: [], + env: { + GITHUB_ACTIONS: "true", + MXC_VERSIONING_BASE_REF: "origin/does-not-exist", + }, + }), + /does-not-exist/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveBaseCommit requires an explicit base ref under GitHub Actions", () => { + const dir = scratchRepo((d) => { + writeFileSync(join(d, "a.txt"), "one\n"); + commitAll(d, "first"); + }); + try { + assert.throws( + () => + resolveBaseCommit(dir, { + argv: [], + env: { GITHUB_ACTIONS: "true" }, + }), + /MXC_VERSIONING_BASE_REF is required in GitHub Actions/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("toGitPath collapses non-canonical spellings", () => { + // These are all meaningful to the filesystem but never appear in git's own + // output, so comparing them literally would make an existing file look absent. + assert.equal(toGitPath("schemas//dev/a.json"), "schemas/dev/a.json"); + assert.equal(toGitPath("././a.json"), "a.json"); + assert.equal(toGitPath("schemas/../a.json"), "a.json"); + assert.equal(toGitPath("schemas/dev/../dev/a.json"), "schemas/dev/a.json"); + // A `..` with nothing to pop can never name a tracked file. + assert.throws(() => toGitPath("../a.json"), /escapes the repository root/); +}); + +test("readFileAtCommit accepts the natural absolute call shape", () => { + // `path.join(repoRoot, ...)` is what a caller naturally writes, and it yields + // an absolute path that matches no ls-tree entry. Returning null there would + // be read as "absent at the base", so a gate would skip its own check. + const dir = scratchRepo((d) => { + mkdirSync(join(d, "schemas", "dev"), { recursive: true }); + writeFileSync(join(d, "schemas", "dev", "a.json"), '{"v":1}\n'); + commitAll(d, "first"); + }); + try { + const expected = '{"v":1}\n'; + for (const path of [ + "schemas/dev/a.json", + "schemas\\dev\\a.json", + join(dir, "schemas", "dev", "a.json"), + "schemas//dev/a.json", + "./schemas/dev/../dev/a.json", + ]) { + assert.equal( + readFileAtCommit(dir, "HEAD", path), + expected, + `should read ${path}` + ); + } + // A genuinely absent file is still null, so the signal keeps its meaning. + assert.equal(readFileAtCommit(dir, "HEAD", "schemas/dev/missing.json"), null); + // A path that cannot name a file in this repository is refused outright + // rather than reported as absent. + assert.throws( + () => readFileAtCommit(dir, "HEAD", join(tmpdir(), "elsewhere.json")), + /outside the repository root/ + ); + assert.throws( + () => readFileAtCommit(dir, "HEAD", "../elsewhere.json"), + /escapes the repository root/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + "repoRelativeGitPath compares Windows absolute paths case-insensitively", + { skip: process.platform !== "win32" }, + () => { + const dir = scratchRepo((d) => { + mkdirSync(join(d, "schemas"), { recursive: true }); + writeFileSync(join(d, "schemas", "a.json"), "{}\n"); + commitAll(d, "add"); + }); + try { + assert.equal( + repoRelativeGitPath(dir.toUpperCase(), join(dir, "schemas", "a.json")), + "schemas/a.json" + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } +); diff --git a/scripts/versioning/tests/git-base.test.js b/scripts/versioning/tests/git-base.test.js new file mode 100644 index 000000000..9b8438596 --- /dev/null +++ b/scripts/versioning/tests/git-base.test.js @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { requestedBaseRef } = require("../lib/git-base"); + +test("explicit base ref wins", () => { + assert.equal( + requestedBaseRef(["--base-ref", "origin/feature"], { + MXC_VERSIONING_BASE_REF: "origin/main", + }), + "origin/feature" + ); +}); + +test("environment base ref is used", () => { + assert.equal( + requestedBaseRef([], { MXC_VERSIONING_BASE_REF: "origin/main" }), + "origin/main" + ); +}); + +test("GitHub Actions fails closed without a base ref", () => { + assert.throws( + () => requestedBaseRef([], { GITHUB_ACTIONS: "true" }), + /required in GitHub Actions/ + ); +}); + +test("local callers may use resolver fallbacks", () => { + assert.equal(requestedBaseRef([], {}), null); +}); diff --git a/scripts/versioning/tests/version.test.js b/scripts/versioning/tests/version.test.js new file mode 100644 index 000000000..20503c2d4 --- /dev/null +++ b/scripts/versioning/tests/version.test.js @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + parseVersion, + parseMajorMinor, + compareVersions, + compareMajorMinor, + majorMinor, +} = require("../lib/version"); + +test("parseVersion accepts a plain release version", () => { + assert.deepEqual(parseVersion("1.2.3"), { + major: 1, + minor: 2, + patch: 3, + prerelease: "", + build: "", + raw: "1.2.3", + }); +}); + +test("parseVersion captures a prerelease label", () => { + const v = parseVersion("0.8.0-alpha"); + assert.equal(v.minor, 8); + assert.equal(v.prerelease, "alpha"); +}); + +test("parseVersion rejects malformed input", () => { + for (const bad of ["", null, undefined, "1.2", "1.2.3.4", "v1.2.3", "a.b.c"]) { + assert.equal(parseVersion(bad), null, `should reject ${JSON.stringify(bad)}`); + } +}); + +test("parseVersion rejects leading zeros in numeric identifiers", () => { + // SemVer forbids these, and accepting them would let 01.0.0 and 1.0.0 both + // parse to the same ordering key. + assert.equal(parseVersion("01.2.3"), null); + assert.equal(parseVersion("1.02.3"), null); + assert.equal(parseVersion("1.2.03"), null); +}); + +test("parseVersion rejects components too large to order", () => { + // An unbounded digit run converts to Infinity; Infinity - Infinity is NaN, and + // every ordinary `< 0` / `> 0` regression check against NaN is false, so a + // version like this would silently pass an ordering gate. + const huge = "1".repeat(400); + assert.equal(parseVersion(`${huge}.0.0`), null); + assert.equal(parseVersion(`0.${huge}.0`), null); + assert.equal(parseVersion(`0.0.${huge}`), null); +}); + +test("numeric prerelease identifiers order exactly at any length", () => { + // SemVer leaves these unbounded, so `Number` loses precision long before the + // digits do: distinct values above 2^53 collapse to one float and long ones + // become Infinity, whose difference is NaN -- falsy, so the comparison used to + // report equality and an ordering gate would fail open. Leading zeros are + // already rejected, so digit length then lexical order is exact. + const lt = (a, b) => compareVersions(parseVersion(a), parseVersion(b)) < 0; + assert.ok(lt("1.0.0-9007199254740992", "1.0.0-9007199254740993"), "past 2^53"); + assert.ok( + compareVersions( + parseVersion("1.0.0-9007199254740993"), + parseVersion("1.0.0-9007199254740992") + ) > 0, + "antisymmetric past 2^53" + ); + const long = "1".repeat(400); + assert.ok(lt(`1.0.0-${long}`, `1.0.0-${"2".repeat(400)}`), "equal length"); + assert.ok(lt(`1.0.0-${long}`, `1.0.0-${"1".repeat(401)}`), "more digits is larger"); + assert.ok(lt(`1.0.0-alpha.${long}`, `1.0.0-alpha.${"2".repeat(400)}`), "dotted"); + assert.equal( + compareVersions(parseVersion(`1.0.0-${long}`), parseVersion(`1.0.0-${long}`)), + 0, + "identical long identifiers are equal" + ); +}); + +test("prerelease precedence follows the SemVer specification", () => { + // The example chain from SemVer 11.4.12, which pins numeric-before-alphanumeric + // and numeric-not-lexical ordering together. + const chain = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ]; + for (let i = 0; i + 1 < chain.length; i++) { + assert.ok( + compareVersions(parseVersion(chain[i]), parseVersion(chain[i + 1])) < 0, + `${chain[i]} should precede ${chain[i + 1]}` + ); + } +}); + +test("compareVersions orders by major, then minor, then patch", () => { + const lt = (a, b) => compareVersions(parseVersion(a), parseVersion(b)) < 0; + assert.ok(lt("1.0.0", "2.0.0")); + assert.ok(lt("1.1.0", "1.2.0")); + assert.ok(lt("1.1.1", "1.1.2")); + assert.equal(compareVersions(parseVersion("1.2.3"), parseVersion("1.2.3")), 0); +}); + +test("compareVersions ranks a prerelease below its release", () => { + assert.ok(compareVersions(parseVersion("1.0.0-alpha"), parseVersion("1.0.0")) < 0); + assert.ok(compareVersions(parseVersion("1.0.0"), parseVersion("1.0.0-alpha")) > 0); +}); + +test("compareVersions orders prerelease identifiers per SemVer", () => { + const lt = (a, b) => compareVersions(parseVersion(a), parseVersion(b)) < 0; + assert.ok(lt("1.0.0-alpha", "1.0.0-beta")); + // Numeric identifiers compare numerically, not as strings. + assert.ok(lt("1.0.0-alpha.2", "1.0.0-alpha.10")); + // Numeric identifiers rank below alphanumeric ones. + assert.ok(lt("1.0.0-1", "1.0.0-alpha")); + // A larger set of identifiers outranks its prefix. + assert.ok(lt("1.0.0-alpha", "1.0.0-alpha.1")); +}); + +test("parseMajorMinor applies the same numeric guards", () => { + assert.deepEqual(parseMajorMinor("0.8"), { major: 0, minor: 8, raw: "0.8" }); + assert.equal(parseMajorMinor("0.8.0"), null); + assert.equal(parseMajorMinor("01.8"), null); + assert.equal(parseMajorMinor(`1.${"9".repeat(400)}`), null); +}); + +test("compareMajorMinor ignores patch and prerelease", () => { + assert.equal(compareMajorMinor(parseVersion("0.8.0-alpha"), parseVersion("0.8.9")), 0); + assert.ok(compareMajorMinor(parseVersion("0.7.0"), parseVersion("0.8.0")) < 0); +}); + +test("majorMinor renders the line of a version", () => { + assert.equal(majorMinor(parseVersion("0.8.0-alpha")), "0.8"); + assert.equal(majorMinor("0.8.0-alpha"), "0.8"); + assert.equal(majorMinor("invalid"), null); + assert.equal(majorMinor(null), null); + assert.throws(() => majorMinor({}), TypeError); + assert.throws(() => majorMinor({ major: 1, minor: undefined }), TypeError); +}); + +// -- Round-1 review regressions ----------------------------------------------- + +test("parseVersion accepts build metadata", () => { + const plain = parseVersion("1.2.3+build.5"); + assert.equal(plain.build, "build.5"); + assert.equal(plain.prerelease, ""); + const withPre = parseVersion("1.2.3-alpha+build.5"); + // Build metadata must not be folded into the prerelease label, or it would + // take part in precedence. + assert.equal(withPre.prerelease, "alpha"); + assert.equal(withPre.build, "build.5"); +}); + +test("build metadata is ignored for precedence", () => { + assert.equal( + compareVersions(parseVersion("1.2.3+a"), parseVersion("1.2.3+b")), + 0 + ); + assert.equal( + compareVersions(parseVersion("1.2.3"), parseVersion("1.2.3+build.99")), + 0 + ); + assert.ok( + compareVersions( + parseVersion("1.2.3-alpha+z"), + parseVersion("1.2.3-beta+a") + ) < 0 + ); +}); + +test("parseVersion rejects malformed prerelease and build identifiers", () => { + assert.equal(parseVersion("1.2.3-01"), null, "numeric prerelease leading zero"); + assert.equal(parseVersion("1.2.3-"), null, "empty prerelease"); + assert.equal(parseVersion("1.2.3+"), null, "empty build"); + assert.equal(parseVersion("1.2.3-al pha"), null, "space in prerelease"); + assert.equal(parseVersion("1.2.3+bui,ld"), null, "comma in build"); + assert.equal(parseVersion("1.2.3-alpha..1"), null, "empty identifier"); +}); + +test("comparing anything other than a parsed version throws", () => { + // Reading .major off a string or a failed parse yields undefined, which would + // silently report the two versions as equal. + assert.throws(() => compareVersions("1.2.3", "1.2.4"), TypeError); + assert.throws(() => compareVersions(parseVersion("bad"), parseVersion("1.2.3")), TypeError); + assert.throws(() => compareMajorMinor(null, parseMajorMinor("0.8")), TypeError); +}); + +test("compareVersions rejects a version line rather than returning NaN", () => { + // A parseMajorMinor result has no patch or prerelease, so subtracting it + // yields NaN, for which both `< 0` and `> 0` are false -- the same fail-open + // the guard exists to prevent. compareMajorMinor is the function for these. + const line = parseMajorMinor("1.2"); + const full = parseVersion("1.2.3"); + assert.throws(() => compareVersions(line, full), TypeError); + assert.throws(() => compareVersions(full, line), TypeError); + assert.throws(() => compareVersions(line, line), TypeError); + // A hand-rolled object satisfying only the loose check is rejected too. + assert.throws(() => compareVersions({ major: 1, minor: 2 }, full), TypeError); + assert.throws( + () => compareVersions({ major: 1, minor: 2, patch: 3 }, full), + TypeError, + "prerelease is still missing" + ); + // The legitimate uses keep working. + assert.ok(compareMajorMinor(line, parseVersion("1.3.0")) < 0); + assert.equal(compareMajorMinor(line, parseMajorMinor("1.2")), 0); + assert.equal(compareVersions(full, parseVersion("1.2.3")), 0); +}); + +test("version parsing rejects non-string input rather than coercing it", () => { + // `RegExp.exec` coerces its argument, so a single-element array, a boxed + // String, or any object with a `toString` would otherwise parse -- and `raw` + // would carry the non-string, breaking round-tripping too. + for (const value of [ + ["1.2.3"], + new String("1.2.3"), + { toString: () => "1.2.3" }, + 123, + null, + undefined, + true, + ]) { + assert.equal(parseVersion(value), null, `parseVersion ${String(value)}`); + } + for (const value of [["1.2"], new String("1.2"), { toString: () => "1.2" }]) { + assert.equal(parseMajorMinor(value), null, `parseMajorMinor ${String(value)}`); + } + assert.equal(parseVersion("1.2.3").raw, "1.2.3", "a real string still parses"); +}); + +test("the comparison guards reject components no parser can produce", () => { + // `Number.isSafeInteger` admits negatives, and a bare typeof check admits a + // prerelease the parser would have rejected. Either would be ordered rather + // than refused, letting a hand-built or mutated object through the guard that + // exists to stop exactly that. + const ok = parseVersion("1.0.0"); + const full = (extra) => ({ major: 1, minor: 0, patch: 0, prerelease: "", ...extra }); + assert.throws(() => compareVersions(full({ major: -1 }), ok), TypeError, "negative major"); + assert.throws(() => compareVersions(full({ patch: -5 }), ok), TypeError, "negative patch"); + assert.throws(() => compareMajorMinor({ major: 1, minor: -2 }, parseMajorMinor("0.8")), TypeError); + for (const prerelease of ["01", "alpha..1", "has space", "alpha+build"]) { + assert.throws( + () => compareVersions(full({ prerelease }), ok), + TypeError, + `prerelease ${JSON.stringify(prerelease)} is not a value parseVersion would produce` + ); + } + // The shapes a parser really does produce are still accepted. + assert.equal(compareVersions(full({}), ok), 0); + assert.ok(compareVersions(full({ prerelease: "alpha.1" }), ok) < 0); +});