fix(ingestion): address PR #1050 review findings — side-effect imports, resolve-cache perf, adapter signature
Three independent fixes surfaced by the production-readiness review of the TypeScript registry-primary scope-resolution migration (RFC #909 Ring 3). All three pass under both REGISTRY_PRIMARY_TYPESCRIPT=0 and =1. 1. Side-effect imports were silently dropped (correctness regression). The legacy DAG emitted IMPORTS edges for `import './polyfill'` because its tree-sitter query matches `(import_statement source: (string))` regardless of clause. The new registry-primary path returned `[]` from `splitImportStatement()` for clause-less imports, so no ParsedImport / ImportEdge was ever produced — silent file-level edge loss. Add a generic 'side-effect' variant to `ParsedImport` and `ImportEdge['kind']` in `gitnexus-shared`; finalize resolves the target file and pre-finalizes the edge (no `targetDefId`, no `BindingRef`) so the SCC fixpoint loop skips it. The TypeScript provider now emits + interprets the new kind end-to-end. The variant is intentionally generic so other languages (Rust `use foo as _`, Python module-init) can adopt it. 2. Per-import re-derivation in `resolveImportTarget` (perf regression). The TS adapter built `new Set(allFilePaths)` on every call and let `resolveTsImportTarget` re-derive `allFileList` / `normalizedFileList` and discard the `resolveCache`. For a workspace with N files and M imports that's O(N × M) work per pass. Wrap the adapter in a closure that memoizes all five derived values keyed on the orchestrator's `ReadonlySet` identity; reset only when the set reference changes (start of new pass). New cost: O(N + M). 3. Misleading fake `ParsedImport` in the adapter (architecture). The adapter constructed `{ kind: 'named', localName: '_', importedName: '_', targetRaw }` to call `resolveTsImportTarget`, even though only `targetRaw` and the structural-typed context are read. Extract `resolveTsTarget(targetRaw, ctx)` so the adapter has an honest signature; `resolveTsImportTarget` still works for other callers. Also extract `narrowTsContext` for the type narrowing. Tests: - New 4-file fixture `typescript-side-effect-imports` with two side-effect imports + one named import. - New "TypeScript side-effect imports" describe in `test/integration/resolvers/typescript.test.ts` (parity-gated by `ci-scope-parity.yml` — runs under both flag states). - Updated 2 unit tests to expect 1 side-effect ParsedImport and 4 `@import.statement` matches (was 0 / 3). - 785 / 785 TS scope-resolution tests pass under both REGISTRY_PRIMARY_TYPESCRIPT=0 and =1. Made-with: Cursor
This commit is contained in:
@@ -335,14 +335,11 @@ function makeEdgeDraft(
|
||||
|
||||
// Edge is unresolvable at the file level — mark unresolved now.
|
||||
if (targetFile === null) {
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile: null,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
linkStatus: 'unresolved',
|
||||
};
|
||||
return {
|
||||
@@ -356,15 +353,15 @@ function makeEdgeDraft(
|
||||
}
|
||||
|
||||
// Resolvable at the file level; intra-SCC fixpoint may still fail to fill
|
||||
// in `targetDefId` (e.g., symbol not exported from target).
|
||||
const edgeKind = parsed.kind === 'wildcard' ? 'wildcard-expanded' : parsed.kind;
|
||||
const localName = parsed.kind === 'wildcard' ? '' : parsed.localName;
|
||||
const targetExportedName = extractExportedName(parsed);
|
||||
// in `targetDefId` (e.g., symbol not exported from target). Side-effect
|
||||
// imports are terminal at the file level — no `targetDefId` needed since
|
||||
// they materialize no `BindingRef`. Pre-finalize them here so the
|
||||
// fixpoint loop skips them entirely.
|
||||
const base: ImportEdge = {
|
||||
localName,
|
||||
localName: extractLocalName(parsed),
|
||||
targetFile,
|
||||
targetExportedName,
|
||||
kind: edgeKind,
|
||||
targetExportedName: extractExportedName(parsed),
|
||||
kind: edgeKindFor(parsed),
|
||||
};
|
||||
return {
|
||||
source: parsed,
|
||||
@@ -372,10 +369,25 @@ function makeEdgeDraft(
|
||||
fromScope: file.moduleScope,
|
||||
targetFile,
|
||||
base,
|
||||
finalized: null,
|
||||
finalized: parsed.kind === 'side-effect' ? base : null,
|
||||
};
|
||||
}
|
||||
|
||||
function edgeKindFor(parsed: ParsedImport): ImportEdge['kind'] {
|
||||
if (parsed.kind === 'wildcard') return 'wildcard-expanded';
|
||||
return parsed.kind;
|
||||
}
|
||||
|
||||
function extractLocalName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'wildcard':
|
||||
case 'side-effect':
|
||||
return '';
|
||||
default:
|
||||
return parsed.localName;
|
||||
}
|
||||
}
|
||||
|
||||
function extractExportedName(parsed: ParsedImport): string {
|
||||
switch (parsed.kind) {
|
||||
case 'named':
|
||||
@@ -385,6 +397,7 @@ function extractExportedName(parsed: ParsedImport): string {
|
||||
return parsed.importedName;
|
||||
case 'wildcard':
|
||||
case 'dynamic-unresolved':
|
||||
case 'side-effect':
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,21 @@ export type ParsedImport =
|
||||
readonly localName: string;
|
||||
/** Source text of the unresolved expression when available; `null` otherwise. */
|
||||
readonly targetRaw: string | null;
|
||||
}
|
||||
/**
|
||||
* Bare-source / side-effect import that introduces no local name binding
|
||||
* but still establishes a file-level dependency. Resolves to a concrete
|
||||
* `targetFile` via `resolveImportTarget` and produces a file→file
|
||||
* `ImportEdge` for module-reachability and impact analysis, with no
|
||||
* `BindingRef` materialized.
|
||||
*
|
||||
* Examples:
|
||||
* - JS / TS `import './polyfill'` → `{ kind: 'side-effect', targetRaw: './polyfill' }`
|
||||
* - Rust `use foo::bar as _` → side-effect (binding hidden under `_`)
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'side-effect';
|
||||
readonly targetRaw: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -253,7 +268,8 @@ export interface ImportEdge {
|
||||
| 'namespace'
|
||||
| 'wildcard-expanded'
|
||||
| 'reexport'
|
||||
| 'dynamic-unresolved';
|
||||
| 'dynamic-unresolved'
|
||||
| 'side-effect';
|
||||
/** Re-export chain, for provenance (e.g., `['./y']` when re-exported via `./y`). */
|
||||
readonly transitiveVia?: readonly string[];
|
||||
/** Set to `'unresolved'` when the SCC fixpoint could not link this edge. */
|
||||
|
||||
@@ -31,10 +31,11 @@
|
||||
* TypeScript scope-resolution layer, types and values share the same
|
||||
* lookup; runtime-emission is a downstream concern.
|
||||
*
|
||||
* Side-effect imports (`import './polyfill'`) produce NO decomposed
|
||||
* match — there is no local binding to resolve and the finalize
|
||||
* algorithm has no ParsedImport variant for bare-source edges. The
|
||||
* caller drops the raw anchor.
|
||||
* Side-effect imports (`import './polyfill'`) produce a single match
|
||||
* with `kind: 'side-effect'`. The shared finalize algorithm resolves
|
||||
* the target file and emits a file-level IMPORTS edge, but
|
||||
* materializes no `BindingRef` (matching the legacy DAG, which counts
|
||||
* `import './polyfill'` as a module-reachability dependency only).
|
||||
*/
|
||||
|
||||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
@@ -54,7 +55,8 @@ type ImportKind =
|
||||
| 'reexport-alias'
|
||||
| 'reexport-wildcard'
|
||||
| 'reexport-namespace'
|
||||
| 'dynamic';
|
||||
| 'dynamic'
|
||||
| 'side-effect';
|
||||
|
||||
interface ImportSpec {
|
||||
readonly kind: ImportKind;
|
||||
@@ -73,11 +75,9 @@ interface ImportSpec {
|
||||
/**
|
||||
* Decompose an import anchor. Handles three node types:
|
||||
*
|
||||
* - `import_statement` : all static import forms
|
||||
* - `import_statement` : all static import forms (incl. side-effect)
|
||||
* - `export_statement` (w/ source) : re-exports
|
||||
* - `call_expression` (import fn) : dynamic `import()`
|
||||
*
|
||||
* Returns `[]` for side-effect imports (no local binding).
|
||||
*/
|
||||
export function splitImportStatement(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
if (stmtNode.type === 'import_statement') return splitImport(stmtNode);
|
||||
@@ -101,8 +101,17 @@ function splitImport(stmtNode: SyntaxNode): CaptureMatch[] {
|
||||
|
||||
const importClause = findChild(stmtNode, 'import_clause');
|
||||
if (importClause === null) {
|
||||
// `import './polyfill'` — no clause, no local binding. Drop it.
|
||||
return [];
|
||||
// `import './polyfill'` — no clause, no local binding. Emit a
|
||||
// side-effect match so the finalize layer still produces a
|
||||
// file-level IMPORTS edge (parity with the legacy DAG).
|
||||
return [
|
||||
buildImportMatch(stmtNode, {
|
||||
kind: 'side-effect',
|
||||
source,
|
||||
name: '',
|
||||
atNode: stmtNode,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
@@ -42,23 +42,34 @@ export function resolveTsImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as TsResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') {
|
||||
// Dynamic imports carry `targetRaw` only for diagnostics; when
|
||||
// the expression isn't a string literal we can't resolve a file.
|
||||
if (parsedImport.targetRaw === null) return null;
|
||||
// A string-literal dynamic import (`import('./m')`) resolves the
|
||||
// same way as a static import, so we fall through.
|
||||
}
|
||||
const ctx = narrowTsContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
|
||||
// Dynamic imports carry `targetRaw` only for diagnostics; when the
|
||||
// expression isn't a string literal we can't resolve a file.
|
||||
// A string-literal dynamic import (`import('./m')`) resolves like a
|
||||
// static import — fall through to the shared path resolver.
|
||||
if (parsedImport.kind === 'dynamic-unresolved' && parsedImport.targetRaw === null) return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
return resolveTsTarget(parsedImport.targetRaw, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw module-path string to a workspace file path using the
|
||||
* same standard-strategy resolver as the legacy DAG. Operates directly on
|
||||
* the source string without requiring a `ParsedImport`, so the
|
||||
* `ScopeResolver.resolveImportTarget` adapter doesn't need to construct
|
||||
* a fake `ParsedImport` to reach the resolver.
|
||||
*
|
||||
* Returns `null` when:
|
||||
* - the context is malformed (missing `fromFile` / `allFilePaths`)
|
||||
* - `targetRaw` is empty
|
||||
* - the resolver finds no matching file
|
||||
*/
|
||||
export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
const language = ctx.language ?? SupportedLanguages.TypeScript;
|
||||
const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths);
|
||||
const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase());
|
||||
@@ -66,7 +77,7 @@ export function resolveTsImportTarget(
|
||||
|
||||
return resolveImportPath(
|
||||
ctx.fromFile,
|
||||
parsedImport.targetRaw,
|
||||
targetRaw,
|
||||
ctx.allFilePaths,
|
||||
allFileList as string[],
|
||||
normalizedFileList as string[],
|
||||
@@ -75,3 +86,15 @@ export function resolveTsImportTarget(
|
||||
ctx.tsconfigPaths ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null {
|
||||
const ctx = workspaceIndex as TsResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -88,5 +88,5 @@ export { getTypescriptCaptureCacheStats, resetTypescriptCaptureCacheStats } from
|
||||
export { interpretTsImport, interpretTsTypeBinding } from './interpret.js';
|
||||
export { typescriptMergeBindings } from './merge-bindings.js';
|
||||
export { typescriptArityCompatibility } from './arity.js';
|
||||
export { resolveTsImportTarget, type TsResolveContext } from './import-target.js';
|
||||
export { resolveTsImportTarget, resolveTsTarget, type TsResolveContext } from './import-target.js';
|
||||
export { tsBindingScopeFor, tsImportOwningScope, tsReceiverBinding } from './simple-hooks.js';
|
||||
|
||||
@@ -132,6 +132,13 @@ export function interpretTsImport(captures: CaptureMatch): ParsedImport | null {
|
||||
targetRaw: sourceCap?.text ?? null,
|
||||
};
|
||||
}
|
||||
case 'side-effect': {
|
||||
// `import './polyfill'` — bare-source, no local binding. The
|
||||
// finalize layer resolves to a target file and emits a
|
||||
// file-level IMPORTS edge; no `BindingRef` is materialized.
|
||||
if (sourceCap === undefined) return null;
|
||||
return { kind: 'side-effect', targetRaw: sourceCap.text };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,32 +21,56 @@ import { typescriptProvider } from '../typescript.js';
|
||||
import {
|
||||
typescriptArityCompatibility,
|
||||
typescriptMergeBindings,
|
||||
resolveTsImportTarget,
|
||||
resolveTsTarget,
|
||||
type TsResolveContext,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Build a `resolveImportTarget` adapter that memoizes the workspace
|
||||
* file list, the lower-cased file list, and the per-pass `resolveCache`
|
||||
* across every import lookup in a single workspace pass. The
|
||||
* orchestrator passes the same `ReadonlySet` reference for every call
|
||||
* within a pass — we use that identity to detect when the workspace
|
||||
* changes and recompute the derived state lazily.
|
||||
*
|
||||
* Without this memoization, `resolveTsTarget` re-derived
|
||||
* `allFileList` and `normalizedFileList` (both O(N_files)) and threw
|
||||
* away the `resolveCache` on every import — O(N_files × N_imports)
|
||||
* total work for what should be O(N_files + N_imports).
|
||||
*/
|
||||
function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
|
||||
let cachedAllFilePaths: ReadonlySet<string> | null = null;
|
||||
let cachedSet: Set<string> | null = null;
|
||||
let cachedAllFileList: readonly string[] | null = null;
|
||||
let cachedNormalizedFileList: readonly string[] | null = null;
|
||||
let cachedResolveCache: Map<string, string | null> | null = null;
|
||||
|
||||
return (targetRaw, fromFile, allFilePaths) => {
|
||||
if (cachedAllFilePaths !== allFilePaths) {
|
||||
cachedAllFilePaths = allFilePaths;
|
||||
cachedSet = new Set(allFilePaths);
|
||||
cachedAllFileList = Array.from(allFilePaths);
|
||||
cachedNormalizedFileList = cachedAllFileList.map((f) => f.toLowerCase());
|
||||
cachedResolveCache = new Map();
|
||||
}
|
||||
|
||||
const ws: TsResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths: cachedSet!,
|
||||
allFileList: cachedAllFileList!,
|
||||
normalizedFileList: cachedNormalizedFileList!,
|
||||
resolveCache: cachedResolveCache!,
|
||||
};
|
||||
return resolveTsTarget(targetRaw, ws);
|
||||
};
|
||||
}
|
||||
|
||||
const typescriptScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.TypeScript,
|
||||
languageProvider: typescriptProvider,
|
||||
importEdgeReason: 'typescript-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
// Copy the orchestrator's `ReadonlySet` into a `Set` because the
|
||||
// underlying standard resolver is typed to receive `Set<string>`
|
||||
// (it uses the set to scan candidate resolutions). O(N) copy
|
||||
// once per import — cost is trivial next to parsing.
|
||||
const ws: TsResolveContext = {
|
||||
fromFile,
|
||||
allFilePaths: new Set(allFilePaths),
|
||||
// tsconfigPaths / pre-normalized lists would be populated by the
|
||||
// orchestrator when available; absent here we let the adapter
|
||||
// derive them on the fly.
|
||||
};
|
||||
return resolveTsImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
resolveImportTarget: makeTsResolveImportTarget(),
|
||||
|
||||
// TypeScript declaration merging + LEGB: local > import > wildcard,
|
||||
// separated by declaration space (value / type / namespace). The
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import './polyfill';
|
||||
import './register';
|
||||
import { greet } from './greeter';
|
||||
|
||||
export function main(): string {
|
||||
return greet('world');
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export function greet(name: string): string {
|
||||
return `hello, ${name}`;
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
declare const globalThis: { __polyfilled?: boolean };
|
||||
globalThis.__polyfilled = true;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
declare const globalThis: { __registry?: string[] };
|
||||
(globalThis.__registry ??= []).push('module-A');
|
||||
@@ -359,6 +359,45 @@ describe('TypeScript named import disambiguation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect imports: `import './polyfill'` produces an IMPORTS edge but
|
||||
// no local binding (parity with the legacy DAG, which counts side-effect
|
||||
// imports as module-reachability dependencies).
|
||||
//
|
||||
// This describe runs under both `REGISTRY_PRIMARY_TYPESCRIPT=0` (legacy
|
||||
// DAG) and `=1` (registry-primary) via the CI parity gate
|
||||
// (`.github/workflows/ci-scope-parity.yml`). Both modes must emit the
|
||||
// same IMPORTS edges; the registry-primary path emits no extra
|
||||
// `BindingRef`s for the side-effect kind.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript side-effect imports', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'typescript-side-effect-imports'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('emits IMPORTS edges for both side-effect imports + the named import', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS').filter((e) => e.source === 'app.ts');
|
||||
const targets = imports.map((e) => e.targetFilePath).sort();
|
||||
expect(targets).toEqual(['src/greeter.ts', 'src/polyfill.ts', 'src/register.ts']);
|
||||
});
|
||||
|
||||
it('does not synthesize local bindings for side-effect imports', () => {
|
||||
// A side-effect import binds no local name; nothing in `app.ts` should
|
||||
// try to call into `polyfill.ts` or `register.ts`. The only resolved
|
||||
// CALL edge from `main` is to `greet` in `greeter.ts`.
|
||||
const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'main');
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].target).toBe('greet');
|
||||
expect(calls[0].targetFilePath).toBe('src/greeter.ts');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Alias import resolution: import { User as U } resolves U → User
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -230,7 +230,7 @@ describe('emitTsScopeCaptures — imports (decomposed)', () => {
|
||||
// `import { A } from './a'` → 1 match (named)
|
||||
// `import B from './b'` → 1 match (default)
|
||||
// `import * as ns from './ns'` → 1 match (namespace)
|
||||
// `import './polyfill'` → 0 matches (side-effect; no local binding)
|
||||
// `import './polyfill'` → 1 match (side-effect; file-level edge only)
|
||||
const src = `
|
||||
import { A } from './a';
|
||||
import B from './b';
|
||||
@@ -238,7 +238,7 @@ describe('emitTsScopeCaptures — imports (decomposed)', () => {
|
||||
import './polyfill';
|
||||
`;
|
||||
const count = countMatches(src, (t) => t.includes('@import.statement'));
|
||||
expect(count).toBe(3);
|
||||
expect(count).toBe(4);
|
||||
|
||||
// Each has the corresponding @import.kind marker.
|
||||
const kinds = tagsFor(src)
|
||||
@@ -247,7 +247,7 @@ describe('emitTsScopeCaptures — imports (decomposed)', () => {
|
||||
const idx = tags.findIndex((t) => t === '@import.kind');
|
||||
return idx >= 0 ? tags[idx] : null;
|
||||
});
|
||||
expect(kinds).toHaveLength(3);
|
||||
expect(kinds).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('decomposes multi-specifier imports into one match per name', () => {
|
||||
|
||||
@@ -122,9 +122,13 @@ describe('interpretTsImport — static imports', () => {
|
||||
expect((ns as { importedName: string }).importedName).toBe('./m');
|
||||
});
|
||||
|
||||
it('side-effect: `import "./polyfill"` emits nothing (no local binding)', () => {
|
||||
it('side-effect: `import "./polyfill"` emits a side-effect ParsedImport (no local binding)', () => {
|
||||
const imps = importsFor('import "./polyfill";');
|
||||
expect(imps).toHaveLength(0);
|
||||
expect(imps).toHaveLength(1);
|
||||
expect(imps[0]).toEqual({
|
||||
kind: 'side-effect',
|
||||
targetRaw: './polyfill',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the module path as written (no quote stripping leftovers)', () => {
|
||||
|
||||
Reference in New Issue
Block a user