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
6 changes: 3 additions & 3 deletions packages/vscode/AGENTS.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { extensionExports } from '../utils/extension';

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

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

function isUnconfigured(state: StackState): boolean {
return (
state.kind === 'running' &&
state.kind === 'not-detected' &&
state.detail !== undefined &&
state.detail.includes('define.lint()')
);
Expand Down Expand Up @@ -113,7 +113,7 @@ suite('Rstack fmt-only config', function () {
fs.rmSync(markerPath, { force: true });
});

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

// Bare `running` appears before the initial config refresh settles;
// only the detail proves the shim's refusal was classified.
await waitForLintStates('the unconfigured running detail', (states) =>
// Bare `running` appears before the initial config refresh settles; the
// folder and its runtime must both show the classified refusal.
await waitForLintStates('the unconfigured not-detected detail', (states) =>
states.every(isUnconfigured),
);
assert.ok(fs.existsSync(markerPath), 'the lint worker never ran the shim');
Expand All @@ -145,7 +145,7 @@ suite('Rstack fmt-only config', function () {
workerPid,
'define.lint() must be picked up by the same lint worker, without a restart',
);
// A healthy state: logged at info, so no warning was recorded.
// Not a failure: logged at info, so no warning was recorded.
assert.deepStrictEqual(
extensionExports().getRecordedWarnings('rslint'),
[],
Expand Down
27 changes: 14 additions & 13 deletions packages/vscode/src/stacks/lint/Rslint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
} from './worker/configDependencyProtocol';
import {
RslintVersionMismatchError,
runningRslintStatus,
liveRslintStatus,
statusForRslintStartFailure,
} from './status';
import {
Expand Down Expand Up @@ -322,7 +322,7 @@ export class Rslint implements Disposable {
private readonly configDependencyEpisode = new NotInstalledEpisode();
private configDependencyRetryPending = false;
private configRefreshFailed = false;
/** Holds the status detail while the bridged shim finds no `define.lint()` (#93). */
/** Holds the `not-detected` detail while the bridged shim finds no `define.lint()` (#93). */
private readonly unconfigured = new MessageLatch();
private readonly configError = new MessageLatch();
private startPromise: Promise<void> | undefined;
Expand Down Expand Up @@ -352,17 +352,18 @@ export class Rslint implements Disposable {
this.reportStatus(state);
}

private reportRunning(): void {
private reportLive(): void {
if (this.configRefreshFailed || this.hasConfigDependencyFailure()) return;
this.report(runningRslintStatus(this.advisory, this.unconfigured.current));
this.report(liveRslintStatus(this.advisory, this.unconfigured.current));
}

private handleConfigDependencyStatus(
notification: ConfigDependencyStatusNotification,
): void {
if (notification.kind === 'unconfigured') {
// Healthy, nothing to lint: no poll, no warning, no crash. The bridged
// config watcher re-runs the shim once `define.lint()` appears.
// Nothing to lint: no poll, no warning, no crash. The worker stays up
// and the bridged config watcher re-runs the shim once `define.lint()`
// appears.
this.configError.clear();
this.configRefreshFailed = false;
this.configDependencyEpisode.clear();
Expand All @@ -376,7 +377,7 @@ export class Rslint implements Disposable {
`Rslint has nothing to lint: ${detail}. Add define.lint(...) to enable it.`,
);
}
this.reportRunning();
this.reportLive();
return;
}
const wasUnconfigured = this.unconfigured.current !== undefined;
Expand Down Expand Up @@ -406,7 +407,7 @@ export class Rslint implements Disposable {
if (notification.kind === 'ok') {
const wasMissing = this.configDependencyEpisode.clear();
if ((wasMissing || wasFailed || wasUnconfigured) && this.isRunning()) {
this.reportRunning();
this.reportLive();
}
return;
}
Expand Down Expand Up @@ -543,7 +544,7 @@ export class Rslint implements Disposable {
detail: 'the Rslint language server stopped',
});
} else if (event.newState === State.Running) {
this.reportRunning();
this.reportLive();
}
});

Expand Down Expand Up @@ -602,7 +603,7 @@ export class Rslint implements Disposable {
);
}
this.logger.info('Rslint language client started successfully');
this.reportRunning();
this.reportLive();
} catch (error: unknown) {
// Keep the initialized runtime available for configRefresh retries.
// Rethrowing this classified rejection would make RuntimeManager close
Expand Down Expand Up @@ -632,7 +633,7 @@ export class Rslint implements Disposable {
void configuredNodeBelowFloor(configured).then((message) => {
if (message !== undefined && !this.closing) {
this.advisory = message;
if (this.isRunning()) this.reportRunning();
if (this.isRunning()) this.reportLive();
}
});
return configured;
Expand Down Expand Up @@ -726,13 +727,13 @@ export class Rslint implements Disposable {
this.configRefreshFailed = false;
try {
await client.sendRequest('rslint/configRefresh', { reason });
if (wasFailed && this.isRunning()) this.reportRunning();
if (wasFailed && this.isRunning()) this.reportLive();
} catch (error) {
// The worker verdict already surfaced this rejection as a real config
// error. Keep the live runtime for config edits without duplicate logs
// or a generic startup failure replacing its precise status.
// Source-change races must still reach the existing startup retry.
// An unconfigured verdict is a healthy state, so Go rejecting that
// An unconfigured verdict is not a failure, so Go rejecting that
// refresh (it keeps a last-good catalog) is not reported either.
if (
isConfigSourceChangeDuringTransaction(error) ||
Expand Down
47 changes: 21 additions & 26 deletions packages/vscode/src/stacks/lint/status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { StackState } from '../../types';
import { type StackState, stackStateDetail } from '../../types';
import { formatNotInstalledStatus } from '../../shared/notInstalled';
import type { SupportedPackage } from '../../shared/versionCheck';
import { RslintResolutionError } from './resolution';
Expand Down Expand Up @@ -58,21 +58,21 @@ export const attributeToCore = (
};

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

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

const detailOf = (state: StackState): string | undefined => {
switch (state.kind) {
case 'crashed':
case 'version-mismatch':
case 'starting':
case 'running':
return state.detail;
case 'disabled':
return state.reason;
case 'not-detected':
return undefined;
}
};

const worstKind = (states: readonly StackState[]): StackState['kind'] =>
states.reduce<StackState['kind']>(
(worst, state) =>
Expand Down Expand Up @@ -143,7 +136,9 @@ export const foldRslintFolderState = (
const kind = worstKind(states);
return withDetail(
kind,
joinDetails(states.filter((state) => state.kind === kind).map(detailOf)),
joinDetails(
states.filter((state) => state.kind === kind).map(stackStateDetail),
),
);
};

Expand Down Expand Up @@ -172,7 +167,7 @@ export const aggregateFolderStates = (
statuses
.filter((entry) => entry.state.kind === kind)
.map((entry) => {
const detail = detailOf(entry.state);
const detail = stackStateDetail(entry.state);
if (!detail) return multiRoot ? entry.name : undefined;
return multiRoot ? `${entry.name}: ${detail}` : detail;
}),
Expand Down Expand Up @@ -202,6 +197,6 @@ const withDetail = (
case 'disabled':
return { kind: 'disabled', reason: detail };
case 'not-detected':
return { kind: 'not-detected' };
return { kind: 'not-detected', detail };
}
};
31 changes: 7 additions & 24 deletions packages/vscode/src/statusBar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type StackState,
type StatusReporter,
STACK_IDS,
// The hover renders a state's two halves apart: the kind as the icon, this as prose.
stackStateDetail,
STACK_LABELS,
stackCommand,
stackCommandTitle,
Expand Down Expand Up @@ -56,7 +58,9 @@ const STATE_STYLES: Readonly<
'not-detected': {
icon: '$(circle-slash)',
color: 'disabledForeground',
spellsOutDetail: false,
// Only set when a runtime found nothing to do, and then that detail is
// the one thing telling the user the stack is not working on purpose.
spellsOutDetail: true,
severity: 0,
},
disabled: {
Expand Down Expand Up @@ -135,27 +139,6 @@ const tableColumns = (slots: number): number => 2 + slots;
const CARD_WIDTH = 140;
const CARD_WIDTH_WITH_NOTICES = 250;

/**
* The free text a state carries, if any — a crash message, a version
* complaint, a disable reason. It is its own function because the hover renders
* the two halves of a state in different places — the kind is the icon, the
* detail is prose — and the switch is exhaustive, so a state kind added to the
* union has to say here whether it carries words.
*/
const stateDetail = (state: StackState): string | undefined => {
switch (state.kind) {
case 'not-detected':
return undefined;
case 'disabled':
return state.reason;
case 'starting':
case 'running':
case 'crashed':
case 'version-mismatch':
return state.detail;
}
};

/**
* The one-line form: the state's kind, plus its detail when it has one. This
* is what the log records and what the icon's native tooltip says, so its
Expand All @@ -166,7 +149,7 @@ const stateText = (state: StackState): string => {
// The ids read as prose once their hyphen is a space ('version-mismatch' →
// 'version mismatch'); no kind has a second one.
const kind = state.kind.replace('-', ' ');
const detail = stateDetail(state);
const detail = stackStateDetail(state);
return detail ? `${kind} — ${detail}` : kind;
};

Expand Down Expand Up @@ -388,7 +371,7 @@ export class StatusBar implements vscode.Disposable {
// `stateText` embeds arbitrary text a stack produced, hence the escaping.
const status = stateIcon(style, stateText(state));
const detail = style.spellsOutDetail
? stateDetail(state)?.trim()
? stackStateDetail(state)?.trim()
: undefined;
if (detail) {
notices.push({ style, label, detail });
Expand Down
23 changes: 22 additions & 1 deletion packages/vscode/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,36 @@ export const stackCommandTitle = (stack: StackId): string =>
* `disabled` covers every "we deliberately did not start" case: the kill-switch
* settings, Restricted Mode, and phase-gated stacks. The reason is shown to the
* user, so it must be a complete sentence fragment.
*
* `not-detected` carries a detail only to say why a runtime found nothing to
* do after detection lit the stack; detection itself never sets one.
*/
export type StackState =
| { readonly kind: 'not-detected' }
| { readonly kind: 'not-detected'; readonly detail?: string }
| { readonly kind: 'disabled'; readonly reason?: string }
| { readonly kind: 'starting'; readonly detail?: string }
| { readonly kind: 'running'; readonly detail?: string }
| { readonly kind: 'crashed'; readonly detail: string }
| { readonly kind: 'version-mismatch'; readonly detail: string };

/**
* The free text a state carries, if any — a crash message, a version
* complaint, a disable reason. The switch is exhaustive, so a state kind added
* to the union has to say here whether it carries words.
*/
export const stackStateDetail = (state: StackState): string | undefined => {
switch (state.kind) {
case 'disabled':
return state.reason;
case 'not-detected':
case 'starting':
case 'running':
case 'crashed':
case 'version-mismatch':
return state.detail;
}
};

/** Raw runtime failures that need dependency recovery, not shell gate states. */
export const isFailedStackState = (
kind: StackState['kind'] | 'stopped',
Expand Down
6 changes: 3 additions & 3 deletions packages/vscode/tests/stacks/lint/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ function refreshConfig(runtime: Rslint, reason: string): Promise<void> {
).requestConfigRefresh(reason);
}

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

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

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