Skip to content

Commit e909aa0

Browse files
fix(cli): os dev -a and os start --artifact serve the named artifact beside a cwd objectstack.config.ts — one artifact precedence for start, dev and the serve child (#21549)
Fixes #21501 Clause-②: no ## What changed The ruling on the card is one precedence, written once: explicit flag > env (`OS_ARTIFACT_URL` / `OS_ARTIFACT_PATH`) > `dist/objectstack.json` > a cwd `objectstack.config.ts`. Triage amended it in `5966519064`: a cwd config joins the boot when the resolved artifact is its own compiled output, because a host config's compiled file cannot carry its code plugins. This PR builds that order in five parts. - **The one resolver lives in `packages/cli/src/utils/artifact-precedence.ts`.** - `resolveArtifactBootSource()` holds the ladder: `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `CWD/dist/objectstack.json` > `HOME/dist/objectstack.json` (`os start` only) > unresolved, which is the cwd config. `os start` and `os dev` both resolve through it. - `cwdConfigJoinsBoot()` answers the last rung, as amended: the config joins only when the resolved artifact is its own compiled output. The supervisors' `Config:` row and the `serve` child both read this one predicate. - `start.ts` drops its private `resolveArtifactSource` and its inline `OS_ARTIFACT_URL` handling. `dev.ts` drops its inline ladder, which had no `OS_ARTIFACT_URL` rung. - A structural pin refuses any read of `OS_ARTIFACT_PATH` or `OS_ARTIFACT_URL` in `start.ts` or `dev.ts`. - **The `serve` child boots the supervisor's answer beside a config** (`serve.ts`). - Before, the child read `OS_INTERNAL_ARTIFACT_PATH` only when the cwd held no config. With a config present, the config boot ran instead, and its standalone stack re-derived the artifact from the environment. - Now the config joins only when the answer is the config's own compiled output. Any other answer boots alone, exactly as from a directory with no config. When the config joins, the config boot is handed the answer explicitly. - **A config's compiled output, recognised where the command compiled it** (`artifact-precedence.ts`, `internal-artifact-channel.ts`, `dev.ts`, `serve.ts`). - The conventional `CONFIGDIR/dist/objectstack.json` still counts. So does the path the supervising command itself compiles the config to. - `os dev` under a local `OS_ARTIFACT_PATH` compiles the cwd config INTO that path, so it declares that path to the child on a second private variable of the same channel, `OS_INTERNAL_CONFIG_OUTPUT_PATH`. That variable is set only when declared and is owned by the parent. - `isConfigCompiledArtifact(path, configPath, compiledTo)` recognises either place, so a host config compiled to a named path still composes its plugins. - **The honest ready banner** (`serve.ts`, `utils/format.ts`). On a config boot, the child's ready banner names what actually loaded: - `Artifact: dist/objectstack.json` when a non-host config's standalone stack served the app from a compiled bundle. The proof is the stack's own AppPlugin over that bundle, and the path comes from the runtime's own `resolveDefaultArtifactPath` over the same explicit input; - `Config: objectstack.config.ts` for a host config (its `plugins` hold code) or a config with no bundle loaded. - No ready-banner row names a file the boot did not load. No `os start` stale-artifact warning is added, per triage. - **`os dev` flag over env** (`internal-artifact-channel.ts`). A `resolved` channel decision removes `OS_ARTIFACT_URL` from the child env. Without a flag, `dev` treats a reference the way `start` does: it hands no channel down, prints a redacted `Artifact: ... (OS_ARTIFACT_URL)` row, and does not compile, watch or run the staleness check. - **Docs**: in `content/docs/deployment/cli.mdx`, the `os dev` options row lists `-a`'s env equivalents as `OS_ARTIFACT_URL` / `OS_ARTIFACT_PATH`, as the resolver's ladder says. That is the only docs edit. The PM declares this docs path to `domain:devx`. What does not change: - A bare `os dev`, a bare `os start` in a project, and the documented `os start --artifact ./dist/objectstack.json` all name the config's own compiled output, so the config still joins on those paths. The showcase is a host config, and it still boots itself. - A direct `os serve` is untouched, since no supervisor channel is involved. - `packages/runtime`'s own fallback ladder is not edited. ## Measured at the public door Two artifacts differ in one served value, the label of object `fx_widget`. The label was read back through `GET /api/v1/meta/object/fx_widget`, booted through the built entry `bin/run.js`. | boot | before (base `550f4cc2fd`) | after | |---|---|---| | leg 1: `os dev -a ALPHA`, beside a config whose `dist/` holds BRAVO | Widget BRAVO | Widget ALPHA | | leg 2: `os start --artifact ALPHA`, same directory | Widget BRAVO | Widget ALPHA | | leg 2: `os start --artifact ALPHA`, config but no `dist/` | Widget CONFIG | Widget ALPHA | | leg 2 control: `os start --artifact ALPHA`, no config | Widget ALPHA | Widget ALPHA | | `os start --artifact ALPHA`, beside a host config (plugin instance in `plugins`) | Widget CONFIG | Widget ALPHA | | `OS_ARTIFACT_URL=file://.../BRAVO.json os dev -a ALPHA` | Widget BRAVO | Widget ALPHA | | `OS_ARTIFACT_PATH=ALPHA os start --artifact ./dist/objectstack.json` (dist = BRAVO) | Widget ALPHA | Widget BRAVO | | `os start --artifact ./dist/objectstack.json` beside its config (documented path) | Widget BRAVO | Widget BRAVO | | bare `os start` beside a host config, `dist/` = BRAVO: ready-banner row | `Config: objectstack.config.ts` (served CONFIG; the supervisor row said `Artifact: dist/objectstack.json`) | `Config: objectstack.config.ts`, served CONFIG | | bare `os start` beside a non-host config, `dist/` = BRAVO: ready-banner row | `Config: objectstack.config.ts` (served BRAVO) | `Artifact: dist/objectstack.json`, served BRAVO | | `OS_ARTIFACT_PATH=build/named.json os dev` beside a host config: plugin roster | marker absent at the round-1 head (ablations D and E below) | marker present, `Config: objectstack.config.ts` | **`Artifact:` banner.** Before the fix, the supervisor printed `Artifact:` from its own resolution before spawning. The child then printed `Loading objectstack.config.ts...` and a ready-banner `Config:` row, so one screen named two sources. After the fix: - the child boots exactly the supervisor's answer; - beside a config it does not load, the child says so; - the supervisors print `Config:` only when `cwdConfigJoinsBoot` says the config takes part; - the child's ready banner names the config or the bundle it actually loaded. **Env leg.** `OS_ARTIFACT_URL` already outranked a cwd config. Two flag-over-env violations were in scope and are now fixed and pinned: the `os dev` reference case and the twin-plus-`OS_ARTIFACT_PATH` case. `OS_ARTIFACT_PATH` beside a config now boots that artifact alone under `os start`. Under `os dev`, it is the path dev compiles the config to, so the config joins. **Raise rule.** No deploy was measured serving a different stack this way. The shipped runtime image and the scaffolded `Dockerfile` copy only the artifact into `/srv/app`, with no config beside it. ## Pins - `packages/cli/test/artifact-flag-precedence.integration.test.ts` (integration tier) runs 10 cases over the source entry. All boots happen in `beforeAll`. - leg 1, leg 2 with and without `dist/`, and the leg 2 no-config control; - a host config beside a named artifact. This is read through the boot's plugin roster, because the source entry runs `NODE_ENV=development`, where the dev metadata door serves the channel's artifact even with the config loaded; - a bare `os start` beside a host config with a differing `dist/`: the ready banner says `Config:`, and the roster marker is present (its positive control); - a bare `os start` beside a non-host config: the ready banner says `Artifact: dist/objectstack.json`; - `os dev` under `OS_ARTIFACT_PATH=build/named.json` beside a host config: the config is compiled there and still composes its plugins; - `dev -a` under `OS_ARTIFACT_URL`; - the documented path under an exported `OS_ARTIFACT_PATH`. - `packages/cli/src/commands/artifact-child-env.pin.test.ts`: - the ladder over `resolveArtifactBootSource`; - `cwdConfigJoinsBoot` and `isConfigCompiledArtifact`, including the command's own compile path; - the channel's ownership of both private variables, and its removal of an outranked `OS_ARTIFACT_URL`; - the structural no-second-ladder pin. - The `serve-banner-config-row.test.ts` and `format.config-artifact-row.test.ts` unit pins cover the new bundle row. ## Reverse verification All mutations ran through `scripts/ablation-replace.mjs` in WRAP mode on the committed tree, with literal anchors. The subject runs from `src/` through `bin/run-dev.js`, so there is no `dist/` leg. | ablation | predicted red | observed | |---|---|---| | A: the whole serve fix | leg 1, leg 2, leg 2 no-dist, host config, documented path | exactly those 5 red | | A1: `configJoins` ignores the channel | host config | 1 red | | A2: config boot not handed the answer | documented path under `OS_ARTIFACT_PATH` | 1 red | | B: channel keeps `OS_ARTIFACT_URL` | flag over env, channel unit pin | 2 red | | C1: ready banner never names the bundle | bare non-host banner (integration) and the banner-row unit pin | 2 red, 14 green | | C2: banner names what the supervisor resolved, not what loaded | bare host-config banner | 1 red, 9 green | | D: channel never hands down the compile path | named-path host case and the channel unit pin | 2 red, 41 green | | E: predicate ignores the command's compile path | named-path host case and the predicate unit pin | 2 red, 41 green | Every run ended with `ok restored: blob == HEAD` and an empty `git diff HEAD`. Ablations A to B ran at round 1 (`f5c0a890b7`), and C1 to E at round 2. ## Verification (final commit `f634c5bf09`) - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 251 files, 3690 tests passed. - `pnpm --filter @objectstack/cli typecheck` passed, including `check:test-typecheck: OK` with the debt ledger unchanged. - Integration: `artifact-flag-precedence.integration.test.ts` and `dev-no-watch.pin.test.ts`, 18 of 18 passed; `artifact-child-env.pin.test.ts`, 33 of 33. - `dispatch-gates --commands --repo objectstack-ai/objectstack` derived 97 families at `f634c5bf09`. All 97 ran with exit 0, after a full `turbo run build`. `--ran` reconciliation: 97 derived, 97 run, 0 NOT-MEASURED, 0 UNRUN, with the zero derived from recorded exit codes. - `pnpm lint`: a proven narrowing, not the repo-wide run. - The population comes from `eslint.config.mjs`'s own globs, and none of the 10 changed `.ts` files is ignored. - `eslint --no-inline-config --format json` over the 10 files reports 10 results, 0 errors, 0 warnings. - The config has no `parserOptions.project` and reads only two untouched baseline JSONs, so untouched files' verdicts cannot move. - Main was merged in at `c4528fad62` (`10454b3afa`). Main has not moved under `serve.ts`, `dev.ts` or `start.ts` since. ## Acceptance notes - **The supervisor's pre-boot `Artifact:` row on the config-joins path is unchanged.** On a bare `os start` beside a host config, `os start` still prints `📦 Artifact: dist/objectstack.json` before it spawns, and that file is not what boots (non-dev). The child's ready banner after the boot now says `Config: objectstack.config.ts`, which is the row triage named for this. The supervisor cannot tell a host config from a non-host one without loading the config. Dropping or rewording its row would change every bare `os dev` / `os start` banner, so this round does not do it, and the seat decides. - `os dev`'s handling of `OS_ARTIFACT_PATH` is pre-existing. It compiles into that path when the file is missing (or under `--compile`), and its watch loop rebuilds there. This PR only makes the child recognise that file as the config's output. - The round-1 merge commit `f5c0a890b7` carries no trailer pair. Every other commit carries the model-free pair. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d674df commit e909aa0

12 files changed

Lines changed: 1225 additions & 212 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`os dev -a PATH` and `os start --artifact PATH` now serve the artifact they name, also from a directory that holds an `objectstack.config.ts` (#21501).
6+
7+
Clause-②: no
8+
9+
- **One precedence, written once.** The order is `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` (`os start` only) > a cwd `objectstack.config.ts`, except that a cwd config joins the boot when the resolved artifact is its own compiled output. It is the order the `os start` reference already published. `os start` and `os dev` both resolve through one module, and the `serve` child they spawn boots exactly their answer.
10+
- **Beside a config.** The child used to read the supervisor's answer only when the working directory held no config. So `os dev -a X` and `os start --artifact X` printed `Artifact: X` and served the config's `dist/objectstack.json`, or the config itself. A named artifact now boots alone, exactly as it boots from a directory with no config. The config takes part only when the artifact is its own compiled output: `<config dir>/dist/objectstack.json`, or the path the command compiled it to. A bare `os dev`, a bare `os start` in a project, and `os start --artifact ./dist/objectstack.json` take that path, and are unchanged. A host config (its `plugins` hold code) boots its own module there, because its compiled output cannot carry that code.
11+
- **`OS_ARTIFACT_PATH` beside a config** follows the same rule: `OS_ARTIFACT_PATH=Y os start` serves `Y` without loading the config. Under `os start --artifact ./dist/objectstack.json` the flag now also wins over an exported `OS_ARTIFACT_PATH` inside the config boot.
12+
- **`os dev` under a local `OS_ARTIFACT_PATH`** compiles the cwd config into that path, so the file there is the config's own compiled output. The config takes part in the boot that serves it, and a host config compiled there keeps its plugins.
13+
- **`os dev` gains the `OS_ARTIFACT_URL` rung.** `--artifact` outranks it. Before, the reference stayed in the child's environment and won. Without the flag the reference drives the boot, as under `os start`. The `Artifact:` row names it (redacted), and nothing is compiled into, watched for or judged stale against it.
14+
- **Banner rows.** `os start` and `os dev` print `Config:` only when the config takes part in the boot. The child says it is not loading a config that sits beside a named artifact, instead of `No objectstack.config.ts found`.
15+
- **The ready banner names what loaded.** On a config boot, a non-host config whose app was served from its compiled artifact gets `Artifact: dist/objectstack.json` in the ready banner, and a host config keeps `Config: objectstack.config.ts`. No ready-banner row names a file the boot did not load.
16+
17+
Upgrading: a project that ran `os dev -a`, `os start --artifact` or `OS_ARTIFACT_PATH` beside its config, and relied on that config being loaded, should drop the override or point it at `./dist/objectstack.json`.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32)
181181

182182
| Flag | Env equivalent | Purpose |
183183
|---|---|---|
184-
| `-a, --artifact <path\|url>` | `OS_ARTIFACT_PATH` | Boot a pre-built artifact directly; **skips auto-compile** |
184+
| `-a, --artifact <path\|url>` | `OS_ARTIFACT_URL` / `OS_ARTIFACT_PATH` | Boot a pre-built artifact directly; **skips auto-compile** |
185185
| `-d, --database <url>` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` |
186186
| `--database-driver <kind>` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` \| `memory` |
187187
| `--database-auth-token <t>` | `OS_DATABASE_AUTH_TOKEN` | libsql/Turso token |

‎packages/cli/src/commands/artifact-child-env.pin.test.ts‎

Lines changed: 223 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,16 @@
1313
*
1414
* The plumbing now travels on `OS_INTERNAL_ARTIFACT_PATH`
1515
* (`utils/internal-artifact-channel.ts`). This file pins both halves of the
16-
* property, plus the two behaviours that had to survive the move: the
17-
* resolution ladder, and `start`'s deliberate refusal to declare an empty boot
18-
* acceptable when a reference is driving the boot.
16+
* property, plus the behaviours that had to survive the move: the resolution
17+
* ladder, and `start`'s deliberate refusal to declare an empty boot acceptable
18+
* when a reference is driving the boot.
19+
*
20+
* #21501 — the ladder is now ONE resolver every door asks
21+
* (`utils/artifact-precedence.ts`): `start` and `dev` resolve through it, and
22+
* its last rung (does the cwd `objectstack.config.ts` take part?) is the one
23+
* predicate both the supervisors' `Config:` row and the `serve` child read.
24+
* The ladder pins below drive that resolver; the boots that prove the child
25+
* obeys it live in `test/artifact-flag-precedence.integration.test.ts`.
1926
*
2027
* Two kinds of assertion here, and both are needed:
2128
*
@@ -37,10 +44,16 @@ import path from 'path';
3744
import ts from 'typescript';
3845
import {
3946
INTERNAL_ARTIFACT_PATH_ENV,
47+
INTERNAL_CONFIG_OUTPUT_PATH_ENV,
4048
childEnvWithResolvedArtifact,
4149
readInternalArtifactPath,
50+
readInternalConfigOutputPath,
4251
} from '../utils/internal-artifact-channel.js';
43-
import { resolveArtifactSource } from './start.js';
52+
import {
53+
cwdConfigJoinsBoot,
54+
isConfigCompiledArtifact,
55+
resolveArtifactBootSource,
56+
} from '../utils/artifact-precedence.js';
4457

4558
const ARTIFACT = '/srv/app/objectstack.json';
4659

@@ -99,6 +112,42 @@ describe('the child `serve` env — OS_ARTIFACT_PATH means an operator set it',
99112
}
100113
});
101114

