@effected/workspaces 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PackedInstall.js +530 -51
- package/README.md +67 -18
- package/SourceBoundary.js +87 -3
- package/internal/packedInstallPlan.js +73 -8
- package/internal/sourceText.js +32 -6
- package/package.json +1 -1
- package/testing.d.ts +484 -29
package/README.md
CHANGED
|
@@ -347,6 +347,7 @@ describe("source boundary", () => {
|
|
|
347
347
|
| `"console"` | any reference to the global `console` |
|
|
348
348
|
| `"console-stdout"` | a reference to the global `console`, except a member access to a method Node writes to stderr: `error`, `warn`, `trace`, `assert`. A bare or aliased reference (`const c = console`, `f(console)`, `console[m]`) is flagged, since it can reach `log` |
|
|
349
349
|
| `{ forbidImports }` | an import equal to an entry, a subpath of one, or starting with an entry's text before a trailing `*` |
|
|
350
|
+
| `{ forbidTokens }` | an entry's exact text in code (never in a comment, string, template text or regex), as whole text: not inside a longer identifier |
|
|
350
351
|
|
|
351
352
|
Neither console rule flags core's `Console` service. `"console-stdout"` suits a stdio server that must keep stdout for its protocol but may log to stderr.
|
|
352
353
|
|
|
@@ -365,11 +366,30 @@ assert.deepStrictEqual([...new Set(scan.waived.map((offence) => offence.file))],
|
|
|
365
366
|
|
|
366
367
|
Both kinds of glob compile with `@effected/glob` and match the scan's relative, `/`-separated path; an uncompilable one fails the scan with `GlobPatternError`.
|
|
367
368
|
|
|
369
|
+
To confine a token to named files rather than forbid it everywhere, forbid it with `{ forbidTokens }` and waive that rule for those files. The `"process"` rule exempts the build-time version define everywhere; this holds it to `version.ts`, and `waived` proves the confinement still matches something:
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
const TOKEN = "process.env.__PACKAGE_VERSION__";
|
|
373
|
+
const scan = yield* SourceBoundary.scan({
|
|
374
|
+
root: SRC,
|
|
375
|
+
rules: ["process", { forbidTokens: [TOKEN] }],
|
|
376
|
+
allowRules: { forbidTokens: ["version.ts"] },
|
|
377
|
+
});
|
|
378
|
+
assert.deepStrictEqual(scan.violations, []);
|
|
379
|
+
assert.deepStrictEqual(
|
|
380
|
+
scan.waived.map((offence) => offence.file),
|
|
381
|
+
["version.ts"],
|
|
382
|
+
);
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`version.ts` names the file at the root; `**/version.ts` names one at any depth. One `"forbidTokens"` key covers every `{ forbidTokens }` rule in a scan, so two tokens with different homes take two scans.
|
|
386
|
+
|
|
368
387
|
The scanner is a lexer, not a type checker. Its known limits:
|
|
369
388
|
|
|
370
389
|
- There is no scope analysis, so any local binding named `process` or `console` is flagged like the global: a parameter (`(process: Handle) => process.kill()`), a variable, a label, or an unannotated class field (`process = 1`). An annotated class field (`process: T`) reads as a type member and is spared. Prefer renaming the binding; failing that, waive that one rule for the file with `allowRules` and assert `scan.waived` names exactly it.
|
|
371
390
|
- `globalThis["process"]` and `const { process: p } = globalThis` are not seen.
|
|
372
391
|
- A regex literal directly after a block-closing `}` reads as a division.
|
|
392
|
+
- A variable named `yield` or `await` in a sloppy-mode script reads as the keyword, so a `/` after it opens a regex. Module and strict code reserve both words, so a module never hits this.
|
|
373
393
|
- JSX text reads as code, which is why `.tsx` and `.jsx` are not scanned by default.
|
|
374
394
|
- `forbidImports: ["node:*"]` matches only the `node:` spelling, not a bare `"fs"`. To forbid both, spread Node's list in the test file: `{ forbidImports: ["node:*", ...builtinModules] }`, with `builtinModules` from `node:module`. That also forbids npm packages named like a built-in (`events`, `buffer`).
|
|
375
395
|
|
|
@@ -377,7 +397,7 @@ The scanner is a lexer, not a type checker. Its known limits:
|
|
|
377
397
|
|
|
378
398
|
`PackedInstall.run` packs a carrier and every workspace package it needs, then installs the carrier into a fresh project outside the workspace under each package manager that answers `--version`, and checks every expected bin is linked and executable. It carries the traps each manager sets: the parent run's `npm_*` and `pnpm_config_*` context is scrubbed, pnpm's overrides go in `pnpm-workspace.yaml`, Yarn Berry gets the `node-modules` linker, `packageManager` is pinned to the probed version, and lifecycle scripts are skipped. By default it packs each package's built `dist/prod/npm/pkg`, the artifact a release publishes; build first.
|
|
379
399
|
|
|
380
|
-
It does not run the bins; the test does, inside the same scope, because the scratch directory is removed when the scope closes. For an MCP server bin, `McpProbe` from `@effected/mcp/testing` is the proof:
|
|
400
|
+
It does not run the bins; the test does, inside the same scope, because the scratch directory (`result.scratch`) is removed when the scope closes. `consumer.runBin(name, args, options?)` runs one to completion and returns `{ stdout, stderr, exitCode }`, with no direct `@effected/commands` dependency. `consumer.command(name, args?, options?)` returns the `ChildProcess` command `runBin` builds, same environment and working directory, with stdin left open, so a probe that writes to the bin can spawn it. `consumer.binProvenance(name)` names the package a `.bin` symlink resolves into. `PackedInstall.closure(carrier, options?)` names every package the run will pack before it runs, and `PackedInstall.timeoutBudget` sizes the outer timeout from those names and the run's own ceilings. For an MCP server bin, `McpProbe` from `@effected/mcp/testing` is the proof:
|
|
381
401
|
|
|
382
402
|
```ts
|
|
383
403
|
// __test__/e2e/packed-install.e2e.test.ts, two levels below the workspace root
|
|
@@ -388,49 +408,78 @@ import { assert, describe, layer } from "@effect/vitest";
|
|
|
388
408
|
import { McpProbe } from "@effected/mcp/testing";
|
|
389
409
|
import { Workspaces } from "@effected/workspaces";
|
|
390
410
|
import { PackedInstall } from "@effected/workspaces/testing";
|
|
391
|
-
import { Effect, Layer } from "effect";
|
|
392
|
-
import { ChildProcess } from "effect/unstable/process";
|
|
411
|
+
import { Duration, Effect, Layer } from "effect";
|
|
393
412
|
|
|
394
413
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
395
414
|
const Live = Workspaces.layer({ cwd: ROOT }).pipe(Layer.provideMerge(NodeServices.layer));
|
|
396
415
|
|
|
416
|
+
const MANAGERS = ["npm", "pnpm", "yarn", "bun"] as const;
|
|
417
|
+
const INSTALL_TIMEOUT = "2 minutes";
|
|
418
|
+
const PACK_TIMEOUT = "1 minute";
|
|
419
|
+
const BIN_TIMEOUT = "30 seconds";
|
|
420
|
+
// The packages the run will pack, known before the test is declared.
|
|
421
|
+
const PACKAGES = await Effect.runPromise(PackedInstall.closure("my-tool").pipe(Effect.provide(Live)));
|
|
422
|
+
// Every ceiling the run sets, plus the two bin runs per consumer. The vitest
|
|
423
|
+
// timeout sits above it, so a named PackedInstallError fires before either guard.
|
|
424
|
+
const BUDGET = PackedInstall.timeoutBudget({
|
|
425
|
+
managers: MANAGERS,
|
|
426
|
+
installTimeout: INSTALL_TIMEOUT,
|
|
427
|
+
packTimeout: PACK_TIMEOUT,
|
|
428
|
+
packages: PACKAGES,
|
|
429
|
+
perConsumer: "1 minute",
|
|
430
|
+
});
|
|
431
|
+
|
|
397
432
|
describe("packed install", () => {
|
|
398
433
|
// The real clock: installs and the probe are real processes.
|
|
399
434
|
layer(Live, { excludeTestServices: true })((it) => {
|
|
400
435
|
it.effect(
|
|
401
|
-
"my-tool's
|
|
436
|
+
"my-tool's bins work from a packed install under every available manager",
|
|
402
437
|
() =>
|
|
403
438
|
Effect.gen(function* () {
|
|
404
439
|
const result = yield* PackedInstall.run({
|
|
405
440
|
carrier: "my-tool",
|
|
406
441
|
closure: "auto",
|
|
407
|
-
managers:
|
|
408
|
-
bins: ["my-tool-mcp"],
|
|
442
|
+
managers: MANAGERS,
|
|
443
|
+
bins: ["my-tool", "my-tool-mcp"],
|
|
409
444
|
env: process.env,
|
|
410
|
-
installTimeout:
|
|
445
|
+
installTimeout: INSTALL_TIMEOUT,
|
|
446
|
+
packTimeout: PACK_TIMEOUT,
|
|
411
447
|
});
|
|
448
|
+
assert.deepStrictEqual(Object.keys(result.tarballs), [...PACKAGES]);
|
|
412
449
|
assert.isAbove(result.consumers.length, 0, `nothing installed; unavailable: ${result.unavailable.join(", ")}`);
|
|
413
|
-
|
|
450
|
+
// The tool's data lives inside the scratch root and is removed with it.
|
|
451
|
+
const env = { XDG_DATA_HOME: `${result.scratch}/xdg` };
|
|
414
452
|
for (const consumer of result.consumers) {
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
453
|
+
const version = yield* consumer.runBin("my-tool", ["--version"], { env, timeout: BIN_TIMEOUT });
|
|
454
|
+
assert.strictEqual(version.exitCode, 0, `${consumer.manager}: ${version.stderr}`);
|
|
455
|
+
// Only the carrier declares my-tool (the run fails BinConflict otherwise, unless
|
|
456
|
+
// allowSharedBins), so the .bin link is the carrier's. pnpm's shims are not read: undefined.
|
|
457
|
+
const provenance = yield* consumer.binProvenance("my-tool");
|
|
458
|
+
if (consumer.manager !== "pnpm") assert.strictEqual(provenance?.package, "my-tool", consumer.manager);
|
|
459
|
+
const probe = McpProbe.initialize(consumer.command("my-tool-mcp", [], { env }));
|
|
460
|
+
const { response, stderr, exitCode } = yield* probe.pipe(Effect.timeout(BIN_TIMEOUT));
|
|
421
461
|
assert.isUndefined(response.error, `${consumer.manager}: initialize was refused`);
|
|
422
462
|
assert.strictEqual(stderr, "", `${consumer.manager}: stderr`);
|
|
423
463
|
assert.strictEqual(exitCode, 0, `${consumer.manager}: exit code`);
|
|
424
464
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
780_000,
|
|
465
|
+
}).pipe(Effect.timeout(BUDGET)),
|
|
466
|
+
Duration.toMillis(BUDGET) + 60_000,
|
|
428
467
|
);
|
|
429
468
|
});
|
|
430
469
|
});
|
|
431
470
|
```
|
|
432
471
|
|
|
433
|
-
Pass `process.env` in: nothing in the subpath reads `process` itself.
|
|
472
|
+
Pass `process.env` in: nothing in the subpath reads `process` itself. `runBin` and `command` run under the environment the install ran under, with `options.env` layered over it after the parent manager's context is stripped, so an explicit entry such as `CI: "true"` wins and a key set to `undefined` removes a variable; a non-zero exit is a result, and a bin that cannot spawn or outlives its ceiling (one minute by default) fails `BinFailed`. `PackedInstall.scrubEnv(...)` returns the same scrubbed environment for any other spawn. A requested manager that is not installed lands in `result.unavailable`; pass `require: "all"` to make that a failure instead. Declare in `consumerDependencies` every package the consumer's own code imports besides the carrier: pnpm links only declared dependencies at a project's top level. An entry that names a packed package is written as its `file:` tarball whatever spec you pass, so any range will do; npm fails `EOVERRIDE` when a direct spec differs from its override.
|
|
473
|
+
|
|
474
|
+
By default only the carrier may declare its bins. If any other packed package declares one of the carrier's bin names, the run fails `BinConflict` before installing: under npm, bun and Yarn's flat layouts either package can take the `.bin` slot (npm and bun were observed to link the package whose name sorts first; Yarn 1 and 4 kept the carrier's, the consumer's direct dependency), so the bin that runs may carry no carrier identity, and the bin check and every bin run could pass on the wrong package. The check reads the `bin` field of the packed packages only (the carrier, the closure and `overrides`): it does not read `directories.bin`, and a registry dependency declaring the same name goes undetected.
|
|
475
|
+
|
|
476
|
+
Sharing bin names is a supported choice for a carrier whose front ends are also installed on their own: pass `allowSharedBins: true`. The expected bins are still verified present, but `runBin` may then run a front end's bin. `consumer.runCarrierBin(name, args?, options?)` runs the carrier's own bin whichever package took the slot: it resolves `name` through the carrier's installed `package.json` `bin` map and runs that file with `node`, under the same environment as `runBin`. Running through `node` assumes a Node script, drops any shebang flags, and bypasses the executable bit, so it proves the carrier's shim runs, not that it is executable. `consumer.carrierCommand(name, args?, options?)` returns that command, stdin open, for `McpProbe`.
|
|
477
|
+
|
|
478
|
+
When a closure member needs a dependency the registry does not have yet, for example a sibling checkout's unreleased build linked through a dogfood `file:` override, pass it in `overrides`: package name to a publish-ready package directory, which is `npm pack`ed, or to a `.tgz`, which is used as it is. A relative path resolves against the workspace root. Every consumer steers that package to its tarball through the same override field as the closure, so the carrier's transitive references install it whatever range they ask for, and it joins `result.tarballs`. `workspaceOverrides: true` also takes every `"<name>": "file:<path>"` entry of the root `pnpm-workspace.yaml`'s `overrides:`, which is the link shape a dogfood loop writes; an explicit `overrides` entry wins over one read there. An override naming the carrier or a closure member, a path that is neither a package directory nor a `.tgz`, or a tarball whose manifest has another name fails `InvalidOverride`.
|
|
479
|
+
|
|
480
|
+
The installs run one after another. `timeoutBudget` adds each manager's `--version` probe (30 seconds), install (`installTimeout`, four minutes by default) and `perConsumer` (one minute by default, one `runBin` at its default ceiling; add a minute per further bin run, or pass `"0 seconds"` for a test that only installs), each package's pack (`packTimeout`, two minutes by default) and manifest read (30 seconds), 30 seconds for the untimed steps, and one minute for cleanup (removing the scratch root when the scope closes, and killing a child after its ceiling interrupts it); a tighter outer guard fires first, as a `TimeoutError` that names no manager. `packages` is a count or the names `PackedInstall.closure` returns for the run's options: pass the run's own options object as the second argument when it sets `closure`, `overrides` or `workspaceOverrides`, and the names equal `Object.keys(result.tarballs)` in order. `PackedInstall.timeoutBudgetFor(runOptions, { perConsumer? })` does both in one Effect, reading `managers`, `installTimeout` and `packTimeout` from the same object. A vitest test's timeout is fixed when it is declared, so await either at module evaluation, as the example above does.
|
|
481
|
+
|
|
482
|
+
`binProvenance` reads the `.bin` entry as a symlink, which npm, bun and Yarn (under the `node-modules` linker the run configures) write, realpaths it, and walks up to the nearest `package.json` with a string `name`, inside the (realpath'd) consumer directory. `undefined` means only that the entry exists and is not a symlink, which is a pnpm shim. An entry that does not exist, or a link to nothing, fails `MissingBin`; a link into no named package inside the consumer fails `UnownedBin`; any other read failure fails `Io`. `PackedInstall` is POSIX-only and fails `UnsupportedPlatform` elsewhere.
|
|
434
483
|
|
|
435
484
|
## Error handling
|
|
436
485
|
|
package/SourceBoundary.js
CHANGED
|
@@ -22,9 +22,10 @@ var Offence = class extends Schema.Class("Offence")({
|
|
|
22
22
|
"stdout-write",
|
|
23
23
|
"console",
|
|
24
24
|
"console-stdout",
|
|
25
|
-
"forbidImports"
|
|
25
|
+
"forbidImports",
|
|
26
|
+
"forbidTokens"
|
|
26
27
|
]),
|
|
27
|
-
/** What matched: the identifier, the call,
|
|
28
|
+
/** What matched: the identifier, the call, the import specifier, or the token. */
|
|
28
29
|
detail: Schema.String
|
|
29
30
|
}) {
|
|
30
31
|
/** `file:line:column rule detail`, the form an assertion message reads best in. */
|
|
@@ -112,6 +113,23 @@ const memberAfter = (code, at, name) => {
|
|
|
112
113
|
while (isIdentifierChar(code[end])) end++;
|
|
113
114
|
return end > j ? code.slice(j, end) : void 0;
|
|
114
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* Every offset where `token` appears in `code` as whole text: a token that
|
|
118
|
+
* starts with an identifier character must not continue one (nor follow `#`),
|
|
119
|
+
* and one that ends with an identifier character must not run into another.
|
|
120
|
+
*/
|
|
121
|
+
const tokenOccurrences = (code, token) => {
|
|
122
|
+
if (token.length === 0) return [];
|
|
123
|
+
const guardStart = isIdentifierChar(token[0]);
|
|
124
|
+
const guardEnd = isIdentifierChar(token[token.length - 1]);
|
|
125
|
+
const found = [];
|
|
126
|
+
for (let at = code.indexOf(token); at !== -1; at = code.indexOf(token, at + 1)) {
|
|
127
|
+
if (guardStart && (isIdentifierChar(code[at - 1]) || code[at - 1] === "#")) continue;
|
|
128
|
+
if (guardEnd && isIdentifierChar(code[at + token.length])) continue;
|
|
129
|
+
found.push(at);
|
|
130
|
+
}
|
|
131
|
+
return found;
|
|
132
|
+
};
|
|
115
133
|
const forbids = (entry, specifier) => entry.endsWith("*") ? specifier.startsWith(entry.slice(0, -1)) : specifier === entry || specifier.startsWith(`${entry}/`);
|
|
116
134
|
const isNodeModule = (specifier, module) => [module, `node:${module}`].some((name) => specifier === name || specifier.startsWith(`${name}/`));
|
|
117
135
|
const FIXTURES = [
|
|
@@ -348,6 +366,48 @@ const FIXTURES = [
|
|
|
348
366
|
source: "// import { NodeServices } from \"@effect/platform-node\";",
|
|
349
367
|
rule: { forbidImports: ["@effect/platform*"] },
|
|
350
368
|
flagged: false
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "forbidTokens: the build-time version constant",
|
|
372
|
+
source: "export const version = process.env.__PACKAGE_VERSION__;",
|
|
373
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
374
|
+
flagged: true
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "forbidTokens: the token reached through globalThis",
|
|
378
|
+
source: "const version = globalThis.process.env.__PACKAGE_VERSION__;",
|
|
379
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
380
|
+
flagged: true
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
name: "forbidTokens: a string and a comment",
|
|
384
|
+
source: "const s = \"process.env.__PACKAGE_VERSION__\"; // process.env.__PACKAGE_VERSION__",
|
|
385
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
386
|
+
flagged: false
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "forbidTokens: a longer identifier",
|
|
390
|
+
source: "const v = process.env.__PACKAGE_VERSION__X;",
|
|
391
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
392
|
+
flagged: false
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: "forbidTokens: the tail of a longer identifier",
|
|
396
|
+
source: "const v = xprocess.env.__PACKAGE_VERSION__;",
|
|
397
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
398
|
+
flagged: false
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: "forbidTokens: after a variable named of, divided",
|
|
402
|
+
source: "const of = 8; const x = of / 2; const v = process.env.__PACKAGE_VERSION__; const y = 8 / 2;",
|
|
403
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
404
|
+
flagged: true
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
name: "forbidTokens: a private field",
|
|
408
|
+
source: "const v = this.#process.env.__PACKAGE_VERSION__;",
|
|
409
|
+
rule: { forbidTokens: ["process.env.__PACKAGE_VERSION__"] },
|
|
410
|
+
flagged: false
|
|
351
411
|
}
|
|
352
412
|
];
|
|
353
413
|
/**
|
|
@@ -383,6 +443,10 @@ const FIXTURES = [
|
|
|
383
443
|
*
|
|
384
444
|
* - JSX text reads as code;
|
|
385
445
|
*
|
|
446
|
+
* - a variable named `yield` or `await` in a sloppy-mode script reads as the
|
|
447
|
+
* keyword, so a `/` after it opens a regex (module and strict code reserve
|
|
448
|
+
* both words);
|
|
449
|
+
*
|
|
386
450
|
* - `forbidImports: ["node:*"]` does not catch a bare built-in such as
|
|
387
451
|
* `"fs"` (see {@link BoundaryRule}).
|
|
388
452
|
*
|
|
@@ -412,7 +476,22 @@ var SourceBoundary = class SourceBoundary {
|
|
|
412
476
|
static importSpecifiers = (text) => specifierLiterals(lex(text)).map((literal) => literal.value);
|
|
413
477
|
/** Whether `text` imports the Node built-in `module`, in either its `node:` or bare form. */
|
|
414
478
|
static importsNode = (text, module) => SourceBoundary.importSpecifiers(text).some((specifier) => isNodeModule(specifier, module));
|
|
415
|
-
/**
|
|
479
|
+
/**
|
|
480
|
+
* Every place `text` breaks one of `rules`, in source order, attributed to `file`.
|
|
481
|
+
*
|
|
482
|
+
* @remarks
|
|
483
|
+
* A `{ forbidTokens }` entry matches its exact text in code only: comments,
|
|
484
|
+
* strings, template text and regex bodies are blanked first, so a token that
|
|
485
|
+
* itself contains a string literal never matches, and whitespace inside a
|
|
486
|
+
* token must match byte for byte. A token that starts with an identifier
|
|
487
|
+
* character does not match inside a longer identifier or after `#`, and one
|
|
488
|
+
* that ends with one does not match when an identifier character follows.
|
|
489
|
+
* A member access is still a match: `globalThis.process.env.X` contains the
|
|
490
|
+
* token `process.env.X`. A token listed twice is reported once. A token with
|
|
491
|
+
* no identifier character at either end (`"=>"`, `"?."`) has no edge guard,
|
|
492
|
+
* so it matches inside longer punctuation, and consecutive matches of it can
|
|
493
|
+
* overlap.
|
|
494
|
+
*/
|
|
416
495
|
static check = (file, text, rules, options) => {
|
|
417
496
|
const lexed = lex(text);
|
|
418
497
|
const at = locate(text);
|
|
@@ -448,6 +527,11 @@ var SourceBoundary = class SourceBoundary {
|
|
|
448
527
|
detail: member === void 0 ? "console" : `console.${member}`
|
|
449
528
|
});
|
|
450
529
|
}
|
|
530
|
+
else if ("forbidTokens" in rule) for (const token of new Set(rule.forbidTokens)) for (const offset of tokenOccurrences(lexed.code, token)) found.push({
|
|
531
|
+
offset,
|
|
532
|
+
rule: "forbidTokens",
|
|
533
|
+
detail: token
|
|
534
|
+
});
|
|
451
535
|
else for (const literal of specifiers) if (rule.forbidImports.some((entry) => forbids(entry, literal.value))) found.push({
|
|
452
536
|
offset: literal.start,
|
|
453
537
|
rule: "forbidImports",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RUNTIME_DEPENDENCY_FIELDS } from "./dependencyFields.js";
|
|
2
|
-
import { Result } from "effect";
|
|
2
|
+
import { Predicate, Result } from "effect";
|
|
3
3
|
|
|
4
4
|
//#region src/internal/packedInstallPlan.ts
|
|
5
5
|
/** Variables that carry the PARENT run's context into a child package manager. */
|
|
@@ -153,22 +153,87 @@ const installArgs = (manager, version) => {
|
|
|
153
153
|
* is left alone: it resolves wherever the file exists.
|
|
154
154
|
*/
|
|
155
155
|
const UNRESOLVABLE = /^(?:workspace:|catalog:|link:|file:(?!\/))/;
|
|
156
|
-
/**
|
|
157
|
-
const
|
|
156
|
+
/** Parse a packed manifest into the facts the run checks; the failure is the parse error or a non-object. */
|
|
157
|
+
const readPackedManifest = (manifestJson) => {
|
|
158
158
|
let manifest;
|
|
159
159
|
try {
|
|
160
160
|
manifest = JSON.parse(manifestJson);
|
|
161
161
|
} catch (cause) {
|
|
162
162
|
return Result.fail(cause);
|
|
163
163
|
}
|
|
164
|
-
if (typeof manifest !== "object" || manifest === null) return Result.fail(/* @__PURE__ */ new Error("package.json is not an object"));
|
|
164
|
+
if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) return Result.fail(/* @__PURE__ */ new Error("package.json is not an object"));
|
|
165
165
|
const record = manifest;
|
|
166
|
-
|
|
166
|
+
const name = typeof record.name === "string" ? record.name : void 0;
|
|
167
|
+
const unresolved = RUNTIME_DEPENDENCY_FIELDS.flatMap((field) => {
|
|
167
168
|
const block = record[field];
|
|
168
169
|
if (typeof block !== "object" || block === null) return [];
|
|
169
|
-
return Object.entries(block).filter(([, spec]) => typeof spec === "string" && UNRESOLVABLE.test(spec)).map(([
|
|
170
|
-
})
|
|
170
|
+
return Object.entries(block).filter(([, spec]) => typeof spec === "string" && UNRESOLVABLE.test(spec)).map(([dependency, spec]) => `${field}.${dependency}: ${String(spec)}`);
|
|
171
|
+
});
|
|
172
|
+
const bin = record.bin;
|
|
173
|
+
const bins = typeof bin === "string" ? name === void 0 ? [] : [name.replace(/^@[^/]+\//, "")] : typeof bin === "object" && bin !== null && !Array.isArray(bin) ? Object.keys(bin) : [];
|
|
174
|
+
return Result.succeed({
|
|
175
|
+
name,
|
|
176
|
+
unresolved,
|
|
177
|
+
bins
|
|
178
|
+
});
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* The file a manifest's `bin` declares for `name`: a `bin` object's entry, or
|
|
182
|
+
* a `bin` string when `name` is the unscoped package name. `undefined` when
|
|
183
|
+
* the manifest is not a JSON object or declares no such bin.
|
|
184
|
+
*/
|
|
185
|
+
const binTargetOf = (manifestJson, name) => {
|
|
186
|
+
let manifest;
|
|
187
|
+
try {
|
|
188
|
+
manifest = JSON.parse(manifestJson);
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!Predicate.isObject(manifest) || Array.isArray(manifest)) return void 0;
|
|
193
|
+
const bin = manifest.bin;
|
|
194
|
+
if (typeof bin === "string") return typeof manifest.name === "string" && manifest.name.replace(/^@[^/]+\//, "") === name ? bin : void 0;
|
|
195
|
+
if (!Predicate.isObject(bin) || Array.isArray(bin)) return void 0;
|
|
196
|
+
const target = bin[name];
|
|
197
|
+
return typeof target === "string" && Object.hasOwn(bin, name) ? target : void 0;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* The first bin the carrier declares that another packed package declares
|
|
201
|
+
* too. Under a flat layout (npm, bun, Yarn's `node-modules` linker) either
|
|
202
|
+
* package can take `node_modules/.bin/<bin>`, so a bin check or a bin run
|
|
203
|
+
* could pass on the wrong package. `PackedInstall.run` refuses it unless the
|
|
204
|
+
* caller shares bin names deliberately (`allowSharedBins`).
|
|
205
|
+
*/
|
|
206
|
+
const binConflict = (carrier, others) => {
|
|
207
|
+
for (const bin of carrier.bins) {
|
|
208
|
+
const other = others.find((pkg) => pkg.name !== carrier.name && pkg.bins.includes(bin));
|
|
209
|
+
if (other !== void 0) return {
|
|
210
|
+
bin,
|
|
211
|
+
package: other.name
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
/** A bare package name, scoped or not: an override key carrying a selector (`a>b`, `a@1`) is not one. */
|
|
216
|
+
const BARE_NAME = /^(?:@[^/@\s>]+\/)?[^/@\s>]+$/;
|
|
217
|
+
/**
|
|
218
|
+
* The `file:` entries of a parsed `pnpm-workspace.yaml`'s `overrides:` map,
|
|
219
|
+
* name to the path after `file:` (relative paths are the caller's to resolve,
|
|
220
|
+
* against the workspace root, as pnpm does). Entries that are not strings,
|
|
221
|
+
* not `file:`, or keyed by anything but a bare package name are skipped, and
|
|
222
|
+
* so is `__proto__`, which no npm package can be named. The map has no
|
|
223
|
+
* prototype, so a package named `constructor` or `prototype` is an ordinary
|
|
224
|
+
* own entry and a missing name never resolves to an inherited member.
|
|
225
|
+
*/
|
|
226
|
+
const fileOverridesOf = (document) => {
|
|
227
|
+
const out = Object.create(null);
|
|
228
|
+
if (!Predicate.isObject(document) || !Predicate.isObject(document.overrides) || Array.isArray(document.overrides)) return out;
|
|
229
|
+
for (const [name, spec] of Object.entries(document.overrides)) {
|
|
230
|
+
if (name === "__proto__") continue;
|
|
231
|
+
if (typeof spec === "string" && spec.startsWith("file:") && BARE_NAME.test(name)) out[name] = spec.slice(5);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
171
234
|
};
|
|
235
|
+
/** An `overrides` value without the `file:` prefix a caller may copy from a workspace file. */
|
|
236
|
+
const overridePath = (spec) => spec.startsWith("file:") ? spec.slice(5) : spec;
|
|
172
237
|
|
|
173
238
|
//#endregion
|
|
174
|
-
export { closureOf, consumerFiles, installArgs,
|
|
239
|
+
export { binConflict, binTargetOf, closureOf, consumerFiles, fileOverridesOf, installArgs, overridePath, readPackedManifest, scrubEnv, versionOf };
|
package/internal/sourceText.js
CHANGED
|
@@ -19,6 +19,13 @@ const REGEX_AFTER = /* @__PURE__ */ new Set([
|
|
|
19
19
|
"yield",
|
|
20
20
|
"await"
|
|
21
21
|
]);
|
|
22
|
+
/** Declaration keywords: the word after one is a binding name, so a `for (const of of xs)` binding named `of` is not the keyword. */
|
|
23
|
+
const DECLARATIONS = /* @__PURE__ */ new Set([
|
|
24
|
+
"const",
|
|
25
|
+
"let",
|
|
26
|
+
"var",
|
|
27
|
+
"using"
|
|
28
|
+
]);
|
|
22
29
|
/** Keywords whose parenthesized condition may be followed directly by a statement, so a `/` after its `)` opens a regex. */
|
|
23
30
|
const CONTROL = /* @__PURE__ */ new Set([
|
|
24
31
|
"if",
|
|
@@ -63,26 +70,45 @@ const lex = (text) => {
|
|
|
63
70
|
while (start >= 0 && isIdentifierChar(code[start])) start--;
|
|
64
71
|
return code.slice(start + 1, end + 1).join("");
|
|
65
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Whether the code at offset `j` ends a binding a `for...of` head iterates
|
|
75
|
+
* into: a destructuring pattern, or a name that is neither a declaration
|
|
76
|
+
* keyword nor itself a keyword (`for (x in of / 2)` divides).
|
|
77
|
+
*/
|
|
78
|
+
const endsBinding = (j) => {
|
|
79
|
+
const char = code[j] ?? "";
|
|
80
|
+
if (char === "]" || char === "}") return true;
|
|
81
|
+
return isIdentifierChar(char) && !DECLARATIONS.has(wordAt(j)) && !isRegexKeyword(j);
|
|
82
|
+
};
|
|
83
|
+
/** Whether the identifier ending at code offset `j` is a keyword after which a `/` opens a regex. */
|
|
84
|
+
const isRegexKeyword = (j) => {
|
|
85
|
+
const word = wordAt(j);
|
|
86
|
+
if (!REGEX_AFTER.has(word)) return false;
|
|
87
|
+
const before = lastCode(j - word.length);
|
|
88
|
+
if (code[before] === "." && !(code[before - 1] === "." && code[before - 2] === ".")) return false;
|
|
89
|
+
if (word === "of") return parens.at(-1) === "for" && before >= 0 && endsBinding(before);
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
66
92
|
/** Whether the code at offset `j` ends an operand (a value a postfix operator or a `/` division can follow). */
|
|
67
93
|
const endsOperand = (j) => {
|
|
68
94
|
const char = code[j] ?? "";
|
|
69
|
-
return char === ")" || char === "]" || isIdentifierChar(char) && !
|
|
95
|
+
return char === ")" || char === "]" || isIdentifierChar(char) && !isRegexKeyword(j);
|
|
70
96
|
};
|
|
71
97
|
/** Whether a `/` here opens a regex, judged by the last code token. */
|
|
72
98
|
const regexAllowed = () => {
|
|
73
99
|
const j = lastCode(code.length - 1);
|
|
74
100
|
if (j < 0) return true;
|
|
75
101
|
const last = code[j] ?? "";
|
|
76
|
-
if (isIdentifierChar(last)) return
|
|
102
|
+
if (isIdentifierChar(last)) return isRegexKeyword(j);
|
|
77
103
|
if (last === "!") return !endsOperand(j - 1);
|
|
78
104
|
if ((last === "+" || last === "-") && code[j - 1] === last) return !endsOperand(lastCode(j - 2));
|
|
79
105
|
if (last === ")") return controlCloses.has(j);
|
|
80
106
|
return !"]}\"'`".includes(last);
|
|
81
107
|
};
|
|
82
|
-
/**
|
|
108
|
+
/** The if/while/for/with keyword whose condition the `(` about to be emitted opens, if any. */
|
|
83
109
|
const opensCondition = () => {
|
|
84
110
|
const j = lastCode(code.length - 1);
|
|
85
|
-
if (j < 0 || !isIdentifierChar(code[j])) return
|
|
111
|
+
if (j < 0 || !isIdentifierChar(code[j])) return void 0;
|
|
86
112
|
let word = wordAt(j);
|
|
87
113
|
let end = j - word.length;
|
|
88
114
|
if (word === "await") {
|
|
@@ -90,7 +116,7 @@ const lex = (text) => {
|
|
|
90
116
|
word = end < 0 ? "" : wordAt(end);
|
|
91
117
|
end -= word.length;
|
|
92
118
|
}
|
|
93
|
-
return CONTROL.has(word) && code[lastCode(end)] !== ".";
|
|
119
|
+
return CONTROL.has(word) && code[lastCode(end)] !== "." ? word : void 0;
|
|
94
120
|
};
|
|
95
121
|
/** Consume a regex literal at `i` if one closes on this line; `false` leaves `i` untouched. */
|
|
96
122
|
const regex = () => {
|
|
@@ -212,7 +238,7 @@ const lex = (text) => {
|
|
|
212
238
|
emit(char, true, true);
|
|
213
239
|
i++;
|
|
214
240
|
} else if (char === ")") {
|
|
215
|
-
if (parens.pop()
|
|
241
|
+
if (parens.pop() !== void 0) controlCloses.add(code.length);
|
|
216
242
|
emit(char, true, true);
|
|
217
243
|
i++;
|
|
218
244
|
} else if (char === "{") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
|
|
6
6
|
"keywords": [
|