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
28 changes: 28 additions & 0 deletions .changeset/22161-lint-slice-15-one-line-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/lint": patch
---

fix(lint): 17 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain

Clause-②: no

- **Shorter fix lines.** Each finding of these 17 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after:
- react pages: `react-chart-field-unprovisioned` (362 → 175), `react-prop-missing-required` (277 → 131), `react-page-source-unparseable` (254 → 105) and `react-block-needs-record-context` (228 → 195);
- action bodies: `action-body-write-unprovisioned-anchor` (358 → 171), `action-body-write-unknown-field` (323 → 91), `action-record-write-discarded` (309 → 104) and `action-body-source-unparseable` (254 → 105);
- agents: `agent-authoring-withdrawn` (363 → 158) and `default-agent-outside-roster` (237 → 118);
- flow nodes: `flow-node-write-unprovisioned-anchor` (358 → 171) and `flow-node-write-unknown-field` (289 → 110);
- object validation rules: `validation-rule-json-schema-uncompilable` (341 → 124) and `validation-rule-regex-uncompilable` (300 → 135);
- translations: `translation-section-name-missing` (359 → 172);
- managed objects: `object/managed-api-method-unaffordable` (357 → 169);
- app navigation: `nav-object-unservable` (348 → 156).
- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`), now on the react-chart, action-body and flow-node anchor ids too; what a partially parsed source leaves unchecked; which action-body write shapes are checked and that the warning never blocks a build; why the flow-node unknown-field finding gates where the body rules advise; that skills attach to the platform agents by `surface` affinity, so dropping a custom agent loses nothing; the escaped regex spellings and the named `format`s; the shapes the common JSON Schema keywords take; that a suggested section name is never a key derived from the label; and that a record block's react-tier replacement binds the record itself.
- **Shared one-line forms.** The anchor and parse ids of the react-page, action-body and flow-node rules print the same one-line forms the hook-body ids print (`If the remote table carries '…', declare it in …'s own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, and `Fix the syntax error and re-run: until the source parses, the checks cover only what the parser recovered`).
- **Quoted from the contract, cut to one sentence.** A missing required react-tier binding still prints the contract's own description of it, now its first sentence, which names the binding and its spelling (`Pass data={…}: The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}`); the rest of the description is in the published react-blocks reference. A record block's line is the spec's named replacement behind the lead-in `Instead:`.
- **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints; eleven entries gain text. The named formats the regex entry spells out are held to the spec's `FormatValidationSchema` by the rule's test. `react-prop-missing-required` has no entry, as before, so its `rule:` line carries no pointer.
- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 5 of the 17 fire; the `hint` FROM → TO, measured at the door:
- an `object` write: `object/managed-api-method-unaffordable` (422 `issues[].hint`, two refused verbs / every write verb 335 / 372 → 132 / 169), `validation-rule-regex-uncompilable` (422, the pattern `([` / a 28-character pattern 288 / 314 → 123 / 149) and `validation-rule-json-schema-uncompilable` (422, 341 → 124);
- a `flow` write: `flow-node-write-unknown-field` (422, with / without a near miss 271 / 250 → 92 / 71) and `flow-node-write-unprovisioned-anchor` (2xx `advisories[].hint`, 361 → 174).

Studio's save-advisory toast prints an advisory's `hint` after its message, so the flow-node anchor toast shortens; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead.
- **Never at the runtime gate:** the four react-page ids, the four action-body ids, the two agent ids, `translation-section-name-missing` and `nav-object-unservable` run on the CLI only.
- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses.
44 changes: 40 additions & 4 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,9 @@ const UNPROVISIONED_ANCHOR_CAUSE =
/**
* The three ways out of an unprovisioned anchor, which the one-line `fix:` of
* the converted anchor ids (`unprovisionedAnchorFix()`) names without the how:
* `sort-field-unprovisioned`, `searchable-field-unprovisioned` and
* `hook-body-write-unprovisioned-anchor` write it out.
* `sort-field-unprovisioned`, `searchable-field-unprovisioned`,
* `react-chart-field-unprovisioned` and the hook, action and flow-node
* write-anchor ids write it out.
*/
const UNPROVISIONED_ANCHOR_REMEDY =
'To fix it, check the remote table. If it really carries the column, declare it in the object\'s ' +
Expand Down Expand Up @@ -951,6 +952,11 @@ const ACTION_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = {
'The nested write lands nothing, and its refusal escapes the body and fails the action. Hook bodies ' +
'get the same check (`hook-body-write-unknown-field`) through the same extractor, so the two rules ' +
'judge the same write shape the same way.',
'Only the literal write shapes in `ACTION_BODY_WRITE_PATTERNS` are checked — today the ' +
'`ctx.api.object(NAME)` call with a literal payload — so a clean run does not prove every write ' +
'lands, and the `ctx.input` shapes are excluded because an action\'s input is its params bag. The ' +
'warning never blocks a build. The `fix:` line suggests the nearest declared or system column when ' +
'one is close.',
],
};

