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: 5 additions & 0 deletions .changeset/quiet-regions-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@baseplate-dev/core-generators': patch
---

Add test:affected command to monorepo
5 changes: 5 additions & 0 deletions .changeset/stable-string-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@baseplate-dev/utils': patch
---

Add stable compareStrings utility to replace localeCompare for deterministic sorting. The new compareStrings function provides consistent, locale-independent string comparison across all operating systems and environments, ensuring stable code generation and preventing merge conflicts caused by locale-dependent sorting.
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,20 @@ Baseplate consists of two main tiers:
- Organize complex generation with Task Phases
- Use Dynamic Tasks for data-driven generation

## String Comparison

**IMPORTANT**: Always use `compareStrings` from `@baseplate-dev/utils` instead of `String.prototype.localeCompare()`.

### When to Use localeCompare

Only use `localeCompare()` when:

1. Building user-facing features that require locale-aware sorting
2. Displaying sorted lists in the UI
3. Explicitly requested by product requirements

For all code generation, file sorting, and internal data structures, use `compareStrings`.

## Key Reminders for Claude Code

- Run `pnpm lint:affected` and `pnpm typecheck` before committing changes
Expand Down
1 change: 1 addition & 0 deletions examples/blog-with-auth/baseplate/generated/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"prettier:write": "turbo run prettier:write && pnpm run prettier:write:root",
"prettier:write:root": "prettier --write . \"!apps/**\"",
"test": "turbo run test",
"test:affected": "turbo run test --affected",
"typecheck": "turbo run typecheck",
"watch": "turbo run watch:gql"
},
Expand Down
1 change: 1 addition & 0 deletions examples/blog-with-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"prettier:write": "turbo run prettier:write && pnpm run prettier:write:root",
"prettier:write:root": "prettier --write . \"!apps/**\"",
"test": "turbo run test",
"test:affected": "turbo run test --affected",
"typecheck": "turbo run typecheck",
"watch": "turbo run watch:gql"
},
Expand Down
1 change: 1 addition & 0 deletions examples/todo-with-auth0/baseplate/generated/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"prettier:write": "turbo run prettier:write && pnpm run prettier:write:root",
"prettier:write:root": "prettier --write . \"!apps/**\"",
"test": "turbo run test",
"test:affected": "turbo run test --affected",
"typecheck": "turbo run typecheck",
"watch": "turbo run watch:gql"
},
Expand Down
1 change: 1 addition & 0 deletions examples/todo-with-auth0/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"prettier:write": "turbo run prettier:write && pnpm run prettier:write:root",
"prettier:write:root": "prettier --write . \"!apps/**\"",
"test": "turbo run test",
"test:affected": "turbo run test --affected",
"typecheck": "turbo run typecheck",
"watch": "turbo run watch:gql"
},
Expand Down
1 change: 1 addition & 0 deletions packages/code-morph/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@baseplate-dev/utils": "workspace:*",
"@inquirer/prompts": "7.8.3",
"change-case": "5.4.4",
"commander": "^12.1.0",
Expand Down
3 changes: 2 additions & 1 deletion packages/code-morph/src/morphers/utils/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
SourceFile,
} from 'ts-morph';

import { compareStrings } from '@baseplate-dev/utils';
import { Node } from 'ts-morph';

