Skip to content

Commit ad0fbfd

Browse files
fix(lint): a permission set's field key on a field an objectExtensions entry adds is not refused as security-fls-unknown-field (#30303) (#30359)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBLUgtNVtQKV2hgJpusD4LCR)_ Fixes #30303 Clause-②: yes (widening: the FLS-key check accepts extension-added fields) Metadata-protocol: none Skills-docs: none owed — no skill, eval or doc teaches that a permission set may not name an extension field (`git grep -c "security-fls-unknown-field" -- skills content/docs ':!content/docs/releases'`: 0 hits; `content/docs/data-modeling/object-extensions.mdx` names no permission or FLS key); the rule's `os explain` entry is updated here Prior rulings read: security-fls-unknown-field,objectextensions,indexobjectgraph → 2 hits; ADR-0029 D3, ADR-0146 D10; thread: none; repo: objectstack-ai/objectstack ## 维护者速读 - 改了什么:权限集 `fields` 里的 `对象.字段` 键,现在按"注册表实际提供的对象"判断字段是否存在,即对象自己声明的字段加上同一制品中任何包通过 `objectExtensions` 加的字段;真不存在的字段照旧报 `security-fls-unknown-field`,规则 id、级别、消息不变。 - 为什么改:HotCRM 的 app 包给模块包扩展出来的 `crm_contract.o2c_probe_note` 配字段权限,`pnpm build` / `objectstack dev` 都被这条规则拒绝,而运行时明明提供这个字段;hotcrm#2315 第 1 阶段因此卡住。 - 风险与回滚:只放宽拒绝面,不新增拒绝;合并规则照抄 objectql 注册表(按字段名合并、同一包对同一对象只保留最后一条扩展、`extend` 按原名匹配)。另外把两份已有的扩展折叠(#23210 的 controlled-by-parent 和 membership-key)收成一份共享实现,两边原有测试全绿。回滚即还原本 PR。 - 先看:`packages/lint/src/object-graph.ts` 新增的 `objectExtensionsByTarget` / `objectsAsServed`;`validate-security-posture.ts` 里 `flsGraph` 那一行和 CBP 段改读共享折叠;新测试 `validate-security-posture-fls-extension.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/lint exec vitest run src/validate-security-posture-fls-extension.test.ts`,预期 8 例全绿(含 per-package 和 union 两种 CLI 运行形态)。 - 需要你做的:无,席位落地。 ## What changes - **`packages/lint/src/object-graph.ts`: one shared extension fold.** - `objectExtensionsByTarget(stack)`: every `objectExtensions` entry by the object it `extend`s, as the registry registers them. With `packages[]`, every body's entries (`carrier` = body index, the artifact's registration order); without it, the stack's own. One entry per package per target (a later entry of the same package replaces an earlier one whole). Fold order: ascending `priority` (default 200), then package order. `extend` names its target verbatim; the registry's short-name to legacy `ns__name` filing (#28858, `SchemaRegistry.extensionTargetKey`) is NOT mirrored, so such an entry folds onto nothing and a key naming its field stays refused (the safe direction), as the docblock says. - `objectsAsServed(stack)`: the stack's `objects` with those entries' `fields` merged by name (a later writer replacing the whole field), plus the `tied` keys two packages write at the deciding priority. Only fields move; `fieldPatches` add no field and are not read. - **`validate-security-posture.ts`: the FLS-key check reads the served objects.** `flsGraph` is now `indexObjectGraph({ objects: objectsAsServed(stack).objects })`. The three skips (an object this stack does not define, an object with no readable field map, a registry-injected column) are unchanged, and the injected-column plan is unchanged too: `resolveInjectedSystemColumns` reads no field, as the registry plans it over the base layer. The finding's rule id, severity, `where`, path and message are unchanged; the hint's nearest name now also considers extension fields. - **The two existing folds now read the shared one** (no third copy): - the controlled-by-parent merge (#23210, `pushExtensionMergedCbpFindings`) iterates `objectExtensionsByTarget` instead of its own collector; its target set (bodies' declarations, a target two bodies declare left unjudged) and its blame logic are unchanged; - `validate-membership-key-declarations.ts` drops its private `mergedObjectsOf` for `objectsAsServed` (same algorithm, same `tied` output). - **`rule-explanations.ts`**: the `security-fls-unknown-field` entry says the columns are the object's as the registry serves it, extension fields from any package of the artifact included. - **`validate-security-posture.test.ts` key-read meta-test (#5017)**: the scan now also reads the shared fold's code, so the reads that moved out of this rule (`stack.objectExtensions`, `body.objectExtensions`, `ext.extend/fields/priority`) stay held to the same schema tables; the fold's own collections are tabled as plumbing. - `.changeset/30303-fls-key-extension-fields.md`: `@objectstack/lint` patch. - **`object-graph.test.ts`** (review round 1): junk-input pins for `objectExtensionsByTarget` and `objectsAsServed` beside the `indexObjectGraph` / `packagesOf` cases: a null or non-record stack, `packages: [null]` and other non-record entries or bodies, a non-array `objectExtensions`, non-record entries and a non-string `extend`, junk `fields` (and the array form read by name), and a string `priority` (read as the default 200; the schema refuses it). Neither export throws; a malformed `packages` stays `packagesOf`'s refusal, met first by `validateSecurityPosture`. ### The runtime merge mirrored `ObjectQL.registerApp` (`packages/objectql/src/engine-registration.ts`, step 2b) registers each manifest `objectExtensions` entry as an `extend` contributor via `SchemaRegistry.registerObject(..., 'extend', ext.priority ?? 200)` under the name `ext.extend` spells. The `extend` arm removes that package's earlier contribution to the same object (`contributors.findIndex(c => c.packageId === packageId && c.ownership === 'extend')`), contributors sort by ascending priority, and `foldExtendersOntoDefinition` applies each through `mergeObjectDefinitions`, whose `fields` merge is `{ ...base.fields, ...extension.fields }`. `fieldPatches` (ADR-0146 D10) patch only the owner's own fields. An extension's `queue` or audit field is governed in the fold, but the name stays served (the injected column), so the served name set is unchanged by it. ADR-0029 D3 confirms any package may `extend` another's object; ADR-0146 D10's `__c` reservation is refused at other doors (publish, `os validate`, install), not by the merge, so this check does not judge it. ### Extensions the registry does not merge, still refused (pinned) - a field a package's earlier entry adds when a later entry of the same package extends the same object (the twin with the entries reversed is accepted); - a field an entry only patches through `fieldPatches`; - a field an entry adds to an object of another spelling (`extend: 'crm__crm_contract'`). An extension of an object no body declares stays unjudged as before (skip 1: the object may be another installed package's). ### Census: rules that resolve `object.field` through the same index `git grep -n "indexObjectGraph" packages/lint/src` (non-test), at this head: | Rule file | Index input | Folds extensions? | | --- | --- | --- | | `validate-security-posture.ts` (FLS keys) | `objectsAsServed(stack)` | yes, this PR | | `validate-membership-key-declarations.ts` | `objectsAsServed(stack)` | yes, before and after (now through the shared fold) | | `validate-dataset-measure-aggregates.ts` | `indexObjectGraph(stack)` | no | | `validate-dataset-references.ts` | `indexObjectGraph(stack)` | no | | `validate-field-consumers.ts` | `indexObjectGraph(stack)` | no (its header says extensions are not judged) | | `validate-list-view-field-refs.ts` | `indexObjectGraph(stack)` | no | | `validate-object-field-refs.ts` | `indexObjectGraph(stack)` | no | | `validate-page-field-bindings.ts` | `indexObjectGraph(stack)` | no | | `validate-preset-comparands.ts` | `indexObjectGraph(stack)` | no | | `validate-report-runtime-filter.ts` | `indexObjectGraph(stack)` | no | | `validate-rls-predicate-enforceability.ts` | `indexObjectGraph(cfg)` | no | | `validate-sharing-rule-enforceability.ts` | `indexObjectGraph({ objects: [target] })`, the declared anchor | no | | `validate-thank-you-record-fields.ts` | `indexObjectGraph(stack)` | no | | `validate-widget-bindings.ts` | `indexObjectGraph(stack)` | no | The twelve "no" rows read the declared objects only, so a path naming an extension-added field reads as unknown there: the same blind spot, by reading (not reproduced per rule). The shared fold does not fix them for free: each would need its index input switched to `objectsAsServed`, a widening of its own with its own pins. **Carried**, not changed here; the seat files or routes them. Two more rules read extension fields through private readers of their own rather than the index, and are carried with the twelve for consolidation onto the shared fold: - `validate-related-list-refs.ts`: the stack's top-level `objectExtensions` only (no `packages[]` bodies); - `validate-mapping-target-fields.ts` (`extensionFieldsByTarget`): the top level plus every body, additive (no one-entry-per-package replacement). ## Why On a multi-package artifact (HotCRM, platform 18.3.0, hotcrm#2315), the app package owns `crm_contract` and the permission sets, and a module package adds `o2c_probe_note` to `crm_contract` through `objectExtensions`. The runtime serves the field (`GET /api/v1/meta/object/crm_contract` lists it), but every app-package permission set naming `crm_contract.o2c_probe_note` was refused with `security-fls-unknown-field`, because the FLS-key index read `stack.objects` only. Both CLI runs saw it: the per-package run over the app body (`packageBodyAsStack`) and the union run (`authoringRuleUnionStack`). The maintainer's ruling of 2026-11-05 on hotcrm#2315 puts the module's field access in the app package's permission sets, so the refusal blocked the ruled design. ## Verification Under `os-verify-lock`, `NODE_OPTIONS=--max-old-space-size=4096`. Round 1 head `8aeb3212` (the review delta: junk-input pins and the fold's docblock); the ablations below were run at `f85110db`, whose code the delta does not change. - `pnpm --filter @objectstack/lint test` at `8aeb3212`: Test Files 194 passed (194), Tests 8015 passed, 5 skipped (8009 at `f85110db`; +6 junk-input pins in `object-graph.test.ts`, 47/47 in that file). `pnpm --filter @objectstack/lint typecheck` (tsc + `check:test-typecheck`): VERDICT command-exit 0. - The new pins, `validate-security-posture-fls-extension.test.ts` (8 tests). Every stack is parsed by `ObjectStackDefinitionSchema` first, and each artifact is judged on both shapes the CLI hands the rule: the per-package run over the app body (`packageBodyAsStack`) and the union run (`authoringRuleUnionStack`). - the card's artifact (app body owns `crm_contract` and the set naming `crm_contract.o2c_probe_note`; a `type: 'module'` body extends `crm_contract` with it): no finding; the same artifact without the extension: refused; - a key on a field no body declares or extends: still `security-fls-unknown-field`, `error`, with `where` and `message` byte-equal to a single-object control's; - a single-package stack with an in-body `objectExtensions`: no finding; without it: refused; - the hint's nearest name is the extension field; - three extensions the registry does not merge (see above): still refused. - The existing pins of the two re-pointed folds stay green: `validate-security-posture-extension-merge.test.ts` (#23210, parse and lint agree on every merged shape), `validate-membership-key-declarations.test.ts` (tied-priority pin included), `validate-rls-predicate-enforceability.membership-keys.test.ts`. <details><summary>Reverse verification (two mutations, each restored by scripts/ablation-replace.mjs to the HEAD blob)</summary> 1. `flsGraph` back to `indexObjectGraph(stack)` in `validate-security-posture.ts` (anchor 1 → 0, blob `cdfe1d906f6a` → `5f665455010e`): the new file goes **5 failed | 3 passed (8)**. The five red are the four acceptance pins and the "same package's later entry" twin; the three still green are refusal pins, which hold either way. Restored: blob == HEAD `cdfe1d906f6a`, `git diff HEAD` empty. 2. The one-entry-per-package rule removed in the shared fold (`.filter((other) => other.carrier !== carrier)` → `.filter(() => true)`, blob `76abadb6999e` → `4f04096e54a2`): **1 failed | 7 passed (8)**, the "earlier entry replaced" refusal pin. Restored: blob == HEAD `76abadb6999e`, `git diff HEAD` empty. The lint tests import the rule from `src` (relative imports), so no dist rebuild sat between mutation and run. </details> <details><summary>Gates: dispatch-gates --commands, 59 derived at 8aeb321, 59 run, 0 NOT-MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran gates-ran.list`: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED", exit 0. Record: `/mnt/project-files/issue-30303/gates-ran.list` (every line `exit 0`), `check-changeset-no-major` and `check:nul-bytes` included. - Round 1 (`8aeb3212`): the derived set is the same 59 families as at `f85110db`; all 59 re-run after a full `turbo run build` (VERDICT command-exit 0), every one exit 0 on the first pass. - At `f85110db`, first-pass exit 3 on four, each re-run green on the same head: `check-plugin-teardown-shape --self-test` (shallow clone; its pinned fixture commit fetched), `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` (no dists; green after a full build). - No `!` in the title and no `(narrowing)` arm: no full dogfood run. CI owns the rest of the farm. </details> ## Acceptance notes - `objectExtensionsByTarget` keys a package by its raw `packages[]` index; the old controlled-by-parent collector used its index among the record members (`packagesOf` filters non-records). They differ only on an artifact whose `packages[]` holds a non-record member, which the schema refuses; the path then points at the real array position. - The removed `mergedObjectsOf` docblock said a tied field is "left out of the merged map"; its code merged it and reported it in `tied`. The shared fold keeps what the code did, and its docblock now says so; the membership rule's verdict on a tied field is still "not judged". ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI-owned run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block excepted, per project convention). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzfKwxFVA5m6bAjSpxf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7d1367 commit ad0fbfd

