feat(extensions): nativescript.commands map — per-command lazy loading for extensions - #6102
feat(extensions): nativescript.commands map — per-command lazy loading for extensions#6102edusperoni wants to merge 12 commits into
Conversation
📝 WalkthroughWalkthroughThe CLI adds deferred command registration for extension manifest maps. It validates command names, resolves ownership conflicts, loads modules lazily, adapts exported command definitions, preserves legacy arrays, and documents supported extension formats. ChangesDeclarative Extension Commands
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ExtensionManifest
participant ExtensibilityService
participant CommandRegistry
participant LazyCommandModule
participant CommandDefinitionAdapter
ExtensionManifest->>ExtensibilityService: Declare nativescript.commands map
ExtensibilityService->>CommandRegistry: Register deferred command
CommandRegistry->>LazyCommandModule: Load command module on lookup
LazyCommandModule->>CommandDefinitionAdapter: Adapt exported command definition
CommandDefinitionAdapter->>CommandRegistry: Register definition under manifest name
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
b15734b to
863f964
Compare
220e027 to
02d6f7c
Compare
863f964 to
74caa8f
Compare
02d6f7c to
d8a8fcf
Compare
74caa8f to
cafa737
Compare
d8a8fcf to
e0c671c
Compare
cafa737 to
a1ba0ef
Compare
e0c671c to
10aaa87
Compare
a1ba0ef to
07c979c
Compare
10aaa87 to
7bbf81e
Compare
07c979c to
d712a4e
Compare
7bbf81e to
bcd08f0
Compare
|
@copilot resolve the merge conflicts in this pull request |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
test/extension-manifests.ts (1)
592-619: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert the single module load in the alias test.
The test name states that the shared module is loaded once. The body does not assert that. Add an assertion on
capture.loadedModulesafter both resolutions, so a regression that reloads the module per alias fails this test.♻️ Proposed assertion
assert.isOk(testInjector.resolveCommand("nsmalias|run")); const aliased = testInjector.resolveCommand("nsmalias|r"); assert.isOk(aliased); + assert.deepEqual(capture.loadedModules, ["alias-run"]); await aliased.execute(["x"]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/extension-manifests.ts` around lines 592 - 619, Add an assertion to the alias test after resolving both commands in “routes two aliases of one command to the same module” that verifies capture.loadedModules contains exactly one load of the shared module; keep the existing command execution and capture.executed assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@extensions.md`:
- Line 18: Convert the section headings in extensions.md, including the heading
near “Depending on the CLI” and those at the referenced locations, from ATX
syntax to setext syntax consistent with the file and related documentation. Add
the text language identifier to the unlabeled fenced block near line 263 while
preserving its contents.
In `@lib/common/yok.ts`:
- Around line 159-233: Update registerDeferredCommand to reject a hierarchical
child when its direct parent command is already registered, before calling
super.register or mutating ownership/command state. Add a dedicated structured
reason to DeferredCommandRejection and handle it in describeRejection,
preserving existing behavior for valid parent-child registrations.
- Around line 107-108: Initialize deferredCommandOwners with a null-prototype
object via Object.create(null) instead of a normal object, so command names such
as constructor cannot resolve inherited Object.prototype properties during
ownership checks.
In `@test/extension-manifests.ts`:
- Around line 462-479: Update the fixture generation logic around
definitionModule to resolve the contracts module through Vitest’s requireService
loader before constructing the generated JavaScript, instead of using plain
require.resolve. Ensure the generated fixture embeds the loader-resolved path so
its later plain require can load lib/contracts consistently.
---
Nitpick comments:
In `@test/extension-manifests.ts`:
- Around line 592-619: Add an assertion to the alias test after resolving both
commands in “routes two aliases of one command to the same module” that verifies
capture.loadedModules contains exactly one load of the shared module; keep the
existing command execution and capture.executed assertions unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7b8aebe3-5ac4-44bf-a4d4-08d2aba24c24
📒 Files selected for processing (13)
defining-commands.mddependency-injection.mdextensions.mdlib/common/contracts/command-registry.tslib/common/contracts/index.tslib/common/definitions/extensibility.d.tslib/common/di/index.tslib/common/di/injector.tslib/common/di/providers.tslib/common/services/command-definition-adapter.tslib/common/yok.tslib/services/extensibility-service.tstest/extension-manifests.ts
| const contractsPath = require.resolve("../lib/contracts"); | ||
|
|
||
| const definitionModule = ( | ||
| commandName: string, | ||
| marker: string, | ||
| exportAs: string = "module.exports", | ||
| ): string => | ||
| `const { defineCommand } = require(${JSON.stringify(contractsPath)}); | ||
| global.__nsmCapture.loadedModules.push(${JSON.stringify(marker)}); | ||
| ${exportAs} = defineCommand({ | ||
| name: ${JSON.stringify(commandName)}, | ||
| arguments: "any", | ||
| async run(ctx) { | ||
| global.__nsmCapture.executed.push({ marker: ${JSON.stringify( | ||
| marker, | ||
| )}, args: ctx.args }); | ||
| }, | ||
| });`; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Inspect how `lib/contracts` resolves and how tests are executed.
set -euo pipefail
fd -a 'contracts' lib --max-depth 2
fd -a 'index.ts' lib/contracts 2>/dev/null || true
# Test runner configuration and TS handling
fd -H -t f 'vitest.config.*|vite.config.*|tsconfig*.json' . --max-depth 2 --exec cat -n {}
# How the test script is invoked
rg -n '"(test|pretest|build)"\s*:' package.json -A2Repository: NativeScript/nativescript-cli
Length of output: 2626
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate extension-manifests.ts and inspect the relevant fixture-generation snippet plus any build hooks.
fd -a 'extension-manifests.ts' test scripts --max-depth 3 --exec sh -c '
echo "FILE: $1"
wc -l "$1"
sed -n "420,530p" "$1" | cat -n
' sh {}
# Check whether lib/contracts has a package.json or otherwise supports Node require from lib-relative paths.
if [ -f lib/contracts/package.json ]; then
cat -n lib/contracts/package.json
else
echo "NO_PACKAGE_JSON"
fi
# Show lockfile/package versions related to vitest/vite/ts to infer runtime support if needed.
for f in package-lock.json pnpm-lock.yaml yarn.lock bun.lock; do
if [ -f "$f" ]; then
echo "LOCKFILE: $f"
rg -n "vitest|typescript|ts-node" "$f" -A2 -B2 | head -n 80 || true
fi
done
# Behavioral probe from repository file contents using Node without running repository scripts:
# - Resolve the same path from the repo root using node's internal require.resolve semantics if available.
# - Check whether the target file exists at the TypeScript source vs package.json "main" vs compiled dist target.
node - <<'JS'
const fs = require('fs');
const path = require('path');
console.log('NODE_VERSION', process.version);
console.log('CWD', process.cwd());
const source = path.join(process.cwd(), 'lib/contracts/index.ts');
const dist = path.join(process.cwd(), 'dist/lib/contracts/index.js');
const pkg = path.join(process.cwd(), 'lib/contracts/package.json');
console.log('SOURCE_EXISTS', fs.existsSync(source));
console.log('SOURCE_REALPATH', fs.realpathSync(source));
console.log('DIST_EXISTS', fs.existsSync(dist));
console.log('PKG_EXISTS', fs.existsSync(pkg));
console.log('PKG_CONTENT', fs.existsSync(pkg) ? JSON.parse(fs.readFileSync(pkg, 'utf8')) : null);
try {
const r = require.resolve('../lib/contracts');
console.log('REQUIRE_RESOLVE', r);
console.log('REQUIRE_RESOLVE_EXISTS', fs.existsSync(r) || fs.existsSync(r + '.js') || fs.existsSync(r + '.' + (path.extname(r) || '').slice(1)));
console.log('FS_STAT', fs.statSync(r));
} catch (e) {
console.log('REQUIRE_RESOLVE_ERROR', e.code || e.message);
}
JSRepository: NativeScript/nativescript-cli
Length of output: 7774
Route requireService through Vitest’s module loader before using require.resolve.
require.resolve("../lib/contracts") resolves inside the test process, but the generated fixture is a compiled .js file that later runs with plain require. Since lib/contracts/index.ts is not a Node module, this can fail unless Vitest’s loader intercepts that load. Make the fixture generation path consistent with test execution.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/extension-manifests.ts` around lines 462 - 479, Update the fixture
generation logic around definitionModule to resolve the contracts module through
Vitest’s requireService loader before constructing the generated JavaScript,
instead of using plain require.resolve. Ensure the generated fixture embeds the
loader-resolved path so its later plain require can load lib/contracts
consistently.
…s map An extension whose package.json declares nativescript.commands as a map of command name to module path is no longer require()d at startup. Each entry is registered with injector.requireCommand against the module's absolute path, so a command's implementation loads only when that command is first resolved, and the CLI stops paying every installed extension's load cost on every invocation. Entries are validated: a command name or module path that is not a non-empty string is warned about and skipped, and a name already claimed by another extension is reported as a warning naming both extensions rather than propagating the injector's "require'd twice" failure. The legacy array shape (and a missing commands key) keeps today's behavior verbatim - eager require of the extension main plus the extensions.require-time-registration deprecation report. Both shapes now feed IExtensionData.commands and the npm install suggestion for unknown commands.
A manifest entry may now point at a module that exports a defineCommand definition instead of registering itself on load: the deferred loader adapts and registers the export under the manifest key. The override also lands on a parent record the entry just created, because dispatch resolves the hierarchical parent before any child module has loaded and the dispatcher only comes into existence once a child registers. Also cross-links the authoring guides from dependency-injection.md.
… seam The service takes $injector as a constructor dependency instead of the module-level import, so manifest registration and the definition-aware loaders target the instance that resolved it. Tests assert on their own per-test injector; the process-wide injector is swapped only because legacy-shape fixture modules register through the published global surface at load, and that seam is labeled as such. extensions.md no longer teaches the global-injector patterns: the legacy array path and self-registering modules are described under their deprecation framing without runnable samples.
Registry operations go through the narrow subsystem contract; the full facade stays only for container-record operations (has, provider registration). First consumer of the per-face tokens.
…iner A record carrying only a lazy-require loader resolves to an error until the loader registers something onto it, so the form is not one callers should be offered: drop ILazyRequireProvider from the exported Provider union and keep it in an InternalProvider alias the container accepts. Add hasResolver() so the deferred paths can tell a record that a loader has filled in from one it left empty.
Claiming a command name and loading its implementation are now separate: the registry builds routing — the command record, the parent's subcommand list and the parent dispatcher — from the name alone, and runs the loader only when that one command is resolved. A sibling's dispatch no longer drags in the first claimant's module, and the outcome comes back as a structured result instead of a thrown message callers have to match on. Names that are not lower case are rejected: dispatch lower-cases what the user typed, so they could never be reached. A loader that throws, or that leaves the command without a resolver, fails naming the owner and the source. Extract registerDefinitionAs so a definition registered under a name chosen by its registrant is built exactly like one registered under its own.
The manifest loader no longer writes injector records or reads exception text to detect conflicts; it hands each entry to registerDeferredCommand and reports the rejection it gets back. A command claimed by another extension names that extension, one the CLI provides says so without exposing internals, and re-loading an already loaded extension is silent rather than a conflict with itself. Entry values may now be an object carrying the module path under `path`, with unrecognised keys ignored, so the shape can grow without stranding manifests on released CLIs. Default commands are registered ahead of their siblings so JSON key order carries no meaning. The manifest key is what the command is dispatched as — routing happens before the module exists — so a definition whose own name disagrees runs under the key and warns naming both, and definitions register through the same helper as registerCommandDefinition.
Lead with the peerDependency + devDependency pair that makes
`require("nativescript/contracts")` resolve and keeps a second CLI copy out of
the tree, and teach inject() as the way to reach a CLI service.
Cover what the manifest actually promises: the key is authoritative for
routing, aliases are duplicate entries pointing at one module, entry values may
be envelopes, an empty map opts out of loading, keys must be lower case, and
"first" in first-wins is the order extensions load in. Drop the JSON key-order
constraint, which no longer exists.
6568c06 to
da7147b
Compare
|
Note GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
test/extension-manifests.ts (1)
611-618: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAssert that the aliased module loads once.
The test name states that two aliases route to the same module. The assertions only confirm that both names resolve.
extensions.mddocuments that the module "is loaded once", andcapture.loadedModulescan prove it.♻️ Proposed assertion
assert.isOk(testInjector.resolveCommand("nsmalias|run")); const aliased = testInjector.resolveCommand("nsmalias|r"); assert.isOk(aliased); + assert.deepEqual(capture.loadedModules, ["alias-run"]); await aliased.execute(["x"]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/extension-manifests.ts` around lines 611 - 618, Update the test covering two aliases routing to the same module to assert that capture.loadedModules contains the aliased module exactly once after executing the alias. Keep the existing resolution and execution assertions intact, and use the existing capture.loadedModules value for the assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@test/extension-manifests.ts`:
- Around line 611-618: Update the test covering two aliases routing to the same
module to assert that capture.loadedModules contains the aliased module exactly
once after executing the alias. Keep the existing resolution and execution
assertions intact, and use the existing capture.loadedModules value for the
assertion.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 144e4ff8-9595-4eaa-b5ab-7a839b0f6f48
📒 Files selected for processing (13)
defining-commands.mddependency-injection.mdextensions.mdlib/common/contracts/command-registry.tslib/common/contracts/index.tslib/common/definitions/extensibility.d.tslib/common/di/index.tslib/common/di/injector.tslib/common/di/providers.tslib/common/services/command-definition-adapter.tslib/common/yok.tslib/services/extensibility-service.tstest/extension-manifests.ts
🚧 Files skipped from review as they are similar to previous changes (10)
- defining-commands.md
- lib/common/di/index.ts
- lib/common/services/command-definition-adapter.ts
- dependency-injection.md
- lib/common/definitions/extensibility.d.ts
- lib/common/contracts/index.ts
- lib/common/di/providers.ts
- lib/common/contracts/command-registry.ts
- lib/common/yok.ts
- lib/common/di/injector.ts
A subcommand whose direct parent is already a command of its own can never be dispatched: no dispatcher is built for the parent, because building one would hide it. Registering the child anyway reported success for a name that could not run, so the registry rejects it with a reason naming the parent that took the name. Command names also come from third-party manifests, where a name such as 'constructor' resolved to an inherited member of the name-keyed maps: a false conflict from the deferred owners map, and a TypeError from the hierarchical one.
|
Thanks — went through the review. Three of the four are addressed in 8230926 and 945528a:
Not taking the fourth ( Three tests were added for the fixed behaviours, each verified to fail against the unfixed code first. Suite: 1815 passed / 9 skipped. |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/common/yok.ts (1)
211-215: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winPreserve diagnostics for non-
Errorthrows.Line 214 reads
err.message. A command module can thrownull,undefined, or a string. In that case, this handler throws a newTypeErroror loses the original detail. The error then omits the command and source diagnostic.Proposed fix
} catch (err) { + const detail = err instanceof Error ? err.message : String(err); throw new Error( `Unable to load command '${name}' of ${options.owner} from ` + - `${options.source}: ${err.message}`, + `${options.source}: ${detail}`, ); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/common/yok.ts` around lines 211 - 215, Update the catch handler in the command-loading flow around the visible `catch (err)` block to safely format any thrown value, including null, undefined, and strings, instead of accessing `err.message` directly. Preserve the existing command name, owner, and source context while retaining the original thrown value’s diagnostic detail in the new error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@lib/common/yok.ts`:
- Around line 211-215: Update the catch handler in the command-loading flow
around the visible `catch (err)` block to safely format any thrown value,
including null, undefined, and strings, instead of accessing `err.message`
directly. Preserve the existing command name, owner, and source context while
retaining the original thrown value’s diagnostic detail in the new error.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fc9103b2-5dcc-4551-950c-a14e206dadfc
📒 Files selected for processing (5)
extensions.mdlib/common/contracts/command-registry.tslib/common/yok.tslib/services/extensibility-service.tstest/extension-manifests.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- lib/common/contracts/command-registry.ts
- extensions.md
- lib/services/extensibility-service.ts
PR Checklist
What is the current behavior?
Every installed extension is eagerly
require()d on every CLI invocation, before the command is even known — the extension's whole module tree loads so its top-level side effects can register commands againstglobal.$injector.nativescript.commandsin an extension's package.json is astring[]used only to suggest installs for unknown commands. Two extensions claiming the same command name crash at startup.What is the new behavior?
nativescript.commandsalso accepts a map of command name → module path, which becomes authoritative:string | { path }so the envelope can grow additively.defineCommanddefinition'snamedisagrees with its manifest key, the CLI warns naming both and runs under the key. Aliases are duplicate manifest entries pointing at the same module.registerDeferredCommandon theCommandRegistryfacet: claiming a name and loading its implementation are now separate registry operations. The registry builds the command record, the parent's subcommand list, and the parent dispatcher from the name alone — a sibling's dispatch never drags in the first claimant's module — and returns a structuredDeferredCommandResult(claimed/built-in/subcommand-parent/invalid-name) instead of exception text callers must match on. This is what keeps the future registry extraction a provider swap.*defaultentries sort first per parent in code — JSON key order carries no meaning. First-wins conflict resolution is defined in the docs (extension load order, alphabetical; the mid-loadns extension installexception documented). Re-declaring a command under the same owner is a no-op, sons extension install <already-installed>no longer warns about conflicting with itself."commands": {}opts out of loading entirely.defineCommanddefinition — one registration code path (registerDefinitionAs) serves both the manifest loader andregisterCommandDefinition.ILazyRequireProvideris no longer part of the exportedProviderunion (container-internal).extensions.md— leads with thepeerDependency+devDependencyonnativescriptandinject()fromnativescript/contracts.Public type names follow the new-API convention (no
Iprefix):DeferredCommandOptions,DeferredCommandResult,DeferredCommandRejection.25 tests in
test/extension-manifests.ts(lazy registration, eager-path preservation, malformed/conflict/self-conflict handling, both suggestion shapes, pure-definition modules incl. resolving the parent dispatcher before any child module has loaded, key-mismatch warning, alias entries,{}opt-out). Full stacked suite: 116 files, 1784 passed / 9 skipped; yok oracle, public-API test, and compat fixtures untouched.Summary by CodeRabbit
New Features
Documentation