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
8 changes: 5 additions & 3 deletions packages/vscode/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten
## The copies are intentional

- `stacks/lint` and `stacks/test` are deliberate near-verbatim copies of the upstream extensions, kept close to upstream so changes can be synced by diffing. Do NOT deduplicate or refactor across the two stacks — the duplication is the point; consolidation is a later, explicit phase.
- The copies diverge from upstream in exactly eleven ways (the "adaptations" below). When syncing upstream, preserve them. A twelfth divergence is either a bug or must be added to this list.
- **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **41daaa1b** (0.12.2). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream.
- The copies diverge from upstream in exactly twelve ways (the "adaptations" below). When syncing upstream, preserve them. A thirteenth divergence is either a bug or must be added to this list.
- **Tracked upstream state.** `stacks/lint` tracks web-infra-dev/rslint `packages/vscode-extension` at **e5d76242** (0.9.3); `stacks/test` tracks web-infra-dev/rstest `packages/vscode` at **e1607861** (post-0.12.2 main). Additional ports: Rslint **84f9c9b5** trace wording and **72cd2b1f** fixAll waits; Rstest **988f8e1d** per-bundle license notices, with explicit native-binding inclusion and no network license-text replenishment. We replace upstream's plugin-host failure toast with status (adaptations 4/7). Diff `CoreResolver.ts`, `RuntimeManager.ts`, `WorkspaceDocumentRouter.ts`, and `Rslint.ts` on future syncs. The rstest#1888 IPC port uses default JSON instead of advanced serialization: values must survive a JSON round-trip, and both `createBirpc` calls spread `rpcErrorCodec` (`stacks/test/shared/rpc.ts`) to preserve thrown errors. Failed-project retry and worker cleanup remain ahead of upstream.
- **Ahead of upstream — offer these back when syncing** (bug fixes, not adaptations): (1) `RuntimeManager.reconcile` resolves the document's core **before** sweeping pending uses (`planDocumentCore`), so a reconcile landing on the key a pending start is already producing adopts that start instead of tearing it down mid-`initialize` — the teardown made vscode-languageclient force-notify ("couldn't create connection to server") whenever the register-time pass, a detection change and `didOpen` landed inside one worker startup window (`tests/stacks/lint/runtimeManager.test.ts`). (2) `Rslint.close()` gives a still-Starting language client a bounded chance to settle before tearing down its transport, so a legitimate mid-start close (document closed during start, core key changed) stops cleanly instead of triggering the same force-notified toasts. (3) The registry-harness E2E gives its never-settling startup operation 500ms to begin and accepts only the in-flight timeout message, so a stalled runner cannot satisfy the assertion through the already-expired path (`e2e/lint/suite/registry-harness.test.ts`). (4) `Project.retryFailedConfig()` keeps a failed Rstest project and retries its config evaluation in place with one single-flight promise, so repeated dependency-change passes neither overlap workers nor repeat an unchanged not-installed warning. (5) `RuntimeManager` retires a stopped client even when its resolved key is unchanged. The existing closing barrier and pending-use adoption share one replacement across documents; running and starting clients remain untouched (`tests/stacks/lint/runtimeManager.test.ts`).

## The eleven adaptations
## The twelve adaptations

1. **Shell activation** — stacks never self-activate; `register()` returns fast and never blocks on starting a server/worker.
2. **Namespace** — everything user-visible is `rstack.*`. Legacy `rslint.*` / `rstest.*` settings and command ids are not read, aliased or migrated (breaking old settings and keybindings was an accepted cost).
Expand All @@ -25,6 +25,8 @@ One extension replacing the standalone `rstack.rslint` and `rstack.rstest` exten

11. **Debugger-owned test processes** (test) — debug runs use js-debug `launch` with child-process auto-attach instead of upstream's `--inspect-wait` plus `attach`. js-debug owns inspector endpoints and process teardown; `debuggerPort` / `debuggerAddress` are removed without migration. `debugWorker.ts` owns the session and a debug-only local socket carrying JSON birpc messages (`shared/socketRpc.ts`) after a first-line per-launch secret authenticates the worker; normal runs retain Node IPC. Preserve the 9229-occupied breakpoint and continue/cancel/stop cleanup regression in `e2e/rstest/suite/debug.test.ts` when syncing.

