Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/32836-advisory-user-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/lint': patch
---

fix(lint): the approval and flow advisories a flow save returns carry a user sentence in every platform locale

Clause-②: no
Metadata-protocol: none

Nineteen advisory rule ids that the save door returns on a flow write now carry `userTextParams` (the flow, node, object, field and input names their user sentence interpolates) and have a sentence in all eleven platform locales, each in a `*.user-text.ts` catalog beside its rule and merged into the one catalog `findingUserMessage` renders from:

- approval approvers: `approval-approver-type-unknown`, `approval-expression-invalid` (its `info` arm, `resolveAs` on a non-expression approver), `approval-approver-not-membership-tier`, `approval-approver-type-deprecated`, `approval-approver-type-unsupported`, `approval-approvers-may-resolve-empty`, `approval-expression-no-empty-policy`, `approval-escalation-reassign-no-target`;
- flow trigger readiness: `flow-trigger-unknown-object` and `flow-input-unknown-object` (as advisories, with no package closure stated), `flow-input-object-less`, `flow-input-record-read-as-list`, `flow-draft-status-ambiguous`;
- flow template paths: `flow-template-unknown-field`, `flow-template-lookup-traversal`, `flow-template-field-unprovisioned`;
- `flow-node-write-unprovisioned-anchor`, `flow-update-readonly-when-field` and `flow-credential-literal`, whose sentence names where the credential sits and never its value.

Rule ids, `message` and `hint` are unchanged, and the refusals that share an id with one of these advisories carry no params, so they render no user sentence. The `lintFlowCredentialLiterals` registry entry now keeps `userTextParams` when it re-maps its findings, so the publish gate's advisory carries them. Because the finding objects now carry `userTextParams`, `os validate --json` and `os build --json` show that key on these findings in `warnings`.
3 changes: 3 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2028,6 +2028,9 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
path: f.path,
message: f.message,
hint: f.hint,
// The user sentence's names ride through the re-map, so the save door
// can render this advisory in the request's language.
...(f.userTextParams !== undefined ? { userTextParams: f.userTextParams } : {}),
})),
},
// [#29049] ADR-0031 region shape: a flow whose `loop` body, `parallel`
Expand Down
590 changes: 590 additions & 0 deletions packages/lint/src/finding-user-text.test.ts

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/lint/src/finding-user-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@

import { platformLocaleTagOf, type PlatformLocaleTag } from '@objectstack/spec/system';
import { SECURITY_POSTURE_USER_TEXT } from './validate-security-posture.user-text.js';
import { APPROVAL_APPROVERS_USER_TEXT } from './validate-approval-approvers.user-text.js';
import { FLOW_TRIGGER_READINESS_USER_TEXT } from './validate-flow-trigger-readiness.user-text.js';
import { FLOW_TEMPLATE_PATHS_USER_TEXT } from './validate-flow-template-paths.user-text.js';
import { FLOW_NODE_WRITES_USER_TEXT } from './validate-flow-node-writes.user-text.js';
import { READONLY_FLOW_WRITES_USER_TEXT } from './validate-readonly-flow-writes.user-text.js';
import { FLOW_CREDENTIAL_LITERALS_USER_TEXT } from './lint-flow-credential-literals.user-text.js';
import { WIDGET_BINDINGS_USER_TEXT } from './validate-widget-bindings.user-text.js';
import { CHART_BINDINGS_USER_TEXT } from './validate-chart-bindings.user-text.js';

Expand Down Expand Up @@ -157,6 +163,14 @@ export function mergeUserTextCatalogs(...catalogs: readonly UserTextCatalog[]):
*/
export const FINDING_USER_TEXT_CATALOG: UserTextCatalog = mergeUserTextCatalogs(
SECURITY_POSTURE_USER_TEXT,
// The automation writes: approval, flow trigger and flow node advisories.
APPROVAL_APPROVERS_USER_TEXT,
FLOW_TRIGGER_READINESS_USER_TEXT,
FLOW_TEMPLATE_PATHS_USER_TEXT,
FLOW_NODE_WRITES_USER_TEXT,
READONLY_FLOW_WRITES_USER_TEXT,
FLOW_CREDENTIAL_LITERALS_USER_TEXT,
// The dashboard writes: widget and chart binding advisories.
WIDGET_BINDINGS_USER_TEXT,
CHART_BINDINGS_USER_TEXT,
);
Expand Down
29 changes: 25 additions & 4 deletions packages/lint/src/lint-flow-credential-literals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import { isCredentialShapedLiteral } from './credential-literal.js';
import { walkFlowNodes } from './flow-walk.js';
import { recordsOf } from './object-graph.js';
import type { UserTextParams } from './finding-user-text.js';

