From 2d147d043b7faa1779868be9f133f81e0e1177ce Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:07:44 +0300 Subject: [PATCH 1/2] feat(finder): implement file precedence for layered locale and plugin resolution - Add file precedence system to prioritize layers and browser-specific files. - Enhance locale merging logic with multi-layered and browser-specific resolution. - Introduce duplicate layer detection and error handling for ambiguous files. - Add layered locale and file precedence fixture tests for rigorous validation. --- AGENTS.md | 7 + .../entrypoint/finder/AbstractAssetFinder.ts | 37 +++-- .../finder/AbstractEntrypointFinder.ts | 9 ++ src/cli/entrypoint/finder/AbstractFinder.ts | 37 +---- .../entrypoint/finder/AbstractViewFinder.ts | 8 +- .../entrypoint/finder/AssetPluginFinder.ts | 22 ++- .../entrypoint/finder/LocaleFinder.test.ts | 130 ++++++++++++++++++ src/cli/entrypoint/finder/LocaleFinder.ts | 35 +++++ .../entrypoint/finder/PluginFinder.test.ts | 63 +++++++++ src/cli/entrypoint/finder/PluginFinder.ts | 16 ++- src/cli/entrypoint/finder/PopupFinder.test.ts | 80 +++++++++++ src/cli/entrypoint/finder/ViewCspFinder.ts | 25 +++- .../src/apps/app/locales/en.json | 3 + .../src/apps/app/locales/en.yaml | 1 + .../layers/plugin-override/locales/en.yaml | 1 + .../locale/layers/plugin/locales/en.yaml | 2 + .../apps/app/app-src/locales/en.chrome.yaml | 2 + .../src/apps/app/app-src/locales/en.yaml | 2 + .../apps/app/empty-app-src/locales/.gitkeep | 0 .../src/apps/app/locales/en.chrome.yaml | 2 + .../src/apps/app/locales/en.firefox.yaml | 2 + .../project/src/apps/app/locales/en.yaml | 2 + .../locale/layers/project/src/locales/en.yaml | 2 + .../layers/project/src/shared/locales/en.yaml | 2 + .../precedence/project/src/apps/app/popup.ts | 1 + .../precedence/project/src/shared/popup.ts | 1 + .../entrypoint/finder/utils/filePrecedence.ts | 95 +++++++++++++ src/types/config.ts | 8 ++ 28 files changed, 538 insertions(+), 57 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/cli/entrypoint/finder/PluginFinder.test.ts create mode 100644 src/cli/entrypoint/finder/PopupFinder.test.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.json create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin-override/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.chrome.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/empty-app-src/locales/.gitkeep create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.chrome.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.firefox.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/shared/locales/en.yaml create mode 100644 src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/apps/app/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/shared/popup.ts create mode 100644 src/cli/entrypoint/finder/utils/filePrecedence.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9d9e84c3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Repository conventions + +## TypeScript file naming + +- Use PascalCase filenames only when the file's primary export is a class, for example `LocaleFinder.ts`. +- Use camelCase filenames for modules that export helper functions, utilities, constants, enums, or types without a primary class, for example `utils/filePrecedence.ts`. +- Keep finder-specific helper modules in `src/cli/entrypoint/finder/utils`. diff --git a/src/cli/entrypoint/finder/AbstractAssetFinder.ts b/src/cli/entrypoint/finder/AbstractAssetFinder.ts index 9ece5b4e..b1bed119 100644 --- a/src/cli/entrypoint/finder/AbstractAssetFinder.ts +++ b/src/cli/entrypoint/finder/AbstractAssetFinder.ts @@ -4,6 +4,7 @@ import fs from "fs"; import {getAppPath, getAppSourcePath, getResolvePath, getSharedPath, getSourcePath} from "@cli/resolvers/path"; import AbstractFinder from "./AbstractFinder"; +import {FileLayer, getWorkspaceFileLayers, setFilePrecedence, type WorkspaceFileLayer} from "./utils/filePrecedence"; import {EntrypointFile} from "@typing/entrypoint"; @@ -35,26 +36,40 @@ export default abstract class extends AbstractFinder { } protected async getFiles(): Promise> { - const files = new Set(); + const files = new Map(); + + const collect = async (directory: string, layer: FileLayer): Promise => { + const assetFiles = await this.findFiles(getResolvePath(directory)); - const parser = async (directory: string): Promise => { - if (files.size === 0 || this.canMerge()) { - const localeFiles = await this.findFiles(getResolvePath(directory)); + for (const file of assetFiles) { + const canonicalPath = fs.realpathSync.native(file.file); - for (const file of localeFiles) { - files.add(file); + if (!files.has(canonicalPath)) { + files.set(canonicalPath, setFilePrecedence(file, {layer})); } } }; const dir = this.getDirectory(); - await parser(getAppSourcePath(this.config, dir)); - await parser(getAppPath(this.config, dir)); - await parser(getSharedPath(this.config, dir)); - await parser(getSourcePath(this.config, dir)); + const directories: Record = { + [FileLayer.Source]: getSourcePath(this.config, dir), + [FileLayer.Shared]: getSharedPath(this.config, dir), + [FileLayer.App]: getAppPath(this.config, dir), + [FileLayer.AppSource]: getAppSourcePath(this.config, dir), + }; - return files; + const merge = this.canMerge(); + + for (const layer of getWorkspaceFileLayers()) { + await collect(directories[layer], layer); + + if (!merge && files.size > 0) { + break; + } + } + + return new Set(files.values()); } protected async findFiles(directory: string): Promise> { diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index 6d0f1d07..401947dd 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -3,6 +3,7 @@ import path from "path"; import pluralize from "pluralize"; import AbstractOptionsFinder from "./AbstractOptionsFinder"; +import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; import {getAppSourcePath, getSharedPath} from "@cli/resolvers/path"; @@ -33,6 +34,10 @@ export default abstract class extends AbstractOptio const appFiles = this.findFiles(getAppSourcePath(this.config)); + for (const file of appFiles) { + setFilePrecedence(file, {layer: FileLayer.AppSource}); + } + if (appFiles.size > 0) { files = appFiles; @@ -44,6 +49,10 @@ export default abstract class extends AbstractOptio if ((appFiles.size > 0 && this.canMerge()) || appFiles.size === 0) { const sharedFiles = this.findFiles(getSharedPath(this.config)); + for (const file of sharedFiles) { + setFilePrecedence(file, {layer: FileLayer.Shared}); + } + if (sharedFiles.size > 0) { files = new Set([...files, ...sharedFiles]); diff --git a/src/cli/entrypoint/finder/AbstractFinder.ts b/src/cli/entrypoint/finder/AbstractFinder.ts index 25dc2319..50a3306b 100644 --- a/src/cli/entrypoint/finder/AbstractFinder.ts +++ b/src/cli/entrypoint/finder/AbstractFinder.ts @@ -1,19 +1,13 @@ -import _ from "lodash"; import path from "path"; import fs from "fs"; import {createRequire} from "module"; import {fileURLToPath} from "url"; +import {compareFilePrecedence} from "./utils/filePrecedence"; + import {toPosixPath} from "@cli/utils/path"; import {isFile} from "@cli/utils/fs"; -import { - getAppPath, - getAppSourcePath, - getResolvePath, - getSharedPath, - getSourcePath, - resolveRootPath, -} from "@cli/resolvers/path"; +import {getResolvePath, getSourcePath, resolveRootPath} from "@cli/resolvers/path"; import {resolveAssetsPath, resolveEntrypointPath} from "@cli/entrypoint/utils"; import {ReadonlyConfig} from "@typing/config"; @@ -24,19 +18,9 @@ export default abstract class implements EntrypointFinder { private readonly require = createRequire(import.meta.url); - protected readonly priorityDirectories: string[]; - protected abstract getFiles(): Promise>; - protected constructor(protected readonly config: ReadonlyConfig) { - this.priorityDirectories = [ - "node_modules", - getSourcePath(config), - getSharedPath(config), - getAppPath(config), - getAppSourcePath(config), - ]; - } + protected constructor(protected readonly config: ReadonlyConfig) {} public clear(): this { this._files = undefined; @@ -50,11 +34,10 @@ export default abstract class implements EntrypointFinder { } const files = Array.from(await this.getFiles()).sort((a, b) => { - const priorityA = this.priority(a); - const priorityB = this.priority(b); + const precedence = compareFilePrecedence(a, b); - if (priorityA !== priorityB) { - return priorityA - priorityB; + if (precedence !== 0) { + return precedence; } return this.sortKey(a).localeCompare(this.sortKey(b)); @@ -133,12 +116,6 @@ export default abstract class implements EntrypointFinder { } } - protected priority(file: EntrypointFile): number { - const priority = _.findIndex(this.priorityDirectories, dir => file.file.includes(dir)); - - return priority >= 0 ? priority : this.priorityDirectories.length; - } - protected sortKey(file: EntrypointFile): string { return toPosixPath(file.import || file.file); } diff --git a/src/cli/entrypoint/finder/AbstractViewFinder.ts b/src/cli/entrypoint/finder/AbstractViewFinder.ts index abb2d46d..e9cbdd08 100644 --- a/src/cli/entrypoint/finder/AbstractViewFinder.ts +++ b/src/cli/entrypoint/finder/AbstractViewFinder.ts @@ -50,18 +50,16 @@ export default abstract class extends AbstractP protected async getViews(): Promise> { const views: ViewItems = new Map(); + const entries = [...(await this.plugin().options())]; + const candidates = this.allowMultiple() ? entries : entries.slice(-1); - for (const [file, options] of await this.plugin().options()) { + for (const [file, options] of candidates) { views.set(this.createViewName(file, options), { alias: this.createViewAlias(file, options), filename: this.createViewFilename(file, options), file, options, }); - - if (!this.allowMultiple()) { - break; - } } return views; diff --git a/src/cli/entrypoint/finder/AssetPluginFinder.ts b/src/cli/entrypoint/finder/AssetPluginFinder.ts index 4354a271..e934c798 100644 --- a/src/cli/entrypoint/finder/AssetPluginFinder.ts +++ b/src/cli/entrypoint/finder/AssetPluginFinder.ts @@ -6,6 +6,7 @@ import {processPluginHandler} from "@cli/resolvers/plugin"; import AbstractFinder from "./AbstractFinder"; import AbstractAssetFinder from "./AbstractAssetFinder"; +import {FileLayer, FileSpecificity, setFilePrecedence, setFileSpecificity} from "./utils/filePrecedence"; import {EntrypointFile} from "@typing/entrypoint"; import {ReadonlyConfig} from "@typing/config"; @@ -27,9 +28,14 @@ export default class extends AbstractFinder { }) ); + // Plugin files preserve registration identity, order and sequence. + // Canonical-path deduplication is intentionally limited to overlapping + // workspace paths in AbstractAssetFinder. const files = new Set(); - for await (let {name, result} of pluginResult) { + for (let pluginIndex = 0; pluginIndex < pluginResult.length; pluginIndex++) { + let {name, result} = pluginResult[pluginIndex]; + if (_.isBoolean(result)) { result = pluralize(this.key); } @@ -39,6 +45,8 @@ export default class extends AbstractFinder { } if (_.isArray(result) || _.isSet(result)) { + let sequence = 0; + for (const item of result) { if (_.isEmpty(item)) { continue; @@ -63,7 +71,19 @@ export default class extends AbstractFinder { const {name: filename} = path.parse(file.file); if (filename.endsWith(`.${this.config.browser}`) || !filename.includes(".")) { + setFilePrecedence(file, { + layer: FileLayer.Plugin, + order: pluginIndex, + sequence, + }); + setFileSpecificity( + file, + filename.endsWith(`.${this.config.browser}`) + ? FileSpecificity.Browser + : FileSpecificity.Generic + ); files.add(file); + sequence++; } } } diff --git a/src/cli/entrypoint/finder/LocaleFinder.test.ts b/src/cli/entrypoint/finder/LocaleFinder.test.ts index cb2cc627..15e33e39 100644 --- a/src/cli/entrypoint/finder/LocaleFinder.test.ts +++ b/src/cli/entrypoint/finder/LocaleFinder.test.ts @@ -41,6 +41,45 @@ const makeFinder = (fixture: string, config: Partial = {}): Test } as ReadonlyConfig); }; +const makeLayeredFinder = (config: Partial = {}): TestLocaleFinder => { + const root = path.join(fixtures, "layers"); + const resolvedConfig = { + app: "app", + appSrcDir: "app-src", + appsDir: "apps", + browser: Browser.Chrome, + command: Command.Build, + lang: Language.English, + localeDir: "locales", + mergeLocales: true, + mode: "production", + plugins: [], + rootDir: path.join(root, "project"), + sharedDir: "shared", + srcDir: "src", + ...config, + } as ReadonlyConfig; + + const finder = new TestLocaleFinder(resolvedConfig); + + resolvedConfig.plugins.push( + { + name: path.join(root, "plugin"), + locale: true, + }, + { + name: path.join(root, "plugin-override"), + locale: true, + }, + { + name: "adnbn:locale", + locale: () => finder.files(), + } + ); + + return finder; +}; + describe("LocaleFinder", () => { test("reports locale finder configuration", () => { const finder = makeFinder("partial", { @@ -152,4 +191,95 @@ describe("LocaleFinder", () => { 'Locale "fr" key "app.greeting" substitutions [firstName] must match default locale "en" substitutions [name]' ); }); + + test("merges plugin, source, shared, app, app source and browser-specific locales from lowest to highest priority", async () => { + const builders = await makeLayeredFinder().builders(); + const locale = builders.get(Language.English); + + expect(locale).toBeDefined(); + expect(Object.fromEntries(locale!.get())).toMatchObject({ + title: "App Source Chrome", + pluginOnly: "Later Plugin", + sourceOnly: "Source", + sharedOnly: "Shared", + appOnly: "App", + appSourceOnly: "App Source", + browserOnly: "Chrome", + appSourceBrowserOnly: "Chrome", + }); + }); + + test("keeps plugin locales as a baseline when workspace locale merging is disabled", async () => { + const builders = await makeLayeredFinder({mergeLocales: false}).builders(); + const locale = builders.get(Language.English); + + expect(locale).toBeDefined(); + expect(Object.fromEntries(locale!.get())).toMatchObject({ + title: "App Source Chrome", + pluginOnly: "Later Plugin", + appSourceOnly: "App Source", + appSourceBrowserOnly: "Chrome", + }); + expect(locale!.get().has("sourceOnly")).toBe(false); + expect(locale!.get().has("sharedOnly")).toBe(false); + expect(locale!.get().has("appOnly")).toBe(false); + expect(locale!.get().has("browserOnly")).toBe(false); + }); + + test("continues to the next workspace layer when a higher-priority locale directory is empty", async () => { + const builders = await makeLayeredFinder({ + appSrcDir: "empty-app-src", + mergeLocales: false, + }).builders(); + const locale = builders.get(Language.English); + + expect(locale).toBeDefined(); + expect(Object.fromEntries(locale!.get())).toMatchObject({ + title: "App Chrome", + pluginOnly: "Later Plugin", + appOnly: "App", + browserOnly: "Chrome", + }); + expect(locale!.get().has("sourceOnly")).toBe(false); + expect(locale!.get().has("sharedOnly")).toBe(false); + expect(locale!.get().has("appSourceOnly")).toBe(false); + expect(locale!.get().has("appSourceBrowserOnly")).toBe(false); + }); + + test("deduplicates locale files discovered through overlapping workspace paths", async () => { + const multiFiles = [...(await makeLayeredFinder().files())].map(({file}) => file); + const singleFiles = [...(await makeLayeredFinder({sharedDir: "."}).files())].map(({file}) => file); + + expect(new Set(multiFiles).size).toBe(multiFiles.length); + expect(new Set(singleFiles).size).toBe(singleFiles.length); + }); + + test("rejects ambiguous locale files in the same layer", async () => { + const root = path.join(fixtures, "duplicate-layer"); + const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + browser: Browser.Chrome, + command: Command.Build, + lang: Language.English, + localeDir: "locales", + mergeLocales: true, + mode: "production", + plugins: [], + rootDir: root, + sharedDir: "shared", + srcDir: "src", + } as ReadonlyConfig; + const finder = new TestLocaleFinder(config); + + config.plugins.push({ + name: "adnbn:locale", + locale: () => finder.files(), + }); + + await expect(finder.builders()).rejects.toThrow( + `Locale "en" has multiple generic files in the app source layer: "${path.join(root, "src/apps/app/locales/en.json")}" and "${path.join(root, "src/apps/app/locales/en.yaml")}"` + ); + }); }); diff --git a/src/cli/entrypoint/finder/LocaleFinder.ts b/src/cli/entrypoint/finder/LocaleFinder.ts index b94f67c0..e8f16a68 100644 --- a/src/cli/entrypoint/finder/LocaleFinder.ts +++ b/src/cli/entrypoint/finder/LocaleFinder.ts @@ -5,11 +5,13 @@ import yaml from "js-yaml"; import AbstractAssetFinder from "./AbstractAssetFinder"; import AssetPluginFinder from "./AssetPluginFinder"; +import {FileLayer, FileSpecificity, getFilePrecedence} from "./utils/filePrecedence"; import localeFactory, {LocaleStructureValidator} from "@cli/builders/locale"; import {isFileExtension} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; +import {EntrypointFile} from "@typing/entrypoint"; import { Language, LanguageCodes, @@ -22,6 +24,19 @@ import { export type {LocaleBuilders} from "@typing/locale"; +const layerNames: Record = { + [FileLayer.Plugin]: "plugin", + [FileLayer.Source]: "source", + [FileLayer.Shared]: "shared", + [FileLayer.App]: "app", + [FileLayer.AppSource]: "app source", +}; + +const specificityNames: Record = { + [FileSpecificity.Generic]: "generic", + [FileSpecificity.Browser]: "browser-specific", +}; + export default class extends AbstractAssetFinder { protected _plugin?: AssetPluginFinder; protected _builders?: LocaleBuilders; @@ -68,6 +83,8 @@ export default class extends AbstractAssetFinder { return _.chain(Array.from(await this.plugin().files())) .groupBy(file => this.getLanguageFromFilename(file.file)) .reduce((map, files, lang) => { + this.assertUniqueFiles(lang as Language, files); + const locale = localeFactory(lang as Language, this.config); for (const {file} of files) { @@ -87,6 +104,24 @@ export default class extends AbstractAssetFinder { .value(); } + protected assertUniqueFiles(lang: Language, files: EntrypointFile[]): void { + const sources = new Map(); + + for (const file of files) { + const {layer, order, specificity} = getFilePrecedence(file); + const source = [layer, order, specificity].join(":"); + const duplicate = sources.get(source); + + if (duplicate) { + throw new Error( + `Locale "${lang}" has multiple ${specificityNames[specificity]} files in the ${layerNames[layer]} layer: "${duplicate.file}" and "${file.file}"` + ); + } + + sources.set(source, file); + } + } + protected getValidator(): LocaleStructureValidator { return (this._validator ??= new LocaleStructureValidator(this.config.lang)); } diff --git a/src/cli/entrypoint/finder/PluginFinder.test.ts b/src/cli/entrypoint/finder/PluginFinder.test.ts new file mode 100644 index 00000000..33821fa6 --- /dev/null +++ b/src/cli/entrypoint/finder/PluginFinder.test.ts @@ -0,0 +1,63 @@ +import path from "path"; + +import PopupFinder from "./PopupFinder"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointFile} from "@typing/entrypoint"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures", "precedence"); + +const pluginFile = (name: string): EntrypointFile => ({ + file: path.join(fixtures, "plugins", name, "popup.ts"), + import: `${name}/popup`, + external: name, +}); + +const makeFinder = (): PopupFinder => { + const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePopup: true, + multiplePopup: false, + plugins: [], + rootDir: path.join(fixtures, "project"), + sharedDir: "shared", + srcDir: "src", + } as Partial as ReadonlyConfig; + const finder = new PopupFinder(config); + + config.plugins.push( + { + name: "first-plugin", + popup: pluginFile("first-plugin"), + }, + { + name: "second-plugin", + popup: pluginFile("second-plugin"), + }, + { + name: "adnbn:popup", + popup: () => finder.files(), + } + ); + + return finder; +}; + +describe("PluginFinder", () => { + test("orders plugin, shared and app entrypoints from lowest to highest priority", async () => { + const files = [...(await makeFinder().plugin().files())].map(({file}) => + file.startsWith(fixtures) ? path.relative(fixtures, file) : file + ); + + expect(files).toEqual([ + path.join("plugins", "first-plugin", "popup.ts"), + path.join("plugins", "second-plugin", "popup.ts"), + path.join("project", "src", "shared", "popup.ts"), + path.join("project", "src", "apps", "app", "popup.ts"), + ]); + }); +}); diff --git a/src/cli/entrypoint/finder/PluginFinder.ts b/src/cli/entrypoint/finder/PluginFinder.ts index 66f26731..147838d5 100644 --- a/src/cli/entrypoint/finder/PluginFinder.ts +++ b/src/cli/entrypoint/finder/PluginFinder.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import AbstractOptionsFinder from "./AbstractOptionsFinder"; +import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; import {processPluginHandler} from "@cli/resolvers/plugin"; @@ -34,7 +35,8 @@ export default class extends AbstractOptionsFinder< const files = new Set(); - for (const {name, result} of pluginResult) { + for (let pluginIndex = 0; pluginIndex < pluginResult.length; pluginIndex++) { + const {name, result} = pluginResult[pluginIndex]; let endpoints: Array = []; if (_.isBoolean(result)) { @@ -47,8 +49,16 @@ export default class extends AbstractOptionsFinder< endpoints = Array.from(result as Set); } - for (const endpoint of endpoints) { - files.add(_.isString(endpoint) ? this.resolve(name, endpoint) : endpoint); + for (let sequence = 0; sequence < endpoints.length; sequence++) { + const endpoint = endpoints[sequence]; + const file = _.isString(endpoint) ? this.resolve(name, endpoint) : endpoint; + + setFilePrecedence(file, { + layer: FileLayer.Plugin, + order: pluginIndex, + sequence, + }); + files.add(file); } } diff --git a/src/cli/entrypoint/finder/PopupFinder.test.ts b/src/cli/entrypoint/finder/PopupFinder.test.ts new file mode 100644 index 00000000..40e08d03 --- /dev/null +++ b/src/cli/entrypoint/finder/PopupFinder.test.ts @@ -0,0 +1,80 @@ +import PopupFinder from "./PopupFinder"; + +import {ReadonlyConfig} from "@typing/config"; +import {EntrypointFile, EntrypointOptionsFinder, EntrypointType} from "@typing/entrypoint"; +import {PopupEntrypointOptions} from "@typing/popup"; + +class TestPopupFinder extends PopupFinder { + public constructor( + config: ReadonlyConfig, + private readonly pluginOptions: Map + ) { + super(config); + } + + public plugin(): EntrypointOptionsFinder { + return createPlugin(this.pluginOptions); + } +} + +const config = { + app: "app", + appSrcDir: ".", + appsDir: "apps", + debug: false, + htmlDir: ".", + mergePopup: false, + multiplePopup: false, + plugins: [], + rootDir: "/project", + sharedDir: "shared", + srcDir: "src", +} as Partial as ReadonlyConfig; + +const file = (filename: string): EntrypointFile => ({ + file: filename, + import: filename, +}); + +const createPlugin = ( + options: Map +): EntrypointOptionsFinder => ({ + type: () => EntrypointType.Popup, + options: async () => options, + contracts: async () => new Map(Array.from(options.keys()).map(entry => [entry, undefined])), + files: async () => new Set(options.keys()), + empty: async () => options.size === 0, + exists: async () => options.size > 0, + clear: function () { + return this; + }, + holds: entry => options.has(entry), +}); + +describe("PopupFinder", () => { + test("selects the highest-priority popup when multiple popups are disabled", async () => { + const plugin = file("/plugins/default/popup.ts"); + const shared = file("/project/src/shared/popup.ts"); + const app = file("/project/src/apps/app/popup.ts"); + const finder = new TestPopupFinder( + config, + new Map([ + [plugin, {csp: {sources: {connect: ["https://plugin.example.com"]}}}], + [shared, {csp: {sources: {connect: ["https://shared.example.com"]}}}], + [app, {csp: {sources: {connect: ["https://app.example.com"]}}}], + ]) + ); + + const views = await finder.views(); + + expect(views.size).toBe(1); + expect([...views.values()][0].file).toBe(app); + await expect(finder.csp()).resolves.toEqual([ + { + sources: { + connect: ["https://app.example.com"], + }, + }, + ]); + }); +}); diff --git a/src/cli/entrypoint/finder/ViewCspFinder.ts b/src/cli/entrypoint/finder/ViewCspFinder.ts index bb42bcf9..5f7d6f2a 100644 --- a/src/cli/entrypoint/finder/ViewCspFinder.ts +++ b/src/cli/entrypoint/finder/ViewCspFinder.ts @@ -6,27 +6,38 @@ import type {ViewEntrypointOptions} from "@typing/view"; type CspEntrypointOptions = ViewEntrypointOptions & {csp?: unknown}; export default abstract class extends AbstractViewFinder { + protected _csp?: Csp[]; + protected async getViews(): Promise> { const views = await super.getViews(); + const policies: Csp[] = []; for (const view of views.values()) { const {csp, ...options} = view.options; + if (csp) { + policies.push(csp as Csp); + } + view.options = options as O; } + this._csp = policies; + return views; } public async csp(): Promise { - const policies: Csp[] = []; - - for (const [, options] of await this.plugin().options()) { - if (options.csp) { - policies.push(options.csp as Csp); - } + if (!this._csp) { + await this.views(); } - return policies; + return this._csp ?? []; + } + + public clear(): this { + this._csp = undefined; + + return super.clear(); } } diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.json b/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.json new file mode 100644 index 00000000..09c64131 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.json @@ -0,0 +1,3 @@ +{ + "title": "JSON" +} diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.yaml new file mode 100644 index 00000000..6ec02aeb --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/duplicate-layer/src/apps/app/locales/en.yaml @@ -0,0 +1 @@ +title: YAML diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin-override/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin-override/locales/en.yaml new file mode 100644 index 00000000..8fe7c355 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin-override/locales/en.yaml @@ -0,0 +1 @@ +pluginOnly: Later Plugin diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin/locales/en.yaml new file mode 100644 index 00000000..0489bf12 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/plugin/locales/en.yaml @@ -0,0 +1,2 @@ +title: Plugin +pluginOnly: Plugin diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.chrome.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.chrome.yaml new file mode 100644 index 00000000..38bd2ffd --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.chrome.yaml @@ -0,0 +1,2 @@ +title: App Source Chrome +appSourceBrowserOnly: Chrome diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.yaml new file mode 100644 index 00000000..76696bf3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/app-src/locales/en.yaml @@ -0,0 +1,2 @@ +title: App Source +appSourceOnly: App Source diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/empty-app-src/locales/.gitkeep b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/empty-app-src/locales/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.chrome.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.chrome.yaml new file mode 100644 index 00000000..55bea89e --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.chrome.yaml @@ -0,0 +1,2 @@ +title: App Chrome +browserOnly: Chrome diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.firefox.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.firefox.yaml new file mode 100644 index 00000000..3e16978c --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.firefox.yaml @@ -0,0 +1,2 @@ +title: App Firefox +browserOnly: Firefox diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.yaml new file mode 100644 index 00000000..04fb87c5 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/apps/app/locales/en.yaml @@ -0,0 +1,2 @@ +title: App +appOnly: App diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/locales/en.yaml new file mode 100644 index 00000000..d1b75dd2 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/locales/en.yaml @@ -0,0 +1,2 @@ +title: Source +sourceOnly: Source diff --git a/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/shared/locales/en.yaml b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/shared/locales/en.yaml new file mode 100644 index 00000000..090fbace --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/locale/layers/project/src/shared/locales/en.yaml @@ -0,0 +1,2 @@ +title: Shared +sharedOnly: Shared diff --git a/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/apps/app/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/apps/app/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/apps/app/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/shared/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/shared/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/precedence/project/src/shared/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/utils/filePrecedence.ts b/src/cli/entrypoint/finder/utils/filePrecedence.ts new file mode 100644 index 00000000..25187817 --- /dev/null +++ b/src/cli/entrypoint/finder/utils/filePrecedence.ts @@ -0,0 +1,95 @@ +import {EntrypointFile} from "@typing/entrypoint"; + +/** + * Numeric values define the composition order from the least specific layer + * to the most specific one. Consumers that merge values therefore let later + * workspace layers override plugins, while singleton consumers select the + * final candidate. + */ +export enum FileLayer { + Plugin, + Source, + Shared, + App, + AppSource, +} + +export type WorkspaceFileLayer = Exclude; + +export enum FileSpecificity { + Generic, + Browser, +} + +export interface FilePrecedence { + layer: FileLayer; + order?: number; + sequence?: number; + specificity?: FileSpecificity; +} + +const precedence = new WeakMap>(); + +const normalize = ({ + layer, + order = 0, + sequence = 0, + specificity = FileSpecificity.Generic, +}: FilePrecedence): Required => ({ + layer, + order, + sequence, + specificity, +}); + +const fallback = normalize({layer: FileLayer.Plugin}); + +const isWorkspaceFileLayer = (layer: string | FileLayer): layer is WorkspaceFileLayer => { + return typeof layer === "number" && layer !== FileLayer.Plugin; +}; + +const workspaceFileLayers = Object.freeze( + Object.values(FileLayer) + .filter(isWorkspaceFileLayer) + .sort((a, b) => b - a) +); + +/** + * Workspace discovery runs from highest to lowest priority. This lets + * non-merge consumers stop at the first non-empty layer and ensures that + * overlapping canonical paths keep their most specific layer. + */ +export const getWorkspaceFileLayers = (): readonly WorkspaceFileLayer[] => workspaceFileLayers; + +export const setFilePrecedence = (file: EntrypointFile, value: FilePrecedence): EntrypointFile => { + if (!precedence.has(file)) { + precedence.set(file, normalize(value)); + } + + return file; +}; + +export const setFileSpecificity = (file: EntrypointFile, specificity: FileSpecificity): EntrypointFile => { + precedence.set(file, { + ...(precedence.get(file) ?? fallback), + specificity, + }); + + return file; +}; + +export const getFilePrecedence = (file: EntrypointFile): Required => { + return precedence.get(file) ?? fallback; +}; + +export const compareFilePrecedence = (a: EntrypointFile, b: EntrypointFile): number => { + const left = getFilePrecedence(a); + const right = getFilePrecedence(b); + + return ( + left.layer - right.layer || + left.order - right.order || + left.specificity - right.specificity || + left.sequence - right.sequence + ); +}; diff --git a/src/types/config.ts b/src/types/config.ts index 683974fc..58b57653 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -467,6 +467,14 @@ export interface Config { * Flag indicating whether to merge localizations from App and Shared directories. * When `true`, localization files from both directories will be combined. * + * Locale values are resolved from the least specific source to the most specific: + * plugins, source root, Shared, and App. Within each layer, a browser-specific + * file overrides its generic file. A value from a later source overrides the + * same key from an earlier source. When `false`, plugin locales remain available + * as a baseline and only the highest available workspace layer is selected. + * Multiple generic or browser-specific files for the same language in one layer + * are rejected as ambiguous. + * * @default true */ mergeLocales: boolean; From 331124c05b15b9d79e559c2cc83ca1b65044e6d0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:21:58 +0300 Subject: [PATCH 2/2] ci(release): simplify and refine release rules and version bump logic - Extract `whatBump` and `hasBreakingChange` for cleaner code organization. - Remove redundant `releaseRules` and inline `whatBump` implementation. - Consolidate breaking change detection logic for better maintainability. --- .release-it.cjs | 110 ++++++++++++++++++++++++--------------- tests/release-it.test.ts | 59 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 42 deletions(-) create mode 100644 tests/release-it.test.ts diff --git a/.release-it.cjs b/.release-it.cjs index d3899e6b..9651a5f5 100644 --- a/.release-it.cjs +++ b/.release-it.cjs @@ -107,7 +107,71 @@ const types = new Map([ const normalizeRepoUrl = url => url.replace(/^git\+/, "").replace(/\.git$/, ""); const repoUrl = pkg?.repository?.url ? normalizeRepoUrl(pkg.repository.url) : null; -module.exports = () => { +const breakingChangePattern = /\bBREAKING[ -]?CHANGE\b/i; + +function hasBreakingChange(commit) { + if (commit.breaking) { + return true; + } + + const type = String(commit.type || "").trim(); + + if (type.endsWith("!")) { + return true; + } + + if (typeof commit.header === "string" && /^\w+(?:\([^)]+\))?!:/.test(commit.header)) { + return true; + } + + if ( + commit.notes?.some(note => + [note.title, note.text].some(value => typeof value === "string" && breakingChangePattern.test(value)) + ) + ) { + return true; + } + + return typeof commit.footer === "string" && breakingChangePattern.test(commit.footer); +} + +function whatBump(commits, currentVersion = pkg.version) { + let isBreaking = false; + let isMinor = false; + let isPatch = false; + + for (const commit of commits) { + if (hasBreakingChange(commit)) { + isBreaking = true; + } + + const type = String(commit.type || "") + .trim() + .toLowerCase() + .replace(/!+$/, ""); + + if (["feat", "revert"].includes(type)) { + isMinor = true; + } + + if (["fix", "perf", "refactor", "ci"].includes(type)) { + isPatch = true; + } + } + + if (isBreaking) { + const currentMajor = Number.parseInt(String(currentVersion).replace(/^v/i, "").split(".")[0], 10); + + return {level: Number.isNaN(currentMajor) || currentMajor >= 1 ? 0 : 1}; + } + + if (isMinor) return {level: 1}; + if (isPatch) return {level: 2}; + + return null; +} + +const createReleaseConfig = () => { const contributors = getContributors(); return { @@ -151,14 +215,6 @@ module.exports = () => { presetConfig: { types: [...types.entries()].map(([type, section]) => ({type, section, hidden: false})), - releaseRules: [ - {breaking: true, release: "major"}, - {type: "feat", release: "minor"}, - {type: "fix", release: "patch"}, - {type: "perf", release: "patch"}, - {type: "refactor", release: "patch"}, - {type: "ci", release: "patch"}, - ], }, context: { @@ -168,39 +224,7 @@ module.exports = () => { contributors, }, - recommendedBump: true, - whatBump: commits => { - let isMajor = false; - let isMinor = false; - let isPatch = false; - - for (const commit of commits) { - const hasBreaking = - Boolean(commit.breaking) || - (commit.notes && - commit.notes.some(n => /BREAKING[ -]CHANGE/i.test(n.title || n.text || ""))); - if (hasBreaking) { - isMajor = true; - break; - } - - const type = (commit.type || "").toLowerCase().replace(/!+$/, ""); - - if (type === "feat") { - isMinor = true; - } - - if (["fix", "perf", "refactor", "ci"].includes(type)) { - isPatch = true; - } - } - - if (isMajor) return {level: 0}; - if (isMinor) return {level: 1}; - if (isPatch) return {level: 2}; - - return null; - }, + whatBump, writerOpts: { headerPartial: "## 🚀 Release {{#if name}}`{{name}}` {{else}}{{#if @root.pkg}}`{{@root.pkg.name}}` {{/if}}{{/if}}v{{version}} ({{date}})\n\n", @@ -259,3 +283,5 @@ module.exports = () => { }, }; }; + +module.exports = Object.assign(createReleaseConfig, {whatBump}); diff --git a/tests/release-it.test.ts b/tests/release-it.test.ts new file mode 100644 index 00000000..8423b248 --- /dev/null +++ b/tests/release-it.test.ts @@ -0,0 +1,59 @@ +type ReleaseCommit = { + type?: string; + header?: string; + breaking?: string | boolean; + footer?: string; + notes?: Array<{title?: string; text?: string}>; +}; + +type Bump = {level: 0 | 1 | 2} | null; + +const {whatBump} = require("../.release-it.cjs") as { + whatBump: (commits: ReleaseCommit[], currentVersion?: string) => Bump; +}; + +describe("release-it version policy", () => { + describe("breaking changes", () => { + test("uses the package's current pre-1.0 version by default", () => { + expect(whatBump([{type: "feat!"}])).toEqual({level: 1}); + }); + + test.each([ + ["parser breaking field", {type: "feat", breaking: "!"}], + ["type suffix", {type: "feat!"}], + ["header suffix", {type: "feat", header: "feat(entrypoint)!: remove legacy API"}], + ["BREAKING CHANGE note", {type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], + ["BREAKING-CHANGE footer", {type: "fix", footer: "BREAKING-CHANGE: new contract"}], + ])("treats %s as a pre-1.0 minor bump", (_label, commit) => { + expect(whatBump([commit], "0.6.0")).toEqual({level: 1}); + }); + + test("becomes a major bump after 1.0", () => { + expect( + whatBump([{type: "fix", notes: [{title: "BREAKING CHANGE", text: "new contract"}]}], "1.4.2") + ).toEqual({level: 0}); + }); + + test("takes precedence over lower-level changes after 1.0", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}, {type: "refactor", breaking: true}], "2.0.0")).toEqual({ + level: 0, + }); + }); + }); + + test.each(["feat", "revert"])("uses a minor bump for %s", type => { + expect(whatBump([{type}], "0.6.0")).toEqual({level: 1}); + }); + + test.each(["fix", "perf", "refactor", "ci"])("uses a patch bump for %s", type => { + expect(whatBump([{type}], "0.6.0")).toEqual({level: 2}); + }); + + test("uses the highest non-breaking bump", () => { + expect(whatBump([{type: "fix"}, {type: "feat"}], "0.6.0")).toEqual({level: 1}); + }); + + test.each(["docs", "test", "chore", "build"])("does not release for %s alone", type => { + expect(whatBump([{type}], "0.6.0")).toBeNull(); + }); +});