@effected/workspaces 0.26.0 → 0.28.0-next-20260927062352
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/ConfigDependencyHooks.js +31 -10
- package/ConfigDependencySpec.js +170 -0
- package/PackedInstall.js +530 -51
- package/README.md +67 -18
- package/SourceBoundary.js +87 -3
- package/WorkspaceCatalogs.js +9 -4
- package/WorkspaceSnapshots.js +11 -5
- package/index.d.ts +217 -12
- package/index.js +2 -1
- package/internal/configDependencyFetch.js +146 -0
- package/internal/configDependencyResolution.js +83 -57
- package/internal/configDependencyShared.js +51 -0
- package/internal/configDependencySpecGrammar.js +15 -0
- package/internal/packedInstallPlan.js +73 -8
- package/internal/sourceText.js +32 -6
- package/package.json +4 -4
- 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",
|
package/WorkspaceCatalogs.js
CHANGED
|
@@ -7,6 +7,7 @@ import { configDependenciesOf, inlinePeerDependencyRules } from "./internal/work
|
|
|
7
7
|
import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
|
|
8
8
|
import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, PartialReleaseAgeGate, ReleaseAgeGate } from "@effected/npm";
|
|
9
9
|
import { Yaml } from "@effected/yaml";
|
|
10
|
+
import { filenameFor } from "@effected/lockfiles";
|
|
10
11
|
|
|
11
12
|
//#region src/WorkspaceCatalogs.ts
|
|
12
13
|
/**
|
|
@@ -289,10 +290,13 @@ const unstubbed = (method) => Effect.die(/* @__PURE__ */ new Error(`WorkspaceCat
|
|
|
289
290
|
* @remarks
|
|
290
291
|
* Rules are SEEDED, not merged afterwards: the workspace file's block goes
|
|
291
292
|
* into the threaded config so the hooks merge onto it, exactly as pnpm seeds
|
|
292
|
-
* its own config and takes back what the hooks return.
|
|
293
|
-
*
|
|
293
|
+
* its own config and takes back what the hooks return. `context` is the
|
|
294
|
+
* document's own side — its lockfile and, at a ref, the ref — so a fetched
|
|
295
|
+
* config dependency is verified against the record of the side that declared
|
|
296
|
+
* it. Not part of the public surface; `WorkspaceSnapshots` imports it
|
|
297
|
+
* directly.
|
|
294
298
|
*/
|
|
295
|
-
const injectFromDocument = (hooks, root, document, inline) => Effect.map(hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document)), (injection) => ({
|
|
299
|
+
const injectFromDocument = (hooks, root, document, inline, context) => Effect.map(hooks.inject(root, configDependenciesOf(document), inline.entries, inlinePeerDependencyRules(document), context), (injection) => ({
|
|
296
300
|
injected: CatalogSet.fromCatalogs(injection.catalogs),
|
|
297
301
|
injection
|
|
298
302
|
}));
|
|
@@ -370,7 +374,8 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
370
374
|
yield* validatePnpmWorkspaceCatalogs(document);
|
|
371
375
|
inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
|
|
372
376
|
inlineGate = yield* inlineReleaseAge(document);
|
|
373
|
-
const
|
|
377
|
+
const lockfileText = Object.keys(configDependenciesOf(document)).length === 0 ? void 0 : yield* fs.readFileString(path.join(root, filenameFor("pnpm"))).pipe(Effect.orElseSucceed(() => void 0));
|
|
378
|
+
const replayed = yield* injectFromDocument(hooks, root, document, inline, { lockfile: lockfileText });
|
|
374
379
|
injected = replayed.injected;
|
|
375
380
|
hookGate = replayed.injection.releaseAge;
|
|
376
381
|
peerDependencyRules = replayed.injection.peerDependencyRules;
|
package/WorkspaceSnapshots.js
CHANGED
|
@@ -127,18 +127,20 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
127
127
|
const catalogsService = yield* WorkspaceCatalogs;
|
|
128
128
|
const hooks = yield* ConfigDependencyHooks;
|
|
129
129
|
const seeded = (snapshot) => options?.seedCatalogs === void 0 ? snapshot : snapshot.withSeededCatalogs(options.seedCatalogs);
|
|
130
|
+
/** A manager's lockfile text at the ref, `none` when the ref has none. */
|
|
131
|
+
const lockfileText = (root, ref, format) => git.show(root, ref, `./${filenameFor(format)}`);
|
|
130
132
|
/**
|
|
131
133
|
* What a manager's lockfile records at the ref: its catalog set and its
|
|
132
134
|
* importer versions, from ONE parse. Empty on both counts when the
|
|
133
135
|
* lockfile is absent or malformed.
|
|
134
136
|
*/
|
|
135
|
-
const lockfileRecord = (
|
|
137
|
+
const lockfileRecord = (content, format) => Option.match(content, {
|
|
136
138
|
onNone: () => Effect.succeed(EMPTY_LOCKFILE_RECORD),
|
|
137
139
|
onSome: (text) => Lockfile.parse(text, { format }).pipe(Effect.map((lockfile) => ({
|
|
138
140
|
catalogs: CatalogSet.fromLockfile(lockfile),
|
|
139
141
|
importerVersions: importerVersionsOf(lockfile)
|
|
140
142
|
})), Effect.catch(() => Effect.succeed(EMPTY_LOCKFILE_RECORD)))
|
|
141
|
-
})
|
|
143
|
+
});
|
|
142
144
|
const computeAt = (root, ref) => Effect.gen(function* () {
|
|
143
145
|
const [pnpmWorkspaceText, rootManifestText] = yield* Effect.all([git.show(root, ref, "./pnpm-workspace.yaml"), git.show(root, ref, "./package.json")], { concurrency: 2 });
|
|
144
146
|
const rootManifest = Option.match(rootManifestText, {
|
|
@@ -159,15 +161,19 @@ var WorkspaceSnapshots = class WorkspaceSnapshots extends Context.Service()("@ef
|
|
|
159
161
|
const pnpmPatterns = pnpmPatternsOf(document);
|
|
160
162
|
patterns = pnpmPatterns.length > 0 ? pnpmPatterns : manifestPatternsOf(rootManifest);
|
|
161
163
|
inline = yield* CatalogSet.fromWorkspaceYaml(pnpmWorkspaceText.value);
|
|
162
|
-
const
|
|
164
|
+
const lockfile = yield* lockfileText(root, ref, "pnpm");
|
|
165
|
+
const replayed = yield* injectFromDocument(hooks, root, document, inline, {
|
|
166
|
+
lockfile: Option.getOrUndefined(lockfile),
|
|
167
|
+
ref
|
|
168
|
+
});
|
|
163
169
|
injected = replayed.injected;
|
|
164
170
|
hookReplays = replayed.injection.replays;
|
|
165
|
-
recorded = yield* lockfileRecord(
|
|
171
|
+
recorded = yield* lockfileRecord(lockfile, "pnpm");
|
|
166
172
|
} else {
|
|
167
173
|
patterns = manifestPatternsOf(rootManifest);
|
|
168
174
|
inline = bunInlineCatalogs(rootManifest);
|
|
169
175
|
injected = CatalogSet.empty();
|
|
170
|
-
recorded = yield* lockfileRecord(root, ref, "bun");
|
|
176
|
+
recorded = yield* lockfileRecord(yield* lockfileText(root, ref, "bun"), "bun");
|
|
171
177
|
}
|
|
172
178
|
const catalogs = CatalogSet.merge(recorded.catalogs, inline, injected);
|
|
173
179
|
const globs = yield* GlobSet.compile(patterns).pipe(Effect.mapError((error) => new CatalogAssemblyError({
|