From fee81453c5e32a314fe2ab40fd7d31d74826ad3a Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 15:33:58 -0800 Subject: [PATCH 01/12] Refuse a second copy of context.core, naming both folders The first copy records its folder under a global symbol before it adds its extensions. A second copy throws an Error naming both folders, and a copy loaded beside an older one that records nothing names itself and says the other is older, rather than failing on "Cannot redefine property: isFunction". Tests load a second copy, an older one, and two copies npm installs side by side from tarballs. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- src/noldova.context.core/README.md | 9 ++ .../src/extensions/object.extensions.ts | 2 + .../src/intrinsics/core-copy.ts | 31 ++++ src/noldova.context.core/src/resources.ts | 7 + .../tests/intrinsics/core-copy.test.ts | 136 ++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 src/noldova.context.core/src/intrinsics/core-copy.ts create mode 100644 src/noldova.context.core/tests/intrinsics/core-copy.test.ts diff --git a/src/noldova.context.core/README.md b/src/noldova.context.core/README.md index 5baa2b1b..f202d313 100644 --- a/src/noldova.context.core/README.md +++ b/src/noldova.context.core/README.md @@ -36,6 +36,15 @@ while `Object.isPlainObject` accepts only object literals, such as the records declaration file `api/index.d.ts` documents every member with its contract and examples. +## One copy + +An app loads one copy of the package, since the additions can be installed only +once. The first copy records its folder; a second copy, such as one npm nests +under a package that pins another version, refuses to load with an `Error` that +names both folders, and `npm ls @noldova/context.core` shows which packages +bring each. When a copy from before this check loaded first, which records no +folder, the error names this copy's folder and says the other is older. + ## License MIT; see `LICENSE`. diff --git a/src/noldova.context.core/src/extensions/object.extensions.ts b/src/noldova.context.core/src/extensions/object.extensions.ts index 5a902e43..af89734f 100644 --- a/src/noldova.context.core/src/extensions/object.extensions.ts +++ b/src/noldova.context.core/src/extensions/object.extensions.ts @@ -6,6 +6,7 @@ * LICENSE file in the root directory of this source tree. */ +import { CoreCopy } from "../intrinsics/core-copy.js"; import { nameof } from "../intrinsics/nameof.js"; import { Resources } from "../resources.js"; @@ -80,6 +81,7 @@ function isUndefined(value: unknown): value is undefined { return value === undefined; } +CoreCopy.claim(); Object.defineProperties(Object, { isFunction: { value: isFunction, writable: false, enumerable: false, configurable: false }, isString: { value: isString, writable: false, enumerable: false, configurable: false }, diff --git a/src/noldova.context.core/src/intrinsics/core-copy.ts b/src/noldova.context.core/src/intrinsics/core-copy.ts new file mode 100644 index 00000000..b99b9d73 --- /dev/null +++ b/src/noldova.context.core/src/intrinsics/core-copy.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright (c) Noldova. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Resources } from "../resources.js"; + +// The one copy of this package an app may load. The first copy records its folder on the global object before it adds its extensions; a second +// copy, which could not add them again, refuses to load with an error naming both folders. +export class CoreCopy { + private static readonly KEY: symbol = Symbol.for(Resources.packageName); + private static readonly FOLDER: string = new URL(Resources.packageFolderFromIntrinsics, import.meta.url).href; + + /** + * Records this copy as the app's one copy of the package, or throws an Error if another copy is already loaded. It runs before the extensions + * exist, so it reads none of them and calls no nameof. + */ + public static claim(): void { + const first: unknown = Object.getOwnPropertyDescriptor(globalThis, CoreCopy.KEY)?.value; + if (first !== undefined) + throw new Error(Resources.secondCopyLoaded.replace(Resources.firstFolderPlaceholder, String(first)).replace(Resources.secondFolderPlaceholder, CoreCopy.FOLDER)); + + if (Object.getOwnPropertyDescriptor(Object, Resources.isFunctionName) !== undefined) + throw new Error(Resources.olderCopyLoaded.replace(Resources.secondFolderPlaceholder, CoreCopy.FOLDER)); + + Object.defineProperty(globalThis, CoreCopy.KEY, { value: CoreCopy.FOLDER, writable: false, enumerable: false, configurable: false }); + } +} diff --git a/src/noldova.context.core/src/resources.ts b/src/noldova.context.core/src/resources.ts index df9c7372..adecc54b 100644 --- a/src/noldova.context.core/src/resources.ts +++ b/src/noldova.context.core/src/resources.ts @@ -16,9 +16,16 @@ export class Resources { public static readonly asciiUppercaseF: string = "F"; public static readonly asciiUppercaseZ: string = "Z"; public static readonly decimalRadix: number = 10; + public static readonly firstFolderPlaceholder: string = "{0}"; public static readonly formatClosingBrace: string = "}"; public static readonly formatOpeningBrace: string = "{"; + public static readonly isFunctionName: string = "isFunction"; public static readonly nameofArgumentInvalid: string = "The value must be a string member name or a selector that returns exactly one selected string member."; + public static readonly olderCopyLoaded: string = "A copy of @noldova/context.core older than this check is already loaded, so this copy, from {1}, cannot add its extensions beside it. An app loads one copy; npm ls @noldova/context.core names the packages that bring each."; + public static readonly packageFolderFromIntrinsics: string = "../"; + public static readonly packageName: string = "@noldova/context.core"; + public static readonly secondCopyLoaded: string = "Two copies of @noldova/context.core are loaded, from {0} and {1}. An app loads one copy; npm ls @noldova/context.core names the packages that bring each."; + public static readonly secondFolderPlaceholder: string = "{1}"; public static readonly typeofBigInt: string = "bigint"; public static readonly typeofBoolean: string = "boolean"; public static readonly typeofFunction: string = "function"; diff --git a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts new file mode 100644 index 00000000..6686437e --- /dev/null +++ b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright (c) Noldova. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +// This file uses Node APIs and will change when we replace Node. + +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; + +import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; + +@TestClass +export class CoreCopyTests { + private static readonly PACKAGE_NAME: string = "@noldova/context.core"; + private static readonly PACKAGE_FOLDER: URL = new URL("../", import.meta.resolve("@noldova/context.core")); + + @TestMethod + public recordsTheFolderOfTheCopyItLoaded(): void { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, Symbol.for(CoreCopyTests.PACKAGE_NAME)); + + Assert.isDefined(descriptor); + Assert.areEqual(CoreCopyTests.PACKAGE_FOLDER.href, descriptor.value); + Assert.isFalse(descriptor.writable ?? true); + Assert.isFalse(descriptor.configurable ?? true); + } + + @TestMethod + public refusesASecondCopyNamingBothFoldersAsync(): Promise { + return CoreCopyTests.withDirectoryAsync(async directory => { + const copy = join(directory, "copy"); + await cp(fileURLToPath(CoreCopyTests.PACKAGE_FOLDER), copy, { recursive: true }); + const scriptPath = join(directory, "load.js"); + await writeFile(scriptPath, CoreCopyTests.loadingScript( + `await import(${JSON.stringify(pathToFileURL(join(copy, "api", "index.js")).href)});`, + `await import("${CoreCopyTests.PACKAGE_NAME}");`)); + + const output = await CoreCopyTests.runAsync(scriptPath, directory); + + Assert.areEqual( + `Two copies of @noldova/context.core are loaded, from ${pathToFileURL(copy).href}/ and ${CoreCopyTests.PACKAGE_FOLDER.href}. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, + output); + }); + } + + @TestMethod + public refusesToLoadBesideAnOlderCopyAsync(): Promise { + return CoreCopyTests.withDirectoryAsync(async directory => { + const scriptPath = join(directory, "load.js"); + await writeFile(scriptPath, CoreCopyTests.loadingScript( + "Object.defineProperty(Object, \"isFunction\", { value: () => true, writable: false, enumerable: false, configurable: false });", + `await import("${CoreCopyTests.PACKAGE_NAME}");`)); + + const output = await CoreCopyTests.runAsync(scriptPath, directory); + + Assert.areEqual( + `A copy of @noldova/context.core older than this check is already loaded, so this copy, from ${CoreCopyTests.PACKAGE_FOLDER.href}, cannot add its extensions beside it. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, + output); + }); + } + + @TestMethod + public refusesTwoCopiesNpmInstallsSideBySideAsync(): Promise { + return CoreCopyTests.withDirectoryAsync(async directory => { + const npmPath = process.env["npm_execpath"]; + Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); + + const dependent = join(directory, "dependent"); + await cp(fileURLToPath(CoreCopyTests.PACKAGE_FOLDER), join(dependent, "node_modules", "@noldova", "context.core"), { recursive: true }); + await writeFile(join(dependent, "package.json"), JSON.stringify({ + name: "dependent", + version: "1.0.0", + type: "module", + main: "index.js", + dependencies: { [CoreCopyTests.PACKAGE_NAME]: "*" }, + bundleDependencies: [CoreCopyTests.PACKAGE_NAME] + })); + await writeFile(join(dependent, "index.js"), `import "${CoreCopyTests.PACKAGE_NAME}";\n`); + await CoreCopyTests.runNpmAsync(npmPath, ["pack", "--silent", "--pack-destination", directory], dependent); + const coreArchive = (await CoreCopyTests.runNpmAsync(npmPath, ["pack", "--silent", "--pack-destination", directory, fileURLToPath(CoreCopyTests.PACKAGE_FOLDER)], directory)).trim(); + + const app = join(directory, "app"); + await mkdir(app); + await writeFile(join(app, "package.json"), JSON.stringify({ + name: "app", + private: true, + type: "module", + dependencies: { [CoreCopyTests.PACKAGE_NAME]: `file:../${coreArchive}`, dependent: "file:../dependent-1.0.0.tgz" } + })); + await CoreCopyTests.runNpmAsync(npmPath, ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app); + const scriptPath = join(app, "main.js"); + await writeFile(scriptPath, CoreCopyTests.loadingScript(`await import("${CoreCopyTests.PACKAGE_NAME}");`, "await import(\"dependent\");")); + + const output = await CoreCopyTests.runAsync(scriptPath, app); + + const installed = join(app, "node_modules", "@noldova", "context.core"); + const nested = join(app, "node_modules", "dependent", "node_modules", "@noldova", "context.core"); + Assert.areEqual( + `Two copies of @noldova/context.core are loaded, from ${pathToFileURL(installed).href}/ and ${pathToFileURL(nested).href}/. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, + output); + }); + } + + private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { + const directory = await mkdtemp(resolve(import.meta.dirname, "../../../core-copies-")); + try { + await action(directory); + } + finally { + await rm(directory, { recursive: true, force: true }); + } + } + + /** A module that runs the first step, then loads the second, and writes the message of the error it ends with, or nothing. */ + private static loadingScript(first: string, second: string): string { + return `try {\n ${first}\n ${second}\n}\ncatch (error) {\n process.stdout.write(error.message);\n}\n`; + } + + private static async runAsync(scriptPath: string, directory: string): Promise { + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: directory, windowsHide: true }); + + return stdout; + } + + private static async runNpmAsync(npmPath: string, values: readonly string[], directory: string): Promise { + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, windowsHide: true }); + + return stdout; + } +} From 84dac160daef02ce5a0354938f3edf28ceb3a4ad Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 15:41:56 -0800 Subject: [PATCH 02/12] Take sibling packages as peers in a range Each package takes the CONTEXT packages it uses as peer dependencies from the current version to the next minor, such as >=0.0.9 <0.1.0, instead of exact dependencies, so an app whose packages need different versions in that range installs one copy of each. The build fills __NEXT_MINOR__ from version.json and orders packages by their peers. The versioning rule and the package contract say so. Tests install exceptions beside a core at another version in its range, which shares one copy, and past it, which npm refuses. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- documents/PACKAGE-CONTRACT.md | 2 +- documents/VERSIONING-AND-COMPATIBILITY.md | 6 +- scripts/build.ts | 7 +- scripts/config.ts | 15 +++ .../package.json | 10 +- .../package.json | 10 +- .../noldova.context.data.sql/package.json | 8 +- src/data/noldova.context.data/package.json | 6 +- src/noldova.context.diagnostics/package.json | 6 +- src/noldova.context.exceptions/package.json | 4 +- .../tests/api/index.test.ts | 95 ++++++++++++++++++- src/noldova.context.json/package.json | 6 +- src/noldova.context.testing/package.json | 10 +- src/noldova.context.text/package.json | 8 +- .../package.json | 12 +-- .../package.json | 12 +-- .../noldova.context.parsers/package.json | 12 +-- 17 files changed, 170 insertions(+), 59 deletions(-) diff --git a/documents/PACKAGE-CONTRACT.md b/documents/PACKAGE-CONTRACT.md index 0f475b65..29109dd4 100644 --- a/documents/PACKAGE-CONTRACT.md +++ b/documents/PACKAGE-CONTRACT.md @@ -63,7 +63,7 @@ A package project's manifest and sources live in the package root's `src/`; its The dependency graph is derived from project manifests and their effective framework compositions, never duplicated in a root script, configuration array, or hand-maintained order list. The temporary Node bootstrap is the sole exception: its explicit ordered configuration builds npm forms before the SDK build provider exists and is not a semantic graph, resolver, or certification input. -Package-local `package.json` files are temporary bootstrap manifests containing only `name`, `version`, `type`, `main`, `types`, and `license`, plus `dependencies` for packages actually imported during the bootstrap, and `repository`, `bugs`, `homepage` and `publishConfig`, which npm's provenance and the registry need. +Package-local `package.json` files are temporary bootstrap manifests containing only `name`, `version`, `type`, `main`, `types`, and `license`, plus `peerDependencies` for the CONTEXT packages actually imported during the bootstrap, in the range [VERSIONING-AND-COMPATIBILITY.md](VERSIONING-AND-COMPATIBILITY.md) sets, and `repository`, `bugs`, `homepage` and `publishConfig`, which npm's provenance and the registry need. Other npm metadata requires a demonstrated bootstrap requirement. These manifests are removed with the bootstrap. diff --git a/documents/VERSIONING-AND-COMPATIBILITY.md b/documents/VERSIONING-AND-COMPATIBILITY.md index b2d70c10..f1c07ee5 100644 --- a/documents/VERSIONING-AND-COMPATIBILITY.md +++ b/documents/VERSIONING-AND-COMPATIBILITY.md @@ -16,10 +16,12 @@ Age, feature count, milestones, distribution to another machine, or use by anoth Only the last number goes up, and only when the packages are published: a publish run is started by hand, when a maintainer decides, and it publishes every package at the next number, as the README's [Releases](../README.md#releases) says. Major and minor stay `0` until the first public release, and the internal version stays `1`. -The one number lives in `version.json` at the repository's root, such as `{ "version": "0.0.4" }`, which `scripts/config.ts` reads; the bootstrap `package.json` files write `__VERSION__` for their own version and for CONTEXT-owned dependencies, and the build fills it in and records the commit it built from as each package's `gitHead`. +The one number lives in `version.json` at the repository's root, such as `{ "version": "0.0.4" }`, which `scripts/config.ts` reads; the bootstrap `package.json` files write `__VERSION__` for their own version and as the lower bound of each CONTEXT-owned peer range, and `__NEXT_MINOR__`, the next minor version, as its upper bound; the build fills both in and records the commit it built from as each package's `gitHead`. Until CONTEXT compiles itself, the `.ctxp` project manifests keep `0.0.1`, since nothing reads their version yet. The repository's root `package.json` and its lockfile have no version, since they are never published. -CONTEXT-owned dependencies name the current number exactly: no caret, tilde, wildcard, range, floating tag, prerelease counter, date version, commit suffix, or independently incremented package version. +A package takes the CONTEXT packages it uses as peer dependencies in the range from the current number to the next minor version, such as `>=0.0.9 <0.1.0`, never as dependencies, so an app whose packages need different versions in that range installs one copy of each; npm refuses an install whose ranges no single version meets, rather than nesting a second copy. +`@noldova/context.core` refuses to load beside another copy of itself, naming both copies' folders. +No other caret, tilde, wildcard, floating tag, prerelease counter, date version, commit suffix, or independently incremented package version names a CONTEXT package. The packages are published to npm under `@noldova`. The internal version is the integer `1`, not `1.0`, `v1`, `0.0.1`, or a counter per revision. diff --git a/scripts/build.ts b/scripts/build.ts index cf5e40fe..e2c66b93 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -22,7 +22,6 @@ import Script from "./script.ts"; export class Build extends Script { private static readonly BUILD_SCRIPT_PATH: string = "scripts/build.ts"; - private static readonly BUILD_DEPENDENCIES_FIELD: "dependencies" = "dependencies"; private static readonly CLEAN_INSTALL_ARGUMENTS: readonly string[] = ["ci", "--ignore-scripts"]; private static readonly GIT_HEAD_ARGUMENTS: readonly string[] = ["rev-parse", "--verify", "HEAD^{commit}"]; private static readonly GIT_HEAD_FIELD: string = "gitHead"; @@ -35,9 +34,11 @@ export class Build extends Script { private static readonly LIST_COPIES_SEPARATOR: string = "\t"; private static readonly MANIFEST_INDENTATION: number = 2; private static readonly NODE_MODULES_DIRECTORY_NAME: string = "node_modules"; + private static readonly NEXT_MINOR_PLACEHOLDER: string = "__NEXT_MINOR__"; private static readonly NO_PACKAGES: string = "No packages to build."; private static readonly NOTICES_FILE_NAME: string = "THIRD-PARTY-NOTICES.txt"; private static readonly PACKAGE_MANIFEST_FILE_NAME: string = "package.json"; + private static readonly PEER_DEPENDENCIES_FIELD: "peerDependencies" = "peerDependencies"; private static readonly VERSION_PLACEHOLDER: string = "__VERSION__"; public override async runAsync(): Promise { @@ -79,7 +80,7 @@ export class Build extends Script { return Config.PACKAGES_INFO.map(t => t.packageName); const manifest: unknown = JSON.parse(await this.readFileAsync(`${Build.SOURCE_DIRECTORY_NAME}/${packageInfo.path}/${Build.PACKAGE_MANIFEST_FILE_NAME}`)); - const dependencies: unknown = typeof manifest === "object" && manifest !== null && Build.BUILD_DEPENDENCIES_FIELD in manifest ? manifest[Build.BUILD_DEPENDENCIES_FIELD] : undefined; + const dependencies: unknown = typeof manifest === "object" && manifest !== null && Build.PEER_DEPENDENCIES_FIELD in manifest ? manifest[Build.PEER_DEPENDENCIES_FIELD] : undefined; return typeof dependencies === "object" && dependencies !== null ? Object.keys(dependencies) : []; } @@ -145,7 +146,7 @@ export class Build extends Script { throw new CommandFailedException(Build.packageManifestMissing(packageFilePath)); const content = await this.readFileAsync(packageFilePath); - const parsed: unknown = JSON.parse(content.replaceAll(Build.VERSION_PLACEHOLDER, Config.VERSION)); + const parsed: unknown = JSON.parse(content.replaceAll(Build.VERSION_PLACEHOLDER, Config.VERSION).replaceAll(Build.NEXT_MINOR_PLACEHOLDER, Config.NEXT_MINOR)); const manifest = { ...(typeof parsed === "object" ? parsed : null), [Build.GIT_HEAD_FIELD]: commit }; await this.writeFileAsync(`${Config.BUILD_FOLDER}/${packageInfo.name}/${Build.PACKAGE_MANIFEST_FILE_NAME}`, `${JSON.stringify(manifest, null, Build.MANIFEST_INDENTATION)}\n`); } diff --git a/scripts/config.ts b/scripts/config.ts index 58e126f8..a82aa38b 100644 --- a/scripts/config.ts +++ b/scripts/config.ts @@ -16,8 +16,11 @@ export default class Config { private static readonly VERSION_FIELD: "version" = "version"; private static readonly VERSION_FILE_ENCODING: BufferEncoding = "utf8"; private static readonly VERSION_FILE_PATH: string = fileURLToPath(new URL("../version.json", import.meta.url)); + private static readonly VERSION_PATTERN: RegExp = /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u; public static readonly VERSION: string = Config.readVersion(); + /** The first version a sibling's peer range leaves out: the next minor version, since every package moves to each version together. */ + public static readonly NEXT_MINOR: string = Config.nextMinor(Config.VERSION); public static readonly BUILD_FOLDER: string = "_build/node"; public static readonly COVERAGE_FOLDER: string = "_build/coverage"; public static readonly PACKAGES_FOLDER: string = "_packages"; @@ -48,6 +51,18 @@ export default class Config { return version; } + private static nextMinor(version: string): string { + const groups = Config.VERSION_PATTERN.exec(version)?.groups; + if (groups?.["major"] === undefined || groups["minor"] === undefined) + throw new CommandFailedException(Config.versionInvalid(version)); + + return `${groups["major"]}.${Number(groups["minor"]) + 1}.0`; + } + + private static versionInvalid(version: string): string { + return `${Config.VERSION_FILE_PATH} holds "${version}", not a version of three numbers such as 0.0.4.`; + } + private static versionMissing(): string { return `${Config.VERSION_FILE_PATH} must hold the version as a string in "${Config.VERSION_FIELD}".`; } diff --git a/src/data/noldova.context.data.sql.postgresql/package.json b/src/data/noldova.context.data.sql.postgresql/package.json index 52d9850e..81232a82 100644 --- a/src/data/noldova.context.data.sql.postgresql/package.json +++ b/src/data/noldova.context.data.sql.postgresql/package.json @@ -18,10 +18,10 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.data": "__VERSION__", - "@noldova/context.data.sql": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.data": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.data.sql": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/data/noldova.context.data.sql.sqlite/package.json b/src/data/noldova.context.data.sql.sqlite/package.json index 8bfedeab..117320a6 100644 --- a/src/data/noldova.context.data.sql.sqlite/package.json +++ b/src/data/noldova.context.data.sql.sqlite/package.json @@ -18,10 +18,10 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.data": "__VERSION__", - "@noldova/context.data.sql": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.data": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.data.sql": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/data/noldova.context.data.sql/package.json b/src/data/noldova.context.data.sql/package.json index c8e8e43a..c572ee9b 100644 --- a/src/data/noldova.context.data.sql/package.json +++ b/src/data/noldova.context.data.sql/package.json @@ -18,9 +18,9 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.data": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.data": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/data/noldova.context.data/package.json b/src/data/noldova.context.data/package.json index d5f8b820..d657d6cc 100644 --- a/src/data/noldova.context.data/package.json +++ b/src/data/noldova.context.data/package.json @@ -18,8 +18,8 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/noldova.context.diagnostics/package.json b/src/noldova.context.diagnostics/package.json index 7c08a4eb..b037079a 100644 --- a/src/noldova.context.diagnostics/package.json +++ b/src/noldova.context.diagnostics/package.json @@ -18,8 +18,8 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/noldova.context.exceptions/package.json b/src/noldova.context.exceptions/package.json index 2ca698ab..1e1166bc 100644 --- a/src/noldova.context.exceptions/package.json +++ b/src/noldova.context.exceptions/package.json @@ -18,7 +18,7 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/noldova.context.exceptions/tests/api/index.test.ts b/src/noldova.context.exceptions/tests/api/index.test.ts index d55094c0..839a099e 100644 --- a/src/noldova.context.exceptions/tests/api/index.test.ts +++ b/src/noldova.context.exceptions/tests/api/index.test.ts @@ -9,7 +9,8 @@ // This file uses Node APIs and will change when we replace Node. import { execFile } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -19,6 +20,10 @@ import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; @TestClass export class ExceptionsApiTests { + private static readonly INSTALL_VALUES: readonly string[] = ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"]; + private static readonly CORE_RANGE: RegExp = /"@noldova\/context\.core": ">=(\d+)\.(\d+)\.(\d+) <([^"]+)"/u; + private static readonly VERSION_FIELD: RegExp = /"version": "[^"]+"/u; + @TestMethod public exportsTheCompleteCatalog(): void { const exportNames = [ @@ -72,4 +77,92 @@ export class ExceptionsApiTests { await rm(directory, { recursive: true, force: true }); } } + + @TestMethod + public async sharesOneCoreAtAnotherVersionInItsRangeAsync(): Promise { + const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); + try { + const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionInRangeAsync()); + + await ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app); + const scriptPath = join(app, "main.js"); + await writeFile(scriptPath, "import \"@noldova/context.core\";\nimport \"@noldova/context.exceptions\";\nprocess.stdout.write(\"loaded\");\n"); + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: app, windowsHide: true }); + + Assert.areEqual("loaded", stdout); + Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions", "node_modules"))); + } + finally { + await rm(directory, { recursive: true, force: true }); + } + } + + @TestMethod + public async refusesToInstallACoreOutsideItsRangeAsync(): Promise { + const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); + try { + const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionPastRangeAsync()); + + const failure = await Assert.throwsAsync(() => ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app), Error); + + Assert.isTrue(failure.message.includes("ERESOLVE"), failure.message); + Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions"))); + } + finally { + await rm(directory, { recursive: true, force: true }); + } + } + + /** A core version inside the range the installed package takes core in, a patch above its lower bound. */ + private static async readCoreVersionInRangeAsync(): Promise { + const range = await ExceptionsApiTests.readCoreRangeAsync(); + + return `${range[1]}.${range[2]}.${Number(range[3]) + 1}`; + } + + /** The first core version past that range: its upper bound. */ + private static async readCoreVersionPastRangeAsync(): Promise { + const range = await ExceptionsApiTests.readCoreRangeAsync(); + + return String(range[4]); + } + + /** The installed package's peer range of core, from its manifest: the lower bound's three numbers, then the upper bound. */ + private static async readCoreRangeAsync(): Promise { + const manifest = await readFile(fileURLToPath(new URL("../package.json", import.meta.resolve("@noldova/context.exceptions"))), "utf8"); + const range = ExceptionsApiTests.CORE_RANGE.exec(manifest); + Assert.isNotNull(range, manifest); + + return range; + } + + /** An app that installs the package and a copy of core at the version given, each from a tarball, and returns the app's folder. */ + private static async createAppAsync(directory: string, coreVersion: string): Promise { + const core = join(directory, "core"); + await cp(fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))), core, { recursive: true }); + const coreManifest = join(core, "package.json"); + await writeFile(coreManifest, (await readFile(coreManifest, "utf8")).replace(ExceptionsApiTests.VERSION_FIELD, `"version": "${coreVersion}"`)); + const coreArchive = (await ExceptionsApiTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, core], directory)).trim(); + const exceptionsArchive = (await ExceptionsApiTests.runNpmAsync( + ["pack", "--silent", "--pack-destination", directory, fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.exceptions")))], + directory)).trim(); + + const app = join(directory, "app"); + await mkdir(app); + await writeFile(join(app, "package.json"), JSON.stringify({ + name: "app", + private: true, + type: "module", + dependencies: { "@noldova/context.core": `file:../${coreArchive}`, "@noldova/context.exceptions": `file:../${exceptionsArchive}` } + })); + return app; + } + + private static async runNpmAsync(values: readonly string[], directory: string): Promise { + const npmPath = process.env["npm_execpath"]; + Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, windowsHide: true }); + + return stdout; + } } diff --git a/src/noldova.context.json/package.json b/src/noldova.context.json/package.json index 15d73d08..3cd545f8 100644 --- a/src/noldova.context.json/package.json +++ b/src/noldova.context.json/package.json @@ -18,8 +18,8 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/noldova.context.testing/package.json b/src/noldova.context.testing/package.json index 0840281c..4af2b210 100644 --- a/src/noldova.context.testing/package.json +++ b/src/noldova.context.testing/package.json @@ -18,10 +18,10 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.collections": "__VERSION__", - "@noldova/context.core": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__", - "@noldova/context.text": "__VERSION__" + "peerDependencies": { + "@noldova/context.collections": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.text": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/noldova.context.text/package.json b/src/noldova.context.text/package.json index 5ae130e4..adc8f560 100644 --- a/src/noldova.context.text/package.json +++ b/src/noldova.context.text/package.json @@ -18,9 +18,9 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.collections": "__VERSION__", - "@noldova/context.core": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__" + "peerDependencies": { + "@noldova/context.collections": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/parsers/noldova.context.parsers.regex/package.json b/src/parsers/noldova.context.parsers.regex/package.json index 06174562..af5ea9cc 100644 --- a/src/parsers/noldova.context.parsers.regex/package.json +++ b/src/parsers/noldova.context.parsers.regex/package.json @@ -18,11 +18,11 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.diagnostics": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__", - "@noldova/context.parsers": "__VERSION__", - "@noldova/context.text": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.diagnostics": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.parsers": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.text": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/parsers/noldova.context.parsers.typescript/package.json b/src/parsers/noldova.context.parsers.typescript/package.json index 1d13020a..2660821b 100644 --- a/src/parsers/noldova.context.parsers.typescript/package.json +++ b/src/parsers/noldova.context.parsers.typescript/package.json @@ -18,11 +18,11 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.core": "__VERSION__", - "@noldova/context.diagnostics": "__VERSION__", - "@noldova/context.parsers": "__VERSION__", - "@noldova/context.parsers.regex": "__VERSION__", - "@noldova/context.text": "__VERSION__" + "peerDependencies": { + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.diagnostics": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.parsers": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.parsers.regex": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.text": ">=__VERSION__ <__NEXT_MINOR__" } } diff --git a/src/parsers/noldova.context.parsers/package.json b/src/parsers/noldova.context.parsers/package.json index 23b0227f..4c60e061 100644 --- a/src/parsers/noldova.context.parsers/package.json +++ b/src/parsers/noldova.context.parsers/package.json @@ -18,11 +18,11 @@ "access": "public", "registry": "https://registry.npmjs.org/" }, - "dependencies": { - "@noldova/context.collections": "__VERSION__", - "@noldova/context.core": "__VERSION__", - "@noldova/context.diagnostics": "__VERSION__", - "@noldova/context.exceptions": "__VERSION__", - "@noldova/context.text": "__VERSION__" + "peerDependencies": { + "@noldova/context.collections": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.core": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.diagnostics": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.exceptions": ">=__VERSION__ <__NEXT_MINOR__", + "@noldova/context.text": ">=__VERSION__ <__NEXT_MINOR__" } } From 0f17f0ace29ba36bafe341366ad3051131c8bc62 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 15:43:51 -0800 Subject: [PATCH 03/12] Name no folder in core's second-copy error Core compiles against the ECMAScript library alone, which has no import.meta.url, and declaring it is host plumbing kept out of context. So a copy refuses to load when it finds the extensions another copy added, a newer or an older one, and the error says two copies are loaded and that the app's packages need one context version in range. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- documents/VERSIONING-AND-COMPATIBILITY.md | 2 +- src/noldova.context.core/README.md | 12 ++-- .../src/intrinsics/core-copy.ts | 19 ++----- src/noldova.context.core/src/resources.ts | 7 +-- .../tests/intrinsics/core-copy.test.ts | 55 ++++++------------- 5 files changed, 30 insertions(+), 65 deletions(-) diff --git a/documents/VERSIONING-AND-COMPATIBILITY.md b/documents/VERSIONING-AND-COMPATIBILITY.md index f1c07ee5..d9ea07ec 100644 --- a/documents/VERSIONING-AND-COMPATIBILITY.md +++ b/documents/VERSIONING-AND-COMPATIBILITY.md @@ -20,7 +20,7 @@ The one number lives in `version.json` at the repository's root, such as `{ "ver Until CONTEXT compiles itself, the `.ctxp` project manifests keep `0.0.1`, since nothing reads their version yet. The repository's root `package.json` and its lockfile have no version, since they are never published. A package takes the CONTEXT packages it uses as peer dependencies in the range from the current number to the next minor version, such as `>=0.0.9 <0.1.0`, never as dependencies, so an app whose packages need different versions in that range installs one copy of each; npm refuses an install whose ranges no single version meets, rather than nesting a second copy. -`@noldova/context.core` refuses to load beside another copy of itself, naming both copies' folders. +`@noldova/context.core` refuses to load beside another copy of itself, with an error that says two copies are loaded. No other caret, tilde, wildcard, floating tag, prerelease counter, date version, commit suffix, or independently incremented package version names a CONTEXT package. The packages are published to npm under `@noldova`. diff --git a/src/noldova.context.core/README.md b/src/noldova.context.core/README.md index f202d313..ced47180 100644 --- a/src/noldova.context.core/README.md +++ b/src/noldova.context.core/README.md @@ -39,11 +39,13 @@ examples. ## One copy An app loads one copy of the package, since the additions can be installed only -once. The first copy records its folder; a second copy, such as one npm nests -under a package that pins another version, refuses to load with an `Error` that -names both folders, and `npm ls @noldova/context.core` shows which packages -bring each. When a copy from before this check loaded first, which records no -folder, the error names this copy's folder and says the other is older. +once. The CONTEXT packages take it as a peer in a range, so npm installs one +copy for packages that need different versions in that range. A second copy that loads anyway, such as +one npm nests under a package that pins another version, refuses to load with +an `Error` saying two copies are loaded; `npm ls @noldova/context.core` shows +which packages bring each. The error names no folder, since a module's own +location is host plumbing outside the ECMAScript library the package compiles +against. ## License diff --git a/src/noldova.context.core/src/intrinsics/core-copy.ts b/src/noldova.context.core/src/intrinsics/core-copy.ts index b99b9d73..b3d82046 100644 --- a/src/noldova.context.core/src/intrinsics/core-copy.ts +++ b/src/noldova.context.core/src/intrinsics/core-copy.ts @@ -8,24 +8,15 @@ import { Resources } from "../resources.js"; -// The one copy of this package an app may load. The first copy records its folder on the global object before it adds its extensions; a second -// copy, which could not add them again, refuses to load with an error naming both folders. +// The one copy of this package an app may load. A copy that finds the extensions another copy added refuses to load, rather than failing to add +// them again. It names no folder, since a module's own location is host plumbing outside the ECMAScript library this package compiles against. export class CoreCopy { - private static readonly KEY: symbol = Symbol.for(Resources.packageName); - private static readonly FOLDER: string = new URL(Resources.packageFolderFromIntrinsics, import.meta.url).href; - /** - * Records this copy as the app's one copy of the package, or throws an Error if another copy is already loaded. It runs before the extensions - * exist, so it reads none of them and calls no nameof. + * Throws an Error if another copy of the package already added its extensions. It runs before this copy adds them, so it reads none of them + * and calls no nameof. */ public static claim(): void { - const first: unknown = Object.getOwnPropertyDescriptor(globalThis, CoreCopy.KEY)?.value; - if (first !== undefined) - throw new Error(Resources.secondCopyLoaded.replace(Resources.firstFolderPlaceholder, String(first)).replace(Resources.secondFolderPlaceholder, CoreCopy.FOLDER)); - if (Object.getOwnPropertyDescriptor(Object, Resources.isFunctionName) !== undefined) - throw new Error(Resources.olderCopyLoaded.replace(Resources.secondFolderPlaceholder, CoreCopy.FOLDER)); - - Object.defineProperty(globalThis, CoreCopy.KEY, { value: CoreCopy.FOLDER, writable: false, enumerable: false, configurable: false }); + throw new Error(Resources.coreCopiesLoaded); } } diff --git a/src/noldova.context.core/src/resources.ts b/src/noldova.context.core/src/resources.ts index adecc54b..96de7485 100644 --- a/src/noldova.context.core/src/resources.ts +++ b/src/noldova.context.core/src/resources.ts @@ -15,17 +15,12 @@ export class Resources { public static readonly asciiUppercaseA: string = "A"; public static readonly asciiUppercaseF: string = "F"; public static readonly asciiUppercaseZ: string = "Z"; + public static readonly coreCopiesLoaded: string = "Two copies of @noldova/context.core are loaded, so this one cannot add its extensions. The app's packages need one context version that each of their ranges meets; npm ls @noldova/context.core names the packages that bring each copy."; public static readonly decimalRadix: number = 10; - public static readonly firstFolderPlaceholder: string = "{0}"; public static readonly formatClosingBrace: string = "}"; public static readonly formatOpeningBrace: string = "{"; public static readonly isFunctionName: string = "isFunction"; public static readonly nameofArgumentInvalid: string = "The value must be a string member name or a selector that returns exactly one selected string member."; - public static readonly olderCopyLoaded: string = "A copy of @noldova/context.core older than this check is already loaded, so this copy, from {1}, cannot add its extensions beside it. An app loads one copy; npm ls @noldova/context.core names the packages that bring each."; - public static readonly packageFolderFromIntrinsics: string = "../"; - public static readonly packageName: string = "@noldova/context.core"; - public static readonly secondCopyLoaded: string = "Two copies of @noldova/context.core are loaded, from {0} and {1}. An app loads one copy; npm ls @noldova/context.core names the packages that bring each."; - public static readonly secondFolderPlaceholder: string = "{1}"; public static readonly typeofBigInt: string = "bigint"; public static readonly typeofBoolean: string = "boolean"; public static readonly typeofFunction: string = "function"; diff --git a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts index 6686437e..74e74301 100644 --- a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts +++ b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts @@ -19,60 +19,41 @@ import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; @TestClass export class CoreCopyTests { private static readonly PACKAGE_NAME: string = "@noldova/context.core"; - private static readonly PACKAGE_FOLDER: URL = new URL("../", import.meta.resolve("@noldova/context.core")); + private static readonly PACKAGE_FOLDER: string = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))); + private static readonly COPIES_LOADED: string = + "Two copies of @noldova/context.core are loaded, so this one cannot add its extensions. The app's packages need one context version that each of their ranges meets; npm ls @noldova/context.core names the packages that bring each copy."; @TestMethod - public recordsTheFolderOfTheCopyItLoaded(): void { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, Symbol.for(CoreCopyTests.PACKAGE_NAME)); - - Assert.isDefined(descriptor); - Assert.areEqual(CoreCopyTests.PACKAGE_FOLDER.href, descriptor.value); - Assert.isFalse(descriptor.writable ?? true); - Assert.isFalse(descriptor.configurable ?? true); - } - - @TestMethod - public refusesASecondCopyNamingBothFoldersAsync(): Promise { + public refusesASecondCopyAsync(): Promise { return CoreCopyTests.withDirectoryAsync(async directory => { const copy = join(directory, "copy"); - await cp(fileURLToPath(CoreCopyTests.PACKAGE_FOLDER), copy, { recursive: true }); + await cp(CoreCopyTests.PACKAGE_FOLDER, copy, { recursive: true }); const scriptPath = join(directory, "load.js"); await writeFile(scriptPath, CoreCopyTests.loadingScript( `await import(${JSON.stringify(pathToFileURL(join(copy, "api", "index.js")).href)});`, `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - const output = await CoreCopyTests.runAsync(scriptPath, directory); - - Assert.areEqual( - `Two copies of @noldova/context.core are loaded, from ${pathToFileURL(copy).href}/ and ${CoreCopyTests.PACKAGE_FOLDER.href}. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, - output); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); }); } @TestMethod - public refusesToLoadBesideAnOlderCopyAsync(): Promise { + public refusesToLoadBesideACopyFromBeforeTheCheckAsync(): Promise { return CoreCopyTests.withDirectoryAsync(async directory => { const scriptPath = join(directory, "load.js"); await writeFile(scriptPath, CoreCopyTests.loadingScript( "Object.defineProperty(Object, \"isFunction\", { value: () => true, writable: false, enumerable: false, configurable: false });", `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - const output = await CoreCopyTests.runAsync(scriptPath, directory); - - Assert.areEqual( - `A copy of @noldova/context.core older than this check is already loaded, so this copy, from ${CoreCopyTests.PACKAGE_FOLDER.href}, cannot add its extensions beside it. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, - output); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); }); } @TestMethod public refusesTwoCopiesNpmInstallsSideBySideAsync(): Promise { return CoreCopyTests.withDirectoryAsync(async directory => { - const npmPath = process.env["npm_execpath"]; - Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - const dependent = join(directory, "dependent"); - await cp(fileURLToPath(CoreCopyTests.PACKAGE_FOLDER), join(dependent, "node_modules", "@noldova", "context.core"), { recursive: true }); + await cp(CoreCopyTests.PACKAGE_FOLDER, join(dependent, "node_modules", "@noldova", "context.core"), { recursive: true }); await writeFile(join(dependent, "package.json"), JSON.stringify({ name: "dependent", version: "1.0.0", @@ -82,8 +63,8 @@ export class CoreCopyTests { bundleDependencies: [CoreCopyTests.PACKAGE_NAME] })); await writeFile(join(dependent, "index.js"), `import "${CoreCopyTests.PACKAGE_NAME}";\n`); - await CoreCopyTests.runNpmAsync(npmPath, ["pack", "--silent", "--pack-destination", directory], dependent); - const coreArchive = (await CoreCopyTests.runNpmAsync(npmPath, ["pack", "--silent", "--pack-destination", directory, fileURLToPath(CoreCopyTests.PACKAGE_FOLDER)], directory)).trim(); + await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory], dependent); + const coreArchive = (await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, CoreCopyTests.PACKAGE_FOLDER], directory)).trim(); const app = join(directory, "app"); await mkdir(app); @@ -93,17 +74,11 @@ export class CoreCopyTests { type: "module", dependencies: { [CoreCopyTests.PACKAGE_NAME]: `file:../${coreArchive}`, dependent: "file:../dependent-1.0.0.tgz" } })); - await CoreCopyTests.runNpmAsync(npmPath, ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app); + await CoreCopyTests.runNpmAsync(["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app); const scriptPath = join(app, "main.js"); await writeFile(scriptPath, CoreCopyTests.loadingScript(`await import("${CoreCopyTests.PACKAGE_NAME}");`, "await import(\"dependent\");")); - const output = await CoreCopyTests.runAsync(scriptPath, app); - - const installed = join(app, "node_modules", "@noldova", "context.core"); - const nested = join(app, "node_modules", "dependent", "node_modules", "@noldova", "context.core"); - Assert.areEqual( - `Two copies of @noldova/context.core are loaded, from ${pathToFileURL(installed).href}/ and ${pathToFileURL(nested).href}/. An app loads one copy; npm ls @noldova/context.core names the packages that bring each.`, - output); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, app)); }); } @@ -128,7 +103,9 @@ export class CoreCopyTests { return stdout; } - private static async runNpmAsync(npmPath: string, values: readonly string[], directory: string): Promise { + private static async runNpmAsync(values: readonly string[], directory: string): Promise { + const npmPath = process.env["npm_execpath"]; + Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, windowsHide: true }); return stdout; From cfd7f8386be3b41803fd44f056a700805c526985 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 15:50:32 -0800 Subject: [PATCH 04/12] Keep npm's compile cache out of the run's temporary folder npm turns on Node's compile cache, which wrote node-compile-cache into the run's temporary folder on every target, and the suite requires that folder left clean. The install tests run npm with the cache off. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- src/noldova.context.core/tests/intrinsics/core-copy.test.ts | 3 ++- src/noldova.context.exceptions/tests/api/index.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts index 74e74301..39c8e370 100644 --- a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts +++ b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts @@ -106,7 +106,8 @@ export class CoreCopyTests { private static async runNpmAsync(values: readonly string[], directory: string): Promise { const npmPath = process.env["npm_execpath"]; Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, windowsHide: true }); + // npm turns on Node's compile cache, which writes into the run's temporary folder; the suite requires that folder left clean. + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: { ...process.env, NODE_DISABLE_COMPILE_CACHE: "1" }, windowsHide: true }); return stdout; } diff --git a/src/noldova.context.exceptions/tests/api/index.test.ts b/src/noldova.context.exceptions/tests/api/index.test.ts index 839a099e..6400aa7a 100644 --- a/src/noldova.context.exceptions/tests/api/index.test.ts +++ b/src/noldova.context.exceptions/tests/api/index.test.ts @@ -161,7 +161,8 @@ export class ExceptionsApiTests { private static async runNpmAsync(values: readonly string[], directory: string): Promise { const npmPath = process.env["npm_execpath"]; Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, windowsHide: true }); + // npm turns on Node's compile cache, which writes into the run's temporary folder; the suite requires that folder left clean. + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: { ...process.env, NODE_DISABLE_COMPILE_CACHE: "1" }, windowsHide: true }); return stdout; } From 5e1e60a8259a1885f0021a14d0480fe88e738e53 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 15:55:07 -0800 Subject: [PATCH 05/12] Give the install tests' children a temporary folder of their own The npm and node children of the core-copy and peer-range tests now get TMPDIR, TMP and TEMP set to a folder inside the test's own folder, which the test removes, so npm's compile cache no longer lands in the run's temporary folder; this replaces NODE_DISABLE_COMPILE_CACHE. Each test packs its archives in one npm pack call rather than two. VERSIONING-AND-COMPATIBILITY.md sections 2 and 3 now say which pairs are tested (the closure built from one source state) and which the peer ranges only allow. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- documents/VERSIONING-AND-COMPATIBILITY.md | 3 ++ .../tests/intrinsics/core-copy.test.ts | 35 +++++++----- .../tests/api/index.test.ts | 53 ++++++++++++------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/documents/VERSIONING-AND-COMPATIBILITY.md b/documents/VERSIONING-AND-COMPATIBILITY.md index d9ea07ec..1363c3e7 100644 --- a/documents/VERSIONING-AND-COMPATIBILITY.md +++ b/documents/VERSIONING-AND-COMPATIBILITY.md @@ -32,6 +32,7 @@ A version-1 contract may change in place: the contract, writer, reader, validato ## 2. No development compatibility CONTEXT makes no backward- or forward-compatibility promise between development revisions, even when both sides report the same `0.0.` and version `1`. +The peer ranges of section 1 allow an install to pair a package with a sibling at another version in the range, so that an app gets one copy of each; they promise nothing about how that pair works together, as section 3 says. Do not add legacy readers or writers, migration or upgrade pipelines, field aliases, deprecated fields retained for compatibility, dual encoding, version negotiation or feature handshakes, probing of historical layouts, fallback parsing after current validation fails, or special cases for historical artifacts. When a contract changes: change the owning contract; change every producer and consumer atomically; replace tests and fixtures rather than preserving obsolete cases as accepted behavior; delete generated, cached, installed, and generation outputs; rebuild the affected dependency closure; and verify only the new state. @@ -40,6 +41,8 @@ A historical test is retained only when it still proves a current invariant or i ## 3. Same version does not mean compatible The canonical workflow uses one exact compatible closure: packages freshly built, packed, and installed from the same source state; a compiler consuming only that closure; the SDK and Runtime as the exact pair selected by the build; and self-host and AOT steps recording the exact identities they consumed. +That closure is the only one tested: the checks build, pack and install every package from one source state, so each package meets its siblings at its own version. +A pair the peer ranges merely allow, such as a package with a sibling one patch apart, is accepted by npm but untested; the one such install the checks run proves only that npm shares one copy of the sibling, not that the pair works together. An older development artifact is disposable. The owning artifact, Runtime, SDK, package, or distribution schema defines an exact development compatibility identity: deterministic, host-independent, derived only from declared compatibility-relevant inputs, never from absolute paths, timestamps, machine or process identity, locale, or wall-clock time. diff --git a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts index 39c8e370..f42f811e 100644 --- a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts +++ b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts @@ -20,6 +20,7 @@ import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; export class CoreCopyTests { private static readonly PACKAGE_NAME: string = "@noldova/context.core"; private static readonly PACKAGE_FOLDER: string = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))); + private static readonly TEMPORARY_FOLDER: string = "temporary"; private static readonly COPIES_LOADED: string = "Two copies of @noldova/context.core are loaded, so this one cannot add its extensions. The app's packages need one context version that each of their ranges meets; npm ls @noldova/context.core names the packages that bring each copy."; @@ -33,7 +34,7 @@ export class CoreCopyTests { `await import(${JSON.stringify(pathToFileURL(join(copy, "api", "index.js")).href)});`, `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory, directory)); }); } @@ -45,7 +46,7 @@ export class CoreCopyTests { "Object.defineProperty(Object, \"isFunction\", { value: () => true, writable: false, enumerable: false, configurable: false });", `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory, directory)); }); } @@ -63,8 +64,8 @@ export class CoreCopyTests { bundleDependencies: [CoreCopyTests.PACKAGE_NAME] })); await writeFile(join(dependent, "index.js"), `import "${CoreCopyTests.PACKAGE_NAME}";\n`); - await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory], dependent); - const coreArchive = (await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, CoreCopyTests.PACKAGE_FOLDER], directory)).trim(); + const packed = await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, dependent, CoreCopyTests.PACKAGE_FOLDER], directory, directory); + const [dependentArchive = "", coreArchive = ""] = packed.trim().split(/\s+/u); const app = join(directory, "app"); await mkdir(app); @@ -72,19 +73,20 @@ export class CoreCopyTests { name: "app", private: true, type: "module", - dependencies: { [CoreCopyTests.PACKAGE_NAME]: `file:../${coreArchive}`, dependent: "file:../dependent-1.0.0.tgz" } + dependencies: { [CoreCopyTests.PACKAGE_NAME]: `file:../${coreArchive}`, dependent: `file:../${dependentArchive}` } })); - await CoreCopyTests.runNpmAsync(["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app); + await CoreCopyTests.runNpmAsync(["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app, directory); const scriptPath = join(app, "main.js"); await writeFile(scriptPath, CoreCopyTests.loadingScript(`await import("${CoreCopyTests.PACKAGE_NAME}");`, "await import(\"dependent\");")); - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, app)); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, app, directory)); }); } private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { const directory = await mkdtemp(resolve(import.meta.dirname, "../../../core-copies-")); try { + await mkdir(join(directory, CoreCopyTests.TEMPORARY_FOLDER)); await action(directory); } finally { @@ -97,18 +99,27 @@ export class CoreCopyTests { return `try {\n ${first}\n ${second}\n}\ncatch (error) {\n process.stdout.write(error.message);\n}\n`; } - private static async runAsync(scriptPath: string, directory: string): Promise { - const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: directory, windowsHide: true }); + private static async runAsync(scriptPath: string, directory: string, root: string): Promise { + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: directory, env: CoreCopyTests.environment(root), windowsHide: true }); return stdout; } - private static async runNpmAsync(values: readonly string[], directory: string): Promise { + private static async runNpmAsync(values: readonly string[], directory: string, root: string): Promise { const npmPath = process.env["npm_execpath"]; Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - // npm turns on Node's compile cache, which writes into the run's temporary folder; the suite requires that folder left clean. - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: { ...process.env, NODE_DISABLE_COMPILE_CACHE: "1" }, windowsHide: true }); + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: CoreCopyTests.environment(root), windowsHide: true }); return stdout; } + + /** + * The children's environment, with a temporary folder of their own under the test's folder, which the test removes: npm turns on Node's + * compile cache, which would otherwise write into the run's temporary folder, and the suite requires that folder left clean. + */ + private static environment(root: string): NodeJS.ProcessEnv { + const temporary = join(root, CoreCopyTests.TEMPORARY_FOLDER); + + return { ...process.env, TMPDIR: temporary, TMP: temporary, TEMP: temporary }; + } } diff --git a/src/noldova.context.exceptions/tests/api/index.test.ts b/src/noldova.context.exceptions/tests/api/index.test.ts index 6400aa7a..dbdb7db6 100644 --- a/src/noldova.context.exceptions/tests/api/index.test.ts +++ b/src/noldova.context.exceptions/tests/api/index.test.ts @@ -23,6 +23,7 @@ export class ExceptionsApiTests { private static readonly INSTALL_VALUES: readonly string[] = ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"]; private static readonly CORE_RANGE: RegExp = /"@noldova\/context\.core": ">=(\d+)\.(\d+)\.(\d+) <([^"]+)"/u; private static readonly VERSION_FIELD: RegExp = /"version": "[^"]+"/u; + private static readonly TEMPORARY_FOLDER: string = "temporary"; @TestMethod public exportsTheCompleteCatalog(): void { @@ -79,34 +80,38 @@ export class ExceptionsApiTests { } @TestMethod - public async sharesOneCoreAtAnotherVersionInItsRangeAsync(): Promise { - const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); - try { + public sharesOneCoreAtAnotherVersionInItsRangeAsync(): Promise { + return ExceptionsApiTests.withDirectoryAsync(async directory => { const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionInRangeAsync()); - await ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app); + await ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app, directory); const scriptPath = join(app, "main.js"); await writeFile(scriptPath, "import \"@noldova/context.core\";\nimport \"@noldova/context.exceptions\";\nprocess.stdout.write(\"loaded\");\n"); - const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: app, windowsHide: true }); + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: app, env: ExceptionsApiTests.environment(directory), windowsHide: true }); Assert.areEqual("loaded", stdout); Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions", "node_modules"))); - } - finally { - await rm(directory, { recursive: true, force: true }); - } + }); } @TestMethod - public async refusesToInstallACoreOutsideItsRangeAsync(): Promise { - const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); - try { + public refusesToInstallACoreOutsideItsRangeAsync(): Promise { + return ExceptionsApiTests.withDirectoryAsync(async directory => { const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionPastRangeAsync()); - const failure = await Assert.throwsAsync(() => ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app), Error); + const failure = await Assert.throwsAsync(() => ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app, directory), Error); Assert.isTrue(failure.message.includes("ERESOLVE"), failure.message); Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions"))); + }); + } + + /** Runs the action in a new folder beside the tests, with a temporary folder of its own for the children, and removes it after. */ + private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { + const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); + try { + await mkdir(join(directory, ExceptionsApiTests.TEMPORARY_FOLDER)); + await action(directory); } finally { await rm(directory, { recursive: true, force: true }); @@ -142,10 +147,9 @@ export class ExceptionsApiTests { await cp(fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))), core, { recursive: true }); const coreManifest = join(core, "package.json"); await writeFile(coreManifest, (await readFile(coreManifest, "utf8")).replace(ExceptionsApiTests.VERSION_FIELD, `"version": "${coreVersion}"`)); - const coreArchive = (await ExceptionsApiTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, core], directory)).trim(); - const exceptionsArchive = (await ExceptionsApiTests.runNpmAsync( - ["pack", "--silent", "--pack-destination", directory, fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.exceptions")))], - directory)).trim(); + const exceptions = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.exceptions"))); + const packed = await ExceptionsApiTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, core, exceptions], directory, directory); + const [coreArchive = "", exceptionsArchive = ""] = packed.trim().split(/\s+/u); const app = join(directory, "app"); await mkdir(app); @@ -158,12 +162,21 @@ export class ExceptionsApiTests { return app; } - private static async runNpmAsync(values: readonly string[], directory: string): Promise { + private static async runNpmAsync(values: readonly string[], directory: string, root: string): Promise { const npmPath = process.env["npm_execpath"]; Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - // npm turns on Node's compile cache, which writes into the run's temporary folder; the suite requires that folder left clean. - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: { ...process.env, NODE_DISABLE_COMPILE_CACHE: "1" }, windowsHide: true }); + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: ExceptionsApiTests.environment(root), windowsHide: true }); return stdout; } + + /** + * The children's environment, with a temporary folder of their own under the test's folder, which the test removes: npm turns on Node's + * compile cache, which would otherwise write into the run's temporary folder, and the suite requires that folder left clean. + */ + private static environment(root: string): NodeJS.ProcessEnv { + const temporary = join(root, ExceptionsApiTests.TEMPORARY_FOLDER); + + return { ...process.env, TMPDIR: temporary, TMP: temporary, TEMP: temporary }; + } } From ffd05dea391c8d024c4d597e3a461804d7ea53c9 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 16:08:52 -0800 Subject: [PATCH 06/12] Hold the packed-install tests in one file with one helper set The three tests that install packed packages with npm, exceptions sharing one core in its range, npm refusing a core outside it, and core refusing a second copy npm installs beside it, move into exceptions' packed-install.test.ts with one set of folder, pack, run and environment helpers. Core keeps its two tests that only run node. The pack checks that npm named exactly two archives and fails quoting its output otherwise. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- .../tests/intrinsics/core-copy.test.ts | 72 ++----- .../tests/api/index.test.ts | 109 +---------- .../tests/api/packed-install.test.ts | 175 ++++++++++++++++++ 3 files changed, 189 insertions(+), 167 deletions(-) create mode 100644 src/noldova.context.exceptions/tests/api/packed-install.test.ts diff --git a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts index f42f811e..ace0e239 100644 --- a/src/noldova.context.core/tests/intrinsics/core-copy.test.ts +++ b/src/noldova.context.core/tests/intrinsics/core-copy.test.ts @@ -9,18 +9,18 @@ // This file uses Node APIs and will change when we replace Node. import { execFile } from "node:child_process"; -import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { cp, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; +// The copy npm installs beside another is tested with exceptions' packed-install tests, which install packages with npm. @TestClass export class CoreCopyTests { private static readonly PACKAGE_NAME: string = "@noldova/context.core"; private static readonly PACKAGE_FOLDER: string = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))); - private static readonly TEMPORARY_FOLDER: string = "temporary"; private static readonly COPIES_LOADED: string = "Two copies of @noldova/context.core are loaded, so this one cannot add its extensions. The app's packages need one context version that each of their ranges meets; npm ls @noldova/context.core names the packages that bring each copy."; @@ -34,7 +34,7 @@ export class CoreCopyTests { `await import(${JSON.stringify(pathToFileURL(join(copy, "api", "index.js")).href)});`, `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory, directory)); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); }); } @@ -46,47 +46,13 @@ export class CoreCopyTests { "Object.defineProperty(Object, \"isFunction\", { value: () => true, writable: false, enumerable: false, configurable: false });", `await import("${CoreCopyTests.PACKAGE_NAME}");`)); - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory, directory)); - }); - } - - @TestMethod - public refusesTwoCopiesNpmInstallsSideBySideAsync(): Promise { - return CoreCopyTests.withDirectoryAsync(async directory => { - const dependent = join(directory, "dependent"); - await cp(CoreCopyTests.PACKAGE_FOLDER, join(dependent, "node_modules", "@noldova", "context.core"), { recursive: true }); - await writeFile(join(dependent, "package.json"), JSON.stringify({ - name: "dependent", - version: "1.0.0", - type: "module", - main: "index.js", - dependencies: { [CoreCopyTests.PACKAGE_NAME]: "*" }, - bundleDependencies: [CoreCopyTests.PACKAGE_NAME] - })); - await writeFile(join(dependent, "index.js"), `import "${CoreCopyTests.PACKAGE_NAME}";\n`); - const packed = await CoreCopyTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, dependent, CoreCopyTests.PACKAGE_FOLDER], directory, directory); - const [dependentArchive = "", coreArchive = ""] = packed.trim().split(/\s+/u); - - const app = join(directory, "app"); - await mkdir(app); - await writeFile(join(app, "package.json"), JSON.stringify({ - name: "app", - private: true, - type: "module", - dependencies: { [CoreCopyTests.PACKAGE_NAME]: `file:../${coreArchive}`, dependent: `file:../${dependentArchive}` } - })); - await CoreCopyTests.runNpmAsync(["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"], app, directory); - const scriptPath = join(app, "main.js"); - await writeFile(scriptPath, CoreCopyTests.loadingScript(`await import("${CoreCopyTests.PACKAGE_NAME}");`, "await import(\"dependent\");")); - - Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, app, directory)); + Assert.areEqual(CoreCopyTests.COPIES_LOADED, await CoreCopyTests.runAsync(scriptPath, directory)); }); } private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { const directory = await mkdtemp(resolve(import.meta.dirname, "../../../core-copies-")); try { - await mkdir(join(directory, CoreCopyTests.TEMPORARY_FOLDER)); await action(directory); } finally { @@ -94,32 +60,20 @@ export class CoreCopyTests { } } - /** A module that runs the first step, then loads the second, and writes the message of the error it ends with, or nothing. */ + // A module that runs the first step, then loads the second, and writes the message of the error it ends with, or nothing. private static loadingScript(first: string, second: string): string { return `try {\n ${first}\n ${second}\n}\ncatch (error) {\n process.stdout.write(error.message);\n}\n`; } - private static async runAsync(scriptPath: string, directory: string, root: string): Promise { - const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: directory, env: CoreCopyTests.environment(root), windowsHide: true }); - - return stdout; - } - - private static async runNpmAsync(values: readonly string[], directory: string, root: string): Promise { - const npmPath = process.env["npm_execpath"]; - Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: CoreCopyTests.environment(root), windowsHide: true }); + // Runs the script with the test's folder as its temporary folder, which the test removes, so anything the child writes there, such as a + // compile cache, goes with it: the suite requires the run's temporary folder left clean. + private static async runAsync(scriptPath: string, directory: string): Promise { + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { + cwd: directory, + env: { ...process.env, TMPDIR: directory, TMP: directory, TEMP: directory }, + windowsHide: true + }); return stdout; } - - /** - * The children's environment, with a temporary folder of their own under the test's folder, which the test removes: npm turns on Node's - * compile cache, which would otherwise write into the run's temporary folder, and the suite requires that folder left clean. - */ - private static environment(root: string): NodeJS.ProcessEnv { - const temporary = join(root, CoreCopyTests.TEMPORARY_FOLDER); - - return { ...process.env, TMPDIR: temporary, TMP: temporary, TEMP: temporary }; - } } diff --git a/src/noldova.context.exceptions/tests/api/index.test.ts b/src/noldova.context.exceptions/tests/api/index.test.ts index dbdb7db6..d55094c0 100644 --- a/src/noldova.context.exceptions/tests/api/index.test.ts +++ b/src/noldova.context.exceptions/tests/api/index.test.ts @@ -9,8 +9,7 @@ // This file uses Node APIs and will change when we replace Node. import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; -import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; @@ -20,11 +19,6 @@ import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; @TestClass export class ExceptionsApiTests { - private static readonly INSTALL_VALUES: readonly string[] = ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"]; - private static readonly CORE_RANGE: RegExp = /"@noldova\/context\.core": ">=(\d+)\.(\d+)\.(\d+) <([^"]+)"/u; - private static readonly VERSION_FIELD: RegExp = /"version": "[^"]+"/u; - private static readonly TEMPORARY_FOLDER: string = "temporary"; - @TestMethod public exportsTheCompleteCatalog(): void { const exportNames = [ @@ -78,105 +72,4 @@ export class ExceptionsApiTests { await rm(directory, { recursive: true, force: true }); } } - - @TestMethod - public sharesOneCoreAtAnotherVersionInItsRangeAsync(): Promise { - return ExceptionsApiTests.withDirectoryAsync(async directory => { - const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionInRangeAsync()); - - await ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app, directory); - const scriptPath = join(app, "main.js"); - await writeFile(scriptPath, "import \"@noldova/context.core\";\nimport \"@noldova/context.exceptions\";\nprocess.stdout.write(\"loaded\");\n"); - const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: app, env: ExceptionsApiTests.environment(directory), windowsHide: true }); - - Assert.areEqual("loaded", stdout); - Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions", "node_modules"))); - }); - } - - @TestMethod - public refusesToInstallACoreOutsideItsRangeAsync(): Promise { - return ExceptionsApiTests.withDirectoryAsync(async directory => { - const app = await ExceptionsApiTests.createAppAsync(directory, await ExceptionsApiTests.readCoreVersionPastRangeAsync()); - - const failure = await Assert.throwsAsync(() => ExceptionsApiTests.runNpmAsync(ExceptionsApiTests.INSTALL_VALUES, app, directory), Error); - - Assert.isTrue(failure.message.includes("ERESOLVE"), failure.message); - Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions"))); - }); - } - - /** Runs the action in a new folder beside the tests, with a temporary folder of its own for the children, and removes it after. */ - private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { - const directory = await mkdtemp(resolve(import.meta.dirname, "../../../peer-range-")); - try { - await mkdir(join(directory, ExceptionsApiTests.TEMPORARY_FOLDER)); - await action(directory); - } - finally { - await rm(directory, { recursive: true, force: true }); - } - } - - /** A core version inside the range the installed package takes core in, a patch above its lower bound. */ - private static async readCoreVersionInRangeAsync(): Promise { - const range = await ExceptionsApiTests.readCoreRangeAsync(); - - return `${range[1]}.${range[2]}.${Number(range[3]) + 1}`; - } - - /** The first core version past that range: its upper bound. */ - private static async readCoreVersionPastRangeAsync(): Promise { - const range = await ExceptionsApiTests.readCoreRangeAsync(); - - return String(range[4]); - } - - /** The installed package's peer range of core, from its manifest: the lower bound's three numbers, then the upper bound. */ - private static async readCoreRangeAsync(): Promise { - const manifest = await readFile(fileURLToPath(new URL("../package.json", import.meta.resolve("@noldova/context.exceptions"))), "utf8"); - const range = ExceptionsApiTests.CORE_RANGE.exec(manifest); - Assert.isNotNull(range, manifest); - - return range; - } - - /** An app that installs the package and a copy of core at the version given, each from a tarball, and returns the app's folder. */ - private static async createAppAsync(directory: string, coreVersion: string): Promise { - const core = join(directory, "core"); - await cp(fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))), core, { recursive: true }); - const coreManifest = join(core, "package.json"); - await writeFile(coreManifest, (await readFile(coreManifest, "utf8")).replace(ExceptionsApiTests.VERSION_FIELD, `"version": "${coreVersion}"`)); - const exceptions = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.exceptions"))); - const packed = await ExceptionsApiTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, core, exceptions], directory, directory); - const [coreArchive = "", exceptionsArchive = ""] = packed.trim().split(/\s+/u); - - const app = join(directory, "app"); - await mkdir(app); - await writeFile(join(app, "package.json"), JSON.stringify({ - name: "app", - private: true, - type: "module", - dependencies: { "@noldova/context.core": `file:../${coreArchive}`, "@noldova/context.exceptions": `file:../${exceptionsArchive}` } - })); - return app; - } - - private static async runNpmAsync(values: readonly string[], directory: string, root: string): Promise { - const npmPath = process.env["npm_execpath"]; - Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); - const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: ExceptionsApiTests.environment(root), windowsHide: true }); - - return stdout; - } - - /** - * The children's environment, with a temporary folder of their own under the test's folder, which the test removes: npm turns on Node's - * compile cache, which would otherwise write into the run's temporary folder, and the suite requires that folder left clean. - */ - private static environment(root: string): NodeJS.ProcessEnv { - const temporary = join(root, ExceptionsApiTests.TEMPORARY_FOLDER); - - return { ...process.env, TMPDIR: temporary, TMP: temporary, TEMP: temporary }; - } } diff --git a/src/noldova.context.exceptions/tests/api/packed-install.test.ts b/src/noldova.context.exceptions/tests/api/packed-install.test.ts new file mode 100644 index 00000000..c996cbbf --- /dev/null +++ b/src/noldova.context.exceptions/tests/api/packed-install.test.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright (c) Noldova. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +// This file uses Node APIs and will change when we replace Node. + +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; + +// The tests that install packed packages into an app with npm: exceptions sharing one core in its peer range, npm refusing a core outside +// it, and core refusing to load beside a second copy npm installs. Exceptions' tests hold them since its package needs core installed too. +@TestClass +export class PackedInstallTests { + private static readonly CORE_NAME: string = "@noldova/context.core"; + private static readonly EXCEPTIONS_NAME: string = "@noldova/context.exceptions"; + private static readonly CORE_FOLDER: string = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.core"))); + private static readonly EXCEPTIONS_FOLDER: string = fileURLToPath(new URL("../", import.meta.resolve("@noldova/context.exceptions"))); + private static readonly INSTALL_VALUES: readonly string[] = ["install", "--offline", "--no-audit", "--no-fund", "--ignore-scripts", "--no-package-lock"]; + private static readonly CORE_RANGE: RegExp = /"@noldova\/context\.core": ">=(\d+)\.(\d+)\.(\d+) <([^"]+)"/u; + private static readonly VERSION_FIELD: RegExp = /"version": "[^"]+"/u; + private static readonly TEMPORARY_FOLDER: string = "temporary"; + private static readonly COPIES_LOADED: string = + "Two copies of @noldova/context.core are loaded, so this one cannot add its extensions. The app's packages need one context version that each of their ranges meets; npm ls @noldova/context.core names the packages that bring each copy."; + + @TestMethod + public sharesOneCoreAtAnotherVersionInItsRangeAsync(): Promise { + return PackedInstallTests.withDirectoryAsync(async directory => { + const app = await PackedInstallTests.createExceptionsAppAsync(directory, await PackedInstallTests.readCoreVersionInRangeAsync()); + + await PackedInstallTests.runNpmAsync(PackedInstallTests.INSTALL_VALUES, app, directory); + const scriptPath = join(app, "main.js"); + await writeFile(scriptPath, `import "${PackedInstallTests.CORE_NAME}";\nimport "${PackedInstallTests.EXCEPTIONS_NAME}";\nprocess.stdout.write("loaded");\n`); + + Assert.areEqual("loaded", await PackedInstallTests.runAsync(scriptPath, app, directory)); + Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions", "node_modules"))); + }); + } + + @TestMethod + public refusesToInstallACoreOutsideItsRangeAsync(): Promise { + return PackedInstallTests.withDirectoryAsync(async directory => { + const app = await PackedInstallTests.createExceptionsAppAsync(directory, await PackedInstallTests.readCoreVersionPastRangeAsync()); + + const failure = await Assert.throwsAsync(() => PackedInstallTests.runNpmAsync(PackedInstallTests.INSTALL_VALUES, app, directory), Error); + + Assert.isTrue(failure.message.includes("ERESOLVE"), failure.message); + Assert.isFalse(existsSync(join(app, "node_modules", "@noldova", "context.exceptions"))); + }); + } + + @TestMethod + public refusesTwoCoresNpmInstallsSideBySideAsync(): Promise { + return PackedInstallTests.withDirectoryAsync(async directory => { + const dependent = join(directory, "dependent"); + await cp(PackedInstallTests.CORE_FOLDER, join(dependent, "node_modules", "@noldova", "context.core"), { recursive: true }); + await writeFile(join(dependent, "package.json"), JSON.stringify({ + name: "dependent", + version: "1.0.0", + type: "module", + main: "index.js", + dependencies: { [PackedInstallTests.CORE_NAME]: "*" }, + bundleDependencies: [PackedInstallTests.CORE_NAME] + })); + await writeFile(join(dependent, "index.js"), `import "${PackedInstallTests.CORE_NAME}";\n`); + const [dependentArchive, coreArchive] = await PackedInstallTests.packAsync(directory, dependent, PackedInstallTests.CORE_FOLDER); + + const app = await PackedInstallTests.createAppAsync(directory, { [PackedInstallTests.CORE_NAME]: `file:../${coreArchive}`, dependent: `file:../${dependentArchive}` }); + await PackedInstallTests.runNpmAsync(PackedInstallTests.INSTALL_VALUES, app, directory); + const scriptPath = join(app, "main.js"); + await writeFile(scriptPath, + `try {\n await import("${PackedInstallTests.CORE_NAME}");\n await import("dependent");\n}\ncatch (error) {\n process.stdout.write(error.message);\n}\n`); + + Assert.areEqual(PackedInstallTests.COPIES_LOADED, await PackedInstallTests.runAsync(scriptPath, app, directory)); + }); + } + + // A core version inside the range the installed exceptions takes core in, a patch above its lower bound. + private static async readCoreVersionInRangeAsync(): Promise { + const range = await PackedInstallTests.readCoreRangeAsync(); + + return `${range[1]}.${range[2]}.${Number(range[3]) + 1}`; + } + + // The first core version past that range: its upper bound. + private static async readCoreVersionPastRangeAsync(): Promise { + const range = await PackedInstallTests.readCoreRangeAsync(); + + return String(range[4]); + } + + // The installed exceptions' peer range of core, from its manifest: the lower bound's three numbers, then the upper bound. + private static async readCoreRangeAsync(): Promise { + const manifest = await readFile(join(PackedInstallTests.EXCEPTIONS_FOLDER, "package.json"), "utf8"); + const range = PackedInstallTests.CORE_RANGE.exec(manifest); + Assert.isNotNull(range, manifest); + + return range; + } + + // An app that installs exceptions and a copy of core at the version given, each from a tarball. + private static async createExceptionsAppAsync(directory: string, coreVersion: string): Promise { + const core = join(directory, "core"); + await cp(PackedInstallTests.CORE_FOLDER, core, { recursive: true }); + const coreManifest = join(core, "package.json"); + await writeFile(coreManifest, (await readFile(coreManifest, "utf8")).replace(PackedInstallTests.VERSION_FIELD, `"version": "${coreVersion}"`)); + const [coreArchive, exceptionsArchive] = await PackedInstallTests.packAsync(directory, core, PackedInstallTests.EXCEPTIONS_FOLDER); + + return PackedInstallTests.createAppAsync(directory, { + [PackedInstallTests.CORE_NAME]: `file:../${coreArchive}`, + [PackedInstallTests.EXCEPTIONS_NAME]: `file:../${exceptionsArchive}` + }); + } + + private static async createAppAsync(directory: string, dependencies: Readonly>): Promise { + const app = join(directory, "app"); + await mkdir(app); + await writeFile(join(app, "package.json"), JSON.stringify({ name: "app", private: true, type: "module", dependencies })); + + return app; + } + + // Packs the two package folders into the directory in one npm call and returns their archives' names, in the same order. + private static async packAsync(directory: string, first: string, second: string): Promise<[string, string]> { + const packed = await PackedInstallTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, first, second], directory, directory); + const [firstArchive, secondArchive, ...others] = packed.trim().split(/\s+/u); + Assert.isDefined(firstArchive, packed); + Assert.isDefined(secondArchive, packed); + Assert.areEqual(0, others.length, packed); + + return [firstArchive, secondArchive]; + } + + private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { + const directory = await mkdtemp(resolve(import.meta.dirname, "../../../packed-install-")); + try { + await mkdir(join(directory, PackedInstallTests.TEMPORARY_FOLDER)); + await action(directory); + } + finally { + await rm(directory, { recursive: true, force: true }); + } + } + + private static async runAsync(scriptPath: string, directory: string, root: string): Promise { + const { stdout } = await promisify(execFile)(process.execPath, [scriptPath], { cwd: directory, env: PackedInstallTests.environment(root), windowsHide: true }); + + return stdout; + } + + private static async runNpmAsync(values: readonly string[], directory: string, root: string): Promise { + const npmPath = process.env["npm_execpath"]; + Assert.isDefined(npmPath, "The suite runs through npm, which names its own path in npm_execpath."); + const { stdout } = await promisify(execFile)(process.execPath, [npmPath, ...values], { cwd: directory, env: PackedInstallTests.environment(root), windowsHide: true }); + + return stdout; + } + + // The children's environment, with a temporary folder of their own under the test's folder, which the test removes: npm turns on Node's + // compile cache, which would otherwise write into the run's temporary folder, and the suite requires that folder left clean. + private static environment(root: string): NodeJS.ProcessEnv { + const temporary = join(root, PackedInstallTests.TEMPORARY_FOLDER); + + return { ...process.env, TMPDIR: temporary, TMP: temporary, TEMP: temporary }; + } +} From 8768fd7965f4614d05705f93955a730e540f613e Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 16:27:22 -0800 Subject: [PATCH 07/12] List the packed-install tests among the slower files Their file's slowest median over three timing runs was 11432 ms, on the Windows test machine (3799 ms on Linux, 8280 ms on macOS), since each test installs packed packages with npm in child processes. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 45d0e136..28986e11 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Test files slower than the timing check's budget: | File | Time | Why | |---|---|---| | `noldova.context.parsers.typescript/conformance/conformance-cases.test.js` | 14000 ms | It runs every vendored TypeScript conformance case. | +| `noldova.context.exceptions/api/packed-install.test.js` | 11500 ms | Each of its tests installs packed packages into an app with npm, in child processes. | | `noldova.context.parsers.typescript/services/typescript-syntax.parser.test.js` | 6900 ms | Its long-chain tests parse inputs large enough to show the parser's work stays linear. | | `noldova.context.testing/services/execution/test-run-entry-point.test.js` | 5000 ms | Each of its tests starts a whole run in a child process. | | `noldova.context.data.sql.postgresql/models/client/postgresql-pool.server.test.js` | 4000 ms | It runs queries against a real PostgreSQL server and waits out its real timeouts. | From 9ecc6f1195ffe7a5539fe4dbd751486cdf65b783 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 16:34:08 -0800 Subject: [PATCH 08/12] Drop the doc comments outside the declarations Config.NEXT_MINOR's name says what it is, and CoreCopy.claim keeps a line comment on why it reads no extension. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- scripts/config.ts | 1 - src/noldova.context.core/src/intrinsics/core-copy.ts | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/config.ts b/scripts/config.ts index a82aa38b..5e590891 100644 --- a/scripts/config.ts +++ b/scripts/config.ts @@ -19,7 +19,6 @@ export default class Config { private static readonly VERSION_PATTERN: RegExp = /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u; public static readonly VERSION: string = Config.readVersion(); - /** The first version a sibling's peer range leaves out: the next minor version, since every package moves to each version together. */ public static readonly NEXT_MINOR: string = Config.nextMinor(Config.VERSION); public static readonly BUILD_FOLDER: string = "_build/node"; public static readonly COVERAGE_FOLDER: string = "_build/coverage"; diff --git a/src/noldova.context.core/src/intrinsics/core-copy.ts b/src/noldova.context.core/src/intrinsics/core-copy.ts index b3d82046..1190c3d7 100644 --- a/src/noldova.context.core/src/intrinsics/core-copy.ts +++ b/src/noldova.context.core/src/intrinsics/core-copy.ts @@ -11,10 +11,7 @@ import { Resources } from "../resources.js"; // The one copy of this package an app may load. A copy that finds the extensions another copy added refuses to load, rather than failing to add // them again. It names no folder, since a module's own location is host plumbing outside the ECMAScript library this package compiles against. export class CoreCopy { - /** - * Throws an Error if another copy of the package already added its extensions. It runs before this copy adds them, so it reads none of them - * and calls no nameof. - */ + // It runs before this copy adds its extensions, so it reads none of them and calls no nameof. public static claim(): void { if (Object.getOwnPropertyDescriptor(Object, Resources.isFunctionName) !== undefined) throw new Error(Resources.coreCopiesLoaded); From de840671456d0fbb050213abbbef3a0f43c9c6f2 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 16:45:57 -0800 Subject: [PATCH 09/12] Return the packed archives' names as a named class, not a tuple Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- .../tests/api/packed-install.test.ts | 16 +++++++++------- .../tests/fixtures/packed-archives.fixture.ts | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 src/noldova.context.exceptions/tests/fixtures/packed-archives.fixture.ts diff --git a/src/noldova.context.exceptions/tests/api/packed-install.test.ts b/src/noldova.context.exceptions/tests/api/packed-install.test.ts index c996cbbf..21048e08 100644 --- a/src/noldova.context.exceptions/tests/api/packed-install.test.ts +++ b/src/noldova.context.exceptions/tests/api/packed-install.test.ts @@ -17,6 +17,8 @@ import { promisify } from "node:util"; import { Assert, TestClass, TestMethod } from "@noldova/context.testing"; +import { PackedArchives } from "../fixtures/packed-archives.fixture.js"; + // The tests that install packed packages into an app with npm: exceptions sharing one core in its peer range, npm refusing a core outside // it, and core refusing to load beside a second copy npm installs. Exceptions' tests hold them since its package needs core installed too. @TestClass @@ -72,9 +74,9 @@ export class PackedInstallTests { bundleDependencies: [PackedInstallTests.CORE_NAME] })); await writeFile(join(dependent, "index.js"), `import "${PackedInstallTests.CORE_NAME}";\n`); - const [dependentArchive, coreArchive] = await PackedInstallTests.packAsync(directory, dependent, PackedInstallTests.CORE_FOLDER); + const archives = await PackedInstallTests.packAsync(directory, dependent, PackedInstallTests.CORE_FOLDER); - const app = await PackedInstallTests.createAppAsync(directory, { [PackedInstallTests.CORE_NAME]: `file:../${coreArchive}`, dependent: `file:../${dependentArchive}` }); + const app = await PackedInstallTests.createAppAsync(directory, { [PackedInstallTests.CORE_NAME]: `file:../${archives.second}`, dependent: `file:../${archives.first}` }); await PackedInstallTests.runNpmAsync(PackedInstallTests.INSTALL_VALUES, app, directory); const scriptPath = join(app, "main.js"); await writeFile(scriptPath, @@ -113,11 +115,11 @@ export class PackedInstallTests { await cp(PackedInstallTests.CORE_FOLDER, core, { recursive: true }); const coreManifest = join(core, "package.json"); await writeFile(coreManifest, (await readFile(coreManifest, "utf8")).replace(PackedInstallTests.VERSION_FIELD, `"version": "${coreVersion}"`)); - const [coreArchive, exceptionsArchive] = await PackedInstallTests.packAsync(directory, core, PackedInstallTests.EXCEPTIONS_FOLDER); + const archives = await PackedInstallTests.packAsync(directory, core, PackedInstallTests.EXCEPTIONS_FOLDER); return PackedInstallTests.createAppAsync(directory, { - [PackedInstallTests.CORE_NAME]: `file:../${coreArchive}`, - [PackedInstallTests.EXCEPTIONS_NAME]: `file:../${exceptionsArchive}` + [PackedInstallTests.CORE_NAME]: `file:../${archives.first}`, + [PackedInstallTests.EXCEPTIONS_NAME]: `file:../${archives.second}` }); } @@ -130,14 +132,14 @@ export class PackedInstallTests { } // Packs the two package folders into the directory in one npm call and returns their archives' names, in the same order. - private static async packAsync(directory: string, first: string, second: string): Promise<[string, string]> { + private static async packAsync(directory: string, first: string, second: string): Promise { const packed = await PackedInstallTests.runNpmAsync(["pack", "--silent", "--pack-destination", directory, first, second], directory, directory); const [firstArchive, secondArchive, ...others] = packed.trim().split(/\s+/u); Assert.isDefined(firstArchive, packed); Assert.isDefined(secondArchive, packed); Assert.areEqual(0, others.length, packed); - return [firstArchive, secondArchive]; + return new PackedArchives(firstArchive, secondArchive); } private static async withDirectoryAsync(action: (directory: string) => Promise): Promise { diff --git a/src/noldova.context.exceptions/tests/fixtures/packed-archives.fixture.ts b/src/noldova.context.exceptions/tests/fixtures/packed-archives.fixture.ts new file mode 100644 index 00000000..27acaab5 --- /dev/null +++ b/src/noldova.context.exceptions/tests/fixtures/packed-archives.fixture.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright (c) Noldova. + * + * This source code is licensed under the license found in the + * LICENSE file in the root directory of this source tree. + */ + +// The names of the two archives one npm pack call writes, in the order of the folders it was given. +export class PackedArchives { + public readonly first: string; + public readonly second: string; + + public constructor(first: string, second: string) { + this.first = first; + this.second = second; + } +} From 6feace2d345b23e75c4e6c7f39a9ae266e266948 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 16:59:27 -0800 Subject: [PATCH 10/12] List the parallel test runner's tests among the slower files Their file's slowest median over three timing runs was 3072 ms, on the macOS test machine (3013 ms on Windows; within the budget on Linux), since each test starts worker processes. The two entries the check names as stale on some machines stay: postgresql-pool.server.test.js took 3858 ms on Windows and parameter-list.parser.test.js 3713 ms on macOS. README now says a file stays listed while its slowest machine needs it. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 28986e11..9d4a3eb3 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Main's run at 8187a0b, the first to print them, had its slowest test on macOS x6 The highest time seen is from the runs of the change that set the limit, before it merged: 15.2 s on macOS x64, `ParameterListParserTests.readsTheFirstOperandOfALongChainInASignatureInitializer[1]`, in one run of three, where the same test took under 5 s in the other two. 60 seconds is 3.9 times that. `npm run test:timing` is the timing check, apart from `npm test` and the checks below since its pass condition is a time: it runs the built suite three times without coverage, takes each test file's median time, the sum of its tests' durations, and fails a file over 2 seconds, or a listed file over the time the list gives it, each with a margin of 50%, since a busy machine slows every file and the median of three runs drops one slow run but not a slower machine. -It also names, as stale, a listed file whose median is within the 2 seconds, plus the margin, that an unlisted file may take, so the list drops it; a stale file fails nothing. +It also names, as stale, a listed file whose median is within the 2 seconds, plus the margin, that an unlisted file may take; a stale file fails nothing, and the list drops it once it is stale on every machine. Test files slower than the timing check's budget: @@ -152,8 +152,10 @@ Test files slower than the timing check's budget: | `noldova.context.testing/services/execution/test-run-entry-point.test.js` | 5000 ms | Each of its tests starts a whole run in a child process. | | `noldova.context.data.sql.postgresql/models/client/postgresql-pool.server.test.js` | 4000 ms | It runs queries against a real PostgreSQL server and waits out its real timeouts. | | `noldova.context.parsers.typescript/services/parsers/declarations/parameter-list.parser.test.js` | 3500 ms | Its long-chain tests parse signature initializers large enough to show the work stays linear. | +| `noldova.context.testing/services/execution/parallel-test-runner.test.js` | 3100 ms | Each of its tests starts worker processes to show the runner handling one way a worker can fail or finish. | Each time is the file's slowest median over three runs on a Linux, a Windows and a macOS test machine, rounded up to 100 ms. +A file stays listed while its slowest machine needs it, so the timing check may name it as stale on a faster one. | Check | What it verifies | |---|---| From e8b6ccfcb86d16112388e855ed0973b1519ec713 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 17:11:45 -0800 Subject: [PATCH 11/12] List the parallel test runner's file at its slowest median, 3400 ms The timing runs at 6feace2 gave it 3384 ms on the Windows test machine, above the 3072 ms of the earlier runs. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9d4a3eb3..2d79b57a 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ Test files slower than the timing check's budget: | `noldova.context.testing/services/execution/test-run-entry-point.test.js` | 5000 ms | Each of its tests starts a whole run in a child process. | | `noldova.context.data.sql.postgresql/models/client/postgresql-pool.server.test.js` | 4000 ms | It runs queries against a real PostgreSQL server and waits out its real timeouts. | | `noldova.context.parsers.typescript/services/parsers/declarations/parameter-list.parser.test.js` | 3500 ms | Its long-chain tests parse signature initializers large enough to show the work stays linear. | -| `noldova.context.testing/services/execution/parallel-test-runner.test.js` | 3100 ms | Each of its tests starts worker processes to show the runner handling one way a worker can fail or finish. | +| `noldova.context.testing/services/execution/parallel-test-runner.test.js` | 3400 ms | Each of its tests starts worker processes to show the runner handling one way a worker can fail or finish. | Each time is the file's slowest median over three runs on a Linux, a Windows and a macOS test machine, rounded up to 100 ms. A file stays listed while its slowest machine needs it, so the timing check may name it as stale on a faster one. From 67dccbf8c3ddddcc6b6cda914a72134ad8d72530 Mon Sep 17 00:00:00 2001 From: Rostislav Rotaru Date: Sat, 14 Nov 2026 17:21:09 -0800 Subject: [PATCH 12/12] Say a stale file is dropped only once it is stale on every machine The timing check's stale line told the machine that ran it to drop the file from the list, though the list keeps each file at its slowest median over several machines and a slower one may still need it. The line now says the file is within the budget on this machine and that the list drops it once it is stale on every machine the list was measured on; TestFileTiming.isStale's documentation says the same. Agent: DEV1 (developer) Agent-Model: Anthropic Opus 5.5, Medium --- src/noldova.context.testing/src/api/index.d.ts | 3 ++- src/noldova.context.testing/src/resources.ts | 2 +- .../tests/models/timing/test-file-timing.test.ts | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/noldova.context.testing/src/api/index.d.ts b/src/noldova.context.testing/src/api/index.d.ts index 49c59a74..6ee093c0 100644 --- a/src/noldova.context.testing/src/api/index.d.ts +++ b/src/noldova.context.testing/src/api/index.d.ts @@ -842,7 +842,8 @@ export declare class TestFileTiming { public readonly isOver: boolean; /** - * True when the suite lists the file but its median is within the time an unlisted file may take, so the list should drop it. + * True when the suite lists the file but its median on this machine is within the time an unlisted file may take. A list measured on + * several machines drops the file only once it is stale on every one of them. */ public readonly isStale: boolean; diff --git a/src/noldova.context.testing/src/resources.ts b/src/noldova.context.testing/src/resources.ts index 335195e9..55e9fe70 100644 --- a/src/noldova.context.testing/src/resources.ts +++ b/src/noldova.context.testing/src/resources.ts @@ -242,7 +242,7 @@ export class Resources { } public static fileTimingStale(file: string, median: string, budgetLimitMilliseconds: number): string { - return `Stale: ${file}, median ${median}, within the ${Math.round(budgetLimitMilliseconds)} ms an unlisted file may take; drop it from the list.`; + return `Stale: ${file}, median ${median}, within the ${Math.round(budgetLimitMilliseconds)} ms an unlisted file may take on this machine; the list drops it once it is stale on every machine the list was measured on.`; } public static fileTimingWithin(file: string, median: string, limitMilliseconds: number): string { diff --git a/src/noldova.context.testing/tests/models/timing/test-file-timing.test.ts b/src/noldova.context.testing/tests/models/timing/test-file-timing.test.ts index 6908ad5a..c0749d7f 100644 --- a/src/noldova.context.testing/tests/models/timing/test-file-timing.test.ts +++ b/src/noldova.context.testing/tests/models/timing/test-file-timing.test.ts @@ -34,7 +34,8 @@ export class TestFileTimingTests { const unlisted = new TestFileTiming("Sample/a.test.js", 900, 1200, 1200, false); Assert.areEqual("true,false,false", [stale.isStale, slower.isStale, unlisted.isStale].join()); - Assert.areEqual("Stale: Sample/fast.test.js, median 1200 ms, within the 1200 ms an unlisted file may take; drop it from the list.", stale.format()); + Assert.areEqual("Stale: Sample/fast.test.js, median 1200 ms, within the 1200 ms an unlisted file may take on this machine; " + + "the list drops it once it is stale on every machine the list was measured on.", stale.format()); Assert.areEqual("Within: Sample/slow.test.js, median 1201 ms, limit 3000 ms.", slower.format()); Assert.areEqual("Within: Sample/a.test.js, median 900 ms, limit 1200 ms.", unlisted.format()); }