fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude
, '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

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots - #14576

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn
Sep 2, 2026
Merged

fix(lint): extend the flattened-scope shadowing warning to the descriptor-declared predicate slots#14576
baozhoutao merged 2 commits into
mainfrom
claude/issue-14288-descriptor-slot-shadow-warn

Conversation

@claude

@claudeclaudeBot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes#14288

This card was measure-first: the ruling made "narrower per-node scope ⇒ close as not-a-gap, no PR" a complete deliverable, so the scope question was answered on the engine before any lint file was touched. The measurement says shared map, for both predicate slot kinds — so the fix applies, and it is one call site.

1. The measurement (engine, read-only)

All citations against packages/services/service-automation/src/engine.ts at base fed4fa409.

The shadowing mechanism is unchanged

seedRunVariables (engine.ts:7673-7692) seeds the flow's declared variables first and flattens the record's fields only where nothing is bound yet:

  • :7679const variables = this.seedDeclaredVariables(flow, context);
  • :7684for (const [k, v] of Object.entries(context.record)) { if (!variables.has(k)) variables.set(k, v); }

So a name bound as BOTH a declared variable and a field on the bound object resolves to the variable, silently. That is the #14089 diagnostic's whole subject.

The single map threads to every node executor — no clone, no narrowing

  • :3835const variables = this.seedRunVariables(flow, flowName, context, runId);
  • :3937await this.executeNode(startNode, flow, variables, runContext, steps);
  • :6578-6583executeNode(node, flow, variables: Map, context, steps)
  • :6647 / :6652executor.execute(node, variables, context)

The two positions PR #14263 already covers ride that same map: node conditions at :3874 and edge conditions at :6955. A grep for new Map(variables / new Map(...variables / scopeFor / narrowScope over engine.ts returns zero hits — the only const scope = new Map in the file is :5097, and it is a superset (below).

Slot kind 1 — decision.conditions[].expression ⇒ SHARED MAP

packages/services/service-automation/src/builtin/logic-nodes.ts:73

