From e02bc87fdbea4f2f2247b70bde66b326e2f7b6be Mon Sep 17 00:00:00 2001 From: Jamie Mason Date: Sat, 11 Feb 2023 18:54:09 +0000 Subject: [PATCH] refactor(versions): replace how versions are read/written --- .../lint-semver-ranges-cli.spec.ts | 4 +- .../lint-semver-ranges.ts | 4 +- src/bin-list-mismatches/list-mismatches.ts | 2 +- src/constants.ts | 14 --- src/get-context/$R.ts | 6 ++ .../get-config/get-core-paths.spec.ts | 94 +++++++++++++++++++ src/get-context/get-config/get-core-paths.ts | 54 +++++++++++ .../get-config/get-dependency-types.ts | 42 --------- src/get-context/get-config/index.ts | 9 +- .../get-config/path-strategy/index.ts | 18 ++++ .../lib/get-non-empty-string-prop.ts | 26 +++++ .../name-and-version-props.spec.ts | 80 ++++++++++++++++ .../path-strategy/name-and-version-props.ts | 69 ++++++++++++++ .../name-and-version-string.spec.ts | 56 +++++++++++ .../path-strategy/name-and-version-string.ts | 59 ++++++++++++ .../get-config/path-strategy/types.ts | 24 +++++ .../path-strategy/version-string.spec.ts | 56 +++++++++++ .../path-strategy/version-string.ts | 58 ++++++++++++ .../path-strategy/versions-by-name.spec.ts | 68 ++++++++++++++ .../path-strategy/versions-by-name.ts | 39 ++++++++ .../get-config/schema/base-group.ts | 9 +- .../get-config/schema/dependency-type.ts | 16 ---- src/get-context/get-config/schema/index.ts | 25 +++-- src/get-context/get-config/schema/paths.ts | 39 ++++++++ .../get-config/schema/semver-group.ts | 12 +-- .../get-config/schema/semver-range.ts | 2 +- .../get-config/schema/version-group.ts | 12 +-- src/get-context/get-context.spec.ts | 64 +------------ .../get-groups/semver-group/index.ts | 4 +- .../get-groups/version-group/index.ts | 6 +- .../version-group/instance-group/index.ts | 4 +- .../package-json-file/index.ts | 75 ++++++++------- .../package-json-file/instance.ts | 57 ++++++++--- src/types.ts | 68 +++++++------- test/mock.ts | 9 +- test/scenarios/create-scenario.ts | 4 +- 36 files changed, 926 insertions(+), 262 deletions(-) create mode 100644 src/get-context/get-config/get-core-paths.spec.ts create mode 100644 src/get-context/get-config/get-core-paths.ts delete mode 100644 src/get-context/get-config/get-dependency-types.ts create mode 100644 src/get-context/get-config/path-strategy/index.ts create mode 100644 src/get-context/get-config/path-strategy/lib/get-non-empty-string-prop.ts create mode 100644 src/get-context/get-config/path-strategy/name-and-version-props.spec.ts create mode 100644 src/get-context/get-config/path-strategy/name-and-version-props.ts create mode 100644 src/get-context/get-config/path-strategy/name-and-version-string.spec.ts create mode 100644 src/get-context/get-config/path-strategy/name-and-version-string.ts create mode 100644 src/get-context/get-config/path-strategy/types.ts create mode 100644 src/get-context/get-config/path-strategy/version-string.spec.ts create mode 100644 src/get-context/get-config/path-strategy/version-string.ts create mode 100644 src/get-context/get-config/path-strategy/versions-by-name.spec.ts create mode 100644 src/get-context/get-config/path-strategy/versions-by-name.ts delete mode 100644 src/get-context/get-config/schema/dependency-type.ts create mode 100644 src/get-context/get-config/schema/paths.ts diff --git a/src/bin-lint-semver-ranges/lint-semver-ranges-cli.spec.ts b/src/bin-lint-semver-ranges/lint-semver-ranges-cli.spec.ts index 4ac523c3..5b761fef 100644 --- a/src/bin-lint-semver-ranges/lint-semver-ranges-cli.spec.ts +++ b/src/bin-lint-semver-ranges/lint-semver-ranges-cli.spec.ts @@ -18,7 +18,7 @@ describe('lintSemverRanges', () => { ['✘ c'], [` 0.1.0 → ~0.1.0 in overrides of ${a}`], ['✘ d'], - [` 0.1.0 → ~0.1.0 in pnpmOverrides of ${a}`], + [` 0.1.0 → ~0.1.0 in pnpm.overrides of ${a}`], ['✘ e'], [` 0.1.0 → ~0.1.0 in peerDependencies of ${a}`], ['✘ f'], @@ -36,7 +36,7 @@ describe('lintSemverRanges', () => { ['✘ c'], [` 0.1.0 → * in overrides of ${a}`], ['✘ d'], - [` 0.1.0 → * in pnpmOverrides of ${a}`], + [` 0.1.0 → * in pnpm.overrides of ${a}`], ['✘ e'], [` 0.1.0 → * in peerDependencies of ${a}`], ['✘ f'], diff --git a/src/bin-lint-semver-ranges/lint-semver-ranges.ts b/src/bin-lint-semver-ranges/lint-semver-ranges.ts index ba6f0e29..4b21e9d3 100644 --- a/src/bin-lint-semver-ranges/lint-semver-ranges.ts +++ b/src/bin-lint-semver-ranges/lint-semver-ranges.ts @@ -36,11 +36,11 @@ export function lintSemverRanges(ctx: Syncpack.Ctx): Syncpack.Ctx { } function logSemverRangeMismatch(instance: Instance, semverGroup: SemverGroup) { - const type = instance.dependencyType; + const path = instance.pathDef.path; const shortPath = instance.packageJsonFile.shortPath; const actual = instance.version; const expected = setSemverRange(semverGroup.range, actual); console.log( - chalk` {red ${actual}} ${ICON.rightArrow} {green ${expected}} {dim in ${type} of ${shortPath}}`, + chalk` {red ${actual}} ${ICON.rightArrow} {green ${expected}} {dim in ${path} of ${shortPath}}`, ); } diff --git a/src/bin-list-mismatches/list-mismatches.ts b/src/bin-list-mismatches/list-mismatches.ts index d1578a2b..b54311f1 100644 --- a/src/bin-list-mismatches/list-mismatches.ts +++ b/src/bin-list-mismatches/list-mismatches.ts @@ -83,7 +83,7 @@ export function listMismatches(ctx: Syncpack.Ctx): Syncpack.Ctx { } function logVersionMismatch(instance: Instance): void { - const type = instance.dependencyType; + const type = instance.pathDef.path; const shortPath = instance.packageJsonFile.shortPath; const actual = instance.version; console.log(chalk` {red ${actual}} {dim in ${type} of ${shortPath}}`); diff --git a/src/constants.ts b/src/constants.ts index 8e19f068..3ff061a9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -13,20 +13,6 @@ export const ICON = { tick: '✓', } as const; -/** - * Aliases for locations of versions within package.json files, it is looped - * over by each command to operate on each are as defined by the user. - */ -export const ALL_DEPENDENCY_TYPES = [ - 'dependencies', - 'devDependencies', - 'overrides', - 'peerDependencies', - 'pnpmOverrides', - 'resolutions', - 'workspace', -] as const; - export const RANGE = { ANY: '*', EXACT: '', diff --git a/src/get-context/$R.ts b/src/get-context/$R.ts index 289b0942..41ce6d8e 100644 --- a/src/get-context/$R.ts +++ b/src/get-context/$R.ts @@ -1,5 +1,6 @@ import { R } from '@mobily/ts-belt'; import { BaseError } from '../lib/error'; +import { verbose } from '../lib/log'; /** Additional helpers for https://mobily.github.io/ts-belt/api/result */ export const $R = { @@ -23,4 +24,9 @@ export const $R = { : R.Error(new BaseError('No R.Ok() returned by $R.onlyOk')); }; }, + /** Log verbose only when R.Result is an R.Err */ + tapErrVerbose>(result: T) { + if (R.isError(result)) verbose(result._0.message); + return result; + }, }; diff --git a/src/get-context/get-config/get-core-paths.spec.ts b/src/get-context/get-config/get-core-paths.spec.ts new file mode 100644 index 00000000..bc0a02d9 --- /dev/null +++ b/src/get-context/get-config/get-core-paths.spec.ts @@ -0,0 +1,94 @@ +import 'expect-more-jest'; +import { getCorePaths } from './get-core-paths'; + +describe('getCorePaths', () => { + const fn = getCorePaths; + const dev = { + name: 'dev', + path: 'devDependencies', + strategy: 'versionsByName', + }; + + const overrides = { + name: 'overrides', + path: 'overrides', + strategy: 'versionsByName', + }; + + const peer = { + name: 'peer', + path: 'peerDependencies', + strategy: 'versionsByName', + }; + + const pnpmOverrides = { + name: 'pnpmOverrides', + path: 'pnpm.overrides', + strategy: 'versionsByName', + }; + + const prod = { + name: 'prod', + path: 'dependencies', + strategy: 'versionsByName', + }; + + const resolutions = { + name: 'resolutions', + path: 'resolutions', + strategy: 'versionsByName', + }; + + const workspace = { + name: 'workspace', + namePath: 'name', + path: 'version', + strategy: 'name~version', + }; + + const allTypes = [ + dev, + overrides, + peer, + pnpmOverrides, + prod, + resolutions, + workspace, + ]; + + it('includes all if none are set', () => { + expect( + fn({ + dev: true, + overrides: true, + peer: true, + pnpmOverrides: true, + prod: true, + resolutions: true, + workspace: true, + }), + ).toEqual(allTypes); + }); + + it('includes all if all are set', () => { + expect(fn({})).toEqual(allTypes); + }); + + it('enables one if it is the only one set', () => { + expect(fn({ dev: true })).toEqual([dev]); + expect(fn({ overrides: true })).toEqual([overrides]); + expect(fn({ peer: true })).toEqual([peer]); + expect(fn({ pnpmOverrides: true })).toEqual([pnpmOverrides]); + expect(fn({ prod: true })).toEqual([prod]); + expect(fn({ resolutions: true })).toEqual([resolutions]); + expect(fn({ workspace: true })).toEqual([workspace]); + }); + + it('enables some if only those are set', () => { + expect(fn({ dev: true, prod: true, workspace: true })).toEqual([ + dev, + prod, + workspace, + ]); + }); +}); diff --git a/src/get-context/get-config/get-core-paths.ts b/src/get-context/get-config/get-core-paths.ts new file mode 100644 index 00000000..479effad --- /dev/null +++ b/src/get-context/get-config/get-core-paths.ts @@ -0,0 +1,54 @@ +import { isBoolean, isObject } from 'expect-more'; +import type { Syncpack } from '../../types'; + +type CorePaths = typeof corePaths; +export type CorePathName = keyof CorePaths; + +const corePaths = { + dev: { + path: 'devDependencies', + strategy: 'versionsByName', + }, + overrides: { + path: 'overrides', + strategy: 'versionsByName', + }, + peer: { + path: 'peerDependencies', + strategy: 'versionsByName', + }, + pnpmOverrides: { + path: 'pnpm.overrides', + strategy: 'versionsByName', + }, + prod: { + path: 'dependencies', + strategy: 'versionsByName', + }, + resolutions: { + path: 'resolutions', + strategy: 'versionsByName', + }, + workspace: { + namePath: 'name', + path: 'version', + strategy: 'name~version', + }, +} as const; + +export function getCorePaths( + fromCli: Pick, CorePathName>, +): Syncpack.PathDefinition[] { + const corePathNames = Object.keys(corePaths) as CorePathName[]; + const hasOverride = corePathNames.some((name) => isBoolean(fromCli[name])); + + return corePathNames + .filter((name) => !hasOverride || fromCli[name] === true) + .map(getByName) + .filter(isObject); + + function getByName(key: CorePathName): Syncpack.PathDefinition | undefined { + const obj = corePaths[key]; + if (obj) return { ...obj, name: key }; + } +} diff --git a/src/get-context/get-config/get-dependency-types.ts b/src/get-context/get-config/get-dependency-types.ts deleted file mode 100644 index ecc0a3b0..00000000 --- a/src/get-context/get-config/get-dependency-types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { isBoolean } from 'expect-more'; -import { ALL_DEPENDENCY_TYPES } from '../../constants'; -import type { Syncpack } from '../../types'; - -export function getDependencyTypes( - fromCli: Partial, - resolved: Syncpack.Config.Public, -): Syncpack.Config.Private['dependencyTypes'] { - const dependencyTypes: Syncpack.Config.Private['dependencyTypes'] = []; - const hasTypeOverride = - isBoolean(fromCli.dev) || - isBoolean(fromCli.overrides) || - isBoolean(fromCli.peer) || - isBoolean(fromCli.pnpmOverrides) || - isBoolean(fromCli.prod) || - isBoolean(fromCli.resolutions) || - isBoolean(fromCli.workspace); - - if (hasTypeOverride) { - resolved.dev = Boolean(fromCli.dev); - resolved.overrides = Boolean(fromCli.overrides); - resolved.peer = Boolean(fromCli.peer); - resolved.pnpmOverrides = Boolean(fromCli.pnpmOverrides); - resolved.prod = Boolean(fromCli.prod); - resolved.resolutions = Boolean(fromCli.resolutions); - resolved.workspace = Boolean(fromCli.workspace); - } - - resolved.dev && dependencyTypes.push('devDependencies'); - resolved.overrides && dependencyTypes.push('overrides'); - resolved.peer && dependencyTypes.push('peerDependencies'); - resolved.pnpmOverrides && dependencyTypes.push('pnpmOverrides'); - resolved.prod && dependencyTypes.push('dependencies'); - resolved.resolutions && dependencyTypes.push('resolutions'); - resolved.workspace && dependencyTypes.push('workspace'); - - if (dependencyTypes.length === 0) { - dependencyTypes.push(...ALL_DEPENDENCY_TYPES); - } - - return dependencyTypes; -} diff --git a/src/get-context/get-config/index.ts b/src/get-context/get-config/index.ts index cffa473c..4e91b218 100644 --- a/src/get-context/get-config/index.ts +++ b/src/get-context/get-config/index.ts @@ -1,7 +1,7 @@ import type { Disk } from '../../lib/disk'; import { verbose } from '../../lib/log'; import type { Syncpack } from '../../types'; -import { getDependencyTypes } from './get-dependency-types'; +import { getCorePaths } from './get-core-paths'; import * as ConfigSchema from './schema'; /** @@ -16,9 +16,10 @@ export const getConfig = ( const fromRcFile = disk.readConfigFileSync(fromCli.configPath); - verbose('rcfile contents:', fromCli); + verbose('rcfile contents:', fromRcFile); const fromPublic = ConfigSchema.Public.parse({ + customPaths: getConfigByName('customPaths'), dev: getConfigByName('dev'), filter: getConfigByName('filter'), indent: getConfigByName('indent'), @@ -36,8 +37,11 @@ export const getConfig = ( workspace: getConfigByName('workspace'), }); + verbose('user config:', fromPublic); + const allConfig = ConfigSchema.Private.parse({ ...fromPublic, + corePaths: getCorePaths(fromCli), defaultSemverGroup: { dependencies: ['**'], isDefault: true, @@ -49,7 +53,6 @@ export const getConfig = ( isDefault: true, packages: ['**'], }, - dependencyTypes: getDependencyTypes(fromCli, fromPublic), }); allConfig.semverGroups.push(allConfig.defaultSemverGroup); diff --git a/src/get-context/get-config/path-strategy/index.ts b/src/get-context/get-config/path-strategy/index.ts new file mode 100644 index 00000000..449de305 --- /dev/null +++ b/src/get-context/get-config/path-strategy/index.ts @@ -0,0 +1,18 @@ +import { BaseError } from '../../../lib/error'; +import { nameAndVersionProps } from './name-and-version-props'; +import { nameAndVersionString } from './name-and-version-string'; +import { versionString } from './version-string'; +import { versionsByName } from './versions-by-name'; + +export type StrategyByName = typeof strategyByName; + +export const strategyByName = { + 'name@version': nameAndVersionString, + 'name~version': nameAndVersionProps, + 'version': versionString, + 'versionsByName': versionsByName, +} as const; + +export function exhaustiveCheck(strategyName: never): never { + throw new BaseError(`Unrecognised strategy "${strategyName}"`); +} diff --git a/src/get-context/get-config/path-strategy/lib/get-non-empty-string-prop.ts b/src/get-context/get-config/path-strategy/lib/get-non-empty-string-prop.ts new file mode 100644 index 00000000..3a43b90c --- /dev/null +++ b/src/get-context/get-config/path-strategy/lib/get-non-empty-string-prop.ts @@ -0,0 +1,26 @@ +import type { R } from '@mobily/ts-belt'; +import { O, pipe } from '@mobily/ts-belt'; +import { isNonEmptyString } from 'expect-more'; +import { BaseError } from '../../../../lib/error'; +import { props } from '../../../get-package-json-files/get-patterns/props'; +import type { PackageJsonFile } from '../../../get-package-json-files/package-json-file'; + +// const root: any = this.packageJsonFile.contents; +// if (this.pathDef.name === 'pnpmOverrides') { +// root.pnpm.overrides[this.name] = version; +// } else if (this.pathDef.name !== 'workspace') { +// root[(this.pathDef as any).path][this.name] = version; +// } + +export function getNonEmptyStringProp( + propPath: string, + file: PackageJsonFile, +): R.Result { + return pipe( + file.contents, + props(propPath, isNonEmptyString), + O.toResult( + new BaseError(`Failed to get ${propPath} in ${file.shortPath}`), + ), + ); +} diff --git a/src/get-context/get-config/path-strategy/name-and-version-props.spec.ts b/src/get-context/get-config/path-strategy/name-and-version-props.spec.ts new file mode 100644 index 00000000..1afd5027 --- /dev/null +++ b/src/get-context/get-config/path-strategy/name-and-version-props.spec.ts @@ -0,0 +1,80 @@ +import { R } from '@mobily/ts-belt'; +import { mockPackage } from '../../../../test/mock'; +import { mockDisk } from '../../../../test/mock-disk'; +import { BaseError } from '../../../lib/error'; +import { PackageJsonFile } from '../../get-package-json-files/package-json-file'; +import { nameAndVersionProps as fn } from './name-and-version-props'; +import type { PathDef } from './types'; + +it('gets and sets a name and version from 2 seperate locations', () => { + const pathDef: PathDef<'name~version'> = { + name: 'workspace', + namePath: 'name', + path: 'version', + strategy: 'name~version', + }; + const jsonFile = mockPackage('foo', { otherProps: { version: '1.2.3' } }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['foo', '1.2.3']]; + const updated = [['foo', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['foo', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('gets and sets a name and version from 2 seperate nested locations', () => { + const pathDef: PathDef<'name~version'> = { + name: 'custom', + namePath: 'sibling.id', + path: 'deeper.versionNumber', + strategy: 'name~version', + }; + const jsonFile = mockPackage('foo', { + otherProps: { + sibling: { id: 'some-name' }, + deeper: { versionNumber: '1.2.3' }, + }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['some-name', '1.2.3']]; + const updated = [['some-name', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['some-name', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('returns R.Error when namePath is not found', () => { + const pathDef: PathDef<'name~version'> = { + name: 'workspace', + namePath: 'never.gonna', + path: 'version', + strategy: 'name~version', + }; + const jsonFile = mockPackage('foo', { otherProps: { version: '1.2.3' } }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + expect(fn.read(file, pathDef)).toEqual( + R.Error( + new BaseError( + 'Strategy failed to get never.gonna in foo/package.json', + ), + ), + ); +}); + +it('returns R.Error when version (path) is not found', () => { + const pathDef: PathDef<'name~version'> = { + name: 'workspace', + namePath: 'name', + path: 'never.gonna', + strategy: 'name~version', + }; + const jsonFile = mockPackage('foo', {}); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + expect(fn.read(file, pathDef)).toEqual( + R.Error( + new BaseError( + 'Strategy failed to get never.gonna in foo/package.json', + ), + ), + ); +}); diff --git a/src/get-context/get-config/path-strategy/name-and-version-props.ts b/src/get-context/get-config/path-strategy/name-and-version-props.ts new file mode 100644 index 00000000..a25ad585 --- /dev/null +++ b/src/get-context/get-config/path-strategy/name-and-version-props.ts @@ -0,0 +1,69 @@ +import { O, pipe, R } from '@mobily/ts-belt'; +import { isObject } from 'expect-more'; +import { BaseError } from '../../../lib/error'; +import { props } from '../../get-package-json-files/get-patterns/props'; +import { getNonEmptyStringProp } from './lib/get-non-empty-string-prop'; +import type { Entry, Strategy } from './types'; + +export const nameAndVersionProps: Strategy<'name~version'> = { + read(file, pathDef) { + return pipe( + // get name prop + getNonEmptyStringProp(pathDef.namePath, file), + R.mapError( + () => + new BaseError( + `Strategy failed to get ${pathDef.namePath} in ${file.shortPath}`, + ), + ), + // add the version prop + R.flatMap((name) => + pipe( + getNonEmptyStringProp(pathDef.path, file), + R.map((version) => ({ name, version })), + R.mapError( + () => + new BaseError( + `Strategy failed to get ${pathDef.path} in ${file.shortPath}`, + ), + ), + ), + ), + // if both are non empty strings, we can return them + R.map(({ name, version }): Entry[] => [[name, version]]), + ); + }, + write(file, pathDef, [, version]) { + const { contents, shortPath } = file; + const isNestedPath = pathDef.path.includes('.'); + + if (isNestedPath) { + const fullPath = pathDef.path.split('.'); + const pathToParent = fullPath.slice(0, fullPath.length - 1).join('.'); + const key = fullPath.slice(-1).join(''); + return pipe( + contents, + props(pathToParent, isObject), + O.toResult, BaseError>(onError()), + R.tap((parent) => { + parent[key] = version; + }), + R.mapError(onError), + R.map(() => file), + ); + } else { + return pipe( + R.fromExecution(() => { + contents[pathDef.path] = version; + }), + R.mapError(onError), + R.map(() => file), + ); + } + + function onError() { + const msg = `Strategy failed to set ${pathDef.path} in ${shortPath}`; + return new BaseError(msg); + } + }, +}; diff --git a/src/get-context/get-config/path-strategy/name-and-version-string.spec.ts b/src/get-context/get-config/path-strategy/name-and-version-string.spec.ts new file mode 100644 index 00000000..dc80dccd --- /dev/null +++ b/src/get-context/get-config/path-strategy/name-and-version-string.spec.ts @@ -0,0 +1,56 @@ +import { R } from '@mobily/ts-belt'; +import { mockPackage } from '../../../../test/mock'; +import { mockDisk } from '../../../../test/mock-disk'; +import { BaseError } from '../../../lib/error'; +import { PackageJsonFile } from '../../get-package-json-files/package-json-file'; +import { nameAndVersionString as fn } from './name-and-version-string'; +import type { PathDef } from './types'; + +it('gets and sets a name and version from a single string', () => { + const pathDef: PathDef<'name@version'> = { + name: 'workspace', + path: 'packageManager', + strategy: 'name@version', + }; + const jsonFile = mockPackage('foo', { + otherProps: { packageManager: 'yarn@1.2.3' }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['yarn', '1.2.3']]; + const updated = [['yarn', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['yarn', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('gets and sets a name and version from a single string nested location', () => { + const pathDef: PathDef<'name@version'> = { + name: 'custom', + path: 'deeper.versionNumber', + strategy: 'name@version', + }; + const jsonFile = mockPackage('foo', { + otherProps: { + deeper: { versionNumber: 'bar@1.2.3' }, + }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['bar', '1.2.3']]; + const updated = [['bar', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['bar', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('returns R.Error when path is not found', () => { + const pathDef: PathDef<'name@version'> = { + name: 'workspace', + path: 'never.gonna', + strategy: 'name@version', + }; + const jsonFile = mockPackage('foo', {}); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + expect(fn.read(file, pathDef)).toEqual( + R.Error(new BaseError('Failed to get never.gonna in foo/package.json')), + ); +}); diff --git a/src/get-context/get-config/path-strategy/name-and-version-string.ts b/src/get-context/get-config/path-strategy/name-and-version-string.ts new file mode 100644 index 00000000..8d63310b --- /dev/null +++ b/src/get-context/get-config/path-strategy/name-and-version-string.ts @@ -0,0 +1,59 @@ +import { O, pipe, R } from '@mobily/ts-belt'; +import { isNonEmptyString, isObject } from 'expect-more'; +import { BaseError } from '../../../lib/error'; +import { props } from '../../get-package-json-files/get-patterns/props'; +import { getNonEmptyStringProp } from './lib/get-non-empty-string-prop'; +import type { Strategy } from './types'; + +export const nameAndVersionString: Strategy<'name@version'> = { + read(file, pathDef) { + return pipe( + // get version prop + getNonEmptyStringProp(pathDef.path, file), + // if it is a non empty string, we can read it + R.flatMap((value) => { + const [name, version] = value.split('@'); + return isNonEmptyString(name) && isNonEmptyString(version) + ? R.Ok([[name, version]]) + : R.Error( + new BaseError( + `Strategy failed to get ${pathDef.path} in ${file.shortPath}`, + ), + ); + }), + ); + }, + write(file, pathDef, [name, version]) { + const { contents, shortPath } = file; + const isNestedPath = pathDef.path.includes('.'); + + if (isNestedPath) { + const fullPath = pathDef.path.split('.'); + const pathToParent = fullPath.slice(0, fullPath.length - 1).join('.'); + const key = fullPath.slice(-1).join(''); + return pipe( + contents, + props(pathToParent, isObject), + O.toResult, BaseError>(onError()), + R.tap((parent) => { + parent[key] = `${name}@${version}`; + }), + R.mapError(onError), + R.map(() => file), + ); + } else { + return pipe( + R.fromExecution(() => { + contents[pathDef.path] = `${name}@${version}`; + }), + R.mapError(onError), + R.map(() => file), + ); + } + + function onError() { + const msg = `Strategy failed to set ${pathDef.path} in ${shortPath}`; + return new BaseError(msg); + } + }, +}; diff --git a/src/get-context/get-config/path-strategy/types.ts b/src/get-context/get-config/path-strategy/types.ts new file mode 100644 index 00000000..1505c60d --- /dev/null +++ b/src/get-context/get-config/path-strategy/types.ts @@ -0,0 +1,24 @@ +import type { R } from '@mobily/ts-belt'; +import type { BaseError } from '../../../lib/error'; +import type { Syncpack } from '../../../types'; +import type { PackageJsonFile } from '../../get-package-json-files/package-json-file'; + +export type PathDef = + Syncpack.PathDefinition & { strategy: T }; + +/** A name/version pair */ +export type Entry = [string, string]; + +export interface Strategy { + /** Read from in-memory package.json file */ + read( + file: PackageJsonFile, + pathDef: PathDef, + ): R.Result; + /** Mutate in-memory package.json file */ + write( + file: PackageJsonFile, + pathDef: PathDef, + entry: [string, string | undefined], + ): R.Result; +} diff --git a/src/get-context/get-config/path-strategy/version-string.spec.ts b/src/get-context/get-config/path-strategy/version-string.spec.ts new file mode 100644 index 00000000..3830c0f8 --- /dev/null +++ b/src/get-context/get-config/path-strategy/version-string.spec.ts @@ -0,0 +1,56 @@ +import { R } from '@mobily/ts-belt'; +import { mockPackage } from '../../../../test/mock'; +import { mockDisk } from '../../../../test/mock-disk'; +import { BaseError } from '../../../lib/error'; +import { PackageJsonFile } from '../../get-package-json-files/package-json-file'; +import type { PathDef } from './types'; +import { versionString as fn } from './version-string'; + +it('gets and sets an anonymous version from a single string', () => { + const pathDef: PathDef<'version'> = { + name: 'workspace', + path: 'someVersion', + strategy: 'version', + }; + const jsonFile = mockPackage('foo', { + otherProps: { someVersion: '1.2.3' }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['someVersion', '1.2.3']]; + const updated = [['someVersion', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['someVersion', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('gets and sets an anonymous version from a single string in a nested location', () => { + const pathDef: PathDef<'version'> = { + name: 'custom', + path: 'engines.node', + strategy: 'version', + }; + const jsonFile = mockPackage('foo', { + otherProps: { + engines: { node: '1.2.3' }, + }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [['node', '1.2.3']]; + const updated = [['node', '2.0.0']]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['node', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('returns R.Error when path is not found', () => { + const pathDef: PathDef<'version'> = { + name: 'workspace', + path: 'never.gonna', + strategy: 'version', + }; + const jsonFile = mockPackage('foo', {}); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + expect(fn.read(file, pathDef)).toEqual( + R.Error(new BaseError('Failed to get never.gonna in foo/package.json')), + ); +}); diff --git a/src/get-context/get-config/path-strategy/version-string.ts b/src/get-context/get-config/path-strategy/version-string.ts new file mode 100644 index 00000000..05082656 --- /dev/null +++ b/src/get-context/get-config/path-strategy/version-string.ts @@ -0,0 +1,58 @@ +import { O, pipe, R } from '@mobily/ts-belt'; +import { isNonEmptyString, isObject } from 'expect-more'; +import { BaseError } from '../../../lib/error'; +import { props } from '../../get-package-json-files/get-patterns/props'; +import { getNonEmptyStringProp } from './lib/get-non-empty-string-prop'; +import type { Strategy } from './types'; + +export const versionString: Strategy<'version'> = { + read(file, pathDef) { + return pipe( + // get version prop + getNonEmptyStringProp(pathDef.path, file), + // if it is a non empty string, we can read it + R.flatMap((version) => { + const name = pathDef.path.split('.').slice(-1).join(''); + return isNonEmptyString(version) + ? R.Ok([[name, version]]) + : R.Error( + new BaseError( + `Strategy failed to get ${pathDef.path} in ${file.shortPath}`, + ), + ); + }), + ); + }, + write(file, pathDef, [, version]) { + const { contents, shortPath } = file; + const isNestedPath = pathDef.path.includes('.'); + if (isNestedPath) { + const fullPath = pathDef.path.split('.'); + const pathToParent = fullPath.slice(0, fullPath.length - 1).join('.'); + const key = fullPath.slice(-1).join(''); + return pipe( + contents, + props(pathToParent, isObject), + O.toResult, BaseError>(onError()), + R.tap((parent) => { + parent[key] = version; + }), + R.mapError(onError), + R.map(() => file), + ); + } else { + return pipe( + R.fromExecution(() => { + contents[pathDef.path] = version; + }), + R.mapError(onError), + R.map(() => file), + ); + } + + function onError() { + const msg = `Strategy failed to set ${pathDef.path} in ${shortPath}`; + return new BaseError(msg); + } + }, +}; diff --git a/src/get-context/get-config/path-strategy/versions-by-name.spec.ts b/src/get-context/get-config/path-strategy/versions-by-name.spec.ts new file mode 100644 index 00000000..861eb234 --- /dev/null +++ b/src/get-context/get-config/path-strategy/versions-by-name.spec.ts @@ -0,0 +1,68 @@ +import { R } from '@mobily/ts-belt'; +import { mockPackage } from '../../../../test/mock'; +import { mockDisk } from '../../../../test/mock-disk'; +import { BaseError } from '../../../lib/error'; +import { PackageJsonFile } from '../../get-package-json-files/package-json-file'; +import type { PathDef } from './types'; +import { versionsByName as fn } from './versions-by-name'; + +it('gets and sets names and versions in an object', () => { + const pathDef: PathDef<'versionsByName'> = { + name: 'workspace', + path: 'dependencies', + strategy: 'versionsByName', + }; + const jsonFile = mockPackage('foo', { deps: ['bar@1.2.3', 'baz@4.4.4'] }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [ + ['bar', '1.2.3'], + ['baz', '4.4.4'], + ]; + const updated = [ + ['bar', '2.0.0'], + ['baz', '4.4.4'], + ]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['bar', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('gets and sets a name and version from a single string nested location', () => { + const pathDef: PathDef<'versionsByName'> = { + name: 'custom', + path: 'deeper.deps', + strategy: 'versionsByName', + }; + const jsonFile = mockPackage('foo', { + otherProps: { deeper: { deps: { bar: '1.2.3', baz: '4.4.4' } } }, + }); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + const initial = [ + ['bar', '1.2.3'], + ['baz', '4.4.4'], + ]; + const updated = [ + ['bar', '2.0.0'], + ['baz', '4.4.4'], + ]; + expect(fn.read(file, pathDef)).toEqual(R.Ok(initial)); + expect(fn.write(file, pathDef, ['bar', '2.0.0'])).toEqual(R.Ok(file)); + expect(fn.read(file, pathDef)).toEqual(R.Ok(updated)); +}); + +it('returns R.Error when path is not found', () => { + const pathDef: PathDef<'versionsByName'> = { + name: 'workspace', + path: 'never.gonna', + strategy: 'versionsByName', + }; + const jsonFile = mockPackage('foo', {}); + const file = new PackageJsonFile(jsonFile, {} as any, mockDisk()); + expect(fn.read(file, pathDef)).toEqual( + R.Error( + new BaseError( + 'Strategy failed to get never.gonna in foo/package.json', + ), + ), + ); +}); diff --git a/src/get-context/get-config/path-strategy/versions-by-name.ts b/src/get-context/get-config/path-strategy/versions-by-name.ts new file mode 100644 index 00000000..94dc0c38 --- /dev/null +++ b/src/get-context/get-config/path-strategy/versions-by-name.ts @@ -0,0 +1,39 @@ +import { O, pipe, R } from '@mobily/ts-belt'; +import { isNonEmptyObject, isObject } from 'expect-more'; +import { BaseError } from '../../../lib/error'; +import { props } from '../../get-package-json-files/get-patterns/props'; +import type { Strategy } from './types'; + +export const versionsByName: Strategy<'versionsByName'> = { + read(file, pathDef) { + return pipe( + file.contents, + props(pathDef.path, isNonEmptyObject), + O.map(Object.entries), + O.toResult( + new BaseError( + `Strategy failed to get ${pathDef.path} in ${file.shortPath}`, + ), + ), + ); + }, + write(file, pathDef, [name, version]) { + const { contents, shortPath } = file; + + return pipe( + contents, + props(pathDef.path, isObject), + O.toResult, BaseError>(onError()), + R.tap((parent) => { + parent[name] = version; + }), + R.mapError(onError), + R.map(() => file), + ); + + function onError() { + const msg = `Strategy failed to set ${pathDef.path} in ${shortPath}`; + return new BaseError(msg); + } + }, +}; diff --git a/src/get-context/get-config/schema/base-group.ts b/src/get-context/get-config/schema/base-group.ts index a37aa036..8a9a9ed7 100644 --- a/src/get-context/get-config/schema/base-group.ts +++ b/src/get-context/get-config/schema/base-group.ts @@ -1,10 +1,9 @@ import { z } from 'zod'; -import * as DependencyTypeSchema from './dependency-type'; -const NonEmptyString = z.string().trim().min(1); +const nonEmptyString = z.string().trim().min(1); export const baseGroupFields = { - dependencies: z.array(NonEmptyString).min(1), - dependencyTypes: DependencyTypeSchema.NameList.optional(), - packages: z.array(NonEmptyString).min(1), + dependencies: z.array(nonEmptyString).min(1), + dependencyTypes: z.array(nonEmptyString).default([]), + packages: z.array(nonEmptyString).min(1), }; diff --git a/src/get-context/get-config/schema/dependency-type.ts b/src/get-context/get-config/schema/dependency-type.ts deleted file mode 100644 index 9c916b06..00000000 --- a/src/get-context/get-config/schema/dependency-type.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from 'zod'; -import { ALL_DEPENDENCY_TYPES, DEFAULT_CONFIG } from '../../../constants'; - -export const Name = z.enum(ALL_DEPENDENCY_TYPES); - -export const NameList = z.array(Name); - -export const Flags = z.object({ - dev: z.boolean().default(DEFAULT_CONFIG.dev), - overrides: z.boolean().default(DEFAULT_CONFIG.overrides), - peer: z.boolean().default(DEFAULT_CONFIG.peer), - pnpmOverrides: z.boolean().default(DEFAULT_CONFIG.pnpmOverrides), - prod: z.boolean().default(DEFAULT_CONFIG.prod), - resolutions: z.boolean().default(DEFAULT_CONFIG.resolutions), - workspace: z.boolean().default(DEFAULT_CONFIG.workspace), -}); diff --git a/src/get-context/get-config/schema/index.ts b/src/get-context/get-config/schema/index.ts index 275ee5a0..36b5aa45 100644 --- a/src/get-context/get-config/schema/index.ts +++ b/src/get-context/get-config/schema/index.ts @@ -1,9 +1,9 @@ import { z } from 'zod'; import { DEFAULT_CONFIG } from '../../../constants'; -import * as DependencyTypeSchema from './dependency-type'; -import * as SemverGroupSchema from './semver-group'; -import * as SemverRangeSchema from './semver-range'; -import * as VersionGroupSchema from './version-group'; +import * as paths from './paths'; +import * as semverGroup from './semver-group'; +import * as semverRange from './semver-range'; +import * as versionGroup from './version-group'; const NonEmptyString = z.string().trim().min(1); @@ -12,13 +12,14 @@ const cliOnly = { } as const; const syncpackRcOnly = { + customPaths: paths.pathConfigByName.optional(), semverGroups: z - .array(SemverGroupSchema.Any) + .array(semverGroup.any) .default([...DEFAULT_CONFIG.semverGroups]), sortAz: z.array(NonEmptyString).default([...DEFAULT_CONFIG.sortAz]), sortFirst: z.array(NonEmptyString).default([...DEFAULT_CONFIG.sortFirst]), versionGroups: z - .array(VersionGroupSchema.Any) + .array(versionGroup.any) .default([...DEFAULT_CONFIG.versionGroups]), } as const; @@ -32,16 +33,14 @@ const cliAndRcFile = { workspace: z.boolean().default(DEFAULT_CONFIG.workspace), filter: NonEmptyString.default(DEFAULT_CONFIG.filter), indent: z.string().default(DEFAULT_CONFIG.indent), - semverRange: SemverRangeSchema.Value.default( - DEFAULT_CONFIG.semverRange as '', - ), + semverRange: semverRange.value.default(DEFAULT_CONFIG.semverRange as ''), source: z.array(NonEmptyString).default([...DEFAULT_CONFIG.source]), } as const; const privateOnly = { - defaultSemverGroup: SemverGroupSchema.Default, - defaultVersionGroup: VersionGroupSchema.Default, - dependencyTypes: DependencyTypeSchema.NameList, + corePaths: z.array(paths.pathDefinition), + defaultSemverGroup: semverGroup.base, + defaultVersionGroup: versionGroup.base, } as const; export const Private = z.object({ @@ -62,7 +61,7 @@ export const Cli = z.object({ }); export const Public = Private.omit({ + corePaths: true, defaultSemverGroup: true, defaultVersionGroup: true, - dependencyTypes: true, }); diff --git a/src/get-context/get-config/schema/paths.ts b/src/get-context/get-config/schema/paths.ts new file mode 100644 index 00000000..4ab05df6 --- /dev/null +++ b/src/get-context/get-config/schema/paths.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +const namedVersionString = z.object({ + path: z.string(), + strategy: z.literal('name@version'), +}); + +const nameAndVersionStrings = z.object({ + namePath: z.string(), + path: z.string(), + strategy: z.literal('name~version'), +}); + +const unnamedVersionString = z.object({ + path: z.string(), + strategy: z.literal('version'), +}); + +const versionsByName = z.object({ + path: z.string(), + strategy: z.literal('versionsByName'), +}); + +const pathConfig = z.discriminatedUnion('strategy', [ + nameAndVersionStrings, + namedVersionString, + unnamedVersionString, + versionsByName, +]); + +/** config */ +export const pathConfigByName = z.record(pathConfig); + +/** private */ +export const pathDefinition = pathConfig.and( + z.object({ + name: z.string().trim().min(1), + }), +); diff --git a/src/get-context/get-config/schema/semver-group.ts b/src/get-context/get-config/schema/semver-group.ts index ba5c4fdb..af8c211c 100644 --- a/src/get-context/get-config/schema/semver-group.ts +++ b/src/get-context/get-config/schema/semver-group.ts @@ -2,20 +2,20 @@ import { z } from 'zod'; import { baseGroupFields } from './base-group'; import * as SemverRangeSchema from './semver-range'; -export const Ignored = z +export const ignored = z .object({ ...baseGroupFields, isIgnored: z.literal(true) }) .strict(); -export const WithRange = z - .object({ ...baseGroupFields, range: SemverRangeSchema.Value }) +export const withRange = z + .object({ ...baseGroupFields, range: SemverRangeSchema.value }) .strict(); -export const Default = z +export const base = z .object({ ...baseGroupFields, - range: SemverRangeSchema.Value, + range: SemverRangeSchema.value, isDefault: z.literal(true), }) .strict(); -export const Any = z.union([Ignored, WithRange, Default]); +export const any = z.union([ignored, withRange, base]); diff --git a/src/get-context/get-config/schema/semver-range.ts b/src/get-context/get-config/schema/semver-range.ts index 176106eb..8a67596d 100644 --- a/src/get-context/get-config/schema/semver-range.ts +++ b/src/get-context/get-config/schema/semver-range.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { RANGE } from '../../../constants'; -export const Value = z.enum([ +export const value = z.enum([ RANGE.ANY, RANGE.EXACT, RANGE.GT, diff --git a/src/get-context/get-config/schema/version-group.ts b/src/get-context/get-config/schema/version-group.ts index 0d342e2d..ca517323 100644 --- a/src/get-context/get-config/schema/version-group.ts +++ b/src/get-context/get-config/schema/version-group.ts @@ -3,22 +3,22 @@ import { baseGroupFields } from './base-group'; const NonEmptyString = z.string().trim().min(1); -export const Standard = z.object(baseGroupFields).strict(); +export const standard = z.object(baseGroupFields).strict(); -export const Banned = z +export const banned = z .object({ ...baseGroupFields, isBanned: z.literal(true) }) .strict(); -export const Ignored = z +export const ignored = z .object({ ...baseGroupFields, isIgnored: z.literal(true) }) .strict(); -export const Pinned = z +export const pinned = z .object({ ...baseGroupFields, pinVersion: NonEmptyString }) .strict(); -export const Default = z +export const base = z .object({ ...baseGroupFields, isDefault: z.literal(true) }) .strict(); -export const Any = z.union([Standard, Banned, Ignored, Pinned, Default]); +export const any = z.union([standard, banned, ignored, pinned, base]); diff --git a/src/get-context/get-context.spec.ts b/src/get-context/get-context.spec.ts index 0a6dc6e6..301cceb8 100644 --- a/src/get-context/get-context.spec.ts +++ b/src/get-context/get-context.spec.ts @@ -5,58 +5,6 @@ import { mockDisk } from '../../test/mock-disk'; import { CWD, DEFAULT_CONFIG } from '../constants'; describe('getContext', () => { - describe('dependencyTypes', () => { - const disk = mockDisk(); - const dev = 'devDependencies'; - const overrides = 'overrides'; - const peer = 'peerDependencies'; - const pnpmOverrides = 'pnpmOverrides'; - const prod = 'dependencies'; - const resolutions = 'resolutions'; - const workspace = 'workspace'; - const allTypes = [ - dev, - overrides, - peer, - pnpmOverrides, - prod, - resolutions, - workspace, - ]; - const ix = { - dev: 'devDependencies', - overrides: 'overrides', - peer: 'peerDependencies', - pnpmOverrides: 'pnpmOverrides', - prod: 'dependencies', - resolutions: 'resolutions', - workspace: 'workspace', - }; - - it('includes all if none are set', () => { - expect(getContext({}, disk)).toHaveProperty( - 'dependencyTypes', - expect.toBeArrayIncludingOnly(allTypes), - ); - }); - it('enables one if it is the only one set', () => { - expect.assertions(allTypes.length); - Object.entries(ix).forEach(([optionName, typeName]) => { - expect(getContext({ [optionName]: true }, disk)).toHaveProperty( - 'dependencyTypes', - expect.toBeArrayIncludingOnly([typeName]), - ); - }); - }); - it('enables some if only those are set', () => { - expect( - getContext({ dev: true, prod: true, workspace: true }, disk), - ).toHaveProperty( - 'dependencyTypes', - expect.toBeArrayIncludingOnly([dev, prod, workspace]), - ); - }); - }); describe('source', () => { it('uses defaults when no CLI options or config are set', () => { const disk = mockDisk(); @@ -80,11 +28,11 @@ describe('getContext', () => { it('combines defaults, values from CLI options, and config', () => { const disk = mockDisk(); disk.readConfigFileSync.mockReturnValue({ source: ['foo'] }); - expect(getContext({ sortAz: ['overridden'] }, disk)).toEqual( + expect(getContext({ indent: ' ' }, disk)).toEqual( expect.objectContaining({ semverRange: '', source: ['foo'], - sortAz: ['overridden'], + indent: ' ', }), ); }); @@ -100,9 +48,7 @@ describe('getContext', () => { }, ], }); - expect( - getContext({ sortAz: ['overridden'] }, disk).semverGroups, - ).toEqual([ + expect(getContext({}, disk).semverGroups).toEqual([ expect.objectContaining({ dependencies: ['@alpha/*'], packages: ['@myrepo/library'], @@ -122,9 +68,7 @@ describe('getContext', () => { { dependencies: ['chalk'], packages: ['foo', 'bar'] }, ], }); - expect( - getContext({ sortAz: ['overridden'] }, disk).versionGroups, - ).toEqual([ + expect(getContext({}, disk).versionGroups).toEqual([ expect.objectContaining({ dependencies: ['chalk'], packages: ['foo', 'bar'], diff --git a/src/get-context/get-groups/semver-group/index.ts b/src/get-context/get-groups/semver-group/index.ts index dec564a1..53322b68 100644 --- a/src/get-context/get-groups/semver-group/index.ts +++ b/src/get-context/get-groups/semver-group/index.ts @@ -4,8 +4,8 @@ import type { Instance } from '../../get-package-json-files/package-json-file/in export class SemverGroup { /** */ dependencies: string[]; - /** Optionally limit this group to dependencies of the provided types */ - dependencyTypes?: Syncpack.Config.DependencyType.NameList; + /** Optionally limit this group to dependencies at these named paths */ + pathNames?: Syncpack.PathName[]; /** */ input: Syncpack.Config.Private; /** */ diff --git a/src/get-context/get-groups/version-group/index.ts b/src/get-context/get-groups/version-group/index.ts index e7138bf9..1d14981b 100644 --- a/src/get-context/get-groups/version-group/index.ts +++ b/src/get-context/get-groups/version-group/index.ts @@ -5,8 +5,8 @@ import type { InstanceGroup } from './instance-group'; export class VersionGroup { /** */ dependencies: string[]; - /** Optionally limit this group to dependencies of the provided types */ - dependencyTypes?: Syncpack.Config.DependencyType.NameList; + /** Optionally limit this group to dependencies at these named paths */ + pathNames?: Syncpack.PathName[]; /** */ input: Syncpack.Config.Private; /** */ @@ -35,7 +35,7 @@ export class VersionGroup { type Pinned = Syncpack.Config.VersionGroup.Pinned; this.dependencies = versionGroup.dependencies; - this.dependencyTypes = versionGroup.dependencyTypes; + this.pathNames = versionGroup.dependencyTypes; this.input = input; this.instanceGroups = []; this.instances = []; diff --git a/src/get-context/get-groups/version-group/instance-group/index.ts b/src/get-context/get-groups/version-group/instance-group/index.ts index 08687023..22b970d1 100644 --- a/src/get-context/get-groups/version-group/instance-group/index.ts +++ b/src/get-context/get-groups/version-group/instance-group/index.ts @@ -7,7 +7,7 @@ import { getHighestVersion } from './get-highest-version'; export class InstanceGroup { /** 1+ `Instance` has a version which does not follow the rules */ hasMismatches: boolean; - /** Every package/dependencyType location where this dependency was found */ + /** Every package/pathName location where this dependency was found */ instances: Instance[]; /** Syncpack must report or fix this groups mismatches */ isInvalid: boolean; @@ -73,7 +73,7 @@ export class InstanceGroup { */ getWorkspaceInstance(): Instance | undefined { return this.instances.find( - (instance) => instance.dependencyType === 'workspace', + (instance) => instance.pathDef.name === 'workspace', ); } } diff --git a/src/get-context/get-package-json-files/package-json-file/index.ts b/src/get-context/get-package-json-files/package-json-file/index.ts index 4eb8d6ec..bff09b76 100644 --- a/src/get-context/get-package-json-files/package-json-file/index.ts +++ b/src/get-context/get-package-json-files/package-json-file/index.ts @@ -1,10 +1,14 @@ -import { isNonEmptyString } from 'expect-more'; +import { pipe, R } from '@mobily/ts-belt'; import { relative } from 'path'; import { CWD } from '../../../constants'; import type { Disk } from '../../../lib/disk'; import { verbose } from '../../../lib/log'; import { newlines } from '../../../lib/newlines'; import type { Syncpack } from '../../../types'; +import { + exhaustiveCheck, + strategyByName, +} from '../../get-config/path-strategy'; import type { JsonFile } from '../get-patterns/read-json-safe'; import { Instance } from './instance'; @@ -32,6 +36,8 @@ export interface PackageJson { | undefined; } +type Entry = [string, string]; + export class PackageJsonFile { /** parsed JSON contents of the file */ contents: PackageJson; @@ -81,50 +87,51 @@ export class PackageJsonFile { } getInstances(): Instance[] { - return this.program.dependencyTypes - .flatMap((dependencyType): Instance[] => - this.getDependencyEntries(dependencyType, this.contents).map( - ([name, version]) => - new Instance(dependencyType, name, this, version), + return this.program.corePaths + .flatMap((pathDef): Instance[] => + this.getPathEntries(pathDef, this).map( + ([name, version]) => new Instance(pathDef, name, this, version), ), ) .filter((instance) => { - const { dependencyType, name, version } = instance; - if (!isNonEmptyString(name)) { - verbose('skip instance, no name', instance); - return false; - } + const { pathDef, name, version } = instance; if (name.search(new RegExp(this.program.filter)) === -1) { verbose('skip instance, name does not match filter', instance); return false; } - if (!isNonEmptyString(version)) { - verbose('skip instance, no version', instance); - return false; - } - verbose(`add ${name}@${version} to ${dependencyType} ${this.filePath}`); + verbose(`add ${name}@${version} to ${pathDef} ${this.filePath}`); return true; }); } - getDependencyEntries( - dependencyType: Syncpack.Config.DependencyType.Name, - contents: PackageJson, - ): [string, string][] { - switch (dependencyType) { - case 'dependencies': - case 'devDependencies': - case 'overrides': - case 'peerDependencies': - case 'resolutions': { - return Object.entries(contents?.[dependencyType] || {}); - } - case 'workspace': { - return [[contents.name || '', contents.version || '']]; - } - case 'pnpmOverrides': { - return Object.entries(contents?.pnpm?.overrides || {}); - } + getPathEntries( + pathDef: Syncpack.PathDefinition, + file: PackageJsonFile, + ): Entry[] { + const strategyName = pathDef.strategy; + switch (strategyName) { + case 'name@version': + return pipe( + strategyByName[strategyName].read(file, pathDef), + R.getWithDefault([] as Entry[]), + ); + case 'name~version': + return pipe( + strategyByName[strategyName].read(file, pathDef), + R.getWithDefault([] as Entry[]), + ); + case 'version': + return pipe( + strategyByName[strategyName].read(file, pathDef), + R.getWithDefault([] as Entry[]), + ); + case 'versionsByName': + return pipe( + strategyByName[strategyName].read(file, pathDef), + R.getWithDefault([] as Entry[]), + ); + default: + return exhaustiveCheck(strategyName); } } } diff --git a/src/get-context/get-package-json-files/package-json-file/instance.ts b/src/get-context/get-package-json-files/package-json-file/instance.ts index f7f0d0bc..3bb4c35b 100644 --- a/src/get-context/get-package-json-files/package-json-file/instance.ts +++ b/src/get-context/get-package-json-files/package-json-file/instance.ts @@ -1,30 +1,38 @@ +import { pipe } from '@mobily/ts-belt'; import { isNonEmptyArray } from 'expect-more'; import minimatch from 'minimatch'; import type { PackageJsonFile } from '.'; +import { $R } from '../../$R'; import { setSemverRange } from '../../../lib/set-semver-range'; import type { Syncpack } from '../../../types'; +import { + exhaustiveCheck, + strategyByName, +} from '../../get-config/path-strategy'; import type { SemverGroup } from '../../get-groups/semver-group'; import type { VersionGroup } from '../../get-groups/version-group'; +type Entry = [string, string | undefined]; + export class Instance { - /** where this dependency is installed */ - dependencyType: Syncpack.Config.DependencyType.Name; /** the name of this dependency */ name: string; /** The package this dependency is installed in this specific time */ packageJsonFile: PackageJsonFile; + /** where this dependency is installed */ + pathDef: Syncpack.PathDefinition; /** The .name property of the package.json file of this instance */ pkgName: string; /** the version of this dependency */ version: string; constructor( - dependencyType: Syncpack.Config.DependencyType.Name, + pathDef: Syncpack.PathDefinition, name: string, packageJsonFile: PackageJsonFile, version: string, ) { - this.dependencyType = dependencyType; + this.pathDef = pathDef; this.name = name; this.packageJsonFile = packageJsonFile; this.pkgName = packageJsonFile.contents.name || 'PACKAGE_JSON_HAS_NO_NAME'; @@ -33,7 +41,7 @@ export class Instance { hasRange(range: Syncpack.Config.SemverRange.Value): boolean { return ( - this.dependencyType !== 'workspace' && + this.pathDef.name !== 'workspace' && this.version === setSemverRange(range, this.version) ); } @@ -47,11 +55,36 @@ export class Instance { * which causes them to be removed by `JSON.stringify`. */ setVersion(version: string | undefined): void { - const root: any = this.packageJsonFile.contents; - if (this.dependencyType === 'pnpmOverrides') { - root.pnpm.overrides[this.name] = version; - } else if (this.dependencyType !== 'workspace') { - root[this.dependencyType][this.name] = version; + const strategyName = this.pathDef.strategy; + const entry: Entry = [this.name, version]; + const file = this.packageJsonFile; + switch (strategyName) { + case 'name@version': + pipe( + strategyByName[strategyName].write(file, this.pathDef, entry), + $R.tapErrVerbose, + ); + break; + case 'name~version': + pipe( + strategyByName[strategyName].write(file, this.pathDef, entry), + $R.tapErrVerbose, + ); + break; + case 'version': + pipe( + strategyByName[strategyName].write(file, this.pathDef, entry), + $R.tapErrVerbose, + ); + break; + case 'versionsByName': + pipe( + strategyByName[strategyName].write(file, this.pathDef, entry), + $R.tapErrVerbose, + ); + break; + default: + return exhaustiveCheck(strategyName); } } @@ -59,8 +92,8 @@ export class Instance { return ( group.packages.some((pattern) => minimatch(this.pkgName, pattern)) && group.dependencies.some((pattern) => minimatch(this.name, pattern)) && - (!isNonEmptyArray(group.dependencyTypes) || - group.dependencyTypes.includes(this.dependencyType)) + (!isNonEmptyArray(group.pathNames) || + group.pathNames.includes(this.pathDef.name)) ); } } diff --git a/src/types.ts b/src/types.ts index 2ae65e71..87b2f266 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,16 @@ import type { z } from 'zod'; import type { Context as TContext } from './get-context'; +import type { CorePathName } from './get-context/get-config/get-core-paths'; import type { - Cli as CliSchema, - Private as PrivateSchema, - Public as PublicSchema, - SyncpackRc as SyncpackRcSchema, + Cli as cli, + Private as privateSchema, + Public as publicSchema, + SyncpackRc as syncpackRc, } from './get-context/get-config/schema'; -import type * as DependencyTypeSchema from './get-context/get-config/schema/dependency-type'; -import type * as SemverGroupSchema from './get-context/get-config/schema/semver-group'; -import type * as SemverRangeSchema from './get-context/get-config/schema/semver-range'; -import type * as VersionGroupSchema from './get-context/get-config/schema/version-group'; +import type * as paths from './get-context/get-config/schema/paths'; +import type * as semverGroup from './get-context/get-config/schema/semver-group'; +import type * as semverRange from './get-context/get-config/schema/semver-range'; +import type * as versionGroup from './get-context/get-config/schema/version-group'; import type { SemverGroup as TSemverGroup } from './get-context/get-groups/semver-group'; import type { VersionGroup as TVersionGroup } from './get-context/get-groups/version-group'; import type { PackageJsonFile as TPackageJsonFile } from './get-context/get-package-json-files/package-json-file'; @@ -19,18 +20,26 @@ export namespace Syncpack { export type Ctx = TContext; export type Instance = TInstance; export type PackageJsonFile = TPackageJsonFile; - export type VersionGroup = TVersionGroup; + export type PathDefinition = z.infer; + export type PathName = CorePathName | string; export type SemverGroup = TSemverGroup; + export type VersionGroup = TVersionGroup; export namespace Config { /** All config which can be set via the command line */ - export type Cli = z.infer; + export type Cli = z.infer; /** @private */ - export type Private = z.infer; + export type Private = z.infer; /** All config which can be set via the command line and/or .syncpackrc */ - export type Public = z.infer; + export type Public = z.infer; /** All valid contents of a .syncpackrc */ - export type SyncpackRc = z.infer; + export type SyncpackRc = z.infer; + + export namespace Paths { + type T = typeof paths; + /** Direct syncpack where and how to find and fix versions elsewhere */ + export type ConfigByName = z.infer; + } export namespace SemverRange { /** @@ -53,42 +62,35 @@ export namespace Syncpack { * * @default "" */ - export type Value = z.infer; + export type Value = z.infer; } export namespace SemverGroup { + type T = typeof semverGroup; /** Let dependencies in this group do whatever they like */ - export type Ignored = z.infer; + export type Ignored = z.infer; /** Ensure the version range of dependencies in this group is always this */ - export type WithRange = z.infer; + export type WithRange = z.infer; /** @private */ - export type Default = z.infer; + export type Default = z.infer; /** Every valid type of SemverGroup */ - export type Any = z.infer; + export type Any = z.infer; } export namespace VersionGroup { + type T = typeof versionGroup; /** Partion these dependencies and make sure they match internally */ - export type Standard = z.infer; + export type Standard = z.infer; /** Prevent dependencies in this group from being added to the project */ - export type Banned = z.infer; + export type Banned = z.infer; /** Let dependencies in this group do whatever they like */ - export type Ignored = z.infer; + export type Ignored = z.infer; /** Override the version of dependencies in this group to always be this */ - export type Pinned = z.infer; + export type Pinned = z.infer; /** @private */ - export type Default = z.infer; + export type Default = z.infer; /** Every valid type of VersionGroup */ - export type Any = z.infer; - } - - export namespace DependencyType { - /** Alias for paths to version properties in package.json files */ - export type Name = z.infer; - /** Array of paths to version properties in package.json files */ - export type NameList = z.infer; - /** The isEnabled status of each DependencyType by name */ - export type Flags = z.infer; + export type Any = z.infer; } } } diff --git a/test/mock.ts b/test/mock.ts index 6baa9c4a..dc395856 100644 --- a/test/mock.ts +++ b/test/mock.ts @@ -1,14 +1,17 @@ import { EOL } from 'os'; import { join } from 'path'; import { CWD } from '../src/constants'; -import type { JsonFile } from '../src/lib/get-context/get-package-json-files/get-patterns/read-json-safe'; +import type { JsonFile } from '../src/get-context/get-package-json-files/get-patterns/read-json-safe'; +import type { PackageJson } from '../src/get-context/get-package-json-files/package-json-file'; import { newlines } from '../src/lib/newlines'; -import type { PackageJson } from '../src/lib/get-context/get-package-json-files/package-json-file'; export function createPackageJsonFile( contents: PackageJson, ): JsonFile { - return withJson({ contents, filePath: join(CWD, 'some/package.json') }); + return withJson({ + contents, + filePath: join(CWD, 'some/package.json'), + }); } export function toJson(contents: PackageJson): string { diff --git a/test/scenarios/create-scenario.ts b/test/scenarios/create-scenario.ts index feb2b7c6..03aeb4c4 100644 --- a/test/scenarios/create-scenario.ts +++ b/test/scenarios/create-scenario.ts @@ -1,8 +1,8 @@ import minimatch from 'minimatch'; import { join, normalize } from 'path'; import { CWD } from '../../src/constants'; -import type { JsonFile } from '../../src/lib/get-context/get-package-json-files/get-patterns/read-json-safe'; -import type { PackageJson } from '../../src/lib/get-context/get-package-json-files/package-json-file'; +import type { JsonFile } from '../../src/get-context/get-package-json-files/get-patterns/read-json-safe'; +import type { PackageJson } from '../../src/get-context/get-package-json-files/package-json-file'; import type { Syncpack } from '../../src/types'; import type { MockDisk } from '../mock-disk'; import { mockDisk } from '../mock-disk';