Skip to content

Commit c651f38

Browse files
os-zhuangclaude
andauthored
feat(autonumber): document tokens in data skill; lint unrecognized format tokens (#2046)
Follow-up to #2043 — close the AI-authoring gap on top of the runtime guards: - skills/objectstack-data field-types rules: the autonumber section only showed `CASE-{0000}`. Expanded with the date / {field} / per-scope tokens and the authoring rules that prevent silent mis-numbering — interpolated fields must be required, put a delimiter between adjacent variable tokens, pad width is a minimum, date tokens are exact/case-sensitive — plus an incorrect/correct example. - compile lint: warn when an autonumber `{...}` token is not a counter/date/{field} token. Unrecognized groups (wrong case, spaces, a second {0..0} slot) render LITERALLY into the record number; the field-reference checks miss the non-identifier cases. New advisory rule `autonumber-unrecognized-token`. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 36138c7 commit c651f38

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
feat(cli): warn on unrecognized autonumber format tokens
6+
7+
`objectstack compile` now flags `autonumber` formats whose `{...}` token is not a
8+
counter (`{0000}`), date (`{YYYY}`/`{MM}`/…) or `{field}` token — an unrecognized
9+
group (wrong case, spaces, punctuation, or a second sequence slot) renders
10+
LITERALLY into the record number, which is a silent footgun for AI-authored
11+
templates. Emitted as an advisory warning (`autonumber-unrecognized-token`),
12+
alongside the existing `{field}`-reference checks. The `objectstack-data` skill's
13+
`field-types` rules were also expanded to document the date/`{field}`/per-scope
14+
tokens and the authoring rules (required interpolated fields, delimited adjacent
15+
tokens, pad width is a minimum, date tokens are exact).

packages/cli/src/utils/lint-autonumber-formats.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AUTONUMBER_UNKNOWN_FIELD,
77
AUTONUMBER_OPTIONAL_FIELD,
88
AUTONUMBER_SELF_REFERENCE,
9+
AUTONUMBER_LITERAL_TOKEN,
910
} from './lint-autonumber-formats.js';
1011

