Skip to content

Commit a487c50

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-16641-readings-seventh-increment
2 parents ff2bd70 + 73053ed commit a487c50

14 files changed

Lines changed: 1050 additions & 22 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
New gating rule `flow-filter-token-unknown`: a `{…}` filter token in a flow node's `config.filter` that NEITHER `{…}` dialect can resolve is now an authoring-time `error`.
6+
7+
`filter-token-unknown` walks seven presentation collections and not `flows`, so `{TOMORROW()}` in a list view's filter failed the build while the identical string in a flow node's `config.filter` was silent — even though this package's other filter rules (`empty-combinator`, the preset-comparand rules) have reached flows all along.
8+
9+
The gap was not an oversight to close by adding a root. A flow node's filter is interpolated by the automation template evaluator **before** ObjectQL sees it, and only what that evaluator cannot resolve is handed on. Judging a flow filter against the ObjectQL vocabulary — the obvious one-line fix — reports every legitimate `{record.id}` and `{recordId}`: measured at **7 findings, all 7 false positives**, on this repo's own example apps. So the new rule is a second rule id with the flow dialect as its reference set, and `filter-token-unknown`'s surface list is untouched.
10+
11+
Reported (`error`): a call to a name in neither table — `{TOMORROW()}`, `{ROUND(x)}`, `{Math.round(x)}`, `{DATEADD(day, -45)}`. The flow template dialect's function vocabulary is closed (`round` / `floor` / `ceil` / `abs` / `min` / `max`, plus the whole-token `NOW()` / `TODAY()` with an optional `± N` day offset), and the evaluator already raises a guard refusal on anything else — so the node cannot run at all, and the build was shipping a flow whose runtime was already decided. This is the same severity axis `flow-template-unknown-field` applies at this exact position.
12+
13+
Silent, deliberately: `{TODAY() - 45}` and every other whole-token date form; `{$User.Id}`; `{current_user_id}` / `{today}` / `{30_days_ago}` and the rest of the filter placeholders; and every bare or dotted identifier (`{recordId}`, `{record.id}`, `{currentTask.id}`), which addresses the run's variable map — declared flow variables, node outputs, and the trigger record's own fields — and is not decidable from authored metadata.
14+
15+
Finding delta on this repo's example apps: **0**. Expect a new `error` only where a flow filter calls a function the evaluator would refuse at run time.

