diff --git a/.changeset/autonumber-authoring-guardrails.md b/.changeset/autonumber-authoring-guardrails.md new file mode 100644 index 0000000000..3697c240cc --- /dev/null +++ b/.changeset/autonumber-authoring-guardrails.md @@ -0,0 +1,15 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): warn on unrecognized autonumber format tokens + +`objectstack compile` now flags `autonumber` formats whose `{...}` token is not a +counter (`{0000}`), date (`{YYYY}`/`{MM}`/…) or `{field}` token — an unrecognized +group (wrong case, spaces, punctuation, or a second sequence slot) renders +LITERALLY into the record number, which is a silent footgun for AI-authored +templates. Emitted as an advisory warning (`autonumber-unrecognized-token`), +alongside the existing `{field}`-reference checks. The `objectstack-data` skill's +`field-types` rules were also expanded to document the date/`{field}`/per-scope +tokens and the authoring rules (required interpolated fields, delimited adjacent +tokens, pad width is a minimum, date tokens are exact). diff --git a/packages/cli/src/utils/lint-autonumber-formats.test.ts b/packages/cli/src/utils/lint-autonumber-formats.test.ts index a870e4041b..d4ee4e240c 100644 --- a/packages/cli/src/utils/lint-autonumber-formats.test.ts +++ b/packages/cli/src/utils/lint-autonumber-formats.test.ts @@ -6,6 +6,7 @@ import { AUTONUMBER_UNKNOWN_FIELD, AUTONUMBER_OPTIONAL_FIELD, AUTONUMBER_SELF_REFERENCE, + AUTONUMBER_LITERAL_TOKEN, } from './lint-autonumber-formats.js'; describe('lintAutonumberFormats', () => { @@ -77,6 +78,40 @@ describe('lintAutonumberFormats', () => { expect(out[0].rule).toBe(AUTONUMBER_SELF_REFERENCE); }); + it('warns on an unrecognized token that would render literally', () => { + const stack = { + objects: [ + { name: 'wo', fields: { wo_no: { type: 'autonumber', autonumberFormat: 'WO-{ YYYY }-{0000}' } } }, + ], + }; + const out = lintAutonumberFormats(stack); + expect(out).toHaveLength(1); + expect(out[0].severity).toBe('warning'); + expect(out[0].rule).toBe(AUTONUMBER_LITERAL_TOKEN); + expect(out[0].message).toContain('{ YYYY }'); + }); + + it('warns on a second sequence slot (only the first counts)', () => { + const stack = { + objects: [ + { name: 'wo', fields: { wo_no: { type: 'autonumber', autonumberFormat: '{0000}-{000}' } } }, + ], + }; + const out = lintAutonumberFormats(stack); + expect(out).toHaveLength(1); + expect(out[0].rule).toBe(AUTONUMBER_LITERAL_TOKEN); + expect(out[0].message).toContain('second sequence slot'); + }); + + it('does not warn on valid date/counter tokens', () => { + const stack = { + objects: [ + { name: 'audit', fields: { audit_no: { type: 'autonumber', autonumberFormat: 'AD{YYYYMMDD}{0000}' } } }, + ], + }; + expect(lintAutonumberFormats(stack)).toEqual([]); + }); + it('handles array-shaped fields and the `format` shorthand', () => { const stack = { objects: [ diff --git a/packages/cli/src/utils/lint-autonumber-formats.ts b/packages/cli/src/utils/lint-autonumber-formats.ts index e2e6938a3e..455c510176 100644 --- a/packages/cli/src/utils/lint-autonumber-formats.ts +++ b/packages/cli/src/utils/lint-autonumber-formats.ts @@ -36,6 +36,7 @@ type AnyRec = Record; export const AUTONUMBER_UNKNOWN_FIELD = 'autonumber-references-unknown-field'; export const AUTONUMBER_OPTIONAL_FIELD = 'autonumber-references-optional-field'; export const AUTONUMBER_SELF_REFERENCE = 'autonumber-references-self'; +export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token'; function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; @@ -67,8 +68,34 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] { ? f.autonumberFormat : (typeof f.format === 'string' ? f.format : ''); if (!fmt) continue; - const refs = referencedFields(parseAutonumberFormat(fmt)); + const tokens = parseAutonumberFormat(fmt); + const refs = referencedFields(tokens); const where = `object '${objectName}' · field '${name}' (autonumber "${fmt}")`; + + // An unrecognized `{...}` group is kept as literal text by the parser, so + // it ships VERBATIM in the record number. This catches case/spacing/typo + // mistakes the field-reference checks miss — date tokens are exact + // (`{YYYY}`, not `{yyyy}` or `{ YYYY }`), and only one `{0..0}` slot counts. + for (const t of tokens) { + if (t.kind !== 'literal') continue; + const braced = t.text.match(/\{[^{}]*\}/g); + if (!braced) continue; + for (const tok of braced) { + const body = tok.slice(1, -1); + const isExtraSeq = /^0+$/.test(body); + findings.push({ + where, + message: isExtraSeq + ? `format has a second sequence slot \`${tok}\` — only the first \`{0..0}\` counts; this one renders literally as "${tok}".` + : `format has an unrecognized token \`${tok}\` — it is not a counter/date/{field} token, so it renders literally as "${tok}" in every record number.`, + hint: isExtraSeq + ? `Use a single \`{0000}\` slot; fold any second number into a literal or a {field} token.` + : `Date tokens are case-sensitive and exact: {YYYY} {YY} {MM} {DD} {YYYYMMDD} (no spaces/punctuation inside). For a field value use {field_name}.`, + rule: AUTONUMBER_LITERAL_TOKEN, + severity: 'warning', + }); + } + } for (const ref of refs) { if (ref === name) { findings.push({ diff --git a/skills/objectstack-data/rules/field-types.md b/skills/objectstack-data/rules/field-types.md index 9b8e426e27..163aa155c6 100644 --- a/skills/objectstack-data/rules/field-types.md +++ b/skills/objectstack-data/rules/field-types.md @@ -85,7 +85,7 @@ All use `fileAttachmentConfig` for size limits, allowed types, virus scanning, a |:-----|:------------|:-------| | `formula` | Computed from an expression referencing other fields | `expression`, `resultType` | | `summary` | Roll-up aggregation from child records | `summaryType` (count/sum/min/max/avg), `summaryField`, `reference` | -| `autonumber` | Auto-incrementing display format | `format` (e.g., `"CASE-{0000}"`) | +| `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"`) | ## Enhanced Types @@ -282,6 +282,23 @@ What kind of data? } ``` +The `format` is literal text interleaved with `{...}` tokens: + +| Token | Renders | Example | +|:------|:--------|:--------| +| `{0000}` | The counter, zero-padded to that many digits (**minimum** width). At most ONE slot. | `CASE-{0000}` → `CASE-0042` | +| `{YYYY}` `{YY}` `{MM}` `{DD}` `{YYYYMMDD}` | Generation date in the request's business timezone | `AD{YYYYMMDD}{0000}` → `AD202606170001` | +| `{field_name}` | The value of another field **on the same record** | `{plan_no}{000}` → `PLAN-001001` | + +**The counter resets per "scope"** — everything rendered *before* the `{0000}` slot. So `AD{YYYYMMDD}{0000}` restarts each day, `{section}{island_zone}{000}` counts per group, `{plan_no}{000}` counts per parent — no separate reset config. A fixed-prefix format (`CASE-{0000}`) has an empty scope → one global counter. + +**Rules — get these wrong and records mis-number silently or fail to save:** + +1. **Every `{field}` you interpolate must be `required: true`** and set before the record is created. An empty interpolated field makes the record number generation *throw* (the compile lint flags a non-existent field as an error, an optional one as a warning). +2. **Put a delimiter between adjacent variable tokens** — `{section}-{zone}{000}`, not `{section}{zone}{000}`. Without one, `('AB','C')` and `('A','BC')` both render prefix `ABC` and share a counter (to keep numbers unique). The literal separator keeps distinct groups apart. +3. **Pad width is a MINIMUM, not a cap.** `{000}` → `001`…`999`, then `1000` (it grows, never wraps). Size it for readability, not as a ceiling. +4. **Only known tokens are interpolated.** Date tokens are **case-sensitive and exact** (`{YYYY}`, not `{yyyy}` or `{YYYY-MM}`). An unrecognized `{...}` is emitted **literally** into the number — `{ YYYY }` (spaces) renders the text `{ YYYY }`. + ### Vector (AI Embeddings) ```typescript @@ -346,3 +363,21 @@ options: [ reference: 'account', // ✅ Target object specified } ``` + +### ❌ Incorrect — Autonumber interpolating an optional / adjacent field + +```typescript +{ + plan_no: { type: 'text' }, // ❌ not required — empty value throws at create + order_no: { type: 'autonumber', format: '{section}{plan_no}{000}' }, // ❌ no delimiter +} +``` + +### ✅ Correct — Required field + delimiter between variable tokens + +```typescript +{ + plan_no: { type: 'text', required: true }, // ✅ always set before generation + order_no: { type: 'autonumber', format: '{section}-{plan_no}-{000}' }, // ✅ delimited +} +```