12. **Function-title discovery** (test) — `stacks/test/parserTest.ts` replaces upstream's scope pre-pass with walk-order bindings and assignment tracking for runtime-name parity. Unbound identifiers (including default imports) and non-computed members use best-effort identifier/property names rather than giving up with `unnamed test`; known unknown bindings still stay unnamed. Re-apply this adaptation when syncing `parserTest.ts`, preserving the upstream tests and local runtime-parity cases.

## Rules

- **Pre-1.0.0 the extension breaks freely.** No compatibility is owed with earlier unpublished states of this extension — settings, command ids and behavior may change without deprecation paths, and dead compat code for them is removed, not kept. No settings migration exists either — not for earlier states of this extension, and not for the two retired standalone extensions (removed in #15; users re-enter their settings under `rstack.*`). Testing and fixtures track only the latest published releases, pinned exactly and bumped by Renovate; a green E2E run speaks only for those releases. `SUPPORT_MATRIX` floors are the minimum versions the extension accepts: each entry is the lowest release evidence shows works with the current code, and its comment records that evidence. Move a floor only when a change makes older releases stop working, never because a devDependency or fixture moved. Raising a floor needs no transition story; the status names the required version.
Expand Down
229 changes: 227 additions & 2 deletions packages/vscode/src/stacks/test/parserTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,219 @@ export const parseTestFile = (
.join('');
};

// Test item names must match runtime `name || '<anonymous>'`. Record bindings
// in walk order so later calls see assignments without a separate scope pass.
type Scope = { isFunction: boolean; names: Map<string, string | null> };
const scopes: Scope[] = [{ isFunction: true, names: new Map() }];
const functionTypes = new Set([
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
]);
const blockTypes = new Set([
Comment thread
fi3ework marked this conversation as resolved.
'BlockStatement',
'StaticBlock',
'ClassDeclaration',
'ClassExpression',
'ForStatement',
'ForInStatement',
'ForOfStatement',
'SwitchStatement',
'CatchClause',
]);

const scopeOf = (name: string) =>
scopes.findLast((scope) => scope.names.has(name))?.names;

const getFunctionName = (
node: Node | undefined,
inferred?: string,
): string | null => {
if (
node?.type === 'FunctionExpression' ||
node?.type === 'ClassExpression'
) {
return isNode(node.id) && node.id.type === 'Identifier'
? node.id.name
: (inferred ?? '<anonymous>');
}
if (node?.type === 'ArrowFunctionExpression') {
return inferred ?? '<anonymous>';
}
if (node?.type === 'Identifier') {
const names = scopeOf(node.name);
// Unbound identifiers (including default imports) use a best-effort name;
// known unknown values must not fall back through a shadowing binding.
return names ? (names.get(node.name) ?? null) : node.name;
}
if (
node?.type === 'MemberExpression' &&
!node.computed &&
node.property.type === 'Identifier'
) {
return node.property.name;
}
return null;
};

const bind = (pattern: Node, name: string | null, isVar = false): void => {
if (pattern.type === 'Identifier') {
const scope = isVar
? scopes.findLast((candidate) => candidate.isFunction)
: scopes.at(-1);
scope?.names.set(pattern.name, name);
} else if (pattern.type === 'RestElement') {
if (isNode(pattern.argument)) {
bind(pattern.argument, null, isVar);
}
} else if (pattern.type === 'AssignmentPattern') {
if (isNode(pattern.left)) {
bind(pattern.left, null, isVar);
}
} else if (pattern.type === 'ArrayPattern') {
for (const element of pattern.elements) {
if (isNode(element)) {
bind(element, null, isVar);
}
}
} else if (pattern.type === 'ObjectPattern') {
for (const property of pattern.properties) {
if (!isNode(property)) {
continue;
}
const value =
property.type === 'RestElement' ? property.argument : property.value;
if (isNode(value)) {
bind(value, null, isVar);
}
}
}
};

const collectBindings = (node: Node): void => {
if (node.type === 'VariableDeclaration') {
for (const declaration of node.declarations) {
if (!isNode(declaration) || !isNode(declaration.id)) {
continue;
}
const init = isNode(declaration.init) ? declaration.init : undefined;
if (
node.kind === 'var' &&
!init &&
declaration.id.type === 'Identifier' &&
scopes
.findLast((scope) => scope.isFunction)
?.names.has(declaration.id.name)
) {
continue;
}
const name =
init && declaration.id.type === 'Identifier'
? getFunctionName(init, declaration.id.name)
: null;
bind(declaration.id, name, node.kind === 'var');
Comment thread
fi3ework marked this conversation as resolved.
}
} else if (
node.type === 'AssignmentExpression' &&
node.operator === '=' &&
node.left.type === 'Identifier'
) {
// Keep deferred writes to outer bindings in a function-local overlay.
for (let index = scopes.length - 1; index >= 0; index--) {
const scope = scopes[index];
if (
scope.names.has(node.left.name) ||
(scope.isFunction && scopeOf(node.left.name))
) {
scope.names.set(
node.left.name,
getFunctionName(node.right, node.left.name),
);
break;
}
if (scope.isFunction) {
break;
Comment thread
fi3ework marked this conversation as resolved.
Comment thread
fi3ework marked this conversation as resolved.
}
}
} else if (
node.type === 'ImportDeclaration' &&
node.importKind !== 'type' &&
Array.isArray(node.specifiers)
) {
for (const specifier of node.specifiers) {
if (!isNode(specifier) || !isNode(specifier.local)) {
continue;
}
if (specifier.type === 'ImportNamespaceSpecifier') {
bind(specifier.local, null);
} else if (
specifier.type === 'ImportSpecifier' &&
specifier.importKind !== 'type' &&
isNode(specifier.imported) &&
specifier.imported.type === 'Identifier'
) {
bind(specifier.local, specifier.imported.name);
}
}
} else if (
(node.type === 'FunctionDeclaration' ||
node.type === 'ClassDeclaration') &&
isNode(node.id) &&
node.id.type === 'Identifier'
) {
bind(node.id, node.id.name);
}
};

const walkNode = (node: Node): void => {
collectBindings(node);
Comment thread
fi3ework marked this conversation as resolved.
const isFunction = functionTypes.has(node.type);
const opensScope = isFunction || blockTypes.has(node.type);
if (opensScope) {
scopes.push({ isFunction, names: new Map() });
Comment thread
fi3ework marked this conversation as resolved.
if (
(node.type === 'FunctionExpression' ||
node.type === 'ClassExpression') &&
isNode(node.id) &&
node.id.type === 'Identifier'
) {
bind(node.id, node.id.name);
}
const params =
node.type === 'CatchClause'
? [node.param]
: isFunction && 'params' in node && Array.isArray(node.params)
? node.params
: [];
for (const param of params) {
if (isNode(param)) {
bind(param, null);
}
}
}

// Hoisted declarations shadow outer names even before their walk position.
const statements =
node.type === 'Program' ||
node.type === 'BlockStatement' ||
node.type === 'StaticBlock'
? node.body
: node.type === 'SwitchStatement'
? node.cases.flatMap((branch) => branch.consequent)
: [];
for (const statement of statements) {
const declaration =
statement.type === 'ExportNamedDeclaration' ||
statement.type === 'ExportDefaultDeclaration'
? statement.declaration
: statement;
if (declaration?.type === 'FunctionDeclaration') {
collectBindings(declaration);
}
Comment thread
fi3ework marked this conversation as resolved.
}

let exit: (() => void) | void | undefined;
let functionTitleNode: Node | undefined;

if (node.type === 'CallExpression') {
let functionName: string | undefined;
Expand All @@ -86,9 +297,20 @@ export const parseTestFile = (
functionName === 'describe' ||
functionName === 'suite'
) {
const title = node.arguments[0];
if (
isNode(title) &&
(title.type === 'FunctionExpression' ||
title.type === 'ClassExpression' ||
title.type === 'ArrowFunctionExpression')
) {
functionTitleNode = title;
}
exit = events.onTest(
offsetToRange(node.start, node.end),
getStringLiteralValue(node.arguments[0]) || 'unnamed test',
getStringLiteralValue(node.arguments[0]) ||
getFunctionName(node.arguments[0]) ||
'unnamed test',
functionName,
);
}
Expand All @@ -97,7 +319,7 @@ export const parseTestFile = (
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const child of value) {
if (isNode(child)) {
if (isNode(child) && child !== functionTitleNode) {
Comment thread
fi3ework marked this conversation as resolved.
walkNode(child);
}
}
Expand All @@ -107,6 +329,9 @@ export const parseTestFile = (
}

exit?.();
if (opensScope) {
scopes.pop();
}
};

walkNode(result.program);
Expand Down
Loading
Loading