type AnyRec = Record<string, unknown>;

Expand All @@ -66,6 +67,13 @@ export interface FlowCredentialLiteralFinding {
path: string;
message: string;
hint: string;
/**
* The names this finding's user sentence interpolates
* (`lint-flow-credential-literals.user-text.ts`): the flow, the node, the
* entry's NAME and its position. ⛔ Never the value, for the module note's
* reason; see `AuthoringFinding.userTextParams`.
*/
userTextParams?: UserTextParams;
}

/**
Expand Down Expand Up @@ -157,7 +165,13 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
const where = regionTrail
? `flow '${flowName}' · ${regionTrail} · node '${nodeId}' (${nodeType})`
: `flow '${flowName}' · node '${nodeId}' (${nodeType})`;
const report = (entryPath: string, what: string, steer: string, route: string): void => {
const report = (
entryPath: string,
what: string,
steer: string,
route: string,
entry: { name: string; position: 'header' | 'query' | 'input' },
): void => {
findings.push({
severity: 'warning',
rule: FLOW_CREDENTIAL_LITERAL,
Expand All @@ -168,6 +182,7 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
// `os explain flow-credential-literal`.
message: `${what} holds a credential-shaped literal, ${SERVED}; ${steer}`,
hint: route,
userTextParams: { flow: flowName, node: nodeId, name: entry.name, position: entry.position },
});
};

Expand All @@ -176,13 +191,19 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
if (isRec(headers)) {
for (const [name, value] of Object.entries(headers)) {
if (!isCredentialShapedLiteral(name, value)) continue;
report(`${path}.config.headers${keySegment(name)}`, `header '${name}'`, STEER_CONNECTOR, HEADER_ROUTE);
report(`${path}.config.headers${keySegment(name)}`, `header '${name}'`, STEER_CONNECTOR, HEADER_ROUTE, {
name,
position: 'header',
});
}
}
if (typeof url === 'string') {
for (const [name, value] of queryParameters(url)) {
if (!isCredentialShapedLiteral(name, value)) continue;
report(`${path}.config.url`, `url query parameter '${name}'`, STEER_CONNECTOR, QUERY_ROUTE);
report(`${path}.config.url`, `url query parameter '${name}'`, STEER_CONNECTOR, QUERY_ROUTE, {
name,
position: 'query',
});
}
}
}
Expand All @@ -202,7 +223,7 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
return;
}
if (isCredentialShapedLiteral(name, value)) {
report(at, `connector input '${label}'`, STEER_INPUT, INPUT_ROUTE);
report(at, `connector input '${label}'`, STEER_INPUT, INPUT_ROUTE, { name: label, position: 'input' });
}
};
walk(connectorConfig.input, '', `${path}.connectorConfig.input`, '', 0);
Expand Down
90 changes: 90 additions & 0 deletions packages/lint/src/lint-flow-credential-literals.user-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The user-facing sentence of `lint-flow-credential-literals.ts`'s advisory
* finding (`flow-credential-literal`), in every platform locale
* (`PLATFORM_LOCALE_TAGS`, `@objectstack/spec/system`). The finding mechanism
* and the locale ladder are `finding-user-text.ts`'s; this file is the rule's
* own half of it, kept beside the rule so its wording and its translations
* change together.
*
* Who reads it: the person who saved a flow in Studio, in their own language,
* once the save door composes `RuntimeAuthoringIssueSchema.userMessage` from
* it. It says who can read the credential and where it belongs instead.
*
* Params, set at the push site in `lint-flow-credential-literals.ts`: `flow`,
* `node`, `name` (the header, query parameter or connector input the value
* sits under) and `position` (`header` · `query` · `input`). ⛔ Never the value
* itself, for the rule's module-note reason: the sentence travels into the
* save response too.
*
* `finding-user-text.test.ts` renders it from the rule's real
* findings in every platform locale, one per position.
*/

import type { UserTextCatalog, UserTextParams } from './finding-user-text.js';
import { FLOW_CREDENTIAL_LITERAL } from './lint-flow-credential-literals.js';

