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
60 changes: 60 additions & 0 deletions .changeset/rule-compilability-publish-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/lint": patch
---

fix(lint): reject a validation rule whose regex or JSON Schema does not compile, at authoring time (#4762)

Two of the six object validation-rule types carry a **static artifact** that the
write path hands to a real compiler, inside a `try/catch` that logs and returns
`null`:

- `format` → `new RegExp(rule.regex)` → *"Validation rule '…' has an invalid regex — skipped"*
- `json_schema` → `ajv.compile(rule.schema)` → *"Validation rule '…' has an uncompilable JSON Schema — skipped"*

"Skipped" means the rule is declared, appears in the metadata, appears in every
"what protects this object" listing — and enforces nothing, on every record, for
as long as the metadata is deployed, with a WARN line in a log nobody reads as
the only signal. That is the shape #4649 was filed about one rule type over;
#4761 flipped the CEL predicates to fail closed and deliberately left these two,
because their blast radius differs (see below).

**New gate — `validateRuleCompilability`**, a `gating` entry in
`AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`,
`os build`, `os lint`) with no per-command wiring. Two rule ids:

| id | fires when |
|:---|:---|
| `validation-rule-regex-uncompilable` | a `format` rule's `regex` throws in `new RegExp(...)` |
| `validation-rule-json-schema-uncompilable` | a `json_schema` rule's `schema` throws in `ajv.compile(...)` |

Each finding names the rule, the object and the config path, and carries the
**compiler's own error text verbatim** — an author cannot act on "invalid
regex", but can act on `Invalid regular expression: /([/: Unterminated character
class`. Rules nested in a `conditional`'s `then` / `otherwise` are judged too
(`evaluateRule` recurses into them and reaches the very same checkers), and the
finding names the branch it is in.

**Detection is the real compilers, never a pattern that judges a pattern.** The
regex is compiled with `new RegExp(source)` — the exact call `checkFormat`
makes. The schema is compiled with ajv constructed with the **same options the
runtime's shared instance uses** (`{ allErrors: true, strict: false }`), read
back out of `rule-validator.ts`'s source by a parity test so the day those
options change, this gate is told rather than left quietly disagreeing.
`strict: false` is load-bearing in both directions: a gate running `strict: true`
would reject author-written schemas carrying vendor keywords that the write path
compiles happily — a gate that turns working metadata red gets switched off, and
then protects nothing.

`ajv` is a new dependency of `@objectstack/lint`, loaded **lazily**: only a
stack that actually declares a `json_schema` validation rule pays for it, pinned
by the package's `lazy-deps.test.ts` alongside `typescript` and `sucrase`. The
kernel boot path (`@objectstack/lint/runtime`) never loads it at all.

**The runtime half is deliberately unchanged.** `rule-validator.ts` still fails
open on both, and the `#4649 — unchanged neighbours` pins that record it stand
exactly as they are. A broken regex or schema is *static* — decidable from the
metadata alone, with no record in hand — so the authoring door closes the class
outright without ever bricking a running deployment, whereas rejecting at write
time would reject **every** write touching that field for as long as the bad
metadata is deployed. Whether a runtime backstop is still wanted on top of a
closed authoring door stays open on #4762.
1 change: 1 addition & 0 deletions packages/lint/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@objectstack/formula": "workspace:*",
"@objectstack/sdui-parser": "workspace:*",
"@objectstack/spec": "workspace:*",
"ajv": "^8.20.0",
"sucrase": "^3.35.1",
"typescript": "^6.0.3"
},
Expand Down
22 changes: 22 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import { validateSecurityPosture } from './validate-security-posture.js';
import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js';
import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js';
import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js';
import { validateRuleCompilability } from './validate-rule-compilability.js';
import { validateActionLocations } from './validate-action-locations.js';
import { lintFlowPatterns } from './lint-flow-patterns.js';
import { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down Expand Up @@ -846,6 +847,27 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
+ 'never run at a door should not claim it.',
run: (stack) => validateRlsPredicateEnforceability(stack),
},
// #4762 — the same "declared but enforces nothing" question, for the two
// STATIC artifacts an object validation rule carries. A `format` rule's
// `regex` that `new RegExp(...)` throws on, and a `json_schema` rule's schema
// ajv cannot compile, are both logged and SKIPPED on the write path
// (`rule-validator.ts`), so the rule ships, lists, and protects nothing.
// Neither needs a record to judge, so the authoring door is the right one:
// rejecting a broken regex at RUNTIME instead would reject every write
// touching that field for as long as the metadata is deployed (#4762's own
// analysis — the runtime-backstop question stays open for the maintainer).
// Gating for the `lint-flow-patterns.ts` bar: no reading of the metadata
// behaves as written, because the rule does not run at all.
{
name: 'validateRuleCompilability',
tier: 'gating',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-rule-compilability.ts',
surfaces: CLI_ONLY,
surfaceReason: RUNTIME_OBJECT_WRITES_P2,
run: (stack) => validateRuleCompilability(stack),
},
];

