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
25 changes: 25 additions & 0 deletions .changeset/cli-lint-strict-warnings-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@objectstack/cli": minor
---

`os lint --strict` makes warning-severity findings fail the run, so an app can rely on the platform's warning-level rules as its gate instead of re-implementing them locally (#15935)

Only an `error` failed `os lint` before. `packages/lint` ships ≈250 authoring rules, 119 of them at `warning`, and a run with any number of warnings and no errors exited 0 — so an app that wanted one of those rules to gate its CI had to re-implement it locally at error level, or bolt a script onto the JSON output to promote a family by hand.

New public flag: **`os lint --strict`**. With it, a run with one or more `warning`-severity findings exits 1 exactly as an `error` does, and the console says why, naming the count and the flag:

```
✗ 1 warning(s) fail this run under --strict (a warning is advisory without the flag)
```

`suggestion`s stay advisory under both. ⛔ The default is unchanged: without the flag the same stack still exits 0, and no existing `os lint` expectation moves.

The `--json` face carries the verdict so a gate can read it without re-deriving it from the counts. Two keys, unconditionally present on every project-lint payload, flag or no flag:

```json
{ "passed": false, "errors": 0, "warnings": 1, "suggestions": 0, "strict": true, "failing": 1 }
```

`strict` says whether the flag was in effect; `failing` is the count the exit code was read from — `errors`, or `errors + warnings` under `--strict`; and `passed` is `failing === 0`, the same statement the exit code makes — so `--strict --json` on a warning-only stack reads `passed: false` beside exit 1, never `passed: true` next to a failing exit.

Not in this change: per-rule severity configuration, any change to a rule's severity, and `--eval` mode, which keeps its own pass bar (`--eval-min`).
13 changes: 13 additions & 0 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1351,6 +1351,7 @@ data-model conventions, translation coverage, with a 0-100 quality score.
os lint # Author-time rules + style / convention checks
os lint --score # Append a 0-100 metadata quality score (letter-graded)
os lint --fix # Show what would be fixed (dry-run)
os lint --strict # Warnings fail the run too (exit 1); suggestions stay advisory
os lint --json # JSON output for CI
```

Expand All @@ -1362,6 +1363,18 @@ before #4409: `os lint` ran one gating rule neither other command ran and missed
six that both of them ran, so it disagreed with the build in **both**
directions.

**What fails the run.** By default only an `error`-severity finding fails
`os lint` (exit 1); warnings and suggestions are printed and the exit code stays
0. `--strict` makes a run with one or more `warning`-severity findings exit 1
exactly as an error does, and says why — `N warning(s) fail this run under
--strict` — so an app can rely on the platform's warning-level rules as its gate
instead of re-implementing them locally; `suggestion`s stay advisory either way,
and the default is unchanged by the flag's existence. On the `--json` face the
verdict is readable without re-deriving it from the counts: `strict` (whether
the flag was in effect), `failing` (the count the exit code was read from —
`errors`, or `errors + warnings` under `--strict`) and `passed` (`failing` is
`0` — the same statement the exit code makes).

#### `os test`

Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server.
Expand Down
53 changes: 42 additions & 11 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,10 @@ export default class Lint extends Command {
static override flags = {
json: Flags.boolean({ description: 'Output as JSON' }),
fix: Flags.boolean({ description: 'Show what would be fixed (dry-run)' }),
strict: Flags.boolean({
description:
'Fail the run (exit 1) on warning-severity findings too, exactly as an error does; suggestions stay advisory. Without it only errors fail',
}),
score: Flags.boolean({
description: 'Print a 0–100 metadata-quality score (the lint rubric) for this project',
}),
Expand Down Expand Up @@ -612,17 +616,38 @@ export default class Lint extends Command {
// Metadata-quality score (the lint rubric expressed as 0–100).
const score = flags.score ? scoreMetadata(normalized) : null;

// ── Verdict ──
// Only an `error` fails a run by default. `--strict` (#15935) makes a
// `warning` fail it too — so an app can rely on the warning-level rules
// this registry ships as its gate instead of re-implementing them
// locally at error level — while a `suggestion` stays advisory under
// both. `failing` is the ONE count the exit code is read from, computed
// here, above the two faces, so `--json` and the console cannot disagree
// about it. ⛔ The default is deliberately unchanged: promoting warnings
// for every app is a separate decision, not this flag's.
const strict = flags.strict ?? false;
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
const suggestions = issues.filter((i) => i.severity === 'suggestion');
const failing = errors.length + (strict ? warnings.length : 0);

// ── JSON output ──
if (flags.json) {
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
const suggestions = issues.filter((i) => i.severity === 'suggestion');
await emitJson({
passed: errors.length === 0,
passed: failing === 0,
total: issues.length,
errors: errors.length,
warnings: warnings.length,
suggestions: suggestions.length,
// [#15935] The verdict, readable without re-deriving it from the
// counts: `strict` says whether the flag was in effect, `failing`
// is the count the exit code was read from — `errors`, or
// `errors + warnings` under `--strict` — and `passed` is
// `failing === 0`, the same statement the exit code makes. Both
// keys are unconditionally present so a gate keying off them never
// has to distinguish "not strict" from "this build does not say".
strict,
failing,
...(hiddenPlatform > 0 ? { hiddenPlatform } : {}),
...(score ? { score: score.score, grade: score.grade } : {}),
issues,
Expand All @@ -635,7 +660,7 @@ export default class Lint extends Command {
// distinguish "did not convert" from "this command does not tell me".
conversions: conversionNotices,
duration: timer.elapsed(),
}, errors.length > 0 ? 1 : 0);
}, failing > 0 ? 1 : 0);
return;
}

Expand All @@ -659,11 +684,6 @@ export default class Lint extends Command {
return;
}

// Group by severity
const errors = issues.filter((i) => i.severity === 'error');
const warnings = issues.filter((i) => i.severity === 'warning');
const suggestions = issues.filter((i) => i.severity === 'suggestion');

const printIssue = (issue: LintIssue) => {
const color =
issue.severity === 'error' ? chalk.red :
Expand Down Expand Up @@ -714,9 +734,20 @@ export default class Lint extends Command {
printInfo('Dry-run mode: no files were modified.');
}

// A run that fails ONLY because of `--strict` says so, naming the count
// and the flag: the summary line above reads identically with and
// without the flag, and exit 1 under a heading that says "Warnings" is
// otherwise a verdict with no stated reason.
if (strict && warnings.length > 0) {
console.log('');
printError(
`${warnings.length} warning(s) fail this run under --strict (a warning is advisory without the flag)`,
);
}

console.log('');

if (errors.length > 0) process.exit(1);
if (failing > 0) process.exit(1);

} catch (error: any) {
if (isExitSignal(error)) throw error;
Expand Down
Loading
Loading