115+
it('a `resolved` answer REMOVES an outranked OS_ARTIFACT_URL; a reference keeps it (#21501)', () => {
116+
// Through the one ladder a supervisor resolves an artifact while the
117+
// reference is set only on the rung above it — `--artifact`. Leaving the
118+
// reference in the child env let `serve` read it first: measured,
119+
// `OS_ARTIFACT_URL=file://…/BRAVO.json os dev -a ALPHA.json` served BRAVO.
120+
const parentEnv = { OS_ARTIFACT_URL: 'https://cdn.example.com/ref.json' };
121+
expect(childEnvWithResolvedArtifact(parentEnv, { kind: 'resolved', path: ARTIFACT }).OS_ARTIFACT_URL)
122+
.toBeUndefined();
123+
for (const decision of [{ kind: 'reference' }, { kind: 'empty' }] as const) {
124+
expect(childEnvWithResolvedArtifact(parentEnv, decision).OS_ARTIFACT_URL)
125+
.toBe('https://cdn.example.com/ref.json');
126+
}
127+
});
128+
129+
it('carries where the parent compiles the cwd config only when the decision says so — and owns that variable too', () => {
130+
const named = '/srv/app/build/named.json';
131+
const withTarget = childEnvWithResolvedArtifact({}, { kind: 'resolved', path: named, configCompiledTo: named });
132+
expect(withTarget[INTERNAL_CONFIG_OUTPUT_PATH_ENV]).toBe(named);
133+
expect(readInternalConfigOutputPath(withTarget)).toBe(named);
134+
135+
// An inherited copy never speaks for a decision the parent did not make.
136+
const parentEnv = { [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: '/stale/inherited.json' };
137+
for (const decision of [
138+
{ kind: 'resolved', path: ARTIFACT },
139+
{ kind: 'reference' },
140+
{ kind: 'empty' },
141+
] as const) {
142+
const childEnv = childEnvWithResolvedArtifact(parentEnv, decision);
143+
expect(
144+
Object.prototype.hasOwnProperty.call(childEnv, INTERNAL_CONFIG_OUTPUT_PATH_ENV),
145+
`decision ${decision.kind} declared no compile path, so the variable must be absent`,
146+
).toBe(false);
147+
}
148+
expect(readInternalConfigOutputPath({ [INTERNAL_CONFIG_OUTPUT_PATH_ENV]: ' ' })).toBeUndefined();
149+
});
150+
102151
it('reads a blank channel value as no decision at all', () => {
103152
expect(readInternalArtifactPath({})).toBeUndefined();
104153
expect(readInternalArtifactPath({ [INTERNAL_ARTIFACT_PATH_ENV]: '' })).toBeUndefined();
@@ -140,7 +189,7 @@ describe('OS_BOOT_EMPTY — the artifact-reference refusal survives the move', (
140189
});
141190
});
142191

143-
describe('resolveArtifactSource — the resolution ladder is unchanged', () => {
192+
describe('resolveArtifactBootSource — THE ladder, written once (#21501)', () => {
144193
let cwd: string;
145194
let home: string;
146195

@@ -150,6 +199,8 @@ describe('resolveArtifactSource — the resolution ladder is unchanged', () => {
150199
writeFileSync(abs, '{}');
151200
return abs;
152201
};
202+
const resolvedPath = (r: ReturnType<typeof resolveArtifactBootSource>) =>
203+
(r.kind === 'resolved' ? r.path : undefined);
153204

154205
beforeEach(() => {
155206
cwd = mkdtempSync(path.join(tmpdir(), 'os-artifact-cwd-'));
@@ -161,55 +212,143 @@ describe('resolveArtifactSource — the resolution ladder is unchanged', () => {
161212
}
162213
});
163214

164-
it('rung 1: --artifact wins over everything, including an operator OS_ARTIFACT_PATH', () => {
215+
it('rung 1: --artifact wins over everything, an operator OS_ARTIFACT_PATH and OS_ARTIFACT_URL included', () => {
165216
const flagFile = write(cwd, 'build/pinned.json');
166217
write(cwd, 'dist/objectstack.json');
167218
write(home, 'dist/objectstack.json');
168219

169-
const r = resolveArtifactSource('build/pinned.json', home, {
220+
const r = resolveArtifactBootSource({
221+
flag: 'build/pinned.json',
170222
cwd,
171-
env: { OS_ARTIFACT_PATH: '/from/env.json' },
223+
homeDir: home,
224+
env: { OS_ARTIFACT_PATH: '/from/env.json', OS_ARTIFACT_URL: 'https://cdn.example.com/ref.json' },
172225
});
173-
expect(r?.path).toBe(flagFile);
226+
expect(r).toMatchObject({ kind: 'resolved', rung: 'flag', path: flagFile });
174227
});
175228

176229
it('rung 1: --artifact passes an http(s) URL through untouched', () => {
177230
const url = 'https://cdn.example.com/app.json';
178-
expect(resolveArtifactSource(url, home, { cwd, env: {} })?.path).toBe(url);
231+
expect(resolvedPath(resolveArtifactBootSource({ flag: url, cwd, homeDir: home, env: {} }))).toBe(url);
179232
});
180233

181-
it('rung 2: $OS_ARTIFACT_PATH wins over both auto-detected locations', () => {
182-
write(cwd, 'dist/objectstack.json');
183-
write(home, 'dist/objectstack.json');
234+
it('rung 1: a named --artifact is not existence-checked — a missing one is the child\'s loud refusal', () => {
235+
expect(resolveArtifactBootSource({ flag: 'nope.json', cwd, env: {} }))
236+
.toMatchObject({ kind: 'resolved', rung: 'flag', path: path.join(cwd, 'nope.json') });
237+
});
184238

185-
const r = resolveArtifactSource(undefined, home, {
239+
it('rung 2a: OS_ARTIFACT_URL is a reference — resolved by the child, never here — and outranks OS_ARTIFACT_PATH', () => {
240+
write(cwd, 'dist/objectstack.json');
241+
const r = resolveArtifactBootSource({
186242
cwd,
187-
env: { OS_ARTIFACT_PATH: 'custom/app.json' },
243+
homeDir: home,
244+
env: { OS_ARTIFACT_URL: ' https://cdn.example.com/ref.json ', OS_ARTIFACT_PATH: 'custom/app.json' },
188245
});
189-
// Anchored on the cwd, exactly as before — the ladder resolves it; the
190-
// variable itself is inherited by the child untouched.
191-
expect(r?.path).toBe(path.join(cwd, 'custom/app.json'));
246+
expect(r).toEqual({ kind: 'reference', url: 'https://cdn.example.com/ref.json' });
247+
});
248+
249+
it('rung 2a: a blank OS_ARTIFACT_URL reads as unset', () => {
250+
const cwdArtifact = write(cwd, 'dist/objectstack.json');
251+
expect(resolvedPath(resolveArtifactBootSource({ cwd, env: { OS_ARTIFACT_URL: ' ' } }))).toBe(cwdArtifact);
252+
});
253+
254+
it('rung 2b: $OS_ARTIFACT_PATH wins over both auto-detected locations', () => {
255+
write(cwd, 'dist/objectstack.json');
256+
write(home, 'dist/objectstack.json');
257+
258+
const r = resolveArtifactBootSource({ cwd, homeDir: home, env: { OS_ARTIFACT_PATH: 'custom/app.json' } });
259+
// Anchored on the cwd — the ladder resolves it; the variable itself is
260+
// inherited by the child untouched.
261+
expect(r).toMatchObject({ kind: 'resolved', rung: 'env-path', path: path.join(cwd, 'custom/app.json') });
192262
});
193263

194-
it('rung 2: $OS_ARTIFACT_PATH may itself be an http(s) URL', () => {
264+
it('rung 2b: $OS_ARTIFACT_PATH may itself be an http(s) URL', () => {
195265
const url = 'https://cdn.example.com/env.json';
196-
expect(resolveArtifactSource(undefined, home, { cwd, env: { OS_ARTIFACT_PATH: url } })?.path)
266+
expect(resolvedPath(resolveArtifactBootSource({ cwd, homeDir: home, env: { OS_ARTIFACT_PATH: url } })))
197267
.toBe(url);
198268
});
199269

200270
it('rung 3: <cwd>/dist/objectstack.json wins over <home>/dist', () => {
201271
const cwdArtifact = write(cwd, 'dist/objectstack.json');
202272
write(home, 'dist/objectstack.json');
203-
expect(resolveArtifactSource(undefined, home, { cwd, env: {} })?.path).toBe(cwdArtifact);
273+
expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} }))
274+
.toMatchObject({ kind: 'resolved', rung: 'cwd-dist', path: cwdArtifact });
204275
});
205276

206-
it('rung 4: <home>/dist/objectstack.json is the last resort', () => {
277+
it('rung 4: <home>/dist/objectstack.json is the last artifact rung — and only for a door that passes a home', () => {
207278
const homeArtifact = write(home, 'dist/objectstack.json');
208-
expect(resolveArtifactSource(undefined, home, { cwd, env: {} })?.path).toBe(homeArtifact);
279+
expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} }))
280+
.toMatchObject({ kind: 'resolved', rung: 'home-dist', path: homeArtifact });
281+
// `os dev` passes no home: its home is per-run state, never an artifact source.
282+
expect(resolveArtifactBootSource({ cwd, env: {} })).toEqual({ kind: 'unresolved' });
283+
});
284+
285+
it('rung 5: nothing reachable is `unresolved` — what is left is the cwd config', () => {
286+
expect(resolveArtifactBootSource({ cwd, homeDir: home, env: {} })).toEqual({ kind: 'unresolved' });
287+
});
288+
});
289+
290+
describe('cwdConfigJoinsBoot — the last rung, one predicate for both ends (#21501)', () => {
291+
const projectDir = path.join(tmpdir(), 'os-project');
292+
const configPath = path.join(projectDir, 'objectstack.config.ts');
293+
const ownArtifact = path.join(projectDir, 'dist', 'objectstack.json');
294+
295+
it('a config takes part when nothing above it answered', () => {
296+
expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'none' } })).toBe(true);
297+
});
298+
299+
it('a config takes part when the artifact IS its own compiled output — however the path is spelled', () => {
300+
expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'path', path: ownArtifact } }))
301+
.toBe(true);
302+
expect(isConfigCompiledArtifact(path.join(projectDir, 'dist', '.', 'objectstack.json'), configPath)).toBe(true);
303+
});
304+
305+
it('a config does NOT take part beside any other named artifact — leg 1 and leg 2 of the card', () => {
306+
for (const other of [
307+
path.join(projectDir, 'build', 'pinned.json'),
308+
path.join(tmpdir(), 'elsewhere', 'objectstack.json'),
309+
path.join(projectDir, '.objectstack', 'dist', 'objectstack.json'),
310+
'https://cdn.example.com/objectstack.json',
311+
]) {
312+
expect(
313+
cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'path', path: other } }),
314+
`${other} must boot alone, not under the cwd config`,
315+
).toBe(false);
316+
}
317+
});
318+
319+
it('a config takes part when the artifact is where THIS command compiled it — a named path (os dev under OS_ARTIFACT_PATH)', () => {
320+
const named = path.join(projectDir, 'build', 'named.json');
321+
expect(cwdConfigJoinsBoot({
322+
configExists: true,
323+
configPath,
324+
artifact: { kind: 'path', path: named, configCompiledTo: named },
325+
})).toBe(true);
326+
expect(isConfigCompiledArtifact(named, configPath, path.join(projectDir, 'build', '.', 'named.json'))).toBe(true);
327+
// Declaring a compile path does not make a DIFFERENT artifact the config's own,
328+
expect(cwdConfigJoinsBoot({
329+
configExists: true,
330+
configPath,
331+
artifact: { kind: 'path', path: path.join(tmpdir(), 'elsewhere.json'), configCompiledTo: named },
332+
})).toBe(false);
333+
// and a URL is never a place a config was compiled to.
334+
expect(isConfigCompiledArtifact('https://cdn.example.com/a.json', configPath, 'https://cdn.example.com/a.json'))
335+
.toBe(false);
336+
// The conventional path stays the config's own output beside a declared one.
337+
expect(isConfigCompiledArtifact(ownArtifact, configPath, named)).toBe(true);
338+
});
339+
340+
it('a config does NOT take part under a reference (OS_ARTIFACT_URL)', () => {
341+
expect(cwdConfigJoinsBoot({ configExists: true, configPath, artifact: { kind: 'reference' } })).toBe(false);
209342
});
210343

