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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/lint-system-fields-derived.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

fix(lint): the seven system-field exemption lists derive from the spec's declarations (#4330)

Five rules in `@objectstack/lint` each carried their own hand-copy of
"registry-injected columns present on almost every object but absent from
authored `fields`" — and they had already drifted from one another (two more
copies had appeared by the time the fix landed). This is the shape #3786
removed from the audit-provenance family, rebuilt one package over: the same
list, maintained in parallel, each under a comment asking to be kept in sync
with one of the others.

The package now has one module, `system-fields.ts`, whose `SYSTEM_FIELDS` is
DERIVED from the spec's two declarations — `FIELD_GROUP_SYSTEM_FIELDS`
(`@objectstack/spec/data`) and `SystemFieldName` (`@objectstack/spec/system`)
— and all seven field-resolving rules consume it. A pin test holds the
boundary in both directions: the set contains exactly the two declarations'
union, and none of the rule-local exemptions.

Two deliberate behavior consequences, both in the permissive direction the
rules' own comments argue for (over-inclusion costs at worst a missed
warning; under-inclusion costs a false one):

- `widget-bindings`, `page-field-bindings` and `react-page-props` now also
exempt `is_deleted`;
- `flow-template-paths` now also exempts `user_id`.

Names that are NOT system columns in the spec's sense (`name`, `owner`,
`record_type`, and the legacy physical spellings `_id` / `space`) stay
rule-local next to the reason each rule exempts them, instead of widening
every rule: `name` in particular is an ordinary authored field on most
objects, and exempting it package-wide would stop the field-existence rules
from catching a reference to a field the object genuinely does not have.
39 changes: 39 additions & 0 deletions packages/lint/src/system-fields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';
import { SYSTEM_FIELDS } from './system-fields.js';

describe('SYSTEM_FIELDS (#4330)', () => {
it('contains every member of both spec declarations — the derivation is complete', () => {
for (const f of FIELD_GROUP_SYSTEM_FIELDS) {
expect(SYSTEM_FIELDS.has(f), f).toBe(true);
}
for (const f of Object.values(SystemFieldName)) {
expect(SYSTEM_FIELDS.has(f), f).toBe(true);
}
});

it('contains nothing BEYOND the two spec declarations — additions go to the spec, not here', () => {
const declared = new Set<string>([
...FIELD_GROUP_SYSTEM_FIELDS,
...Object.values(SystemFieldName),
]);
for (const f of SYSTEM_FIELDS) {
expect(declared.has(f), f).toBe(true);
}
});

it('excludes the rule-local exemptions — they are decisions, not system columns', () => {
// `name` is an ordinary authored field on most objects; `owner` and
// `record_type` are not registry-injected; `_id` and `space` are legacy
// physical spellings. Rules that exempt them do so locally, next to their
// reason (see the module note). Putting any of them here would silently
// stop every consumer from catching a reference to a genuinely missing
// field — this test is where that accidental widening fails.
for (const f of ['name', 'owner', 'record_type', '_id', 'space']) {
expect(SYSTEM_FIELDS.has(f), f).toBe(false);
}
});
});
45 changes: 45 additions & 0 deletions packages/lint/src/system-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The ONE answer to "which columns does the registry inject on (almost) every
* object without them appearing in authored `fields`?" (#4330).
*
* Every field-resolving rule in this package needs that answer: a reference to
* `created_at` or `owner_id` is authored against a real, addressable column
* even though no object declares it, so flagging it would be the false finding
* that makes authors stop trusting the linter (ADR-0072 D1). Before this
* module, five rules each carried their own hand-copied list and had already
* drifted from one another — the exact shape #3786 removed from the
* audit-provenance family, rebuilt one package over.
*
* DERIVED from the spec's two declarations, so it cannot drift from them:
*
* - {@link FIELD_GROUP_SYSTEM_FIELDS} (`@objectstack/spec/data`) — audit
* provenance plus `organization_id` / `tenant_id` / `is_deleted` /
* `deleted_at`;
* - {@link SystemFieldName} (`@objectstack/spec/system`) — the protocol-level
* ids: `id`, `owner_id`, `user_id` and the timestamp/tenant columns.
*
* The union is deliberately generous, because the cost asymmetry is the same
* in every consumer: over-inclusion costs at worst a missed finding on a
* `systemFields: false` object (rare); under-inclusion costs a false one.
*
* What does NOT belong here: names that are ordinary AUTHORED fields on most
* objects (`name`, `owner`, `record_type`) or legacy physical spellings
* (`_id`, `space`). A rule that deliberately exempts those keeps them in a
* rule-local extension next to its reason — adding them here would silently
* stop every other rule from catching a reference to a field the object
* genuinely does not have.
*/