/**
Expand Down Expand Up @@ -66,7 +67,7 @@ export function addOrUpdateImport(
if (newImports.length > 0) {
// Collect all import names (existing + new)
const allImportNames = [...existingNames, ...newImports].sort((a, b) =>
a.localeCompare(b),
compareStrings(a, b),
);

// Replace with sorted named imports
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createGenerator,
createGeneratorTask,
} from '@baseplate-dev/sync';
import { compareStrings } from '@baseplate-dev/utils';
import { z } from 'zod';

import { packageScope } from '#src/providers/scopes.js';
Expand Down Expand Up @@ -67,7 +68,7 @@ export const nodeGitIgnoreGenerator = createGenerator({
];
if (exclusions.size > 0) {
const sortedExclusions = [...exclusions.entries()].sort((a, b) =>
a[0].localeCompare(b[0]),
compareStrings(a[0], b[0]),
);
exclusionLines.push(
...sortedExclusions.flatMap(([, value]) => ['', ...value]),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { TemplateExtractorContext } from '@baseplate-dev/sync';

import { TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY } from '@baseplate-dev/sync';
import { mapValuesOfMap } from '@baseplate-dev/utils';
import { compareStrings, mapValuesOfMap } from '@baseplate-dev/utils';
import { posixJoin } from '@baseplate-dev/utils/node';
import { camelCase } from 'change-case';
import { z } from 'zod';
Expand Down Expand Up @@ -127,7 +127,7 @@ function createPathsTask(
methodName: root.method,
})),
}))
.toSorted((a, b) => a.providerName.localeCompare(b.providerName));
.toSorted((a, b) => compareStrings(a.providerName, b.providerName));

const dependencies = TsCodeUtils.mergeFragmentsAsObject(
Object.fromEntries(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createTemplateExtractorPlugin,
TEMPLATE_EXTRACTOR_GENERATED_DIRECTORY,
} from '@baseplate-dev/sync';
import { compareStrings } from '@baseplate-dev/utils';
import { posixJoin } from '@baseplate-dev/utils/node';

import { normalizeTsPathToJsPath } from '#src/utils/ts-paths.js';
Expand Down Expand Up @@ -76,7 +77,7 @@ export const typedTemplatesFilePlugin = createTemplateExtractorPlugin({
templateContents: TS_TEMPLATE,
variables: {
TPL_TEMPLATE_FRAGMENTS: templatesFragment,
TPL_TEMPLATE_EXPORTS: `{ ${templateExports.toSorted((a, b) => a.localeCompare(b)).join(', ')} }`,
TPL_TEMPLATE_EXPORTS: `{ ${templateExports.toSorted((a, b) => compareStrings(a, b)).join(', ')} }`,
TPL_EXPORT_NAME: exportName,
},
options: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TemplateExtractorTemplateEntry } from '@baseplate-dev/sync';

import { quot, sortObjectKeys } from '@baseplate-dev/utils';
import { compareStrings, quot, sortObjectKeys } from '@baseplate-dev/utils';
import { camelCase } from 'change-case';
import { groupBy } from 'es-toolkit';

Expand Down Expand Up @@ -56,7 +56,7 @@ function renderTextTypedTemplateGroup(
): TemplateExtractorTypedTemplate {
const renderedTemplates = templates
.map(({ name, config }) => renderTextTypedTemplate(name, config, context))
.toSorted((a, b) => a.exportName.localeCompare(b.exportName));
.toSorted((a, b) => compareStrings(a.exportName, b.exportName));
const exportName = `${camelCase(groupName)}Group`;

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* and returns a mapping for simple replacements.
*/

import { compareStrings } from '@baseplate-dev/utils';

const SIMPLE_REPLACEMENT_REGEX = /\/\* TPL_([A-Z0-9_]+)=([^*]*?) \*\/\n*/g;
const ALLOWED_VALUE_PATTERN = /^[a-zA-Z0-9_$/.\-?]+$/;

