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
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Glossary of terms used across rstack-editor. Code, docs, commit messages and rev

- **Stack** — one tool integration (lint, test, fmt) hosted by the extension shell. A stack registers against the shell and reports status through it; stacks never own UI chrome.
- **Shell** — the always-activating extension core: detection, status bar, output channels, stack lifecycle.
- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. Detection signals are config files and installed tool binaries, never user settings.
- **Detection** — the per-workspace-folder scan deciding which stacks a folder lights up. The decision is per folder, but native lint's config signal may come from an ancestor directory. Detection signals are config files and installed tool binaries, never user settings.
- **Gate** — the per-stack activation condition: detected, workspace trusted, and that stack's enable setting on.

## Runtimes
Expand Down
1 change: 1 addition & 0 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

- **Why test file ownership exists** (adaptation 10): native configs can overlap upstream too, and the Rstack bridge adds a root contender. VS Code merges same-location items into one gutter action; routing chooses among the request's published items, which already reflect each config's include/exclude rules.
- fmt's `handleShowMessage` suppresses only the classified config-dependency Error. Non-classified `window/showMessage` notifications are re-presented exactly as vscode-languageclient's default handler would (Error/Warning/Info toast). An exact-prefix `rs fmt cannot format this workspace:` Error that is not a missing dependency also reports `crashed` and one Output error line per distinct message; the known config error survives server restarts so polling continues until a format produces edits. This passes through the server's protocol UI request unchanged; "stacks own no UI chrome" constrains UI the stack originates, not protocol UI it relays. Warning and error episodes end on nonempty formatting edits, not merely a response with no new notification: the server returns empty edits on failure and deduplicates showMessage.
- **Native lint detection looks above the workspace folder; nothing else does.** The rslint CLI and its Go server walk up to the filesystem root for a config, so a folder without its own `rslint.config.*` and without a root `rstack.config.*` uses the nearest one above it (skipping `node_modules`); fmt, Rstest and bridged lint stay folder-local because `rs fmt` / `rs lint` read only their cwd. The ancestor counts only while `@rslint/core` resolves from the folder, so a stray `~/rslint.config.*` does not put every folder below it into the not-installed state (`corePath` does not satisfy this). Folder-relative watchers cannot see ancestors, so detection watches each ancestor directory non-recursively for the config names and lockfiles; an ancestor lockfile counts as a dependency change exactly like a folder one, since a monorepo opened at a subdirectory installs at the root. Every native runtime also watches the ancestor directories for `rslint.config.*` (the set Go watches) and sends `rslint/configRefresh`; the watchers are installed before the server boots: Go registers its own ancestor watchers only after initialization, and a watcher misses writes made right after it is created (macOS CI lost an edit made ~40 ms after Go's registration). Known gap: a folder whose ancestor config is gated out is not detected, so the ADR 0005 poll never runs for it; an install that leaves the lockfile untouched (`npm ci`, `pnpm install --frozen-lockfile`) is picked up only on the next detection pass or a window reload.
- The lint × `rstack.config.*` bridge stays thin on purpose: only a root Rstack config can claim a bridged folder, any native config anywhere in the folder wins ownership, and the worker evaluates rstack's published shim from the folder root. Never generate a shim, load the Rstack config in the extension host, or interpret `define.lint()` ourselves.
- **Yarn Plug'n'Play is unsupported by decision, extension-wide.** Every stack resolves through physical `node_modules` (`shared/packageResolve.ts`, `resolution.ts`'s rstack → `@rslint/core` chain, the fmt bin probe, the rstest package lookup) and the lint worker's own `createRequire` from the core directory does too. Lint once carried a `.pnp.cjs` branch for the find-`@rslint/core` hop only; nothing after that hop (config evaluation, plugin resolution, the other stacks) had PnP hooks, so it never produced a working folder, and upstream removed its own PnP path in the same refactor that introduced `corePath`. Real support would be a PnP editor-SDK-shaped project across all three stacks, not a resolver branch — do not reintroduce one.
- **A Lint runtime lives as long as a document needs it, and a folder with none is `running: idle`.** Since the #1617 sync, `RuntimeManager` refcounts each runtime by open document: the first document to resolve a core starts one, the last to release it closes it, so a detected folder with nothing open holds zero workers and zero Go processes. That folder still reports `running` — with the detail `idle` — because it is live and will start a runtime on the next `didOpen`; do **not** add a `StackState` kind for it (the shell's status bar and `when` clauses read the kinds, and idle is not a kind of health). A folder's state is the **worst of** its runtimes plus any document whose core resolution currently fails (last-good: that document keeps the runtime it already had), so one failing core is never masked by a healthy sibling — the same invariant fmt pins across folders, applied inside one and across them alike (lint's rank table matches fmt's: `disabled` there means "a package is not installed" — no `rstack`, or no `@rslint/core` — not the kill switch). Dependency retries come only through the shell's detection pass: lockfile events are the low-latency path and ADR 0005's conditional poll covers unchanged lockfiles. The former lint-owned `node_modules/@rslint/core/package.json` watcher was removed because pnpm produced no event in either isolated or hoisted layout. Failures report through the status only: upstream's `window.showWarningMessage` is dropped, since stacks own no UI chrome. Consequently `whenStackActive('rslint')` means "the controller registered its folders", not "a server is up" — E2E suites open a document and await diagnostics.
Expand Down
2 changes: 1 addition & 1 deletion packages/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The extension activates on startup, then decides **per workspace folder** which

| Tool | Started when the folder contains |
| --- | --- |
| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists |
| Rslint | `rslint.config.{js,mjs,ts,mts}` anywhere in the folder, or `rstack.config.*` at the folder root when no native config exists. A folder with neither uses the nearest `rslint.config.*` above it, provided `@rslint/core` is installed for the folder |
| Rstest | `rstest.config.{mjs,ts,js,cjs,mts,cts}` (configurable) or `rstack.config.*` |
| rstack-cli | `rstack.config.*` or `node_modules/.bin/rs` |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function pause(): void {
debugger;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"strict": true
},
"include": ["src/**/*.ts"]
}
10 changes: 10 additions & 0 deletions packages/vscode/e2e/lint/fixtures/ancestor/rslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// The only Rslint config in this fixture. The opened workspace folder is
// `packages/app`, so detection must find this file above the folder.
export default [
{
files: ['**/*.ts'],
rules: {
'no-debugger': 'error',
},
},
];
8 changes: 8 additions & 0 deletions packages/vscode/e2e/lint/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,14 @@ async function main(): Promise<void> {
workspaceEntry: 'nested-initial.code-workspace',
workspaceFolders: ['parent', 'parent/nested', 'sentinel'],
},
{
// The opened folder is a sub-package; the only config is above it.
name: 'Ancestor config tests',
workspace: fixture('ancestor'),
tests: suiteDir('suite-ancestor'),
workspaceEntry: 'packages/app',
workspaceFolders: ['packages/app'],
},
{
name: 'No config tests',
workspace: fixture('noconfig'),
Expand Down
95 changes: 95 additions & 0 deletions packages/vscode/e2e/lint/suite-ancestor/ancestor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// The opened workspace folder is `packages/app`; the fixture's only
// `rslint.config.*` sits two levels above it. The rslint CLI and its Go server
// walk up to the filesystem root for a config, so native lint detection must
// light this folder, and its ancestor watchers must keep that decision fresh.
import * as vscode from 'vscode';
import path from 'node:path';
import fs from 'node:fs';
import {
diagnosticRuleIdIncludes,
waitForRslintDiagnostics as waitForDiagnostics,
} from '../utils/diagnostics';
import { waitForLintStackRegistration } from '../utils/extension';

function configWithNoDebugger(severity: 'error' | 'off'): string {
return `export default [
{
files: ['**/*.ts'],
rules: {
'no-debugger': '${severity}',
},
},
];
`;
}

suite('rslint config above the workspace folder', function () {
this.timeout(120000);

const folderPath = (): string =>
vscode.workspace.workspaceFolders![0].uri.fsPath;
const rootConfigPath = (): string =>
path.join(folderPath(), '..', '..', 'rslint.config.mjs');
const nearerConfigPath = (): string =>
path.join(folderPath(), '..', 'rslint.config.mjs');

async function openIndex(): Promise<vscode.TextDocument> {
const document = await vscode.workspace.openTextDocument(
path.join(folderPath(), 'src', 'index.ts'),
);
await vscode.window.showTextDocument(document);
return document;
}

const hasNoDebugger = (diagnostics: vscode.Diagnostic[]): boolean =>
diagnostics.some((diagnostic) =>
diagnosticRuleIdIncludes(diagnostic, 'no-debugger'),
);

teardown(async () => {
fs.rmSync(nearerConfigPath(), { force: true });
fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error'));
await waitForLintStackRegistration(true);
});

test('lints with the config above the folder and follows its edits', async () => {
const document = await openIndex();
await waitForDiagnostics(document, hasNoDebugger);

fs.writeFileSync(rootConfigPath(), configWithNoDebugger('off'));
await waitForDiagnostics(
document,
(diagnostics) => !hasNoDebugger(diagnostics),
);

fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error'));
await waitForDiagnostics(document, hasNoDebugger);
});

// The live runtime keeps its key; the extension's ancestor watchers send
// configRefresh, as do the Go server's own (`ancestorJSConfigFileWatchers`,
// registered through the worker) once they are live.
test('applies a nearer config created above the folder while linting', async () => {
const document = await openIndex();
await waitForDiagnostics(document, hasNoDebugger);

fs.writeFileSync(nearerConfigPath(), configWithNoDebugger('off'));
await waitForDiagnostics(
document,
(diagnostics) => !hasNoDebugger(diagnostics),
);

fs.rmSync(nearerConfigPath());
await waitForDiagnostics(document, hasNoDebugger);
});

test('re-runs detection when the config above the folder is deleted or created', async () => {
fs.rmSync(rootConfigPath());
await waitForLintStackRegistration(false);

fs.writeFileSync(rootConfigPath(), configWithNoDebugger('error'));
await waitForLintStackRegistration(true);
const document = await openIndex();
await waitForDiagnostics(document, hasNoDebugger);
});
});
5 changes: 5 additions & 0 deletions packages/vscode/e2e/lint/suite-ancestor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRun } from '../runSuite';

// The opened folder has no config of its own; the one above it must light the
// lint stack.
export const run = createRun();
15 changes: 9 additions & 6 deletions packages/vscode/e2e/lint/suite-noconfig/noconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@
// (origin/main), with the expectations rewritten for this extension's
// detection rules:
//
// - Upstream treated `rslint.json` as a working fallback config: creating it
// produced diagnostics, and the suite walked a JSON → JS → JSON lifecycle.
// - This extension does not support `rslint.json` at all. It is not a
// detection signal, so a folder with only `rslint.json` stays
// `not detected`, the lint stack is never registered, and zero diagnostics
// is the *designed* outcome — not a fallback state.
// - Upstream's suite walked a JSON → JS → JSON lifecycle from when
// `rslint.json` was a working fallback config. Upstream main no longer
// loads legacy JSON configs, and this extension never supported them.
// `rslint.json` is not a detection signal, so a folder with only
// `rslint.json` stays `not detected`, the lint stack is never registered,
// and zero diagnostics is the *designed* outcome — not a fallback state.
// - Native detection also looks above the folder (`suite-ancestor`). The
// sandbox's ancestors hold no `rslint.config.*`, so this suite doubles as
// the negative case: no config anywhere still means `not detected`.
// - `rslint.config.*` remains a live detection signal: creating one must
// register the stack without a window reload, and deleting the last one
// must deregister it (the config-glob + lockfile detection watcher).
Expand Down
86 changes: 62 additions & 24 deletions packages/vscode/src/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
type StackId,
STACK_IDS,
} from './types';
import { decideRslintMode } from './stacks/lint/resolution';
import {
ancestorDirectories,
decideRslintMode,
findAncestorRslintConfig,
RSLINT_CONFIG_NAMES,
} from './stacks/lint/resolution';