// ─── Runner ─────────────────────────────────────────────────────────
Expand Down
17 changes: 17 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,23 @@ export {
} from './validate-chart-bindings.js';
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';

// #4762 — the two STATIC artifacts an object validation rule carries (a
// `format` rule's `regex`, a `json_schema` rule's `schema`) are fail-OPEN at
// runtime: one that does not compile is logged and skipped, so the rule is
// declared and enforces nothing. Both are decidable without a record, so they
// are rejected at authoring/publish time — with the REAL compilers, and for ajv
// with the runtime's own options.
export {
validateRuleCompilability,
RUNTIME_AJV_OPTIONS,
VALIDATION_RULE_REGEX_UNCOMPILABLE,
VALIDATION_RULE_SCHEMA_UNCOMPILABLE,
} from './validate-rule-compilability.js';
export type {
RuleCompilabilityFinding,
RuleCompilabilitySeverity,
} from './validate-rule-compilability.js';

export { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js';
export type { NavAccessFinding, NavAccessSeverity } from './validate-nav-access.js';

Expand Down
48 changes: 44 additions & 4 deletions packages/lint/src/lazy-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
// by the L2 body write-set gates (validate-hook-body-writes.ts since
// #4271, validate-action-body-writes.ts since #4345);
// - `sucrase` (~1.5 MB), loaded by the react syntax gate
// (validate-react-pages.ts).
// (validate-react-pages.ts);
// - `ajv` (~2.4 MB installed), loaded by the #4762 publish gate
// (validate-rule-compilability.ts) only when a stack declares a
// `json_schema` validation rule — the one rule type whose static artifact
// needs a JSON-Schema compiler to judge.
//
// "A react page" was the whole story when this file was written; it is not any
// more, and the cases below say which trigger they are pinning. Keep them
Expand Down Expand Up @@ -38,7 +42,7 @@ const distDir = join(srcDir, '..', 'dist');

// Deps that must never load at import time. Extend this list when another
// heavy, rarely-hit dependency joins the package.
const LAZY_DEPS = ['typescript', 'sucrase'];
const LAZY_DEPS = ['typescript', 'sucrase', 'ajv'];

const depLoaded = (cache: Record<string, unknown> | undefined, dep: string) =>
Object.keys(cache ?? {}).some((p) => p.split(/[/\\]/).join('/').includes(`/node_modules/${dep}/`));
Expand Down Expand Up @@ -101,6 +105,18 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
const props = mod.validateReactPageProps(${reactStack('function Page(){ return <ObjectForm mode="edit" />; }')});
if (!loaded('typescript')) fail('typescript was not loaded by a react-page props validation');
if (!props.some((f) => f.rule === 'react-prop-missing-required')) fail('props gate produced no finding');
// #4762 — the JSON-Schema compiler is the third lazy dep. A stack whose
// validation rules are all format rules never pays for it.
const ruleStack = (validation) => ({ objects: [{ name: 'a', fields: { payload: {} }, validations: [validation] }] });
mod.validateRuleCompilability(ruleStack({ type: 'format', name: 'f', field: 'payload', regex: '([', message: 'm' }));
if (loaded('ajv')) fail('the rule-compilability gate must not load ajv to judge a format rule');
const schemaFindings = mod.validateRuleCompilability(
ruleStack({ type: 'json_schema', name: 'j', field: 'payload', schema: { type: 'not-a-type' }, message: 'm' }),
);
if (!loaded('ajv')) fail('ajv was not loaded by a json_schema validation-rule check');
if (!schemaFindings.some((f) => f.rule === 'validation-rule-json-schema-uncompilable')) {
fail('rule-compilability gate produced no finding');
}
console.log('OK');
};
`;
Expand Down Expand Up @@ -132,8 +148,13 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {

it('loads each dep lazily in-process and the gates still work', async () => {
const req = createRequire(import.meta.url);
const { validateReactPages, validateReactPageProps, validateHookBodyWrites, validateActionBodyWrites } =
await import('./index.js');
const {
validateReactPages,
validateReactPageProps,
validateHookBodyWrites,
validateActionBodyWrites,
validateRuleCompilability,
} = await import('./index.js');

// Stacks without a react-source page never touch either dep.
expect(validateReactPages({ pages: [{ name: 'p', kind: 'object' }] })).toEqual([]);
Expand All @@ -157,10 +178,29 @@ describe('lazy dependency loading (kernel boot-path contract)', () => {
actions: [action({ language: 'js', source: 'ctx.input.amout = 1; return { ok: true };' })],
}),
).toEqual([]);
// #4762 — nor does judging a `format` rule's regex: that needs only the
// JavaScript engine's own `new RegExp`, so a stack with no `json_schema`
// validation rule never loads a JSON-Schema compiler.
const ruleStack = (validation: unknown) => ({
objects: [{ name: 'a', fields: { payload: {} }, validations: [validation] }],
});
expect(
validateRuleCompilability(
ruleStack({ type: 'format', name: 'f', field: 'payload', regex: '([', message: 'm' }),
).map((f) => f.rule),
).toEqual(['validation-rule-regex-uncompilable']);

for (const dep of LAZY_DEPS) {
expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source or L2-body validation`).toBe(false);
}

