Skip to content
Merged
18 changes: 18 additions & 0 deletions .changeset/22161-lint-slice-2-one-line.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@objectstack/lint": patch
---

fix(lint): 26 more author-time findings print one verdict line, and `os explain <rule-id>` carries their reasoning

Clause-②: no

- **Shorter verdicts.** Each finding of these 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids:
- dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`;
- datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`;
- security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`;
- visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`.

The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. 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.
- **`os explain <rule-id>` takes these 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. 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 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 entries.
- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged.
- **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call).
478 changes: 478 additions & 0 deletions packages/lint/src/rule-explanations.ts

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions packages/lint/src/runtime-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,8 +923,10 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
expect(f!.severity).toBe('warning');
expect(f!.path).toBe('dashboards[0].widgets[0]');
expect(f!.message).toMatch(/not_a_dim/);
// The message names the refusal, not a query that never runs.
expect(f!.message).toContain('ignores an authored axis `field`');
// The message names the refusal, not a query that never runs. [#22161]
// The one-line verdict says the key is ignored; HOW the renderer strips
// it is `os explain chart-field-unknown`.
expect(f!.message).toContain('is ignored anyway');
expect(f!.message).not.toContain('will not contain');
// And the rule genuinely ran, rather than the door skipping the type.
expect(result.rulesRun).toContain('validateWidgetBindings');
Expand Down
14 changes: 14 additions & 0 deletions packages/lint/src/system-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,20 @@ export function unprovisionedAnchorCause(objectName: string, field: string): str
);
}

/**
* [#22161] The SHORT form of {@link unprovisionedAnchorCause}, for a rule whose
* finding is one verdict sentence: it names the same column and object and
* says the same thing — an injected anchor with no storage behind it — in one
* clause, and the rule's `os explain` entry (`rule-explanations.ts`) carries
* the long cause (ADR-0015 federation, who owns the schema, why the anchor is
* registered unprovisioned). One wording for every converted rule, so the
* drift the long form's note warns about cannot reopen between them; a rule
* still on the long form converges here when its message is shortened.
*/
export function unprovisionedAnchorVerdict(objectName: string, field: string): string {
return `'${field}' is an injected column with no storage on external object '${objectName}'`;
}

