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
9 changes: 9 additions & 0 deletions .changeset/19772-autonumber-lint-empty-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/lint": patch
---

`os validate` and `os build` now check an `autonumber` field's `format` when its `autonumberFormat` is an empty string (#19772).

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`.

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.
65 changes: 65 additions & 0 deletions packages/lint/src/lint-autonumber-formats.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { DEFAULT_AUTONUMBER_FORMAT, resolveAutonumberFormat } from '@objectstack/spec/data';
import {
lintAutonumberFormats,
AUTONUMBER_UNKNOWN_FIELD,
Expand Down Expand Up @@ -127,3 +128,67 @@ describe('lintAutonumberFormats', () => {
expect(lintAutonumberFormats(stack)).toEqual([]);
});
});

/**
* Which format the lint checks. The engine and the SQL driver both mint through
* `resolveAutonumberFormat`: canonical `autonumberFormat`, then the `format`
* shorthand, then the contract default — and a key holding anything but a
* NON-EMPTY string counts as undeclared. A lint that answers "which format?"
* any other way checks a format the runtime never renders, so it can pass a
* field whose every create then throws.
*/
describe('lintAutonumberFormats — checks the format the runtime mints with', () => {
const stackOf = (field: Record<string, unknown>) => ({
objects: [
{
name: 'task',
fields: [{ name: 'plan_no', type: 'text', required: true }, { name: 'task_no', type: 'autonumber', ...field }],
},
],
});

it('an EMPTY autonumberFormat is undeclared: the `format` shorthand is linted, as the runtime mints it', () => {
const out = lintAutonumberFormats(stackOf({ autonumberFormat: '', format: '{nope}{000}' }));
expect(out).toHaveLength(1);
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
expect(out[0].severity).toBe('error');
expect(out[0].where).toContain('"{nope}{000}"');
});

it('a non-empty autonumberFormat wins over the `format` shorthand', () => {
const out = lintAutonumberFormats(stackOf({ autonumberFormat: '{gone}{000}', format: '{plan_no}{000}' }));
expect(out).toHaveLength(1);
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
expect(out[0].message).toContain('{gone}');
});

it('the `format` shorthand is linted when autonumberFormat is absent', () => {
const out = lintAutonumberFormats(stackOf({ format: '{nope}{000}' }));
expect(out).toHaveLength(1);
expect(out[0].rule).toBe(AUTONUMBER_UNKNOWN_FIELD);
});

it('with neither key declared, the contract default is linted — and draws no finding', () => {
const declaredDefault = lintAutonumberFormats(stackOf({ autonumberFormat: DEFAULT_AUTONUMBER_FORMAT }));
for (const undeclared of [{}, { autonumberFormat: '', format: '' }]) {
expect(lintAutonumberFormats(stackOf(undeclared))).toEqual(declaredDefault);
}
expect(declaredDefault).toEqual([]);
});

it('for every spelling of the two keys, the findings equal those for the resolved format', () => {
const values: unknown[] = [undefined, '', 7, 'A-{0000}', '{nope}{000}'];
const diverged: string[] = [];
for (const autonumberFormat of values) {
for (const format of values) {
const field: Record<string, unknown> = {};
if (autonumberFormat !== undefined) field.autonumberFormat = autonumberFormat;
if (format !== undefined) field.format = format;
const asWritten = lintAutonumberFormats(stackOf(field));
const asMinted = lintAutonumberFormats(stackOf({ autonumberFormat: resolveAutonumberFormat(field) }));
if (JSON.stringify(asWritten) !== JSON.stringify(asMinted)) diverged.push(JSON.stringify(field));
}
}
expect(diverged).toEqual([]);
});
});
15 changes: 10 additions & 5 deletions packages/lint/src/lint-autonumber-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@
*
* A self-reference (`{self}` on the autonumber field itself) is always an
* ERROR — the value does not exist yet when the format renders.
*
* The format checked is the one the runtime mints with, answered by
* `resolveAutonumberFormat` — the resolver the engine and the SQL driver call —
* never by a precedence rule kept here. A local copy already disagreed with it
* once: it took `autonumberFormat: ''` as declared, checked nothing, and passed
* a `format` shorthand the runtime then rendered and threw on. A field that
* declares neither key is checked against the contract default, which is what
* it mints with.
*/

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

export interface AutonumberLintFinding {
Expand Down Expand Up @@ -57,10 +65,7 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
for (const f of fields) {
if (f.type !== 'autonumber') continue;
const name = typeof f.name === 'string' ? f.name : '(unnamed field)';
const fmt = typeof f.autonumberFormat === 'string'
? f.autonumberFormat
: (typeof f.format === 'string' ? f.format : '');
if (!fmt) continue;
const fmt = resolveAutonumberFormat(f);
const tokens = parseAutonumberFormat(fmt);
const refs = referencedFields(tokens);
const where = `object '${objectName}' · field '${name}' (autonumber "${fmt}")`;
Expand Down
Loading