211-
it('rung 5: nothing reachable resolves to undefined', () => {
212-
expect(resolveArtifactSource(undefined, home, { cwd, env: {} })).toBeUndefined();
344+
it('no config never takes part', () => {
345+
for (const artifact of [
346+
{ kind: 'none' },
347+
{ kind: 'reference' },
348+
{ kind: 'path', path: ownArtifact },
349+
] as const) {
350+
expect(cwdConfigJoinsBoot({ configExists: false, configPath, artifact })).toBe(false);
351+
}
213352
});
214353
});
215354

@@ -311,3 +450,62 @@ describe('structural: the supervisors never write the operator knob', () => {
311450
expect(/OS_ARTIFACT_PATH\s*:/.test(textScanned)).toBe(false);
312451
});
313452
});
453+
454+
describe('structural: the supervisors carry no private copy of the ladder (#21501)', () => {
455+
/**
456+
* `start` and `dev` each used to read the operator's artifact variables
457+
* themselves, in their own order — and `dev`'s order had no
458+
* `OS_ARTIFACT_URL` rung, which is how `--artifact` came to lose to the
459+
* reference. Every rung is now read in `utils/artifact-precedence.ts` alone.
460+
* A READ of either variable reappearing in a supervisor is a second copy of
461+
* the order starting to grow, so it is refused here by the AST (strings and
462+
* comments that merely NAME the variables stay free).
463+
*/
464+
const ARTIFACT_VARS = new Set(['OS_ARTIFACT_PATH', 'OS_ARTIFACT_URL']);
465+
466+
const artifactVarReadsIn = (file: string, src: string): string[] => {
467+
const sourceFile = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true);
468+
const hits: string[] = [];
469+
const at = (node: ts.Node) =>
470+
`${file}:${sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1}`;
471+
const visit = (node: ts.Node): void => {
472+
if (ts.isPropertyAccessExpression(node) && ARTIFACT_VARS.has(node.name.text)) {
473+
hits.push(`${at(node)} ${node.getText(sourceFile)}`);
474+
}
475+
if (
476+
ts.isElementAccessExpression(node)
477+
&& ts.isStringLiteral(node.argumentExpression)
478+
&& ARTIFACT_VARS.has(node.argumentExpression.text)
479+
) {
480+
hits.push(`${at(node)} ${node.getText(sourceFile)}`);
481+
}
482+
ts.forEachChild(node, visit);
483+
};
484+
visit(sourceFile);
485+
return hits;
486+
};
487+
const artifactVarReads = (file: string): string[] =>
488+
artifactVarReadsIn(file, readFileSync(new URL(`./${file}`, import.meta.url), 'utf8'));
489+
490+
for (const file of ['start.ts', 'dev.ts']) {
491+
it(`${file} reads neither artifact variable itself — it asks resolveArtifactBootSource`, () => {
492+
expect(
493+
artifactVarReads(file),
494+
`${file} must resolve the artifact through utils/artifact-precedence.ts, never by reading `
495+
+ 'OS_ARTIFACT_PATH / OS_ARTIFACT_URL itself: a second reading is a second copy of the order.',
496+
).toEqual([]);
497+
});
498+
}
499+
500+
it('the detector sees both spellings of a read — and not a name inside a string', () => {
501+
const specimen = [
502+
'const a = process.env.OS_ARTIFACT_URL;',
503+
"const b = env['OS_ARTIFACT_PATH'];",
504+
"printKV('Artifact', `${x} (OS_ARTIFACT_URL)`); // OS_ARTIFACT_PATH in a comment",
505+
].join('\n');
506+
expect(artifactVarReadsIn('specimen.ts', specimen)).toEqual([
507+
'specimen.ts:1 process.env.OS_ARTIFACT_URL',
508+
"specimen.ts:2 env['OS_ARTIFACT_PATH']",
509+
]);
510+
});
511+
});

0 commit comments

Comments
 (0)