Skip to content

Commit ce8be40

Browse files
authored
fix(vscode): show an unconfigured Rstack lint config as not detected (#95)
Follow-up to #94 (#93). A bridged lint runtime whose root rstack.config.* has no define.lint() reported `running` with a detail, which the status bar hover rendered as a green check beside Rslint with the reason only in the icon tooltip. The status bar's word for "this tool has no configuration here" is `not-detected`, so the `unconfigured` verdict now maps to `{ kind: 'not-detected', detail }` and the reason is shown as a hover notice row. - `StackState['not-detected']` gains an optional `detail`; only a runtime-level "nothing configured" verdict sets it, detection never does, so every other hover is unchanged. - The status bar spells out the `not-detected` detail; one shared `stackStateDetail` replaces the two identical switches in the shell and the lint fold. - `liveRslintStatus` / `reportLive` (renamed from `running*`) keep the advisory-wins precedence; the worker stays up, no poll, no toast. - Fold: an unconfigured-only folder stays `not-detected` with its reason; a healthy native runtime beside it wins. - AGENTS.md adaptation 7 and the lint-shim gotcha record the design, including that the verdict lives with the runtime (an idle folder reads `running: idle` until a document opens).
1 parent 5514572 commit ce8be40

9 files changed

Lines changed: 140 additions & 83 deletions

File tree

‎packages/vscode/AGENTS.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎packages/vscode/e2e/lint/suite-fmt-only-config/fmtOnlyConfig.test.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import { extensionExports } from '../utils/extension';
1212

1313
// rstack's lint shim calls `process.exit(1)` when the Rstack config has no
1414
// `define.lint()` (#93). The root config still lights the lint stack, so the
15-
// bridged runtime must survive the shim's refusal and report a healthy
16-
// runtime with nothing to lint, then pick up `define.lint()` through its
17-
// config watcher in the same worker.
15+
// bridged runtime must survive the shim's refusal and report `not-detected`
16+
// with the reason (the status bar's word for "no configuration here"), then
17+
// pick up `define.lint()` through its config watcher in the same worker.
1818

1919
function lintExports(): {
2020
getFolderStates(): ReadonlyMap<string, StackState>;
@@ -35,7 +35,7 @@ function lintStates(): StackState[] {
3535

3636
function isUnconfigured(state: StackState): boolean {
3737
return (
38-
state.kind === 'running' &&
38+
state.kind === 'not-detected' &&
3939
state.detail !== undefined &&
4040
state.detail.includes('define.lint()')
4141
);
@@ -113,7 +113,7 @@ suite('Rstack fmt-only config', function () {
113113
fs.rmSync(markerPath, { force: true });
114114
});
115115

116-
test('a formatting-only config keeps a healthy runtime and recovers on define.lint()', async () => {
116+
test('a formatting-only config reports not-detected and recovers on define.lint()', async () => {
117117
// Still formatting-only; the lint runtime starts on `didOpen`, so the
118118
// first shim evaluation records the lint worker's pid.
119119
fs.writeFileSync(configPath, configSource(markerPath, false), 'utf8');
@@ -122,9 +122,9 @@ suite('Rstack fmt-only config', function () {
122122
);
123123
await vscode.window.showTextDocument(document);
124124

125-
// Bare `running` appears before the initial config refresh settles;
126-
// only the detail proves the shim's refusal was classified.
127-
await waitForLintStates('the unconfigured running detail', (states) =>
125+
// Bare `running` appears before the initial config refresh settles; the
126+
// folder and its runtime must both show the classified refusal.
127+
await waitForLintStates('the unconfigured not-detected detail', (states) =>
128128
states.every(isUnconfigured),
129129
);
130130
assert.ok(fs.existsSync(markerPath), 'the lint worker never ran the shim');
@@ -145,7 +145,7 @@ suite('Rstack fmt-only config', function () {
145145
workerPid,
146146
'define.lint() must be picked up by the same lint worker, without a restart',
147147
);
148-
// A healthy state: logged at info, so no warning was recorded.
148+
// Not a failure: logged at info, so no warning was recorded.
149149
assert.deepStrictEqual(
150150
extensionExports().getRecordedWarnings('rslint'),
151151
[],

‎packages/vscode/src/stacks/lint/Rslint.ts‎

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import {
5151
} from './worker/configDependencyProtocol';
5252
import {
5353
RslintVersionMismatchError,
54-
runningRslintStatus,
54+
liveRslintStatus,
5555
statusForRslintStartFailure,
5656
} from './status';
5757
import {
@@ -322,7 +322,7 @@ export class Rslint implements Disposable {
322322
private readonly configDependencyEpisode = new NotInstalledEpisode();
323323
private configDependencyRetryPending = false;
324324
private configRefreshFailed = false;
325-
/** Holds the status detail while the bridged shim finds no `define.lint()` (#93). */
325+
/** Holds the `not-detected` detail while the bridged shim finds no `define.lint()` (#93). */
326326
private readonly unconfigured = new MessageLatch();
327327
private readonly configError = new MessageLatch();
328328
private startPromise: Promise<void> | undefined;
@@ -352,17 +352,18 @@ export class Rslint implements Disposable {
352352
this.reportStatus(state);
353353
}
354354

355-
private reportRunning(): void {
355+
private reportLive(): void {
356356
if (this.configRefreshFailed || this.hasConfigDependencyFailure()) return;
357-
this.report(runningRslintStatus(this.advisory, this.unconfigured.current));
357+
this.report(liveRslintStatus(this.advisory, this.unconfigured.current));
358358
}
359359

360360
private handleConfigDependencyStatus(
361361
notification: ConfigDependencyStatusNotification,
362362
): void {
363363
if (notification.kind === 'unconfigured') {
364-
// Healthy, nothing to lint: no poll, no warning, no crash. The bridged
365-
// config watcher re-runs the shim once `define.lint()` appears.
364+
// Nothing to lint: no poll, no warning, no crash. The worker stays up
365+
// and the bridged config watcher re-runs the shim once `define.lint()`
366+
// appears.
366367
this.configError.clear();
367368
this.configRefreshFailed = false;
368369
this.configDependencyEpisode.clear();
@@ -376,7 +377,7 @@ export class Rslint implements Disposable {
376377
`Rslint has nothing to lint: ${detail}. Add define.lint(...) to enable it.`,
377378
);
378379
}
379-
this.reportRunning();
380+
this.reportLive();
380381
return;
381382
}
382383
const wasUnconfigured = this.unconfigured.current !== undefined;
@@ -406,7 +407,7 @@ export class Rslint implements Disposable {
406407
if (notification.kind === 'ok') {
407408
const wasMissing = this.configDependencyEpisode.clear();
408409
if ((wasMissing || wasFailed || wasUnconfigured) && this.isRunning()) {
409-
this.reportRunning();
410+
this.reportLive();
410411
}
411412
return;
412413
}
@@ -543,7 +544,7 @@ export class Rslint implements Disposable {
543544
detail: 'the Rslint language server stopped',
544545
});
545546
} else if (event.newState === State.Running) {
546-
this.reportRunning();
547+
this.reportLive();
547548
}
548549
});
549550

@@ -602,7 +603,7 @@ export class Rslint implements Disposable {
602603
);
603604
}
604605
this.logger.info('Rslint language client started successfully');
605-
this.reportRunning();
606+
this.reportLive();
606607
} catch (error: unknown) {
607608
// Keep the initialized runtime available for configRefresh retries.
608609
// Rethrowing this classified rejection would make RuntimeManager close
@@ -632,7 +633,7 @@ export class Rslint implements Disposable {
632633
void configuredNodeBelowFloor(configured).then((message) => {
633634
if (message !== undefined && !this.closing) {
634635
this.advisory = message;
635-
if (this.isRunning()) this.reportRunning();
636+
if (this.isRunning()) this.reportLive();
636637
}
637638
});
638639
return configured;
@@ -726,13 +727,13 @@ export class Rslint implements Disposable {
726727
this.configRefreshFailed = false;
727728
try {
728729
await client.sendRequest('rslint/configRefresh', { reason });
729-
if (wasFailed && this.isRunning()) this.reportRunning();
730+
if (wasFailed && this.isRunning()) this.reportLive();
730731
} catch (error) {
731732
// The worker verdict already surfaced this rejection as a real config
732733
// error. Keep the live runtime for config edits without duplicate logs
733734
// or a generic startup failure replacing its precise status.
734735
// Source-change races must still reach the existing startup retry.
735-
// An unconfigured verdict is a healthy state, so Go rejecting that
736+
// An unconfigured verdict is not a failure, so Go rejecting that
736737
// refresh (it keeps a last-good catalog) is not reported either.
737738
if (
738739
isConfigSourceChangeDuringTransaction(error) ||

‎packages/vscode/src/stacks/lint/status.ts‎

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { StackState } from '../../types';
1+
import { type StackState, stackStateDetail } from '../../types';
22
import { formatNotInstalledStatus } from '../../shared/notInstalled';
33
import type { SupportedPackage } from '../../shared/versionCheck';
44
import { RslintResolutionError } from './resolution';
@@ -58,21 +58,21 @@ export const attributeToCore = (
5858
};
5959

6060
/**
61-
* An advisory wins over `detail`, which is the unconfigured bridge's
62-
* `no define.lint()` note: healthy with nothing to lint, so `running` plus a
63-
* detail like `idle`, not `disabled` — that would keep the dependency poll
64-
* re-evaluating an unchanged config (#93).
61+
* An advisory wins over everything: a configured Node below the floor is worth
62+
* fixing whatever the config says. `unconfigured` is the bridged shim's
63+
* `no define.lint()` note, reported as `not-detected` with that note as the
64+
* detail while the worker stays up (#93; AGENTS.md adaptation 7).
6565
*/
66-
export const runningRslintStatus = (
66+
export const liveRslintStatus = (
6767
advisory?: string,
68-
detail?: string,
68+
unconfigured?: string,
6969
): StackState => {
7070
if (advisory !== undefined) {
7171
return { kind: 'version-mismatch', detail: advisory };
7272
}
73-
return detail === undefined
73+
return unconfigured === undefined
7474
? { kind: 'running' }
75-
: { kind: 'running', detail };
75+
: { kind: 'not-detected', detail: unconfigured };
7676
};
7777

7878
/** A detected folder with no Lint runtime: `running` plus a detail, never a new kind (AGENTS.md, lint gotcha). */
@@ -85,6 +85,13 @@ const RSLINT_IDLE_DETAIL = 'idle';
8585
* this folder needs is not installed, so it will not lint" (`missingPackageOf`),
8686
* a fact worth showing over a healthy runtime or sibling folder — unlike the
8787
* shell's kill switch.
88+
*
89+
* A runtime's `not-detected` (the unconfigured bridge) ranks below `running`
90+
* on purpose: a folder that also has a healthy native runtime is linting, and
91+
* showing "nothing configured" for its bridged half instead would hide that.
92+
* Across folders the aggregate keeps details only from folders at the worst
93+
* kind, so beside a `running` sibling an unconfigured folder's reason does not
94+
* reach the hover.
8895
*/
8996
const STATE_RANK: Readonly<Record<StackState['kind'], number>> = {
9097
crashed: 5,
@@ -95,20 +102,6 @@ const STATE_RANK: Readonly<Record<StackState['kind'], number>> = {
95102
'not-detected': 0,
96103
};
97104

98-
const detailOf = (state: StackState): string | undefined => {
99-
switch (state.kind) {
100-
case 'crashed':
101-
case 'version-mismatch':
102-
case 'starting':
103-
case 'running':
104-
return state.detail;
105-
case 'disabled':
106-
return state.reason;
107-
case 'not-detected':
108-
return undefined;
109-
}
110-
};
111-
112105
const worstKind = (states: readonly StackState[]): StackState['kind'] =>
113106
states.reduce<StackState['kind']>(
114107
(worst, state) =>
@@ -143,7 +136,9 @@ export const foldRslintFolderState = (
143136
const kind = worstKind(states);
144137
return withDetail(
145138
kind,
146-
joinDetails(states.filter((state) => state.kind === kind).map(detailOf)),
139+
joinDetails(
140+
states.filter((state) => state.kind === kind).map(stackStateDetail),
141+
),
147142
);
148143
};
149144

@@ -172,7 +167,7 @@ export const aggregateFolderStates = (
172167
statuses
173168
.filter((entry) => entry.state.kind === kind)
174169
.map((entry) => {
175-
const detail = detailOf(entry.state);
170+
const detail = stackStateDetail(entry.state);
176171
if (!detail) return multiRoot ? entry.name : undefined;
177172
return multiRoot ? `${entry.name}: ${detail}` : detail;
178173
}),
@@ -202,6 +197,6 @@ const withDetail = (
202197
case 'disabled':
203198
return { kind: 'disabled', reason: detail };
204199
case 'not-detected':
205-
return { kind: 'not-detected' };
200+
return { kind: 'not-detected', detail };
206201
}
207202
};

‎packages/vscode/src/statusBar.ts‎

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
type StackState,
55
type StatusReporter,
66
STACK_IDS,
7+
// The hover renders a state's two halves apart: the kind as the icon, this as prose.
8+
stackStateDetail,
79
STACK_LABELS,
810
stackCommand,
911
stackCommandTitle,
@@ -56,7 +58,9 @@ const STATE_STYLES: Readonly<
5658
'not-detected': {
5759
icon: '$(circle-slash)',
5860
color: 'disabledForeground',
59-
spellsOutDetail: false,
61+
// Only set when a runtime found nothing to do, and then that detail is
62+
// the one thing telling the user the stack is not working on purpose.
63+
spellsOutDetail: true,
6064
severity: 0,
6165
},
6266
disabled: {
@@ -135,27 +139,6 @@ const tableColumns = (slots: number): number => 2 + slots;
135139
const CARD_WIDTH = 140;
136140
const CARD_WIDTH_WITH_NOTICES = 250;
137141

138-
/**
139-
* The free text a state carries, if any — a crash message, a version
140-
* complaint, a disable reason. It is its own function because the hover renders
141-
* the two halves of a state in different places — the kind is the icon, the
142-
* detail is prose — and the switch is exhaustive, so a state kind added to the
143-
* union has to say here whether it carries words.
144-
*/
145-
const stateDetail = (state: StackState): string | undefined => {
146-
switch (state.kind) {
147-
case 'not-detected':
148-
return undefined;
149-
case 'disabled':
150-
return state.reason;
151-
case 'starting':
152-
case 'running':
153-
case 'crashed':
154-
case 'version-mismatch':
155-
return state.detail;
156-
}
157-
};
158-
159142
/**
160143
* The one-line form: the state's kind, plus its detail when it has one. This
161144
* is what the log records and what the icon's native tooltip says, so its
@@ -166,7 +149,7 @@ const stateText = (state: StackState): string => {
166149
// The ids read as prose once their hyphen is a space ('version-mismatch' →
167150
// 'version mismatch'); no kind has a second one.
168151
const kind = state.kind.replace('-', ' ');
169-
const detail = stateDetail(state);
152+
const detail = stackStateDetail(state);
170153
return detail ? `${kind} — ${detail}` : kind;
171154
};
172155

@@ -388,7 +371,7 @@ export class StatusBar implements vscode.Disposable {
388371
// `stateText` embeds arbitrary text a stack produced, hence the escaping.
389372
const status = stateIcon(style, stateText(state));
390373
const detail = style.spellsOutDetail
391-
? stateDetail(state)?.trim()
374+
? stackStateDetail(state)?.trim()
392375
: undefined;
393376
if (detail) {
394377
notices.push({ style, label, detail });

‎packages/vscode/src/types.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,36 @@ export const stackCommandTitle = (stack: StackId): string =>
4747
* `disabled` covers every "we deliberately did not start" case: the kill-switch
4848
* settings, Restricted Mode, and phase-gated stacks. The reason is shown to the
4949
* user, so it must be a complete sentence fragment.
50+
*
51+
* `not-detected` carries a detail only to say why a runtime found nothing to
52+
* do after detection lit the stack; detection itself never sets one.
5053
*/
5154
export type StackState =
52-
| { readonly kind: 'not-detected' }
55+
| { readonly kind: 'not-detected'; readonly detail?: string }
5356
| { readonly kind: 'disabled'; readonly reason?: string }
5457
| { readonly kind: 'starting'; readonly detail?: string }
5558
| { readonly kind: 'running'; readonly detail?: string }
5659
| { readonly kind: 'crashed'; readonly detail: string }
5760
| { readonly kind: 'version-mismatch'; readonly detail: string };
5861

62+
/**
63+
* The free text a state carries, if any — a crash message, a version
64+
* complaint, a disable reason. The switch is exhaustive, so a state kind added
65+
* to the union has to say here whether it carries words.
66+
*/
67+
export const stackStateDetail = (state: StackState): string | undefined => {
68+
switch (state.kind) {
69+
case 'disabled':
70+
return state.reason;
71+
case 'not-detected':
72+
case 'starting':
73+
case 'running':
74+
case 'crashed':
75+
case 'version-mismatch':
76+
return state.detail;
77+
}
78+
};
79+
5980
/** Raw runtime failures that need dependency recovery, not shell gate states. */
6081
export const isFailedStackState = (
6182
kind: StackState['kind'] | 'stopped',

‎packages/vscode/tests/stacks/lint/start.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ function refreshConfig(runtime: Rslint, reason: string): Promise<void> {
374374
).requestConfigRefresh(reason);
375375
}
376376

377-
it('keeps a bridged runtime running with a detail when the shim finds no define.lint()', async () => {
377+
it('reports a bridged runtime as not-detected with a detail when the shim finds no define.lint()', async () => {
378378
refreshOutcome = 'unconfigured';
379379
const { runtime, states, warnings, errors, infos } = createRuntime({
380380
mode: 'bridged',
@@ -383,7 +383,7 @@ it('keeps a bridged runtime running with a detail when the shim finds no define.
383383
});
384384
runtime.setBridgeConfigPath('/project/rstack.config.ts');
385385
const unconfigured = {
386-
kind: 'running',
386+
kind: 'not-detected',
387387
detail: 'no define.lint() in rstack.config.ts',
388388
};
389389

@@ -396,7 +396,7 @@ it('keeps a bridged runtime running with a detail when the shim finds no define.
396396
expect(notices()).toEqual([
397397
'Rslint has nothing to lint: no define.lint() in rstack.config.ts. Add define.lint(...) to enable it.',
398398
]);
399-
// Healthy: the dependency poll has nothing to retry.
399+
// Not a failure: the dependency poll has nothing to retry.
400400
expect(runtime.retryConfigDependency()).toBeUndefined();
401401

402402
// An unchanged episode logs once; Go rejecting with a last-good catalog is

0 commit comments

Comments
 (0)