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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/witty-cars-relate.md
Original file line number Diff line number Diff line change
@@ -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
159 changes: 129 additions & 30 deletions packages/tools-packages/README.md

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion packages/tools-packages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,24 @@
"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"
},
"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"
Expand Down
64 changes: 51 additions & 13 deletions packages/tools-packages/src/accessors.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,34 +16,67 @@ import type { GetPackageValue, PackageInfo } from "./types.ts";
*/
export function createPackageValueLoader<T>(
friendlyName: string,
initialize: (pkgInfo: PackageInfo) => T
initialize: (pkgInfo: PackageContext) => T
): GetPackageValue<T> {
return createValueLoader<PackageContext, T>(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<TObj extends object, T>(
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<T>(friendlyName: string) {
export function createPackageValueAccessors<T>(
friendlyName: string
): PackageValueAccessors<T> {
return createObjectValueAccessors<PackageContext, T>(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<TObj extends object, TVal>(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use Record<symbol, T> instead of object here to avoid casting? I've managed to reduce it to 1 casting with this:

   friendlyName: string,
   initialize: (pkgInfo: PackageInfo) => T
 ): GetPackageValue<T> {
+  return createValueLoader<T, PackageInfo<T>>(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<T, TObj extends Record<symbol, T>>(
+  friendlyName: string,
+  initialize: (obj: TObj) => T
+): (obj: TObj) => T {
   const symbolKey = Symbol(friendlyName);
-  return (pkgInfo: PackageInfo) => {
-    if (!(symbolKey in pkgInfo)) {
-      pkgInfo[symbolKey] = initialize(pkgInfo);
+  return (obj: TObj) => {
+    if (!Object.hasOwn(obj, symbolKey)) {
+      (obj as Record<symbol, T>)[symbolKey] = initialize(obj);
     }
-    return pkgInfo[symbolKey] as T;
+    return obj[symbolKey];
   };
 }

And the following changes in types.ts:

diff --git a/packages/tools-packages/src/types.ts b/packages/tools-packages/src/types.ts
index cbcdb16cf..eb67240fd 100644
--- a/packages/tools-packages/src/types.ts
+++ b/packages/tools-packages/src/types.ts
@@ -1,6 +1,6 @@
 import type { PackageManifest } from "@rnx-kit/types-node";

-export type PackageInfo = {
+export type PackageInfo<T = unknown> = {
   /** name of the package */
   name: string;

@@ -18,13 +18,13 @@ export type PackageInfo = {
    * other packages to leverage any caching happening for PackageInfo entries to store additional
    * information and ensure it is only loaded once.
    */
-  [key: symbol]: unknown;
+  [key: symbol]: T;
 };

 /**
  * Typed accessors for retrieving values from the package info
  */
-export type GetPackageValue<T> = (pkgInfo: PackageInfo) => T;
+export type GetPackageValue<T> = (pkgInfo: PackageInfo<T>) => T;

 /**
  * Set of accessor functions that can be retrieved for a specific symbol

The final cast can't be removed because of TS2862 Type 'T' is generic and can only be indexed for reading. Which I guess is a limitation in TypeScript itself.

friendlyName: string
): ObjectValueAccessors<TObj, TVal> {
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;
},
};
}
126 changes: 126 additions & 0 deletions packages/tools-packages/src/context.ts
Original file line number Diff line number Diff line change
@@ -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<TManifest> {
root = path.resolve(root);
const manifest =
loadedManifest ??
readJSONFileSync<TManifest>(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<TManifest> {
const context = createPackageContext<TManifest>(base, manifest);
const jsonFilePath = path.join(context.root, "package.json");

return createJSONValidator(
context.manifest as Record<string, JSONValue>,
{ ...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<TManifest>): PackageValidationContext<TManifest> {
if (isJSONValidator(context)) {
return context;
}
const jsonFilePath = path.join(context.root, "package.json");
return createJSONValidator(
context.manifest as Record<string, JSONValue>,
{ 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<TManifest> {
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
}
27 changes: 27 additions & 0 deletions packages/tools-packages/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading