Skip to content

Commit 01df025

Browse files
fix(lint): check the autonumber format the runtime resolves, not a local copy (#19787)
Fixes #19772 Clause-②: no ## The defect `lintAutonumberFormats` chose the format it checks with its own copy of the precedence rule. It took `autonumberFormat` whenever that was a string, `''` included, and skipped the field when the string was empty. `resolveAutonumberFormat` (`@objectstack/spec/data`) is what the engine (`engine.ts:5051`) and driver-sql (`sql-driver.ts:10639`) mint through, and it counts only a non-empty string as declared. So `{ type: 'autonumber', autonumberFormat: '', format: '{nope}{000}' }` parses through `FieldSchema` and passes the lint. At runtime it resolves to `{nope}{000}`, which names no field, so every create throws. The fix replaces the local rule with a call to `resolveAutonumberFormat`. That function is already exported from `@objectstack/spec/data`, which the lint already imported from. No export is added and `packages/spec` is not touched. ## Undeclared fields: the lint now checks the contract default A field that declares neither key resolves to `DEFAULT_AUTONUMBER_FORMAT` (`{0000}`). The lint now checks that format instead of skipping the field. The reasons: - Skipping would need the lint to decide whether a key is declared. That decision is the resolver's non-empty-string rule, so skipping would keep a second copy of the rule this PR removes. - Measured: `{0000}` parses to one sequence token, with no field references and no literal brace groups, so none of the four finding arms can fire. I tried six undeclared spellings (`{}`, `''` on either key, both empty, `null`, a non-string). Base and head both return 0 findings for each, so no message names a format the author did not write. - If the contract default changes one day to a format that does draw a finding, the runtime will mint with it too, so reporting it is correct. The undeclared-field test below goes red on that day, so the change gets noticed. ## What was measured: base `4b23e4fab5` against head `26c2a1d762` - Premises, on the built dist at base. The lint on the document above returns `[]`. The control `{ format: '{nope}{000}' }` returns `autonumber-references-unknown-field`. `resolveAutonumberFormat` returns `{nope}{000}`, and `missingFieldValues` on that format with an empty record returns `['nope']`. `FieldSchema.safeParse` succeeds and keeps both keys, and the lint on the parsed field is `[]` too (the rule's input tier is `parsed`). The engine's own suite, `engine-autonumber-default-format.test.ts`, pins the shorthand winning over an empty canonical key. It passes 11/11 locally. - Base against head, over 121 spellings of the two keys (11 values each: absent, `null`, `''`, a number, an object and six formats). 4 spellings changed. All 4 are `autonumberFormat: ''` with a non-empty `format`, and each now draws the finding the shorthand draws on its own: unknown field, optional field, self-reference or unrecognised token. Head matches the resolver on all 121. - Runtime publish gate (`runRuntimeAuthoringRules`, type `object`). Base publishes the document with `errors: []`. Head refuses it with `autonumber-references-unknown-field`. An undeclared field gets `[]` on both. ## Tests `lint-autonumber-formats.test.ts` gains five tests: 1. An empty canonical key lints the shorthand. This is the test written to fail first. 2. A non-empty canonical key wins over the shorthand. 3. The shorthand is linted when the canonical key is absent. 4. With neither key declared, the findings equal those for the declared default, and are `[]`. 5. A table over 25 spellings asserts the findings equal those for the format the resolver returns. On `f395eafd6f` (the tests alone, without the fix): 2 failed, 12 passed. The failures are test 1 and the table, and the table's only divergent spelling is `{"autonumberFormat":"","format":"{nope}{000}"}`. On head: 14/14 pass. Ablations start from the committed fix and go through `scripts/ablation-replace.mjs`. In every leg the anchor went 1 to 0 and the blob changed. The restore was proven by blob == HEAD and an empty `git diff HEAD`. - Revert the fix (put the old copy back): 2 red, test 1 and the table. - Swap the precedence: 2 red, test 2 and the table. - Ignore the shorthand: 3 red, tests 1 and 3 and the table. - Make the default draw a finding: 1 red, test 4. ## Verification, at head `26c2a1d762` - `@objectstack/lint` tests: base has 108 files and 4111 tests, head has 108 and 4116. The +5 are the new tests. Typecheck exits 0 on both. The test layer keeps its 6 ledgered errors in 2 other files, and `--listFiles` shows the edited test file is compiled. The build exits 0, and both the ESM and CJS dist carry the resolver call. The CJS build, loaded and run on the document, returns `autonumber-references-unknown-field`. - `dispatch-gates --repo objectstack-ai/objectstack`: 59 derived and 57 exited 0. Two are NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt` exit 3 until the whole workspace is built. CI builds it and runs both. `--ran` with the exit codes recorded reconciles 59 derived, 57 run, 2 NOT MEASURED, 0 unrun. `check:lean-entry-closure` first exited 3 as well. After building `@objectstack/objectql` it exited 0. - Consumers. `@objectstack/cli` runs this rule through `authoringRulesFor` in `compile.ts:382` and `validate.ts:331`, and the rule table is unchanged. `test/authoring-rule-command-parity.test.ts` exercises this rule through the lint dist. Its 10 in-process cases pass, including `autonumber-references-unknown-field gates on all three commands`. Its one spawned case, an approval-flow repro that does not touch this rule, was filtered out locally and is left to CI's integration tier. `@objectstack/metadata-protocol` `protocol.runtime-authoring-gate.test.ts` passes 35/35. - Hygiene: the three changed files, the commit messages and this body have 0 control bytes and 0 model identifiers. Both positive controls fired. ## Acceptance notes - The sweep found no other copy of the autonumber precedence in `packages/lint/src`. Among non-test sources, `lint-autonumber-formats.ts` is the only file that names `autonumberFormat`. - A field whose `autonumberFormat` is `''` and whose `format` is broken now fails `os validate`, `os build` and the runtime publish gate. Before, it passed all three and then failed on every create. No field in this repository's sources declares `autonumberFormat: ''`. Outside tests, it appears only in changelog and comment prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a690c49 commit 01df025

3 files changed

Lines changed: 84 additions & 5 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`os validate` and `os build` now check an `autonumber` field's `format` when its `autonumberFormat` is an empty string (#19772).
6+
7+
An empty `autonumberFormat` counts as not set: the platform numbers records with the field's `format` instead, and with `{0000}` when neither is set. The build-time check stopped at the empty `autonumberFormat` and looked no further, so a `format` naming a field the object does not have — `{ type: 'autonumber', autonumberFormat: '', format: '{nope}{000}' }` — passed `os validate` and `os build`, and then every record create failed with `Cannot generate autonumber … referenced field(s) [nope] are empty on the record`.
8+
9+
The check now reads the format the platform numbers records with. That field now fails the build with the same `autonumber-references-unknown-field` error it gets when `format` is written alone, and publishing the object at runtime is refused with the same error. The optional-field, self-reference and unrecognised-token checks follow the same format. A field that sets a non-empty `autonumberFormat`, sets only `format`, or sets neither is checked exactly as before.

‎packages/lint/src/lint-autonumber-formats.test.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import { DEFAULT_AUTONUMBER_FORMAT, resolveAutonumberFormat } from '@objectstack/spec/data';
45
import {
56
lintAutonumberFormats,
67
AUTONUMBER_UNKNOWN_FIELD,
@@ -127,3 +128,67 @@ describe('lintAutonumberFormats', () => {
127128
expect(lintAutonumberFormats(stack)).toEqual([]);
128129
});
129130
});
131+
132+
/**
133+
* Which format the lint checks. The engine and the SQL driver both mint through
134+
* `resolveAutonumberFormat`: canonical `autonumberFormat`, then the `format`
135+
* shorthand, then the contract default — and a key holding anything but a
136+
* NON-EMPTY string counts as undeclared. A lint that answers "which format?"
137+
* any other way checks a format the runtime never renders, so it can pass a
138+
* field whose every create then throws.
139+
*/
140+
describe('lintAutonumberFormats — checks the format the runtime mints with', () => {
141+
const stackOf = (field: Record<string, unknown>) => ({
142+
objects: [
143+
{
144+
name: 'task',
145+
fields: [{ name: 'plan_no', type: 'text', required: true }, { name: 'task_no', type: 'autonumber', ...field }],
146+
},
147+
],
148+
});
149+
150+
it('an EMPTY autonumberFormat is undeclared: the `format` shorthand is linted, as the runtime mints it', () => {
151+
const out = lintAutonumberFormats(stackOf({ autonumberFormat: '', format: '{nope}{000}' }));
152+
expect(out).toHaveLength(1);
153+
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
154+
expect(out[0].severity).toBe('error');
155+
expect(out[0].where).toContain('"{nope}{000}"');
156+
});
157+
158+
it('a non-empty autonumberFormat wins over the `format` shorthand', () => {
159+
const out = lintAutonumberFormats(stackOf({ autonumberFormat: '{gone}{000}', format: '{plan_no}{000}' }));
160+
expect(out).toHaveLength(1);
161+
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
162+
expect(out[0].message).toContain('{gone}');
163+
});
164+
165+
it('the `format` shorthand is linted when autonumberFormat is absent', () => {
166+
const out = lintAutonumberFormats(stackOf({ format: '{nope}{000}' }));
167+
expect(out).toHaveLength(1);
168+
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
169+
});
170+
171+
it('with neither key declared, the contract default is linted — and draws no finding', () => {
172+
const declaredDefault = lintAutonumberFormats(stackOf({ autonumberFormat: DEFAULT_AUTONUMBER_FORMAT }));
173+
for (const undeclared of [{}, { autonumberFormat: '', format: '' }]) {
174+
expect(lintAutonumberFormats(stackOf(undeclared))).toEqual(declaredDefault);
175+
}
176+
expect(declaredDefault).toEqual([]);
177+
});
178+
179+
it('for every spelling of the two keys, the findings equal those for the resolved format', () => {
180+
const values: unknown[] = [undefined, '', 7, 'A-{0000}', '{nope}{000}'];
181+
const diverged: string[] = [];
182+
for (const autonumberFormat of values) {
183+
for (const format of values) {
184+
const field: Record<string, unknown> = {};
185+
if (autonumberFormat !== undefined) field.autonumberFormat = autonumberFormat;
186+
if (format !== undefined) field.format = format;
187+
const asWritten = lintAutonumberFormats(stackOf(field));
188+
const asMinted = lintAutonumberFormats(stackOf({ autonumberFormat: resolveAutonumberFormat(field) }));
189+
if (JSON.stringify(asWritten) !== JSON.stringify(asMinted)) diverged.push(JSON.stringify(field));
190+
}
191+
}
192+
expect(diverged).toEqual([]);
193+
});
194+
});

‎packages/lint/src/lint-autonumber-formats.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,17 @@
1919
*
2020
* A self-reference (`{self}` on the autonumber field itself) is always an
2121
* ERROR — the value does not exist yet when the format renders.
22+
*
23+
* The format checked is the one the runtime mints with, answered by
24+
* `resolveAutonumberFormat` — the resolver the engine and the SQL driver call —
25+
* never by a precedence rule kept here. A local copy already disagreed with it
26+
* once: it took `autonumberFormat: ''` as declared, checked nothing, and passed
27+
* a `format` shorthand the runtime then rendered and threw on. A field that
28+
* declares neither key is checked against the contract default, which is what
29+
* it mints with.
2230
*/
2331

24-
import { parseAutonumberFormat, referencedFields } from '@objectstack/spec/data';
32+
import { parseAutonumberFormat, referencedFields, resolveAutonumberFormat } from '@objectstack/spec/data';
2533
import { recordsOf } from './object-graph.js';
2634

2735
export interface AutonumberLintFinding {
@@ -57,10 +65,7 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
5765
for (const f of fields) {
5866
if (f.type !== 'autonumber') continue;
5967
const name = typeof f.name === 'string' ? f.name : '(unnamed field)';
60-
const fmt = typeof f.autonumberFormat === 'string'
61-
? f.autonumberFormat
62-
: (typeof f.format === 'string' ? f.format : '');
63-
if (!fmt) continue;
68+
const fmt = resolveAutonumberFormat(f);
6469
const tokens = parseAutonumberFormat(fmt);
6570
const refs = referencedFields(tokens);
6671
const where = `object '${objectName}' · field '${name}' (autonumber "${fmt}")`;

0 commit comments

Comments
 (0)