Skip to content

Commit fd887ab

Browse files
committed
Merge origin/main into claude/issue-18776-client-sdk-install-example
Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude <noreply@anthropic.com>
2 parents 50dfb28 + 9846f27 commit fd887ab

17 files changed

Lines changed: 4464 additions & 287 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
`os validate` runs the per-package author-time rule pass `os build` already ran — the false-clean residue #17069 left one layer down.
6+
7+
`os build` runs the artifact's authoring rules **twice**: once over the union-folded stack, then a second `runAuthoringRules('build', …)` pass over each `artifactPackages(…)` entry with `packageBodyAsStack(…)` as resolution context, de-duplicated against the union run. `os validate` ran the union pass and stopped — it imported neither seam. By `compile.ts`' own description the survivors of that second pass are "exactly the set the union could not see", so that whole set was findings `os build` reported and `os validate` **structurally could not**. The direction is false-clean, and on the worse door: the fast pre-flight is what an author runs *before* shipping, so its clean bill of health is the strongest false assurance the three commands can give.
8+
9+
Measured on `origin/main` 09e16a574 over `examples/app-multi-package`, both commands exiting 0:
10+
11+
```
12+
os build --json warnings: 4 <- 3 union + 1 per-package survivor
13+
os validate --json warnings: 3 <- the survivor is the defect
14+
```
15+
16+
After: both report 4, the same set, in the same order.
17+
18+
**The loop is now one seam, not two copies.** `runPerPackageAuthoringRules` lives beside `artifactPackages` / `packageBodyAsStack` in `utils/artifact-packages.ts`, whose header already forbids a second copy of that shape by name. What would have drifted between two hand-written loops is not the package reading but the **verdict** — the de-duplication key, the severity split, the `where` prefix. `os build`'s observable output is unchanged (text face byte-identical modulo timings; `--json` payload identical).
19+
20+
**Severity mapping is `os build`'s, unchanged.** A per-package `error` refuses (exit 1); an advisory joins `warnings`. So `os validate` is narrowed only to the bar the command that *ships* already holds: every input it can now refuse is one `os build` already refuses, which means **nothing that builds today stops validating**. No newly-refused input could be exhibited on any fixture — across the repo's own two-package example and three constructed variants the observable change is advisory-only, because `packageBodyAsStack` hands each package the artifact's whole `packages[]` as resolution context and the reference-integrity suite resolves object names through it. Graded `minor` rather than `patch` for the new observable step line, the new advisories and the newly reachable non-zero exit; ⛔ **not** declared breaking, because the narrowing could not be exhibited and is bounded by an existing gate.
21+
22+
Unchanged and out of scope: the ADR-0130 D4 union fold (#17069, fixed — `authoringRuleUnionStack` is in both commands), `--json` rendering (#11727), and disagreements *within* the per-package pass's verdicts (#18204). `os lint` still runs the union pass alone; its `artifactPackages` / `packageBodyAsStack` imports serve its own intra-package duplicate-name advisory, not the shared table.

‎.changeset/spooky-poems-repeat.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Say it out loud when a `.refine()` never reaches the published JSON Schema.
6+
7+
`z.toJSONSchema()` has no arm for a `custom` check, so every rule written as a
8+
`.refine()` / `.superRefine()` is enforced by the runtime and absent from the
9+
`json-schema/` tree that ships inside this package — a published file that is
10+
WIDER than the Zod type it was generated from, in the direction where an
11+
author's (or an AI's) validator says yes and the platform then says no. Measured
12+
on zod 4.4.3: 688 refinement sites across 240 published schemas, none of which
13+
projected anything.
14+
15+
Nothing about what the schemas accept changes. Each affected file now carries an
16+
`x-dropped-refinements` annotation naming the paths whose rules it does not
17+
state — `x-` keywords are ignored by every validator, so the accepted document
18+
set is byte-for-byte what it was — and the generator reports the population on
19+
every run and refuses to grow it silently
20+
(`packages/spec/dropped-refinements.baseline.json`).
21+
22+
Clause-②: no