/** Where the credential sits, per locale: `header`, `query` or `input`, the entry named. */
const POSITION = {
en: { header: 'header', query: 'URL query parameter', input: 'connector input' },
'zh-Hans': { header: '请求头', query: 'URL 查询参数', input: '连接器输入' },
'zh-Hant': { header: '請求標頭', query: 'URL 查詢參數', input: '連接器輸入' },
es: { header: 'el encabezado', query: 'el parámetro de consulta de la URL', input: 'la entrada del conector' },
ar: { header: 'الترويسة', query: 'معامل الاستعلام في عنوان URL', input: 'مُدخل الموصل' },
fr: { header: 'l’en-tête', query: 'le paramètre de requête de l’URL', input: 'l’entrée du connecteur' },
'pt-BR': { header: 'o cabeçalho', query: 'o parâmetro de consulta da URL', input: 'a entrada do conector' },
ru: { header: 'заголовок', query: 'параметр запроса URL', input: 'входной параметр коннектора' },
de: { header: 'den Header', query: 'den URL-Abfrageparameter', input: 'die Connector-Eingabe' },
ja: { header: 'ヘッダー', query: 'URL のクエリパラメーター', input: 'コネクタの入力' },
ko: { header: '헤더', query: 'URL 쿼리 매개변수', input: '커넥터 입력' },
} as const;

const position = (locale: keyof typeof POSITION, p: UserTextParams): string =>
POSITION[locale][p.position === 'query' ? 'query' : p.position === 'input' ? 'input' : 'header'];

export const FLOW_CREDENTIAL_LITERALS_USER_TEXT: UserTextCatalog = Object.freeze({
[FLOW_CREDENTIAL_LITERAL]: {
en: (p) =>
`Node "${p.node}" in flow "${p.flow}" has a credential written directly into ${position('en', p)} ` +
`"${p.name}", so everyone who can view flows can read it. Store it in a connector's credentials and have ` +
`the flow call that connector instead.`,
'zh-Hans': (p) =>
`流程“${p.flow}”的节点“${p.node}”在${position('zh-Hans', p)}“${p.name}”中直接写入了凭据,因此所有能查看流程的人都能读到它。` +
`请把它存放到连接器的凭据中,并改为让流程调用该连接器。`,
'zh-Hant': (p) =>
`流程「${p.flow}」的節點「${p.node}」在${position('zh-Hant', p)}「${p.name}」中直接寫入了憑證,因此所有能檢視流程的人都能讀到它。` +
`請把它存放到連接器的憑證中,並改為讓流程呼叫該連接器。`,
es: (p) =>
`El nodo "${p.node}" del flujo "${p.flow}" tiene una credencial escrita directamente en ` +
`${position('es', p)} "${p.name}", así que cualquiera que pueda ver los flujos puede leerla. Guárdela en ` +
`las credenciales de un conector y haga que el flujo llame a ese conector.`,
ar: (p) =>
`تحتوي العقدة "${p.node}" في التدفق "${p.flow}" على بيانات اعتماد مكتوبة مباشرة في ${position('ar', p)} ` +
`"${p.name}"، لذلك يستطيع كل من يمكنه عرض التدفقات قراءتها. خزّنها في بيانات اعتماد موصل، واجعل التدفق ` +
`يستدعي ذلك الموصل بدلًا من ذلك.`,
fr: (p) =>
`Le nœud « ${p.node} » du flux « ${p.flow} » contient un identifiant secret écrit directement dans ` +
`${position('fr', p)} « ${p.name} » : toute personne pouvant consulter les flux peut donc le lire. ` +
`Stockez-le dans les identifiants d’un connecteur et faites appeler ce connecteur par le flux.`,
'pt-BR': (p) =>
`O nó "${p.node}" do fluxo "${p.flow}" tem uma credencial escrita diretamente em ${position('pt-BR', p)} ` +
`"${p.name}", então qualquer pessoa que possa ver fluxos consegue lê-la. Guarde-a nas credenciais de um ` +
`conector e faça o fluxo chamar esse conector.`,
ru: (p) =>
`В узле «${p.node}» потока «${p.flow}» учётные данные записаны прямо в ${position('ru', p)} «${p.name}», ` +
`поэтому их может прочитать любой, кто видит потоки. Сохраните их в учётных данных коннектора и вызывайте ` +
`этот коннектор из потока.`,
de: (p) =>
`Der Knoten „${p.node}“ im Flow „${p.flow}“ enthält Zugangsdaten, die direkt in ${position('de', p)} ` +
`„${p.name}“ geschrieben sind. Jeder, der Flows sehen darf, kann sie daher lesen. Speichern Sie sie in den ` +
`Zugangsdaten eines Connectors, und lassen Sie den Flow stattdessen diesen Connector aufrufen.`,
ja: (p) =>
`フロー「${p.flow}」のノード「${p.node}」で、${position('ja', p)}「${p.name}」に認証情報が直接書き込まれているため、` +
`フローを閲覧できる人なら誰でも読み取れます。認証情報はコネクタに保存し、フローからはそのコネクタを呼び出してください。`,
ko: (p) =>
`플로우 "${p.flow}"의 노드 "${p.node}"에서 ${position('ko', p)} "${p.name}"에 자격 증명이 직접 작성되어 있어, ` +
`플로우를 볼 수 있는 사람은 누구나 읽을 수 있습니다. 자격 증명은 커넥터에 저장하고, 플로우가 그 커넥터를 ` +
`호출하도록 바꾸세요.`,
},
});
15 changes: 15 additions & 0 deletions packages/lint/src/validate-approval-approvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { BUILTIN_MEMBERSHIP_ROLES } from '@objectstack/spec';
import { collectCelRootIdentifiers } from '@objectstack/formula';
import { walkFlowNodes } from './flow-walk.js';
import { recordsOf } from './object-graph.js';
import type { UserTextParams } from './finding-user-text.js';

