-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathvalidate.ts
More file actions
650 lines (616 loc) · 33.7 KB
/
Copy pathvalidate.ts
File metadata and controls
650 lines (616 loc) · 33.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { Args, Command, Flags } from '@oclif/core';
import { dirname } from 'node:path';
import chalk from 'chalk';
import { ZodError } from 'zod';
import {
ObjectStackDefinitionSchema,
normalizeStackInput,
lintUnknownAuthoringKeys,
lintUnknownStackKeys,
formatUnknownAuthoringKey,
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
import {
printHeader,
printKV,
printSuccess,
printError,
printStep,
formatConversionNotice,
printAuthoringRuleErrors,
printDocIssueErrors,
JSON_FULL_LIST_REMEDY,
createTimer,
formatZodErrors,
collectMetadataStats,
printMetadataStats,
printWarning,
printBulletList,
emitJson,
isExitSignal,
errorCodeFields,
isReportedError,
} from '../utils/format.js';
import { checkProtocolVersionGap } from '../utils/protocol-version-gap.js';
// [#14553] The navigation-contribution group check, shared with `os compile`.
// Reports; never refuses — the runtime still relocates, deliberately.
import { findNavGroupDiagnostics } from '../utils/nav-contribution-groups.js';
import type { NavContributionGroupDiagnostic } from '@objectstack/objectql';
export default class Validate extends Command {
static override description =
'Validate ObjectStack configuration against the protocol schema, CEL expressions, and widget bindings (no artifact emitted)';
static override args = {
config: Args.string({ description: 'Configuration file path', required: false }),
};
static override flags = {
strict: Flags.boolean({ description: 'Treat warnings as errors' }),
json: Flags.boolean({ description: 'Output results as JSON' }),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Validate);
const timer = createTimer();
if (!flags.json) {
printHeader('Validate');
}
// [#12047] THE ADVISORY LISTS THIS RUN HAS COMPUTED SO FAR, hoisted out of
// the `try` so that EVERY `emitJson` exit can read them — not the terminal
// success payload alone.
//
// The defect: all five failure exits published strictly less than the run
// had already computed. Two carried `ruleAdvisories` and nothing else; the
// other three carried no advisory list at all. The text face prints these
// blocks ending `— re-run with --json for the full list`, so an author
// whose tree failed a LATER gate was told to re-run with `--json` and got
// a payload without the withheld entries in it — the "the remedy named is
// unreachable" shape of #11643 and #11391.
//
// The strongest instance is the parse-failure exit. `unknownKeyWarnings`
// is computed PRE-parse (see its own note below) precisely so the finding
// survives an unrelated schema error — and then that exit dropped it
// anyway, defeating the one hoist that existed to prevent exactly this.
//
// Maintainer ruling 2026-08-25 on #11772, inherited here under the
// same-family rule: every failure exit carries the lists the run has
// ALREADY COMPUTED, so `warnings` means the same thing on every exit and a
// machine consumer has exactly one way to read it. Option 2 — carry them
// only where the text face printed them, making the payload's SHAPE depend
// on how far the run got — was rejected as the hardest contract to
// declare. Option 3 (weaken the pointer) was rejected as making the
// product worse.
//
// ⛔ CARRYING, NOT COMPUTING. Every list stays computed at exactly the step
// that owns it; these bindings only make the value visible to the exits
// DOWNSTREAM of that step. An exit that runs before a given step therefore
// still reports that list empty, and that is the honest reading of "what
// the run has already computed". Hoisting a computation earlier so an
// early exit looks fuller would be option 2 wearing option 1's clothes,
// and it would change what the command costs on its failure paths too.
//
// ⛔ `structuralWarnings` is the member that is measured, not assumed. It
// is computed LAST — below every one of the five failure exits — so it
// rides `warningsSoFar()` as an empty list on all of them, and the only
// exit that can ever see it non-empty is the success payload. It is a
// member of the same class as the other four (a non-blocking advisory
// about the stack, gated by `--strict`, already in the success payload's
// `warnings`); it differs only in WHEN it becomes available, which is the
// same axis `docWarnings` and `capProviderWarnings` already differ on. It
// is included here rather than special-cased so the order lives at ONE
// site — ⛔ do not "fix" its emptiness by moving its computation up.
//
// ORDER IS THE SUCCESS PAYLOAD'S, stated ONCE here and read by that
// payload too — the "one list cannot drift from itself" idiom this file
// has already had to apply three times. The spread used to be written out
// at the payload, so a seventh exit could have been added with a different
// member order and nothing would have caught it.
// Typed off `splitBySeverity` rather than by naming `AuthoringFinding`: the
// #4409 import scan (packages/lint/src/authoring-rule-wiring.test.ts) reads
// every symbol this file names from `@objectstack/lint` and strips `type `
// rather than exempting it, and `splitBySeverity` — which produces this
// list — is already ratcheted there. Binding the annotation to the producer
// is also the tighter statement: the list cannot disagree with the function
// that fills it.
let ruleAdvisories: ReturnType<typeof splitBySeverity>['advisories'] = [];
let capProviderWarnings: Array<{ token: string; message: string }> = [];
let unknownKeyWarnings: string[] = [];
let docWarnings: DocIssue[] = [];
let structuralWarnings: string[] = [];
// [#14553] Computed HERE as well as in `os compile`, not only there. The
// #11727 residue pin asserts that nothing rides in build's `warnings` that
// validate does not also report, and the two commands being one wall with
// two doors is the #4409 / #4463 discipline this list already follows.
let navGroupWarnings: NavContributionGroupDiagnostic[] = [];
const warningsSoFar = () => [
...ruleAdvisories,
...docWarnings,
...unknownKeyWarnings,
...capProviderWarnings,
...structuralWarnings,
// [#14553] APPENDED, and the position is load-bearing. #12047's
// `the order lives at ONE site` pin matches the five members above as
// CONTIGUOUS source text — that is how it proves the order is defined
// once rather than re-spelled per exit. Slotting a sixth member (or even
// a comment) between them breaks that match, so a new member goes on the
// end and the pin keeps guarding exactly what it was written to guard.
// ⛔ Do not "fix" that pin by loosening its regex.
...navGroupWarnings,
];
// [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
// and under the SAME ruling as the five lists above — one field over. The
// notices were computed at step 2 (below) and reached the terminal SUCCESS
// payload alone, so all five failure exits dropped a list already in hand.
//
// ⛔ CARRYING, NOT COMPUTING — and here that is a pure SCOPE change. This is
// the same `const` array the `onConversionNotice` sink pushes into, moved
// above the `try` only so the catch-all exit can read it. `normalizeStackInput`
// still runs at exactly step 2, so a run that throws in `loadConfig` — above
// it — reports `[]` honestly, exactly as `warningsSoFar()` does there.
//
// ⛔ NOT FOLDED INTO `warningsSoFar()`, in either direction. The two fields
// are separate on the success payload by an explicit decision recorded at
// that call site: the text face folds these notices into its `⚠` block (so
// `--strict` gates on them) while the payload carries them under their own
// key with their structured `conversionId`/`retiresIn` fields intact — the
// one advisory class that carries an EXPIRY. Whether the two should become
// one field is an open question this change was explicitly not given the
// authority to settle, so the shape is mirrored, not merged.
//
// No `conversionsSoFar()` wrapper: `warningsSoFar()` exists because five
// producers had to be concatenated in ONE stated order, and this list has
// exactly one producer. Reading the binding directly already is the "a list
// cannot drift from itself" idiom the wrapper was built to buy.
const conversionNotices: ConversionNotice[] = [];
try {
// 1. Load configuration
if (!flags.json) printStep('Loading configuration...');
const { config, absolutePath, duration } = await loadConfig(args.config);
if (!flags.json) {
printKV('Config', absolutePath);
printKV('Load time', `${duration}ms`);
}
// 2. Normalize map-formatted stack definition and validate against schema.
// The ADR-0087 D2 conversion layer runs here (inside normalizeStackInput);
// surface each applied conversion as a non-blocking deprecation notice so
// the author knows the source still carries an old-shape key that will
// retire from the load path in a future major.
if (!flags.json) printStep('Validating against ObjectStack Protocol...');
// The sink is declared above the `try` (see its note there); the CALL that
// fills it stays right here, at the step that owns it.
const normalized = normalizeStackInput(config as Record<string, unknown>, {
onConversionNotice: (n) => conversionNotices.push(n),
});
// [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so drop
// silently. PRE-parse for the same reason the registry's `normalized`-tier
// rules are: the parse is what strips them, so `result.data` no longer
// carries the key the author actually wrote. Computed here rather than
// down in the warnings section so the `--json` path reports it too — the
// "computed, then discarded" shape this file already had to fix once.
unknownKeyWarnings = [
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
].map(formatUnknownAuthoringKey);
// 2b. [#16544] Lower inline `function` handlers (Hook.handler, action
// `target`, top-level `functions`) to a metadata `body` + string ref
// BEFORE the parse — the same `lowerCallables` call `os build` makes
// at its step 2b and `os lint` makes in `lintConfig`, not a copy.
//
// Every rule in the `hook-body-*` / `hook-api-update-readonly-*`
// family opens on `body.language === 'js'`. A hook authored as
// `handler: async (ctx) => { … }` carries no `body`, so on the
// un-lowered stack the whole family returned before reading
// anything, and this command passed (exit 0, no finding) a stack
// `os build` refuses with `hook-api-update-readonly-field` — the
// #3782 / #4409 class one door over, on the shape the reference app
// uses for 39 of 39 hooks. The body-authored control fired here all
// along, so the silence was the door, not the rule.
//
// POSITION IS LOAD-BEARING: after the two pre-parse unknown-key
// lints above, which keep reading `normalized` exactly as before,
// and before the parse, which now reads the lowered view. That is
// `compile.ts`'s lower-BEFORE-parse order exactly; its key lints sit
// AFTER its parse, so on both doors what protects the lints' input
// is non-mutation, not ordering: `lowerCallables` returns a NEW
// top-level object and never mutates its input, so `normalized` —
// the registry's `normalized` tier below, `collectMetadataStats(
// config)`, the structural advisories — is byte-for-byte what it
// was; only what the parse and the registry's `parsed` tier see
// changes.
//
// NOT A PURE NARROWING. The same pass also lowers an inline action
// `target` callable (`actions[*]`, `objects[*].actions[*]`) to a ref
// string plus `body`, and names a nameless `functions` ARRAY entry
// (`[{ handler: fn }]`) `anon_fn`. `ActionSchema.target` is
// `z.string()`, the array entry requires `name`, and
// `normalizeStackInput` touches neither, so before this step the
// un-lowered parse REFUSED both configs (`invalid_type` at
// `actions.0.target`; `invalid_union` at `functions`; exit 1) while
// `os build` accepted them all along. Both are accepted here now —
// accepted-set relaxations on this command, each measured through
// the real CLI on both sides and pinned in
// `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`. Not
// limbs: `hooks[*].handler` accepts a function un-lowered, and the
// `functions` MAP forms parse either way. Parity with the build is
// the intent, and it is declared rather than assumed because a
// sibling's acceptance is evidence of intent, not a declaration on
// this command's face.
//
// Nothing is emitted, so `lowering.functions` is unused here, and
// the extraction refusals in `bodyExtractionWarnings` are NOT
// surfaced: a handler the extractor refuses is left with no `body`
// on every door, the family stays silent on it, and the refusal is
// `os lint`'s `hook-body/*` rules' to report. Publishing it here
// would add a key to this command's `--json` payload, which is its
// own contract decision (`compile.ts` records why the key is
// build's alone). No step line is printed either: the text face is
// byte-for-byte what it was, and the docs transcripts stay true.
const { lowered } = lowerCallables(normalized as Record<string, unknown>);
const result = ObjectStackDefinitionSchema.safeParse(lowered);
if (!result.success) {
if (flags.json) {
await emitJson({
valid: false,
errors: (result.error as unknown as ZodError).issues,
// [#12047] The list computed at `unknownKeyWarnings` above — six
// lines up, and dropped here until now. This is the exit the card
// called the strongest instance: the hoist exists so the finding
// SURVIVES a schema error, and this payload discarded it anyway.
warnings: warningsSoFar(),
// [#12125] Filled by `normalizeStackInput` two statements above this
// exit — the tightest instance of this card, and the one it measured.
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
console.log('');
printError('Validation failed');
formatZodErrors(result.error as unknown as ZodError);
this.exit(1);
}
// 3. The author-time rule registry (#4409). Every rule the three authoring
// commands share — expressions, view shape, widget/action/filter/name
// references, SDUI styling, page sources, security posture, the CLI's
// own authoring lints — runs from ONE table, so `os validate`,
// `os build` and `os lint` hold a stack to the same bar by construction.
// Before it, each command hand-wired its own subset: 23 of 26 rules ran
// on some strict subset of the three, and `os build` — the command that
// PUBLISHES — was the weakest gate of the three.
//
// Which rules run, on which stack tier, and why any of them is scoped
// is declared in `lint/authoring-rules.ts`. Do not add a call site here.
const registered = authoringRulesFor('validate');
if (!flags.json) printStep(`Running author-time rules (${registered.length})...`);
const findings = runAuthoringRules('validate', {
normalized: normalized as Record<string, unknown>,
parsed: result.data as Record<string, unknown>,
sduiManifest: resolveSduiManifest(),
});
const { errors: ruleErrors, advisories } = splitBySeverity(findings);
ruleAdvisories = advisories;
if (ruleErrors.length > 0) {
// Every failing rule reports at once. The command used to exit at the
// first failing gate, so an author with three unrelated problems fixed
// them in three round trips and could not see how deep the hole went.
if (flags.json) {
await emitJson({
valid: false,
errors: ruleErrors,
// [#12047] Was `ruleAdvisories` alone. Reading the shared site adds
// the pre-parse `unknownKeyWarnings` — computed long before this
// gate — and keeps the member ORDER identical to every other exit.
warnings: warningsSoFar(),
// [#12125] Computed at step 2, above this gate.
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
console.log('');
printError(`Author-time rules failed (${ruleErrors.length} issue${ruleErrors.length > 1 ? 's' : ''})`);
// [#11642] The comment above is the reason this render may not be
// silently capped: reporting every failing rule at once is the whole
// point of the block, and a cut with no notice restores a smaller
// version of the round-trip it removed. `--json` on this same exit
// publishes all of them as `errors`, so the pointer resolves.
printAuthoringRuleErrors(ruleErrors, { remedy: JSON_FULL_LIST_REMEDY });
this.exit(1);
}
// 3b. [#3366] Installable-provider preflight — the shift-left of the
// `serve`-time capability check. `os validate` previously only checked
// the `requires` tokens against the vocabulary (ADR-0066), never
// whether each token's provider is resolvable in the active edition. A
// token whose provider has NO installable version here (e.g. `ai` →
// @objectstack/service-ai, cloud-only) fails; absent-but-installable is
// an advisory `pnpm add` hint. Mirrors the `os build` gate exactly.
//
// Not a registry rule: it reads `node_modules`, not the stack.
// [#14553] Navigation contributions whose `group` names no group in an
// app this same compilation unit ships. Reports, never refuses: the
// runtime relocates the items to the app's top level deliberately
// (the read-time fold stays order-independent, contributions into
// optional groups keep working), so what was missing was visibility,
// not a gate. A contribution aimed at an app no package here ships is
// NOT reported — that is the supported cross-artifact case.
navGroupWarnings = await findNavGroupDiagnostics(result.data as Record<string, unknown>);
if (navGroupWarnings.length > 0 && !flags.json) {
console.log('');
printWarning(
`Navigation contributions aimed at a group the target app does not declare ` +
`(${navGroupWarnings.length}) — the items still install, RELOCATED to the app's top level`,
);
printBulletList(
navGroupWarnings.map((d) => `[${d.code}] ${d.message} Fix: ${d.fix}`),
{ noun: 'navigation-contribution diagnostic' },
);
}
if (!flags.json) printStep('Checking capability providers (#3366)...');
const capProviderPreflight = preflightRequiredCapabilities({
requires: Array.isArray((config as { requires?: unknown[] }).requires)
? ((config as { requires?: unknown[] }).requires as unknown[])
: [],
projectDir: dirname(absolutePath),
});
const capProviderErrors = capProviderPreflight.errors;
capProviderWarnings = capProviderPreflight.warnings.map((c) => ({
token: c.token,
message: renderCapabilityMessage(c),
}));
if (capProviderErrors.length > 0) {
if (flags.json) {
await emitJson({
valid: false,
errors: capProviderErrors.map((c) => ({ token: c.token, message: renderCapabilityMessage(c) })),
// [#12047] The FATAL tokens ride `errors`; the advisory ones ride
// `warnings` beside the two lists computed before this gate. The
// two classes being separate is the whole point of the split.
warnings: warningsSoFar(),
// [#12125] Computed at step 2, above this gate.
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
console.log('');
printError(`Capability provider check failed (${capProviderErrors.length} issue${capProviderErrors.length > 1 ? 's' : ''})`);
for (const c of capProviderErrors) {
console.log(` • ${renderCapabilityMessage(c)}`);
}
this.exit(1);
}
// 3c. Package docs (ADR-0046) — flatness, namespace-prefixed names, the
// MDX/image ban, same-package link resolution. `os build` has always
// FAILED on a doc error (the artifact is the publish unit, so that is
// the publish lint for docs) while this command never ran it: the same
// "build rejects what validate accepts" hole #4409 found among the
// metadata rules, one gate over. It went unnoticed because the parity
// guard keyed on the `lint*`/`validate*` naming convention and this
// one is called `collectAndLintDocs`.
//
// Not a registry rule: it reads `src/docs/*.md` off disk.
if (!flags.json) printStep('Checking package docs (ADR-0046)...');
const docsResult = collectAndLintDocs(absolutePath, result.data as Record<string, unknown>);
const docErrors = docsResult.issues.filter((i) => i.severity === 'error');
docWarnings = docsResult.issues.filter((i) => i.severity !== 'error');
if (docErrors.length > 0) {
if (flags.json) {
await emitJson({
valid: false,
errors: docErrors,
// [#12047] Was `ruleAdvisories` alone, on the very exit that had
// the most computed: the doc advisories from this same call, the
// capability hints, and the pre-parse key findings were all in
// hand and none of them reached the payload.
warnings: warningsSoFar(),
// [#12125] Computed at step 2, above this gate.
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
console.log('');
printError(`Package docs validation failed (${docErrors.length} issue${docErrors.length > 1 ? 's' : ''})`);
// [#11642] `--json` on this same exit publishes them all as `errors`.
printDocIssueErrors(docErrors, { remedy: JSON_FULL_LIST_REMEDY });
this.exit(1);
}
// 4. Collect and display stats
const stats = collectMetadataStats(config);
// Protocol drift advisory (non-blocking): if the installed platform is a
// newer major than the app's declared `engines.protocol` range admits,
// point at the migration guide.
const protocolGap = checkProtocolVersionGap(config.manifest);
// 4b. Structural advisories (non-blocking) — computed HERE, above the
// `if (flags.json)` branch, for exactly the reason `unknownKeyWarnings`
// is computed up beside `normalized`: everything below that branch only
// ever feeds the text path. These four were in that state — printed for
// a human, structurally unreachable for `--json`, which is the one
// audience the flag exists for. A CI script gating on
// `os validate --json` advisories saw `warnings: []` however true the
// conditions were. Computed once and consumed by BOTH faces below, so
// the two cannot disagree by construction — the same "a single list
// cannot drift from itself" move this file already had to make twice.
structuralWarnings = [];
if (stats.objects === 0) {
structuralWarnings.push('No objects defined — this stack has no data model');
}
if (stats.apps === 0 && stats.plugins === 0) {
structuralWarnings.push('No apps or plugins defined — this stack may not do much');
}
if (!config.manifest?.id) {
structuralWarnings.push('Missing manifest.id — required for deployment');
}
if (!config.manifest?.namespace) {
structuralWarnings.push('Missing manifest.namespace — required for multi-app hosting');
}
// 5. Warnings (non-blocking) — assembled HERE, above the `if (flags.json)`
// branch, because this is the list `--strict` gates on and the JSON
// face has to reach the SAME verdict from it. It could not: the payload
// was emitted and `return`ed above the only `flags.strict` reader, so
// `os validate --json --strict` exited 0 on the very configs
// `os validate --strict` exited 1 for. The flag was accepted,
// documented (`content/docs/deployment/cli.mdx` spells the pair twice
// in its CI/CD section, once as a GitHub Actions step) and inert — a
// pipeline gating on the exit status of the documented invocation read
// 0 and called the stack clean.
//
// Hoisting the assembly rather than restating the condition is the same
// move `structuralWarnings` just above and `unknownKeyWarnings` up
// beside `normalized` already made, for the third time in this file:
// ONE list, consumed by both faces, so the two exit codes cannot drift
// from each other by construction. The push ORDER is unchanged, so the
// text face's warning output is byte-for-byte what it was.
const warnings: string[] = [];
// [#3366] Installable-provider hints — a declared capability whose provider
// is absent but addable (`pnpm add`), or an unknown token (typo).
for (const w of capProviderWarnings) {
warnings.push(w.message);
}
// [#3786] Undeclared object/field keys — computed pre-parse above,
// alongside `normalized`, for the same reason.
warnings.push(...unknownKeyWarnings);
// ADR-0087 D2 conversion notices: the source used a deprecated shape that
// was auto-converted at load. No action is required to keep loading, but
// the notice steers the author to the canonical key before it retires.
for (const n of conversionNotices) {
warnings.push(formatConversionNotice(n));
}
// Every advisory the registry raised. All of them feed `--strict` now:
// before, roughly half were printed inline and invisible to it, so
// `--strict` failed or passed depending on which gate happened to raise
// the finding — a second, quieter version of the same coverage drift.
for (const f of ruleAdvisories) {
warnings.push(`${f.where}: ${f.message}`);
}
for (const w of docWarnings) {
warnings.push(`${w.path}: ${w.message}`);
}
// The four structural advisories, computed further up so the `--json`
// payload can carry them too. Appended HERE, last, in the position the
// four inline `if` blocks used to occupy, so the text face's warning ORDER
// is byte-for-byte what it was.
warnings.push(...structuralWarnings);
if (flags.json) {
await emitJson(
{
valid: true,
manifest: config.manifest,
stats,
// One advisory list for the whole registry. This used to be a
// hand-maintained concatenation of per-gate arrays, and it leaked
// twice: warnings computed and then dropped from `--json` while the
// console printed them. A single list cannot drift from itself.
// [#12047] The spread that used to be written out here now lives
// at `warningsSoFar()` above, which every one of the six exits
// reads. Content is unchanged on this payload — what changed is
// that a seventh exit cannot be added with a different member
// order, and the five failure exits no longer publish less than
// this one.
warnings: warningsSoFar(),
conversions: conversionNotices,
// The payload key keeps its published name. The AXIS it reports
// moved from the undeclared `manifest.specVersion` to
// `manifest.engines.protocol` (#13860), but this is a machine face
// with pinned consumers, and renaming it is a break nobody asked
// for. Its value shape is unchanged.
specVersionGap: protocolGap,
duration: timer.elapsed(),
},
// `--strict` means one thing — "treat warnings as errors" — and it now
// means it on both faces. The gate reads `warnings`, the text face's
// OWN list, rather than the payload's `warnings` field: the two differ
// by the ADR-0087 conversion notices, which the text face folds into
// its `⚠` block while the payload carries them under `conversions`.
// Gating on the payload field would have left `--json --strict` at 0
// for a config whose only advisories are conversion notices — the
// same divergence one collection narrower. `specVersionGap` stays out
// on both faces; it is never gated by `--strict` (see below).
//
// `valid: true` beside a 1 is not a contradiction, it is the text
// face verbatim: that path prints "Validation passed" and THEN fails
// for strict. The stack IS schema-valid; `--strict` is what promotes
// its advisories to a failure.
//
// The status rides in `emitJson`'s `CliExitCode` slot rather than a
// following `this.exit(1)`, unlike the failure paths above: those
// must stop a fall-through into the text rendering, while here the
// payload is complete and the `return` is right there. The slot is
// the declared channel for pairing a `--json` document with the
// status the shell reads (`utils/format.ts`; pinned by
// `utils/format.exit-code.test.ts` and `test/migrate-exit-code.e2e.test.ts`),
// and it emits the one document without an ExitError unwinding
// through the catch below.
flags.strict && warnings.length > 0 ? 1 : 0,
);
return;
}
// 6. Display results
console.log('');
printSuccess(`Validation passed ${chalk.dim(`(${timer.display()})`)}`);
console.log('');
if (config.manifest) {
console.log(` ${chalk.bold(config.manifest.name || config.manifest.id || 'Unnamed')} ${chalk.dim(`v${config.manifest.version || '0.0.0'}`)}`);
if (config.manifest.description) {
console.log(chalk.dim(` ${config.manifest.description}`));
}
console.log('');
}
printMetadataStats(stats);
if (warnings.length > 0) {
console.log('');
for (const w of warnings) {
console.log(chalk.yellow(` ⚠ ${w}`));
}
// The text face's half of the `--strict` gate. Its JSON counterpart is
// the `CliExitCode` argument at the `emitJson` call above, reading this
// same `warnings` list — change one and change the other, or the two
// faces start disagreeing about the exit status again.
if (flags.strict) {
console.log('');
printError('Strict mode: warnings treated as errors');
this.exit(1);
}
}
// Non-blocking upgrade advisory — never gated by --strict.
if (protocolGap) {
console.log('');
console.log(chalk.yellow(` ⚠ ${protocolGap.message}`));
console.log(chalk.dim(` → ${protocolGap.hint}`));
}
console.log('');
} catch (error: any) {
if (isExitSignal(error)) throw error;
if (flags.json) {
await emitJson({
valid: false,
error: error.message,
...errorCodeFields(error),
// [#12047] Whatever the run had reached before the throw. A config
// that dies in `loadConfig` reports `[]` here honestly — nothing was
// computed yet — while a throw from a later step (a `src/docs` that
// is a FILE, say, which makes `readdirSync` raise ENOTDIR) carries
// the three lists already in hand.
warnings: warningsSoFar(),
// [#12125] Same reading, one field over: `[]` for a throw at load —
// step 2 had not run — and the notices in hand for any later throw.
conversions: conversionNotices,
duration: timer.elapsed(),
});
this.exit(1);
}
// [#15547] `resolveConfigPath()` already wrote its refusal and hint
// lines to stderr before throwing; printing the sentence again here
// would put a second copy on stdout.
if (!isReportedError(error)) {
console.log('');
printError(error.message || String(error));
}
this.exit(1);
}
}
}