Expand Down Expand Up @@ -85,7 +87,7 @@ export function generateSimpleReplacementComments(
replacements: Record<string, string>,
): string[] {
return Object.entries(replacements)
.sort(([, a], [, b]) => a.localeCompare(b)) // Sort by variable name
.sort(([, a], [, b]) => compareStrings(a, b)) // Sort by variable name
.map(([value, variable]) => {
// Extract just the part after TPL_
const varName = variable.replace(/^TPL_/, '');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TemplateExtractorTemplateEntry } from '@baseplate-dev/sync';

import { quot, sortObjectKeys } from '@baseplate-dev/utils';
import { compareStrings, quot, sortObjectKeys } from '@baseplate-dev/utils';
import { camelCase } from 'change-case';
import { groupBy } from 'es-toolkit';

Expand Down Expand Up @@ -89,7 +89,7 @@ function renderTsTypedTemplateGroup(
): TemplateExtractorTypedTemplate {
const renderedTemplates = templates
.map(({ name, config }) => renderTsTypedTemplate(name, config, context))
.toSorted((a, b) => a.exportName.localeCompare(b.exportName));
.toSorted((a, b) => compareStrings(a.exportName, b.exportName));
const exportName = `${camelCase(groupName)}Group`;

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { toposortLocal } from '@baseplate-dev/utils';
import { compareStrings, toposortLocal } from '@baseplate-dev/utils';
import { isEqual, keyBy, uniqWith } from 'es-toolkit';

import type { TsImportDeclaration } from '../imports/types.js';
Expand Down Expand Up @@ -191,7 +191,7 @@ export function mergeFragmentsWithHoistedFragments(
// to the root fragment that uses them
if (isARoot && !isBRoot) return -1;
if (!isARoot && isBRoot) return 1;
return a.localeCompare(b);
return compareStrings(a, b);
},
);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mapGroupBy } from '@baseplate-dev/utils';
import { compareStrings, mapGroupBy } from '@baseplate-dev/utils';
import { uniqBy } from 'es-toolkit';

import type { TsImportDeclaration } from './types.js';
Expand Down Expand Up @@ -81,7 +81,7 @@ function convertToImportDeclarations(
name: e.name as string,
alias: e.alias === e.name ? undefined : e.alias,
}))
.toSorted((a, b) => (a.alias ?? a.name).localeCompare(b.alias ?? b.name));
.toSorted((a, b) => compareStrings(a.alias ?? a.name, b.alias ?? b.name));

const importDeclarations: TsImportDeclaration[] = [];
const addDeclaration = (declaration: Partial<TsImportDeclaration>): void => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CodeBlockWriter, SourceFile } from 'ts-morph';

import { compareStrings } from '@baseplate-dev/utils';
import { Project } from 'ts-morph';

