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
25 changes: 25 additions & 0 deletions .changeset/22161-rule-message-one-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
---

feat(lint, cli): one-line author-time rule verdicts, and `os explain <rule-id>` for the reasoning

Clause-②: yes (widening)

- **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads:

```text
⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert)
fix: add it to a view column or a form section, or remove the declaration
rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer
```

The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead.

`security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints.
- **`os explain <rule-id>`.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`.
- **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only.
- **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line.
- **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only.
- **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry.
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ visible: 'status != "resolved"'
formula/validation expression binds the record as the `record` namespace,
not at top level, so `status` resolves to nothing and the expression
silently evaluates to null. Write `record.status`.
source: `status != "resolved"`
— source: `status != "resolved"`
rule: expression-invalid at stack · action 'resolve_ticket' visible
```

Expand Down
4 changes: 2 additions & 2 deletions content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ by tag and through `<Block type="record:…">` alike:
```
✗ Author-time rules failed (1 issue)
• page "showcase_renewals_pipeline" › <RecordHighlights>: <RecordHighlights> renders "record:highlights", which reads its record from the record context a record page mounts — a kind:'react' page never mounts one, so the block renders empty no matter how it is bound (its objectName/recordId are not read by the renderer).
On a react page bind the record yourself: <ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />, or read the record with useAdapter().findOne and lay the strip out in JSX.
fix: On a react page bind the record yourself: <ObjectForm objectName="…" mode="view" recordId={…} fields={[…]} />, or read the record with useAdapter().findOne and lay the strip out in JSX.
rule: react-block-needs-record-context at pages[27].source
```

Expand Down Expand Up @@ -401,7 +401,7 @@ A **missing required binding** fails the build:
```
✗ Author-time rules failed (1 issue)
• page "showcase_renewals_pipeline" › <ObjectChart>: <ObjectChart> is missing the required prop "objectName".
Pass objectName={…}. See the react-tier component contract.
fix: Pass objectName={…}: The object this block binds to (server-connected).
rule: react-prop-missing-required at pages[27].source
```

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ The group has no `install`: ADR-0025 records the code-plugin install half (downl

| Command | Description |
|---------|-------------|
| `os explain [schema]` | Display human-readable explanation of an ObjectStack schema |
| `os explain [schema]` | Display a human-readable explanation of an ObjectStack schema, or of an author-time rule by its id (`os explain field-no-consumers`) |

## Configuration

Expand Down
78 changes: 70 additions & 8 deletions packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import { Args, Command, Flags } from '@oclif/core';
import chalk from 'chalk';
// [#22161] The long-form author-time rule explanations, from the data-only
// entry — `os explain object` does not pay for loading the rule engine.
import { RULE_EXPLANATIONS, explainRule, type RuleExplanation } from '@objectstack/lint/rule-explanations';
import {
printHeader,
printSuccess,
Expand Down Expand Up @@ -406,13 +409,50 @@ export const SCHEMAS: Record<string, SchemaInfo> = {
},
};

// ─── Rule explanations ─────────────────────────────────────────────

/** Word-wrap one paragraph to `width` columns, each line indented by `indent`. */
function wrapParagraph(text: string, width: number, indent: string): string[] {
const lines: string[] = [];
let line = '';
for (const word of text.split(/\s+/).filter(Boolean)) {
if (line && indent.length + line.length + 1 + word.length > width) {
lines.push(indent + line);
line = word;
} else {
line = line ? `${line} ${word}` : word;
}
}
if (line) lines.push(indent + line);
return lines;
}

/**
* [#22161] `os explain <rule-id>`: the reasoning an author-time finding no
* longer carries. A finding prints one verdict and one fix, and its `rule:`
* line points here for the rules `@objectstack/lint` explains.
*/
function printRuleExplanation(explanation: RuleExplanation): void {
printHeader(`Rule: ${explanation.rule}`);
for (const paragraph of explanation.paragraphs) {
console.log('');
for (const line of wrapParagraph(paragraph, 88, ' ')) console.log(line);
}
console.log('');
}

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

export default class Explain extends Command {
static override description = 'Display human-readable explanation of an ObjectStack schema';
static override description =
'Display a human-readable explanation of an ObjectStack schema, or of an author-time rule by its id';

static override args = {
schema: Args.string({ description: 'Schema name (e.g., object, field, view, flow, agent, app)', required: false }),
schema: Args.string({
description:
'Schema name (e.g., object, field, view, flow, agent, app) or author-time rule id (e.g., field-no-consumers)',
required: false,
}),
};

static override flags = {
Expand All @@ -423,14 +463,15 @@ export default class Explain extends Command {
const { args, flags } = await this.parse(Explain);
const schemaName = args.schema;

// ── No argument: list all schemas ──
// ── No argument: list all schemas, then the rules that have an explanation ──
if (!schemaName) {
if (flags.json) {
await emitJson({
schemas: Object.entries(SCHEMAS).map(([key, s]) => ({
name: key,
description: s.description,
})),
rules: Object.values(RULE_EXPLANATIONS).map((r) => ({ id: r.rule, covers: r.covers })),
});
return;
}
Expand All @@ -442,21 +483,42 @@ export default class Explain extends Command {
console.log(` ${chalk.bold.cyan(key.padEnd(12))} ${chalk.dim(desc.length > 70 ? desc.slice(0, 70) + '...' : desc)}`);
}
console.log('');
printInfo(`Run ${chalk.white('objectstack explain <schema>')} for details.`);
printHeader('Rule Explanations');
console.log('');
for (const rule of Object.values(RULE_EXPLANATIONS)) {
console.log(` ${chalk.bold.cyan(rule.rule)} ${chalk.dim(`— ${rule.covers}`)}`);
}
console.log('');
printInfo(`Run ${chalk.white('objectstack explain <schema>')} or ${chalk.white('objectstack explain <rule-id>')} for details.`);
console.log('');
return;
}

// ── Lookup schema ──
const schema = SCHEMAS[schemaName.toLowerCase()];
// ── Lookup: a schema name first (as before), then an author-time rule id ──
// The two sets are disjoint (pinned in test/explain-rule-id.test.ts), so the
// order decides nothing today. Both are OWN-key lookups: a bare index read
// answered `constructor` / `__proto__` with Object's own members and the
// pretty printer then threw `schema.required is not iterable`.
const schemaKey = schemaName.toLowerCase();
const schema = Object.prototype.hasOwnProperty.call(SCHEMAS, schemaKey) ? SCHEMAS[schemaKey] : undefined;
const ruleExplanation = schema ? undefined : explainRule(schemaName);
if (!schema && ruleExplanation) {
if (flags.json) {
await emitJson(ruleExplanation);
return;
}
printRuleExplanation(ruleExplanation);
return;
}
if (!schema) {
if (flags.json) {
await emitJson({ error: `Unknown schema: ${schemaName}` }, 0, { compact: true });
await emitJson({ error: `Unknown schema or rule id: ${schemaName}` }, 0, { compact: true });
process.exit(1);
}
printError(`Unknown schema: "${schemaName}"`);
printError(`Unknown schema or rule id: "${schemaName}"`);
console.log('');
printInfo(`Available schemas: ${Object.keys(SCHEMAS).join(', ')}`);
printInfo(`Rules with an explanation: ${Object.keys(RULE_EXPLANATIONS).join(', ')}`);
console.log('');
process.exit(1);
}
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
isExitSignal,
errorCodeFields,
isReportedError,
explainPointer,
} from '../utils/format.js';

// ─── Types ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1122,7 +1123,10 @@ export default class Lint extends Command {
'ℹ';

console.log(` ${color(icon)} ${color(issue.message)}`);
console.log(chalk.dim(` ${issue.rule} at ${issue.path}`));
// [#22161] The same `os explain <rule-id>` pointer `os validate` and
// `os build` print, from the same one spelling, for the rules that have
// a long-form explanation.
console.log(chalk.dim(` ${issue.rule} at ${issue.path}${explainPointer(issue.rule)}`));
if (flags.fix && issue.fix) {
console.log(chalk.green(` → fix: ${issue.fix}`));
}
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
authoringFindingDetailLines,
type AuthoringRuleFinding,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
createTimer,
Expand Down Expand Up @@ -846,7 +848,14 @@ export default class Validate extends Command {
// before, roughly half were printed inline and invisible to it, so
// `--strict` failed or passed depending on which gate happened to raise
// the finding — a second, quieter version of the same coverage drift.
//
// [#22161] Each one also remembers its finding, so the text face below
// prints its `fix:` and `rule:` lines (with the `os explain` pointer) the
// way `os build` does — the warning line itself is one verdict sentence
// now, and the rule id is how an author reaches the rest.
const registryFindingAt = new Map<number, AuthoringRuleFinding>();
for (const f of ruleAdvisories) {
registryFindingAt.set(warnings.length, f);
warnings.push(`${f.where}: ${f.message}`);
}
for (const w of docWarnings) {
Expand Down Expand Up @@ -949,8 +958,12 @@ export default class Validate extends Command {

if (warnings.length > 0) {
console.log('');
for (const w of warnings) {
for (const [i, w] of warnings.entries()) {
console.log(chalk.yellow(` ⚠ ${w}`));
const finding = registryFindingAt.get(i);
if (finding) {
for (const line of authoringFindingDetailLines(finding)) console.log(chalk.dim(` ${line}`));
}
}
// The text face's half of the `--strict` gate. Its JSON counterpart is
// the `CliExitCode` argument at the `emitJson` call above, reading this
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/utils/author-time-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ describe('judgeAuthorTimeRules — the stage os verify runs first (#21323)', ()
expect(verdict.refusal).toBeNull();
const perPackage = verdict.advisories.filter((f) => PER_PACKAGE_WHERE.test(f.where));
expect(perPackage.map((f) => f.rule)).toContain('field-no-consumers');
expect(perPackage.some((f) => f.message.includes('industry'))).toBe(true);
// [#22161] The verdict no longer restates the location; the field is named in `where`.
expect(perPackage.some((f) => f.where.includes('field "industry"'))).toBe(true);
// ...and the union run did not raise it, so the pass is the only source.
const union = verdict.advisories.filter((f) => !PER_PACKAGE_WHERE.test(f.where));
expect(union.some((f) => f.rule === 'field-no-consumers' && f.message.includes('industry'))).toBe(false);
expect(union.some((f) => f.rule === 'field-no-consumers' && f.where.includes('field "industry"'))).toBe(false);
});

it('refuses a stack that does not parse — there is no rule verdict to give about it', () => {
Expand Down
Loading
Loading