‎examples/app-showcase/src/ui/views/task.view.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,33 @@ export const TaskViews = defineView({
228228
{ field: 'estimate_hours' },
229229
{ field: 'due_date' },
230230
],
231-
rowColor: { field: 'priority' },
231+
// Row colouring by priority. The `colors` map is what does the work:
232+
// `rowColor: { field }` alone parses and publishes clean and colours
233+
// nothing (author-time diagnostic `view/row-color-without-colors`).
234+
//
235+
// The values are COLOUR NAMES, not hex. objectui `plugin-grid`'s
236+
// `useRowColor` resolves each authored value through its own
237+
// `COLOR_TO_CLASS` vocabulary to a literal Tailwind class
238+
// (`red` -> `bg-red-100`), hands a `bg-*` value through untouched, and
239+
// returns `undefined` for everything else — Tailwind v4 has no runtime,
240+
// so a fabricated class string would name no rule in the compiled
241+
// stylesheet. A hex here would clear the resolver's `!config.colors`
242+
// guard, silence the diagnostic above, and still colour no row.
243+
//
244+
// Each name is the entry of that vocabulary nearest to the colour
245+
// `priority` already declares on the option itself (`task.object.ts`):
246+
// #94A3B8 slate, #3B82F6 blue, #F59E0B amber, #EF4444 red. The renderer
247+
// paints the `-100` tint of the name, so the row background stays light
248+
// enough to read the row's own text on.
249+
rowColor: {
250+
field: 'priority',
251+
colors: {
252+
low: 'slate',
253+
medium: 'blue',
254+
high: 'amber',
255+
urgent: 'red',
256+
},
257+
},
232258
// List-level inline edit — cells become editable in place, with a
233259
// per-row edit affordance and a save-all/cancel-all toolbar (view-level
234260
// master switch; distinct from the master-detail `inlineEdit` on fields).

‎examples/app-showcase/test/inert-wirings.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { stripComments } from '../../../scripts/js-comment-mask.mjs';
1414
import stack from '../objectstack.config.js';
1515
import { PLATFORM_CAPABILITY_NAMES } from '@objectstack/spec/security';
1616
import { FILE_REFERENCE_TYPES, valueSchemaFor } from '@objectstack/spec/data';
17+
import { VIEW_ROW_COLOR_WITHOUT_COLORS } from '@objectstack/spec/kernel';
18+
// The SHIPPED ADR-0078 completeness gate, never a re-derivation of it —
19+
// `vitest.config.ts` aliases `@objectstack/lint` to its `src/`, so the verdict
20+
// is about the rule in this checkout rather than about `packages/lint/dist`.
21+
import { validateFunctionalCompleteness } from '@objectstack/lint';
1722
// The job handler's argument type, from the package that BUILDS it
1823
// (`AppPlugin`) — not a local re-description of it. A fake context typed to a
1924
// hand-written approximation is how a double drifts looser than the contract it
@@ -741,3 +746,90 @@ describe('sharing rules target an object under record-sharing enforcement (#9237
741746
).toEqual([]);
742747
});
743748
});
749+
750+
/**
751+
* #15100 — the task Grid view's `rowColor` named a field and declared no
752+
* `colors` map, so objectui `plugin-grid`'s `useRowColor` returned before it
753+
* read a record and every row kept the default background, while the metadata
754+
* parsed, validated and published clean. The same class as every guard above:
755+
* a declaration the platform accepts at authoring time and then does not
756+
* honour, announced only by an advisory line nobody reads.
757+
*
758+
* Two arms, because the wiring has two independent ways to be inert and only
759+
* the first has a shipped rule:
760+
*
761+
* ① NO MAP — the author-time diagnostic `view/row-color-without-colors`
762+
* names it. WARNING severity, so `os validate` prints it and still exits
763+
* 0; a pin asserting "validate succeeds" would have passed on the broken
764+
* tree, so this asserts on the FINDINGS.
765+
*
766+
* ② A MAP THE RENDERER CANNOT RESOLVE — invisible to ①. `colorToClass`
767+
* hands a value that already starts with `bg-` through untouched,
768+
* otherwise looks it up in its own `COLOR_TO_CLASS` vocabulary of colour
769+
* names, and returns `undefined` for anything else; it never fabricates a
770+
* class string, because Tailwind v4 has no runtime and only complete class
771+
* literals present in scanned source reach the compiled stylesheet. So a
772+
* hex — the spelling the `priority` field uses for its own option colours,
773+
* and the obvious thing to copy — clears the `!config.colors` guard,
774+
* silences ①, and still colours no row.
775+
*/
776+
describe("the task Grid's rowColor is wired all the way to a colour (#15100)", () => {
777+
it('reports no `view/row-color-without-colors` finding anywhere in this stack', () => {
778+
const offenders = validateFunctionalCompleteness(stack)
779+
.filter((f) => f.rule === VIEW_ROW_COLOR_WITHOUT_COLORS)
780+
.map((f) => `${f.where} (${f.path})`);
781+
expect(
782+
offenders,
783+
`\`rowColor\` block(s) with no \`colors\` map — the row-className resolver `
784+
+ `returns before it reads a record: ${offenders.join(', ')}`,
785+
).toEqual([]);
786+
});
787+
788+
it('spells every authored colour as a value the renderer can resolve', () => {
789+
// A colour NAME (`red`) or a complete Tailwind class (`bg-red-200`). The
790+
// vocabulary itself is not transcribed here: a hand-copy of another repo's
791+
// 23-entry map is a second opinion that drifts, and the shape rule is what
792+
// separates a resolvable value from a hex.
793+
const RESOLVABLE = /^(?:[a-z]+|bg-[a-z0-9/[\]().,%#-]+)$/;
794+
const offenders: string[] = [];
795+
for (const { path, block } of rowColorBlocks(stack)) {
796+
for (const [value, colour] of Object.entries(block.colors ?? {})) {
797+
if (typeof colour !== 'string' || !RESOLVABLE.test(colour)) {
798+
offenders.push(`${path}.colors.${value} = ${JSON.stringify(colour)}`);
799+
}
800+
}
801+
}
802+
expect(
803+
offenders,
804+
`authored row colour(s) \`colorToClass\` resolves to \`undefined\` — the block passes `
805+
+ `the completeness rule above and still colours no row: ${offenders.join(', ')}`,
806+
).toEqual([]);
807+
});
808+
});
809+
810+
/** Every `rowColor` block anywhere in this app's stack, with where it sits. */
811+
function rowColorBlocks(
812+
root: unknown,
813+
): Array<{ path: string; block: { colors?: Record<string, unknown> } }> {
814+
const out: Array<{ path: string; block: { colors?: Record<string, unknown> } }> = [];
815+
const seen = new WeakSet<object>();
816+
const walk = (node: unknown, path: string): void => {
817+
if (!node || typeof node !== 'object') return;
818+
if (seen.has(node)) return;
819+
seen.add(node);
820+
if (Array.isArray(node)) {
821+
node.forEach((child, i) => walk(child, `${path}[${i}]`));
822+
return;
823+
}
824+
for (const [key, child] of Object.entries(node as Record<string, unknown>)) {
825+
// The BOOLEAN toolbar toggle `userActions.rowColor` shares this key name
826+
// and is a different thing entirely — only an object is the config.
827+
if (key === 'rowColor' && child && typeof child === 'object' && !Array.isArray(child)) {
828+
out.push({ path: `${path}.rowColor`, block: child as { colors?: Record<string, unknown> } });
829+
}
830+
walk(child, `${path}.${key}`);
831+
}
832+
};
833+
walk(root, '$');
834+
return out;
835+
}

‎packages/cli/src/commands/compile.ts‎

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1717
import { lowerCallables } from '../utils/lower-callables.js';
1818
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
19-
import { artifactPackages, packageBodyAsStack } from '../utils/artifact-packages.js';
19+
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
2020
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2121
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
2222
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
@@ -59,10 +59,6 @@ import {
5959
} from '../utils/permission-set-name-collisions.js';
6060
import type { PermissionSetNameCollisionDiagnostic } from '@objectstack/plugin-security';
6161

62-
/** Identity of one finding, for the per-package de-duplication below. */
63-
const findingKey = (f: { rule: string; where: string; path: string; message: string }): string =>
64-
`${f.rule}\u0000${f.where}\u0000${f.path}\u0000${f.message}`;
65-
6662
export default class Compile extends Command {
6763
static override description = 'Compile ObjectStack configuration to JSON artifact';
6864

@@ -425,32 +421,35 @@ export default class Compile extends Command {
425421
// is the one `artifactPackages` above walked, off the same parsed
426422
// stack, so the context a package resolves against is exactly the set
427423
// of packages this artifact will register (ADR-0130 D4/D5).
428-
const artifactPackageEntries = (result.data as Record<string, unknown>).packages;
424+
//
425+
// ⛔ [#18677] The LOOP itself is not written here either — it is
426+
// `runPerPackageAuthoringRules`, beside the two seams it reads, for
427+
// the reason that module's header already gives about them: the
428+
// `os validate` door owes the identical pass, and the thing that
429+
// would have drifted between two hand-written copies is the VERDICT
430+
// (the de-duplication key, the severity split, the `where` prefix),
431+
// not the package reading. Every observable of this step — the step
432+
// line, the advisory order, the error sentence, the `--json` envelope
433+
// — is unchanged; only the loop moved.
434+
//
435+
// The count is read for the step LINE before the pass runs, so the
436+
// line still precedes the work it announces on every path — including
437+
// a rule that throws inside it.
429438
const packageEntries = artifactPackages(result.data as Record<string, unknown>);
430439
if (packageEntries.length > 0) {
431440
if (!flags.json) {
432441
printStep(`Running author-time rules per package (${packageEntries.length})...`);
433442
}
434-
const alreadyReported = new Set(findings.map(findingKey));
435-
const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> = [];
436-
for (const pkg of packageEntries) {
437-
const asStack = packageBodyAsStack(pkg.body, artifactPackageEntries);
438-
const pkgFindings = runAuthoringRules('build', {
439-
normalized: asStack,
440-
parsed: asStack,
441-
sduiManifest: resolveSduiManifest(),
442-
loweredHookRefs: lowering.loweredHookRefs,
443-
}).filter((f) => !alreadyReported.has(findingKey(f)));
444-
for (const f of pkgFindings) alreadyReported.add(findingKey(f));
445-
const split = splitBySeverity(pkgFindings);
446-
ruleAdvisories = [
447-
...ruleAdvisories,
448-
...split.advisories.map((a) => ({ ...a, where: `package '${pkg.id}' — ${a.where}` })),
449-
];
450-
perPackageErrors.push(
451-
...split.errors.map((e) => ({ ...e, package: pkg.id, where: `package '${pkg.id}' — ${e.where}` })),
452-
);
453-
}
443+
const perPackage = runPerPackageAuthoringRules({
444+
command: 'build',
445+
parsed: result.data as Record<string, unknown>,
446+
unionFindings: findings,
447+
sduiManifest: resolveSduiManifest(),
448+
loweredHookRefs: lowering.loweredHookRefs,
449+
});
450+
const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> =
451+
perPackage.errors;
452+
ruleAdvisories = [...ruleAdvisories, ...perPackage.advisories];
454453
if (perPackageErrors.length > 0) {
455454
if (flags.json) {
456455
await emitJson(

‎packages/cli/src/commands/validate.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
1616
import { lowerCallables } from '../utils/lower-callables.js';
1717
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
18+
// [#18677] The per-package half of the author-time rule run, shared with
19+
// `os compile` — ⛔ the loop is not re-written here; see that module's header.
20+
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
1821
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
1922
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
2023
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
@@ -382,6 +385,70 @@ export default class Validate extends Command {
382385
this.exit(1);
383386
}
384387

388+
// 3a-ii. [ADR-0130 D4, #18677] The SAME rule table, once per PACKAGE —
389+
// the second half of the run above, and the half this door ran
390+
// without.
391+
//
392+
// `os build` has run it since #16611; `os validate` ran the union
393+
// fold and stopped, importing neither `artifactPackages` nor
394+
// `packageBodyAsStack`. `compile.ts` step 3b-ii says what survives
395+
// the de-duplication is "exactly the set the union could not see" ⇒
396+
// that whole set was findings `os build` reported and this command
397+
// structurally could not. Same FALSE-CLEAN direction #17069 fixed one
398+
// layer up, and the worse door for it: the fast inner-loop check is
399+
// what an author runs BEFORE shipping, so its clean bill of health is
400+
// the strongest false assurance the three commands can give.
401+
//
402+
// ⛔ Not a second copy of the loop — `runPerPackageAuthoringRules` is
403+
// the one the build door calls, so the de-duplication key, the
404+
// severity split and the `where` prefix cannot drift between the two
405+
// doors. That drift is the defect this step closes, one layer down.
406+
//
407+
// The SEVERITY MAPPING is `os build`'s, unchanged and deliberately:
408+
// an `error` refuses (exit 1), an advisory joins `ruleAdvisories` and
409+
// rides `warningsSoFar()`. The card asked for the asymmetry, ⛔ not
410+
// for a severity judgement, and a per-package `error` is one
411+
// `os build` ALREADY refuses — so this narrows `os validate` to the
412+
// bar the command that ships already holds, never past it.
413+
//
414+
// Skipped entirely for a stack with no `packages[]`: one package by
415+
// definition, already judged whole by the union run above.
416+
const packageEntries = artifactPackages(result.data as Record<string, unknown>);
417+
if (packageEntries.length > 0) {
418+
if (!flags.json) {
419+
printStep(`Running author-time rules per package (${packageEntries.length})...`);
420+
}
421+
const perPackage = runPerPackageAuthoringRules({
422+
command: 'validate',
423+
parsed: result.data as Record<string, unknown>,
424+
unionFindings: findings,
425+
sduiManifest: resolveSduiManifest(),
426+
// [#16546] The same ref set the union run above was handed, so a
427+
// per-package hook write-set finding reports at the same `path` the
428+
// other two doors report it at.
429+
loweredHookRefs: lowering.loweredHookRefs,
430+
});
431+
ruleAdvisories = [...ruleAdvisories, ...perPackage.advisories];
432+
if (perPackage.errors.length > 0) {
433+
if (flags.json) {
434+
await emitJson({
435+
valid: false,
436+
errors: perPackage.errors,
437+
warnings: warningsSoFar(),
438+
conversions: conversionNotices,
439+
duration: timer.elapsed(),
440+
});
441+
this.exit(1);
442+
}
443+
console.log('');
444+
printError(
445+
`Author-time rules failed inside the artifact's packages (${perPackage.errors.length} issue${perPackage.errors.length > 1 ? 's' : ''})`,
446+
);
447+
printAuthoringRuleErrors(perPackage.errors, { remedy: JSON_FULL_LIST_REMEDY });
448+
this.exit(1);
449+
}
450+
}
451+
385452
// 3b. [#3366] Installable-provider preflight — the shift-left of the
386453
// `serve`-time capability check. `os validate` previously only checked
387454
// the `requires` tokens against the vocabulary (ADR-0066), never

0 commit comments

Comments
 (0)