@intentius/chant 0.25.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/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +6 -5
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/config.d.ts +13 -10
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/entity-wire-codec.d.ts +14 -9
- package/dist/discovery/entity-wire-codec.d.ts.map +1 -1
- package/dist/discovery/graph.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +16 -0
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +29 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +18 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/sandbox/policy-run.d.ts +33 -0
- package/dist/discovery/sandbox/policy-run.d.ts.map +1 -0
- package/dist/discovery/sandbox/policy-wire.d.ts +177 -0
- package/dist/discovery/sandbox/policy-wire.d.ts.map +1 -0
- package/dist/intrinsic-interpolation.d.ts.map +1 -1
- package/dist/lexicon-output.d.ts.map +1 -1
- package/dist/lint/policy-import.d.ts +50 -0
- package/dist/lint/policy-import.d.ts.map +1 -0
- package/dist/lint/policy-sandbox.d.ts +89 -0
- package/dist/lint/policy-sandbox.d.ts.map +1 -0
- package/dist/lint/policy.d.ts +12 -1
- package/dist/lint/policy.d.ts.map +1 -1
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/build.test.ts +77 -1
- package/src/build.ts +15 -2
- package/src/cli/commands/build.test.ts +16 -2
- package/src/cli/commands/build.ts +42 -13
- package/src/cli/main.test.ts +99 -17
- package/src/cli/main.ts +111 -13
- package/src/config.test.ts +28 -1
- package/src/config.ts +16 -12
- package/src/discovery/entity-wire-codec.ts +14 -9
- package/src/discovery/graph.test.ts +40 -1
- package/src/discovery/graph.ts +8 -2
- package/src/discovery/sandbox/config-wire.ts +21 -0
- package/src/discovery/sandbox/driver.ts +132 -0
- package/src/discovery/sandbox/fork.ts +39 -1
- package/src/discovery/sandbox/policy-boundary.test.ts +325 -0
- package/src/discovery/sandbox/policy-run.ts +180 -0
- package/src/discovery/sandbox/policy-wire.test.ts +310 -0
- package/src/discovery/sandbox/policy-wire.ts +277 -0
- package/src/intrinsic-interpolation.test.ts +27 -1
- package/src/intrinsic-interpolation.ts +10 -2
- package/src/lexicon-output.test.ts +36 -0
- package/src/lexicon-output.ts +12 -2
- package/src/lint/policy-import.ts +70 -0
- package/src/lint/policy-sandbox.ts +123 -0
- package/src/lint/policy.ts +20 -2
- package/src/stack-output.test.ts +118 -0
- package/src/stack-output.ts +21 -5
package/src/cli/main.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { loadPlugins, resolveProjectLexicons } from "./plugins";
|
|
|
7
7
|
import { resolveCommand, type CommandDef, type ParsedArgs } from "./registry";
|
|
8
8
|
import { loadChantConfigUpward } from "../config";
|
|
9
9
|
import { armSandboxConfigEvaluation } from "../config-sandbox";
|
|
10
|
+
import { armSandboxPolicyExecution } from "../lint/policy-import";
|
|
10
11
|
import { ENV_VAR, unknownEnvError } from "../env";
|
|
11
12
|
import { initRuntime } from "../runtime-adapter";
|
|
12
13
|
import { runBuild } from "./handlers/build";
|
|
@@ -27,10 +28,61 @@ import { runGraph } from "./handlers/graph";
|
|
|
27
28
|
import { runOp, runOpList, runOpStatus, runOpSignal, runOpCancel, runOpLog } from "./handlers/run";
|
|
28
29
|
import { runEmulator } from "./handlers/emulator";
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Long-form flags that are pure booleans in {@link parseArgs} — their branch
|
|
33
|
+
* below sets a field to `true` and never consumes a following array element.
|
|
34
|
+
* Used only to reject a joined `--flag=value` form for these (chant #1127):
|
|
35
|
+
* a boolean has no value to assign, and silently reinterpreting the joined
|
|
36
|
+
* value as the next positional argument (path, component name, ...) would be
|
|
37
|
+
* exactly the kind of silent misparse this issue exists to close. `--report`
|
|
38
|
+
* is deliberately excluded — it's context-sensitive (bare boolean vs a SARIF
|
|
39
|
+
* path, decided by lookahead), so a joined value for it is legitimate and
|
|
40
|
+
* already handled correctly once split.
|
|
41
|
+
*/
|
|
42
|
+
const BOOLEAN_FLAGS = new Set([
|
|
43
|
+
"--help",
|
|
44
|
+
"--force",
|
|
45
|
+
"--fix",
|
|
46
|
+
"--watch",
|
|
47
|
+
"--verbose",
|
|
48
|
+
"--live",
|
|
49
|
+
"--overlay",
|
|
50
|
+
"--owned",
|
|
51
|
+
"--verbatim",
|
|
52
|
+
"--apply-rewrites",
|
|
53
|
+
"--write",
|
|
54
|
+
"--strict",
|
|
55
|
+
"--validate",
|
|
56
|
+
"--use-composites",
|
|
57
|
+
"--stacks",
|
|
58
|
+
"--components",
|
|
59
|
+
"--up",
|
|
60
|
+
"--down",
|
|
61
|
+
"--include-dependents",
|
|
62
|
+
"--local",
|
|
63
|
+
"--temporal",
|
|
64
|
+
"--json",
|
|
65
|
+
"--progress-json",
|
|
66
|
+
"--update-snapshot",
|
|
67
|
+
"--run-examples",
|
|
68
|
+
"--check",
|
|
69
|
+
"--bump",
|
|
70
|
+
"--no-release-record",
|
|
71
|
+
"--fold",
|
|
72
|
+
"--no-fold",
|
|
73
|
+
"--sandbox",
|
|
74
|
+
]);
|
|
75
|
+
|
|
30
76
|
/**
|
|
31
77
|
* Parse command line arguments
|
|
32
78
|
*/
|
|
33
79
|
export function parseArgs(args: string[]): ParsedArgs {
|
|
80
|
+
// Local mutable copy — chant #1127's joined-`--flag=value` splitting below
|
|
81
|
+
// rewrites the array in place (one token becomes two), so this must not
|
|
82
|
+
// mutate whatever array the caller passed in (e.g. `process.argv.slice(2)`
|
|
83
|
+
// is already a fresh copy, but callers shouldn't have to know that).
|
|
84
|
+
args = args.slice();
|
|
85
|
+
|
|
34
86
|
const result: ParsedArgs = {
|
|
35
87
|
command: "",
|
|
36
88
|
path: ".",
|
|
@@ -67,7 +119,29 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
67
119
|
|
|
68
120
|
let i = 0;
|
|
69
121
|
while (i < args.length) {
|
|
70
|
-
|
|
122
|
+
let arg = args[i];
|
|
123
|
+
|
|
124
|
+
// chant #1127 — generic joined `--flag=value` support. Every value-taking
|
|
125
|
+
// flag below is matched by an exact `arg === "--flag"` check and then
|
|
126
|
+
// consumes the *next* array element (`args[++i]`) as its value; a joined
|
|
127
|
+
// token like `--env=prod` never matches any of those, doesn't match the
|
|
128
|
+
// trailing positional branch either (it starts with `-`), and used to
|
|
129
|
+
// vanish with no error. Splitting the token at its FIRST `=` and
|
|
130
|
+
// re-dispatching as two array elements makes every flag below see the
|
|
131
|
+
// exact shape it already handles — including a flag like `--param`
|
|
132
|
+
// whose own value legitimately contains `=` (`--param=tier=production`
|
|
133
|
+
// splits to flag `--param`, value `tier=production`, not further split
|
|
134
|
+
// on the second `=`).
|
|
135
|
+
if (arg.startsWith("--") && arg.includes("=")) {
|
|
136
|
+
const eq = arg.indexOf("=");
|
|
137
|
+
const flag = arg.slice(0, eq);
|
|
138
|
+
const value = arg.slice(eq + 1);
|
|
139
|
+
if (BOOLEAN_FLAGS.has(flag)) {
|
|
140
|
+
throw new Error(`${arg} — ${flag} is a boolean flag and does not take a value. Pass ${flag} on its own.`);
|
|
141
|
+
}
|
|
142
|
+
args.splice(i, 1, flag, value);
|
|
143
|
+
arg = args[i];
|
|
144
|
+
}
|
|
71
145
|
|
|
72
146
|
if (arg === "--help" || arg === "-h") {
|
|
73
147
|
result.help = true;
|
|
@@ -221,22 +295,35 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
221
295
|
result.noReleaseRecord = true;
|
|
222
296
|
} else if (arg === "--fold") {
|
|
223
297
|
result.fold = true;
|
|
298
|
+
} else if (arg === "--no-fold") {
|
|
299
|
+
// chant #1134 — fold is the default build path; this is the explicit
|
|
300
|
+
// opt-out, and like --fold it beats chant.config.ts's build.fold.
|
|
301
|
+
result.fold = false;
|
|
224
302
|
} else if (arg === "--sandbox") {
|
|
225
303
|
result.sandbox = true;
|
|
226
304
|
} else if (arg === "--param") {
|
|
305
|
+
// chant #1118/#1127 — `--param name=value` (space-separated) and
|
|
306
|
+
// `--param=name=value` (joined, split above at its first `=` into flag
|
|
307
|
+
// `--param` + value `name=value`) both land here and behave
|
|
308
|
+
// identically; there is no separate joined-form error anymore (the
|
|
309
|
+
// #1118 hard error this superseded only existed because the parser
|
|
310
|
+
// didn't support joined forms at all — now that it does, the joined
|
|
311
|
+
// form is just as valid as the space-separated one).
|
|
227
312
|
(result.param ??= []).push(args[++i]);
|
|
228
|
-
} else if (arg.startsWith("--param=")) {
|
|
229
|
-
// chant #1118 — this parser never supports an `--flag=value` joined
|
|
230
|
-
// form for any value-taking flag (every branch above is an exact `===`
|
|
231
|
-
// match, so a joined token falls through unrecognized and is silently
|
|
232
|
-
// dropped — see the "ignores unknown flags" case below). `--param
|
|
233
|
-
// name=value` (space-separated) is the only accepted form. Rather than
|
|
234
|
-
// teach the parser joined forms generally, `--param=name=value` is
|
|
235
|
-
// called out as a hard error instead of a silent no-op: a dropped
|
|
236
|
-
// `--param` can silently change what a build measures/deploys.
|
|
237
|
-
throw new Error(`${arg} is not supported. Use --param name=value (space-separated) instead.`);
|
|
238
313
|
} else if (arg === "--params-file") {
|
|
239
314
|
result.paramsFile = args[++i];
|
|
315
|
+
} else if (arg.startsWith("--")) {
|
|
316
|
+
// chant #1127 — every recognized flag is matched above; anything left
|
|
317
|
+
// starting with `--` is unrecognized, whether it arrived bare
|
|
318
|
+
// (`--bogus`) or joined (`--bogus=value`, already split into
|
|
319
|
+
// `--bogus` + `value` above). This used to fall through silently (the
|
|
320
|
+
// "ignores unknown flags" case) — a typo'd or misremembered flag would
|
|
321
|
+
// vanish with no diagnostic, exactly like the silent-drop this issue
|
|
322
|
+
// closes for joined values. Point at --help rather than enumerating
|
|
323
|
+
// every flag here: this parser's flag set is one flat list shared by
|
|
324
|
+
// every command, not scoped per-command, so "the command's known
|
|
325
|
+
// flags" isn't something this loop can name in isolation.
|
|
326
|
+
throw new Error(`Unknown flag: ${arg}\nRun "chant --help" to see supported flags.`);
|
|
240
327
|
} else if (!arg.startsWith("-")) {
|
|
241
328
|
if (!result.command) {
|
|
242
329
|
result.command = arg;
|
|
@@ -432,8 +519,11 @@ Options:
|
|
|
432
519
|
to run per-file for anything else outside the fold
|
|
433
520
|
subset (a cross-file-only reference, a re-export,
|
|
434
521
|
\`export default\`, ...). Logs which path each file
|
|
435
|
-
took.
|
|
436
|
-
chant.config.ts
|
|
522
|
+
took. DEFAULT since #1134 — this flag forces it on
|
|
523
|
+
over a chant.config.ts \`build.fold: false\`.
|
|
524
|
+
--no-fold (build) Opt out of folding for this invocation: every
|
|
525
|
+
source module is imported and run, the pre-#1134
|
|
526
|
+
behavior. Beats chant.config.ts's build.fold.
|
|
437
527
|
--sandbox (build) Run run-fallback source files (or every
|
|
438
528
|
file, without --fold) together, isolated, in one
|
|
439
529
|
sandboxed child process instead of in-process
|
|
@@ -608,6 +698,14 @@ async function main(): Promise<void> {
|
|
|
608
698
|
// `../config-sandbox.ts`.
|
|
609
699
|
if (args.sandbox) armSandboxConfigEvaluation();
|
|
610
700
|
|
|
701
|
+
// chant #1131 — the same for `lint.policies`. Armed from the flag here so
|
|
702
|
+
// the mode is set for the whole invocation, not just `chant build`; the build
|
|
703
|
+
// command arms it again from the RESOLVED value (a project's own
|
|
704
|
+
// `build.sandbox: true` also sandboxes its policies — unlike the config,
|
|
705
|
+
// policies have no bootstrap limit, since they load long after the config is
|
|
706
|
+
// known). See `../lint/policy-sandbox.ts`.
|
|
707
|
+
if (args.sandbox) armSandboxPolicyExecution();
|
|
708
|
+
|
|
611
709
|
// Initialize runtime adapter early — before plugins or commands run.
|
|
612
710
|
// chant #1117 — walks up from `args.path` to the project root: for a
|
|
613
711
|
// subdirectory build/command (`chant build src/<stack> --env prod`) the
|
package/src/config.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
loadChantConfig,
|
|
4
|
+
DEFAULT_CHANT_CONFIG,
|
|
5
|
+
resolveAutoReleaseDisabled,
|
|
6
|
+
resolveFoldEnabled,
|
|
7
|
+
resolveSbomFormat,
|
|
8
|
+
} from "./config";
|
|
3
9
|
import { writeFileSync, mkdirSync, rmSync } from "fs";
|
|
4
10
|
import { join } from "path";
|
|
5
11
|
|
|
@@ -128,6 +134,27 @@ describe("loadChantConfig", () => {
|
|
|
128
134
|
});
|
|
129
135
|
});
|
|
130
136
|
|
|
137
|
+
describe("resolveFoldEnabled (#1134 — fold is the default build path)", () => {
|
|
138
|
+
test("default (no flag, no config) → fold ON", () => {
|
|
139
|
+
expect(resolveFoldEnabled({})).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("config build.fold: false turns it off; true keeps it on", () => {
|
|
143
|
+
expect(resolveFoldEnabled({ build: { fold: false } })).toBe(false);
|
|
144
|
+
expect(resolveFoldEnabled({ build: { fold: true } })).toBe(true);
|
|
145
|
+
expect(resolveFoldEnabled({ build: {} })).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("--fold (flag true) beats config false", () => {
|
|
149
|
+
expect(resolveFoldEnabled({ build: { fold: false } }, true)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("--no-fold (flag false) beats config true and the default", () => {
|
|
153
|
+
expect(resolveFoldEnabled({ build: { fold: true } }, false)).toBe(false);
|
|
154
|
+
expect(resolveFoldEnabled({}, false)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
131
158
|
describe("resolveAutoReleaseDisabled", () => {
|
|
132
159
|
test("default (no flag, no config) → not disabled", () => {
|
|
133
160
|
expect(resolveAutoReleaseDisabled({})).toBe(false);
|
package/src/config.ts
CHANGED
|
@@ -139,12 +139,12 @@ export interface ChantConfig {
|
|
|
139
139
|
*/
|
|
140
140
|
build?: {
|
|
141
141
|
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
142
|
+
* Fold source modules statically instead of importing/running them,
|
|
143
|
+
* falling back to run per-file for anything the folder can't represent.
|
|
144
|
+
* DEFAULT `true` since chant #1134 — set `false` to make this project
|
|
145
|
+
* run every module (the pre-#1134 behavior). The `--fold`/`--no-fold`
|
|
146
|
+
* CLI flags override this per-invocation in either direction. See
|
|
147
|
+
* {@link resolveFoldEnabled}.
|
|
148
148
|
*/
|
|
149
149
|
fold?: boolean;
|
|
150
150
|
|
|
@@ -353,14 +353,18 @@ export function resolveAutoReleaseDisabled(config: ChantConfig, cliFlag?: boolea
|
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
355
|
* Whether `chant build` should use the fold path (#1022, epic #1019)
|
|
356
|
-
* instead of running each source module.
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
356
|
+
* instead of running each source module. DEFAULT-ON since chant #1134: fold
|
|
357
|
+
* is the build path unless something turns it off. Precedence, most specific
|
|
358
|
+
* wins: an explicit CLI flag (`--fold` → true, `--no-fold` → false, arriving
|
|
359
|
+
* here as `cliFlag`), then the project config's `build.fold`, then the
|
|
360
|
+
* default of `true`. The epic's evidence base for the flip — coverage,
|
|
361
|
+
* byte-identity, and the sandbox execution boundary — is recorded on #1134
|
|
362
|
+
* and #1090.
|
|
360
363
|
*/
|
|
361
364
|
export function resolveFoldEnabled(config: ChantConfig, cliFlag?: boolean): boolean {
|
|
362
|
-
if (cliFlag) return
|
|
363
|
-
|
|
365
|
+
if (cliFlag !== undefined) return cliFlag;
|
|
366
|
+
if (config.build?.fold !== undefined) return config.build.fold;
|
|
367
|
+
return true;
|
|
364
368
|
}
|
|
365
369
|
|
|
366
370
|
/**
|
|
@@ -23,20 +23,25 @@
|
|
|
23
23
|
* value) becomes a name-keyed marker instead. {@link decodeEntitySet} is the
|
|
24
24
|
* inverse — it rebuilds a live `Map<string, Declarable>` whose entities are
|
|
25
25
|
* BEHAVIORALLY indistinguishable from what `discover()` would have produced
|
|
26
|
-
* in-process: real `AttrRef` instances
|
|
27
|
-
* `
|
|
26
|
+
* in-process: real `AttrRef` instances, not a duck-typed `{__attrRef}`
|
|
27
|
+
* envelope alone. `new AttrRef(...)` here is plain, direct construction from
|
|
28
|
+
* this module's own class — this codec runs inside the same module graph as
|
|
29
|
+
* every downstream reader (`intrinsic-interpolation.ts`'s
|
|
28
30
|
* `defaultInterpolationSerializer`, `discovery/graph.ts`'s
|
|
29
31
|
* `buildDependencyGraph`, `build.ts`'s `detectCrossLexiconRefs`/
|
|
30
|
-
* `computeStackGraph
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
32
|
+
* `computeStackGraph`, all converted to `isAttrRefLike` duck-typing by chant
|
|
33
|
+
* #1137 for the OTHER hazard, a separately-loaded lexicon copy) — so there is
|
|
34
|
+
* no dual-package boundary to duck-type across here, and building the real
|
|
35
|
+
* class is simply less code than hand-assembling a shape-alike stand-in with
|
|
36
|
+
* matching methods, and whole-entity embeds restored to the SAME object
|
|
37
|
+
* reference (not a structurally-equal clone), so `entityNames.get(decl)`
|
|
38
|
+
* keeps working by identity exactly as it does today.
|
|
34
39
|
*
|
|
35
40
|
* `serializer-walker.ts`'s `walkValue` needs NO changes for this: it already
|
|
36
41
|
* falls back to reading a plain `{__attrRef}` envelope (added for intrinsics
|
|
37
42
|
* whose own `toJSON()` embeds one). `decodeEntitySet` goes further and
|
|
38
|
-
* reconstructs the real class
|
|
39
|
-
*
|
|
43
|
+
* reconstructs the real class, both simpler here and a belt-and-suspenders
|
|
44
|
+
* match for any call site that still checks `instanceof AttrRef` directly.
|
|
40
45
|
*
|
|
41
46
|
* Naming happens exactly once, inside the boundary — `resolveAttrRefs`
|
|
42
47
|
* (./resolve.ts) runs as part of `discover()`, before `encodeEntitySet` is
|
|
@@ -85,7 +90,7 @@ import { isChildProject } from "../child-project";
|
|
|
85
90
|
* form. `refs` additionally captures any `AttrRef`/whole-entity reference
|
|
86
91
|
* found while walking the intrinsic's OWN fields (not through `toJSON()`)
|
|
87
92
|
* — `buildDependencyGraph` and `detectCrossLexiconRefs`/`computeStackGraph`
|
|
88
|
-
* walk raw entity property trees looking for `
|
|
93
|
+
* walk raw entity property trees looking for an `AttrRef`-like value/a
|
|
89
94
|
* tracked `Declarable`, not through `toJSON()`, so a ref nested inside e.g.
|
|
90
95
|
* a `Sub` template needs to still be discoverable post-decode for
|
|
91
96
|
* cross-lexicon output auto-detection and dependency ordering to keep
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, test, expect } from "vitest";
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
2
|
import { buildDependencyGraph } from "./graph";
|
|
3
3
|
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
4
4
|
import { AttrRef } from "../attrref";
|
|
@@ -513,4 +513,43 @@ describe("buildDependencyGraph", () => {
|
|
|
513
513
|
expect(graph.get("Entity3")?.has("Entity2")).toBe(true);
|
|
514
514
|
expect(graph.get("Entity3")?.size).toBe(1);
|
|
515
515
|
});
|
|
516
|
+
|
|
517
|
+
// chant #1137 — `findDependencies` used to check `value instanceof
|
|
518
|
+
// AttrRef`, which returns false for an AttrRef built by a SEPARATELY-
|
|
519
|
+
// LOADED copy of `../attrref` (the same dual-npm-copy hazard #1122 fixed
|
|
520
|
+
// for `LexiconOutput`). `vi.resetModules()` + a fresh dynamic import
|
|
521
|
+
// reproduces that split module graph exactly. Before the fix, a foreign
|
|
522
|
+
// AttrRef here recurses into the object's own (unhelpful) fields instead
|
|
523
|
+
// of being recorded as a dependency, silently dropping the edge — which
|
|
524
|
+
// can misorder the file-discovery build order this graph exists to compute.
|
|
525
|
+
test("detects dependency from an AttrRef built by a second, separately-loaded copy", async () => {
|
|
526
|
+
const parent: Declarable = {
|
|
527
|
+
lexicon: "test",
|
|
528
|
+
entityType: "parent",
|
|
529
|
+
[DECLARABLE_MARKER]: true,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
vi.resetModules();
|
|
533
|
+
const secondCopy = await import("../attrref");
|
|
534
|
+
expect(secondCopy.AttrRef).not.toBe(AttrRef);
|
|
535
|
+
|
|
536
|
+
const foreignRef = new secondCopy.AttrRef(parent, "someAttr");
|
|
537
|
+
expect(foreignRef instanceof AttrRef).toBe(false); // the historic bug
|
|
538
|
+
|
|
539
|
+
const child: Declarable & { ref: AttrRef } = {
|
|
540
|
+
lexicon: "test",
|
|
541
|
+
entityType: "child",
|
|
542
|
+
[DECLARABLE_MARKER]: true,
|
|
543
|
+
ref: foreignRef,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const entities = new Map([
|
|
547
|
+
["Parent", parent],
|
|
548
|
+
["Child", child],
|
|
549
|
+
]);
|
|
550
|
+
const graph = buildDependencyGraph(entities);
|
|
551
|
+
|
|
552
|
+
expect(graph.get("Child")?.has("Parent")).toBe(true);
|
|
553
|
+
vi.resetModules();
|
|
554
|
+
});
|
|
516
555
|
});
|
package/src/discovery/graph.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Declarable } from "../declarable";
|
|
2
2
|
import { isDeclarable } from "../declarable";
|
|
3
3
|
import { AttrRef } from "../attrref";
|
|
4
|
+
import { isAttrRefLike } from "../utils";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Builds a dependency graph from a collection of entities
|
|
@@ -95,8 +96,13 @@ function findDependencies(
|
|
|
95
96
|
return;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
// Check if this is an AttrRef
|
|
99
|
-
|
|
99
|
+
// Check if this is an AttrRef. Duck-type, not `instanceof` (chant #1137):
|
|
100
|
+
// a lexicon built against a separate copy of `@intentius/chant` produces
|
|
101
|
+
// AttrRefs that fail `instanceof AttrRef` here but carry the same shape.
|
|
102
|
+
// Without this, the dependency edge is silently dropped instead of
|
|
103
|
+
// recorded, which can misorder — or fail to detect a cycle in — the
|
|
104
|
+
// file-discovery build order this graph exists to compute.
|
|
105
|
+
if (isAttrRefLike(value)) {
|
|
100
106
|
if (visited.has(value)) {
|
|
101
107
|
return;
|
|
102
108
|
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* the project code it is inspecting — so it must not import anything, and must
|
|
19
19
|
* not touch the filesystem, the environment or the process.
|
|
20
20
|
*
|
|
21
|
+
* chant #1131 reuses the same walk for the OTHER direction: a `lint.policies`
|
|
22
|
+
* check runs inside a sandboxed child and its `PostSynthDiagnostic[]` has to
|
|
23
|
+
* come back as data (`./policy-wire.ts`). That is the same "JSON is lossy
|
|
24
|
+
* without complaining" problem with a different root value, so the walk is
|
|
25
|
+
* exported as {@link scanValueWireSafety} rather than copied.
|
|
26
|
+
*
|
|
21
27
|
* ## What `ChantConfig` legally holds
|
|
22
28
|
*
|
|
23
29
|
* Every field of `ChantConfig` (`../../config.ts`) is JSON data: string arrays
|
|
@@ -133,6 +139,21 @@ function walk(
|
|
|
133
139
|
seen.delete(obj);
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
/**
|
|
143
|
+
* chant #1131 — report every value ANYWHERE in `value` that cannot cross the
|
|
144
|
+
* sandbox boundary as JSON, with paths rooted at `rootPath`.
|
|
145
|
+
*
|
|
146
|
+
* {@link scanConfigWireSafety} is the config-shaped entry point (it tolerates a
|
|
147
|
+
* module namespace object at the root); this one takes the value as given, so
|
|
148
|
+
* an array root (`diagnostics`) or a plain object root both work. Same rules,
|
|
149
|
+
* same `undefined`-object-property allowance — see the module doc.
|
|
150
|
+
*/
|
|
151
|
+
export function scanValueWireSafety(value: unknown, rootPath = ""): ConfigWireOffender[] {
|
|
152
|
+
const out: ConfigWireOffender[] = [];
|
|
153
|
+
walk(value, rootPath, 0, new Set<object>(), out);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
136
157
|
/**
|
|
137
158
|
* Report every value in `config` that cannot cross the sandbox boundary as
|
|
138
159
|
* JSON. An empty array means a `JSON.parse(JSON.stringify(config))` round-trip
|
|
@@ -43,6 +43,9 @@ const CHILD_ERRORS_MODULE = join(HERE, "child-errors.ts");
|
|
|
43
43
|
const PROVENANCE_MODULE = join(dirname(DISCOVERY_DIR), "provenance.ts");
|
|
44
44
|
// chant #1113 — the config driver's serializability contract (see ./config-wire.ts).
|
|
45
45
|
const CONFIG_WIRE_MODULE = join(HERE, "config-wire.ts");
|
|
46
|
+
// chant #1131 — the policy driver's build-result decoding + diagnostics contract.
|
|
47
|
+
const POLICY_WIRE_MODULE = join(HERE, "policy-wire.ts");
|
|
48
|
+
const POST_SYNTH_MODULE = join(dirname(DISCOVERY_DIR), "lint", "post-synth.ts");
|
|
46
49
|
|
|
47
50
|
export interface GenerateDriverOptions {
|
|
48
51
|
/** Absolute paths to the run-fallback files this build decided NOT to fold — see `discover()`'s fold/taint loop in `../index.ts`. */
|
|
@@ -213,3 +216,132 @@ export function generateConfigDriverSource(configPath: string): string {
|
|
|
213
216
|
`});`,
|
|
214
217
|
].join("\n");
|
|
215
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* chant #1131 — generate the driver module that imports a project's
|
|
222
|
+
* `lint.policies` modules INSIDE the sandboxed child, runs their checks over
|
|
223
|
+
* the build result the parent hands it, and sends back plain
|
|
224
|
+
* `PostSynthDiagnostic`s.
|
|
225
|
+
*
|
|
226
|
+
* Same machinery again: literal-specifier dynamic `import()`s esbuild can trace
|
|
227
|
+
* and inline, `./child-errors.ts` for classification so a permission denial
|
|
228
|
+
* names the policy file, one IPC message back. Two things are specific to this
|
|
229
|
+
* one:
|
|
230
|
+
*
|
|
231
|
+
* - **It receives before it sends.** The run and config drivers are fully
|
|
232
|
+
* parameterized by their generated source; a policy check needs the finished
|
|
233
|
+
* build result, which is neither known at bundle time nor something to bake
|
|
234
|
+
* into a source literal. It arrives as one IPC message (see `./fork.ts`'s
|
|
235
|
+
* `send`). The `process.on("message", …)` registration is top-level and
|
|
236
|
+
* synchronous, so it is in place before the event loop can deliver anything
|
|
237
|
+
* — a message the parent sent before the child finished booting is queued on
|
|
238
|
+
* the channel, not lost.
|
|
239
|
+
* - **Checks run wrapped, not raw.** `runPostSynthChecks` (chant's own, from
|
|
240
|
+
* `../../lint/post-synth.ts`) is invoked ONCE over every check from every
|
|
241
|
+
* policy module, exactly as `cli/commands/build.ts` invokes it in-process —
|
|
242
|
+
* so the checks share one `PostSynthContext` and run in one order, and the
|
|
243
|
+
* diagnostics come back in the same sequence. The wrapper around each check
|
|
244
|
+
* is what makes a bad return value attributable: it scans that check's own
|
|
245
|
+
* output and throws a `PolicyWireError` naming the module the check was
|
|
246
|
+
* loaded from, rather than reporting an offending index in a merged array.
|
|
247
|
+
*/
|
|
248
|
+
export function generatePolicyDriverSource(policyPaths: readonly string[]): string {
|
|
249
|
+
const lines: string[] = [
|
|
250
|
+
`import { classifyChildError } from ${lit(CHILD_ERRORS_MODULE)};`,
|
|
251
|
+
`import { decodePolicyBuildResult, scanPolicyDiagnostics, PolicyWireError } from ${lit(POLICY_WIRE_MODULE)};`,
|
|
252
|
+
`import { runPostSynthChecks, isPostSynthCheck } from ${lit(POST_SYNTH_MODULE)};`,
|
|
253
|
+
``,
|
|
254
|
+
`function send(payload) {`,
|
|
255
|
+
` if (typeof process.send === "function") process.send(payload);`,
|
|
256
|
+
` else console.log(JSON.stringify(payload));`,
|
|
257
|
+
`}`,
|
|
258
|
+
``,
|
|
259
|
+
`function fail(file, err, type) {`,
|
|
260
|
+
` if (err instanceof PolicyWireError) {`,
|
|
261
|
+
` send({ kind: "chant-policy", ok: false, offenders: err.offenders });`,
|
|
262
|
+
` return;`,
|
|
263
|
+
` }`,
|
|
264
|
+
` send({ kind: "chant-policy", ok: false, error: classifyChildError(file, err, type).toJSON() });`,
|
|
265
|
+
`}`,
|
|
266
|
+
``,
|
|
267
|
+
// The wrapper described in the doc above: same id/description so any
|
|
268
|
+
// chant-side reporting keyed off them is unchanged, same ctx, same return
|
|
269
|
+
// value — plus the per-check serializability scan.
|
|
270
|
+
`function guard(check, policy) {`,
|
|
271
|
+
` return {`,
|
|
272
|
+
` id: check.id,`,
|
|
273
|
+
` description: check.description,`,
|
|
274
|
+
` check(ctx) {`,
|
|
275
|
+
` const produced = check.check(ctx);`,
|
|
276
|
+
` const offenders = scanPolicyDiagnostics(produced, policy);`,
|
|
277
|
+
` if (offenders.length > 0) throw new PolicyWireError(offenders);`,
|
|
278
|
+
` return produced;`,
|
|
279
|
+
` },`,
|
|
280
|
+
` };`,
|
|
281
|
+
`}`,
|
|
282
|
+
``,
|
|
283
|
+
`async function main(request) {`,
|
|
284
|
+
` let buildResult;`,
|
|
285
|
+
` try {`,
|
|
286
|
+
` buildResult = decodePolicyBuildResult(request.buildResult);`,
|
|
287
|
+
` } catch (err) {`,
|
|
288
|
+
` fail("", err, "resolution");`,
|
|
289
|
+
` return;`,
|
|
290
|
+
` }`,
|
|
291
|
+
``,
|
|
292
|
+
` const checks = [];`,
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// One block per policy module, in the order `lint.policies` declares them —
|
|
296
|
+
// the same order `loadPolicyChecks` collects in, so the diagnostics sequence
|
|
297
|
+
// matches the in-process one exactly.
|
|
298
|
+
for (const policyPath of policyPaths) {
|
|
299
|
+
lines.push(
|
|
300
|
+
` try {`,
|
|
301
|
+
` const mod = await import(${lit(policyPath)});`,
|
|
302
|
+
` for (const value of Object.values(mod)) {`,
|
|
303
|
+
` if (isPostSynthCheck(value)) checks.push(guard(value, ${lit(policyPath)}));`,
|
|
304
|
+
` }`,
|
|
305
|
+
` } catch (err) {`,
|
|
306
|
+
` fail(${lit(policyPath)}, err, "import");`,
|
|
307
|
+
` return;`,
|
|
308
|
+
` }`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
lines.push(
|
|
313
|
+
``,
|
|
314
|
+
` let diagnostics;`,
|
|
315
|
+
` try {`,
|
|
316
|
+
` diagnostics = runPostSynthChecks(checks, buildResult, request.env ?? undefined);`,
|
|
317
|
+
` } catch (err) {`,
|
|
318
|
+
` fail("", err, "resolution");`,
|
|
319
|
+
` return;`,
|
|
320
|
+
` }`,
|
|
321
|
+
``,
|
|
322
|
+
` send({ kind: "chant-policy", ok: true, diagnostics });`,
|
|
323
|
+
`}`,
|
|
324
|
+
``,
|
|
325
|
+
// Registered synchronously at module top level — see the doc above on why
|
|
326
|
+
// that is what makes the parent's send-before-boot safe.
|
|
327
|
+
//
|
|
328
|
+
// Removed again as soon as the input arrives, and that is not tidiness: a
|
|
329
|
+
// `message` listener REFS the IPC channel, so leaving it registered would
|
|
330
|
+
// keep this child's event loop alive after it had already answered — and a
|
|
331
|
+
// live channel keeps the PARENT's alive too. `chant build` would not have
|
|
332
|
+
// noticed (`cli/main.ts` ends in `process.exit`); anything embedding chant
|
|
333
|
+
// as a library would have hung. `./fork.ts` kills the child on receipt as
|
|
334
|
+
// the other half of the same fix.
|
|
335
|
+
`let started = false;`,
|
|
336
|
+
`function onRequest(request) {`,
|
|
337
|
+
` if (started) return;`,
|
|
338
|
+
` if (!request || request.kind !== "chant-policy-request") return;`,
|
|
339
|
+
` started = true;`,
|
|
340
|
+
` process.off("message", onRequest);`,
|
|
341
|
+
` main(request).catch((err) => fail("", err, "resolution"));`,
|
|
342
|
+
`}`,
|
|
343
|
+
`process.on("message", onRequest);`,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return lines.join("\n");
|
|
347
|
+
}
|
|
@@ -45,6 +45,24 @@ export interface SandboxForkOptions {
|
|
|
45
45
|
timeoutMs: number;
|
|
46
46
|
/** What timed out / exited early, for the error message (e.g. `"sandboxed run"`). */
|
|
47
47
|
label: string;
|
|
48
|
+
/**
|
|
49
|
+
* chant #1131 — an optional payload sent to the child over the SAME IPC
|
|
50
|
+
* channel its response comes back on, immediately after the fork.
|
|
51
|
+
*
|
|
52
|
+
* The run and config children are fully described by their generated driver
|
|
53
|
+
* source, so they need nothing inbound. The policy child does: its input is
|
|
54
|
+
* the finished build result, which exists only after the parent has merged
|
|
55
|
+
* and serialized, long after the bundle was built. Sending it rather than
|
|
56
|
+
* baking it into a source literal keeps the bundle small (esbuild would
|
|
57
|
+
* otherwise parse a multi-megabyte literal) and keeps it off disk.
|
|
58
|
+
*
|
|
59
|
+
* Safe to send before the child has booted: `child.send` writes to the IPC
|
|
60
|
+
* pipe and Node queues the message until the child's channel is read, and
|
|
61
|
+
* the driver registers its `process.on("message", …)` synchronously at module
|
|
62
|
+
* top level — before the event loop can deliver anything. This is NOT a
|
|
63
|
+
* second protocol: same channel, same JSON, same one-message-back response.
|
|
64
|
+
*/
|
|
65
|
+
send?: Record<string, unknown>;
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
/**
|
|
@@ -56,7 +74,7 @@ export function forkSandboxed<T>(
|
|
|
56
74
|
options: SandboxForkOptions,
|
|
57
75
|
isResponse: (value: unknown) => value is T,
|
|
58
76
|
): Promise<T> {
|
|
59
|
-
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label } = options;
|
|
77
|
+
const { bundlePath, bundleDir, projectRealpath, externalReadPaths, env, timeoutMs, label, send } = options;
|
|
60
78
|
|
|
61
79
|
return new Promise((resolvePromise, reject) => {
|
|
62
80
|
const readAllowances = [bundleDir, projectRealpath, ...externalReadPaths].map(
|
|
@@ -78,6 +96,16 @@ export function forkSandboxed<T>(
|
|
|
78
96
|
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
79
97
|
}, timeoutMs);
|
|
80
98
|
|
|
99
|
+
if (send !== undefined) {
|
|
100
|
+
child.send(send, (err) => {
|
|
101
|
+
if (settled || !err) return;
|
|
102
|
+
settled = true;
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
child.kill();
|
|
105
|
+
reject(new Error(`${label}: failed to send the child its input: ${err.message}`));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
child.stderr?.on("data", (chunk: Buffer) => {
|
|
82
110
|
stderrBuf += chunk.toString();
|
|
83
111
|
});
|
|
@@ -86,6 +114,16 @@ export function forkSandboxed<T>(
|
|
|
86
114
|
if (settled || !isResponse(msg)) return;
|
|
87
115
|
settled = true;
|
|
88
116
|
clearTimeout(timeout);
|
|
117
|
+
// chant #1131 — the child's entire job is to send this one message, so
|
|
118
|
+
// once it has arrived there is nothing left to wait for. Killing it here
|
|
119
|
+
// rather than hoping it exits on its own closes a real hang: an open
|
|
120
|
+
// handle on the child side (a `setInterval` in project source, an
|
|
121
|
+
// `http.Server` a policy started, or simply an IPC listener the driver
|
|
122
|
+
// registered to RECEIVE its input) keeps the child's event loop alive,
|
|
123
|
+
// and a live IPC channel then keeps the PARENT's alive too. `chant build`
|
|
124
|
+
// never noticed because `cli/main.ts` ends with `process.exit`; anything
|
|
125
|
+
// embedding chant as a library would have hung forever.
|
|
126
|
+
child.kill();
|
|
89
127
|
resolvePromise(msg);
|
|
90
128
|
});
|
|
91
129
|
|