import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';

/**
* Registry-injected columns addressable at runtime without being authored in
* `fields` — the union of the spec's two system-field declarations.
*/
export const SYSTEM_FIELDS: ReadonlySet<string> = new Set<string>([
...FIELD_GROUP_SYSTEM_FIELDS,
...Object.values(SystemFieldName),
]);
32 changes: 13 additions & 19 deletions packages/lint/src/validate-flow-template-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
// - Structured scalar heads (`json` / `composite` / `repeater` / `record`) may
// carry legitimate sub-paths — their `.<sub>` access is left alone.

import { SYSTEM_FIELDS } from './system-fields.js';

export type FlowTemplatePathSeverity = 'error' | 'warning';

export interface FlowTemplatePathFinding {
Expand Down Expand Up @@ -86,24 +88,16 @@ function asArray(v: unknown): AnyRec[] {
return [];
}

// System/audit columns the platform injects on every object — always
// addressable in a `{record.<col>}` template even though they are not authored
// fields. Mirrors `validateFormLayout`'s system-field set plus the audit/tenant
// columns from `FIELD_GROUP_SYSTEM_FIELDS`.
const SYSTEM_FIELDS: ReadonlySet<string> = new Set([
'id',
'name',
'owner',
'owner_id',
'created_at',
'created_by',
'updated_at',
'updated_by',
'organization_id',
'tenant_id',
'is_deleted',
'deleted_at',
'record_type',
// Path heads addressable in a `{record.<col>}` template without being authored
// fields: the package-shared registry-injected columns (`system-fields.ts`,
// #4330) plus three heads this rule has always exempted. `name`, `owner` and
// `record_type` are NOT registry-injected system columns (`name` in particular
// is an ordinary authored field on most objects), so they stay rule-local —
// see the shared module's note — instead of widening every field-existence
// rule in the package.
const IMPLICIT_HEADS: ReadonlySet<string> = new Set([
...SYSTEM_FIELDS,
'name', 'owner', 'record_type',
]);

// Field types that address ANOTHER object — a `.<subfield>` hop through one is
Expand Down Expand Up @@ -332,7 +326,7 @@ export function validateFlowTemplatePaths(stack: AnyRec): FlowTemplatePathFindin
// A trailing numeric segment is an array index (#1872), not a hop.
const nextIsIdentifier = hasSubPath && !/^\d+$/.test(rest[1]);

const isKnown = fieldTypes.has(head) || SYSTEM_FIELDS.has(head);
const isKnown = fieldTypes.has(head) || IMPLICIT_HEADS.has(head);

if (!isKnown) {
if (seenUnknown.has(head)) continue;
Expand Down
29 changes: 15 additions & 14 deletions packages/lint/src/validate-hook-body-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ import { createRequire } from 'node:module';
import type ts from 'typescript';
import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared';

import { SYSTEM_FIELDS } from './system-fields.js';

// The TypeScript compiler must NOT be imported at module top level: it is
// ~9 MB of CJS, and @objectstack/lint sits on the kernel boot path — while
// this gate only parses when a hook actually carries an L2 JS body. Same
Expand Down Expand Up @@ -175,18 +177,17 @@ const API_WRITE_METHODS: ReadonlyMap<string, number> = new Map([
const INPUT_ENVELOPE_KEYS: ReadonlySet<string> = new Set(['id', 'options', 'ast', 'data']);

/**
* Registry-injected columns present on (almost) every object but absent from
* `object.fields` — always legitimately writable by automation. The UNION of
* the sets the other field-resolving rules carry, because the cost asymmetry
* is the same everywhere: over-inclusion is at worst a missed finding,
* under-inclusion is a false one.
* Columns always legitimately writable by automation without appearing in
* `object.fields`: the package-shared registry-injected columns
* (`system-fields.ts`, #4330) plus the UNION of its sibling rules' local
* exemptions (`_id`/`name`/`space` from validate-translation-references,
* `name`/`owner`/`record_type` from validate-flow-template-paths) — because
* the cost asymmetry is the same everywhere: over-inclusion is at worst a
* missed finding, under-inclusion is a false one.
*/
const SYSTEM_FIELDS: ReadonlySet<string> = new Set([
'id', '_id', 'name', 'space',
'owner', 'owner_id', 'user_id',
'created_at', 'created_by', 'updated_at', 'updated_by',
'organization_id', 'tenant_id',
'is_deleted', 'deleted_at', 'record_type',
const IMPLICIT_FIELDS: ReadonlySet<string> = new Set([
...SYSTEM_FIELDS,
'_id', 'name', 'space', 'owner', 'record_type',
]);

type AnyRec = Record<string, unknown>;
Expand Down Expand Up @@ -414,7 +415,7 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
// missing on EVERY named target (a multi-target body may branch per
// object, so a partial miss is not statically wrong).
if (!inputJudgeable) continue;
if (SYSTEM_FIELDS.has(w.field)) continue;
if (IMPLICIT_FIELDS.has(w.field)) continue;
if (targetSets.some((s) => s!.has(w.field))) continue;

reported.add(dedupeKey);
Expand All @@ -438,7 +439,7 @@ export function validateHookBodyWrites(stack: AnyRec): HookBodyWriteFinding[] {
// ctx.api write → the named object.
const known = objectFields!.get(w.object);
if (!known) continue; // object declared by another package — cannot judge
if (SYSTEM_FIELDS.has(w.field) || known.has(w.field)) continue;
if (IMPLICIT_FIELDS.has(w.field) || known.has(w.field)) continue;

reported.add(dedupeKey);
findings.push({
Expand Down Expand Up @@ -468,7 +469,7 @@ function unionCandidates(targetSets: ReadonlyArray<Set<string> | undefined>): st

/** Did-you-mean (declared + system columns as candidates) plus the fix. */
function fixHint(field: string, declared: string[]): string {
const suggestion = formatSuggestion(findClosestMatches(field, [...declared, ...SYSTEM_FIELDS]));
const suggestion = formatSuggestion(findClosestMatches(field, [...declared, ...IMPLICIT_FIELDS]));
return (
(suggestion ? `${suggestion} ` : '') +
`Fix the field name, or declare '${field}' on the object. Only the literal write patterns in ` +
Expand Down
20 changes: 4 additions & 16 deletions packages/lint/src/validate-page-field-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,22 +59,10 @@ export interface PageFieldFinding {
}

import { walkPageComponents, type AnyRec } from './page-walk.js';

/**
* Registry-injected fields present on (almost) every object but NOT declared in
* `object.fields`. Copied verbatim from `validate-widget-bindings` so the two
* rules agree on what counts as a field. Deliberately generous: over-inclusion
* costs at worst a missed warning on a `systemFields: false` object; under-
* inclusion costs a false one, and a false finding is what makes authors stop
* trusting the linter (ADR-0072 D1). Real pages DO reference these — e.g.
* `sys_user.page.ts` lists `created_at` in a related-list's columns.
*/
const SYSTEM_FIELDS = new Set<string>([
'id',
'created_at', 'created_by', 'updated_at', 'updated_by',
'owner_id', 'organization_id', 'tenant_id', 'user_id',
'deleted_at',
]);
// Real pages DO reference registry-injected columns — e.g. `sys_user.page.ts`
// lists `created_at` in a related-list's columns — so the shared set is load-
// bearing here, not merely defensive.
import { SYSTEM_FIELDS } from './system-fields.js';

function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
Expand Down
15 changes: 2 additions & 13 deletions packages/lint/src/validate-react-page-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import { createRequire } from 'node:module';
import type ts from 'typescript';
import { REACT_BLOCKS, chartAggregateResultKeys } from '@objectstack/spec/ui';

import { SYSTEM_FIELDS } from './system-fields.js';

// The TypeScript compiler must NOT be imported at module top level: it is
// ~9 MB of CJS (~70 ms+ to parse, worse on container cold starts), and
// @objectstack/lint sits on the kernel boot path — while this gate only runs
Expand Down Expand Up @@ -202,19 +204,6 @@ export const REACT_CHART_AXIS_UNKNOWN = 'react-chart-axis-unknown';

const CHART_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const;

/**
* Registry-injected fields present on (almost) every object but absent from
* `object.fields`. Same set as `validate-page-field-bindings`, for the same
* reason: over-inclusion costs at worst a missed finding, under-inclusion
* costs a false one.
*/
const SYSTEM_FIELDS = new Set<string>([
'id',
'created_at', 'created_by', 'updated_at', 'updated_by',
'owner_id', 'organization_id', 'tenant_id', 'user_id',
'deleted_at',
]);

/**
* Both `objects` and an object's `fields` are authored either as an array of
* `{ name }` records or as a name-keyed map — normalize to the array form, the
Expand Down
21 changes: 4 additions & 17 deletions packages/lint/src/validate-searchable-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
* 2. An object that declares no field map at all — external objects and
* datasource-introspected schemas whose columns are resolved at runtime.
* 3. Registry-injected system columns, which are searchable at runtime but
* never appear in authored `fields`. Derived from the spec's own
* declarations rather than hand-copied: this package already carries five
* slightly-different copies of that list (#3786's lesson, unlearned).
* never appear in authored `fields`the package-shared `SYSTEM_FIELDS`
* (`system-fields.ts`), derived from the spec's own declarations rather
* than hand-copied (#4330).
*
* Dotted paths are NOT skipped here, unlike every sibling rule. Elsewhere
* `owner_id.name` is left alone because the query engine resolves the traversal;
Expand All @@ -68,8 +68,7 @@
* one wrong spelling most likely to be borrowed from `select`/`sort`.
*/

import { FIELD_GROUP_SYSTEM_FIELDS } from '@objectstack/spec/data';
import { SystemFieldName } from '@objectstack/spec/system';
import { SYSTEM_FIELDS } from './system-fields.js';

export const SEARCHABLE_FIELD_UNKNOWN = 'searchable-field-unknown';

Expand All @@ -92,18 +91,6 @@ export interface SearchableFieldFinding {

type AnyRec = Record<string, unknown>;

/**
* Registry-injected columns that are addressable at runtime without being
* authored in `fields`. DERIVED from the spec's two declarations —
* `FIELD_GROUP_SYSTEM_FIELDS` (audit provenance + tenant + soft-delete) and
* `SystemFieldName` (the protocol-level ids) — so it cannot drift from them the
* way five hand-copied variants in this package already have.
*/
const SYSTEM_FIELDS: ReadonlySet<string> = new Set<string>([
...FIELD_GROUP_SYSTEM_FIELDS,
...Object.values(SystemFieldName),
]);

/** Coerce a collection (array or name-keyed map) to an array of records. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
Expand Down
21 changes: 12 additions & 9 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@

import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system';
import { walkPageComponents } from './page-walk.js';
import { SYSTEM_FIELDS } from './system-fields.js';

export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown';
export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown';
Expand Down Expand Up @@ -163,15 +164,17 @@ function listNames(names: Iterable<string>, max = 12): string {
}

/**
* Audit/system fields every object carries implicitly. A bundle translating
* them is authoring against a real field, so they must not read as orphans.
* Mirrors `validate-page-field-bindings`' list.
* Fields a bundle may translate without reading as orphans: the package-shared
* registry-injected columns (`system-fields.ts`, #4330) plus three exemptions
* this rule has carried since it landed — `_id` and `space` are legacy
* physical spellings older bundles still key, and `name` is an ordinary
* authored field on most objects. None of the three is a system column in the
* spec's sense, so they stay rule-local (see the shared module's note) instead
* of widening every field-existence rule in the package.
*/
const SYSTEM_FIELDS: ReadonlySet<string> = new Set([
'id', '_id', 'name',
'created_at', 'created_by', 'updated_at', 'updated_by',
'owner_id', 'organization_id', 'tenant_id', 'user_id',
'is_deleted', 'deleted_at', 'space',
const IMPLICIT_FIELDS: ReadonlySet<string> = new Set([
...SYSTEM_FIELDS,
'_id', 'name', 'space',
]);

/** Everything a bundle may legally name under one object. */
Expand Down Expand Up @@ -502,7 +505,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind
const fieldPath = `${objPath}.fields.${fieldName}`;
const field = facts.fields.get(fieldName);
if (!field) {
if (SYSTEM_FIELDS.has(fieldName)) continue;
if (IMPLICIT_FIELDS.has(fieldName)) continue;
orphan(
`${inLocale} · object "${objectName}" · field "${fieldName}"`,
fieldPath,
Expand Down
Loading
Loading