Compare commits
9
Commits
2fd40d9d51
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f422a748e | ||
|
|
a25adacaee | ||
|
|
5993da4ce6 | ||
|
|
3a3d70efeb | ||
|
|
90dc18a167 | ||
|
|
19dbfe0f34 | ||
|
|
84a44bedfd | ||
|
|
b5e055216c | ||
|
|
21c2275c00 |
@@ -11,6 +11,8 @@ debug.log
|
||||
docs/**
|
||||
OpenSAGE/**
|
||||
test/**
|
||||
images/**
|
||||
!images/icon.png
|
||||
**/*.map
|
||||
tsconfig.json
|
||||
esbuild.mjs
|
||||
|
||||
+103
@@ -1,5 +1,108 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.26 — 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- **AI Agent access (MCP).** `RA3 Mod XML: Enable AI Agent access…` exposes the
|
||||
semantic index to AI agent clients through a local, read-only MCP (Model
|
||||
Context Protocol) server. It exports a stable snapshot, writes a launcher
|
||||
under `~/.ra3modxml/`, starts a loopback query server while VS Code is
|
||||
running, and offers to install the Agent Skill and/or write the MCP client
|
||||
configuration (Claude Desktop, Cursor global/project, or a copied generic
|
||||
block). `Disable AI Agent access` stops the live server for the workspace;
|
||||
`Uninstall AI Agent integration…` removes MCP config entries, the launcher,
|
||||
and Skill copies after a confirmation prompt.
|
||||
- **Agent Skill (`ra3-mod-xml`).** `RA3 Mod XML: Install Agent Skill…` installs
|
||||
`SKILL.md` plus a query-tool reference to `~/.agents/skills/`, and optionally
|
||||
to Claude Code or project-local skill directories. It explains when the index
|
||||
applies and how to reach it with or without MCP. `Uninstall Agent Skill…`
|
||||
removes only directories that still carry the extension's marker.
|
||||
- **`RA3 Mod XML: Export AI Agent index snapshot`** writes the gzipped,
|
||||
versioned snapshot that the MCP server and CLI fall back to when VS Code is
|
||||
closed.
|
||||
- **Agent CLI** (`dist/agent/cli.js`): live-index first, exported-snapshot
|
||||
fallback. `outgoing` and `projects` need element context and report that
|
||||
explicitly instead of returning an empty result.
|
||||
- **First-run / upgrade introduction.** After the first index, the extension
|
||||
offers AI Agent access once (per machine). Upgrades from a build that already
|
||||
had the feature stay silent, and "Don't show again" is remembered.
|
||||
- **Live discovery and multi-window safety.** Per-project endpoint files, one
|
||||
`instances/` entry per VS Code window, and a merged `~/.ra3modxml/index.json`.
|
||||
Queries are pinned to a project and a mismatched answer is refused. A crashed
|
||||
window's instance file is pruned by the next activation.
|
||||
- **No Node installation required.** The MCP launcher for AI Agent Access runs
|
||||
the bundled server on VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`)
|
||||
and falls back to `node` on `PATH` only when that binary is missing.
|
||||
Launchers are refreshed on activation, so moving or updating VS Code does not
|
||||
break an existing MCP configuration.
|
||||
|
||||
## 0.1.25 — 2026-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Repository and homepage links now point to the GitHub mirror; README links to both GitHub and Gitea.
|
||||
- Extension now activates in untrusted (Restricted Mode) workspaces; workspace-defined `ra3modxml.sdkPath` and `ra3modxml.additionalDataSearchPaths` are ignored until the workspace is trusted.
|
||||
|
||||
- **Find asset.** `RA3 Mod XML: Find asset (id or Type:Id)…` opens a search
|
||||
picker over the whole index: type `Type:Id`, an exact id or a partial id and
|
||||
the result list updates as you type (`Type:` alone lists every asset of that
|
||||
type). Rows show the asset type, origin (project / SDK / manifest), reference
|
||||
count and defining file; accepting one jumps to the definition. When a
|
||||
`type:id` is defined in several places (mod override + vanilla source +
|
||||
compiled manifest) a second picker chooses which definition to open. The
|
||||
query can also be prefilled from the current selection through the editor
|
||||
context menu entry.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The editor context-menu entries were declared under a top-level `menus` key
|
||||
instead of `contributes.menus`, so VS Code never showed them. Adding the new
|
||||
search entry moved the block where it belongs; `Find unreferenced assets of
|
||||
this type` now appears in the editor context menu as documented.
|
||||
|
||||
## 0.1.24 — 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manifest-style qualified reference values (`Type:Id`, e.g.
|
||||
`inheritFrom="AudioEvent:BaseSoundEffect"`,
|
||||
`Sound="AudioEvent:JAP_Refinery_Select"`, `Side="PlayerTemplate:Allies"`)
|
||||
now resolve to the plain-id definitions indexed from XML (mod or
|
||||
`SageXml`). Previously the plugin only applied the “last colon segment”
|
||||
rule to manifest asset names, so qualified XML references were reported as
|
||||
unresolved even when the definition existed (the reported
|
||||
`AudioEvent:BaseSoundEffect` case).
|
||||
- The same normalization now applies to simple-content references, the
|
||||
semantic reverse index (Find All References / CodeLens counts), and the
|
||||
reference peek path, so hover, Ctrl+click, diagnostics, reference counts
|
||||
and unreferenced reports all agree.
|
||||
- Value completion keeps a `Type:` prefix the user already typed:
|
||||
`inheritFrom="AudioEvent:Base…` completes to
|
||||
`AudioEvent:BaseSoundEffect` instead of dropping the prefix. Plain ids
|
||||
without a prefix keep the previous bare-id behavior.
|
||||
|
||||
- `inheritFrom` is now accepted on all `BaseAssetType`-derived assets (e.g. `FXList`, `AIMicroManagerData`, `ObjectCreationList`, `OnDemandTextureImage`, `AITargetingHeuristic`). The XSD only declares it on `BaseInheritableAsset`, but vanilla and Corona data use it more broadly. Attribute legality is now separate from the CodeLens / Find All References “reference target by design” filter, so the universal attribute does not widen the code-lens type list.
|
||||
- `simpleContent` complex types (`AudioFileRefWithWeight`, `MultisoundSubsoundRef`) keep their XSD attributes (`Weight`, `Volume`, `PitchShiftLow/High`, ...) and their text content (`<Sound>AudioFile</Sound>`, `<Subsound>VoiceEvent</Subsound>`) is now handled as a typed asset reference by completion, hover, navigation, diagnostics, the semantic reference index, and Find All References.
|
||||
- Fragment roots whose name also appears as a nested child type (e.g. `<EvaEvent>`, `<UpgradeTemplate>`) now resolve to the top-level `AssetDeclaration` type instead of the colliding child type.
|
||||
- Element-name completion for simple-content children now re-triggers value suggestions after inserting the `<Name>$1</Name>` snippet.
|
||||
|
||||
### Added
|
||||
|
||||
- 128×128 PNG extension icon (converted from `images/icon.webp`). The `.vsix` no longer bundles the `images` folder, so the large GIF demos stay out of the package.
|
||||
|
||||
## 0.1.22 — 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manifest assets that share the same id under different types are now all kept in the by-id index. Previously the first same-id entry (e.g. `W3DHierarchy:AUMCV_HOVER`) could shadow later definitions (e.g. `W3DContainer:AUMCV_HOVER`), causing `Model@Name` and other `BaseRenderAssetType` references to be reported as unresolved even though the asset existed in `Static.manifest`.
|
||||
- `xi:include` without an `xpointer` now splices the target document's root element itself (XInclude semantics), so fragments like `GenericCelestialBuildingSuicide.xml` keep their module wrapper (`CreateObjectDie`) instead of only inserting its children.
|
||||
- Fragment files (documents whose root is not `AssetDeclaration`) no longer trigger standalone-document diagnostics: top-level `missing-id`, duplicate-id, unresolved-reference and undefined-define checks are skipped, unknown wrapper roots are not reported as unknown elements, and a known fragment root still validates its subtree's elements/attributes.
|
||||
|
||||
### Added
|
||||
|
||||
- Missing `xi:include` targets now surface in the Problems panel as `include-not-found` warnings for the edited document (previously only tracked in indexer diagnostics).
|
||||
|
||||
## 0.1.21 — 2026-08-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -14,6 +14,8 @@ A VS Code extension that brings **IntelliSense, navigation, reference tracking,
|
||||
|
||||
It understands the RA3 Mod SDK's XML schema, asset types, references, includes, and vanilla game data — so editing a large mod feels much more like working with a real programming language.
|
||||
|
||||
Source code: [GitHub mirror](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
@@ -84,6 +86,14 @@ Catch common modding mistakes while you edit.
|
||||
|
||||
The extension can analyze the entire workspace rather than only the file currently open.
|
||||
|
||||
**Find asset** searches the index for an asset by `Type:Id`, by exact id, or by a partial id — typed directly into the picker, with results updating as you type. The result list shows each asset's type, origin (project / SDK / manifest), reference count and defining file; accepting a result jumps to its definition. When the same `type:id` exists in several places (for example a mod override of a vanilla asset), a second picker asks which definition to open.
|
||||
|
||||
Run:
|
||||
|
||||
`RA3 Mod XML: Find asset (id or Type:Id)…`
|
||||
|
||||
You can also select an id in the editor and use the context menu entry to search for it.
|
||||
|
||||
**Find unreferenced assets** lists project assets that are not referenced anywhere in the workspace, helping identify obsolete or accidentally unused definitions.
|
||||
|
||||
Run:
|
||||
@@ -151,8 +161,97 @@ to an empty string opts out of SDK features permanently.
|
||||
* `RA3 Mod XML: Clear caches and rebuild`
|
||||
* `RA3 Mod XML: Configure SDK path…`
|
||||
* `RA3 Mod XML: Show cache report`
|
||||
* `RA3 Mod XML: Find asset (id or Type:Id)…`
|
||||
* `RA3 Mod XML: Find unreferenced assets…`
|
||||
* `RA3 Mod XML: Find unreferenced assets of this type`
|
||||
* `RA3 Mod XML: Enable AI Agent access…`
|
||||
* `RA3 Mod XML: Disable AI Agent access`
|
||||
* `RA3 Mod XML: Install Agent Skill…`
|
||||
* `RA3 Mod XML: Uninstall Agent Skill…`
|
||||
* `RA3 Mod XML: Uninstall AI Agent integration…`
|
||||
* `RA3 Mod XML: Export AI Agent index snapshot`
|
||||
|
||||
## AI Agent Access
|
||||
|
||||
The extension can expose its semantic asset index to AI Agent clients through
|
||||
a local, read-only MCP (Model Context Protocol) server. This is optional and
|
||||
does not modify `PATH` or install global commands.
|
||||
|
||||
Run:
|
||||
|
||||
`RA3 Mod XML: Enable AI Agent access…`
|
||||
|
||||
The command:
|
||||
|
||||
1. Exports a stable index snapshot for the active project.
|
||||
2. Creates a stable launcher under `~/.ra3modxml/`.
|
||||
3. Starts a local read-only query server while VS Code is running.
|
||||
4. Offers to:
|
||||
* install the `ra3-mod-xml` Agent Skill to `~/.agents/skills/` (and
|
||||
optionally to Claude Code or project-local skill directories),
|
||||
* write the MCP client configuration for Claude Desktop or Cursor,
|
||||
* copy a generic MCP configuration block.
|
||||
|
||||
The MCP server prefers the live in-memory index while the extension is
|
||||
running and falls back to the last exported snapshot when VS Code is closed.
|
||||
The exposed tools include asset lookup, incoming semantic references,
|
||||
**outgoing reference edges** (`get_asset_references`: which assets an asset
|
||||
uses, through which element/attribute, and where in the XML), active-file
|
||||
checks, `$DEFINE` lookup, and Include source resolution.
|
||||
|
||||
`get_asset_references` follows `inheritFrom` ancestors and marks the
|
||||
ancestor's entries with `definedIn`, so "this unit has no `WeaponSetUpdate`,
|
||||
but the base unit it inherits from does" is answerable in a single call. It is
|
||||
bounded by `depth` (default 1, max 3), `targetTypes` and `maxEdges`, and
|
||||
reports truncation instead of silently dropping results.
|
||||
|
||||
Discovery is per project (`~/.ra3modxml/endpoints/<project>.json`), so several
|
||||
VS Code windows can enable agent access at the same time without shadowing each
|
||||
other, and a client can never be silently answered from a different project.
|
||||
Each window also registers itself under `~/.ra3modxml/instances/`, and a small
|
||||
merged `~/.ra3modxml/index.json` lists the live instances and their project
|
||||
roots. A window that crashes is cleaned up by whichever instance starts next —
|
||||
no workspace has to be reopened first.
|
||||
|
||||
**No Node installation is required.** The launcher runs the bundled server on
|
||||
VS Code's own Electron binary (`ELECTRON_RUN_AS_NODE=1`) and only falls back to
|
||||
`node` from `PATH` if that binary is missing. The launcher is rewritten on every
|
||||
activation, so updating or moving VS Code does not break an existing MCP config.
|
||||
|
||||
The MCP server is plain JSON-RPC over stdio, so an agent can also query the
|
||||
index without any MCP setup at all by piping a request into the launcher. A
|
||||
matching CLI (`cli.js`, next to `mcpServer.js` in `dist/agent/`) answers from
|
||||
the live index when VS Code is running and from the exported snapshot
|
||||
otherwise; commands that need element context say so explicitly rather than
|
||||
returning an empty result.
|
||||
|
||||
### Installing and removing the integration
|
||||
|
||||
* `RA3 Mod XML: Install Agent Skill…` works with or without an indexed project.
|
||||
The default target is the cross-agent `~/.agents/skills/ra3-mod-xml/`
|
||||
convention; Claude Code (`~/.claude/skills/`), project-local `.agents/skills/`
|
||||
and `.claude/skills/`, and any custom folder can be selected too.
|
||||
* `RA3 Mod XML: Uninstall Agent Skill…` lists the recorded copies and removes
|
||||
only directories that still carry the extension's marker. A folder the user
|
||||
replaced or created by hand is never deleted.
|
||||
* `RA3 Mod XML: Disable AI Agent access` stops the live query server for the
|
||||
current workspace (project discovery files for this window are cleaned up).
|
||||
Installed skills and MCP client configurations are kept.
|
||||
* `RA3 Mod XML: Uninstall AI Agent integration…` is the cleanup wizard. It can
|
||||
stop live access, remove installed Agent Skills, delete the MCP entries this
|
||||
extension wrote (from the recorded files and the conventional Claude
|
||||
Desktop / Cursor paths, leaving other servers untouched), and delete the
|
||||
stable launcher. Nothing is removed before a confirmation prompt.
|
||||
|
||||
After an extension upgrade, recorded Skill copies are rewritten automatically
|
||||
to the new version; directories without the extension's marker are left alone.
|
||||
|
||||
The first time an AI Agent feature becomes available (fresh install, or an
|
||||
upgrade from an older version), the extension offers to enable it after the
|
||||
first index. It never asks twice: choosing "Don't show again" — or simply
|
||||
ignoring the message — is remembered, and later upgrades stay silent.
|
||||
|
||||
See `docs/ai-agent-integration-plan.md` for the full design and progress.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
它理解 RA3 Mod SDK 的 XML schema、资产类型、引用、include 以及原版游戏数据——编辑大型模组的体验会更接近真正的编程语言。
|
||||
|
||||
源码:[GitHub 镜像](https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git) · [Gitea](https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
@@ -84,6 +86,14 @@
|
||||
|
||||
扩展可以分析整个工作区,而不仅仅是当前打开的文件。
|
||||
|
||||
**查找资产** 可以在索引中按 `类型:ID`、精确 ID 或部分 ID 搜索资产——直接在输入框中输入,结果随输入实时更新。结果列表显示资产的类型、来源(项目 / SDK / manifest)、引用次数与所在文件;选中后会跳转到其定义。当同一个 `类型:ID` 存在多处定义时(例如 mod 覆盖了原版资产),会再弹出一次选择框让你选择打开哪一处定义。
|
||||
|
||||
运行:
|
||||
|
||||
`RA3 Mod XML: Find asset (id or Type:Id)…`
|
||||
|
||||
你也可以在编辑器中选中一个 ID,然后用右键菜单直接搜索它。
|
||||
|
||||
**查找未引用的资产** 会列出工作区中任何地方都未被引用的项目资产,帮助识别过时或意外未使用的定义。
|
||||
|
||||
运行:
|
||||
@@ -148,8 +158,56 @@
|
||||
* `RA3 Mod XML: Clear caches and rebuild`(清除缓存并重建)
|
||||
* `RA3 Mod XML: Configure SDK path…`(配置 SDK 路径)
|
||||
* `RA3 Mod XML: Show cache report`(显示缓存报告)
|
||||
* `RA3 Mod XML: Find asset (id or Type:Id)…`(查找资产(ID 或 类型:ID)…)
|
||||
* `RA3 Mod XML: Find unreferenced assets…`(查找未引用的资产…)
|
||||
* `RA3 Mod XML: Find unreferenced assets of this type`(查找此类型的未引用资产)
|
||||
* `RA3 Mod XML: Enable AI Agent access…`(启用 AI Agent 访问…)
|
||||
* `RA3 Mod XML: Disable AI Agent access`(禁用 AI Agent 访问)
|
||||
* `RA3 Mod XML: Install Agent Skill…`(安装 Agent Skill…)
|
||||
* `RA3 Mod XML: Uninstall Agent Skill…`(卸载 Agent Skill…)
|
||||
* `RA3 Mod XML: Uninstall AI Agent integration…`(卸载 AI Agent 集成…)
|
||||
* `RA3 Mod XML: Export AI Agent index snapshot`(导出 AI Agent 索引快照)
|
||||
|
||||
## AI Agent 访问
|
||||
|
||||
扩展可以通过本地只读 MCP(Model Context Protocol)Server,把语义索引提供给 AI Agent 使用。该功能可选,不会修改 `PATH`,也不会注册全局命令。
|
||||
|
||||
运行:
|
||||
|
||||
`RA3 Mod XML: Enable AI Agent access…`
|
||||
|
||||
该命令会:
|
||||
|
||||
1. 为当前项目导出稳定的索引快照;
|
||||
2. 在 `~/.ra3modxml/` 下创建稳定 launcher;
|
||||
3. 在 VS Code 运行期间启动本地只读查询服务;
|
||||
4. 让用户选择:
|
||||
* 安装 `ra3-mod-xml` Agent Skill 到 `~/.agents/skills/`(也可选择 Claude Code 或项目级目录);
|
||||
* 写入 Claude Desktop / Cursor 的 MCP 配置;
|
||||
* 复制通用 MCP 配置。
|
||||
|
||||
MCP Server 在扩展运行时优先查询内存中的实时索引;扩展关闭后回退到最近一次导出的快照。暴露的工具包括资产定义查询、语义引用查询、**正向引用边**(`get_asset_references`:该资产用了哪些资产、通过哪个元素/属性、写在 XML 的哪一行)、文件是否有效 include、`$DEFINE` 查询以及 Include source 解析。
|
||||
|
||||
`get_asset_references` 会沿 `inheritFrom` 祖先链遍历,并用 `definedIn` 标出条目实际写在哪个祖先的文件里,因此"这个单位自己没有 `WeaponSetUpdate`,但它继承的基础单位有"可以在一次调用里回答。它受 `depth`(默认 1,上限 3)、`targetTypes` 与 `maxEdges` 三重限制,并在截断时显式报告,而不是静默丢弃结果。
|
||||
|
||||
Endpoint 按项目存放(`~/.ra3modxml/endpoints/<project>.json`),因此多个 VS Code 窗口可以同时启用 AI Agent 访问而不互相覆盖,客户端也不会被静默地用另一个项目的数据回答。每个窗口还会在 `~/.ra3modxml/instances/` 下登记自己,并由一份合并的只读 `~/.ra3modxml/index.json` 列出当前实例与项目根。崩溃残留的实例会被下一个启动的实例顺手清理,不需要先重新打开同一个工作区。
|
||||
|
||||
**不需要安装 Node。** launcher 使用 VS Code 自带的 Electron 二进制(`ELECTRON_RUN_AS_NODE=1`)运行内置的 MCP Server,只有在该二进制缺失时才回退到 `PATH` 上的 `node`。launcher 在每次激活时重写,所以升级或移动 VS Code 都不会让已有的 MCP 配置失效。
|
||||
|
||||
MCP Server 本质上就是 stdio 上的 JSON-RPC,因此 agent 也可以完全不做 MCP 配置,直接把请求管道给 launcher。另有一个配套 CLI(`cli.js`,与 `mcpServer.js` 同在 `dist/agent/`):VS Code 运行时走实时索引,否则读导出的快照;需要元素上下文的命令会明确说明,而不是返回空结果。
|
||||
|
||||
### 安装与移除
|
||||
|
||||
* `RA3 Mod XML: Install Agent Skill…` 不要求项目已建立索引。默认安装到跨 agent 的 `~/.agents/skills/ra3-mod-xml/`,也可选择 Claude Code(`~/.claude/skills/`)、项目级 `.agents/skills/` 与 `.claude/skills/`,或任意自定义文件夹。
|
||||
* `RA3 Mod XML: Uninstall Agent Skill…` 列出已记录的安装位置,只删除仍带有本扩展标记的目录;被用户替换或手工创建的目录不会被删除。
|
||||
* `RA3 Mod XML: Disable AI Agent access` 停止当前工作区的实时查询服务并清理本窗口的发现文件;已安装的 Skill 与 MCP 客户端配置会保留。
|
||||
* `RA3 Mod XML: Uninstall AI Agent integration…` 是清理向导:可停止实时访问、移除已安装的 Agent Skill、删除本扩展写入的 MCP 配置项(来自记录文件与常规的 Claude Desktop / Cursor 路径,不会动其他 server),以及删除稳定 launcher。确认之前不会删除任何内容。
|
||||
|
||||
扩展升级后,记录中的 Skill 副本会自动重写到新版本;不带本扩展标记的目录不会被改写。
|
||||
|
||||
当 AI Agent 功能首次可用时(全新安装,或从旧版本升级),扩展会在第一次索引完成后询问是否启用。它不会反复打扰:选择"不再提示"——或者干脆忽略——都会被记住,之后的升级保持静默。
|
||||
|
||||
完整设计与进度见 `docs/ai-agent-integration-plan.md`。
|
||||
|
||||
## 环境要求
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1813,3 +1813,405 @@ manifest 候选就会被劫持到 mod 文件。
|
||||
|
||||
> 备注:ART/AUDIO 源映射按用户意见不作为本轮目标;`buildVanillaSearchPaths`
|
||||
> 已包含对应 SDK 目录,将来若有源码可直接复用。
|
||||
|
||||
---
|
||||
|
||||
## 二十九、问题分析(2026-08-11):manifest 同名不同类型资产被 `assetsById` 去重丢弃
|
||||
|
||||
### 现象
|
||||
|
||||
Corona `Data\Allied\Units\AlliedMCV.xml` 的
|
||||
`ScriptedModelDraw → ModelConditionState → Model Name="AUMCV_Hover"`
|
||||
报 unresolved-reference:
|
||||
|
||||
```xml
|
||||
<ScriptedModelDraw id="ModuleTag_Draw_Hover" OkToChangeModelColor="true">
|
||||
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
|
||||
<Model Name="AUMCV_Hover" />
|
||||
</ModelConditionState>
|
||||
</ScriptedModelDraw>
|
||||
```
|
||||
|
||||
提示为“没有类型为 `BaseRenderAssetType` 的定义(其他类型存在同名 id)”,
|
||||
但 `Static.manifest` 中确实存在 `W3DContainer:AUMCV_HOVER`。
|
||||
|
||||
### 根因
|
||||
|
||||
`src/indexer/indexer.ts` 的 `addAsset()` 在维护两个索引时用了同一套去重:
|
||||
|
||||
- `assets`:`类型 -> id -> 定义`,按 `(file, line)` 去重;
|
||||
- `assetsById`:`id -> 所有类型定义`,也按 `(file, line)` 去重。
|
||||
|
||||
XML 定义的行号各不相同,所以 `(file, line)` 足够;但 manifest 资产入库时
|
||||
`line` 固定为 0,于是同一个 manifest 里 id 相同、类型不同的多个资产会被
|
||||
当成同一条定义,只保留最先出现的类型。
|
||||
|
||||
`Static.manifest` 中 `AUMCV_HOVER` 的实际顺序是:
|
||||
|
||||
```text
|
||||
W3DHierarchy:AUMCV_HOVER
|
||||
W3DAnimation:AUMCV_HOVER
|
||||
W3DContainer:AUMCV_HOVER
|
||||
```
|
||||
|
||||
`W3DContainer` 因此被 `W3DHierarchy` 挤掉。`Model@Name` 的 `refType` 是
|
||||
`BaseRenderAssetType`,`W3DHierarchy` 按 XSD 继承链不是渲染资产,所以
|
||||
`assetsById` 里“有同名 id”但“没有匹配类型”,正好产生上述提示。
|
||||
|
||||
### 为什么以前没暴露 / 不是回归
|
||||
|
||||
第三轮修复的 `AUAntiVehicleVehicleTech1_SKN` 在 static.manifest 里只有一个
|
||||
同名定义(`W3DContainer`),没有类型竞争,因此当时测不到该分支。git blame
|
||||
显示 `addAsset` 的 `(file, line)` 去重从首个提交就存在,所以这是潜在缺陷被
|
||||
新数据形态首次触发,不是近期改动造成的回归。
|
||||
|
||||
### 影响面(真实 manifest 扫描)
|
||||
|
||||
对 `Static / Global / Audio` 三个 manifest 模拟当前入库逻辑:
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 同名 id 跨类型的 ID | 1318 |
|
||||
| 被丢弃的类型定义 | 1470 |
|
||||
| `W3DContainer` / `W3DMesh` 被丢弃的 id | 412 |
|
||||
|
||||
`Audio.manifest` 无此类碰撞。受影响的不止诊断和 hover:
|
||||
`resolveReferenceTargetsForType`、语义 FAR / CodeLens 引用计数、未类型化补全
|
||||
都经 `assetsById` 查找,因此 manifest 中的模型引用普遍可能误报或漏计。
|
||||
|
||||
### 修复
|
||||
|
||||
`assetsById` 是“按 id 汇总所有类型定义”的索引,去重身份必须包含类型:
|
||||
|
||||
1. `src/indexer/indexer.ts` 的 `addAsset()`:`assets` 与 `assetsById` 的去重
|
||||
都改为 `(type, file, line)`;
|
||||
2. `src/indexer/localScope.ts` 的 `addAsset()`:同样的去重修正,避免局部
|
||||
overlay 未来遇到同构数据时重复踩坑。
|
||||
|
||||
`mergeLocalAndGlobalDefs`、`assetDefKey` 本来已按 `(type, id, file, line)`
|
||||
区分定义,修复后三处语义一致。
|
||||
|
||||
### 测试(新增 1 个集成测试,全量 198 个通过)
|
||||
|
||||
`test/indexer.test.mjs` 新增自包含用例:
|
||||
|
||||
- 用最小 version-5 manifest 构造 `W3DHierarchy / W3DAnimation / W3DContainer`
|
||||
三个同 id 资产,顺序刻意让渲染类型排在最后;
|
||||
- 再构造 `Texture:ABAirfield` 在前、`W3DContainer:ABAIRFIELD` 在后的常见形态;
|
||||
- 断言 `assetsById` 保留全部类型;
|
||||
- 断言 `Model@Name` 经 `resolveReferenceTargetsForType` 命中 `W3DContainer`;
|
||||
- 断言反向引用索引把该引用记到 `W3DContainer` 名下。
|
||||
|
||||
### 文档同步
|
||||
|
||||
`docs/plan.md` 的 manifest 建模小节补充:`assetsById` 必须保留同 id 的不同
|
||||
类型定义,去重身份为 `(type, file, line)`。
|
||||
|
||||
---
|
||||
|
||||
## 二十八、问题分析(2026-08-11):xi:include 无 xpointer 语义与片段文件诊断(P0)
|
||||
|
||||
### 现象
|
||||
|
||||
`Data/Includes/GenericCelestialBuildingSuicide.xml` 这类被 `xi:include` 引用的
|
||||
片段文件在独立打开时被报一串错误:`DieMuxData` 报 `missing-id`(“顶层资产需要
|
||||
id”),wrapper 根不在 XSD 里的文件报 `unknown-element`,引用在完整索引下能解析
|
||||
前还会报未解析引用。
|
||||
|
||||
### 根因
|
||||
|
||||
1. **无 `xpointer` 的展开语义错误**:`expandDocument` 把目标 `root.children`
|
||||
拼进父节点。按 XInclude 语义(也是 Corona 的实际用法),没有 `xpointer` 时应
|
||||
整体包含目标文档的根元素。`GenericCelestialBuildingSuicide.xml` 的根
|
||||
`CreateObjectDie` 本身就是要放进 GameObject 的模块;旧实现会丢掉它,只把
|
||||
`DieMuxData` 拼进去。
|
||||
2. **诊断层把片段当完整文档**:`isTopLevel` 假定根一定是 `AssetDeclaration`,
|
||||
于是片段根的子元素被当成顶层资产要求 id;未知 wrapper 根也被当成未知元素。
|
||||
3. **引用/define 与上下文耦合**:片段里的引用可能由 include 者(或 include 者
|
||||
的 include 链)提供,独立打开片段时无法可靠判定。
|
||||
|
||||
### 修复(P0,不猜测外部上下文)
|
||||
|
||||
1. `logicalTree.expandDocument`:无 `xpointer` 时 `handleChild(parse.root)`,
|
||||
整体包含目标根元素;有 `xpointer` 时保持 `/n:Name/child::*` 语义。
|
||||
2. `diagnostics` 片段模式:根 localName 不是 `AssetDeclaration` 即为片段。
|
||||
- 一律跳过顶层 `missing-id` / 跨文件重复 id、未解析引用、未定义 `$DEFINE`;
|
||||
- 根是已知 XSD 元素时(如 `CreateObjectDie`),根自身提供类型上下文,整棵子树
|
||||
的未知元素 / 未知属性仍正常校验;
|
||||
- 根不在 XSD 中(wrapper/container,如 `CommonArmorDraws`)时,只报 XML 语法
|
||||
与片段内部 `xi:include` / `<Include>` 目标缺失,其余检查延后到上下文诊断。
|
||||
3. 新增 `checkXiInclude`:`xi:include` 目标缺失在 Problems 中上报
|
||||
`include-not-found`(此前只在 indexer 内部诊断)。
|
||||
|
||||
### 测试(202 → 202 全绿)
|
||||
|
||||
- `localScope.test.mjs`:无 `xpointer` 的 `xi:include` 把目标根元素
|
||||
`CreateObjectDie` 整体拼入 `Behaviors`,`DieMuxData` 仍挂在它下面;
|
||||
- `contentFeatures.test.mjs`:片段已知根不再报 `missing-id` / 未解析引用,但子树
|
||||
未知属性仍报;未知 wrapper 根不报元素/属性,片段内部缺失 `xi:include` 仍报;
|
||||
完整 `AssetDeclaration` 文档的顶层 id 检查不受影响。
|
||||
|
||||
### 边界与后续
|
||||
|
||||
- **P1 上下文诊断**:indexer 增加“反向 include 表”(`xi:include` 目标 →
|
||||
include 者列表),打开片段时用 include 者的逻辑树做真实上下文校验,再恢复引用 /
|
||||
define / 子元素结构检查。多上下文取并集去重。
|
||||
- `<Include type="all|instance|reference">` 与 `xi:include` 语义不同:前者的目标
|
||||
是完整 `AssetDeclaration`,不进入片段模式;后者才允许片段文件。
|
||||
|
||||
---
|
||||
|
||||
## 三十、问题分析(2026-08-11):`FXList inheritFrom` 误报未知属性与模型修正
|
||||
|
||||
### 现象
|
||||
|
||||
`GlobalData/FX_List.xml` 中:
|
||||
|
||||
```xml
|
||||
<FXList id="FX_LargeEMCannonHitCrit" inheritFrom="FX_LargeEMCannonHit">
|
||||
<NuggetList>
|
||||
<ParticleSystem Particle="CritHit" OrientToObject="true" Ricochet="true"/>
|
||||
</NuggetList>
|
||||
</FXList>
|
||||
```
|
||||
|
||||
报 `Unknown attribute "inheritFrom" for <FXList>`。
|
||||
|
||||
### 根因(三个独立问题)
|
||||
|
||||
**A. `inheritFrom` 的 XSD 白名单落后于 BAB 实际语义**
|
||||
|
||||
- SDK 与 Corona 的 `AssetTypeFXList.xsd` 都写 `FXList extends BaseAssetType`;
|
||||
- `BaseAssetType` 只有 `id` / `typeHashCode` / `buildRule`,`inheritFrom` 只挂在
|
||||
`BaseInheritableAsset` 上;
|
||||
- 内置模型因此认为 `FXList` 没有 `inheritFrom`,diagnostics 的 unknown-attribute
|
||||
直接按模型属性表判断;
|
||||
- 但引用 / hover / 跳转层早就把 `inheritFrom` 当作通用引用属性处理,只有“属性名
|
||||
合法性”这一层还在用 XSD 白名单,所以表现为局部不一致。
|
||||
|
||||
证据(用插件同一套解析器 + 模型扫描):
|
||||
|
||||
| 类型 | 原版 SageXml 顶层使用 `inheritFrom` | Corona 顶层使用 |
|
||||
|---|---|---|
|
||||
| `AIMicroManagerData` | 233 | 128 |
|
||||
| `FXList` | 142 | 10 |
|
||||
| `AITargetingHeuristic` | 10 | 5 |
|
||||
| `ObjectCreationList` | 2 | 0 |
|
||||
| `OnDemandTextureImage` | 0 | 9 |
|
||||
|
||||
原版 `FXListSoviet.xml` / `FXListJapan.xml` 大量使用该写法,说明这是 BAB 接受的
|
||||
真实语义,不是用户笔误。
|
||||
|
||||
**B. `simpleContent` 复杂类型的属性被生成器丢弃**
|
||||
|
||||
`xsd-to-model.mjs` 的 `expandComplexType` 只读 `complexContent/extension`,没有读
|
||||
`simpleContent/extension`。因此:
|
||||
|
||||
- `AudioFileRefWithWeight`(XSD 有 `Weight` / `Volume`)在模型里属性为空;
|
||||
- `MultisoundSubsoundRef`(XSD 有 `Weight` / `PitchShiftLow/High` / `Volume` /
|
||||
`PlayPercent` / `VolumeShift`)同样为空。
|
||||
|
||||
Corona 实测 `<Sound Weight="...">` 565 处、`<Subsound Weight="...">` 33 处会被误报。
|
||||
simpleContent 复杂类型的**文本内容**引用语义(如 `<Sound>AudioFile</Sound>` 的补全 /
|
||||
hover / 跳转 / 诊断 / FAR)在第三十一轮补齐,见下。
|
||||
|
||||
**C. 片段根元素仍受“元素名→类型”全局单映射影响**
|
||||
|
||||
`EvaEvent` 既是顶层资产,也是 `FXNuggetTypes` 的子元素
|
||||
(`EvaEventFXNugget`)。全局 `elementTypeName("EvaEvent")` 取到的是先注册的
|
||||
`EvaEventFXNugget`。完整 `AssetDeclaration` 文档有父上下文可以纠正;但
|
||||
`additionalmaps/ALLC.xml` 这类根元素就是 `<EvaEvent>` 的片段没有父上下文,于是
|
||||
`Priority`、`TimeBetweenEvents`、`ExpirationTime` 等合法属性被当成未知。
|
||||
|
||||
### 修复
|
||||
|
||||
1. **通用属性合法性集中到模型层**:`schemaModel.ts` 新增
|
||||
`isAssetType()`(`BaseAssetType` 及其后代)与通用 `inheritFrom` 属性;
|
||||
`attributesOfType()` 对资产类型统一返回它。诊断、属性补全、hover 自动一致。
|
||||
2. **CodeLens / FAR 的“设计目标”判定保持窄口径**:`refs.ts` 的
|
||||
`referenceTargetTypes()` 仍只看 XSD 显式声明的 `inheritFrom` 与类型化引用,
|
||||
不会因为通用属性把全部 317 个资产类型变成计数目标。`isReferenceAttributeOfType`
|
||||
与 `resolveReferenceTargetsForType` 同步改为只对资产类型接受 `inheritFrom`。
|
||||
3. **生成器支持 `simpleContent/extension`**:`expandComplexType` 现在同时读
|
||||
`complexContent` 与 `simpleContent` 的 extension,重新生成模型后
|
||||
`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全。
|
||||
4. **片段根优先取顶层类型**:`schemaModel.topLevelElementType()` 从
|
||||
`AssetDeclaration` 的子元素声明解析类型;`resolveElementType()` 对文档根先用它,
|
||||
再回退全局映射;hover 的元素名展示也使用已解析类型。
|
||||
|
||||
### 测试(举一反三,全量 210 通过)
|
||||
|
||||
- `schemaModel.test.mjs`:`FXList` / `AIMicroManagerData` / `ObjectCreationList` /
|
||||
`OnDemandTextureImage` / `AITargetingHeuristic` 均接受 `inheritFrom`,
|
||||
`Include` 不接受;`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 属性齐全;
|
||||
- `refs.test.mjs`:`FXList inheritFrom` 是引用,`Include inheritFrom` 不是;
|
||||
`Credits` 接受通用 `inheritFrom` 但仍是 `isReferenceTargetType() === false`,
|
||||
证明两个判定已分离;
|
||||
- `typeContext.test.mjs`:片段根 `<EvaEvent>` / `<UpgradeTemplate>` 解析为顶层
|
||||
类型,`<Weapon>` 仍回退到 `WeaponRef`;
|
||||
- `completion.test.mjs`:`FXList` 属性补全出现 `inheritFrom`;
|
||||
- `contentFeatures.test.mjs`:`FXList inheritFrom`、`<Sound Weight>`、片段根
|
||||
`<EvaEvent>` 不再报 unknown-attribute,真实拼写错误仍报。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- `docs/requirements.md`:继承机制补充“对资产类型通用”;
|
||||
- `docs/plan.md`:XSD 结构说明补充实测差异与两个判定的分离;
|
||||
- `docs/features-reference-counts.md`:说明通用 `inheritFrom` 不扩大 CodeLens
|
||||
目标集合。
|
||||
|
||||
---
|
||||
|
||||
## 三十一、问题分析(2026-08-11):补齐所有“内容即引用”的语义(含 simpleContent 复杂类型)
|
||||
|
||||
### 目标
|
||||
|
||||
上一轮只恢复了 `AudioFileRefWithWeight` / `MultisoundSubsoundRef` 的属性,但它们
|
||||
的文本内容(`<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)仍然
|
||||
没有按引用处理。本轮把 simple-content 的内容语义统一到一条管线:**凡是元素文本
|
||||
内容带 `xas:refType` 的,无论底层是 simple type 还是 simpleContent complexType,
|
||||
都参与补全 / hover / 跳转 / 诊断 / 引用索引 / FAR**。
|
||||
|
||||
### 真实项目验证
|
||||
|
||||
用插件同一套解析器 + 模型扫描 Corona `Data`(7540 个 XML,跳过 w3x):
|
||||
|
||||
| 类别 | 唯一元素/类型组合 | 出现次数 | 典型元素 |
|
||||
|---|---|---|---|
|
||||
| 带 `refType` 的内容引用 | 62 | 20,813 | `Sound`→AudioFile、`Subsound`→BaseAudioEventInfo、`CreateObject`→GameObject、`TriggeredBy`→UpgradeTemplate |
|
||||
| 无 `refType` 的 `isRef` 内容 | 5 | 1,150 | `Value`/`AddEmotion`/`Compare`/`Campaign`/`Mission`→AssetReference |
|
||||
| 普通标量/枚举内容 | 6 | 9,012 | `IncludeThing`/`ExcludeThing`→WeakReference、`Script`、`SpecificBarrelOverride` |
|
||||
|
||||
结论:
|
||||
|
||||
- `Sound` / `Attack` / `Decay`(`AudioFileRefWithWeight`)内容确实是 `AudioFile`
|
||||
引用;`Subsound`(`MultisoundSubsoundRef`)内容确实是 `BaseAudioEventInfo`
|
||||
引用,必须纳入全局引用语义。
|
||||
- `AssetReference` 系的无类型内容(`Value` 等)是着色器常量、脚本参数等,
|
||||
**不应**按全局资产 ID 解析;保持上一轮“只处理带 refType 的内容”的边界。
|
||||
- `IncludeThing` / `ExcludeThing` 等 `WeakReference` 内容是对象过滤/局部语义,
|
||||
也没有 refType,不参与全局引用。
|
||||
- 内联 simpleContent(如 w3x 的 `Frame`)是 `xs:float` 标量,`contentInfoOfType`
|
||||
能识别但 `refType === null`,不会误报。
|
||||
|
||||
### 实现
|
||||
|
||||
1. **模型层**:`schemaModel.ts` 新增 `SimpleContentInfo` / `ContentTypeInfo` 和
|
||||
`contentInfoOfType()`;`ComplexTypeInfo` 增加可选 `content` 字段。
|
||||
`xsd-to-model.mjs` 对 `simpleContent/extension` 记录 base 类型的
|
||||
`refType` / `isRef` / 枚举 / list / `$DEFINE` 能力。
|
||||
2. **引用判定**:`refs.ts` 的 `isReferenceContentType()` 与
|
||||
`resolveContentReferenceTargets()` 改用统一内容描述;simpleContent 复杂类型
|
||||
的 `refType` 也进入 `referenceTargetTypes()`,保证 CodeLens 类型过滤正确。
|
||||
3. **索引**:`records.ts` 内容记录的 `refType` 从统一内容描述提取,FAR 与
|
||||
引用计数不再丢 `Sound` / `Subsound`。
|
||||
4. **补全**:`completion.ts` 的元素片段、内容值补全、子元素补全触发 suggest 均
|
||||
统一走 `contentInfoOfType()`,`<Sound>` 会生成 `<Sound>$1</Sound>` 并弹
|
||||
AudioFile 候选。
|
||||
5. **hover / 导航 / 诊断**:全部改为从 `contentInfoOfType()` 取 `refType`。
|
||||
|
||||
### 测试(全量 219 通过)
|
||||
|
||||
- `schemaModel.test.mjs`:`contentInfoOfType` 对 simple 与 simpleContent 统一;
|
||||
- `refs.test.mjs`:`AudioFileRefWithWeight` / `MultisoundSubsoundRef` 是内容引用,
|
||||
`@inline:Frame` 不是;同名 AudioFile / AudioEvent 严格按 refType 过滤;
|
||||
- `records.test.mjs`:`Sound` / `Subsound` 文本进入引用索引;
|
||||
- `completion.test.mjs`:`<Sound>` 补全成值对并触发 suggest,内容值只补
|
||||
AudioFile;
|
||||
- `contentFeatures.test.mjs`:`<Sound>` hover / Ctrl+点击 / `<Subsound>` 未解析
|
||||
诊断;
|
||||
- `referenceProvider.test.mjs`:FAR 返回 `Sound` 文本引用。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- `docs/requirements.md`:simple-content 元素补充 simpleContent 复杂类型示例;
|
||||
- `docs/plan.md`:simple-content 文本引用说明补充第三十一轮扩展;
|
||||
- `docs/features-reference-counts.md`:引用语义说明补充“含 simpleContent 复杂
|
||||
类型”。
|
||||
|
||||
---
|
||||
|
||||
## 三十二、问题分析(2026-08-11):限定引用值 `类型:ID` 未被归一化导致误报未解析
|
||||
|
||||
### 现象
|
||||
|
||||
Corona `Data\Allied\Units\AlliedFutureTankX-1\AudioEvent.xml`:
|
||||
|
||||
```xml
|
||||
<Includes>
|
||||
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
|
||||
</Includes>
|
||||
|
||||
<AudioEvent
|
||||
id="ALL_FutureTank_ArmPrimaryWeapon"
|
||||
inheritFrom="AudioEvent:BaseSoundEffect"
|
||||
... />
|
||||
```
|
||||
|
||||
报 `Unresolved reference "AudioEvent:BaseSoundEffect"`,提示当前索引中未找到;
|
||||
但 `SageXml\Sounds\BaseSoundEffect.xml` 里确实存在 `<AudioEvent id="BaseSoundEffect" />`,
|
||||
且 `instance` include 会被索引器与文档局部 overlay 正常 walk。
|
||||
|
||||
### 根因
|
||||
|
||||
插件只在 **manifest 一侧**做了“资产名 `类型:ID` → 裸 ID(取最后冒号段)”
|
||||
的归一化(`manifestParser.deriveAssetId`);**XML 引用值一侧**直接用原始值查
|
||||
`assetsById`。于是 `AudioEvent:BaseSoundEffect` 被当成完整 ID 精确匹配,
|
||||
索引里只有 `BaseSoundEffect`,必然查不到。
|
||||
|
||||
实测最小复现:索引中包含 `AudioEvent@BaseSoundEffect`(origin=sdk),
|
||||
`assetsById.get("audioevent:basesoundeffect")` 返回 NOT FOUND,
|
||||
`resolveReferenceTargetsForType` 返回 0 目标。
|
||||
|
||||
### 影响面(真实数据统计)
|
||||
|
||||
这是原版数据的**普遍写法**,不是用户笔误:
|
||||
|
||||
| 属性 | SageXml | Corona Data | 典型值 |
|
||||
|---|---|---:|---|
|
||||
| `inheritFrom` | 5,483 | 3,219 | `AudioEvent:BaseSoundEffect` |
|
||||
| `Sound`(AudioEntry) | 39 | 98 | `AudioEvent:JAP_Refinery_Select` |
|
||||
| `Side` | 67 | 195 | `PlayerTemplate:Allies` |
|
||||
| `ParticleTexture` | 2 | 2 | `Texture:FXLenzFlare01` |
|
||||
|
||||
前缀全部是**定义资产的具体类型**(manifest 全名格式),而 XSD refType 可能是
|
||||
基类(如 `Sound` 的 refType 是 `BaseAudioEventInfo`,前缀是 `AudioEvent`)。
|
||||
两侧数据的 `id="类型:ID"` 出现次数均为 0,说明定义侧永远是裸 ID,取最后冒号段
|
||||
没有歧义。少数 `Sound="AudioEvent:MammothTankTurretMoveLoop"` 等引用在 SDK
|
||||
源码与三个 manifest 中都找不到定义,是原版数据自身的死引用,归一化后仍会
|
||||
(且应该)继续报未解析。
|
||||
|
||||
### 修复
|
||||
|
||||
1. `refs.ts` 新增 `normalizeReferenceId(value)`:取最后冒号段(与
|
||||
`deriveAssetId` 同一规则;冒号后为空时保留原值,避免半输入误匹配),
|
||||
应用到 `resolveReferenceTargetsForType` 与 `resolveContentReferenceTargets`。
|
||||
2. `referenceIndex.ts` 的 `buildReferenceIndex` 与 `features/references.ts`
|
||||
的 `definitionsForReference` 同样归一化,FAR / CodeLens / 引用 peek 与
|
||||
诊断、hover、跳转保持一致。
|
||||
3. `records.ts` 不修改:记录仍保存原始值与原始偏移,导航/悬停范围不受影响,
|
||||
缓存格式与版本不变。
|
||||
4. `completion.ts` 的 `assetIdItems`:当前输入段含 `:` 时按冒号后片段过滤,
|
||||
补全项 label/insertText 为“已输入前缀 + 裸 ID”(如 `AudioEvent:Base…`
|
||||
→ `AudioEvent:BaseSoundEffect`);未输入前缀时保持裸 ID 补全,不特判任何
|
||||
类型、也不改变默认补全形态。
|
||||
|
||||
### 测试(219 → 226 全绿)
|
||||
|
||||
- `refs.test.mjs`:`normalizeReferenceId` 边界;qualified `inheritFrom`
|
||||
解析、裸 ID 不变、错误类型前缀仍被 selfType 过滤;qualified 属性
|
||||
(`Sound` / `Side`)与 simple-content(`AudioFile:...`)引用解析;
|
||||
- `referenceIndex.test.mjs`:qualified 记录计入反向索引(FAR / CodeLens 桶);
|
||||
- `indexer.test.mjs`:临时项目集成——`instance` include 进 SageXml +
|
||||
`inheritFrom="AudioEvent:BaseSoundEffect"`,断言定义入库、解析命中、
|
||||
反向索引落点(即用户报告的完整场景);
|
||||
- `completion.test.mjs`:`AudioEvent:Base…` 补全为
|
||||
`AudioEvent:BaseSoundEffect` 且替换范围只覆盖当前段;无前缀仍补裸 ID;
|
||||
- `contentFeatures.test.mjs`:qualified `inheritFrom` 不产生未解析诊断,
|
||||
Ctrl+点击精确定位到裸 ID 定义。
|
||||
|
||||
### 文档同步
|
||||
|
||||
- `docs/requirements.md`:情况描述补充 `类型:ID` 引用写法与归一化规则;
|
||||
- `docs/plan.md`:设计决策 5 补充限定引用值归一化,实施记录追加第 29 轮;
|
||||
- `CHANGELOG.md`:0.1.24。
|
||||
|
||||
@@ -31,10 +31,21 @@
|
||||
与补全 / hover / 跳转 / 诊断完全一致(`refs.ts` 是唯一判定来源):
|
||||
|
||||
- 带 `xas:refType` 的属性值(如 `CommandSet` → `LogicCommandSet`);
|
||||
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`);
|
||||
- 带 `xas:refType` 的 simple-content 文本(如 `<CreateObject>ID</CreateObject>`,
|
||||
含 simpleContent 复杂类型 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`);
|
||||
- `inheritFrom`(按元素自身类型过滤);
|
||||
- 无 `refType` 的 `isRef` 属性(按同名 ID 匹配任意声明类型)。
|
||||
|
||||
引用值本身支持原版/Mod 常用的 manifest 风格全名 `类型:ID`
|
||||
(`inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:..."`、
|
||||
`Side="PlayerTemplate:Allies"`):解析与反向索引先按 `normalizeReferenceId`
|
||||
取最后冒号段,再执行上述类型过滤;记录里的原始值与偏移保持不变。
|
||||
|
||||
`inheritFrom` 对 `BaseAssetType` 系资产是通用合法属性(XSD 只在
|
||||
`BaseInheritableAsset` 声明,但原版数据在 `FXList` 等类型上也使用)。这里的
|
||||
“合法属性”判定与“设计上应显示引用计数”的 `referenceTargetTypes()` 是分开的:
|
||||
通用 `inheritFrom` 不会把每个资产类型都变成 CodeLens / 未引用报告的目标。
|
||||
|
||||
不算引用:
|
||||
|
||||
- 元素自己的 `id` 定义点(除非是 `RoadObject@id→Road` 这类跨类型 id 引用);
|
||||
|
||||
+34
-20
@@ -1,6 +1,6 @@
|
||||
# 调研结论与实施计划(已按最新代码同步更新)
|
||||
|
||||
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-10)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)等。
|
||||
> 说明:本文档随实现演进持续同步。最近一次同步(2026-08-11)对齐了实现过程中新增的模块与设计变更:BAB 精确搜索路径、manifest 类型/ID 推导、上下文感知元素类型、属性级 refType / Poid 局部引用(`id` 定义点)、精确跳转范围、嵌套 `xi:include`、注入式语法高亮、bit-flag 列表补全(空格触发 / 排除已用 / 追加模式)、simple-content 元素文本引用(补全 / hover / 跳转 / 诊断 / Find All References)、语义引用索引 / CodeLens 引用计数 / 未引用资产命令、属性补全换行判定与按 id 去重、manifest 源地址按 vanilla-only 解析(避免 mod 同名 DATA 路径遮蔽)、`assetsById` 保留同 id 的不同类型 manifest 定义等。
|
||||
|
||||
## 一、调研结论(带证据)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
- 共 **821 个 XSD / 1.5 MB**,入口 `CnC3Types.xsd`。
|
||||
- 根元素 `AssetDeclaration`:`Tags` / `Includes` / `Defines` + **295 个顶层资产元素**(含内联声明)。
|
||||
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(如 `BaseInheritableAsset` 提供 `inheritFrom`)。
|
||||
- 每个元素名对应一个 `complexType`,子元素用 `xs:sequence` / `xs:choice` 定义,属性用 `xs:attribute` 定义;复杂类型通过 `xs:extension` 继承(XSD 中 `BaseInheritableAsset` 提供 `inheritFrom`)。实测 BAB / 原版数据也接受 `FXList`、`AIMicroManagerData`、`AITargetingHeuristic`、`ObjectCreationList` 等 `BaseAssetType` 系资产使用 `inheritFrom`,因此插件把 `inheritFrom` 作为所有资产类型的通用属性处理;“设计上应显示引用计数”的判定仍按 XSD 显式声明的可继承类型,两者分开。
|
||||
- `Includes/Ref.xsd` 定义了大量带 `xas:refType="<资产类型>"` 的引用类型(如 `CommandSet` 引用 `LogicCommandSet`)→ 补全/导航按引用类型过滤的依据。
|
||||
- `XmlEdit:Default` 提供默认值;`xs:enumeration` 提供枚举值。`xas:refType` 可声明在 simple type 上,也可声明在 `<xs:attribute>` 节点上(模型生成器两者都读、属性级优先)。
|
||||
- `Poid`("Pipeline Object Id",`xas:isWeakRef="true"`)表示**管线局部标识**:`id` 属性定义元素自身(如 `ModuleData@id` → refType `ModuleData`);`ModuleId`、`AutoResolveBody`、`SoundRef` 等 Poid 属性引用同一资产/子树内的模块、子对象、材质——它们都不对全局资产索引做 resolved 判定。
|
||||
@@ -140,9 +140,9 @@ test/
|
||||
`W3DHierarchy` / `W3DCollisionBox` 等),使 `Model@Name`、`Hierarchy`、`Mesh`
|
||||
等引用可解析;大模型文件**浅扫描**(不建 DOM),结果缓存在 workspace 级、
|
||||
跨重建复用(详见设计决策 14)。
|
||||
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。
|
||||
3. **manifest 资产建模**:类型优先用哈希表,未知时从名称前缀推导;可引用 ID 取最后冒号段;类型名统一走大小写规范化(`W3dContainer` ↔ `W3DContainer`),类型匹配严格遵循 XSD 继承链。`assetsById` 按 id 汇总**全部类型**的定义,去重身份为 `(type, file, line)`,同一 manifest 中同名但不同类型的美术资产(如 `W3DHierarchy:AUMCV_HOVER` 与 `W3DContainer:AUMCV_HOVER`)必须全部保留,避免 `Model@Name` 这类 `BaseRenderAssetType` 引用因先到的非渲染类型而被误判为未解析。
|
||||
4. **上下文感知元素类型**:同名元素按父元素类型解析(`resolveElementType` 沿解析树逐层 `childTypeOf`,失败回退全局映射),保证 `<Weapon>` 等元素的属性/引用判定正确。
|
||||
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。
|
||||
5. **引用判定与解析**:`refType` 或 `isRef` 均视为引用;带 `refType` 时严格按类型过滤(同名 ID 不串类型);`inheritFrom` 按可继承类型过滤。**局部作用域例外**(`isLocalReferenceAttribute`):`id` 是元素自身的定义点——无 refType 或 refType 与自身类型兼容时不检查、不解析(`RoadObject@id→Road` 这类跨类型 id 引用保留检查);Poid 类型属性是管线局部引用,全局索引无法判定,不检查、不解析。**限定引用值**:原版/Mod 数据常用 manifest 风格全名 `类型:ID`(如 `AudioEvent:BaseSoundEffect`、`PlayerTemplate:Allies`);XML 定义侧 id 从不含冒号,所以查询统一走 `normalizeReferenceId`(取最后冒号段,与 manifest 的 `deriveAssetId` 同一规则)后再按 refType/selfType 过滤,records 仍保留原始值与偏移供导航/悬停使用。
|
||||
6. **重复 ID 诊断**:与 `check_duplicate_ids.py` 一致——SageXml 不参与冲突判定,mod 覆盖原版视为正常。
|
||||
7. **未解析引用诊断**:按设置严重级别报告(默认 warning);类型不匹配时给出明确文案("有同名 ID 但类型不匹配")。`definitionMode` 设置控制跳转候选:`all`(mod + 原版全部列出,mod 优先)或 `project-only`。
|
||||
8. **跳转精度**:XML 定义跳转到 `id` 属性值的精确 Range;manifest 定义映射到源码文件(如 SageXml)时也在文件内精确定位;找不到再回退到记录行。
|
||||
@@ -252,16 +252,19 @@ test/
|
||||
第一个独占一行的完整属性作为规范缩进,插入换行时顺带吞掉触发补全留下的
|
||||
尾随空格;属性名补全改用 `SnippetString`(`$1` 成为真正占位符),并新增
|
||||
输出通道调试日志。
|
||||
27. **simple-content 元素文本引用(第十六轮,2026-08-04)**:simple type
|
||||
子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
|
||||
`GameObjectWeakRef`)的**标签间文本**就是资产引用。内容区补全现在区分
|
||||
“复杂元素 → 子元素名”与“简单元素 → 值补全”;用户已输入 `<` 时替换范围从
|
||||
`<` 开始,杜绝 `<<`;simple type 元素片段固定为 `<Name>$1</Name>`(可填
|
||||
值)并自动触发值补全。hover / Ctrl 跳转 / 诊断 / Find All References
|
||||
均增加内容 token 分支。只有**带 `xas:refType`** 的 simple 内容按全局引用
|
||||
处理(291 处子元素声明);无类型 `AssetReference`
|
||||
(`FXShaderConstantTexture@Value`、`RenderSubObjectReference@Mesh` 等
|
||||
真实数据是贴图/子对象名)与 `Poid` 不参与全局解析,避免误报。
|
||||
27. **simple-content 元素文本引用(第十六轮,2026-08-04;第三十一轮扩展)**:
|
||||
simple type 子元素(如 `ObjectCreationList` 内嵌套 `<CreateObject>`,类型
|
||||
`GameObjectWeakRef`)以及 simpleContent 复杂类型(如 `<Sound>` →
|
||||
`AudioFileRefWithWeight`、`<Subsound>` → `MultisoundSubsoundRef`)的
|
||||
**标签间文本**就是资产引用。内容区补全现在区分“复杂元素 → 子元素名”与
|
||||
“内容元素 → 值补全”;用户已输入 `<` 时替换范围从 `<` 开始,杜绝 `<<`;
|
||||
内容元素片段固定为 `<Name>$1</Name>`(可填值)并自动触发值补全。hover /
|
||||
Ctrl 跳转 / 诊断 / Find All References 均增加内容 token 分支。只有**带
|
||||
`xas:refType`** 的内容按全局引用处理(291 处 simple type 子元素声明 +
|
||||
`Sound` / `Attack` / `Decay` / `Subsound` 等 simpleContent 复杂类型);
|
||||
无类型 `AssetReference`(`FXShaderConstantTexture@Value`、
|
||||
`RenderSubObjectReference@Mesh` 等真实数据是贴图/子对象名)与 `Poid`
|
||||
不参与全局解析,避免误报。
|
||||
补充:真实文件中 `<` 后还有 `</…>` 时,`findTagEnd` 曾把闭合标签的 `>` 误
|
||||
当成残缺开始标签的结束,生成空名/半截名伪元素,补全走 element-name 分支
|
||||
导致 `<<`。修复为引号外遇到 `<` 即视为未闭合(行尾恢复),且
|
||||
@@ -421,6 +424,14 @@ test/
|
||||
修正;SageXml 源缺失时保持 manifest-only,文件存在但 id 被删时降级
|
||||
到文件顶部;测试 178 → 184;分析见 `docs/analysis-issues.md`
|
||||
二十八。
|
||||
29. [x] 限定引用值 `类型:ID` 归一化(2026-08-11,v0.1.24):新增
|
||||
`refs.normalizeReferenceId`(取最后冒号段,与 manifest `deriveAssetId`
|
||||
同一规则),应用到属性引用、simple-content 引用、语义反向索引与
|
||||
FAR/CodeLens 的 `definitionsForReference`;records 仍保存原始值与
|
||||
偏移;补全在已输入 `类型:` 前缀时按冒号后片段过滤并保留前缀;
|
||||
实测 SageXml 5483 / Corona 3219 处 `inheritFrom="AudioEvent:..."`
|
||||
等受限引用全部修复;测试 219 → 226;分析见
|
||||
`docs/analysis-issues.md` 三十二。
|
||||
|
||||
## 四、验证结果(实测)
|
||||
|
||||
@@ -464,7 +475,7 @@ test/
|
||||
展开(第十二轮,见第六节);顶层 `<Include type="all">` 与 `inheritFrom` +
|
||||
`xai:joinAction` 的深合并仍未实现,后续如需要“当前文档视角的全量合并诊断”再继续。
|
||||
|
||||
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮)
|
||||
## 六、include 展开设计备忘(2026-08-01;xi:include 部分已实施于第十二轮,无 xpointer 语义与片段诊断见第二十八轮)
|
||||
|
||||
> 目的:集中记录 include 处理相关的现状、结论与设计,下次遇到 include 问题时从这里继续,
|
||||
> 并在实施后把结果回写本节。
|
||||
@@ -477,7 +488,8 @@ test/
|
||||
| `reference` → builtmods manifest 解析 / 缺失回退占位 XML | 已实现 |
|
||||
| 嵌套 `xi:include`(任意层级):目标可索引、缺失报 `include-not-found`、Ctrl+点击跳转、`href` hover 解析目标 | 已实现(第二轮 + 第五轮) |
|
||||
| `xi:include` 及其属性不参与 XSD 校验(外来命名空间守卫 `isXsdElementName` / `isXsdAttributeName`) | 已实现(第五轮) |
|
||||
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);顶层 `<Include type="all">` 仍不展开 |
|
||||
| include 目标内容“虚拟合并”进父文档的逻辑树 | 已实现 `xi:include`(第十二轮);**无 `xpointer` 时整体包含目标根元素**(第二十八轮修正);顶层 `<Include type="all">` 仍不展开 |
|
||||
| 片段文件(根非 `AssetDeclaration`)的诊断 | 已实现 P0(第二十八轮):跳过顶层 id/重复/引用/define 检查;根为已知 XSD 元素时仍校验子树;根未知时只报语法与 include 缺失 |
|
||||
|
||||
### 2. 已确认的方向
|
||||
|
||||
@@ -488,9 +500,11 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
|
||||
|
||||
- **不要**把 include 目标展开成文本再整体重新解析:源码偏移会断裂,诊断 / 跳转 / hover /
|
||||
补全全部无法映射回原始文件。
|
||||
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点按 `xpointer`
|
||||
子集挂进父元素,节点保留各自的源文件与原始偏移(来源追溯)。后续分析跑在逻辑树上,
|
||||
范围映射按节点 `sourceFile` 回到对应文件的 lineMap。
|
||||
- **要做**的是:解析器逐文件解析(现状不变);展开器把目标文件选中节点挂进父元素——
|
||||
有 `xpointer` 时取 `/n:Name/child::*` 选中 children,无 `xpointer` 时按 XInclude 语义
|
||||
整体包含目标根元素(RA3 片段如 `CreateObjectDie` 依赖这一行为);节点保留各自的源文件
|
||||
与原始偏移(来源追溯)。后续分析跑在逻辑树上,范围映射按节点 `sourceFile` 回到对应
|
||||
文件的 lineMap。
|
||||
- 现有 `parseXml` 已记录标签 / 属性 / 值的起止偏移,`XmlElement` 结构可直接复用;拼接时
|
||||
用浅拷贝节点壳并重建 parent 链,避免破坏目标文件缓存树自身的 parent 指针。
|
||||
|
||||
@@ -498,7 +512,7 @@ BAB(`defaultscript.cs`)编译时正是这样把整个 Mod 合并成一份大
|
||||
|
||||
| 构造 | 拼入逻辑树 | 理由 |
|
||||
|---|---|---|
|
||||
| `xi:include` | ✅ | 内容并入父元素(HeadlightDraw2 场景) |
|
||||
| `xi:include` | ✅ | 有 `xpointer`:选中容器 children;无 `xpointer`:整体包含目标根元素(CreateObjectDie / TechUpgradeReceiver 等片段场景) |
|
||||
| EA `<Include type="all">` | ✅ | BAB“内容合并”,等价于复制进来 |
|
||||
| `type="instance"` | ❌ | 只影响编译可见性;拼树会把 BaseVehicle 的顶层资产错误塞进当前文档 |
|
||||
| `type="reference"` | ❌ | manifest 编译产物,无文本内容 |
|
||||
|
||||
+10
-5
@@ -23,7 +23,9 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- 无前缀的路径相对于当前文件所在目录;
|
||||
- `ART:` 路径支持“文件名前两个小写字母作为子目录”的匹配(如 `JUAntiShip` → `ju/JUAntiShip`)。
|
||||
|
||||
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。
|
||||
继承机制:`inheritFrom` 让一个元素默认获得目标元素的所有内容;具体合并行为由 `xai:joinAction`(`uri:ea.com:eala:asset:instance` 命名空间)控制,实际项目中出现的取值为 `Replace`、`Remove`。XSD 只在 `BaseInheritableAsset` 上显式声明 `inheritFrom`,但原版与 Corona 数据也在 `FXList`、`AIMicroManagerData` 等 `BaseAssetType` 系资产上使用它,插件按“所有资产类型的通用属性”处理。
|
||||
|
||||
引用值写法:原版与 Corona 数据中的引用既可以是裸 ID,也可以是 manifest 风格的全名 `类型:ID`(如 `inheritFrom="AudioEvent:BaseSoundEffect"`、`Sound="AudioEvent:JAP_Refinery_Select"`、`Side="PlayerTemplate:Allies"`、`ParticleTexture="Texture:FXLenzFlare01"`)。XML 定义侧的 `id` 从不含冒号,因此插件统一按“最后冒号段”归一化后再匹配定义,类型过滤仍按 refType / 元素自身类型执行。
|
||||
|
||||
全部 XML 语法由 XSD 定义:SDK 自带 `Schemas/xsd/CnC3Types.xsd`(及其 800+ 个子 XSD)。大型 Mod 项目(如 Corona)还会携带自己修改过的 XSD 副本。
|
||||
|
||||
@@ -38,13 +40,15 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- 属性值:
|
||||
- 引用型属性(XSD 中带 `xas:refType`)补全已定义的资产 ID;
|
||||
- `inheritFrom` 补全可继承的资产 ID;
|
||||
- 引用值支持 `类型:ID` 前缀写法:已输入 `AudioEvent:` 时按冒号后的 ID 过滤,插入时保留已输入的前缀;未输入前缀时保持裸 ID 补全;
|
||||
- 枚举值(XSD `xs:enumeration`);
|
||||
- `$DEFINE` 常量(如 `$CIV_HEALTH_SMALL`);
|
||||
- `<Include source>` 补全可解析的文件路径(`DATA:` / `ART:` / `AUDIO:`)。
|
||||
- **元素文本内容(simple content)**:带 `xas:refType` 的简单内容元素
|
||||
(如 `<CreateObject>ID</CreateObject>`)在标签间补全对应类型的资产 ID、
|
||||
枚举或 `$DEFINE`;补全出的 simple-content 元素必须是可填值的成对标签
|
||||
(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
|
||||
- **元素文本内容(simple content)**:带 `xas:refType` 的内容元素
|
||||
(simple type 如 `<CreateObject>ID</CreateObject>`,simpleContent 复杂类型
|
||||
如 `<Sound>AudioFile</Sound>`、`<Subsound>VoiceEvent</Subsound>`)在标签间
|
||||
补全对应类型的资产 ID、枚举或 `$DEFINE`;补全出的 simple-content 元素必须
|
||||
是可填值的成对标签(`<Name></Name>`),且内容区已输入 `<` 时不得产生 `<<`。
|
||||
3. **引用提示(Hover)**:元素/属性悬停显示 XSD 文档、类型、默认值;资产 ID 悬停显示定义位置;`$DEFINE` 悬停显示值与定义位置。
|
||||
- 元素文本内容(simple content 引用)悬停同样显示定义位置。
|
||||
4. **引用导航**:
|
||||
@@ -60,6 +64,7 @@ XML 之间的组织靠 `<Include>` 标签,共有三种语义:
|
||||
- 缺失必填 `id`(顶层资产);
|
||||
- 重复 ID(同类型 + 同 id,mod 文件之间;覆盖原版 SageXml 不算冲突);
|
||||
- 引用未解析(引用了不存在的资产 ID,可配置是否忽略原版 manifest 中的 ID);
|
||||
- 引用值带 `类型:` 前缀时先归一化为裸 ID 再判定(如 `AudioEvent:BaseSoundEffect` → `BaseSoundEffect`),前缀不影响类型过滤;
|
||||
- simple-content 引用元素的文本未解析(同属性引用规则,仅带 refType 的类型)。
|
||||
- `<Include>` 目标文件找不到、Include 循环;
|
||||
- `$DEFINE` 未定义。
|
||||
|
||||
+14
-2
@@ -1,11 +1,23 @@
|
||||
import * as esbuild from "esbuild";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
// Remove previous outputs first. esbuild only overwrites the files its current
|
||||
// entry points produce, so without this a packaged VSIX can ship stale
|
||||
// artifacts from an older layout (e.g. the pre-`outbase` dist/mcpServer.js
|
||||
// next to the current dist/agent/mcpServer.js).
|
||||
await rm("dist", { recursive: true, force: true });
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["./src/extension.ts"],
|
||||
entryPoints: [
|
||||
"./src/extension.ts",
|
||||
"./src/agent/mcpServer.ts",
|
||||
"./src/agent/cli.ts",
|
||||
],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
outdir: "dist",
|
||||
outbase: "src",
|
||||
external: ["vscode"],
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
+73
-1
@@ -66,6 +66,7 @@
|
||||
" (based on a partial index)": " (based on a partial index)",
|
||||
" (index incomplete — may be a false positive)": " (index incomplete — may be a false positive)",
|
||||
"Include target not found: {0}": "Include target not found: {0}",
|
||||
"xi:include target not found: {0}": "xi:include target not found: {0}",
|
||||
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.",
|
||||
"**Top-level asset element**": "**Top-level asset element**",
|
||||
"Attributes: {0} · Children: {1}": "Attributes: {0} · Children: {1}",
|
||||
@@ -152,5 +153,76 @@
|
||||
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)",
|
||||
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)",
|
||||
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "Reference \"{0}\" has no matching definition (ids with the same name exist for other types)",
|
||||
"Unresolved reference \"{0}\" (not found in the current index)": "Unresolved reference \"{0}\" (not found in the current index)"
|
||||
"Unresolved reference \"{0}\" (not found in the current index)": "Unresolved reference \"{0}\" (not found in the current index)",
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
|
||||
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML: exported AI Agent index snapshot to {0}",
|
||||
"Reveal in Explorer": "Reveal in Explorer",
|
||||
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
|
||||
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.": "RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
|
||||
"Install Agent Skill (recommended)": "Install Agent Skill (recommended)",
|
||||
"Write MCP config to Claude Desktop": "Write MCP config to Claude Desktop",
|
||||
"Write MCP config to Cursor (global)": "Write MCP config to Cursor (global)",
|
||||
"Write MCP config to Cursor (project)": "Write MCP config to Cursor (project)",
|
||||
"Copy MCP config": "Copy MCP config",
|
||||
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent access enabled. Choose an optional next step.",
|
||||
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill installed to {0}",
|
||||
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML: could not write the Agent Skill to {0}",
|
||||
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP config written to {0}",
|
||||
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP config copied to clipboard.",
|
||||
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML: failed to enable AI Agent access: {0}",
|
||||
"Default (~/.agents/skills)": "Default (~/.agents/skills)",
|
||||
"Claude Code (~/.claude/skills)": "Claude Code (~/.claude/skills)",
|
||||
"Current project .agents/skills": "Current project .agents/skills",
|
||||
"Current project .claude/skills": "Current project .claude/skills",
|
||||
"Choose a custom folder…": "Choose a custom folder…",
|
||||
"The skill is installed as <folder>/{0}": "The skill is installed as <folder>/{0}",
|
||||
"Select Agent Skill install locations": "Select Agent Skill install locations",
|
||||
"Choose a skill folder": "Choose a skill folder",
|
||||
"Choose the folder that should contain the {0} skill": "Choose the folder that should contain the {0} skill",
|
||||
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).",
|
||||
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
|
||||
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill installed to {0} location(s).",
|
||||
"Show installed skills": "Show installed skills",
|
||||
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML: no recorded Agent Skill installation to remove.",
|
||||
"missing — will be dropped from the record": "missing — will be dropped from the record",
|
||||
"installed by this extension (v{0})": "installed by this extension (v{0})",
|
||||
"not managed by this extension — will be skipped": "not managed by this extension — will be skipped",
|
||||
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "No RA3 Mod XML skill marker found; remove it manually if you want it gone.",
|
||||
"Select Agent Skill installations to remove": "Select Agent Skill installations to remove",
|
||||
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
|
||||
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML: removed {0} Agent Skill installation(s).",
|
||||
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
|
||||
"Uninstall AI Agent integration…": "Uninstall AI Agent integration…",
|
||||
"Stop live AI Agent access in this window": "Stop live AI Agent access in this window",
|
||||
"Remove installed Agent Skills": "Remove installed Agent Skills",
|
||||
"{0} location(s)": "{0} location(s)",
|
||||
"Remove MCP client configuration entries": "Remove MCP client configuration entries",
|
||||
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor and files recorded by this extension",
|
||||
"Remove the stable MCP launcher": "Remove the stable MCP launcher",
|
||||
"Select what to remove (nothing is removed until you confirm)": "Select what to remove (nothing is removed until you confirm)",
|
||||
"Remove the selected AI Agent components?": "Remove the selected AI Agent components?",
|
||||
"Remove": "Remove",
|
||||
"live access stopped": "live access stopped",
|
||||
"{0} skill installation(s) removed": "{0} skill installation(s) removed",
|
||||
"{0} unmanaged skill folder(s) skipped": "{0} unmanaged skill folder(s) skipped",
|
||||
"MCP config removed from {0} file(s)": "MCP config removed from {0} file(s)",
|
||||
"MCP launcher removed": "MCP launcher removed",
|
||||
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent cleanup: {0}.",
|
||||
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
|
||||
"Enable AI Agent access": "Enable AI Agent access",
|
||||
"Learn more": "Learn more",
|
||||
"Don't show again": "Don't show again",
|
||||
"RA3 Mod XML: Find asset": "RA3 Mod XML: Find asset",
|
||||
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
|
||||
"Type an id, a partial id or Type:Id to search.": "Type an id, a partial id or Type:Id to search.",
|
||||
"No asset matches \"{0}\".": "No asset matches \"{0}\".",
|
||||
"{0} of {1} matches — keep typing to narrow the list.": "{0} of {1} matches — keep typing to narrow the list.",
|
||||
"1 asset found.": "1 asset found.",
|
||||
"{0} assets found.": "{0} assets found.",
|
||||
"{0} definitions": "{0} definitions",
|
||||
"Select the definition to open": "Select the definition to open",
|
||||
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML: no XML source location for {0} ({1}).",
|
||||
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML: the index has no assets yet — wait for indexing to finish.",
|
||||
"local": "local"
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
" (based on a partial index)": "(基于部分索引)",
|
||||
" (index incomplete — may be a false positive)": "(索引不完整——可能是误报)",
|
||||
"Include target not found: {0}": "找不到 Include 目标:{0}",
|
||||
"xi:include target not found: {0}": "找不到 xi:include 目标:{0}",
|
||||
"XInclude element (W3C XInclude namespace) — not part of the RA3 XSD model.": "XInclude 元素(W3C XInclude 命名空间)——不属于 RA3 XSD 模型。",
|
||||
"**Top-level asset element**": "**顶层资产元素**",
|
||||
"Attributes: {0} · Children: {1}": "属性:{0} · 子元素:{1}",
|
||||
@@ -152,5 +153,76 @@
|
||||
"Reference \"{0}\" has no definition of type `{1}` (ids with the same name exist for other types)": "引用 \"{0}\" 没有类型为 `{1}` 的定义(其他类型存在同名 id)",
|
||||
"Reference \"{0}\" has no definition of the expected declared type (ids with the same name exist for other types)": "引用 \"{0}\" 没有符合声明类型的定义(其他类型存在同名 id)",
|
||||
"Reference \"{0}\" has no matching definition (ids with the same name exist for other types)": "引用 \"{0}\" 没有匹配的定义(其他类型存在同名 id)",
|
||||
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)"
|
||||
"Unresolved reference \"{0}\" (not found in the current index)": "无法解析的引用 \"{0}\"(当前索引中未找到)",
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再导出 AI Agent 快照。",
|
||||
"RA3 Mod XML: exported AI Agent index snapshot to {0}": "RA3 Mod XML:已导出 AI Agent 索引快照到 {0}",
|
||||
"Reveal in Explorer": "在资源管理器中显示",
|
||||
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}": "RA3 Mod XML:导出 AI Agent 索引快照失败:{0}",
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.": "RA3 Mod XML:暂无可用索引。请等待索引完成后再启用 AI Agent 访问。",
|
||||
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.": "RA3 Mod XML:AI Agent 启动器将使用 PATH 中的 Node。请安装 Node,或从正常安装的 VS Code 启动,以使用内置运行时。",
|
||||
"Install Agent Skill (recommended)": "安装 Agent Skill(推荐)",
|
||||
"Write MCP config to Claude Desktop": "将 MCP 配置写入 Claude Desktop",
|
||||
"Write MCP config to Cursor (global)": "将 MCP 配置写入 Cursor(全局)",
|
||||
"Write MCP config to Cursor (project)": "将 MCP 配置写入 Cursor(项目)",
|
||||
"Copy MCP config": "复制 MCP 配置",
|
||||
"RA3 Mod XML AI Agent access enabled. Choose an optional next step.": "RA3 Mod XML AI Agent 访问已启用。请选择可选的后续操作。",
|
||||
"RA3 Mod XML Agent Skill installed to {0}": "RA3 Mod XML Agent Skill 已安装到 {0}",
|
||||
"RA3 Mod XML: could not write the Agent Skill to {0}": "RA3 Mod XML:无法将 Agent Skill 写入 {0}",
|
||||
"RA3 Mod XML MCP config written to {0}": "RA3 Mod XML MCP 配置已写入 {0}",
|
||||
"RA3 Mod XML MCP config copied to clipboard.": "RA3 Mod XML MCP 配置已复制到剪贴板。",
|
||||
"RA3 Mod XML: failed to enable AI Agent access: {0}": "RA3 Mod XML:启用 AI Agent 访问失败:{0}",
|
||||
"Default (~/.agents/skills)": "默认(~/.agents/skills)",
|
||||
"Claude Code (~/.claude/skills)": "Claude Code(~/.claude/skills)",
|
||||
"Current project .agents/skills": "当前项目 .agents/skills",
|
||||
"Current project .claude/skills": "当前项目 .claude/skills",
|
||||
"Choose a custom folder…": "选择自定义文件夹…",
|
||||
"The skill is installed as <folder>/{0}": "Skill 将安装到 <文件夹>/{0}",
|
||||
"Select Agent Skill install locations": "选择 Agent Skill 安装位置",
|
||||
"Choose a skill folder": "选择 Skill 文件夹",
|
||||
"Choose the folder that should contain the {0} skill": "选择用于存放 {0} Skill 的文件夹",
|
||||
"RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).": "RA3 Mod XML:无法写入 Agent Skill({0} 个位置全部失败)。",
|
||||
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置;{1} 个失败。",
|
||||
"RA3 Mod XML Agent Skill installed to {0} location(s).": "RA3 Mod XML Agent Skill 已安装到 {0} 个位置。",
|
||||
"Show installed skills": "查看已安装的 Skill",
|
||||
"RA3 Mod XML: no recorded Agent Skill installation to remove.": "RA3 Mod XML:没有可移除的 Agent Skill 安装记录。",
|
||||
"missing — will be dropped from the record": "已缺失——将从记录中删除",
|
||||
"installed by this extension (v{0})": "由本扩展安装(v{0})",
|
||||
"not managed by this extension — will be skipped": "不由本扩展管理——将跳过",
|
||||
"No RA3 Mod XML skill marker found; remove it manually if you want it gone.": "未找到 RA3 Mod XML Skill 标记;如需删除请手动处理。",
|
||||
"Select Agent Skill installations to remove": "选择要移除的 Agent Skill 安装",
|
||||
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装;跳过 {1} 个(不由本扩展管理)。",
|
||||
"RA3 Mod XML: removed {0} Agent Skill installation(s).": "RA3 Mod XML:已移除 {0} 个 Agent Skill 安装。",
|
||||
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.": "RA3 Mod XML:已禁用此工作区的 AI Agent 访问。已安装的 Skill 和 MCP 客户端配置已保留。",
|
||||
"Uninstall AI Agent integration…": "卸载 AI Agent 集成…",
|
||||
"Stop live AI Agent access in this window": "在此窗口中停止实时 AI Agent 访问",
|
||||
"Remove installed Agent Skills": "移除已安装的 Agent Skill",
|
||||
"{0} location(s)": "{0} 个位置",
|
||||
"Remove MCP client configuration entries": "移除 MCP 客户端配置项",
|
||||
"Claude Desktop / Cursor and files recorded by this extension": "Claude Desktop / Cursor 以及本扩展记录过的配置文件",
|
||||
"Remove the stable MCP launcher": "移除稳定的 MCP 启动器",
|
||||
"Select what to remove (nothing is removed until you confirm)": "选择要移除的内容(确认前不会删除任何内容)",
|
||||
"Remove the selected AI Agent components?": "要移除选中的 AI Agent 组件吗?",
|
||||
"Remove": "移除",
|
||||
"live access stopped": "已停止实时访问",
|
||||
"{0} skill installation(s) removed": "已移除 {0} 个 Skill 安装",
|
||||
"{0} unmanaged skill folder(s) skipped": "已跳过 {0} 个非本扩展管理的 Skill 目录",
|
||||
"MCP config removed from {0} file(s)": "已从 {0} 个配置文件中移除 MCP 配置",
|
||||
"MCP launcher removed": "已移除 MCP 启动器",
|
||||
"RA3 Mod XML AI Agent cleanup: {0}.": "RA3 Mod XML AI Agent 清理完成:{0}。",
|
||||
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?": "RA3 Mod XML:此版本可以把项目资产索引提供给 AI Agent(MCP + Agent Skill)。要启用吗?",
|
||||
"Enable AI Agent access": "启用 AI Agent 访问",
|
||||
"Learn more": "了解更多",
|
||||
"Don't show again": "不再提示",
|
||||
"RA3 Mod XML: Find asset": "RA3 Mod XML:查找资产",
|
||||
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)": "ID、部分 ID 或 类型:ID(例如 WeaponTemplate:AssaultRifle)",
|
||||
"Type an id, a partial id or Type:Id to search.": "输入 ID、部分 ID 或 类型:ID 进行搜索。",
|
||||
"No asset matches \"{0}\".": "没有匹配 \"{0}\" 的资产。",
|
||||
"{0} of {1} matches — keep typing to narrow the list.": "{0}/{1} 个匹配——继续输入可缩小范围。",
|
||||
"1 asset found.": "找到 1 个资产。",
|
||||
"{0} assets found.": "找到 {0} 个资产。",
|
||||
"{0} definitions": "{0} 处定义",
|
||||
"Select the definition to open": "选择要打开的定义",
|
||||
"RA3 Mod XML: no XML source location for {0} ({1}).": "RA3 Mod XML:{0}({1})没有可打开的 XML 源位置。",
|
||||
"RA3 Mod XML: the index has no assets yet — wait for indexing to finish.": "RA3 Mod XML:索引中还没有资产——请等待索引完成。",
|
||||
"local": "本地"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ra3-mod-xml",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ra3-mod-xml",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.25",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.5.0"
|
||||
|
||||
+57
-13
@@ -2,14 +2,25 @@
|
||||
"name": "ra3-mod-xml",
|
||||
"displayName": "%ra3modxml.displayName%",
|
||||
"description": "%ra3modxml.description%",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.26",
|
||||
"publisher": "lanyi",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"icon": "images/icon.png",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt.git"
|
||||
"url": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt.git"
|
||||
},
|
||||
"homepage": "https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt",
|
||||
"capabilities": {
|
||||
"untrustedWorkspaces": {
|
||||
"supported": "limited",
|
||||
"description": "The workspace-defined SDK path and additional DATA: search paths are ignored until the workspace is trusted.",
|
||||
"restrictedConfigurations": [
|
||||
"ra3modxml.sdkPath",
|
||||
"ra3modxml.additionalDataSearchPaths"
|
||||
]
|
||||
}
|
||||
},
|
||||
"homepage": "https://git.ra3battle.cn/RA3CoronaDevelopers/Ra3ModXmlExt",
|
||||
"l10n": "./l10n",
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
@@ -115,6 +126,34 @@
|
||||
"command": "ra3modxml.showCacheReport",
|
||||
"title": "%ra3modxml.command.showCacheReport.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.enableAgentAccess",
|
||||
"title": "%ra3modxml.command.enableAgentAccess.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.disableAgentAccess",
|
||||
"title": "%ra3modxml.command.disableAgentAccess.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.installAgentSkill",
|
||||
"title": "%ra3modxml.command.installAgentSkill.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.uninstallAgentSkill",
|
||||
"title": "%ra3modxml.command.uninstallAgentSkill.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.uninstallAgentIntegration",
|
||||
"title": "%ra3modxml.command.uninstallAgentIntegration.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.exportIndexSnapshot",
|
||||
"title": "%ra3modxml.command.exportIndexSnapshot.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.findAsset",
|
||||
"title": "%ra3modxml.command.findAsset.title%"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.findUnreferencedAssets",
|
||||
"title": "%ra3modxml.command.findUnreferencedAssets.title%"
|
||||
@@ -123,16 +162,21 @@
|
||||
"command": "ra3modxml.findUnreferencedAssetsOfType",
|
||||
"title": "%ra3modxml.command.findUnreferencedAssetsOfType.title%"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "ra3modxml.findUnreferencedAssetsOfType",
|
||||
"when": "editorLangId == xml && ra3modxml.active",
|
||||
"group": "navigation@50"
|
||||
}
|
||||
]
|
||||
],
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "ra3modxml.findAsset",
|
||||
"when": "editorLangId == xml && ra3modxml.active",
|
||||
"group": "navigation@45"
|
||||
},
|
||||
{
|
||||
"command": "ra3modxml.findUnreferencedAssetsOfType",
|
||||
"when": "editorLangId == xml && ra3modxml.active",
|
||||
"group": "navigation@50"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node esbuild.mjs",
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"ra3modxml.command.clearCache.title": "RA3 Mod XML: Clear caches and rebuild",
|
||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: Configure SDK path…",
|
||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: Show cache report",
|
||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: Enable AI Agent access…",
|
||||
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: Disable AI Agent access",
|
||||
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: Install Agent Skill…",
|
||||
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: Uninstall Agent Skill…",
|
||||
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: Uninstall AI Agent integration…",
|
||||
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: Export AI Agent index snapshot",
|
||||
"ra3modxml.command.findAsset.title": "RA3 Mod XML: Find asset (id or Type:Id)…",
|
||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: Find unreferenced assets…",
|
||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: Find unreferenced assets of this type"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
"ra3modxml.command.clearCache.title": "RA3 Mod XML: 清空缓存并重建",
|
||||
"ra3modxml.command.configureSdkPath.title": "RA3 Mod XML: 配置 SDK 路径…",
|
||||
"ra3modxml.command.showCacheReport.title": "RA3 Mod XML: 显示缓存报告",
|
||||
"ra3modxml.command.enableAgentAccess.title": "RA3 Mod XML: 启用 AI Agent 访问…",
|
||||
"ra3modxml.command.disableAgentAccess.title": "RA3 Mod XML: 禁用 AI Agent 访问",
|
||||
"ra3modxml.command.installAgentSkill.title": "RA3 Mod XML: 安装 Agent Skill…",
|
||||
"ra3modxml.command.uninstallAgentSkill.title": "RA3 Mod XML: 卸载 Agent Skill…",
|
||||
"ra3modxml.command.uninstallAgentIntegration.title": "RA3 Mod XML: 卸载 AI Agent 集成…",
|
||||
"ra3modxml.command.exportIndexSnapshot.title": "RA3 Mod XML: 导出 AI Agent 索引快照",
|
||||
"ra3modxml.command.findAsset.title": "RA3 Mod XML: 查找资产(ID 或 类型:ID)…",
|
||||
"ra3modxml.command.findUnreferencedAssets.title": "RA3 Mod XML: 查找未引用的资产…",
|
||||
"ra3modxml.command.findUnreferencedAssetsOfType.title": "RA3 Mod XML: 查找该类型的未引用资产"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Thin CLI for RA3 Mod XML index queries.
|
||||
*
|
||||
* Deliberately thin: it does **not** reimplement the query surface. It does
|
||||
* exactly two things.
|
||||
*
|
||||
* 1. Forward the call to the live VS Code extension through the shared
|
||||
* `LiveClient` (same transport, project pinning and validation as the MCP
|
||||
* server, so the two entry points cannot drift apart).
|
||||
* 2. Fall back to the on-disk snapshot exported by the extension when no live
|
||||
* instance is reachable.
|
||||
*
|
||||
* Features that need the DOM (element-level provenance) are live-only; the
|
||||
* CLI reports that clearly instead of returning an empty result that would
|
||||
* look like "no references exist".
|
||||
*
|
||||
* The CLI is not installed into PATH. It is reached through an absolute path,
|
||||
* normally by an agent following the skill's discovery instructions.
|
||||
*
|
||||
* Usage:
|
||||
* node dist/agent/cli.js status
|
||||
* node dist/agent/cli.js find <id> [type]
|
||||
* node dist/agent/cli.js refs <id> [type]
|
||||
* node dist/agent/cli.js outgoing <id> [type] [--depth N] [--target-types A,B]
|
||||
* node dist/agent/cli.js list <type> [prefix]
|
||||
* node dist/agent/cli.js active <file>
|
||||
* node dist/agent/cli.js define <name>
|
||||
* node dist/agent/cli.js resolve <source>
|
||||
* node dist/agent/cli.js projects
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { findProjectRootUpward } from "../projectRoot";
|
||||
import { LiveClient, type LiveClientOptions } from "./liveClient";
|
||||
import {
|
||||
findAssets,
|
||||
findDefine,
|
||||
findReferenceGroups,
|
||||
isFileActive,
|
||||
listAssetsByType,
|
||||
resolveIncludeSource,
|
||||
statusFromSnapshot,
|
||||
} from "./query";
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import type { AgentIndexSnapshot } from "./types";
|
||||
|
||||
const EXIT_OK = 0;
|
||||
const EXIT_USAGE = 2;
|
||||
const EXIT_UNAVAILABLE = 3;
|
||||
|
||||
export interface CliOptions {
|
||||
projectDir: string | null;
|
||||
snapshotPath: string | null;
|
||||
agentHome: string | undefined;
|
||||
command: string;
|
||||
args: string[];
|
||||
depth: number | undefined;
|
||||
targetTypes: string[];
|
||||
maxEdges: number | undefined;
|
||||
includeUnresolved: boolean;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliOptions {
|
||||
const options: CliOptions = {
|
||||
projectDir: null,
|
||||
snapshotPath: null,
|
||||
agentHome: undefined,
|
||||
command: "status",
|
||||
args: [],
|
||||
depth: undefined,
|
||||
targetTypes: [],
|
||||
maxEdges: undefined,
|
||||
includeUnresolved: false,
|
||||
};
|
||||
const positional: string[] = [];
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--project" || arg === "-p") {
|
||||
options.projectDir = argv[++i] ?? null;
|
||||
} else if (arg === "--snapshot" || arg === "-s") {
|
||||
options.snapshotPath = argv[++i] ?? null;
|
||||
} else if (arg === "--agent-home") {
|
||||
options.agentHome = argv[++i] ?? undefined;
|
||||
} else if (arg === "--depth" || arg === "-d") {
|
||||
const raw = argv[++i];
|
||||
options.depth = raw != null ? Number(raw) : undefined;
|
||||
} else if (arg === "--target-types" || arg === "-t") {
|
||||
options.targetTypes = (argv[++i] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
} else if (arg === "--max-edges") {
|
||||
const raw = argv[++i];
|
||||
options.maxEdges = raw != null ? Number(raw) : undefined;
|
||||
} else if (arg === "--include-unresolved") {
|
||||
options.includeUnresolved = true;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
options.command = "help";
|
||||
} else if (arg.startsWith("-")) {
|
||||
// Ignore unknown flags rather than failing an agent's probing call.
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
if (positional.length > 0) {
|
||||
options.command = positional[0];
|
||||
options.args = positional.slice(1);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
process.stdout.write(`RA3 Mod XML agent CLI
|
||||
|
||||
Usage:
|
||||
node cli.js [--project <dir>] <command> [args...]
|
||||
node cli.js [--snapshot <file>] <command> [args...]
|
||||
|
||||
Project resolution order:
|
||||
1. --project
|
||||
2. --snapshot
|
||||
3. nearest mod project root above the current directory
|
||||
|
||||
Commands:
|
||||
status index state (+ source: live or snapshot)
|
||||
find <id> [type] asset definitions
|
||||
refs <id> [type] incoming semantic references
|
||||
outgoing <id> [type] outgoing reference edges (live only)
|
||||
--depth N --target-types A,B --max-edges N --include-unresolved
|
||||
list <type> [prefix] assets of one type
|
||||
active <file> whether a file is in an indexed stream
|
||||
define <name> $DEFINE constants
|
||||
resolve <source> Include source candidate
|
||||
projects indexed project roots (live only)
|
||||
help
|
||||
`);
|
||||
}
|
||||
|
||||
/** Resolves the project directory, inferring it from cwd when not given. */
|
||||
export function resolveProjectDir(options: CliOptions): string | null {
|
||||
if (options.projectDir) return resolve(options.projectDir);
|
||||
const inferred = findProjectRootUpward(process.cwd());
|
||||
return inferred;
|
||||
}
|
||||
|
||||
/** Writes JSON to stdout and returns the exit code. */
|
||||
function emit(value: unknown): number {
|
||||
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
function fail(message: string, code = EXIT_USAGE): number {
|
||||
process.stderr.write(`${message}\n`);
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Live-only commands, reported explicitly when live is unreachable. */
|
||||
export const LIVE_ONLY = new Set(["outgoing", "projects"]);
|
||||
|
||||
export function liveArgsFor(options: CliOptions): Record<string, unknown> {
|
||||
const [a, b] = options.args;
|
||||
switch (options.command) {
|
||||
case "find":
|
||||
return { id: a, type: b };
|
||||
case "refs":
|
||||
return { id: a, type: b };
|
||||
case "outgoing":
|
||||
return {
|
||||
id: a,
|
||||
type: b,
|
||||
depth: options.depth,
|
||||
targetTypes: options.targetTypes?.length ? options.targetTypes : undefined,
|
||||
maxEdges: options.maxEdges,
|
||||
includeUnresolved: options.includeUnresolved ? true : undefined,
|
||||
};
|
||||
case "list":
|
||||
return { type: a, prefix: b };
|
||||
case "active":
|
||||
return { path: a };
|
||||
case "define":
|
||||
return { name: a };
|
||||
case "resolve":
|
||||
return { source: a };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Maps a CLI command to the live/MCP tool name. */
|
||||
export function toolNameFor(command: string): string {
|
||||
switch (command) {
|
||||
case "find":
|
||||
return "find_asset";
|
||||
case "refs":
|
||||
return "find_references";
|
||||
case "outgoing":
|
||||
return "get_asset_references";
|
||||
case "list":
|
||||
return "list_assets_by_type";
|
||||
case "active":
|
||||
return "is_file_active";
|
||||
case "define":
|
||||
return "find_define";
|
||||
case "resolve":
|
||||
return "resolve_include";
|
||||
case "projects":
|
||||
return "list_projects";
|
||||
default:
|
||||
return "get_status";
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the command against the on-disk snapshot. */
|
||||
function runSnapshotCommand(
|
||||
options: CliOptions,
|
||||
snapshot: AgentIndexSnapshot,
|
||||
): number {
|
||||
const [a, b] = options.args;
|
||||
switch (options.command) {
|
||||
case "status":
|
||||
return emit({ source: "snapshot", index: statusFromSnapshot(snapshot) });
|
||||
case "find":
|
||||
if (!a) return fail("find requires an id.");
|
||||
return emit(findAssets(snapshot, a, b));
|
||||
case "refs":
|
||||
if (!a) return fail("refs requires an id.");
|
||||
return emit(findReferenceGroups(snapshot, a, b));
|
||||
case "list":
|
||||
if (!a) return fail("list requires a type.");
|
||||
return emit(listAssetsByType(snapshot, a, b ?? ""));
|
||||
case "active":
|
||||
if (!a) return fail("active requires a file path.");
|
||||
return emit({ active: isFileActive(snapshot, a) });
|
||||
case "define":
|
||||
if (!a) return fail("define requires a name.");
|
||||
return emit(findDefine(snapshot, a.replace(/^\$/, "")));
|
||||
case "resolve":
|
||||
if (!a) return fail("resolve requires a source.");
|
||||
return emit(resolveIncludeSource(snapshot, a));
|
||||
default:
|
||||
return fail(`Unknown command: ${options.command}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Explains why a live-only command could not run. */
|
||||
function liveOnlyUnavailable(options: CliOptions): number {
|
||||
return emit({
|
||||
index: { state: "no_index" },
|
||||
source: "unavailable",
|
||||
error:
|
||||
options.command === "outgoing"
|
||||
? "get_asset_references requires a live index: the element context it reports is not stored in the on-disk snapshot. Open the project in VS Code with AI Agent access enabled, then retry."
|
||||
: "list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.command === "help") {
|
||||
printHelp();
|
||||
return EXIT_OK;
|
||||
}
|
||||
|
||||
const projectDir = resolveProjectDir(options);
|
||||
|
||||
// Live first: the extension's in-memory index is the most complete source.
|
||||
if (!options.snapshotPath) {
|
||||
const liveOptions: LiveClientOptions = {
|
||||
projectDir,
|
||||
agentHome: options.agentHome,
|
||||
};
|
||||
const client = new LiveClient(liveOptions);
|
||||
const result = await client.query(
|
||||
toolNameFor(options.command),
|
||||
liveArgsFor(options),
|
||||
);
|
||||
if (result?.mismatched) {
|
||||
return emit({
|
||||
index: { state: "error" },
|
||||
source: "live",
|
||||
error: `The live server answered for a different project than "${projectDir}"; refusing the result.`,
|
||||
});
|
||||
}
|
||||
if (result) {
|
||||
return emit({ source: "live", ...(result.payload as object) });
|
||||
}
|
||||
// No live index: fall through to the snapshot when the command allows it.
|
||||
if (LIVE_ONLY.has(options.command)) return liveOnlyUnavailable(options);
|
||||
}
|
||||
|
||||
// Snapshot fallback.
|
||||
const snapshotPath =
|
||||
options.snapshotPath ??
|
||||
(projectDir ? snapshotPathForProject(projectDir, options.agentHome) : null);
|
||||
if (!snapshotPath) {
|
||||
return fail(
|
||||
"No project found. Pass --project <dir>, --snapshot <file>, or run from inside a mod project.",
|
||||
EXIT_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
const snapshot = await readSnapshotFile(snapshotPath);
|
||||
if (!snapshot) {
|
||||
return fail(
|
||||
`No live VS Code instance and no readable snapshot at ${snapshotPath}. Open the project in VS Code with AI Agent access enabled, or run the "Export AI Agent index snapshot" command.`,
|
||||
EXIT_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
return runSnapshotCommand(options, snapshot);
|
||||
}
|
||||
|
||||
// Only run the CLI when executed directly, so the module stays importable by
|
||||
// tests (same guard as the MCP server).
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
void main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exitCode = EXIT_UNAVAILABLE;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Read/write helpers for the local endpoint files used by MCP/CLI tools to
|
||||
* find a live RA3 Mod XML query server.
|
||||
*
|
||||
* Discovery is **per project** (`endpoints/<slug>-<hash>.json`) because a
|
||||
* single global `endpoint.json` breaks as soon as more than one VS Code
|
||||
* window (or more than one project) has AI Agent access enabled: the last
|
||||
* window to start would overwrite the file, and a client started for project
|
||||
* A could silently receive project B's index.
|
||||
*
|
||||
* A global `endpoint.json` is still written as a legacy/"most recent" pointer
|
||||
* so tooling that does not know the project can find something, but readers
|
||||
* that do know the project must prefer the per-project file.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
|
||||
|
||||
export interface AgentEndpoint {
|
||||
/** Base URL of the local server, e.g. http://127.0.0.1:54321 */
|
||||
url: string;
|
||||
token: string;
|
||||
/** Absolute project root this endpoint serves (may serve several). */
|
||||
projectDir?: string;
|
||||
/** Every project root the server can answer for. */
|
||||
projects?: string[];
|
||||
/** PID of the VS Code extension host, used to detect stale files. */
|
||||
processId?: number;
|
||||
/** ISO timestamp of the last write, useful for diagnostics. */
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** Path to the legacy global endpoint file. */
|
||||
export function endpointPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "endpoint.json");
|
||||
}
|
||||
|
||||
/** Directory holding one endpoint file per project. */
|
||||
export function endpointDir(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "endpoints");
|
||||
}
|
||||
|
||||
/** Path to the per-project endpoint file. */
|
||||
export function endpointPathForProject(
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): string {
|
||||
return join(endpointDir(agentHome), `${snapshotBaseName(projectDir)}.json`);
|
||||
}
|
||||
|
||||
async function writeJson(file: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function readJson<T>(file: string): Promise<T | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the legacy global endpoint file. */
|
||||
export async function writeEndpoint(
|
||||
endpoint: AgentEndpoint,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string> {
|
||||
const file = endpointPath(agentHome);
|
||||
await writeJson(file, endpoint);
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Writes one project's endpoint file (does not touch the global pointer). */
|
||||
export async function writeEndpointForProject(
|
||||
projectDir: string,
|
||||
endpoint: AgentEndpoint,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string> {
|
||||
const file = endpointPathForProject(projectDir, agentHome);
|
||||
await writeJson(file, { ...endpoint, projectDir: resolve(projectDir) });
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Reads the legacy global endpoint file. */
|
||||
export async function readEndpoint(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentEndpoint | null> {
|
||||
const parsed = await readJson<AgentEndpoint>(endpointPath(agentHome));
|
||||
if (!parsed?.url || !parsed.token) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one project's endpoint. Returns null when the file is missing or the
|
||||
* recorded project does not match the requested one (defence in depth: the
|
||||
* file name already encodes the project, but a stale/hand-edited file must
|
||||
* never be able to redirect a client to another project).
|
||||
*/
|
||||
export async function readEndpointForProject(
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentEndpoint | null> {
|
||||
const parsed = await readJson<AgentEndpoint>(
|
||||
endpointPathForProject(projectDir, agentHome),
|
||||
);
|
||||
if (!parsed?.url || !parsed.token) return null;
|
||||
if (parsed.projectDir && !sameProject(parsed.projectDir, projectDir)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Removes the legacy global endpoint file. */
|
||||
export async function clearEndpoint(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
await rm(endpointPath(agentHome), { force: true });
|
||||
}
|
||||
|
||||
/** Removes one project's endpoint file. */
|
||||
export async function clearEndpointForProject(
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
await rm(endpointPathForProject(projectDir, agentHome), { force: true });
|
||||
}
|
||||
|
||||
/** True when both paths resolve to the same project root. */
|
||||
export function sameProject(a: string, b: string): boolean {
|
||||
return resolve(a).toLowerCase() === resolve(b).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a recorded extension-host PID is still running.
|
||||
*
|
||||
* `process.kill(pid, 0)` only probes existence: EPERM means the process
|
||||
* exists but we may not signal it, which still counts as alive. Unknown PIDs
|
||||
* are treated as alive so that a missing/older endpoint file does not disable
|
||||
* the live path.
|
||||
*/
|
||||
export function isProcessAlive(pid: number | undefined | null): boolean {
|
||||
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return true;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return (err as NodeJS.ErrnoException).code === "EPERM";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* Bounded, provenance-carrying forward-reference (outgoing edge) queries.
|
||||
*
|
||||
* The reverse index answers "who references this asset". This module answers
|
||||
* the opposite, which the index does not store: "which assets does this asset
|
||||
* reference, and through which XML element/attribute?".
|
||||
*
|
||||
* Design decisions (see docs/ai-agent-integration-plan.md §9):
|
||||
*
|
||||
* - It returns **edges**, not a flat node set. Each edge carries the element
|
||||
* name, parent element name and attribute that produced it, plus the exact
|
||||
* file/line of the XML text. A node list without that context forces the
|
||||
* agent back into reading source, which is exactly what this should avoid.
|
||||
* - It is **bounded three ways**: `depth` (1 default, 3 max), `targetTypes`
|
||||
* (assignability filter) and `maxEdges`. Truncation is reported together
|
||||
* with a per-type summary of what was dropped, so the caller can narrow the
|
||||
* query instead of silently receiving a partial answer.
|
||||
* - `inheritFrom` does **not** consume depth. Inheritance is part of an
|
||||
* asset's own effective definition, so the ancestor's XML is walked at the
|
||||
* same level and tagged with `definedIn`. This is what makes "AthenaCannon
|
||||
* has no WeaponSetUpdate, but BaseCannon does" answerable in one call.
|
||||
* - It is DOM-based and therefore **live-only**: resolving element context
|
||||
* needs the parse tree, and storing element names on all ~98k reference
|
||||
* records would measurably inflate the persistent cache.
|
||||
* - It deliberately does **not** compute merged/inherited effective values:
|
||||
* `xai:joinAction` Replace/Remove semantics are too easy to get wrong.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import type { AssetDef, ModIndex } from "../indexer/types";
|
||||
import { localName } from "../indexer/xpointer";
|
||||
import {
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
resolveContentReferenceTargets,
|
||||
resolveReferenceTargetsForType,
|
||||
} from "../indexer/refs";
|
||||
import {
|
||||
LineMap,
|
||||
parseXml,
|
||||
type XmlDocument,
|
||||
type XmlElement,
|
||||
} from "../language/xmlParser";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import { isAssignableTo } from "../model/schemaModel";
|
||||
|
||||
export interface ForwardRefVia {
|
||||
kind: "attribute" | "content" | "inheritFrom";
|
||||
/** Element carrying the reference (e.g. `Weapon`). */
|
||||
element: string;
|
||||
/** Parent element giving the element its context (e.g. `WeaponSlotHardpoint`). */
|
||||
parent: string | null;
|
||||
/** Attribute name for `attribute` kind; null for `content`/`inheritFrom`. */
|
||||
attribute: string | null;
|
||||
}
|
||||
|
||||
/** A resolved definition location. */
|
||||
export interface ForwardRefTarget {
|
||||
type: string;
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ForwardRefEdge {
|
||||
/** 1-based hop number from the queried asset. */
|
||||
depth: number;
|
||||
/** The asset the edge is attributed to (the queried asset for inherited XML). */
|
||||
from: { type: string; id: string };
|
||||
/** Resolved definition, or null for an unresolved reference. */
|
||||
to: ForwardRefTarget | null;
|
||||
via: ForwardRefVia;
|
||||
/**
|
||||
* Present only when the XML text lives in an `inheritFrom` ancestor rather
|
||||
* than in `from` itself.
|
||||
*/
|
||||
definedIn?: { type: string; id: string };
|
||||
/** Exact source position of the reference value. */
|
||||
source: { file: string; line: number; character: number };
|
||||
/** Raw value, present for unresolved references and `inheritFrom` edges. */
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ForwardRefNode extends ForwardRefTarget {
|
||||
/** Shallowest depth at which this node was reached. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface ForwardRefOptions {
|
||||
/** Levels of assets to expand. 1 = only the queried asset (default). Max 3. */
|
||||
depth?: number;
|
||||
/** Only keep edges/nodes whose target is assignable to one of these types. */
|
||||
targetTypes?: string[];
|
||||
/** Hard cap on returned edges (default 200). */
|
||||
maxEdges?: number;
|
||||
/** Include edges whose reference value could not be resolved. */
|
||||
includeUnresolved?: boolean;
|
||||
/** Walk `inheritFrom` ancestors (default true). */
|
||||
includeInheritance?: boolean;
|
||||
}
|
||||
|
||||
export interface ForwardRefResult {
|
||||
roots: AssetDef[];
|
||||
edges: ForwardRefEdge[];
|
||||
nodes: ForwardRefNode[];
|
||||
truncated: boolean;
|
||||
/** Target types dropped because `maxEdges` was reached. */
|
||||
omittedByTargetType: Record<string, number>;
|
||||
/** Non-fatal warnings (missing files, missing definitions, capped chains). */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** A parsed XML file plus the raw text, as needed by the DOM walk. */
|
||||
export interface LoadedXmlFile {
|
||||
parse: XmlDocument;
|
||||
lineMap: LineMap;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type XmlFileLoader = (file: string) => Promise<LoadedXmlFile | null>;
|
||||
|
||||
const DEFAULT_MAX_EDGES = 200;
|
||||
const MAX_DEPTH_LIMIT = 3;
|
||||
/**
|
||||
* Inheritance is walked at the same depth, so the chain length is capped
|
||||
* separately to keep pathological hierarchies from expanding without bound.
|
||||
*/
|
||||
const MAX_INHERIT_CHAIN = 8;
|
||||
|
||||
/** Parses a file's text into the shape this module needs. */
|
||||
export function parseLoadedXml(text: string): LoadedXmlFile {
|
||||
return { parse: parseXml(text), lineMap: new LineMap(text), text };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the definitions to start from, then walks their outgoing
|
||||
* references. Returns an empty result (with a warning) when nothing matches.
|
||||
*/
|
||||
export async function collectAssetReferences(
|
||||
index: ModIndex,
|
||||
id: string,
|
||||
type: string | null,
|
||||
loadFile: XmlFileLoader,
|
||||
options: ForwardRefOptions = {},
|
||||
): Promise<ForwardRefResult> {
|
||||
const wanted = id.toLowerCase();
|
||||
const wantedType = type?.toLowerCase() ?? null;
|
||||
const roots = (index.assetsById.get(wanted) ?? []).filter(
|
||||
(d) => !wantedType || d.type.toLowerCase() === wantedType,
|
||||
);
|
||||
if (!roots.length) {
|
||||
return {
|
||||
roots: [],
|
||||
edges: [],
|
||||
nodes: [],
|
||||
truncated: false,
|
||||
omittedByTargetType: {},
|
||||
warnings: [
|
||||
`No definition found for id "${id}"${type ? ` of type "${type}"` : ""}.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
const result = await collectForwardReferences(index, roots, loadFile, options);
|
||||
return { ...result, roots };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the outgoing references of `roots` and returns bounded edges.
|
||||
*/
|
||||
export async function collectForwardReferences(
|
||||
index: ModIndex,
|
||||
roots: readonly AssetDef[],
|
||||
loadFile: XmlFileLoader,
|
||||
options: ForwardRefOptions = {},
|
||||
): Promise<ForwardRefResult> {
|
||||
const depthLimit = clampDepth(options.depth);
|
||||
const maxEdges = Math.max(1, Math.floor(options.maxEdges ?? DEFAULT_MAX_EDGES));
|
||||
const targetTypes = (options.targetTypes ?? []).filter(Boolean);
|
||||
const includeUnresolved = options.includeUnresolved ?? false;
|
||||
const includeInheritance = options.includeInheritance ?? true;
|
||||
|
||||
const edges: ForwardRefEdge[] = [];
|
||||
const nodes = new Map<string, ForwardRefNode>();
|
||||
const omitted = new Map<string, number>();
|
||||
const warnings: string[] = [];
|
||||
let truncated = false;
|
||||
let edgeCount = 0;
|
||||
|
||||
const fileCache = new Map<string, LoadedXmlFile | null>();
|
||||
const load = async (file: string): Promise<LoadedXmlFile | null> => {
|
||||
const key = file.toLowerCase();
|
||||
if (!fileCache.has(key)) {
|
||||
try {
|
||||
fileCache.set(key, await loadFile(file));
|
||||
} catch {
|
||||
fileCache.set(key, null);
|
||||
}
|
||||
}
|
||||
return fileCache.get(key) ?? null;
|
||||
};
|
||||
|
||||
interface WorkItem {
|
||||
/** Asset whose XML is walked. */
|
||||
def: AssetDef;
|
||||
/** 0-based expansion level. */
|
||||
level: number;
|
||||
/** Asset the edges are attributed to (the queried asset). */
|
||||
effective: { type: string; id: string };
|
||||
/** Chain from `effective` (exclusive) down to `def` (inclusive). */
|
||||
trail: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
const queue: WorkItem[] = roots.map((def) => ({
|
||||
def,
|
||||
level: 0,
|
||||
effective: { type: def.type, id: def.id },
|
||||
trail: [],
|
||||
}));
|
||||
const visited = new Set<string>();
|
||||
|
||||
const matchesTargetType = (typeName: string): boolean =>
|
||||
targetTypes.length === 0 ||
|
||||
targetTypes.some((wanted) => isAssignableTo(typeName, wanted));
|
||||
|
||||
const addEdge = (
|
||||
level: number,
|
||||
effective: WorkItem["effective"],
|
||||
trail: WorkItem["trail"],
|
||||
via: ForwardRefVia,
|
||||
target: ForwardRefTarget | null,
|
||||
source: ForwardRefEdge["source"],
|
||||
value?: string,
|
||||
): void => {
|
||||
// `targetTypes` drops edges the caller did not ask for. `inheritFrom`
|
||||
// edges always survive: they explain where the remaining edges came from
|
||||
// (an inherited weapon lives in the ancestor's file, and editing the
|
||||
// derived asset would be wrong). This filter is applied before the edge
|
||||
// cap so `omittedByTargetType` only reports cap-driven truncation.
|
||||
if (target && via.kind !== "inheritFrom" && !matchesTargetType(target.type)) {
|
||||
return;
|
||||
}
|
||||
if (edgeCount >= maxEdges) {
|
||||
truncated = true;
|
||||
const bucket = target?.type ?? "#unresolved";
|
||||
omitted.set(bucket, (omitted.get(bucket) ?? 0) + 1);
|
||||
return;
|
||||
}
|
||||
edgeCount++;
|
||||
const edge: ForwardRefEdge = {
|
||||
depth: level + 1,
|
||||
from: { type: effective.type, id: effective.id },
|
||||
to: target,
|
||||
via,
|
||||
source,
|
||||
};
|
||||
if (trail.length) {
|
||||
const owner = trail[trail.length - 1];
|
||||
edge.definedIn = { type: owner.type, id: owner.id };
|
||||
}
|
||||
if (value !== undefined) edge.value = value;
|
||||
edges.push(edge);
|
||||
|
||||
if (target) {
|
||||
const key = nodeKey(target);
|
||||
const existing = nodes.get(key);
|
||||
if (!existing || level + 1 < existing.depth) {
|
||||
nodes.set(key, { ...target, depth: level + 1 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
while (queue.length) {
|
||||
const item = queue.shift()!;
|
||||
const { def, level, effective, trail } = item;
|
||||
const visitKey = [
|
||||
effective.type,
|
||||
effective.id.toLowerCase(),
|
||||
def.type,
|
||||
def.id.toLowerCase(),
|
||||
def.file.toLowerCase(),
|
||||
def.line,
|
||||
].join("\u0000");
|
||||
if (visited.has(visitKey)) continue;
|
||||
visited.add(visitKey);
|
||||
|
||||
const loaded = await load(def.file);
|
||||
if (!loaded) {
|
||||
warnings.push(`Could not read ${def.file} (definition of ${def.type}:${def.id}).`);
|
||||
continue;
|
||||
}
|
||||
const ownerEl = findDefinitionElement(loaded, def);
|
||||
if (!ownerEl) {
|
||||
warnings.push(
|
||||
`Could not locate <${def.type} id="${def.id}"> inside ${def.file}; the index line may be stale.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceAt = (offset: number): ForwardRefEdge["source"] => {
|
||||
const pos = loaded.lineMap.positionAt(offset);
|
||||
return { file: def.file, line: pos.line + 1, character: pos.character };
|
||||
};
|
||||
|
||||
for (const el of subtreeInDocumentOrder(ownerEl)) {
|
||||
const elType = resolveElementType(el);
|
||||
const elLocal = localName(el.name);
|
||||
const parentLocal = el.parent ? localName(el.parent.name) : null;
|
||||
|
||||
for (const attr of el.attrs) {
|
||||
if (!attr.hasValue) continue;
|
||||
const nameLower = attr.name.toLowerCase();
|
||||
// inheritFrom belongs to the asset element itself and is handled
|
||||
// separately below so it can also expand the ancestor chain.
|
||||
if (nameLower === "inheritfrom") continue;
|
||||
const value = attr.value;
|
||||
if (!value || value.startsWith("$") || value.startsWith("=")) continue;
|
||||
if (!isReferenceAttributeOfType(elType, attr.name)) continue;
|
||||
|
||||
const via: ForwardRefVia = {
|
||||
kind: "attribute",
|
||||
element: elLocal,
|
||||
parent: parentLocal,
|
||||
attribute: attr.name,
|
||||
};
|
||||
const source = sourceAt(attr.valueStart);
|
||||
const targets = resolveReferenceTargetsForType(index, elType, attr.name, value);
|
||||
if (!targets.length) {
|
||||
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
|
||||
continue;
|
||||
}
|
||||
for (const target of targets) {
|
||||
const to = targetOf(target.def);
|
||||
addEdge(level, effective, trail, via, to, source);
|
||||
if (level + 1 < depthLimit) {
|
||||
queue.push({
|
||||
def: target.def,
|
||||
level: level + 1,
|
||||
effective: { type: target.def.type, id: target.def.id },
|
||||
trail: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple-content references (e.g. <CreateObject>temp_id</CreateObject>).
|
||||
if (elType && isReferenceContentType(elType) && !el.selfClosing && el.closeTagStart >= 0) {
|
||||
const raw = loaded.text.slice(el.startTagEnd, el.closeTagStart);
|
||||
const value = raw.trim();
|
||||
if (
|
||||
!value ||
|
||||
value.startsWith("$") ||
|
||||
value.startsWith("=") ||
|
||||
value.includes("<")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const start = el.startTagEnd + raw.indexOf(value);
|
||||
const via: ForwardRefVia = {
|
||||
kind: "content",
|
||||
element: elLocal,
|
||||
parent: parentLocal,
|
||||
attribute: null,
|
||||
};
|
||||
const source = sourceAt(start);
|
||||
const targets = resolveContentReferenceTargets(index, elType, value);
|
||||
if (!targets.length) {
|
||||
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
|
||||
continue;
|
||||
}
|
||||
for (const target of targets) {
|
||||
const to = targetOf(target.def);
|
||||
addEdge(level, effective, trail, via, to, source);
|
||||
if (level + 1 < depthLimit) {
|
||||
queue.push({
|
||||
def: target.def,
|
||||
level: level + 1,
|
||||
effective: { type: target.def.type, id: target.def.id },
|
||||
trail: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inheritFrom on the definition element: walk the ancestor at the SAME
|
||||
// level so its XML contributes to the queried asset's effective definition.
|
||||
if (includeInheritance) {
|
||||
const inheritAttr = ownerEl.attrs.find(
|
||||
(a) => a.name.toLowerCase() === "inheritfrom",
|
||||
);
|
||||
const value = inheritAttr?.value;
|
||||
if (inheritAttr?.hasValue && value && !value.startsWith("$") && !value.startsWith("=")) {
|
||||
const via: ForwardRefVia = {
|
||||
kind: "inheritFrom",
|
||||
element: localName(ownerEl.name),
|
||||
parent: ownerEl.parent ? localName(ownerEl.parent.name) : null,
|
||||
attribute: inheritAttr.name,
|
||||
};
|
||||
const source = sourceAt(inheritAttr.valueStart);
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
index,
|
||||
def.type,
|
||||
"inheritFrom",
|
||||
value,
|
||||
);
|
||||
if (!targets.length) {
|
||||
if (includeUnresolved) addEdge(level, effective, trail, via, null, source, value);
|
||||
} else {
|
||||
for (const target of targets) {
|
||||
const to = targetOf(target.def);
|
||||
addEdge(level, effective, trail, via, to, source, value);
|
||||
// The trail records the asset whose XML we are about to walk, so
|
||||
// edges found there report the correct `definedIn`.
|
||||
const nextTrail = [
|
||||
...trail,
|
||||
{ type: target.def.type, id: target.def.id },
|
||||
];
|
||||
if (nextTrail.length < MAX_INHERIT_CHAIN) {
|
||||
queue.push({ def: target.def, level, effective, trail: nextTrail });
|
||||
} else {
|
||||
warnings.push(
|
||||
`Inheritance chain for ${effective.type}:${effective.id} exceeded ${MAX_INHERIT_CHAIN} levels; deeper ancestors were not walked.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roots: [...roots],
|
||||
edges,
|
||||
nodes: [...nodes.values()].sort(
|
||||
(a, b) => a.depth - b.depth || a.type.localeCompare(b.type) || a.id.localeCompare(b.id),
|
||||
),
|
||||
truncated,
|
||||
omittedByTargetType: Object.fromEntries(omitted),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function clampDepth(depth: number | undefined): number {
|
||||
if (depth == null || !Number.isFinite(depth)) return 1;
|
||||
return Math.max(1, Math.min(MAX_DEPTH_LIMIT, Math.floor(depth)));
|
||||
}
|
||||
|
||||
function targetOf(def: AssetDef): ForwardRefTarget {
|
||||
return { type: def.type, id: def.id, file: def.file, line: def.line };
|
||||
}
|
||||
|
||||
function nodeKey(target: ForwardRefTarget): string {
|
||||
return `${target.type}\u0000${target.id.toLowerCase()}\u0000${target.file.toLowerCase()}\u0000${target.line}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the element defining `def` inside a parsed file.
|
||||
*
|
||||
* The index stores a line number for the `id` attribute, so an exact line
|
||||
* match is the strongest signal; top-level placement is the next best. This
|
||||
* matters when a nested element reuses the same id.
|
||||
*/
|
||||
export function findDefinitionElement(
|
||||
loaded: LoadedXmlFile,
|
||||
def: Pick<AssetDef, "id" | "line">,
|
||||
): XmlElement | null {
|
||||
const wanted = def.id.toLowerCase();
|
||||
const root = loaded.parse.root;
|
||||
let best: XmlElement | null = null;
|
||||
let bestScore = -1;
|
||||
for (const el of loaded.parse.elements) {
|
||||
const idAttr = el.attrs.find((a) => a.name.toLowerCase() === "id");
|
||||
if (!idAttr?.hasValue) continue;
|
||||
if (idAttr.value.toLowerCase() !== wanted) continue;
|
||||
let score = 0;
|
||||
const isTopLevel = el.parent === root || (root == null && el.parent == null);
|
||||
if (isTopLevel) score += 2;
|
||||
if (def.line > 0) {
|
||||
const line = loaded.lineMap.positionAt(idAttr.valueStart).line + 1;
|
||||
if (line === def.line) score += 4;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = el;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Depth-first, document-order element list for a subtree (no recursion). */
|
||||
export function subtreeInDocumentOrder(root: XmlElement): XmlElement[] {
|
||||
const out: XmlElement[] = [];
|
||||
const stack: XmlElement[] = [root];
|
||||
while (stack.length) {
|
||||
const el = stack.pop()!;
|
||||
out.push(el);
|
||||
for (let i = el.children.length - 1; i >= 0; i--) {
|
||||
stack.push(el.children[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Live-instance registry.
|
||||
*
|
||||
* Each VS Code window that enables AI Agent access writes **its own** file
|
||||
* under `instances/`. Two properties follow from that:
|
||||
*
|
||||
* - **No locking and no merging.** Writers never touch each other's files, so
|
||||
* there is no read-modify-write race to guard. An earlier design that
|
||||
* shared a single JSON file would have needed a lock file (itself another
|
||||
* thing a crash can leave behind) plus a merge step.
|
||||
* - **Crash recovery does not wait for the same workspace.** Any instance can
|
||||
* prune entries whose recorded PID is dead, so a crashed window is cleaned
|
||||
* up the next time *any* VS Code window with the extension activates.
|
||||
*
|
||||
* A merged, read-only `index.json` is derived from the instance files purely
|
||||
* for discovery (agents/humans looking at the directory), so the machine
|
||||
* coordination files and the human-readable view stay separate.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { clearEndpoint, isProcessAlive, writeEndpoint, type AgentEndpoint } from "./endpoint";
|
||||
import { defaultAgentHome, snapshotBaseName } from "./snapshot";
|
||||
|
||||
/** One live extension-host instance. */
|
||||
export interface AgentInstance extends AgentEndpoint {
|
||||
/** Unique per window; also the file name stem. */
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
/** Discovery manifest derived from all live instances. */
|
||||
export interface AgentIndexManifest {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
instances: Array<{
|
||||
instanceId: string;
|
||||
processId?: number;
|
||||
url: string;
|
||||
/** Tokens are intentionally omitted: the manifest is for discovery. */
|
||||
projects: string[];
|
||||
}>;
|
||||
projects: string[];
|
||||
}
|
||||
|
||||
export const INSTANCE_SCHEMA_VERSION = 1;
|
||||
|
||||
/** Directory holding one file per live extension host. */
|
||||
export function instancesDir(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "instances");
|
||||
}
|
||||
|
||||
/** Path to the merged discovery manifest. */
|
||||
export function manifestPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "index.json");
|
||||
}
|
||||
|
||||
/** File name for one instance. */
|
||||
export function instanceFileName(instanceId: string): string {
|
||||
return `vscode-${instanceId}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a process-unique instance id. Combining the PID with a random suffix
|
||||
* keeps two windows of the same process id from colliding across restarts.
|
||||
*/
|
||||
export function makeInstanceId(pid = process.pid): string {
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `${pid}-${rand}`;
|
||||
}
|
||||
|
||||
/** Writes this instance's own file. */
|
||||
export async function writeInstance(
|
||||
instance: AgentInstance,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string> {
|
||||
const file = join(instancesDir(agentHome), instanceFileName(instance.instanceId));
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(instance, null, 2)}\n`, "utf8");
|
||||
return file;
|
||||
}
|
||||
|
||||
/** Reads every instance file, skipping malformed ones. */
|
||||
export async function readInstances(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentInstance[]> {
|
||||
const dir = instancesDir(agentHome);
|
||||
let names: string[];
|
||||
try {
|
||||
names = await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: AgentInstance[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith(".json")) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(join(dir, name), "utf8"),
|
||||
) as AgentInstance;
|
||||
if (parsed?.url && parsed?.token) {
|
||||
// Fall back to the file name when an older file lacks the field.
|
||||
parsed.instanceId ??= name.replace(/^vscode-/, "").replace(/\.json$/, "");
|
||||
out.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Skip unreadable/corrupt entries.
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Removes this instance's own file. */
|
||||
export async function clearInstance(
|
||||
instanceId: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
await rm(join(instancesDir(agentHome), instanceFileName(instanceId)), {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
export interface PruneResult {
|
||||
removed: string[];
|
||||
kept: AgentInstance[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes instance files whose recorded PID is no longer alive.
|
||||
*
|
||||
* This is how crashes are cleaned up without waiting for the same workspace to
|
||||
* be reopened. Only clearly-dead PIDs are pruned: `isProcessAlive` treats an
|
||||
* unknown or unparseable PID as alive, so an older file that predates the
|
||||
* `processId` field is never deleted by mistake.
|
||||
*/
|
||||
export async function pruneInstances(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<PruneResult> {
|
||||
const instances = await readInstances(agentHome);
|
||||
const removed: string[] = [];
|
||||
const kept: AgentInstance[] = [];
|
||||
for (const instance of instances) {
|
||||
if (isProcessAlive(instance.processId)) {
|
||||
kept.push(instance);
|
||||
} else {
|
||||
removed.push(instance.instanceId);
|
||||
await clearInstance(instance.instanceId, agentHome).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return { removed, kept };
|
||||
}
|
||||
|
||||
/** Collects every project root across live instances. */
|
||||
export function projectsOf(instances: readonly AgentInstance[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const instance of instances) {
|
||||
const candidates = [
|
||||
...(instance.projects ?? []),
|
||||
...(instance.projectDir ? [instance.projectDir] : []),
|
||||
];
|
||||
for (const project of candidates) {
|
||||
const key = project.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(project);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates the merged read-only manifest from the live instances.
|
||||
* Best-effort: failures are swallowed because the manifest is a convenience,
|
||||
* not a correctness requirement (readers can always scan `instances/`).
|
||||
*/
|
||||
export async function writeManifest(
|
||||
instances: readonly AgentInstance[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentIndexManifest> {
|
||||
const manifest: AgentIndexManifest = {
|
||||
schemaVersion: INSTANCE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
instances: instances.map((instance) => ({
|
||||
instanceId: instance.instanceId,
|
||||
processId: instance.processId,
|
||||
url: instance.url,
|
||||
projects: [
|
||||
...(instance.projects ?? []),
|
||||
...(instance.projectDir ? [instance.projectDir] : []),
|
||||
],
|
||||
})),
|
||||
projects: projectsOf(instances),
|
||||
};
|
||||
try {
|
||||
const file = manifestPath(agentHome);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
||||
} catch {
|
||||
// Discovery only: never fail the caller over this.
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/** Reads the discovery manifest, or null when absent/malformed. */
|
||||
export async function readManifest(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentIndexManifest | null> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(manifestPath(agentHome), "utf8"),
|
||||
) as AgentIndexManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derives the legacy global endpoint pointer and the merged discovery
|
||||
* manifest from the instance files that still exist and whose recorded
|
||||
* process is alive.
|
||||
*
|
||||
* Called after an instance file is removed (window closed, or pruned as
|
||||
* crashed). Closing one window must never make another still-running window
|
||||
* undiscoverable: whichever writer changed the instance set has to rebuild
|
||||
* both derived files from the survivors instead of clearing them.
|
||||
*
|
||||
* Best-effort: the pointer/manifest are discovery conveniences, and readers
|
||||
* can always scan `instances/`, so failures never propagate to the caller.
|
||||
*/
|
||||
export async function refreshDiscovery(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<AgentIndexManifest> {
|
||||
const { kept } = await pruneInstances(agentHome);
|
||||
const first = kept[0];
|
||||
try {
|
||||
if (first) {
|
||||
await writeEndpoint(endpointOf(first), agentHome);
|
||||
} else {
|
||||
await clearEndpoint(agentHome);
|
||||
}
|
||||
} catch {
|
||||
// Discovery pointer only; the manifest below is still worth writing.
|
||||
}
|
||||
return writeManifest(kept, agentHome);
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint fields of an instance file, without the registry-only
|
||||
* `instanceId` (endpoint.json predates the instance registry and readers do
|
||||
* not expect that field).
|
||||
*/
|
||||
function endpointOf(instance: AgentInstance): AgentEndpoint {
|
||||
return {
|
||||
url: instance.url,
|
||||
token: instance.token,
|
||||
projectDir: instance.projectDir,
|
||||
projects: instance.projects,
|
||||
processId: instance.processId,
|
||||
updatedAt: instance.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Snapshot path for a project, re-exported for discovery convenience. */
|
||||
export function projectKey(projectDir: string): string {
|
||||
return snapshotBaseName(projectDir);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Shared live-index client used by both the MCP server and the CLI.
|
||||
*
|
||||
* Keeping this in one place matters because the forwarding path carries safety
|
||||
* logic that must not drift between entry points:
|
||||
*
|
||||
* - the requested project is always pinned on the request, so the server
|
||||
* cannot answer from whichever project its active editor points at;
|
||||
* - a response reporting a different `projectDir` is refused rather than
|
||||
* shown, because a plausible wrong answer is worse than no answer;
|
||||
* - a dead extension-host PID marks the instance file stale;
|
||||
* - failed attempts are negatively cached so every tool call does not pay a
|
||||
* connection timeout.
|
||||
*
|
||||
* Transport is currently loopback HTTP. Swapping it for a named pipe / Unix
|
||||
* domain socket only requires changing this file.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import {
|
||||
isProcessAlive,
|
||||
readEndpoint,
|
||||
readEndpointForProject,
|
||||
sameProject,
|
||||
type AgentEndpoint,
|
||||
} from "./endpoint";
|
||||
import { readInstances } from "./instances";
|
||||
|
||||
/** Cooldown after a failed live attempt, to avoid a probe per tool call. */
|
||||
const LIVE_RETRY_COOLDOWN_MS = 5000;
|
||||
/** Per-request timeout for a live query. */
|
||||
const LIVE_TIMEOUT_MS = 1500;
|
||||
|
||||
export interface LiveQueryResult {
|
||||
payload: unknown;
|
||||
/** True when the live server answered but for a different project. */
|
||||
mismatched: boolean;
|
||||
/** Which endpoint produced the answer (for diagnostics). */
|
||||
endpoint: AgentEndpoint;
|
||||
}
|
||||
|
||||
/** Normalizes a path for case/separator-insensitive comparison. */
|
||||
export function normalizePath(p: string): string {
|
||||
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live response that belongs to a different project than the one
|
||||
* the client was started for. See docs/ai-agent-integration-plan.md §10 for
|
||||
* why this guard exists: in a multi-window setup a client configured for
|
||||
* project A could otherwise silently receive project B's index.
|
||||
*/
|
||||
export function responseProjectMismatch(
|
||||
payload: unknown,
|
||||
projectDir: string | null,
|
||||
): boolean {
|
||||
if (!projectDir) return false;
|
||||
const index = (payload as { index?: { projectDir?: string } } | null)?.index;
|
||||
const reported = index?.projectDir;
|
||||
if (!reported) return false;
|
||||
return normalizePath(reported) !== normalizePath(projectDir);
|
||||
}
|
||||
|
||||
/** Tools that can be answered by the live index and their HTTP paths. */
|
||||
const TOOL_PATHS: Record<string, string> = {
|
||||
get_status: "/status",
|
||||
find_asset: "/find_asset",
|
||||
find_references: "/find_references",
|
||||
get_asset_references: "/get_asset_references",
|
||||
list_assets_by_type: "/list_assets",
|
||||
is_file_active: "/is_file_active",
|
||||
find_define: "/find_define",
|
||||
resolve_include: "/resolve_include",
|
||||
list_projects: "/projects",
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the live request URL for a tool call, always pinning the project.
|
||||
* Returns null for tools the live server does not serve.
|
||||
*/
|
||||
export function liveUrlForTool(
|
||||
endpointUrl: string,
|
||||
projectDir: string | null,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
): string | null {
|
||||
const path = TOOL_PATHS[toolName];
|
||||
if (!path) return null;
|
||||
const base = endpointUrl.replace(/\/$/, "");
|
||||
const q = new URLSearchParams();
|
||||
// Always pin the requested project. Without this the server would silently
|
||||
// answer from whatever project its active editor points at.
|
||||
if (projectDir) q.set("project", projectDir);
|
||||
switch (toolName) {
|
||||
case "get_status":
|
||||
case "list_projects":
|
||||
break;
|
||||
case "find_asset":
|
||||
case "find_references":
|
||||
case "get_asset_references":
|
||||
q.set("id", String(args.id ?? ""));
|
||||
if (args.type != null) q.set("type", String(args.type));
|
||||
if (toolName === "get_asset_references") {
|
||||
if (args.depth != null) q.set("depth", String(args.depth));
|
||||
if (Array.isArray(args.targetTypes)) {
|
||||
q.set("targetTypes", (args.targetTypes as unknown[]).map(String).join(","));
|
||||
}
|
||||
if (args.maxEdges != null) q.set("maxEdges", String(args.maxEdges));
|
||||
if (args.includeUnresolved != null) {
|
||||
q.set("includeUnresolved", String(args.includeUnresolved));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "list_assets_by_type":
|
||||
q.set("type", String(args.type ?? ""));
|
||||
if (args.prefix != null) q.set("prefix", String(args.prefix));
|
||||
if (args.limit != null) q.set("limit", String(args.limit));
|
||||
break;
|
||||
case "is_file_active":
|
||||
q.set("path", String(args.path ?? ""));
|
||||
break;
|
||||
case "find_define":
|
||||
q.set("name", String(args.name ?? ""));
|
||||
break;
|
||||
case "resolve_include":
|
||||
q.set("source", String(args.source ?? ""));
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const query = q.toString();
|
||||
return query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
export interface LiveClientOptions {
|
||||
/** Project the client was started for (pins every request). */
|
||||
projectDir?: string | null;
|
||||
/** Override the agent home directory (used by tests). */
|
||||
agentHome?: string;
|
||||
/** Clock injection for deterministic negative-cache tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a usable endpoint for this client's project.
|
||||
*
|
||||
* Order: per-project file, then any live instance whose `projects` list
|
||||
* contains this project, then the legacy global pointer (only when its
|
||||
* recorded project matches). Endpoints with a dead PID are skipped.
|
||||
*/
|
||||
export async function findEndpoint(
|
||||
options: LiveClientOptions = {},
|
||||
): Promise<AgentEndpoint | null> {
|
||||
const projectDir = options.projectDir ?? null;
|
||||
const agentHome = options.agentHome;
|
||||
|
||||
if (projectDir) {
|
||||
const direct = await readEndpointForProject(projectDir, agentHome);
|
||||
if (direct && isProcessAlive(direct.processId)) return direct;
|
||||
}
|
||||
|
||||
// Scan live instances: this is what makes a freshly started window work
|
||||
// even if its per-project file has not been written yet.
|
||||
try {
|
||||
const instances = await readInstances(agentHome);
|
||||
for (const instance of instances) {
|
||||
if (!isProcessAlive(instance.processId)) continue;
|
||||
if (projectDir && instance.projectDir && !sameProject(instance.projectDir, projectDir)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
projectDir &&
|
||||
instance.projects?.length &&
|
||||
!instance.projects.some((p) => sameProject(p, projectDir))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
} catch {
|
||||
// Instance scanning is best-effort.
|
||||
}
|
||||
|
||||
const fallback = await readEndpoint(agentHome);
|
||||
if (fallback && isProcessAlive(fallback.processId)) {
|
||||
if (
|
||||
!projectDir ||
|
||||
!fallback.projectDir ||
|
||||
sameProject(fallback.projectDir, projectDir)
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the live extension server. Returns null when no live index is
|
||||
* reachable, so callers can fall back to the on-disk snapshot.
|
||||
*/
|
||||
export class LiveClient {
|
||||
private unavailableUntil = 0;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(private readonly options: LiveClientOptions = {}) {
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
/** True while the negative cache is suppressing attempts. */
|
||||
get suppressed(): boolean {
|
||||
return this.now() < this.unavailableUntil;
|
||||
}
|
||||
|
||||
/** Clears the negative cache (e.g. after the user re-enables access). */
|
||||
reset(): void {
|
||||
this.unavailableUntil = 0;
|
||||
}
|
||||
|
||||
/** Marks the live path unavailable for the cooldown period. */
|
||||
markUnavailable(): void {
|
||||
this.unavailableUntil = this.now() + LIVE_RETRY_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
async query(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<LiveQueryResult | null> {
|
||||
if (this.suppressed) return null;
|
||||
if (typeof fetch !== "function") return null;
|
||||
const projectDir = this.options.projectDir ?? null;
|
||||
const endpoint = await findEndpoint(this.options);
|
||||
if (!endpoint) return null;
|
||||
|
||||
const url = liveUrlForTool(endpoint.url, projectDir, toolName, args);
|
||||
if (!url) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LIVE_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${endpoint.token}` },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 401/404 mean the endpoint is stale rather than merely slow.
|
||||
if (res.status === 401 || res.status === 404) this.markUnavailable();
|
||||
return null;
|
||||
}
|
||||
const payload: unknown = await res.json();
|
||||
return {
|
||||
payload,
|
||||
mismatched: responseProjectMismatch(payload, projectDir),
|
||||
endpoint,
|
||||
};
|
||||
} catch {
|
||||
this.markUnavailable();
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience one-shot query using a fresh client. */
|
||||
export async function queryLive(
|
||||
toolName: string,
|
||||
args: Record<string, unknown> = {},
|
||||
options: LiveClientOptions = {},
|
||||
): Promise<LiveQueryResult | null> {
|
||||
return new LiveClient(options).query(toolName, args);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Query helpers that operate directly on the live in-memory ModIndex.
|
||||
*
|
||||
* These are useful for local HTTP/live services where converting the full
|
||||
* index to an external snapshot on every query would be wasteful.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import type { AssetDef, DefineDef, ModIndex, ReferenceSite } from "../indexer/types";
|
||||
import { statusFromIndex } from "./snapshot";
|
||||
import type { AgentIndexStatus, AgentReferenceGroup } from "./types";
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
return resolve(p).replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
/** Finds asset definitions by id in the live index. */
|
||||
export function findAssetsLive(
|
||||
index: ModIndex,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): AssetDef[] {
|
||||
const wanted = id.toLowerCase();
|
||||
const wantedType = type?.toLowerCase();
|
||||
const candidates = index.assetsById.get(wanted) ?? [];
|
||||
if (!wantedType) return candidates;
|
||||
return candidates.filter((a) => a.type.toLowerCase() === wantedType);
|
||||
}
|
||||
|
||||
/** Lists assets of one type in the live index. */
|
||||
export function listAssetsByTypeLive(
|
||||
index: ModIndex,
|
||||
type: string,
|
||||
idPrefix = "",
|
||||
limit?: number,
|
||||
): AssetDef[] {
|
||||
const wantedType = type.toLowerCase();
|
||||
const wantedPrefix = idPrefix.toLowerCase();
|
||||
const byType = index.assets.get(type);
|
||||
if (!byType) return [];
|
||||
const out: AssetDef[] = [];
|
||||
for (const [id, defs] of byType) {
|
||||
if (!id.startsWith(wantedPrefix)) continue;
|
||||
for (const def of defs) {
|
||||
if (def.type.toLowerCase() !== wantedType) continue;
|
||||
out.push(def);
|
||||
if (limit != null && out.length >= limit) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the reverse reference map into groups for one asset id.
|
||||
* Reference map keys are `type\0id\0file\0line`.
|
||||
*/
|
||||
export function findReferenceGroupsLive(
|
||||
index: ModIndex,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): AgentReferenceGroup[] {
|
||||
const wanted = id.toLowerCase();
|
||||
const wantedType = type?.toLowerCase();
|
||||
const groups: AgentReferenceGroup[] = [];
|
||||
for (const [key, sites] of index.references) {
|
||||
const parts = key.split("\u0000");
|
||||
if (parts.length !== 4) continue;
|
||||
const [typeName, defId, file, lineText] = parts;
|
||||
if (defId.toLowerCase() !== wanted) continue;
|
||||
if (wantedType && typeName.toLowerCase() !== wantedType) continue;
|
||||
groups.push({
|
||||
type: typeName,
|
||||
id: defId,
|
||||
file,
|
||||
line: Number(lineText) || 0,
|
||||
sites,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Flattens live reference groups into sites. */
|
||||
export function findReferenceSitesLive(
|
||||
index: ModIndex,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): ReferenceSite[] {
|
||||
return findReferenceGroupsLive(index, id, type).flatMap((g) => g.sites);
|
||||
}
|
||||
|
||||
/** Returns streams containing a file in the live index. */
|
||||
export function streamsForFileLive(
|
||||
index: ModIndex,
|
||||
file: string,
|
||||
): ModIndex["streams"] {
|
||||
const key = normalizePath(file);
|
||||
return index.streams.filter((s) =>
|
||||
[...s.files].some((candidate) => normalizePath(candidate) === key),
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a file belongs to a live stream. */
|
||||
export function isFileActiveLive(index: ModIndex, file: string): boolean {
|
||||
return streamsForFileLive(index, file).length > 0;
|
||||
}
|
||||
|
||||
/** Finds defines by name in the live index. */
|
||||
export function findDefineLive(
|
||||
index: ModIndex,
|
||||
name: string,
|
||||
): DefineDef[] {
|
||||
const wanted = name.toLowerCase();
|
||||
return index.defines.get(wanted) ?? [];
|
||||
}
|
||||
|
||||
/** Resolves Include source using the live source-candidate list. */
|
||||
export function resolveIncludeLive(
|
||||
index: ModIndex,
|
||||
source: string,
|
||||
): { source: string; path: string } | null {
|
||||
const wanted = source.toLowerCase();
|
||||
const hit = index.sourceCandidates.find(
|
||||
(c) => c.source.toLowerCase() === wanted,
|
||||
);
|
||||
return hit ? { source: hit.source, path: hit.path } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a status object for the live index.
|
||||
*
|
||||
* `requestedProjectDir` is only echoed when no index is available, so callers
|
||||
* can still verify which server they reached. It is never used to fake a
|
||||
* `projectDir` when an index exists — the index is the source of truth.
|
||||
*/
|
||||
export function liveStatus(
|
||||
index: ModIndex | null | undefined,
|
||||
requestedProjectDir?: string,
|
||||
): AgentIndexStatus {
|
||||
return statusFromIndex(index, requestedProjectDir);
|
||||
}
|
||||
|
||||
/** Every project root this index belongs to (for `/projects` listing). */
|
||||
export function knownProjectDirs(index: ModIndex | null | undefined): string[] {
|
||||
return index ? [index.projectDir] : [];
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Local read-only HTTP server for live RA3 Mod XML index queries.
|
||||
*
|
||||
* The server runs inside the VS Code extension host when the user has enabled
|
||||
* AI Agent access. It listens only on 127.0.0.1 and requires a bearer token so
|
||||
* unrelated local processes cannot query it by accident.
|
||||
*
|
||||
* Requests may carry `?project=<dir>` to select which project's index answers
|
||||
* the query. When omitted, the server falls back to the active project. Every
|
||||
* response echoes `index.projectDir` so the caller can verify it reached the
|
||||
* server/project it asked for — see docs/ai-agent-integration-plan.md §10 for
|
||||
* why that check is required in multi-window setups.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { ModIndex } from "../indexer/types";
|
||||
import {
|
||||
collectAssetReferences,
|
||||
type ForwardRefOptions,
|
||||
type XmlFileLoader,
|
||||
} from "./forwardRefs";
|
||||
import {
|
||||
findAssetsLive,
|
||||
findDefineLive,
|
||||
findReferenceGroupsLive,
|
||||
isFileActiveLive,
|
||||
listAssetsByTypeLive,
|
||||
liveStatus,
|
||||
resolveIncludeLive,
|
||||
} from "./liveQuery";
|
||||
|
||||
const USAGE_GUIDE = `RA3 Mod XML live index query API
|
||||
|
||||
Endpoints (all require Authorization: Bearer <token>):
|
||||
GET /status
|
||||
GET /projects
|
||||
GET /find_asset?id=...&type=...
|
||||
GET /find_references?id=...&type=...
|
||||
GET /get_asset_references?id=...&type=...&depth=1&targetTypes=A,B&maxEdges=200
|
||||
GET /list_assets?type=...&prefix=...&limit=...
|
||||
GET /is_file_active?path=...
|
||||
GET /find_define?name=...
|
||||
GET /resolve_include?source=...
|
||||
GET /get_usage_guide
|
||||
|
||||
All endpoints accept an optional ?project=<absolute dir> selector. Responses
|
||||
echo index.projectDir; treat the result as belonging to a different project
|
||||
when it does not match what you asked for.
|
||||
`;
|
||||
|
||||
export interface LocalServerHandle {
|
||||
port: number;
|
||||
token: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalServerOptions {
|
||||
/** Returns the current in-memory index for a project (null when unknown). */
|
||||
getIndex: (projectDir?: string) => ModIndex | null;
|
||||
/** Every project root the live workspace currently knows about. */
|
||||
listProjects?: () => string[];
|
||||
/**
|
||||
* Reads + parses one XML file. Required by /get_asset_references, which
|
||||
* needs element context that the index does not store.
|
||||
*/
|
||||
loadFile?: XmlFileLoader;
|
||||
token?: string;
|
||||
/** Defaults to an OS-assigned port on 127.0.0.1. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
||||
const body = JSON.stringify(value);
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function sendText(res: ServerResponse, status: number, text: string): void {
|
||||
res.writeHead(status, {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(text),
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
|
||||
function isAuthorized(req: IncomingMessage, token: string): boolean {
|
||||
const header = req.headers.authorization ?? "";
|
||||
return header === `Bearer ${token}`;
|
||||
}
|
||||
|
||||
/** Parses a comma-separated `targetTypes` parameter. */
|
||||
function parseList(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseNumber(raw: string | null): number | undefined {
|
||||
if (raw == null || raw === "") return undefined;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function forwardRefOptionsFrom(q: URLSearchParams): ForwardRefOptions {
|
||||
return {
|
||||
depth: parseNumber(q.get("depth")),
|
||||
targetTypes: parseList(q.get("targetTypes")),
|
||||
maxEdges: parseNumber(q.get("maxEdges")),
|
||||
includeUnresolved: q.get("includeUnresolved") === "true",
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(
|
||||
options: LocalServerOptions,
|
||||
token: string,
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<void> {
|
||||
if (!isAuthorized(req, token)) {
|
||||
sendJson(res, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
const q = url.searchParams;
|
||||
const projectDir = q.get("project") ?? undefined;
|
||||
const index = options.getIndex(projectDir);
|
||||
// When an index exists its own projectDir is authoritative; the selector is
|
||||
// only echoed for no-index responses so callers can still verify the server.
|
||||
const status = liveStatus(index, projectDir);
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/status":
|
||||
sendJson(res, 200, status);
|
||||
return;
|
||||
|
||||
case "/projects":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: options.listProjects?.() ?? [],
|
||||
});
|
||||
return;
|
||||
|
||||
case "/find_asset":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: index ? findAssetsLive(index, q.get("id") ?? "", q.get("type")) : [],
|
||||
});
|
||||
return;
|
||||
|
||||
case "/find_references":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: index
|
||||
? findReferenceGroupsLive(index, q.get("id") ?? "", q.get("type"))
|
||||
: [],
|
||||
});
|
||||
return;
|
||||
|
||||
case "/list_assets": {
|
||||
const limit = parseNumber(q.get("limit"));
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: index
|
||||
? listAssetsByTypeLive(index, q.get("type") ?? "", q.get("prefix") ?? "", limit)
|
||||
: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "/is_file_active":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: { active: index ? isFileActiveLive(index, q.get("path") ?? "") : false },
|
||||
});
|
||||
return;
|
||||
|
||||
case "/find_define":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: index
|
||||
? findDefineLive(index, (q.get("name") ?? "").replace(/^\$/, ""))
|
||||
: [],
|
||||
});
|
||||
return;
|
||||
|
||||
case "/resolve_include":
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: index ? resolveIncludeLive(index, q.get("source") ?? "") : null,
|
||||
});
|
||||
return;
|
||||
|
||||
case "/get_asset_references": {
|
||||
if (!index) {
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: null,
|
||||
error:
|
||||
"get_asset_references requires a live index (VS Code must be open with the project indexed).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!options.loadFile) {
|
||||
sendJson(res, 200, {
|
||||
index: status,
|
||||
data: null,
|
||||
error: "The live server was started without XML file access.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await collectAssetReferences(
|
||||
index,
|
||||
q.get("id") ?? "",
|
||||
q.get("type"),
|
||||
options.loadFile,
|
||||
forwardRefOptionsFrom(q),
|
||||
);
|
||||
sendJson(res, 200, { index: status, data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
case "/get_usage_guide":
|
||||
sendText(res, 200, USAGE_GUIDE);
|
||||
return;
|
||||
|
||||
default:
|
||||
sendJson(res, 404, { error: `Not found: ${url.pathname}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a local HTTP server; resolves once it is listening. */
|
||||
export async function startLocalServer(
|
||||
options: LocalServerOptions,
|
||||
): Promise<LocalServerHandle> {
|
||||
const token = options.token ?? randomToken();
|
||||
const server = createServer((req, res) => {
|
||||
void handle(options, token, req, res).catch((err) => {
|
||||
sendJson(res, 500, {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolveListen, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(options.port ?? 0, "127.0.0.1", () => resolveListen());
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
port: address.port,
|
||||
token,
|
||||
close: () =>
|
||||
new Promise<void>((resolveClose, rejectClose) => {
|
||||
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
return `ra3-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* Minimal MCP (Model Context Protocol) stdio server exposing the RA3 Mod XML
|
||||
* agent snapshot query API.
|
||||
*
|
||||
* This is intentionally dependency-free. It speaks the JSON-RPC-over-stdio
|
||||
* subset used by MCP clients:
|
||||
*
|
||||
* initialize
|
||||
* notifications/initialized
|
||||
* ping
|
||||
* tools/list
|
||||
* tools/call
|
||||
*
|
||||
* Usage:
|
||||
* node out/agent/mcpServer.js --project D:/Mods/Example
|
||||
* node out/agent/mcpServer.js --snapshot /path/to/snapshot.json.gz
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { LiveClient } from "./liveClient";
|
||||
import { pruneInstances } from "./instances";
|
||||
import { readSnapshotFile, snapshotPathForProject } from "./snapshot";
|
||||
import {
|
||||
findAssets,
|
||||
findDefine,
|
||||
findReferenceGroups,
|
||||
isFileActive,
|
||||
listAssetsByType,
|
||||
resolveIncludeSource,
|
||||
statusFromSnapshot,
|
||||
} from "./query";
|
||||
import type { AgentIndexSnapshot } from "./types";
|
||||
|
||||
interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
handler: (args: Record<string, unknown>, snapshot: AgentIndexSnapshot | null) => unknown;
|
||||
}
|
||||
|
||||
const USAGE_GUIDE = `RA3 Mod XML index query tools
|
||||
|
||||
This MCP server exposes the semantic index built by the RA3 Mod XML VS Code extension.
|
||||
|
||||
Use these tools instead of full-text grepping the XML tree when you need exact facts:
|
||||
- find_asset(id, type?) -> definition sites (file/line/origin/stream)
|
||||
- find_references(id, type?) -> semantic reference sites
|
||||
- get_asset_references(id, type?, depth?, targetTypes?) -> outgoing reference EDGES with element context
|
||||
- list_assets_by_type(type, prefix?, limit?) -> assets of a type
|
||||
- is_file_active(path) -> whether a file is part of an indexed include stream
|
||||
- find_define(name) -> $DEFINE definitions
|
||||
- resolve_include(source) -> candidate source file
|
||||
- list_projects() -> project roots the live extension has indexed
|
||||
- get_status() -> current index state
|
||||
|
||||
Tips:
|
||||
- Asset ids are case-insensitive.
|
||||
- Prefer passing type when the same id exists for multiple asset types.
|
||||
- Always check the returned index state; if it is stale/incomplete, treat results as provisional.
|
||||
- Use get_asset_references to follow "which weapon/model/upgrade does this asset use" chains.
|
||||
It returns edges annotated with the element name, parent element and attribute that produced
|
||||
them, plus the exact XML file/line, so you do not have to read source to find the link.
|
||||
Start with depth 1 (the default) and pass targetTypes (e.g. ["WeaponTemplate"]) to cut noise.
|
||||
Edges with a "definedIn" field come from an inheritFrom ancestor's XML.
|
||||
When "truncated" is true, read "omittedByTargetType" and narrow the query instead of retrying.
|
||||
- Do not attempt to read the entire snapshot file; query narrowly.`;
|
||||
|
||||
const TOOLS: McpTool[] = [
|
||||
{
|
||||
name: "get_status",
|
||||
description: "Returns the current index state and basic statistics.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: (_args, snapshot) => statusFromSnapshot(snapshot),
|
||||
},
|
||||
{
|
||||
name: "find_asset",
|
||||
description: "Finds asset definitions by id, optionally filtered by asset type.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Asset id to find" },
|
||||
type: { type: "string", description: "Optional asset type filter" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const id = String(args.id ?? "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: findAssets(snapshot, id, args.type ? String(args.type) : null),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find_references",
|
||||
description: "Finds semantic reference sites pointing to an asset id, optionally filtered by type.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Asset id whose references to find" },
|
||||
type: { type: "string", description: "Optional asset type filter" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const id = String(args.id ?? "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: findReferenceGroups(snapshot, id, args.type ? String(args.type) : null),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_assets_by_type",
|
||||
description: "Lists asset definitions of one type, optionally filtered by id prefix.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", description: "Asset type" },
|
||||
prefix: { type: "string", description: "Optional id prefix" },
|
||||
limit: { type: "number", description: "Maximum number of results" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const type = String(args.type ?? "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: listAssetsByType(snapshot, type, args.prefix ? String(args.prefix) : "", limit),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "is_file_active",
|
||||
description: "Returns whether a file belongs to an indexed include stream (i.e. is not a dead file).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Absolute file path" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const path = String(args.path ?? "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: { active: isFileActive(snapshot, path) },
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "find_define",
|
||||
description: "Finds $DEFINE constants by name.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Define name (with or without leading $)" },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const name = String(args.name ?? "").replace(/^\$/, "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: findDefine(snapshot, name),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolve_include",
|
||||
description: "Resolves an Include source string from the snapshot's candidate list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: { type: "string", description: "Include source, e.g. DATA:Units/Example.xml" },
|
||||
},
|
||||
required: ["source"],
|
||||
},
|
||||
handler: (args, snapshot) => {
|
||||
const source = String(args.source ?? "");
|
||||
if (!snapshot) return statusFromSnapshot(snapshot);
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data: resolveIncludeSource(snapshot, source),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_asset_references",
|
||||
description:
|
||||
"Returns the outgoing references (edges) of an asset: which assets it references, through which element/attribute, and at which file/line. Also follows inheritFrom ancestors (marked with definedIn). Live index required.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Asset id whose outgoing references to return" },
|
||||
type: { type: "string", description: "Optional asset type filter" },
|
||||
depth: {
|
||||
type: "number",
|
||||
description:
|
||||
"Levels of assets to expand: 1 (default) = the asset itself, including inherited XML; max 3.",
|
||||
},
|
||||
targetTypes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
"Only keep edges whose target is assignable to one of these types, e.g. [\"WeaponTemplate\"].",
|
||||
},
|
||||
maxEdges: { type: "number", description: "Hard cap on returned edges (default 200)." },
|
||||
includeUnresolved: {
|
||||
type: "boolean",
|
||||
description: "Also return edges whose reference value could not be resolved.",
|
||||
},
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
// Live-only: element context is not stored in the on-disk snapshot.
|
||||
handler: () => ({
|
||||
index: { state: "no_index" },
|
||||
error:
|
||||
"get_asset_references requires a live index. Open the project in VS Code (with AI Agent access enabled) and retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "list_projects",
|
||||
description:
|
||||
"Lists the project roots the live extension currently has indexed. Use it to discover which projects this server can answer for.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: () => ({
|
||||
index: { state: "no_index" },
|
||||
error:
|
||||
"list_projects requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "get_usage_guide",
|
||||
description: "Returns guidance for using the RA3 Mod XML index tools.",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
handler: () => ({ text: USAGE_GUIDE }),
|
||||
},
|
||||
];
|
||||
|
||||
/** Tools that can only be answered by the live extension server. */
|
||||
const LIVE_ONLY_TOOLS = new Set(["get_asset_references", "list_projects"]);
|
||||
|
||||
function sendMessage(message: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function resultFor(id: unknown, result: unknown): unknown {
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
}
|
||||
|
||||
function errorFor(id: unknown, code: number, message: string): unknown {
|
||||
return { jsonrpc: "2.0", id, error: { code, message } };
|
||||
}
|
||||
|
||||
async function handleRequest(
|
||||
message: Record<string, unknown>,
|
||||
snapshot: AgentIndexSnapshot | null,
|
||||
projectDir: string | null,
|
||||
live: LiveClient,
|
||||
): Promise<unknown | null> {
|
||||
const method = String(message.method ?? "");
|
||||
const id = message.id;
|
||||
const params = (message.params ?? {}) as Record<string, unknown>;
|
||||
|
||||
switch (method) {
|
||||
case "initialize":
|
||||
return resultFor(id, {
|
||||
protocolVersion: params.protocolVersion ?? "2024-11-05",
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "ra3-mod-xml", version: "0.1.0" },
|
||||
});
|
||||
case "ping":
|
||||
return resultFor(id, {});
|
||||
case "tools/list":
|
||||
return resultFor(id, {
|
||||
tools: TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
})),
|
||||
});
|
||||
case "tools/call": {
|
||||
const toolName = String(params.name ?? "");
|
||||
const tool = TOOLS.find((t) => t.name === toolName);
|
||||
if (!tool) return errorFor(id, -32602, `Unknown tool: ${toolName}`);
|
||||
const args = (params.arguments ?? {}) as Record<string, unknown>;
|
||||
const result = await live.query(toolName, args);
|
||||
|
||||
if (result?.mismatched) {
|
||||
// The server answered for another project. Refuse it: a plausible
|
||||
// wrong answer is worse than an explicit failure.
|
||||
live.markUnavailable();
|
||||
return textResult(id, {
|
||||
index: { state: "error", projectDir: projectDir ?? undefined },
|
||||
error: `The live server answered for a different project than "${projectDir}"; refusing the result. Re-run "RA3 Mod XML: Enable AI Agent access…" for this project.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (LIVE_ONLY_TOOLS.has(toolName)) {
|
||||
// Report a clear reason instead of an empty result, so the agent does
|
||||
// not conclude "this asset has no references".
|
||||
return textResult(
|
||||
id,
|
||||
result?.payload ?? {
|
||||
index: { state: "no_index", projectDir: projectDir ?? undefined },
|
||||
error: `"${toolName}" requires a live index. Open the project in VS Code with AI Agent access enabled, then retry.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const output = result?.payload ?? tool.handler(args, snapshot);
|
||||
return textResult(id, output);
|
||||
}
|
||||
default:
|
||||
// Notifications have no id; ignore them.
|
||||
if (id === undefined) return null;
|
||||
return errorFor(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wraps any tool payload into an MCP text content result. */
|
||||
function textResult(id: unknown, payload: unknown): unknown {
|
||||
const text = typeof payload === "string" ? payload : JSON.stringify(payload, null, 2);
|
||||
return resultFor(id, { content: [{ type: "text", text }] });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
let projectDir: string | null = null;
|
||||
let snapshotPath: string | null = null;
|
||||
let agentHome: string | undefined;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--project" || args[i] === "-p") projectDir = args[++i] ?? null;
|
||||
else if (args[i] === "--snapshot" || args[i] === "-s") snapshotPath = args[++i] ?? null;
|
||||
else if (args[i] === "--agent-home") agentHome = args[++i] ?? undefined;
|
||||
}
|
||||
const resolvedSnapshotPath =
|
||||
snapshotPath ??
|
||||
(projectDir ? snapshotPathForProject(projectDir, agentHome) : null);
|
||||
let snapshot: AgentIndexSnapshot | null = null;
|
||||
if (resolvedSnapshotPath) snapshot = await readSnapshotFile(resolvedSnapshotPath);
|
||||
|
||||
// One-shot crash cleanup: a window that died without disposing leaves its
|
||||
// instance file behind, and whichever instance starts next prunes it.
|
||||
void pruneInstances(agentHome).catch(() => undefined);
|
||||
|
||||
const live = new LiveClient({ projectDir, agentHome });
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
let message: Record<string, unknown>;
|
||||
try {
|
||||
message = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void handleRequest(message, snapshot, projectDir, live).then((response) => {
|
||||
if (response != null) sendMessage(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Only run the stdio loop when executed directly, so the module stays
|
||||
// importable by tests.
|
||||
if (typeof require !== "undefined" && require.main === module) {
|
||||
void main();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* One-time "this version can expose the index to AI agents" notification.
|
||||
*
|
||||
* Pure decision logic (no VS Code dependency) so the anti-nag rules can be
|
||||
* unit tested:
|
||||
*
|
||||
* - a fresh install is informed once;
|
||||
* - an upgrade from a build that predates the feature is informed once;
|
||||
* - an upgrade from a build that already had the feature stays silent;
|
||||
* - "Don't show again" (and simply ignoring the message) is remembered, so
|
||||
* the prompt never becomes a recurring nag.
|
||||
*/
|
||||
|
||||
/** Extension version that introduced AI Agent access. */
|
||||
export const AGENT_FEATURE_VERSION = "0.1.26";
|
||||
|
||||
export interface AgentOnboardingState {
|
||||
/** Extension version whose notification was already shown. */
|
||||
informedVersion?: string;
|
||||
/** The user explicitly chose "Don't show again". */
|
||||
dismissed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the AI Agent introduction should be shown for this version.
|
||||
*/
|
||||
export function shouldOfferAgentOnboarding(
|
||||
state: AgentOnboardingState | undefined,
|
||||
currentVersion: string,
|
||||
): boolean {
|
||||
if (state?.dismissed) return false;
|
||||
if (!state?.informedVersion) return true; // First run with this feature.
|
||||
if (state.informedVersion === currentVersion) return false;
|
||||
// Already informed by a build that had the feature: never repeat.
|
||||
// Only upgrades from before the feature introduce it once.
|
||||
return compareVersions(state.informedVersion, AGENT_FEATURE_VERSION) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares dotted numeric versions ("1.2.3" > "1.2"). Non-numeric segments
|
||||
* count as 0, and a missing segment is smaller than a present one, so
|
||||
* "0.1.26" > "0.1" and "0.1.26" > "0.1.26-beta".
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const parse = (value: string): number[] =>
|
||||
String(value)
|
||||
.split(".")
|
||||
.map((part) => {
|
||||
const match = /^(\d+)/.exec(part.trim());
|
||||
return match ? Number(match[1]) : 0;
|
||||
});
|
||||
const left = parse(a);
|
||||
const right = parse(b);
|
||||
const length = Math.max(left.length, right.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const l = left[i] ?? 0;
|
||||
const r = right[i] ?? 0;
|
||||
if (l !== r) return l < r ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Query helpers over the stable AgentIndexSnapshot.
|
||||
*
|
||||
* Pure TypeScript and dependency-free, so the same functions can back a CLI,
|
||||
* MCP tools, or a local HTTP API.
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import type { AssetDef, ReferenceSite } from "../indexer/types";
|
||||
import type {
|
||||
AgentDefine,
|
||||
AgentIndexSnapshot,
|
||||
AgentIndexStatus,
|
||||
AgentReferenceGroup,
|
||||
AgentStream,
|
||||
} from "./types";
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
return resolve(p).replace(/\\/g, "/").toLowerCase();
|
||||
}
|
||||
|
||||
/** Returns all asset definitions whose id equals `id`, optionally filtered by type. */
|
||||
export function findAssets(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): AssetDef[] {
|
||||
const wanted = id.toLowerCase();
|
||||
const wantedType = type?.toLowerCase();
|
||||
const out: AssetDef[] = [];
|
||||
for (const asset of snapshot.assets) {
|
||||
if (asset.id.toLowerCase() !== wanted) continue;
|
||||
if (wantedType && asset.type.toLowerCase() !== wantedType) continue;
|
||||
out.push(asset);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Returns asset definitions whose type and id prefix match. */
|
||||
export function listAssetsByType(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
type: string,
|
||||
idPrefix = "",
|
||||
limit?: number,
|
||||
): AssetDef[] {
|
||||
const wantedType = type.toLowerCase();
|
||||
const wantedPrefix = idPrefix.toLowerCase();
|
||||
const out: AssetDef[] = [];
|
||||
for (const asset of snapshot.assets) {
|
||||
if (asset.type.toLowerCase() !== wantedType) continue;
|
||||
if (!asset.id.toLowerCase().startsWith(wantedPrefix)) continue;
|
||||
out.push(asset);
|
||||
if (limit != null && out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Returns reference groups pointing to definitions matching `id` and optional `type`. */
|
||||
export function findReferenceGroups(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): AgentReferenceGroup[] {
|
||||
const wanted = id.toLowerCase();
|
||||
const wantedType = type?.toLowerCase();
|
||||
return snapshot.references.filter((r) => {
|
||||
if (r.id.toLowerCase() !== wanted) return false;
|
||||
if (wantedType && r.type.toLowerCase() !== wantedType) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Flattens reference groups into plain reference sites. */
|
||||
export function findReferenceSites(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
id: string,
|
||||
type?: string | null,
|
||||
): ReferenceSite[] {
|
||||
return findReferenceGroups(snapshot, id, type).flatMap((g) => g.sites);
|
||||
}
|
||||
|
||||
/** True when a file belongs to at least one indexed stream. */
|
||||
export function isFileActive(snapshot: AgentIndexSnapshot, file: string): boolean {
|
||||
const key = normalizePath(file);
|
||||
return snapshot.streams.some((s) =>
|
||||
s.files.some((candidate) => normalizePath(candidate) === key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the streams that contain a file. */
|
||||
export function streamsForFile(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
file: string,
|
||||
): AgentStream[] {
|
||||
const key = normalizePath(file);
|
||||
return snapshot.streams.filter((s) =>
|
||||
s.files.some((candidate) => normalizePath(candidate) === key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Finds a define by case-insensitive name. */
|
||||
export function findDefine(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
name: string,
|
||||
): AgentDefine[] {
|
||||
const wanted = name.toLowerCase();
|
||||
return snapshot.defines.filter((d) => d.name.toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
/** Resolves an Include source using the snapshot's candidate list. */
|
||||
export function resolveIncludeSource(
|
||||
snapshot: AgentIndexSnapshot,
|
||||
source: string,
|
||||
): { source: string; path: string } | null {
|
||||
const wanted = source.toLowerCase();
|
||||
const hit = snapshot.sourceCandidates.find(
|
||||
(c) => c.source.toLowerCase() === wanted,
|
||||
);
|
||||
return hit ? { source: hit.source, path: hit.path } : null;
|
||||
}
|
||||
|
||||
/** Returns the snapshot's status (ready_xml/ready/stale). */
|
||||
export function statusFromSnapshot(
|
||||
snapshot: AgentIndexSnapshot | null | undefined,
|
||||
): AgentIndexStatus {
|
||||
if (!snapshot) {
|
||||
return { state: "no_index", detail: "No index snapshot is available." };
|
||||
}
|
||||
const state: AgentIndexStatus["state"] = snapshot.stale
|
||||
? "stale"
|
||||
: !snapshot.complete
|
||||
? "ready_xml"
|
||||
: "ready";
|
||||
return {
|
||||
state,
|
||||
projectDir: snapshot.projectDir,
|
||||
phase: snapshot.phase,
|
||||
complete: snapshot.complete,
|
||||
stale: snapshot.stale,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
buildId: snapshot.buildId,
|
||||
stats: snapshot.stats,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience aggregate returned by MCP/CLI query tools. */
|
||||
export interface QueryResult<T> {
|
||||
index: AgentIndexStatus;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** Wraps any query data with current index status. */
|
||||
export function withStatus<T>(snapshot: AgentIndexSnapshot | null, data: T): QueryResult<T> {
|
||||
return {
|
||||
index: statusFromSnapshot(snapshot),
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Runtime resolution for the agent launcher and CLI.
|
||||
*
|
||||
* The extension must not assume the user has Node installed. VS Code ships an
|
||||
* Electron binary that can run as a plain Node process when launched with
|
||||
* `ELECTRON_RUN_AS_NODE=1`, which makes it a zero-dependency runtime that is
|
||||
* already present wherever the extension is installed.
|
||||
*
|
||||
* Verified on Windows with VS Code 1.135.0 / Electron 42.8.1 (Node 24.18.1):
|
||||
* `fs`, `fs/promises`, `path`, `os`, `http`, `readline`, `zlib`, `crypto`,
|
||||
* `util`, `net`, `child_process` and even `node:test` are all available, and
|
||||
* the bundled `dist/agent/mcpServer.js` completes a full stdio MCP session.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency, so the same rules are used by the
|
||||
* launcher generator, the CLI and the tests.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
export type AgentRuntimeKind = "electron" | "node";
|
||||
|
||||
export interface AgentRuntime {
|
||||
kind: AgentRuntimeKind;
|
||||
/**
|
||||
* Executable to spawn. For `electron` this is the VS Code / Electron binary
|
||||
* (or a bare product name when only a hint is known).
|
||||
*/
|
||||
executable: string;
|
||||
/** Extra environment variables required to run the executable as Node. */
|
||||
env: Record<string, string>;
|
||||
/**
|
||||
* True when the executable is a bare command name resolved through PATH
|
||||
* rather than an absolute path.
|
||||
*/
|
||||
viaPath: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current process is a VS Code / Electron host.
|
||||
*
|
||||
* Desktop VS Code runs its extension host as the Electron binary with
|
||||
* `ELECTRON_RUN_AS_NODE=1`, so `process.versions.electron` is set and
|
||||
* `process.execPath` points at the VS Code executable. That is exactly the
|
||||
* binary the launcher wants.
|
||||
*/
|
||||
export function isElectronHost(): boolean {
|
||||
return typeof process.versions.electron === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the runtime to bake into the launcher.
|
||||
*
|
||||
* Returns the Electron runtime when running inside VS Code (the normal case),
|
||||
* and falls back to a PATH-resolved `node` otherwise (e.g. when the launcher
|
||||
* is generated from a plain Node CLI or a unit test).
|
||||
*/
|
||||
export function resolveRuntime(): AgentRuntime {
|
||||
if (isElectronHost()) {
|
||||
return {
|
||||
kind: "electron",
|
||||
executable: process.execPath,
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
viaPath: false,
|
||||
};
|
||||
}
|
||||
return { kind: "node", executable: "node", env: {}, viaPath: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the runtime descriptor for an explicit Electron/VSCode executable.
|
||||
* Used when the extension knows the host path but is not itself running as
|
||||
* Electron, and by tests.
|
||||
*/
|
||||
export function electronRuntime(executable: string): AgentRuntime {
|
||||
return {
|
||||
kind: "electron",
|
||||
executable,
|
||||
env: { ELECTRON_RUN_AS_NODE: "1" },
|
||||
viaPath: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Product names that indicate a VS Code-family Electron binary.
|
||||
*
|
||||
* Covers both the branded Windows/Linux launchers (`Code.exe`, `code-oss`,
|
||||
* `codium`, `cursor`) and the macOS bundle executable, which is literally
|
||||
* named `Electron`.
|
||||
*/
|
||||
const ELECTRON_PRODUCT_NAMES = [
|
||||
"code",
|
||||
"code-insiders",
|
||||
"codium",
|
||||
"cursor",
|
||||
"electron",
|
||||
];
|
||||
|
||||
/**
|
||||
* Best-effort candidates for a VS Code / Electron binary when running under a
|
||||
* plain Node process. These are only fallbacks: the authoritative path always
|
||||
* comes from `process.execPath` inside the extension host.
|
||||
*/
|
||||
export function electronExecutableCandidates(): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (isWin && process.env.LOCALAPPDATA) {
|
||||
candidates.push(
|
||||
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\Code.exe`,
|
||||
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\Code - Insiders.exe`,
|
||||
);
|
||||
}
|
||||
if (isWin && process.env.PROGRAMFILES) {
|
||||
candidates.push(`${process.env.PROGRAMFILES}\\Microsoft VS Code\\Code.exe`);
|
||||
}
|
||||
if (isMac) {
|
||||
candidates.push("/Applications/Visual Studio Code.app/Contents/MacOS/Electron");
|
||||
} else if (!isWin) {
|
||||
candidates.push("/usr/share/code/code", "/usr/bin/code");
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/** The first existing Electron candidate, or null. */
|
||||
export function findElectronExecutable(): string | null {
|
||||
for (const candidate of electronExecutableCandidates()) {
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Renders a path for a POSIX shell single-quoted string. */
|
||||
function shQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Renders a path for a Windows `cmd.exe` double-quoted `set` value. */
|
||||
function cmdValue(value: string): string {
|
||||
// `set "VAR=value"` keeps quotes out of the value; embedded `"` would break
|
||||
// it, so strip them rather than emit a broken script.
|
||||
return value.replace(/"/g, "");
|
||||
}
|
||||
|
||||
export interface LauncherScriptOptions {
|
||||
runtime: AgentRuntime;
|
||||
/** Absolute path to the bundled MCP server entry. */
|
||||
serverPath: string;
|
||||
/** Project root passed to the MCP server. */
|
||||
projectDir: string;
|
||||
/**
|
||||
* Optional absolute path to a `node` executable used when the Electron
|
||||
* binary is missing. When omitted the script falls back to `node` on PATH.
|
||||
*/
|
||||
nodeFallback?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the stable launcher script.
|
||||
*
|
||||
* The script prefers the Electron runtime (works without Node installed) and
|
||||
* falls back to Node only when that binary is gone, so a moved/uninstalled
|
||||
* VS Code does not leave the user with a dead launcher.
|
||||
*/
|
||||
export function launcherScript(
|
||||
options: LauncherScriptOptions,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
const { runtime, serverPath, projectDir } = options;
|
||||
const node = options.nodeFallback ?? "node";
|
||||
const paths = { serverPath: cmdValue(serverPath), projectDir: cmdValue(projectDir) };
|
||||
|
||||
if (platform === "win32") {
|
||||
// A `goto` jump is used instead of a parenthesised `if (...)` block:
|
||||
// inside such a block `%errorlevel%` is expanded when the whole block is
|
||||
// parsed, not when each command runs, so the exit code would be wrong.
|
||||
const lines = ["@echo off", "setlocal"];
|
||||
if (runtime.kind === "node") {
|
||||
// No Electron available: the launcher is Node-only.
|
||||
lines.push(
|
||||
`set "RA3_NODE=${cmdValue(node)}"`,
|
||||
`set "RA3_SERVER=${paths.serverPath}"`,
|
||||
`set "RA3_PROJECT=${paths.projectDir}"`,
|
||||
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
);
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
lines.push(
|
||||
`set "RA3_RUNTIME=${cmdValue(runtime.executable)}"`,
|
||||
`set "RA3_SERVER=${paths.serverPath}"`,
|
||||
`set "RA3_PROJECT=${paths.projectDir}"`,
|
||||
`set "RA3_NODE=${cmdValue(node)}"`,
|
||||
'if not exist "%RA3_RUNTIME%" goto :ra3_node',
|
||||
"set ELECTRON_RUN_AS_NODE=1",
|
||||
'"%RA3_RUNTIME%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
":ra3_node",
|
||||
'"%RA3_NODE%" "%RA3_SERVER%" --project "%RA3_PROJECT%"',
|
||||
"exit /b %errorlevel%",
|
||||
);
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
const lines = ["#!/usr/bin/env sh"];
|
||||
if (runtime.kind === "node") {
|
||||
lines.push(
|
||||
`exec ${shQuote(node)} ${shQuote(serverPath)} --project ${shQuote(projectDir)}`,
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`RA3_RUNTIME=${shQuote(runtime.executable)}`,
|
||||
`RA3_SERVER=${shQuote(serverPath)}`,
|
||||
`RA3_PROJECT=${shQuote(projectDir)}`,
|
||||
'if [ -x "$RA3_RUNTIME" ]; then',
|
||||
' ELECTRON_RUN_AS_NODE=1 exec "$RA3_RUNTIME" "$RA3_SERVER" --project "$RA3_PROJECT"',
|
||||
"fi",
|
||||
`exec ${shQuote(node)} "$RA3_SERVER" --project "$RA3_PROJECT"`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the launcher is able to run without a Node installation: the
|
||||
* Electron runtime is used unconditionally (no PATH lookup of `node`).
|
||||
*
|
||||
* Used by tests and by the enable flow to warn when the launcher would depend
|
||||
* on Node being installed.
|
||||
*/
|
||||
export function isNodeFreeLauncher(script: string, platform: NodeJS.Platform = process.platform): boolean {
|
||||
if (platform === "win32") {
|
||||
return (
|
||||
script.includes("ELECTRON_RUN_AS_NODE=1") &&
|
||||
script.includes('set "RA3_RUNTIME=') &&
|
||||
// The Node path must only be reachable through the guard jump.
|
||||
script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node') &&
|
||||
!/^\s*"node"\s/m.test(script)
|
||||
);
|
||||
}
|
||||
return (
|
||||
script.includes("ELECTRON_RUN_AS_NODE=1") &&
|
||||
script.includes("RA3_RUNTIME=") &&
|
||||
!/^exec node /m.test(script)
|
||||
);
|
||||
}
|
||||
|
||||
/** Product names that indicate a VS Code-family Electron binary. */
|
||||
export function looksLikeElectronExecutable(path: string): boolean {
|
||||
const base = path.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? "";
|
||||
return ELECTRON_PRODUCT_NAMES.some((name) => base.includes(name));
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Helpers for creating the stable MCP launcher and MCP client configuration.
|
||||
*
|
||||
* The launcher lives outside the VS Code extension install directory (under
|
||||
* ~/.ra3modxml) so AI client configs do not break when the extension is
|
||||
* updated to a new version. The extension refreshes the launcher on every
|
||||
* activation/update.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { defaultAgentHome } from "./snapshot";
|
||||
import {
|
||||
launcherScript,
|
||||
resolveRuntime,
|
||||
type AgentRuntime,
|
||||
} from "./runtime";
|
||||
|
||||
/** Key this extension uses for its MCP server entry in every client config. */
|
||||
export const MCP_SERVER_KEY = "ra3-mod-xml";
|
||||
|
||||
export interface McpConfigTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Result of writing the stable launcher. */
|
||||
export interface LauncherResult {
|
||||
path: string;
|
||||
serverPath: string;
|
||||
runtime: AgentRuntime;
|
||||
/**
|
||||
* True when the launcher runs on the VS Code Electron runtime and therefore
|
||||
* does not need a Node installation.
|
||||
*/
|
||||
nodeFree: boolean;
|
||||
}
|
||||
|
||||
/** File name of the stable launcher on the current platform. */
|
||||
export function launcherFileName(): string {
|
||||
return process.platform === "win32" ? "ra3-mod-xml-mcp.cmd" : "ra3-mod-xml-mcp";
|
||||
}
|
||||
|
||||
/** Absolute path to the stable launcher under the agent home. */
|
||||
export function launcherPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, launcherFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the bundled MCP server inside an extension install/dev directory.
|
||||
* The packaged extension ships this file under dist/agent/mcpServer.js.
|
||||
*/
|
||||
export function bundledMcpServerPath(extensionRoot: string): string {
|
||||
return join(extensionRoot, "dist", "agent", "mcpServer.js");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the stable launcher script. It prefers the VS Code Electron runtime
|
||||
* (so no Node installation is required) and falls back to Node only when that
|
||||
* binary is missing.
|
||||
*
|
||||
* Returns the launcher path plus the resolved runtime, so callers can warn
|
||||
* when the launcher will depend on Node being on PATH.
|
||||
*/
|
||||
export async function writeLauncher(
|
||||
extensionRoot: string,
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
runtime?: AgentRuntime,
|
||||
): Promise<LauncherResult> {
|
||||
const server = bundledMcpServerPath(extensionRoot);
|
||||
const launcher = launcherPath(agentHome);
|
||||
const resolved = runtime ?? resolveRuntime();
|
||||
const script = launcherScript({
|
||||
runtime: resolved,
|
||||
serverPath: server,
|
||||
projectDir,
|
||||
});
|
||||
await mkdir(dirname(launcher), { recursive: true });
|
||||
await writeFile(launcher, script, "utf8");
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(launcher, 0o755);
|
||||
}
|
||||
return {
|
||||
path: launcher,
|
||||
serverPath: server,
|
||||
runtime: resolved,
|
||||
nodeFree: resolved.kind === "electron",
|
||||
};
|
||||
}
|
||||
|
||||
/** MCP client config entry for one project. */
|
||||
export function mcpServerConfig(
|
||||
launcher: string,
|
||||
projectDir: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
mcpServers: {
|
||||
"ra3-mod-xml": {
|
||||
command: launcher,
|
||||
args: ["--project", projectDir],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable JSON config block users can paste into AI clients. */
|
||||
export function mcpConfigJson(
|
||||
launcher: string,
|
||||
projectDir: string,
|
||||
): string {
|
||||
return JSON.stringify(mcpServerConfig(launcher, projectDir), null, 2);
|
||||
}
|
||||
|
||||
/** Claude Desktop config path (Windows/macOS/Linux common locations). */
|
||||
export function claudeDesktopConfigPath(): string {
|
||||
if (process.env.APPDATA) return join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
|
||||
return join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
||||
}
|
||||
|
||||
/** Cursor's global MCP config path. */
|
||||
export function cursorGlobalConfigPath(): string {
|
||||
return join(homedir(), ".cursor", "mcp.json");
|
||||
}
|
||||
|
||||
/** Cursor's project-scoped MCP config path. */
|
||||
export function cursorProjectConfigPath(projectDir: string): string {
|
||||
return join(projectDir, ".cursor", "mcp.json");
|
||||
}
|
||||
|
||||
/** Common local MCP config files this extension can offer to update. */
|
||||
export function commonMcpConfigTargets(projectDir: string): McpConfigTarget[] {
|
||||
return [
|
||||
{
|
||||
id: "claude-desktop",
|
||||
label: "Claude Desktop",
|
||||
path: claudeDesktopConfigPath(),
|
||||
},
|
||||
{
|
||||
id: "cursor-global",
|
||||
label: "Cursor (global)",
|
||||
path: cursorGlobalConfigPath(),
|
||||
},
|
||||
{
|
||||
id: "cursor-project",
|
||||
label: "Cursor (current project)",
|
||||
path: cursorProjectConfigPath(projectDir),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the RA3 Mod XML MCP server entry to a JSON config file, preserving any
|
||||
* existing keys and `mcpServers`. Creates the file when it does not exist.
|
||||
*/
|
||||
export async function addMcpServerToConfigFile(
|
||||
filePath: string,
|
||||
launcher: string,
|
||||
projectDir: string,
|
||||
serverKey = MCP_SERVER_KEY,
|
||||
): Promise<void> {
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
// File absent or malformed: start fresh.
|
||||
}
|
||||
const servers = (config.mcpServers as Record<string, unknown> | undefined) ?? {};
|
||||
servers[serverKey] = {
|
||||
command: launcher,
|
||||
args: ["--project", projectDir],
|
||||
};
|
||||
config.mcpServers = servers;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes this extension's server entry from a JSON config file, preserving
|
||||
* every other server and top-level key. Handles both the widely used
|
||||
* `mcpServers` container and VS Code's `servers` container, and deletes a
|
||||
* container that becomes empty.
|
||||
*
|
||||
* Returns true only when an entry was actually removed; a missing, malformed
|
||||
* or already-clean file is left untouched.
|
||||
*/
|
||||
export async function removeMcpServerFromConfigFile(
|
||||
filePath: string,
|
||||
serverKey = MCP_SERVER_KEY,
|
||||
): Promise<boolean> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let removed = false;
|
||||
for (const containerKey of ["mcpServers", "servers"] as const) {
|
||||
const container = config[containerKey];
|
||||
if (!container || typeof container !== "object" || Array.isArray(container)) {
|
||||
continue;
|
||||
}
|
||||
const servers = container as Record<string, unknown>;
|
||||
if (!(serverKey in servers)) continue;
|
||||
delete servers[serverKey];
|
||||
removed = true;
|
||||
if (Object.keys(servers).length === 0) delete config[containerKey];
|
||||
}
|
||||
if (!removed) return false;
|
||||
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One MCP config file this extension wrote, so uninstall can undo exactly that. */
|
||||
export interface McpInstallRecord {
|
||||
path: string;
|
||||
serverKey: string;
|
||||
/** Top-level JSON container the entry was written under. */
|
||||
format: "mcpServers" | "servers";
|
||||
/** Human label (Claude Desktop / Cursor global / ...). */
|
||||
label?: string;
|
||||
sourceVersion?: string;
|
||||
}
|
||||
|
||||
/** Path to the managed MCP config install record under the agent home. */
|
||||
export function mcpInstallRecordPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "mcp-install.json");
|
||||
}
|
||||
|
||||
/** Reads the managed MCP config install record, or returns an empty list. */
|
||||
export async function readMcpInstallRecord(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<McpInstallRecord[]> {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(mcpInstallRecordPath(agentHome), "utf8"),
|
||||
) as { installed?: McpInstallRecord[] };
|
||||
return Array.isArray(parsed.installed) ? parsed.installed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the managed MCP config install record. */
|
||||
export async function writeMcpInstallRecord(
|
||||
installed: McpInstallRecord[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
const file = mcpInstallRecordPath(agentHome);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(file, JSON.stringify({ installed }, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export interface InstallMcpConfigOptions {
|
||||
filePath: string;
|
||||
launcher: string;
|
||||
projectDir: string;
|
||||
label?: string;
|
||||
sourceVersion?: string;
|
||||
serverKey?: string;
|
||||
agentHome?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the server entry into a config file and remembers that we did, so
|
||||
* "uninstall AI Agent integration" can remove exactly what this extension
|
||||
* created without touching the user's other MCP servers.
|
||||
*/
|
||||
export async function installMcpServerConfigToFile(
|
||||
options: InstallMcpConfigOptions,
|
||||
): Promise<void> {
|
||||
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
|
||||
await addMcpServerToConfigFile(
|
||||
options.filePath,
|
||||
options.launcher,
|
||||
options.projectDir,
|
||||
serverKey,
|
||||
);
|
||||
const agentHome = options.agentHome ?? defaultAgentHome();
|
||||
const path = resolve(options.filePath);
|
||||
const installed = (await readMcpInstallRecord(agentHome)).filter(
|
||||
(r) => resolve(r.path).toLowerCase() !== path.toLowerCase(),
|
||||
);
|
||||
installed.push({
|
||||
path,
|
||||
serverKey,
|
||||
format: "mcpServers",
|
||||
label: options.label,
|
||||
sourceVersion: options.sourceVersion,
|
||||
});
|
||||
await writeMcpInstallRecord(installed, agentHome);
|
||||
}
|
||||
|
||||
export interface UninstallMcpConfigOptions {
|
||||
/** Also scan the conventional client paths for this project. */
|
||||
projectDir?: string | null;
|
||||
agentHome?: string;
|
||||
serverKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the server entry from every config file this extension recorded,
|
||||
* plus the conventional client config paths for `projectDir` (which covers
|
||||
* configs written before the install record existed).
|
||||
*
|
||||
* Returns the paths that actually changed. Missing/malformed files count as
|
||||
* "nothing to remove" and are not reported.
|
||||
*/
|
||||
export async function uninstallMcpServerConfigs(
|
||||
options: UninstallMcpConfigOptions = {},
|
||||
): Promise<string[]> {
|
||||
const serverKey = options.serverKey ?? MCP_SERVER_KEY;
|
||||
const agentHome = options.agentHome ?? defaultAgentHome();
|
||||
const record = await readMcpInstallRecord(agentHome);
|
||||
const candidates = new Map<string, string>();
|
||||
for (const entry of record) {
|
||||
if (entry.serverKey && entry.serverKey !== serverKey) continue;
|
||||
candidates.set(resolve(entry.path).toLowerCase(), entry.path);
|
||||
}
|
||||
if (options.projectDir) {
|
||||
for (const target of commonMcpConfigTargets(options.projectDir)) {
|
||||
candidates.set(resolve(target.path).toLowerCase(), target.path);
|
||||
}
|
||||
}
|
||||
|
||||
const changed: string[] = [];
|
||||
for (const path of candidates.values()) {
|
||||
try {
|
||||
if (await removeMcpServerFromConfigFile(path, serverKey)) changed.push(path);
|
||||
} catch {
|
||||
// Best effort; keep going through the remaining files.
|
||||
}
|
||||
}
|
||||
const changedKeys = new Set(changed.map((p) => resolve(p).toLowerCase()));
|
||||
await writeMcpInstallRecord(
|
||||
record.filter((r) => !changedKeys.has(resolve(r.path).toLowerCase())),
|
||||
agentHome,
|
||||
);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Deletes the stable MCP launcher. Returns false when nothing was removable. */
|
||||
export async function removeLauncher(agentHome = defaultAgentHome()): Promise<boolean> {
|
||||
try {
|
||||
await rm(launcherPath(agentHome), { force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Default path used for the agent home. */
|
||||
export function defaultAgentHomeForSetup(): string {
|
||||
return join(homedir(), ".ra3modxml");
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Agent Skill generator/installer for the RA3 Mod XML MCP tools.
|
||||
*
|
||||
* The Skill focuses on the functionality itself: when to use it and how to
|
||||
* use the RA3 Mod XML MCP query tools. It intentionally avoids referencing
|
||||
* any project-specific docs (e.g. docs/codebase-navigation-guide.md) so the
|
||||
* agent is not distracted by unrelated workspace guidance.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency.
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { defaultAgentHome } from "./snapshot";
|
||||
|
||||
export const SKILL_NAME = "ra3-mod-xml";
|
||||
export const SKILL_MARKER_FILE = ".ra3modxml-skill.json";
|
||||
|
||||
const SKILL_MD = `---
|
||||
name: ra3-mod-xml
|
||||
description: Use the RA3 Mod XML semantic index to find asset definitions, references, outgoing references, active files, defines, and include sources in SAGE / BinaryAssetBuilder XML projects such as Command & Conquer: Red Alert 3 mods. Use this when you need exact facts about mod assets instead of guessing or full-text searching the XML tree.
|
||||
---
|
||||
|
||||
# RA3 Mod XML Index
|
||||
|
||||
This skill provides access to the semantic index built by the RA3 Mod XML VS Code extension.
|
||||
|
||||
## When this skill applies
|
||||
|
||||
Use these tools only for SAGE / BinaryAssetBuilder mod XML projects — the kind
|
||||
used by Command & Conquer: Red Alert 3 mods.
|
||||
|
||||
Positive signals (any single one is enough to try the tools):
|
||||
|
||||
- \`Data/Mod.xml\` exists.
|
||||
- \`Data/additionalmaps/mapmetadata_*.xml\` exists.
|
||||
- A \`*.babproj\` file exists.
|
||||
- XML whose root element is \`<AssetDeclaration>\`.
|
||||
- XML that uses \`<Includes><Include source="DATA:…" /></Includes>\` or declares
|
||||
\`xmlns="uri:ea.com:eala:asset"\`.
|
||||
|
||||
Do **not** use these tools for unrelated repositories. SAGE-looking XML, a
|
||||
build script, copied \`.xsd\` schema files, or a folder named \`Data\` are not
|
||||
by themselves evidence of a Red Alert 3 mod project: require at least one of
|
||||
the positive signals above. When the repository is not a SAGE / RA3 mod
|
||||
project, ignore this skill entirely.
|
||||
|
||||
If you are unsure whether the current project is in scope, call \`get_status\`
|
||||
first: it is cheap and reports the \`projectDir\` the index belongs to. Stop
|
||||
using the index tools when:
|
||||
|
||||
- the state is \`no_index\`, or
|
||||
- the reported \`projectDir\` does not match the workspace you are working in.
|
||||
|
||||
In those cases read the files directly instead. Never present index results
|
||||
from one project as if they belonged to another.
|
||||
|
||||
## When to use
|
||||
|
||||
Use this skill when you need any of the following:
|
||||
|
||||
- Find where an asset id is defined (GameObject, WeaponTemplate, Texture, etc.).
|
||||
- Find which files/positions reference an asset id.
|
||||
- Find which assets an asset references, and through which element/attribute.
|
||||
- List assets of a particular type.
|
||||
- Check whether a file is actually part of the active include graph (i.e. not a dead file).
|
||||
- Resolve an Include source string.
|
||||
- Look up a $DEFINE constant.
|
||||
- Get the current index status and statistics.
|
||||
|
||||
Do not use full-text search over the XML tree when one of the MCP query tools
|
||||
can answer the question directly.
|
||||
|
||||
## Reaching the index
|
||||
|
||||
Work down this list and stop at the first step that works.
|
||||
|
||||
1. **The query tools are already in your tool list** (names like
|
||||
\`find_asset\`, \`get_status\`). Use them directly. You do not need to
|
||||
configure anything.
|
||||
|
||||
2. **The tools are not available, but you can run commands.** The index is
|
||||
reachable without any MCP setup, because the MCP server speaks JSON-RPC over
|
||||
stdio. Read \`~/.ra3modxml/index.json\` first: it is a small, stable
|
||||
discovery manifest listing the live instances and their project roots.
|
||||
|
||||
- If it does not exist, live discovery has not been enabled on this
|
||||
machine yet (or no VS Code window is running). Tell the user to open the
|
||||
project in VS Code and run
|
||||
"RA3 Mod XML: Enable AI Agent access…". Do not guess or search further.
|
||||
- If it exists, use the launcher at \`~/.ra3modxml/ra3-mod-xml-mcp.cmd\`
|
||||
(Windows) or \`~/.ra3modxml/ra3-mod-xml-mcp\` (elsewhere), or call the
|
||||
bundled server directly through that instance's runtime. Feed it one
|
||||
JSON-RPC request per line on stdin, for example:
|
||||
|
||||
\`\`\`
|
||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"find_asset","arguments":{"id":"AthenaCannon","type":"GameObject"}}}
|
||||
\`\`\`
|
||||
|
||||
A CLI with the same capabilities is bundled alongside the MCP server
|
||||
(\`cli.js\` next to \`mcpServer.js\` in the extension's \`dist/agent/\`).
|
||||
It is a plain Node script: run it with
|
||||
\`node <path-to-cli.js> <command>\`. If Node is not installed, do not
|
||||
install anything — run the same file with the VS Code runtime recorded in
|
||||
the launcher instead:
|
||||
\`ELECTRON_RUN_AS_NODE=1 <runtime executable from the launcher> <path-to-cli.js> <command>\`.
|
||||
\`cli.js help\` lists the commands. The CLI answers from the live index
|
||||
when VS Code is running and falls back to the last exported snapshot
|
||||
otherwise. Commands that need element context (\`outgoing\`, \`projects\`)
|
||||
require the live index and will say so explicitly instead of returning an
|
||||
empty result.
|
||||
|
||||
3. **You can write configuration, if the user agrees.** You may add the MCP
|
||||
server to your own client's configuration. Most clients only load MCP
|
||||
servers at startup, so tell the user the change takes effect in a new session;
|
||||
check what your own client supports before promising otherwise.
|
||||
|
||||
Never fabricate index results. If none of the steps work, say the index is
|
||||
unavailable and read the XML files directly.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Call \`get_status\` first when you are unsure whether an index is available,
|
||||
current, or belongs to the project you are working in.
|
||||
2. Use narrow queries:
|
||||
- \`find_asset(id, type?)\` for definition locations.
|
||||
- \`find_references(id, type?)\` for incoming semantic references.
|
||||
- \`get_asset_references(id, type?, depth?, targetTypes?)\` for outgoing
|
||||
references (what this asset uses, and where that link is written).
|
||||
- \`list_assets_by_type(type, prefix?, limit?)\` for browsing assets.
|
||||
- \`is_file_active(path)\` to determine whether a file is included in an indexed stream.
|
||||
- \`find_define(name)\` for $DEFINE constants.
|
||||
- \`resolve_include(source)\` for Include source candidates.
|
||||
3. Asset ids are case-insensitive.
|
||||
4. If an id exists for multiple asset types, pass the type filter to avoid mixing definitions.
|
||||
5. If the returned index state is \`stale\`, \`ready_xml\`, or \`building\`, treat
|
||||
results as provisional.
|
||||
6. Never read or dump the whole index snapshot; query narrowly.
|
||||
|
||||
See [query-guide.md](./references/query-guide.md) for the full tool reference,
|
||||
parameter defaults and result shapes.
|
||||
|
||||
## Following references with get_asset_references
|
||||
|
||||
Use \`get_asset_references\` to answer "which weapon / model / upgrade / die-object
|
||||
does this asset actually use" without reading source first. It returns **edges**,
|
||||
not a flat list, so provenance is preserved:
|
||||
|
||||
- \`from\` is the asset you asked about.
|
||||
- \`to\` is the resolved definition (file + line), or null when unresolved.
|
||||
- \`via.element\` / \`via.parent\` / \`via.attribute\` say exactly which XML
|
||||
element and attribute created the link.
|
||||
- \`source\` is the file/line where that link is written.
|
||||
- \`definedIn\` is present when the link comes from an \`inheritFrom\` ancestor's
|
||||
XML rather than from the asset's own file. Inheritance is walked at the same
|
||||
depth, so a base asset's weapon slot configuration is reported together with
|
||||
the derived asset's own modules.
|
||||
|
||||
Practical rules:
|
||||
|
||||
- Start with the default \`depth: 1\` (the queried asset only). Raise it to 2 or 3
|
||||
only for a specific node you already decided to follow. The maximum is 3.
|
||||
- Pass \`targetTypes\` to cut noise, e.g. \`["WeaponTemplate"]\`, or
|
||||
\`["GameObject"]\` for \`CreateObjectDie\`-style die-object links.
|
||||
- If \`truncated\` is true, read \`omittedByTargetType\` and narrow the query
|
||||
(smaller \`targetTypes\`, lower \`depth\`) instead of blindly retrying.
|
||||
- Call \`find_references\` in the opposite direction: it tells you who else would
|
||||
be affected by a change.
|
||||
- Merged/inherited *effective values* are not computed. When \`xai:joinAction\`
|
||||
(\`Replace\` / \`Remove\`) appears in the merge path, open the file and confirm
|
||||
the real result yourself.
|
||||
`;
|
||||
|
||||
const QUERY_GUIDE_MD = `# RA3 Mod XML query tool reference
|
||||
|
||||
The MCP server exposes these tools:
|
||||
|
||||
- get_status()
|
||||
- find_asset(id, type?)
|
||||
- find_references(id, type?)
|
||||
- get_asset_references(id, type?, depth?, targetTypes?, maxEdges?, includeUnresolved?)
|
||||
- list_assets_by_type(type, prefix?, limit?)
|
||||
- is_file_active(path)
|
||||
- find_define(name)
|
||||
- resolve_include(source)
|
||||
- list_projects()
|
||||
- get_usage_guide()
|
||||
|
||||
\`get_asset_references\` and \`list_projects\` require a live index (VS Code
|
||||
running with the project indexed). All other tools also answer from the last
|
||||
exported snapshot when VS Code is closed.
|
||||
|
||||
## Result metadata
|
||||
|
||||
Every query result includes an \`index\` object:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"state": "ready",
|
||||
"projectDir": "D:/Mods/Example",
|
||||
"complete": true,
|
||||
"stale": false
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Possible states:
|
||||
|
||||
- no_index: no index snapshot exists.
|
||||
- building: a rebuild is in progress.
|
||||
- ready_xml: XML assets are available but art assets may be incomplete.
|
||||
- ready: complete index.
|
||||
- stale: index may be outdated.
|
||||
- error: last build failed.
|
||||
|
||||
Always check \`index.projectDir\`. If it does not match the project you are
|
||||
working on, discard the result and read files directly instead.
|
||||
|
||||
## get_asset_references
|
||||
|
||||
Returns outgoing reference edges with full provenance:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"index": { "state": "ready", "projectDir": "D:/Mods/Example" },
|
||||
"data": {
|
||||
"roots": [{ "type": "GameObject", "id": "AthenaCannon", "file": "...", "line": 12 }],
|
||||
"edges": [
|
||||
{
|
||||
"depth": 1,
|
||||
"from": { "type": "GameObject", "id": "AthenaCannon" },
|
||||
"to": { "type": "WeaponTemplate", "id": "AthenaCannonWeapon", "file": "...", "line": 88 },
|
||||
"via": { "kind": "attribute", "element": "Weapon", "parent": "WeaponSlotHardpoint", "attribute": "Template" },
|
||||
"source": { "file": "D:/Mods/Example/Data/Allied/Units/AthenaCannon.xml", "line": 40, "character": 24 }
|
||||
}
|
||||
],
|
||||
"nodes": [],
|
||||
"truncated": false,
|
||||
"omittedByTargetType": {},
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`via.kind\` is one of \`attribute\`, \`content\` or \`inheritFrom\`. Edges carrying
|
||||
\`definedIn\` come from an \`inheritFrom\` ancestor's XML.
|
||||
|
||||
This tool requires a **live** index (VS Code open with the project indexed).
|
||||
It is not available from the on-disk snapshot because element context is not
|
||||
stored there. When live is unavailable the tool returns an explicit error
|
||||
instead of an empty result.
|
||||
`;
|
||||
|
||||
export interface SkillInstallRecord {
|
||||
/** Skill directory (the directory containing SKILL.md). */
|
||||
path: string;
|
||||
/** Extension version that installed/updated this copy. */
|
||||
sourceVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a managed copy of the Skill into `targetDir` (the directory that
|
||||
* should contain SKILL.md).
|
||||
*/
|
||||
export async function writeSkillTo(
|
||||
targetDir: string,
|
||||
sourceVersion: string,
|
||||
): Promise<void> {
|
||||
await mkdir(join(targetDir, "references"), { recursive: true });
|
||||
await writeFile(join(targetDir, "SKILL.md"), SKILL_MD, "utf8");
|
||||
await writeFile(
|
||||
join(targetDir, "references", "query-guide.md"),
|
||||
QUERY_GUIDE_MD,
|
||||
"utf8",
|
||||
);
|
||||
const marker: SkillInstallRecord = {
|
||||
path: targetDir,
|
||||
sourceVersion,
|
||||
};
|
||||
await writeFile(
|
||||
join(targetDir, SKILL_MARKER_FILE),
|
||||
JSON.stringify(marker, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/** Conventional ~/.agents/skills/<skill-name> path. */
|
||||
export function agentsSkillsDirForUser(home = homedir()): string {
|
||||
return join(home, ".agents", "skills", SKILL_NAME);
|
||||
}
|
||||
|
||||
/** Conventional ~/.claude/skills/<skill-name> path (Claude Code). */
|
||||
export function claudeSkillsDirForUser(home = homedir()): string {
|
||||
return join(home, ".claude", "skills", SKILL_NAME);
|
||||
}
|
||||
|
||||
/** Path to the managed-install record under the agent home. */
|
||||
export function skillInstallRecordPath(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "skill-install.json");
|
||||
}
|
||||
|
||||
/** Reads the managed skill install record, or returns an empty list. */
|
||||
export async function readSkillInstallRecord(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<SkillInstallRecord[]> {
|
||||
try {
|
||||
const text = await readFile(skillInstallRecordPath(agentHome), "utf8");
|
||||
const parsed = JSON.parse(text) as { installed?: SkillInstallRecord[] };
|
||||
return Array.isArray(parsed.installed) ? parsed.installed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the managed skill install record. */
|
||||
export async function writeSkillInstallRecord(
|
||||
installed: SkillInstallRecord[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
const file = skillInstallRecordPath(agentHome);
|
||||
await mkdir(dirname(file), { recursive: true });
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({ installed }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the Skill into several directories and records each managed copy.
|
||||
* Returns the directories successfully written.
|
||||
*/
|
||||
export async function installSkillToDirectories(
|
||||
directories: string[],
|
||||
sourceVersion: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<string[]> {
|
||||
const installed = await readSkillInstallRecord(agentHome);
|
||||
const succeeded: string[] = [];
|
||||
for (const dir of directories) {
|
||||
try {
|
||||
await writeSkillTo(dir, sourceVersion);
|
||||
if (!installed.some((r) => r.path === dir)) {
|
||||
installed.push({ path: dir, sourceVersion });
|
||||
} else {
|
||||
const record = installed.find((r) => r.path === dir);
|
||||
if (record) record.sourceVersion = sourceVersion;
|
||||
}
|
||||
succeeded.push(dir);
|
||||
} catch {
|
||||
// Keep going; caller can surface per-directory failures.
|
||||
}
|
||||
}
|
||||
await writeSkillInstallRecord(installed, agentHome);
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/** Removes one managed Skill directory and its install record entry. */
|
||||
export async function uninstallSkillFromDirectory(
|
||||
directory: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
const installed = (await readSkillInstallRecord(agentHome)).filter(
|
||||
(r) => r.path !== directory,
|
||||
);
|
||||
await writeSkillInstallRecord(installed, agentHome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the managed marker of a Skill directory.
|
||||
*
|
||||
* Returns null when the directory has no `.ra3modxml-skill.json`, or when the
|
||||
* marker does not describe that exact directory. A hand-copied or
|
||||
* third-party skill is therefore never treated as ours, which is what makes
|
||||
* uninstall/update safe.
|
||||
*/
|
||||
export async function readSkillMarker(
|
||||
directory: string,
|
||||
): Promise<SkillInstallRecord | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(join(directory, SKILL_MARKER_FILE), "utf8"),
|
||||
) as SkillInstallRecord;
|
||||
if (!parsed?.path) return null;
|
||||
if (resolve(parsed.path) !== resolve(directory)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstalledSkillStatus extends SkillInstallRecord {
|
||||
/** The recorded directory currently exists. */
|
||||
exists: boolean;
|
||||
/** The directory still carries our marker (safe to update/remove). */
|
||||
managed: boolean;
|
||||
}
|
||||
|
||||
/** Status of every recorded Skill copy (drives the manage/uninstall UI). */
|
||||
export async function installedSkillStatus(
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<InstalledSkillStatus[]> {
|
||||
const installed = await readSkillInstallRecord(agentHome);
|
||||
const out: InstalledSkillStatus[] = [];
|
||||
for (const record of installed) {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = (await stat(record.path)).isDirectory();
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
const managed = exists ? (await readSkillMarker(record.path)) != null : false;
|
||||
out.push({ ...record, exists, managed });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes recorded Skill copies that still carry our marker. Directories that
|
||||
* are missing, replaced by the user, or lack the marker are skipped and
|
||||
* reported instead of being deleted.
|
||||
*/
|
||||
export async function uninstallRecordedSkills(
|
||||
directories: readonly string[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<{ removed: string[]; skipped: string[] }> {
|
||||
const removed: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const dir of directories) {
|
||||
const marker = await readSkillMarker(dir);
|
||||
if (!marker) {
|
||||
skipped.push(dir);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await uninstallSkillFromDirectory(dir, agentHome);
|
||||
removed.push(dir);
|
||||
} catch {
|
||||
skipped.push(dir);
|
||||
}
|
||||
}
|
||||
return { removed, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops record entries for directories that are already gone, without
|
||||
* touching the filesystem. Used by the uninstall UI when a recorded copy no
|
||||
* longer exists.
|
||||
*/
|
||||
export async function forgetSkillInstallRecords(
|
||||
directories: readonly string[],
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<void> {
|
||||
const wanted = new Set(directories.map((d) => resolve(d)));
|
||||
const installed = (await readSkillInstallRecord(agentHome)).filter(
|
||||
(r) => !wanted.has(resolve(r.path)),
|
||||
);
|
||||
await writeSkillInstallRecord(installed, agentHome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-writes every recorded Skill copy with the current extension version.
|
||||
*
|
||||
* Copies that no longer carry our marker are never overwritten (that would
|
||||
* clobber user content), but stay in the record so the uninstall UI can still
|
||||
* show them. Records whose directory disappeared are dropped.
|
||||
*/
|
||||
export async function syncInstalledSkills(
|
||||
sourceVersion: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): Promise<SkillInstallRecord[]> {
|
||||
const installed = await readSkillInstallRecord(agentHome);
|
||||
const synced: SkillInstallRecord[] = [];
|
||||
for (const record of installed) {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = (await stat(record.path)).isDirectory();
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
if (!exists) continue; // Nothing left to manage.
|
||||
if (!(await readSkillMarker(record.path))) {
|
||||
synced.push(record); // User-owned now: keep the record, never rewrite.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await writeSkillTo(record.path, sourceVersion);
|
||||
synced.push({ path: record.path, sourceVersion });
|
||||
} catch {
|
||||
synced.push(record); // Keep so a later run can retry.
|
||||
}
|
||||
}
|
||||
await writeSkillInstallRecord(synced, agentHome);
|
||||
return synced;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Convert an internal ModIndex into a stable, external agent snapshot and
|
||||
* read/write those snapshots on disk.
|
||||
*
|
||||
* Pure TypeScript: no VS Code dependency, so CLI/MCP/tools can reuse this
|
||||
* module outside the extension.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { gunzip, gzip } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
import type { ModIndex } from "../indexer/types";
|
||||
import {
|
||||
AGENT_SNAPSHOT_SCHEMA_VERSION,
|
||||
type AgentIndexSnapshot,
|
||||
type AgentIndexStatus,
|
||||
} from "./types";
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const gunzipAsync = promisify(gunzip);
|
||||
|
||||
/** Default directory used for launcher/snapshots/skill installation state. */
|
||||
export function defaultAgentHome(): string {
|
||||
return join(homedir(), ".ra3modxml");
|
||||
}
|
||||
|
||||
/** Directory where current external snapshots are stored. */
|
||||
export function defaultSnapshotDir(agentHome = defaultAgentHome()): string {
|
||||
return join(agentHome, "snapshots");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, collision-resistant identity for one project directory.
|
||||
* Case-insensitive (Windows paths) and independent of the current drive
|
||||
* mapping case, so the same project always maps to the same key.
|
||||
*/
|
||||
export function projectHash(projectDir: string): string {
|
||||
return createHash("sha1")
|
||||
.update(resolve(projectDir).toLowerCase(), "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Short, filesystem-safe, human-readable prefix for a project (directory
|
||||
* basename, sanitized). Falls back to "project" when the basename has no
|
||||
* usable characters.
|
||||
*/
|
||||
export function projectSlug(projectDir: string): string {
|
||||
const raw = basename(resolve(projectDir));
|
||||
const slug = raw
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toLowerCase();
|
||||
return slug || "project";
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable, readable file name for one project:
|
||||
* `<slug>-<sha1-12>` (e.g. `corona-9f3a1c2b4d5e`).
|
||||
*/
|
||||
export function snapshotBaseName(projectDir: string): string {
|
||||
return `${projectSlug(projectDir)}-${projectHash(projectDir)}`;
|
||||
}
|
||||
|
||||
/** Converts the internal ModIndex to the stable external snapshot shape. */
|
||||
export function snapshotFromIndex(index: ModIndex, buildId?: number): AgentIndexSnapshot {
|
||||
const assets: AgentIndexSnapshot["assets"] = [];
|
||||
for (const byId of index.assets.values()) {
|
||||
for (const defs of byId.values()) {
|
||||
assets.push(...defs);
|
||||
}
|
||||
}
|
||||
|
||||
const defines: AgentIndexSnapshot["defines"] = [];
|
||||
for (const defs of index.defines.values()) {
|
||||
defines.push(...defs);
|
||||
}
|
||||
|
||||
const references: AgentIndexSnapshot["references"] = [];
|
||||
for (const [key, sites] of index.references) {
|
||||
const parts = key.split("\u0000");
|
||||
if (parts.length !== 4) continue;
|
||||
references.push({
|
||||
type: parts[0],
|
||||
id: parts[1],
|
||||
file: parts[2],
|
||||
line: Number(parts[3]) || 0,
|
||||
sites,
|
||||
});
|
||||
}
|
||||
|
||||
const streams: AgentIndexSnapshot["streams"] = index.streams.map((s) => ({
|
||||
name: s.name,
|
||||
entry: s.entry,
|
||||
files: [...s.files],
|
||||
}));
|
||||
|
||||
return {
|
||||
schemaVersion: AGENT_SNAPSHOT_SCHEMA_VERSION,
|
||||
projectDir: index.projectDir,
|
||||
sdkDir: index.sdkDir,
|
||||
phase: index.phase,
|
||||
complete: index.complete,
|
||||
stale: index.stale,
|
||||
generatedAt: new Date().toISOString(),
|
||||
buildId,
|
||||
stats: {
|
||||
assetCount: assets.length,
|
||||
referenceCount: index.references.size,
|
||||
defineCount: defines.length,
|
||||
fileCount: index.files.size,
|
||||
streamCount: streams.length,
|
||||
sourceCandidateCount: index.sourceCandidates.length,
|
||||
manifestFileCount: index.manifests.size,
|
||||
manifestAssetCount: index.stats.manifestAssetCount,
|
||||
},
|
||||
assets,
|
||||
defines,
|
||||
references,
|
||||
streams,
|
||||
sourceCandidates: index.sourceCandidates,
|
||||
diagnostics: index.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a status object for a missing/not-yet-built index. */
|
||||
export function noIndexStatus(projectDir?: string, detail?: string): AgentIndexStatus {
|
||||
return {
|
||||
state: "no_index",
|
||||
projectDir,
|
||||
detail: detail ?? "No index has been built yet.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a status object for the current index state. */
|
||||
export function statusFromIndex(index: ModIndex | null | undefined, projectDir?: string): AgentIndexStatus {
|
||||
if (!index) return noIndexStatus(projectDir);
|
||||
const state: AgentIndexStatus["state"] = index.stale
|
||||
? "stale"
|
||||
: !index.complete
|
||||
? "ready_xml"
|
||||
: "ready";
|
||||
return {
|
||||
state,
|
||||
projectDir: index.projectDir,
|
||||
phase: index.phase,
|
||||
complete: index.complete,
|
||||
stale: index.stale,
|
||||
generatedAt: new Date().toISOString(),
|
||||
stats: {
|
||||
assetCount: index.stats.assetCount,
|
||||
referenceCount: index.stats.referenceCount,
|
||||
defineCount: index.stats.defineCount,
|
||||
fileCount: index.stats.indexedFiles,
|
||||
streamCount: index.stats.streams,
|
||||
sourceCandidateCount: index.stats.sourceCandidates,
|
||||
manifestFileCount: index.stats.manifestFiles,
|
||||
manifestAssetCount: index.stats.manifestAssetCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Serializes a snapshot to a JSON string (not compressed). */
|
||||
export function snapshotToJson(snapshot: AgentIndexSnapshot): string {
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
/** Writes a snapshot as gzip-compressed JSON using atomic temp+rename. */
|
||||
export async function writeSnapshotFile(
|
||||
filePath: string,
|
||||
snapshot: AgentIndexSnapshot,
|
||||
): Promise<string> {
|
||||
const payload = Buffer.from(snapshotToJson(snapshot), "utf8");
|
||||
const buf = await gzipAsync(payload);
|
||||
const target = resolve(filePath);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
const tmp = `${target}.tmp`;
|
||||
await writeFile(tmp, buf);
|
||||
await rename(tmp, target);
|
||||
return target;
|
||||
}
|
||||
|
||||
/** Reads a gzip-compressed JSON snapshot written by writeSnapshotFile. */
|
||||
export async function readSnapshotFile(filePath: string): Promise<AgentIndexSnapshot | null> {
|
||||
try {
|
||||
const buf = await readFile(filePath);
|
||||
const text = (await gunzipAsync(buf)).toString("utf8");
|
||||
return JSON.parse(text) as AgentIndexSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the conventional snapshot path for a project under the agent home. */
|
||||
export function snapshotPathForProject(
|
||||
projectDir: string,
|
||||
agentHome = defaultAgentHome(),
|
||||
): string {
|
||||
return join(defaultSnapshotDir(agentHome), `${snapshotBaseName(projectDir)}.json.gz`);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Public, stable data types for exposing RA3 Mod XML indexes to AI Agents
|
||||
* and external tools.
|
||||
*
|
||||
* These types intentionally mirror the internal index model but use plain
|
||||
* serializable arrays instead of Maps/Sets. They are independent of the VS
|
||||
* Code API and of the extension's internal workspaceStorage layout.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AssetDef,
|
||||
DefineDef,
|
||||
IndexerDiagnostic,
|
||||
ReferenceSite,
|
||||
SourceCandidate,
|
||||
} from "../indexer/types";
|
||||
|
||||
/** Current external snapshot schema version. */
|
||||
export const AGENT_SNAPSHOT_SCHEMA_VERSION = 1;
|
||||
|
||||
export type AgentIndexState =
|
||||
| "no_index"
|
||||
| "building"
|
||||
| "ready_xml"
|
||||
| "ready"
|
||||
| "stale"
|
||||
| "error";
|
||||
|
||||
export interface AgentIndexStats {
|
||||
assetCount: number;
|
||||
referenceCount: number;
|
||||
defineCount: number;
|
||||
fileCount: number;
|
||||
streamCount: number;
|
||||
sourceCandidateCount: number;
|
||||
manifestFileCount: number;
|
||||
manifestAssetCount: number;
|
||||
}
|
||||
|
||||
export interface AgentAsset extends AssetDef {
|
||||
// AssetDef is already plain/serializable.
|
||||
}
|
||||
|
||||
export interface AgentDefine extends DefineDef {
|
||||
// DefineDef is already plain/serializable.
|
||||
}
|
||||
|
||||
/** A stream (static or global:<name>) with the normalized file paths in it. */
|
||||
export interface AgentStream {
|
||||
name: string;
|
||||
entry: string;
|
||||
files: string[];
|
||||
}
|
||||
|
||||
/** Reference sites grouped by the definition they point to. */
|
||||
export interface AgentReferenceGroup {
|
||||
type: string;
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
sites: ReferenceSite[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable, tool-facing snapshot of one project index.
|
||||
*/
|
||||
export interface AgentIndexSnapshot {
|
||||
schemaVersion: number;
|
||||
projectDir: string;
|
||||
sdkDir: string;
|
||||
/** Last finished phase: "xml" or "art". */
|
||||
phase: "xml" | "art";
|
||||
complete: boolean;
|
||||
stale?: boolean;
|
||||
generatedAt: string;
|
||||
/** Build counter from the workspace; useful for change detection. */
|
||||
buildId?: number;
|
||||
stats: AgentIndexStats;
|
||||
assets: AgentAsset[];
|
||||
defines: AgentDefine[];
|
||||
/**
|
||||
* Reverse references grouped by target definition key.
|
||||
* Consumers normally filter by `type` + `id`, then aggregate groups.
|
||||
*/
|
||||
references: AgentReferenceGroup[];
|
||||
streams: AgentStream[];
|
||||
sourceCandidates: SourceCandidate[];
|
||||
diagnostics: IndexerDiagnostic[];
|
||||
}
|
||||
|
||||
/** Status returned by query interfaces when an index may not be ready. */
|
||||
export interface AgentIndexStatus {
|
||||
state: AgentIndexState;
|
||||
projectDir?: string;
|
||||
phase?: "xml" | "art";
|
||||
complete?: boolean;
|
||||
stale?: boolean;
|
||||
generatedAt?: string;
|
||||
buildId?: number;
|
||||
stats?: AgentIndexStats;
|
||||
/** Human-readable explanation for no_index/error states. */
|
||||
detail?: string;
|
||||
}
|
||||
+749
-1
@@ -1,4 +1,7 @@
|
||||
import * as vscode from "vscode";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { stripBom } from "./language/xmlParser";
|
||||
import { ModWorkspace } from "./workspace";
|
||||
import { SdkSetup } from "./sdkSetup";
|
||||
import { Ra3CompletionProvider } from "./features/completion";
|
||||
@@ -15,12 +18,59 @@ import {
|
||||
findUnreferencedAssets,
|
||||
findUnreferencedAssetsOfType,
|
||||
} from "./features/unreferenced";
|
||||
import { findAsset } from "./features/findAsset";
|
||||
import { Ra3Diagnostics } from "./features/diagnostics";
|
||||
import {
|
||||
Ra3SemanticTokensProvider,
|
||||
RA3_SEMANTIC_TOKENS_LEGEND,
|
||||
} from "./features/semanticTokens";
|
||||
import { t } from "./localize";
|
||||
import {
|
||||
snapshotFromIndex,
|
||||
snapshotPathForProject,
|
||||
writeSnapshotFile,
|
||||
} from "./agent/snapshot";
|
||||
import {
|
||||
claudeDesktopConfigPath,
|
||||
cursorGlobalConfigPath,
|
||||
cursorProjectConfigPath,
|
||||
installMcpServerConfigToFile,
|
||||
launcherPath,
|
||||
mcpConfigJson,
|
||||
removeLauncher,
|
||||
uninstallMcpServerConfigs,
|
||||
writeLauncher,
|
||||
} from "./agent/setup";
|
||||
import {
|
||||
SKILL_NAME,
|
||||
agentsSkillsDirForUser,
|
||||
claudeSkillsDirForUser,
|
||||
forgetSkillInstallRecords,
|
||||
installSkillToDirectories,
|
||||
installedSkillStatus,
|
||||
readSkillInstallRecord,
|
||||
syncInstalledSkills,
|
||||
uninstallRecordedSkills,
|
||||
} from "./agent/skill";
|
||||
import {
|
||||
shouldOfferAgentOnboarding,
|
||||
type AgentOnboardingState,
|
||||
} from "./agent/onboarding";
|
||||
import { startLocalServer, type LocalServerHandle } from "./agent/localServer";
|
||||
import { parseLoadedXml } from "./agent/forwardRefs";
|
||||
import {
|
||||
clearEndpointForProject,
|
||||
writeEndpoint,
|
||||
writeEndpointForProject,
|
||||
} from "./agent/endpoint";
|
||||
import {
|
||||
clearInstance,
|
||||
makeInstanceId,
|
||||
pruneInstances,
|
||||
refreshDiscovery,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "./agent/instances";
|
||||
|
||||
const XML_SELECTOR: vscode.DocumentSelector = [{ language: "xml" }];
|
||||
/** Safety-net refresh interval while a rebuild is running. */
|
||||
@@ -96,9 +146,232 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
ws.log("[codelens] retry started");
|
||||
};
|
||||
ws.onBuildStart = startCodeLensRetry;
|
||||
/** Running extension version (used by the agent onboarding / upgrade sync). */
|
||||
const extensionVersion = String(
|
||||
(context.extension.packageJSON as { version?: string }).version ?? "dev",
|
||||
);
|
||||
// Coalesced agent snapshot refresh: after AI Agent access is enabled, keep
|
||||
// the external snapshot current without writing on every intermediate
|
||||
// rebuild. The timer only fires after a quiet period following a complete,
|
||||
// non-stale final index.
|
||||
let agentAccessEnabled = context.workspaceState.get<boolean>(
|
||||
"ra3modxml.agentAccessEnabled",
|
||||
false,
|
||||
);
|
||||
const AGENT_ONBOARDING_KEY = "ra3modxml.agentOnboarding";
|
||||
/** Session guard: one notification at most per activation. */
|
||||
let agentOnboardingChecked = false;
|
||||
/**
|
||||
* One-time introduction of the AI Agent feature. Shown after the first
|
||||
* index for this workspace, once per machine (see agent/onboarding.ts for
|
||||
* the anti-nag rules). Choosing an action is optional and silent.
|
||||
*/
|
||||
const maybeOfferAgentOnboarding = async (): Promise<void> => {
|
||||
if (agentOnboardingChecked) return;
|
||||
if (!ws.activeIndex()) return;
|
||||
const state = context.globalState.get<AgentOnboardingState>(
|
||||
AGENT_ONBOARDING_KEY,
|
||||
);
|
||||
if (!shouldOfferAgentOnboarding(state, extensionVersion)) {
|
||||
agentOnboardingChecked = true;
|
||||
return;
|
||||
}
|
||||
agentOnboardingChecked = true;
|
||||
const enable = t("Enable AI Agent access…");
|
||||
const learnMore = t("Learn more");
|
||||
const never = t("Don't show again");
|
||||
const pick = await vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: this version can expose the project's asset index to AI agents (MCP + Agent Skill). Enable it?",
|
||||
),
|
||||
enable,
|
||||
learnMore,
|
||||
never,
|
||||
);
|
||||
// Whatever the user chose (including ignoring the message), remember that
|
||||
// this version already informed them so it never becomes a recurring nag.
|
||||
await context.globalState.update(AGENT_ONBOARDING_KEY, {
|
||||
informedVersion: extensionVersion,
|
||||
dismissed: pick === never,
|
||||
} satisfies AgentOnboardingState);
|
||||
if (pick === enable) {
|
||||
void vscode.commands.executeCommand("ra3modxml.enableAgentAccess");
|
||||
} else if (pick === learnMore) {
|
||||
void vscode.env.openExternal(
|
||||
vscode.Uri.parse(
|
||||
"https://github.com/RA3CoronaDevelopers/Ra3ModXmlExt#ai-agent-access",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
const AGENT_SNAPSHOT_QUIET_MS = 5000;
|
||||
let agentSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleAgentSnapshot = (): void => {
|
||||
if (!agentAccessEnabled) return;
|
||||
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
|
||||
agentSnapshotTimer = setTimeout(() => {
|
||||
agentSnapshotTimer = null;
|
||||
const current = ws.activeIndex();
|
||||
if (!current?.complete || current.stale === true) return;
|
||||
void writeSnapshotFile(
|
||||
snapshotPathForProject(current.projectDir),
|
||||
snapshotFromIndex(current, ws.buildCount),
|
||||
).catch((err) => {
|
||||
ws.log(
|
||||
`[agent-snapshot] export failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
}, AGENT_SNAPSHOT_QUIET_MS);
|
||||
};
|
||||
let agentLocalServer: LocalServerHandle | null = null;
|
||||
/** Project roots whose per-project endpoint file this window wrote. */
|
||||
const agentEndpointProjects = new Set<string>();
|
||||
/**
|
||||
* This window's own instance id. Each window writes only its own file under
|
||||
* `instances/`, which is what removes the need for locking/merging between
|
||||
* concurrently running VS Code windows.
|
||||
*/
|
||||
const agentInstanceId = makeInstanceId();
|
||||
const startAgentLocalServer = async (): Promise<void> => {
|
||||
if (agentLocalServer) return;
|
||||
try {
|
||||
// Clean up instances left behind by crashed windows. Any instance can do
|
||||
// this, so a crash does not have to wait for the same workspace to be
|
||||
// reopened before its stale entry disappears.
|
||||
const pruned = await pruneInstances().catch(() => ({ removed: [], kept: [] }));
|
||||
if (pruned.removed.length) {
|
||||
ws.log(
|
||||
`[agent] pruned ${pruned.removed.length} stale instance(s): ${pruned.removed.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const handle = await startLocalServer({
|
||||
// Route by explicit project so a query can never be answered by
|
||||
// whichever project the active editor happens to point at.
|
||||
getIndex: (projectDir) =>
|
||||
projectDir ? ws.indexForProject(projectDir) : ws.activeIndex(),
|
||||
listProjects: () => ws.getProjectRoots(),
|
||||
loadFile: async (file) => {
|
||||
const text = stripBom(await readFile(file, "utf8"));
|
||||
return parseLoadedXml(text);
|
||||
},
|
||||
});
|
||||
agentLocalServer = handle;
|
||||
const url = `http://127.0.0.1:${handle.port}`;
|
||||
const projects = ws.getProjectRoots();
|
||||
const endpoint = {
|
||||
instanceId: agentInstanceId,
|
||||
url,
|
||||
token: handle.token,
|
||||
projectDir: ws.projectRoot ?? undefined,
|
||||
projects,
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// One file per project, so two open windows cannot shadow each other.
|
||||
for (const project of projects) {
|
||||
const file = await writeEndpointForProject(project, {
|
||||
...endpoint,
|
||||
projectDir: project,
|
||||
});
|
||||
agentEndpointProjects.add(project);
|
||||
ws.log(`[agent-local-server] endpoint for ${project} -> ${file}`);
|
||||
}
|
||||
// This window's own instance file (authoritative for liveness/pruning).
|
||||
const instanceFile = await writeInstance(endpoint);
|
||||
ws.log(`[agent-local-server] instance -> ${instanceFile}`);
|
||||
// Legacy/global pointer for tooling that does not know the project.
|
||||
await writeEndpoint(endpoint);
|
||||
await writeManifest(await refreshInstanceFiles(url, handle.token, projects));
|
||||
ws.log(`[agent-local-server] listening on ${url}`);
|
||||
} catch (err) {
|
||||
ws.log(
|
||||
`[agent-local-server] failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Re-reads every live instance file (including other windows') and rewrites
|
||||
* the merged discovery manifest.
|
||||
*/
|
||||
const refreshInstanceFiles = async (
|
||||
url: string,
|
||||
token: string,
|
||||
projects: string[],
|
||||
) => {
|
||||
const instance = {
|
||||
instanceId: agentInstanceId,
|
||||
url,
|
||||
token,
|
||||
projectDir: ws.projectRoot ?? undefined,
|
||||
projects,
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeInstance(instance).catch(() => undefined);
|
||||
const { kept } = await pruneInstances().catch(() => ({
|
||||
removed: [],
|
||||
kept: [] as typeof instance[],
|
||||
}));
|
||||
// Ensure this window is present even if its file was just pruned/written.
|
||||
const others = kept.filter((k) => k.instanceId !== agentInstanceId);
|
||||
return [...others, instance];
|
||||
};
|
||||
const stopAgentLocalServer = async (): Promise<void> => {
|
||||
if (agentLocalServer) {
|
||||
const server = agentLocalServer;
|
||||
agentLocalServer = null;
|
||||
await server.close().catch(() => undefined);
|
||||
}
|
||||
// Only remove what this window owns: another VS Code window may still be
|
||||
// serving its own projects.
|
||||
for (const project of agentEndpointProjects) {
|
||||
await clearEndpointForProject(project).catch(() => undefined);
|
||||
}
|
||||
agentEndpointProjects.clear();
|
||||
await clearInstance(agentInstanceId).catch(() => undefined);
|
||||
// Other VS Code windows may still be serving an index. Re-derive the
|
||||
// legacy global pointer and the merged manifest from the surviving
|
||||
// instance files instead of clearing them, so closing this window never
|
||||
// hides a still-running window from agents reading ~/.ra3modxml/index.json.
|
||||
await refreshDiscovery().catch(() => undefined);
|
||||
};
|
||||
/**
|
||||
* Republishes this window's endpoint/instance files for every project it now
|
||||
* knows about. Called on each index update so projects discovered later get
|
||||
* an endpoint without restarting the server, and so the merged manifest is
|
||||
* refreshed.
|
||||
*/
|
||||
const refreshAgentEndpoints = async (): Promise<void> => {
|
||||
if (!agentLocalServer) return;
|
||||
const url = `http://127.0.0.1:${agentLocalServer.port}`;
|
||||
const token = agentLocalServer.token;
|
||||
const projects = ws.getProjectRoots();
|
||||
const base = {
|
||||
url,
|
||||
token,
|
||||
projects,
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
for (const project of projects) {
|
||||
try {
|
||||
await writeEndpointForProject(project, { ...base, projectDir: project });
|
||||
agentEndpointProjects.add(project);
|
||||
} catch (err) {
|
||||
ws.log(
|
||||
`[agent-local-server] could not write endpoint for ${project}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const instances = await refreshInstanceFiles(url, token, projects);
|
||||
await writeManifest(instances);
|
||||
};
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
if (codeLensRetryTimer) clearInterval(codeLensRetryTimer);
|
||||
if (agentSnapshotTimer) clearTimeout(agentSnapshotTimer);
|
||||
void stopAgentLocalServer();
|
||||
},
|
||||
});
|
||||
context.subscriptions.push(
|
||||
@@ -123,6 +396,9 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
`[codelens] refresh (project=${idx.stats.projectDir}, phase=${idx.phase}, assets=${idx.stats.assetCount}, complete=${idx.complete}, stale=${idx.stale === true})`,
|
||||
);
|
||||
}
|
||||
scheduleAgentSnapshot();
|
||||
void refreshAgentEndpoints();
|
||||
void maybeOfferAgentOnboarding();
|
||||
for (const doc of vscode.workspace.textDocuments) {
|
||||
if (doc.languageId === "xml") void diagnostics.update(doc);
|
||||
}
|
||||
@@ -282,6 +558,408 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.exportIndexSnapshot", async () => {
|
||||
const idx = ws.activeIndex();
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before exporting an AI Agent snapshot.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const path = snapshotPathForProject(idx.projectDir);
|
||||
try {
|
||||
const snapshot = snapshotFromIndex(idx, ws.buildCount);
|
||||
await writeSnapshotFile(path, snapshot);
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: exported AI Agent index snapshot to {0}",
|
||||
path,
|
||||
),
|
||||
t("Reveal in Explorer"),
|
||||
).then((pick) => {
|
||||
if (pick) void vscode.commands.executeCommand("revealInExplorer", vscode.Uri.file(path));
|
||||
});
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(
|
||||
t(
|
||||
"RA3 Mod XML: failed to export AI Agent index snapshot: {0}",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.enableAgentAccess", async () => {
|
||||
const idx = ws.activeIndex();
|
||||
if (!idx) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no index available yet. Wait for indexing to finish before enabling AI Agent access.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const projectDir = idx.projectDir;
|
||||
try {
|
||||
const snapshot = snapshotFromIndex(idx, ws.buildCount);
|
||||
await writeSnapshotFile(snapshotPathForProject(projectDir), snapshot);
|
||||
const launcher = await writeLauncher(
|
||||
context.extensionUri.fsPath,
|
||||
projectDir,
|
||||
);
|
||||
const configJson = mcpConfigJson(launcher.path, projectDir);
|
||||
ws.log(
|
||||
`[agent] launcher runtime=${launcher.runtime.kind} (${launcher.runtime.executable}), nodeFree=${launcher.nodeFree}`,
|
||||
);
|
||||
if (!launcher.nodeFree) {
|
||||
void vscode.window.showWarningMessage(
|
||||
t(
|
||||
"RA3 Mod XML: the AI Agent launcher will use Node from PATH. Install Node, or run VS Code from a normal installation so the bundled runtime can be used.",
|
||||
),
|
||||
);
|
||||
}
|
||||
agentAccessEnabled = true;
|
||||
await context.workspaceState.update("ra3modxml.agentAccessEnabled", true);
|
||||
void startAgentLocalServer();
|
||||
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
|
||||
const installSkill = t("Install Agent Skill (recommended)");
|
||||
const writeClaude = t("Write MCP config to Claude Desktop");
|
||||
const writeCursorGlobal = t("Write MCP config to Cursor (global)");
|
||||
const writeCursorProject = t("Write MCP config to Cursor (project)");
|
||||
const copyConfig = t("Copy MCP config");
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
[
|
||||
{
|
||||
label: installSkill,
|
||||
description: agentsSkillsDirForUser(),
|
||||
id: "skill",
|
||||
},
|
||||
{
|
||||
label: writeClaude,
|
||||
description: claudeDesktopConfigPath(),
|
||||
id: "claude",
|
||||
},
|
||||
{
|
||||
label: writeCursorGlobal,
|
||||
description: cursorGlobalConfigPath(),
|
||||
id: "cursor-global",
|
||||
},
|
||||
{
|
||||
label: writeCursorProject,
|
||||
description: cursorProjectConfigPath(projectDir),
|
||||
id: "cursor-project",
|
||||
},
|
||||
{
|
||||
label: copyConfig,
|
||||
id: "copy",
|
||||
},
|
||||
],
|
||||
{
|
||||
placeHolder: t("RA3 Mod XML AI Agent access enabled. Choose an optional next step."),
|
||||
},
|
||||
);
|
||||
if (pick?.id === "skill") {
|
||||
const target = agentsSkillsDirForUser();
|
||||
const installed = await installSkillToDirectories([target], version);
|
||||
if (installed.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML Agent Skill installed to {0}", target),
|
||||
);
|
||||
} else {
|
||||
void vscode.window.showErrorMessage(
|
||||
t("RA3 Mod XML: could not write the Agent Skill to {0}", target),
|
||||
);
|
||||
}
|
||||
} else if (pick?.id === "claude") {
|
||||
await installMcpServerConfigToFile({
|
||||
filePath: claudeDesktopConfigPath(),
|
||||
launcher: launcher.path,
|
||||
projectDir,
|
||||
label: "Claude Desktop",
|
||||
sourceVersion: version,
|
||||
});
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", claudeDesktopConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-global") {
|
||||
await installMcpServerConfigToFile({
|
||||
filePath: cursorGlobalConfigPath(),
|
||||
launcher: launcher.path,
|
||||
projectDir,
|
||||
label: "Cursor (global)",
|
||||
sourceVersion: version,
|
||||
});
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorGlobalConfigPath()),
|
||||
);
|
||||
} else if (pick?.id === "cursor-project") {
|
||||
await installMcpServerConfigToFile({
|
||||
filePath: cursorProjectConfigPath(projectDir),
|
||||
launcher: launcher.path,
|
||||
projectDir,
|
||||
label: "Cursor (project)",
|
||||
sourceVersion: version,
|
||||
});
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config written to {0}", cursorProjectConfigPath(projectDir)),
|
||||
);
|
||||
} else if (pick?.id === "copy") {
|
||||
await vscode.env.clipboard.writeText(configJson);
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML MCP config copied to clipboard."),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
void vscode.window.showErrorMessage(
|
||||
t(
|
||||
"RA3 Mod XML: failed to enable AI Agent access: {0}",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.installAgentSkill", async () => {
|
||||
// Installing the Skill only writes text files; it does not need an
|
||||
// index, so users can prepare their agent tooling before the first build.
|
||||
const projectDir = ws.projectRoot;
|
||||
const version = String((context.extension.packageJSON as { version?: string }).version ?? "dev");
|
||||
const choices: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
target: string | null;
|
||||
picked?: boolean;
|
||||
}> = [
|
||||
{
|
||||
label: t("Default (~/.agents/skills)"),
|
||||
description: agentsSkillsDirForUser(),
|
||||
target: agentsSkillsDirForUser(),
|
||||
picked: true,
|
||||
},
|
||||
{
|
||||
label: t("Claude Code (~/.claude/skills)"),
|
||||
description: claudeSkillsDirForUser(),
|
||||
target: claudeSkillsDirForUser(),
|
||||
},
|
||||
];
|
||||
if (projectDir) {
|
||||
choices.push(
|
||||
{
|
||||
label: t("Current project .agents/skills"),
|
||||
description: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||
target: join(projectDir, ".agents", "skills", SKILL_NAME),
|
||||
},
|
||||
{
|
||||
label: t("Current project .claude/skills"),
|
||||
description: join(projectDir, ".claude", "skills", SKILL_NAME),
|
||||
target: join(projectDir, ".claude", "skills", SKILL_NAME),
|
||||
},
|
||||
);
|
||||
}
|
||||
choices.push({
|
||||
label: t("Choose a custom folder…"),
|
||||
description: t("The skill is installed as <folder>/{0}", SKILL_NAME),
|
||||
target: null,
|
||||
});
|
||||
const picked = await vscode.window.showQuickPick(choices, {
|
||||
canPickMany: true,
|
||||
placeHolder: t("Select Agent Skill install locations"),
|
||||
});
|
||||
if (!picked?.length) return;
|
||||
|
||||
const targets = picked
|
||||
.map((p) => p.target)
|
||||
.filter((p): p is string => p != null);
|
||||
if (picked.some((p) => p.target == null)) {
|
||||
const folder = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: t("Choose a skill folder"),
|
||||
title: t("Choose the folder that should contain the {0} skill", SKILL_NAME),
|
||||
});
|
||||
const dir = folder?.[0]?.fsPath;
|
||||
if (dir) targets.push(join(dir, SKILL_NAME));
|
||||
}
|
||||
if (!targets.length) return;
|
||||
|
||||
const succeeded = await installSkillToDirectories(targets, version);
|
||||
const failed = targets.length - succeeded.length;
|
||||
if (!succeeded.length) {
|
||||
void vscode.window.showErrorMessage(
|
||||
t("RA3 Mod XML: could not write the Agent Skill (0 of {0} locations).", targets.length),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void vscode.window.showInformationMessage(
|
||||
failed > 0
|
||||
? t(
|
||||
"RA3 Mod XML Agent Skill installed to {0} location(s); {1} failed.",
|
||||
succeeded.length,
|
||||
failed,
|
||||
)
|
||||
: t("RA3 Mod XML Agent Skill installed to {0} location(s).", succeeded.length),
|
||||
t("Show installed skills"),
|
||||
).then((pick) => {
|
||||
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentSkill");
|
||||
});
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.uninstallAgentSkill", async () => {
|
||||
const installed = await installedSkillStatus();
|
||||
if (!installed.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML: no recorded Agent Skill installation to remove."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const picks = await vscode.window.showQuickPick(
|
||||
installed.map((entry) => ({
|
||||
label: entry.path,
|
||||
description: !entry.exists
|
||||
? t("missing — will be dropped from the record")
|
||||
: entry.managed
|
||||
? t("installed by this extension (v{0})", entry.sourceVersion)
|
||||
: t("not managed by this extension — will be skipped"),
|
||||
detail: entry.managed || !entry.exists ? undefined : t("No RA3 Mod XML skill marker found; remove it manually if you want it gone."),
|
||||
path: entry.path,
|
||||
picked: entry.exists && entry.managed,
|
||||
})),
|
||||
{
|
||||
canPickMany: true,
|
||||
placeHolder: t("Select Agent Skill installations to remove"),
|
||||
},
|
||||
);
|
||||
if (!picks?.length) return;
|
||||
const selected = picks.map((p) => p.path);
|
||||
// A record whose directory is already gone only needs its record entry
|
||||
// dropped; a managed directory is deleted by uninstallRecordedSkills.
|
||||
const missing = installed
|
||||
.filter((e) => selected.includes(e.path) && !e.exists)
|
||||
.map((e) => e.path);
|
||||
const { removed, skipped } = await uninstallRecordedSkills(
|
||||
selected.filter((p) => !missing.includes(p)),
|
||||
);
|
||||
if (missing.length) {
|
||||
await forgetSkillInstallRecords(missing);
|
||||
}
|
||||
void vscode.window.showInformationMessage(
|
||||
skipped.length
|
||||
? t(
|
||||
"RA3 Mod XML: removed {0} Agent Skill installation(s); {1} skipped (not managed by this extension).",
|
||||
removed.length,
|
||||
skipped.length,
|
||||
)
|
||||
: t("RA3 Mod XML: removed {0} Agent Skill installation(s).", removed.length),
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.disableAgentAccess", async () => {
|
||||
agentAccessEnabled = false;
|
||||
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
|
||||
// Stops the loopback server and removes this window's instance /
|
||||
// endpoint files; discovery files are re-derived from other windows.
|
||||
await stopAgentLocalServer();
|
||||
ws.log("[agent] AI Agent access disabled for this workspace");
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: AI Agent access disabled for this workspace. Installed skills and MCP client configurations were kept.",
|
||||
),
|
||||
t("Uninstall AI Agent integration…"),
|
||||
).then((pick) => {
|
||||
if (pick) void vscode.commands.executeCommand("ra3modxml.uninstallAgentIntegration");
|
||||
});
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.uninstallAgentIntegration", async () => {
|
||||
const projectDir = ws.projectRoot;
|
||||
const skills = await installedSkillStatus();
|
||||
const removableSkills = skills.filter((s) => s.exists);
|
||||
const live = agentAccessEnabled || agentLocalServer != null;
|
||||
const choices: Array<{
|
||||
label: string;
|
||||
description?: string;
|
||||
id: "disable" | "skills" | "mcp" | "launcher";
|
||||
picked?: boolean;
|
||||
}> = [];
|
||||
if (live) {
|
||||
choices.push({
|
||||
label: t("Stop live AI Agent access in this window"),
|
||||
id: "disable",
|
||||
picked: true,
|
||||
});
|
||||
}
|
||||
if (removableSkills.length) {
|
||||
choices.push({
|
||||
label: t("Remove installed Agent Skills"),
|
||||
description: t("{0} location(s)", removableSkills.length),
|
||||
id: "skills",
|
||||
picked: true,
|
||||
});
|
||||
}
|
||||
choices.push(
|
||||
{
|
||||
label: t("Remove MCP client configuration entries"),
|
||||
description: t("Claude Desktop / Cursor and files recorded by this extension"),
|
||||
id: "mcp",
|
||||
picked: true,
|
||||
},
|
||||
{
|
||||
label: t("Remove the stable MCP launcher"),
|
||||
description: launcherPath(),
|
||||
id: "launcher",
|
||||
},
|
||||
);
|
||||
const picked = await vscode.window.showQuickPick(choices, {
|
||||
canPickMany: true,
|
||||
placeHolder: t("Select what to remove (nothing is removed until you confirm)"),
|
||||
});
|
||||
if (!picked?.length) return;
|
||||
|
||||
const confirm = await vscode.window.showWarningMessage(
|
||||
t("Remove the selected AI Agent components?"),
|
||||
{ modal: true },
|
||||
t("Remove"),
|
||||
);
|
||||
if (confirm !== t("Remove")) return;
|
||||
|
||||
const summary: string[] = [];
|
||||
if (picked.some((p) => p.id === "disable")) {
|
||||
agentAccessEnabled = false;
|
||||
await context.workspaceState.update("ra3modxml.agentAccessEnabled", false);
|
||||
await stopAgentLocalServer();
|
||||
summary.push(t("live access stopped"));
|
||||
}
|
||||
if (picked.some((p) => p.id === "skills")) {
|
||||
const { removed, skipped } = await uninstallRecordedSkills(
|
||||
removableSkills.filter((s) => s.managed).map((s) => s.path),
|
||||
);
|
||||
summary.push(t("{0} skill installation(s) removed", removed.length));
|
||||
if (skipped.length) {
|
||||
summary.push(t("{0} unmanaged skill folder(s) skipped", skipped.length));
|
||||
}
|
||||
}
|
||||
if (picked.some((p) => p.id === "mcp")) {
|
||||
const changed = await uninstallMcpServerConfigs({ projectDir });
|
||||
summary.push(t("MCP config removed from {0} file(s)", changed.length));
|
||||
}
|
||||
if (picked.some((p) => p.id === "launcher")) {
|
||||
await removeLauncher();
|
||||
summary.push(t("MCP launcher removed"));
|
||||
}
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML AI Agent cleanup: {0}.", summary.join("; ")),
|
||||
);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.showReferences",
|
||||
@@ -289,6 +967,21 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
void showReferencesForDef(ws, args),
|
||||
),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("ra3modxml.findAsset", () => {
|
||||
// Prefill with a single-line selection so the editor context menu entry
|
||||
// can search for the id the user highlighted.
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
const selected = editor
|
||||
? editor.document.getText(editor.selection).trim()
|
||||
: "";
|
||||
const initial =
|
||||
selected && !/[\r\n]/.test(selected) && selected.length <= 200
|
||||
? selected
|
||||
: "";
|
||||
return findAsset(ws, initial);
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"ra3modxml.findUnreferencedAssets",
|
||||
@@ -302,8 +995,63 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Refreshes the stable launcher for every known project. Called on
|
||||
* activation for already-enabled workspaces so a VS Code update (or an
|
||||
* extension update) repoints the launcher at the current runtime without
|
||||
* the user having to re-run the enable command.
|
||||
*/
|
||||
const refreshLaunchers = async (): Promise<void> => {
|
||||
const projects = ws.getProjectRoots();
|
||||
if (!projects.length) return;
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const launcher = await writeLauncher(
|
||||
context.extensionUri.fsPath,
|
||||
project,
|
||||
);
|
||||
ws.log(
|
||||
`[agent] refreshed launcher for ${project}: runtime=${launcher.runtime.kind}, nodeFree=${launcher.nodeFree}`,
|
||||
);
|
||||
} catch (err) {
|
||||
ws.log(
|
||||
`[agent] could not refresh launcher for ${project}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (agentAccessEnabled) {
|
||||
void startAgentLocalServer();
|
||||
void refreshLaunchers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps recorded Skill copies in sync after an extension upgrade. Only runs
|
||||
* when a recorded copy is at a different version, and never rewrites a
|
||||
* directory that no longer carries our marker.
|
||||
*/
|
||||
void (async () => {
|
||||
try {
|
||||
const installed = await readSkillInstallRecord();
|
||||
if (!installed.some((r) => r.sourceVersion !== extensionVersion)) return;
|
||||
const synced = await syncInstalledSkills(extensionVersion);
|
||||
ws.log(
|
||||
`[agent] synced ${synced.length} installed skill(s) to v${extensionVersion}`,
|
||||
);
|
||||
} catch {
|
||||
// Best effort: the install/uninstall commands can always repair this.
|
||||
}
|
||||
})();
|
||||
|
||||
void sdkSetup.evaluate(ws);
|
||||
void ws.initialize().then(() => void sdkSetup.evaluate(ws));
|
||||
void ws.initialize().then(() => {
|
||||
void sdkSetup.evaluate(ws);
|
||||
if (agentAccessEnabled) {
|
||||
void startAgentLocalServer();
|
||||
void refreshLaunchers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Pure asset-search core for the "Find asset…" command.
|
||||
*
|
||||
* A query is matched against the live `ModIndex` maps (type -> id ->
|
||||
* definitions) plus the document-local overlay, so assets that only exist in
|
||||
* the current file's include chain (including unsaved edits) are searchable
|
||||
* too. Keeping the parsing/matching/ranking logic free of the VS Code API
|
||||
* makes it unit-testable and reusable by other surfaces (CLI, tools).
|
||||
*
|
||||
* Supported query forms (all case-insensitive):
|
||||
* - `Type:Id` — type filter plus id filter (both may be partial);
|
||||
* - `Id` — exact / prefix / substring id match; type names match as
|
||||
* well, so `GameObject` also lists the assets of that type;
|
||||
* - `Type:` — every asset of that type.
|
||||
*
|
||||
* Manifest-style qualified names can carry several colon segments
|
||||
* (`W3DContainer:W3DContainer:AUGunship`). Asset ids never contain ":", so the
|
||||
* segment after the last colon is the id, exactly like `normalizeReferenceId`.
|
||||
*/
|
||||
|
||||
import type { AssetDef, ModIndex } from "../indexer/types";
|
||||
|
||||
/** Default result cap; the UI reports how many matches were hidden. */
|
||||
export const ASSET_SEARCH_LIMIT = 200;
|
||||
|
||||
export interface AssetSearchQuery {
|
||||
/** Trimmed original text (used in messages). */
|
||||
raw: string;
|
||||
/** Lowercased type filter, or null when the query has no `Type:` part. */
|
||||
type: string | null;
|
||||
/** Lowercased id filter; "" when the query only filters by type. */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One searchable asset (a `type:id` pair). The same id can be defined in
|
||||
* several places (project + SDK + manifest, or duplicated files); the pair is
|
||||
* what users search for, so the list is de-duplicated and the best-ranked
|
||||
* definition is kept while `definitionCount` records the other sites.
|
||||
*/
|
||||
export interface AssetSearchCandidate {
|
||||
def: AssetDef;
|
||||
/** Distinct definition sites (file + line) for this type:id. */
|
||||
definitionCount: number;
|
||||
}
|
||||
|
||||
export interface AssetSearchResult {
|
||||
matches: AssetSearchCandidate[];
|
||||
/** Total matches before the limit was applied. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** `0` exact, `1` prefix, `2` substring, `3` no match. */
|
||||
const RANK_NONE = 3;
|
||||
|
||||
/** Parses a user query into a type filter and an id filter. */
|
||||
export function parseAssetSearchQuery(rawInput: string): AssetSearchQuery {
|
||||
const raw = rawInput.trim();
|
||||
const colon = raw.indexOf(":");
|
||||
if (colon < 0) return { raw, type: null, id: raw.toLowerCase() };
|
||||
|
||||
const type = raw.slice(0, colon).trim().toLowerCase();
|
||||
const rest = raw.slice(colon + 1).trim();
|
||||
const lastColon = rest.lastIndexOf(":");
|
||||
const id = (lastColon >= 0 ? rest.slice(lastColon + 1) : rest)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return { raw, type: type || null, id };
|
||||
}
|
||||
|
||||
/** True when the query asks for nothing (the UI shows only the placeholder). */
|
||||
export function isEmptyAssetSearchQuery(query: AssetSearchQuery): boolean {
|
||||
return !query.type && !query.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower rank = better. Document-local (unsaved) definitions win, then mod
|
||||
* definitions, then SDK sources, then compiled manifests.
|
||||
*/
|
||||
export function assetOriginRank(def: AssetDef): number {
|
||||
if (def.stream === "local") return 0;
|
||||
switch (def.origin) {
|
||||
case "project":
|
||||
return 1;
|
||||
case "sdk":
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the index (and the optional local overlay) into one de-duplicated
|
||||
* candidate per `type:id`, keeping the best-ranked definition for each pair.
|
||||
*/
|
||||
export function collectAssetSearchCandidates(
|
||||
index: ModIndex,
|
||||
): AssetSearchCandidate[] {
|
||||
const byKey = new Map<
|
||||
string,
|
||||
{ def: AssetDef; sites: Set<string>; count: number }
|
||||
>();
|
||||
|
||||
const add = (def: AssetDef): void => {
|
||||
const key = `${def.type}\u0000${def.id.toLowerCase()}`;
|
||||
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = { def, sites: new Set(), count: 0 };
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
if (entry.sites.has(site)) return;
|
||||
entry.sites.add(site);
|
||||
entry.count++;
|
||||
if (assetOriginRank(def) < assetOriginRank(entry.def)) entry.def = def;
|
||||
};
|
||||
|
||||
// Local overlay first: on equal origin rank the first definition wins, and
|
||||
// the overlay carries the freshest text of the current file chain.
|
||||
if (index.local) {
|
||||
for (const byId of index.local.assets.values()) {
|
||||
for (const defs of byId.values()) for (const def of defs) add(def);
|
||||
}
|
||||
}
|
||||
for (const byId of index.assets.values()) {
|
||||
for (const defs of byId.values()) for (const def of defs) add(def);
|
||||
}
|
||||
|
||||
return [...byKey.values()].map((entry) => ({
|
||||
def: entry.def,
|
||||
definitionCount: entry.count,
|
||||
}));
|
||||
}
|
||||
|
||||
interface ScoredCandidate {
|
||||
candidate: AssetSearchCandidate;
|
||||
/** Lower is better: how strongly the query matched. */
|
||||
tier: number;
|
||||
/** id match rank (RANK_NONE when the query has no id part). */
|
||||
idRank: number;
|
||||
/** type match rank (RANK_NONE when the query has no type part). */
|
||||
typeRank: number;
|
||||
originRank: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the candidates for one query and returns the best `limit` matches
|
||||
* plus the total number of matches (so the UI can say "N of M").
|
||||
*/
|
||||
export function searchAssetCandidates(
|
||||
candidates: readonly AssetSearchCandidate[],
|
||||
query: AssetSearchQuery,
|
||||
limit: number = ASSET_SEARCH_LIMIT,
|
||||
): AssetSearchResult {
|
||||
if (isEmptyAssetSearchQuery(query)) return { matches: [], total: 0 };
|
||||
|
||||
const scored: ScoredCandidate[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const score = scoreCandidate(candidate, query);
|
||||
if (score) scored.push(score);
|
||||
}
|
||||
scored.sort(compareScoredCandidates);
|
||||
const capped = Math.max(0, limit);
|
||||
return {
|
||||
matches: scored.slice(0, capped).map((entry) => entry.candidate),
|
||||
total: scored.length,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreCandidate(
|
||||
candidate: AssetSearchCandidate,
|
||||
query: AssetSearchQuery,
|
||||
): ScoredCandidate | null {
|
||||
const id = candidate.def.id.toLowerCase();
|
||||
const type = candidate.def.type.toLowerCase();
|
||||
const idRank = query.id ? rankMatch(id, query.id) : RANK_NONE;
|
||||
// With a bare query the same text also matches type names, so
|
||||
// `GameObject` lists that type's assets as well as every id containing
|
||||
// "gameobject".
|
||||
const typeRank = query.type
|
||||
? rankMatch(type, query.type)
|
||||
: query.id
|
||||
? rankMatch(type, query.id)
|
||||
: RANK_NONE;
|
||||
|
||||
if (query.id && query.type) {
|
||||
if (idRank === RANK_NONE || typeRank === RANK_NONE) return null;
|
||||
} else if (query.id) {
|
||||
if (idRank === RANK_NONE && typeRank === RANK_NONE) return null;
|
||||
} else if (typeRank === RANK_NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
candidate,
|
||||
tier: bestTier(idRank, typeRank, query.type != null),
|
||||
idRank,
|
||||
typeRank,
|
||||
originRank: assetOriginRank(candidate.def),
|
||||
};
|
||||
}
|
||||
|
||||
/** 0 exact, 1 prefix, 2 substring, 3 no match. */
|
||||
function rankMatch(value: string, filter: string): number {
|
||||
if (value === filter) return 0;
|
||||
if (value.startsWith(filter)) return 1;
|
||||
return value.includes(filter) ? 2 : RANK_NONE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match strength tiers. When the user filtered by type explicitly, only the
|
||||
* id quality ranks the results (the type part is a filter, not a signal).
|
||||
* Otherwise the strongest of the two matches wins.
|
||||
*/
|
||||
function bestTier(
|
||||
idRank: number,
|
||||
typeRank: number,
|
||||
typeFiltered: boolean,
|
||||
): number {
|
||||
if (typeFiltered) {
|
||||
if (idRank === 0) return 0;
|
||||
if (idRank === 1) return 2;
|
||||
if (idRank === 2) return 4;
|
||||
return 6; // `Type:` alone: list the whole type
|
||||
}
|
||||
if (idRank === 0) return 0;
|
||||
if (typeRank === 0) return 1;
|
||||
if (idRank === 1) return 2;
|
||||
if (typeRank === 1) return 3;
|
||||
if (idRank === 2) return 4;
|
||||
return 5;
|
||||
}
|
||||
|
||||
function compareScoredCandidates(a: ScoredCandidate, b: ScoredCandidate): number {
|
||||
return (
|
||||
a.tier - b.tier ||
|
||||
a.originRank - b.originRank ||
|
||||
a.idRank - b.idRank ||
|
||||
a.typeRank - b.typeRank ||
|
||||
compareText(a.candidate.def.id, b.candidate.def.id) ||
|
||||
compareText(a.candidate.def.type, b.candidate.def.type)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain code-unit comparison: asset ids and type names are ASCII, and this is
|
||||
* much cheaper than `localeCompare` when a short query matches thousands of
|
||||
* candidates (the list is re-ranked on every keystroke).
|
||||
*/
|
||||
function compareText(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every definition site of one search result, local overlay first and then by
|
||||
* origin rank. Used by the UI when several definitions share a `type:id`
|
||||
* (e.g. a mod override of a vanilla asset) so the user can pick which one to
|
||||
* open.
|
||||
*/
|
||||
export function assetDefsForCandidate(
|
||||
index: ModIndex,
|
||||
candidate: AssetSearchCandidate,
|
||||
): AssetDef[] {
|
||||
const { type, id } = candidate.def;
|
||||
const key = id.toLowerCase();
|
||||
const out: AssetDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (defs: readonly AssetDef[] | undefined): void => {
|
||||
if (!defs) return;
|
||||
for (const def of defs) {
|
||||
const site = `${def.file.toLowerCase()}\u0000${def.line}`;
|
||||
if (seen.has(site)) continue;
|
||||
seen.add(site);
|
||||
out.push(def);
|
||||
}
|
||||
};
|
||||
|
||||
push(index.local?.assets.get(type)?.get(key));
|
||||
push(index.assets.get(type)?.get(key));
|
||||
out.sort((a, b) => assetOriginRank(a) - assetOriginRank(b));
|
||||
return out;
|
||||
}
|
||||
+36
-16
@@ -8,7 +8,7 @@ import {
|
||||
} from "../language/context";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import * as model from "../model/schemaModel";
|
||||
import type { AttributeInfo, SimpleTypeInfo } from "../model/schemaModel";
|
||||
import type { AttributeInfo, ContentTypeInfo } from "../model/schemaModel";
|
||||
import { isLocalReferenceAttribute } from "../indexer/refs";
|
||||
import {
|
||||
findContainingGameObject,
|
||||
@@ -84,6 +84,13 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
item.documentation = docText ? new vscode.MarkdownString(docText) : undefined;
|
||||
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
||||
item.insertText = this.elementSnippet(child.name, type, ctx.element == null);
|
||||
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
|
||||
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
|
||||
item.command = {
|
||||
command: "editor.action.triggerSuggest",
|
||||
title: t("Suggest content value"),
|
||||
};
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
@@ -120,13 +127,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
if (model.isTopLevelElement(name)) {
|
||||
return new vscode.SnippetString(`${open}${name} id="$1">\n\t$0\n</${name}>`);
|
||||
}
|
||||
const info = type ? model.typeInfo(type) : undefined;
|
||||
// Simple types hold text content (asset id / enum / define / string), so
|
||||
// they need an explicit closing tag and a value placeholder instead of a
|
||||
// self-closing tag that can never contain a value.
|
||||
if (info?.kind === "simple") {
|
||||
// Simple types and simpleContent complex types hold text content (asset
|
||||
// id / enum / define / string), so they need an explicit closing tag and
|
||||
// a value placeholder instead of a self-closing tag that can never
|
||||
// contain a value.
|
||||
if (type && model.contentInfoOfType(type)) {
|
||||
return new vscode.SnippetString(`${open}${name}>$1</${name}>`);
|
||||
}
|
||||
const info = type ? model.typeInfo(type) : undefined;
|
||||
const hasChildren = info?.kind === "complex" && info.children.length > 0;
|
||||
if (hasChildren) {
|
||||
return new vscode.SnippetString(`${open}${name}>\n\t$0\n</${name}>`);
|
||||
@@ -338,6 +346,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
|
||||
// inheritFrom: same element type first, then everything.
|
||||
if (attrName === "inheritfrom") {
|
||||
if (!model.isAssetType(elType)) return [];
|
||||
if (!idx) return [];
|
||||
return this.assetIdItems(idx, el.name, null, prefix, make);
|
||||
}
|
||||
@@ -475,6 +484,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
make: (label: string, kind: vscode.CompletionItemKind, detail: string, doc?: string) => vscode.CompletionItem,
|
||||
): vscode.CompletionItem[] | vscode.CompletionList<vscode.CompletionItem> {
|
||||
const lower = prefix.toLowerCase();
|
||||
// Manifest-style qualified values ("AudioEvent:BaseSoundEffect") are
|
||||
// common in vanilla data. When the user already typed a "Type:" prefix,
|
||||
// filter on the id part after the last colon and keep the prefix in the
|
||||
// inserted label (e.g. typing "AudioEvent:Base" completes to
|
||||
// "AudioEvent:BaseSoundEffect", never to a bare "BaseSoundEffect").
|
||||
const colon = lower.lastIndexOf(":");
|
||||
const idPrefix = colon >= 0 ? lower.slice(colon + 1) : lower;
|
||||
const typePrefix = colon >= 0 ? prefix.slice(0, colon + 1) : "";
|
||||
// Deduplicate by id: the same asset can be defined in several places at
|
||||
// once (current file's local overlay + global index, project XML +
|
||||
// compiled manifest, or an override). Showing one completion entry per
|
||||
@@ -493,7 +510,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
if (seen.has(defKey)) return;
|
||||
seen.add(defKey);
|
||||
const idKey = def.id.toLowerCase();
|
||||
if (!idKey.startsWith(lower)) return;
|
||||
if (!idKey.startsWith(idPrefix)) return;
|
||||
let score = 3;
|
||||
if (refType && model.isAssignableTo(def.type, refType)) score = 1;
|
||||
if (selfType && model.isAssignableTo(def.type, selfType)) score = 0;
|
||||
@@ -560,7 +577,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
);
|
||||
}
|
||||
return make(
|
||||
def.id,
|
||||
typePrefix ? `${typePrefix}${def.id}` : def.id,
|
||||
vscode.CompletionItemKind.Value,
|
||||
t("{0} · {1}", def.type, origin),
|
||||
doc.value,
|
||||
@@ -653,12 +670,14 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
const el = ctx.element;
|
||||
if (!el) return [];
|
||||
const elType = resolveElementType(el);
|
||||
const info = elType ? model.typeInfo(elType) : undefined;
|
||||
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||
|
||||
// Simple-content element: the text between the tags is the value itself
|
||||
// (e.g. <CreateObject>CrateDebris_01</CreateObject>), so offer value
|
||||
// completions (asset ids / enums / defines) instead of child elements.
|
||||
if (info?.kind === "simple") {
|
||||
// Simple-content element (simple type or simpleContent complex type):
|
||||
// the text between the tags is the value itself (e.g.
|
||||
// <CreateObject>CrateDebris_01</CreateObject> or
|
||||
// <Sound>AudioFile</Sound>), so offer value completions (asset ids /
|
||||
// enums / defines) instead of child elements.
|
||||
if (info) {
|
||||
return this.simpleContentItems(el, elType, info, document, position, idx);
|
||||
}
|
||||
|
||||
@@ -668,7 +687,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
private simpleContentItems(
|
||||
el: XmlElement,
|
||||
elType: string | null,
|
||||
info: SimpleTypeInfo,
|
||||
info: ContentTypeInfo,
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
idx: ModIndex | null,
|
||||
@@ -756,7 +775,8 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
item.detail = type ? t("RA3 XML · {0}", type) : t("RA3 XML");
|
||||
const doc = child.doc || (info?.kind === "complex" ? info.doc : "");
|
||||
if (doc) item.documentation = new vscode.MarkdownString(doc);
|
||||
if (info?.kind === "simple" && this.simpleContentValueKind(info)) {
|
||||
const contentInfo = type ? model.contentInfoOfType(type) : undefined;
|
||||
if (contentInfo && this.simpleContentValueKind(contentInfo)) {
|
||||
item.command = {
|
||||
command: "editor.action.triggerSuggest",
|
||||
title: t("Suggest content value"),
|
||||
@@ -767,7 +787,7 @@ export class Ra3CompletionProvider implements vscode.CompletionItemProvider {
|
||||
return items;
|
||||
}
|
||||
|
||||
private simpleContentValueKind(info: SimpleTypeInfo): boolean {
|
||||
private simpleContentValueKind(info: ContentTypeInfo): boolean {
|
||||
return (
|
||||
info.refType != null ||
|
||||
info.enumValues.length > 0 ||
|
||||
|
||||
+73
-18
@@ -139,6 +139,17 @@ export class Ra3Diagnostics {
|
||||
): void {
|
||||
const settings = this.ws.settings;
|
||||
const fileDuplicates = new Map<string, { line: number }>();
|
||||
// A file whose root is not AssetDeclaration is an xi:include fragment
|
||||
// (e.g. Data/Includes/GenericCelestialBuildingSuicide.xml). It is not a
|
||||
// standalone RA3 document: top-level id / duplicate checks do not apply,
|
||||
// and references/defines can only be resolved in the includer's context.
|
||||
// When the fragment root itself is a known XSD element (e.g.
|
||||
// CreateObjectDie), the root supplies the type context for its whole
|
||||
// subtree, so element/attribute validation is still reliable.
|
||||
const rootName = root ? localName(root.name) : "";
|
||||
const isFragment = rootName !== "AssetDeclaration";
|
||||
const validateTree =
|
||||
!isFragment || (root !== null && model.elementTypeName(rootName) !== null);
|
||||
|
||||
for (const el of doc.elements) {
|
||||
// Only report diagnostics for nodes that belong to the document being
|
||||
@@ -155,7 +166,7 @@ export class Ra3Diagnostics {
|
||||
const range = tagRange(document, el);
|
||||
|
||||
// Top-level assets must have an id.
|
||||
if (isTopLevel) {
|
||||
if (!isFragment && isTopLevel) {
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (!idAttr || !idAttr.value) {
|
||||
diags.push(
|
||||
@@ -206,7 +217,7 @@ export class Ra3Diagnostics {
|
||||
const isXsdElement = model.isXsdElementName(el.name);
|
||||
|
||||
// Unknown element.
|
||||
if (settings.diagnoseUnknownElements && isXsdElement) {
|
||||
if (settings.diagnoseUnknownElements && isXsdElement && validateTree) {
|
||||
const knownType = model.elementTypeName(local);
|
||||
if (!knownType) {
|
||||
diags.push(
|
||||
@@ -221,7 +232,7 @@ export class Ra3Diagnostics {
|
||||
}
|
||||
|
||||
// Attributes.
|
||||
if (isXsdElement) {
|
||||
if (isXsdElement && validateTree) {
|
||||
const elType = resolveElementType(el);
|
||||
const knownAttrs = model.attributesOfType(elType);
|
||||
const knownNames = new Set(knownAttrs.map((a) => a.name));
|
||||
@@ -247,27 +258,70 @@ export class Ra3Diagnostics {
|
||||
}
|
||||
|
||||
if (!attr.hasValue) continue;
|
||||
this.checkValueReferences(
|
||||
elType,
|
||||
attr.name,
|
||||
attr.value,
|
||||
attr,
|
||||
document,
|
||||
idx,
|
||||
diags,
|
||||
provisional,
|
||||
);
|
||||
// References and $DEFINE constants inside fragments depend on the
|
||||
// includer's context; don't report them until P1 resolves the real
|
||||
// include sites.
|
||||
if (!isFragment) {
|
||||
this.checkValueReferences(
|
||||
elType,
|
||||
attr.name,
|
||||
attr.value,
|
||||
attr,
|
||||
document,
|
||||
idx,
|
||||
diags,
|
||||
provisional,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!isFragment) {
|
||||
this.checkContentReferences(el, elType, document, idx, diags, provisional);
|
||||
}
|
||||
this.checkContentReferences(el, elType, document, idx, diags, provisional);
|
||||
}
|
||||
|
||||
// Include-specific checks.
|
||||
if (local === "Include") {
|
||||
this.checkInclude(el, document, idx, diags);
|
||||
} else if (local === "include" && el.name.toLowerCase().startsWith("xi:")) {
|
||||
this.checkXiInclude(el, document, idx, diags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkXiInclude(
|
||||
el: XmlElement,
|
||||
document: vscode.TextDocument,
|
||||
idx: ModIndex | null,
|
||||
diags: vscode.Diagnostic[],
|
||||
): void {
|
||||
const hrefAttr = el.attrs.find((a) => a.name === "href");
|
||||
if (!hrefAttr?.hasValue) return;
|
||||
const searchPaths = idx
|
||||
? buildSearchPaths(idx.sdkDir, idx.projectDir)
|
||||
: this.ws.searchPaths(document);
|
||||
if (!searchPaths) return;
|
||||
const resolved = resolveSource(
|
||||
hrefAttr.value,
|
||||
dirname(document.uri.fsPath),
|
||||
searchPaths,
|
||||
);
|
||||
if (resolved.path) return;
|
||||
if (/^(DATA|ART|AUDIO):/i.test(hrefAttr.value.trim())) {
|
||||
if (this.sdkUnusable()) return;
|
||||
}
|
||||
diags.push(
|
||||
this.diag(
|
||||
new vscode.Range(
|
||||
document.positionAt(hrefAttr.valueStart),
|
||||
document.positionAt(hrefAttr.valueEnd),
|
||||
),
|
||||
t("xi:include target not found: {0}", hrefAttr.value),
|
||||
vscode.DiagnosticSeverity.Warning,
|
||||
"include-not-found",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private checkCrossFileDuplicate(
|
||||
type: string,
|
||||
id: string,
|
||||
@@ -397,10 +451,11 @@ export class Ra3Diagnostics {
|
||||
diags: vscode.Diagnostic[],
|
||||
provisional: boolean,
|
||||
): void {
|
||||
// Only simple-content elements carry a text value; complex elements'
|
||||
// "content" is child markup and must not be scanned for value refs.
|
||||
const info = elType ? model.typeInfo(elType) : undefined;
|
||||
if (info?.kind !== "simple") return;
|
||||
// Only simple-content elements carry a text value (simple types and
|
||||
// simpleContent complex types); ordinary complex elements' "content" is
|
||||
// child markup and must not be scanned for value refs.
|
||||
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||
if (!info) return;
|
||||
if (el.selfClosing || el.closeTagStart < 0) return;
|
||||
const text = document.getText();
|
||||
const raw = text.slice(el.startTagEnd, el.closeTagStart);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* "RA3 Mod XML: Find asset…" — quick-pick search over indexed assets.
|
||||
*
|
||||
* One picker does both the typing and the selection: results are filtered by
|
||||
* the extension itself rather than by VS Code's built-in label filter, so the
|
||||
* `Type:Id` form (where the type never appears in the visible label) keeps
|
||||
* working. Accepting a result jumps to its definition; when the same
|
||||
* `type:id` exists in several places (mod override + SDK + compiled
|
||||
* manifest) a second picker chooses which definition to open.
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import { relative } from "node:path";
|
||||
import {
|
||||
ASSET_SEARCH_LIMIT,
|
||||
assetDefsForCandidate,
|
||||
collectAssetSearchCandidates,
|
||||
parseAssetSearchQuery,
|
||||
searchAssetCandidates,
|
||||
type AssetSearchCandidate,
|
||||
type AssetSearchQuery,
|
||||
} from "./assetSearch";
|
||||
import { assetDefinitionLocation } from "./navigation";
|
||||
import { referenceSitesForDef } from "../indexer/referenceIndex";
|
||||
import type { AssetDef, ModIndex } from "../indexer/types";
|
||||
import type { ModWorkspace } from "../workspace";
|
||||
import { t } from "../localize";
|
||||
|
||||
interface AssetPickItem extends vscode.QuickPickItem {
|
||||
candidate: AssetSearchCandidate;
|
||||
}
|
||||
|
||||
interface DefinitionPickItem extends vscode.QuickPickItem {
|
||||
def: AssetDef;
|
||||
}
|
||||
|
||||
/** Entry point for the `ra3modxml.findAsset` command. */
|
||||
export async function findAsset(
|
||||
ws: ModWorkspace,
|
||||
initialQuery = "",
|
||||
): Promise<void> {
|
||||
const index = await searchIndexFor(ws);
|
||||
if (!index) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML: no index available yet."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
if (!candidates.length) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t("RA3 Mod XML: the index has no assets yet — wait for indexing to finish."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const picked = await pickAsset(index, candidates, initialQuery.trim());
|
||||
if (!picked) return;
|
||||
await revealAsset(ws, index, picked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index used for searching: the active XML document's project index with its
|
||||
* local overlay attached when possible (so unsaved edits are searchable),
|
||||
* otherwise the active project's last published snapshot.
|
||||
*/
|
||||
async function searchIndexFor(ws: ModWorkspace): Promise<ModIndex | null> {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor && editor.document.languageId === "xml" && ws.isRa3Workspace()) {
|
||||
try {
|
||||
const scope = await ws.getScope(editor.document);
|
||||
if (scope.merged) return scope.merged;
|
||||
} catch {
|
||||
// Fall through to the plain snapshot.
|
||||
}
|
||||
}
|
||||
return ws.activeIndex();
|
||||
}
|
||||
|
||||
function pickAsset(
|
||||
index: ModIndex,
|
||||
candidates: readonly AssetSearchCandidate[],
|
||||
initialQuery: string,
|
||||
): Promise<AssetSearchCandidate | undefined> {
|
||||
return new Promise<AssetSearchCandidate | undefined>((resolve) => {
|
||||
const picker = vscode.window.createQuickPick<AssetPickItem>();
|
||||
picker.title = t("RA3 Mod XML: Find asset");
|
||||
picker.placeholder = t(
|
||||
"Id, partial id or Type:Id (e.g. WeaponTemplate:AssaultRifle)",
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const finish = (value: AssetSearchCandidate | undefined): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
picker.dispose();
|
||||
};
|
||||
|
||||
const refresh = (value: string): void => {
|
||||
const query = parseAssetSearchQuery(value);
|
||||
const { matches, total } = searchAssetCandidates(
|
||||
candidates,
|
||||
query,
|
||||
ASSET_SEARCH_LIMIT,
|
||||
);
|
||||
picker.items = matches.map((candidate) => toPickItem(index, candidate));
|
||||
picker.prompt = promptFor(query, matches.length, total);
|
||||
if (picker.items.length) picker.activeItems = [picker.items[0]];
|
||||
};
|
||||
|
||||
picker.onDidChangeValue((value) => refresh(value));
|
||||
picker.onDidAccept(() => {
|
||||
const item = picker.activeItems[0] ?? picker.items[0];
|
||||
if (item) finish(item.candidate);
|
||||
});
|
||||
picker.onDidHide(() => finish(undefined));
|
||||
|
||||
picker.value = initialQuery;
|
||||
refresh(initialQuery);
|
||||
picker.show();
|
||||
});
|
||||
}
|
||||
|
||||
function toPickItem(
|
||||
index: ModIndex,
|
||||
candidate: AssetSearchCandidate,
|
||||
): AssetPickItem {
|
||||
const def = candidate.def;
|
||||
const refs = referenceSitesForDef(index, def).length;
|
||||
const description = [def.type, originLabel(def)];
|
||||
if (refs > 0) {
|
||||
description.push(refs === 1 ? t("1 reference") : t("{0} references", refs));
|
||||
}
|
||||
|
||||
const detail = [`${displayPath(index.projectDir, def.file)}:${def.line}`];
|
||||
if (candidate.definitionCount > 1) {
|
||||
detail.push(t("{0} definitions", candidate.definitionCount));
|
||||
}
|
||||
|
||||
return {
|
||||
label: def.id,
|
||||
description: description.join(" · "),
|
||||
detail: detail.join(" · "),
|
||||
// The picker filters the list itself: a `Type:Id` query never appears in
|
||||
// the visible label, so VS Code's built-in filter must not hide it.
|
||||
alwaysShow: true,
|
||||
candidate,
|
||||
};
|
||||
}
|
||||
|
||||
function promptFor(
|
||||
query: AssetSearchQuery,
|
||||
shown: number,
|
||||
total: number,
|
||||
): string {
|
||||
if (!query.type && !query.id) {
|
||||
return t("Type an id, a partial id or Type:Id to search.");
|
||||
}
|
||||
if (total === 0) return t('No asset matches "{0}".', query.raw);
|
||||
if (total > shown) {
|
||||
return t(
|
||||
"{0} of {1} matches — keep typing to narrow the list.",
|
||||
shown,
|
||||
total,
|
||||
);
|
||||
}
|
||||
return total === 1 ? t("1 asset found.") : t("{0} assets found.", total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a search result. Definitions of the same `type:id` are offered when
|
||||
* there is more than one (e.g. the mod override and the vanilla source).
|
||||
*/
|
||||
async function revealAsset(
|
||||
ws: ModWorkspace,
|
||||
index: ModIndex,
|
||||
candidate: AssetSearchCandidate,
|
||||
): Promise<void> {
|
||||
const defs = assetDefsForCandidate(index, candidate);
|
||||
let def = defs[0] ?? candidate.def;
|
||||
|
||||
if (defs.length > 1) {
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
defs.map((d) => toDefinitionItem(index, d)),
|
||||
{
|
||||
title: `${def.type}:${def.id}`,
|
||||
placeHolder: t("Select the definition to open"),
|
||||
matchOnDescription: true,
|
||||
},
|
||||
);
|
||||
if (!picked) return;
|
||||
def = picked.def;
|
||||
}
|
||||
|
||||
const location = await assetDefinitionLocation(ws, def, index);
|
||||
if (!location) {
|
||||
void vscode.window.showInformationMessage(
|
||||
t(
|
||||
"RA3 Mod XML: no XML source location for {0} ({1}).",
|
||||
`${def.type}:${def.id}`,
|
||||
displayPath(index.projectDir, def.file),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = await vscode.workspace.openTextDocument(location.uri);
|
||||
await vscode.window.showTextDocument(document, {
|
||||
selection: location.range,
|
||||
preview: true,
|
||||
});
|
||||
}
|
||||
|
||||
function toDefinitionItem(
|
||||
index: ModIndex,
|
||||
def: AssetDef,
|
||||
): DefinitionPickItem {
|
||||
return {
|
||||
label: `${displayPath(index.projectDir, def.file)}:${def.line}`,
|
||||
description: originLabel(def),
|
||||
detail: `${def.type}:${def.id}`,
|
||||
def,
|
||||
};
|
||||
}
|
||||
|
||||
function originLabel(def: AssetDef): string {
|
||||
if (def.stream === "local") return t("local");
|
||||
switch (def.origin) {
|
||||
case "sdk":
|
||||
return t("SDK");
|
||||
case "manifest":
|
||||
return t("manifest");
|
||||
default:
|
||||
return t("project");
|
||||
}
|
||||
}
|
||||
|
||||
function displayPath(projectDir: string, file: string): string {
|
||||
const rel = relative(projectDir, file);
|
||||
return rel && !rel.startsWith("..") ? rel : file;
|
||||
}
|
||||
+11
-6
@@ -59,12 +59,15 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
||||
// Element name.
|
||||
const nameStart = el.start + 1;
|
||||
if (offset >= nameStart && offset <= nameStart + el.name.length) {
|
||||
return this.elementHover(el.name);
|
||||
return this.elementHover(el.name, elType);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private elementHover(name: string): vscode.Hover | null {
|
||||
private elementHover(
|
||||
name: string,
|
||||
resolvedType: string | null = null,
|
||||
): vscode.Hover | null {
|
||||
if (name.startsWith("xi:")) {
|
||||
const md = new vscode.MarkdownString();
|
||||
md.appendCodeblock(`<${name}>`, "xml");
|
||||
@@ -75,7 +78,9 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
||||
);
|
||||
return new vscode.Hover(md);
|
||||
}
|
||||
const type = model.elementTypeName(name);
|
||||
const type =
|
||||
resolvedType ??
|
||||
(model.topLevelElementType(name) ?? model.elementTypeName(name));
|
||||
const info = type ? model.typeInfo(type) : undefined;
|
||||
const md = new vscode.MarkdownString();
|
||||
md.appendCodeblock(`<${name}>`, "xml");
|
||||
@@ -87,7 +92,7 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
||||
md.appendMarkdown(
|
||||
`${t(
|
||||
"Attributes: {0} · Children: {1}",
|
||||
info.attributes.length,
|
||||
model.attributesOfType(type).length,
|
||||
info.children.length,
|
||||
)} \n`,
|
||||
);
|
||||
@@ -267,8 +272,8 @@ export class Ra3HoverProvider implements vscode.HoverProvider {
|
||||
}
|
||||
const targets = resolveContentReferenceTargets(idx, elType, value);
|
||||
if (targets.length) return this.definitionsHover(targets, document);
|
||||
const info = elType ? model.typeInfo(elType) : undefined;
|
||||
const refType = info?.kind === "simple" ? info.refType : null;
|
||||
const info = elType ? model.contentInfoOfType(elType) : undefined;
|
||||
const refType = info?.refType ?? null;
|
||||
return this.noDefinitionHover(refType ? "typed" : "untyped", refType ?? undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as vscode from "vscode";
|
||||
import { dirname } from "node:path";
|
||||
import { findElementAt, parseXml, textContentTokenAt } from "../language/xmlParser";
|
||||
import {
|
||||
findElementAt,
|
||||
LineMap,
|
||||
parseXml,
|
||||
textContentTokenAt,
|
||||
} from "../language/xmlParser";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import {
|
||||
buildSearchPaths,
|
||||
@@ -220,6 +225,94 @@ async function assetDefLocation(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Location of one asset definition for the asset search command.
|
||||
*
|
||||
* Unlike {@link assetDefLocation} this needs no document scope: an already
|
||||
* open (possibly unsaved) document wins, then manifest sources are resolved
|
||||
* with the SDK-only search paths, then the owning indexer's cached DOM is
|
||||
* asked for the precise `id` attribute range. Returns null only when a
|
||||
* manifest definition has no resolvable XML source at all.
|
||||
*/
|
||||
export async function assetDefinitionLocation(
|
||||
ws: ModWorkspace,
|
||||
def: AssetDef,
|
||||
idx: ModIndex,
|
||||
): Promise<vscode.Location | null> {
|
||||
const open = vscode.workspace.textDocuments.find(
|
||||
(doc) => scopePathKey(doc.uri.fsPath) === scopePathKey(def.file),
|
||||
);
|
||||
if (open) {
|
||||
const precise = locationInText(open.uri, open.getText(), def.id);
|
||||
if (precise) return precise;
|
||||
}
|
||||
|
||||
// While a rebuild is running, avoid readDom() mutating the live indexer's
|
||||
// caches mid-build; a line-based location is a fine temporary fallback.
|
||||
if (ws.isBuilding) return lineLocation(def);
|
||||
|
||||
if (def.origin === "manifest") {
|
||||
const src = def.manifestSource;
|
||||
if (!src) return null;
|
||||
// manifestSource is a vanilla build path: resolve it with SDK-only search
|
||||
// paths so a mod file shadowing the same DATA: path cannot hijack the
|
||||
// jump. Opening the binary manifest itself would not help the user, so a
|
||||
// missing SDK source stays unresolved instead.
|
||||
const resolved = resolveSource(
|
||||
src,
|
||||
null,
|
||||
buildVanillaSearchPaths(idx.sdkDir),
|
||||
).path;
|
||||
if (!resolved) return null;
|
||||
return (
|
||||
(await locationInDocument(ws, resolved, def.id)) ??
|
||||
new vscode.Location(vscode.Uri.file(resolved), new vscode.Position(0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
return (await locationInDocument(ws, def.file, def.id)) ?? lineLocation(def);
|
||||
}
|
||||
|
||||
function lineLocation(def: AssetDef): vscode.Location {
|
||||
const line = Math.max(0, def.line - 1);
|
||||
return new vscode.Location(
|
||||
vscode.Uri.file(def.file),
|
||||
new vscode.Range(new vscode.Position(line, 0), new vscode.Position(line, 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Precise `id` range inside arbitrary (possibly unsaved) document text. */
|
||||
function locationInText(
|
||||
uri: vscode.Uri,
|
||||
text: string,
|
||||
id: string,
|
||||
): vscode.Location | null {
|
||||
const parsed = parseXml(text);
|
||||
const wanted = id.toLowerCase();
|
||||
const el = parsed.elements.find((e) =>
|
||||
e.attrs.some((a) => a.name === "id" && a.value.toLowerCase() === wanted),
|
||||
);
|
||||
if (!el) return null;
|
||||
const lineMap = new LineMap(text);
|
||||
const idAttr = el.attrs.find((a) => a.name === "id");
|
||||
if (idAttr?.hasValue) {
|
||||
return new vscode.Location(
|
||||
uri,
|
||||
new vscode.Range(
|
||||
toVscodePosition(lineMap.positionAt(idAttr.valueStart)),
|
||||
toVscodePosition(lineMap.positionAt(idAttr.valueEnd)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return new vscode.Location(
|
||||
uri,
|
||||
new vscode.Range(
|
||||
toVscodePosition(lineMap.positionAt(el.start)),
|
||||
toVscodePosition(lineMap.positionAt(el.startTagEnd)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function locationInCurrentDocument(
|
||||
scope: DocumentScope,
|
||||
id: string,
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
textContentTokenAt,
|
||||
} from "../language/xmlParser";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
||||
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
|
||||
import {
|
||||
filterAndScoreDefs,
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
mergeLocalAndGlobalDefs,
|
||||
normalizeReferenceId,
|
||||
} from "../indexer/refs";
|
||||
import {
|
||||
referenceSitesForDef,
|
||||
@@ -76,10 +77,10 @@ export function referenceContextAt(
|
||||
if (elType && isReferenceContentType(elType)) {
|
||||
const token = textContentTokenAt(text, el, offset);
|
||||
if (token && !token.value.startsWith("$") && !token.value.startsWith("=")) {
|
||||
const info = typeInfo(elType);
|
||||
const info = contentInfoOfType(elType);
|
||||
return {
|
||||
id: token.value,
|
||||
refType: info?.kind === "simple" ? info.refType : null,
|
||||
refType: info?.refType ?? null,
|
||||
selfType: null,
|
||||
};
|
||||
}
|
||||
@@ -92,9 +93,10 @@ export function definitionsForReference(
|
||||
idx: ModIndex,
|
||||
ctx: ReferenceContext,
|
||||
): AssetDef[] {
|
||||
const lookupId = normalizeReferenceId(ctx.id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(ctx.id.toLowerCase()),
|
||||
idx.assetsById.get(ctx.id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
return filterAndScoreDefs(defs, ctx.refType, ctx.selfType).map((t) => t.def);
|
||||
}
|
||||
|
||||
+20
-2
@@ -1014,14 +1014,32 @@ export class ModIndexer {
|
||||
}
|
||||
const arr = byId.get(idKey);
|
||||
if (arr) {
|
||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
if (
|
||||
arr.some(
|
||||
(a) =>
|
||||
a.type === def.type &&
|
||||
a.file === def.file &&
|
||||
a.line === def.line,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
arr.push(def);
|
||||
} else {
|
||||
byId.set(idKey, [def]);
|
||||
}
|
||||
const all = this.assetsById.get(idKey);
|
||||
if (all) {
|
||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
if (
|
||||
all.some(
|
||||
(a) =>
|
||||
a.type === def.type &&
|
||||
a.file === def.file &&
|
||||
a.line === def.line,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
all.push(def);
|
||||
} else {
|
||||
this.assetsById.set(idKey, [def]);
|
||||
|
||||
@@ -244,14 +244,32 @@ class OverlayBuilder {
|
||||
}
|
||||
const arr = byId.get(idKey);
|
||||
if (arr) {
|
||||
if (arr.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
if (
|
||||
arr.some(
|
||||
(a) =>
|
||||
a.type === def.type &&
|
||||
a.file === def.file &&
|
||||
a.line === def.line,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
arr.push(def);
|
||||
} else {
|
||||
byId.set(idKey, [def]);
|
||||
}
|
||||
const all = this.overlay.assetsById.get(idKey);
|
||||
if (all) {
|
||||
if (all.some((a) => a.file === def.file && a.line === def.line)) return;
|
||||
if (
|
||||
all.some(
|
||||
(a) =>
|
||||
a.type === def.type &&
|
||||
a.file === def.file &&
|
||||
a.line === def.line,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
all.push(def);
|
||||
} else {
|
||||
this.overlay.assetsById.set(idKey, [def]);
|
||||
|
||||
@@ -169,12 +169,26 @@ async function expandXi(
|
||||
if (!target?.parse?.root) return;
|
||||
|
||||
const xpointer = xi.attrs.find((a) => a.name === "xpointer")?.value ?? "";
|
||||
const selected = xpointer
|
||||
? findXPointerContainer(target.parse, xpointer)?.children ?? []
|
||||
: target.parse.root.children;
|
||||
|
||||
for (const sel of selected) {
|
||||
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
|
||||
if (xpointer) {
|
||||
const container = findXPointerContainer(target.parse, xpointer);
|
||||
if (!container) return;
|
||||
for (const sel of container.children) {
|
||||
await handleChild(sel, logicalParent, resolved, depth + 1, ctx, elements, stack);
|
||||
}
|
||||
} else {
|
||||
// XInclude semantics: without an xpointer the whole target document is
|
||||
// included, i.e. its root element replaces the <xi:include> node.
|
||||
// RA3 fragments such as GenericCelestialBuildingSuicide.xml rely on this
|
||||
// to splice the module element itself (CreateObjectDie) into the parent.
|
||||
await handleChild(
|
||||
target.parse.root,
|
||||
logicalParent,
|
||||
resolved,
|
||||
depth + 1,
|
||||
ctx,
|
||||
elements,
|
||||
stack,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
stack.delete(key);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { LineMap, XmlDocument } from "../language/xmlParser";
|
||||
import type { ShallowDocument } from "./shallowScan";
|
||||
import { attributesOfType, typeInfo } from "../model/schemaModel";
|
||||
import { attributesOfType, contentInfoOfType } from "../model/schemaModel";
|
||||
import { resolveElementType } from "../language/typeContext";
|
||||
import {
|
||||
isReferenceAttributeOfType,
|
||||
@@ -246,10 +246,10 @@ function collectReferenceRecords(
|
||||
continue;
|
||||
}
|
||||
const start = el.startTagEnd + raw.indexOf(value);
|
||||
const info = typeInfo(elType);
|
||||
const info = contentInfoOfType(elType);
|
||||
out.push({
|
||||
kind: "content",
|
||||
refType: info?.kind === "simple" ? info.refType : null,
|
||||
refType: info?.refType ?? null,
|
||||
selfType: null,
|
||||
value,
|
||||
line: lineOf(lineMap, start),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { extractIndexRecords, type IndexRecords } from "./records";
|
||||
import {
|
||||
filterAndScoreDefs,
|
||||
isReferenceTargetType,
|
||||
normalizeReferenceId,
|
||||
type ReferenceLookup,
|
||||
} from "./refs";
|
||||
import { buildVanillaSearchPaths, resolveSource } from "./includeResolver";
|
||||
@@ -50,7 +51,9 @@ export function buildReferenceIndex(
|
||||
const map = new Map<string, ReferenceSite[]>();
|
||||
for (const { file, records } of sources) {
|
||||
for (const ref of records.references) {
|
||||
const defs = lookup.assetsById.get(ref.value.toLowerCase());
|
||||
const defs = lookup.assetsById.get(
|
||||
normalizeReferenceId(ref.value).toLowerCase(),
|
||||
);
|
||||
if (!defs?.length) continue;
|
||||
const targets = filterAndScoreDefs(defs, ref.refType, ref.selfType);
|
||||
for (const target of targets) {
|
||||
|
||||
+56
-15
@@ -2,7 +2,9 @@ import {
|
||||
allTypeNames,
|
||||
attributesOfType,
|
||||
canonicalTypeName,
|
||||
contentInfoOfType,
|
||||
elementTypeName,
|
||||
isAssetType,
|
||||
isAssignableTo,
|
||||
typeChain,
|
||||
typeInfo,
|
||||
@@ -14,6 +16,23 @@ export interface ReferenceTarget {
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a reference value that may use the manifest-style qualified
|
||||
* form `Type:Id` (e.g. `inheritFrom="AudioEvent:BaseSoundEffect"`,
|
||||
* `Sound="AudioEvent:JAP_Refinery_Select"` or
|
||||
* `Side="PlayerTemplate:Allies"`). XML asset ids never contain ":" (the same
|
||||
* InstanceId rule the manifest parser relies on), so the referenceable id is
|
||||
* the last colon-separated segment, exactly like `deriveAssetId`. Plain ids
|
||||
* are returned unchanged. A trailing colon with an empty remainder is left
|
||||
* unchanged so a half-typed value cannot accidentally match an id.
|
||||
*/
|
||||
export function normalizeReferenceId(value: string): string {
|
||||
const idx = value.lastIndexOf(":");
|
||||
if (idx < 0) return value;
|
||||
const id = value.slice(idx + 1);
|
||||
return id.length > 0 ? id : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of `ModIndex` that reference resolution needs. Kept narrow so
|
||||
* the reverse reference index can resolve records against the indexer's live
|
||||
@@ -74,7 +93,7 @@ export function isReferenceAttributeOfType(
|
||||
typeName: string | null,
|
||||
attrName: string,
|
||||
): boolean {
|
||||
if (attrName.toLowerCase() === "inheritfrom") return true;
|
||||
if (attrName.toLowerCase() === "inheritfrom") return isAssetType(typeName);
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
if (attr == null || !(attr.refType != null || attr.isRef)) return false;
|
||||
// Definitions (id) and pipeline-local references (Poid) are not references
|
||||
@@ -114,9 +133,10 @@ export function resolveReferenceTargetsForType(
|
||||
attrName: string,
|
||||
id: string,
|
||||
): ReferenceTarget[] {
|
||||
const lookupId = normalizeReferenceId(id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(id.toLowerCase()),
|
||||
idx.assetsById.get(id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
if (!defs.length) return [];
|
||||
|
||||
@@ -125,6 +145,7 @@ export function resolveReferenceTargetsForType(
|
||||
let selfType: string | null = null;
|
||||
|
||||
if (nameLower === "inheritfrom") {
|
||||
if (!isAssetType(typeName)) return [];
|
||||
selfType = typeName;
|
||||
} else {
|
||||
const attr = attributesOfType(typeName).find((a) => a.name === attrName);
|
||||
@@ -141,7 +162,9 @@ export function resolveReferenceTargetsForType(
|
||||
/**
|
||||
* True when an element's text content is a typed reference to a global
|
||||
* asset: the element's resolved XSD type is a simple type carrying an
|
||||
* `xas:refType` (e.g. `<CreateObject>` with `GameObjectWeakRef`).
|
||||
* `xas:refType`, or a simpleContent complex type carrying `xas:refType`
|
||||
* (e.g. `<CreateObject>` with `GameObjectWeakRef`, `<Sound>` with
|
||||
* `AudioFileRefWithWeight`).
|
||||
*
|
||||
* Only *typed* refs are treated as content references. Generic untyped
|
||||
* `AssetReference` content is used by real data for shader constants,
|
||||
@@ -152,8 +175,8 @@ export function resolveReferenceTargetsForType(
|
||||
*/
|
||||
export function isReferenceContentType(typeName: string | null): boolean {
|
||||
if (!typeName) return false;
|
||||
const info = typeInfo(typeName);
|
||||
if (info?.kind !== "simple") return false;
|
||||
const info = contentInfoOfType(typeName);
|
||||
if (!info) return false;
|
||||
if (typeChain(typeName).includes("Poid")) return false;
|
||||
return info.refType != null;
|
||||
}
|
||||
@@ -170,13 +193,14 @@ export function resolveContentReferenceTargets(
|
||||
): ReferenceTarget[] {
|
||||
if (!isReferenceContentType(typeName)) return [];
|
||||
if (!typeName) return [];
|
||||
const lookupId = normalizeReferenceId(id);
|
||||
const defs = mergeLocalAndGlobalDefs(
|
||||
idx.local?.assetsById.get(id.toLowerCase()),
|
||||
idx.assetsById.get(id.toLowerCase()),
|
||||
idx.local?.assetsById.get(lookupId.toLowerCase()),
|
||||
idx.assetsById.get(lookupId.toLowerCase()),
|
||||
);
|
||||
if (!defs.length) return [];
|
||||
const info = typeInfo(typeName);
|
||||
const refType = info?.kind === "simple" ? info.refType : null;
|
||||
const info = contentInfoOfType(typeName);
|
||||
const refType = info?.refType ?? null;
|
||||
return filterAndScoreDefs(defs, refType, null);
|
||||
}
|
||||
|
||||
@@ -223,13 +247,29 @@ export function mergeLocalAndGlobalDefs(
|
||||
|
||||
let referenceTargetTypeSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* True when the XSD itself declares `inheritFrom` for the type. This is the
|
||||
* narrower "designed reference target" signal used by CodeLens / unreferenced
|
||||
* reports; the universal BAB `inheritFrom` attribute must not widen it to
|
||||
* every BaseAssetType descendant.
|
||||
*/
|
||||
function xsdDeclaresInheritFrom(typeName: string): boolean {
|
||||
const info = typeInfo(typeName);
|
||||
return (
|
||||
info?.kind === "complex" &&
|
||||
info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of XSD types that are "reference targets by design": at least one
|
||||
* typed reference attribute / simple-content reference points at them, or
|
||||
* they are inheritable (`inheritFrom`). Types outside this set are
|
||||
* auto-registered / structural (settings, map metadata, w3x sub-assets...),
|
||||
* so a zero reference count is their normal state and counts would only be
|
||||
* noise.
|
||||
* the XSD explicitly declares them inheritable (`inheritFrom`). The universal
|
||||
* BAB `inheritFrom` attribute on every BaseAssetType descendant is a separate
|
||||
* legality concern and intentionally does NOT widen this set. Types outside
|
||||
* this set are auto-registered / structural (settings, map metadata, w3x
|
||||
* sub-assets...), so a zero reference count is their normal state and counts
|
||||
* would only be noise.
|
||||
*/
|
||||
export function referenceTargetTypes(): ReadonlySet<string> {
|
||||
if (referenceTargetTypeSet) return referenceTargetTypeSet;
|
||||
@@ -246,7 +286,8 @@ export function referenceTargetTypes(): ReadonlySet<string> {
|
||||
if (isLocalReferenceAttribute(typeName, attr.name)) continue;
|
||||
if (attr.refType) add(attr.refType);
|
||||
}
|
||||
if (info.attributes.some((a) => a.name.toLowerCase() === "inheritfrom")) {
|
||||
if (info.content?.refType) add(info.content.refType);
|
||||
if (xsdDeclaresInheritFrom(typeName)) {
|
||||
add(typeName);
|
||||
}
|
||||
} else if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { childTypeOf, elementTypeName } from "../model/schemaModel";
|
||||
import { childTypeOf, elementTypeName, topLevelElementType } from "../model/schemaModel";
|
||||
import type { XmlElement } from "./xmlParser";
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,12 @@ import type { XmlElement } from "./xmlParser";
|
||||
*/
|
||||
export function resolveElementType(el: XmlElement): string | null {
|
||||
if (!el.parent) {
|
||||
return elementTypeName(el.name);
|
||||
// A document root (fragment or full AssetDeclaration) has no parent to
|
||||
// provide context. When the root is a top-level asset whose name also
|
||||
// appears as a nested child type (EvaEvent, UpgradeTemplate, ...),
|
||||
// prefer the AssetDeclaration declaration over the global single-map
|
||||
// fallback.
|
||||
return topLevelElementType(el.name) ?? elementTypeName(el.name);
|
||||
}
|
||||
const parentType = resolveElementType(el.parent);
|
||||
return childTypeOf(parentType, el.name) ?? elementTypeName(el.name);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+125
-1
@@ -33,6 +33,21 @@ export interface ComplexTypeInfo {
|
||||
attributes: AttributeInfo[];
|
||||
base: string | null;
|
||||
doc: string;
|
||||
/**
|
||||
* Present only for complexType + simpleContent types (e.g.
|
||||
* AudioFileRefWithWeight / MultisoundSubsoundRef). Describes the text
|
||||
* between the tags just like a simple type's value semantics.
|
||||
*/
|
||||
content?: SimpleContentInfo | null;
|
||||
}
|
||||
|
||||
export interface SimpleContentInfo {
|
||||
refType: string | null;
|
||||
isRef: boolean;
|
||||
enumValues: string[];
|
||||
isList: boolean;
|
||||
allowsDefine: boolean;
|
||||
base: string | null;
|
||||
}
|
||||
|
||||
export interface SimpleTypeInfo {
|
||||
@@ -48,6 +63,24 @@ export interface SimpleTypeInfo {
|
||||
|
||||
export type TypeInfo = ComplexTypeInfo | SimpleTypeInfo;
|
||||
|
||||
/**
|
||||
* Unified value semantics for element text content. Both simple types
|
||||
* (`<CreateObject>` -> GameObjectWeakRef) and simpleContent complex types
|
||||
* (`<Sound>` -> AudioFileRefWithWeight) share this shape so the completion /
|
||||
* hover / navigation / diagnostics / indexer pipelines do not have to know
|
||||
* which XSD construct produced the content.
|
||||
*/
|
||||
export interface ContentTypeInfo {
|
||||
kind: "simple" | "simpleContent";
|
||||
refType: string | null;
|
||||
isRef: boolean;
|
||||
enumValues: string[];
|
||||
isList: boolean;
|
||||
allowsDefine: boolean;
|
||||
base: string | null;
|
||||
doc: string;
|
||||
}
|
||||
|
||||
interface RawModel {
|
||||
version: number;
|
||||
rootXsd: string;
|
||||
@@ -59,6 +92,33 @@ interface RawModel {
|
||||
|
||||
const model = schemaModel as unknown as RawModel;
|
||||
|
||||
/**
|
||||
* `inheritFrom` is accepted by BAB / real RA3 data on BaseAssetType-derived
|
||||
* assets even though the XSD only declares it on BaseInheritableAsset
|
||||
* (vanilla SageXml uses it on FXList, AIMicroManagerData,
|
||||
* AITargetingHeuristic, ObjectCreationList, ...). It is therefore exposed as
|
||||
* a universal attribute for every asset type.
|
||||
*
|
||||
* This is deliberately separate from `referenceTargetTypes()` in refs.ts:
|
||||
* "may legally appear in the document" and "is a designed CodeLens / FAR
|
||||
* reference target" are different decisions.
|
||||
*/
|
||||
const UNIVERSAL_INHERIT_FROM: AttributeInfo = {
|
||||
name: "inheritFrom",
|
||||
required: false,
|
||||
default: null,
|
||||
doc: "Inherits another asset of the same type.",
|
||||
kind: "simple",
|
||||
type: "@attr:inheritFrom",
|
||||
refType: null,
|
||||
enumValues: [],
|
||||
isList: false,
|
||||
allowsDefine: false,
|
||||
isRef: false,
|
||||
isBoolean: false,
|
||||
base: "string",
|
||||
};
|
||||
|
||||
/** Lowercase type name -> canonical (XSD) type name. */
|
||||
const typeNameIndex = new Map<string, string>();
|
||||
for (const name of Object.keys(model.types)) {
|
||||
@@ -109,6 +169,43 @@ export function typeInfo(name: string): TypeInfo | undefined {
|
||||
return model.types[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns content-value semantics for a type, or null when the element is a
|
||||
* normal complex element (children, not text).
|
||||
*/
|
||||
export function contentInfoOfType(
|
||||
typeName: string | null,
|
||||
): ContentTypeInfo | null {
|
||||
if (!typeName) return null;
|
||||
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||
if (!info) return null;
|
||||
if (info.kind === "simple") {
|
||||
return {
|
||||
kind: "simple",
|
||||
refType: info.refType,
|
||||
isRef: info.isRef,
|
||||
enumValues: info.enumValues,
|
||||
isList: info.isList,
|
||||
allowsDefine: info.allowsDefine,
|
||||
base: info.base,
|
||||
doc: info.doc,
|
||||
};
|
||||
}
|
||||
if (info.kind === "complex" && info.content) {
|
||||
return {
|
||||
kind: "simpleContent",
|
||||
refType: info.content.refType,
|
||||
isRef: info.content.isRef,
|
||||
enumValues: info.content.enumValues,
|
||||
isList: info.content.isList,
|
||||
allowsDefine: info.content.allowsDefine,
|
||||
base: info.content.base,
|
||||
doc: info.doc,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function elementTypeName(name: string): string | null {
|
||||
const t = elementToType.get(name);
|
||||
return t ? t : null;
|
||||
@@ -135,7 +232,23 @@ export function attributesOfElement(name: string): AttributeInfo[] {
|
||||
export function attributesOfType(typeName: string | null): AttributeInfo[] {
|
||||
if (!typeName) return [];
|
||||
const info = model.types[canonicalTypeName(typeName) ?? typeName];
|
||||
return info && info.kind === "complex" ? info.attributes : [];
|
||||
if (!info || info.kind !== "complex") return [];
|
||||
if (
|
||||
isAssetType(typeName) &&
|
||||
!info.attributes.some((a) => a.name === "inheritFrom")
|
||||
) {
|
||||
return [...info.attributes, UNIVERSAL_INHERIT_FROM];
|
||||
}
|
||||
return info.attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for types in the asset hierarchy (BaseAssetType and its descendants).
|
||||
* These are the types on which BAB accepts the universal `inheritFrom`
|
||||
* attribute even when the XSD does not declare it.
|
||||
*/
|
||||
export function isAssetType(typeName: string | null): boolean {
|
||||
return !!typeName && typeChain(typeName).includes("BaseAssetType");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,6 +283,17 @@ export function elementTypeIn(
|
||||
return elementTypeName(childName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a top-level asset element name to the type declared inside
|
||||
* AssetDeclaration. This is the type a fragment/standalone document root
|
||||
* should use when its name collides with a nested child type (e.g. EvaEvent
|
||||
* is both a top-level asset and an FXNugget child).
|
||||
*/
|
||||
export function topLevelElementType(name: string): string | null {
|
||||
const declType = elementTypeName("AssetDeclaration");
|
||||
return declType ? childTypeOf(declType, name) : null;
|
||||
}
|
||||
|
||||
export function typeDoc(name: string): string {
|
||||
const info = model.types[name];
|
||||
return info?.doc ?? "";
|
||||
|
||||
@@ -229,6 +229,18 @@ export class ModWorkspace {
|
||||
return this.activeState()?.indexer ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of one specific project (normalized absolute root), or null.
|
||||
*
|
||||
* The live agent server routes queries through this instead of
|
||||
* `activeIndex()`, so a query for project A can never be answered from
|
||||
* whatever project the user happens to be editing right now.
|
||||
*/
|
||||
indexForProject(projectDir: string): ModIndex | null {
|
||||
if (!projectDir) return null;
|
||||
return this.states.get(normKey(projectDir))?.index ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers project roots from the current workspace folders and open
|
||||
* documents, registers per-project state and starts the initial build(s):
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
LIVE_ONLY,
|
||||
liveArgsFor,
|
||||
parseArgs,
|
||||
toolNameFor,
|
||||
} from "../out/agent/cli.js";
|
||||
|
||||
// ── Argument parsing and command mapping (pure) ───────────────────────
|
||||
|
||||
test("parseArgs reads the project, snapshot and command", () => {
|
||||
const options = parseArgs(["--project", "D:/Mods/Alpha", "find", "AthenaCannon", "GameObject"]);
|
||||
assert.equal(options.projectDir, "D:/Mods/Alpha");
|
||||
assert.equal(options.command, "find");
|
||||
assert.deepEqual(options.args, ["AthenaCannon", "GameObject"]);
|
||||
});
|
||||
|
||||
test("parseArgs ignores unknown flags without failing the call", () => {
|
||||
// An agent probing with a flag we do not know yet must not crash the CLI.
|
||||
const withCommand = parseArgs(["--json", "status"]);
|
||||
assert.equal(withCommand.command, "status");
|
||||
const trailing = parseArgs(["find", "X", "--future-flag"]);
|
||||
assert.equal(trailing.command, "find");
|
||||
assert.deepEqual(trailing.args, ["X"]);
|
||||
// A stray positional is still treated as the command name.
|
||||
assert.equal(parseArgs([]).command, "status");
|
||||
});
|
||||
|
||||
test("parseArgs reads outgoing-specific options", () => {
|
||||
const options = parseArgs([
|
||||
"outgoing", "AthenaCannon", "GameObject",
|
||||
"--depth", "2",
|
||||
"--target-types", "WeaponTemplate, GameObject",
|
||||
"--max-edges", "25",
|
||||
"--include-unresolved",
|
||||
]);
|
||||
assert.equal(options.command, "outgoing");
|
||||
assert.equal(options.depth, 2);
|
||||
assert.deepEqual(options.targetTypes, ["WeaponTemplate", "GameObject"]);
|
||||
assert.equal(options.maxEdges, 25);
|
||||
assert.equal(options.includeUnresolved, true);
|
||||
});
|
||||
|
||||
test("toolNameFor maps every CLI command to a live tool", () => {
|
||||
assert.equal(toolNameFor("find"), "find_asset");
|
||||
assert.equal(toolNameFor("refs"), "find_references");
|
||||
assert.equal(toolNameFor("outgoing"), "get_asset_references");
|
||||
assert.equal(toolNameFor("list"), "list_assets_by_type");
|
||||
assert.equal(toolNameFor("active"), "is_file_active");
|
||||
assert.equal(toolNameFor("define"), "find_define");
|
||||
assert.equal(toolNameFor("resolve"), "resolve_include");
|
||||
assert.equal(toolNameFor("projects"), "list_projects");
|
||||
assert.equal(toolNameFor("status"), "get_status");
|
||||
});
|
||||
|
||||
test("LIVE_ONLY covers exactly the commands needing DOM/project context", () => {
|
||||
assert.equal(LIVE_ONLY.has("outgoing"), true);
|
||||
assert.equal(LIVE_ONLY.has("projects"), true);
|
||||
assert.equal(LIVE_ONLY.has("find"), false);
|
||||
assert.equal(LIVE_ONLY.has("status"), false);
|
||||
});
|
||||
|
||||
test("liveArgsFor builds the right payload per command", () => {
|
||||
assert.deepEqual(
|
||||
liveArgsFor({ command: "find", args: ["X", "GameObject"] }),
|
||||
{ id: "X", type: "GameObject" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
liveArgsFor({ command: "active", args: ["D:/f.xml"] }),
|
||||
{ path: "D:/f.xml" },
|
||||
);
|
||||
// Outgoing omits unset options so the server applies its own defaults.
|
||||
const outgoing = liveArgsFor({ command: "outgoing", args: ["X"] });
|
||||
assert.equal(outgoing.id, "X");
|
||||
assert.equal(outgoing.depth, undefined);
|
||||
assert.equal(outgoing.targetTypes, undefined);
|
||||
assert.equal(outgoing.maxEdges, undefined);
|
||||
// An explicit depth of 0 is falsy but must still be forwarded.
|
||||
const zero = liveArgsFor({ command: "outgoing", args: ["X"], depth: 0 });
|
||||
assert.equal(zero.depth, 0);
|
||||
});
|
||||
|
||||
// ── Project inference from the current directory ──────────────────────
|
||||
|
||||
function makeModProject() {
|
||||
const root = mkdtempSync(join(tmpdir(), "ra3-cli-proj-"));
|
||||
mkdirSync(join(root, "Data"), { recursive: true });
|
||||
writeFileSync(join(root, "Data", "Mod.xml"), "<AssetDeclaration/>");
|
||||
return root;
|
||||
}
|
||||
|
||||
test("the CLI finds the project root by walking up from the cwd", async () => {
|
||||
const root = makeModProject();
|
||||
const nested = join(root, "Data", "Allied", "Units");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
const previous = process.cwd();
|
||||
try {
|
||||
process.chdir(nested);
|
||||
const options = parseArgs([]);
|
||||
const resolved = (await import("../out/agent/cli.js")).resolveProjectDir(options);
|
||||
assert.equal(resolved?.toLowerCase(), root.toLowerCase());
|
||||
} finally {
|
||||
process.chdir(previous);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveProjectDir prefers an explicit --project over the cwd", async () => {
|
||||
const { resolveProjectDir } = await import("../out/agent/cli.js");
|
||||
const options = parseArgs(["--project", "D:/Somewhere/Else", "status"]);
|
||||
assert.equal(resolveProjectDir(options), join("D:\\Somewhere\\Else").replace(/\\/g, "\\"));
|
||||
});
|
||||
|
||||
// ── End-to-end execution (skipped when the sandbox forbids spawning) ──
|
||||
|
||||
function canSpawnShell() {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
|
||||
} else {
|
||||
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spawnable = canSpawnShell();
|
||||
const cliPath = join(process.cwd(), "dist", "agent", "cli.js");
|
||||
|
||||
test("CLI exits 3 with a clear message outside a project", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
|
||||
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-empty-"));
|
||||
try {
|
||||
let code = 0;
|
||||
let stderr = "";
|
||||
try {
|
||||
execFileSync(process.execPath, [cliPath, "status"], {
|
||||
cwd: empty,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning");
|
||||
return;
|
||||
}
|
||||
code = err.status;
|
||||
stderr = err.stderr ?? "";
|
||||
}
|
||||
assert.equal(code, 3);
|
||||
assert.match(stderr, /No project found/);
|
||||
} finally {
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("CLI reports a live-only command as unavailable instead of empty", { skip: !spawnable || !existsSync(cliPath) }, (t) => {
|
||||
const empty = mkdtempSync(join(tmpdir(), "ra3-cli-liveonly-"));
|
||||
try {
|
||||
let stdout = "";
|
||||
try {
|
||||
stdout = execFileSync(process.execPath, [cliPath, "--project", "D:/Mods/Nope", "outgoing", "X"], {
|
||||
cwd: empty,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning");
|
||||
return;
|
||||
}
|
||||
stdout = err.stdout ?? "";
|
||||
}
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.equal(payload.source, "unavailable");
|
||||
// Must explain why, never look like "this asset has no references".
|
||||
assert.match(payload.error, /requires a live index/);
|
||||
} finally {
|
||||
rmSync(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
clearEndpoint,
|
||||
clearEndpointForProject,
|
||||
endpointPathForProject,
|
||||
isProcessAlive,
|
||||
readEndpoint,
|
||||
readEndpointForProject,
|
||||
sameProject,
|
||||
writeEndpoint,
|
||||
writeEndpointForProject,
|
||||
} from "../out/agent/endpoint.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/ExampleA";
|
||||
const PROJECT_B = "D:/Mods/ExampleB";
|
||||
|
||||
test("endpoint file round-trips and clears", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-test-"));
|
||||
try {
|
||||
await writeEndpoint(
|
||||
{ url: "http://127.0.0.1:12345", token: "abc", projectDir: PROJECT_A },
|
||||
home,
|
||||
);
|
||||
const loaded = await readEndpoint(home);
|
||||
assert.equal(loaded?.url, "http://127.0.0.1:12345");
|
||||
assert.equal(loaded?.token, "abc");
|
||||
|
||||
await clearEndpoint(home);
|
||||
assert.equal(await readEndpoint(home), null);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("per-project endpoints do not shadow each other", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-multi-"));
|
||||
try {
|
||||
// Two "windows" enable agent access for different projects.
|
||||
await writeEndpointForProject(
|
||||
PROJECT_A,
|
||||
{ url: "http://127.0.0.1:1111", token: "token-a", processId: process.pid },
|
||||
home,
|
||||
);
|
||||
await writeEndpointForProject(
|
||||
PROJECT_B,
|
||||
{ url: "http://127.0.0.1:2222", token: "token-b", processId: process.pid },
|
||||
home,
|
||||
);
|
||||
|
||||
const a = await readEndpointForProject(PROJECT_A, home);
|
||||
const b = await readEndpointForProject(PROJECT_B, home);
|
||||
assert.equal(a?.url, "http://127.0.0.1:1111");
|
||||
assert.ok(sameProject(a.projectDir, PROJECT_A));
|
||||
assert.equal(b?.url, "http://127.0.0.1:2222");
|
||||
assert.ok(sameProject(b.projectDir, PROJECT_B));
|
||||
|
||||
// Clearing one project must not disturb the other.
|
||||
await clearEndpointForProject(PROJECT_A, home);
|
||||
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
|
||||
assert.equal((await readEndpointForProject(PROJECT_B, home))?.token, "token-b");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a per-project endpoint recording another project is rejected", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-endpoint-mismatch-"));
|
||||
try {
|
||||
const file = endpointPathForProject(PROJECT_A, home);
|
||||
// Simulate a stale/edited file that claims to serve a different project.
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:3333",
|
||||
token: "t",
|
||||
projectDir: PROJECT_B,
|
||||
}),
|
||||
);
|
||||
assert.equal(await readEndpointForProject(PROJECT_A, home), null);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("isProcessAlive detects dead pids and trusts unknown ones", () => {
|
||||
assert.equal(isProcessAlive(process.pid), true);
|
||||
assert.equal(isProcessAlive(undefined), true);
|
||||
assert.equal(isProcessAlive(0), true);
|
||||
assert.equal(isProcessAlive(NaN), true);
|
||||
// Not a valid Windows PID, so it cannot correspond to a running process.
|
||||
assert.equal(isProcessAlive(0x7fffffff), false);
|
||||
});
|
||||
|
||||
test("sameProject is case-insensitive", () => {
|
||||
assert.equal(sameProject("D:/Mods/Example", "d:/mods/example"), true);
|
||||
assert.equal(sameProject("D:/Mods/Example", "D:/Mods/Other"), false);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
collectAssetReferences,
|
||||
parseLoadedXml,
|
||||
} from "../out/agent/forwardRefs.js";
|
||||
|
||||
// ── Fixture files ────────────────────────────────────────────────────
|
||||
// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon,
|
||||
// which owns the weapon slot, and has its own die-object content reference.
|
||||
const ATHENA = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
|
||||
<CreateObjectDie>
|
||||
<CreateObject>AthenaCannon_Die</CreateObject>
|
||||
</CreateObjectDie>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const BASE = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="BaseCannon">
|
||||
<WeaponSetUpdate>
|
||||
<WeaponSlotHardpoint>
|
||||
<Weapon Template="AthenaCannonWeapon" />
|
||||
</WeaponSlotHardpoint>
|
||||
</WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const DIE = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon_Die">
|
||||
<WeaponSetUpdate>
|
||||
<WeaponSlotHardpoint>
|
||||
<Weapon Template="DieExplosionWeapon" />
|
||||
</WeaponSlotHardpoint>
|
||||
</WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const FILES = {
|
||||
"D:/Mods/Example/Data/AthenaCannon.xml": ATHENA,
|
||||
"D:/Mods/Example/Data/BaseCannon.xml": BASE,
|
||||
"D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE,
|
||||
};
|
||||
|
||||
function def(type, id, file, line) {
|
||||
return { type, id, file, line, origin: "project", stream: "static" };
|
||||
}
|
||||
|
||||
const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3);
|
||||
const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3);
|
||||
const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3);
|
||||
const WEAPON_DEF = def(
|
||||
"WeaponTemplate",
|
||||
"AthenaCannonWeapon",
|
||||
"D:/Mods/Example/Data/Weapon.xml",
|
||||
88,
|
||||
);
|
||||
const DIE_WEAPON_DEF = def(
|
||||
"WeaponTemplate",
|
||||
"DieExplosionWeapon",
|
||||
"D:/Mods/Example/Data/Weapon.xml",
|
||||
120,
|
||||
);
|
||||
|
||||
function makeIndex() {
|
||||
const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF];
|
||||
const assetsById = new Map();
|
||||
for (const d of all) {
|
||||
const key = d.id.toLowerCase();
|
||||
if (!assetsById.has(key)) assetsById.set(key, []);
|
||||
assetsById.get(key).push(d);
|
||||
}
|
||||
const assets = new Map([
|
||||
["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])],
|
||||
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])],
|
||||
]);
|
||||
return {
|
||||
projectDir: "D:/Mods/Example",
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
assets,
|
||||
assetsById,
|
||||
defines: new Map(),
|
||||
files: new Map(),
|
||||
streams: [],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map(),
|
||||
recordsHashes: new Map(),
|
||||
stats: {},
|
||||
};
|
||||
}
|
||||
|
||||
function loader(map = FILES) {
|
||||
return async (file) => {
|
||||
const text = map[file];
|
||||
return text ? parseLoadedXml(text) : null;
|
||||
};
|
||||
}
|
||||
|
||||
function findEdge(edges, predicate) {
|
||||
return edges.find(predicate);
|
||||
}
|
||||
|
||||
test("depth 1 returns only the queried asset's own edges", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
{},
|
||||
);
|
||||
assert.equal(result.roots.length, 1);
|
||||
// Own content ref + inherited weapon ref + the inheritFrom edge itself.
|
||||
assert.ok(result.edges.length >= 3);
|
||||
assert.deepEqual(
|
||||
[...new Set(result.edges.map((e) => e.depth))],
|
||||
[1],
|
||||
"all edges must be at depth 1",
|
||||
);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
|
||||
test("attribute references carry element, parent and attribute provenance", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
);
|
||||
const weapon = findEdge(
|
||||
result.edges,
|
||||
(e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon",
|
||||
);
|
||||
assert.ok(weapon, "weapon edge should exist");
|
||||
assert.equal(weapon.via.element, "Weapon");
|
||||
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
|
||||
assert.equal(weapon.via.attribute, "Template");
|
||||
assert.equal(weapon.to.type, "WeaponTemplate");
|
||||
assert.equal(weapon.to.line, 88);
|
||||
assert.ok(weapon.source.file.endsWith("BaseCannon.xml"));
|
||||
assert.ok(weapon.source.line > 0);
|
||||
});
|
||||
|
||||
test("content references (CreateObjectDie) are reported with kind=content", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
);
|
||||
const die = findEdge(result.edges, (e) => e.via.kind === "content");
|
||||
assert.ok(die, "die-object content edge should exist");
|
||||
assert.equal(die.via.element, "CreateObject");
|
||||
assert.equal(die.via.parent, "CreateObjectDie");
|
||||
assert.equal(die.via.attribute, null);
|
||||
assert.equal(die.to.type, "GameObject");
|
||||
assert.equal(die.to.id, "AthenaCannon_Die");
|
||||
assert.ok(die.source.file.endsWith("AthenaCannon.xml"));
|
||||
});
|
||||
|
||||
test("inheritFrom is walked and marked with definedIn", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
);
|
||||
const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom");
|
||||
assert.ok(inherit, "inheritFrom edge should exist");
|
||||
assert.equal(inherit.to.id, "BaseCannon");
|
||||
assert.equal(inherit.value, "BaseCannon");
|
||||
assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon");
|
||||
|
||||
const inheritedWeapon = findEdge(
|
||||
result.edges,
|
||||
(e) => e.to?.id === "AthenaCannonWeapon",
|
||||
);
|
||||
assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported");
|
||||
assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" });
|
||||
assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset");
|
||||
});
|
||||
|
||||
test("targetTypes filters edges, but inheritFrom edges always survive", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
{ targetTypes: ["WeaponTemplate"] },
|
||||
);
|
||||
assert.ok(result.edges.length > 0);
|
||||
assert.ok(
|
||||
result.edges.some((e) => e.to.type === "WeaponTemplate"),
|
||||
"expected at least one WeaponTemplate edge",
|
||||
);
|
||||
for (const edge of result.edges) {
|
||||
// inheritFrom is kept so the caller can see where the weapon is written.
|
||||
if (edge.via.kind === "inheritFrom") continue;
|
||||
assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`);
|
||||
}
|
||||
// Nodes mirror the kept edges, so the inheritFrom target may appear too.
|
||||
const inheritIds = new Set(
|
||||
result.edges
|
||||
.filter((e) => e.via.kind === "inheritFrom")
|
||||
.map((e) => e.to.id.toLowerCase()),
|
||||
);
|
||||
for (const node of result.nodes) {
|
||||
assert.ok(
|
||||
node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()),
|
||||
`unexpected node ${node.type}:${node.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("depth 2 expands into referenced assets", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
{ depth: 2 },
|
||||
);
|
||||
const depths = new Set(result.edges.map((e) => e.depth));
|
||||
assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject");
|
||||
const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon");
|
||||
assert.ok(dieWeapon, "the die object's weapon should appear at depth 2");
|
||||
assert.equal(dieWeapon.depth, 2);
|
||||
assert.equal(dieWeapon.from.id, "AthenaCannon_Die");
|
||||
});
|
||||
|
||||
test("depth is clamped to the max of 3", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
{ depth: 99 },
|
||||
);
|
||||
for (const edge of result.edges) {
|
||||
assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`);
|
||||
}
|
||||
});
|
||||
|
||||
test("maxEdges truncates and reports what was dropped", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
loader(),
|
||||
{ maxEdges: 1 },
|
||||
);
|
||||
assert.equal(result.edges.length, 1);
|
||||
assert.equal(result.truncated, true);
|
||||
const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0);
|
||||
assert.ok(omitted >= 1, "expected the dropped edges to be summarised");
|
||||
});
|
||||
|
||||
test("unresolved references are opt-in", async () => {
|
||||
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="Ghost">
|
||||
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2);
|
||||
const index = makeIndex();
|
||||
index.assetsById.set("ghost", [ghost]);
|
||||
const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text });
|
||||
|
||||
const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader);
|
||||
assert.equal(without.edges.length, 0);
|
||||
|
||||
const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, {
|
||||
includeUnresolved: true,
|
||||
});
|
||||
assert.equal(withUnresolved.edges.length, 1);
|
||||
assert.equal(withUnresolved.edges[0].to, null);
|
||||
assert.equal(withUnresolved.edges[0].value, "DoesNotExist");
|
||||
});
|
||||
|
||||
test("unknown ids produce a warning instead of throwing", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"NoSuchAsset",
|
||||
"GameObject",
|
||||
loader(),
|
||||
);
|
||||
assert.equal(result.edges.length, 0);
|
||||
assert.equal(result.roots.length, 0);
|
||||
assert.equal(result.warnings.length, 1);
|
||||
assert.match(result.warnings[0], /No definition found/);
|
||||
});
|
||||
|
||||
test("unreadable files produce a warning and no edges", async () => {
|
||||
const result = await collectAssetReferences(
|
||||
makeIndex(),
|
||||
"AthenaCannon",
|
||||
"GameObject",
|
||||
async () => null,
|
||||
);
|
||||
assert.equal(result.edges.length, 0);
|
||||
assert.ok(result.warnings.some((w) => w.includes("Could not read")));
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
INSTANCE_SCHEMA_VERSION,
|
||||
clearInstance,
|
||||
instanceFileName,
|
||||
instancesDir,
|
||||
makeInstanceId,
|
||||
manifestPath,
|
||||
projectsOf,
|
||||
pruneInstances,
|
||||
readInstances,
|
||||
readManifest,
|
||||
refreshDiscovery,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "../out/agent/instances.js";
|
||||
import { endpointPath, readEndpoint } from "../out/agent/endpoint.js";
|
||||
|
||||
function instanceHome() {
|
||||
return mkdtempSync(join(tmpdir(), "ra3-instances-"));
|
||||
}
|
||||
|
||||
function makeInstance(id, pid = process.pid) {
|
||||
return {
|
||||
instanceId: id,
|
||||
url: `http://127.0.0.1:${10000 + (pid % 1000)}`,
|
||||
token: `tok-${id}`,
|
||||
projectDir: "D:/Mods/Alpha",
|
||||
projects: ["D:/Mods/Alpha"],
|
||||
processId: pid,
|
||||
};
|
||||
}
|
||||
|
||||
test("instances are written and read back", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
const file = await writeInstance(makeInstance("a-1"), home);
|
||||
assert.ok(existsSync(file));
|
||||
assert.ok(file.includes(instancesDir(home)));
|
||||
assert.equal(file.endsWith(instanceFileName("a-1")), true);
|
||||
|
||||
const all = await readInstances(home);
|
||||
assert.equal(all.length, 1);
|
||||
assert.equal(all[0].instanceId, "a-1");
|
||||
assert.equal(all[0].token, "tok-a-1");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent windows do not overwrite each other", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// Two windows enabling agent access at the same time: separate files, so
|
||||
// there is no read-modify-write race to guard.
|
||||
await writeInstance(makeInstance("win-a"), home);
|
||||
await writeInstance(makeInstance("win-b"), home);
|
||||
const ids = (await readInstances(home)).map((i) => i.instanceId).sort();
|
||||
assert.deepEqual(ids, ["win-a", "win-b"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pruneInstances removes entries whose PID is dead", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// 0x7fffffff is not a valid Windows PID, so it cannot be alive.
|
||||
await writeInstance(makeInstance("alive", process.pid), home);
|
||||
await writeInstance(makeInstance("dead", 0x7fffffff), home);
|
||||
|
||||
const result = await pruneInstances(home);
|
||||
assert.deepEqual(result.removed, ["dead"]);
|
||||
assert.deepEqual(
|
||||
result.kept.map((i) => i.instanceId),
|
||||
["alive"],
|
||||
);
|
||||
// The dead file must actually be gone from disk.
|
||||
assert.equal(existsSync(join(instancesDir(home), instanceFileName("dead"))), false);
|
||||
assert.equal((await readInstances(home)).length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("pruneInstances keeps entries with an unknown PID", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
// An older/simpler instance file without processId must never be pruned.
|
||||
await writeInstance(
|
||||
{ instanceId: "no-pid", url: "http://127.0.0.1:1", token: "t" },
|
||||
home,
|
||||
);
|
||||
const result = await pruneInstances(home);
|
||||
assert.deepEqual(result.removed, []);
|
||||
assert.equal(result.kept.length, 1);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearInstance only removes its own file", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
await writeInstance(makeInstance("mine"), home);
|
||||
await writeInstance(makeInstance("theirs"), home);
|
||||
await clearInstance("mine", home);
|
||||
const ids = (await readInstances(home)).map((i) => i.instanceId);
|
||||
assert.deepEqual(ids, ["theirs"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readInstances tolerates corrupt and unrelated files", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
await writeInstance(makeInstance("good"), home);
|
||||
writeFileSync(join(instancesDir(home), "broken.json"), "{ not json");
|
||||
writeFileSync(join(instancesDir(home), "notes.txt"), "ignore me");
|
||||
const all = await readInstances(home);
|
||||
assert.deepEqual(all.map((i) => i.instanceId), ["good"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("projectsOf unions projects across instances without duplicates", () => {
|
||||
const projects = projectsOf([
|
||||
{ instanceId: "a", url: "u", token: "t", projectDir: "D:/Mods/Alpha", projects: ["D:/Mods/Alpha"] },
|
||||
{ instanceId: "b", url: "u", token: "t", projectDir: "D:/Mods/Beta", projects: ["D:/Mods/beta", "D:/Mods/Gamma"] },
|
||||
]);
|
||||
// "beta" appears twice with different casing and must collapse to one entry,
|
||||
// keeping the first spelling seen.
|
||||
assert.deepEqual(projects, ["D:/Mods/Alpha", "D:/Mods/beta", "D:/Mods/Gamma"]);
|
||||
});
|
||||
|
||||
test("writeManifest produces a discovery manifest without tokens", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
const manifest = await writeManifest(
|
||||
[makeInstance("a-1"), makeInstance("b-2")],
|
||||
home,
|
||||
);
|
||||
assert.equal(manifest.schemaVersion, INSTANCE_SCHEMA_VERSION);
|
||||
assert.deepEqual(manifest.projects, ["D:/Mods/Alpha"]);
|
||||
assert.equal(manifest.instances.length, 2);
|
||||
|
||||
const raw = readFileSync(manifestPath(home), "utf8");
|
||||
// The manifest is for discovery; secrets must not leak into it.
|
||||
assert.equal(raw.includes("tok-a-1"), false);
|
||||
assert.equal(raw.includes('"token"'), false);
|
||||
|
||||
const reread = await readManifest(home);
|
||||
assert.equal(reread?.instances.length, 2);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("makeInstanceId is unique across rapid calls", () => {
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < 200; i++) ids.add(makeInstanceId(1234));
|
||||
assert.equal(ids.size, 200);
|
||||
for (const id of ids) assert.ok(id.startsWith("1234-"), id);
|
||||
});
|
||||
|
||||
test("refreshDiscovery keeps surviving windows discoverable", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
const mine = makeInstance("mine");
|
||||
const other = {
|
||||
...makeInstance("other"),
|
||||
url: "http://127.0.0.1:19999",
|
||||
token: "tok-other",
|
||||
projectDir: "D:/Mods/Beta",
|
||||
projects: ["D:/Mods/Beta"],
|
||||
};
|
||||
await writeInstance(mine, home);
|
||||
await writeInstance(other, home);
|
||||
// This window closes: only its own instance file goes away.
|
||||
await clearInstance(mine.instanceId, home);
|
||||
|
||||
const manifest = await refreshDiscovery(home);
|
||||
assert.deepEqual(manifest.projects, ["D:/Mods/Beta"]);
|
||||
assert.deepEqual(manifest.instances.map((i) => i.instanceId), ["other"]);
|
||||
|
||||
// The legacy global pointer must follow the survivor, not be deleted.
|
||||
const endpoint = await readEndpoint(home);
|
||||
assert.equal(endpoint?.url, other.url);
|
||||
assert.equal(endpoint?.token, other.token);
|
||||
assert.equal(existsSync(endpointPath(home)), true);
|
||||
|
||||
// The merged manifest on disk must agree with the returned value.
|
||||
const reread = await readManifest(home);
|
||||
assert.deepEqual(reread?.instances.map((i) => i.instanceId), ["other"]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshDiscovery clears the global pointer when the last window closes", async () => {
|
||||
const home = instanceHome();
|
||||
try {
|
||||
await writeInstance(makeInstance("only"), home);
|
||||
await clearInstance("only", home);
|
||||
|
||||
const manifest = await refreshDiscovery(home);
|
||||
assert.deepEqual(manifest.instances, []);
|
||||
assert.deepEqual(manifest.projects, []);
|
||||
assert.equal(existsSync(endpointPath(home)), false);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
LiveClient,
|
||||
findEndpoint,
|
||||
liveUrlForTool,
|
||||
normalizePath,
|
||||
queryLive,
|
||||
responseProjectMismatch,
|
||||
} from "../out/agent/liveClient.js";
|
||||
import { writeInstance } from "../out/agent/instances.js";
|
||||
import { writeEndpoint, writeEndpointForProject } from "../out/agent/endpoint.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Alpha";
|
||||
const PROJECT_B = "D:/Mods/Beta";
|
||||
|
||||
function home() {
|
||||
return mkdtempSync(join(tmpdir(), "ra3-liveclient-"));
|
||||
}
|
||||
|
||||
test("live URLs pin the requested project for every tool", () => {
|
||||
const cases = [
|
||||
["get_status", {}],
|
||||
["find_asset", { id: "X" }],
|
||||
["find_references", { id: "X" }],
|
||||
["list_assets_by_type", { type: "GameObject" }],
|
||||
["is_file_active", { path: "D:/f.xml" }],
|
||||
["find_define", { name: "D" }],
|
||||
["resolve_include", { source: "DATA:a.xml" }],
|
||||
["list_projects", {}],
|
||||
];
|
||||
for (const [tool, args] of cases) {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, tool, args);
|
||||
assert.ok(url, `${tool} should have a live URL`);
|
||||
assert.equal(
|
||||
new URL(url).searchParams.get("project"),
|
||||
PROJECT_A,
|
||||
`${tool} must pin the project`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("get_asset_references serialises all of its options", () => {
|
||||
const url = liveUrlForTool("http://127.0.0.1:1234", PROJECT_A, "get_asset_references", {
|
||||
id: "AthenaCannon",
|
||||
type: "GameObject",
|
||||
depth: 2,
|
||||
targetTypes: ["WeaponTemplate", "GameObject"],
|
||||
maxEdges: 25,
|
||||
includeUnresolved: true,
|
||||
});
|
||||
const q = new URL(url).searchParams;
|
||||
assert.equal(q.get("id"), "AthenaCannon");
|
||||
assert.equal(q.get("type"), "GameObject");
|
||||
assert.equal(q.get("depth"), "2");
|
||||
assert.equal(q.get("targetTypes"), "WeaponTemplate,GameObject");
|
||||
assert.equal(q.get("maxEdges"), "25");
|
||||
assert.equal(q.get("includeUnresolved"), "true");
|
||||
assert.equal(q.get("project"), PROJECT_A);
|
||||
});
|
||||
|
||||
test("unknown tools have no live URL", () => {
|
||||
assert.equal(liveUrlForTool("http://127.0.0.1:1", PROJECT_A, "not_a_tool", {}), null);
|
||||
});
|
||||
|
||||
test("responseProjectMismatch refuses another project's answer", () => {
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: "d:/mods/alpha" } }, PROJECT_A),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
responseProjectMismatch({ index: { projectDir: PROJECT_B } }, PROJECT_A),
|
||||
true,
|
||||
);
|
||||
// Nothing to compare against: not a mismatch.
|
||||
assert.equal(responseProjectMismatch({ index: { state: "ready" } }, PROJECT_A), false);
|
||||
assert.equal(responseProjectMismatch({ index: { projectDir: PROJECT_B } }, null), false);
|
||||
});
|
||||
|
||||
test("normalizePath ignores case and trailing separators", () => {
|
||||
assert.equal(normalizePath("D:\\Mods\\Alpha\\"), normalizePath("d:/mods/alpha"));
|
||||
});
|
||||
|
||||
test("findEndpoint prefers the per-project endpoint", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeEndpointForProject(
|
||||
PROJECT_A,
|
||||
{ url: "http://127.0.0.1:1111", token: "tok-a", processId: process.pid },
|
||||
h,
|
||||
);
|
||||
const endpoint = await findEndpoint({ projectDir: PROJECT_A, agentHome: h });
|
||||
assert.equal(endpoint?.url, "http://127.0.0.1:1111");
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint finds a live instance when no per-project file exists", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
// A window that has not yet written per-project endpoints, only its own
|
||||
// instance file. Discovery must still find it.
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "w1",
|
||||
url: "http://127.0.0.1:2222",
|
||||
token: "tok-inst",
|
||||
projects: [PROJECT_A, PROJECT_B],
|
||||
processId: process.pid,
|
||||
},
|
||||
h,
|
||||
);
|
||||
const endpoint = await findEndpoint({ projectDir: PROJECT_B, agentHome: h });
|
||||
assert.equal(endpoint?.url, "http://127.0.0.1:2222");
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint ignores instances that do not serve the project", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "other",
|
||||
url: "http://127.0.0.1:3333",
|
||||
token: "tok",
|
||||
projects: ["D:/Mods/Unrelated"],
|
||||
processId: process.pid,
|
||||
},
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint ignores dead instances", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "dead",
|
||||
url: "http://127.0.0.1:4444",
|
||||
token: "tok",
|
||||
projects: [PROJECT_A],
|
||||
processId: 0x7fffffff,
|
||||
},
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("findEndpoint does not use a global endpoint recording another project", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
await writeEndpoint(
|
||||
{ url: "http://127.0.0.1:5555", token: "tok", projectDir: PROJECT_B, processId: process.pid },
|
||||
h,
|
||||
);
|
||||
assert.equal(await findEndpoint({ projectDir: PROJECT_A, agentHome: h }), null);
|
||||
// It is still usable when asked for its own project.
|
||||
assert.ok(await findEndpoint({ projectDir: PROJECT_B, agentHome: h }));
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a query with no reachable live instance returns null", async () => {
|
||||
const h = home();
|
||||
try {
|
||||
assert.equal(
|
||||
await queryLive("get_status", {}, { projectDir: PROJECT_A, agentHome: h }),
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
rmSync(h, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("the negative cache suppresses repeated attempts and can be reset", async () => {
|
||||
let now = 1_000_000;
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, now: () => now });
|
||||
assert.equal(client.suppressed, false);
|
||||
client.markUnavailable();
|
||||
assert.equal(client.suppressed, true);
|
||||
// Still suppressed just before the cooldown expires.
|
||||
now += 4999;
|
||||
assert.equal(client.suppressed, true);
|
||||
// Expired afterwards.
|
||||
now += 2;
|
||||
assert.equal(client.suppressed, false);
|
||||
|
||||
client.markUnavailable();
|
||||
client.reset();
|
||||
assert.equal(client.suppressed, false);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* End-to-end live-path test.
|
||||
*
|
||||
* Starts a real local HTTP live server (the same module the extension runs),
|
||||
* registers it exactly the way the extension does (per-project endpoint +
|
||||
* instance file + merged manifest), and then drives it through the shared
|
||||
* `LiveClient` that both the MCP server and the CLI use.
|
||||
*
|
||||
* This is the in-process equivalent of "start the extension, then query it
|
||||
* from the CLI/MCP", so it covers the transport, project pinning, project
|
||||
* resolution and cross-project refusal without needing to spawn anything.
|
||||
*/
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { startLocalServer } from "../out/agent/localServer.js";
|
||||
import {
|
||||
clearInstance,
|
||||
readManifest,
|
||||
pruneInstances,
|
||||
writeInstance,
|
||||
writeManifest,
|
||||
} from "../out/agent/instances.js";
|
||||
import { writeEndpointForProject } from "../out/agent/endpoint.js";
|
||||
import { LiveClient, findEndpoint } from "../out/agent/liveClient.js";
|
||||
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Alpha";
|
||||
const PROJECT_B = "D:/Mods/Beta";
|
||||
|
||||
function def(type, id, file, line) {
|
||||
return { type, id, file, line, origin: "project", stream: "static" };
|
||||
}
|
||||
|
||||
const UNIT_FILE = `${PROJECT_A}/Data/AthenaCannon.xml`;
|
||||
const BASE_FILE = `${PROJECT_A}/Data/BaseCannon.xml`;
|
||||
const UNIT = def("GameObject", "AthenaCannon", UNIT_FILE, 2);
|
||||
const BASE = def("GameObject", "BaseCannon", BASE_FILE, 2);
|
||||
const WEAPON = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT_A}/Data/Weapon.xml`, 88);
|
||||
|
||||
const ATHENA_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
|
||||
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="BaseCannon">
|
||||
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const XML_FILES = {
|
||||
[UNIT_FILE]: ATHENA_XML,
|
||||
[BASE_FILE]: BASE_XML,
|
||||
};
|
||||
|
||||
function makeIndex(projectDir) {
|
||||
const assets = new Map([
|
||||
["GameObject", new Map([["athenacannon", [UNIT]], ["basecannon", [BASE]]])],
|
||||
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON]]])],
|
||||
]);
|
||||
return {
|
||||
projectDir,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets,
|
||||
assetsById: new Map([
|
||||
["athenacannon", [UNIT]],
|
||||
["basecannon", [BASE]],
|
||||
["athenacannonweapon", [WEAPON]],
|
||||
]),
|
||||
defines: new Map([
|
||||
["d", [{ name: "D", value: "1", file: UNIT_FILE, line: 1, origin: "project" }]],
|
||||
]),
|
||||
files: new Map(),
|
||||
streams: [
|
||||
{
|
||||
name: "static",
|
||||
entry: `${projectDir}/Data/Mod.xml`,
|
||||
files: new Set([UNIT_FILE.toLowerCase().replace(/\\/g, "/")]),
|
||||
},
|
||||
],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[
|
||||
`GameObject\u0000athenacannon\u0000${UNIT_FILE}\u00002`,
|
||||
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
|
||||
],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: {
|
||||
projectDir, sdkDir: "", phase: "art", complete: true,
|
||||
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
|
||||
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
|
||||
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
|
||||
assetCount: 3, referenceCount: 1, defineCount: 1, manifestFiles: 0,
|
||||
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Boots a live server + registration files, and returns a cleanup handle. */
|
||||
async function bootLive() {
|
||||
const agentHome = mkdtempSync(join(tmpdir(), "ra3-e2e-"));
|
||||
const handle = await startLocalServer({
|
||||
getIndex: (projectDir) =>
|
||||
!projectDir || projectDir === PROJECT_A ? makeIndex(PROJECT_A) : null,
|
||||
listProjects: () => [PROJECT_A, PROJECT_B],
|
||||
loadFile: async (file) => {
|
||||
const text = XML_FILES[file];
|
||||
return text ? parseLoadedXml(text) : null;
|
||||
},
|
||||
});
|
||||
const endpoint = {
|
||||
instanceId: "e2e-1",
|
||||
url: `http://127.0.0.1:${handle.port}`,
|
||||
token: handle.token,
|
||||
projectDir: PROJECT_A,
|
||||
projects: [PROJECT_A, PROJECT_B],
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeEndpointForProject(PROJECT_A, { ...endpoint, projectDir: PROJECT_A }, agentHome);
|
||||
await writeInstance(endpoint, agentHome);
|
||||
await writeManifest([endpoint], agentHome);
|
||||
return {
|
||||
agentHome,
|
||||
endpoint,
|
||||
async close() {
|
||||
await handle.close();
|
||||
rmSync(agentHome, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("LiveClient reaches a real live server and answers every snapshot tool", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
|
||||
const status = await client.query("get_status");
|
||||
assert.equal(status?.mismatched, false);
|
||||
assert.equal(status.payload.state, "ready");
|
||||
assert.equal(status.payload.projectDir, PROJECT_A);
|
||||
|
||||
const found = await client.query("find_asset", { id: "AthenaCannon", type: "GameObject" });
|
||||
assert.equal(found.payload.data.length, 1);
|
||||
|
||||
const refs = await client.query("find_references", { id: "AthenaCannon" });
|
||||
assert.equal(refs.payload.data.length, 1);
|
||||
|
||||
const active = await client.query("is_file_active", { path: UNIT_FILE });
|
||||
assert.equal(active.payload.data.active, true);
|
||||
|
||||
const define = await client.query("find_define", { name: "D" });
|
||||
assert.equal(define.payload.data.length, 1);
|
||||
|
||||
const list = await client.query("list_assets_by_type", { type: "GameObject" });
|
||||
assert.equal(list.payload.data.length, 2);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the live path answers get_asset_references with element provenance", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const result = await client.query("get_asset_references", {
|
||||
id: "AthenaCannon",
|
||||
type: "GameObject",
|
||||
targetTypes: ["WeaponTemplate"],
|
||||
});
|
||||
const data = result.payload.data;
|
||||
assert.ok(data, "expected edge data");
|
||||
|
||||
// The weapon is written in BaseCannon's XML, reached through inheritFrom.
|
||||
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
|
||||
assert.ok(weapon, "expected the inherited weapon edge");
|
||||
assert.equal(weapon.via.element, "Weapon");
|
||||
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
|
||||
assert.equal(weapon.definedIn.id, "BaseCannon");
|
||||
assert.equal(weapon.source.file, BASE_FILE);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("list_projects reports the live project roots", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const result = await client.query("list_projects");
|
||||
assert.deepEqual(result.payload.data, [PROJECT_A, PROJECT_B]);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a client for an unknown project cannot use another project's answer", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// The registered instance only serves PROJECT_A/PROJECT_B, so a client
|
||||
// pinned to a third project must find nothing at all rather than fall
|
||||
// back to PROJECT_A's data.
|
||||
const outsider = new LiveClient({
|
||||
projectDir: "D:/Mods/Unrelated",
|
||||
agentHome: live.agentHome,
|
||||
});
|
||||
assert.equal(await outsider.query("find_asset", { id: "AthenaCannon" }), null);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery works through the instance file alone (no per-project endpoint)", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// Simulate a window that registered its instance but whose per-project
|
||||
// endpoint has not been written yet.
|
||||
rmSync(join(live.agentHome, "endpoints"), { recursive: true, force: true });
|
||||
const endpoint = await findEndpoint({
|
||||
projectDir: PROJECT_A,
|
||||
agentHome: live.agentHome,
|
||||
});
|
||||
assert.equal(endpoint?.instanceId, "e2e-1");
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("the merged manifest lists projects for discovery", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
const manifest = await readManifest(live.agentHome);
|
||||
assert.ok(manifest);
|
||||
assert.deepEqual(new Set(manifest.projects), new Set([PROJECT_A, PROJECT_B]));
|
||||
assert.equal(manifest.instances.length, 1);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a crashed instance is pruned by a later instance and then unreachable", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
// Add a second instance that looks crashed.
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "dead-window",
|
||||
url: "http://127.0.0.1:1",
|
||||
token: "t",
|
||||
projects: [PROJECT_A],
|
||||
processId: 0x7fffffff,
|
||||
},
|
||||
live.agentHome,
|
||||
);
|
||||
const pruned = await pruneInstances(live.agentHome);
|
||||
assert.ok(pruned.removed.includes("dead-window"));
|
||||
// The surviving instance still works after the prune.
|
||||
const client = new LiveClient({ projectDir: PROJECT_A, agentHome: live.agentHome });
|
||||
const status = await client.query("get_status");
|
||||
assert.equal(status.payload.state, "ready");
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clearing this window's instance leaves other windows untouched", async () => {
|
||||
const live = await bootLive();
|
||||
try {
|
||||
await writeInstance(
|
||||
{
|
||||
instanceId: "other-window",
|
||||
url: "http://127.0.0.1:9999",
|
||||
token: "t2",
|
||||
projects: [PROJECT_A],
|
||||
processId: process.pid,
|
||||
},
|
||||
live.agentHome,
|
||||
);
|
||||
await clearInstance("e2e-1", live.agentHome);
|
||||
const { readInstances } = await import("../out/agent/instances.js");
|
||||
const ids = (await readInstances(live.agentHome)).map((i) => i.instanceId);
|
||||
assert.deepEqual(ids, ["other-window"]);
|
||||
} finally {
|
||||
await live.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { startLocalServer } from "../out/agent/localServer.js";
|
||||
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Example";
|
||||
const PROJECT_B = "D:/Mods/Other";
|
||||
|
||||
function makeStats() {
|
||||
return {
|
||||
projectDir: "P",
|
||||
sdkDir: "",
|
||||
phase: "art",
|
||||
complete: true,
|
||||
indexedFiles: 1,
|
||||
parsedFiles: 1,
|
||||
shallowScannedFiles: 0,
|
||||
deferredArtFiles: 0,
|
||||
shallowCacheHits: 0,
|
||||
recordsCacheHits: 0,
|
||||
resolveCacheHits: 0,
|
||||
resolveCalls: 0,
|
||||
snapshotHits: 0,
|
||||
snapshotFallbacks: 0,
|
||||
candidatesMs: 0,
|
||||
walkMs: 0,
|
||||
artScanMs: 0,
|
||||
assetCount: 1,
|
||||
referenceCount: 1,
|
||||
defineCount: 1,
|
||||
manifestFiles: 0,
|
||||
manifestAssetCount: 0,
|
||||
streams: 1,
|
||||
sourceCandidates: 1,
|
||||
elapsedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const CANNON_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
|
||||
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="BaseCannon">
|
||||
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const FILES = {
|
||||
"D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML,
|
||||
"D:/Mods/Example/Data/BaseCannon.xml": BASE_XML,
|
||||
};
|
||||
|
||||
function makeIndex(projectDir, unitId, extraDefs = []) {
|
||||
const file = `${projectDir}/Data/${unitId}.xml`;
|
||||
const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" };
|
||||
const all = [unit, ...extraDefs];
|
||||
const assets = new Map();
|
||||
const assetsById = new Map();
|
||||
for (const d of all) {
|
||||
if (!assets.has(d.type)) assets.set(d.type, new Map());
|
||||
const byId = assets.get(d.type);
|
||||
if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []);
|
||||
byId.get(d.id.toLowerCase()).push(d);
|
||||
const key = d.id.toLowerCase();
|
||||
if (!assetsById.has(key)) assetsById.set(key, []);
|
||||
assetsById.get(key).push(d);
|
||||
}
|
||||
return {
|
||||
projectDir,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets,
|
||||
assetsById,
|
||||
defines: new Map([
|
||||
["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]],
|
||||
]),
|
||||
files: new Map(),
|
||||
streams: [
|
||||
{
|
||||
name: "static",
|
||||
entry: `${projectDir}/Data/Mod.xml`,
|
||||
files: new Set([file.toLowerCase().replace(/\\/g, "/")]),
|
||||
},
|
||||
],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[
|
||||
`GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`,
|
||||
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
|
||||
],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: makeStats(),
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT_A_RELATED = [
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "BaseCannon",
|
||||
file: "D:/Mods/Example/Data/BaseCannon.xml",
|
||||
line: 2,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "AthenaCannon_Die",
|
||||
file: "D:/Mods/Example/Data/AthenaCannon_Die.xml",
|
||||
line: 2,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
{
|
||||
type: "WeaponTemplate",
|
||||
id: "AthenaCannonWeapon",
|
||||
file: "D:/Mods/Example/Data/Weapon.xml",
|
||||
line: 88,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
];
|
||||
|
||||
/** Routes to a distinct index per requested project, like the extension does. */
|
||||
function routedServerOptions() {
|
||||
const indexes = new Map([
|
||||
[PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)],
|
||||
[PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")],
|
||||
]);
|
||||
return {
|
||||
getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)),
|
||||
listProjects: () => [...indexes.keys()],
|
||||
loadFile: async (file) => {
|
||||
const text = FILES[file];
|
||||
return text ? parseLoadedXml(text) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(fn) {
|
||||
const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" });
|
||||
const base = `http://127.0.0.1:${handle.port}`;
|
||||
const headers = { authorization: "Bearer test-token" };
|
||||
const get = async (path) => (await fetch(`${base}${path}`, { headers })).json();
|
||||
try {
|
||||
await fn({ base, headers, get });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("local server requires the bearer token", async () => {
|
||||
await withServer(async ({ base }) => {
|
||||
const unauthorized = await fetch(`${base}/status`);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
const forbidden = await fetch(`${base}/status`, {
|
||||
headers: { authorization: "Bearer wrong" },
|
||||
});
|
||||
assert.equal(forbidden.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test("local server exposes read-only queries", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`);
|
||||
assert.equal(status.state, "ready");
|
||||
assert.equal(status.projectDir, PROJECT_A);
|
||||
|
||||
const asset = await get(
|
||||
`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
|
||||
);
|
||||
assert.equal(asset.data.length, 1);
|
||||
|
||||
const refs = await get(
|
||||
`/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`,
|
||||
);
|
||||
assert.equal(refs.data.length, 1);
|
||||
|
||||
const active = await get(
|
||||
`/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`,
|
||||
);
|
||||
assert.equal(active.data.active, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("?project= selects the index instead of the active editor's project", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`);
|
||||
assert.equal(a.index.projectDir, PROJECT_A);
|
||||
assert.equal(a.data.length, 1);
|
||||
|
||||
const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`);
|
||||
assert.equal(b.index.projectDir, PROJECT_B);
|
||||
assert.equal(b.data.length, 1);
|
||||
|
||||
// The same id must not resolve when asked about the other project.
|
||||
const cross = await get(
|
||||
`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`,
|
||||
);
|
||||
assert.equal(cross.data.length, 0);
|
||||
assert.equal(cross.index.projectDir, PROJECT_B);
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown project reports no_index without faking a projectDir", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const unknown = "D:/Mods/Unknown";
|
||||
const result = await get(`/status?project=${encodeURIComponent(unknown)}`);
|
||||
assert.equal(result.state, "no_index");
|
||||
assert.equal(result.projectDir, unknown);
|
||||
});
|
||||
});
|
||||
|
||||
test("/projects lists the known roots", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const result = await get("/projects");
|
||||
assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B]));
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references returns provenance-carrying edges", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const result = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
|
||||
);
|
||||
assert.equal(result.index.state, "ready");
|
||||
const data = result.data;
|
||||
assert.ok(data, "expected a data payload");
|
||||
assert.equal(data.edges.length >= 3, true);
|
||||
|
||||
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
|
||||
assert.ok(weapon, "expected the inherited weapon edge");
|
||||
assert.equal(weapon.via.element, "Weapon");
|
||||
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
|
||||
assert.equal(weapon.definedIn.id, "BaseCannon");
|
||||
assert.equal(weapon.source.file, "D:/Mods/Example/Data/BaseCannon.xml");
|
||||
|
||||
const die = data.edges.find((e) => e.via.kind === "content");
|
||||
assert.ok(die, "expected the CreateObjectDie content edge");
|
||||
assert.equal(die.to.id, "AthenaCannon_Die");
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references honours targetTypes and depth parameters", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const filtered = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`,
|
||||
);
|
||||
for (const edge of filtered.data.edges) {
|
||||
if (edge.via.kind === "inheritFrom") continue;
|
||||
assert.equal(edge.to.type, "WeaponTemplate");
|
||||
}
|
||||
|
||||
const shallow = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`,
|
||||
);
|
||||
for (const edge of shallow.data.edges) {
|
||||
assert.equal(edge.depth, 1);
|
||||
}
|
||||
|
||||
const deep = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`,
|
||||
);
|
||||
for (const edge of deep.data.edges) {
|
||||
assert.ok(edge.depth <= 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references explains itself when live data is unavailable", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`);
|
||||
assert.equal(missing.data.roots.length, 0);
|
||||
assert.ok(missing.data.warnings.length > 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// The URL building, project pinning and cross-project refusal now live in
|
||||
// `liveClient` and are covered by agentLiveClient.test.mjs. What remains
|
||||
// MCP-layer-specific is that the server module is *importable*: it must not
|
||||
// start its stdio loop as a side effect of being imported, otherwise tests and
|
||||
// any tool that merely inspects the module would hang waiting on stdin.
|
||||
|
||||
test("the MCP server module imports without starting its stdio loop", async () => {
|
||||
const mod = await import("../out/agent/mcpServer.js");
|
||||
assert.equal(typeof mod, "object");
|
||||
// Reaching this line proves main() did not run on import.
|
||||
});
|
||||
|
||||
test("the CLI module imports without running", async () => {
|
||||
const mod = await import("../out/agent/cli.js");
|
||||
assert.equal(typeof mod.parseArgs, "function");
|
||||
assert.equal(typeof mod.toolNameFor, "function");
|
||||
assert.equal(typeof mod.liveArgsFor, "function");
|
||||
assert.ok(mod.LIVE_ONLY instanceof Set);
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
electronExecutableCandidates,
|
||||
electronRuntime,
|
||||
findElectronExecutable,
|
||||
isElectronHost,
|
||||
isNodeFreeLauncher,
|
||||
launcherScript,
|
||||
looksLikeElectronExecutable,
|
||||
resolveRuntime,
|
||||
} from "../out/agent/runtime.js";
|
||||
import { writeLauncher } from "../out/agent/setup.js";
|
||||
|
||||
const WIN = "win32";
|
||||
const LINUX = "linux";
|
||||
|
||||
// ── Pure launcher generation ──────────────────────────────────────────
|
||||
|
||||
test("electron runtime launcher runs without Node on PATH", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
||||
serverPath: "C:\\ext\\dist\\agent\\mcpServer.js",
|
||||
projectDir: "D:\\Mods\\Example",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(script.startsWith("@echo off"));
|
||||
assert.ok(script.includes("set ELECTRON_RUN_AS_NODE=1"));
|
||||
assert.ok(script.includes("C:\\Apps\\VSCode\\Code.exe"));
|
||||
assert.ok(script.includes("C:\\ext\\dist\\agent\\mcpServer.js"));
|
||||
// The Node path exists but must be guarded by the runtime-existence jump.
|
||||
assert.ok(script.includes('if not exist "%RA3_RUNTIME%" goto :ra3_node'));
|
||||
assert.ok(script.includes(":ra3_node"));
|
||||
assert.equal(isNodeFreeLauncher(script, WIN), true);
|
||||
});
|
||||
|
||||
test("Windows launcher avoids the parse-time %errorlevel% batch pitfall", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Apps\\VSCode\\Code.exe"),
|
||||
serverPath: "C:\\ext\\mcpServer.js",
|
||||
projectDir: "D:\\P",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
// Inside a parenthesised block %errorlevel% would expand at parse time.
|
||||
assert.ok(!/^if exist .*\(\s*$/m.test(script), "must not use an if (...) block");
|
||||
assert.equal(
|
||||
(script.match(/exit \/b %errorlevel%/g) ?? []).length,
|
||||
2,
|
||||
"both Electron and Node endings should propagate the exit code",
|
||||
);
|
||||
});
|
||||
|
||||
test("node runtime launcher is generated when Electron is unavailable", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: { kind: "node", executable: "node", env: {}, viaPath: true },
|
||||
serverPath: "C:\\ext\\mcpServer.js",
|
||||
projectDir: "D:\\P",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(!script.includes("ELECTRON_RUN_AS_NODE"));
|
||||
assert.ok(script.includes('set "RA3_NODE=node"'));
|
||||
assert.equal(isNodeFreeLauncher(script, WIN), false);
|
||||
});
|
||||
|
||||
test("POSIX launcher prefers Electron and falls back to Node", () => {
|
||||
const script = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("/usr/share/code/code"),
|
||||
serverPath: "/ext/mcpServer.js",
|
||||
projectDir: "/mods/example",
|
||||
},
|
||||
LINUX,
|
||||
);
|
||||
assert.ok(script.startsWith("#!/usr/bin/env sh"));
|
||||
assert.ok(script.includes("ELECTRON_RUN_AS_NODE=1 exec"));
|
||||
assert.ok(script.includes('if [ -x "$RA3_RUNTIME" ]'));
|
||||
assert.equal(isNodeFreeLauncher(script, LINUX), true);
|
||||
});
|
||||
|
||||
test("launcher quotes paths safely", () => {
|
||||
const win = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("C:\\Program Files\\VS Code\\Code.exe"),
|
||||
serverPath: "C:\\my ext\\mcpServer.js",
|
||||
projectDir: "D:\\My Mods\\Example",
|
||||
},
|
||||
WIN,
|
||||
);
|
||||
assert.ok(win.includes('set "RA3_RUNTIME=C:\\Program Files\\VS Code\\Code.exe"'));
|
||||
assert.ok(win.includes('set "RA3_SERVER=C:\\my ext\\mcpServer.js"'));
|
||||
|
||||
const sh = launcherScript(
|
||||
{
|
||||
runtime: electronRuntime("/opt/it's here/code"),
|
||||
serverPath: "/tmp/server.js",
|
||||
projectDir: "/tmp/proj",
|
||||
},
|
||||
LINUX,
|
||||
);
|
||||
// Single quotes inside a single-quoted POSIX string must be escaped.
|
||||
assert.ok(sh.includes(`'/opt/it'\\''s here/code'`));
|
||||
});
|
||||
|
||||
test("looksLikeElectronExecutable recognises VS Code-family binaries", () => {
|
||||
assert.equal(looksLikeElectronExecutable("C:\\...\\Microsoft VS Code\\Code.exe"), true);
|
||||
assert.equal(looksLikeElectronExecutable("/usr/share/code/code"), true);
|
||||
assert.equal(looksLikeElectronExecutable("/Applications/Visual Studio Code.app/Contents/MacOS/Electron"), true);
|
||||
assert.equal(looksLikeElectronExecutable("C:\\Windows\\System32\\cmd.exe"), false);
|
||||
});
|
||||
|
||||
test("runtime resolution falls back to Node outside an Electron host", () => {
|
||||
// Tests run under plain Node, so the resolved runtime must be Node.
|
||||
assert.equal(isElectronHost(), false);
|
||||
const runtime = resolveRuntime();
|
||||
assert.equal(runtime.kind, "node");
|
||||
assert.equal(runtime.viaPath, true);
|
||||
assert.deepEqual(runtime.env, {});
|
||||
});
|
||||
|
||||
test("electronRuntime carries the ELECTRON_RUN_AS_NODE env", () => {
|
||||
const runtime = electronRuntime("C:\\Code.exe");
|
||||
assert.equal(runtime.kind, "electron");
|
||||
assert.equal(runtime.executable, "C:\\Code.exe");
|
||||
assert.deepEqual(runtime.env, { ELECTRON_RUN_AS_NODE: "1" });
|
||||
assert.equal(runtime.viaPath, false);
|
||||
});
|
||||
|
||||
test("electronExecutableCandidates returns platform-appropriate probes", () => {
|
||||
const candidates = electronExecutableCandidates();
|
||||
assert.ok(Array.isArray(candidates));
|
||||
if (process.platform === "win32") {
|
||||
assert.ok(candidates.every((c) => c.endsWith(".exe")));
|
||||
}
|
||||
// findElectronExecutable must never throw and returns a string or null.
|
||||
const found = findElectronExecutable();
|
||||
assert.ok(found === null || typeof found === "string");
|
||||
});
|
||||
|
||||
// ── writeLauncher integration ─────────────────────────────────────────
|
||||
|
||||
test("writeLauncher writes an executable launcher and reports node-freeness", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-runtime-test-"));
|
||||
try {
|
||||
const result = await writeLauncher("C:\\ext", "D:\\Mods\\Example", home, electronRuntime("C:\\Code.exe"));
|
||||
assert.ok(existsSync(result.path));
|
||||
assert.equal(result.nodeFree, true);
|
||||
assert.equal(result.runtime.kind, "electron");
|
||||
const text = readFileSync(result.path, "utf8");
|
||||
assert.ok(text.includes("ELECTRON_RUN_AS_NODE=1"));
|
||||
assert.ok(text.includes("mcpServer.js"));
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Live launcher execution (skipped when the sandbox forbids spawning) ──
|
||||
|
||||
const electron = findElectronExecutable();
|
||||
|
||||
/**
|
||||
* True when this process may launch the launcher, i.e. spawn a shell.
|
||||
*
|
||||
* Some sandboxes allow spawning `node` directly but deny `cmd.exe`/`sh`, so
|
||||
* the probe must exercise the same capability the test needs instead of just
|
||||
* spawning any child process.
|
||||
*/
|
||||
function canSpawnShell() {
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
execFileSync("cmd.exe", ["/d", "/c", "exit 0"], { stdio: "ignore" });
|
||||
} else {
|
||||
execFileSync("/bin/sh", ["-c", "exit 0"], { stdio: "ignore" });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const spawnable = canSpawnShell();
|
||||
|
||||
test(
|
||||
"generated launcher completes an MCP session on the Electron runtime",
|
||||
{ skip: !spawnable || !electron },
|
||||
(t) => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-launcher-run-"));
|
||||
try {
|
||||
const serverPath = join(process.cwd(), "dist", "agent", "mcpServer.js");
|
||||
const launcher = join(home, process.platform === "win32" ? "launch.cmd" : "launch.sh");
|
||||
writeFileSync(
|
||||
launcher,
|
||||
launcherScript({
|
||||
runtime: electronRuntime(electron),
|
||||
serverPath,
|
||||
projectDir: "D:/Mods/Example",
|
||||
// Point the Node fallback at a path that cannot exist, so a
|
||||
// successful session proves the Electron branch was taken and the
|
||||
// launcher really is Node-free.
|
||||
nodeFallback: join(home, "no-such-node"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const requests = [
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
||||
JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }),
|
||||
].join("\n");
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(launcher, [], {
|
||||
input: requests,
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.code === "EPERM") {
|
||||
t.skip("sandbox forbids spawning a shell");
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const lines = stdout.split(/\r?\n/).filter((l) => l.trim());
|
||||
const init = JSON.parse(lines[0]);
|
||||
assert.equal(init.result.serverInfo.name, "ra3-mod-xml");
|
||||
const tools = JSON.parse(lines[1]);
|
||||
assert.ok(tools.result.tools.length >= 9);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
addMcpServerToConfigFile,
|
||||
installMcpServerConfigToFile,
|
||||
launcherPath,
|
||||
mcpConfigJson,
|
||||
mcpServerConfig,
|
||||
readMcpInstallRecord,
|
||||
removeLauncher,
|
||||
removeMcpServerFromConfigFile,
|
||||
uninstallMcpServerConfigs,
|
||||
} from "../out/agent/setup.js";
|
||||
|
||||
test("mcpServerConfig and mcpConfigJson use the stable launcher", () => {
|
||||
const config = mcpServerConfig("C:/Users/me/.ra3modxml/ra3-mod-xml-mcp.cmd", "D:/Mods/Example");
|
||||
assert.deepEqual(config.mcpServers["ra3-mod-xml"].args, ["--project", "D:/Mods/Example"]);
|
||||
const json = mcpConfigJson("C:/launcher.cmd", "D:/Proj");
|
||||
assert.ok(json.includes("C:/launcher.cmd"));
|
||||
assert.ok(json.includes("D:/Proj"));
|
||||
});
|
||||
|
||||
test("addMcpServerToConfigFile creates and merges config", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-test-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
|
||||
const first = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.ok(first.mcpServers["ra3-mod-xml"]);
|
||||
|
||||
writeFileSync(file, JSON.stringify({ mcpServers: { other: { command: "x" } } }, null, 2));
|
||||
await addMcpServerToConfigFile(file, "C:/launcher.cmd", "D:/Proj");
|
||||
const merged = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.ok(merged.mcpServers.other);
|
||||
assert.ok(merged.mcpServers["ra3-mod-xml"]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeMcpServerFromConfigFile removes only our entry and preserves the rest", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-remove-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
other: { command: "x" },
|
||||
"ra3-mod-xml": { command: "launcher", args: ["--project", "D:/Mods/A"] },
|
||||
},
|
||||
someOtherKey: 1,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), true);
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.ok(parsed.mcpServers.other);
|
||||
assert.equal(parsed.mcpServers["ra3-mod-xml"], undefined);
|
||||
assert.equal(parsed.someOtherKey, 1);
|
||||
|
||||
// Nothing left to remove: second call is a no-op.
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeMcpServerFromConfigFile handles VS Code's servers key and drops empty containers", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-vscode-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ servers: { "ra3-mod-xml": { command: "x" } }, inputs: [] }, null, 2),
|
||||
);
|
||||
assert.equal(await removeMcpServerFromConfigFile(file), true);
|
||||
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
||||
assert.equal(parsed.servers, undefined);
|
||||
assert.deepEqual(parsed.inputs, []);
|
||||
|
||||
// A missing file is "nothing to remove", not an error.
|
||||
assert.equal(await removeMcpServerFromConfigFile(join(dir, "nope.json")), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installed MCP configs can be uninstalled through the install record", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-setup-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-setup-record-"));
|
||||
try {
|
||||
const file = join(dir, "mcp.json");
|
||||
await installMcpServerConfigToFile({
|
||||
filePath: file,
|
||||
launcher: "C:/launcher.cmd",
|
||||
projectDir: "D:/Mods/A",
|
||||
label: "Test client",
|
||||
sourceVersion: "1.2.3",
|
||||
agentHome: home,
|
||||
});
|
||||
|
||||
const record = await readMcpInstallRecord(home);
|
||||
assert.equal(record.length, 1);
|
||||
assert.equal(record[0].label, "Test client");
|
||||
assert.equal(record[0].serverKey, "ra3-mod-xml");
|
||||
assert.equal(record[0].sourceVersion, "1.2.3");
|
||||
|
||||
const removed = await uninstallMcpServerConfigs({ agentHome: home });
|
||||
assert.deepEqual(removed, [file]);
|
||||
assert.equal((await readMcpInstallRecord(home)).length, 0);
|
||||
// Our entry was the only server: the empty container is dropped.
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, "utf8")), {});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("removeLauncher deletes the stable launcher and tolerates its absence", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-setup-launcher-"));
|
||||
try {
|
||||
writeFileSync(launcherPath(home), "dummy", "utf8");
|
||||
assert.equal(existsSync(launcherPath(home)), true);
|
||||
assert.equal(await removeLauncher(home), true);
|
||||
assert.equal(existsSync(launcherPath(home)), false);
|
||||
// force: true, so removing it again is still a success.
|
||||
assert.equal(await removeLauncher(home), true);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
SKILL_MARKER_FILE,
|
||||
forgetSkillInstallRecords,
|
||||
installSkillToDirectories,
|
||||
installedSkillStatus,
|
||||
readSkillInstallRecord,
|
||||
readSkillMarker,
|
||||
uninstallRecordedSkills,
|
||||
uninstallSkillFromDirectory,
|
||||
writeSkillInstallRecord,
|
||||
writeSkillTo,
|
||||
} from "../out/agent/skill.js";
|
||||
|
||||
test("writeSkillTo creates SKILL.md and avoids project-doc noise", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-test-"));
|
||||
try {
|
||||
const skillDir = join(dir, "ra3-mod-xml");
|
||||
await writeSkillTo(skillDir, "0.1.25");
|
||||
assert.ok(existsSync(join(skillDir, "SKILL.md")));
|
||||
assert.ok(existsSync(join(skillDir, "references", "query-guide.md")));
|
||||
assert.ok(existsSync(join(skillDir, SKILL_MARKER_FILE)));
|
||||
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||
assert.ok(content.includes("find_asset"));
|
||||
assert.ok(!content.includes("codebase-navigation-guide"));
|
||||
|
||||
// The bundled reference must list every tool the MCP server exposes.
|
||||
const guide = readFileSync(
|
||||
join(skillDir, "references", "query-guide.md"),
|
||||
"utf8",
|
||||
);
|
||||
for (const tool of [
|
||||
"get_status",
|
||||
"find_asset",
|
||||
"find_references",
|
||||
"get_asset_references",
|
||||
"list_assets_by_type",
|
||||
"is_file_active",
|
||||
"find_define",
|
||||
"resolve_include",
|
||||
"list_projects",
|
||||
"get_usage_guide",
|
||||
]) {
|
||||
assert.ok(guide.includes(tool), `query-guide.md must mention ${tool}`);
|
||||
}
|
||||
assert.equal(guide.includes("CnC3Types.xsd"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SKILL.md scopes itself to SAGE/RA3 projects and warns off others", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-scope-"));
|
||||
try {
|
||||
const skillDir = join(dir, "ra3-mod-xml");
|
||||
await writeSkillTo(skillDir, "0.1.25");
|
||||
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||
|
||||
// Must state its applicability and list concrete positive signals.
|
||||
assert.match(content, /When this skill applies/);
|
||||
assert.ok(content.includes("Data/Mod.xml"));
|
||||
assert.ok(content.includes("babproj"));
|
||||
assert.ok(content.includes("AssetDeclaration"));
|
||||
|
||||
// Must give an explicit negative rule and a cheap probe.
|
||||
assert.match(content, /Do \*\*not\*\* use these tools for unrelated repositories/);
|
||||
assert.ok(content.includes("get_status"));
|
||||
assert.ok(content.includes("projectDir"));
|
||||
// `CnC3Types.xsd` is the shared SAGE base schema (the RA3 Mod SDK ships it
|
||||
// too), so it must not be presented as evidence in either direction.
|
||||
assert.equal(content.includes("CnC3Types.xsd"), false);
|
||||
assert.equal(content.includes("Tiberium Wars"), false);
|
||||
|
||||
// Second-phase capability must be documented.
|
||||
assert.ok(content.includes("get_asset_references"));
|
||||
assert.ok(content.includes("definedIn"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("SKILL.md explains how to reach the index without MCP", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-reach-"));
|
||||
try {
|
||||
const skillDir = join(dir, "ra3-mod-xml");
|
||||
await writeSkillTo(skillDir, "0.1.25");
|
||||
const content = readFileSync(join(skillDir, "SKILL.md"), "utf8");
|
||||
|
||||
assert.match(content, /## Reaching the index/);
|
||||
// The discovery manifest is the stable entry point.
|
||||
assert.ok(content.includes("~/.ra3modxml/index.json"));
|
||||
// The launcher and the bundled CLI are both mentioned.
|
||||
assert.ok(content.includes("ra3-mod-xml-mcp"));
|
||||
assert.ok(content.includes("cli.js"));
|
||||
// The CLI is a Node script, and the skill must say how to run it when Node
|
||||
// is not installed (VS Code's Electron binary as Node).
|
||||
assert.ok(content.includes("ELECTRON_RUN_AS_NODE=1"));
|
||||
// The stdio escape hatch must be shown, since it needs no setup at all.
|
||||
assert.ok(content.includes("tools/call"));
|
||||
// It must not pretend configuring a client takes effect immediately.
|
||||
assert.ok(content.includes("new session"));
|
||||
// And it must refuse to invent results.
|
||||
assert.match(content, /Never fabricate index results/);
|
||||
// The bundled tool reference must be linked from SKILL.md, otherwise
|
||||
// skills-compatible clients never load it (resources load on demand).
|
||||
assert.ok(content.includes("./references/query-guide.md"));
|
||||
// The tool list must stay a real list; a merged bullet hides an item.
|
||||
assert.match(content, /indexed stream\.\n\s+- `find_define\(name\)`/);
|
||||
// Instructions must not send it looking for project-specific docs.
|
||||
assert.equal(content.includes("codebase-navigation-guide"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installSkillToDirectories records managed copies and uninstall removes them", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-target-"));
|
||||
try {
|
||||
const target = join(dir, "ra3-mod-xml");
|
||||
const succeeded = await installSkillToDirectories([target], "0.1.25", home);
|
||||
assert.deepEqual(succeeded, [target]);
|
||||
const record = await readSkillInstallRecord(home);
|
||||
assert.equal(record.length, 1);
|
||||
assert.equal(record[0].path, target);
|
||||
|
||||
await uninstallSkillFromDirectory(target, home);
|
||||
assert.equal(existsSync(target), false);
|
||||
assert.equal((await readSkillInstallRecord(home)).length, 0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readSkillMarker requires our marker to describe the same directory", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-marker-"));
|
||||
try {
|
||||
const managed = join(dir, "managed");
|
||||
await writeSkillTo(managed, "1.0.0");
|
||||
const marker = await readSkillMarker(managed);
|
||||
assert.equal(marker?.sourceVersion, "1.0.0");
|
||||
|
||||
// A hand-copied skill without a marker is never treated as ours.
|
||||
const foreign = join(dir, "foreign");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||
assert.equal(await readSkillMarker(foreign), null);
|
||||
|
||||
// A marker pointing at another directory must not make this one managed.
|
||||
const spoofed = join(dir, "spoofed");
|
||||
mkdirSync(spoofed, { recursive: true });
|
||||
writeFileSync(
|
||||
join(spoofed, SKILL_MARKER_FILE),
|
||||
JSON.stringify({ path: managed, sourceVersion: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
assert.equal(await readSkillMarker(spoofed), null);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uninstallRecordedSkills removes managed copies and skips user-owned ones", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-uninstall-"));
|
||||
try {
|
||||
const managed = join(dir, "managed");
|
||||
await installSkillToDirectories([managed], "1.0.0", home);
|
||||
|
||||
// A directory the user owns: recorded, but with no marker of ours.
|
||||
const foreign = join(dir, "foreign");
|
||||
mkdirSync(foreign, { recursive: true });
|
||||
writeFileSync(join(foreign, "SKILL.md"), "user content", "utf8");
|
||||
const record = await readSkillInstallRecord(home);
|
||||
record.push({ path: foreign, sourceVersion: "0.0.0" });
|
||||
await writeSkillInstallRecord(record, home);
|
||||
|
||||
const status = await installedSkillStatus(home);
|
||||
assert.equal(status.length, 2);
|
||||
assert.deepEqual(
|
||||
status.map((s) => ({ path: s.path, managed: s.managed })),
|
||||
[
|
||||
{ path: managed, managed: true },
|
||||
{ path: foreign, managed: false },
|
||||
],
|
||||
);
|
||||
|
||||
const { removed, skipped } = await uninstallRecordedSkills(
|
||||
[managed, foreign],
|
||||
home,
|
||||
);
|
||||
assert.deepEqual(removed, [managed]);
|
||||
assert.deepEqual(skipped, [foreign]);
|
||||
assert.equal(existsSync(managed), false);
|
||||
assert.equal(readFileSync(join(foreign, "SKILL.md"), "utf8"), "user content");
|
||||
|
||||
// The unmanaged entry stays in the record so it can still be reviewed.
|
||||
const after = await readSkillInstallRecord(home);
|
||||
assert.deepEqual(after.map((r) => r.path), [foreign]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("installedSkillStatus and forgetSkillInstallRecords handle a missing directory", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "ra3-skill-missing-home-"));
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-skill-missing-"));
|
||||
try {
|
||||
const gone = join(dir, "gone");
|
||||
await writeSkillInstallRecord([{ path: gone, sourceVersion: "1.0.0" }], home);
|
||||
|
||||
const status = await installedSkillStatus(home);
|
||||
assert.equal(status.length, 1);
|
||||
assert.equal(status[0].exists, false);
|
||||
assert.equal(status[0].managed, false);
|
||||
|
||||
await forgetSkillInstallRecords([gone], home);
|
||||
assert.equal((await readSkillInstallRecord(home)).length, 0);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { snapshotFromIndex, writeSnapshotFile, readSnapshotFile } from "../out/agent/snapshot.js";
|
||||
import {
|
||||
findAssets,
|
||||
findReferenceGroups,
|
||||
isFileActive,
|
||||
listAssetsByType,
|
||||
resolveIncludeSource,
|
||||
statusFromSnapshot,
|
||||
} from "../out/agent/query.js";
|
||||
|
||||
function makeStats() {
|
||||
return {
|
||||
projectDir: "P",
|
||||
sdkDir: "",
|
||||
phase: "art",
|
||||
complete: true,
|
||||
indexedFiles: 3,
|
||||
parsedFiles: 2,
|
||||
shallowScannedFiles: 1,
|
||||
deferredArtFiles: 0,
|
||||
shallowCacheHits: 0,
|
||||
recordsCacheHits: 0,
|
||||
resolveCacheHits: 0,
|
||||
resolveCalls: 0,
|
||||
snapshotHits: 0,
|
||||
snapshotFallbacks: 0,
|
||||
candidatesMs: 0,
|
||||
walkMs: 0,
|
||||
artScanMs: 0,
|
||||
assetCount: 2,
|
||||
referenceCount: 1,
|
||||
defineCount: 1,
|
||||
manifestFiles: 0,
|
||||
manifestAssetCount: 0,
|
||||
streams: 1,
|
||||
sourceCandidates: 1,
|
||||
elapsedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIndex() {
|
||||
const file = "D:/Mods/Example/Data/Units/Example.xml";
|
||||
const assets = new Map([
|
||||
[
|
||||
"GameObject",
|
||||
new Map([
|
||||
[
|
||||
"exampleunit",
|
||||
[
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "ExampleUnit",
|
||||
file,
|
||||
line: 5,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
],
|
||||
]);
|
||||
const references = new Map([
|
||||
[
|
||||
"GameObject\u0000exampleunit\u0000D:/Mods/Example/Data/Units/Example.xml\u00005",
|
||||
[
|
||||
{
|
||||
file: "D:/Mods/Example/Data/Other.xml",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 21,
|
||||
kind: "attr",
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
return {
|
||||
projectDir: "D:/Mods/Example",
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets,
|
||||
assetsById: new Map([
|
||||
[
|
||||
"exampleunit",
|
||||
[
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "ExampleUnit",
|
||||
file,
|
||||
line: 5,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
defines: new Map([
|
||||
[
|
||||
"exampledefine",
|
||||
[
|
||||
{
|
||||
name: "ExampleDefine",
|
||||
value: "1",
|
||||
file,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
files: new Map(),
|
||||
streams: [
|
||||
{
|
||||
name: "static",
|
||||
entry: "D:/Mods/Example/Data/Mod.xml",
|
||||
files: new Set(["d:/mods/example/data/units/example.xml"]),
|
||||
},
|
||||
],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [
|
||||
{
|
||||
source: "DATA:Units/Example.xml",
|
||||
path: "D:/Mods/Example/Data/Units/Example.xml",
|
||||
prefix: "DATA",
|
||||
baseDir: "D:/Mods/Example/Data",
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
references,
|
||||
recordsHashes: new Map(),
|
||||
stats: makeStats(),
|
||||
};
|
||||
}
|
||||
|
||||
test("snapshotFromIndex flattens assets, defines, streams and references", () => {
|
||||
const snapshot = snapshotFromIndex(makeIndex(), 42);
|
||||
assert.equal(snapshot.schemaVersion, 1);
|
||||
assert.equal(snapshot.assets.length, 1);
|
||||
assert.equal(snapshot.assets[0].id, "ExampleUnit");
|
||||
assert.equal(snapshot.defines.length, 1);
|
||||
assert.equal(snapshot.streams.length, 1);
|
||||
assert.deepEqual(snapshot.streams[0].files, [
|
||||
"d:/mods/example/data/units/example.xml",
|
||||
]);
|
||||
assert.equal(snapshot.references.length, 1);
|
||||
assert.equal(snapshot.references[0].sites.length, 1);
|
||||
assert.equal(snapshot.buildId, 42);
|
||||
});
|
||||
|
||||
test("query helpers operate on snapshots", () => {
|
||||
const snapshot = snapshotFromIndex(makeIndex(), 1);
|
||||
assert.equal(findAssets(snapshot, "ExampleUnit").length, 1);
|
||||
assert.equal(findAssets(snapshot, "exampleunit", "GameObject").length, 1);
|
||||
assert.equal(findAssets(snapshot, "exampleunit", "WeaponTemplate").length, 0);
|
||||
assert.equal(listAssetsByType(snapshot, "gameobject", "exa").length, 1);
|
||||
assert.equal(findReferenceGroups(snapshot, "ExampleUnit").length, 1);
|
||||
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Units/Example.xml"), true);
|
||||
assert.equal(isFileActive(snapshot, "D:/Mods/Example/Data/Dead.xml"), false);
|
||||
assert.equal(resolveIncludeSource(snapshot, "data:units/example.xml")?.path, "D:/Mods/Example/Data/Units/Example.xml");
|
||||
assert.equal(statusFromSnapshot(snapshot).state, "ready");
|
||||
});
|
||||
|
||||
test("snapshot file write/read round-trips", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "ra3-agent-test-"));
|
||||
try {
|
||||
const file = join(dir, "snapshot.json.gz");
|
||||
const snapshot = snapshotFromIndex(makeIndex(), 7);
|
||||
await writeSnapshotFile(file, snapshot);
|
||||
const loaded = await readSnapshotFile(file);
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.assets.length, snapshot.assets.length);
|
||||
assert.equal(loaded.references[0].sites[0].file, "D:/Mods/Example/Data/Other.xml");
|
||||
assert.equal(loaded.buildId, 7);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
assetDefsForCandidate,
|
||||
assetOriginRank,
|
||||
collectAssetSearchCandidates,
|
||||
isEmptyAssetSearchQuery,
|
||||
parseAssetSearchQuery,
|
||||
searchAssetCandidates,
|
||||
} from "../out/features/assetSearch.js";
|
||||
|
||||
function makeDef(type, id, file, line, extra = {}) {
|
||||
return { type, id, file, line, origin: "project", ...extra };
|
||||
}
|
||||
|
||||
function put(map, d) {
|
||||
let byId = map.get(d.type);
|
||||
if (!byId) {
|
||||
byId = new Map();
|
||||
map.set(d.type, byId);
|
||||
}
|
||||
const key = d.id.toLowerCase();
|
||||
const arr = byId.get(key);
|
||||
if (arr) arr.push(d);
|
||||
else byId.set(key, [d]);
|
||||
}
|
||||
|
||||
/** Minimal ModIndex surface used by the search core. */
|
||||
function makeIndex({ assets = [], local = [] } = {}) {
|
||||
const index = { assets: new Map() };
|
||||
for (const d of assets) put(index.assets, d);
|
||||
if (local.length) {
|
||||
const localAssets = new Map();
|
||||
for (const d of local) put(localAssets, d);
|
||||
index.local = {
|
||||
assets: localAssets,
|
||||
assetsById: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
const PROJECT = "D:/Mods/Corona";
|
||||
const SDK = "C:/Apps/RA3-MODSDK-X";
|
||||
|
||||
test("parseAssetSearchQuery splits Type:Id, bare id and type-only queries", () => {
|
||||
assert.deepEqual(parseAssetSearchQuery(" Assault "), {
|
||||
raw: "Assault",
|
||||
type: null,
|
||||
id: "assault",
|
||||
});
|
||||
assert.deepEqual(parseAssetSearchQuery("WeaponTemplate:AssaultRifle"), {
|
||||
raw: "WeaponTemplate:AssaultRifle",
|
||||
type: "weapontemplate",
|
||||
id: "assaultrifle",
|
||||
});
|
||||
assert.deepEqual(parseAssetSearchQuery("Weapon:"), {
|
||||
raw: "Weapon:",
|
||||
type: "weapon",
|
||||
id: "",
|
||||
});
|
||||
// A bare leading colon is just an id search.
|
||||
assert.deepEqual(parseAssetSearchQuery(":Assault"), {
|
||||
raw: ":Assault",
|
||||
type: null,
|
||||
id: "assault",
|
||||
});
|
||||
// Manifest-style qualified names: type = first segment, id = last segment.
|
||||
assert.deepEqual(parseAssetSearchQuery("W3DContainer:W3DContainer:AU"), {
|
||||
raw: "W3DContainer:W3DContainer:AU",
|
||||
type: "w3dcontainer",
|
||||
id: "au",
|
||||
});
|
||||
});
|
||||
|
||||
test("parseAssetSearchQuery recognises empty queries", () => {
|
||||
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery("")));
|
||||
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(" ")));
|
||||
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(":")));
|
||||
assert.ok(!isEmptyAssetSearchQuery(parseAssetSearchQuery("Weapon:")));
|
||||
});
|
||||
|
||||
test("collectAssetSearchCandidates dedupes type:id and counts definition sites", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 4),
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 9),
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
|
||||
origin: "manifest",
|
||||
manifestSource: "DATA:globaldata/weapon.xml",
|
||||
}),
|
||||
makeDef("GameObject", "AssaultRifle", `${PROJECT}/Data/units.xml`, 2),
|
||||
],
|
||||
});
|
||||
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
assert.equal(candidates.length, 2);
|
||||
|
||||
const weapon = candidates.find((c) => c.def.type === "WeaponTemplate");
|
||||
assert.equal(weapon.definitionCount, 3);
|
||||
// The mod definition wins over the manifest one.
|
||||
assert.equal(weapon.def.origin, "project");
|
||||
assert.equal(weapon.def.line, 4);
|
||||
|
||||
const gameObject = candidates.find((c) => c.def.type === "GameObject");
|
||||
assert.equal(gameObject.definitionCount, 1);
|
||||
});
|
||||
|
||||
test("collectAssetSearchCandidates merges the local overlay (unsaved wins)", () => {
|
||||
const index = makeIndex({
|
||||
assets: [makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12)],
|
||||
local: [
|
||||
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12, {
|
||||
stream: "local",
|
||||
}),
|
||||
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/units.xml`, 40, {
|
||||
stream: "local",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const byId = new Map(
|
||||
collectAssetSearchCandidates(index).map((c) => [c.def.id, c]),
|
||||
);
|
||||
assert.equal(byId.size, 2);
|
||||
assert.equal(byId.get("Tank").def.stream, "local");
|
||||
// The same file+line from overlay and global index is one definition.
|
||||
assert.equal(byId.get("Tank").definitionCount, 1);
|
||||
assert.equal(byId.get("TankPrototype").definitionCount, 1);
|
||||
});
|
||||
|
||||
test("assetOriginRank orders local, project, SDK and manifest definitions", () => {
|
||||
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { stream: "local" })), 0);
|
||||
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1)), 1);
|
||||
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { origin: "sdk" })), 2);
|
||||
assert.equal(
|
||||
assetOriginRank(makeDef("A", "a", "f", 1, { origin: "manifest" })),
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test("searchAssetCandidates finds exact, prefix and partial ids", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("GameObject", "CrateDebris_01", `${PROJECT}/Data/a.xml`, 1),
|
||||
makeDef("GameObject", "CrateDebris_02", `${PROJECT}/Data/a.xml`, 5),
|
||||
makeDef("GameObject", "MyCrateDebris", `${PROJECT}/Data/a.xml`, 9),
|
||||
makeDef("GameObject", "Unrelated", `${PROJECT}/Data/a.xml`, 13),
|
||||
],
|
||||
});
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
|
||||
const exact = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("CrateDebris_02"),
|
||||
);
|
||||
assert.equal(exact.total, 1);
|
||||
assert.equal(exact.matches[0].def.id, "CrateDebris_02");
|
||||
|
||||
const partial = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("cratedebris"),
|
||||
);
|
||||
assert.equal(partial.total, 3);
|
||||
// Prefix matches rank before substring matches.
|
||||
assert.deepEqual(
|
||||
partial.matches.map((m) => m.def.id),
|
||||
["CrateDebris_01", "CrateDebris_02", "MyCrateDebris"],
|
||||
);
|
||||
|
||||
const mid = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("Debris"),
|
||||
);
|
||||
assert.equal(mid.total, 3);
|
||||
});
|
||||
|
||||
test("searchAssetCandidates is case-insensitive and type-aware for bare queries", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
|
||||
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
|
||||
makeDef("GameObject", "OxTank", `${PROJECT}/Data/a.xml`, 5),
|
||||
],
|
||||
});
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
|
||||
const upper = searchAssetCandidates(candidates, parseAssetSearchQuery("TANK"));
|
||||
assert.equal(upper.total, 3);
|
||||
|
||||
// A bare query that matches a type name lists that type's assets too.
|
||||
const byType = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("GameObject"),
|
||||
);
|
||||
assert.equal(byType.total, 2);
|
||||
assert.deepEqual(
|
||||
byType.matches.map((m) => m.def.id).sort(),
|
||||
["OxTank", "Tank"],
|
||||
);
|
||||
});
|
||||
|
||||
test("searchAssetCandidates honours Type:Id, Type: and prefixed queries", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
|
||||
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/a.xml`, 5),
|
||||
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/b.xml`, 9),
|
||||
],
|
||||
});
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
|
||||
const typed = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("GameObject:Tank"),
|
||||
);
|
||||
assert.deepEqual(
|
||||
typed.matches.map((m) => m.def.id),
|
||||
["Tank", "TankPrototype"],
|
||||
);
|
||||
|
||||
// Partial type names work as a filter as well.
|
||||
const partialType = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("weapontemplate:assault"),
|
||||
);
|
||||
assert.equal(partialType.total, 1);
|
||||
assert.equal(partialType.matches[0].def.id, "AssaultRifle");
|
||||
|
||||
// `Type:` lists every asset of that type.
|
||||
const typeOnly = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("WeaponTemplate:"),
|
||||
);
|
||||
assert.equal(typeOnly.total, 2);
|
||||
|
||||
// A mismatching type excludes otherwise matching ids.
|
||||
const mismatch = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("WeaponTemplate:Tank"),
|
||||
);
|
||||
assert.equal(mismatch.total, 1); // TankGun only (prefix), not GameObject Tank
|
||||
assert.equal(mismatch.matches[0].def.id, "TankGun");
|
||||
});
|
||||
|
||||
test("searchAssetCandidates ranks mod definitions before vanilla ones", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
|
||||
origin: "manifest",
|
||||
}),
|
||||
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapon.xml`, 7),
|
||||
],
|
||||
});
|
||||
const candidates = collectAssetSearchCandidates(index);
|
||||
const result = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("AssaultRifle"),
|
||||
);
|
||||
assert.equal(result.total, 1);
|
||||
assert.equal(result.matches[0].def.origin, "project");
|
||||
assert.equal(result.matches[0].definitionCount, 2);
|
||||
});
|
||||
|
||||
test("searchAssetCandidates returns no rows for an empty query and caps results", () => {
|
||||
const assets = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
assets.push(
|
||||
makeDef("GameObject", `Target_${String(i).padStart(2, "0")}`, "f", i + 1),
|
||||
);
|
||||
}
|
||||
const candidates = collectAssetSearchCandidates(makeIndex({ assets }));
|
||||
|
||||
const empty = searchAssetCandidates(candidates, parseAssetSearchQuery(""));
|
||||
assert.equal(empty.total, 0);
|
||||
assert.equal(empty.matches.length, 0);
|
||||
|
||||
const all = searchAssetCandidates(candidates, parseAssetSearchQuery("Target"));
|
||||
assert.equal(all.total, 25);
|
||||
assert.equal(all.matches.length, 25);
|
||||
|
||||
const capped = searchAssetCandidates(
|
||||
candidates,
|
||||
parseAssetSearchQuery("Target"),
|
||||
10,
|
||||
);
|
||||
assert.equal(capped.total, 25);
|
||||
assert.equal(capped.matches.length, 10);
|
||||
});
|
||||
|
||||
test("assetDefsForCandidate lists distinct sites, local overlay first", () => {
|
||||
const index = makeIndex({
|
||||
assets: [
|
||||
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3),
|
||||
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
|
||||
origin: "sdk",
|
||||
}),
|
||||
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
|
||||
origin: "sdk",
|
||||
}),
|
||||
],
|
||||
local: [
|
||||
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3, {
|
||||
stream: "local",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const candidate = collectAssetSearchCandidates(index).find(
|
||||
(c) => c.def.id === "Tank",
|
||||
);
|
||||
const defs = assetDefsForCandidate(index, candidate);
|
||||
assert.equal(defs.length, 2);
|
||||
assert.equal(defs[0].stream, "local");
|
||||
assert.equal(defs[1].origin, "sdk");
|
||||
});
|
||||
@@ -308,6 +308,21 @@ test("element and attribute name completions work without an index", async () =>
|
||||
assert.ok(labels.includes("Surfaces"));
|
||||
});
|
||||
|
||||
test("universal inheritFrom is offered on asset attribute completion", async () => {
|
||||
const text = `<AssetDeclaration>\n <FXList `;
|
||||
const line1 = text.split("\n")[1];
|
||||
const pos = new Position(1, line1.length);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = listItems(items).map((i) => i.label);
|
||||
assert.ok(labels.includes("inheritFrom"), "FXList offers universal inheritFrom");
|
||||
assert.ok(labels.includes("id"));
|
||||
});
|
||||
|
||||
test("attribute completion after a closed quote inserts a space", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
@@ -762,6 +777,138 @@ test("simple-content value completion works before the closing tag is typed", as
|
||||
assert.equal(item.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("asset value completion keeps a typed Type: prefix the user already typed", async () => {
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: "Sounds.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([["AudioEvent", new Map([["basesoundeffect", [def]]])]]),
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
};
|
||||
|
||||
// Qualified input: the typed "AudioEvent:" prefix must be kept, so the
|
||||
// completed value is "AudioEvent:BaseSoundEffect" and the replacement
|
||||
// range still covers only the current segment.
|
||||
const qualifiedText =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="AudioEvent:Base\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const qualifiedLine = qualifiedText.split("\n")[1];
|
||||
const qualifiedPos = new Position(1, qualifiedLine.length);
|
||||
const qualifiedItems = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(qualifiedText),
|
||||
qualifiedPos,
|
||||
token,
|
||||
);
|
||||
const qualifiedLabels = qualifiedItems.map((i) => i.label);
|
||||
assert.ok(
|
||||
qualifiedLabels.includes("AudioEvent:BaseSoundEffect"),
|
||||
"qualified label offered for AudioEvent:Base",
|
||||
);
|
||||
assert.ok(
|
||||
!qualifiedLabels.includes("BaseSoundEffect"),
|
||||
"the bare id is not offered when a type prefix was typed",
|
||||
);
|
||||
const qualifiedItem = qualifiedItems.find(
|
||||
(i) => i.label === "AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(qualifiedItem.insertText, "AudioEvent:BaseSoundEffect");
|
||||
assert.equal(
|
||||
qualifiedItem.range.start.character,
|
||||
qualifiedLine.lastIndexOf('"') + 1,
|
||||
);
|
||||
assert.equal(qualifiedItem.range.end.character, qualifiedPos.character);
|
||||
|
||||
// Plain input stays plain: no prefix typed -> bare id, unchanged behavior.
|
||||
const plainText =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="Base\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const plainLine = plainText.split("\n")[1];
|
||||
const plainPos = new Position(1, plainLine.length);
|
||||
const plainItems = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(plainText),
|
||||
plainPos,
|
||||
token,
|
||||
);
|
||||
const plainItem = plainItems.find((i) => i.label === "BaseSoundEffect");
|
||||
assert.ok(plainItem, "bare id offered without a type prefix");
|
||||
assert.equal(plainItem.insertText, "BaseSoundEffect");
|
||||
});
|
||||
|
||||
test("simpleContent complex child inserts a value pair and triggers suggest", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="A">\n` +
|
||||
` <S`;
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.length);
|
||||
|
||||
const items = await providerNoIndex.provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const sound = listItems(items).find((i) => i.label === "Sound");
|
||||
assert.ok(sound, "Sound child is offered under AudioEvent");
|
||||
assert.equal(sound.insertText.value, "Sound>$1</Sound>");
|
||||
assert.ok(sound.command, "simpleContent child re-triggers value suggest");
|
||||
});
|
||||
|
||||
test("simpleContent complex text offers typed asset ids as the value", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="A">\n` +
|
||||
` <Sound>V</Sound>\n` +
|
||||
` </AudioEvent>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.indexOf(">V") + 2);
|
||||
const audioFile = {
|
||||
type: "AudioFile",
|
||||
id: "VoiceFile",
|
||||
file: "AudioFiles.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const audioEvent = {
|
||||
type: "AudioEvent",
|
||||
id: "VoiceEvent",
|
||||
file: "Voice.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assets: new Map([
|
||||
["AudioFile", new Map([["voicefile", [audioFile]]])],
|
||||
["AudioEvent", new Map([["voiceevent", [audioEvent]]])],
|
||||
]),
|
||||
assetsById: new Map([
|
||||
["voicefile", [audioFile]],
|
||||
["voiceevent", [audioEvent]],
|
||||
]),
|
||||
};
|
||||
|
||||
const items = await makeProvider(idx).provideCompletionItems(
|
||||
makeDocument(text),
|
||||
pos,
|
||||
token,
|
||||
);
|
||||
const labels = items.map((i) => i.label);
|
||||
assert.ok(labels.includes("VoiceFile"));
|
||||
assert.ok(
|
||||
!labels.includes("VoiceEvent"),
|
||||
"Sound content is filtered by AudioFileRefWithWeight refType AudioFile",
|
||||
);
|
||||
const item = items.find((i) => i.label === "VoiceFile");
|
||||
assert.equal(item.range.start.character, line.indexOf(">V") + 1);
|
||||
assert.equal(item.range.end.character, pos.character);
|
||||
});
|
||||
|
||||
test("content start after accepting a simple-content snippet offers values, not attributes", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
|
||||
@@ -239,6 +239,145 @@ test("Ctrl+click on simple-content text jumps to the definition", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom is diagnosed and navigated as resolved", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration xmlns="uri:ea.com:eala:asset">\n` +
|
||||
` <AudioEvent id="BaseSoundEffect"/>\n` +
|
||||
` <AudioEvent id="X" inheritFrom="AudioEvent:BaseSoundEffect"/>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: URI,
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = makeIdx([def]);
|
||||
const scope = await makeScope(text, idx);
|
||||
|
||||
// Diagnostics: the manifest-style qualified value must not be reported as
|
||||
// an unresolved reference (the reported FutureTank scenario).
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const diagnostics = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: false,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
diagnostics["collection"] = collection;
|
||||
await diagnostics.update(makeDocument(text));
|
||||
const messages = collection.last.diags.map((d) => d.message);
|
||||
assert.ok(
|
||||
!messages.some((m) => m.includes("AudioEvent:BaseSoundEffect")),
|
||||
"qualified inheritFrom is not unresolved",
|
||||
);
|
||||
|
||||
// Ctrl+click on the qualified value jumps to the plain-id definition.
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: null,
|
||||
});
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.indexOf("AudioEvent:BaseSoundEffect") + 8);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.ok(locations && locations.length === 1, "qualified reference resolves");
|
||||
const defLine = text.split("\n")[1];
|
||||
const defStartChar =
|
||||
defLine.indexOf('id="BaseSoundEffect"') + 'id="'.length;
|
||||
assert.equal(locations[0].range.start.line, 1);
|
||||
assert.equal(locations[0].range.start.character, defStartChar);
|
||||
assert.equal(
|
||||
locations[0].range.end.character,
|
||||
defStartChar + "BaseSoundEffect".length,
|
||||
);
|
||||
});
|
||||
|
||||
test("hover on simpleContent complex content shows the referenced definition", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="A">\n` +
|
||||
` <Sound>VoiceFile</Sound>\n` +
|
||||
` </AudioEvent>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const def = {
|
||||
type: "AudioFile",
|
||||
id: "VoiceFile",
|
||||
file: URI,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const scope = await makeScope(text, makeIdx([def]));
|
||||
const provider = new Ra3HoverProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
searchPaths: () => null,
|
||||
});
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
|
||||
const hover = await provider.provideHover(makeDocument(text), pos, {});
|
||||
assert.ok(hover, "hover is returned for AudioFileRefWithWeight content");
|
||||
assert.match(hover.contents.value, /1 definition/);
|
||||
assert.match(hover.contents.value, /AudioFile/);
|
||||
});
|
||||
|
||||
test("Ctrl+click on simpleContent complex content jumps to the definition", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="A">\n` +
|
||||
` <Sound>VoiceFile</Sound>\n` +
|
||||
` </AudioEvent>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const def = {
|
||||
type: "AudioFile",
|
||||
id: "VoiceFile",
|
||||
file: URI,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const scope = await makeScope(text, makeIdx([def]));
|
||||
const provider = new Ra3DefinitionProvider({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: { definitionMode: "all" },
|
||||
indexer: null,
|
||||
});
|
||||
const line = text.split("\n")[2];
|
||||
const pos = new Position(2, line.indexOf("VoiceFile") + 3);
|
||||
const locations = await provider.provideDefinition(makeDocument(text), pos, {});
|
||||
assert.ok(locations && locations.length === 1);
|
||||
assert.equal(locations[0].uri.fsPath, URI);
|
||||
});
|
||||
|
||||
test("diagnostics report unresolved simpleContent complex content references", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <Multisound id="M">\n` +
|
||||
` <Subsound>MissingEvent</Subsound>\n` +
|
||||
` </Multisound>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: false,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const messages = collection.last.diags.map((d) => d.message);
|
||||
assert.ok(
|
||||
messages.some((m) => m.includes('Unresolved reference "MissingEvent"')),
|
||||
"MultisoundSubsoundRef text is diagnosed as a typed content reference",
|
||||
);
|
||||
});
|
||||
|
||||
test("Ctrl+click on a manifest definition maps to SageXml even when the mod shadows the DATA path", async () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-nav-manifest-"));
|
||||
try {
|
||||
@@ -452,3 +591,186 @@ test("diagnostics report unresolved typed content references only", async () =>
|
||||
"untyped WeakReference content is not diagnosed as a global ref",
|
||||
);
|
||||
});
|
||||
|
||||
test("fragment diagnostics skip document-level checks but keep subtree validation", async () => {
|
||||
const text =
|
||||
`<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X">\n` +
|
||||
` <DieMuxData DeathTypo="SUICIDED"/>\n` +
|
||||
`</CreateObjectDie>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
!codes.includes("missing-id"),
|
||||
"fragment children are not treated as top-level assets",
|
||||
);
|
||||
assert.ok(
|
||||
!codes.some((c) => c.startsWith("unresolved-reference")),
|
||||
"fragment references are deferred to the includer context",
|
||||
);
|
||||
assert.ok(
|
||||
codes.includes("unknown-attribute"),
|
||||
"a known fragment root still validates its subtree attributes",
|
||||
);
|
||||
});
|
||||
|
||||
test("fragment diagnostics ignore unknown wrapper roots and still report missing xi:include", async () => {
|
||||
const text =
|
||||
`<CommonArmorDraws xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">\n` +
|
||||
` <ScriptedModelDraw id="M" Bogus="x"/>\n` +
|
||||
` <xi:include href="MissingTarget.xml"/>\n` +
|
||||
`</CommonArmorDraws>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
!codes.includes("unknown-element"),
|
||||
"wrapper roots are not validated as standalone documents",
|
||||
);
|
||||
assert.ok(
|
||||
!codes.includes("unknown-attribute"),
|
||||
"unknown wrapper roots do not trigger subtree attribute guessing",
|
||||
);
|
||||
assert.ok(
|
||||
codes.includes("include-not-found"),
|
||||
"missing xi:include targets inside fragments are still reported",
|
||||
);
|
||||
});
|
||||
|
||||
test("diagnostics accept universal inheritFrom on asset types", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <FXList id="FX_A" inheritFrom="FX_Base">\n` +
|
||||
` <NuggetList/>\n` +
|
||||
` </FXList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "none",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
!codes.includes("unknown-attribute"),
|
||||
"FXList inheritFrom must not be flagged as unknown",
|
||||
);
|
||||
|
||||
const badText =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <FXList id="FX_B" Bogus="x">\n` +
|
||||
` <NuggetList/>\n` +
|
||||
` </FXList>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const badScope = await makeScope(badText, makeIdx([]));
|
||||
const badCollection = new FakeDiagnosticCollection();
|
||||
const badProvider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => badScope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "none",
|
||||
},
|
||||
});
|
||||
badProvider["collection"] = badCollection;
|
||||
await badProvider.update(makeDocument(badText));
|
||||
assert.ok(
|
||||
badCollection.last.diags.map((d) => d.code).includes("unknown-attribute"),
|
||||
"a real unknown attribute is still reported",
|
||||
);
|
||||
});
|
||||
|
||||
test("diagnostics keep simpleContent extension attributes known", async () => {
|
||||
const text =
|
||||
`<AssetDeclaration>\n` +
|
||||
` <AudioEvent id="A">\n` +
|
||||
` <Sound Weight="100">AudioFile</Sound>\n` +
|
||||
` </AudioEvent>\n` +
|
||||
`</AssetDeclaration>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "none",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
!codes.includes("unknown-attribute"),
|
||||
"Weight on AudioFileRefWithWeight must be known",
|
||||
);
|
||||
});
|
||||
|
||||
test("diagnostics use the top-level asset type for colliding fragment roots", async () => {
|
||||
const text =
|
||||
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "none",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
!codes.includes("unknown-attribute"),
|
||||
"EvaEvent fragment root attributes must resolve against the top-level asset type",
|
||||
);
|
||||
});
|
||||
|
||||
test("full documents still require ids on top-level assets", async () => {
|
||||
const text = `<AssetDeclaration>\n <GameObject/>\n</AssetDeclaration>`;
|
||||
const scope = await makeScope(text, makeIdx([]));
|
||||
const collection = new FakeDiagnosticCollection();
|
||||
const provider = new Ra3Diagnostics({
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
settings: {
|
||||
diagnoseUnknownElements: true,
|
||||
reportUnresolvedReferences: "warning",
|
||||
},
|
||||
});
|
||||
provider["collection"] = collection;
|
||||
await provider.update(makeDocument(text));
|
||||
const codes = collection.last.diags.map((d) => d.code);
|
||||
assert.ok(
|
||||
codes.includes("missing-id"),
|
||||
"AssetDeclaration documents keep top-level id checks",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IndexRecordsCache,
|
||||
} from "../out/indexer/caches.js";
|
||||
import { resolveReferenceTargetsForType } from "../out/indexer/refs.js";
|
||||
import { assetDefKey } from "../out/indexer/referenceIndex.js";
|
||||
|
||||
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const project = join(root, "test", "fixtures", "minimod");
|
||||
@@ -29,6 +30,75 @@ async function buildIndex() {
|
||||
return indexer.build();
|
||||
}
|
||||
|
||||
function u32(value) {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(value >>> 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function u16(value) {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16LE(value >>> 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Minimal version-5 manifest with one asset entry per supplied descriptor. */
|
||||
function minimalManifestV5(assets) {
|
||||
const nameParts = [];
|
||||
const sourceParts = [];
|
||||
let nameOffset = 0;
|
||||
let sourceOffset = 0;
|
||||
const entries = assets.map((asset) => {
|
||||
const name = Buffer.from(`${asset.name}\0`, "ascii");
|
||||
const source = Buffer.from(`${asset.source ?? ""}\0`, "ascii");
|
||||
const entry = {
|
||||
typeId: asset.typeId,
|
||||
nameOffset,
|
||||
sourceFileNameOffset: sourceOffset,
|
||||
};
|
||||
nameParts.push(name);
|
||||
sourceParts.push(source);
|
||||
nameOffset += name.length;
|
||||
sourceOffset += source.length;
|
||||
return entry;
|
||||
});
|
||||
const names = Buffer.concat(nameParts);
|
||||
const sources = Buffer.concat(sourceParts);
|
||||
|
||||
const parts = [
|
||||
Buffer.from([0, 1]), // isBigEndian=false, isLinked=true
|
||||
u16(5), // version
|
||||
u32(0), // streamChecksum
|
||||
u32(0), // allTypesHash
|
||||
u32(assets.length), // assetCount
|
||||
u32(0), // totalInstanceDataSize
|
||||
u32(0), // maxInstanceChunkSize
|
||||
u32(0), // maxRelocationChunkSize
|
||||
u32(0), // maxImportsChunkSize
|
||||
u32(0), // assetReferenceBufferSize
|
||||
u32(0), // referencedManifestNameBufferSize
|
||||
u32(names.length), // assetNameBufferSize
|
||||
u32(sources.length), // sourceFileNameBufferSize
|
||||
];
|
||||
for (const entry of entries) {
|
||||
parts.push(
|
||||
u32(entry.typeId),
|
||||
u32(0), // instanceId
|
||||
u32(0), // typeHash
|
||||
u32(0), // instanceHash
|
||||
u32(0), // assetReferenceOffset
|
||||
u32(0), // assetReferenceCount
|
||||
u32(entry.nameOffset),
|
||||
u32(entry.sourceFileNameOffset),
|
||||
u32(0), // instanceDataSize
|
||||
u32(0), // relocationDataSize
|
||||
u32(0), // importsDataSize
|
||||
);
|
||||
}
|
||||
parts.push(names, sources);
|
||||
return Buffer.concat(parts);
|
||||
}
|
||||
|
||||
test("indexes assets, defines, streams and include errors", async () => {
|
||||
const idx = await buildIndex();
|
||||
|
||||
@@ -133,6 +203,177 @@ test("w3x files appear in Include source completion candidates", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("manifest assets sharing an id keep every type in assetsById", async () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-manifest-multitype-"));
|
||||
const projectDir = join(tmp, "project");
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
const builtmodsDir = join(sdkDir, "builtmods");
|
||||
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
|
||||
fs.mkdirSync(builtmodsDir, { recursive: true });
|
||||
fs.writeFileSync(join(sdkDir, "Static.xml"), "<AssetDeclaration/>");
|
||||
fs.writeFileSync(
|
||||
join(projectDir, "Data", "Mod.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="reference" source="DATA:static.xml" />
|
||||
</Includes>
|
||||
<GameObject id="AlliedMCV">
|
||||
<Draws>
|
||||
<ScriptedModelDraw id="ModuleTag_Draw_Hover">
|
||||
<ModelConditionState ParseCondStateType="PARSE_DEFAULT">
|
||||
<Model Name="AUMCV_Hover" />
|
||||
</ModelConditionState>
|
||||
</ScriptedModelDraw>
|
||||
</Draws>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(builtmodsDir, "static.manifest"),
|
||||
minimalManifestV5([
|
||||
{
|
||||
typeId: 0x11111111,
|
||||
name: "W3DHierarchy:AUMCV_HOVER",
|
||||
source: "ART:aumcv_hover.w3x",
|
||||
},
|
||||
{
|
||||
typeId: 0x22222222,
|
||||
name: "W3DAnimation:AUMCV_HOVER",
|
||||
source: "ART:aumcv_hover.w3x",
|
||||
},
|
||||
{
|
||||
typeId: 0x33333333,
|
||||
name: "W3DContainer:AUMCV_HOVER",
|
||||
source: "ART:aumcv_hover.w3x",
|
||||
},
|
||||
{
|
||||
typeId: 0x44444444,
|
||||
name: "Texture:ABAirfield",
|
||||
source: "ART:abairfield.tga",
|
||||
},
|
||||
{
|
||||
typeId: 0x55555555,
|
||||
name: "W3DContainer:ABAIRFIELD",
|
||||
source: "ART:abairfield.w3x",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const indexer = new ModIndexer({
|
||||
projectDir,
|
||||
sdkDir,
|
||||
builtmodsDirs: [builtmodsDir],
|
||||
indexSageXml: false,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
});
|
||||
const idx = await indexer.build();
|
||||
|
||||
// The reported AUMCV_HOVER shape: Hierarchy/Animation precede the
|
||||
// W3DContainer, so the by-id index must not drop the render asset.
|
||||
const hover = idx.assetsById.get("aumcv_hover");
|
||||
assert.ok(hover?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
|
||||
assert.ok(hover?.some((d) => d.type === "W3DHierarchy"), "W3DHierarchy retained");
|
||||
assert.ok(hover?.some((d) => d.type === "W3DAnimation"), "W3DAnimation retained");
|
||||
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"ScriptedModelDrawModel",
|
||||
"Name",
|
||||
"AUMCV_Hover",
|
||||
);
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0].def.type, "W3DContainer");
|
||||
|
||||
const container = hover.find((d) => d.type === "W3DContainer");
|
||||
const sites = idx.references.get(assetDefKey(container));
|
||||
assert.ok(
|
||||
sites?.some((s) => s.kind === "attr" && /Mod\.xml$/.test(s.file)),
|
||||
"Model reference is attributed to the W3DContainer definition",
|
||||
);
|
||||
|
||||
// Common Texture-first shape must also keep the render definition.
|
||||
const airfield = idx.assetsById.get("abairfield");
|
||||
assert.ok(airfield?.some((d) => d.type === "Texture"), "Texture retained");
|
||||
assert.ok(airfield?.some((d) => d.type === "W3DContainer"), "W3DContainer retained");
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom resolves against an instance-included SageXml definition", async () => {
|
||||
const tmp = fs.mkdtempSync(join(os.tmpdir(), "ra3-qualified-ref-"));
|
||||
try {
|
||||
const projectDir = join(tmp, "project");
|
||||
const sdkDir = join(tmp, "sdk");
|
||||
fs.mkdirSync(join(projectDir, "Data"), { recursive: true });
|
||||
fs.mkdirSync(join(sdkDir, "SageXml", "Sounds"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
join(projectDir, "Data", "Mod.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="all" source="Units.xml" />
|
||||
</Includes>
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(projectDir, "Data", "Units.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<Includes>
|
||||
<Include type="instance" source="DATA:SageXml/Sounds/BaseSoundEffect.xml" />
|
||||
</Includes>
|
||||
<AudioEvent id="ALL_FutureTank_ArmPrimaryWeapon" inheritFrom="AudioEvent:BaseSoundEffect" />
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
join(sdkDir, "SageXml", "Sounds", "BaseSoundEffect.xml"),
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<AudioEvent id="BaseSoundEffect" />
|
||||
</AssetDeclaration>`,
|
||||
);
|
||||
|
||||
const indexer = new ModIndexer({
|
||||
projectDir,
|
||||
sdkDir,
|
||||
builtmodsDirs: [],
|
||||
indexSageXml: false,
|
||||
additionalDataSearchPaths: [],
|
||||
walker: new CachedDirectoryWalker(),
|
||||
});
|
||||
const idx = await indexer.build();
|
||||
|
||||
assert.ok(
|
||||
!idx.diagnostics.some((d) => d.code === "include-not-found"),
|
||||
"the DATA:SageXml instance include resolves",
|
||||
);
|
||||
const defs = idx.assetsById.get("basesoundeffect");
|
||||
assert.ok(
|
||||
defs?.some((d) => d.type === "AudioEvent"),
|
||||
"the SageXml definition is indexed through the instance include",
|
||||
);
|
||||
|
||||
const targets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"AudioEvent",
|
||||
"inheritFrom",
|
||||
"AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(targets.length, 1);
|
||||
assert.equal(targets[0].def.id, "BaseSoundEffect");
|
||||
assert.equal(targets[0].def.type, "AudioEvent");
|
||||
|
||||
const sageDef = defs.find((d) => d.type === "AudioEvent");
|
||||
const sites = idx.references.get(assetDefKey(sageDef));
|
||||
assert.ok(
|
||||
sites?.some((s) => /Units\.xml$/.test(s.file)),
|
||||
"the qualified inheritFrom lands in the reverse index (FAR / CodeLens)",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("build publishes an immutable XML phase before art scanning", async () => {
|
||||
let phaseA;
|
||||
const indexer = new ModIndexer({
|
||||
|
||||
@@ -99,6 +99,54 @@ test("logical xi:include expansion gives included modules their Draws context",
|
||||
assert.ok(localIds.includes("ModuleTag_Headlight"));
|
||||
});
|
||||
|
||||
test("xi:include without xpointer splices the target root element itself", async (t) => {
|
||||
const tmp = await mkdtemp(join(tmpdir(), "ra3-local-noxpointer-"));
|
||||
t.after(() => rm(tmp, { recursive: true, force: true }));
|
||||
const dataDir = join(tmp, "Data");
|
||||
const includesDir = join(dataDir, "Includes");
|
||||
await mkdir(includesDir, { recursive: true });
|
||||
const mainPath = join(dataDir, "Main.xml");
|
||||
const fragmentPath = join(includesDir, "Fragment.xml");
|
||||
await writeFile(
|
||||
fragmentPath,
|
||||
'<CreateObjectDie xmlns="uri:ea.com:eala:asset" id="ModuleTag_X" CreationList="OCL_X"><DieMuxData DeathTypes="SUICIDED"/></CreateObjectDie>',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
mainPath,
|
||||
'<AssetDeclaration xmlns="uri:ea.com:eala:asset" xmlns:xi="http://www.w3.org/2001/XInclude">' +
|
||||
'<GameObject id="G"><Behaviors><xi:include href="DATA:Includes/Fragment.xml"/></Behaviors></GameObject>' +
|
||||
"</AssetDeclaration>",
|
||||
"utf8",
|
||||
);
|
||||
const searchPaths = buildSearchPaths(sdk, tmp);
|
||||
const text = await readFile(mainPath, "utf8");
|
||||
const scope = await buildDocumentScope(mainPath, text, 1, {
|
||||
projectDir: tmp,
|
||||
sdkDir: sdk,
|
||||
searchPaths,
|
||||
readRecords: readParsed,
|
||||
readDom: readParsed,
|
||||
});
|
||||
const behaviors = scope.expanded.elements.find((e) => e.name === "Behaviors");
|
||||
assert.ok(behaviors, "Behaviors exists");
|
||||
assert.equal(behaviors.children.length, 1);
|
||||
const module = behaviors.children[0];
|
||||
assert.equal(module.name, "CreateObjectDie");
|
||||
assert.equal(
|
||||
module.attrs.find((a) => a.name === "id")?.value,
|
||||
"ModuleTag_X",
|
||||
);
|
||||
assert.match(module.sourceFile, /Fragment\.xml$/i);
|
||||
const dieMux = scope.expanded.elements.find((e) => e.name === "DieMuxData");
|
||||
assert.ok(dieMux, "DieMuxData is expanded");
|
||||
assert.equal(
|
||||
dieMux.parent,
|
||||
module,
|
||||
"DieMuxData stays inside the included CreateObjectDie module",
|
||||
);
|
||||
});
|
||||
|
||||
test("local overlay wins over a global definition with the same id", async () => {
|
||||
const scope = await makeScope();
|
||||
const global = {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
AGENT_FEATURE_VERSION,
|
||||
compareVersions,
|
||||
shouldOfferAgentOnboarding,
|
||||
} from "../out/agent/onboarding.js";
|
||||
|
||||
test("compareVersions orders dotted numeric versions", () => {
|
||||
assert.equal(compareVersions("0.1.26", "0.1.26"), 0);
|
||||
assert.equal(compareVersions("0.1.26", "0.1.25"), 1);
|
||||
assert.equal(compareVersions("0.1.25", "0.1.26"), -1);
|
||||
assert.equal(compareVersions("0.1.26", "0.1"), 1);
|
||||
assert.equal(compareVersions("0.2", "0.1.99"), 1);
|
||||
assert.equal(compareVersions("1.0.0", "2.0.0"), -1);
|
||||
assert.equal(compareVersions("0.1.26-beta", "0.1.26"), 0);
|
||||
assert.equal(compareVersions("dev", "0.1.25"), -1);
|
||||
});
|
||||
|
||||
test("a fresh install is offered the AI Agent introduction once", () => {
|
||||
assert.equal(shouldOfferAgentOnboarding(undefined, "0.1.26"), true);
|
||||
assert.equal(shouldOfferAgentOnboarding({}, "0.1.26"), true);
|
||||
});
|
||||
|
||||
test("upgrading from before the feature informs once", () => {
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.25" }, "0.1.26"),
|
||||
true,
|
||||
);
|
||||
// ...but not again on the same version or later ones.
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.26"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.27"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("upgrading from a build that already had the feature stays silent", () => {
|
||||
// 0.1.26 introduced it; someone informed on 0.1.26 must not be re-prompted.
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding({ informedVersion: "0.1.26" }, "0.1.30"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("dismissed state suppresses the prompt forever", () => {
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding(
|
||||
{ informedVersion: "0.1.20", dismissed: true },
|
||||
"0.1.26",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOfferAgentOnboarding(
|
||||
{ informedVersion: AGENT_FEATURE_VERSION, dismissed: true },
|
||||
"0.1.30",
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -113,3 +113,26 @@ test("extractIndexRecords records typed references and skips non-references", ()
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("extractIndexRecords records simpleContent complex text as references", () => {
|
||||
const text = `<AssetDeclaration>
|
||||
<AudioEvent id="A">
|
||||
<Sound Weight="100">VoiceFile</Sound>
|
||||
</AudioEvent>
|
||||
<Multisound id="M">
|
||||
<Subsound Weight="50">VoiceEvent</Subsound>
|
||||
</Multisound>
|
||||
</AssetDeclaration>`;
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parseXml(text), lineMap, text);
|
||||
const content = records.references.filter((r) => r.kind === "content");
|
||||
|
||||
const sound = content.find((r) => r.value === "VoiceFile");
|
||||
assert.ok(sound, "Sound text is recorded as a content reference");
|
||||
assert.equal(sound.refType, "AudioFile");
|
||||
assert.equal(sound.selfType, null);
|
||||
|
||||
const subsound = content.find((r) => r.value === "VoiceEvent");
|
||||
assert.ok(subsound, "Subsound text is recorded as a content reference");
|
||||
assert.equal(subsound.refType, "BaseAudioEventInfo");
|
||||
});
|
||||
|
||||
@@ -157,6 +157,42 @@ test("records extracted from XML resolve through the reference index", () => {
|
||||
assert.equal(csSites[0].kind, "attr");
|
||||
});
|
||||
|
||||
test("qualified Type:Id reference records resolve to plain-id definitions", () => {
|
||||
const def = makeDef("AudioEvent", "BaseSoundEffect", "C:/sdk/Sounds.xml", 2);
|
||||
const lookup = {
|
||||
assets: new Map(),
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
};
|
||||
const records = {
|
||||
assets: [],
|
||||
defines: [],
|
||||
includes: [],
|
||||
rootXiIncludes: [],
|
||||
nestedXiIncludes: [],
|
||||
references: [
|
||||
{
|
||||
kind: "attr",
|
||||
refType: null,
|
||||
selfType: "AudioEvent",
|
||||
value: "AudioEvent:BaseSoundEffect",
|
||||
line: 3,
|
||||
start: 10,
|
||||
end: 40,
|
||||
},
|
||||
],
|
||||
};
|
||||
const map = buildReferenceIndex(
|
||||
[{ file: "C:/mod/AudioEvent.xml", records }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
const sites = map.get(assetDefKey(def));
|
||||
assert.equal(sites?.length, 1);
|
||||
assert.equal(sites[0].file, "C:/mod/AudioEvent.xml");
|
||||
assert.equal(sites[0].start, 10);
|
||||
assert.equal(sites[0].end, 40);
|
||||
});
|
||||
|
||||
test("referenceSitesForDefinition unions manifest-source sites onto the SageXml source file", () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), "ra3-refindex-"));
|
||||
try {
|
||||
|
||||
@@ -177,3 +177,80 @@ test("FAR from the reference site itself returns the same result", async () => {
|
||||
assert.equal(refs.length, 1);
|
||||
assert.equal(refs[0].range.start.line, 4);
|
||||
});
|
||||
|
||||
test("FAR includes simpleContent complex content references", async () => {
|
||||
const text = `<AssetDeclaration>
|
||||
<AudioFile id="VoiceFile"/>
|
||||
<AudioEvent id="A">
|
||||
<Sound>VoiceFile</Sound>
|
||||
</AudioEvent>
|
||||
</AssetDeclaration>`;
|
||||
const parse = parseXml(text);
|
||||
const lineMap = new LineMap(text);
|
||||
const records = extractIndexRecords(parse, lineMap, text);
|
||||
const def = {
|
||||
type: "AudioFile",
|
||||
id: "VoiceFile",
|
||||
file: FILE,
|
||||
line: 2,
|
||||
origin: "project",
|
||||
};
|
||||
const lookup = {
|
||||
assets: new Map([["AudioFile", new Map([["voicefile", [def]]])]]),
|
||||
assetsById: new Map([["voicefile", [def]]]),
|
||||
};
|
||||
const references = buildReferenceIndex([{ file: FILE, records }], lookup);
|
||||
const idx = {
|
||||
...lookup,
|
||||
references,
|
||||
complete: true,
|
||||
phase: "art",
|
||||
projectDir: "C:/mod",
|
||||
sdkDir: "C:/sdk",
|
||||
defines: new Map(),
|
||||
files: new Map(),
|
||||
streams: [],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
stats: {},
|
||||
};
|
||||
const scope = { merged: idx };
|
||||
const localParse = parseXml(text);
|
||||
const localLineMap = new LineMap(text);
|
||||
const localIndexer = {
|
||||
readDom: async (path) =>
|
||||
path === FILE
|
||||
? { file: { path: FILE }, parse: localParse, lineMap: localLineMap, records: null }
|
||||
: null,
|
||||
};
|
||||
const localWs = {
|
||||
isRa3Workspace: () => true,
|
||||
getScope: async () => scope,
|
||||
indexer: localIndexer,
|
||||
indexerForFile: () => localIndexer,
|
||||
activeIndexer: () => localIndexer,
|
||||
recordsSyncSurfaceFor: () => ({
|
||||
get index() {
|
||||
return scope.merged;
|
||||
},
|
||||
invalidate: () => {},
|
||||
scheduleRebuild: () => {},
|
||||
}),
|
||||
};
|
||||
const provider = new Ra3ReferenceProvider(localWs);
|
||||
const document = makeDocument(text);
|
||||
const defLine = text.split("\n")[1];
|
||||
const defPos = new Position(1, defLine.indexOf('id="') + 4);
|
||||
|
||||
const refs = await provider.provideReferences(document, defPos, {
|
||||
includeDeclaration: true,
|
||||
}, {});
|
||||
assert.ok(refs, "references are returned");
|
||||
assert.equal(refs.length, 1);
|
||||
assert.equal(refs[0].range.start.line, 3);
|
||||
assert.equal(
|
||||
text.split("\n")[3].slice(refs[0].range.start.character, refs[0].range.end.character),
|
||||
"VoiceFile",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
isReferenceAttribute,
|
||||
isReferenceAttributeOfType,
|
||||
isReferenceContentType,
|
||||
isReferenceTargetType,
|
||||
normalizeReferenceId,
|
||||
resolveContentReferenceTargets,
|
||||
resolveReferenceTargets,
|
||||
resolveReferenceTargetsForType,
|
||||
@@ -70,7 +72,25 @@ test("isReferenceAttribute distinguishes references from enums/paths", () => {
|
||||
// Typed references and inheritFrom are references.
|
||||
assert.equal(isReferenceAttribute("GameObject", "CommandSet"), true);
|
||||
assert.equal(isReferenceAttribute("GameObject", "inheritFrom"), true);
|
||||
assert.equal(isReferenceAttribute("FXList", "inheritFrom"), true);
|
||||
assert.equal(isReferenceAttribute("AIMicroManagerData", "inheritFrom"), true);
|
||||
assert.equal(isReferenceAttribute("FireWeaponNugget", "WeaponName"), true);
|
||||
// inheritFrom is an asset-level attribute; non-asset elements stay non-refs.
|
||||
assert.equal(isReferenceAttribute("Include", "inheritFrom"), false);
|
||||
});
|
||||
|
||||
test("universal inheritFrom legality is separate from CodeLens target design", () => {
|
||||
// FXList and other BaseAssetType descendants legally accept inheritFrom,
|
||||
// but the XSD does not declare it there. That must not widen the designed
|
||||
// reference-target set (Credits is still not a CodeLens target).
|
||||
assert.ok(
|
||||
model.attributesOfElement("FXList").some((a) => a.name === "inheritFrom"),
|
||||
);
|
||||
assert.ok(
|
||||
model.attributesOfElement("Credits").some((a) => a.name === "inheritFrom"),
|
||||
);
|
||||
assert.equal(isReferenceTargetType("Credits"), false);
|
||||
assert.equal(isReferenceTargetType("FXList"), true); // via FXListRef, not universal attr
|
||||
});
|
||||
|
||||
test("attribute-level xas:refType is preserved in the model", () => {
|
||||
@@ -287,6 +307,170 @@ test("typed simple content resolves like a typed attribute reference", () => {
|
||||
assert.equal(targets[0].def.type, "GameObject");
|
||||
});
|
||||
|
||||
test("simpleContent complex types resolve as typed content references", () => {
|
||||
// <Sound>AudioFile</Sound> / <Subsound>VoiceEvent</Subsound> use
|
||||
// simpleContent complex types (AudioFileRefWithWeight /
|
||||
// MultisoundSubsoundRef) whose text is still a typed asset reference.
|
||||
assert.equal(isReferenceContentType("AudioFileRefWithWeight"), true);
|
||||
assert.equal(isReferenceContentType("MultisoundSubsoundRef"), true);
|
||||
// Inline Frame's simpleContent is a scalar float, not a reference.
|
||||
assert.equal(isReferenceContentType("@inline:Frame"), false);
|
||||
|
||||
const idx = {
|
||||
assetsById: new Map([
|
||||
[
|
||||
"shared",
|
||||
[
|
||||
{ type: "AudioFile", id: "Shared", file: "Audio.xml", line: 1, origin: "project" },
|
||||
{ type: "AudioEvent", id: "Shared", file: "Voice.xml", line: 2, origin: "project" },
|
||||
],
|
||||
],
|
||||
]),
|
||||
assets: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
|
||||
const soundTargets = resolveContentReferenceTargets(
|
||||
idx,
|
||||
"AudioFileRefWithWeight",
|
||||
"Shared",
|
||||
);
|
||||
assert.equal(soundTargets.length, 1);
|
||||
assert.equal(soundTargets[0].def.type, "AudioFile");
|
||||
|
||||
const subsoundTargets = resolveContentReferenceTargets(
|
||||
idx,
|
||||
"MultisoundSubsoundRef",
|
||||
"Shared",
|
||||
);
|
||||
assert.equal(subsoundTargets.length, 1);
|
||||
assert.equal(subsoundTargets[0].def.type, "AudioEvent");
|
||||
});
|
||||
|
||||
test("normalizeReferenceId strips a manifest-style Type: prefix", () => {
|
||||
assert.equal(normalizeReferenceId("BaseSoundEffect"), "BaseSoundEffect");
|
||||
assert.equal(
|
||||
normalizeReferenceId("AudioEvent:BaseSoundEffect"),
|
||||
"BaseSoundEffect",
|
||||
);
|
||||
// Art-asset manifest names can carry a subtype segment; the referenceable
|
||||
// id is still the last colon segment.
|
||||
assert.equal(
|
||||
normalizeReferenceId("W3dContainer:W3DContainer:ABC_SKN"),
|
||||
"ABC_SKN",
|
||||
);
|
||||
// A trailing colon has no id yet; keep the raw value so a half-typed
|
||||
// qualified value never matches anything.
|
||||
assert.equal(normalizeReferenceId("AudioEvent:"), "AudioEvent:");
|
||||
});
|
||||
|
||||
test("qualified Type:Id inheritFrom values resolve to plain-id definitions", () => {
|
||||
const def = {
|
||||
type: "AudioEvent",
|
||||
id: "BaseSoundEffect",
|
||||
file: "Sounds.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const idx = {
|
||||
assetsById: new Map([["basesoundeffect", [def]]]),
|
||||
assets: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
|
||||
// The reported scenario: <AudioEvent inheritFrom="AudioEvent:BaseSoundEffect"/>.
|
||||
const qualified = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"AudioEvent",
|
||||
"inheritFrom",
|
||||
"AudioEvent:BaseSoundEffect",
|
||||
);
|
||||
assert.equal(qualified.length, 1);
|
||||
assert.equal(qualified[0].def.id, "BaseSoundEffect");
|
||||
|
||||
// Plain ids keep working unchanged.
|
||||
assert.equal(
|
||||
resolveReferenceTargetsForType(idx, "AudioEvent", "inheritFrom", "BaseSoundEffect")
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
|
||||
// A wrong type prefix is still filtered by selfType: the AudioEvent def
|
||||
// must never satisfy a GameObject inheritFrom.
|
||||
assert.equal(
|
||||
resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"GameObject",
|
||||
"inheritFrom",
|
||||
"GameObject:BaseSoundEffect",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("qualified Type:Id values resolve for typed attributes and content refs", () => {
|
||||
const audioEvent = {
|
||||
type: "AudioEvent",
|
||||
id: "JAP_Refinery_Select",
|
||||
file: "SoundEffects.xml",
|
||||
line: 1,
|
||||
origin: "sdk",
|
||||
};
|
||||
const playerTemplate = {
|
||||
type: "PlayerTemplate",
|
||||
id: "Allies",
|
||||
file: "PlayerTemplates.xml",
|
||||
line: 1,
|
||||
origin: "manifest",
|
||||
};
|
||||
const audioFile = {
|
||||
type: "AudioFile",
|
||||
id: "Shared",
|
||||
file: "Audio.xml",
|
||||
line: 1,
|
||||
origin: "project",
|
||||
};
|
||||
const idx = {
|
||||
assetsById: new Map([
|
||||
["jap_refinery_select", [audioEvent]],
|
||||
["allies", [playerTemplate]],
|
||||
["shared", [audioFile]],
|
||||
]),
|
||||
assets: new Map(),
|
||||
defines: new Map(),
|
||||
};
|
||||
|
||||
// SoundOrEvaEvent@Sound refType is BaseAudioEventInfo; the concrete
|
||||
// "AudioEvent:" prefix must survive normalization and the type filter.
|
||||
const soundTargets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"SoundOrEvaEvent",
|
||||
"Sound",
|
||||
"AudioEvent:JAP_Refinery_Select",
|
||||
);
|
||||
assert.equal(soundTargets.length, 1);
|
||||
assert.equal(soundTargets[0].def.type, "AudioEvent");
|
||||
|
||||
// Side="PlayerTemplate:Allies" style attribute.
|
||||
const sideTargets = resolveReferenceTargetsForType(
|
||||
idx,
|
||||
"SideSound",
|
||||
"Side",
|
||||
"PlayerTemplate:Allies",
|
||||
);
|
||||
assert.equal(sideTargets.length, 1);
|
||||
assert.equal(sideTargets[0].def.type, "PlayerTemplate");
|
||||
|
||||
// Simple-content references use the same convention.
|
||||
const contentTargets = resolveContentReferenceTargets(
|
||||
idx,
|
||||
"AudioFileRefWithWeight",
|
||||
"AudioFile:Shared",
|
||||
);
|
||||
assert.equal(contentTargets.length, 1);
|
||||
assert.equal(contentTargets[0].def.type, "AudioFile");
|
||||
});
|
||||
|
||||
test("untyped and pipeline-local content is not a global reference", () => {
|
||||
// Generic AssetReference content is used for shader constants and model
|
||||
// sub-object names, not global asset ids; Poid is pipeline-local.
|
||||
|
||||
@@ -17,6 +17,70 @@ test("GameObject has expected attributes", () => {
|
||||
assert.ok(attrs.some((a) => a.name === "inheritFrom"));
|
||||
});
|
||||
|
||||
test("inheritFrom is a universal asset attribute, not only BaseInheritableAsset", () => {
|
||||
// BAB / vanilla data accepts inheritFrom on FXList, AIMicroManagerData,
|
||||
// ObjectCreationList, OnDemandTextureImage and AITargetingHeuristic even
|
||||
// though the XSD only declares it on BaseInheritableAsset.
|
||||
for (const name of [
|
||||
"FXList",
|
||||
"AIMicroManagerData",
|
||||
"ObjectCreationList",
|
||||
"OnDemandTextureImage",
|
||||
"AITargetingHeuristic",
|
||||
]) {
|
||||
assert.ok(
|
||||
model.attributesOfElement(name).some((a) => a.name === "inheritFrom"),
|
||||
`${name} should accept universal inheritFrom`,
|
||||
);
|
||||
}
|
||||
// Structural elements that are not assets must not get the attribute.
|
||||
assert.ok(
|
||||
!model.attributesOfElement("Include").some((a) => a.name === "inheritFrom"),
|
||||
"Include is not an asset and must not accept inheritFrom",
|
||||
);
|
||||
});
|
||||
|
||||
test("simpleContent extension attributes are preserved by the model generator", () => {
|
||||
const sound = model.typeInfo("AudioFileRefWithWeight");
|
||||
assert.equal(sound?.kind, "complex");
|
||||
assert.ok(sound.attributes.some((a) => a.name === "Weight"));
|
||||
assert.ok(sound.attributes.some((a) => a.name === "Volume"));
|
||||
|
||||
const subsound = model.typeInfo("MultisoundSubsoundRef");
|
||||
assert.equal(subsound?.kind, "complex");
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "Weight"));
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftLow"));
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "PitchShiftHigh"));
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "Volume"));
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "PlayPercent"));
|
||||
assert.ok(subsound.attributes.some((a) => a.name === "VolumeShift"));
|
||||
});
|
||||
|
||||
test("contentInfoOfType unifies simple and simpleContent content semantics", () => {
|
||||
const simple = model.contentInfoOfType("GameObjectWeakRef");
|
||||
assert.equal(simple?.kind, "simple");
|
||||
assert.equal(simple?.refType, "GameObject");
|
||||
|
||||
const sound = model.contentInfoOfType("AudioFileRefWithWeight");
|
||||
assert.equal(sound?.kind, "simpleContent");
|
||||
assert.equal(sound?.refType, "AudioFile");
|
||||
assert.equal(sound?.isRef, true);
|
||||
|
||||
const subsound = model.contentInfoOfType("MultisoundSubsoundRef");
|
||||
assert.equal(subsound?.kind, "simpleContent");
|
||||
assert.equal(subsound?.refType, "BaseAudioEventInfo");
|
||||
|
||||
// Ordinary complex elements and structural elements have no content value.
|
||||
assert.equal(model.contentInfoOfType("GameObject"), null);
|
||||
assert.equal(model.contentInfoOfType("Include"), null);
|
||||
|
||||
// Inline simpleContent with a scalar base has content info but no refType.
|
||||
const frame = model.contentInfoOfType("@inline:Frame");
|
||||
assert.ok(frame, "inline simpleContent is exposed through contentInfoOfType");
|
||||
assert.equal(frame?.refType, null);
|
||||
assert.equal(frame?.base, "float");
|
||||
});
|
||||
|
||||
test("attribute-level xas:refType is captured (module ids, map objects)", () => {
|
||||
// ModuleData@id is declared as <xs:attribute name="id" type="Poid"
|
||||
// xas:refType="ModuleData" />; the refType must reach every module subtype.
|
||||
|
||||
@@ -39,3 +39,24 @@ test("model childTypeOf primitives", () => {
|
||||
assert.equal(model.childTypeOf("WeaponSlot_WeaponData", "Weapon"), null);
|
||||
assert.equal(model.childTypeOf(null, "Weapon"), null);
|
||||
});
|
||||
|
||||
test("fragment roots prefer top-level AssetDeclaration types over name collisions", () => {
|
||||
// <EvaEvent> is both a top-level asset and an FXNugget child. A fragment
|
||||
// root has no parent context, so it must resolve to the top-level asset
|
||||
// type (with Priority / TimeBetweenEvents etc.), not to EvaEventFXNugget.
|
||||
const evaDoc = parseXml(
|
||||
`<EvaEvent id="IncomingTransmission" Priority="100" TimeBetweenEvents="0ms" ExpirationTime="10000ms"/>`,
|
||||
);
|
||||
assert.equal(resolveElementType(evaDoc.root), "EvaEvent");
|
||||
assert.ok(
|
||||
model.attributesOfType("EvaEvent").some((a) => a.name === "Priority"),
|
||||
);
|
||||
|
||||
const upgradeDoc = parseXml(`<UpgradeTemplate id="Upgrade_X" inheritFrom="Base"/>`);
|
||||
assert.equal(resolveElementType(upgradeDoc.root), "UpgradeTemplate");
|
||||
|
||||
// Non-top-level fragment roots still fall back to the contextual child
|
||||
// mapping (no AssetDeclaration child exists for Weapon).
|
||||
const weaponDoc = parseXml(`<Weapon Ordering="PRIMARY_WEAPON"/>`);
|
||||
assert.equal(resolveElementType(weaponDoc.root), "WeaponRef");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Probe: which Node APIs does the VS Code Electron binary provide in
|
||||
// ELECTRON_RUN_AS_NODE mode? Run with:
|
||||
// ELECTRON_RUN_AS_NODE=1 "<Code.exe>" probe.cjs
|
||||
const out = {};
|
||||
|
||||
try {
|
||||
out.nodeVersion = process.versions.node;
|
||||
out.electronVersion = process.versions.electron ?? null;
|
||||
out.isElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE === "1";
|
||||
out.execPath = process.execPath;
|
||||
out.platform = process.platform;
|
||||
} catch (e) {
|
||||
out.baseError = String(e);
|
||||
}
|
||||
|
||||
const modules = [
|
||||
"node:fs",
|
||||
"node:fs/promises",
|
||||
"node:path",
|
||||
"node:os",
|
||||
"node:http",
|
||||
"node:readline",
|
||||
"node:zlib",
|
||||
"node:crypto",
|
||||
"node:util",
|
||||
"node:net",
|
||||
"node:child_process",
|
||||
"node:test",
|
||||
"node:assert",
|
||||
"node:url",
|
||||
"node:events",
|
||||
];
|
||||
out.modules = {};
|
||||
for (const m of modules) {
|
||||
try {
|
||||
require(m);
|
||||
out.modules[m] = "ok";
|
||||
} catch (e) {
|
||||
out.modules[m] = "FAIL: " + (e && e.code ? e.code : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// The APIs the MCP server actually depends on.
|
||||
try {
|
||||
const { parseLoadedXml } = require("../out/agent/forwardRefs.js");
|
||||
const parsed = parseLoadedXml('<AssetDeclaration xmlns="uri:ea.com:eala:asset"><GameObject id="X"/></AssetDeclaration>');
|
||||
out.forwardRefs = "ok: elements=" + parsed.parse.elements.length;
|
||||
} catch (e) {
|
||||
out.forwardRefs = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
try {
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
out.localServer = typeof startLocalServer === "function" ? "loadable" : "missing";
|
||||
} catch (e) {
|
||||
out.localServer = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
// Async smoke test: gzip round-trip + http listen (both used at runtime).
|
||||
(async () => {
|
||||
try {
|
||||
const { gzip, gunzip } = require("node:zlib");
|
||||
const { promisify } = require("node:util");
|
||||
const buf = await promisify(gzip)(Buffer.from("hello"));
|
||||
const back = await promisify(gunzip)(buf);
|
||||
out.zlibRoundTrip = back.toString() === "hello" ? "ok" : "mismatch";
|
||||
} catch (e) {
|
||||
out.zlibRoundTrip = "FAIL: " + String(e).slice(0, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
const handle = await startLocalServer({
|
||||
getIndex: () => null,
|
||||
listProjects: () => [],
|
||||
token: "probe",
|
||||
});
|
||||
const res = await fetch(`http://127.0.0.1:${handle.port}/status`, {
|
||||
headers: { authorization: "Bearer probe" },
|
||||
});
|
||||
const body = await res.json();
|
||||
out.httpServer = "ok: " + JSON.stringify(body);
|
||||
await handle.close();
|
||||
} catch (e) {
|
||||
out.httpServer = "FAIL: " + String(e).slice(0, 160);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Starts a real local live-index server with a fake in-memory index and
|
||||
* registers it exactly like the extension does (instance file + per-project
|
||||
* endpoint + merged manifest). Used by the integration smoke test.
|
||||
*
|
||||
* Prints the agent home as its first stdout line, then stays alive.
|
||||
*/
|
||||
const { startLocalServer } = require("../out/agent/localServer.js");
|
||||
const { writeInstance, writeManifest } = require("../out/agent/instances.js");
|
||||
const { writeEndpoint, writeEndpointForProject } = require("../out/agent/endpoint.js");
|
||||
const { mkdirSync, writeFileSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
|
||||
const PROJECT = process.env.RA3_PROJECT || "D:/Mods/Alpha";
|
||||
const OTHER = "D:/Mods/Beta";
|
||||
// The agent home is passed in, so the caller knows it without reading stdout.
|
||||
const home = process.argv[2];
|
||||
if (!home) {
|
||||
console.error("usage: serve-fake-index.cjs <agentHome>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function def(type, id, file, line) {
|
||||
return { type, id, file, line, origin: "project", stream: "static" };
|
||||
}
|
||||
|
||||
const unitFile = `${PROJECT}/Data/AthenaCannon.xml`;
|
||||
const unit = def("GameObject", "AthenaCannon", unitFile, 2);
|
||||
const weapon = def("WeaponTemplate", "AthenaCannonWeapon", `${PROJECT}/Data/Weapon.xml`, 88);
|
||||
|
||||
const index = {
|
||||
projectDir: PROJECT,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets: new Map([
|
||||
["GameObject", new Map([["athenacannon", [unit]]])],
|
||||
["WeaponTemplate", new Map([["athenacannonweapon", [weapon]]])],
|
||||
]),
|
||||
assetsById: new Map([
|
||||
["athenacannon", [unit]],
|
||||
["athenacannonweapon", [weapon]],
|
||||
]),
|
||||
defines: new Map([["d", [{ name: "D", value: "1", file: unitFile, line: 1, origin: "project" }]]]),
|
||||
files: new Map(),
|
||||
streams: [{ name: "static", entry: `${PROJECT}/Data/Mod.xml`, files: new Set([unitFile.toLowerCase().replace(/\\/g, "/")]) }],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[`GameObject\u0000athenacannon\u0000${unitFile}\u00002`, [{ file: `${PROJECT}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }]],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: {
|
||||
projectDir: PROJECT, sdkDir: "", phase: "art", complete: true,
|
||||
indexedFiles: 2, parsedFiles: 2, shallowScannedFiles: 0, deferredArtFiles: 0,
|
||||
shallowCacheHits: 0, recordsCacheHits: 0, resolveCacheHits: 0, resolveCalls: 0,
|
||||
snapshotHits: 0, snapshotFallbacks: 0, candidatesMs: 0, walkMs: 0, artScanMs: 0,
|
||||
assetCount: 2, referenceCount: 1, defineCount: 1, manifestFiles: 0,
|
||||
manifestAssetCount: 0, streams: 1, sourceCandidates: 0, elapsedMs: 1,
|
||||
},
|
||||
};
|
||||
|
||||
(async () => {
|
||||
mkdirSync(home, { recursive: true });
|
||||
const handle = await startLocalServer({
|
||||
getIndex: (projectDir) => (!projectDir || projectDir === PROJECT ? index : null),
|
||||
listProjects: () => [PROJECT, OTHER],
|
||||
loadFile: async () => null,
|
||||
});
|
||||
const endpoint = {
|
||||
instanceId: "smoke-1",
|
||||
url: `http://127.0.0.1:${handle.port}`,
|
||||
token: handle.token,
|
||||
projectDir: PROJECT,
|
||||
projects: [PROJECT, OTHER],
|
||||
processId: process.pid,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeEndpointForProject(PROJECT, { ...endpoint, projectDir: PROJECT }, home);
|
||||
await writeEndpoint(endpoint, home);
|
||||
await writeInstance(endpoint, home);
|
||||
await writeManifest([endpoint], home);
|
||||
// Signal readiness through a file so the caller never has to parse stdout.
|
||||
writeFileSync(join(home, "READY"), `${handle.port}\n`);
|
||||
// Stay alive until killed.
|
||||
setInterval(() => {}, 1 << 30);
|
||||
})();
|
||||
+29
-2
@@ -368,8 +368,13 @@ function expandComplexType(name, chain = []) {
|
||||
const node = complexTypes.get(name);
|
||||
if (!node) return null;
|
||||
|
||||
const extension = node?.complexContent?.extension;
|
||||
const baseName = normalizeTypeName(extension?.["@_base"] ?? node?.simpleContent?.extension?.["@_base"]);
|
||||
// Both `complexContent/extension` and `simpleContent/extension` contribute
|
||||
// attributes. simpleContent types (AudioFileRefWithWeight,
|
||||
// MultisoundSubsoundRef, ...) were previously losing all of theirs because
|
||||
// only the complexContent branch was read.
|
||||
const extension =
|
||||
node?.complexContent?.extension ?? node?.simpleContent?.extension ?? null;
|
||||
const baseName = normalizeTypeName(extension?.["@_base"] ?? null);
|
||||
const base = baseName ? expandComplexType(baseName, [...chain, name]) : null;
|
||||
|
||||
const ownChildren = collectChildren(extension ?? node);
|
||||
@@ -389,6 +394,28 @@ function expandComplexType(name, chain = []) {
|
||||
base: baseName,
|
||||
doc: docOf(node),
|
||||
};
|
||||
if (node?.simpleContent) {
|
||||
const simpleBaseName = normalizeTypeName(
|
||||
node.simpleContent.extension?.["@_base"] ?? null,
|
||||
);
|
||||
const baseDesc = simpleBaseName
|
||||
? resolveTypeDescriptor(simpleBaseName)
|
||||
: null;
|
||||
result.content = {
|
||||
refType:
|
||||
normalizeTypeName(node["@_refType"]) ??
|
||||
normalizeTypeName(baseDesc?.refType) ??
|
||||
null,
|
||||
isRef:
|
||||
node["@_isRef"] === "true" ||
|
||||
node["@_isWeakRef"] === "true" ||
|
||||
baseDesc?.isRef === true,
|
||||
enumValues: baseDesc?.enumValues ?? [],
|
||||
isList: baseDesc?.isList ?? false,
|
||||
allowsDefine: baseDesc?.allowsDefine ?? false,
|
||||
base: simpleBaseName,
|
||||
};
|
||||
}
|
||||
expandedTypes.set(name, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user