Skip to content
15 changes: 15 additions & 0 deletions .changeset/20113-jsx-gate-parse-level-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/cli': patch
---

fix(cli): `os validate` / `os build` / `os lint` now say when the JSX page gate ran at parse level only, and refuse a project `sdui.manifest.json` they cannot use (#20113)

Clause-②: no

The gate that checks `kind: 'html'` pages (and the deprecated `kind: 'jsx'`) validates components and props only when an SDUI component manifest resolves: the project's `sdui.manifest.json` in the working directory, then the copy inside `@objectstack/console`. With neither, it falls back to parse-level checking (syntax, tag matching, forbidden constructs). All three commands used to do that silently and report success, so "passed" read as "components and props checked" when they were not.

A page "to check" below means one the gate actually judges, wherever it is declared: at the top level of the stack, or inside a `packages[]` entry. The per-package pass judges those even when the top level carries its own `pages` key.

- **No manifest, `kind: 'html'` pages to check: a notice, exit status unchanged.** Each command reports one notice, tagged `sdui/jsx-parse-level-only`. It gives the number of distinct pages checked at parse level only, top-level and package-carried alike, and names every place a manifest was looked for. `os validate` and `os build` print it as an info line plus a hint line; `os lint` lists it under Suggestions. `--json` carries it in the channel each command already publishes. It is an `info` record (the author-time finding shape) at the end of `warnings` on `os validate` / `os build`, and a `suggestion` in `os lint`'s `issues`, which also moves that run's `total` and `suggestions` counts by one. No top-level key is added. `--strict` does not promote it on any command, so a project without its own manifest exits exactly as before.
- **A project `sdui.manifest.json` that exists but cannot be used: refused (exit 1).** This applies when there is a `kind: 'html'` page to check, top-level or package-carried, and the file cannot be read, is not valid JSON, or is not a JSON object with a `components` map. The file and the reason are named on stderr and in the `--json` envelope's `error`. Before, invalid JSON, `null` and an unreadable file were ignored in silence (parse-level checking, exit 0), while `{}` or an array crashed the run with `Cannot convert undefined or null to object` (exit 1). **Fix:** correct the file, or remove it to check those pages at parse level only.
- **Unchanged:** a project with no `kind: 'html'` page to check, at the top level or in any package, says nothing and refuses nothing, whatever its manifest looks like. A resolvable manifest arms full validation exactly as before. `os init`'s scaffold check reads the manifest as it did.
27 changes: 24 additions & 3 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js';
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { attachPackageDocs, collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
Expand Down Expand Up @@ -164,13 +164,22 @@ export default class Compile extends Command {
// `severity: 'warning'` is declared at the producer — this reports, it
// never refuses.
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
// [#20113] The JSX gate's parse-level notice — empty, or ONE `info` record
// saying `kind:'html'` pages were checked at parse level only because no
// SDUI component manifest resolved. A member of `warningsSoFar()`, ⛔ not a
// payload key, for the three reasons the two lists above record: the
// undeclared-key pins refuse a new top-level key by name, `os validate`
// computes the identical record so the residue pin holds, and it reports
// without ever refusing. Appended LAST, in `os validate`'s order.
let jsxGateNotices: ReturnType<typeof resolveJsxGateManifest>['notices'] = [];
const warningsSoFar = () => [
...ruleAdvisories,
...docWarnings,
...unknownKeyWarnings,
...capProviderWarnings,
...navGroupWarnings,
...permissionSetCollisionWarnings,
...jsxGateNotices,
];
// [#18780] ONE rendering of the author-time advisory block, from the
// COMPLETE list — hoisted here for the same reason the lists above are.
Expand Down Expand Up @@ -396,10 +405,22 @@ export default class Compile extends Command {
// `packages[]`. The union run is the only one of the two that can see
// a finding spanning packages, which is exactly what an empty input
// silently stops reporting.
//
// [#20113] The SDUI manifest is resolved ONCE, and both rule runs are
// handed the same answer; its pages are counted over the union fold
// AND each package body the pass below judges — see `validate.ts`
// step 3 for the three outcomes. Printed here, at the
// gate's step, so the parse-level notice shows on the failing paths
// too; it never changes this command's exit status. A project
// manifest that exists but cannot be used is refused instead
// (already reported on stderr; the catch-all exits 1).
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const findings = runAuthoringRules('build', {
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
sduiManifest: resolveSduiManifest(),
sduiManifest: jsxGate.sduiManifest,
// [#16546] Ref strings, not hook indices — survive the union fold and
// the per-package re-slice below unchanged (see `LoweringResult.
// loweredHookRefs`'s header for why an index would not).
Expand Down Expand Up @@ -497,7 +518,7 @@ export default class Compile extends Command {
command: 'build',
parsed: result.data as Record<string, unknown>,
unionFindings: findings,
sduiManifest: resolveSduiManifest(),
sduiManifest: jsxGate.sduiManifest,
loweredHookRefs: lowering.loweredHookRefs,
});
const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> =
Expand Down
41 changes: 32 additions & 9 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { GLOBAL_ACTION_OBJECT_KEY } from '@objectstack/objectql';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js';
import { lintDataModel, runAuthoringRules } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { resolveJsxGateManifest } from '../utils/sdui-manifest.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { checkHookBodyLowering } from '../lint/hook-body-lowering.js';
Expand Down Expand Up @@ -748,18 +748,28 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
// ⛔ ONE mapping for both halves. A second copy of this expression is how one
// list comes to render `info` as `suggestion` and the other does not.
for (const f of [...unionFindings, ...perPackageFindings]) {
issues.push({
severity: f.severity === 'info' ? 'suggestion' : f.severity,
rule: f.rule,
message: `${f.where}: ${f.message}`,
path: f.path,
fix: f.hint,
});
issues.push(authoringFindingToLintIssue(f));
}

return issues;
}

/**
* The ONE mapping from an author-time finding to an `os lint` issue — `info`
* grades as `suggestion`, the location leads the message, the hint is the
* fix. Read by both halves of `lintConfig`'s rule run and by the command's
* JSX-gate notice (#20113), so the three cannot render one severity two ways.
*/
function authoringFindingToLintIssue(f: ReturnType<typeof runAuthoringRules>[number]): LintIssue {
return {
severity: f.severity === 'info' ? 'suggestion' : f.severity,
rule: f.rule,
message: `${f.where}: ${f.message}`,
path: f.path,
fix: f.hint,
};
}

// ─── Command ────────────────────────────────────────────────────────

export default class Lint extends Command {
Expand Down Expand Up @@ -947,7 +957,20 @@ export default class Lint extends Command {
printWarning(formatConversionNotice(n));
}
}
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
// [#20113] The SDUI manifest, resolved once; its pages are counted over
// the fold `lintConfig` judges AND each package body its per-package
// pass judges — see `validate.ts` step 3 for the three outcomes. With
// `kind:'html'` pages to check and no manifest, the JSX gate runs at
// parse level and the run SAYS so: one `info` finding, graded
// `suggestion` by the one mapping, so it rides `issues` and the counts
// and never fails a run — `--strict` promotes warnings, not suggestions.
// Pushed HERE rather than inside `lintConfig`, because `scoreMetadata`
// reaches that function without a manifest and must not score a notice
// about the filesystem. A project manifest that exists but cannot be
// used is refused instead (already reported on stderr; exit 1).
const jsxGate = resolveJsxGateManifest(normalized as Record<string, unknown>);
const issues = lintConfig(normalized, { sduiManifest: jsxGate.sduiManifest });
issues.push(...jsxGate.notices.map(authoringFindingToLintIssue));

// ── Package docs (ADR-0046) ── collected src/docs/*.md + inline docs:
// flatness, namespace-prefixed names, MDX/image ban, link resolution.
Expand Down
41 changes: 38 additions & 3 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js';
// `os compile` — ⛔ the loop is not re-written here; see that module's header.
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import {
Expand Down Expand Up @@ -151,6 +151,14 @@ export default class Validate extends Command {
// reason the line above it gives: the #11727 residue pin asserts that
// nothing rides in build's `warnings` that validate does not also report.
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
// [#20113] The JSX gate's parse-level notice: empty, or ONE `info` record
// saying `kind:'html'` pages were checked at parse level only because no
// SDUI component manifest resolved, and where one was looked for. Same
// class as the two members above it — reports, never refuses, and is NOT
// in the `warnings` list `--strict` reads (see step 3), so the exit status
// of a project without a manifest is unchanged on both faces. `os compile`
// computes the identical record, so the residue pin keeps holding.
let jsxGateNotices: ReturnType<typeof resolveJsxGateManifest>['notices'] = [];
const warningsSoFar = () => [
...ruleAdvisories,
...docWarnings,
Expand All @@ -167,6 +175,8 @@ export default class Validate extends Command {
...navGroupWarnings,
// [#18024] APPENDED for the same reason, one member later.
...permissionSetCollisionWarnings,
// [#20113] APPENDED for the same reason, one member later again.
...jsxGateNotices,
];
// [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
// and under the SAME ruling as the five lists above — one field over. The
Expand Down Expand Up @@ -346,10 +356,28 @@ export default class Validate extends Command {
// 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.
//
// [#20113] The SDUI manifest is resolved ONCE, and both rule runs below
// are handed the same answer. Its pages are counted over EVERY stack
// the JSX gate is handed — the union fold AND each package body the
// per-package pass judges — so html pages carried only in `packages[]`
// count beside a top-level `pages` key the fold keeps.
// With `kind:'html'` pages to check and no manifest, the gate runs at
// parse level and SAYS so — here, at its step, so the line shows on
// the failing paths below too, and in `warningsSoFar()` for `--json`.
// It stays out of the `warnings` list `--strict` reads, exactly like
// the navigation and permission-set advisories: a project with no
// manifest of its own has no remedy but to author one, so promoting
// this to a failure under `--strict` would break every such project.
// A project manifest that exists but cannot be used is REFUSED
// instead (thrown, already reported on stderr; the catch-all exits 1).
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
jsxGateNotices = [...jsxGate.notices];
if (!flags.json) printJsxGateNotices(jsxGateNotices);
const findings = runAuthoringRules('validate', {
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
sduiManifest: resolveSduiManifest(),
sduiManifest: jsxGate.sduiManifest,
// [#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.
loweredHookRefs: lowering.loweredHookRefs,
Expand Down Expand Up @@ -433,7 +461,7 @@ export default class Validate extends Command {
command: 'validate',
parsed: result.data as Record<string, unknown>,
unionFindings: findings,
sduiManifest: resolveSduiManifest(),
sduiManifest: jsxGate.sduiManifest,
// [#16546] The same ref set the union run above was handed, so a
// per-package hook write-set finding reports at the same `path` the
// other two doors report it at.
Expand Down Expand Up @@ -723,6 +751,13 @@ export default class Validate extends Command {
// same divergence one collection narrower. `protocolVersionGap` stays
// out on both faces; it is never gated by `--strict` (see below).
//
// [#20113] The difference runs the other way too, and that half is
// deliberate: the navigation-contribution and permission-set
// advisories (#14553, #18024) and the JSX gate's parse-level notice
// ride the payload's `warnings` but not this list, so none of them
// is gated by `--strict` on either face — each has its own text
// block rather than a `⚠` line in the one below.
//
// `valid: true` beside a 1 is not a contradiction, it is the text
// face verbatim: that path prints "Validation passed" and THEN fails
// for strict. The stack IS schema-valid; `--strict` is what promotes
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/utils/scaffold-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,17 @@ export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport {
};
}

// [#20113] The resolver now says WHY it has no manifest. This door keeps its
// old reading — the manifest, or nothing — and neither notices nor refuses:
// it reads the INVOKER's directory, not the scaffold's (see the note on
// `validateScaffold` below), so a verdict about that file would be a verdict
// about somewhere else. The three authoring commands the scaffold meets next
// make that call through `resolveJsxGateManifest`, in the project itself.
const sdui = resolveSduiManifest();
const findings = runAuthoringRules(SCAFFOLD_RULE_COMMAND, {
normalized: normalized as Record<string, unknown>,
parsed: result.data as Record<string, unknown>,
sduiManifest: resolveSduiManifest(),
sduiManifest: sdui.status === 'resolved' ? sdui.manifest : undefined,
// [#16546] Same ref set the other three doors compute — keeps this
// door's hook write-set findings at the same `path` as they are.
loweredHookRefs: lowering.loweredHookRefs,
Expand Down
Loading
Loading