Skip to content

Commit d8c7d38

Browse files
fix(lint): one-line verdicts for the flow-credential, AI skill, flow-filter-token, managed-API, org-axis, retired-permission, seed and semantic-role rules, and a bounded field-no-consumers clause; os explain RULE_ID carries their reasoning (#22828)
Part of #22161 Clause-②: no Stage 2 of the card, slice 10: the last 10 `packages/lint` rule ids from PR #22799's "Remaining for later slices" list, one whole source file each, plus stage 1's `field-no-consumers` (the card's own subject), whose "a consumer of the same name on …" clause listed every object sharing the field name. The card stays open: the `packages/cli` ids, the fenced ids and the hint call remain (below). ## What changes - **One verdict line per finding.** Each finding of the 10 ids prints a `message` of one verdict sentence, followed by `Did you mean "…"?` where the rule already offered the nearest name. Every finding the rules' own suites fire is now 194 characters or fewer; the longest of each id was 222 to 441 before. The runtime publish gate's suite (the whole `@objectstack/metadata-protocol` suite) fires two of the 10, at 181 or fewer (390 and 235 before). The `packages/cli` unit tier and `os validate` on the four example apps fire none of the 10. - **`field-no-consumers` names the first other object and counts the rest.** `a consumer of the same name on "showcase_account" (and 9 more) does not count`, where it listed all ten. On `os validate` of `examples/app-showcase` its longest finding drops from 341 to 199 characters (that 341-character finding now prints 143). Its `inert` arm is byte-identical, so the CLI pins and stage 1's pending changeset that quote it are untouched. Its explanation is unchanged: the verdict cut names, not reasoning, and the explanation already says "Verdicts are per object". - **Where a verdict keeps a name, it is the one the author needs.** A verdict no longer repeats what `where` already names: the skill (`ai-skill-tool-unresolved` now opens on the tool), the managed object, the sharing rule's object and the semantic-role object. `seed-value-outside-state-machine` names the first three declared states, then `(and N more)`. - **`flow-credential-literal` keeps a short route in the verdict** (`move it to a connector's auth.credentialRef`, or `drop it` for a connector input). Measured at the base: `os validate` and `os build` print the hint as their `fix:` line (`authoringFindingDetailLines`), but `os lint`'s text face prints it only under `--fix` (`commands/lint.ts`, `flags.fix && issue.fix`). The module comment said both text faces "leave the hint to `--json`", which is no longer true for `os validate`; it now says what was measured. The route by auth variant (`bearer`, a header `api-key`, `api-key` with `paramName`) stays the hint's, unchanged. - **`semantic-role-field-unprovisioned` keeps `ADR-0015` and the word "storage".** `validate-expressions.test.ts` (the cross-site convergence pin) holds this finding and the expression site's unprovisioned-anchor warning to the same object, column and reason. The first cut dropped both and that pin went red on the whole-suite run; the verdict was fixed, not the pin, which is outside this slice's files. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 10 new entries, so `os explain RULE_ID` prints it and the CLI's `rule:` line ends with the pointer for these ids. No CLI source changes: `explainPointer()` and `os explain` resolve any key the table holds (`packages/cli/test/explain-rule-id.test.ts` iterates every key; it ran in the unit tier below). `node bin/run.js explain org-axis-cross-org-bu-grant` prints the entry. The 121 entries already in the table are byte-equal (the diff of `rule-explanations.ts` is additions only, +266 / -0). - **Nothing else moves.** Rule ids, severities, `path`, `hint` (the `fix:` line) and what each rule accepts or refuses are unchanged. No condition, branch, skip or dedupe moved, and no export was added, removed or renamed: every hunk in the eleven rule files is a `message` expression, a comment, a verdict helper, or the removal of a value that fed only the old message. The plumbing hunks, each message-only: - `lint-flow-credential-literals.ts`: `SERVED` becomes one clause; `STEER_HEADER` and `STEER_QUERY` become one `STEER_CONNECTOR` (both arms now give the same short route; the hint keeps routing by shape); `STEER_INPUT` is shortened; the query arm's position reads `url query parameter`. - `validate-managed-api-methods.ts`: the import of `describeManagedApiMethodConflicts` (`@objectstack/spec/data`) goes; it fed only the message. That spec sentence alone measured about 190 characters at three verbs, so no verdict built on it fits; see Acceptance notes for what that leaves in `packages/spec`. - `validate-seed-state-machine.ts`: `atMostThree()` (new, module-private) bounds the known-state list. - `validate-field-consumers.ts`: the `sharedClause` expression names `sharedWith[0]` and counts the rest. - No helper changed shape (string in, string out); no truth table is involved. - `.changeset/22161-lint-slice-10-one-line.md`: `@objectstack/lint` `patch`, naming every door that prints the new text (below). `Clause-②: no`, as line 2 says: every rule id these entries key already had an exported constant on the root barrel (`index.ts` is untouched), and slice 1's contract review (② of its record) reads `RULE_EXPLANATIONS` entries as data in an existing export. - **The declared rider went unused:** the `packages/cli` and `packages/metadata-protocol` tests that assert these ids' text pin fragments the new verdicts keep (`auth.credentialRef` and `every member who can read flows` for `flow-credential-literal`; the unchanged `inert` arm of `field-no-consumers`), and they ran green unedited (below). The verdict forms, the longest of each arm the rule suites fired (the last line is `os validate` on the showcase app): ```text connector input 'headers.Authorization' holds a credential-shaped literal, readable by every member who can read flows; drop it: the connector authenticates through `auth.credentialRef` url query parameter 'api_key' holds a credential-shaped literal, readable by every member who can read flows; move it to a connector's `auth.credentialRef` Agent "helper" (surface 'ask') references skill "metadata_authoring" (surface 'build'), so the runtime refuses the binding and chatting with this agent fails at request time Tool family "crm_*" matches no tool this stack can resolve, so the subscription contributes zero tools at runtime Tool "action_send_invoice" names action "send_invoice", which does not become an AI tool (it needs `ai.exposed` and a headless path), so the runtime drops the reference Tool "knowledge_base" resolves to no stack, platform or materialised action tool, so the runtime drops it and the agent cannot call it. Did you mean "search_knowledge_base"? Filter value "{DATEADD(day, -45)}" calls 'DATEADD', which neither the flow template dialect nor the filter placeholders define, so the evaluator raises a guard refusal and this node cannot run `enable.apiMethods` advertises [create, update, delete], which `managedBy: 'better-auth'` affordances refuse (needs userActions create/edit/delete), so the registry strips them Recipient `unit_and_subordinates` (a business unit AND every descendant unit) on an object that opted out of tenancy spans EVERY organization: a cross-org business-unit grant ADR-0105 D6 forbids sets `allowRestore: false`, the retired default of a key removed in @objectstack/spec 17, which the parse strips in silence, so this line grants nothing `mode: 'insert'` re-inserts every record on each replay boot (dev-server restart, package re-publish) with no existing-row check, so every restart duplicates its rows seeds 'status=complete', which the 'proj' state machine does not declare (known states: active, cancelled, completed (and 2 more)), and seed writes skip that rule, so a typo lands silently highlightFields points at "organization_id", an injected system column with no storage on this external object (ADR-0015), so every consumer renders it empty on every record declared, but nothing in this stack displays or reads it (carrier-only: 4 site(s) name it without reading it; a consumer of the same name on "showcase_project_membership" (and 1 more) does not count) ``` ## Shared prose: written once (the dispatch's route, measured) | shared text | ids | where it is written once | held to its source by | |---|---|---|---| | who can read a flow's literal (`readable by every member who can read flows`) | `flow-credential-literal`, all three positions | `SERVED` in the rule file (verdict); the explanation's first paragraph (reasoning) | the rule's test, every position fired | The dispatch expected shared paragraphs across ids, as slices 3 to 9 found. Measured: none of the 10 explanations shares a paragraph with another. The two AI rules judge different references (an agent's `skills`, a skill's `tools`) against different universes; the two seed rules answer different questions (replay idempotence, the state-machine exemption); `org-axis-cross-org-bu-grant`'s sibling `org-axis-permission-inheritance` is not converted (174 at its longest) and has no entry to share with. So the table holds the one in-file shared clause. The explanation module imports nothing, so each fact it writes out is held to its source by the rule's own test: the credential positions and the three region kinds by firing each; the surface defaults, the `both` arm and the skipped unresolved skill by the existing cases; the three tool sources, the wildcard and the four headless paths by each one silencing the rule; the flow template functions to `FLOW_TEMPLATE_DATE_FUNCTIONS` and `FLOW_TEMPLATE_VALUE_FUNCTIONS`, and the placeholders by each one staying silent; the verb table to `MANAGED_WRITE_VERB_AFFORDANCE`, verb by verb through the rule; the business-unit recipients to the `ShareRecipientType` partition the suite pins, each fired; the retired keys and their residue value to the spec's capture; the declared-state union by each of its three sources silencing the rule; and the external-object provenance by the existing cases. ## Census (taken first, before any edit, at the base `23419bafb7`) Method: slice 4 to 9's scratch preload (`NODE_OPTIONS=--import`, never committed), which patches `Array.prototype.push` to record every finding-shaped object (`rule` + `message`), deduped by (rule, message) per process, with the push site for the 11 ids and the other ids in their files. Lengths are `message` alone; the printed line adds `where` and `: `. Rows from `os lint`'s own printer (`commands/lint.ts:761`, which pushes `where: message`) are named as such. Positive control in every run: `field-no-consumers`, plus `sort-field-unknown` and `searchable-field-unknown` in the runtime-gate suite, recorded in every run. - **`packages/lint` suite**, base 137 files / 6,416 tests, after the CLI, metadata-protocol and example closure build at the base: **all 10 ids fire**, every one over 200 at its longest; each longest equals PR #22799's list. `field-no-consumers`: 14 messages, longest 167. - **`packages/cli` unit tier**, the whole project (281 files, 4,174 tests): fires none of the 10, before and after; `field-no-consumers` fires 7 messages, the longest 142 as `os lint`'s printer row, all on the unchanged `inert` arm. - **Runtime publish gate:** the whole `@objectstack/metadata-protocol` suite (228 files, 225 run, 28,129 passed), which reads `@objectstack/lint` from its built `dist/`, before on the base-built dist and after on the rebuilt one: fires `flow-credential-literal` (3 messages, `runtime-authoring-gate.flow-credential-literal.test.ts`) and `permission-retired-lifecycle-residue` (2); none of the other 9. - **Example apps:** `os validate` (the built CLI) on `app-crm`, `app-todo`, `app-showcase` and `app-multi-package`, exit 0 each, before and after: **none of the 10 fire**; `field-no-consumers` fires on all four, and every app prints the same number of `rule:` lines before and after (9 / 7 / 70 / 3). - **Extra, outside the dispatch's list:** the four `packages/cli` e2e files that name these ids (`flow-credential-literal-doors.e2e.test.ts`, `rule-line-explain-pointer.e2e.test.ts`, `starter-field-consumers.e2e.test.ts`, `generate-scaffold-gates.e2e.test.ts`; in no per-PR vitest project, run here under `OS_TEST_TIERS=nightly`) and the one integration file (`union-fold-command-parity.test.ts`). - **After:** the lint `dist/` rebuilt from the slice at `dfec085942` (marker `holds a credential-shaped literal` 1 hit each in `dist/index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`; the old `holds a literal value that reads as a credential` 0 in each; `why business-unit trees stay inside one organization` 1 in `dist/rule-explanations.js` and `.cjs`). Every after-run except the final lint suite read that dist; the commits after it add the changeset and the `origin/main` merge, which touches no `packages/lint` file. | rule id | severity | lint suite: before (msgs · range) → after | runtime-gate suite | examples `os validate` | cli e2e (4 files) | |---|---|---|---|---|---| | `flow-credential-literal` | warning | 10 · 282–390 → 10 · 144–185 | 3 · 294–390 → 3 · 148–181 | — | 3 · 294–390 → 3 · 148–181 (printer rows 346–430 → 188–233) | | `ai-skill-surface-mismatch` | error | 6 · 259–281 → 6 · 151–173 | — | — | — | | `ai-skill-tool-unresolved` | warning | 24 · 238–441 → 23 · 113–173 | — | — | — | | `flow-filter-token-unknown` | error | 8 · 267–278 → 8 · 181–192 | — | — | — | | `object/managed-api-method-unaffordable` | error | 3 · 392–412 → 7 · 146–176 | — | — | — | | `org-axis-cross-org-bu-grant` | error | 2 · 331–365 → 2 · 160–194 | — | — | — | | `permission-retired-lifecycle-residue` | warning | 2 · 233–235 → 2 · 150–152 | 2 · 233–235 → 2 · 150–152 | — | — | | `seed-insert-mode-duplicates-on-replay` | warning | 1 · 222 → 1 · 166 | — | — | — | | `seed-value-outside-state-machine` | warning | 3 · 288–320 → 4 · 161–188 | — | — | — | | `semantic-role-field-unprovisioned` | warning | 4 · 285–291 → 3 · 163–173 | — | — | — | | `field-no-consumers` | warning | 14 · 64–167 → 19 · 64–178 | — | crm 4 · 202 → 4 · 184; todo 2 · 109 → 2 · 109; showcase 21 · 341 → 19 · 199; multi-package 1 · 64 → 1 · 64 | 4 · 64–108 → 4 · 64–108 | The cli unit tier column is not drawn: it fires `field-no-consumers`' unchanged `inert` arm alone (7 · 64–142), before and after. The cli integration file fires `field-no-consumers` only (2 · 64–140, before and after). `org-axis-permission-inheritance` (not converted): lint suite 4 · 167–174 before and after. A message count that falls after (`ai-skill-tool-unresolved`, `semantic-role-field-unprovisioned`, showcase's `field-no-consumers`) is two findings whose old texts differed only in a name the verdict no longer repeats or a list it now cuts; a count that rises is a case this slice added. Every push site of the 11 ids fires before and after (one each, `semantic-role-field-unprovisioned`'s helper reached from its two roles). ## Doors that print the new text Read from the registry (`authoring-rules.ts`): `lintFlowCredentialLiterals` and `validateFlowFilterTokens` are CLI and runtime on `['flow']`, `validateManagedApiMethods` on `['object']`, `validateRetiredPermissionResidue` on `['permission']`; `validateOrgAxisRedLines`, `validateFieldConsumers`, `validateSemanticRoles` and the two seed rules are CLI only; the two AI rules are members of the reference-integrity suite on its default `['flow']`. The CLI's callers: `commands/validate.ts`, `compile.ts` (`os build` is its alias), `lint.ts`, `verify.ts` through `judgeAuthorTimeRules` on the `validate` set, and `init.ts` through `validateScaffold` on the `build` set (one caller, `commands/init.ts:1314`; `commands/generate.ts` calls no authoring rule). Measured at the runtime door with `runRuntimeAuthoringRules` on the rebuilt dist (scratch probe): a `flow` write answers `flow-filter-token-unknown` in `errors` and `flow-credential-literal` in `advisories`; an `object` write answers `object/managed-api-method-unaffordable` in `errors`; a `permission` write answers `permission-retired-lifecycle-residue` in `advisories`; an `agent`, `skill` or `sharing_rule` write runs no rule. | door | ids | what changes | |---|---|---| | `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, per watch recompile), `os lint` (`where: message`), the scaffold check `os init` runs | all 11 | the text-face verdict line; the `rule:` line gains the `os explain` pointer for the 10; `os validate --json` `errors` and `os build --json` author-time `issues` carry the new `message` | | `os verify` | the four errors: `ai-skill-surface-mismatch`, `flow-filter-token-unknown`, `object/managed-api-method-unaffordable`, `org-axis-cross-org-bu-grant` | its refusal list; it counts advisories without printing them | | runtime publish gate, `flow` write | `flow-filter-token-unknown` (error), `flow-credential-literal` (warning) | the 422 issue's `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line; the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line | | runtime publish gate, `object` write | `object/managed-api-method-unaffordable` (error) | the 422 issue's `message` and the refusal log line | | runtime publish gate, `permission` write | `permission-retired-lifecycle-residue` (warning) | the 2xx `advisories` entry and the advisory log line | | never at the runtime gate | `org-axis-cross-org-bu-grant`, both seed ids, `semantic-role-field-unprovisioned`, `field-no-consumers` (CLI-only rules); both AI ids (run there only for a `flow` write, whose snapshot carries no agents or skills) | | | unchanged | every `hint`; every other rule id | | Every row is named in the changeset. ## `field-no-consumers` across doors (the dispatch's item 6, measured on `examples/app-showcase`) - `os validate`: 51 findings, each printed once (verdict, `fix:`, `rule:` with the pointer); the longest printed verdict line 391 → 253 characters. - `os build`: 37 of them, each once, inside the 50-entry advisory cap (`… and 20 more author-time warning(s) not shown (50 of 70)`); longest printed verdict line 257 → 253. - `os dev --compile --fresh`: the startup compile (`stdio: 'inherit'`) prints the same 37, once; a watch recompile prints its output only under `--verbose`, else only stderr on failure (`commands/dev.ts`, `startWatchRecompile`). Nothing is printed again at serve. - **No second paragraph remains.** `Give … a consumer` and `Roots scanned` appear 0 times in the `os validate`, `os build` and `os dev` output, before and after: stage 1 moved that text into the explanation. No `packages/cli` printer was edited. ## Tests The rule suites import the rule source; the CLI, runtime-gate, example and e2e runs read the rebuilt `@objectstack/lint` dist. Every heavy run went through `scripts/pm/os-verify-lock.sh`; its VERDICT lines are quoted. - **Each rule's own suite pins the new shape**, as in slices 2 to 9. The eleven touched suites wrap their rule import and record every finding their direct calls fire; the LAST case in each file holds every recorded verdict of its id to one line of at most 200 characters, behind a coverage control (each arm: all three credential positions; both surface directions; all three tool arms; one and several refused verbs; both business-unit recipients; both retired keys; a cut and an uncut state list; both semantic roles; and `field-no-consumers`' `inert`, `carrier-only`, one-name and counted arms). Exact pins hold one verdict per arm; explanation pins hold, per id, that `explainRule(id)` exists and names what the verdict stopped saying, and the source-held facts above. Every refusal assertion, rule id, severity, `path`, `where` and hint pin is unchanged. The pins that read the old text moved to the new verdict: the credential route fragments to `move it to a connector's auth.credentialRef` (and the hint's `type: 'bearer', credentialRef`, newly pinned beside the existing `headerName` one); the AI tool suite's `/references tool "…"/` matcher to `/^Tool "…"/` and `tool family` to `Tool family`. - **Lint suite** at the final head: `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: Test Files 137 passed (137), Tests 6,455 passed (6,455); VERDICT command-exit 0 (base 137 / 6,416; no new file, 39 new cases). - **Lint build + typecheck:** `pnpm --filter @objectstack/lint build` (VERDICT command-exit 0, `check-dts-emitted` 6/6), and after the merge `turbo run build --filter=@objectstack/lint` (4 tasks, VERDICT command-exit 0). `pnpm --filter @objectstack/lint run typecheck` at the final head: VERDICT command-exit 0; `check:test-typecheck` OK, 2 files / 6 errors / 2 pinned signatures held. `tsc -p packages/lint/tsconfig.test.json --listFiles` lists all eleven touched test files. - **CLI unit tier**, against the rebuilt lint dist: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: Test Files 281 passed (281), Tests 4,174 passed (4,174), before and after; VERDICT command-exit 0 both. It includes `test/explain-rule-id.test.ts`. The integration tier is declared to CI (no file in it asserts these ids' text, and the diff touches no spawn entry); its one file naming these ids ran as the census extra: Test Files 1 passed (1), Tests 9 passed (9), before and after. - **CLI e2e files naming these ids** (`OS_TEST_TIERS=nightly`): Test Files 4 passed (4), Tests 56 passed (56), before and after; VERDICT command-exit 0 both. `flow-credential-literal-doors.e2e.test.ts` asserts the message carries `auth.credentialRef` at every door; it does. - **Runtime gate:** the whole `@objectstack/metadata-protocol` suite, before and after: Test Files 225 passed | 3 skipped (228), Tests 28,129 passed | 19 skipped (28,148), both runs; VERDICT command-exit 0. `runtime-authoring-gate.flow-credential-literal.test.ts` asserts `every member who can read flows` and `auth.credentialRef`; both are kept. - **Corpus pins over `packages/lint/src`:** `git grep lint/src -- packages/spec/src` names two that walk it, `src/shared/retired-key-migrate-sentence.test.ts` (judges every `os migrate meta --from N` sentence) and `src/identity/position-delegatable-enforcer.pin.test.ts` (reads `security-*` rule-id lines); the other hits name single files outside this slice or are comments. No new text carries the migrate marker (the residue explanation names `os migrate meta` without `--from`), and no rule-id line moved. `pnpm --filter @objectstack/spec test:repo` at the final head: Test Files 55 passed (55), Tests 971 passed (971); VERDICT command-exit 0. Both pins are in its `vitest.repo-tests.json` list. - **Tracker ids:** no `#` plus digits in any printed message or explanation (`rule-explanations.test.ts` refuses one in every paragraph); the citations stay in `//` comments and test names. - **Ablation** (one-shot, from the committed state `92a7bebbb4`, through `scripts/ablation-replace.mjs` wrap mode under the verify lock, plus a shell `trap` restoring `HEAD` by absolute path). The rule suites import the rule source, so there is no dist leg. `seed-insert-mode-duplicates-on-replay`'s pre-slice message tail was restored verbatim through the anchor of its new tail: anchor x1 → x0, blob `2e2c47f4e718` → `9acef22a58d3`. Predicted before the run: exactly two cases red, the id's exact-text pin and the 200-character bound pin; the suite's other assertion on the message (`toContain('replay')`) holds on the old text too. Observed: `src/validate-seed-replay-safety.test.ts` Test Files 1 failed (1), Tests 2 failed | 7 passed (9); the two red cases are exactly those two (the bound pin read 222 characters). Restored: blob `2e2c47f4e718` == blob at HEAD, `git diff HEAD` empty, `git status --porcelain` empty. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 23 changed `.ts` files gave 23 files in the report, 0 errors, 0 warnings. The population is `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` block plus the `packages/**/*.{ts,tsx,mts,cts}` blocks, which all 23 are in (the changeset is not a linted kind). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed `@typescript-eslint` rules; the config's own note says so), so no untouched file's verdict can move. A control-character scan of every changed file found no match. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, at the final head `82555d5ca8` (merge base `b7cd1af9df`, after one merge of `origin/main` through `scripts/pm/os-regen-merge.sh`; the merge touched no `packages/lint` file, and its step 2 took `origin/main`'s side of the generated artifacts main moved, none of which this branch edits), derived 62 commands. I ran 60 of them sequentially at that head, each with its own log and its exit code captured before any pipe: all 60 exited 0. Two are NOT MEASURED, as the dispatch directs: `pnpm check:dual-build-cjs-loads` (it needs every package's `dist/`, and the dispatch excludes a whole-workspace build) and `pnpm check:type-check-debt` (`--re-measure`, which builds every package); the non-re-measuring half, `check:type-check-coverage`, ran and exited 0. `--ran` over the exit-coded record: `Run reconciliation — 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.` The artifact-roster block the no-path run prints (51 families) ran too: the 48 that need no pull request all exited 0, `check:published-readme-exports` only after one `PREREQUISITE NOT MET` (exit 3: `client-react`, `embedder-openai`, `knowledge-ragflow` and `organizations` had no `dist/`; I built exactly those four with their closure under the lock, VERDICT command-exit 0, and it then exited 0). The four rostered under a directory this diff touches (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`) are among the 48. The three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) run against this PR once it exists, and their verdicts go to the card report (this body is written once). The long ones on the shared box: `check:query-options-erasure` 249s, `check:slot-lookup` 135s, `check-comment-mask-corpus` 112s. ## Remaining for this card - **`packages/lint`:** no author-time id outside the fenced files fires over 200 characters in the lint suite after this slice. Measured over the final census: 28 ids still exceed 200; 26 are PR #22448's fenced ids, and 2 are `lint-startup-registry-verdict.ts`'s (`startup-open-vocabulary-verdict` 779, `startup-verdict-assertive-wording` 731), which PR #22448 records as the repo gate `check:startup-registry-verdict`'s, not an author's. `react-prop-deprecated` fires in no suite, as PR #22700 notes. - **The fenced ids, as PR #22448 lists them (26):** `lint-flow-patterns.ts` (15), `validate-flow-template-paths.ts` (3), `validate-component-props.ts` (2), `validate-page-field-bindings.ts` (2), `validate-expressions.ts` (1, `expression-invalid`), `validate-filter-tokens.ts` (1, `filter-token-unknown`), `validate-form-layout.ts` (1), `validate-print-page-blocks.ts` (1). - **The 9 ids owned by `packages/cli`,** as PR #22448 lists them: `hook-body/not-lowerable`, `hook-body/unparseable`, `hook-body/bundled-fallback`, `hook-body/extraction-failed` (`packages/cli/src/lint/hook-body-lowering.ts`); `docs/uncollected-directory`, `docs/duplicate-name`, `docs/nav-target`, `docs/frontmatter-tags` (`packages/cli/src/utils/collect-docs.ts`); `picklist-reference-unverified` (`packages/cli/src/utils/picklist-references.ts`). Also the `action-governance.ts` boot-log lines (`packages/objectql`), stage 1's own later slice. - **The open hint call:** the four items slice 8's landing record lists, plus one from this slice: `field-no-consumers`' hint lists every carrier site, unbounded; on `os validate` of `examples/app-showcase` its `fix:` line runs to 629 characters (16 carrier sites on `showcase_account.website`). Unchanged here, as every slice leaves hints. None of the other 10 ids' hints points at text the verdicts cut: each was read against the cut text and is self-contained. The nearest case: `seed-value-outside-state-machine`'s hint says "The exemption lets a seed be born mid-lifecycle", and the verdict still names that exemption (`seed writes skip that rule`). - **`field-no-consumers` printers (item 6):** no second paragraph exists outside `packages/lint`; `os build` and `os dev` print each finding once, inside the 50-entry advisory cap. Nothing owed to the `domain:cli` half from this measurement. ## Acceptance notes - **`describeManagedApiMethodConflicts` keeps no caller outside its own test.** Its docblock (`packages/spec/src/data/managed-api-affordance.ts`) calls it "the one sentence both consumers say", the registry's strip warning and this lint finding. Measured at the base: objectql's `reconcileManagedApiMethods` already spells its own sentence and never called it, so the lint verdict was its only caller, and this slice's verdict cannot carry it under 200 characters. The export is a published `@objectstack/spec` symbol and outside this slice's files; not removed and not filed (an observation about a docblock and a now-uncalled export). Carrier: none. - **A long name can still lengthen a line.** The 200 bound holds on every variant the suites and the four example apps fire; the names in a verdict (object, field, skill, tool, recipient, state, input key) are the author's. The closest measured case is the showcase's `field-no-consumers` on `showcase_client_brief.project` at 199, whose one named object is 27 characters long. - **`field-group-shadowed`** (`validate-semantic-roles.ts`, not one of this slice's ids) lists the group's members unbounded; the suites fire it at 168 at its longest. Observation only. - **`os lint` printer rows** prefix `where: ` to the message, so `flow-credential-literal`'s connector-input row prints at 233 in the e2e run while its message is 181; slice 9 recorded the same shape (188 against 175). --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 048f389 commit d8c7d38

24 files changed

Lines changed: 1211 additions & 86 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the flow-credential, AI skill, flow-filter-token, managed-object API method, organization-axis business-unit grant, retired permission residue, seed and semantic-role provenance findings print one verdict line, `os explain <rule-id>` carries their reasoning, and `field-no-consumers` stops listing every object that shares a field name
6+
7+
Clause-②: no
8+
9+
- **Shorter verdicts.** Each finding of these 10 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 194 characters, and the runtime publish gate's suite at most 181; before, the longest of each ran from 222 to 441 characters. The ids:
10+
- flows: `flow-credential-literal` (a literal credential in an `http` node's headers or url query, or in a `connectorConfig.input`) and `flow-filter-token-unknown` (a `{NAME()}` filter token neither the flow template dialect nor the filter placeholders define);
11+
- AI agents and skills: `ai-skill-surface-mismatch` and `ai-skill-tool-unresolved`;
12+
- managed objects: `object/managed-api-method-unaffordable`;
13+
- sharing rules: `org-axis-cross-org-bu-grant` (its sibling `org-axis-permission-inheritance` already printed one sentence of at most 174 characters and is unchanged);
14+
- permission sets: `permission-retired-lifecycle-residue`;
15+
- seed datasets: `seed-insert-mode-duplicates-on-replay` and `seed-value-outside-state-machine`;
16+
- semantic roles: `semantic-role-field-unprovisioned`.
17+
18+
A verdict no longer repeats what the finding's `where` already names: the skill (`ai-skill-tool-unresolved` now opens on the tool), the managed object, the sharing rule's object and the semantic-role object. `flow-credential-literal` keeps a short route (`move it to a connector's \`auth.credentialRef\``, or `drop it` for a connector input) because `os lint` prints the fix only under `--fix`; the route by auth variant stays the fix line's. `seed-value-outside-state-machine` names the first three declared states, then `(and N more)`. `object/managed-api-method-unaffordable` no longer appends the spec's `describeManagedApiMethodConflicts` sentence; it names the refused verbs, the `managedBy` bucket and the affordances they need. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead.
19+
- **`field-no-consumers`** names the first other object that declares a field of the same name and counts the rest (`a consumer of the same name on "showcase_account" (and 9 more) does not count`), where it listed every one. On `os validate` of the showcase app its longest finding drops from 341 characters to 199, and that 341-character one, which named ten objects, now prints 143. Its `inert` and `carrier-only` verdicts and its explanation are otherwise unchanged.
20+
- **`os explain <rule-id>` takes these 10 ids**, for example `os explain flow-credential-literal`. It prints the reasoning the verdicts no longer carry: which flow positions are served to every flow reader and where a credential belongs, by auth variant; how an agent's and a skill's surfaces must match, and why an unresolved skill name is skipped; how a skill's tool name resolves and when an action materialises as a tool; the two vocabularies a flow filter token resolves in, and why a miss stops the node; which affordance each write verb needs and what the registry does with a refused one; why business-unit trees stay inside one organization and which recipients may share a platform-global object; why the retired `allowRestore` / `allowPurge` default is accepted in silence and which channels miss it; why seeds must be safe to replay and which states a seeded value may name; and why an injected column on an external object is blank. The `rule:` line under each of these findings now ends with `` — `os explain <rule-id>` for … ``. The no-argument listing and its `--json` `rules` array list the 10 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 10 entries.
21+
- **Where the new text prints.** On the CLI, all 11 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs when it compiles at startup, and on each watch recompile under `--verbose`), `os lint` (as `where: message`) and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `os verify` prints the error-severity ids' new text in its refusal list (`ai-skill-surface-mismatch`, `flow-filter-token-unknown`, `object/managed-api-method-unaffordable`, `org-axis-cross-org-bu-grant`) and counts the warnings without printing them. At the runtime publish gate (Studio, REST `/meta`, MCP): a `flow` write's `flow-filter-token-unknown` and an `object` write's `object/managed-api-method-unaffordable` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `flow` write's `flow-credential-literal` and a `permission` write's `permission-retired-lifecycle-residue` change the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged.
22+
- **Never at the runtime gate:** `org-axis-cross-org-bu-grant`, the two seed ids, `semantic-role-field-unprovisioned` and `field-no-consumers` (CLI-only rules), and the two AI ids, whose rules run at that door only for a `flow` write, whose snapshot carries no agents or skills of their own.

‎packages/lint/src/lint-flow-credential-literals.test.ts‎

Lines changed: 119 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,27 @@ import {
2727
authoringRulesFor,
2828
runAuthoringRules,
2929
} from './authoring-rules.js';
30-
import { FLOW_CREDENTIAL_LITERAL, lintFlowCredentialLiterals } from './lint-flow-credential-literals.js';
30+
import {
31+
FLOW_CREDENTIAL_LITERAL,
32+
lintFlowCredentialLiterals as lintFlowCredentialLiteralsUnrecorded,
33+
} from './lint-flow-credential-literals.js';
34+
import { explainRule } from './rule-explanations.js';
3135
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
3236
import * as barrel from './index.js';
3337

38+
// [#22161] Each finding is one verdict sentence; the reasoning it used to
39+
// carry is `os explain flow-credential-literal`. Every direct call below
40+
// records what it fired, and the last cases in this file hold each recorded
41+
// verdict to one line of at most 200 characters — every firing variant this
42+
// suite exercises, not a chosen few. Run the whole file: those cases read what
43+
// the cases above fired.
44+
const fired: Array<{ rule: string; message: string }> = [];
45+
const lintFlowCredentialLiterals: typeof lintFlowCredentialLiteralsUnrecorded = (...args) => {
46+
const findings = lintFlowCredentialLiteralsUnrecorded(...args);
47+
fired.push(...findings);
48+
return findings;
49+
};
50+
3451
/** The probe sentinels. Each names the position it stands in. */
3552
const S = {
3653
controlHeader1: 'control-header-literal-1',
@@ -201,21 +218,23 @@ describe('flow-credential-literal — the pin set', () => {
201218
expect(header.where).toBe("flow 'scan_control' · node 'h' (http)");
202219
expect(header.message).toContain("header 'X-Api-Key'");
203220
expect(header.message).toContain('every member who can read flows');
204-
// The route rides the MESSAGE: the CLI text faces print `where: message`
205-
// and leave the hint to `--json`.
221+
// [#22161] A short route rides the MESSAGE: `os lint`'s text face prints
222+
// the hint only under `--fix`. The route by auth variant is the hint's
223+
// (the `fix:` line `os validate` and `os build` print).
224+
expect(header.message).toContain("move it to a connector's `auth.credentialRef`");
206225
// Routed by shape, to an auth variant the connector schema really has.
207-
expect(header.message).toContain('`bearer` or `api-key` (a header), with `auth.credentialRef`');
226+
expect(header.hint).toContain("type: 'bearer', credentialRef");
208227
expect(header.hint).toContain("type: 'api-key', headerName, credentialRef");
209228
expect(header.hint).toContain('connector_action');
210229

211230
const url = byPath.get('flows[2].nodes[1].config.url')!;
212231
expect(url.message).toContain("query parameter 'api_key'");
213-
expect(url.message).toContain('`api-key` with `paramName`');
232+
expect(url.message).toContain("move it to a connector's `auth.credentialRef`");
214233
expect(url.hint).toContain("type: 'api-key', paramName, credentialRef");
215234

216235
const input = byPath.get('flows[2].nodes[3].connectorConfig.input.auth.clientSecret')!;
217236
expect(input.message).toContain("connector input 'auth.clientSecret'");
218-
expect(input.message).toContain('authenticates through its own `auth.credentialRef`');
237+
expect(input.message).toContain('the connector authenticates through `auth.credentialRef`');
219238
expect(input.hint).toContain('credentialRef');
220239

221240
const nested = byPath.get('flows[2].nodes[2].config.try.nodes[0].config.headers.Authorization')!;
@@ -358,7 +377,100 @@ describe('flow-credential-literal — registered once, at every door, as an advi
358377

359378
it('exports the rule and its id from the package entry', () => {
360379
const b = barrel as Record<string, unknown>;
361-
expect(b.lintFlowCredentialLiterals).toBe(lintFlowCredentialLiterals);
380+
expect(b.lintFlowCredentialLiterals).toBe(lintFlowCredentialLiteralsUnrecorded);
362381
expect(b.FLOW_CREDENTIAL_LITERAL).toBe(FLOW_CREDENTIAL_LITERAL);
363382
});
364383
});
384+
385+
describe('[#22161] one-line verdicts — flow-credential-literal', () => {
386+
it('reads as one sentence per position, the short route included', () => {
387+
const byPath = new Map(lintFlowCredentialLiterals(controlStack()).map((f) => [f.path, f.message]));
388+
expect(byPath.get('flows[0].nodes[0].config.headers["X-Api-Key"]')).toBe(
389+
"header 'X-Api-Key' holds a credential-shaped literal, readable by every member who can read flows; "
390+
+ "move it to a connector's `auth.credentialRef`",
391+
);
392+
expect(byPath.get('flows[2].nodes[1].config.url')).toBe(
393+
"url query parameter 'api_key' holds a credential-shaped literal, readable by every member who can read "
394+
+ "flows; move it to a connector's `auth.credentialRef`",
395+
);
396+
expect(byPath.get('flows[2].nodes[3].connectorConfig.input.auth.clientSecret')).toBe(
397+
"connector input 'auth.clientSecret' holds a credential-shaped literal, readable by every member who can "
398+
+ 'read flows; drop it: the connector authenticates through `auth.credentialRef`',
399+
);
400+
});
401+
402+
it('`os explain` carries what the verdict no longer says', () => {
403+
const entry = explainRule(FLOW_CREDENTIAL_LITERAL);
404+
expect(entry, 'no `os explain flow-credential-literal` entry').toBeDefined();
405+
const text = entry!.paragraphs.join('\n');
406+
for (const fact of [
407+
'served, as authored, to every member who can read flows',
408+
'cannot withhold a value here',
409+
'ADR-0097 §3',
410+
'`bearer` or a header `api-key`',
411+
'`api-key` with `paramName`',
412+
'No variant carries a secret in a url path',
413+
'a heuristic',
414+
'A `{variable}` template is not a literal',
415+
'never the value itself',
416+
]) {
417+
expect(text, `explanation names ${fact}`).toContain(fact);
418+
}
419+
});
420+
421+
it('each position and each region kind the explanation names fires', () => {
422+
// The explanation module imports nothing, so its positions are held to the
423+
// rule by running the rule: one probe per position, top level and inside
424+
// each region kind it names.
425+
const text = explainRule(FLOW_CREDENTIAL_LITERAL)!.paragraphs.join('\n');
426+
for (const named of ['`config.headers`', '`config.url`', '`connectorConfig.input`', '`try_catch`', '`loop`', '`parallel`']) {
427+
expect(text, named).toContain(named);
428+
}
429+
const call = (id: string) => ({
430+
id,
431+
type: 'http',
432+
config: { url: 'https://x.invalid/p?api_key=probe-url-sentinel', headers: { Authorization: 'Bearer probe-sentinel' } },
433+
});
434+
const stack = {
435+
flows: [
436+
{
437+
name: 'regions',
438+
nodes: [
439+
{ id: 'loop', type: 'loop', config: { collection: '{items}', body: { nodes: [call('in_loop')], edges: [] } } },
440+
{
441+
id: 'par',
442+
type: 'parallel',
443+
config: { branches: [{ nodes: [call('in_branch')], edges: [] }] },
444+
},
445+
{
446+
id: 'conn',
447+
type: 'connector_action',
448+
connectorConfig: { connectorId: 'rest', actionId: 'request', input: { token: 'probe-input-sentinel' } },
449+
},
450+
],
451+
edges: [],
452+
},
453+
],
454+
};
455+
const paths = lintFlowCredentialLiterals(stack).map((f) => f.path);
456+
expect(paths).toEqual([
457+
'flows[0].nodes[0].config.body.nodes[0].config.headers.Authorization',
458+
'flows[0].nodes[0].config.body.nodes[0].config.url',
459+
'flows[0].nodes[1].config.branches[0].nodes[0].config.headers.Authorization',
460+
'flows[0].nodes[1].config.branches[0].nodes[0].config.url',
461+
'flows[0].nodes[2].connectorConfig.input.token',
462+
]);
463+
});
464+
465+
it('every verdict the cases in this file fired is one line of at most 200 characters', () => {
466+
const converted = fired.filter((f) => f.rule === FLOW_CREDENTIAL_LITERAL);
467+
// The coverage control first: all three positions fired.
468+
for (const opening of ['header ', 'url query parameter ', 'connector input ']) {
469+
expect(converted.some((f) => f.message.startsWith(opening)), opening).toBe(true);
470+
}
471+
for (const f of converted) {
472+
expect(f.message, f.rule).not.toContain('\n');
473+
expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200);
474+
}
475+
});
476+
});

‎packages/lint/src/lint-flow-credential-literals.ts‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -68,30 +68,27 @@ export interface FlowCredentialLiteralFinding {
6868
hint: string;
6969
}
7070

71-
/** Why the value is exposed — the same sentence for all three positions. */
72-
const SERVED =
73-
'A flow definition is served, as authored, to every member who can read flows, so this value is ' +
74-
'readable by all of them';
71+
/**
72+
* [#22161] Who can read the value — the one clause all three positions share.
73+
* Why the read path cannot withhold it is `os explain flow-credential-literal`.
74+
*/
75+
const SERVED = 'readable by every member who can read flows';
7576

7677
/**
77-
* The route, in the MESSAGE and not only in the hint: `os validate` and `os lint`
78-
* print `where: message` on their text faces and leave the hint to `--json`, so
79-
* a route stated only in the hint never reaches the author who reads the terminal.
78+
* The route, short, in the VERDICT as well as in the hint: `os validate` and
79+
* `os build` print the hint as their `fix:` line, but `os lint`'s text face
80+
* prints it only under `--fix`, so a route stated only in the hint misses the
81+
* author who runs `os lint`.
8082
*
81-
* Routed BY SHAPE, to a variant `ConnectorInstanceAuthSchema` really has
82-
* (`none` / `bearer` / `api-key` / `basic`): a header credential goes to
83-
* `bearer` or a header `api-key`, a query-string key to `api-key` with
83+
* The hint carries the route BY SHAPE, to a variant `ConnectorInstanceAuthSchema`
84+
* really has (`none` / `bearer` / `api-key` / `basic`): a header credential goes
85+
* to `bearer` or a header `api-key`, a query-string key to `api-key` with
8486
* `paramName`. ⛔ No variant carries a secret in a url PATH, so no text here
8587
* may promise that `credentialRef` can hold one — an author cannot follow it.
8688
* (This rule does not judge url paths; it judges query parameters only.)
8789
*/
88-
const STEER_HEADER =
89-
"; move it to a declarative connector whose `auth` is `bearer` or `api-key` (a header), with " +
90-
'`auth.credentialRef` naming the secret, called from a `connector_action` node.';
91-
const STEER_QUERY =
92-
"; move it to a declarative connector whose `auth` is `api-key` with `paramName` (the query parameter), " +
93-
'with `auth.credentialRef` naming the secret, called from a `connector_action` node.';
94-
const STEER_INPUT = "; drop it from `input` — the connector authenticates through its own `auth.credentialRef`.";
90+
const STEER_CONNECTOR = "move it to a connector's `auth.credentialRef`";
91+
const STEER_INPUT = 'drop it: the connector authenticates through `auth.credentialRef`';
9592

9693
/** Where `credentialRef` resolves — stated once, in every hint. */
9794
const RESOLVES =
@@ -172,7 +169,10 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
172169
rule: FLOW_CREDENTIAL_LITERAL,
173170
where,
174171
path: entryPath,
175-
message: `${what} holds a literal value that reads as a credential. ${SERVED}${steer}`,
172+
// [#22161] One verdict sentence; the three positions, the route by
173+
// auth variant and why it only warns are
174+
// `os explain flow-credential-literal`.
175+
message: `${what} holds a credential-shaped literal, ${SERVED}; ${steer}`,
176176
hint: `${route}${NOT_A_GATE}`,
177177
});
178178
};
@@ -182,13 +182,13 @@ export function lintFlowCredentialLiterals(stack: AnyRec): FlowCredentialLiteral
182182
if (isRec(headers)) {
183183
for (const [name, value] of Object.entries(headers)) {
184184
if (!isCredentialShapedLiteral(name, value)) continue;
185-
report(`${path}.config.headers${keySegment(name)}`, `header '${name}'`, STEER_HEADER, HEADER_ROUTE);
185+
report(`${path}.config.headers${keySegment(name)}`, `header '${name}'`, STEER_CONNECTOR, HEADER_ROUTE);
186186
}
187187
}
188188
if (typeof url === 'string') {
189189
for (const [name, value] of queryParameters(url)) {
190190
if (!isCredentialShapedLiteral(name, value)) continue;
191-
report(`${path}.config.url`, `the url's query parameter '${name}'`, STEER_QUERY, QUERY_ROUTE);
191+
report(`${path}.config.url`, `url query parameter '${name}'`, STEER_CONNECTOR, QUERY_ROUTE);
192192
}
193193
}
194194
}

0 commit comments

Comments
 (0)