Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-shadow-warning-descriptor-predicate-slots.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): run the flattened-scope shadowing warning on the descriptor-declared predicate slots too (#14288)

The #14089 shadowing warning — a bare name that is BOTH a declared flow
variable AND a field on the bound object, where the variable silently wins at
runtime — reached exactly two expression positions: the node `condition` and
the edge `condition`. The `#4027` descriptor-declared predicate slots were
validated for dialect by the same traversal but were never passed through the
shadowing pass, so the identical mistake stayed silent on them.

The warning is about the **scope** an expression is evaluated in, not the key
it was authored under, and the engine measurement says both `predicate` slots
on the ledger share the run's one flattened variable map:

- `decision.conditions[].expression` — the decision executor evaluates against
the very `variables` parameter the engine hands every node executor, which is
the same `Map` object `seedRunVariables` built and a node `condition` is
judged against. Nothing on the path clones or narrows it.
- `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates against
`run.variables` (the persisted snapshot of that same seeded map) with the
submitted bag overlaid. A superset, so the shadow still reaches it: the
overlay carries the screen's own collected values, never the bound record's
field, so it can never hand back a field the variable displaced.

`loop.collection` and `map.collection` are `flow-template`, not `predicate`,
and the slot loop already skips them.

Warning-only and within the 2026-09-01 option-C ruling's letter: one more call
site reusing the `declaredVariables` set already collected once per flow, no
new rule id, no severity above `warning`, no accept set moved, and no bare
identifier judged for being bare. Nothing that linted clean before can newly
fail a build.
151 changes: 151 additions & 0 deletions packages/lint/src/validate-expressions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -585,6 +585,157 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
});
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});

/**
* ── #14288 — the same warning on the #4027 descriptor-declared slots ─────
*
* The warning is about the SCOPE, not the key it was authored under, and
* the engine measurement says both `predicate` slots on the ledger share
* the run's ONE flattened variable map:
*
* • `decision.conditions[].expression` — the decision executor
* (`builtin/logic-nodes.ts`) evaluates against the very `variables`
* parameter the engine hands every node executor, which is the same Map
* object `seedRunVariables` built and a node `condition` is judged
* against. Nothing on the path clones or narrows it.
* • `screen.fields[].visibleWhen` — `refuseInvalidScreenInput` evaluates
* against `run.variables` (the persisted snapshot of that same seeded
* map) with the submitted bag overlaid. A SUPERSET, so the shadow still
* reaches it; the overlay is the screen's own collected values and can
* never hand back the bound-object field the variable displaced.
*
* `loop.collection` / `map.collection` are `flow-template`, not
* `predicate`, and the slot loop skips them before this pass — so they are
* deliberately absent here.
*/
describe('reaches the descriptor-declared predicate slots (#14288)', () => {
// ── slot kind 1: screen field visibleWhen ──
it('warns on a screen field `visibleWhen` reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'ask'");
// The located slot, indexed into the repeater — same label the #4027
// dialect check reports, so both findings point at one place.
expect(issues[0].where).toContain('screen field visibleWhen');
expect(issues[0].where).toContain('config.fields[0].visibleWhen');
expect(issues[0].message).toMatch(/BOTH a declared flow variable and a field on `duly_assignment`/);
});

// NEGATIVE CONTROL — a screen predicate reading a name that is only a
// FIELD. This is the canon-taught form; warning here would be exactly the
// over-reach options A and B were excluded for.
it('stays silent on a screen `visibleWhen` whose bare name is a FIELD ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'retry_count', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// ── slot kind 2: decision branch expression ──
it('warns on a decision branch expression reading a shadowed bare name', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(1);
expect(issues[0].severity).toBe('warning');
expect(issues[0].where).toContain("node 'check'");
expect(issues[0].where).toContain('decision branch expression');
expect(issues[0].where).toContain('config.conditions[0].expression');
expect(issues[0].message).toMatch(/bare reference `amount`/);
});

// NEGATIVE CONTROL — an ordinary flow-variable read on the same slot.
it('stays silent on a decision expression whose bare name is a VARIABLE ONLY', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'batch_size', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'batch_size > 0' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(0);
});

// Option C's severity floor holds on the new call site too: the slots
// gain a warning and nothing else. A `toHaveLength(1)` above would still
// pass if that one issue were an error, so this asserts it separately.
it('is advisory only on the descriptor slots — never an error', () => {
const issues = validateStackExpressions({
objects: [{ name: 'duly_assignment', fields: shadowFields }],
flows: [{
name: 'record_change',
variables: [{ name: 'status', type: 'text' }, { name: 'amount', type: 'number' }],
nodes: [
{ id: 'start', type: 'start', config: { objectName: 'duly_assignment' } },
{
id: 'ask',
type: 'screen',
config: { fields: [{ name: 'note', type: 'text', visibleWhen: 'status == "dispatched"' }] },
},
{
id: 'check',
type: 'decision',
config: { conditions: [{ label: 'Large', expression: 'amount > 100000' }] },
},
],
edges: [],
}],
});
expect(issues).toHaveLength(2);
expect(issues.filter((i) => i.severity !== 'warning')).toEqual([]);
});
});
});

// #1928 tier 4 — a text/boolean field used with an arithmetic/ordering
Expand Down
34 changes: 30 additions & 4 deletions packages/lint/src/validate-expressions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1136,10 +1136,36 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
const slotWhere = `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`;
checkDeclaredPredicate(slotWhere, found.value);
// [#14288] The shadowing warning is about the SCOPE an expression is
// evaluated in, not about which key it was authored under — so it
// belongs on every `predicate` slot the ledger declares, not just the
// two hardcoded `condition` keys #14089 reached. Measured on the
// engine before it was extended here, because "same scope" is the
// whole premise and a narrower per-node scope would have made this
// call site a false positive:
//
// • `seedRunVariables` builds ONE map per run (`engine.ts`, declared
// variables first, then the record's fields only where nothing is
// bound yet) and it threads UNCHANGED into every node executor —
// `execute()` seeds it, `executeNode` passes it down, and
// `executor.execute(node, variables, context)` hands that same Map
// object over. No clone, no narrowing, anywhere on the path.
// • `decision.conditions[].expression` (`builtin/logic-nodes.ts`)
// evaluates against that very parameter — literally the same Map
// a node `condition` is judged against.
// • `screen.fields[].visibleWhen` (`refuseInvalidScreenInput`)
// evaluates against `run.variables` — the persisted snapshot of
// the same seeded map — with the SUBMITTED bag overlaid. A
// superset, so the shadow still reaches it: the overlay carries
// the screen's own collected values, never the bound record's
// field, so it can never hand back a field the variable displaced.
//
// Same `declaredVariables` set, same severity, no new rule id: this
// moves no accept set and judges no bare identifier for being bare
// (the 2026-09-01 option-C ruling's letter).
warnShadowedFieldReads(slotWhere, found.value);
}
// #1870 — a `script` node must name a callable, and since #4343 that is
// the whole of what the node does: `config.function`. A node without one
Expand Down
Loading