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
11 changes: 11 additions & 0 deletions .changeset/spotty-jars-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/cli': patch
---

`os validate` and `os lint` now judge the same stack `os build` judges when a project declares its metadata only in `packages[]`.

A project in the ADR-0130 D4 artifact shape — every definition inside `packages[]`, no collections at the top level — was handed to the author-time rule table as an **empty stack** by both commands, so all 44 rules reported nothing and both exited 0 having read none of the project. `os build` folds the packages back in first (`authoringRuleUnionStack`) and refuses the same stack. Two of the three authoring gates were certifying an unread project as clean, and `os validate` is the check an author runs before shipping.

Both commands now hand the rule table the stack that same helper returns — one fold, shared with `os build`, not a second implementation. It is a rule **input** only: neither command's output, `--json` payload nor `os lint`'s metadata score changes, and a stack that still carries its top-level collections is returned by identity, so single-package projects are unaffected by construction.

⚠️ **A project that was silently passing may now fail.** That is the defect surfacing, not a new rule: the finding was always there and `os build` was always reporting it. Run `os build` on the same tree to see the identical diagnostic.
20 changes: 18 additions & 2 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { checkHookBodyLowering } from '../lint/hook-body-lowering.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
import { DEFAULT_METADATA_EVAL_CORPUS } from '../lint/corpus.js';
import {
Expand Down Expand Up @@ -511,10 +512,25 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
// the family stays silent on it and `checkHookBodyLowering` is what
// reports it, so no verdict is ever given about a body that was not
// produced. Nothing here touches what `os build` accepts (#13838).
//
// ── Both tiers are handed the UNION-FOLDED stack (ADR-0130 D4, #17069) ──
// Under option B every definition lives in `packages[]` and the top level
// carries none, so both tiers above were handed an EMPTY stack: the whole
// table reported nothing and `os lint` returned no finding of any severity
// for a project `os build` refuses. `authoringRuleUnionStack` is the one
// helper `compile.ts` calls for its union run — the same fold, not a second
// one — and it fills only the collections the top level does not carry, so
// a stack that still carries them comes back BY IDENTITY and every
// single-package project lints exactly as before.
//
// Scoped to this call, as it is in `compile.ts`: the hand-written checks
// above and `scoreMetadata` (which reaches `lintConfig` through
// `lint/score.js`) keep reading the caller's own stack, so nothing about
// what this function returns for a top-level stack moves.
const { lowered, loweredHookRefs } = lowerCallables(config as Record<string, unknown>);
for (const f of runAuthoringRules('lint', {
normalized: config,
parsed: lowered,
normalized: authoringRuleUnionStack(config as Record<string, unknown>),
parsed: authoringRuleUnionStack(lowered),
sduiManifest: opts.sduiManifest,
// [#16546] Same ref set `os build` computes from the same normalized
// input — what lets `validateReadonlyHookWrites` / `validateHookBodyWrites`
Expand Down
21 changes: 19 additions & 2 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { authoringRuleUnionStack } from '../utils/stack-collections.js';
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
Expand Down Expand Up @@ -300,9 +301,25 @@ export default class Validate extends Command {
// is declared in `lint/authoring-rules.ts`. Do not add a call site here.
const registered = authoringRulesFor('validate');
if (!flags.json) printStep(`Running author-time rules (${registered.length})...`);
// [ADR-0130 D4 / option B, #17069] Judged on the SAME folded stack
// `os build` judges (`compile.ts` step 3b), through the one helper
// both doors call. Under option B every definition lives in
// `packages[]` and the top level carries none, so this run's input
// was an EMPTY stack: `os validate` printed `✓ Validation passed` and
// exited 0 having judged nothing, on a stack `os build` refuses. That
// is the weakest-gate class #4409 was filed for, and the direction it
// arrived in here is the worst one — the fast inner-loop check is
// what an author runs BEFORE shipping, so its clean bill of health is
// the strongest false assurance the three commands can give.
//
// Rule INPUT only, exactly as in `compile.ts`: this command emits no
// artifact at all, and the folded stack reaches neither the metadata
// stats below nor the `--json` payload. A stack that still carries
// its collections is returned by identity, so every single-package
// project is unaffected by construction.
const findings = runAuthoringRules('validate', {
normalized: normalized as Record<string, unknown>,
parsed: result.data as Record<string, unknown>,
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
sduiManifest: resolveSduiManifest(),
// [#16546] Same ref set `os build` / `os lint` compute — keeps this
// door's hook write-set findings at the same `path` as the other two.
Expand Down
174 changes: 174 additions & 0 deletions packages/cli/test/union-fold-command-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #17069 — the three authoring commands judge the SAME STACK, on the ADR-0130
* D4 / option-B shape.
*
* `authoring-rule-command-parity.test.ts` next door proves the three commands
* reach the same verdict about the same rule TABLE. This file proves the other
* half of the same promise, one layer earlier: that they hand that table the
* same INPUT. A command can run every rule in the registry and still certify a
* project clean — if what it hands the registry is an empty stack.
*
* ## The defect this file pins
*
* A project whose definitions live only in `packages[]` — the ADR-0130 D4
* artifact shape, no collections at the top level — was judged by `os validate`
* and `os lint` as if it declared NOTHING. `compile.ts` folds the packages back
* in through `authoringRuleUnionStack` before it runs the table; neither
* sibling command imported that helper, so each ran 44 rules over `{}`.
*
* Measured through the real binaries on this card's repro, before the fix:
*
* os validate EXIT=0 ✓ Validation passed
* os lint EXIT=0 (no finding of any severity)
* os build EXIT=1 object-reference-unknown
*
* ⭐ The dangling `ob_nowhere` is only the PROBE that makes the blindness
* visible. The same silence covered every author-time rule, because the input
* was empty — which is why the fix is the shared fold and not a rule.
*
* ## Why this is the p1 direction of the #4409 class
*
* #4409 was `os build` publishing what the other two refuse. This is the
* mirror, and it is worse: `os validate` is the fast inner-loop check an author
* runs BEFORE shipping, so its clean bill of health on a stack it read nothing
* of is the strongest false assurance the three commands can give.
* `content/docs/deployment/validating-metadata.mdx` states the parity as a
* promise — *"anything that can fail a build fails `os lint` too"* — and on
* this stack shape the promise was false in the loudest direction.
*
* ## Why it is spawned, and why BOTH fixtures are here
*
* Spawned because the exit CODE is the contract a CI pipeline reads, and only a
* real process produces one; `os validate`'s rule run is an expression inside
* the oclif command body, so there is no exported seam a probe could call
* instead (the same reason the option-B acceptance pin could not reach it).
*
* The CLEAN fixture is not decoration. A fold wired in backwards — or a rule
* that fires on any `packages[]` at all — would satisfy the failing case alone.
* The pair asserts what the card actually claims: that the option-B stack is
* READ, not that it is rejected.
*/

import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from './helpers/serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');
const CLI = resolve(HERE, '../bin/run-dev.js');

/** The three commands the #4409 registry holds to one bar. */
const AUTHORING_COMMANDS = ['validate', 'lint', 'build'] as const;

/** The rule the probe trips, and the path it reports it at. */
const RULE = 'object-reference-unknown';
const RULE_PATH = 'objects[0].fields.ghost.reference';

/**
* The card's repro verbatim: no top-level `objects`, one `packages[]` entry
* carrying an object whose `ghost` lookup points at an object that does not
* exist. Every collection this project declares lives inside `packages[]`.
*/
const optionBStack = (reference: string): Record<string, unknown> => ({
manifest: { id: 'com.example.ob', name: 'ob', version: '1.0.0', type: 'app', namespace: 'ob' },
packages: [
{
manifest: {
id: 'com.example.ob',
name: 'ob',
version: '1.0.0',
type: 'app',
namespace: 'ob',
objects: [
{
name: 'ob_order',
label: 'Order',
sharingModel: 'private',
fields: {
number: { type: 'text', label: 'Number' },
ghost: { type: 'lookup', label: 'Ghost', reference },
},
},
],
},
},
],
});

interface Run {
code: number;
output: string;
}

/**
* One authoring command over one option-B project, as a shell sees it.
*
* A plain literal config with no imports, so it resolves with no `node_modules`
* next to it — the `authoring-rule-command-parity.test.ts` pattern.
*/
function runCommand(command: string, stack: Record<string, unknown>): Run {
const dir = mkdtempSync(join(tmpdir(), 'os-union-fold-'));
try {
writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`);
try {
const stdout = execFileSync(process.execPath, [CLI, command], {
cwd: dir,
encoding: 'utf8',
stdio: 'pipe',
// Every spawned child under this directory declares its environment at
// the call site (#11595).
env: childEnv({ NO_COLOR: '1' }),
});
return { code: 0, output: String(stdout) };
} catch (error: any) {
return { code: error.status ?? 1, output: `${error.stdout ?? ''}${error.stderr ?? ''}` };
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

describe('#17069 — os validate and os lint judge the option-B stack, not an empty one', () => {
it.each(AUTHORING_COMMANDS)(
'os %s refuses a packages[]-only project whose lookup target does not exist',
(command) => {
const run = runCommand(command, optionBStack('ob_nowhere'));
expect(
run.code,
`os ${command} exited ${run.code} on a project whose definitions live only in packages[]. ` +
`Before #17069 os validate and os lint both exited 0 here — they handed the author-time ` +
`rule table an EMPTY stack, so all 44 rules reported nothing and the project was certified ` +
`clean without being read. Hand the table authoringRuleUnionStack(...) as compile.ts does.` +
`\n--- output ---\n${run.output}`,
).toBe(1);
expect(run.output, `os ${command} refused the stack without naming the rule`).toContain(RULE);
expect(
run.output,
`os ${command} named ${RULE} at a different path than the other doors — the three commands ` +
`must report one finding one way`,
).toContain(RULE_PATH);
},
180_000,
);

it.each(AUTHORING_COMMANDS)(
'control: os %s passes the identical option-B project once the lookup resolves',
(command) => {
const run = runCommand(command, optionBStack('ob_order'));
expect(
run.code,
`os ${command} exited ${run.code} on a CLEAN option-B project. The fold folds packages[] ` +
`back in as the rule table's INPUT — it does not make a packages[]-only project fail. ` +
`Without this control the case above would pass on a command that refuses every ` +
`multi-package stack.\n--- output ---\n${run.output}`,
).toBe(0);
expect(run.output, `os ${command} reported ${RULE} on a stack whose lookup resolves`).not.toContain(RULE);
},
180_000,
);
});
59 changes: 59 additions & 0 deletions packages/cli/test/validate-build-gate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,72 @@ function gateCallsIn(file: string): Set<string> {
/** Is `name` invoked anywhere in this command's source? */
const calls = (file: string, name: string) => new RegExp(String.raw`\b${name}\s*\(`).test(sourceOf(file));

/**
* The `runAuthoringRules(...)` call in one command's source, from the call
* through to its closing brace — the object literal whose `normalized` and
* `parsed` members ARE the stack the rule table judges.
*/
function ruleTableCallIn(file: string): string {
const src = sourceOf(file);
const at = src.indexOf('runAuthoringRules(');
return at === -1 ? '' : src.slice(at, src.indexOf('})', at));
}

describe('os validate is the read-only superset of os build (#3782, #4409)', () => {
it('both commands run the shared authoring-rule registry', () => {
for (const file of ['compile.ts', 'validate.ts']) {
expect(calls(file, 'runAuthoringRules'), `${file} must run the authoring-rule registry`).toBe(true);
}
});

/**
* The same drift one layer EARLIER than every check in this file: not "does
* this command run the table" but "what stack does it hand the table".
*
* ⭐ [#17069] A project whose definitions live only in `packages[]` — the
* ADR-0130 D4 / option-B shape — carries no collections at the top level.
* `compile.ts` folds them back in with `authoringRuleUnionStack` before it
* runs the table; `validate.ts` and `lint.ts` did not, so each ran all 44
* rules over an EMPTY stack and reported a clean bill of health for a project
* it had read nothing of, at exit 0. That is the #4409 weakest-gate class
* arriving through the INPUT rather than the rule set — and in its worst
* direction, because `os validate` is the check an author runs before
* shipping.
*
* Source-level for the same reason as the gate check above: it fails when a
* door drops the fold, which is the moment it is cheap to fix. The
* behavioural half — the card's own repro through the three real binaries —
* is `test/union-fold-command-parity.test.ts`.
*/
it('all three authoring commands hand the rule table the union-folded stack', () => {
// Positive control FIRST: the helper must still exist and still be the ONE
// fold. Without it, deleting `authoringRuleUnionStack` outright would
// satisfy nothing below — but renaming it would make every assertion here
// fail for the wrong reason, and this line says which.
const helper = readFileSync(join(UTILS_DIR, 'stack-collections.ts'), 'utf8');
expect(
/export function authoringRuleUnionStack\b/.test(helper),
'src/utils/stack-collections.ts must export authoringRuleUnionStack — if it moved, move this ' +
'guard with it. ⛔ Do not answer a red here by writing a second fold.',
).toBe(true);

for (const file of AUTHORING_COMMANDS) {
const call = ruleTableCallIn(file);
expect(call, `${file} must call runAuthoringRules`).not.toBe('');
for (const tier of ['normalized', 'parsed']) {
expect(
new RegExp(String.raw`\b${tier}\s*:\s*authoringRuleUnionStack\s*\(`).test(call),
`${file} hands the authoring-rule table a '${tier}' stack that has NOT been through ` +
`authoringRuleUnionStack(). On an ADR-0130 D4 / option-B project — every definition in ` +
`packages[], none at the top level — that input is an EMPTY stack, so every rule in the ` +
`table reports nothing and this command certifies an unread project as clean at exit 0. ` +
`Wrap it, as compile.ts does. ⛔ Do not reimplement the fold here — import the one in ` +
`src/utils/stack-collections.ts.`,
).toBe(true);
}
}
});

it.each(SHARED_NON_REGISTRY_GATES)('both commands run %s', (gate) => {
// Guard the guard: a gate that has been renamed or deleted must fail here
// rather than pass vacuously on both sides.
Expand Down
Loading