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-13-one-line-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"@objectstack/lint": patch
---

fix(lint): 18 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 18 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:
- flow credentials: `flow-credential-literal` (646 → 177);
- flow writes: `flow-update-readonly-when-field` (549 → 174) and `flow-update-readonly-field` (400 → 143);
- list-view bulk wiring: `action-dispatch-contract-mismatch` (514 → 157);
- security posture: `security-controlled-by-parent-ambiguous-relation` (496 → 174), `security-master-detail-ungranted` (314 → 153), `security-controlled-by-parent-no-relation` (294 → 194), `security-fls-unknown-field` (293 → 132) and `security-wildcard-vama` (218 → 103);
- sharing rules: `sharing-rule-object-not-shareable` (482 → 144) and `sharing-rule-object-controlled-by-parent` (395 → 169);
- interface pages: `page/visualization-without-binding` (480 → 183);
- action bodies: `action-api-update-readonly-when-field` (470 → 188);
- flow triggers: `flow-api-trigger-secret-missing` (461 → 171), `flow-time-relative-descriptor-unroutable` (441 → 166), `flow-trigger-unroutable` (295 → 163), `flow-trigger-unknown-event` (268 → 101) and `flow-trigger-unknown-object` (210 → 156).
- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why `runAs: 'system'` is the channel for a static readonly field and why a `beforeInsert` stamp survives the strip; why a hook-derived value lands on a `readonlyWhen`-locked record, the drop-the-field alternative and that the warning never blocks a build; how to pick between the two bulk contracts; the controlled-by-parent tier precedence and what each sharing baseline means; how to tell which half of a sharing rule on a public object is wrong; why `sourceView` is an interface page's one binding channel; the descriptor's optional keys and the schedule's default; the api trigger's signature format; the connector's `provider`, the Basic auth variant, where `credentialRef` resolves and that a `{variable}` template draws nothing.
- **A list becomes a nearest name.** `security-fls-unknown-field` no longer lists up to twelve of the object's fields; it names the nearest declared or registry-injected column when one is close (`'crm_account.description' is the nearest`), and none otherwise. `page/visualization-without-binding` names the field TYPE the derivation looks for and no longer repeats the name pattern it also tries.
- **`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. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `security-wildcard-vama` ("why the '*' entry cannot carry View All / Modify All") and `flow-trigger-unknown-object` ("why an unknown trigger object only warns"). The other 16 entries gain paragraphs. The auth variants and the descriptor keys the lines and entries spell out are held to the spec's `ConnectorInstanceAuthSchema` and `TimeRelativeTriggerSchema` by the rules' tests.
- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 13 of the 18 fire; the `hint` FROM → TO, measured at the door:
- a `flow` write: `flow-credential-literal` (2xx `advisories[].hint`, header / query / connector input 646 / 528 / 488 → 173 / 177 / 141), `flow-update-readonly-field` (422 `issues[].hint`, update / create 239 / 396 → 116 / 139), `flow-update-readonly-when-field` (2xx advisory, 527 → 161, under `runAs: 'user'` and `'system'` alike), `flow-trigger-unknown-object` (2xx advisory, record / time-relative 210 / 179 → 156 / 125), `flow-trigger-unknown-event` (422, token / array 191 / 268 → 101 / 96), `flow-time-relative-descriptor-unroutable` (422, 441 → 166), `flow-trigger-unroutable` (422, 295 → 163) and `flow-api-trigger-secret-missing` (422, 461 → 171);
- an `object` write: `security-controlled-by-parent-no-relation` (422, 294 → 194), `security-controlled-by-parent-ambiguous-relation` (422, 496 → 167) and `security-master-detail-ungranted` (2xx advisory, 304 → 147);
- a `permission` write: `security-fls-unknown-field` (422, 324 → 132), `security-wildcard-vama` (422, 218 → 103) and `security-master-detail-ungranted` (2xx advisory, 304 → 147).

Studio's save-advisory toast prints an advisory's `hint` after its message, so the credential, readonlyWhen, unknown-object and ungranted-detail toasts shorten, and Studio's draft-changes panel shows the `hint` in place of the message for the two controlled-by-parent errors its in-browser security check finds on object drafts. 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:** `action-dispatch-contract-mismatch`, `page/visualization-without-binding`, `action-api-update-readonly-when-field` and the two `sharing-rule-object-*` ids run on the CLI only.
- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses.
56 changes: 55 additions & 1 deletion packages/lint/src/lint-flow-credential-literals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
FLOW_CREDENTIAL_LITERAL,
lintFlowCredentialLiterals as lintFlowCredentialLiteralsUnrecorded,
} from './lint-flow-credential-literals.js';
import { ConnectorInstanceAuthSchema } from '@objectstack/spec/integration';

import { explainRule } from './rule-explanations.js';
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
import * as barrel from './index.js';
Expand All @@ -41,7 +43,7 @@ import * as barrel from './index.js';
// verdict to one line of at most 200 characters — every firing variant this
// suite exercises, not a chosen few. Run the whole file: those cases read what
// the cases above fired.
const fired: Array<{ rule: string; message: string }> = [];
const fired: Array<{ rule: string; message: string; hint: string }> = [];
const lintFlowCredentialLiterals: typeof lintFlowCredentialLiteralsUnrecorded = (...args) => {
const findings = lintFlowCredentialLiteralsUnrecorded(...args);
fired.push(...findings);
Expand Down Expand Up @@ -474,3 +476,55 @@ describe('[#22161] one-line verdicts — flow-credential-literal', () => {
}
});
});

// [#22161] Slice 13: the `fix:` line. Each position's hint is one remedy line:
// the auth variant that holds the credential and the node that calls the
// connector. Where `credentialRef` resolves, the connector's `provider`, the
// Basic variant, that a template draws nothing and that nothing is refused are
// the explanation's.
describe('[#22161] one-line fixes — flow-credential-literal', () => {
it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => {
const converted = fired.filter((f) => f.rule === FLOW_CREDENTIAL_LITERAL);
// Coverage control: all three positions fired, each with its own route.
for (const opening of ['header ', 'url query parameter ', 'connector input ']) {
expect(converted.some((f) => f.message.startsWith(opening)), opening).toBe(true);
}
expect(new Set(converted.map((f) => f.hint)).size).toBe(3);
for (const f of converted) {
expect(f.hint, f.rule).not.toContain('\n');
expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200);
}
});

it('`os explain` carries what the `fix:` line no longer says', () => {
const text = explainRule(FLOW_CREDENTIAL_LITERAL)!.paragraphs.join('\n');
for (const fact of [
'resolved at boot (an environment variable in the open tier)',
"names a `provider` ('rest' for a plain HTTP API)",
"`auth: { type: 'basic', username, credentialRef }`",
'A `{variable}` template is not a literal and draws nothing',
'nothing is refused',
'content/docs/automation/connectors.mdx',
]) {
expect(text, `explanation names ${fact}`).toContain(fact);
}
});

it('every auth variant the `fix:` lines and the explanation name is one the connector auth schema accepts', () => {
// The explanation module imports nothing, so the variants it and the
// `fix:` lines spell out are held to the spec's schema here.
for (const auth of [
{ type: 'bearer', credentialRef: 'PROBE_REF' },
{ type: 'api-key', headerName: 'X-Api-Key', credentialRef: 'PROBE_REF' },
{ type: 'api-key', paramName: 'api_key', credentialRef: 'PROBE_REF' },
{ type: 'basic', username: 'probe', credentialRef: 'PROBE_REF' },
]) {
expect(ConnectorInstanceAuthSchema.safeParse(auth).success, JSON.stringify(auth)).toBe(true);
}
// The connector-input line offers every secret-bearing variant by name.
const input = lintFlowCredentialLiteralsUnrecorded(controlStack()).find((f) =>
f.message.startsWith('connector input '),
)!;
expect(input.hint).toContain("type: 'bearer' | 'api-key' | 'basic', credentialRef");
});
});
36 changes: 14 additions & 22 deletions packages/lint/src/lint-flow-credential-literals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,32 +90,24 @@ const SERVED = 'readable by every member who can read flows';
const STEER_CONNECTOR = "move it to a connector's `auth.credentialRef`";
const STEER_INPUT = 'drop it: the connector authenticates through `auth.credentialRef`';

/** Where `credentialRef` resolves — stated once, in every hint. */
const RESOLVES =
' `credentialRef` names a secrets-layer reference that is resolved at boot (an environment variable in ' +
'the open tier), so the secret never lands in metadata.';

/**
* [#22161] One remedy line per position: the auth variant that holds the
* credential and the node that calls the connector. Where `credentialRef`
* resolves, the connector's `provider`, the Basic variant, that a `{variable}`
* template draws nothing, that nothing is refused and the connector guide are
* `os explain flow-credential-literal`.
*/
const HEADER_ROUTE =
"Declare a `connectors:` entry with a `provider` (`'rest'` for a plain HTTP API) and " +
"`auth: { type: 'bearer', credentialRef }` for a bearer token, or " +
"`auth: { type: 'api-key', headerName, credentialRef }` for a key in a named header (a Basic pair is " +
"`auth: { type: 'basic', username, credentialRef }`), and call it from a `connector_action` node." +
RESOLVES;
"Declare a `connectors:` entry with `auth: { type: 'bearer', credentialRef }` (or " +
"`{ type: 'api-key', headerName, credentialRef }`) and call it from a `connector_action` node";

const QUERY_ROUTE =
"Declare a `connectors:` entry with a `provider` (`'rest'` for a plain HTTP API) and " +
"`auth: { type: 'api-key', paramName, credentialRef }`, which sends the key as that query parameter, and " +
'call it from a `connector_action` node.' +
RESOLVES;
"Declare a `connectors:` entry with `auth: { type: 'api-key', paramName, credentialRef }`, which sends the " +
'key as that query parameter, and call it from a `connector_action` node';

const INPUT_ROUTE =
'The connector authenticates through its own declaration: give its `connectors:` entry ' +
"`auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }` and drop the credential from `input`." +
RESOLVES;

const NOT_A_GATE =
' A `{variable}` template is not a literal and draws nothing. This is an advisory: nothing is ' +
'refused. See content/docs/automation/connectors.mdx.';
"Give the connector's `connectors:` entry `auth: { type: 'bearer' | 'api-key' | 'basic', credentialRef }` " +
'and drop the credential from `input`';

/** A config-path segment for a map key: `.key` when it is an identifier, `["x-key"]` otherwise. */
function keySegment(key: string): string {
Expand Down Expand Up @@ -173,7 +165,7 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
// auth variant and why it only warns are
// `os explain flow-credential-literal`.
message: `${what} holds a credential-shaped literal, ${SERVED}; ${steer}`,
hint: `${route}${NOT_A_GATE}`,
hint: route,
});
};

Expand Down
Loading
Loading