Expand All @@ -969,7 +975,8 @@ const ACTION_RECORD_WRITE_DISCARDED_EXPLANATION: RuleExplanation = {
'It is reported only when the write is provably dead: `ctx.record` never leaves the body as a ' +
'value. Handed to anything — an argument, an assignment, a spread, a return — the snapshot may be ' +
'a payload under construction, so every write in that body is skipped. An alias ' +
'(`const r = ctx.record`) counts as an escape; a property read does not.',
'(`const r = ctx.record`) counts as an escape; a property read does not. The warning never blocks ' +
'a build.',
],
};

Expand All @@ -992,6 +999,7 @@ const ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION: RuleExplanation = {
'On an action body only the `ctx.api.object(NAME)` write is judged: an action\'s `ctx.input` is its ' +
'params bag, not a record, and its `ctx.record` is a snapshot the runtime never writes back ' +
'(`action-record-write-discarded`).',
UNPROVISIONED_ANCHOR_REMEDY,
],
};

Expand Down Expand Up @@ -1023,6 +1031,7 @@ const FLOW_NODE_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION: RuleExplanation = {
'node\'s literal object name. This finding is a warning where the node\'s undeclared-field finding ' +
'is an error: that one is a certainty about this stack, this one is a claim about a remote schema ' +
'the build cannot see.',
UNPROVISIONED_ANCHOR_REMEDY,
],
};

Expand Down Expand Up @@ -1723,6 +1732,11 @@ const VALIDATION_RULE_REGEX_UNCOMPILABLE_EXPLANATION: RuleExplanation = {
'The pattern is compiled exactly as `checkFormat` compiles it — `new RegExp(source)`, no flags — and ' +
'the verdict quotes the engine\'s reason; the `fix:` line quotes the source. The source is a STRING, ' +
'so a backslash in it is written twice in TypeScript.',
'To fix it, escape a literal `(`, `[` or `\\`: in TypeScript source that is `\\\\(`, `\\\\[` and ' +
'`\\\\\\\\`, and a pattern such as `^\\d{2}-\\d{7}$` is written `\'^\\\\d{2}-\\\\d{7}$\'`. A rule that only ' +
'checks a common shape can drop `regex` and name a `format` instead: ' +
// Held equal to `FormatValidationSchema`'s `format` enum by the rule's test.
'`email`, `url`, `phone` or `json`.',
],
};

Expand All @@ -1738,6 +1752,10 @@ const VALIDATION_RULE_JSON_SCHEMA_UNCOMPILABLE_EXPLANATION: RuleExplanation = {
'outcome that depends on which schema the runtime happened to compile first — is not judged here. ' +
'A misspelled `format` name compiles in both environments; that is ' +
'`validation-rule-json-schema-unknown-format`\'s verdict, not this one\'s.',
'To fix it, correct the keyword the verdict names. The usual offenders: `type` must be one of ' +
'`null`, `boolean`, `object`, `array`, `number`, `string` or `integer` (or an array of those), ' +
'`required` an array of strings, and every `$ref` must resolve. A vendor keyword is fine, because ' +
'the runtime runs `strict: false`; a MALFORMED standard keyword is not.',
],
};

Expand Down Expand Up @@ -1915,6 +1933,7 @@ const REACT_CHART_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = {
'name maps a remote column the author vouches for and is never reported. The finding is a warning ' +
'because the rule cannot see the remote table, only that the platform provisions no storage for ' +
'the anchor.',
UNPROVISIONED_ANCHOR_REMEDY,
],
};

Expand All @@ -1930,6 +1949,10 @@ const REACT_BLOCK_NEEDS_RECORD_CONTEXT_EXPLANATION: RuleExplanation = {
'binding makes the block render. A component the page declares itself under the same name ' +
'shadows the injected one and is not judged. The `fix:` line names the block\'s react-tier ' +
'replacement, or a record page.',
'On a react page the record is ordinary React state, so the replacement binds it itself: ' +
'`<ObjectForm>` by its own `objectName` / `recordId` props, `<ListView>` with the parent binding ' +
'as an ordinary filter, and a strip or a stage bar by reading the record with ' +
'`useAdapter().findOne` and laying it out in JSX.',
],
};

Expand Down Expand Up @@ -2528,6 +2551,12 @@ const PLATFORM_AGENT_ROSTER =
'for old bookmarks and persisted `agent_id`s, never separate records, and the agent catalog ' +
'shows each agent once under its canonical name.';

/** How an app reaches the platform agents without an agent of its own — the two agent ids it fixes. */
const SKILLS_ATTACH_BY_SURFACE =
'A capability or persona an app wants from an agent is authored as skills: a skill attaches to the ' +
'platform agent (`ask` or `build`) by `surface` affinity, not through a custom agent or a custom ' +
'`defaultAgent` value, so the skills a stack declares reach the agent that serves their surface.';

