diff --git a/.changeset/witty-cars-relate.md b/.changeset/witty-cars-relate.md new file mode 100644 index 0000000000..1bd88bc255 --- /dev/null +++ b/.changeset/witty-cars-relate.md @@ -0,0 +1,5 @@ +--- +"@rnx-kit/tools-packages": minor +--- + +Add package context, validation context, and abstractions that allow working in yarn constraints and directly against files diff --git a/packages/tools-packages/README.md b/packages/tools-packages/README.md index 53edad770e..8c268b4a84 100644 --- a/packages/tools-packages/README.md +++ b/packages/tools-packages/README.md @@ -3,23 +3,31 @@ [![Build](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml/badge.svg)](https://github.com/microsoft/rnx-kit/actions/workflows/build.yml) [![npm version](https://img.shields.io/npm/v/@rnx-kit/tools-packages)](https://www.npmjs.com/package/@rnx-kit/tools-packages) -This package has utilities for loading base information about packages, -retrieved in a `PackageInfo` type, with a layer of caching that happens -automatically, as well as the ability to store additional custom values in the -retrieved `PackageInfo` +This package provides utilities for working with npm packages from Node.js +tooling. It covers three areas: + +- **Package info** — load and cache `PackageInfo` for a package by path or by + workspace name, with helpers for attaching custom data to the cached entry + via symbol-keyed accessors. +- **Package contexts** — build a `PackageContext` from a root directory, and + optionally extend it into a `PackageValidationContext` that can validate or + fix the package's `package.json`. +- **JSON validator** — a standalone `JSONValidator` for any JSON document that + reports differences (or applies them as fixes) at given paths, with optional + file-write-on-finish behavior. The same surface drives both standalone + validation and the Yarn constraints adapter. ## Motivation -While loading package.json is pretty quick, this can quickly end up being a -redundant operation as there different packages in rnx-kit all need different -information from the file. This adds a simple caching layer for retrieving -packages so work is not done multiple times. +While loading `package.json` is pretty quick, it quickly becomes redundant when +multiple tools in rnx-kit each need to read the same file. This package adds a +caching layer so the work is done once. -The packages can also have custom accessors defined that allow storing of -additional data in the `PackageInfo` and because of that, associated with that -package in the cache. This might be loading the `KitConfig` parsing and -validating a tsconfig.json file. This package doesn't need to care what is being -stored, other packages can add their custom accessors as needed. +Tools also frequently need to enforce the same rules on `package.json` — +sometimes as a CI check (report-only) and sometimes as a fix-up step. The +`JSONValidator` API lets a single piece of validation code run in either mode, +and the `createYarnWorkspaceContext` adapter lets the same code be wired into +Yarn constraints without changes. ## Installation @@ -35,23 +43,114 @@ npm add --save-dev @rnx-kit/tools-packages ## Usage -There are two main parts of this package, helpers for retrieving package info -and helpers for accessors. +### Package info + +```ts +import { + createPackageValueLoader, + getPackageInfoFromPath, +} from "@rnx-kit/tools-packages"; + +const getTsConfigPath = createPackageValueLoader("tsconfigPath", (pkg) => { + const candidate = path.join(pkg.root, "tsconfig.json"); + return fs.existsSync(candidate) ? candidate : undefined; +}); + +const pkg = getPackageInfoFromPath("/path/to/some/package"); +const tsconfig = getTsConfigPath(pkg); // computed once, cached on pkg +``` + +### Package validation + +```ts +import { createPackageValidationContext } from "@rnx-kit/tools-packages"; + +const ctx = createPackageValidationContext("/path/to/pkg", undefined, { + fix: process.argv.includes("--fix"), + reportPrefix: "[my-tool] ", +}); +ctx.enforce("license", "MIT"); +ctx.enforce(["scripts", "build"], "rnx-kit-scripts build"); +const { changes, errors } = ctx.finish(); +``` + +When `fix` is true and a `jsonFilePath` is set (the validation context infers +this from the package root), `finish()` writes the updated `package.json` back +to disk. + +### Yarn constraints + +The same validation code can run inside a Yarn constraints file by adapting +the workspace object Yarn provides: + +```ts +import { createYarnWorkspaceContext } from "@rnx-kit/tools-packages"; + +export default { + async constraints({ Yarn }) { + for (const workspace of Yarn.workspaces()) { + const ctx = createYarnWorkspaceContext(workspace); + ctx.enforce("license", "MIT"); + } + }, +}; +``` ### Types -| Type Name | Description | -| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PlatformInfo` | Main returned type for the module. This contains information about the package name, root package path, the loaded package.json in `Manifest` form, whether or not the package is a workspace, as well as a `symbol` based index signature for attaching additional information to the type. | -| `GetPackageValue` | Format for a value accessor, used when creating accessors that only need to be loaded once. | -| `PackageValueAccessors` | Typed has/get/set methods to access values attached to the `PackageInfo` when they may be updated. | - -### Functions - -| Function | Description | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `getPackageInfoFromPath` | Given a path to either the root folder of a package, or the package.json for that package, return a loaded `PackageInfo` for that package. This will attempt to look up the package in the cache, loading it if not found. It will throw an exception on an invalid path. | -| `getPackageInfoFromWorkspaces` | Try to retrieve a `PackageInfo` by name. This only works for in-workspace packages as module resolution outside of that scope is more complicated. Note that by default this only finds packages previously cached. If the optional boolean parameter is set to true, in the case that the package is not found, all workspaces will be loaded into the cache. This can be expensive though it is a one time cost. | -| `getRootPackageInfo` | Get the package info for the root of the workspaces | -| `createPackageValueLoader` | Create a function which retrieves a cached value from `PackageInfo` calling the initializer function if it hasn't been loaded yet. This creates an internal symbol for to make the access unique with the supplied friendly name to make debugging easier. | -| `createPackageValueAccessors` | Create three typed functions matching the has/get/set signature associated with a new and contained symbol. This is for accessors that may need to change over time. | +| Type | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PackageContext` | Basic package information: `name`, fully resolved `root`, the loaded `manifest`, plus a symbol index signature for attaching additional information to the context. | +| `PackageInfo` | A `PackageContext` returned from the cached lookup helpers. Adds an optional `workspace` flag indicating whether the package is part of the current workspace. | +| `PackageValidationContext` | `PackageContext & JSONValidator`. The package context augmented with `enforce` / `error` / `changed` / `finish` for validating or fixing the `package.json`. | +| `JSONValidator` | The validation surface used by both standalone and yarn-mode validators. See the function table below. | +| `JSONValidatorOptions` | Options accepted by `createJSONValidator`: `fix`, `jsonFilePath`, `reportError`, `reportPrefix`. All optional; missing values fall back to the module-level defaults set via `setDefaultValidationOptions`. | +| `JSONValidationResult` | Result returned from `finish()`: `{ changes: boolean; errors: boolean }`. | +| `JSONValuePath` | `string \| string[]`. A dotted string is split into segments; an array form lets segments contain literal `.` characters (e.g. `["exports", ".", "import"]`). | +| `JSONValue` | Recursive JSON value type — primitives, arrays, or `Record`. | +| `GetPackageValue` | Single-function accessor produced by `createPackageValueLoader`. Always returns `T` because the loader initializes the value on first access. | +| `PackageValueAccessors` | `has` / `get` / `set` accessors produced by `createPackageValueAccessors`. `get` returns `T \| undefined` since the value may not have been set. | +| `ObjectValueAccessors` | Generalized form of `PackageValueAccessors` for any object type, produced by `createObjectValueAccessors`. | + +### Package info functions + +| Function | Description | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `getPackageInfoFromPath` | Given a path to a package root or its `package.json`, returns a cached `PackageInfo`. Loads the package the first time it is seen. Throws if the path is not a valid package. | +| `findPackageInfo` | Walks up from the start path (or `process.cwd()` if none is given) to the nearest `package.json` and returns the cached `PackageInfo` for it. | +| `getPackageInfoFromWorkspaces` | Looks up a `PackageInfo` by package name. Only resolves packages that are part of the current workspace. By default it only consults the cache; pass `true` as the second argument to load all workspace packages into the cache on a miss. The full load is a one-time cost but can be expensive in large repos. | + +### Context functions + +| Function | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `createPackageContext` | Build a `PackageContext` from a root directory. Reads `package.json` from disk unless a manifest is supplied. The root path is resolved to an absolute path. | +| `createPackageValidationContext` | Build a `PackageValidationContext` from a root directory. Sets `jsonFilePath` to `/package.json` so `finish()` will write changes when `fix` is enabled. | +| `asPackageValidationContext` | Promote an existing `PackageContext` to a `PackageValidationContext`. If the supplied context is already a validator (recognized via brand symbol) the same instance is returned unchanged — fix mode and reporter on the existing validator are preserved. | +| `createYarnWorkspaceContext` | Adapt a Yarn `Workspace` (as exposed via Yarn constraints) into a `PackageValidationContext`. `enforce` is routed to `workspace.set` / `workspace.unset` and `error` is routed to `workspace.error`. `changed` and `finish` are no-ops — Yarn manages those. | + +### JSON validator functions + +| Function | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createJSONValidator` | Wrap a JSON object as a `JSONValidator`. `enforce(path, value)` reports or applies a difference depending on `fix`; `enforce(path, undefined)` removes a value. An optional `baseObj` mixes the validator methods onto an existing object. When `fix` is true and `jsonFilePath` is provided, `finish()` writes the file. | +| `isJSONValidator` | Brand-symbol check for objects produced by `createJSONValidator`. Plain objects with a matching shape are not recognized. | +| `compareValues` | Deep equality for JSON-shaped values. Object keys are compared with order significance (since JSON files have a meaningful key order on disk). | +| `setDefaultValidationOptions` | Set process-wide defaults for `fix`, `reportError`, and `reportPrefix`. Useful for wiring CLI flags once at startup. Per-call options always take precedence. | + +### Accessor functions + +| Function | Description | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createPackageValueLoader` | Returns a single function which retrieves a value from a `PackageInfo`, calling the supplied initializer the first time and caching the result on the context. The result is keyed by a fresh symbol; the friendly name is used only for debugging. | +| `createPackageValueAccessors` | Returns a `{ has, get, set }` triple for storing values that may change over time on a `PackageInfo`. Backed by a fresh symbol per call. | +| `createObjectValueAccessors` | Like `createPackageValueAccessors` but generic over the host object type — useful for attaching internal state to any object that has a string-or-symbol index signature. | + +### `JSONValidator` methods + +| Method | Description | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `enforce(path, value)` | If `value` is a JSON value and the document differs, either applies the change (fix mode) or reports an error. If `value` is `undefined`, removes the property at `path` (fix mode) or reports its presence (non-fix mode). | +| `error(message)` | Report a custom validation error. Sets the `errors` flag on the result. | +| `changed()` | Mark that an out-of-band change was made to the underlying JSON. Sets the `changes` flag on the result so the file will be written on `finish()` when in fix mode. | +| `finish()` | Returns `{ changes, errors }`. In fix mode, when `jsonFilePath` is set and `changes` is true, writes the JSON file before returning. | diff --git a/packages/tools-packages/package.json b/packages/tools-packages/package.json index 45fff69caf..2eb4b51a57 100644 --- a/packages/tools-packages/package.json +++ b/packages/tools-packages/package.json @@ -34,6 +34,7 @@ "test": "rnx-kit-scripts test" }, "dependencies": { + "@rnx-kit/tools-filesystem": "^0.2.0", "@rnx-kit/tools-node": "^3.0.4", "@rnx-kit/tools-workspaces": "^0.2.1", "@rnx-kit/types-node": "^1.0.0" @@ -41,7 +42,16 @@ "devDependencies": { "@rnx-kit/scripts": "*", "@rnx-kit/tsconfig": "*", - "@types/node": "^24.0.0" + "@types/node": "^24.0.0", + "@yarnpkg/types": "^4.0.0" + }, + "peerDependencies": { + "@yarnpkg/types": ">=4.0.0" + }, + "peerDependenciesMeta": { + "@yarnpkg/types": { + "optional": true + } }, "engines": { "node": ">=16.17" diff --git a/packages/tools-packages/src/accessors.ts b/packages/tools-packages/src/accessors.ts index 3af2c68cbc..a193b17239 100644 --- a/packages/tools-packages/src/accessors.ts +++ b/packages/tools-packages/src/accessors.ts @@ -1,4 +1,9 @@ -import type { GetPackageValue, PackageInfo } from "./types.ts"; +import type { + GetPackageValue, + ObjectValueAccessors, + PackageContext, + PackageValueAccessors, +} from "./types.ts"; /** * Helper function to create a typed accessor function for getting and storing information @@ -11,34 +16,67 @@ import type { GetPackageValue, PackageInfo } from "./types.ts"; */ export function createPackageValueLoader( friendlyName: string, - initialize: (pkgInfo: PackageInfo) => T + initialize: (pkgInfo: PackageContext) => T ): GetPackageValue { + return createValueLoader(friendlyName, initialize); +} + +/** + * Helper function to create a typed accessor function for getting and storing information + * in any object. This can be whatever you want, the key is only created and stored in + * the generated function so there are no collisions. + * + * @param friendlyName name used to create a symbol key for the package info + * @param initialize function used to initialize the value stored in the key + * @returns a function to retrieve the value from the object, if unset the initialize function is called + */ +export function createValueLoader( + friendlyName: string, + initialize: (obj: TObj) => T +): (obj: TObj) => T { const symbolKey = Symbol(friendlyName); - return (pkgInfo: PackageInfo) => { - if (!(symbolKey in pkgInfo)) { - pkgInfo[symbolKey] = initialize(pkgInfo); + type ObjCast = { [symbolKey]: T }; + return (obj: TObj) => { + if (!(symbolKey in obj)) { + (obj as ObjCast)[symbolKey] = initialize(obj); } - return pkgInfo[symbolKey] as T; + return (obj as ObjCast)[symbolKey]; }; } /** - * Create has/get/set accessors for a newly created symbol key that can look up values in PackageInfo + * Create has/get/set accessors for a newly created symbol key that can look up values in PackageContext + * in a way that is guaranteed to be unique and not collide with any other properties on the package context. * * @param friendlyName name used to create a symbol key for the package info * @returns a set of accessors for the symbol key */ -export function createPackageValueAccessors(friendlyName: string) { +export function createPackageValueAccessors( + friendlyName: string +): PackageValueAccessors { + return createObjectValueAccessors(friendlyName); +} + +/** + * Create has/get/set accessors using a newly created symbol key that can look up values in any object + * in a way that is guaranteed to be unique and not collide with any other properties on the object. + * @param friendlyName name used to create a symbol key for the object + * @returns a set of accessors for the symbol key + */ +export function createObjectValueAccessors( + friendlyName: string +): ObjectValueAccessors { const symbolKey = Symbol(friendlyName); + type ObjCast = { [symbolKey]?: TVal }; return { - has(pkgInfo: PackageInfo) { + has(pkgInfo: TObj) { return symbolKey in pkgInfo; }, - get(pkgInfo: PackageInfo) { - return pkgInfo[symbolKey] as T; + get(pkgInfo: TObj) { + return (pkgInfo as ObjCast)[symbolKey]; }, - set(pkgInfo: PackageInfo, value: T) { - pkgInfo[symbolKey] = value; + set(pkgInfo: TObj, value: TVal) { + (pkgInfo as ObjCast)[symbolKey] = value; }, }; } diff --git a/packages/tools-packages/src/context.ts b/packages/tools-packages/src/context.ts new file mode 100644 index 0000000000..521770db2a --- /dev/null +++ b/packages/tools-packages/src/context.ts @@ -0,0 +1,126 @@ +import { readJSONFileSync } from "@rnx-kit/tools-filesystem"; +import type { PackageManifest } from "@rnx-kit/types-node"; +import type { Yarn } from "@yarnpkg/types"; +import path from "node:path"; +import { + createJSONValidator, + getJSONPathSegments, + isJSONValidator, + type JSONValidatorOptions, +} from "./json.ts"; +import type { + JSONValue, + PackageContext, + PackageValidationContext, + JSONValuePath, + JSONValidationResult, +} from "./types"; + +/** + * Create a core package context for a given root directory and optional manifest. + * @param root root directory of the package + * @param manifest optional package manifest, if not provided it will be loaded from root/package.json + * @returns a CorePackageContext with basic properties and file checking capabilities, but no enforce or validate functions + */ +export function createPackageContext< + TManifest extends PackageManifest = PackageManifest, +>(root: string, loadedManifest?: TManifest): PackageContext { + root = path.resolve(root); + const manifest = + loadedManifest ?? + readJSONFileSync(path.join(root, "package.json")); + return { + root, + manifest, + name: manifest.name, + }; +} + +/** + * Creates a package validation context from a path to a package root (with an optional manifest). + * This will wrap the package manifest in a JSON validator so that enforce, error, changed, and finish + * methods are available for validating and optionally fixing the package.json contents. + * + * @param base root path or for the package + * @param manifest optional package manifest to use instead of loading from the package root + * @param options optional JSON validator options to configure how the package.json is validated and fixed + * @returns a PackageValidationContext wrapping the package manifest with JSON validation capabilities + */ +export function createPackageValidationContext< + TManifest extends PackageManifest = PackageManifest, +>( + base: string, + manifest: TManifest | undefined = undefined, + options: JSONValidatorOptions = {} +): PackageValidationContext { + const context = createPackageContext(base, manifest); + const jsonFilePath = path.join(context.root, "package.json"); + + return createJSONValidator( + context.manifest as Record, + { ...options, jsonFilePath }, + context + ); +} + +/** + * Adds JSON validator capabilities to an existing package context using the default options for JSON validation. + * If it is already a JSON validator it will be returned as-is without modification. + * + * @param context the package context to enhance with JSON validator capabilities + * @returns a package validation context wrapping the provided package context with JSON validation capabilities + */ +export function asPackageValidationContext< + TManifest extends PackageManifest = PackageManifest, +>(context: PackageContext): PackageValidationContext { + if (isJSONValidator(context)) { + return context; + } + const jsonFilePath = path.join(context.root, "package.json"); + return createJSONValidator( + context.manifest as Record, + { jsonFilePath }, + context + ); +} + +/** + * Create a package validation context for a yarn workspace provided by the constraints API. This will route the validation context + * APIs to the provided workspace, which will error or fix depending on whether fix mode is enabled for the constraints execution. + * This allows the same validation code to be used both in standalone mode and as part of yarn constraints. + * + * --- IMPORTANT NOTE --- + * When running in yarn constraints mode, yarn handles error reporting and tracking internally. As a result, the 'changed' and 'finish' + * methods will be no-ops and will not reflect changes. Also manual modifications to manifest may have unexpected results. + * + * @param workspace The yarn workspace to create the context for + * @returns A package validation context for the provided yarn workspace + */ +export function createYarnWorkspaceContext< + TManifest extends PackageManifest = PackageManifest, +>(workspace: Yarn.Constraints.Workspace): PackageValidationContext { + return { + ...createPackageContext(workspace.cwd, workspace.manifest as TManifest), + enforce(path: JSONValuePath, value: JSONValue | undefined): void { + const safePath = getJSONPathSegments(path); + if (value === undefined) { + workspace.unset(safePath); + } else { + workspace.set(safePath, value); + } + }, + changed: yarnChangedStub, + finish: yarnFinishStub, + error(message: string): void { + workspace.error(message); + }, + }; +} + +function yarnFinishStub(): JSONValidationResult { + return { changes: false, errors: false }; +} + +function yarnChangedStub(): void { + // no-op as yarn constraints will handle this internally +} diff --git a/packages/tools-packages/src/index.ts b/packages/tools-packages/src/index.ts index 935bab253e..f4d65bd4fb 100644 --- a/packages/tools-packages/src/index.ts +++ b/packages/tools-packages/src/index.ts @@ -1,14 +1,41 @@ export { + createObjectValueAccessors, createPackageValueAccessors, createPackageValueLoader, + createValueLoader, } from "./accessors.ts"; + +export { + asPackageValidationContext, + createPackageContext, + createPackageValidationContext, + createYarnWorkspaceContext, +} from "./context.ts"; + +export type { JSONValidatorOptions } from "./json.ts"; +export { + createJSONValidator, + getJSONPathSegments, + isJSONValidator, + compareValues, + setDefaultValidationOptions, +} from "./json.ts"; + export { findPackageInfo, getPackageInfoFromPath, getPackageInfoFromWorkspaces, } from "./package.ts"; + export type { + JSONValue, + JSONValidationResult, + JSONValidator, + JSONValuePath, GetPackageValue, + ObjectValueAccessors, + PackageContext, + PackageValidationContext, PackageInfo, PackageValueAccessors, } from "./types.ts"; diff --git a/packages/tools-packages/src/json.ts b/packages/tools-packages/src/json.ts new file mode 100644 index 0000000000..eac28edf38 --- /dev/null +++ b/packages/tools-packages/src/json.ts @@ -0,0 +1,393 @@ +import { writeJSONFileSync } from "@rnx-kit/tools-filesystem"; +import { styleText } from "node:util"; +import { createObjectValueAccessors } from "./accessors.ts"; +import type { + JSONValue, + JSONValidator, + JSONValidationResult, + JSONValuePath, +} from "./types.ts"; + +type ResolvedOptions = { + /** whether to apply fixes automatically when enforcing values */ + fix: boolean; + + /** + * path to the JSON file being validated. + * - if provided - the validator will write the JSON file to disk if changed + * - if not provided - the caller is responsible for writing the file if changes are made + */ + jsonFilePath?: string; + + /** + * error reporting callback. If not provided errors will be sent to console.error. + */ + reportError: (message: string) => void; + + /** + * report prefix. If provided this string will be prepended to all error messages + * reported via the `reportError` callback or to console.error. + */ + reportPrefix?: string; +}; + +/** + * Options type for creating a JSON validator. All properties are optional and will + * be resolved to a fully specified ResolvedOptions object internally by the validator. + */ +export type JSONValidatorOptions = Partial; + +/** helper to attach and retrieve hidden resolved options on a JSONValidator instance */ +const accessOptions = createObjectValueAccessors< + JSONValidator, + ResolvedOptions +>("JSONValidatorOptions"); + +/** + * Internal type for the editing context used by the various validation helper functions + */ +type JSONEditingContext = Pick< + JSONValidator, + "error" | "changed" | "finish" +> & { + json: Record; + fix: boolean; +}; + +const defaultOptions: Omit = { + fix: false, +}; + +/** + * Sets the default validation options for JSON validators where options are not explicitly specified + * @param options the default options to use when a JSON validator is created without explicit options + */ +export function setDefaultValidationOptions( + options: Omit +): void { + Object.assign(defaultOptions, options); +} + +/** + * Resolve the validator options ensuring the required defaults are set + * @param options the user-specified JSON validator options to resolve + * @returns a fully resolved set of options with defaults applied where not specified + */ +function resolveValidatorOptions({ + fix, + reportError, + reportPrefix, + jsonFilePath, +}: JSONValidatorOptions): ResolvedOptions { + return { + fix: fix ?? defaultOptions.fix ?? false, + reportError: reportError ?? defaultOptions.reportError ?? console.error, + reportPrefix: reportPrefix ?? defaultOptions.reportPrefix, + jsonFilePath: jsonFilePath, + }; +} + +/** + * Creates a JSON editing context for a given JSON object and validator options. + * The returned context provides methods for reporting errors, tracking changes, + * and finalizing the validation process, as well as access to the JSON object + * and the `fix` flag indicating whether automatic fixes should be applied. + * @param json the JSON object to be edited + * @param options the JSON validator options controlling fix behavior and error reporting + * @returns a JSONEditingContext instance for use by JSON validation helpers + */ +function createJSONEditingContext( + json: Record, + options: ResolvedOptions +): JSONEditingContext { + const { fix, reportError, reportPrefix } = options; + let changes = false; + let errors = false; + + // returned (and internally used) error reporting function + function error(message: string) { + errors = true; + if (reportPrefix) { + message = `${reportPrefix}${message}`; + } + reportError(message); + } + function changed() { + changes = true; + } + function finish(): JSONValidationResult { + return { changes, errors }; + } + + return { json, fix, error, changed, finish }; +} + +/** + * Utility function to safely parse a JSONValuePath into an array of path segments, while blocking potentially dangerous segments + * such as "__proto__", "constructor", and "prototype" which could lead to prototype pollution vulnerabilities if allowed + * to be walked or set on the JSON object. + * @param path the JSONValuePath to parse into segments + * @returns an array of path segments if the path is valid + * @throws an error if any blocked segments are found in the path + */ +export const getJSONPathSegments = (() => { + const blocked = new Set(["__proto__", "constructor", "prototype"]); + return (path: JSONValuePath): string[] => { + const segments = Array.isArray(path) ? path : path.split("."); + for (const segment of segments) { + if (blocked.has(segment)) { + throw new Error( + `Blocked JSON path segment: ${segment} in "${segments.join(".")}"` + ); + } + } + return segments; + }; +})(); + +/** + * Creates a JSON validator for a given JSON object and options. The returned validator + * provides methods to enforce values at specific paths, report errors, track changes, + * and finalize the validation process, optionally writing changes back to disk if fixes + * are enabled and a file path is provided. + * @param json loaded JSON object to validate and potentially modify + * @param userOptions options controlling fix behavior, error reporting, and file writing + * @param baseObj optional object to assign the validator methods to, allowing the validator to be mixed into another context object if desired + * @returns a JSONValidator instance for validating and optionally fixing the provided JSON object + */ +export function createJSONValidator( + json: Record, + userOptions: JSONValidatorOptions = defaultOptions, + baseObj?: T +): JSONValidator & T { + const resolvedOptions = resolveValidatorOptions(userOptions); + + // create the editing context used for helpers and change tracking + const context = createJSONEditingContext(json, resolvedOptions); + const { error, changed, finish: finishResult } = context; + + // enforce a value at a given path in the JSON object + function enforce(path: JSONValuePath, value: JSONValue | undefined) { + const pathArray = getJSONPathSegments(path); + if (value === undefined) { + unsetValue(pathArray, context); + } else { + setValue(pathArray, value, context); + } + } + + // write the JSON file if needed and report the results + function finish(): JSONValidationResult { + const result = finishResult(); + if (result.changes && context.fix && resolvedOptions.jsonFilePath) { + writeJSONFileSync(resolvedOptions.jsonFilePath, json); + } + return result; + } + + const validator: JSONValidator = { enforce, error, changed, finish }; + const result = baseObj + ? Object.assign(baseObj, validator) + : (validator as JSONValidator & T); + + // attach the resolved options to the result + accessOptions.set(result, resolvedOptions); + return result; +} + +/** + * Determine whether a given object is a JSONValidator by checking if it is a record + * and has the resolved options attached via the internal accessOptions accessors. + */ +export function isJSONValidator(obj: unknown): obj is JSONValidator { + return isRecord(obj) && accessOptions.has(obj as JSONValidator); +} + +/** + * Removes a value at a given path in the JSON object. If the value exists at the specified path, + * it will either be deleted (if `context.fix` is true) or reported as an error. + * @param path the path to the value to remove, as an array of keys representing the path in the JSON object + * @param context the editing context used for error reporting and change tracking + */ +function unsetValue(path: string[], context: JSONEditingContext) { + if (path.length > 0) { + const parent = walkPath(path, context, false); + if (parent) { + const valueKey = path[path.length - 1]; + if (valueKey in parent) { + if (context.fix) { + context.changed(); + delete parent[valueKey]; + } else { + context.error(valueMessage(path, undefined, parent[valueKey])); + } + } + } + } +} + +/** + * Sets a value at a given path in the JSON object, creating intermediate objects as needed. + * If the current value at the path differs from the desired value, the change will either be applied + * (if `context.fix` is true) or reported as an error. + * @param path the to the value to set, as an array of keys representing the path in the JSON object + * @param value the value to set at the specified path + * @param context the editing context used for error reporting and change tracking + */ +function setValue( + path: string[], + value: JSONValue, + context: JSONEditingContext +) { + const parent = walkPath(path, context, true); + if (!parent) { + context.error(valueMessage(path, value, undefined)); + } else { + const currentValue = parent[path[path.length - 1]]; + if (!compareValues(currentValue, value)) { + if (context.fix) { + context.changed(); + parent[path[path.length - 1]] = value; + } else { + context.error(valueMessage(path, value, currentValue)); + } + } + } +} + +/** + * Walks a path through a JSON object returning the parent object of the last key in the path. + * If `ensureExists` is true, missing intermediate objects will be created along the path, potentially overwriting + * existing non-object values. + * @param path path to walk through the JSON object, as an array of keys + * @param context editing context used for error reporting and change tracking + * @param ensureExists if true, missing intermediate objects along the path will be created + * @returns the parent object of the last key in the path, or undefined if the path cannot be walked + */ +function walkPath( + path: string[], + context: JSONEditingContext, + ensureExists: boolean +): Record | undefined { + let current: Record = context.json; + // walk to the second to last segment, the last one is the key we want to set/unset + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + if (!isRecord(current[segment])) { + if (ensureExists && context.fix) { + context.changed(); + current[segment] = {}; + } else { + return undefined; + } + } + current = current[segment] as Record; + } + return current; +} + +/** plain object type assertion checker */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Does a deep equality check of two values. Will check objects and arrays recursively. Objects are + * treated as ordered structures as they are written to JSON in a specific order and a mismatch indicates + * a change is needed. + * @param value1 The first value to compare. + * @param value2 The second value to compare. + * @returns True if the values are equal, false otherwise. + */ +export function compareValues(value1: unknown, value2: unknown): boolean { + // short-circuit for primitive equality and identical object references + if (value1 === value2) { + return true; + } + + // only non-null objects need to have special handling, otherwise fall through to false below + if ( + typeof value1 === "object" && + typeof value2 === "object" && + value1 !== null && + value2 !== null + ) { + // handle the both objects are arrays case, in this case walk through them and compare each value deeply + if (Array.isArray(value1) && Array.isArray(value2)) { + return compareArrays(value1, value2); + } + + // handle the both plain objects case, checking keys including key ordering and comparing values deeply + if (isRecord(value1) && isRecord(value2)) { + return compareObjects(value1, value2); + } + } + // already did the === check at the top of the function + return false; +} + +/** + * Performs a deep equality check of two arrays, comparing each element recursively using `compareValues`. + * @param array1 The first array to compare. + * @param array2 The second array to compare. + * @returns True if the arrays are equal, false otherwise. + */ +function compareArrays(array1: unknown[], array2: unknown[]): boolean { + if (array1.length !== array2.length) { + return false; + } + for (let i = 0; i < array1.length; i++) { + if (!compareValues(array1[i], array2[i])) { + return false; + } + } + return true; +} + +/** + * Performs a deep equality check of two objects, comparing each key and value recursively using `compareValues`. + * The order of keys is significant and must match exactly for the objects to be considered equal. + * @param obj1 The first object to compare. + * @param obj2 The second object to compare. + * @returns True if the objects are equal, false otherwise. + */ +function compareObjects( + obj1: Record, + obj2: Record +): boolean { + const keys = Object.keys(obj1); + if (!compareArrays(keys, Object.keys(obj2))) { + return false; + } + for (const key of keys) { + if (!compareValues(obj1[key], obj2[key])) { + return false; + } + } + return true; +} + +/** + * Formats a value for display in error messages, highlighting undefined values in red + * and other values in green. + */ +function formatValue(value: unknown): string { + if (value === undefined) { + return styleText("red", "UNSET"); + } + if (typeof value === "object" && value !== null) { + return styleText("green", JSON.stringify(value)); + } + return styleText("green", String(value)); +} + +/** + * Format a validation message showing the expected and current values for a given JSON path. + */ +function valueMessage( + path: string[], + expected: unknown, + current: unknown +): string { + return `${styleText("cyan", path.join("."))} should be: ${formatValue(expected)} [current: ${formatValue(current)}]`; +} diff --git a/packages/tools-packages/src/types.ts b/packages/tools-packages/src/types.ts index cbcdb16cfd..6c6cf87b9a 100644 --- a/packages/tools-packages/src/types.ts +++ b/packages/tools-packages/src/types.ts @@ -1,22 +1,105 @@ import type { PackageManifest } from "@rnx-kit/types-node"; -export type PackageInfo = { +/** + * Basic information about a package + */ +export type PackageContext< + TManifest extends PackageManifest = PackageManifest, +> = { /** name of the package */ - name: string; + readonly name: string; - /** root path of the package, effectively path.dirname of the package json path */ - root: string; + /** fully resolved root path of the package */ + readonly root: string; - /** Access the loaded package.json for the package */ - manifest: PackageManifest; + /** loaded package manifest, templated to allow type injection */ + readonly manifest: TManifest; +}; +/** + * A path to a value in a JSON file. A single string will be split on dots to create the path, + * but an array of strings can also be used to avoid ambiguity with dots in property names. When using an array, + * each segment will be treated as-is. + * + * Examples: + * - "dependencies.react" is equivalent to ["dependencies", "react"] + * - ["exports", ".", "import"] is ambiguous if using a string, but unambiguous as an array + */ +export type JSONValuePath = string | string[]; + +/** JSON primitive types */ +export type JSONPrimitive = string | number | boolean | null; +/** JSON value type */ +export type JSONValue = JSONPrimitive | Record | JSONValue[]; + +/** Results of JSON validation */ +export type JSONValidationResult = { + /** whether the JSON file was modified to fix issues */ + changes: boolean; + + /** whether any unfixed errors were found during validation */ + errors: boolean; +}; + +/** + * A editor and validator for a JSON object. This type provides methods to enforce values and can be run in fix mode + * where edits will apply, or in non-fix mode where errors will be reported but no changes will be made. + */ +export type JSONValidator = { + /** + * Enforce a value in the JSON file. This will either report an error in not in fix mode, or update the + * manifest if in fix mode. If the value is undefined, the property will be removed from the manifest as undefined + * is not a valid JSON value and cannot be written to the manifest. + * + * Note that this signature is such that this can run against either a normal package context or the workspace + * provided by yarn constraints. + * + * @param path manifest value to target + * @param value either the value type to enforce, or undefined if the value should be removed + */ + enforce(path: JSONValuePath, value: JSONValue | undefined): void; + + /** + * Report an error related to JSON validation. This will log an error message and cause the validation result + * to be "error", even in fix mode. + */ + error(message: string): void; + + /** + * Report that an edit was made to the JSON object. This will mark the validation as having changes + * so that if running in fix mode the JSON file will be written with the changes applied when finish() + * is called. + */ + changed(): void; + + /** + * Finish the validation run and return the result of the JSON validation. If in fix mode and changes were made, + * the file will be written with the changes applied before returning the result. + */ + finish(): JSONValidationResult; +}; + +/** + * Combined context for validating a package.json file, including both the package information and the validation utilities. + * This provides a wrapper around the manifest and will be able to edit the manifest when running in fix mode. + */ +export type PackageValidationContext< + TManifest extends PackageManifest = PackageManifest, +> = PackageContext & JSONValidator; + +/** + * PackageInfo objects are cached instances of PackageContext that may include additional metadata + * such as whether the package is part of a workspace. These objects are intended to be reused + * across multiple operations to avoid repeatedly reading and parsing the same package.json files. + */ +export type PackageInfo = PackageContext & { /** Is this a workspace package */ - workspace: boolean; + workspace?: boolean; /** * data storage by symbol value is allowed to have package specific values stored here. This allows - * other packages to leverage any caching happening for PackageInfo entries to store additional - * information and ensure it is only loaded once. + * other packages to attach custom data to a package context in a way that is guaranteed to be unique + * and not collide with any other properties on the package context. */ [key: symbol]: unknown; }; @@ -27,10 +110,20 @@ export type PackageInfo = { export type GetPackageValue = (pkgInfo: PackageInfo) => T; /** - * Set of accessor functions that can be retrieved for a specific symbol + * Generic accessor set for storing typed values on an arbitrary object via a hidden symbol key. + * `get` returns `undefined` when no value has been set, so callers should check `has` first or + * handle `undefined` explicitly. */ -export type PackageValueAccessors = { - get: GetPackageValue; - has: (pkgInfo: PackageInfo) => boolean; - set: (pkgInfo: PackageInfo, value: T) => void; +export type ObjectValueAccessors = { + has: (obj: TObj) => boolean; + get: (obj: TObj) => TVal | undefined; + set: (obj: TObj, value: TVal) => void; }; + +/** + * Set of accessor functions that can be retrieved for a specific symbol on a PackageInfo. + * `get` returns `undefined` when no value has been set, so callers should check `has` first + * or handle `undefined` explicitly. Use `createPackageValueLoader` instead when an + * initialize-on-miss pattern is desired (its `get` is guaranteed to return `T`). + */ +export type PackageValueAccessors = ObjectValueAccessors; diff --git a/packages/tools-packages/test/accessors.test.ts b/packages/tools-packages/test/accessors.test.ts index d6b9b21429..a29a371d40 100644 --- a/packages/tools-packages/test/accessors.test.ts +++ b/packages/tools-packages/test/accessors.test.ts @@ -1,9 +1,13 @@ -import { deepEqual } from "node:assert/strict"; +import { deepEqual, equal } from "node:assert/strict"; import * as fs from "node:fs"; import * as path from "node:path"; import { describe, it } from "node:test"; import { fileURLToPath } from "node:url"; -import { createPackageValueLoader } from "../src/accessors.ts"; +import { + createObjectValueAccessors, + createPackageValueAccessors, + createPackageValueLoader, +} from "../src/accessors.ts"; import { getPackageInfoFromPath, getRootPackageInfo } from "../src/package.ts"; import type { PackageInfo } from "../src/types.ts"; @@ -29,3 +33,56 @@ describe("package value loader", () => { deepEqual(pkgInfo.workspace, true); }); }); + +describe("createObjectValueAccessors", () => { + type Obj = Record; + + it("get returns undefined and has returns false before set", () => { + const acc = createObjectValueAccessors("test"); + const obj: Obj = {}; + equal(acc.has(obj), false); + equal(acc.get(obj), undefined); + }); + + it("set then get returns the stored value", () => { + const acc = createObjectValueAccessors("test"); + const obj: Obj = {}; + acc.set(obj, "hello"); + equal(acc.has(obj), true); + equal(acc.get(obj), "hello"); + }); + + it("each call creates a unique symbol key", () => { + const a = createObjectValueAccessors("k"); + const b = createObjectValueAccessors("k"); + const obj: Obj = {}; + a.set(obj, "from-a"); + equal(b.has(obj), false); + equal(b.get(obj), undefined); + equal(a.get(obj), "from-a"); + }); + + it("does not collide with other string-keyed properties", () => { + const acc = createObjectValueAccessors("count"); + const obj: Obj = { count: "shadow" }; + equal(acc.has(obj), false); + acc.set(obj, 42); + equal(acc.get(obj), 42); + equal(obj.count, "shadow"); + }); +}); + +describe("createPackageValueAccessors", () => { + it("returns accessors that read/write on a PackageInfo", () => { + const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url)); + const pkgInfo = getPackageInfoFromPath(pkgPath); + const acc = createPackageValueAccessors<{ flag: boolean }>("flagBag"); + + equal(acc.has(pkgInfo), false); + equal(acc.get(pkgInfo), undefined); + + acc.set(pkgInfo, { flag: true }); + equal(acc.has(pkgInfo), true); + deepEqual(acc.get(pkgInfo), { flag: true }); + }); +}); diff --git a/packages/tools-packages/test/context.test.ts b/packages/tools-packages/test/context.test.ts new file mode 100644 index 0000000000..75368af3b1 --- /dev/null +++ b/packages/tools-packages/test/context.test.ts @@ -0,0 +1,277 @@ +import type { Yarn } from "@yarnpkg/types"; +import { deepEqual, equal, notEqual, ok, throws } from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, mock } from "node:test"; +import { + asPackageValidationContext, + createPackageContext, + createPackageValidationContext, + createYarnWorkspaceContext, +} from "../src/context.ts"; +import { isJSONValidator } from "../src/json.ts"; +import type { JSONValue, JSONValuePath } from "../src/types.ts"; + +function makeTempPackage(manifest: object): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tools-pkgs-ctx-")); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify(manifest, null, 2) + ); + return dir; +} + +describe("createPackageContext", () => { + it("loads manifest from disk when not provided", () => { + const dir = makeTempPackage({ name: "test-pkg", version: "1.0.0" }); + try { + const ctx = createPackageContext(dir); + equal(ctx.name, "test-pkg"); + equal(ctx.manifest.version, "1.0.0"); + equal(path.isAbsolute(ctx.root), true); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("uses provided manifest without reading from disk", () => { + const ctx = createPackageContext("/no/such/dir", { + name: "x", + version: "1.0.0", + }); + equal(ctx.name, "x"); + equal(ctx.manifest.version, "1.0.0"); + }); + + it("resolves relative root paths to absolute", () => { + const dir = makeTempPackage({ name: "y" }); + try { + const relative = path.relative(process.cwd(), dir); + const ctx = createPackageContext(relative, { name: "y" }); + equal(path.isAbsolute(ctx.root), true); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("throws when no manifest provided and file does not exist", () => { + let threw = false; + try { + createPackageContext("/definitely/not/a/real/path/__tools_pkgs_test__"); + } catch { + threw = true; + } + equal(threw, true); + }); +}); + +describe("createPackageValidationContext", () => { + it("returns a context that satisfies both PackageContext and JSONValidator", () => { + const dir = makeTempPackage({ name: "vt", version: "1.0.0" }); + try { + const v = createPackageValidationContext(dir); + equal(v.name, "vt"); + equal(typeof v.enforce, "function"); + equal(typeof v.error, "function"); + equal(typeof v.changed, "function"); + equal(typeof v.finish, "function"); + equal(isJSONValidator(v), true); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("uses provided manifest in place of reading the file", () => { + const dir = makeTempPackage({ name: "vt", version: "1.0.0" }); + try { + const v = createPackageValidationContext(dir, { + name: "vt", + version: "9.9.9", + }); + equal(v.manifest.version, "9.9.9"); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("writes the package.json on finish in fix mode", () => { + const dir = makeTempPackage({ name: "vt", version: "1.0.0" }); + try { + const v = createPackageValidationContext(dir, undefined, { fix: true }); + v.enforce("version", "2.0.0"); + const result = v.finish(); + equal(result.changes, true); + const written = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf-8") + ); + equal(written.version, "2.0.0"); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("does not write package.json in non-fix mode", () => { + const dir = makeTempPackage({ name: "vt", version: "1.0.0" }); + try { + const v = createPackageValidationContext(dir, undefined, { + fix: false, + reportError: () => undefined, + }); + v.enforce("version", "2.0.0"); + v.finish(); + const written = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf-8") + ); + equal(written.version, "1.0.0"); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); +}); + +describe("asPackageValidationContext", () => { + it("wraps a basic PackageContext as a validator", () => { + const dir = makeTempPackage({ name: "as", version: "1.0.0" }); + try { + const base = createPackageContext(dir); + equal(isJSONValidator(base), false); + const v = asPackageValidationContext(base); + equal(isJSONValidator(v), true); + equal(v.name, "as"); + equal(typeof v.enforce, "function"); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("returns the same instance if already a validator", () => { + const dir = makeTempPackage({ name: "as" }); + try { + const v1 = createPackageValidationContext(dir); + const v2 = asPackageValidationContext(v1); + equal(v1 as object, v2 as object); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("infers jsonFilePath from the context root", () => { + const dir = makeTempPackage({ name: "as", version: "1.0.0" }); + try { + const base = createPackageContext(dir); + const v = asPackageValidationContext(base); + v.enforce("version", "2.0.0"); + // calling finish without changes flag still respects fix=false default + const result = v.finish(); + equal(result.errors, true); + equal(result.changes, false); + // confirm file was not modified (fix not enabled by default) + const written = JSON.parse( + fs.readFileSync(path.join(dir, "package.json"), "utf-8") + ); + equal(written.version, "1.0.0"); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); +}); + +describe("createYarnWorkspaceContext", () => { + function makeFakeWorkspace( + cwd: string, + manifest: object + ): Yarn.Constraints.Workspace { + return { + cwd, + ident: null, + manifest, + pkg: {} as Yarn.Constraints.Workspace["pkg"], + set: mock.fn() as unknown as ( + path: JSONValuePath, + value: JSONValue + ) => void, + unset: mock.fn() as unknown as (path: JSONValuePath) => void, + error: mock.fn() as unknown as (message: string) => void, + } as unknown as Yarn.Constraints.Workspace; + } + + it("forwards set() to the workspace as a normalized array path", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + ctx.enforce("version", "1.0.0"); + const setMock = w.set as unknown as ReturnType; + equal(setMock.mock.callCount(), 1); + deepEqual(setMock.mock.calls[0].arguments, [["version"], "1.0.0"]); + }); + + it("splits dotted string paths before forwarding to set()", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + ctx.enforce("scripts.build", "rnx-kit-scripts build"); + const setMock = w.set as unknown as ReturnType; + deepEqual(setMock.mock.calls[0].arguments, [ + ["scripts", "build"], + "rnx-kit-scripts build", + ]); + }); + + it("forwards unset() to the workspace for undefined values", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + ctx.enforce(["devDependencies", "react"], undefined); + const unsetMock = w.unset as unknown as ReturnType; + equal(unsetMock.mock.callCount(), 1); + deepEqual(unsetMock.mock.calls[0].arguments, [ + ["devDependencies", "react"], + ]); + }); + + it("forwards error() to the workspace", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + ctx.error("oops"); + const errorMock = w.error as unknown as ReturnType; + equal(errorMock.mock.callCount(), 1); + deepEqual(errorMock.mock.calls[0].arguments, ["oops"]); + }); + + it("populates context fields from workspace.cwd and manifest", () => { + const w = makeFakeWorkspace("/some/path", { + name: "x", + version: "1.0.0", + }); + const ctx = createYarnWorkspaceContext(w); + equal(ctx.name, "x"); + equal(ctx.manifest.name, "x"); + equal(path.isAbsolute(ctx.root), true); + }); + + it("changed() and finish() are no-op stubs (yarn manages state internally)", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + ctx.changed(); + deepEqual(ctx.finish(), { changes: false, errors: false }); + }); + + it("yarn-mode validator is NOT recognized by isJSONValidator (no brand)", () => { + // The yarn adapter constructs a plain object that implements the JSONValidator + // surface but does not go through createJSONValidator, so the brand symbol is + // absent. This test pins down the current behavior. + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + notEqual(isJSONValidator(ctx), true); + ok(typeof ctx.enforce === "function"); + }); + + it("rejects prototype-pollution paths before forwarding to the workspace", () => { + const w = makeFakeWorkspace("/some/path", { name: "x" }); + const ctx = createYarnWorkspaceContext(w); + throws(() => ctx.enforce("__proto__.polluted", "yes")); + throws(() => ctx.enforce(["constructor", "prototype", "x"], 1)); + const setMock = w.set as unknown as ReturnType; + const unsetMock = w.unset as unknown as ReturnType; + equal(setMock.mock.callCount(), 0); + equal(unsetMock.mock.callCount(), 0); + }); +}); diff --git a/packages/tools-packages/test/json.test.ts b/packages/tools-packages/test/json.test.ts new file mode 100644 index 0000000000..16e3d80f90 --- /dev/null +++ b/packages/tools-packages/test/json.test.ts @@ -0,0 +1,434 @@ +import { deepEqual, equal, ok, throws } from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, describe, it } from "node:test"; +import { + compareValues, + createJSONValidator, + getJSONPathSegments, + isJSONValidator, + setDefaultValidationOptions, +} from "../src/json.ts"; +import type { JSONValue } from "../src/types.ts"; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "tools-pkgs-json-")); +} + +describe("compareValues", () => { + it("compares primitives by value", () => { + equal(compareValues(1, 1), true); + equal(compareValues("a", "a"), true); + equal(compareValues(null, null), true); + equal(compareValues(true, true), true); + equal(compareValues(true, false), false); + equal(compareValues(1, "1"), false); + equal(compareValues(null, undefined), false); + }); + + it("returns false when mixing object and primitive", () => { + equal(compareValues({}, null), false); + equal(compareValues([], null), false); + equal(compareValues({}, []), false); + equal(compareValues({ a: 1 }, "a"), false); + }); + + it("compares arrays elementwise", () => { + equal(compareValues([1, 2, 3], [1, 2, 3]), true); + equal(compareValues([], []), true); + equal(compareValues([1, 2], [1, 2, 3]), false); + equal(compareValues([1, 2, 3], [3, 2, 1]), false); + equal(compareValues([{ a: 1 }], [{ a: 1 }]), true); + }); + + it("compares objects with key order significance", () => { + equal(compareValues({ a: 1, b: 2 }, { a: 1, b: 2 }), true); + equal(compareValues({ a: 1, b: 2 }, { b: 2, a: 1 }), false); + equal(compareValues({ a: 1 }, { a: 1, b: 2 }), false); + equal(compareValues({ a: 1, b: 2 }, { a: 1 }), false); + }); + + it("recurses into nested structures", () => { + equal(compareValues({ a: [1, { b: 2 }] }, { a: [1, { b: 2 }] }), true); + equal(compareValues({ a: [1, { b: 2 }] }, { a: [1, { b: 3 }] }), false); + equal(compareValues({ a: { b: { c: 1 } } }, { a: { b: { c: 1 } } }), true); + }); + + it("treats identical references as equal", () => { + const obj = { a: 1 }; + equal(compareValues(obj, obj), true); + }); +}); + +describe("createJSONValidator: enforce semantics", () => { + it("setting an existing matching value is a no-op", () => { + const json: Record = { name: "foo" }; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce("name", "foo"); + deepEqual(v.finish(), { changes: false, errors: false }); + deepEqual(errors, []); + }); + + it("non-fix mode reports error and does not mutate", () => { + const json: Record = { name: "foo" }; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce("name", "bar"); + deepEqual(json, { name: "foo" }); + const result = v.finish(); + equal(result.errors, true); + equal(result.changes, false); + equal(errors.length, 1); + }); + + it("fix mode mutates and reports changes", () => { + const json: Record = { name: "foo" }; + const v = createJSONValidator(json, { fix: true }); + v.enforce("name", "bar"); + equal(json.name, "bar"); + deepEqual(v.finish(), { changes: true, errors: false }); + }); + + it("splits dotted string paths into segments", () => { + const json: Record = { dependencies: { react: "18" } }; + const v = createJSONValidator(json, { fix: true }); + v.enforce("dependencies.react", "19"); + equal((json.dependencies as Record).react, "19"); + }); + + it("array path lets keys contain dots", () => { + const json: Record = {}; + const v = createJSONValidator(json, { fix: true }); + v.enforce(["a.b", "c"], 1); + deepEqual(json, { "a.b": { c: 1 } }); + }); + + it("creates intermediate objects in fix mode", () => { + const json: Record = {}; + const v = createJSONValidator(json, { fix: true }); + v.enforce(["a", "b", "c"], 1); + deepEqual(json, { a: { b: { c: 1 } } }); + deepEqual(v.finish(), { changes: true, errors: false }); + }); + + it("reports error and does NOT create intermediates in non-fix mode", () => { + const json: Record = {}; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce(["a", "b", "c"], 1); + deepEqual(json, {}); + equal(errors.length, 1); + deepEqual(v.finish(), { changes: false, errors: true }); + }); + + it("undefined value removes property in fix mode", () => { + const json: Record = { a: { b: 1, c: 2 } }; + const v = createJSONValidator(json, { fix: true }); + v.enforce(["a", "b"], undefined); + deepEqual(json, { a: { c: 2 } }); + deepEqual(v.finish(), { changes: true, errors: false }); + }); + + it("undefined value reports error in non-fix mode", () => { + const json: Record = { a: { b: 1 } }; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce(["a", "b"], undefined); + deepEqual(json, { a: { b: 1 } }); + equal(errors.length, 1); + deepEqual(v.finish(), { changes: false, errors: true }); + }); + + it("undefined for missing path is silent no-op", () => { + const json: Record = {}; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce(["a", "b"], undefined); + deepEqual(errors, []); + deepEqual(v.finish(), { changes: false, errors: false }); + }); + + it("multiple enforce calls accumulate flags", () => { + const json: Record = { a: 1, b: 2 }; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + }); + v.enforce("a", 1); // no-op + v.enforce("b", 99); // mismatch -> error + v.enforce("c", 3); // missing -> error + deepEqual(v.finish(), { changes: false, errors: true }); + equal(errors.length, 2); + }); + + it("throws on prototype-pollution paths and does not mutate", () => { + const json: Record = {}; + const v = createJSONValidator(json, { fix: true }); + throws(() => v.enforce("__proto__.polluted", "yes")); + throws(() => v.enforce(["constructor", "prototype", "x"], 1)); + throws(() => v.enforce(["a", "__proto__"], 1)); + throws(() => v.enforce("__proto__", undefined)); + deepEqual(json, {}); + deepEqual(v.finish(), { changes: false, errors: false }); + }); +}); + +describe("getJSONPathSegments", () => { + it("returns array paths as-is", () => { + deepEqual(getJSONPathSegments(["a", "b", "c"]), ["a", "b", "c"]); + }); + + it("preserves literal dots in array segments", () => { + deepEqual(getJSONPathSegments(["exports", ".", "import"]), [ + "exports", + ".", + "import", + ]); + }); + + it("splits dotted strings into segments", () => { + deepEqual(getJSONPathSegments("a.b.c"), ["a", "b", "c"]); + deepEqual(getJSONPathSegments("name"), ["name"]); + }); + + it("blocks __proto__ in either form", () => { + throws(() => getJSONPathSegments("a.__proto__.b")); + throws(() => getJSONPathSegments(["a", "__proto__", "b"])); + throws(() => getJSONPathSegments("__proto__")); + }); + + it("blocks constructor", () => { + throws(() => getJSONPathSegments("a.constructor.prototype")); + throws(() => getJSONPathSegments(["constructor"])); + }); + + it("blocks prototype", () => { + throws(() => getJSONPathSegments("a.prototype")); + throws(() => getJSONPathSegments(["prototype"])); + }); + + it("does not mutate the supplied array on success", () => { + const input = ["a", "b"]; + const out = getJSONPathSegments(input); + deepEqual(input, ["a", "b"]); + deepEqual(out, ["a", "b"]); + }); + + it("error message names the offending segment", () => { + try { + getJSONPathSegments("dependencies.__proto__.foo"); + ok(false, "expected throw"); + } catch (e) { + ok(e instanceof Error); + ok( + e.message.includes("__proto__"), + `expected message to include '__proto__', got: ${e.message}` + ); + } + }); +}); + +describe("createJSONValidator: reporter and result accessors", () => { + it("reportPrefix is prepended to error messages", () => { + const json: Record = { a: 1 }; + const errors: string[] = []; + const v = createJSONValidator(json, { + reportError: (m) => errors.push(m), + reportPrefix: "[pkg-foo] ", + }); + v.enforce("a", 2); + ok(errors[0].startsWith("[pkg-foo] ")); + }); + + it("error() flips errors flag in result", () => { + const errors: string[] = []; + const v = createJSONValidator({}, { reportError: (m) => errors.push(m) }); + v.error("custom error"); + deepEqual(v.finish(), { changes: false, errors: true }); + deepEqual(errors, ["custom error"]); + }); + + it("changed() flips changes flag in result", () => { + const v = createJSONValidator({}, { fix: true }); + v.changed(); + deepEqual(v.finish(), { changes: true, errors: false }); + }); +}); + +describe("createJSONValidator: file writing", () => { + it("writes JSON file in fix mode when jsonFilePath is provided", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "out.json"); + try { + const json: Record = { a: 1 }; + const v = createJSONValidator(json, { + fix: true, + jsonFilePath: filePath, + }); + v.enforce("a", 2); + v.finish(); + const written = JSON.parse(fs.readFileSync(filePath, "utf-8")); + deepEqual(written, { a: 2 }); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("does not write file when no changes occurred", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "out.json"); + try { + const json: Record = { a: 1 }; + const v = createJSONValidator(json, { + fix: true, + jsonFilePath: filePath, + }); + v.enforce("a", 1); + v.finish(); + equal(fs.existsSync(filePath), false); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("does not write file when fix is false", () => { + const dir = makeTempDir(); + const filePath = path.join(dir, "out.json"); + try { + const json: Record = { a: 1 }; + const v = createJSONValidator(json, { + fix: false, + jsonFilePath: filePath, + reportError: () => undefined, + }); + v.enforce("a", 2); + v.finish(); + equal(fs.existsSync(filePath), false); + } finally { + fs.rmSync(dir, { recursive: true }); + } + }); + + it("does not write file when fix is true but no jsonFilePath provided", () => { + const json: Record = { a: 1 }; + const v = createJSONValidator(json, { fix: true }); + v.enforce("a", 2); + const result = v.finish(); + equal(result.changes, true); + equal(json.a, 2); + }); +}); + +describe("createJSONValidator: baseObj mixing", () => { + it("mixes validator methods onto the provided base object", () => { + type Base = { name: string; root: string }; + const base: Base = { name: "foo", root: "/tmp" }; + const json: Record = { a: 1 }; + const v = createJSONValidator(json, { fix: true }, base); + equal(v.name, "foo"); + equal(v.root, "/tmp"); + equal(typeof v.enforce, "function"); + }); + + it("returned object is the same instance as the provided base", () => { + const base = { name: "x", root: "/" }; + const v = createJSONValidator({}, {}, base); + equal(v as object, base as object); + }); +}); + +describe("isJSONValidator", () => { + it("returns true for created validators", () => { + const v = createJSONValidator({}, { reportError: () => undefined }); + equal(isJSONValidator(v), true); + }); + + it("returns false for plain objects with the same shape", () => { + const fake = { + enforce: () => undefined, + error: () => undefined, + changed: () => undefined, + finish: () => ({ changes: false, errors: false }), + }; + equal(isJSONValidator(fake), false); + }); + + it("returns false for non-objects", () => { + equal(isJSONValidator(undefined), false); + equal(isJSONValidator(null), false); + equal(isJSONValidator(42), false); + equal(isJSONValidator("x"), false); + equal(isJSONValidator([]), false); + }); + + it("recognizes a validator mixed onto a base object", () => { + const base = { name: "foo" }; + const v = createJSONValidator({}, { reportError: () => undefined }, base); + equal(isJSONValidator(v), true); + equal(isJSONValidator(base), true); + }); +}); + +describe("setDefaultValidationOptions", () => { + afterEach(() => { + setDefaultValidationOptions({ + fix: false, + reportError: console.error, + reportPrefix: undefined, + }); + }); + + it("default fix flag is applied when options omit fix", () => { + setDefaultValidationOptions({ fix: true }); + const json: Record = { a: 1 }; + const v = createJSONValidator(json); + v.enforce("a", 2); + equal(json.a, 2); + deepEqual(v.finish(), { changes: true, errors: false }); + }); + + it("explicit option overrides default", () => { + setDefaultValidationOptions({ fix: true }); + const json: Record = { a: 1 }; + const errors: string[] = []; + const v = createJSONValidator(json, { + fix: false, + reportError: (m) => errors.push(m), + }); + v.enforce("a", 2); + equal(json.a, 1); + equal(errors.length, 1); + }); + + it("default reportError is applied when omitted", () => { + const captured: string[] = []; + setDefaultValidationOptions({ reportError: (m) => captured.push(m) }); + const json: Record = { a: 1 }; + const v = createJSONValidator(json); + v.enforce("a", 2); + equal(captured.length, 1); + }); + + it("default reportPrefix is applied when omitted", () => { + const captured: string[] = []; + setDefaultValidationOptions({ + reportError: (m) => captured.push(m), + reportPrefix: "[default] ", + }); + const v = createJSONValidator({ a: 1 }); + v.enforce("a", 2); + ok(captured[0].startsWith("[default] ")); + }); +}); diff --git a/yarn.lock b/yarn.lock index c471dd44a8..e76c5bec26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6130,11 +6130,18 @@ __metadata: resolution: "@rnx-kit/tools-packages@workspace:packages/tools-packages" dependencies: "@rnx-kit/scripts": "npm:*" + "@rnx-kit/tools-filesystem": "npm:^0.2.0" "@rnx-kit/tools-node": "npm:^3.0.4" "@rnx-kit/tools-workspaces": "npm:^0.2.1" "@rnx-kit/tsconfig": "npm:*" "@rnx-kit/types-node": "npm:^1.0.0" "@types/node": "npm:^24.0.0" + "@yarnpkg/types": "npm:^4.0.0" + peerDependencies: + "@yarnpkg/types": ">=4.0.0" + peerDependenciesMeta: + "@yarnpkg/types": + optional: true languageName: unknown linkType: soft