Skip to content

Commit 1182fdc

Browse files
committed
lint: hand the registry's parsed tier the lowered stack so hook write rules reach handler-authored hooks
`os lint` judged the un-lowered normalized stack, so every rule in the `hook-body-*` / `hook-api-update-readonly-*` family returned before reading a hook authored as an inline `handler` function; `os build` lowers first and never had the gap. `lintConfig` now runs `lowerCallables` on the same input and hands the lowered view to the `parsed` tier only, leaving the function-reading rules and the caller's stack untouched. Doors measured with a control per leg (lint / build / validate), the two validators carry the reach ledger in their headers, docs and changeset added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
1 parent 5d55afe commit 1182fdc

8 files changed

Lines changed: 504 additions & 3 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/lint": patch
4+
---
5+
6+
`objectstack lint` now judges hooks authored as inline `handler` functions with the same write-set rules it already applied to explicit `body` hooks.
7+
8+
The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, so on the stack `objectstack lint` handed the rule registry the whole family returned before reading anything — while the reference app authors every one of its hooks that way. `objectstack build` never had the gap: it lowers each inline handler to a metadata body before it parses and judges the lowered stack.
9+
10+
`objectstack lint` now hands the registry's parsed-tier rules that same lowered view (the `lowerCallables` pass the build runs), so a handler-authored hook writing a `readonly` field through `ctx.api` is refused by the pre-flight exactly as the build would refuse it. What this does and does not change:
11+
12+
- A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack lint` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` with the same finding, so nothing that built green fails lint red.
13+
- The warning-severity members of the family (`hook-body-write-unknown-field`, `hook-api-update-readonly-when-field`, …) now report on inline handlers too; they never fail a run without `--strict`.
14+
- Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers.
15+
- The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced.
16+
17+
`@objectstack/lint` carries only the header ledger recording which commands reach each hook rule; its behaviour is unchanged.

