From d4e4123b5840b5f8a8ce84d42fce80b264369b01 Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Wed, 23 Sep 2026 19:40:54 +0000 Subject: [PATCH 1/2] [heft-sass-plugin] Restore resolution of bare specifiers Since the move to the `pkg:` importer, a bare specifier such as `@use '@scope/pkg/theme'` only ever resolved relative to the importing file, so it failed with "Can't find stylesheet to import". There was no configuration option to change this, and the failing import is often inside a third-party package that the consuming project cannot edit. - Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Relative resolution still takes precedence, matching Sass semantics. - Package resolution failures return null instead of throwing, so Sass reports its usual error pointing at the offending line. - The legacy `~` rewrite is applied in the resolver rather than only by the `@use`/`@import`/`@forward` preprocessor, so it also works in constructs such as `meta.load-css()`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b --- ...-bare-specifier-resolution_2026-09-23.json | 9 ++ heft-plugins/heft-sass-plugin/README.md | 25 ++++ .../heft-sass-plugin/src/SassPlugin.ts | 3 + .../heft-sass-plugin/src/SassProcessor.ts | 70 +++++++++- .../src/schemas/heft-sass-plugin.schema.json | 9 ++ .../heft-sass-plugin/src/templates/sass.json | 10 ++ .../src/test/SassProcessor.test.ts | 127 ++++++++++++++++++ .../__snapshots__/SassProcessor.test.ts.snap | 118 ++++++++++++++++ .../fixtures/load-paths/theme/_colors.scss | 1 + .../test/fixtures/use-load-path.module.scss | 5 + 10 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss create mode 100644 heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss diff --git a/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json b/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json new file mode 100644 index 0000000000..5fcbb5c192 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json @@ -0,0 +1,9 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Fix a regression where a bare specifier such as `@use '@scope/pkg/theme'` could not be resolved. Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Also apply the legacy `~` rewrite in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`.", + "type": "patch" + } + ] +} diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index a55f5b462c..08f81bc019 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -162,6 +162,7 @@ All options are set in `config/sass.json`. Every option is optional. | `fileExtensions` | `[".sass", ".scss", ".css"]` | File extensions to treat as CSS modules | | `nonModuleFileExtensions` | `[".global.sass", ".global.scss", ".global.css"]` | File extensions to treat as global (non-module) stylesheets | | `excludeFiles` | `[]` | Paths relative to `srcFolder` to skip entirely | +| `loadPaths` | `[]` | Folders, relative to the project folder, to search when a bare specifier such as `@use "theme/colors"` cannot be resolved relative to the importing file. Analogous to the Sass compiler's `loadPaths` option. Searched before `node_modules`. | | `doNotTrimOriginalFileExtension` | `false` | When `true`, preserves the original extension in the CSS output filename. E.g. `styles.scss` → `styles.scss.css` instead of `styles.css`. Useful when downstream tooling needs to distinguish the source format. | | `preserveIcssExports` | `false` | When `true`, keeps the `:export { }` block in the emitted CSS. This is needed when a webpack loader (e.g. `css-loader`'s `icssParser`) must extract `:export` values at bundle time. Has no effect on the generated `.d.ts`. | | `silenceDeprecations` | `[]` | List of Sass deprecation codes to suppress (e.g. `"mixed-decls"`, `"import"`, `"global-builtin"`, `"color-functions"`) | @@ -203,6 +204,12 @@ require("./global.global.css"); ## Sass import resolution +A load specifier is resolved in this order: + +1. Relative to the importing file. +2. Each folder in the `loadPaths` option, in order (bare specifiers only). +3. `node_modules`, resolved using Node module resolution (bare specifiers only). + The plugin supports the modern `pkg:` protocol for importing from npm packages: ```scss @@ -217,6 +224,24 @@ The legacy `~` prefix is automatically converted to `pkg:` for compatibility wit @use "pkg:@fluentui/react/dist/sass/variables"; ``` +This also applies to specifiers that are not part of an `@use`/`@import`/`@forward` rule, such as +`@include meta.load-css("~@fluentui/react/dist/sass/variables")`. + +### Bare specifiers + +A "bare" specifier is one that does not start with `.`, `/`, or a URL scheme. When it cannot be +resolved relative to the importing file, it is resolved from `loadPaths` and then from `node_modules`: + +```scss +// Resolves to node_modules/@fluentui/react/dist/sass/variables.scss +@use "@fluentui/react/dist/sass/variables"; +``` + +`pkg:` is preferred for stylesheets you own, because it is unambiguous. Bare specifiers are supported +because they are the portable form understood by every other Sass toolchain (the Dart Sass CLI's +`--load-path`, `sass-loader`, Vite, the Angular CLI, and so on), so third-party packages that ship +Sass sources commonly use them internally, where a consuming project cannot rewrite them. + ## Incremental builds The plugin tracks inter-file dependencies (via `@use`, `@forward`, and `@import`) and only recompiles files that changed or whose dependencies changed. This makes `heft build --watch` fast even in large projects. diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index 32d1ef5a74..cfcc3446a3 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -29,6 +29,7 @@ export interface ISassConfigurationJson { nonModuleFileExtensions?: string[]; silenceDeprecations?: string[]; excludeFiles?: string[]; + loadPaths?: string[]; doNotTrimOriginalFileExtension?: boolean; preserveIcssExports?: boolean; sourceMap?: boolean; @@ -100,6 +101,7 @@ export default class SassPlugin implements IHeftPlugin { nonModuleFileExtensions, silenceDeprecations, excludeFiles, + loadPaths, doNotTrimOriginalFileExtension, preserveIcssExports, sourceMap @@ -117,6 +119,7 @@ export default class SassPlugin implements IHeftPlugin { exportAsDefault, srcFolder: resolveFolder(srcFolder), excludeFiles, + loadPaths: loadPaths?.map(resolveFolder), fileExtensions, nonModuleFileExtensions, cssOutputFolders: cssOutputFolders?.map((folder: string | ICssOutputFolder) => { diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 1ac502077a..2170645f94 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -104,6 +104,13 @@ export interface ISassProcessorOptions { */ excludeFiles?: string[]; + /** + * Absolute paths of folders to search when resolving a bare specifier, e.g. `@use 'theme/colors'`. + * These are analogous to the `loadPaths` option of the Sass compiler, and are consulted after + * resolution relative to the importing file fails, but before resolution from `node_modules`. + */ + loadPaths?: string[]; + /** * If set, deprecation warnings from dependencies will be suppressed. */ @@ -179,6 +186,13 @@ interface ISerializedFileRecord { */ const importTildeRegex: RegExp = /^(\s*@(?:import|use|forward)\s*)('~(?:[^']+)'|"~(?:[^"]+)")/gm; +/** + * Regexp matching the scheme of an absolute URL, e.g. the `pkg:` in `pkg:@fluentui/react/dist/sass/blah`. + * Per RFC 3986 a scheme starts with a letter and may contain letters, digits, `+`, `-` and `.`. + * This also matches a Windows drive letter prefix such as `C:`, which is likewise not a bare specifier. + */ +const urlSchemeRegex: RegExp = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + // eslint-disable-next-line @rushstack/no-new-null type SyncResolution = URL | null; type AsyncResolution = Promise; @@ -204,6 +218,7 @@ export class SassProcessor { readonly #resolutions: Map; readonly #isFileModule: (filePath: string) => boolean; + readonly #loadPaths: readonly string[]; readonly #options: ISassProcessorOptions; readonly #realpathSync: (path: string) => string; readonly #scssOptions: Options<'async'>; @@ -258,6 +273,7 @@ export class SassProcessor { this.#configFilePath = undefined; this.#fileInfo = new Map(); this.#isFileModule = isFileModule; + this.#loadPaths = options.loadPaths ?? []; this.#resolutions = new Map(); this.#options = options; this.#realpathSync = new RealNodeModulePathResolver().realNodeModulePath; @@ -558,7 +574,11 @@ export class SassProcessor { */ async #canonicalizeAsync(url: string, context: CanonicalizeContext): AsyncResolution { if (url.startsWith('~')) { - throw new Error(`Unexpected tilde in URL: ${url} in context: ${context.containingUrl?.href}`); + // Legacy `~` syntax. `preprocessScss` rewrites these to `pkg:` in `@import`, `@use` and + // `@forward` rules, but a tilde can also appear in constructs it does not cover, most notably + // `@include meta.load-css('~')`. Apply the same rewrite here so that all of them behave + // consistently instead of failing with a confusing error. + return await this.#canonicalizePackageAsync(`pkg:${url.slice(1)}`, context); } if (url.startsWith('pkg:')) { @@ -576,7 +596,42 @@ export class SassProcessor { } const resolvedUrl: string = new URL(url, containingUrl.toString()).toString(); - return await this.#canonicalizeHeftUrlAsync(resolvedUrl, context); + const relativeResolution: SyncResolution = await this.#canonicalizeHeftUrlAsync(resolvedUrl, context); + if (relativeResolution || !isBareSpecifier(url)) { + return relativeResolution; + } + + // Resolution relative to the importing file failed and the specifier is bare, e.g. + // `@use '@fluentui/react/dist/sass/blah'`. Fall back to the configured load paths and then to + // `node_modules`, matching the behavior of the Sass `loadPaths` option and `NodePackageImporter`. + // This form is what non-Heft Sass toolchains emit, so stylesheets inside third-party packages + // frequently use it and cannot be rewritten by the consuming project. + return await this.#canonicalizeBareSpecifierAsync(url, context); + } + + /** + * Resolves a bare specifier, e.g. `theme/colors` or `@fluentui/react/dist/sass/blah`, by searching the + * configured load paths and then `node_modules`. + * @param url - The bare specifier to canonicalize + * @param context - The context in which the canonicalization is being performed + * @returns The canonical URL of the target file, or null if it does not resolve + */ + async #canonicalizeBareSpecifierAsync(url: string, context: CanonicalizeContext): AsyncResolution { + for (const loadPath of this.#loadPaths) { + const candidateUrl: string = pathToHeftUrl(`${loadPath}/${url}`).href; + const result: SyncResolution = await this.#canonicalizeHeftUrlAsync(candidateUrl, context); + if (result) { + return result; + } + } + + try { + return await this.#canonicalizePackageAsync(`pkg:${url}`, context); + } catch { + // The specifier does not name an installed package. Returning null lets Sass report its usual + // "Can't find stylesheet to import" error, which points at the offending line in the stylesheet. + return null; + } } /** @@ -1060,6 +1115,17 @@ function isSassPartial(filePath: string): boolean { return path.basename(filePath)[0] === '_'; } +/** + * Determines whether a Sass load specifier is "bare", i.e. it names a package or a file within a load + * path rather than a location relative to the importing file. For example `@fluentui/react/dist/sass/blah` + * and `theme/colors` are bare, while `./colors`, `../theme/colors`, `/theme/colors` and `pkg:blah` are not. + * @param url - The specifier exactly as it was written in the stylesheet + * @returns true if the specifier is bare + */ +function isBareSpecifier(url: string): boolean { + return url.length > 0 && !url.startsWith('.') && !url.startsWith('/') && !urlSchemeRegex.test(url); +} + function getContentsHash(fileName: string, fileContents: string): string { return crypto.createHmac('sha1', fileName).update(fileContents).digest('base64'); } diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 71d163bff9..202a151490 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -95,6 +95,15 @@ } }, + "loadPaths": { + "type": "array", + "description": "Folders, relative to the project folder, to search when resolving a bare specifier such as `@use 'theme/colors'`. Analogous to the Sass compiler's `loadPaths` option. Load paths are consulted only after resolution relative to the importing file fails, and before resolution from `node_modules`.", + "items": { + "type": "string", + "pattern": "[^\\\\]" + } + }, + "ignoreDeprecationsInDependencies": { "type": "boolean", "description": "If set, deprecation warnings from dependencies will be suppressed." diff --git a/heft-plugins/heft-sass-plugin/src/templates/sass.json b/heft-plugins/heft-sass-plugin/src/templates/sass.json index 2a874148f6..c927169ed0 100644 --- a/heft-plugins/heft-sass-plugin/src/templates/sass.json +++ b/heft-plugins/heft-sass-plugin/src/templates/sass.json @@ -79,6 +79,16 @@ */ // "excludeFiles": [], + /** + * Folders, relative to the project folder, to search when resolving a bare specifier such as + * `@use 'theme/colors'`. Analogous to the Sass compiler's "loadPaths" option. These are consulted + * only after resolution relative to the importing file fails, and before resolution from + * "node_modules". + * + * Default value: undefined + */ + // "loadPaths": ["src/styles"], + /** * If true, the original file extension will not be trimmed when generating the output CSS filename. * For example, "styles.scss" will generate "styles.scss.css" instead of "styles.css". diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 4faa802770..73f1113b7f 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -13,6 +13,40 @@ import { type ICssOutputFolder, type ISassProcessorOptions, SassProcessor } from const projectFolder: string = path.resolve(__dirname, '../..'); const fixturesFolder: string = path.resolve(__dirname, '../../src/test/fixtures'); +/** + * Root of a synthesized project used by the bare specifier tests. It is generated on disk rather than + * checked in because it contains a `node_modules` folder, which is excluded by the repository .gitignore. + */ +const bareSpecifierFolder: string = `${Path.convertToSlashes(projectFolder)}/temp/test/bare-specifiers`; +const bareSpecifierSrcFolder: string = `${bareSpecifierFolder}/src`; + +/** Contents of the synthesized project, keyed by path relative to {@link bareSpecifierFolder}. */ +const BARE_SPECIFIER_FILES: Record = { + // A package that ships Sass sources, like a design system or component library. + 'node_modules/fake-sass-package/package.json': '{ "name": "fake-sass-package", "version": "1.0.0" }', + 'node_modules/fake-sass-package/lib/sass/_colors.scss': '$fake-brand: #00ff00;\n', + + // A package that consumes the one above using a bare specifier. A consuming project cannot rewrite + // this import, so it must resolve without any modification to node_modules. + 'node_modules/shared-styles/package.json': '{ "name": "shared-styles", "version": "1.0.0" }', + 'node_modules/shared-styles/_index.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.shared {\n color: colors.$fake-brand;\n}\n", + + 'src/bare-import.module.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.root {\n color: colors.$fake-brand;\n}\n", + 'src/dependency-bare-import.module.scss': + "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('pkg:shared-styles');\n }\n}\n", + 'src/tilde-load-css.module.scss': + "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('~shared-styles');\n }\n}\n", + 'src/missing-bare-import.module.scss': "@use 'definitely-not-a-real-package/colors';\n", + + // A folder that shadows the package name, to verify that relative resolution takes precedence. + // It lives in a subfolder so that it does not shadow the package for the other fixtures. + 'src/nested/fake-sass-package/lib/sass/_colors.scss': '$fake-brand: #0000ff;\n', + 'src/nested/relative-precedence.module.scss': + "@use 'fake-sass-package/lib/sass/colors';\n\n.root {\n color: colors.$fake-brand;\n}\n" +}; + // Fake output folder paths - never actually written to disk because FileSystem.writeFileAsync is mocked. const FAKE_OUTPUT_BASE_FOLDER: string = '/fake/output'; const NORMALIZED_PLATFORM_FAKE_OUTPUT_BASE_FOLDER: string = Path.convertToSlashes( @@ -29,6 +63,7 @@ type ICreateProcessorOptions = Partial< | 'dtsOutputFolders' | 'exportAsDefault' | 'fileExtensions' + | 'loadPaths' | 'nonModuleFileExtensions' | 'postProcessCssAsync' | 'preserveIcssExports' @@ -761,6 +796,98 @@ describe(SassProcessor.name, () => { }); }); + describe('bare specifier resolution', () => { + beforeAll(() => { + // Written with the synchronous API because `FileSystem.writeFileAsync` is mocked per-test. + FileSystem.ensureEmptyFolder(bareSpecifierFolder); + for (const [relativePath, content] of Object.entries(BARE_SPECIFIER_FILES)) { + FileSystem.writeFile(`${bareSpecifierFolder}/${relativePath}`, content, { + ensureFolderExists: true + }); + } + }); + + function createBareSpecifierProcessor(): { processor: SassProcessor; logger: MockScopedLogger } { + return createProcessor(terminalProvider, { srcFolder: bareSpecifierSrcFolder }); + } + + async function compileBareSpecifierFixtureAsync( + processor: SassProcessor, + relativePath: string + ): Promise { + await processor.compileFilesAsync(new Set([`${bareSpecifierSrcFolder}/${relativePath}`])); + } + + it('resolves a bare specifier from node_modules', async () => { + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'bare-import.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('bare-import.module.scss')).toContain('#00ff00'); + }); + + it('resolves a bare specifier used inside a dependency stylesheet', async () => { + // The failing import lives in node_modules/shared-styles, which the consuming project cannot edit. + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'dependency-bare-import.module.scss'); + + expect(logger.errors).toHaveLength(0); + const css: string = getCssOutput('dependency-bare-import.module.scss'); + expect(css).toContain('.shared'); + expect(css).toContain('#00ff00'); + }); + + it('resolves a legacy tilde specifier inside meta.load-css()', async () => { + // The `~` rewrite is applied by the resolver, not only by the @use/@import/@forward preprocessor. + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'tilde-load-css.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('tilde-load-css.module.scss')).toContain('.shared'); + }); + + it('prefers a file relative to the importer over a package of the same name', async () => { + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'nested/relative-precedence.module.scss'); + + expect(logger.errors).toHaveLength(0); + const css: string = getCssOutput('relative-precedence.module.scss'); + expect(css).toContain('#0000ff'); + expect(css).not.toContain('#00ff00'); + }); + + it('reports a normal Sass error when a bare specifier names no installed package', async () => { + const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'missing-bare-import.module.scss'); + + expect(logger.errors).toHaveLength(1); + const message: string = logger.errors[0].message; + expect(message).toContain("Can't find stylesheet to import"); + // The package resolution failure must not leak out in place of the normal Sass diagnostic. + expect(message).not.toContain('Cannot find package'); + }); + }); + + describe('loadPaths option', () => { + it('resolves a bare specifier from a configured load path', async () => { + const { processor, logger } = createProcessor(terminalProvider, { + loadPaths: [`${fixturesFolder}/load-paths`] + }); + await compileFixtureAsync(processor, 'use-load-path.module.scss'); + + expect(logger.errors).toHaveLength(0); + expect(getCssOutput('use-load-path.module.scss')).toContain('#ff00ff'); + }); + + it('does not resolve a bare specifier from an unconfigured folder', async () => { + const { processor, logger } = createProcessor(terminalProvider); + await compileFixtureAsync(processor, 'use-load-path.module.scss'); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain("Can't find stylesheet to import"); + }); + }); + describe('sourceMap option', () => { it('emits .css.map and sourceMappingURL comment when sourceMap is true', async () => { const { processor } = createProcessor(terminalProvider, { sourceMap: true }); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index d2fab19a07..aecfa19b9f 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -174,6 +174,95 @@ module.exports.default = module.exports;", } `; +exports[`SassProcessor bare specifier resolution prefers a file relative to the importer over a package of the same name: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution prefers a file relative to the importer over a package of the same name: written-files 1`] = ` +Map { + "/fake/output/dts/nested/relative-precedence.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/nested/relative-precedence.module.css" => ".root { + color: #0000ff; +}", +} +`; + +exports[`SassProcessor bare specifier resolution reports a normal Sass error when a bare specifier names no installed package: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution reports a normal Sass error when a bare specifier names no installed package: written-files 1`] = `Map {}`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier from node_modules: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier from node_modules: written-files 1`] = ` +Map { + "/fake/output/dts/bare-import.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/bare-import.module.css" => ".root { + color: #00ff00; +}", +} +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier used inside a dependency stylesheet: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a bare specifier used inside a dependency stylesheet: written-files 1`] = ` +Map { + "/fake/output/dts/dependency-bare-import.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/dependency-bare-import.module.css" => ".root .shared { + color: #00ff00; +}", +} +`; + +exports[`SassProcessor bare specifier resolution resolves a legacy tilde specifier inside meta.load-css(): terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution resolves a legacy tilde specifier inside meta.load-css(): written-files 1`] = ` +Map { + "/fake/output/dts/tilde-load-css.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/tilde-load-css.module.css" => ".root .shared { + color: #00ff00; +}", +} +`; + exports[`SassProcessor classes-and-exports.module.scss generates correct .d.ts with both class names and :export values: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -817,6 +906,35 @@ h1 { } `; +exports[`SassProcessor loadPaths option does not resolve a bare specifier from an unconfigured folder: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor loadPaths option does not resolve a bare specifier from an unconfigured folder: written-files 1`] = `Map {}`; + +exports[`SassProcessor loadPaths option resolves a bare specifier from a configured load path: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor loadPaths option resolves a bare specifier from a configured load path: written-files 1`] = ` +Map { + "/fake/output/dts/use-load-path.module.scss.d.ts" => "declare interface IStyles { + root: string; +} +declare const styles: IStyles; +export default styles;", + "/fake/output/css/use-load-path.module.css" => ".root { + color: #ff00ff; +}", +} +`; + exports[`SassProcessor mixin-with-exports.module.scss (Sass @mixin) expands @mixin calls in CSS output: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss new file mode 100644 index 0000000000..8ddb7955ae --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/load-paths/theme/_colors.scss @@ -0,0 +1 @@ +$brand: #ff00ff; diff --git a/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss b/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss new file mode 100644 index 0000000000..e800624b72 --- /dev/null +++ b/heft-plugins/heft-sass-plugin/src/test/fixtures/use-load-path.module.scss @@ -0,0 +1,5 @@ +@use 'theme/colors'; + +.root { + color: colors.$brand; +} From 7ee6d822738d2181b6283861129458661fbd1dda Mon Sep 17 00:00:00 2001 From: Ian Clanton-Thuon Date: Mon, 28 Sep 2026 01:30:17 +0000 Subject: [PATCH 2/2] Make bare specifier resolution opt-in Addresses review feedback: per the Sass specification the target of `@use`/`@import`/`@forward` is a URL, so `@use '@scope/pkg/theme'` is a relative path and resolving it from node_modules is a deviation. That deviation is now opt-in rather than automatic. - Add `resolveBareSpecifiersAsPackages`, defaulting to false. Package resolution of a bare specifier happens only when it is enabled. - `loadPaths` is likewise consulted only when configured. - The legacy `~` rewrite is unchanged by this commit; `~` is an explicit package reference, so it is not gated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b66c2e44-e9ac-4611-8c45-85cb60478f7b --- ...ecifier-resolution-options_2026-09-28.json | 14 ++++++ ...-bare-specifier-resolution_2026-09-23.json | 9 ---- heft-plugins/heft-sass-plugin/README.md | 38 ++++++++++---- .../heft-sass-plugin/src/SassPlugin.ts | 3 ++ .../heft-sass-plugin/src/SassProcessor.ts | 36 +++++++++++--- .../src/schemas/heft-sass-plugin.schema.json | 7 ++- .../heft-sass-plugin/src/templates/sass.json | 16 +++++- .../src/test/SassProcessor.test.ts | 49 ++++++++++++++++--- .../__snapshots__/SassProcessor.test.ts.snap | 13 ++++- 9 files changed, 145 insertions(+), 40 deletions(-) create mode 100644 common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json delete mode 100644 common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json diff --git a/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json b/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json new file mode 100644 index 0000000000..c8efb06c87 --- /dev/null +++ b/common/changes/@rushstack/heft-sass-plugin/bare-specifier-resolution-options_2026-09-28.json @@ -0,0 +1,14 @@ +{ + "changes": [ + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Add opt-in `loadPaths` and `resolveBareSpecifiersAsPackages` options, which allow a bare specifier such as `@use '@scope/pkg/theme'` to be resolved from a load path or as a package when it does not resolve relative to the importing file. Both are disabled by default, preserving the specification behavior in which such a specifier is a relative URL.", + "type": "minor" + }, + { + "packageName": "@rushstack/heft-sass-plugin", + "comment": "Apply the legacy `~` to `pkg:` rewrite during canonicalization, so that it also works in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`. Previously these threw an `Unexpected tilde in URL` error.", + "type": "patch" + } + ] +} diff --git a/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json b/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json deleted file mode 100644 index 5fcbb5c192..0000000000 --- a/common/changes/@rushstack/heft-sass-plugin/fix-bare-specifier-resolution_2026-09-23.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "changes": [ - { - "packageName": "@rushstack/heft-sass-plugin", - "comment": "Fix a regression where a bare specifier such as `@use '@scope/pkg/theme'` could not be resolved. Bare specifiers now fall back to the new `loadPaths` option and then to `node_modules` when they do not resolve relative to the importing file. Also apply the legacy `~` rewrite in constructs other than `@use`/`@import`/`@forward`, such as `meta.load-css()`.", - "type": "patch" - } - ] -} diff --git a/heft-plugins/heft-sass-plugin/README.md b/heft-plugins/heft-sass-plugin/README.md index 08f81bc019..68ba17ee5e 100644 --- a/heft-plugins/heft-sass-plugin/README.md +++ b/heft-plugins/heft-sass-plugin/README.md @@ -162,7 +162,8 @@ All options are set in `config/sass.json`. Every option is optional. | `fileExtensions` | `[".sass", ".scss", ".css"]` | File extensions to treat as CSS modules | | `nonModuleFileExtensions` | `[".global.sass", ".global.scss", ".global.css"]` | File extensions to treat as global (non-module) stylesheets | | `excludeFiles` | `[]` | Paths relative to `srcFolder` to skip entirely | -| `loadPaths` | `[]` | Folders, relative to the project folder, to search when a bare specifier such as `@use "theme/colors"` cannot be resolved relative to the importing file. Analogous to the Sass compiler's `loadPaths` option. Searched before `node_modules`. | +| `loadPaths` | `[]` | Folders, relative to the project folder, to search when a bare specifier such as `@use "theme/colors"` cannot be resolved relative to the importing file. Analogous to the Sass compiler's `loadPaths` option. | +| `resolveBareSpecifiersAsPackages` | `false` | When `true`, a bare specifier that resolves neither relative to the importing file nor from `loadPaths` is additionally resolved as a package via Node module resolution. See [Bare specifiers](#bare-specifiers). | | `doNotTrimOriginalFileExtension` | `false` | When `true`, preserves the original extension in the CSS output filename. E.g. `styles.scss` → `styles.scss.css` instead of `styles.css`. Useful when downstream tooling needs to distinguish the source format. | | `preserveIcssExports` | `false` | When `true`, keeps the `:export { }` block in the emitted CSS. This is needed when a webpack loader (e.g. `css-loader`'s `icssParser`) must extract `:export` values at bundle time. Has no effect on the generated `.d.ts`. | | `silenceDeprecations` | `[]` | List of Sass deprecation codes to suppress (e.g. `"mixed-decls"`, `"import"`, `"global-builtin"`, `"color-functions"`) | @@ -208,7 +209,8 @@ A load specifier is resolved in this order: 1. Relative to the importing file. 2. Each folder in the `loadPaths` option, in order (bare specifiers only). -3. `node_modules`, resolved using Node module resolution (bare specifiers only). +3. As a package, via Node module resolution — only when `resolveBareSpecifiersAsPackages` is enabled + (bare specifiers only). The plugin supports the modern `pkg:` protocol for importing from npm packages: @@ -229,18 +231,34 @@ This also applies to specifiers that are not part of an `@use`/`@import`/`@forwa ### Bare specifiers -A "bare" specifier is one that does not start with `.`, `/`, or a URL scheme. When it cannot be -resolved relative to the importing file, it is resolved from `loadPaths` and then from `node_modules`: +A "bare" specifier is one that does not start with `.`, `/`, or a URL scheme, for example +`@use "@fluentui/react/dist/sass/variables"`. + +Per the Sass specification the target of `@use`, `@import` and `@forward` is a **URL**, so a bare +specifier is a *relative path*, not a reference to a package. That is how this plugin treats it by +default, and `pkg:` is the supported way to reference a package: ```scss -// Resolves to node_modules/@fluentui/react/dist/sass/variables.scss -@use "@fluentui/react/dist/sass/variables"; +// Preferred: unambiguous, and always enabled +@use "pkg:@fluentui/react/dist/sass/variables"; +``` + +Some other Sass toolchains (the Dart Sass CLI's `--load-path`, `sass-loader`, Vite, the Angular CLI) +instead resolve bare specifiers from `node_modules`. Stylesheets authored for those toolchains — most +commonly inside third-party packages, where a consuming project cannot rewrite the import — depend on +that behavior. Two opt-in options support them: + +```json +{ + "loadPaths": ["src/styles"], + "resolveBareSpecifiersAsPackages": true +} ``` -`pkg:` is preferred for stylesheets you own, because it is unambiguous. Bare specifiers are supported -because they are the portable form understood by every other Sass toolchain (the Dart Sass CLI's -`--load-path`, `sass-loader`, Vite, the Angular CLI, and so on), so third-party packages that ship -Sass sources commonly use them internally, where a consuming project cannot rewrite them. +`loadPaths` resolves a bare specifier against a list of folders. `resolveBareSpecifiersAsPackages` +additionally resolves it as a package using Node module resolution, which honors the package's +`exports` field. Both apply only after relative resolution has failed, so enabling them cannot change +the meaning of a specifier that already resolves. ## Incremental builds diff --git a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts index cfcc3446a3..1429e775c0 100644 --- a/heft-plugins/heft-sass-plugin/src/SassPlugin.ts +++ b/heft-plugins/heft-sass-plugin/src/SassPlugin.ts @@ -30,6 +30,7 @@ export interface ISassConfigurationJson { silenceDeprecations?: string[]; excludeFiles?: string[]; loadPaths?: string[]; + resolveBareSpecifiersAsPackages?: boolean; doNotTrimOriginalFileExtension?: boolean; preserveIcssExports?: boolean; sourceMap?: boolean; @@ -102,6 +103,7 @@ export default class SassPlugin implements IHeftPlugin { silenceDeprecations, excludeFiles, loadPaths, + resolveBareSpecifiersAsPackages, doNotTrimOriginalFileExtension, preserveIcssExports, sourceMap @@ -120,6 +122,7 @@ export default class SassPlugin implements IHeftPlugin { srcFolder: resolveFolder(srcFolder), excludeFiles, loadPaths: loadPaths?.map(resolveFolder), + resolveBareSpecifiersAsPackages, fileExtensions, nonModuleFileExtensions, cssOutputFolders: cssOutputFolders?.map((folder: string | ICssOutputFolder) => { diff --git a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts index 2170645f94..7b062c392c 100644 --- a/heft-plugins/heft-sass-plugin/src/SassProcessor.ts +++ b/heft-plugins/heft-sass-plugin/src/SassProcessor.ts @@ -107,10 +107,24 @@ export interface ISassProcessorOptions { /** * Absolute paths of folders to search when resolving a bare specifier, e.g. `@use 'theme/colors'`. * These are analogous to the `loadPaths` option of the Sass compiler, and are consulted after - * resolution relative to the importing file fails, but before resolution from `node_modules`. + * resolution relative to the importing file fails. */ loadPaths?: string[]; + /** + * If true, a bare specifier that does not resolve relative to the importing file or from `loadPaths` + * will additionally be resolved as a package, using Node module resolution. + * + * This is off by default because the Sass specification defines the target of `@use`, `@import` and + * `@forward` to be a URL, so `@use '@scope/pkg/theme'` is a relative path rather than a reference to + * a package. Enabling this deviates from the specification, and should only be done when consuming + * stylesheets that rely on it, such as third-party packages authored for toolchains that resolve + * bare specifiers from `node_modules`. + * + * Prefer the unambiguous `pkg:` scheme in stylesheets that you control. + */ + resolveBareSpecifiersAsPackages?: boolean; + /** * If set, deprecation warnings from dependencies will be suppressed. */ @@ -219,6 +233,7 @@ export class SassProcessor { readonly #isFileModule: (filePath: string) => boolean; readonly #loadPaths: readonly string[]; + readonly #resolveBareSpecifiersAsPackages: boolean; readonly #options: ISassProcessorOptions; readonly #realpathSync: (path: string) => string; readonly #scssOptions: Options<'async'>; @@ -274,6 +289,7 @@ export class SassProcessor { this.#fileInfo = new Map(); this.#isFileModule = isFileModule; this.#loadPaths = options.loadPaths ?? []; + this.#resolveBareSpecifiersAsPackages = options.resolveBareSpecifiersAsPackages ?? false; this.#resolutions = new Map(); this.#options = options; this.#realpathSync = new RealNodeModulePathResolver().realNodeModulePath; @@ -601,17 +617,17 @@ export class SassProcessor { return relativeResolution; } - // Resolution relative to the importing file failed and the specifier is bare, e.g. - // `@use '@fluentui/react/dist/sass/blah'`. Fall back to the configured load paths and then to - // `node_modules`, matching the behavior of the Sass `loadPaths` option and `NodePackageImporter`. - // This form is what non-Heft Sass toolchains emit, so stylesheets inside third-party packages - // frequently use it and cannot be rewritten by the consuming project. + // Per the Sass specification the target of `@use`/`@import`/`@forward` is a URL, so a bare + // specifier such as `@use '@scope/pkg/theme'` is a relative path and has already been handled + // above. The fallbacks below deviate from that, so each one happens only when the configuration + // explicitly asks for it. return await this.#canonicalizeBareSpecifierAsync(url, context); } /** - * Resolves a bare specifier, e.g. `theme/colors` or `@fluentui/react/dist/sass/blah`, by searching the - * configured load paths and then `node_modules`. + * Resolves a bare specifier, e.g. `theme/colors` or `@fluentui/react/dist/sass/blah`, against the + * opt-in `loadPaths` and `resolveBareSpecifiersAsPackages` options. Returns null when neither option + * is configured. * @param url - The bare specifier to canonicalize * @param context - The context in which the canonicalization is being performed * @returns The canonical URL of the target file, or null if it does not resolve @@ -625,6 +641,10 @@ export class SassProcessor { } } + if (!this.#resolveBareSpecifiersAsPackages) { + return null; + } + try { return await this.#canonicalizePackageAsync(`pkg:${url}`, context); } catch { diff --git a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json index 202a151490..f4b34b6085 100644 --- a/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json +++ b/heft-plugins/heft-sass-plugin/src/schemas/heft-sass-plugin.schema.json @@ -97,13 +97,18 @@ "loadPaths": { "type": "array", - "description": "Folders, relative to the project folder, to search when resolving a bare specifier such as `@use 'theme/colors'`. Analogous to the Sass compiler's `loadPaths` option. Load paths are consulted only after resolution relative to the importing file fails, and before resolution from `node_modules`.", + "description": "Folders, relative to the project folder, to search when resolving a bare specifier such as `@use 'theme/colors'`. Analogous to the Sass compiler's `loadPaths` option. Load paths are consulted only after resolution relative to the importing file fails.", "items": { "type": "string", "pattern": "[^\\\\]" } }, + "resolveBareSpecifiersAsPackages": { + "type": "boolean", + "description": "If true, a bare specifier that does not resolve relative to the importing file or from `loadPaths` will additionally be resolved as a package, using Node module resolution. Defaults to false, because the Sass specification defines the target of `@use`, `@import` and `@forward` to be a URL, making `@use '@scope/pkg/theme'` a relative path rather than a package reference. Enable this only when consuming stylesheets that rely on bare specifiers being resolved from `node_modules`; prefer the `pkg:` scheme in stylesheets that you control." + }, + "ignoreDeprecationsInDependencies": { "type": "boolean", "description": "If set, deprecation warnings from dependencies will be suppressed." diff --git a/heft-plugins/heft-sass-plugin/src/templates/sass.json b/heft-plugins/heft-sass-plugin/src/templates/sass.json index c927169ed0..73f4eff658 100644 --- a/heft-plugins/heft-sass-plugin/src/templates/sass.json +++ b/heft-plugins/heft-sass-plugin/src/templates/sass.json @@ -82,13 +82,25 @@ /** * Folders, relative to the project folder, to search when resolving a bare specifier such as * `@use 'theme/colors'`. Analogous to the Sass compiler's "loadPaths" option. These are consulted - * only after resolution relative to the importing file fails, and before resolution from - * "node_modules". + * only after resolution relative to the importing file fails. * * Default value: undefined */ // "loadPaths": ["src/styles"], + /** + * If true, a bare specifier that resolves neither relative to the importing file nor from + * "loadPaths" will additionally be resolved as a package, using Node module resolution. + * + * Per the Sass specification the target of `@use`, `@import` and `@forward` is a URL, so + * `@use '@scope/pkg/theme'` is a relative path rather than a package reference. Enable this only + * when consuming stylesheets that rely on bare specifiers resolving from "node_modules"; prefer the + * `pkg:` scheme in stylesheets that you control. + * + * Default value: false + */ + // "resolveBareSpecifiersAsPackages": true, + /** * If true, the original file extension will not be trimmed when generating the output CSS filename. * For example, "styles.scss" will generate "styles.scss.css" instead of "styles.css". diff --git a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts index 73f1113b7f..dfef765220 100644 --- a/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts +++ b/heft-plugins/heft-sass-plugin/src/test/SassProcessor.test.ts @@ -32,12 +32,17 @@ const BARE_SPECIFIER_FILES: Record = { 'node_modules/shared-styles/_index.scss': "@use 'fake-sass-package/lib/sass/colors';\n\n.shared {\n color: colors.$fake-brand;\n}\n", + // A package with no bare specifiers of its own, so that the legacy `~` tests exercise only the + // tilde rewrite and not the opt-in bare specifier fallback. + 'node_modules/plain-styles/package.json': '{ "name": "plain-styles", "version": "1.0.0" }', + 'node_modules/plain-styles/_index.scss': '.plain {\n color: #123456;\n}\n', + 'src/bare-import.module.scss': "@use 'fake-sass-package/lib/sass/colors';\n\n.root {\n color: colors.$fake-brand;\n}\n", 'src/dependency-bare-import.module.scss': "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('pkg:shared-styles');\n }\n}\n", 'src/tilde-load-css.module.scss': - "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('~shared-styles');\n }\n}\n", + "@use 'sass:meta';\n\n.root {\n :global {\n @include meta.load-css('~plain-styles');\n }\n}\n", 'src/missing-bare-import.module.scss': "@use 'definitely-not-a-real-package/colors';\n", // A folder that shadows the package name, to verify that relative resolution takes precedence. @@ -67,6 +72,7 @@ type ICreateProcessorOptions = Partial< | 'nonModuleFileExtensions' | 'postProcessCssAsync' | 'preserveIcssExports' + | 'resolveBareSpecifiersAsPackages' | 'silenceDeprecations' | 'sourceMap' | 'srcFolder' @@ -807,8 +813,14 @@ describe(SassProcessor.name, () => { } }); - function createBareSpecifierProcessor(): { processor: SassProcessor; logger: MockScopedLogger } { - return createProcessor(terminalProvider, { srcFolder: bareSpecifierSrcFolder }); + function createBareSpecifierProcessor(options: ICreateProcessorOptions = {}): { + processor: SassProcessor; + logger: MockScopedLogger; + } { + return createProcessor(terminalProvider, { + srcFolder: bareSpecifierSrcFolder, + ...options + }); } async function compileBareSpecifierFixtureAsync( @@ -819,7 +831,9 @@ describe(SassProcessor.name, () => { } it('resolves a bare specifier from node_modules', async () => { - const { processor, logger } = createBareSpecifierProcessor(); + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); await compileBareSpecifierFixtureAsync(processor, 'bare-import.module.scss'); expect(logger.errors).toHaveLength(0); @@ -828,7 +842,9 @@ describe(SassProcessor.name, () => { it('resolves a bare specifier used inside a dependency stylesheet', async () => { // The failing import lives in node_modules/shared-styles, which the consuming project cannot edit. - const { processor, logger } = createBareSpecifierProcessor(); + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); await compileBareSpecifierFixtureAsync(processor, 'dependency-bare-import.module.scss'); expect(logger.errors).toHaveLength(0); @@ -839,15 +855,30 @@ describe(SassProcessor.name, () => { it('resolves a legacy tilde specifier inside meta.load-css()', async () => { // The `~` rewrite is applied by the resolver, not only by the @use/@import/@forward preprocessor. + // `~` is an explicit package reference, so it is not gated by resolveBareSpecifiersAsPackages; + // this processor deliberately leaves that option at its default. const { processor, logger } = createBareSpecifierProcessor(); await compileBareSpecifierFixtureAsync(processor, 'tilde-load-css.module.scss'); expect(logger.errors).toHaveLength(0); - expect(getCssOutput('tilde-load-css.module.scss')).toContain('.shared'); + expect(getCssOutput('tilde-load-css.module.scss')).toContain('.plain'); }); - it('prefers a file relative to the importer over a package of the same name', async () => { + it('does not resolve a bare specifier as a package by default', async () => { + // Per the Sass specification a bare specifier is a relative path, so package resolution must + // happen only when the configuration explicitly opts in. This processor does not pass the + // option at all, so it asserts the default value and not merely an explicit `false`. const { processor, logger } = createBareSpecifierProcessor(); + await compileBareSpecifierFixtureAsync(processor, 'bare-import.module.scss'); + + expect(logger.errors).toHaveLength(1); + expect(logger.errors[0].message).toContain("Can't find stylesheet to import"); + }); + + it('prefers a file relative to the importer over a package of the same name', async () => { + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); await compileBareSpecifierFixtureAsync(processor, 'nested/relative-precedence.module.scss'); expect(logger.errors).toHaveLength(0); @@ -857,7 +888,9 @@ describe(SassProcessor.name, () => { }); it('reports a normal Sass error when a bare specifier names no installed package', async () => { - const { processor, logger } = createBareSpecifierProcessor(); + const { processor, logger } = createBareSpecifierProcessor({ + resolveBareSpecifiersAsPackages: true + }); await compileBareSpecifierFixtureAsync(processor, 'missing-bare-import.module.scss'); expect(logger.errors).toHaveLength(1); diff --git a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap index aecfa19b9f..c31dfb7402 100644 --- a/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap +++ b/heft-plugins/heft-sass-plugin/src/test/__snapshots__/SassProcessor.test.ts.snap @@ -174,6 +174,15 @@ module.exports.default = module.exports;", } `; +exports[`SassProcessor bare specifier resolution does not resolve a bare specifier as a package by default: terminal-output 1`] = ` +Array [ + "[verbose] Checking for changes to 1 files...[n]", + "[ log] Compiling 1 files...[n]", +] +`; + +exports[`SassProcessor bare specifier resolution does not resolve a bare specifier as a package by default: written-files 1`] = `Map {}`; + exports[`SassProcessor bare specifier resolution prefers a file relative to the importer over a package of the same name: terminal-output 1`] = ` Array [ "[verbose] Checking for changes to 1 files...[n]", @@ -257,8 +266,8 @@ Map { } declare const styles: IStyles; export default styles;", - "/fake/output/css/tilde-load-css.module.css" => ".root .shared { - color: #00ff00; + "/fake/output/css/tilde-load-css.module.css" => ".root .plain { + color: #123456; }", } `;