GitNexus/gitnexus/test/unit/node-workspace-packages.test.ts
Gergő Magyar 28187bb3a7
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(typescript): resolve imports against declared config, not path suffixes (#2953) (#2956)
* fix(typescript): resolve imports against declared config, not path suffixes (#2953)

TypeScript/JavaScript/Vue import resolution ended in `suffixResolve`, which
answers "does any file in this repo have a path ending in this specifier?" and
answers it by dropping leading segments until something matches. That is not
module resolution, and it failed in both directions at once:

  - `@acme/telemetry/nest`, a registry dependency with no in-repo file, landed
    on the repo's only path ending in `nest/index.ts` — a false IMPORTS edge at
    confidence 1.0, indistinguishable downstream from a real one. The reporter
    measured 44 of 74 `apps/ -> packages/` edges landing on two such files.
  - `@repo/utils`, a first-party workspace package, resolved to nothing: its
    name lives in `packages/utils/package.json` and appears in no file path, so
    a path matcher cannot find it. Zero CALLS from 75 import statements.

Both come from the same missing input — nothing read the config that says what
exists — so both are fixed by reading it.

Replaces the suffix matcher on this path with the algorithm tsc and Node
actually run, in their order: relative/absolute, `#imports`, tsconfig `paths`
(longest literal prefix wins, every target tried), tsconfig `baseUrl`, then the
workspace package's own `exports`/`main`. A specifier none of those declare is
external, and resolves to nothing. There is deliberately no fallback.

New:
  - `typescript/tsconfig.ts` — every tsconfig/jsconfig in the repo with
    `extends` chains resolved, nearest-config-wins per file. The old loader read
    three filenames at the repo root, required `paths` to exist, and kept only
    `targets[0]` — none of which describes a monorepo, where `apps/web/
    tsconfig.json` is what governs `apps/web/src/main.ts`.
  - `typescript/module-resolution.ts` — the algorithm.
  - `typescript/file-candidates.ts` — 11 TS-family extensions, replacing a
    shared 39-entry list spanning every indexed language, so a TypeScript
    import can no longer resolve to a `.py` file.
  - `import-resolvers/node-workspace-packages.ts` — in-repo manifests, with
    `exports` subpath maps, patterns, condition nesting, and the restriction
    that a package declaring `exports` exposes only what it lists.

The per-pass `SuffixIndex` is gone from these three adapters: real resolution
derives nothing from the file list — every candidate comes from a declared
source and is checked with one `Set.has` — so there is nothing left to cache.
Their `*-import-index-reuse` guards and the JS index-vs-scan differential are
deleted with the mechanism they measured; the cross-language contract test
moves the three languages to its existing `KNOWN_UNINDEXED` channel, and pins
the exemption as a list so a fourth arrival is deliberate.

Python, Ruby, Java, Go and the rest still route through `suffixResolve` and are
untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(scope-resolution): assert every resolver refuses external imports (#2953)

One property, for all 16 registered resolvers: a specifier naming something
outside the repository must not resolve to a file inside it.

That is the property #2953 was filed against, and its violation is not a
missing edge but a fabricated one — an IMPORTS edge at full confidence between
two files with no relationship, which `impact` then reports as blast radius.
The mechanism is shared (`suffixResolve`), so the guard is too.

Every case pairs an external specifier with a DECOY: an unrelated in-repo file
whose path ends the way the specifier does. Without one a resolver that merely
found nothing would pass while holding no property at all, so each case also
asserts the decoy is reachable by the spelling that SHOULD find it — a typo in
a fixture cannot manufacture a pass.

Two fixtures had to be corrected before the results meant anything, and both
would have recorded a false gap:

  - C# reads its #1881 gate from scanned namespace evidence and fails OPEN
    without any, so passing `undefined` measured nothing. Armed, C# holds.
  - C++ was posting a pass on an extension mismatch (`vector` could never match
    `src/vector.hpp` whatever the resolver did). Given the header spelling, it
    does not hold.

Result: six hold it — TypeScript, JavaScript and Vue because they resolve
against declared config only (#2953); Python (#898) and C# (#1881) because they
gate the fallback on in-repo evidence; Rust because `::` never decomposes into
a path suffix, which the decoy-reachability arm confirms is a real pass rather
than a vacuous one.

Ten do not, and are recorded in KNOWN_GAPS with what each currently answers:
Java, Kotlin, Go, Ruby, PHP, Dart, Swift, C, C++, COBOL. The map is a work
list, not an allowance — the entries are ASSERTED, so a language that starts
holding the property fails here and its line gets deleted deliberately rather
than rotting into a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): admit only declared workspace packages, and fix four resolver defects (#2953)

Review of #2956 found one boundary bug and four correctness defects. The
boundary one is the same defect class this PR exists to fix, arriving from a
different direction.

## The workspace boundary (review)

`loadNodeWorkspacePackages` registered every `package.json` the repo-wide scan
found, and never read `pnpm-workspace.yaml` or a root `workspaces`
declaration. Finding a manifest is not the same as the workspace admitting one:
an app importing registry package `foo` would bind to an excluded fixture or
example that happens to declare `name: "foo"` — the false-positive half of
#2953, from a new source of evidence. This repository is the example, since
`test/fixtures/**` declares `@repo/utils` among others.

The admitted set now comes from the declaration — `workspaces` (array and yarn
object form), `pnpm-workspace.yaml`, `lerna.json`, with `!` exclusions and
`*`/`**` — plus the root package itself. A repo that declares no workspace has
exactly one package: the root. A negative fixture pins it, with a named package
outside the declared globs that must not resolve.

## Four defects

  - tsconfig `paths` targets were resolved against the config's own directory
    when it declared `paths` but inherited `baseUrl`. tsc resolves them against
    the EFFECTIVE base, so an extending config loaded the right alias pattern
    and pointed every target at the wrong directory.
  - two configs in one directory were ranked by directory-listing order, so
    `tsconfig.base.json` could govern instead of `tsconfig.json` and a config's
    own `paths` went invisible. Found by the test written for the fix above.
  - an unexported package subpath also tried `<dir>/src/<subpath>`. Nothing
    declares that mapping; it is the same kind of guess this PR removes, and
    the import it "resolved" is broken in the real project too.
  - `imports` pattern keys (`"#internal/*"`) were looked up exactly, so a valid
    `#internal/foo` never matched. `exports` and `imports` now share one
    matcher, which is where they should never have diverged.
  - a relative specifier climbing past the repo root was silently clamped, so
    `../../../secret` from `src/main.ts` became `secret` and could resolve a
    root file it never named.

## Test rigor

The conformance suite asserted less than it claimed. The decoy-reachability arm
only checked non-empty, so five cases paired `reachesDecoy` with a different
file than `decoy` and passed while establishing nothing; the KNOWN_GAPS arm
likewise accepted any in-repo answer instead of the recorded one. Both now
assert the exact file. The reachability arm runs only for languages that HOLD
the property — for a gap language the recorded-answer assertion IS that proof,
and for Swift and COBOL no other spelling exists, since `Foundation` and
`EXTERNAL` name the in-repo directory and copybook as well as the external
module, which is precisely why those resolvers cannot tell them apart.

## Benchmarks

Both `--check` guards were red, and both were reporting something true.

`import-target`: the ts-family arms resolved 0 of 3200 imports. Their corpus is
bare specifiers with no config, which the deleted `suffixResolve` answered
without one — so the arms measured an empty branch while printing a clean
scaling ratio. Each now carries the config its corpus is spelled for, and the
`deep` arm's uniform prefix reaches it. THE FINGERPRINTS THEN MATCHED THE
RECORDED BASELINES EXACTLY: same corpus, same targets, once the config it
always implied is passed explicitly. Retained per-pass index went from
26 745 296 B (js, ts) and 28 884 016 B (vue) at 32 000 files to 0-16 B, because
these resolvers no longer build one; they move to the `HEAP_BOUNDED` tier rust
already occupies for the same reason. Depth ratio moved 2.0 -> ~2.2 and the
budget goes to 2.6: candidates now carry the 16-segment baseUrl prefix, so each
`Set.has` hashes a longer string — linear in path LENGTH, independent of file
COUNT.

`scope-capture`: TypeScript capture fingerprint drift, caused by this PR's 12
new `.ts` fixtures entering the corpus. Attribution is exact rather than
inferred — moving that one fixture directory aside returns the fingerprint to
`f719163e…` byte-for-byte with `fixture_count` back at 155 and all 15 languages
passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): honour exports fallback arrays, paths precedence and package extends (#2953)

Second review round. Four findings, judged against what this tool is: a static
analyser building a code graph, not a compiler. The bar is resolving what the
project DECLARES, on a checkout that may never have been built or installed,
and never inventing an edge.

  - `exports` and `imports` ARRAYS were skipped. An array is Node's ordered
    fallback list, and `{"./feature": ["./dist/feature.js", "./src/feature.ts"]}`
    is exactly what a workspace package publishes to mean "built output, or
    source". Skipping it dropped the declaration entirely and left the package
    looking as though it exported no subpaths. The source arm is the one that
    matters here, because `dist/` is build output and is not indexed — and for
    a static analyser the build need not have run at all.
  - an exact `paths` pattern did not reliably outrank a wildcard. `a` and `a*`
    both match `a` with the same literal prefix length, so sorting on length
    alone left tsc's exact-wins rule to declaration order.
  - package-form `extends` (`"@acme/tsconfig"`) was refused outright. Not
    indexing `node_modules` is different from not READING it, and a shared
    internal base is where a monorepo puts the `paths` its packages import
    through. It is now read from disk, walking `node_modules` up from the
    extending config the way Node does, and absent on an un-installed checkout
    it degrades to whatever that config declared itself.

    The test pins what tsc actually does with such a base rather than what one
    might hope: `extends` never rebases `baseUrl`, so a package base's paths
    point at the package's own directory. That is why a published base rarely
    contributes aliases a repo's files resolve through, and why the
    `@tsconfig/*` family — which sets `target` and `lib`, never `paths` — is a
    no-op here either way.
  - CodeQL flagged `String.replace('*', …)` in two places as replacing only the
    first occurrence. Node subpath patterns and tsconfig `paths` both allow AT
    MOST one `*`, so that IS the specified behaviour — but the spelling states
    it by accident and reads as the replace-all footgun. `substituteStar`
    slices at the known index and says the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): treat `exports` as the whole interface, and keep empty tsconfig scopes (#2953)

Third review round. Two findings, both valid, both cases of this resolver being
laxer than the thing it models — which is the direction that fabricates edges.

  - `exports`, when a manifest declares it, is the package's ENTIRE public
    interface: Node ignores `main` outright and refuses any subpath the map
    does not list. This resolver already honoured that restriction for
    SUBPATHS and not for the package ROOT, which is the same rule. A manifest
    exporting only `"./feature"` therefore still answered a bare `@repo/pkg`
    with `main` or `src/index` — an edge for an import that does not resolve in
    the real project. Legacy and conventional root candidates are now offered
    only when there is no `exports` field at all.

  - a tsconfig declaring neither `baseUrl` nor `paths` was dropped rather than
    kept as an empty scope, so `tsconfigFor` fell through to an enclosing
    config. A package whose own tsconfig declares no `baseUrl` — meaning its
    non-relative specifiers are package lookups — silently inherited the repo
    root's aliases instead. An empty scope is the accurate answer for such a
    file, and only a scope can express it.

Both are pinned at the level they broke: the manifest arms assert what
`readManifest` produces, not a hand-built package, since the resolver honouring
empty entries and the loader producing them are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:13:04 +01:00

341 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TypeScript/JavaScript module resolution — the declared-input rules (#2953).
*
* The property under test is one sentence: a specifier resolves when something
* in the repo DECLARES it, and not otherwise. Each arm names the declaration
* doing the work — a real path, a tsconfig mapping, a package manifest — and
* the negative arms are the ones the old suffix matcher got wrong, so they are
* paired with a positive arm resolving the same specifier to the same file once
* a declaration exists.
*/
import { describe, it, expect } from 'vitest';
import {
nodePackageNameOf,
substituteStar,
type NodeWorkspacePackages,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
import { resolveTsModule } from '../../src/core/ingestion/languages/typescript/module-resolution.js';
import type { TsconfigIndex } from '../../src/core/ingestion/languages/typescript/tsconfig.js';
const FILES = new Set([
'apps/web/src/main.ts',
'apps/web/src/utils/foo.ts',
'packages/inner/src/nest/index.ts',
'packages/inner/src/secret.ts',
'packages/utils/src/index.ts',
'packages/utils/src/deep/thing.ts',
]);
const PACKAGES: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/utils',
{
dir: 'packages/utils',
entries: ['packages/utils/src/index'],
subpathExports: new Map(),
subpathImports: new Map(),
},
],
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
// An `exports` map is a restriction: `nest` is public, `secret` is not.
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
subpathImports: new Map([['#hidden', ['packages/inner/src/secret']]]),
},
],
]),
};
function resolve(
specifier: string,
opts: {
from?: string;
tsconfigs?: TsconfigIndex | null;
packages?: NodeWorkspacePackages | null;
} = {},
): string | null {
return resolveTsModule(specifier, {
fromFile: opts.from ?? 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: opts.tsconfigs ?? null,
workspacePackages: opts.packages === undefined ? PACKAGES : opts.packages,
});
}
const BASE_URL_SRC: TsconfigIndex = {
scopes: [{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] }],
};
describe('nodePackageNameOf', () => {
it('takes two segments for a scoped specifier and one otherwise', () => {
expect(nodePackageNameOf('@acme/telemetry/nest')).toBe('@acme/telemetry');
expect(nodePackageNameOf('@repo/utils')).toBe('@repo/utils');
expect(nodePackageNameOf('lodash/fp')).toBe('lodash');
expect(nodePackageNameOf('utils/foo')).toBe('utils');
});
it('returns null for specifiers that name a path or a package-internal import', () => {
expect(nodePackageNameOf('./sibling')).toBeNull();
expect(nodePackageNameOf('../up')).toBeNull();
expect(nodePackageNameOf('/abs')).toBeNull();
expect(nodePackageNameOf('#hidden')).toBeNull();
expect(nodePackageNameOf('@scope-only')).toBeNull();
});
});
describe('relative specifiers', () => {
it('resolves by exact path, extension and directory index', () => {
expect(resolve('./utils/foo')).toBe('apps/web/src/utils/foo.ts');
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
});
it('resolves the ESM `.js` spelling of a `.ts` source', () => {
expect(resolve('./utils/foo.js')).toBe('apps/web/src/utils/foo.ts');
});
it('resolves to nothing when the path does not exist', () => {
expect(resolve('./nowhere')).toBeNull();
});
});
describe('external packages (#2953 direction 1)', () => {
it('does not resolve a registry package into the repo', () => {
// The reported defect: `@acme/telemetry` is a registry dependency, and the
// repo's only path ending in `nest/index.ts` belongs to an unrelated
// package. Dropping leading segments found it; declared resolution does not.
expect(resolve('@acme/telemetry/nest')).toBeNull();
});
it('does not resolve a bare specifier that merely matches a path suffix', () => {
expect(resolve('utils/foo')).toBeNull();
expect(resolve('src/utils/foo')).toBeNull();
});
it('resolves that same specifier once a tsconfig baseUrl declares it', () => {
expect(resolve('utils/foo', { tsconfigs: BASE_URL_SRC })).toBe('apps/web/src/utils/foo.ts');
});
});
describe('relative traversal out of the repository', () => {
it('resolves nothing when a specifier climbs past the root', () => {
// Popping an empty segment list silently CLAMPS at the root, so
// `../../../secret` from `apps/web/src/main.ts` became `secret` and could
// resolve a repo-root file the specifier never named. Outside the repo
// there is nothing indexed, so the answer is nothing.
expect(resolve('../../../../../../etc/passwd')).toBeNull();
});
it('still resolves a traversal that lands exactly on the root', () => {
// The paired positive: climbing to the root is legal, only climbing PAST
// it is not, and the guard must not take the legal case with it.
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
});
});
describe('tsconfig paths', () => {
const withPaths: TsconfigIndex = {
scopes: [
{
dir: '',
baseUrl: null,
paths: [
{ pattern: '@/*', targets: ['apps/web/src/*'] },
// A longer literal prefix must win over `@/*` even though it is
// declared second — tsc ranks by prefix length, not declaration order.
{ pattern: '@/utils/*', targets: ['packages/utils/src/*'] },
{ pattern: 'exact', targets: ['packages/utils/src/index'] },
],
},
],
};
it('substitutes the wildcard', () => {
expect(resolve('@/main', { tsconfigs: withPaths })).toBe('apps/web/src/main.ts');
});
it('prefers the longest matching pattern, not the first declared', () => {
expect(resolve('@/utils/deep/thing', { tsconfigs: withPaths })).toBe(
'packages/utils/src/deep/thing.ts',
);
});
it('supports a starless exact pattern', () => {
expect(resolve('exact', { tsconfigs: withPaths })).toBe('packages/utils/src/index.ts');
});
it('tries every target in order, not just the first', () => {
const twoTargets: TsconfigIndex = {
scopes: [
{
dir: '',
baseUrl: null,
// The first target names nothing; the old loader kept only this one.
paths: [{ pattern: '~/*', targets: ['generated/*', 'packages/utils/src/*'] }],
},
],
};
expect(resolve('~/index', { tsconfigs: twoTargets })).toBe('packages/utils/src/index.ts');
});
it('applies the nearest config, not the root one', () => {
const nested: TsconfigIndex = {
scopes: [
{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] },
{ dir: '', baseUrl: 'packages/utils/src', paths: [] },
],
};
// `apps/web/src/main.ts` is governed by `apps/web`, whose baseUrl resolves
// `utils/foo`. The root config would have looked in `packages/utils/src`.
expect(resolve('utils/foo', { tsconfigs: nested })).toBe('apps/web/src/utils/foo.ts');
});
});
describe('workspace packages (#2953 direction 2)', () => {
it('resolves a package name to its manifest entry point', () => {
// The name appears in no file path, so nothing but the manifest can find it.
expect(resolve('@repo/utils')).toBe('packages/utils/src/index.ts');
});
it('resolves a subpath a package exports', () => {
expect(resolve('@repo/inner/nest')).toBe('packages/inner/src/nest/index.ts');
});
it('honours the restriction an `exports` map imposes', () => {
// `secret.ts` exists and its path would satisfy any suffix match, but the
// package does not export it. Node would refuse, and so does this.
expect(resolve('@repo/inner/secret')).toBeNull();
});
it('resolves a package-internal `#` import against the importing package', () => {
expect(resolve('#hidden', { from: 'packages/inner/src/nest/index.ts' })).toBe(
'packages/inner/src/secret.ts',
);
});
it('does not honour another packages `#` imports', () => {
expect(resolve('#hidden', { from: 'apps/web/src/main.ts' })).toBeNull();
});
it('does not fall back into `src/` for an unexported subpath', () => {
// `@repo/utils` declares no `exports`, so Node resolves a subpath against
// the package DIRECTORY and only against it. An earlier draft also tried
// `<dir>/src/<subpath>` on the theory that a workspace package is consumed
// from source — but nothing declares that mapping, so it is the same kind
// of guess this module exists to remove, and the import it "resolves" is
// broken in the real project too.
expect(resolve('@repo/utils/deep/thing')).toBeNull();
});
it('resolves a `#imports` PATTERN key, not just an exact one', () => {
const patterned: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
subpathExports: new Map(),
subpathImports: new Map([['#internal/*', ['packages/inner/src/*']]]),
},
],
]),
};
expect(
resolveTsModule('#internal/secret', {
fromFile: 'packages/inner/src/nest/index.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: patterned,
}),
).toBe('packages/inner/src/secret.ts');
});
it('honours an `exports` ARRAY as an ordered fallback list', () => {
// `["./dist/x.js", "./src/x.ts"]` is what a workspace package publishes to
// say "built output, or source". For a static analyser the source arm is
// the one that matters, because `dist/` is build output and is not indexed
// — and a build need not have run at all for the repo to be analysable.
const withArray: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
subpathExports: new Map([
['nest', ['packages/inner/dist/nest', 'packages/inner/src/nest']],
]),
subpathImports: new Map(),
},
],
]),
};
expect(
resolveTsModule('@repo/inner/nest', {
fromFile: 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: withArray,
}),
).toBe('packages/inner/src/nest/index.ts');
});
it('refuses the package root when `exports` omits it', () => {
// `exports` is the package's ENTIRE public interface when present: Node
// ignores `main` and refuses anything the map does not list. The root is
// the same rule as a subpath, so a manifest exporting only `./nest` must
// not answer a bare `@repo/inner` — that import does not resolve in the
// real project, and an edge for it is a fabricated one.
const subpathOnly: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
// What `readManifest` produces for `{"exports": {"./nest": …}}`:
// no root entry, and no legacy/conventional fallbacks.
entries: [],
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
subpathImports: new Map(),
},
],
]),
};
const ctx = {
fromFile: 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: subpathOnly,
};
expect(resolveTsModule('@repo/inner', ctx)).toBeNull();
// The paired positive: what the map DOES list still resolves.
expect(resolveTsModule('@repo/inner/nest', ctx)).toBe('packages/inner/src/nest/index.ts');
});
it('resolves nothing when the repo declares no packages at all', () => {
expect(resolve('@repo/utils', { packages: null })).toBeNull();
});
});
describe('subpath pattern substitution', () => {
it('substitutes the single `*` a pattern is allowed to contain', () => {
// Node subpath patterns and tsconfig `paths` both allow AT MOST one `*`, so
// substituting the first occurrence is the specified behaviour. Pinned
// because the obvious spelling — `String.replace` with a string needle —
// states that only by accident and reads as the replace-all footgun.
expect(substituteStar('packages/utils/src/*.ts', 'deep/thing')).toBe(
'packages/utils/src/deep/thing.ts',
);
});
it('leaves a starless target alone', () => {
expect(substituteStar('packages/utils/src/index', 'ignored')).toBe('packages/utils/src/index');
});
});