export const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER = 'approval-approver-not-membership-tier';
export const APPROVAL_APPROVER_TYPE_DEPRECATED = 'approval-approver-type-deprecated';
Expand Down Expand Up @@ -267,6 +268,11 @@ export interface ApprovalApproverFinding {
message: string;
/** How to fix it. */
hint: string;
/**
* The names this finding's user sentence interpolates, on the advisory push
* sites only (``validate-approval-approvers.user-text.ts`` sibling catalog); see `AuthoringFinding.userTextParams`.
*/
userTextParams?: UserTextParams;
}

type AnyRec = Record<string, unknown>;
Expand Down Expand Up @@ -392,6 +398,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
hint: fix
? `Use the spec value: { type: '${fix}', value: '${value}' }.`
: `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`,
userTextParams: { flow: flowName, node: nodeId, type, suggested: fix ?? '' },
});
continue;
}
Expand Down Expand Up @@ -463,6 +470,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
path: `${path}.resolveAs`,
message: `resolveAs has no effect on a '${type}' approver — it only applies to type 'expression'.`,
hint: `Remove it, or switch this approver to { type: 'expression', value: '<CEL>', resolveAs: '${String(a.resolveAs)}' }.`,
userTextParams: { flow: flowName, node: nodeId, type },
});
}

Expand All @@ -488,6 +496,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
hint:
`If it is an org position, author { type: 'position', value: '${value}' }; keep ` +
`'org_membership_level' for the tiers the verdict lists`,
userTextParams: { flow: flowName, node: nodeId, value },
});
} else if (type in DEPRECATED_APPROVER_TYPES) {
const fix = canonicalApproverType(type);
Expand All @@ -500,6 +509,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
`approver type '${type}' is the deprecated spelling of '${fix}' (ADR-0090 D3) and ` +
`is removed in the next major.`,
hint: `Author { type: '${fix}', value: '${value}' }. It resolves identically today.`,
userTextParams: { flow: flowName, node: nodeId, type, replacement: fix },
});
} else if (
(APPROVER_VALUE_BINDINGS as Record<string, { source: string }>)[canonical]?.source === 'unsupported'
Expand All @@ -523,6 +533,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
hint:
`Route to people the engine can expand: ` +
`{ type: ${EXPANDABLE_GROUP_TYPES.map((t) => `'${t}'`).join(' | ')}, ... }.`,
userTextParams: { flow: flowName, node: nodeId, type, routes: EXPANDABLE_GROUP_TYPES.join(', ') },
});
}

Expand Down Expand Up @@ -580,6 +591,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
`none is staffed the request opens on the organization's administrators` +
(locks ? STAYS_LOCKED : ''),
hint: GROUP_ONLY_FIX,
userTextParams: { flow: flowName, node: nodeId, slate: 'group', locked: locks ? 'yes' : 'no' },
});
}

Expand Down Expand Up @@ -626,6 +638,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
`is unset the request opens on the organization's administrators` +
(locks ? STAYS_LOCKED : ''),
hint: MANAGER_ONLY_FIX,
userTextParams: { flow: flowName, node: nodeId, slate: 'manager', locked: locks ? 'yes' : 'no' },
});
}

Expand Down Expand Up @@ -653,6 +666,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
hint:
`Declare onEmptyApprovers on the node: 'admin_rescue', 'fail', 'auto_approve', or 'fallback' ` +
`with a fallbackApprovers list`,
userTextParams: { flow: flowName, node: nodeId },
});
}

Expand Down Expand Up @@ -694,6 +708,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin
hint:
`Set escalateTo to a position machine name (expanded via sys_user_position, ` +
`ADR-0090 D3) or a specific user id, or change action to 'notify'.`,
userTextParams: { flow: flowName, node: nodeId },
});
}
}
Expand Down
Loading
Loading