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
15 changes: 15 additions & 0 deletions .changeset/autonumber-authoring-guardrails.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 35 additions & 0 deletions packages/cli/src/utils/lint-autonumber-formats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AUTONUMBER_UNKNOWN_FIELD,
AUTONUMBER_OPTIONAL_FIELD,
AUTONUMBER_SELF_REFERENCE,
AUTONUMBER_LITERAL_TOKEN,
} from './lint-autonumber-formats.js';

describe('lintAutonumberFormats', () => {
Expand Down Expand Up @@ -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: [
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/utils/lint-autonumber-formats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type AnyRec = Record<string, unknown>;
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[];
Expand Down Expand Up @@ -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({
Expand Down
37 changes: 36 additions & 1 deletion skills/objectstack-data/rules/field-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
```
Loading