content/docs/deployment/cli.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ os compile --json # JSON output for CI pipelines
498498
→ Normalizing stack definition...
499499
→ Lowering inline handlers...
500500
→ Validating protocol compliance...
501-
→ Running author-time rules (43)...
501+
→ Running author-time rules (44)...
502502
→ Checking capability providers (#3366)...
503503
→ Collecting package docs (ADR-0046)...
504504
→ Writing artifact...

content/docs/deployment/validating-metadata.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ A clean run walks the registry and reports timing:
555555
Config: /path/to/support-desk/objectstack.config.ts
556556
Load time: 21ms
557557
→ Validating against ObjectStack Protocol...
558-
→ Running author-time rules (43)...
558+
→ Running author-time rules (44)...
559559
→ Checking capability providers (#3366)...
560560
→ Checking package docs (ADR-0046)...
561561

content/docs/getting-started/build-with-claude-code.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ visible: 'status != "resolved"'
263263
◆ Validate
264264
────────────────────────────────────────
265265
→ Validating against ObjectStack Protocol...
266-
→ Running author-time rules (43)...
266+
→ Running author-time rules (44)...
267267
268268
✗ Author-time rules failed (1 issue)
269269
• stack · action 'resolve_ticket' visible: bare reference `status` — a

content/docs/ui/react-pages.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ objectstack validate
380380
────────────────────────────────────────
381381
→ Loading configuration...
382382
→ Validating against ObjectStack Protocol...
383-
→ Running author-time rules (43)...
383+
→ Running author-time rules (44)...
384384
→ Checking capability providers (#3366)...
385385
→ Checking package docs (ADR-0046)...
386386

packages/core/src/plugin-type-closed-set.test.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,21 @@
1414
// The COMPILE-TIME half — a non-member literal or a `string`-typed value no
1515
// longer type-checks against the PUBLISHED `Plugin.type` — lives in
1616
// `packages/rest/src/plugin-type-closed-set.pin.test.ts`, deliberately NOT
17-
// here: `@objectstack/core` has no `typecheck` script (type-check DEBT ledger
18-
// entry), so a `@ts-expect-error` in this package is a phantom pin no tsc
19-
// program a `typecheck` script runs would ever evaluate —
20-
// `check:type-check-coverage` refuses exactly that. The rest package's
21-
// `tsconfig.test.json` program is compiled by its `typecheck` script and reads
22-
// core's BUILT `.d.ts`, so the pin over there guards the published contract.
17+
// here. The rest package's `tsconfig.test.json` program is compiled by its
18+
// `typecheck` script and resolves `@objectstack/core` to core's BUILT
19+
// `dist/index.d.ts`, so the pin over there guards the contract consumers
20+
// actually resolve.
21+
//
22+
// ⚠️ This used to read as though the split were forced — that
23+
// `@objectstack/core` "has no `typecheck` script (type-check DEBT ledger
24+
// entry)", making a `@ts-expect-error` here a phantom pin
25+
// `check:type-check-coverage` refuses. False on this tree: #14613 split a
26+
// `tsconfig.test.json` out of the build config, `package.json`'s `typecheck`
27+
// NAMES it (via `check:test-typecheck --project`), and this package holds no
28+
// DEBT entry. A directive here WOULD be evaluated — against `./types.ts`,
29+
// this package's own SOURCE. The published `.d.ts` is what those pins are
30+
// about and only the rest program reads it, which is a reason that outlives
31+
// any package's script list.
2332

2433
import { describe, it, expect } from 'vitest';
2534
import { CORE_PLUGIN_TYPES, PluginSchema } from '@objectstack/spec/kernel';

packages/lint/src/authoring-rules.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
105105
import { validateWidgetBindings } from './validate-widget-bindings.js';
106106
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
107107
import { validateFilterTokens } from './validate-filter-tokens.js';
108+
import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js';
108109
import { validatePresetComparands } from './validate-preset-comparands.js';
109110
import { validateEmptyCombinators } from './validate-empty-combinators.js';
110111
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
@@ -612,6 +613,26 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
612613
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
613614
run: (stack) => validateFilterTokens(stack),
614615
},
616+
// #16096 — the FLOW half of the same question, and a different answer,
617+
// because a flow node's `config.filter` is evaluated by the automation
618+
// template evaluator before ObjectQL ever sees it. Reports only the class
619+
// NEITHER dialect resolves: a call to a name outside the flow template
620+
// dialect's closed function table, where `resolveToken` raises a guard
621+
// refusal and the node cannot run. The open arm (bare/dotted identifiers
622+
// addressing the run's VariableMap) is deliberately left silent — judging it
623+
// against the ObjectQL vocabulary reports 7 findings on this repo's own
624+
// examples, all 7 false positives. Reads `flows` alone, so the per-write
625+
// snapshot carries everything it needs.
626+
{
627+
name: 'validateFlowFilterTokens',
628+
tier: 'gating',
629+
input: 'parsed',
630+
commands: ALL,
631+
source: 'packages/lint/src/validate-flow-filter-tokens.ts',
632+
surfaces: CLI_AND_RUNTIME,
633+
runtimeTypes: ['flow'],
634+
run: (stack) => validateFlowFilterTokens(stack),
635+
},
615636
// #8793 (the ruled C half of #8690) — a declared dashboard date-range preset
616637
// name (`last_30_days`, …) authored as a bare ORDERING comparand resolves in
617638
// no layer: the engine refuses it on a declared temporal field at query time
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The CROSS-PACKAGE DRIFT PIN for `flow-template-grammar.ts` (#16096).
4+
//
5+
// That module MIRRORS the automation template evaluator's whole-token dispatch,
6+
// because `@objectstack/lint` depends on `@objectstack/spec` and never on a
7+
// runtime, so the dialect cannot be imported from the package that owns it. A
8+
// mirror nobody checks is the "N copies, the next author fixes one of N" shape
9+
// `filter-walk.ts` was written against — so this file reads the ORIGINAL from
10+
// disk and fails when any mirrored piece stops matching it.
11+
//
12+
// The read escapes this package, spelled so `check:cross-package-test-inputs`
13+
// can see it, and `$TURBO_ROOT$/packages/services/service-automation/src/**` is
14+
// already a declared input of `@objectstack/lint#test` in turbo.json.
15+
16+
import { existsSync, readFileSync } from 'node:fs';
17+
import { dirname, join } from 'node:path';
18+
import { fileURLToPath } from 'node:url';
19+
import { describe, expect, it } from 'vitest';
20+
21+
import {
22+
classifyFlowTemplateToken,
23+
DATE_FUNCTION_RE,
24+
VARIABLE_PATH_RE,
25+
SAFE_EXPRESSION_RE,
26+
IDENTIFIER_SCAN_RE,
27+
CALL_POSITION_RE,
28+
FLOW_TEMPLATE_DATE_FUNCTIONS,
29+
FLOW_TEMPLATE_VALUE_FUNCTIONS,
30+
} from './flow-template-grammar.js';
31+
32+
const HERE = dirname(fileURLToPath(import.meta.url));
33+
34+
/** Walk up to the workspace root — the directory holding pnpm-workspace.yaml. */
35+
function findUp(predicate: (dir: string) => boolean): string {
36+
let dir = HERE;
37+
for (;;) {
38+
if (predicate(dir)) return dir;
39+
const parent = dirname(dir);
40+
if (parent === dir) throw new Error('workspace root not found from ' + HERE);
41+
dir = parent;
42+
}
43+
}
44+
const REPO = findUp((dir) => existsSync(join(dir, 'pnpm-workspace.yaml')));
45+
46+
const ORIGINAL = join(REPO, 'packages/services/service-automation/src/builtin/template.ts');
47+
// Loud absence: if this file moves, the mirror is unpinned, and an unpinned
48+
// mirror is the defect this test exists to prevent. Failing to read IS the
49+
// regression — never a skip.
50+
const source = readFileSync(ORIGINAL, 'utf8');
51+
52+
describe('the mirrored grammar still matches the evaluator that owns it', () => {
53+
const mirrored: Array<[string, RegExp]> = [
54+
['the NOW()/TODAY() ± N day form', DATE_FUNCTION_RE],
55+
['the variable / dotted-path form', VARIABLE_PATH_RE],
56+
['the arithmetic character set', SAFE_EXPRESSION_RE],
57+
['the identifier scan', IDENTIFIER_SCAN_RE],
58+
['the call-position lookahead', CALL_POSITION_RE],
59+
];
60+
for (const [label, re] of mirrored) {
61+
it(`${label} appears verbatim in template.ts`, () => {
62+
expect(source).toContain(re.source);
63+
});
64+
}
65+
66+
it('mirrors the value-function table exactly — no name added, none dropped', () => {
67+
const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source);
68+
expect(block, 'EXPRESSION_FUNCTION_ARITY not found in template.ts').toBeTruthy();
69+
const names = [...block![1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:/gm)].map((m) => m[1]);
70+
expect(names.sort()).toEqual([...FLOW_TEMPLATE_VALUE_FUNCTIONS].sort());
71+
});
72+
73+
it('mirrors the two whole-token date function names', () => {
74+
for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(DATE_FUNCTION_RE.source).toContain(name);
75+
// And the evaluator still keeps them OUT of the value table — the reason
76+
// `{TODAY() - 45 - 10}` is refused while `{TODAY() - 45}` is not.
77+
const block = /const EXPRESSION_FUNCTION_ARITY[^{]*\{([\s\S]*?)\n\};/.exec(source);
78+
for (const name of FLOW_TEMPLATE_DATE_FUNCTIONS) expect(block![1]).not.toContain(name);
79+
});
80+
81+
it('the evaluator still REFUSES an unknown call rather than resolving it to null', () => {
82+
// The mirror only means something while the runtime still throws here.
83+
expect(source).toContain('throw unknownFunctionError(match, trimmed)');
84+
});
85+
86+
it('the filter position still hands an unresolved KNOWN filter token to the engine', () => {
87+
// The layer-one/layer-two split the rule is built on.
88+
expect(source).toContain('isKnownFilterToken');
89+
});
90+
});
91+
92+
describe('dispatch ORDER — the property the negative control depends on', () => {
93+
it('classifies {TODAY() - 45} as a date function, never as a call', () => {
94+
expect(classifyFlowTemplateToken('TODAY() - 45')).toEqual({ kind: 'date-function', name: 'TODAY' });
95+
});
96+
97+
it('classifies TOMORROW() as an unknown function', () => {
98+
expect(classifyFlowTemplateToken('TOMORROW()')).toEqual({ kind: 'unknown-function', name: 'TOMORROW' });
99+
});
100+
101+
it('classifies the open arm as variable-path, never as a finding', () => {
102+
expect(classifyFlowTemplateToken('recordId')).toEqual({ kind: 'variable-path', head: 'recordId' });
103+
expect(classifyFlowTemplateToken('record.id')).toEqual({ kind: 'variable-path', head: 'record' });
104+
});
105+
106+
it('classifies $User.* as user context', () => {
107+
expect(classifyFlowTemplateToken('$User.Id')).toEqual({ kind: 'user-context' });
108+
});
109+
110+
it('classifies a junk shape as unresolvable rather than as a call', () => {
111+
expect(classifyFlowTemplateToken('30 days ago')).toEqual({ kind: 'unresolvable-shape' });
112+
expect(classifyFlowTemplateToken('')).toEqual({ kind: 'unresolvable-shape' });
113+
});
114+
115+
it('never reports a reserved literal in call position', () => {
116+
expect(classifyFlowTemplateToken('null(1)').kind).not.toBe('unknown-function');
117+
});
118+
});

0 commit comments

Comments
 (0)