From 730cd1154d0f7d354e40212470103ab5c65e4bb1 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:02:33 +0000 Subject: [PATCH] fix(init): preserve scope/path separators in project slugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by users running `sentry init` in monorepos with npm-scoped package names. The package.json `name` field `@t3tools/web` was being slugified to `t3toolsweb` (silently mashed) instead of `t3tools-web`. Root cause: `slugify` mirrored Sentry's frontend canonical implementation (getsentry/sentry/static/app/utils/slugify.tsx), which strips `@` and `/` along with other invalid characters in a single pass. For npm scopes and monorepo path segments this destroys structural information. Fix: normalize `/` and `\` to a space before the strip step. Existing collapse rule `[-\s]+` then folds them into hyphens. slugify("@t3tools/web") // before: "t3toolsweb" now: "t3tools-web" slugify("@scope/pkg") // before: "scopepkg" now: "scope-pkg" slugify("packages/api") // before: "packagesapi" now: "packages-api" slugify("apps\\api") // before: "appsapi" now: "apps-api" All other inputs unchanged: `My Cool App` → `my-cool-app`, `Café Project` → `cafe-project`, `my_app` → `my_app`, etc. Adds: - test/lib/utils.test.ts — unit cases for JSDoc examples, npm scopes, monorepo paths, edge cases (empty, all-invalid, leading/trailing separators) - test/lib/utils.property.test.ts — invariants for any input: output matches /^[a-z0-9_-]*$/, no leading/trailing/consecutive hyphens, idempotent, separator-between-segments always becomes a hyphen Note for users who previously ran `sentry init` against a scoped-package project: a re-run will produce a new slug (e.g. `t3tools-web`) and create a new Sentry project. The old `t3toolsweb` project is orphaned and can be deleted manually via the Sentry UI or `sentry project delete`. --- src/lib/utils.ts | 17 +++-- test/lib/utils.property.test.ts | 102 ++++++++++++++++++++++++++++ test/lib/utils.test.ts | 113 ++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 test/lib/utils.property.test.ts create mode 100644 test/lib/utils.test.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c35405dd5..993ed893f 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -25,15 +25,24 @@ export function isAllDigits(str: string): boolean { * Aligned with Sentry's canonical implementation: * https://github.com/getsentry/sentry/blob/master/static/app/utils/slugify.tsx * - * @example slugify("My Cool App") // "my-cool-app" - * @example slugify("my-app") // "my-app" - * @example slugify("Café Project") // "cafe-project" - * @example slugify("my_app") // "my_app" + * Diverges from Sentry's frontend canonical version in one place: `/` and `\` + * are normalized to a space before invalid-character stripping, so structural + * separators (npm scopes, monorepo path segments) become hyphens instead of + * being silently dropped. Without this, `@scope/pkg` would slugify to + * `scopepkg` instead of `scope-pkg`. + * + * @example slugify("My Cool App") // "my-cool-app" + * @example slugify("my-app") // "my-app" + * @example slugify("Café Project") // "cafe-project" + * @example slugify("my_app") // "my_app" + * @example slugify("@t3tools/web") // "t3tools-web" + * @example slugify("packages/api") // "packages-api" */ export function slugify(name: string): string { return name .normalize("NFKD") .toLowerCase() + .replace(/[\\/]+/g, " ") .replace(/[^a-z0-9_\s-]/g, "") .replace(/[-\s]+/g, "-") .replace(/^-|-$/g, ""); diff --git a/test/lib/utils.property.test.ts b/test/lib/utils.property.test.ts new file mode 100644 index 000000000..2dc04f0e4 --- /dev/null +++ b/test/lib/utils.property.test.ts @@ -0,0 +1,102 @@ +/** + * Property-Based Tests for src/lib/utils.ts + * + * Verifies invariants that should hold for any input to slugify, regardless + * of the characters present. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + string, +} from "fast-check"; +import { slugify } from "../../src/lib/utils.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +/** Mix of valid slug chars, separators, scope/path glyphs, whitespace, and unicode */ +const messyChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + "_-/\\@.: \tCaféñü漢"; + +const messyInputArb = array(constantFrom(...messyChars.split("")), { + minLength: 0, + maxLength: 30, +}).map((chars) => chars.join("")); + +const VALID_SLUG_RE = /^[a-z0-9_-]*$/; + +describe("property: slugify", () => { + test("output contains only [a-z0-9_-]", () => { + fcAssert( + property(messyInputArb, (input) => { + expect(slugify(input)).toMatch(VALID_SLUG_RE); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never starts or ends with a hyphen", () => { + fcAssert( + property(messyInputArb, (input) => { + const out = slugify(input); + if (out.length > 0) { + expect(out.startsWith("-")).toBe(false); + expect(out.endsWith("-")).toBe(false); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output never contains consecutive hyphens", () => { + fcAssert( + property(messyInputArb, (input) => { + expect(slugify(input).includes("--")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("idempotent: slugify(slugify(x)) === slugify(x)", () => { + fcAssert( + property(messyInputArb, (input) => { + const once = slugify(input); + const twice = slugify(once); + expect(twice).toBe(once); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("any arbitrary string still produces a valid slug", () => { + // Broader coverage with the unconstrained string arbitrary — catches + // anything the curated charset above might miss. + fcAssert( + property(string(), (input) => { + expect(slugify(input)).toMatch(VALID_SLUG_RE); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("path/scope separators always become hyphens between alnum runs", () => { + // For inputs of the form "/" or "\" where both halves contain + // valid slug chars, the separator must produce a hyphen in the output — + // never a silent mash-up like "". + const segmentChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + const segmentArb = array(constantFrom(...segmentChars.split("")), { + minLength: 1, + maxLength: 10, + }).map((chars) => chars.join("")); + + fcAssert( + property(segmentArb, segmentArb, constantFrom("/", "\\"), (a, b, sep) => { + expect(slugify(`${a}${sep}${b}`)).toBe(`${a}-${b}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts new file mode 100644 index 000000000..af9bd16bb --- /dev/null +++ b/test/lib/utils.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for src/lib/utils.ts + * + * Note: Core invariants (charset, idempotency, no leading/trailing/consecutive + * hyphens) are tested via property-based tests in utils.property.test.ts. + * These tests focus on specific inputs documented in JSDoc and regression + * cases for the npm-scope / monorepo path bug (CLI-1XX). + */ + +import { describe, expect, test } from "bun:test"; +import { isAllDigits, slugify } from "../../src/lib/utils.js"; + +describe("slugify", () => { + describe("JSDoc examples (canonical alignment)", () => { + test('slugify("My Cool App") → "my-cool-app"', () => { + expect(slugify("My Cool App")).toBe("my-cool-app"); + }); + + test('slugify("my-app") → "my-app"', () => { + expect(slugify("my-app")).toBe("my-app"); + }); + + test('slugify("Café Project") → "cafe-project"', () => { + expect(slugify("Café Project")).toBe("cafe-project"); + }); + + test('slugify("my_app") → "my_app"', () => { + expect(slugify("my_app")).toBe("my_app"); + }); + }); + + describe("npm scoped package names", () => { + // Regression for the t3tools/web monorepo report — silently stripping + // `/` produced unreadable mashups like `t3toolsweb` instead of a useful + // `t3tools-web` slug. + test('slugify("@t3tools/web") → "t3tools-web"', () => { + expect(slugify("@t3tools/web")).toBe("t3tools-web"); + }); + + test('slugify("@scope/pkg") → "scope-pkg"', () => { + expect(slugify("@scope/pkg")).toBe("scope-pkg"); + }); + + test('slugify("@my-org/some-package") → "my-org-some-package"', () => { + expect(slugify("@my-org/some-package")).toBe("my-org-some-package"); + }); + }); + + describe("monorepo path-style names", () => { + test('slugify("packages/api") → "packages-api"', () => { + expect(slugify("packages/api")).toBe("packages-api"); + }); + + test('slugify("apps/api/web") → "apps-api-web"', () => { + expect(slugify("apps/api/web")).toBe("apps-api-web"); + }); + + test('slugify("apps\\\\api") → "apps-api"', () => { + expect(slugify("apps\\api")).toBe("apps-api"); + }); + + test('slugify("@scope/My App") → "scope-my-app"', () => { + expect(slugify("@scope/My App")).toBe("scope-my-app"); + }); + }); + + describe("edge cases", () => { + test('slugify("") → ""', () => { + expect(slugify("")).toBe(""); + }); + + test('slugify("///") → ""', () => { + expect(slugify("///")).toBe(""); + }); + + test('slugify("@@@") → ""', () => { + expect(slugify("@@@")).toBe(""); + }); + + test('slugify("@/foo") → "foo"', () => { + expect(slugify("@/foo")).toBe("foo"); + }); + + test('slugify("///foo///") → "foo"', () => { + expect(slugify("///foo///")).toBe("foo"); + }); + + test('slugify("---foo---") → "foo"', () => { + expect(slugify("---foo---")).toBe("foo"); + }); + + test("collapses runs of slashes/spaces/hyphens", () => { + expect(slugify("foo // bar -- baz")).toBe("foo-bar-baz"); + }); + }); +}); + +describe("isAllDigits", () => { + test("returns true for pure digits", () => { + expect(isAllDigits("0")).toBe(true); + expect(isAllDigits("123456")).toBe(true); + }); + + test("returns false for non-digit strings", () => { + expect(isAllDigits("PROJECT-ABC")).toBe(false); + expect(isAllDigits("abc123")).toBe(false); + expect(isAllDigits("123abc")).toBe(false); + expect(isAllDigits("")).toBe(false); + expect(isAllDigits("12.3")).toBe(false); + expect(isAllDigits("-1")).toBe(false); + expect(isAllDigits(" 123")).toBe(false); + }); +});