const DEFAULT_AGENT_LEGACY_ALIAS_EXPLANATION: RuleExplanation = {
rule: 'default-agent-legacy-alias',
covers: 'why a retired agent alias is the wrong spelling',
Expand All @@ -2552,6 +2581,7 @@ const DEFAULT_AGENT_OUTSIDE_ROSTER_EXPLANATION: RuleExplanation = {
'The key is a plain snake_case identifier in the schema, so any value parses, validates and ' +
'builds. The check is a warning rather than a schema enum on purpose: narrowing the key is a ' +
'breaking authoring change ADR-0063 already walked back once.',
SKILLS_ATTACH_BY_SURFACE,
],
};

Expand All @@ -2573,6 +2603,10 @@ const AGENT_AUTHORING_WITHDRAWN_EXPLANATION: RuleExplanation = {
'the rule cannot tell a platform package from an app package by reading the stack alone — the ' +
'runtime is what gates. Not a schema refinement either: an existing stack must keep parsing ' +
'(ADR-0078).',
SKILLS_ATTACH_BY_SURFACE,
'Everything an agent carries that a skill does not is persona text, so the useful parts of its ' +
'`instructions` move into the skills\' own instructions. The skills the agent references ' +
'already carry its capability, so nothing is lost by dropping the persona.',
],
};

Expand Down Expand Up @@ -2947,7 +2981,9 @@ const TRANSLATION_SECTION_NAME_MISSING_EXPLANATION: RuleExplanation = {
'Why the renderer does not fall back to a key slugified from the label: that would fossilise ' +
'a second contract (`_sections.SLUG_OF_LABEL`) beside the declared one, and the slug would ' +
'move the day anyone edits the heading text. The defect is at the producer, so this rule is ' +
'where the producer hears about it.',
'where the producer hears about it. For the same reason the name the `fix:` line suggests is a ' +
'suggestion to write down, never a key derived from the label: the renderers look the heading ' +
'up by name only, so renaming the heading later cannot break the lookup.',
'A warning: the section is real and renders, and only its heading stays in the source locale. ' +
'Judged only on an object that some `stack.translations` bundle carries a node for (a ' +
'monolingual stack owes nobody a section name), and only on the array form of `sections`: a ' +
Expand Down
43 changes: 42 additions & 1 deletion packages/lint/src/validate-action-body-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const SHORTENED_RULE_IDS: readonly string[] = [
ACTION_RECORD_WRITE_DISCARDED,
ACTION_BODY_SOURCE_UNPARSEABLE,
];
const firedShortened: Array<{ rule: string; message: string }> = [];
const firedShortened: Array<{ rule: string; message: string; hint: string }> = [];
const validateActionBodyWrites: typeof validateActionBodyWritesUnrecorded = (...args) => {
const findings = validateActionBodyWritesUnrecorded(...args);
for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f);
Expand Down Expand Up @@ -656,3 +656,44 @@ describe('[#22161] one-line verdicts — the rule ids this file shortened', () =
for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact);
});
});

// [#22161] Slice 15: the `fix:` line of the four ids. Each names its remedy in
// one line, the hook sibling's where there is one; which write shapes are
// checked and that the warning never blocks a build, how to vouch for or opt
// out of an unprovisioned anchor, why only a provably dead record write is
// reported, and what a partially recovered tree leaves unchecked are the
// explanations'.
describe('[#22161] one-line fixes — the rule ids this file shortened', () => {
it('an unknown field with no near miss gets the remedy without a suggestion', () => {
const findings = validateActionBodyWrites(
stackWith("await ctx.api.object('crm_deal').update({ totally_unrelated_column: 1 });"),
);
expect(findings).toHaveLength(1);
expect(findings[0].hint).toBe("Fix the field name, or declare 'totally_unrelated_column' on the object");
});

it('every `fix:` line the cases above fired is one line of at most 200 characters', () => {
// Coverage control: every id fired, the unknown-field line with and
// without a suggestion.
expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort());
const unknown = firedShortened.filter((f) => f.rule === ACTION_BODY_WRITE_UNKNOWN_FIELD);
expect(unknown.some((f) => f.hint.startsWith('Did you mean'))).toBe(true);
expect(unknown.some((f) => f.hint.startsWith('Fix the field name'))).toBe(true);
for (const f of firedShortened) {
expect(f.hint, f.rule).not.toContain('\n');
expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200);
}
});

const MOVED_FROM_FIX: Record<string, readonly string[]> = {
[ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR]: ['`columnMap`', "`ownership: 'none'`", '`systemFields: { audit: false }`'],
[ACTION_BODY_WRITE_UNKNOWN_FIELD]: ['`ACTION_BODY_WRITE_PATTERNS`', 'params bag', 'never blocks a build'],
[ACTION_RECORD_WRITE_DISCARDED]: ['provably dead', 'Handed to anything', 'never blocks a build'],
[ACTION_BODY_SOURCE_UNPARSEABLE]: ['never executed', 'partially recovered'],
};

it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its `fix:` line no longer says', (rule) => {
const text = explanationOf(rule);
for (const fact of MOVED_FROM_FIX[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact);
});
});
Loading
Loading