Skip to content

Commit 993cb89

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-18683-card-comments-page-ladder
2 parents 71e1246 + 7572329 commit 993cb89

15 files changed

Lines changed: 4345 additions & 286 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

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

packages/cli/src/utils/artifact-packages.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,30 @@
2424
* slightly differently is how one entry comes to judge a different set of
2525
* packages than the other while both look right — so the functions moved here
2626
* unchanged and both entries call these.
27+
*
28+
* Since #18677 the module also carries the PASS those two seams exist to feed —
29+
* {@link runPerPackageAuthoringRules} — for the same reason one layer out: the
30+
* `os build` door ran it and the `os validate` door did not, and a second copy
31+
* of the loop is how that asymmetry would come back.
2732
*/
2833

34+
import {
35+
runAuthoringRules,
36+
splitBySeverity,
37+
type AuthoringCommand,
38+
type AuthoringFinding,
39+
} from '@objectstack/lint';
40+
41+
/**
42+
* Identity of one finding, for the per-package de-duplication below.
43+
*
44+
* Moved here from `compile.ts` unchanged (#18677): the two doors must
45+
* de-duplicate identically, or "the set the union could not see" means two
46+
* different things depending on which command the author happened to run.
47+
*/
48+
const findingKey = (f: { rule: string; where: string; path: string; message: string }): string =>
49+
[f.rule, f.where, f.path, f.message].join('\u0000');
50+
2951
/**
3052
* The artifact's package entries, as `{ index, id, body }` (ADR-0130 D4).
3153
*
@@ -107,3 +129,90 @@ export function packageBodyAsStack(
107129
): Record<string, unknown> {
108130
return { ...body, manifest: body, packages: artifactPackageEntries };
109131
}
132+
133+
/**
134+
* The author-time rule table, run ONCE PER PACKAGE and de-duplicated against a
135+
* union run — the pass `os build` has run since #16611 and `os validate` did
136+
* not (#18677).
137+
*
138+
* ## Why it lives here and not in one of the two commands
139+
*
140+
* It is the THIRD entry to owe the shape the module header describes, and the
141+
* header's fence binds it: the only ways to reach `compile.ts`' loop from
142+
* `validate.ts` are to import one oclif command from another — pulling the
143+
* lowerer and the docs sweep into every `os validate` invocation — or to write
144+
* a second copy. ⛔ The second copy is what must not happen, and here it would
145+
* not be the `{index,id,body}` reading that drifted but the VERDICT: two loops
146+
* choosing their own de-duplication key, their own severity split or their own
147+
* `where` prefix is how one door comes to report a different set from the other
148+
* while both look right. That is the defect #18677 is, one layer down.
149+
*
150+
* ## What the asymmetry was, measured
151+
*
152+
* `os build` ran this pass; `os validate` ran the union fold and stopped,
153+
* importing neither seam above. `compile.ts`' own comment says what survives
154+
* the de-duplication is "exactly the set the union could not see" ⇒ that whole
155+
* set was findings `os build` reported and `os validate` structurally could
156+
* not. The direction is FALSE-CLEAN, and on the command an author runs BEFORE
157+
* shipping — the same direction and the same door #17069 fixed one layer up,
158+
* which is why `authoringRuleUnionStack` being in both commands did not settle
159+
* it. `packages/cli/test/build-json-advisory-parity.e2e.test.ts` already
160+
* asserted "nothing rides in build's `warnings` that validate does not also
161+
* report"; it stayed green because its fixture declares no `packages[]` at all,
162+
* so the pass it would have caught never ran there.
163+
*
164+
* ## The de-duplication key is the caller's, and it is not perfect
165+
*
166+
* `findingKey` below is `compile.ts`' key, moved unchanged: `rule`, `where`,
167+
* `path`, `message`. ⚠️ `path` is POSITIONAL, and a collection index in one
168+
* package's own body is not the index the flattened top level gives the same
169+
* item — so a finding on any package whose local index differs from its
170+
* flattened one survives the filter as an ECHO of a union finding rather than
171+
* as something the union could not see. Measured on `examples/app-multi-package`
172+
* (2 packages, `crm_account.industry`): 1 survivor, 0 of them new. ⛔ Not fixed
173+
* here — changing the key changes what `os build` reports, which is a separate
174+
* decision from making the two doors agree, and agreeing IMPERFECTLY at one
175+
* seam is strictly better than disagreeing at two. When it is fixed it is
176+
* fixed once, for both commands, which is the property this module buys.
177+
*/
178+
export function runPerPackageAuthoringRules(run: {
179+
/** Which door is asking — the same string its union run passed. */
180+
command: AuthoringCommand;
181+
/** The PARSED stack, as `artifactPackages` reads it. */
182+
parsed: Record<string, unknown>;
183+
/** The union run's findings, whose keys this pass de-duplicates against. */
184+
unionFindings: readonly AuthoringFinding[];
185+
sduiManifest?: unknown;
186+
loweredHookRefs?: ReadonlySet<string>;
187+
}): {
188+
/** How many package entries were walked — 0 means the pass did not run. */
189+
packageCount: number;
190+
errors: Array<{ package: string } & AuthoringFinding>;
191+
advisories: AuthoringFinding[];
192+
} {
193+
const artifactPackageEntries = run.parsed.packages;
194+
const packageEntries = artifactPackages(run.parsed);
195+
const errors: Array<{ package: string } & AuthoringFinding> = [];
196+
const advisories: AuthoringFinding[] = [];
197+
if (packageEntries.length === 0) return { packageCount: 0, errors, advisories };
198+
199+
const alreadyReported = new Set(run.unionFindings.map(findingKey));
200+
for (const pkg of packageEntries) {
201+
const asStack = packageBodyAsStack(pkg.body, artifactPackageEntries);
202+
const pkgFindings = runAuthoringRules(run.command, {
203+
normalized: asStack,
204+
parsed: asStack,
205+
sduiManifest: run.sduiManifest,
206+
loweredHookRefs: run.loweredHookRefs,
207+
}).filter((f) => !alreadyReported.has(findingKey(f)));
208+
for (const f of pkgFindings) alreadyReported.add(findingKey(f));
209+
const split = splitBySeverity(pkgFindings);
210+
advisories.push(
211+
...split.advisories.map((a) => ({ ...a, where: `package '${pkg.id}' — ${a.where}` })),
212+
);
213+
errors.push(
214+
...split.errors.map((e) => ({ ...e, package: pkg.id, where: `package '${pkg.id}' — ${e.where}` })),
215+
);
216+
}
217+
return { packageCount: packageEntries.length, errors, advisories };
218+
}

0 commit comments

Comments
 (0)