8 files changed

Lines changed: 468 additions & 113 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): a permission set's field key on a field that an `objectExtensions` entry adds, a sibling package's in the same artifact included, is no longer refused as `security-fls-unknown-field` (#30303)
6+
7+
Clause-②: yes (widening: the FLS-key check accepts extension-added fields)
8+
Metadata-protocol: none
9+
10+
- **Before.** The FLS-key check resolved `object.field` against the object's declared `fields` only. On a multi-package artifact, an app package's permission set naming `crm_contract.o2c_probe_note`, a field its module package adds to `crm_contract` through `objectExtensions`, failed `os build`, `os validate` and `os lint` with "crm_contract declares no field o2c_probe_note", although the runtime serves the field and the key masks it. A single-package stack's own extension fields were refused the same way.
11+
- **Now.** The key resolves against the object as the registry serves it: its declared fields plus every field an `objectExtensions` entry of any body of the artifact adds to it, folded as `@objectstack/objectql` folds them (fields by name; one entry per package per object, a later entry replacing an earlier one; `extend` naming the object verbatim). A field that no body declares or adds keeps the same refusal, with the same rule id, severity and message, and so does a field an entry only patches through `fieldPatches`, which adds no field. The hint's nearest name reads the served fields too.
12+
- **One fold.** The controlled-by-parent merge (#23210), the membership-key column check and the FLS-key check now read extensions through one shared fold in `object-graph.ts`.

‎packages/lint/src/object-graph.test.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
nearestName,
1616
listNames,
1717
packagesOf,
18+
objectExtensionsByTarget,
19+
objectsAsServed,
1820
RELATIONSHIP_FIELD_TYPES,
1921
} from './object-graph.js';
2022
import { walkFilterFieldKeys, type FilterFieldKey } from './filter-walk.js';
@@ -345,6 +347,93 @@ describe('object-graph — packagesOf (#20206, ruling A on #15293 `5634034754`)'
345347
});
346348
});
347349

