From 8b0626c57d7a05186056e5f1d3060808058b5f76 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Tue, 16 Jun 2026 23:15:28 +0800 Subject: [PATCH] docs(skill): sync CEL function tables to 9.8.0 stdlib + drift-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoring skills under-listed the stdlib (6 functions + "only the above"), omitting the 14 registered in 9.8.0 (daysBetween/abs/round/min/max/upper/lower/ contains/startsWith/endsWith/matches/len/isEmpty/date/datetime) — so AI authors never used daysBetween and kept hand-rolling workarounds. - Rewrite objectstack-formula stdlib table (grouped + CEL built-ins); fix the daysFromNow/daysAgo note (keep wall-clock time, not midnight). - Rewrite objectstack-automation "time-relative rule" anti-pattern to the precise one-day window pattern ($gte daysFromNow(N) / $lt daysFromNow(N+1) + $or); add daysBetween for days-remaining. - Add a drift-guard test (packages/formula) pinning skill ↔ CEL_STDLIB_FUNCTIONS. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/skill-catalog-9.8-sync.md | 19 +++++++ .../formula/src/skill-catalog-sync.test.ts | 32 +++++++++++ skills/objectstack-automation/SKILL.md | 26 +++++++-- skills/objectstack-formula/SKILL.md | 55 +++++++++++++++---- 4 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 .changeset/skill-catalog-9.8-sync.md create mode 100644 packages/formula/src/skill-catalog-sync.test.ts diff --git a/.changeset/skill-catalog-9.8-sync.md b/.changeset/skill-catalog-9.8-sync.md new file mode 100644 index 0000000000..1149df63c2 --- /dev/null +++ b/.changeset/skill-catalog-9.8-sync.md @@ -0,0 +1,19 @@ +--- +--- + +docs(skill): sync objectstack-formula/automation CEL function tables to the 9.8.0 stdlib + +The authoring skills (what the AI reads to know what's callable) under-listed the +stdlib: they showed ~6 functions and capped with "only the stdlib above", omitting +the 14 functions registered in 9.8.0 (`daysBetween`, `abs`, `round`, `min`, `max`, +`upper`, `lower`, `contains`, `startsWith`, `endsWith`, `matches`, `len`, `isEmpty`, +`date`/`datetime`). So AI authors never reached for `daysBetween` (days-remaining) +and kept hand-rolling workarounds. + +Rewrites the formula stdlib table (grouped, with the CEL built-ins), fixes the +`daysFromNow`/`daysAgo` note (they keep the wall-clock time, NOT midnight), and +rewrites the automation "time-relative rule" anti-pattern to the precise one-day +**window** pattern (`$gte daysFromNow(N)` / `$lt daysFromNow(N+1)` + `$or`) instead +of the imprecise `BETWEEN TODAY and TODAY+N`. Adds a drift-guard test pinning the +skill ↔ `CEL_STDLIB_FUNCTIONS` so the AI-facing list can't fall behind the runtime +catalog again. diff --git a/packages/formula/src/skill-catalog-sync.test.ts b/packages/formula/src/skill-catalog-sync.test.ts new file mode 100644 index 0000000000..5b40d08dab --- /dev/null +++ b/packages/formula/src/skill-catalog-sync.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { CEL_STDLIB_FUNCTIONS } from './validate'; + +/** + * Drift-guard (#1928 follow-up): the objectstack-formula authoring skill is what + * the AI author reads to know which CEL functions exist. It MUST stay in sync + * with the runtime catalog — a function advertised in the catalog but missing + * from the skill means the AI never reaches for it; one in the skill but not the + * catalog means the AI calls a function that faults the build. This pins the + * skill ↔ `CEL_STDLIB_FUNCTIONS` mapping so neither drifts silently again + * (mirrors the runtime drift-guard in cel-engine.test.ts). + */ +describe('objectstack-formula skill ↔ CEL_STDLIB_FUNCTIONS', () => { + const here = dirname(fileURLToPath(import.meta.url)); + const skillPath = resolve(here, '../../../skills/objectstack-formula/SKILL.md'); + const skill = readFileSync(skillPath, 'utf8'); + + it('documents every advertised stdlib function', () => { + // A function is "documented" if its name appears as `` `name(` `` (a call + // form) anywhere in the skill — robust to table layout / grouping changes. + const missing = CEL_STDLIB_FUNCTIONS.filter((fn) => !skill.includes(`\`${fn}(`)); + expect( + missing, + `These CEL_STDLIB_FUNCTIONS are not documented in skills/objectstack-formula/SKILL.md:\n` + + `${missing.join(', ')}\nAdd them to the stdlib table so AI authors know they exist.`, + ).toEqual([]); + }); +}); diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 1fce611d71..1ef8d23ca7 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -39,7 +39,7 @@ pipelines. > envelope evaluated by `@objectstack/formula`. Use the `P\`...\`` and > `cel\`...\`` tagged templates from `@objectstack/spec`. See the > **objectstack-formula** skill for the full CEL contract, stdlib -> (`now()`, `today()`, `daysFromNow(n)`, `isBlank(v)`, `coalesce(v, fb)`), +> (`now()`, `today()`, `daysFromNow(n)`, `daysBetween(a, b)`, `isBlank(v)`, `coalesce(v, fb)`), > and the legacy → CEL translation table. --- @@ -537,9 +537,22 @@ them right the first time: 8. **Time-relative rules ("alert N days before a date") are SCHEDULE flows, not record-change date-equality.** `record.end_date == daysFromNow(60)` on a `record-*` trigger only fires if the record happens to be written on that exact - day — unattended rules never run. - ✅ A daily `schedule` flow whose `get_record` filters `end_date` BETWEEN - `{TODAY()}` and `{TODAY() + N}`, then loops over the results. + day — unattended rules never run. **And date EQUALITY never matches anyway**: a + `date` field carries a time component, so `field == daysFromNow(N)` (or + `{ $in: [daysFromNow(N), …] }`) compares two differently-timed timestamps and + silently returns nothing (build warns `flow-date-equality-filter`). + ✅ A daily `schedule` flow whose `get_record` filters each tier as a one-day + **window** (`$gte`/`$lt`), never an equality: + ```ts + filter: { status: 'active', $or: [ + { end_date: { $gte: cel`daysFromNow(7)`, $lt: cel`daysFromNow(8)` } }, + { end_date: { $gte: cel`daysFromNow(30)`, $lt: cel`daysFromNow(31)` } }, + { end_date: { $gte: cel`daysFromNow(60)`, $lt: cel`daysFromNow(61)` } }, + ] } + ``` + Abutting windows tile the timeline so each record matches exactly one tier — + fires once, idempotent, no guard field. For "days remaining" in the message, + `daysBetween(today(), record.end_date)`. 9. **`script` nodes must name a callable.** Set `config.actionType` to a built-in side-effect (`email` / `slack`) **or** `config.function` to a function @@ -573,8 +586,9 @@ them right the first time: that's an L2 **hook** (objectstack-data) — hooks get `ctx.api`; flow functions don't. 10. **Conditions are bare CEL — only the stdlib is callable.** `now()`, - `today()`, `daysFromNow(n)`, `daysAgo(n)`, `isBlank(v)`, `coalesce(a, b)`, - `trim(s)`, plus CEL built-ins (`has`, `size`, `contains`, `startsWith`, …). + `today()`, `daysFromNow(n)`, `daysAgo(n)`, `daysBetween(a, b)`, `isBlank(v)`, + `coalesce(a, b)`, `abs/round/min/max`, `upper/lower/contains/matches`, plus CEL + built-ins (`has`, `size`, `int`, `string`, …) — see **objectstack-formula** for the full table. An UNKNOWN function (`PRIOR()`, a typo'd name) **fails the build**. And never wrap a field reference in `{…}` inside a condition — that's a template brace and fails as CEL: write `record.x`, not `{record.x}`. diff --git a/skills/objectstack-formula/SKILL.md b/skills/objectstack-formula/SKILL.md index aed57dd21c..e581861328 100644 --- a/skills/objectstack-formula/SKILL.md +++ b/skills/objectstack-formula/SKILL.md @@ -126,24 +126,59 @@ in `coalesce(..., '')`. Registered automatically. Source: [`packages/formula/src/stdlib.ts`](../../packages/formula/src/stdlib.ts). +The canonical list is `CEL_STDLIB_FUNCTIONS` in +[`packages/formula/src/validate.ts`](../../packages/formula/src/validate.ts) — a +test asserts every entry resolves at runtime, so this table stays in sync with it. + +**Dates** + +| Function | Returns | Notes | +|:---|:---|:---| +| `now()` | timestamp | Current instant. Pinned per evaluation run; deterministic in build | +| `today()` | timestamp | UTC **start-of-day** (midnight) | +| `daysFromNow(n)` | timestamp | `now()` + `n` days — **keeps the current time-of-day** (NOT midnight) | +| `daysAgo(n)` | timestamp | `now()` − `n` days — keeps the current time-of-day | +| `daysBetween(a, b)` | int | Whole days from `a` to `b` (negative if `b` precedes `a`). `daysBetween(today(), record.due)` = days remaining | +| `date(s)` / `datetime(s)` | timestamp | Parse an ISO date / date-time string to a timestamp | + +**Numbers** + +| Function | Returns | Notes | +|:---|:---|:---| +| `abs(x)` | double | Absolute value | +| `round(x)` | int | Round to the nearest integer | +| `min(a, b)` / `max(a, b)` | dyn | Smaller / larger operand (numeric comparison) | + +**Strings** + +| Function | Returns | Notes | +|:---|:---|:---| +| `upper(s)` / `lower(s)` | string | Case conversion | +| `trim(s)` | string | Strip surrounding whitespace (`''` for null) | +| `contains(s, sub)` | bool | Substring test | +| `startsWith(s, p)` / `endsWith(s, p)` | bool | Prefix / suffix test | +| `matches(s, re)` | bool | Regex test | +| `joinNonEmpty(list, sep)` | string | Join, dropping null/empty entries | + +**Collections / null-ish** + | Function | Returns | Notes | |:---|:---|:---| -| `now()` | timestamp | Pinned per evaluation run; deterministic in build | -| `today()` | timestamp | UTC start-of-day | -| `daysFromNow(n)` | timestamp | `today() + n*24h` | -| `daysAgo(n)` | timestamp | `today() - n*24h` | | `isBlank(v)` | bool | true for `null`, `undefined`, `''`, `[]` | +| `isEmpty(v)` | bool | true for `null`, `undefined`, empty string / list / map | | `coalesce(v, fallback)` | dyn | `v` when non-null, else `fallback` | +| `len(v)` | int | Length of a string / list / map | + +Plus CEL built-ins: `has(x)`, `size(x)`, `int(x)`, `string(x)`, `bool(x)`, +`double(x)`, `timestamp(s)`, `duration(s)`. If you need a helper that doesn't exist, prefer adding it to the stdlib (small, pure, dependency-free) over inlining a complex CEL expression. -> **Only the stdlib above + CEL built-ins (`has`, `size`, `contains`, -> `startsWith`, `endsWith`, `matches`, `min`, `max`, …) are callable.** An -> UNKNOWN function — `PRIOR()`, a legacy `ISBLANK()`, a typo'd `isBlnk()` — now -> **fails `objectstack build`** with a "no matching overload" type error (#1877), -> rather than silently no-op'ing the predicate at run time. Use `previous.x` -> (not `PRIOR()`), `isBlank()` (not `ISBLANK()`). +> **Only the functions above are callable.** An UNKNOWN function — `PRIOR()`, a +> legacy `ISBLANK()`, a typo'd `isBlnk()` — **fails `objectstack build`** with a +> "no matching overload" type error (#1877), rather than silently no-op'ing the +> predicate at run time. Use `previous.x` (not `PRIOR()`), `isBlank()` (not `ISBLANK()`). ---