1112
describe('lintAutonumberFormats', () => {
@@ -77,6 +78,40 @@ describe('lintAutonumberFormats', () => {
7778
expect(out[0].rule).toBe(AUTONUMBER_SELF_REFERENCE);
7879
});
7980

81+
it('warns on an unrecognized token that would render literally', () => {
82+
const stack = {
83+
objects: [
84+
{ name: 'wo', fields: { wo_no: { type: 'autonumber', autonumberFormat: 'WO-{ YYYY }-{0000}' } } },
85+
],
86+
};
87+
const out = lintAutonumberFormats(stack);
88+
expect(out).toHaveLength(1);
89+
expect(out[0].severity).toBe('warning');
90+
expect(out[0].rule).toBe(AUTONUMBER_LITERAL_TOKEN);
91+
expect(out[0].message).toContain('{ YYYY }');
92+
});
93+
94+
it('warns on a second sequence slot (only the first counts)', () => {
95+
const stack = {
96+
objects: [
97+
{ name: 'wo', fields: { wo_no: { type: 'autonumber', autonumberFormat: '{0000}-{000}' } } },
98+
],
99+
};
100+
const out = lintAutonumberFormats(stack);
101+
expect(out).toHaveLength(1);
102+
expect(out[0].rule).toBe(AUTONUMBER_LITERAL_TOKEN);
103+
expect(out[0].message).toContain('second sequence slot');
104+
});
105+
106+
it('does not warn on valid date/counter tokens', () => {
107+
const stack = {
108+
objects: [
109+
{ name: 'audit', fields: { audit_no: { type: 'autonumber', autonumberFormat: 'AD{YYYYMMDD}{0000}' } } },
110+
],
111+
};
112+
expect(lintAutonumberFormats(stack)).toEqual([]);
113+
});
114+
80115
it('handles array-shaped fields and the `format` shorthand', () => {
81116
const stack = {
82117
objects: [

packages/cli/src/utils/lint-autonumber-formats.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type AnyRec = Record<string, unknown>;
3636
export const AUTONUMBER_UNKNOWN_FIELD = 'autonumber-references-unknown-field';
3737
export const AUTONUMBER_OPTIONAL_FIELD = 'autonumber-references-optional-field';
3838
export const AUTONUMBER_SELF_REFERENCE = 'autonumber-references-self';
39+
export const AUTONUMBER_LITERAL_TOKEN = 'autonumber-unrecognized-token';
3940

4041
function asArray(v: unknown): AnyRec[] {
4142
if (Array.isArray(v)) return v as AnyRec[];
@@ -67,8 +68,34 @@ export function lintAutonumberFormats(stack: AnyRec): AutonumberLintFinding[] {
6768
? f.autonumberFormat
6869
: (typeof f.format === 'string' ? f.format : '');
6970
if (!fmt) continue;
70-
const refs = referencedFields(parseAutonumberFormat(fmt));
71+
const tokens = parseAutonumberFormat(fmt);
72+
const refs = referencedFields(tokens);
7173
const where = `object '${objectName}' · field '${name}' (autonumber "${fmt}")`;
74+
75+
// An unrecognized `{...}` group is kept as literal text by the parser, so
76+
// it ships VERBATIM in the record number. This catches case/spacing/typo
77+
// mistakes the field-reference checks miss — date tokens are exact
78+
// (`{YYYY}`, not `{yyyy}` or `{ YYYY }`), and only one `{0..0}` slot counts.
79+
for (const t of tokens) {
80+
if (t.kind !== 'literal') continue;
81+
const braced = t.text.match(/\{[^{}]*\}/g);
82+
if (!braced) continue;
83+
for (const tok of braced) {
84+
const body = tok.slice(1, -1);
85+
const isExtraSeq = /^0+$/.test(body);
86+
findings.push({
87+
where,
88+
message: isExtraSeq
89+
? `format has a second sequence slot \`${tok}\` — only the first \`{0..0}\` counts; this one renders literally as "${tok}".`
90+
: `format has an unrecognized token \`${tok}\` — it is not a counter/date/{field} token, so it renders literally as "${tok}" in every record number.`,
91+
hint: isExtraSeq
92+
? `Use a single \`{0000}\` slot; fold any second number into a literal or a {field} token.`
93+
: `Date tokens are case-sensitive and exact: {YYYY} {YY} {MM} {DD} {YYYYMMDD} (no spaces/punctuation inside). For a field value use {field_name}.`,
94+
rule: AUTONUMBER_LITERAL_TOKEN,
95+
severity: 'warning',
96+
});
97+
}
98+
}
7299
for (const ref of refs) {
73100
if (ref === name) {
74101
findings.push({

skills/objectstack-data/rules/field-types.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ All use `fileAttachmentConfig` for size limits, allowed types, virus scanning, a
8585
|:-----|:------------|:-------|
8686
| `formula` | Computed from an expression referencing other fields | `expression`, `resultType` |
8787
| `summary` | Roll-up aggregation from child records | `summaryType` (count/sum/min/max/avg), `summaryField`, `reference` |
88-
| `autonumber` | Auto-incrementing display format | `format` (e.g., `"CASE-{0000}"`) |
88+
| `autonumber` | Auto-incrementing display format ({0000} counter + optional date / {field} tokens, resets per scope) | `format` (e.g., `"CASE-{0000}"`, `"AD{YYYYMMDD}{0000}"`) |
8989

9090
## Enhanced Types
9191

@@ -282,6 +282,23 @@ What kind of data?
282282
}
283283
```
284284

285+
The `format` is literal text interleaved with `{...}` tokens:
286+
287+
| Token | Renders | Example |
288+
|:------|:--------|:--------|
289+
| `{0000}` | The counter, zero-padded to that many digits (**minimum** width). At most ONE slot. | `CASE-{0000}``CASE-0042` |
290+
| `{YYYY}` `{YY}` `{MM}` `{DD}` `{YYYYMMDD}` | Generation date in the request's business timezone | `AD{YYYYMMDD}{0000}``AD202606170001` |
291+
| `{field_name}` | The value of another field **on the same record** | `{plan_no}{000}``PLAN-001001` |
292+
293+
**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.
294+
295+
**Rules — get these wrong and records mis-number silently or fail to save:**
296+
297+
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).
298+
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.
299+
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.
300+
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 }`.
301+
285302
### Vector (AI Embeddings)
286303

287304
```typescript
@@ -346,3 +363,21 @@ options: [
346363
reference: 'account', // ✅ Target object specified
347364
}
348365
```
366+
367+
### ❌ Incorrect — Autonumber interpolating an optional / adjacent field
368+
369+
```typescript
370+
{
371+
plan_no: { type: 'text' }, // ❌ not required — empty value throws at create
372+
order_no: { type: 'autonumber', format: '{section}{plan_no}{000}' }, // ❌ no delimiter
373+
}
374+
```
375+
376+
### ✅ Correct — Required field + delimiter between variable tokens
377+
378+
```typescript
379+
{
380+
plan_no: { type: 'text', required: true }, // ✅ always set before generation
381+
order_no: { type: 'autonumber', format: '{section}-{plan_no}-{000}' }, // ✅ delimited
382+
}
383+
```

0 commit comments

Comments
 (0)