Repository navigation
Commit d1dbe70
fix(lint)!: four authoring rules now fire on the sibling spelling of a defect they already caught (#22281)
Part of #22212
Clause-②: no (narrowing: each fix makes an existing rule fire on a
sibling spelling)
Four of the card's six members are closed here: items 1, 3, 4 and 6.
Items 2 and 5 are left out because each needs a new rule id. Details are
in "Left out" below. No rule id is added, and no file outside
`packages/lint/src` or `.changeset/` is touched.
There is one commit per item, item 1 first, each carrying its own pins,
plus one changeset commit. Every pin keeps the card's firing control
beside the probe that now fires.
## What changes
### Item 1: `hook-api-update-readonly-field` /
`hook-body-write-unknown-field` and an aliased `ctx.api`
- **Mechanism (H1 confirmed).** Both rules read writes from
`extractHookBodyWriteSet` in `validate-hook-body-writes.ts`.
`validate-readonly-hook-writes.ts` imports it and has no extractor of
its own. The `api-crud-literal` matcher required `isCtxDot(receiver,
'api')`, so only the literal `ctx.api.object(...)` matched.
- **Fix.** The receiver can now be `ctx.api` or a local bound to it. A
new `collectCtxApiAliases` pre-pass finds these locals. Type-only and
non-null wrappers are seen through: `as T`, a type assertion, `satisfies
T`, `x!` and parentheses.
- **Followed:** `const|let|var api = ctx.api` (including the mandated
`as HookApi | undefined` spelling), `const { api } = ctx`, `const { api:
db } = ctx`, and an optional call `api?.object(...)`.
- **Left opaque, deliberately.** The card left open whether a reassigned
or shadowed `api` must stay opaque. It does, and so do these:
- a name declared more than once anywhere in the body, such as a nested
parameter or a second `const`;
- any assignment to the name;
- a reference outside the declaring block, or outside the declaring
function for `var`;
- a non-alias initializer such as `ctx.api ?? x`;
- an alias of an alias;
- a destructure with a default;
- `const api = ctx.api.sudo()`. The elevated channel stays invisible, as
the readonly rule's header requires.
- **Ledger.** `HOOK_BODY_WRITE_PATTERNS` `api-crud-literal` now states
the receiver in its syntax line, and its reconciliation example includes
the aliased spelling. The action-body write rules share the extractor
and consume `api-crud-literal`, so they read the same receivers. The
action ledger's end-to-end test pins that through the updated example.
### Item 3: `visibility-bare-identifier` and an unbound namespace root
- **Mechanism.** `firstBareIdentifier` declared every receiver-position
name (`namespaceRoots`) before the strict check. `foo.duplicate_of_type`
therefore always passed. The module note justified this with "the set of
legal roots is not yet trustworthy enough to gate on (#6146)".
- **H3, measured.** That doubt concerns *members* of the root set, and
the rule still judges none of them. What it judges now is the
*complement* of a set that is generous by contract. The checker already
declares `@objectstack/formula` `SCOPE_ROOTS`, whose published contract
is that a missing root is a false build error. `VIEW_PAGE_EXTRA_ROOTS`
(`current_user`, `page`) is added to that.
- **What the renderers bind** (objectui `main` `f3a0488`, read at
`buildExpressionScope`, `SchemaRenderer`, the form renderer and the
metadata-admin `predicate.ts`):
- form field: `record`, `previous`, `current_user`, `user`, `ctx`, `os`,
`features`;
- page component: those plus `page` and the adapter `data`;
- metadata form: `data` plus the identity roots.
Every one of these is in the union.
- **Fix.** The verdict is now the checker's alone. The receiver walk
only classifies how the name was written, so an unbound namespace gets
its own sentence and hint under the same id.
- **One stand-down.** On a metadata-editing form, a dotted chain on the
right of `==` / `!=` stays `predicate-rhs-path-shaped`'s, which is
#7696's single-voice rule one step further (`rhsChainRoots`).
- **Three pins changed.** They encoded the old exclusion:
- `my_record.x` on the metadata layer: still no mis-layer advisory, and
now an unbound-root finding;
- "an UNKNOWN root is left to the wrong-root rules": rewritten to its
surviving half, which is that a wrong-but-bound root stays the ADR-0089
rules';
- "an unknown root does not mask a bare identifier": both are now
defects, the first is reported, and fixing it reveals the next.
### Item 4: `security-master-detail-ungranted` and a lookup-bound child
- **H4 measured.** `SecurityPlugin.resolveCbpRelation`
(`plugin-security/src/security-plugin.ts`) resolves a
`controlled_by_parent` object's master in this order: required
`master_detail`, then any `master_detail`, then a **required `lookup`**.
Lint's `resolveCbpRelation` / `CBP_TIERS` mirror it point for point. On
the card's shape (a cbp object with a lookup and no `master_detail`),
the runtime's parent is that lookup. An optional lookup resolves
nothing, and `security-controlled-by-parent-no-relation` already reports
that.
- **Fix.** The new `derivedAccessParent` answers a cbp object through
`resolveCbpRelation`. Every other object keeps the
`firstMasterDetailField` reading. A child with two `master_detail`
fields is now reported against the master the runtime picks. The message
names the relation type.
### Item 6: `component-props-*` and a nested component with no
`properties`
- **H6 confirmed.** `validateComponentProps` ran `if (!props) continue`
on `isRec(component.properties)`.
- **Fix.** An absent bag is judged as `{}`, which is the value
`PageComponentSchema`'s default gives a top-level node. A present
non-object bag is still skipped.
- **Fixture triage.** The #5775 container fixture used `{ type:
'element:text' }` as filler children. The fix updated the filler (it now
carries the one required prop). The test's subject, the container keys,
is unchanged.
## Left out, and why (`Part of`)
- **Item 2 (an action body referencing an undeclared identifier).** No
existing rule judges it. `action-body-source-unparseable` answers a body
that does not parse, and this body parses. The `os lint` `hook-body/*`
rules answer a *handler* refused at lowering, not an authored
`body.source`.
- H2: the sandbox's globals DO have one declared, measured source. That
is `SANDBOX_GLOBALS` in
`packages/cli/src/utils/detect-free-identifiers.ts`, pinned by
`sandbox-globals-probe.test.ts`.
- That source lives in `@objectstack/cli`, which depends on
`@objectstack/lint`, so the lint package cannot read it without moving
it.
- Item 2 therefore needs a new rule id and an edit outside the surface.
Both are stop conditions.
- **Item 5 (an empty `record:details` section, `fields: []`).** H5
holds. `page-section-group-unknown` is a reference rule: it resolves a
section's `group` key against the object's field groups. An empty
`fields` array carries no reference to resolve. Firing that id on it
would change what the id means, so item 5 needs a new rule id.
## Measurements
**Unit-door probe** (`validate*` from the built `@objectstack/lint`
dist).
- Before: at the base `9f0de32a`, every probe is silent and every
control fires.
- After: at `4ab0ff40`, every probe fires beside its control:
```text
item 1 control ctx.api.object(..).update readonly-field(error) + unknown-field(warning)
probe const api = ctx.api before: silent after: same two findings
probe ... as HookApi | undefined before: silent after: same two findings
probe api?.object(..) before: silent after: same two findings
probe const api = ctx.api! before: silent after: same two findings
opaque reassigned / shadowed silent before and after
item 3 control bare duplicate_of_type == ... visibility-bare-identifier(error)
probe foo.duplicate_of_type before: silent after: visibility-bare-identifier(error)
item 4 control master_detail child ungranted security-master-detail-ungranted(warning)
probe required-lookup cbp child before: silent after: security-master-detail-ungranted(warning)
item 6 control top-level, properties {} component-props-invalid(warning)
probe nested, properties absent before: silent after: component-props-invalid(warning)
```
**Public door, `os lint --strict` at `4ab0ff40`.** These are scratch
configs inside `examples/app-todo`, removed afterwards (`git status`
clean).
- The control config fires all four rules:
`hook-api-update-readonly-field` at `hooks[0].handler`,
`visibility-bare-identifier`, `security-master-detail-ungranted` at
`objects[2].fields.campaign`, and `component-props-invalid`.
- The probe config, with each item's sibling spelling, fires the same
four rules, with the nested path
`...components[0].properties.children[0].properties.relationshipField`.
- Both exit 1.
**Fixture sweep.**
- `os lint --json --skip-i18n` at `4ab0ff40` on `examples/app-crm`,
`app-todo`, `app-showcase` and `app-multi-package` reports **0**
findings from any affected rule id. The registry runs on that door:
`security-private-no-readscope` and
`approval-approvers-may-resolve-empty` both appear.
- No change can remove a finding: each one only widens what its rule
reads. So zero at head also means no newly firing example site.
- The lint fixture corpus is the package's own suite: one fixture newly
fired (the #5775 filler above), and it is triaged.
**Tests and typecheck.**
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 126
files and 5793 tests passed.
- `pnpm --filter @objectstack/lint typecheck` (tsc plus
`check:test-typecheck`): exit 0.
**ESLint, narrowed and proven.**
- The command was `eslint --no-inline-config --format json` on the 10
changed `.ts` files. The JSON output lists 10 files linted, with 0
errors and 0 warnings. None was ignored.
- `eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`; its own note at the `QUERY_OPTIONS_TEST_GLOBS`
block says so). So this diff cannot move any untouched file's verdict.
**Gates.** The derived gate list (`dispatch-gates --commands`, 62
families at `4ab0ff40`) was run, and `--ran` was reconciled. The results
are in the report comment on the card.
## Acceptance notes
- **Message wording.** The readonly and unknown-field messages still
quote the receiver as `ctx.api.object('X')...` when the author wrote
`api.object('X')...`. That is true of the call, because `api` is
`ctx.api`, but an author grepping for it will not find it. The location
path points at the body or handler. Not filed.
- **Over-approximate stand-down.** `rhsChainRoots` stands down a root
that appears anywhere on the right of a metadata form's `==` / `!=`,
including inside a macro body. That direction can only remove a finding.
- **Changeset level: `minor`, not `patch`.** The PR declares `Clause-②:
no (narrowing)`. `check-changeset-no-major` enforces at least `minor` on
a package whose `src` this PR grows when the arm is `narrowing`, and
AGENTS.md reads that arm as BREAKING. The changeset therefore carries
the `**BREAKING**` banner and an ADR-0087 `not-required
(no-migration-prescription)` disposition. That gate passes locally.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5870716 commit d1dbe70
11 files changed
Lines changed: 692 additions & 63 deletions
File tree
- .changeset
- packages/lint/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
226 | 271 | | |
227 | 272 | | |
228 | 273 | | |
| |||
415 | 460 | | |
416 | 461 | | |
417 | 462 | | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
418 | 467 | | |
419 | 468 | | |
420 | | - | |
421 | | - | |
422 | | - | |
423 | | - | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
424 | 473 | | |
425 | 474 | | |
426 | 475 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | | - | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
247 | 260 | | |
248 | 261 | | |
249 | 262 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
315 | 334 | | |
316 | 335 | | |
317 | 336 | | |
| |||
0 commit comments