diff --git a/.changeset/tauri-alignment-groups.md b/.changeset/tauri-alignment-groups.md new file mode 100644 index 00000000..0c59db2d --- /dev/null +++ b/.changeset/tauri-alignment-groups.md @@ -0,0 +1,5 @@ +--- +"@stll/oxlint-config": patch +--- + +Keep Tauri dependency families together across all update types in generated and validated Dependabot groups. diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index fc479209..6f35871b 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -410,6 +410,12 @@ Use `stll-dependabot --check` in CI to compare bytes without writing. Missing or stale output exits with status 1; current output exits with status 0. Pass the same `--config` argument to write and check commands when using explicit input. +Repositories declaring Tauri dependencies receive a `tauri` group before the +shared semver groups: `@tauri-apps/*` for Bun/npm, and `tauri` plus `tauri-*` +for Cargo. The cohort includes major, minor, and patch updates; fallback +groups exclude those patterns. Generator and guard derive this from dependency +manifest ownership, including Cargo workspace and target dependencies. + An optional `--config stll-dependabot.json` input declares only coverage: ```json diff --git a/packages/oxlint-config/src/toolchain-dependabot.test.ts b/packages/oxlint-config/src/toolchain-dependabot.test.ts index d858e8cc..e715e0a8 100644 --- a/packages/oxlint-config/src/toolchain-dependabot.test.ts +++ b/packages/oxlint-config/src/toolchain-dependabot.test.ts @@ -1472,3 +1472,114 @@ describe("Dependabot policy", () => { expect(check(text).length).toBeGreaterThan(0); }); }); + +describe("Tauri dependency alignment", () => { + test("separate npm roots do not add a cohort to an unrelated Bun workspace", () => { + const files = { + "package.json": JSON.stringify({ + packageManager: "bun@1.4.4", + dependencies: { react: "19" }, + }), + "standalone/package.json": JSON.stringify({ + packageManager: "npm@10.9.9", + dependencies: { "@tauri-apps/api": "2" }, + }), + "standalone/package-lock.json": "{}", + }; + const generated = generateDependabotConfig({ files, policy }); + const document = parseDocument(generated); + expect(document.getIn(["updates", 0, "package-ecosystem"])).toBe("bun"); + expect(document.hasIn(["updates", 0, "groups", "tauri"])).toBeFalse(); + expect(document.getIn(["updates", 1, "package-ecosystem"])).toBe("npm"); + expect(document.hasIn(["updates", 1, "groups", "tauri"])).toBeTrue(); + expect(check(generated, files)).toEqual([]); + }); + + test("detected JavaScript and Cargo owners share generated and guarded cohorts", () => { + for (const [file, text] of [ + [ + "package.json", + JSON.stringify({ + packageManager: "bun@1.4.4", + dependencies: { "@tauri-apps/api": "2.0.0" }, + }), + ], + [ + "package.json", + JSON.stringify({ devDependencies: { "@tauri-apps/cli": "2.0.0" } }), + ], + [ + "Cargo.toml", + '[package]\nname="desktop"\nversion="0.1.0"\n[dependencies]\ntauri="2"', + ], + ["Cargo.toml", '[workspace.dependencies]\ntauri-build="2"'], + [ + "Cargo.toml", + '[target.windows.dependencies]\nbridge={package="tauri",version="2"}', + ], + ] as const) { + const files = { [file]: text }; + const generated = generateDependabotConfig({ files, policy }); + const document = parseDocument(generated); + const types = document.getIn([ + "updates", + 0, + "groups", + "tauri", + "update-types", + ]); + const exclusions = document.getIn([ + "updates", + 0, + "groups", + "dependencies", + "exclude-patterns", + ]); + const patterns = document.getIn([ + "updates", + 0, + "groups", + "tauri", + "patterns", + ]); + if (!isSeq(types) || !isSeq(exclusions) || !isSeq(patterns)) + throw new Error("expected alignment group sequences"); + expect(types.toJSON()).toEqual(["major", "minor", "patch"]); + expect(exclusions.toJSON()).toEqual(patterns.toJSON()); + expect(check(generated, files)).toEqual([]); + for (const key of [ + ["updates", 0, "groups", "dependencies", "exclude-patterns"], + ["updates", 0, "groups", "tauri"], + ["updates", 0, "groups", "tauri", "update-types", 0], + ]) { + const invalid = parseDocument(generated); + invalid.deleteIn(key); + expect( + check(invalid.toString(), files).some(({ message }) => + message.includes("groups"), + ), + ).toBeTrue(); + } + } + }); + + test("unrelated manifests and Cargo metadata do not create an alignment cohort", () => { + for (const [file, text] of [ + [ + "package.json", + JSON.stringify({ description: "tauri", dependencies: { react: "19" } }), + ], + [ + "Cargo.toml", + '[package]\nname="tauri-example"\nversion="0.1.0"\n[package.metadata.dependencies]\ntauri="2"', + ], + ] as const) { + const files = { [file]: text }; + const generated = generateDependabotConfig({ files, policy }); + expect( + parseDocument(generated).hasIn(["updates", 0, "groups", "tauri"]), + ).toBeFalse(); + expect(check(generated, files)).toEqual([]); + } + }); +}); diff --git a/packages/oxlint-config/src/toolchain-dependabot.ts b/packages/oxlint-config/src/toolchain-dependabot.ts index 854d9bef..8bcda178 100644 --- a/packages/oxlint-config/src/toolchain-dependabot.ts +++ b/packages/oxlint-config/src/toolchain-dependabot.ts @@ -85,18 +85,106 @@ type DependabotPolicy = { type DependabotGroup = { patterns: readonly string[]; updateTypes?: readonly string[] | undefined; + excludePatterns?: readonly string[] | undefined; }; -/** Ordered groups for one ecosystem: GitHub Actions first gathers first-party refs. */ -const groupsFor = ( +const tauriPatterns = (ecosystem: string): readonly string[] => { + if (ecosystem === "cargo") return ["tauri", "tauri-*"]; + if (ecosystem === "bun" || ecosystem === "npm") return ["@tauri-apps/*"]; + return []; +}; + +const ownsTauriDependencies = ( ecosystem: string, - policy: DependabotPolicy, -): [string, DependabotGroup][] => [ - ...(ecosystem === "github-actions" - ? Object.entries(policy.actionGroups) - : []), - ...Object.entries(policy.groups), -]; + files: Readonly>, +) => { + const patterns = tauriPatterns(ecosystem); + if (patterns.length === 0) return false; + const matches = picomatch([...patterns]); + const dependencyTables = + ecosystem === "cargo" + ? ["dependencies", "dev-dependencies", "build-dependencies"] + : [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ]; + const owns = (owner: unknown) => { + if (!record(owner)) return false; + for (const key of dependencyTables) { + const dependencies = owner[key]; + if (!record(dependencies)) continue; + if ( + Object.entries(dependencies).some(([name, value]) => + matches( + record(value) && typeof value["package"] === "string" + ? value["package"] + : name, + ), + ) + ) + return true; + } + return false; + }; + return Object.entries(files).some(([file, text]) => { + const manifest = ecosystem === "cargo" ? "Cargo.toml" : "package.json"; + if (path.posix.basename(file) !== manifest || !text.includes("tauri")) + return false; + if ( + ecosystem !== "cargo" && + javascriptEcosystem(directoryOf(file), files) !== ecosystem + ) + return false; + const manifestValue: unknown = + ecosystem === "cargo" ? parseToml(text) : JSON.parse(text); + if (owns(manifestValue)) return true; + if (ecosystem !== "cargo" || !record(manifestValue)) return false; + // Only Cargo's dependency owners count; package metadata is arbitrary data. + if (owns(manifestValue["workspace"])) return true; + const targets = manifestValue["target"]; + return record(targets) && Object.values(targets).some(owns); + }); +}; + +type DependabotGroupsOptions = { + ecosystem: string; + policy: DependabotPolicy; + files: Readonly>; +}; + +/** Alignment cohorts precede semver groups so coupled major updates stay together. */ +const groupsFor = ({ + ecosystem, + policy, + files, +}: DependabotGroupsOptions): [string, DependabotGroup][] => { + const alignment = ownsTauriDependencies(ecosystem, files) + ? tauriPatterns(ecosystem) + : []; + return [ + ...(ecosystem === "github-actions" + ? Object.entries(policy.actionGroups) + : []), + ...(alignment.length === 0 + ? [] + : ([ + [ + "tauri", + { patterns: alignment, updateTypes: ["major", "minor", "patch"] }, + ], + ] satisfies [string, DependabotGroup][])), + ...Object.entries(policy.groups).map( + ([name, group]): [string, DependabotGroup] => [ + name, + alignment.length === 0 + ? group + : { ...group, excludePatterns: alignment }, + ], + ), + ]; +}; type Diagnostic = { rule: (typeof dependabotRules)[number]; @@ -820,11 +908,13 @@ export const generateDependabotConfig = ({ }, cooldown: { "default-days": policy.cooldown.defaultDays }, groups: Object.fromEntries( - groupsFor(ecosystem, policy).map(([name, group]) => [ + groupsFor({ ecosystem, policy, files }).map(([name, group]) => [ name, - group.updateTypes === undefined - ? { patterns: group.patterns } - : { patterns: group.patterns, "update-types": group.updateTypes }, + { + patterns: group.patterns, + "update-types": group.updateTypes, + "exclude-patterns": group.excludePatterns, + }, ]), ), ignore: ignore.length > 0 ? ignore : undefined, @@ -1019,7 +1109,7 @@ export const checkDependabot = ({ `${ecosystem}: cooldown must contain only default-days: ${policy.cooldown.defaultDays}`, ); const groups = update["groups"]; - const expectedGroups = groupsFor(ecosystem, policy); + const expectedGroups = groupsFor({ ecosystem, policy, files }); if ( !record(groups) || // Dependabot assigns an update to the first matching group. @@ -1030,8 +1120,15 @@ export const checkDependabot = ({ return ( !record(group) || Object.keys(group).length !== - (expected.updateTypes === undefined ? 1 : 2) || + 1 + + Number(expected.updateTypes !== undefined) + + Number(expected.excludePatterns !== undefined) || !sameStrings(group["patterns"], expected.patterns) || + (expected.excludePatterns !== undefined && + !sameStrings( + group["exclude-patterns"], + expected.excludePatterns, + )) || (expected.updateTypes !== undefined && !sameStrings(group["update-types"], expected.updateTypes)) );