Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@
"rules": {
"recommended": true,
"style": {
"useBlockStatements": "off"
"useBlockStatements": {
"level": "error",
"fix": "safe"
}
}
}
},
Expand Down
36 changes: 27 additions & 9 deletions packages/config/src/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,23 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* keys — which would otherwise turn off a user's file output without a word.
*/
function assertNoRemovedReleaseNotesFields(config: unknown): void {
if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) return;
if (!isRecord(config) || !isRecord(config.notes) || !isRecord(config.notes.releaseNotes)) {
return;
}
const rn = config.notes.releaseNotes;
const removed: string[] = [];
if ('mode' in rn) removed.push('mode');
if ('directory' in rn) removed.push('directory');
if (typeof rn.file === 'string') removed.push('file (string)');
if (removed.length === 0) return;
if ('mode' in rn) {
removed.push('mode');
}
if ('directory' in rn) {
removed.push('directory');
}
if (typeof rn.file === 'string') {
removed.push('file (string)');
}
if (removed.length === 0) {
return;
}
throw new ConfigError(
`notes.releaseNotes no longer supports ${removed.join(', ')}. Release notes go to the GitHub ` +
'release body by default; for in-repo per-version files set notes.releaseNotes.file to ' +
Expand All @@ -66,12 +76,16 @@ function assertNoRemovedReleaseNotesFields(config: unknown): void {
* generated JSON schema, where editors/CI reject the same unknown keys).
*/
function assertNoRemovedVersionFields(config: unknown): void {
if (!isRecord(config) || !isRecord(config.version)) return;
if (!isRecord(config) || !isRecord(config.version)) {
return;
}
const v = config.version;
const removed = ['versionStrategy', 'branchPatterns', 'defaultReleaseType', 'updateInternalDependencies'].filter(
(k) => k in v,
);
if (removed.length === 0) return;
if (removed.length === 0) {
return;
}
throw new ConfigError(
`version no longer supports ${removed.join(', ')}. Branch-pattern versioning was never functional — ` +
'use Conventional Commits or --bump for version bumps. For coupled packages use version.groups; to ' +
Expand All @@ -86,7 +100,9 @@ function assertNoRemovedVersionFields(config: unknown): void {
* silently stripping these (which also diverges from the JSON schema's unknown-key rejection).
*/
function assertNoRemovedTopLevelFields(config: unknown): void {
if (!isRecord(config)) return;
if (!isRecord(config)) {
return;
}
if (isRecord(config.ci)) {
const removed = ['autoRelease', 'skipPatterns', 'minChanges'].filter((k) => k in (config.ci as object));
if (removed.length > 0) {
Expand Down Expand Up @@ -154,7 +170,9 @@ export function loadVersionConfig(options?: LoadOptions): VersionConfig | undefi
export function loadPublishConfig(options?: LoadOptions): PublishConfig | undefined {
const config = loadConfig(options);

if (!config.publish) return undefined;
if (!config.publish) {
return undefined;
}

const mergedGit = mergeGitConfig(config.git, config.publish.git);

Expand Down
20 changes: 15 additions & 5 deletions packages/config/src/merge.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import type { GitConfig, PublishGitConfig } from './schema.js';

export function mergeGitConfig(topLevel?: GitConfig, packageLevel?: PublishGitConfig): GitConfig | undefined {
if (!topLevel && !packageLevel) return undefined;
if (!topLevel && !packageLevel) {
return undefined;
}

const base: GitConfig = topLevel ?? {
remote: 'origin',
branch: 'main',
pushMethod: 'auto',
};

if (!packageLevel) return base;
if (!packageLevel) {
return base;
}

return {
remote: packageLevel.remote ?? base.remote,
Expand All @@ -27,9 +31,15 @@ export function deepMerge<T extends Record<string, unknown>>(
target: T | undefined,
source: Partial<T> | undefined,
): T | undefined {
if (!target && !source) return undefined;
if (!target) return source as T;
if (!source) return target;
if (!target && !source) {
return undefined;
}
if (!target) {
return source as T;
}
if (!source) {
return target;
}

const result = { ...target };

Expand Down
8 changes: 6 additions & 2 deletions packages/config/test/unit/generate-schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ describe('generate-schema', () => {
let objectsChecked = 0;
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
if (!node || typeof node !== 'object') {
continue;
}
const obj = node as Record<string, unknown>;
if (obj.type === 'object') {
objectsChecked++;
Expand All @@ -52,7 +54,9 @@ describe('generate-schema', () => {
violations.push('object node has additionalProperties: true (silent leniency)');
}
}
for (const value of Object.values(obj)) stack.push(value);
for (const value of Object.values(obj)) {
stack.push(value);
}
}
expect(violations).toEqual([]);
expect(objectsChecked).toBeGreaterThan(10);
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export function findCargoLockfile(startDir: string): string | undefined {
while (true) {
const lockPath = path.join(dir, 'Cargo.lock');
const hasLock = fs.existsSync(lockPath);
if (hasLock && !nearestLock) nearestLock = lockPath;
if (hasLock && !nearestLock) {
nearestLock = lockPath;
}

const cargoToml = path.join(dir, 'Cargo.toml');
if (fs.existsSync(cargoToml) && isWorkspaceManifest(cargoToml)) {
Expand All @@ -38,7 +40,9 @@ export function findCargoLockfile(startDir: string): string | undefined {
}

const parent = path.dirname(dir);
if (parent === dir) break; // reached the filesystem root
if (parent === dir) {
break; // reached the filesystem root
}
dir = parent;
}
return nearestLock;
Expand Down
32 changes: 24 additions & 8 deletions packages/core/src/changelogRefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,22 @@ export type ChangelogRefsMode = 'strip' | 'escape' | 'link';
*/
export function parseGitHubOwnerRepo(repoUrl: string): { owner: string; repo: string } | null {
const scpMatch = repoUrl.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
if (scpMatch) return { owner: scpMatch[1]!, repo: scpMatch[2]! };
if (scpMatch) {
return { owner: scpMatch[1]!, repo: scpMatch[2]! };
}

try {
const url = new URL(repoUrl);
if (url.hostname !== 'github.com') return null;
if (url.hostname !== 'github.com') {
return null;
}
const parts = url.pathname
.replace(/^\//, '')
.replace(/\.git$/, '')
.split('/');
if (parts.length >= 2 && parts[0] && parts[1]) return { owner: parts[0], repo: parts[1] };
if (parts.length >= 2 && parts[0] && parts[1]) {
return { owner: parts[0], repo: parts[1] };
}
} catch {
// not a parseable URL
}
Expand Down Expand Up @@ -60,14 +66,18 @@ export function renderIssueRefs(
repoUrl: string | null,
prNumber?: string,
): string {
if (mode === 'strip') return '';
if (mode === 'strip') {
return '';
}
const ids = issueIds ?? [];
// prNumber is normally already in issueIds; fold it in defensively so no path can silently drop it.
const allIds =
prNumber && !ids.some((id) => Number(id.replace(/^#/, '')) === Number(prNumber.replace(/^#/, '')))
? [prNumber, ...ids]
: ids;
if (allIds.length === 0) return '';
if (allIds.length === 0) {
return '';
}

const ownerRepo = mode === 'link' && repoUrl ? parseGitHubOwnerRepo(repoUrl) : null;

Expand All @@ -76,7 +86,9 @@ export function renderIssueRefs(
const pr = prNumber.replace(/^#/, '');
const prPart = `PR [#${pr}](https://github.com/${owner}/${repo}/pull/${pr})`;
const closes = allIds.map((raw) => raw.replace(/^#/, '')).filter((num) => Number(num) !== Number(pr));
if (closes.length === 0) return `(${prPart})`;
if (closes.length === 0) {
return `(${prPart})`;
}
const closesPart = closes.map((num) => `[#${num}](https://github.com/${owner}/${repo}/issues/${num})`).join(', ');
return `(${prPart} · closes ${closesPart})`;
}
Expand Down Expand Up @@ -144,10 +156,14 @@ export function neutralizeDescriptionRefs(
CODE_SPAN_OR_DESC_REF,
(match, lead: string | undefined, parenNum?: string, bareNum?: string) => {
const num = parenNum ?? bareNum;
if (num === undefined) return match; // inline-code span — leave untouched
if (num === undefined) {
return match; // inline-code span — leave untouched
}
const ws = lead ?? '';
// A ref already shown in the appended label is redundant here; `strip` drops every ref.
if (appended.has(Number(num)) || mode === 'strip') return '';
if (appended.has(Number(num)) || mode === 'strip') {
return '';
}
const inner = ownerRepo
? `[#${num}](https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/issues/${num})`
: `\\#${num}`;
Expand Down
56 changes: 43 additions & 13 deletions packages/core/src/dependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende
const deps = new Map<string, Set<string>>();
const dependents = new Map<string, Set<string>>();
for (const p of packages) {
if (!deps.has(p.name)) deps.set(p.name, new Set());
if (!dependents.has(p.name)) dependents.set(p.name, new Set());
if (!deps.has(p.name)) {
deps.set(p.name, new Set());
}
if (!dependents.has(p.name)) {
dependents.set(p.name, new Set());
}
}
for (const p of packages) {
for (const d of p.deps) {
// Ignore self-edges and dependencies outside the workspace (the ∩-workspace filter).
if (d === p.name || !known.has(d)) continue;
if (d === p.name || !known.has(d)) {
continue;
}
deps.get(p.name)?.add(d);
dependents.get(d)?.add(p.name);
}
Expand All @@ -65,9 +71,13 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende
const stack = [...(deps.get(name) ?? [])];
while (stack.length > 0) {
const cur = stack.pop();
if (cur === undefined || cur === name || out.has(cur)) continue;
if (cur === undefined || cur === name || out.has(cur)) {
continue;
}
out.add(cur);
for (const d of deps.get(cur) ?? []) stack.push(d);
for (const d of deps.get(cur) ?? []) {
stack.push(d);
}
}
return out;
};
Expand All @@ -78,7 +88,11 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende
const indegree = new Map<string, number>();
for (const n of subset) {
let degree = 0;
for (const dep of deps.get(n) ?? []) if (inSubset.has(dep)) degree++;
for (const dep of deps.get(n) ?? []) {
if (inSubset.has(dep)) {
degree++;
}
}
indegree.set(n, degree);
}
// Seed with nodes that have no in-subset dependencies, preserving input order for stability.
Expand All @@ -87,18 +101,28 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende
const emitted = new Set<string>();
while (ready.length > 0) {
const n = ready.shift();
if (n === undefined || emitted.has(n)) continue;
if (n === undefined || emitted.has(n)) {
continue;
}
result.push(n);
emitted.add(n);
for (const dependent of dependents.get(n) ?? []) {
if (!inSubset.has(dependent) || emitted.has(dependent)) continue;
if (!inSubset.has(dependent) || emitted.has(dependent)) {
continue;
}
const next = (indegree.get(dependent) ?? 1) - 1;
indegree.set(dependent, next);
if (next === 0) ready.push(dependent);
if (next === 0) {
ready.push(dependent);
}
}
}
// Cycle members never reach indegree 0 — append them in input order rather than dropping them.
for (const n of subset) if (!emitted.has(n)) result.push(n);
for (const n of subset) {
if (!emitted.has(n)) {
result.push(n);
}
}
return result;
};

Expand All @@ -107,17 +131,23 @@ export function buildDependencyGraph(packages: GraphPackage[]): WorkspaceDepende
if (cycle === undefined) {
// A full toposort that can't emit every node has a cycle.
const indegree = new Map<string, number>();
for (const p of packages) indegree.set(p.name, deps.get(p.name)?.size ?? 0);
for (const p of packages) {
indegree.set(p.name, deps.get(p.name)?.size ?? 0);
}
const ready = packages.filter((p) => indegree.get(p.name) === 0).map((p) => p.name);
let emittedCount = 0;
while (ready.length > 0) {
const n = ready.shift();
if (n === undefined) break;
if (n === undefined) {
break;
}
emittedCount++;
for (const dependent of dependents.get(n) ?? []) {
const next = (indegree.get(dependent) ?? 1) - 1;
indegree.set(dependent, next);
if (next === 0) ready.push(dependent);
if (next === 0) {
ready.push(dependent);
}
}
}
cycle = emittedCount !== known.size;
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,20 @@ export function getLogLevel(): LogLevel {
function shouldLog(level: LogLevel): boolean {
// JSON mode no longer suppresses stderr logging — JSON output goes to stdout,
// logs go to stderr, so they don't interfere with each other.
if (quietMode && level !== 'error') return false;
if (quietMode && level !== 'error') {
return false;
}
// Debug messages are shown when DEBUG env var is set or log level allows it
if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) return true;
if (level === 'debug' && (process.env.DEBUG === 'true' || process.env.DEBUG === '1')) {
return true;
}
return LOG_LEVELS[level] <= LOG_LEVELS[currentLevel];
}

export function log(message: string, level: LogLevel = 'info'): void {
if (!shouldLog(level)) return;
if (!shouldLog(level)) {
return;
}

const formatted = COLORS[level](`${PREFIXES[level]} ${message}`);

Expand All @@ -91,7 +97,9 @@ export function info(message: string): void {
}

export function success(message: string): void {
if (!shouldLog('info')) return;
if (!shouldLog('info')) {
return;
}
console.error(chalk.green(`[SUCCESS] ${message}`));
}

Expand Down
Loading
Loading