import type {
Expand Down Expand Up @@ -83,7 +84,7 @@ function mergeImportsAndHoistedFragments(
// This can be improved in the future but since the use-case is very limited,
// we'll just throw an error if this happens.
const sortedPositionedHoistedFragments = positionedHoistedFragments.sort(
(a, b) => a.key.localeCompare(b.key),
(a, b) => compareStrings(a.key, b.key),
);
if (
new Set(sortedPositionedHoistedFragments.map((f) => f.key)).size !==
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { quot } from '@baseplate-dev/utils';
import { compareStrings, quot } from '@baseplate-dev/utils';
import { sortBy } from 'es-toolkit';

import type { TsCodeFragmentOptions } from '../fragments/creators.js';
Expand Down Expand Up @@ -270,7 +270,9 @@ export const TsCodeUtils = {

const sortedKeys = disableSort
? keys
: keys.toSorted(caseSensitive ? undefined : (a, b) => a.localeCompare(b));
: keys.toSorted(
caseSensitive ? undefined : (a, b) => compareStrings(a, b),
);

if (!disableSort && keys.some((k) => k.startsWith('...'))) {
throw new Error('Cannot have spread keys when sorting is enabled');
Expand Down Expand Up @@ -350,7 +352,9 @@ export const TsCodeUtils = {

const sortedKeys = disableSort
? keys
: keys.toSorted(caseSensitive ? undefined : (a, b) => a.localeCompare(b));
: keys.toSorted(
caseSensitive ? undefined : (a, b) => compareStrings(a, b),
);

const mergedContent = sortedKeys
.filter((key) => map.get(key))
Expand Down
12 changes: 8 additions & 4 deletions packages/core-generators/src/test-helpers/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { compareStrings } from '@baseplate-dev/utils';
import { isDeepStrictEqual } from 'node:util';

import type {
Expand Down Expand Up @@ -31,15 +32,18 @@ export function normalizeImports(
...imp,
// Sort named imports alphabetically
namedImports: imp.namedImports?.slice().sort((a, b) => {
const nameCompare = a.name.localeCompare(b.name);
const nameCompare = compareStrings(a.name, b.name);
if (nameCompare !== 0) return nameCompare;
// If names are equal, sort by alias
return (a.alias ?? '').localeCompare(b.alias ?? '');
return compareStrings(a.alias ?? '', b.alias ?? '');
}),
}))
.sort((a, b) => {
// Primary sort: module specifier
const moduleCompare = a.moduleSpecifier.localeCompare(b.moduleSpecifier);
const moduleCompare = compareStrings(
a.moduleSpecifier,
b.moduleSpecifier,
);
if (moduleCompare !== 0) return moduleCompare;

// Secondary sort: import type (namespace > default > named)
Expand Down Expand Up @@ -82,7 +86,7 @@ export function normalizeHoistedFragments(
key: frag.key,
} as TsHoistedFragment;
})
.sort((a, b) => a.key.localeCompare(b.key));
.sort((a, b) => compareStrings(a.key, b.key));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
createProviderType,
createReadOnlyProviderType,
} from '@baseplate-dev/sync';
import { NamedArrayFieldContainer } from '@baseplate-dev/utils';
import { compareStrings, NamedArrayFieldContainer } from '@baseplate-dev/utils';
import { posixJoin } from '@baseplate-dev/utils/node';
import { kebabCase } from 'change-case';
import path from 'node:path';
Expand Down Expand Up @@ -140,7 +140,7 @@ export const serviceFileGenerator = createGenerator({
build: async (builder) => {
const orderedHeaders = headersContainer
.getValue()
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => compareStrings(a.name, b.name));
const orderedMethods = methodsContainer
.getValue()
.sort((a, b) => a.order - b.order);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createProviderType,
} from '@baseplate-dev/sync';
import {
compareStrings,
lowercaseFirstChar,
NamedArrayFieldContainer,
} from '@baseplate-dev/utils';
Expand Down Expand Up @@ -161,7 +162,7 @@ export const prismaDataServiceGenerator = createGenerator({
}),
),
...virtualInputFields.toSorted((a, b) =>
a.name.localeCompare(b.name),
compareStrings(a.name, b.name),
),
];

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { compareStrings } from '@baseplate-dev/utils';

import type { ScalarFieldType } from '#src/types/field-types.js';
import type { PrismaOutputModel } from '#src/types/prisma-output.js';

Expand Down Expand Up @@ -186,7 +188,7 @@ export class PrismaModelBlockWriter {
.sort((a, b) => a.order - b.order);
const relationFields = this.fields
.filter((field) => field.fieldType === 'relation')
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => compareStrings(a.name, b.name));

// Look for duplicated orders in scalar fields
const orderSet = new Set(scalarFields.map((field) => field.order));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export class RootPackageCompiler extends PackageCompiler {
lint: `turbo run lint`,
'lint:affected': `turbo run lint --affected`,
test: `turbo run test`,
'test:affected': `turbo run test --affected`,
'prettier:check': `turbo run prettier:check && pnpm run prettier:check:root`,
'prettier:check:affected': `turbo run prettier:check --affected`,
'prettier:write': `turbo run prettier:write && pnpm run prettier:write:root`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { stringifyPrettyStable } from '@baseplate-dev/utils';
import { compareStrings, stringifyPrettyStable } from '@baseplate-dev/utils';
import {
handleFileNotFoundError,
readJsonWithSchema,
Expand Down Expand Up @@ -37,7 +37,7 @@ export async function saveSnapshotManifest(
added: manifest.files.added.toSorted(),
deleted: manifest.files.deleted.toSorted(),
modified: manifest.files.modified.toSorted((a, b) =>
a.path.localeCompare(b.path),
compareStrings(a.path, b.path),
),
},
};
Expand Down
4 changes: 2 additions & 2 deletions packages/project-builder-server/src/sync/file-id-map.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { enhanceErrorWithContext } from '@baseplate-dev/utils';
import { compareStrings, enhanceErrorWithContext } from '@baseplate-dev/utils';
import {
handleFileNotFoundError,
readJsonWithSchema,
Expand Down Expand Up @@ -51,7 +51,7 @@ export async function writeGeneratedFileIdMap(
const fileIdMapPath = path.join(projectDirectory, FILE_ID_MAP_PATH);
const fileIdMap = Object.fromEntries(
[...fileIdToRelativePathMap.entries()].sort(([a], [b]) =>
a.localeCompare(b),
compareStrings(a, b),
),
);
await writeJson(fileIdMapPath, fileIdMap);
Expand Down
Loading