/**
* The FIX clause paired with {@link unprovisionedAnchorCause} — the two ways
* out, in the order an author should consider them: vouch for the remote column
Expand Down
54 changes: 53 additions & 1 deletion packages/lint/src/validate-dataset-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,33 @@

import { describe, it, expect } from 'vitest';
import {
validateDatasetReferences,
validateDatasetReferences as validateDatasetReferencesUnrecorded,
DATASET_INCLUDE_UNKNOWN,
DATASET_FIELD_UNKNOWN,
DATASET_FIELD_NOT_INCLUDED,
DATASET_FILTER_FIELD_UNKNOWN,
} from './validate-dataset-references.js';
import { explainRule } from './rule-explanations.js';

// [#22161] Each finding of the rule ids this file's rule shortened is one
// verdict sentence; the reasoning it used to carry is the id's `os explain`
// entry. Every call below records what it fired, and the last case in this
// file holds each recorded verdict of those ids to one line of at most 200
// characters — so the pin covers every firing variant this suite exercises,
// not a chosen few. Run the whole file: that case reads what the cases above
// fired.
const SHORTENED_RULE_IDS: readonly string[] = [
DATASET_INCLUDE_UNKNOWN,
DATASET_FIELD_UNKNOWN,
DATASET_FIELD_NOT_INCLUDED,
DATASET_FILTER_FIELD_UNKNOWN,
];
const firedShortened: Array<{ rule: string; message: string }> = [];
const validateDatasetReferences: typeof validateDatasetReferencesUnrecorded = (...args) => {
const findings = validateDatasetReferencesUnrecorded(...args);
for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f);
return findings;
};

/**
* The object graph every case below resolves against. Deliberately a real
Expand Down Expand Up @@ -557,3 +578,34 @@ describe('validateDatasetReferences — the shipped dataset shapes', () => {
).toEqual([]);
});
});

describe('[#22161] one-line verdicts — the rule ids this file shortened', () => {
it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => {
// The coverage control first: each shortened id fired at least once, so
// the shape assertion below cannot pass over an empty record.
expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort());
for (const f of firedShortened) {
expect(f.message, f.rule).not.toContain('\n');
expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200);
}
});

// What each verdict stopped saying, which `os explain RULE_ID` now prints.
const MOVED: Record<string, readonly string[]> = {
[DATASET_INCLUDE_UNKNOWN]: ['Joins are COMPILED', 'ADR-0071', 'addresses nothing'],
[DATASET_FIELD_UNKNOWN]: ['compiled into the analytics query as written', 'empty or wrong numbers', 'nothing reports the miss', 'widget-dimension-unknown'],
[DATASET_FIELD_NOT_INCLUDED]: ['joins ONLY', 'empty or wrong numbers', 'nothing reports the miss'],
[DATASET_FILTER_FIELD_UNKNOWN]: ['widens the scope', 'empty or wrong numbers', 'filter-token-unknown'],
};

it('covers exactly the shortened ids', () => {
expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort());
});

it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict no longer says', (rule) => {
const explanation = explainRule(rule);
expect(explanation, `no \`os explain ${rule}\` entry`).toBeDefined();
const text = explanation!.paragraphs.join('\n');
for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact);
});
});
24 changes: 9 additions & 15 deletions packages/lint/src/validate-dataset-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,11 +163,10 @@ function strName(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}

/** The shared consequence sentence — why an unresolved path is not merely inert. */
const SILENT_EMPTY =
'The path is compiled into the analytics query as written, so it addresses a column ' +
'that does not exist: the surface renders successfully with empty or wrong numbers, ' +
'and nothing reports the miss.';
// [#22161] Each finding is one verdict sentence plus its fix. Why an unresolved
// path is not merely inert — it is compiled into the analytics query as written,
// and the surface renders successfully with empty or wrong numbers — is each
// rule id's long-form explanation (`rule-explanations.ts`, `os explain RULE_ID`).

/**
* Validate every ADR-0021 dataset's references against the object graph.
Expand Down Expand Up @@ -232,8 +231,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
message:
`include[${ii}] "${entry}" names a` +
`${type ? ` \`${type}\`` : 'n ordinary'} field on object "${verdict.object}", ` +
`not a relationship — no join can be derived from it, so every dimension or ` +
`measure written against that prefix addresses nothing.`,
`not a relationship, so no join is derived from it`,
hint: prescription,
});
return;
Expand All @@ -246,9 +244,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
rule: DATASET_INCLUDE_UNKNOWN,
where,
path,
message:
`${account.message} Joins are COMPILED from \`include\` (ADR-0021), so an entry ` +
`that resolves to nothing produces no join at all.`,
message: account.message,
hint: `${prescription} ${account.detail}`,
});
});
Expand Down Expand Up @@ -279,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
rule,
where: positionWhere,
path,
message: `${account.message} ${SILENT_EMPTY}`,
message: account.message,
hint: `${prescription} ${account.detail}`,
});
return;
Expand All @@ -300,10 +296,8 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
where: positionWhere,
path,
message:
`${subject} "${written}" resolves on the object graph, but its relationship ` +
`prefix "${prefix}" is not declared in this dataset's \`include\` — and ADR-0021 ` +
`joins ONLY declared paths, so no join is compiled and the column is out of the ` +
`query's reach. ${SILENT_EMPTY}`,
`${subject} "${written}" resolves, but its relationship prefix "${prefix}" is not ` +
`declared in this dataset's \`include\`, so no join reaches the column`,
hint:
`Add "${prefix}" to include (declaring "a.b" implicitly includes "a"), or bind ` +
`this position to a field on "${object}" itself. Declared include paths: ` +
Expand Down
Loading
Loading