350+
/**
351+
* [#30303] The shared extension fold on junk input, beside the
352+
* `indexObjectGraph` and `packagesOf` cases above. Neither export refuses
353+
* anything: a malformed `packages` is {@link packagesOf}'s refusal, met by a
354+
* caller that reads the bodies through it (`validateSecurityPosture` does,
355+
* before either fold runs). Each shape below reads as "nothing to fold" for
356+
* the part that is junk, and never throws.
357+
*/
358+
describe('[#30303] object-graph — objectExtensionsByTarget / objectsAsServed on junk input', () => {
359+
const OBJ = { name: 'crm_contract', fields: { contract_no: { type: 'text' } } };
360+
const EXT = { extend: 'crm_contract', fields: { note: { type: 'text' } } };
361+
const served = (stack: unknown) =>
362+
objectsAsServed(stack).objects.map((o) => [o.name, (o.fields as Array<{ name: string }> | Record<string, unknown>)]);
363+
364+
it('a null or non-record stack: no extensions, no objects', () => {
365+
for (const stack of [null, undefined, 'junk', 42, []]) {
366+
expect(objectExtensionsByTarget(stack).size, String(stack)).toBe(0);
367+
expect(objectsAsServed(stack), String(stack)).toEqual({ objects: [], tied: new Map() });
368+
}
369+
});
370+
371+
it('packages: [null] and other non-record entries or bodies contribute nothing; a record body beside them still does', () => {
372+
expect(objectExtensionsByTarget({ packages: [null] }).size).toBe(0);
373+
const stack = {
374+
objects: [OBJ],
375+
packages: [null, 'junk', 42, [], { manifest: null }, { manifest: 'junk' }, { manifest: { objectExtensions: [EXT] } }],
376+
};
377+
expect([...objectExtensionsByTarget(stack).entries()].map(([t, list]) => [t, list.map((e) => [e.carrier, e.path])])).toEqual([
378+
['crm_contract', [[6, 'packages[6].manifest.objectExtensions[0]']]],
379+
]);
380+
expect(objectsAsServed(stack).objects[0].fields).toEqual([
381+
{ name: 'contract_no', type: 'text' },
382+
{ name: 'note', type: 'text' },
383+
]);
384+
});
385+
386+
it('a non-array objectExtensions folds nothing', () => {
387+
for (const objectExtensions of [null, 'junk', 42, { crm_contract: EXT }]) {
388+
const stack = { objects: [OBJ], objectExtensions };
389+
expect(objectExtensionsByTarget(stack).size, JSON.stringify(objectExtensions)).toBe(0);
390+
expect(objectsAsServed(stack).objects, JSON.stringify(objectExtensions)).toEqual([OBJ]);
391+
}
392+
});
393+
394+
it('a non-record entry, or one whose extend is not a string, is skipped; the record entry beside it is read', () => {
395+
const stack = {
396+
objects: [OBJ],
397+
objectExtensions: [null, 'junk', 42, [], { extend: 7, fields: { x: { type: 'text' } } }, { fields: { y: {} } }, EXT],
398+
};
399+
expect([...objectExtensionsByTarget(stack).keys()]).toEqual(['crm_contract']);
400+
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => e.path)).toEqual(['objectExtensions[6]']);
401+
expect(served(stack)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
402+
});
403+
404+
it('junk fields add nothing: a non-collection, non-record members, a member with no name', () => {
405+
for (const fields of [null, 'junk', 42, [null, 'x', 3, { type: 'text' }, { name: '', type: 'text' }]]) {
406+
const stack = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields }] };
407+
expect(() => objectsAsServed(stack), JSON.stringify(fields)).not.toThrow();
408+
expect(objectsAsServed(stack).objects[0].fields, JSON.stringify(fields)).toEqual([{ name: 'contract_no', type: 'text' }]);
409+
}
410+
// The array form is read by name, as the map form is.
411+
const arrayForm = { objects: [OBJ], objectExtensions: [{ extend: 'crm_contract', fields: [{ name: 'note', type: 'text' }] }] };
412+
expect(served(arrayForm)).toEqual([['crm_contract', [{ name: 'contract_no', type: 'text' }, { name: 'note', type: 'text' }]]]);
413+
});
414+
415+
// `ObjectExtensionSchema.priority` is a number, so a parsed stack never
416+
// carries this shape; the fold reads a non-number as absent (the default 200,
417+
// as `SchemaRegistry.registerObjectExtension` does) rather than guessing.
418+
it('a string priority reads as the default 200 and never throws', () => {
419+
const stack = {
420+
objects: [OBJ],
421+
packages: [
422+
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: '100', fields: { note: { type: 'text', label: 'A' } } }] } },
423+
{ manifest: { objectExtensions: [{ extend: 'crm_contract', priority: 150, fields: { note: { type: 'text', label: 'B' } } }] } },
424+
],
425+
};
426+
expect(objectExtensionsByTarget(stack).get('crm_contract')!.map((e) => [e.carrier, e.priority])).toEqual([
427+
[1, 150],
428+
[0, 200],
429+
]);
430+
// So the string-priority entry folds LAST and its definition wins, untied.
431+
const { objects, tied } = objectsAsServed(stack);
432+
expect((objects[0].fields as Array<Record<string, unknown>>).find((f) => f.name === 'note')?.label).toBe('A');
433+
expect(tied.size).toBe(0);
434+
});
435+
});
436+
348437
/**
349438
* [#19289] A `{ type: 'user' }` field with no `reference` is TRAVERSABLE — the
350439
* third defect found by the implicit-target census, and the widest-reaching of

‎packages/lint/src/object-graph.ts‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,141 @@ export function indexObjectGraph(stack: unknown): ObjectGraph {
365365
return graph;
366366
}
367367

368+
/**
369+
* One `objectExtensions` entry as the registry registers it — see
370+
* {@link objectExtensionsByTarget}.
371+
*/
372+
export interface VisibleObjectExtension {
373+
/** The position of the package that ships it: registration order across packages. */
374+
carrier: number;
375+
/** The entry's path, e.g. `objectExtensions[0]` or `packages[1].manifest.objectExtensions[0]`. */
376+
path: string;
377+
/** `ext.priority ?? 200`, the registry's default. */
378+
priority: number;
379+
/** Its field records, name folded in ({@link recordsOf}). */
380+
fields: AnyRec[];
381+
}
382+
383+
/**
384+
* [#30303] Every `objectExtensions` entry of a stack, by the object it
385+
* `extend`s, as the registry registers and folds them. This is the ONE place
386+
* this package reads extensions for an object's merged field set: the
387+
* `controlled_by_parent` merge of `validate-security-posture.ts` (#23210),
388+
* the membership-key columns of `validate-membership-key-declarations.ts`,
389+
* and the served field set {@link objectsAsServed} hands the FLS-key check.
390+
*
391+
* Read as the registry reads a manifest, since `@objectstack/lint` imports no
392+
* runtime (`ObjectQL.registerApp`'s `objectExtensions` loop,
393+
* `SchemaRegistry.registerObject`'s `extend` arm and `foldExtenders`, in
394+
* `@objectstack/objectql`):
395+
*
396+
* - Source: with `packages[]`, every body's `objectExtensions`
397+
* (`packages[j].manifest`, `carrier` = `j`, the artifact's registration
398+
* order); without it, the stack's own (`carrier` 0). An artifact's top level
399+
* repeats its bodies' collections, so it is not read beside them.
400+
* - `extend` names its target verbatim. ⚠️ The registry does one more thing
401+
* this fold does not mirror (#28858, `SchemaRegistry.extensionTargetKey`):
402+
* a short name that nothing is registered under but that `getObject` folds
403+
* to a legacy `<ns>__<name>` key is filed under that key, with a warning.
404+
* Lint reads the stack's own object names and knows no namespace, so such
405+
* an entry folds onto nothing here: a key naming its field reads as
406+
* unknown, the refusing direction, and the warning's remedy (write the key
407+
* the object is registered under) is also what makes the fold see it.
408+
* - ONE entry per package per target: registering an extension removes that
409+
* package's earlier one, so a later entry of the same package replaces an
410+
* earlier one whole, its fields included.
411+
* - Fold order: ascending `priority` (default 200), equal priorities in
412+
* package order. Each list is returned in that order.
413+
*
414+
* Which targets to judge is the caller's question: an entry is listed here
415+
* whether or not the stack declares its target.
416+
*/
417+
export function objectExtensionsByTarget(stack: unknown): Map<string, VisibleObjectExtension[]> {
418+
const byTarget = new Map<string, VisibleObjectExtension[]>();
419+
if (!isRec(stack)) return byTarget;
420+
// The receivers are named as `validate-security-posture.test.ts`'s key-read
421+
// meta-test tables them (`stack`, `entry`, `body`, `ext`, `obj`, `f`): that
422+
// scan reads this fold as part of the rules that read through it.
423+
const collect = (entries: unknown, carrier: number, at: string) => {
424+
if (!Array.isArray(entries)) return;
425+
entries.forEach((ext: unknown, i: number) => {
426+
if (!isRec(ext) || typeof ext.extend !== 'string') return;
427+
const kept = (byTarget.get(ext.extend) ?? []).filter((other) => other.carrier !== carrier);
428+
kept.push({
429+
carrier,
430+
path: `${at}objectExtensions[${i}]`,
431+
priority: typeof ext.priority === 'number' ? ext.priority : 200,
432+
fields: recordsOf(ext.fields),
433+
});
434+
byTarget.set(ext.extend, kept);
435+
});
436+
};
437+
if (Array.isArray(stack.packages)) {
438+
stack.packages.forEach((entry: unknown, j: number) => {
439+
const body = isRec(entry) ? entry.manifest : undefined;
440+
if (isRec(body)) collect(body.objectExtensions, j, `packages[${j}].manifest.`);
441+
});
442+
} else {
443+
collect(stack.objectExtensions, 0, '');
444+
}
445+
for (const list of byTarget.values()) {
446+
list.sort((left, right) => left.priority - right.priority || left.carrier - right.carrier);
447+
}
448+
return byTarget;
449+
}
450+
451+
/**
452+
* [#30303] A stack's `objects` as the registry SERVES them: each with every
453+
* {@link objectExtensionsByTarget} entry that extends it folded in, the way
454+
* `mergeObjectDefinitions` merges `fields` (by name, additively, a later
455+
* writer replacing the whole field). An object nothing extends is returned as
456+
* it is.
457+
*
458+
* Only the fields move. The registry plans an object's injected system
459+
* columns over its base layer (`foldExtenders` reads
460+
* `resolveInjectedSystemColumns(baseDefinition)`), and that plan reads no
461+
* field, so {@link indexObjectGraph} over these objects injects what it
462+
* injects over the declared ones. `fieldPatches` add no field (they patch the
463+
* owner's own), so they are not read.
464+
*
465+
* A field two packages write at the priority that decides it is merged (its
466+
* name is served whichever wins) and named in `tied`, for a caller whose
467+
* verdict depends on which definition wins: registration order decides that,
468+
* and a stack does not state it.
469+
*/
470+
export function objectsAsServed(stack: unknown): { objects: AnyRec[]; tied: Map<string, Set<string>> } {
471+
const objects = isRec(stack) ? recordsOf(stack.objects) : [];
472+
const tied = new Map<string, Set<string>>();
473+
const extensions = objectExtensionsByTarget(stack);
474+
if (extensions.size === 0) return { objects, tied };
475+
476+
const served = objects.map((obj) => {
477+
const name = strName(obj.name);
478+
const exts = name ? extensions.get(name) : undefined;
479+
if (!name || !exts) return obj;
480+
const fields = new Map<string, AnyRec>();
481+
for (const f of recordsOf(obj.fields)) {
482+
const key = strName(f.name);
483+
if (key) fields.set(key, f);
484+
}
485+
const lastWriter = new Map<string, VisibleObjectExtension>();
486+
const tiedKeys = new Set<string>();
487+
for (const ext of exts) {
488+
for (const f of ext.fields) {
489+
const key = strName(f.name);
490+
if (!key) continue;
491+
if (lastWriter.get(key)?.priority === ext.priority) tiedKeys.add(key);
492+
else tiedKeys.delete(key);
493+
lastWriter.set(key, ext);
494+
fields.set(key, f);
495+
}
496+
}
497+
if (tiedKeys.size > 0) tied.set(name, tiedKeys);
498+
return { ...obj, fields: [...fields.values()] };
499+
});
500+
return { objects: served, tied };
501+
}
502+
368503
/** What a path resolved to, or precisely how it failed. */
369504
export type FieldPathVerdict =
370505
/**

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,7 +819,8 @@ const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = {
819819
'Unlike an unqualified key (`security-fls-unqualified-key`) this one looks correct in review, and ' +
820820
'it is exactly what a field rename leaves behind; an empty remainder is what a half-finished edit ' +
821821
'leaves. A key with more dots is judged on its whole remainder, because FLS keys address columns, ' +
822-
'never joins.',
822+
'never joins. The columns are the object\'s as the registry serves it: the fields it declares and ' +
823+
'every field an `objectExtensions` entry adds to it, from any package of the same artifact.',
823824
'Not judged: an object this stack does not define, an object with no readable field map, and a ' +
824825
'registry-injected system column, which is real and addressable. To fix it, point the key at a ' +
825826
'field the object declares, or delete the entry: an entry that cannot match is not protection, ' +

‎packages/lint/src/validate-membership-key-declarations.ts‎

Lines changed: 2 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
import {
6767
indexObjectGraph,
6868
isUnjudgeable,
69+
objectsAsServed,
6970
recordsOf,
7071
resolveFieldPath,
7172
suggestName,
@@ -205,75 +206,6 @@ export function stackObjectNamesOf(stack: unknown): Set<string> {
205206
return names;
206207
}
207208

208-
/** One `objectExtensions` entry, as the registry folds it. */
209-
interface VisibleExtension {
210-
/** Package order: registration order across packages. */
211-
carrier: number;
212-
/** `priority ?? 200`, the registry's default. */
213-
priority: number;
214-
fields: AnyRec[];
215-
}
216-
217-
/**
218-
* The stack's objects with every `objectExtensions` entry folded in, as the
219-
* registry merges them (this file's header). A field two packages write at the
220-
* priority that decides it is left out of the merged map and named in
221-
* `tied`, so its verdict is "not judged" rather than either writer's.
222-
*/
223-
function mergedObjectsOf(stack: AnyRec): { objects: AnyRec[]; tied: Map<string, Set<string>> } {
224-
const objects = recordsOf(stack.objects);
225-
const tied = new Map<string, Set<string>>();
226-
const names = new Set(objects.map((obj) => str(obj.name)).filter(Boolean));
227-
const extensions = new Map<string, VisibleExtension[]>();
228-
const collect = (entries: unknown, carrier: number) => {
229-
if (!Array.isArray(entries)) return;
230-
for (const entry of entries) {
231-
if (!isRec(entry) || typeof entry.extend !== 'string' || !names.has(entry.extend)) continue;
232-
const list = extensions.get(entry.extend) ?? [];
233-
// One per package per target: the later entry wins.
234-
const kept = list.filter((ext) => ext.carrier !== carrier);
235-
kept.push({
236-
carrier,
237-
priority: typeof entry.priority === 'number' ? entry.priority : 200,
238-
fields: recordsOf(entry.fields),
239-
});
240-
extensions.set(entry.extend, kept);
241-
}
242-
};
243-
if (Array.isArray(stack.packages)) {
244-
stack.packages.forEach((pkg: unknown, j: number) => {
245-
if (isRec(pkg) && isRec(pkg.manifest)) collect(pkg.manifest.objectExtensions, j);
246-
});
247-
} else {
248-
collect(stack.objectExtensions, 0);
249-
}
250-
if (extensions.size === 0) return { objects, tied };
251-
252-
const merged = objects.map((obj) => {
253-
const name = str(obj.name);
254-
const exts = extensions.get(name);
255-
if (!exts) return obj;
256-
const fields = new Map<string, AnyRec>();
257-
for (const f of recordsOf(obj.fields)) if (str(f.name)) fields.set(str(f.name), f);
258-
const lastWriter = new Map<string, VisibleExtension>();
259-
const tiedKeys = new Set<string>();
260-
for (const ext of [...exts].sort((a, b) => a.priority - b.priority || a.carrier - b.carrier)) {
261-
for (const f of ext.fields) {
262-
const key = str(f.name);
263-
if (!key) continue;
264-
const previous = lastWriter.get(key);
265-
if (previous && previous.priority === ext.priority) tiedKeys.add(key);
266-
else tiedKeys.delete(key);
267-
lastWriter.set(key, ext);
268-
fields.set(key, f);
269-
}
270-
}
271-
if (tiedKeys.size > 0) tied.set(name, tiedKeys);
272-
return { ...obj, fields: [...fields.values()] };
273-
});
274-
return { objects: merged, tied };
275-
}
276-
277209
/** U's verdict: `null` when it is a single-valued user reference, else the reason. */
278210
function userFieldFault(meta: GraphField): string | null {
279211
const isUserRef =
@@ -332,7 +264,7 @@ export function validateMembershipKeyDeclarations(
332264
const declared = declaredMembershipKeysOf(stack);
333265
if (declared.length === 0) return findings;
334266

335-
const { objects: merged, tied } = mergedObjectsOf(stack);
267+
const { objects: merged, tied } = objectsAsServed(stack);
336268
const graph = indexObjectGraph({ objects: merged });
337269
const stackNames = stackObjectNamesOf(stack);
338270
const owners = options.membershipKeyOwners;

0 commit comments

Comments
 (0)