// The first `json_schema` rule pays for ajv — and the gate still works.
const schemaFindings = validateRuleCompilability(
ruleStack({ type: 'json_schema', name: 'j', field: 'payload', schema: { type: 'not-a-type' }, message: 'm' }),
);
expect(depLoaded(req.cache, 'ajv')).toBe(true);
expect(schemaFindings.map((f) => f.rule)).toEqual(['validation-rule-json-schema-uncompilable']);

// The first react page with source pays the cost of exactly its own gate's
// dep — and the gates still work.
const syntax = validateReactPages({
Expand Down
11 changes: 8 additions & 3 deletions packages/lint/src/runtime-lazy-deps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//
// The kernel boot-path contract for `@objectstack/lint/runtime` (#4463).
//
// `lazy-deps.test.ts` next door pins that IMPORTING the package loads neither
// `typescript` (~9 MB) nor `sucrase`. That was enough while the only consumer
// `lazy-deps.test.ts` next door pins that IMPORTING the package loads none of
// `typescript` (~9 MB), `sucrase` or `ajv`. That was enough while the only consumer
// was the CLI, which may load anything. #4463 gave the package a consumer on
// the kernel boot path — `@objectstack/metadata-protocol`, reached by every
// runtime metadata write — and that consumer needs the stronger claim:
Expand All @@ -30,7 +30,12 @@ import { describe, it, expect } from 'vitest';
const srcDir = dirname(fileURLToPath(import.meta.url));
const distDir = join(srcDir, '..', 'dist');

const LAZY_DEPS = ['typescript', 'sucrase'];
// `ajv` joined the list in #4762: the rule-compilability gate needs a real
// JSON-Schema compiler to judge a `json_schema` validation rule, and that rule
// is CLI-only (`surfaceReason: RUNTIME_OBJECT_WRITES_P2`). So the boot path must
// not pay for it — not at import, and not while gating. Should that rule ever be
// widened to `runtime-publish`, this assertion is what says so out loud.
const LAZY_DEPS = ['typescript', 'sucrase', 'ajv'];

const depLoaded = (cache: Record<string, unknown> | undefined, dep: string) =>
Object.keys(cache ?? {}).some((p) => p.split(/[/\\]/).join('/').includes(`/node_modules/${dep}/`));
Expand Down
Loading
Loading