content/docs/automation/hook-bodies.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ The dropped case is the dangerous one: nothing fails, the step reports success,
268268
- `hook-api-update-readonly-field`**error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` / `.insert()` writes a field the named object declares `readonly: true`. Since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) the `insert` row of the table above is reported at build time exactly like the `update` row — same id, same severity, a message naming the verb — unless the hook declares `runAs: 'system'`. Only the static shape is judged on an insert: a `readonlyWhen` field has no prior record to lock on and the engine runs no conditional strip on INSERT, so no warning is produced there.
269269
- `hook-api-update-readonly-when-field`**warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object.
270270

271+
Which hooks these rules can *see* depends on the command, because every rule in this family opens on `body.language === 'js'`. A hook authored as an inline `handler` function carries no `body`, so it is judged only where the command has first lowered the handler to a metadata body: `objectstack build` always has (it lowers before it parses — see [How the build lowers a handler](#build-pipeline)), and since [#16095](https://github.com/objectstack-ai/objectstack/issues/16095) `objectstack lint` judges that same lowered view, so an author who runs only the pre-flight is told the same thing the build would refuse. `objectstack validate` parses without lowering, so there a handler-authored hook is not seen by this family — the explicit-`body` form is. A handler the build cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by the lowering rules instead, never guessed at here.
272+
271273
Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name and an object this stack does not declare are all skipped, so the rule has no opinion on them. `.create()` is skipped too, for a reason about the **sandbox** rather than the engine: the VM-side `ctx.api.object()` installs `insert` / `update` / `delete` / `updateMany` / `deleteMany` / `upsert` and no `create` leaf, so a body calling `.create()` throws `TypeError: not a function` on its first run — a loud failure, not a silent drop — and the same payload spelled `.insert()` is what the rule judges. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425), and since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) it reports a non-`runAs: 'system'` `create_record` node's static-`readonly` write at the same **error**, again with no conditional finding on a create.
272274

273275
The table above is about a **hook** body. An **action** body is the one surface where the answer changes, so read this before you move a body from one to the other: an action body runs **elevated** — its `ctx.api` is built over the caller's envelope with `isSystem` set, which is the same trusted posture that lets an action bypass row and field permissions — and the static strip applies only to non-system callers. So `ctx.api.object('x').update({ someReadonlyField })` **lands** in an action, and there is no finding for it. Elevation does not waive the *conditional* lock, though, so that half does carry across: `action-api-update-readonly-when-field` — a **warning** — on an action body's literal `ctx.api` update to a `readonlyWhen` field ([#13770](https://github.com/objectstack-ai/objectstack/issues/13770)). Net effect when you move a body: a `readonly` write changes behaviour, a `readonlyWhen` write does not.

packages/cli/src/commands/lint.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { resolveSduiManifest } from '../utils/sdui-manifest.js';
1313
import { collectAndLintDocs } from '../utils/collect-docs.js';
1414
import { scoreMetadata } from '../lint/score.js';
1515
import { checkHookBodyLowering } from '../lint/hook-body-lowering.js';
16+
import { lowerCallables } from '../utils/lower-callables.js';
1617
import { runMetadataEval } from '../lint/metadata-eval.js';
1718
import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js';
1819
import {
@@ -481,7 +482,40 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
481482
// give), so the registry runs both stack tiers against the normalized input —
482483
// which is what this command already did for the reference-integrity suite
483484
// and the security linter.
484-
for (const f of runAuthoringRules('lint', { normalized: config, sduiManifest: opts.sduiManifest })) {
485+
//
486+
// ── The `parsed` tier is handed the LOWERED view (#16095) ──
487+
// A hook authored as an inline `handler` function carries no `body`, and the
488+
// `hook-body-*` / `hook-api-update-readonly-*` family opens on
489+
// `body.language === 'js'` — so on the un-lowered stack the whole family
490+
// returned before reading anything, while the reference app authors 39 of
491+
// 39 hooks that way. `os build` never had that gap: it runs `lowerCallables`
492+
// BEFORE its parse and judges the lowered stack, so the same rules fire
493+
// there. This is the same call on the same normalized input, so what the
494+
// family sees here cannot drift from what `os build` sees (the parity
495+
// `checkHookBodyLowering` above already claims for the refusal side).
496+
//
497+
// What this changes and what it does not:
498+
// - `parsed`-tier rules see `body: { language: 'js', source }` on every
499+
// hook/action whose handler extracts, and `handler: '<ref>'` in place of
500+
// the function — exactly the stack `os build` parses. Still unparsed:
501+
// no defaults are filled, which is the standing condition of this tier
502+
// under `os lint` and one every rule already tolerates.
503+
// - `normalized`-tier rules keep the un-lowered input, as they do in `os
504+
// build` (which hands them `normalized`, not `lowering.lowered`).
505+
// - `lowerCallables` returns a NEW top-level object and re-maps the slots
506+
// it touches (`hooks`, `objects[*].actions`, `actions`, `functions`,
507+
// `packages[*].manifest`); the caller's stack is never mutated, so the
508+
// function-reading rule above and `scoreMetadata` keep their live
509+
// callables. A handler the extractor refuses is left with no `body` —
510+
// the family stays silent on it and `checkHookBodyLowering` is what
511+
// reports it, so no verdict is ever given about a body that was not
512+
// produced. Nothing here touches what `os build` accepts (#13838).
513+
const { lowered } = lowerCallables(config as Record<string, unknown>);
514+
for (const f of runAuthoringRules('lint', {
515+
normalized: config,
516+
parsed: lowered,
517+
sduiManifest: opts.sduiManifest,
518+
})) {
485519
issues.push({
486520
severity: f.severity === 'info' ? 'suggestion' : f.severity,
487521
rule: f.rule,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16095 — which authoring commands can SEE a hook authored as an inline
5+
* `handler` function, measured over the real CLI, one door per leg, with a
6+
* control beside every leg.
7+
*
8+
* The family (`hook-api-update-readonly-*`, `hook-body-*`) opens on
9+
* `body.language === 'js'`. Whether a handler-authored hook reaches it is a
10+
* property of the DOOR — what each command hands the rule registry — not of
11+
* the rule, so a claim measured through one command says nothing about the
12+
* others (#16109's lesson, applied here). The doors:
13+
*
14+
* `os build` lowers inline handlers to a metadata body BEFORE the parse
15+
* (`lowerCallables`) and judges the lowered stack — the family
16+
* reached handler-authored hooks here all along.
17+
* `os lint` used to judge the un-lowered normalized stack; since #16095
18+
* `lintConfig` hands the registry's `parsed` tier the same
19+
* lowered view `os build` judges. This file's RED leg.
20+
* `os validate` parses the normalized stack WITHOUT lowering, so a
21+
* handler-authored hook carries no body there and the family
22+
* does not fire. Recorded below as a MEASUREMENT of that door,
23+
* not as a contract: an author who runs `os validate` alone is
24+
* not told. Closing it changes what `os validate` refuses and
25+
* is its own decision (see the card's report).
26+
*
27+
* The fixture is the card's own: a readonly `is_escalated` written through
28+
* `ctx.api.object('crm_case').update(…)` from an `afterUpdate` hook — the write
29+
* the engine strips on a non-system context while the call reports success.
30+
* The control authors the identical statement as an explicit `body`, which
31+
* fired on every door before this change.
32+
*/
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { execFile } from 'node:child_process';
36+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { join, resolve } from 'node:path';
39+
import { fileURLToPath } from 'node:url';
40+
import { childEnv } from './helpers/serve-process.js';
41+
42+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
43+
const CLI = resolve(HERE, '../bin/run-dev.js');
44+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
45+
46+
const READONLY_RULE = 'hook-api-update-readonly-field';
47+
48+
interface Run {
49+
code: number;
50+
stdout: string;
51+
stderr: string;
52+
}
53+
54+
function runCli(args: string[], cwd: string): Promise<Run> {
55+
return new Promise((resolvePromise) => {
56+
execFile(
57+
TSX,
58+
[CLI, ...args],
59+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
60+
(err, stdout, stderr) => {
61+
resolvePromise({
62+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
63+
stdout: String(stdout),
64+
stderr: String(stderr),
65+
});
66+
},
67+
);
68+
});
69+
}
70+
71+
const OBJECT = `{
72+
name: 'crm_case',
73+
label: 'Case',
74+
sharingModel: 'private',
75+
fields: {
76+
title: { type: 'text', label: 'Title' },
77+
is_escalated: { type: 'boolean', label: 'Escalated', readonly: true },
78+
},
79+
}`;
80+
81+
/** INTAKE: the reference app's shape — an inline handler, no `body`. */
82+
const CONFIG_HANDLER = `
83+
export default {
84+
manifest: { id: 'com.example.reach_handler', name: 'reach_handler', version: '1.0.0', type: 'app' },
85+
objects: [${OBJECT}],
86+
hooks: [{
87+
name: 'escalate',
88+
object: 'crm_case',
89+
events: ['afterUpdate'],
90+
handler: async (ctx: any) => {
91+
await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true });
92+
},
93+
}],
94+
};
95+
`;
96+
97+
/** CONTROL: the identical statement authored as an explicit `body`. */
98+
const CONFIG_BODY = `
99+
export default {
100+
manifest: { id: 'com.example.reach_body', name: 'reach_body', version: '1.0.0', type: 'app' },
101+
objects: [${OBJECT}],
102+
hooks: [{
103+
name: 'escalate',
104+
object: 'crm_case',
105+
events: ['afterUpdate'],
106+
body: {
107+
language: 'js',
108+
source: "await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true });",
109+
},
110+
}],
111+
};
112+
`;
113+
114+
const dirs: Record<string, string> = {};
115+
116+
function project(key: string, source: string): string {
117+
const dir = mkdtempSync(join(tmpdir(), `os-reach-${key}-`));
118+
writeFileSync(join(dir, 'objectstack.config.ts'), source);
119+
dirs[key] = dir;
120+
return dir;
121+
}
122+
123+
beforeAll(() => {
124+
project('handler', CONFIG_HANDLER);
125+
project('body', CONFIG_BODY);
126+
});
127+
128+
afterAll(() => {
129+
for (const dir of Object.values(dirs)) rmSync(dir, { recursive: true, force: true });
130+
});
131+
132+
/** Rule ids named anywhere in a `--json` payload's issue/finding lists. */
133+
function rulesIn(run: Run): string[] {
134+
const json = JSON.parse(run.stdout);
135+
const lists: unknown[] = [json.issues, json.errors, json.warnings, json.findings].filter(Array.isArray);
136+
const ids: string[] = [];
137+
for (const list of lists) {
138+
for (const entry of list as Array<Record<string, unknown>>) {
139+
if (typeof entry?.rule === 'string') ids.push(entry.rule);
140+
}
141+
}
142+
return ids;
143+
}
144+
145+
const label = (run: Run) => `exit ${run.code}\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`;
146+
147+
describe('#16095 — door: `os lint`', () => {
148+
it('INTAKE — a handler-authored hook writing a readonly field is refused (error, exit 1)', async () => {
149+
const run = await runCli(['lint', 'objectstack.config.ts', '--json'], dirs.handler);
150+
expect(run.code, label(run)).toBe(1);
151+
expect(rulesIn(run)).toContain(READONLY_RULE);
152+
}, 60_000);
153+
154+
it('CONTROL — the same statement as an explicit body is refused identically', async () => {
155+
const run = await runCli(['lint', 'objectstack.config.ts', '--json'], dirs.body);
156+
expect(run.code, label(run)).toBe(1);
157+
expect(rulesIn(run)).toContain(READONLY_RULE);
158+
}, 60_000);
159+
});
160+
161+
describe('#16095 — door: `os build` (the door that never had the gap)', () => {
162+
it('INTAKE — the lowered handler is refused at build, exit 1 — unchanged by this card', async () => {
163+
const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.handler);
164+
expect(run.code, label(run)).toBe(1);
165+
expect(rulesIn(run)).toContain(READONLY_RULE);
166+
}, 90_000);
167+
168+
it('CONTROL — the explicit body is refused at build identically', async () => {
169+
const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.body);
170+
expect(run.code, label(run)).toBe(1);
171+
expect(rulesIn(run)).toContain(READONLY_RULE);
172+
}, 90_000);
173+
});
174+
175+
describe('#16095 — door: `os validate` (measured, NOT lowered)', () => {
176+
// A reading of the door as it stands, so a change to it is a change someone
177+
// chose: `os validate` parses the normalized stack without lowering, and the
178+
// handler-authored hook carries no body there. If this leg starts failing
179+
// because `os validate` began lowering, the intake row becomes the control
180+
// row — update the ledger in the file header, do not delete the pin.
181+
it('INTAKE — the handler-authored hook is NOT seen by the family here (exit 0, no finding)', async () => {
182+
const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.handler);
183+
expect(run.code, label(run)).toBe(0);
184+
expect(rulesIn(run)).not.toContain(READONLY_RULE);
185+
}, 60_000);
186+
187+
it('CONTROL — the explicit body IS refused here, so the silence above is the door, not the rule', async () => {
188+
const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.body);
189+
expect(run.code, label(run)).toBe(1);
190+
expect(rulesIn(run)).toContain(READONLY_RULE);
191+
}, 60_000);
192+
});

0 commit comments

Comments
 (0)