if (engine.evaluateCondition({ dialect: 'cel', source: cond.expression }, variables)) {

variables is the executor's own second parameter (async execute(node, variables, _context), logic-nodes.ts:46) — literally the same Map object handed in at engine.ts:6647/:6652, and therefore the same object a node condition is judged against at :3874. Same verdict as condition.

Slot kind 2 — screen.fields[].visibleWhen ⇒ SHARED MAP (superset)

refuseInvalidScreenInput (engine.ts:5084, called :4848):

  • :5097const scope = new Map(Object.entries(run.variables));
  • :5098for (const [k, v] of Object.entries(bag)) scope.set(k, v);
  • :5102return this.evaluateCondition(String(field.visibleWhen), scope);

run.variablesis the persisted seedRunVariables map: the suspend path snapshots it at :4007 (Object.fromEntries(variables)) into persistSuspendedRun:4014; the re-suspend path does the same at :4971/:4976, and executeWithoutRetry at :7866/:7873. Resume rebuilds the traversal map from that identical field at :4855.

The submitted bag is overlaid on top, making the scope a superset — so the shadow still reaches it. The overlay carries the screen's own collected values (user input), never the bound record's field, so it can never hand back a field the variable displaced. A superset makes the warning more correct here, not less.

Verdict

Both predicate rows on the ledger evaluate against the run's one flattened variable map. Outcome A.loop.collection and map.collection are flow-template, not predicate, and the slot loop already skips them before this pass — so they are correctly untouched.

2. The change

packages/lint/src/validate-expressions.ts — the descriptor-slot loop (:1137-1143 on base). The location label is hoisted into one slotWhere const (it was already built inline for checkDeclaredPredicate) and onewarnShadowedFieldReads(slotWhere, found.value) call is added inside the existing role === 'predicate' branch, reusing the declaredVariables set collected once per flow at :1093. The comment records the measurement above so the next reader does not have to re-derive it.

Within option C's letter, as the ruling requires: warning-only, same severity, no new rule id, no accept set moved, and no bare identifier judged for being bare. Nothing that linted clean before can newly fail a build.

3. Tests

packages/lint/src/validate-expressions.test.ts — one positive and one negative per predicate slot kind, plus a severity assertion, nested inside the existing flattened-scope shadowing (#14089) block and reusing its shadowFields / record_change fixtures:

  • screen visibleWhen reading a shadowed bare name ⇒ exactly one warning naming screen field visibleWhen at config.fields[0].visibleWhen
  • screen visibleWhen whose bare name is a field only ⇒ zero issues (the canon-taught form)
  • decision branch expression reading a shadowed bare name ⇒ exactly one warning naming decision branch expression at config.conditions[0].expression
  • decision expression whose bare name is a variable only ⇒ zero issues
  • both slots together ⇒ 2 issues, none above warning (a toHaveLength(1) would still pass if the issue were an error, so severity is asserted separately)

Reverse verification. Predicted direction: RED. The subject under test is imported relatively (from './validate-expressions.js'), so it resolves to package source, not dist — no rebuild leg applies. From the committed state, the single call-site line was deleted and the mutation confirmed on disk before measuring (marker count 1 before, 0 after; git diff --stat showed 1 deletion). Result: 3 failed, 2 passed — both positives (expected [] to have a length of 1) and the severity test (expected [] to have a length of 2). The two negative controls stayed green, which is correct: they assert zero warnings and so cannot detect the call site — they are controls, not detectors. The restore leg was then proven by byte identity, not by an exit code: worktree blob 47a90b260358d4c0ffdf8d4ca8adfe15e686844b equals the HEAD blob, git diff HEAD empty, marker count back to 1.

4. Verification

Union run on the final merged commit b3fbd92bc (after git merge origin/main).

  • pnpm --filter @objectstack/lint test93 files, 2822 passed
  • pnpm --filter @objectstack/lint typecheck — clean. tsconfig.json excludes *.test.ts, so the check:test-typecheck half is what covers the new tests; tsc -p tsconfig.test.json --listFiles confirms both edited files are in that program (the "green over source nothing read" trap), and the 6 residual errors are the ledgered TS6059 in two other files, none of them mine.
  • pnpm lintwhole repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing argument is owed.
  • Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed — the script derives its own change set from the merge base): 34 families, identical before and after the merge. Harvested with --commands, not from the prose. Result: 33 exit 0, 1 NOT MEASURED — check-test-completeness.mjs, which grades a CI-produced turbo run test log and prints PREREQUISITE NOT MET with exit 3 locally, its own documented NOT MEASURED branch. node scripts/pm/dispatch-gates.mjs --ran reconciles: 34 derived, 34 run, 0 UNRUN.
  • The build-dependent ratchets were re-run on the merged head against a rebuilt workspace (turbo run build, 70/70): check:type-check-debt (--re-measure: OK, none above its recorded number) and check:dual-build-cjs-loads both exit 0, as did the build-free ratchets.

Exit codes were captured by redirecting first and reading $? before any pipe, and each verdict above is the gate's own printed judgement line rather than a bare $?.

5. Changeset

.changeset/lint-shadow-warning-descriptor-predicate-slots.md@objectstack/lint: patch.

Draft, per the dispatch order: not marked ready, auto-merge not enabled.

Generated by Claude Code


Generated by Claude Code

…ptor-declared predicate slots (#14288)
The #14089 shadowing warning ran on the node `condition` and the edge
`condition` only. The #4027 descriptor-declared predicate slots were checked
for dialect by the same traversal but never passed through the shadowing pass,
so a bare name that is BOTH a declared flow variable AND a field on the bound
object stayed silent there for exactly the reason it was silent on `condition`
before #14089.
Measured on the engine before extending the lint, because "same scope" is the
whole premise: `seedRunVariables` builds ONE map per run and it threads
unchanged into every node executor. `decision.conditions[].expression`
evaluates against that same Map object; `screen.fields[].visibleWhen`
evaluates against the persisted snapshot of it with the submitted bag overlaid
(a superset, and the overlay can never restore the displaced field).
One more `warnShadowedFieldReads` call site reusing the `declaredVariables`
set already collected once per flow. Warning-only: no new rule id, no severity
above `warning`, no accept set moved, no bare identifier judged for being bare.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 1 documentable anchor(s).

1 release-owned page(s) name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via validateStackExpressions (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 84c605e5de514dcae6217d69c81ef4f8776e4371 — the merge of head b3fbd92bc3db882601a32037d05bdb3b2695fc6e into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 84c605e5de514dcae6217d69c81ef4f8776e4371 && git checkout 84c605e5de514dcae6217d69c81ef4f8776e4371
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad b3fbd92bc3db882601a32037d05bdb3b2695fc6e && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff b3fbd92bc3db882601a32037d05bdb3b2695fc6e
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@baozhoutao@claude