Skip to content

Commit 3815407

Browse files
committed
feat(lint): publish the user-field-foreign-reference id and its os explain entry
Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5458cba commit 3815407

4 files changed

Lines changed: 38 additions & 2 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
---
2-
'@objectstack/lint': patch
2+
'@objectstack/lint': minor
33
'@objectstack/spec': patch
44
---
55

66
feat(lint,spec): `os validate`, `os build` and `os lint` warn on a `user` field whose `reference` names an object other than `sys_user`, and `Field.user()` marks `reference` deprecated
77

8-
Clause-②: no
8+
Clause-②: yes (widening)
99

1010
- **What was wrong.** The spec declares a `user` field's target fixed to `sys_user`, and nothing enforced it. `{ type: 'user', reference: 'probe_account' }` parsed, `os validate` passed it, and `referenceTargetOf` resolved it to `probe_account`. So every reader that follows the target (`$expand`, imports, seed loading, the object graph) treated the field as a link to `probe_account`, while every reader that branches on the type (the person picker, the `current_user` default, a sharing rule over the users a field holds) treated its values as users.
1111
- **What changed.** A new advisory, `user-field-foreign-reference`, fires on such a field on an object or an object extension. It names the field and the target, and its hint is the canonical spelling: `{ type: 'lookup', reference: '<target>' }` (`Field.lookup('<target>', …)`), or drop `reference` if a person was meant. It is a warning, so no command's exit code changes. `Field.user()`'s config now types `reference` as `@deprecated`. The accepted values are unchanged.
1212
- **Why only a warning in 18.x.** A narrowing ships its refusal in an 18.x minor only when an ADR-0087 conversion can rewrite the old shape. This one cannot, because the rewrite is not lossless. A `user` field is outside the referential delete walk, while a `lookup` gets `deleteBehavior: 'set_null'` and joins it. `deleteBehavior` has no value that means "do nothing", so a converted field would start clearing child columns when its target record is deleted, which a `user` field never does. The refusal is planned for the next major.
13+
- **Published.** `USER_FIELD_FOREIGN_REFERENCE` (`user-field-foreign-reference`) is exported from `@objectstack/lint` beside `OBJECT_REFERENCE_UNKNOWN`, and `RULE_EXPLANATIONS` gains its entry, so `os explain user-field-foreign-reference` prints why the field is half a link and half a user, and why the rewrite to a `lookup` is left to the author.
1314
- **Unchanged.** Parsing, the runtime's resolution of such a field (it still follows the authored `reference`), `Field.user()`'s output, and `user` fields written without `reference` or with `reference: 'sys_user'`.
1415
- **What to do.** Change a `user` field that points at another object to a `lookup` on that object. No in-repo app or package wrote this shape. Two test fixtures did: one is now a `lookup`, and the other deliberately pins the 18.x resolution.

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@ export {
426426
validateObjectReferences,
427427
OBJECT_REFERENCE_UNKNOWN,
428428
OBJECT_REFERENCE_UNREGISTERED_PLATFORM,
429+
USER_FIELD_FOREIGN_REFERENCE,
429430
} from './validate-object-references.js';
430431
export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-references.js';
431432

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3562,6 +3562,28 @@ const OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION: RuleExplanation = {
35623562
],
35633563
};
35643564

3565+
const USER_FIELD_FOREIGN_REFERENCE_EXPLANATION: RuleExplanation = {
3566+
rule: 'user-field-foreign-reference',
3567+
covers: 'why a user field cannot point at another object',
3568+
paragraphs: [
3569+
'A `user` field is a person picker: its target is fixed to the `sys_user` object by the type, ' +
3570+
'which is why `Field.user()` takes no target argument. Writing `reference` with any other ' +
3571+
'object name contradicts the type, and today nothing refuses it.',
3572+
'Such a field is half one thing and half another. Every reader that follows the target ' +
3573+
'(`$expand`, imports, seed loading, field paths) treats it as a link to the named object, ' +
3574+
'while every reader that branches on the type treats its values as users: the person picker ' +
3575+
'searches people, a `current_user` default stores the acting user\'s id, and a sharing rule over ' +
3576+
'the users a field holds shares the record with those ids.',
3577+
'A link to another object is a `lookup`: `{ type: \'lookup\', reference: \'<object>\' }`, or ' +
3578+
'`Field.lookup(\'<object>\', …)`. If a person was meant, drop `reference`.',
3579+
'It warns rather than refuses because no automatic rewrite keeps the field\'s behaviour: a `lookup` ' +
3580+
'takes part in the delete walk with `deleteBehavior: \'set_null\'` by default, so it would start ' +
3581+
'clearing the column when the target record is deleted, which a `user` field never does. Make ' +
3582+
'the change by hand and check that delete behaviour is what you want. Refusing the shape at ' +
3583+
'parse is planned for the next major.',
3584+
],
3585+
};
3586+
35653587
// ── Capability references (`validate-capability-references.ts`) ───────────
35663588

35673589
const CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION: RuleExplanation = {
@@ -4858,6 +4880,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
48584880
[OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION,
48594881
[OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION,
48604882
[OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION,
4883+
[USER_FIELD_FOREIGN_REFERENCE_EXPLANATION.rule]: USER_FIELD_FOREIGN_REFERENCE_EXPLANATION,
48614884
[CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION,
48624885
[LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION.rule]: LIST_VIEW_FILTERS_IN_VIEWS_MODE_EXPLANATION,
48634886
[LIVENESS_LEDGER_UNREADABLE_EXPLANATION.rule]: LIVENESS_LEDGER_UNREADABLE_EXPLANATION,

‎packages/lint/src/validate-object-references.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,17 @@ describe('[#24808] validateObjectReferences — a `user` field naming another ob
784784
.toThrow(/`reference` is an object/);
785785
});
786786

787+
it('prints one line each, and `os explain` carries the reasoning the verdict leaves out', () => {
788+
const [f] = validateObjectReferences(stackWith(PROBE));
789+
for (const text of [f.message, f.hint]) {
790+
expect(text).not.toContain('\n');
791+
expect(text.length, text).toBeLessThanOrEqual(200);
792+
}
793+
const entry = explainRule(USER_FIELD_FOREIGN_REFERENCE);
794+
expect(entry?.rule).toBe(USER_FIELD_FOREIGN_REFERENCE);
795+
expect(entry!.paragraphs.join('\n')).toContain("deleteBehavior: 'set_null'");
796+
});
797+
787798
for (const command of ['validate', 'build', 'lint'] as const) {
788799
it(`reaches \`os ${command}\` through the authoring registry, as an advisory and never a gate`, () => {
789800
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: stackWith(PROBE) as never }));

0 commit comments

Comments
 (0)