/**
* `rstack.config.*` is a config source for all three tool stacks. Lint only
Expand All @@ -26,7 +31,7 @@ export const RSTACK_CONFIG_GLOB = '**/rstack.config.{ts,js,mts,mjs}';
* JSON configs (`rslint.json` / `rslint.jsonc`) are deliberately not detection
* signals — upstream deprecated them and ships `rslint --init` to migrate.
*/
export const RSLINT_CONFIG_GLOB = '**/rslint.config.{js,mjs,ts,mts}';
export const RSLINT_CONFIG_GLOB = `**/{${RSLINT_CONFIG_NAMES.join(',')}}`;

export const DEFAULT_RSTEST_CONFIG_GLOBS = [
'**/rstest.config.{mjs,ts,js,cjs,mts,cts}',
Expand Down Expand Up @@ -81,10 +86,7 @@ export const emptySnapshot = (): DetectionSnapshot => new Snapshot([]);
* and the Rslint/Rstest package resolution, so it counts as a detection input.
*/
export const DETECTION_WATCH_NAMES = [
'rslint.config.js',
'rslint.config.mjs',
'rslint.config.ts',
'rslint.config.mts',
...RSLINT_CONFIG_NAMES,
...RSTACK_CONFIG_NAMES,
...LOCKFILE_NAMES,
] as const;
Expand All @@ -107,6 +109,16 @@ export const detectionWatchPatterns = (
...new Set([`**/{${DETECTION_WATCH_NAMES.join(',')}}`, ...rstestGlobs]),
];

/**
* Watched non-recursively in every folder ancestor, which folder-relative
* watchers cannot see: native lint's ancestor config, and the lockfile of a
* monorepo opened at a subdirectory.
*/
const ANCESTOR_WATCH_PATTERN = `{${[
...RSLINT_CONFIG_NAMES,
...LOCKFILE_NAMES,
].join(',')}}`;

const readRstestGlobs = (folder: vscode.WorkspaceFolder): readonly string[] => {
const configured = vscode.workspace
.getConfiguration('rstack.rstest', folder.uri)
Expand Down Expand Up @@ -188,8 +200,16 @@ export const detectFolder = async (
).find((candidate) =>
rstackConfigFiles.some((uri) => uri.toString() === candidate.toString()),
)?.fsPath;
// Like the Go server, look above the folder, but only when nothing inside
// it decides ownership: an ancestor never displaces a bridged folder.
const ancestorConfigPath =
rslintConfigFiles.length === 0 && rootRstackConfigPath === undefined
? findAncestorRslintConfig(folder.uri.fsPath)
: undefined;
Comment thread
fi3ework marked this conversation as resolved.
const rslintMode = decideRslintMode({
nativeConfigPaths: rslintConfigFiles.map((uri) => uri.fsPath),
nativeConfigPaths: ancestorConfigPath
? [ancestorConfigPath]
: rslintConfigFiles.map((uri) => uri.fsPath),
rootRstackConfigPath,
});

Expand All @@ -199,6 +219,7 @@ export const detectFolder = async (
configFiles: rslintConfigFiles,
rstackConfigFiles,
mode: rslintMode,
ancestorConfigPath,
},
rstest: {
detected: rstestConfigFiles.length > 0 || rstackConfigFiles.length > 0,
Expand All @@ -225,7 +246,7 @@ const signatureOf = (snapshot: DetectionSnapshot): string =>
.map((uri) => uri.toString())
.sort()
.join(',');
return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${files}`;
return `${stack}:${detection.detected ? 1 : 0}:${detection.mode ?? ''}:${detection.binPath ?? ''}:${detection.ancestorConfigPath ?? ''}:${files}`;
}).join('|');
return `${entry.folder.uri.toString()}=>${stacks}`;
})
Expand All @@ -235,7 +256,8 @@ const signatureOf = (snapshot: DetectionSnapshot): string =>
/**
* Runs detection over every workspace folder and keeps it fresh without a
* window reload: one `FileSystemWatcher` per folder and watch pattern covers
* the config globs plus the lockfiles.
* the config globs plus the lockfiles, and one non-recursive watcher per
* ancestor directory covers native lint's ancestor signal.
*/
export class DetectionService implements vscode.Disposable {
#snapshot: DetectionSnapshot = emptySnapshot();
Expand Down Expand Up @@ -369,28 +391,44 @@ export class DetectionService implements vscode.Disposable {
watcher.dispose();
}
this.#watchers = [];
// A lockfile event is a dependency change wherever it is watched: a
// monorepo opened at a subdirectory installs at an ancestor.
const onEvent = (uri: vscode.Uri) => {
if (isLockfile(uri)) {
this.#notifyUnchanged = true;
Comment thread
fi3ework marked this conversation as resolved.
}
this.schedule();
};
const patterns: vscode.RelativePattern[] = [];
const ancestors = new Set<string>();
for (const folder of vscode.workspace.workspaceFolders ?? []) {
if (folder.uri.scheme !== 'file') {
continue;
}
const onEvent = (uri: vscode.Uri) => {
if (isLockfile(uri)) {
this.#notifyUnchanged = true;
}
this.schedule();
};
for (const pattern of detectionWatchPatterns(readRstestGlobs(folder))) {
const watcher = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(folder, pattern),
);
this.#watchers.push(
watcher,
watcher.onDidCreate(onEvent),
watcher.onDidChange(onEvent),
watcher.onDidDelete(onEvent),
);
patterns.push(new vscode.RelativePattern(folder, pattern));
}
for (const directory of ancestorDirectories(folder.uri.fsPath)) {
ancestors.add(directory);
}
}
for (const directory of ancestors) {
patterns.push(
new vscode.RelativePattern(
vscode.Uri.file(directory),
ANCESTOR_WATCH_PATTERN,
),
);
}
for (const pattern of patterns) {
const watcher = vscode.workspace.createFileSystemWatcher(pattern);
this.#watchers.push(
watcher,
watcher.onDidCreate(onEvent),
watcher.onDidChange(onEvent),
watcher.onDidDelete(onEvent),
);
}
}

dispose(): void {
Expand Down
Loading
Loading