diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d6ca05..f34e060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,3 +46,4 @@ jobs: npm run benchmark:browser-artifacts npm run benchmark:uploads npm run benchmark:transfer-queue + npm run benchmark:devtools diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f90906..1b48d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.13.0 — 2026-10-05 + +- Add command/language/theme/job/browser/configuration scaffolds with preview, + generated runtime metadata, TypeScript declarations, JSON schemas and tests. + Exact-digest regeneration preserves edited/additional files and cooperatively + stages verified output; doctor detects source drift and packaging omissions. +- Add owner-injected clocks, deterministic scenario fixtures, CAS memory files, + settings/secret ports, memory checkpoints, fault injection and explicit resource + ledgers. Retain actual activation/diagnostic work slots after ignored cancellation. + Expose host inspection and explicit grant replacement without implicit authority. +- Add opt-in synthetic fixture hashes and versioned pure replay, bounded local + diagnostic sessions, host/transport adapters, redacted error codes and measured + profiler overhead. Reports do not collect request/response bodies or paths. +- Add explicit CLI profile and ephemeral loopback debug/wait flags. Each debugger + child has a hard lifetime limit and uses the existing owned process cleanup. +- Exercise all templates and the combined scenario from an actual packed install; + keep existing protocol contracts and historical archive verification through 0.12. + New plugin packages declare `>=0.13.0 <0.14.0`. + ## 0.12.0 — 2026-10-05 - Add bounded versioned settings, default/user/workspace precedence, atomic CAS, diff --git a/PUBLIC_SURFACE.json b/PUBLIC_SURFACE.json index 6761b06..1970ff5 100644 --- a/PUBLIC_SURFACE.json +++ b/PUBLIC_SURFACE.json @@ -14,6 +14,7 @@ "docs/CONSENT.md", "docs/DATA_SCHEMAS.md", "docs/DEVTOOLS.md", + "docs/DIAGNOSTICS.md", "docs/FILE_REVISIONS.md", "docs/host-contract.md", "docs/JOB_HISTORY.md", @@ -65,6 +66,7 @@ "examples/configuration/serve.mjs", "examples/consent/prompt.mjs", "examples/consent/README.md", + "examples/development-tools/run.mjs", "examples/durable-jobs/run.mjs", "examples/hello-diagnostics/plugin.mjs", "examples/hello-diagnostics/run.mjs", @@ -143,6 +145,8 @@ "schemas/command.schema.json", "schemas/completion-item.schema.json", "schemas/data-schema.schema.json", + "schemas/development-definition.schema.json", + "schemas/diagnostic-report.schema.json", "schemas/diagnostics-request.schema.json", "schemas/diagnostics-result.schema.json", "schemas/display-metadata.schema.json", @@ -150,6 +154,8 @@ "schemas/error.schema.json", "schemas/folding-ranges.schema.json", "schemas/formatting-options.schema.json", + "schemas/generation-receipt.schema.json", + "schemas/generation-report.schema.json", "schemas/inlay-hints.schema.json", "schemas/job-artifact-content.schema.json", "schemas/job-capabilities.schema.json", @@ -169,6 +175,8 @@ "schemas/locale-catalog.schema.json", "schemas/localized-message.schema.json", "schemas/manifest.schema.json", + "schemas/memory-job-snapshot.schema.json", + "schemas/plugin-plan.schema.json", "schemas/project-entry.schema.json", "schemas/project-query-capabilities.schema.json", "schemas/project-query-options.schema.json", @@ -191,6 +199,7 @@ "schemas/stored-artifact-reference.schema.json", "schemas/stored-artifact.schema.json", "schemas/stored-job.schema.json", + "schemas/test-trace.schema.json", "schemas/theme.schema.json", "schemas/transfer-queue-options.schema.json", "schemas/transfer-snapshot.schema.json", @@ -236,9 +245,16 @@ "src/data-schema-schemas.mjs", "src/data-schema.d.mts", "src/data-schema.mjs", + "src/dev-files.mjs", + "src/dev-generation.mjs", + "src/dev-scaffolds.mjs", "src/dev-worker.mjs", + "src/development-schemas.mjs", "src/devtools.d.mts", "src/devtools.mjs", + "src/diagnostics.d.mts", + "src/diagnostics.mjs", + "src/host-runtime.mjs", "src/host.d.mts", "src/host.mjs", "src/index.d.mts", @@ -297,6 +313,12 @@ "src/sha256-stream.mjs", "src/stored-artifact-download-node.d.mts", "src/stored-artifact-download-node.mjs", + "src/testing-faults.mjs", + "src/testing-job-store.mjs", + "src/testing-runtime.mjs", + "src/testing-scenario.mjs", + "src/testing-trace.mjs", + "src/testing-workspace.mjs", "src/testing.d.mts", "src/testing.mjs", "src/text-edits.mjs", diff --git a/README.md b/README.md index 570aef2..ecc69bf 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ a small ESM package, TypeScript declarations and versioned contracts. Connect your own workspace and tools with the Node.js host and HTTP client. The SDK has **zero runtime dependencies**. +Version 0.13 adds six [project templates and guarded contract generation](docs/DEVTOOLS.md), +[deterministic synthetic test hosts](docs/testing.md) and opt-in +[local diagnostics and loopback debugging](docs/DIAGNOSTICS.md). Run +`npm run example:devtools` to exercise virtual time, fault injection, pure trace +replay and resource cleanup. Generated projects include separate runtime, test, +TypeScript and distribution checks. + Version 0.12 adds [versioned settings](docs/SETTINGS.md), [execution-scoped secret references](docs/SECRETS.md), [structured command schemas](docs/DATA_SCHEMAS.md) and [localization/accessibility helpers](docs/LOCALIZATION.md). Run @@ -40,7 +47,7 @@ application, private engines/services and production signing systems are outside this repository. Read the [public scope](docs/PUBLIC_SCOPE.md) and [compatibility table](docs/COMPATIBILITY.md) for exact boundaries. -Version **0.12.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain +Version **0.13.0** supports Node 22 and 24. Manifest v1 diagnostic plugins remain compatible. Manifest v2 adds commands, permission-scoped workspace and backend APIs, and open-source or proprietary distribution metadata. Themes export to the XML format already supported by DDS. The workspace host runs trusted plugins @@ -100,7 +107,7 @@ mkdir my-dds-plugin cd my-dds-plugin npm init -y npm pkg set type=module -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.12.0/altifigence-dds-plugin-sdk-0.12.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.13.0/altifigence-dds-plugin-sdk-0.13.0.tgz ``` Copy the example's `plugin.mjs` and `run.mjs` into this directory, then run: diff --git a/bin/dds-plugin.mjs b/bin/dds-plugin.mjs index 569f11f..cb66af3 100644 --- a/bin/dds-plugin.mjs +++ b/bin/dds-plugin.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node import {packPlugin, validatePluginPackage, verifyPluginArchive} from '../src/publishing.mjs'; -import {initPlugin, doctorPlugin, runPluginDev} from '../src/devtools.mjs'; +import {initPlugin, doctorPlugin, runPluginDev, generatePluginContracts} from '../src/devtools.mjs'; const usage = `Usage: - dds-plugin init [--id ] [--publisher ] [--name ] + dds-plugin init [--id ] [--publisher ] [--name ] [--template ] [--dry-run] [--license ] [--license-file ] + dds-plugin generate [--check|--dry-run] [--expected-digest ] dds-plugin doctor [--json] - dds-plugin dev --trust-local-code [--watch] [--command ] [--input ] [--job] [--timeout-ms ] + dds-plugin dev --trust-local-code [--watch] [--command ] [--input ] [--job] [--timeout-ms ] [--profile] [--debug] [--debug-wait] dds-plugin validate dds-plugin pack --out dds-plugin verify [--metadata ] [--sha256 ]`; @@ -26,19 +27,23 @@ try { if (!directory) throw new Error(usage); let result; if (command === 'init') { - const values = flags(args, ['--id', '--publisher', '--name']); - result = await initPlugin(directory, Object.fromEntries(Object.entries(values).map(([key, value]) => [key.slice(2), value]))); + const values = flags(args, ['--id', '--publisher', '--name', '--template', '--license', '--license-file'], ['--dry-run']); + result = await initPlugin(directory, Object.fromEntries(Object.entries(values).map(([key, value]) => [key.slice(2).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()), value]))); + } else if (command === 'generate') { + const values=flags(args,['--expected-digest'],['--check','--dry-run']); + result=await generatePluginContracts(directory,{...(values['--check']?{check:true}:{}),...(values['--dry-run']?{dryRun:true}:{}),...(values['--expected-digest']?{expectedDigest:values['--expected-digest']}:{})}); + if(result.ok===false)process.exitCode=1; } else if (command === 'doctor') { const values = flags(args, [], ['--json']); result = await doctorPlugin(directory); if (!result.ok) process.exitCode = 1; if (!values['--json']) {process.stdout.write(`${result.checks.map(check => `${check.status.toUpperCase()} ${check.id}: ${check.message}${check.status === 'error' && check.fix ? `\n ${check.fix}` : ''}`).join('\n')}\n`); result = undefined;} } else if (command === 'dev') { - const values = flags(args, ['--command', '--input', '--timeout-ms'], ['--trust-local-code', '--watch', '--job']); + const values = flags(args, ['--command', '--input', '--timeout-ms'], ['--trust-local-code', '--watch', '--job', '--profile', '--debug', '--debug-wait']); if (values['--timeout-ms'] && !/^[1-9]\d*$/.test(values['--timeout-ms'])) throw new Error('Invalid --timeout-ms'); const controller = new AbortController(); const stop = () => controller.abort(); process.once('SIGINT', stop); process.once('SIGTERM', stop); try { - result = await runPluginDev(directory, {trustLocalCode: values['--trust-local-code'] === true, watch: values['--watch'] === true, job: values['--job'] === true, ...(values['--command'] ? {command: values['--command']} : {}), ...(values['--input'] ? {input: JSON.parse(values['--input'])} : {}), ...(values['--timeout-ms'] ? {timeoutMs: Number(values['--timeout-ms'])} : {}), signal: controller.signal, onEvent: event => process.stdout.write(`${JSON.stringify(event)}\n`)}); + result = await runPluginDev(directory, {trustLocalCode: values['--trust-local-code'] === true, watch: values['--watch'] === true, profile: values['--profile'] === true, debug: values['--debug'] === true, debugWait: values['--debug-wait'] === true, job: values['--job'] === true, ...(values['--command'] ? {command: values['--command']} : {}), ...(values['--input'] ? {input: JSON.parse(values['--input'])} : {}), ...(values['--timeout-ms'] ? {timeoutMs: Number(values['--timeout-ms'])} : {}), signal: controller.signal, onEvent: event => process.stdout.write(`${JSON.stringify(event)}\n`)}); if (!result.ok) process.exitCode = 1; } finally {process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);} } else if (command === 'verify') { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index fee86f3..2a3fb0b 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,5 +1,23 @@ # Compatibility and availability +## 0.13 development and diagnostics + +Six feature templates, guarded contract generation, synthetic scenario ports, +optional owner-supplied runtimes and local diagnostics add no workspace protocol +method or implicit grant. Existing `createTestHost()` diagnostics defaults remain. +New `host.inspect()` reports unsettled SDK-owned operations after disposal; +diagnostic providers and pending activations now retain their slots until actual +settlement. Operator `replaceGrants()` deactivates plugins losing permissions. +Ports are trusted host controls, not APIs granted to a plugin. +Settings-store `inspect()` now also remains available after disposal for cleanup +checks; its data read and mutation methods still reject a disposed store. + +New plugin archives use `>=0.13.0 <0.14.0`. Archive verification retains exact +released 0.3.x through 0.12.x metadata, without widening an old plugin's declared +range. Independent source/contract compatibility checks are separate from that +package-manager range. See [development tools](DEVTOOLS.md), [testing](testing.md) +and [local diagnostics](DIAGNOSTICS.md) for the v1 report and trace contracts. + ## 0.12 configuration and display [Settings](SETTINGS.md) and [secret references](SECRETS.md) are operator-owned @@ -45,8 +63,8 @@ is unchanged. These features do not establish a DDS product editor integration. | Browser-only DDS host | Native workspace transport is unavailable | | Language providers | Diagnostics plus completion, hover, definition, references and document symbols in SDK hosts since 0.3.0; no released DDS editor bridge is implied | | Project edit sessions | Client helpers in 0.3.0 over unchanged workspace protocol v1, including 0.2.x servers | -| Download verification | Node API/CLI since 0.3.1; 0.12.0 accepts exact 0.3.x–0.12.x package metadata; no extraction, code execution or publisher authentication | -| Development tools | `init`, `doctor`, `dev` since 0.4.0; local Node tools, explicit trusted-code execution | +| Download verification | Node API/CLI since 0.3.1; 0.13.0 accepts exact 0.3.x–0.13.x package metadata; no extraction, code execution or publisher authentication | +| Development tools | `init`, `doctor`, `dev` since 0.4.0; six templates, `generate`, synthetic testing and local profiles in 0.13 | | Command jobs | Opt-in SDK host/client extension since 0.4.0; progress/logs/UTF-8 artifacts; v1 hello unchanged | | File and job observation | Client helpers since 0.5.0; 0.6 uses revision reads when supported; job reads need enabled 0.4+ hosts | | Binary job results | Optional 0.7.0 extension; explicit host opt-in; 1 GiB files and 64 KiB chunks; Node resume/full-file verification | diff --git a/docs/DEVTOOLS.md b/docs/DEVTOOLS.md index 57bc599..25e5b26 100644 --- a/docs/DEVTOOLS.md +++ b/docs/DEVTOOLS.md @@ -1,7 +1,7 @@ # Plugin development tools -SDK 0.4.0 adds `init`, `doctor` and `dev` to the existing `validate`, `pack` and -`verify` CLI. Node 22 or 24 is required. These tools operate on your local project; +SDK 0.13 provides `init`, `generate`, `doctor` and `dev` alongside `validate`, +`pack` and `verify`. Node 22 or 24 is required. These tools operate on your local project; they do not publish a plugin or change an installed DDS application. ## Create a project @@ -12,7 +12,7 @@ Install the released SDK in a tools directory, then create a sibling project: mkdir dds-tools cd dds-tools npm init -y -npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.12.0/altifigence-dds-plugin-sdk-0.12.0.tgz +npm install --ignore-scripts --save-exact https://github.com/Altifigence/dds-plugin-sdk/releases/download/v0.13.0/altifigence-dds-plugin-sdk-0.13.0.tgz npx --no-install dds-plugin init ../my-plugin --id my-plugin --publisher example cd ../my-plugin npm install --ignore-scripts @@ -38,7 +38,7 @@ npx --no-install dds-plugin doctor . npx --no-install dds-plugin doctor . --json ``` -Checks cover the Node major version, local SDK 0.7.x resolution, manifest, +Checks cover the Node major version, local SDK 0.13.x resolution, manifest, disclosure, license, allowlisted files and example publisher metadata. Each check has `id`, `status`, `message` and an optional suggested `fix`. Errors set exit status 1; warnings alone do not. Doctor reads metadata and file bytes. It never @@ -100,9 +100,83 @@ installs it implicitly. `runPluginDev` resolves `{ok, runs, stopped}` after a single run or after watch is stopped. The CLI exits unsuccessfully for a failed single run, invalid configuration, timeout or output overflow. +## Feature templates and reviewable generation + +```sh +npx --no-install dds-plugin init ../my-language --template language --dry-run +npx --no-install dds-plugin init ../my-language --template language +``` + +| Template | Included exercise | +| --- | --- | +| `command` (default) | A greeting with legacy parameters; no host grants | +| `language` | Diagnostics, hover and completion over synthetic UTF-16 text | +| `theme` | Typed palette metadata and separate generated DDS XML | +| `job` | Explicit job start, progress/logs and terminal success | +| `browser` | Accessible local form and portable in-browser SDK host | +| `configuration` | Structured input/output, defaulted settings, EN/KO catalog and browser form | + +`planPlugin()` and `initPlugin(..., {dryRun:true})` return the exact proposed file +inventory, byte lengths, SHA-256 hashes and any existing-destination conflict. +They create nothing. All templates include `dds-dev.json`, runtime metadata, +generated `.d.mts` declarations, JSON schemas, a test and an independently +executable `run.mjs`. `npm run typecheck` uses the explicitly declared TypeScript +5.9.3 development dependency. The SDK still has no runtime dependencies. + +The default publisher license is Apache-2.0. To choose proprietary terms, supply +both `--license LicenseRef-Proprietary` and `--license-file `. +The selected text becomes `LICENSE`. Inherited `LICENSE.sdk` and `NOTICE` remain +unchanged in either case. Generation does not grant proprietary rights or invent +terms for the publisher. The generated manifest/disclosure records the selection. + +After editing `dds-dev.json`, inspect and regenerate contracts: + +```sh +npx --no-install dds-plugin generate . --dry-run +npx --no-install dds-plugin generate . --expected-digest +npx --no-install dds-plugin generate . --check +npm run doctor +npm test +npm run typecheck +``` + +`generated/generation.json` records the SDK version, canonical source digest and +individual generated hashes. Output is deterministic. Replacing existing output +requires the exact previous digest and unchanged generated files; hand edits, +additional files, links, a source change during commit or an existing cooperative +lock cause a conflict. Keep source changes in `dds-dev.json` and plugin code +outside `generated/`. Preserve any manual changes elsewhere before regenerating. + +The writer uses a cooperative lock and verified staging/backup, and only removes +entries it created. It does not claim an OS-wide filesystem transaction or safety +against hostile concurrent writers. After an interrupted process, inspect retained +`.dds-generation-*`, `.dds-generation-backup-*` and `.dds-generate.lock` entries; +no stale lock or uncertain backup is removed automatically. Never discard an +unreviewed backup to force regeneration. + +New generated schemas must also appear in `dds-package.json`'s explicit file +list. `doctor` checks this; generation does not silently broaden distribution. +Input schemas describe raw input with defaulted fields optional; validated input +types describe defaults after SDK validation. JSON schema `default` is an +annotation, not code that applies defaults. Byte/depth/Unicode and current +authorization checks still belong to runtime validators. + +`doctor` also checks generated drift, template metadata, requested permissions, +inherited notices, and the selected SDK dependency. It never executes a schema or +installs TypeScript. Browser templates serve only selected files on an ephemeral +`127.0.0.1` port. `npm run browser` prints the URL; Ctrl+C closes that server. +Templates with language/settings use their explicit example hosts (`npm run +example`), while generic `dev` intentionally mounts no file/backend/settings ports. + +For deterministic fixtures see [testing](testing.md). For `--profile`, `--debug` +and `--debug-wait`, including their cleanup limits, see [diagnostics](DIAGNOSTICS.md). + ## 한국어 -0.4.0의 `init`은 존재하지 않는 새 디렉터리에 명령 플러그인과 배포용 메타데이터를 +0.13의 `init --template`은 여섯 종류의 프로젝트를 제공하며 `--dry-run`은 파일과 +해시를 미리 보여줍니다. `generate --expected-digest`는 검증한 이전 생성물만 교체하고 +수동 편집·추가 파일·잠금 충돌을 보존합니다. `doctor`는 생성 상태와 배포 목록도 +검사합니다. 존재하지 않는 새 디렉터리에 플러그인과 배포용 메타데이터를 만듭니다. 기존 파일을 덮어쓰거나 의존성을 설치하지 않습니다. 생성된 프로젝트에서 `npm install --ignore-scripts` 후 `npm run doctor`로 확인하세요. `doctor --json`은 자동화용 결과를 제공하며 플러그인 코드를 실행하지 않습니다. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..5dbfda6 --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,109 @@ +# Local diagnostics and debugging + +Import `createDiagnosticSession`, `profileHost`, `profileTransport` and +`parseDiagnosticReport` from `@altifigence/dds-plugin-sdk/diagnostics`. +Sessions are disabled by default. They do not transmit reports or inspect plugin +arguments, response bodies, filenames, URLs, stack traces or arbitrary errors. + +```js +const diagnostics = createDiagnosticSession({enabled: true, sampleEvery: 1}); +const host = profileHost(createPluginHost({jobs: true}), diagnostics); +// Activate and exercise your explicitly trusted plugin. +host.dispose(); +const report = diagnostics.snapshot(); +diagnostics.dispose(); +``` + +`profileHost` is an explicit adapter: it keeps the original signatures and +results, measures activation, commands, language calls, job admission, binary +reads and disposal, and records the SDK-owned resource counts. Job admission +duration is not whole-job duration. Wrap the wait for completion in +`diagnostics.measure('job', callback)` when that is the quantity you need. +`profileTransport(fetch, diagnostics)` measures the caller's transport operation +without consuming/duplicating a request or response body. Fetch duration ends at +response headers, not at completed streaming download. + +## Correlation and measurements + +For nested stages, retain a live parent span explicitly: + +```js +const command = diagnostics.begin('command', {inputBytes: 64, queueDepth: 2}); +const transport = diagnostics.begin('transport', { + ...(command.id ? {parentId: command.id} : {}), inputBytes: 64, +}); +// Perform the operation yourself; no input is passed to the recorder. +transport.end({outputBytes: 128, resources: {memoryBytes: 1024}}); +command.end({outputBytes: 128}); +``` + +Only fixed operation/resource names and bounded numeric metrics are accepted. +IDs are generated locally. Parent IDs must identify a currently retained span. +Durations are milliseconds from a monotonic local `performance.now()` clock +(or the caller's `now` function). `startedAt` is local clock time, not a UTC +timestamp. Backwards clock readings are clamped. Byte counts, queue depth and +memory estimates in manual spans are supplied by the owner; absent measurements +remain `null`, not zero. Retained byte counts divided by measured duration can +describe throughput for that selected operation; they are not network telemetry. + +Errors retain a fixed SDK error code and status, with unknown errors mapped to +`provider_failed`. The original error still reaches the caller. Diagnostic +failures in `measure`, `profileHost` or `profileTransport` do not change the +underlying operation's result. Validate manually supplied metadata with `begin` +and `end` when you need explicit validation errors. + +The report is schema version 1, also available as `diagnostic-report.schema.json`. +Limits are 256 retained events, 64 active spans, 256 KiB export, sampling every +1–1,000 attempts and 1–300,000 ms retention. Default capacity is 128 and retention +60 seconds. Expiry is lazy on recording/readback: the profiler owns no background +timer. Full buffers discard oldest events; expired/untracked active spans count +as dropped. Sampling is deterministic by attempt number. Totals describe the +session, while retained events describe its sampled/retained subset. `clear()` +clears completed events; `dispose()` stops recording and releases active spans. + +`host.inspect()` is allowed after disposal. Pending activations, normal/provider +operations, jobs and checkpoints stay visible until the actual work settles, +including a provider that ignores cancellation. Other fields count registrations, +commands, retained jobs, language caches and SDK timers. Arbitrary process memory, +global timers and escaped child processes are outside this inspection. A host +retains no authority to stop an in-process infinite loop. + +## CLI profile and debugger + +```sh +npx --no-install dds-plugin dev . --trust-local-code --command greet --input '{"name":"Ada"}' --profile +npx --no-install dds-plugin dev . --trust-local-code --debug --debug-wait --timeout-ms 30000 +``` + +`--profile` emits one bounded `profile` event at ordinary child completion. +Crashes, forced timeouts and kills may have no final profile. Trusted plugin +stdout/stderr remain their normal, unredacted streams and are separately limited +to 256 KiB; review them before sharing. The metadata report itself excludes their +bodies. No source code, heap snapshot or CPU profile is collected automatically. + +`--debug` explicitly opens the current development child on a fresh ephemeral +`127.0.0.1` inspector port. There is no host/address option. The `debug` event +contains its WebSocket URL and remaining lifetime. `--debug-wait` waits before +plugin import until the debugger sends `Runtime.runIfWaitingForDebugger`. +Each debug child has a hard deadline of at most 30 seconds, including waiting or +paused execution. Watch restarts receive a new child/endpoint under the same +explicit command invocation. Completion, cancellation, deadline and crash use the +existing owner-only process-tree cleanup. Debugger connections end with that child. + +The inspector executes code with the child's authority. Only use these flags for +trusted local code. The SDK does not isolate code, grant product access, clean +escaped processes, or undo prior effects. Node documents the inspector's +[open/wait/close behavior](https://nodejs.org/docs/latest-v22.x/api/inspector.html#inspectoropenport-host-wait). + +`npm run benchmark:devtools` measures five batches of 10,000 tiny synchronous +operations with no instrumentation, disabled recording, every-tenth sampling and +all-event recording, then checks 100 scenario lifecycles. It reports actual local +overhead; it imposes no universal timing threshold. See [performance](performance.md). + +## 한국어 + +진단은 명시적으로 켠 로컬 세션에서 고정 지표와 SDK 소유 자원 수만 수집합니다. +입력·응답 본문·경로·임의 오류 메시지는 보고서에 넣지 않습니다. stdout/stderr는 +별도 원문 출력이므로 공유 전에 확인하세요. 취소를 무시한 작업은 실제 종료까지 +계수에 남습니다. `--debug --debug-wait`는 신뢰한 자식 프로세스의 loopback 임시 +포트만 열며 대기·일시정지를 포함해 최대 30초 후 정리합니다. OS 격리는 제공하지 않습니다. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 1f568e0..52f6747 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -26,7 +26,7 @@ notes for the capabilities and hosts actually supported by that release. what data the plugin uses, which backend receives it, and how users can find updates and contact the publisher about security. 4. List the plugin's distribution files explicitly in `dds-package.json`, then - validate and pack them with the SDK 0.12.0 CLI described below. Inspect the + validate and pack them with the SDK 0.13.0 CLI described below. Inspect the archive; do not list credentials, private project data or files that you do not intend to distribute. 5. Publish the immutable versioned `.tgz`, its external `.release.json` and @@ -44,10 +44,10 @@ file. Errors do not print the detected value. This is a conservative additional check, not a complete secret or malware scanner. Inspect your source and final archive; obfuscated credentials and confidential implementation may evade it. -## Package format and SDK 0.12.0 CLI +## Package format and SDK 0.13.0 CLI The CLI has been available since SDK 0.2.0; SDK 0.1.0 does not provide it. -Use an installed 0.12.0 package containing the `dds-plugin` executable. +Use an installed 0.13.0 package containing the `dds-plugin` executable. For a new project, start with [init and doctor](DEVTOOLS.md). Prepare this layout: @@ -87,9 +87,9 @@ The manifest, disclosure, manifest entry and selected license file must all be listed. The packer automatically includes `dds-package.json` and generates `package.json`; do not list either file in `files`. The generated npm metadata uses `type: "module"`, the manifest entry and a peer dependency on this SDK: -`>=0.12.0 <0.13.0`. The packer targets its current tested SDK line; it does not -infer compatibility by inspecting imports. Older archives from the 0.2.x–0.11.x -CLIs exclude SDK 0.12.0 in their peer ranges. Validate against 0.12.0 and +`>=0.13.0 <0.14.0`. The packer targets its current tested SDK line; it does not +infer compatibility by inspecting imports. Older archives from the 0.2.x–0.12.x +CLIs exclude SDK 0.13.0 in their peer ranges. Validate against 0.13.0 and publish a new plugin version with this packer when migrating those archives. List `NOTICE`, third-party notices, other runtime modules, assets and source material explicitly when they are applicable. The CLI does not determine which legal @@ -153,7 +153,7 @@ separate from validating and packaging your own plugin. ## Select a distribution channel -Before installing a downloaded plugin, use the SDK 0.12.0 +Before installing a downloaded plugin, use the SDK 0.13.0 [`dds-plugin verify` command](VERIFYING.md) or `verifyPluginArchive()` API to check its archive and release metadata without running or extracting it. Supply an independently obtained expected SHA-256 when selecting exact release diff --git a/docs/VERIFYING.md b/docs/VERIFYING.md index cac1978..0b26011 100644 --- a/docs/VERIFYING.md +++ b/docs/VERIFYING.md @@ -1,7 +1,7 @@ # Verify a downloaded plugin release -SDK **0.12.0** provides a Node.js API and CLI for checking archives produced by the -**0.3.x, 0.4.x, 0.5.x, 0.6.x, 0.7.x or 0.8.x DDS plugin packer**. Keep the publisher's `.tgz` and corresponding +SDK **0.13.0** provides a Node.js API and CLI for checking archives produced by the +**0.3.x through 0.13.x DDS plugin packers**. Keep the publisher's `.tgz` and corresponding `.tgz.release.json` together. Verification reads these local files without extracting them, importing a plugin, running a package script or installing it. It makes no network requests. @@ -33,7 +33,7 @@ const receipt = await verifyPluginArchive(process.argv[2], { console.log(receipt.pluginId, receipt.pluginVersion, receipt.artifact.sha256); ``` -Save that code as `verify.mjs` in an ESM project with SDK 0.12.0 installed. Run it +Save that code as `verify.mjs` in an ESM project with SDK 0.13.0 installed. Run it with the archive path, metadata path and independently obtained hash as its three arguments. The API returns a frozen report, and the CLI serializes it as JSON. The report contains the plugin @@ -82,11 +82,11 @@ The receipt describes the bytes read during that call. If another process can change a download before installation or use, recheck the expected hash on the actual bytes being consumed. A report is not a grant to execute a mutable path. -0.12.0 preserves existing runtime/workspace contracts and verifies the exact -published peer metadata from 0.3.x through 0.11.x, plus new 0.12.x metadata -(`>=0.12.0 <0.13.0`). Each prior minor line retains its original minor-bounded range. +0.13.0 preserves existing runtime/workspace contracts and verifies the exact +published peer metadata from 0.3.x through 0.12.x, plus new 0.13.x metadata +(`>=0.13.0 <0.14.0`). Each prior minor line retains its original minor-bounded range. Verification does not alter the peer range or authorize installation on another -SDK line: validate and repack a new version for 0.12. Existing 0.3.x–0.11.x packages that +SDK line: validate and repack a new version for 0.13. Existing 0.3.x–0.12.x packages that satisfy the strengthened file policy can be verified unchanged. The verifier rejects private credential names and Windows device aliases; remove them and publish a new plugin version if needed. See [Security](../SECURITY.md). @@ -96,7 +96,7 @@ SDK's own npm release tarball. ## 한국어 -SDK **0.12.0**의 `dds-plugin verify`와 `verifyPluginArchive()`는 **0.3.x~0.12.x DDS +SDK **0.13.0**의 `dds-plugin verify`와 `verifyPluginArchive()`는 **0.3.x~0.13.x DDS 플러그인 packer**가 만든 `.tgz`와 외부 `.tgz.release.json`을 검사합니다. 플러그인을 실행·설치하거나 파일을 디스크에 추출하지 않으며 네트워크 요청도 하지 않습니다. 실행 명령과 API는 위 예제와 같습니다. 별도 위치의 메타데이터는 @@ -116,8 +116,8 @@ manifest·발행자 고지·진입 모듈·라이선스 및 생성된 npm 메타 격리·설치 동의·DDS 제품 호환성 검증을 대신하지 않습니다. 실제 실행 시 파일이 바뀔 수 있는 환경에서는 소비하는 바로 그 파일의 해시를 다시 확인해야 합니다. -강화된 파일 정책을 만족하는 기존 0.3.x~0.11.x 플러그인 아카이브는 그대로 검증할 수 있고 -각각 기존 peer 범위도 유지됩니다. 새 패키지는 `>=0.12.0 <0.13.0`을 +강화된 파일 정책을 만족하는 기존 0.3.x~0.12.x 플러그인 아카이브는 그대로 검증할 수 있고 +각각 기존 peer 범위도 유지됩니다. 새 패키지는 `>=0.13.0 <0.14.0`을 선언하며, 기존 패키지를 0.12에 설치하려면 발행자가 검증 후 새 버전으로 패키징해야 합니다. 검증 자체는 호환 범위를 바꾸지 않습니다. 제외한 자격증명 경로나 Windows 장치명이 들어 있으면 해당 파일을 제거한 새 플러그인 버전을 발행하세요. diff --git a/docs/api.md b/docs/api.md index fb247c7..7091424 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,4 +1,11 @@ -# API reference — 0.12.0 +# API reference — 0.13.0 + +SDK 0.13 adds `/testing` scenario/clock/fault/trace helpers, `/diagnostics` local +sessions and adapters, and `/devtools` feature templates plus `planPlugin()` and +`generatePluginContracts()`. `createPluginHost({runtime})`, registry runtimes, +settings/secret runtime ports, `host.inspect()` and operator `replaceGrants()` +are optional additions. See [testing](testing.md), [diagnostics](DIAGNOSTICS.md) +and [development tools](DEVTOOLS.md) for exact limits and ownership. SDK 0.8 adds optional [job storage](JOB_STORAGE.md), [history and retry](JOB_HISTORY.md) and [stored result files](ARTIFACT_STORAGE.md). Existing v1 response shapes remain valid. @@ -80,7 +87,7 @@ license eligibility or agreement acceptance. See [LICENSING](LICENSING.md). | Activation context member | Behavior | | --- | --- | -| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.12.0', protocolVersion: 1}` | +| `host` | `{id: 'test-host' \| 'workspace-host', version: '0.13.0', protocolVersion: 1}` | | `pluginId`, `scope` | Manifest ID and opaque host `{projectId, sessionId}` | | `grants` | Frozen intersection of host grants and manifest permissions | | `signal` | Aborted on deactivation, host disposal or activation timeout | diff --git a/docs/performance.md b/docs/performance.md index c26420a..efbd9c5 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,5 +1,15 @@ # Performance +## Development diagnostics (0.13) + +`npm run benchmark:devtools` compares five batches of 10,000 tiny synchronous +calls: an uninstrumented loop, disabled diagnostics, every-tenth sampling and +all-event recording. It reports median batch time and measured extra microseconds +per operation, then verifies 100 independent scenario activate/execute/dispose +cycles. This workload measures recorder overhead, not plugin compute speed or +production network throughput. Reports include Node/platform and are regenerated +in the supported Windows/Linux CI matrix; inspect the run's actual output. + `npm run benchmark` measures five cold core imports in fresh Node processes, ten warmed validation batches, ten local provider batches and ten command batches. It also measures a one-item completion response and a single text edit in ten 100-call batches. The input is a diff --git a/docs/testing.md b/docs/testing.md index a046a50..5278d47 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,6 +10,120 @@ grants by default. `createPluginHost()` from the core accepts workspace/backend ports and defaults to no grants. Both run explicitly imported trusted modules in-process; neither follows a manifest entry or establishes an OS sandbox. +## Deterministic scenarios (0.13) + +`createScenarioHost()` from `/testing` defaults to no grants or mounted file, +backend, settings, secret or job capability. Select each explicitly: + +```js +const scenario = createScenarioHost({ + seed: 7, start: 1000, + files: {'result.txt': 'synthetic example'}, + backends: {analyzer: {count: 3}}, + grants: ['workspace.read', 'backend.invoke'], + jobs: true, binaryArtifacts: true, +}); +try { + // Activate your trusted plugin on scenario.host. + scenario.faults.enqueue({operation: 'backend.analyzer', kind: 'delay', delayMs: 25}); + const pending = scenario.host.executeCommand('your-plugin', 'analyze', {}); + await scenario.clock.advance(25); + const result = await pending; +} finally { + await scenario.dispose(); + scenario.assertClean(); +} +``` + +Await `clock.advance()` before awaiting an operation blocked on its virtual timer. +`createTestClock({seed,start})` exposes a trusted `runtime` port containing `now`, +`setTimeout`, `clearTimeout` and `randomUUID`. Core host/diagnostic/language/job +lifecycles, resolve-cache expiry, job checkpoint/history clocks, settings migrations +and secret leases can use this port without patching globals. Test UUIDs are +deterministic and are **not cryptographic production entropy**. The existing +`now` secret-resolver option remains available but cannot be combined with `runtime`. + +Read-only resource `inspect()` methods remain available after disposal, including +settings-store inspection (new in 0.13). Data reads and mutations still reject a +disposed owner. This lets teardown checks observe a provider that ignored abort +until it actually settles instead of reporting it as already released. + +Equal-time timers run in registration order. Each advance has at most 10,000 +steps and 4,096 scheduled timers; concurrent advances fail. An advance accepts +at most one day, and drains bounded microtask turns between callbacks. +`flushTestMicrotasks(turns)` helps settle explicitly bounded promise chains. +Real I/O, WebCrypto completion, arbitrary plugin timers, OS watchers/processes +and network transports are not made virtual. Do not call `runUntilIdle()` for a +deliberately repeating timer without a suitable finite step budget. + +`scenario.capabilities` lists mounted fixtures. `replaceGrants(next)` deactivates +plugins losing any grant, cancels their pending work and changes subsequent +activations; newly added grants apply when a plugin next activates. These are +operator controls, never a plugin API. Mounting a fixture does not grant access. + +| Fixture | Contract and limit | +| --- | --- | +| `createMemoryWorkspace` / `scenario.workspace` | UTF-8 reads, exact revision CAS writes, bounded lists, immutable binary sources and parsed project snapshots; 256 files, 256 KiB/file, 4 MiB total | +| Workspace `subscribe` | Explicit synthetic snapshot callback; no native filesystem watching; 32 subscriptions | +| Scenario `backends` | Named JSON fixture responses; explicit `backend.invoke` grant | +| Scenario `settings` | Real bounded `SettingsStore` validation/CAS/migration with virtual operation timers; explicit supplied definitions | +| Scenario `secrets` | Explicit synthetic byte arrays with real scoped resolver leases, virtual expiry and owned-copy zeroing | +| `createMemoryJobStore` | Checksummed bounded contract snapshots, CAS and synthetic unavailable/corrupt records; no disk durability claim | + +Faults are queued for `file.read`, `file.write`, `file.list`, `file.capture`, +`file.chunk`, `backend.`, `settings.read`, `secret.resolve` or `store.write`. +`delay` requires `delayMs`; `deny`, `disconnect`, `drop` and `exhaust` simulate +their corresponding failure. `corrupt` returns the explicitly supplied JSON +value without executing the real fixture operation. A dropped operation waits +for cancellation or fault-controller disposal. `faults.events()` can drop, +duplicate or reverse returned fixture events; it never duplicates actual effects. +Standalone ports/controllers remain owner-managed. Attach an external job store +via `jobStorage` and await `host.flushJobStore()` before inspecting its checkpoints. + +`scenario.inspect()` and `host.inspect()` count actual owned work, including +pending activation/provider operations that ignored cancellation. Disposal cannot +stop that code; `assertClean()` fails until it really settles. `createTestResources` +tracks only explicitly registered file handles, processes, subscriptions, timers +or operations. Supply their cleanup callback, retain failed cleanup entries and +call `assertEmpty()`. It does not discover unrelated operating-system handles. +The scenario's fake clock is also inspectable after disposal so leaked fixture +timers are not silently hidden by clearing them. + +## Synthetic trace recording and pure replay + +```js +const fixtures = { + synthetic: true, seed: 7, fixtureVersion: 1, + fixtures: [{id: 'greeting', operation: 'command', + input: {name: 'Example'}, output: {message: 'Hello, Example!'}}], +}; +const recorder = createTestRecorder({...fixtures, now: scenario.clock.now}); +recorder.record('greeting', {name: 'Example'}, {message: 'Hello, Example!'}); +const trace = recorder.snapshot(); +const replay = createTestReplay(trace, fixtures); +const result = replay.next('greeting', {name: 'Example'}); +replay.assertComplete(); +``` + +Recording requires the caller's explicit assertion that every selected fixture is +synthetic. The trace contains only its schema/fixture version, seed, selected +fixture IDs, fixed operation names, virtual time, ordered input/output hashes and +a hash chain. It contains no raw payloads, paths or secret bytes. Fixture IDs must +also be non-sensitive. A hash is not encryption or anonymization: low-entropy +values may be guessable. Keep customer, credential and PDK data out of fixtures. +The SDK cannot establish that caller-provided material is actually synthetic. + +`parseTestTrace` checks exact fields, at most 1,024 events/1 MiB, sequence and hash +integrity. Replay checks the seed, fixture version, complete fixture digest and +request order, then returns the supplied frozen synthetic response. It accepts +no execution callback and performs no filesystem/backend/secret effects. Job-store +snapshots are separate full contract checkpoints and are not privacy-filtered +trace exports; their default persisted job content policy remains metadata-only. + +Run `npm run example:devtools` for language, workspace, backend, settings, secrets, +binary jobs and trace/profiler examples. Memory restart/corruption fixtures do not +replace the separate real killed-child, HTTP, filesystem and browser examples. + ## Test the included diagnostics plugin In a clone of the SDK repository, save `plugin.test.mjs` at the repository root: diff --git a/examples/development-tools/run.mjs b/examples/development-tools/run.mjs new file mode 100644 index 0000000..71efa53 --- /dev/null +++ b/examples/development-tools/run.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import {definePlugin,createLanguageResult} from '@altifigence/dds-plugin-sdk'; +import {createScenarioHost,createTestRecorder,createTestReplay,flushTestMicrotasks} from '@altifigence/dds-plugin-sdk/testing'; +import {createDiagnosticSession,profileHost} from '@altifigence/dds-plugin-sdk/diagnostics'; + +// Only these tiny invented fixtures are used. No workspace discovery or real credentials. +const manifest={manifestVersion:2,id:'development-example',name:'Development Example',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./run.mjs',runtime:'workspace',capabilities:['commands','settings'],permissions:['workspace.read','backend.invoke','settings.read','secrets.resolve'],supportedHosts:['workspace-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}}; +const definition={schemaVersion:1,pluginId:manifest.id,version:1,settings:{multiplier:{schema:{schemaVersion:1,schema:{type:'integer',default:2,minimum:1,maximum:10}},scopes:['user','workspace']}}}; +const scenario=createScenarioHost({seed:7,start:1000,files:{'result.txt':'synthetic result'},backends:{analyzer:{value:3}},settings:[definition],secrets:{token:new Uint8Array([1,2,3])},grants:['workspace.read','backend.invoke','settings.read','secrets.resolve'],jobs:true,binaryArtifacts:true}); +const diagnostics=createDiagnosticSession({enabled:true,now:scenario.clock.now}),host=profileHost(scenario.host,diagnostics); +try{ + await host.activate(definePlugin(manifest,ctx=>ctx.registerCommand({id:'analyze',title:'Analyze'},async(_input,{signal,job})=>{ + const file=await ctx.workspace.readFile('result.txt',{signal}),value=await ctx.backends.invoke('analyzer',{}, {signal}),settings=await ctx.settings.read({signal}); + if(job){job.log('info','Synthetic analysis');await job.addBinaryArtifact({id:'result',path:'result.txt'});job.reportProgress({completed:1,total:1});} + return {total:value.value*settings.values.multiplier,bytes:new TextEncoder().encode(file.content).byteLength}; + }))); + const expected={total:6,bytes:16},options={synthetic:true,seed:7,fixtureVersion:1,fixtures:[{id:'analyze',operation:'command',input:{},output:expected}]}; + const recorder=createTestRecorder({...options,now:scenario.clock.now});scenario.faults.enqueue({operation:'backend.analyzer',kind:'delay',delayMs:25}); + const pending=host.executeCommand(manifest.id,'analyze',{});await scenario.clock.advance(25);const result=await pending;assert.deepEqual(result,expected);recorder.record('analyze',{},result); + const replay=createTestReplay(recorder.snapshot(),options);assert.deepEqual(replay.next('analyze',{}),result);replay.assertComplete(); + const watched=[],subscription=scenario.workspace.subscribe('',snapshot=>watched.push(snapshot));scenario.workspace.setFile('second.txt','another fixture');assert.equal(watched.length,1);subscription.dispose(); + const jobId=scenario.clock.runtime.randomUUID();host.startCommandJob(manifest.id,'analyze',{}, {jobId});await flushTestMicrotasks(128);assert.equal(host.getJob(jobId).state,'succeeded');const artifact=host.listJobBinaryArtifacts(jobId).artifacts[0];assert.equal((await host.readJobBinaryArtifactChunk(jobId,'result',artifact.revision,0,16)).eof,true); + const reference=scenario.secrets.issue({secretId:'token',pluginId:manifest.id,workspaceId:'synthetic-project',commandIds:['analyze'],ttlMs:10});let borrowed; + await scenario.secrets.withSecret(reference,{pluginId:manifest.id,workspaceId:'synthetic-project',commandId:'analyze',executionId:scenario.clock.runtime.randomUUID()},bytes=>{borrowed=bytes;assert.equal(bytes.length,3);});assert.ok(borrowed.every(byte=>byte===0)); + await scenario.clock.advance(10);assert.equal(scenario.secrets.inspect().references,0); + const language=definePlugin({...manifest,id:'synthetic-language',runtime:'ui',capabilities:['hover'],permissions:['document.read','language.provide']},ctx=>ctx.registerLanguageProvider('hover',{languages:['synthetic']},{provide:request=>createLanguageResult(request,{text:'Synthetic hover'})})); + await host.activate(language).then(()=>assert.fail('Missing grants must reject'),error=>assert.equal(error.code,'permission_denied')); + scenario.replaceGrants(['workspace.read','backend.invoke','settings.read','secrets.resolve','document.read','language.provide']);await host.activate(language);host.setDocument({uri:'memory:///synthetic.txt',languageId:'synthetic',modelVersion:1,workspaceRevision:'one',text:'example'});assert.equal((await host.requestLanguage('hover',{position:{line:0,character:0}})).data.text,'Synthetic hover'); + scenario.replaceGrants(['workspace.read']);await assert.rejects(host.executeCommand(manifest.id,'analyze',{}),{code:'disposed'}); + host.dispose();const report=diagnostics.snapshot();assert.ok(report.events.some(event=>event.operation==='command'&&event.durationMs===25));assert.equal(report.events.at(-1).resources.commands,0);assert.ok(!JSON.stringify(report).includes('result.txt')); + console.log('Development tools: seeded clock, file CAS/snapshot, backend delay, settings, secret cleanup, job binary result, pure trace replay and bounded diagnostics verified'); +}finally{host.dispose();await scenario.dispose();scenario.assertClean();diagnostics.dispose();} diff --git a/package-lock.json b/package-lock.json index 1a0ed85..eb4e8b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.12.0", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@altifigence/dds-plugin-sdk", - "version": "0.12.0", + "version": "0.13.0", "license": "Apache-2.0", "bin": { "dds-plugin": "bin/dds-plugin.mjs" diff --git a/package.json b/package.json index 90ea06a..ec8cc0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@altifigence/dds-plugin-sdk", - "version": "0.12.0", + "version": "0.13.0", "description": "Plugin, theme and user-owned workspace tools for Digital Design Studio", "type": "module", "license": "Apache-2.0", @@ -152,6 +152,10 @@ "./transfer-queue": { "types": "./src/transfer-queue.d.mts", "import": "./src/transfer-queue.mjs" + }, + "./diagnostics": { + "import": "./src/diagnostics.mjs", + "types": "./src/diagnostics.d.mts" } }, "bin": { @@ -215,7 +219,9 @@ "example:workspace-edits": "node examples/workspace-edits/run.mjs", "example:language-editing": "node examples/language-editing/run.mjs", "example:language-display": "node examples/language-display/run.mjs", - "example:language-display-browser": "node examples/language-display/serve.mjs" + "example:language-display-browser": "node examples/language-display/serve.mjs", + "example:devtools": "node examples/development-tools/run.mjs", + "benchmark:devtools": "node scripts/benchmark-devtools.mjs" }, "devDependencies": { "typescript": "5.9.3" diff --git a/schemas/artifact-sink-capabilities.schema.json b/schemas/artifact-sink-capabilities.schema.json index 840ed15..e59170e 100644 --- a/schemas/artifact-sink-capabilities.schema.json +++ b/schemas/artifact-sink-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-sink-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/artifact-sink-capabilities.schema.json", "title": "artifact-sink-capabilities", "type": "object", "properties": { diff --git a/schemas/artifact-storage-capabilities.schema.json b/schemas/artifact-storage-capabilities.schema.json index 160f0c9..8516c04 100644 --- a/schemas/artifact-storage-capabilities.schema.json +++ b/schemas/artifact-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/artifact-storage-capabilities.schema.json", "title": "artifact-storage-capabilities", "oneOf": [ { diff --git a/schemas/artifact-transfer-receipt.schema.json b/schemas/artifact-transfer-receipt.schema.json index 94dc84a..453791c 100644 --- a/schemas/artifact-transfer-receipt.schema.json +++ b/schemas/artifact-transfer-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/artifact-transfer-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/artifact-transfer-receipt.schema.json", "title": "artifact-transfer-receipt", "type": "object", "properties": { diff --git a/schemas/binary-artifact-capabilities.schema.json b/schemas/binary-artifact-capabilities.schema.json index 931c115..d1bdaba 100644 --- a/schemas/binary-artifact-capabilities.schema.json +++ b/schemas/binary-artifact-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/binary-artifact-capabilities.schema.json", "title": "binary-artifact-capabilities", "type": "object", "properties": { diff --git a/schemas/binary-artifact-chunk.schema.json b/schemas/binary-artifact-chunk.schema.json index 8f7d4a9..e79995a 100644 --- a/schemas/binary-artifact-chunk.schema.json +++ b/schemas/binary-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/binary-artifact-chunk.schema.json", "title": "binary-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/binary-artifact-list.schema.json b/schemas/binary-artifact-list.schema.json index 960d8fe..7786f41 100644 --- a/schemas/binary-artifact-list.schema.json +++ b/schemas/binary-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/binary-artifact-list.schema.json", "title": "binary-artifact-list", "type": "object", "properties": { diff --git a/schemas/binary-artifact-reference.schema.json b/schemas/binary-artifact-reference.schema.json index 406ca27..d1517a3 100644 --- a/schemas/binary-artifact-reference.schema.json +++ b/schemas/binary-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/binary-artifact-reference.schema.json", "title": "binary-artifact-reference", "type": "object", "properties": { diff --git a/schemas/binary-artifact.schema.json b/schemas/binary-artifact.schema.json index a1d7411..09f3563 100644 --- a/schemas/binary-artifact.schema.json +++ b/schemas/binary-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/binary-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/binary-artifact.schema.json", "title": "binary-artifact", "type": "object", "properties": { diff --git a/schemas/browser-artifact-checkpoint.schema.json b/schemas/browser-artifact-checkpoint.schema.json index c286e93..5b6b745 100644 --- a/schemas/browser-artifact-checkpoint.schema.json +++ b/schemas/browser-artifact-checkpoint.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/browser-artifact-checkpoint.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/browser-artifact-checkpoint.schema.json", "title": "browser-artifact-checkpoint", "type": "object", "properties": { diff --git a/schemas/code-action.schema.json b/schemas/code-action.schema.json index 4818d4d..0a8bbe7 100644 --- a/schemas/code-action.schema.json +++ b/schemas/code-action.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/code-action.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/code-action.schema.json", "title": "code-action", "type": "object", "properties": { diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 4471a70..c447aa7 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/command.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/command.schema.json", "title": "command", "type": "object", "properties": { diff --git a/schemas/completion-item.schema.json b/schemas/completion-item.schema.json index 055452d..5f95509 100644 --- a/schemas/completion-item.schema.json +++ b/schemas/completion-item.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/completion-item.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/completion-item.schema.json", "title": "completion-item", "type": "object", "properties": { diff --git a/schemas/data-schema.schema.json b/schemas/data-schema.schema.json index 9bd0763..47d22f6 100644 --- a/schemas/data-schema.schema.json +++ b/schemas/data-schema.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/data-schema.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/data-schema.schema.json", "title": "data-schema", "type": "object", "properties": { diff --git a/schemas/development-definition.schema.json b/schemas/development-definition.schema.json new file mode 100644 index 0000000..b9c824d --- /dev/null +++ b/schemas/development-definition.schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/development-definition.schema.json", + "title": "development-definition", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "template": { + "enum": [ + "command", + "language", + "theme", + "job", + "browser", + "configuration" + ] + }, + "commands": { + "type": "array", + "items": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/command.schema.json" + }, + "maxItems": 64 + }, + "settings": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/settings-definition.schema.json" + }, + "catalog": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/locale-catalog.schema.json" + }, + "theme": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/theme.schema.json" + } + }, + "required": [ + "schemaVersion", + "template", + "commands" + ], + "additionalProperties": false +} diff --git a/schemas/diagnostic-report.schema.json b/schemas/diagnostic-report.schema.json new file mode 100644 index 0000000..33dbda3 --- /dev/null +++ b/schemas/diagnostic-report.schema.json @@ -0,0 +1,277 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/diagnostic-report.schema.json", + "title": "diagnostic-report", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "enabled": { + "type": "boolean" + }, + "sampleEvery": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "retentionMs": { + "type": "integer", + "minimum": 1, + "maximum": 300000 + }, + "capacity": { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + "attempted": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "sampled": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "dropped": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "pending": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^d[1-9][0-9]{0,15}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^d[1-9][0-9]{0,15}$" + }, + { + "type": "null" + } + ] + }, + "operation": { + "enum": [ + "activation", + "command", + "language", + "transport", + "workspace", + "project", + "job", + "binary", + "settings", + "secret", + "backend", + "dispose" + ] + }, + "startedAt": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "durationMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "enum": [ + "ok", + "cancelled", + "error", + "disposed" + ] + }, + "inputBytes": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + { + "type": "null" + } + ] + }, + "outputBytes": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + { + "type": "null" + } + ] + }, + "queueDepth": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + { + "type": "null" + } + ] + }, + "resources": { + "type": "object", + "properties": { + "plugins": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "commands": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "registrations": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "pendingActivations": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "pendingOperations": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "providerOperations": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "pendingJobs": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "binaryOperations": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "pendingCheckpoints": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "retainedJobs": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "resolveTokens": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "semanticEntries": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "timers": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "subscriptions": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "fileHandles": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "processes": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + }, + "memoryBytes": { + "type": "integer", + "minimum": 0, + "maximum": 1000000000000 + } + }, + "required": [], + "additionalProperties": false + }, + "errorCode": { + "enum": [ + "invalid_contract", + "permission_denied", + "unsupported_host", + "version_mismatch", + "cancelled", + "stale_snapshot", + "budget_exceeded", + "disposed", + "provider_failed", + "provider_unavailable", + "capability_unavailable", + "conflict" + ] + } + }, + "required": [ + "id", + "parentId", + "operation", + "startedAt", + "durationMs", + "status", + "inputBytes", + "outputBytes", + "queueDepth", + "resources" + ], + "additionalProperties": false + }, + "maxItems": 256 + } + }, + "required": [ + "schemaVersion", + "enabled", + "sampleEvery", + "retentionMs", + "capacity", + "attempted", + "sampled", + "dropped", + "pending", + "events" + ], + "additionalProperties": false +} diff --git a/schemas/diagnostics-request.schema.json b/schemas/diagnostics-request.schema.json index 4fd2d32..4cb7ea4 100644 --- a/schemas/diagnostics-request.schema.json +++ b/schemas/diagnostics-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/diagnostics-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/diagnostics-request.schema.json", "title": "diagnostics-request", "type": "object", "properties": { diff --git a/schemas/diagnostics-result.schema.json b/schemas/diagnostics-result.schema.json index 1058df5..abe69bb 100644 --- a/schemas/diagnostics-result.schema.json +++ b/schemas/diagnostics-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/diagnostics-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/diagnostics-result.schema.json", "title": "diagnostics-result", "type": "object", "properties": { diff --git a/schemas/display-metadata.schema.json b/schemas/display-metadata.schema.json index 656650d..0777013 100644 --- a/schemas/display-metadata.schema.json +++ b/schemas/display-metadata.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/display-metadata.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/display-metadata.schema.json", "title": "display-metadata", "type": "object", "properties": { diff --git a/schemas/document-symbol-tree.schema.json b/schemas/document-symbol-tree.schema.json index 89f35a0..aeb9557 100644 --- a/schemas/document-symbol-tree.schema.json +++ b/schemas/document-symbol-tree.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/document-symbol-tree.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/document-symbol-tree.schema.json", "title": "document-symbol-tree", "type": "array", "items": { diff --git a/schemas/error.schema.json b/schemas/error.schema.json index 3e72fcc..acef0c0 100644 --- a/schemas/error.schema.json +++ b/schemas/error.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/error.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/error.schema.json", "title": "error", "type": "object", "properties": { diff --git a/schemas/folding-ranges.schema.json b/schemas/folding-ranges.schema.json index 1c62773..53b09a8 100644 --- a/schemas/folding-ranges.schema.json +++ b/schemas/folding-ranges.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/folding-ranges.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/folding-ranges.schema.json", "title": "folding-ranges", "type": "array", "items": { diff --git a/schemas/formatting-options.schema.json b/schemas/formatting-options.schema.json index 64e21de..9ce2046 100644 --- a/schemas/formatting-options.schema.json +++ b/schemas/formatting-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/formatting-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/formatting-options.schema.json", "title": "formatting-options", "type": "object", "properties": { diff --git a/schemas/generation-receipt.schema.json b/schemas/generation-receipt.schema.json new file mode 100644 index 0000000..16c8303 --- /dev/null +++ b/schemas/generation-receipt.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/generation-receipt.schema.json", + "title": "generation-receipt", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "sdkVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[a-z0-9.-]+)?$" + }, + "sourceSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 1048576 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "bytes", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 255 + }, + "digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "schemaVersion", + "sdkVersion", + "sourceSha256", + "files", + "digest" + ], + "additionalProperties": false +} diff --git a/schemas/generation-report.schema.json b/schemas/generation-report.schema.json new file mode 100644 index 0000000..efb3d4a --- /dev/null +++ b/schemas/generation-report.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/generation-report.schema.json", + "title": "generation-report", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "sdkVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[a-z0-9.-]+)?$" + }, + "digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "previousDigest": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + { + "type": "null" + } + ] + }, + "current": { + "type": "boolean" + }, + "written": { + "type": "boolean" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 1048576 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "bytes", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 256 + }, + "conflicts": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "maxItems": 1 + }, + "ok": { + "type": "boolean" + } + }, + "required": [ + "schemaVersion", + "directory", + "sdkVersion", + "digest", + "previousDigest", + "current", + "written", + "files", + "conflicts" + ], + "additionalProperties": false +} diff --git a/schemas/inlay-hints.schema.json b/schemas/inlay-hints.schema.json index 0e046f2..a9fb456 100644 --- a/schemas/inlay-hints.schema.json +++ b/schemas/inlay-hints.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/inlay-hints.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/inlay-hints.schema.json", "title": "inlay-hints", "type": "array", "items": { diff --git a/schemas/job-artifact-content.schema.json b/schemas/job-artifact-content.schema.json index e3eb0a8..f826629 100644 --- a/schemas/job-artifact-content.schema.json +++ b/schemas/job-artifact-content.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-artifact-content.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-artifact-content.schema.json", "title": "job-artifact-content", "type": "object", "properties": { diff --git a/schemas/job-capabilities.schema.json b/schemas/job-capabilities.schema.json index 7326367..a720f6c 100644 --- a/schemas/job-capabilities.schema.json +++ b/schemas/job-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-capabilities.schema.json", "title": "job-capabilities", "type": "object", "properties": { diff --git a/schemas/job-events.schema.json b/schemas/job-events.schema.json index 98d2caf..a098d6f 100644 --- a/schemas/job-events.schema.json +++ b/schemas/job-events.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-events.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-events.schema.json", "title": "job-events", "type": "object", "properties": { diff --git a/schemas/job-history-page.schema.json b/schemas/job-history-page.schema.json index a4cb3cd..66adb78 100644 --- a/schemas/job-history-page.schema.json +++ b/schemas/job-history-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-history-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-history-page.schema.json", "title": "job-history-page", "type": "object", "properties": { diff --git a/schemas/job-history-query.schema.json b/schemas/job-history-query.schema.json index 273e814..e5eb5f2 100644 --- a/schemas/job-history-query.schema.json +++ b/schemas/job-history-query.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-history-query.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-history-query.schema.json", "title": "job-history-query", "type": "object", "properties": { diff --git a/schemas/job-options.schema.json b/schemas/job-options.schema.json index 1501192..6cc8930 100644 --- a/schemas/job-options.schema.json +++ b/schemas/job-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-options.schema.json", "title": "job-options", "type": "object", "properties": { diff --git a/schemas/job-recovery.schema.json b/schemas/job-recovery.schema.json index d94630b..7f8ca44 100644 --- a/schemas/job-recovery.schema.json +++ b/schemas/job-recovery.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-recovery.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-recovery.schema.json", "title": "job-recovery", "oneOf": [ { @@ -125,7 +125,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -691,7 +691,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", @@ -1257,7 +1257,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/job-snapshot.schema.json b/schemas/job-snapshot.schema.json index c3e309a..73808f7 100644 --- a/schemas/job-snapshot.schema.json +++ b/schemas/job-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-snapshot.schema.json", "title": "job-snapshot", "oneOf": [ { @@ -305,7 +305,7 @@ "const": "succeeded" }, "result": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/json-value.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/json-value.schema.json" } }, "required": [ diff --git a/schemas/job-storage-capabilities.schema.json b/schemas/job-storage-capabilities.schema.json index a5e49cc..3aafaeb 100644 --- a/schemas/job-storage-capabilities.schema.json +++ b/schemas/job-storage-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-storage-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-storage-capabilities.schema.json", "title": "job-storage-capabilities", "oneOf": [ { diff --git a/schemas/job-store-identity.schema.json b/schemas/job-store-identity.schema.json index e7b6d56..f934e3b 100644 --- a/schemas/job-store-identity.schema.json +++ b/schemas/job-store-identity.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-store-identity.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-store-identity.schema.json", "title": "job-store-identity", "type": "object", "properties": { diff --git a/schemas/json-value.schema.json b/schemas/json-value.schema.json index 0765b68..0f1469c 100644 --- a/schemas/json-value.schema.json +++ b/schemas/json-value.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/json-value.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/json-value.schema.json", "title": "json-value", "$ref": "#/$defs/value", "x-maxUtf8Bytes": 262144, diff --git a/schemas/language-capabilities.schema.json b/schemas/language-capabilities.schema.json index 4eb3dcd..8769d3c 100644 --- a/schemas/language-capabilities.schema.json +++ b/schemas/language-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/language-capabilities.schema.json", "title": "language-capabilities", "type": "object", "properties": { diff --git a/schemas/language-request.schema.json b/schemas/language-request.schema.json index 36674bb..3f4ba08 100644 --- a/schemas/language-request.schema.json +++ b/schemas/language-request.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-request.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/language-request.schema.json", "title": "language-request", "oneOf": [ { diff --git a/schemas/language-result.schema.json b/schemas/language-result.schema.json index 5a77e18..583d6f9 100644 --- a/schemas/language-result.schema.json +++ b/schemas/language-result.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-result.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/language-result.schema.json", "title": "language-result", "oneOf": [ { @@ -3178,7 +3178,7 @@ "const": "document-symbol-tree" }, "data": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/document-symbol-tree.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/document-symbol-tree.schema.json" } }, "required": [ diff --git a/schemas/language-snippet.schema.json b/schemas/language-snippet.schema.json index 5672938..a8917f8 100644 --- a/schemas/language-snippet.schema.json +++ b/schemas/language-snippet.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/language-snippet.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/language-snippet.schema.json", "title": "language-snippet", "type": "string", "minLength": 0, diff --git a/schemas/locale-catalog.schema.json b/schemas/locale-catalog.schema.json index d6f7ded..31eca23 100644 --- a/schemas/locale-catalog.schema.json +++ b/schemas/locale-catalog.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/locale-catalog.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/locale-catalog.schema.json", "title": "locale-catalog", "type": "object", "properties": { diff --git a/schemas/localized-message.schema.json b/schemas/localized-message.schema.json index 2307e26..da7d60d 100644 --- a/schemas/localized-message.schema.json +++ b/schemas/localized-message.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/localized-message.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/localized-message.schema.json", "title": "localized-message", "type": "object", "properties": { diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json index 8a2458b..5c188ca 100644 --- a/schemas/manifest.schema.json +++ b/schemas/manifest.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/manifest.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/manifest.schema.json", "title": "manifest", "properties": { "id": { diff --git a/schemas/memory-job-snapshot.schema.json b/schemas/memory-job-snapshot.schema.json new file mode 100644 index 0000000..2d929c4 --- /dev/null +++ b/schemas/memory-job-snapshot.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/memory-job-snapshot.schema.json", + "title": "memory-job-snapshot", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "identity": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-store-identity.schema.json" + }, + "records": { + "type": "array", + "items": { + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-job.schema.json" + }, + "maxItems": 256 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "schemaVersion", + "identity", + "records", + "sha256" + ], + "additionalProperties": false +} diff --git a/schemas/plugin-plan.schema.json b/schemas/plugin-plan.schema.json new file mode 100644 index 0000000..3143942 --- /dev/null +++ b/schemas/plugin-plan.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/plugin-plan.schema.json", + "title": "plugin-plan", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "pluginId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "template": { + "enum": [ + "command", + "language", + "theme", + "job", + "browser", + "configuration" + ] + }, + "sdkVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[a-z0-9.-]+)?$" + }, + "generationDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "pattern": "^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 1048576 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "path", + "bytes", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 256 + }, + "conflicts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "const": "." + }, + "reason": { + "const": "destination_exists" + } + }, + "required": [ + "path", + "reason" + ], + "additionalProperties": false + }, + "maxItems": 1 + }, + "writes": { + "const": false + } + }, + "required": [ + "schemaVersion", + "directory", + "pluginId", + "template", + "sdkVersion", + "generationDigest", + "files", + "conflicts", + "writes" + ], + "additionalProperties": false +} diff --git a/schemas/project-entry.schema.json b/schemas/project-entry.schema.json index e0655a0..9c50f54 100644 --- a/schemas/project-entry.schema.json +++ b/schemas/project-entry.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-entry.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-entry.schema.json", "title": "project-entry", "type": "object", "properties": { diff --git a/schemas/project-query-capabilities.schema.json b/schemas/project-query-capabilities.schema.json index bc78e79..68af61c 100644 --- a/schemas/project-query-capabilities.schema.json +++ b/schemas/project-query-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-query-capabilities.schema.json", "title": "project-query-capabilities", "type": "object", "properties": { diff --git a/schemas/project-query-options.schema.json b/schemas/project-query-options.schema.json index 8f2bca2..56b7e8e 100644 --- a/schemas/project-query-options.schema.json +++ b/schemas/project-query-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-query-options.schema.json", "title": "project-query-options", "oneOf": [ { diff --git a/schemas/project-query-page.schema.json b/schemas/project-query-page.schema.json index 96835f0..06b73a0 100644 --- a/schemas/project-query-page.schema.json +++ b/schemas/project-query-page.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-query-page.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-query-page.schema.json", "title": "project-query-page", "type": "object", "properties": { diff --git a/schemas/project-snapshot.schema.json b/schemas/project-snapshot.schema.json index 42a7166..93e5f76 100644 --- a/schemas/project-snapshot.schema.json +++ b/schemas/project-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-snapshot.schema.json", "title": "project-snapshot", "type": "object", "properties": { diff --git a/schemas/project-watch-capabilities.schema.json b/schemas/project-watch-capabilities.schema.json index 6c3fb10..6d53918 100644 --- a/schemas/project-watch-capabilities.schema.json +++ b/schemas/project-watch-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-watch-capabilities.schema.json", "title": "project-watch-capabilities", "type": "object", "properties": { diff --git a/schemas/project-watch-event.schema.json b/schemas/project-watch-event.schema.json index 7df6f42..45364b1 100644 --- a/schemas/project-watch-event.schema.json +++ b/schemas/project-watch-event.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-event.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-watch-event.schema.json", "title": "project-watch-event", "type": "object", "properties": { diff --git a/schemas/project-watch-options.schema.json b/schemas/project-watch-options.schema.json index 522639f..4ce6736 100644 --- a/schemas/project-watch-options.schema.json +++ b/schemas/project-watch-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/project-watch-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/project-watch-options.schema.json", "title": "project-watch-options", "type": "object", "properties": { diff --git a/schemas/secret-reference.schema.json b/schemas/secret-reference.schema.json index 243765d..c15c517 100644 --- a/schemas/secret-reference.schema.json +++ b/schemas/secret-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/secret-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/secret-reference.schema.json", "title": "secret-reference", "type": "object", "properties": { diff --git a/schemas/secret-scope.schema.json b/schemas/secret-scope.schema.json index f5e3e43..db693d0 100644 --- a/schemas/secret-scope.schema.json +++ b/schemas/secret-scope.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/secret-scope.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/secret-scope.schema.json", "title": "secret-scope", "type": "object", "properties": { diff --git a/schemas/semantic-legend.schema.json b/schemas/semantic-legend.schema.json index 6814339..d549254 100644 --- a/schemas/semantic-legend.schema.json +++ b/schemas/semantic-legend.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-legend.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/semantic-legend.schema.json", "title": "semantic-legend", "type": "object", "properties": { diff --git a/schemas/semantic-tokens-delta.schema.json b/schemas/semantic-tokens-delta.schema.json index 8634ba8..64fa666 100644 --- a/schemas/semantic-tokens-delta.schema.json +++ b/schemas/semantic-tokens-delta.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-tokens-delta.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/semantic-tokens-delta.schema.json", "title": "semantic-tokens-delta", "type": "object", "properties": { diff --git a/schemas/semantic-tokens.schema.json b/schemas/semantic-tokens.schema.json index eb35c0d..4f5881f 100644 --- a/schemas/semantic-tokens.schema.json +++ b/schemas/semantic-tokens.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/semantic-tokens.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/semantic-tokens.schema.json", "title": "semantic-tokens", "type": "object", "properties": { diff --git a/schemas/settings-definition.schema.json b/schemas/settings-definition.schema.json index dc7509f..79b55aa 100644 --- a/schemas/settings-definition.schema.json +++ b/schemas/settings-definition.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-definition.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/settings-definition.schema.json", "title": "settings-definition", "type": "object", "properties": { diff --git a/schemas/settings-snapshot.schema.json b/schemas/settings-snapshot.schema.json index d5162da..13b3689 100644 --- a/schemas/settings-snapshot.schema.json +++ b/schemas/settings-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/settings-snapshot.schema.json", "title": "settings-snapshot", "type": "object", "properties": { diff --git a/schemas/settings-state.schema.json b/schemas/settings-state.schema.json index 11be777..6753225 100644 --- a/schemas/settings-state.schema.json +++ b/schemas/settings-state.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/settings-state.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/settings-state.schema.json", "title": "settings-state", "type": "object", "properties": { diff --git a/schemas/signature-help.schema.json b/schemas/signature-help.schema.json index 01f3a71..a147c35 100644 --- a/schemas/signature-help.schema.json +++ b/schemas/signature-help.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/signature-help.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/signature-help.schema.json", "title": "signature-help", "oneOf": [ { diff --git a/schemas/stored-artifact-chunk.schema.json b/schemas/stored-artifact-chunk.schema.json index beebcd8..bee9a7b 100644 --- a/schemas/stored-artifact-chunk.schema.json +++ b/schemas/stored-artifact-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-artifact-chunk.schema.json", "title": "stored-artifact-chunk", "type": "object", "properties": { diff --git a/schemas/stored-artifact-list.schema.json b/schemas/stored-artifact-list.schema.json index 4d0c43f..bb89f7a 100644 --- a/schemas/stored-artifact-list.schema.json +++ b/schemas/stored-artifact-list.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-list.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-artifact-list.schema.json", "title": "stored-artifact-list", "type": "object", "properties": { diff --git a/schemas/stored-artifact-reference.schema.json b/schemas/stored-artifact-reference.schema.json index 8de486a..58851c9 100644 --- a/schemas/stored-artifact-reference.schema.json +++ b/schemas/stored-artifact-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-artifact-reference.schema.json", "title": "stored-artifact-reference", "type": "object", "properties": { diff --git a/schemas/stored-artifact.schema.json b/schemas/stored-artifact.schema.json index 474d54e..934b85e 100644 --- a/schemas/stored-artifact.schema.json +++ b/schemas/stored-artifact.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-artifact.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-artifact.schema.json", "title": "stored-artifact", "type": "object", "properties": { diff --git a/schemas/stored-job.schema.json b/schemas/stored-job.schema.json index 17b8004..72e03ae 100644 --- a/schemas/stored-job.schema.json +++ b/schemas/stored-job.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/stored-job.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/stored-job.schema.json", "title": "stored-job", "type": "object", "properties": { @@ -79,7 +79,7 @@ "uniqueItems": true }, "snapshot": { - "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/job-snapshot.schema.json" + "$ref": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/job-snapshot.schema.json" }, "events": { "type": "array", diff --git a/schemas/test-trace.schema.json b/schemas/test-trace.schema.json new file mode 100644 index 0000000..1d1b5d6 --- /dev/null +++ b/schemas/test-trace.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/test-trace.schema.json", + "title": "test-trace", + "type": "object", + "properties": { + "schemaVersion": { + "const": 1 + }, + "seed": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "fixtureVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "fixtureDigest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "minimum": 1, + "maximum": 1024 + }, + "at": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "fixtureId": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "operation": { + "enum": [ + "command", + "language", + "workspace", + "project", + "job", + "binary", + "settings", + "secret", + "backend" + ] + }, + "inputSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "outputSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "previousSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "sequence", + "at", + "fixtureId", + "operation", + "inputSha256", + "outputSha256", + "previousSha256", + "sha256" + ], + "additionalProperties": false + }, + "maxItems": 1024 + }, + "digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "required": [ + "schemaVersion", + "seed", + "fixtureVersion", + "fixtureDigest", + "events", + "digest" + ], + "additionalProperties": false +} diff --git a/schemas/transfer-queue-options.schema.json b/schemas/transfer-queue-options.schema.json index ef4aa75..a2df50f 100644 --- a/schemas/transfer-queue-options.schema.json +++ b/schemas/transfer-queue-options.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/transfer-queue-options.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/transfer-queue-options.schema.json", "title": "transfer-queue-options", "type": "object", "properties": { diff --git a/schemas/transfer-snapshot.schema.json b/schemas/transfer-snapshot.schema.json index 0467ce7..2b02d5e 100644 --- a/schemas/transfer-snapshot.schema.json +++ b/schemas/transfer-snapshot.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/transfer-snapshot.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/transfer-snapshot.schema.json", "title": "transfer-snapshot", "type": "object", "properties": { diff --git a/schemas/upload-capabilities.schema.json b/schemas/upload-capabilities.schema.json index adef461..ab0e08e 100644 --- a/schemas/upload-capabilities.schema.json +++ b/schemas/upload-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/upload-capabilities.schema.json", "title": "upload-capabilities", "type": "object", "properties": { diff --git a/schemas/upload-chunk.schema.json b/schemas/upload-chunk.schema.json index a7415ca..13d1a92 100644 --- a/schemas/upload-chunk.schema.json +++ b/schemas/upload-chunk.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-chunk.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/upload-chunk.schema.json", "title": "upload-chunk", "type": "object", "properties": { diff --git a/schemas/upload-reference.schema.json b/schemas/upload-reference.schema.json index 81d3119..c535ee9 100644 --- a/schemas/upload-reference.schema.json +++ b/schemas/upload-reference.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-reference.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/upload-reference.schema.json", "title": "upload-reference", "type": "object", "properties": { diff --git a/schemas/upload-spec.schema.json b/schemas/upload-spec.schema.json index 67f0f61..f000a6b 100644 --- a/schemas/upload-spec.schema.json +++ b/schemas/upload-spec.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-spec.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/upload-spec.schema.json", "title": "upload-spec", "type": "object", "properties": { diff --git a/schemas/upload-status.schema.json b/schemas/upload-status.schema.json index 59ffdbe..97430af 100644 --- a/schemas/upload-status.schema.json +++ b/schemas/upload-status.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/upload-status.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/upload-status.schema.json", "title": "upload-status", "type": "object", "properties": { diff --git a/schemas/workspace-conditional-file.schema.json b/schemas/workspace-conditional-file.schema.json index 77d18f1..71d0af9 100644 --- a/schemas/workspace-conditional-file.schema.json +++ b/schemas/workspace-conditional-file.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-conditional-file.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-conditional-file.schema.json", "title": "workspace-conditional-file", "oneOf": [ { diff --git a/schemas/workspace-edit-preview.schema.json b/schemas/workspace-edit-preview.schema.json index 50ffb83..c712b87 100644 --- a/schemas/workspace-edit-preview.schema.json +++ b/schemas/workspace-edit-preview.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-preview.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-edit-preview.schema.json", "title": "workspace-edit-preview", "type": "object", "properties": { diff --git a/schemas/workspace-edit-receipt.schema.json b/schemas/workspace-edit-receipt.schema.json index 1fd5818..c3ea446 100644 --- a/schemas/workspace-edit-receipt.schema.json +++ b/schemas/workspace-edit-receipt.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-receipt.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-edit-receipt.schema.json", "title": "workspace-edit-receipt", "type": "object", "properties": { diff --git a/schemas/workspace-edit-record.schema.json b/schemas/workspace-edit-record.schema.json index 92bc050..82ad500 100644 --- a/schemas/workspace-edit-record.schema.json +++ b/schemas/workspace-edit-record.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit-record.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-edit-record.schema.json", "title": "workspace-edit-record", "type": "object", "properties": { diff --git a/schemas/workspace-edit.schema.json b/schemas/workspace-edit.schema.json index 7c9ed1d..cc550f7 100644 --- a/schemas/workspace-edit.schema.json +++ b/schemas/workspace-edit.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-edit.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-edit.schema.json", "title": "workspace-edit", "type": "object", "properties": { diff --git a/schemas/workspace-file-capabilities.schema.json b/schemas/workspace-file-capabilities.schema.json index d0b5cf8..50e5d70 100644 --- a/schemas/workspace-file-capabilities.schema.json +++ b/schemas/workspace-file-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-file-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-file-capabilities.schema.json", "title": "workspace-file-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-file-revision.schema.json b/schemas/workspace-file-revision.schema.json index 485ef46..1530501 100644 --- a/schemas/workspace-file-revision.schema.json +++ b/schemas/workspace-file-revision.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-file-revision.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-file-revision.schema.json", "title": "workspace-file-revision", "type": "object", "properties": { diff --git a/schemas/workspace-project-capabilities.schema.json b/schemas/workspace-project-capabilities.schema.json index a0a0d16..78cf90f 100644 --- a/schemas/workspace-project-capabilities.schema.json +++ b/schemas/workspace-project-capabilities.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-project-capabilities.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-project-capabilities.schema.json", "title": "workspace-project-capabilities", "type": "object", "properties": { diff --git a/schemas/workspace-project-poll.schema.json b/schemas/workspace-project-poll.schema.json index 994bc56..215f8ca 100644 --- a/schemas/workspace-project-poll.schema.json +++ b/schemas/workspace-project-poll.schema.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.12.0/schemas/workspace-project-poll.schema.json", + "$id": "https://github.com/Altifigence/dds-plugin-sdk/blob/v0.13.0/schemas/workspace-project-poll.schema.json", "title": "workspace-project-poll", "type": "object", "properties": { diff --git a/scripts/benchmark-devtools.mjs b/scripts/benchmark-devtools.mjs new file mode 100644 index 0000000..ffc08c7 --- /dev/null +++ b/scripts/benchmark-devtools.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import {performance} from 'node:perf_hooks'; +import {createDiagnosticSession} from '../src/diagnostics.mjs'; +import {createScenarioHost} from '../src/testing.mjs'; +import {definePlugin} from '../src/index.mjs'; + +const iterations=10000,batches=5,median=values=>values.sort((a,b)=>a-b)[Math.floor(values.length/2)];let checksum=0; +const baseline=[],disabled=[],sampled=[],all=[]; +for(let batch=0;batch{checksum++;});else checksum++;} + const elapsed=performance.now()-start;result.push(elapsed);if(session){const report=session.snapshot();assert.ok(report.events.length<=128);assert.equal(report.pending,0);session.dispose();} +} +const manifest={manifestVersion:2,id:'benchmark',name:'Benchmark',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./plugin.mjs',runtime:'workspace',capabilities:['commands'],permissions:[],supportedHosts:['workspace-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}}; +for(let i=0;i<100;i++){const scenario=createScenarioHost({seed:i});await scenario.host.activate(definePlugin(manifest,ctx=>ctx.registerCommand({id:'example',title:'Example'},()=>null)));await scenario.host.executeCommand('benchmark','example',{});await scenario.dispose();scenario.assertClean();} +const results={schemaVersion:1,node:process.version,platform:process.platform,iterations,batches,baselineMedianMs:median(baseline),disabledMedianMs:median(disabled),sampleEvery10MedianMs:median(sampled),allEventsMedianMs:median(all),allEventsOverheadUsPerOperation:1000*(median(all)-median(baseline))/iterations,cleanScenarioLifecycles:100,checksum}; +console.log(JSON.stringify(results,null,2)); diff --git a/scripts/pack-check.mjs b/scripts/pack-check.mjs index 43bc1e5..60d00f8 100644 --- a/scripts/pack-check.mjs +++ b/scripts/pack-check.mjs @@ -43,6 +43,19 @@ try { const diagnosed = JSON.parse(run(process.execPath, [devCli, 'doctor', 'scaffold', '--json'], consumer)); assert.equal(diagnosed.ok, true); run(process.execPath, ['--test', 'plugin.test.mjs'], join(consumer, 'scaffold')); + const generatedTypeScript=join(root,'node_modules','typescript','bin','tsc'); + run(process.execPath,[generatedTypeScript,'--noEmit','-p','tsconfig.json'],join(consumer,'scaffold')); + for(const template of ['language','theme','job','browser','configuration']){ + const name='scaffold-'+template,directory=join(consumer,name); + run(process.execPath,[devCli,'init',name,'--template',template],consumer); + assert.equal(JSON.parse(run(process.execPath,[devCli,'doctor',name,'--json'],consumer)).ok,true); + assert.equal(JSON.parse(run(process.execPath,[devCli,'generate',name,'--check'],consumer)).ok,true); + run(process.execPath,['--test','plugin.test.mjs'],directory); + run(process.execPath,[generatedTypeScript,'--noEmit','-p','tsconfig.json'],directory); + assert.equal(JSON.parse(run(process.execPath,['run.mjs'],directory)).verified,true); + const packedPlugin=JSON.parse(run(process.execPath,[devCli,'pack',name,'--out',name+'-dist'],consumer)); + assert.equal(JSON.parse(run(process.execPath,[devCli,'verify',packedPlugin.archivePath,'--sha256',packedPlugin.artifact.sha256],consumer)).checksumPinned,true); + } for (const job of [false, true]) { const output = run(process.execPath, [devCli, 'dev', 'scaffold', '--trust-local-code', '--command', 'greet', '--input', JSON.stringify({name:'Packed'}), ...(job ? ['--job'] : [])], consumer); assert.ok(output.includes('Hello, Packed!')); assert.ok(output.includes('"ok": true')); @@ -195,6 +208,9 @@ try { `); run(process.execPath, ['workspace-smoke.mjs'], consumer); await cp(join(root, 'tests', 'types', 'consumer.mts'), join(consumer, 'consumer.mts')); + await cp(join(root,'tests','types','development.mts'),join(consumer,'development.mts')); + await cp(join(installed,'examples','development-tools'),join(consumer,'development-example'),{recursive:true}); + assert.match(run(process.execPath,['development-example/run.mjs'],consumer),/pure trace replay and bounded diagnostics verified/); await cp(join(root, 'tests', 'types', 'configuration.mts'), join(consumer, 'configuration.mts')); await cp(join(root, 'tests', 'types', 'localization.mts'), join(consumer, 'localization.mts')); await cp(join(installed, 'examples', 'configuration'), join(consumer, 'configuration-example'), {recursive: true}); diff --git a/src/configuration-operations.mjs b/src/configuration-operations.mjs index cf73654..34c88ff 100644 --- a/src/configuration-operations.mjs +++ b/src/configuration-operations.mjs @@ -1,5 +1,6 @@ import {ErrorCode, PluginSdkError} from './limits.mjs'; import {configurationObject, configurationInteger} from './configuration-values.mjs'; +import {hostRuntime} from './host-runtime.mjs'; export const configurationError = code => new PluginSdkError(code, 'Configuration operation failed'); export function configurationOptions(options = {}) { @@ -10,7 +11,8 @@ export function configurationOptions(options = {}) { } /** Cancellation ends the caller's wait; ignored cancellation still occupies a slot. */ -export function createConfigurationOperations(maximum) { +export function createConfigurationOperations(maximum,runtimeInput) { + const runtime=hostRuntime(runtimeInput); const pending = new Map(); let closed = false; const abort = (controller, code) => {if (!controller.signal.aborted) controller.abort(configurationError(code));}; return Object.freeze({ @@ -26,13 +28,13 @@ export function createConfigurationOperations(maximum) { let rejectAbort; const aborted = new Promise((_, reject) => {rejectAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', rejectAbort, {once: true});}); const expiryFirst = expiresIn !== undefined && expiresIn <= timeoutMs; - const timer = setTimeout(() => abort(controller, expiryFirst ? ErrorCode.PERMISSION_DENIED : ErrorCode.BUDGET_EXCEEDED), Math.min(timeoutMs, expiresIn ?? timeoutMs)); + const timer = runtime.setTimeout(() => abort(controller, expiryFirst ? ErrorCode.PERMISSION_DENIED : ErrorCode.BUDGET_EXCEEDED), Math.min(timeoutMs, expiresIn ?? timeoutMs)); const actual = Promise.resolve().then(() => { if (controller.signal.aborted) throw controller.signal.reason; return operation(controller.signal); }).then(result => {if (controller.signal.aborted) throw controller.signal.reason; return result;}).finally(() => pending.delete(controller)); try {return await Promise.race([actual, aborted]);} - finally {clearTimeout(timer); signal?.removeEventListener('abort', callerAbort); controller.signal.removeEventListener('abort', rejectAbort);} + finally {runtime.clearTimeout(timer); signal?.removeEventListener('abort', callerAbort); controller.signal.removeEventListener('abort', rejectAbort);} }, abort(tag, code = ErrorCode.PERMISSION_DENIED) {for (const [controller, owner] of pending) if (owner === tag) abort(controller, code);}, get pending() {return pending.size;}, diff --git a/src/configuration-values.mjs b/src/configuration-values.mjs index 2879ea8..f78575a 100644 --- a/src/configuration-values.mjs +++ b/src/configuration-values.mjs @@ -9,8 +9,8 @@ export class DataValidationError extends PluginSdkError { } } export function configurationFailure(reason, path = '', budget = false) {throw new DataValidationError(reason, path, budget);} -export function configurationCopy(value, maxBytes = 65_536) { - try {return copyWorkspaceJson(value, {maxBytes, maxDepth: 32, maxNodes: 10_000});} +export function configurationCopy(value, maxBytes = 65_536,maxNodes=10_000) { + try {return copyWorkspaceJson(value, {maxBytes, maxDepth: 32, maxNodes});} catch (error) {configurationFailure(error instanceof WorkspaceError && error.code === 'budget_exceeded' ? 'budget' : 'invalid_json', '', error instanceof WorkspaceError && error.code === 'budget_exceeded');} } export function configurationObject(value, required, optional = [], path = '') { diff --git a/src/dev-files.mjs b/src/dev-files.mjs new file mode 100644 index 0000000..89df26a --- /dev/null +++ b/src/dev-files.mjs @@ -0,0 +1,152 @@ +import * as fs from 'node:fs/promises'; +import {constants} from 'node:fs'; +import path from 'node:path'; +import {createHash, randomUUID} from 'node:crypto'; +import {WINDOWS_DEVICE_COMPONENT} from './patterns.mjs'; + +export const devJson = value => `${JSON.stringify(value, null, 2)}\n`; +export const devHash = value => createHash('sha256').update(value).digest('hex'); +export function devFailure(code, message) {const failure=new Error(message);failure.code=code;return failure;} +export function devOptions(value, keys) { + if(!value||typeof value!=='object'||Array.isArray(value)||![Object.prototype,null].includes(Object.getPrototypeOf(value)))throw devFailure('invalid_options','Invalid development options'); + for(const key of Reflect.ownKeys(value)){ + const d=Object.getOwnPropertyDescriptor(value,key); + if(!keys.includes(key)||!d?.enumerable||!('value'in d))throw devFailure('invalid_options','Invalid development options'); + } +} +const samePath=(a,b)=>process.platform==='win32'?a.toLowerCase()===b.toLowerCase():a===b; +const sameFile=(a,b)=>a.dev===b.dev&&a.ino===b.ino; +export async function realDirectory(directory) { + if(typeof directory!=='string'||!directory||!directory.isWellFormed()||/[\u0000-\u001f]/u.test(directory))throw devFailure('invalid_path','Expected a local directory'); + const absolute=path.resolve(directory),info=await fs.lstat(absolute),real=await fs.realpath(absolute); + if(!info.isDirectory()||info.isSymbolicLink()||!samePath(absolute,real))throw devFailure('unsafe_path','Expected an unlinked real directory'); + return real; +} +export async function newDirectoryPath(directory) { + if(typeof directory!=='string'||!directory||!directory.isWellFormed()||/[\u0000-\u001f]/u.test(directory))throw devFailure('invalid_path','Provide a new plugin directory'); + const absolute=path.resolve(directory),name=path.basename(absolute); + if(!name||/[. ]$/.test(name)||WINDOWS_DEVICE_COMPONENT.test(name))throw devFailure('invalid_path','Invalid destination name'); + return path.join(await realDirectory(path.dirname(absolute)),name); +} +export async function readDevFile(filename, maxBytes=1_048_576) { + const before=await fs.lstat(filename); + if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1||before.size>maxBytes)throw devFailure('unsafe_file','Expected a bounded, unlinked regular development file'); + const handle=await fs.open(filename,constants.O_RDONLY|(constants.O_NOFOLLOW??0)); + try{ + const opened=await handle.stat(); + if(!sameFile(before,opened)||!opened.isFile()||opened.nlink!==1||opened.size>maxBytes)throw devFailure('conflict','Development file changed while opening'); + const buffer=Buffer.alloc(maxBytes+1);let used=0; + while(usedmaxBytes)throw devFailure('budget_exceeded','Development file exceeds its byte limit'); + if(!sameFile(opened,after)||opened.size!==after.size||opened.mtimeMs!==after.mtimeMs||after.nlink!==1)throw devFailure('conflict','Development file changed while reading'); + try{return new TextDecoder('utf-8',{fatal:true}).decode(buffer.subarray(0,used));}catch{throw devFailure('invalid_file','Expected UTF-8 development data');} + }finally{await handle.close();} +} +export function checkedFiles(files) { + if(!(files instanceof Map)||files.size<1||files.size>256)throw devFailure('budget_exceeded','Invalid generated file count'); + const result=new Map();let bytes=0; + for(const [name,text]of [...files].sort(([a],[b])=>ab?1:0)){ + if(typeof name!=='string'||name.length>240||name.includes('\\')||name.split('/').some(part=>!part||part==='.'||part==='..'||!/^[A-Za-z0-9_.-]+$/.test(part)||/[. ]$/.test(part)||WINDOWS_DEVICE_COMPONENT.test(part)))throw devFailure('invalid_path','Invalid generated file path'); + if(typeof text!=='string'||!text.isWellFormed()||Buffer.byteLength(text)>1_048_576)throw devFailure('budget_exceeded','Invalid generated file contents'); + bytes+=Buffer.byteLength(text);if(bytes>4_194_304)throw devFailure('budget_exceeded','Generated project exceeds 4 MiB'); + result.set(name,text); + } + for(const name of result.keys())for(const other of result.keys())if(other!==name&&(other.toLowerCase()===name.toLowerCase()||other.toLowerCase().startsWith(name.toLowerCase()+'/')))throw devFailure('invalid_path','Generated paths collide'); + return result; +} +export function fileInventory(files) {return [...checkedFiles(files)].map(([name,text])=>({path:name,bytes:Buffer.byteLength(text),sha256:devHash(text)}));} +export async function destinationExists(target){try{await fs.lstat(target);return true;}catch(e){if(e.code==='ENOENT')return false;throw e;}} + +/** Claim with mkdir; on failure remove only still-owned created entries. */ +export async function writeNewDirectory(target, input, {write=(handle,text)=>handle.writeFile(text,'utf8')}={}) { + const files=checkedFiles(input),owned=[]; + target=await newDirectoryPath(target); + try{await fs.mkdir(target);}catch(e){if(e.code==='EEXIST')throw devFailure('conflict','Destination already exists; choose a new directory');throw e;} + owned.push({path:target,info:await fs.lstat(target),directory:true}); + const directories=new Set(['']); + try{ + for(const [name,text]of files){ + const parts=name.split('/');parts.pop();let relative=''; + for(const part of parts){relative=relative?relative+'/'+part:part;if(directories.has(relative))continue; + const directory=path.join(target,relative);await fs.mkdir(directory);owned.push({path:directory,info:await fs.lstat(directory),directory:true});directories.add(relative); + } + const filename=path.join(target,name),handle=await fs.open(filename,'wx',0o600); + const entry={path:filename,info:await handle.stat(),directory:false,expected:Buffer.from(text),finished:null};owned.push(entry); + try{await write(handle,text);}finally{try{entry.finished=await handle.stat();}finally{await handle.close();}} + } + return target; + }catch(failure){ + let preserved=false; + for(const item of owned.reverse()){ + try{const current=await fs.lstat(item.path);if(!sameFile(current,item.info)||current.isSymbolicLink()){preserved=true;continue;} + if(item.directory)await fs.rmdir(item.path);else{ + if(!item.finished||current.size!==item.finished.size||current.mtimeMs!==item.finished.mtimeMs||current.nlink!==1||current.size>item.expected.length){preserved=true;continue;} + const actual=await fs.readFile(item.path);if(!actual.equals(item.expected.subarray(0,actual.length))){preserved=true;continue;}await fs.unlink(item.path); + } + }catch(e){if(e.code!=='ENOENT')preserved=true;} + } + const error=devFailure('write_failed',preserved?'Generated write failed; changed or additional entries were preserved for inspection':'Generated write failed; created entries were removed'); + error.cleanupPreserved=preserved;error.ioCode=typeof failure?.code==='string'?failure.code:'write_failed';throw error; + } +} + +export async function inspectGeneratedDirectory(directory, expected) { + const root=await realDirectory(directory),files=checkedFiles(expected),found=[]; + async function visit(relative=''){ + for(const entry of await fs.readdir(path.join(root,relative),{withFileTypes:true})){ + const name=relative?relative+'/'+entry.name:entry.name; + if(entry.isSymbolicLink())throw devFailure('conflict','Generated directory contains a linked entry'); + if(entry.isDirectory()){ + if(![...files.keys()].some(key=>key.startsWith(name+'/')))throw devFailure('conflict','Generated directory contains an unrelated directory'); + await visit(name); + }else if(entry.isFile())found.push(name);else throw devFailure('conflict','Generated directory contains a non-regular entry'); + if(found.length>256)throw devFailure('budget_exceeded','Generated directory entry limit exceeded'); + } + } + await visit();found.sort(); + if(JSON.stringify(found)!==JSON.stringify([...files.keys()].sort()))throw devFailure('conflict','Generated file inventory differs; preserve and inspect local edits'); + for(const [name,text]of files)if(await readDevFile(path.join(root,name))!==text)throw devFailure('conflict','A generated file was edited; preserve and inspect local edits'); + return Object.freeze({root,info:await fs.lstat(root)}); +} +async function removeGeneratedDirectory(directory, files) { + await inspectGeneratedDirectory(directory,files); + const directories=new Set(); + for(const name of files.keys()){ + await fs.unlink(path.join(directory,name));let parent=path.posix.dirname(name); + while(parent!=='.'){directories.add(parent);parent=path.posix.dirname(parent);} + } + for(const name of [...directories].sort((a,b)=>b.length-a.length))await fs.rmdir(path.join(directory,name)); + await fs.rmdir(directory); +} + +/** Cooperative lock + verified backup; unrelated or modified output is never replaced. */ +export async function replaceGeneratedDirectory(project, files, previousFiles, verifySource) { + project=await realDirectory(project);files=checkedFiles(files); + const target=path.join(project,'generated'),lockPath=path.join(project,'.dds-generate.lock'); + let lock;try{lock=await fs.open(lockPath,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw devFailure('conflict','Generation lock exists; inspect a previous interrupted operation');throw e;} + const lockInfo=await lock.stat(),suffix=randomUUID(),stage=path.join(project,'.dds-generation-'+suffix),backup=path.join(project,'.dds-generation-backup-'+suffix); + let stageCreated=false,moved=false; + try{ + await lock.writeFile(devJson({schemaVersion:1,pid:process.pid,stage:path.basename(stage),backup:path.basename(backup)})); + if(previousFiles)await inspectGeneratedDirectory(target,previousFiles); + else if(await destinationExists(target))throw devFailure('conflict','Generated destination already exists'); + await writeNewDirectory(stage,files);stageCreated=true;await verifySource(); + if(previousFiles){await inspectGeneratedDirectory(target,previousFiles);await fs.rename(target,backup);moved=true;} + else if(await destinationExists(target))throw devFailure('conflict','Generated destination appeared during generation'); + try{await fs.rename(stage,target);stageCreated=false;} + catch(e){if(moved&&!await destinationExists(target)){await fs.rename(backup,target);moved=false;}throw e;} + await inspectGeneratedDirectory(target,files); + if(moved){await removeGeneratedDirectory(backup,previousFiles);moved=false;} + return target; + }finally{ + let stagePreserved=false; + try{if(stageCreated)await removeGeneratedDirectory(stage,files);} + catch(e){stagePreserved=true;throw e;} + finally{ + await lock.close(); + // An uncertain publication/cleanup retains the lock for explicit recovery. + if(!moved&&!stagePreserved){const current=await fs.lstat(lockPath);if(sameFile(current,lockInfo)&&!current.isSymbolicLink())await fs.unlink(lockPath);} + } + } +} diff --git a/src/dev-generation.mjs b/src/dev-generation.mjs new file mode 100644 index 0000000..0ef9f77 --- /dev/null +++ b/src/dev-generation.mjs @@ -0,0 +1,127 @@ +import path from 'node:path'; +import {parseManifest,parseCommandDefinition} from './contracts.mjs'; +import {parseSettingsDefinition} from './settings.mjs'; +import {parseLocaleCatalog,validateLocalizedSurfaces} from './localization.mjs'; +import {parseTheme,serializeThemeXml} from './themes.mjs'; +import {configurationCopy,configurationObject,freezeConfiguration} from './configuration-values.mjs'; +import {SDK_VERSION} from './version.mjs'; +import {devJson,devHash,devFailure,devOptions,readDevFile,realDirectory,fileInventory,checkedFiles,destinationExists,inspectGeneratedDirectory,replaceGeneratedDirectory} from './dev-files.mjs'; + +export const PLUGIN_TEMPLATES=Object.freeze(['command','language','theme','job','browser','configuration']); +export const DEVTOOLS_LIMITS=Object.freeze({definitionBytes:262_144,commands:64,generatedFiles:256,generatedBytes:4_194_304}); +const stable=value=>JSON.stringify(value,(_key,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item); +export function parseDevelopmentDefinition(input, manifest) { + const value=configurationCopy(input,DEVTOOLS_LIMITS.definitionBytes); + configurationObject(value,['schemaVersion','template','commands'],['settings','catalog','theme']); + if(value.schemaVersion!==1||!PLUGIN_TEMPLATES.includes(value.template)||!Array.isArray(value.commands)||value.commands.length>64)throw devFailure('invalid_definition','Unsupported development definition'); + const commands=value.commands.map(parseCommandDefinition); + if(new Set(commands.map(c=>c.id)).size!==commands.length)throw devFailure('invalid_definition','Duplicate development command ID'); + const result={schemaVersion:1,template:value.template,commands}; + if(Object.hasOwn(value,'settings'))result.settings=parseSettingsDefinition(value.settings); + if(Object.hasOwn(value,'catalog'))result.catalog=parseLocaleCatalog(value.catalog); + if(Object.hasOwn(value,'theme'))result.theme=parseTheme(value.theme); + if(manifest){ + manifest=parseManifest(manifest); + if(commands.length&&!manifest.capabilities.includes('commands'))throw devFailure('invalid_definition','Development commands require the commands capability'); + if(result.settings&&(result.settings.pluginId!==manifest.id||!manifest.capabilities.includes('settings')||!manifest.permissions.includes('settings.read')))throw devFailure('invalid_definition','Settings definition must match the plugin and declared settings capability/permission'); + } + if(result.catalog)validateLocalizedSurfaces(result.catalog,{...(manifest?{manifest}:{}),commands,...(result.settings?{settings:result.settings}:{})}); + return freezeConfiguration(result); +} +function schemaType(node, mode='output') { + if(node.format==='dds-secret-reference')return 'SecretReference'; + if(node.enum)return node.enum.map(value=>JSON.stringify(value)).join(' | '); + if(node.type==='object'){ + const fields=Object.entries(node.properties).map(([key,value])=>{ + const hasDefault=Object.hasOwn(value,'default'),required=mode==='input'&&hasDefault?false:node.required?.includes(key)||mode==='validated'&&hasDefault; + return `readonly ${JSON.stringify(key)}${required?'':'?'}: ${schemaType(value,mode)};`; + }); + return fields.length?`{ ${fields.join(' ')} }`:'Readonly>'; + } + if(node.type==='array')return `ReadonlyArray<${schemaType(node.items,mode)}>`; + return node.type==='integer'?'number':node.type; +} +function parameterSchema(command) { + if(!command.parameters)return null; + return {schemaVersion:1,schema:{type:'object',additionalProperties:false,properties:Object.fromEntries(command.parameters.map(p=>[p.name,{type:p.type,...(p.choices?{enum:p.choices}:{})}])),required:command.parameters.filter(p=>p.required).map(p=>p.name)}}; +} +function jsonSchemaNode(node, bounded=true,mode='output') { + if(node.format==='dds-secret-reference')return {type:'object',additionalProperties:false,properties:{kind:{const:'dds-secret-reference'},id:{type:'string',pattern:'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'}},required:['kind','id']}; + const result={}; + for(const [key,value]of Object.entries(node)){ + if(key==='display'){result['x-dds-display']=value;continue;} + if(key==='properties')result.properties=Object.fromEntries(Object.entries(value).map(([name,item])=>[name,jsonSchemaNode(item,bounded,mode)])); + else if(key==='items')result.items=jsonSchemaNode(value,bounded,mode); + else if(key==='required'&&mode==='input')result.required=value.filter(name=>!Object.hasOwn(node.properties[name],'default')); + else result[key]=value; + } + if(bounded&&node.type==='string')result.maxLength=Math.min(result.maxLength??16_384,16_384); + if(bounded&&node.type==='array')result.maxItems=Math.min(result.maxItems??256,256); + if(bounded&&node.type==='integer'){result.minimum=Math.max(result.minimum??-Number.MAX_SAFE_INTEGER,-Number.MAX_SAFE_INTEGER);result.maximum=Math.min(result.maximum??Number.MAX_SAFE_INTEGER,Number.MAX_SAFE_INTEGER);} + return result; +} +/** Pure deterministic generation. Runtime validators remain authoritative for shared budgets. */ +export function createGeneratedContracts(manifest, input) { + manifest=parseManifest(manifest);const definition=parseDevelopmentDefinition(input,manifest),files=new Map(); + const sourceSha256=devHash(stable({manifest,definition})); + files.set('contracts.json',devJson(definition)); + files.set('contracts.mjs',`// Generated by DDS Plugin SDK ${SDK_VERSION}; do not edit.\nimport definition from './contracts.json' with {type:'json'};\nimport {parseCommandDefinition} from '@altifigence/dds-plugin-sdk';\nimport {parseSettingsDefinition} from '@altifigence/dds-plugin-sdk/settings';\nimport {parseLocaleCatalog} from '@altifigence/dds-plugin-sdk/localization';\nimport {parseTheme} from '@altifigence/dds-plugin-sdk/themes';\nexport const commands=Object.freeze(Object.fromEntries(definition.commands.map(value=>{const command=parseCommandDefinition(value);return [command.id,command];})));\nexport const settingsDefinition=definition.settings?parseSettingsDefinition(definition.settings):null;\nexport const catalog=definition.catalog?parseLocaleCatalog(definition.catalog):null;\nexport const theme=definition.theme?parseTheme(definition.theme):null;\n`); + const declarations=[`// Generated by DDS Plugin SDK ${SDK_VERSION}; validation constraints remain runtime checks.`,"import type {JsonValue,CommandDefinition} from '@altifigence/dds-plugin-sdk';","import type {SecretReference} from '@altifigence/dds-plugin-sdk/data-schema';","import type {SettingsDefinition} from '@altifigence/dds-plugin-sdk/settings';","import type {LocaleCatalog} from '@altifigence/dds-plugin-sdk/localization';","import type {DdsTheme} from '@altifigence/dds-plugin-sdk/themes';"]; + const maps={CommandInputs:[],ValidatedCommandInputs:[],CommandOutputs:[]}; + for(const command of definition.commands){ + const inputSchema=command.inputSchema??parameterSchema(command),outputSchema=command.outputSchema; + maps.CommandInputs.push(`readonly ${JSON.stringify(command.id)}: ${inputSchema?schemaType(inputSchema.schema,'input'):'JsonValue'};`); + maps.ValidatedCommandInputs.push(`readonly ${JSON.stringify(command.id)}: ${inputSchema?schemaType(inputSchema.schema,'validated'):'JsonValue'};`); + maps.CommandOutputs.push(`readonly ${JSON.stringify(command.id)}: ${outputSchema?schemaType(outputSchema.schema):'JsonValue'};`); + for(const [kind,schema]of [['input',inputSchema],['output',outputSchema]])if(schema)files.set(`schemas/${command.id}-${kind}.schema.json`,devJson({$schema:'https://json-schema.org/draft/2020-12/schema',$id:`urn:dds:${manifest.id}:${command.id}:${kind}`,title:command.title+' '+kind,...jsonSchemaNode(schema.schema,kind==='output'||!!command.inputSchema,kind)})); + } + for(const [name,fields]of Object.entries(maps))declarations.push(`export interface ${name} { ${fields.join(' ')} }`); + declarations.push('export const commands: Readonly<{ '+definition.commands.map(c=>`readonly ${JSON.stringify(c.id)}: CommandDefinition;`).join(' ')+' }>;'); + declarations.push(`export const settingsDefinition: ${definition.settings?'SettingsDefinition':'null'};`,`export const catalog: ${definition.catalog?'LocaleCatalog':'null'};`,`export const theme: ${definition.theme?'DdsTheme':'null'};`); + const settings=definition.settings?.settings??{}; + declarations.push('export interface SettingsValues { '+Object.entries(settings).map(([name,item])=>`readonly ${JSON.stringify(name)}${Object.hasOwn(item.schema.schema,'default')?'':'?'}: ${schemaType(item.schema.schema,'validated')};`).join(' ')+' }'); + if(definition.settings)files.set('schemas/settings.schema.json',devJson({$schema:'https://json-schema.org/draft/2020-12/schema',$id:`urn:dds:${manifest.id}:settings`,type:'object',additionalProperties:false,properties:Object.fromEntries(Object.entries(settings).map(([name,item])=>[name,jsonSchemaNode(item.schema.schema)])),required:Object.entries(settings).filter(([,item])=>Object.hasOwn(item.schema.schema,'default')).map(([name])=>name)})); + if(definition.theme)files.set('theme.xml',serializeThemeXml(definition.theme)); + files.set('contracts.d.mts',declarations.join('\n')+'\n'); + const receipt={schemaVersion:1,sdkVersion:SDK_VERSION,sourceSha256,files:fileInventory(files)}; + receipt.digest=devHash(stable(receipt));files.set('generation.json',devJson(receipt)); + return {definition,files:checkedFiles(files),receipt:freezeConfiguration(receipt)}; +} +async function sourceFiles(root){ + const manifestText=await readDevFile(path.join(root,'plugin.json'),65_536),definitionText=await readDevFile(path.join(root,'dds-dev.json'),DEVTOOLS_LIMITS.definitionBytes); + return {manifest:parseManifest(JSON.parse(manifestText)),definition:JSON.parse(definitionText),bytesHash:devHash(stable([manifestText,definitionText]))}; +} +async function previousGeneration(root) { + const directory=await realDirectory(path.join(root,'generated')),text=await readDevFile(path.join(directory,'generation.json'),131_072),receipt=JSON.parse(text); + configurationObject(receipt,['schemaVersion','sdkVersion','sourceSha256','files','digest']); + if(receipt.schemaVersion!==1||typeof receipt.sdkVersion!=='string'||!/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(receipt.sdkVersion)||!Array.isArray(receipt.files)||receipt.files.length>255||!['sourceSha256','digest'].every(key=>/^[0-9a-f]{64}$/.test(receipt[key])))throw devFailure('invalid_generation','Invalid generated receipt'); + const {digest,...body}=receipt;if(devHash(stable(body))!==digest)throw devFailure('conflict','Generated receipt digest is invalid'); + const files=new Map(); + for(const item of receipt.files){ + configurationObject(item,['path','bytes','sha256']); + if(typeof item.path!=='string'||! /^(?:contracts\.(?:mjs|d\.mts|json)|theme\.xml|schemas\/(?:settings|[a-z][a-z0-9._-]*-(?:input|output))\.schema\.json)$/.test(item.path)||files.has(item.path)||!Number.isSafeInteger(item.bytes)||item.bytes<0||item.bytes>1_048_576||!/^[a-f0-9]{64}$/.test(item.sha256))throw devFailure('invalid_generation','Invalid generated file receipt'); + const content=await readDevFile(path.join(directory,item.path)); + if(Buffer.byteLength(content)!==item.bytes||devHash(content)!==item.sha256)throw devFailure('conflict','A generated file was edited; preserve and inspect local edits');files.set(item.path,content); + } + files.set('generation.json',text);await inspectGeneratedDirectory(directory,files);return {receipt,files}; +} +export async function generatePluginContracts(directory,options={}) { + devOptions(options,['dryRun','check','expectedDigest']); + for(const key of ['dryRun','check'])if(options[key]!==undefined&&typeof options[key]!=='boolean')throw devFailure('invalid_options','Invalid generation option'); + if(options.dryRun&&options.check)throw devFailure('invalid_options','Choose dryRun or check'); + if(options.expectedDigest!==undefined&&!/^[a-f0-9]{64}$/.test(options.expectedDigest))throw devFailure('invalid_options','Expected a SHA-256 generation digest'); + const root=await realDirectory(directory),source=await sourceFiles(root),generated=createGeneratedContracts(source.manifest,source.definition),target=path.join(root,'generated'); + let previous=null,conflict=null; + if(await destinationExists(target))try{previous=await previousGeneration(root);}catch(e){conflict=e.code??'invalid_generation';} + const current=!!previous&&previous.receipt.digest===generated.receipt.digest; + const report={schemaVersion:1,directory:root,sdkVersion:SDK_VERSION,digest:generated.receipt.digest,previousDigest:previous?.receipt.digest??null,current,written:false,files:fileInventory(generated.files),conflicts:conflict?[conflict]:[]}; + if(options.dryRun)return freezeConfiguration(report); + if(options.check)return freezeConfiguration({...report,ok:current&&!conflict}); + if(conflict)throw devFailure('conflict','Generated files differ from their receipt; preserve local edits'); + if(current&&options.expectedDigest===undefined)return freezeConfiguration({...report,ok:true}); + if(previous&&options.expectedDigest!==previous.receipt.digest)throw devFailure('conflict','Replacing generated files requires their exact expectedDigest'); + if(!previous&&options.expectedDigest!==undefined)throw devFailure('conflict','No previous generation matches expectedDigest'); + if(current)return freezeConfiguration({...report,ok:true}); + await replaceGeneratedDirectory(root,generated.files,previous?.files,async()=>{if((await sourceFiles(root)).bytesHash!==source.bytesHash)throw devFailure('conflict','Development source changed before generation');}); + return freezeConfiguration({...report,ok:true,current:true,written:true}); +} diff --git a/src/dev-scaffolds.mjs b/src/dev-scaffolds.mjs new file mode 100644 index 0000000..955117d --- /dev/null +++ b/src/dev-scaffolds.mjs @@ -0,0 +1,212 @@ +import * as fs from 'node:fs/promises'; +import path from 'node:path'; +import {parseManifest} from './contracts.mjs'; +import {SDK_VERSION} from './version.mjs'; +import {createGeneratedContracts,PLUGIN_TEMPLATES} from './dev-generation.mjs'; +import {devJson,devOptions,devFailure,readDevFile,newDirectoryPath,destinationExists,fileInventory,checkedFiles,writeNewDirectory} from './dev-files.mjs'; +import {freezeConfiguration} from './configuration-values.mjs'; + +const sdkImport="import {definePlugin} from '@altifigence/dds-plugin-sdk';\nimport manifest from './plugin.json' with {type:'json'};\nimport {commands} from './generated/contracts.mjs';\n"; +const greet={id:'greet',title:'Say hello',parameters:[{name:'name',label:'Your name',type:'string',required:true}]}; +const theme={name:'My Ocean',colors:{light:{backdrop:'#dbe8ef',navigation:'#edf5f8',tool:'#f3f8fb',main:'#ffffff',text:'#183b4e',muted:'#486477'},dark:{backdrop:'#0c1922',navigation:'#102430',tool:'#17303e',main:'#1c3949',text:'#e8f4fb',muted:'#adcadb'}}}; +function definitions(template,id){ + const definition={schemaVersion:1,template,commands:template==='language'?[]:template==='theme'?[{id:'inspect-theme',title:'Inspect theme'}]:[greet]}; + if(template==='theme')definition.theme=theme; + if(template==='configuration'){ + definition.commands=[{id:'greet',title:'Summarize values',display:{labelKey:'greet',accessibility:{nameKey:'greet',shortcut:'Control+Enter'}},inputSchema:{schemaVersion:1,schema:{type:'object',additionalProperties:false,properties:{name:{type:'string',minLength:1,maxLength:64},values:{type:'array',maxItems:8,items:{type:'number',minimum:0,maximum:100},default:[1,2]}},required:['name','values']}},outputSchema:{schemaVersion:1,schema:{type:'object',additionalProperties:false,properties:{message:{type:'string',maxLength:256},total:{type:'number'}},required:['message','total']}}}]; + definition.settings={schemaVersion:1,pluginId:id,version:1,settings:{multiplier:{schema:{schemaVersion:1,schema:{type:'integer',minimum:1,maximum:10,default:2}},scopes:['user','workspace'],display:{labelKey:'count',accessibility:{nameKey:'count'}}}}}; + definition.catalog={schemaVersion:1,defaultLocale:'en',messages:{en:{greet:'Summarize',count:'Multiplier',done:'Total {count}'},ko:{greet:'합계 계산',count:'배수',done:'합계 {count}'}}}; + } + return definition; +} +function pluginSource(template){ + if(template==='language')return `import {definePlugin,createDiagnosticsResult,createLanguageResult} from '@altifigence/dds-plugin-sdk'; +import manifest from './plugin.json' with {type:'json'}; +export default definePlugin(manifest, context => { + context.registerDiagnosticsProvider({languages:['template']},{provideDiagnostics(request,{signal}){ + signal.throwIfAborted();const diagnostics=[]; + request.snapshot.text.split(/\\r\\n|\\n|\\r/).forEach((line,index)=>{const at=line.indexOf('TODO');if(at>=0)diagnostics.push({range:{start:{line:index,character:at},end:{line:index,character:at+4}},severity:'info',code:'todo',message:'Resolve the TODO.'});}); + return createDiagnosticsResult(request,diagnostics); + }}); + context.registerLanguageProvider('hover',{languages:['template']},{provide(request){return createLanguageResult(request,{text:'Template language provider'});}}); + context.registerLanguageProvider('completion',{languages:['template']},{provide(request){return createLanguageResult(request,[{label:'sample',insertText:'sample'}]);}}); +}); +`; + if(template==='theme')return sdkImport+`import {theme} from './generated/contracts.mjs'; +export default definePlugin(manifest,context=>context.registerCommand(commands['inspect-theme'],()=>({themeName:theme.name,light:theme.colors.light.main,dark:theme.colors.dark.main}))); +`; + if(template==='configuration')return sdkImport+`export {settingsDefinition,catalog} from './generated/contracts.mjs'; +export default definePlugin(manifest,context=>context.registerCommand(commands.greet,async(input,{signal})=>{ + signal.throwIfAborted();const snapshot=await context.settings.read({signal}); + return {message:'Hello, '+input.name+'!',total:input.values.reduce((sum,value)=>sum+value,0)*snapshot.values.multiplier}; +})); +`; + if(template==='job')return sdkImport+`export default definePlugin(manifest,context=>context.registerCommand(commands.greet,async(input,{signal,job})=>{ + if(!job)throw new Error('Start this command as a job'); + for(let i=1;i<=3;i++){signal.throwIfAborted();job.reportProgress({completed:i,total:3});job.log('info','Completed synthetic step '+i);await Promise.resolve();} + return {message:'Hello, '+input.name+'!',pluginId:context.pluginId}; +})); +`; + return sdkImport+`export default definePlugin(manifest,context=>context.registerCommand(commands.greet,(input,{signal,job})=>{ + signal.throwIfAborted();job?.reportProgress({completed:1,total:1,message:'Greeting ready'});job?.log('info','Greeting command completed'); + return {message:'Hello, '+input.name+'!',pluginId:context.pluginId}; +})); +`; +} +function exerciseSource(template){ + const grants=template==='language'?['document.read','diagnostics.publish','language.provide']:template==='configuration'?['settings.read']:[]; + let action; + if(template==='language')action=`const document={uri:'memory:///template.txt',languageId:'template',modelVersion:1,workspaceRevision:'one',text:'TODO sample'}; + host.setDocument(document);assert.equal((await host.requestDiagnostics()).diagnostics.length,1); + assert.equal((await host.requestLanguage('hover',{position:{line:0,character:5}})).data.text,'Template language provider'); + assert.equal((await host.requestLanguage('completion',{position:{line:0,character:5}})).data[0].insertText,'sample'); + host.setDocument({...document,modelVersion:2,text:'Done'});assert.equal((await host.requestDiagnostics()).diagnostics.length,0);`; + else if(template==='theme')action=`assert.equal((await host.executeCommand(plugin.manifest.id,'inspect-theme',{})).themeName,theme.name); + assert.equal(await readFile(new URL('./generated/theme.xml',import.meta.url),'utf8'),serializeThemeXml(theme));`; + else if(template==='configuration')action=`assert.equal((await host.executeCommand(plugin.manifest.id,'greet',{name:'Developer'})).total,6); + settings.update({workspaceId:'template-project',scope:'workspace',expectedRevision:0,values:{multiplier:3}}); + assert.equal((await host.executeCommand(plugin.manifest.id,'greet',{name:'Developer'})).total,9); + await assert.rejects(host.executeCommand(plugin.manifest.id,'greet',{name:''}),{code:'invalid_contract'});`; + else if(template==='job')action=`const id=crypto.randomUUID();host.startCommandJob(plugin.manifest.id,'greet',{name:'Developer'},{jobId:id}); + let result;for(let i=0;i<100;i++){result=host.getJob(id);if(result.state!=='running')break;await new Promise(resolve=>setTimeout(resolve,5));} + assert.equal(result.state,'succeeded');assert.equal(result.result.message,'Hello, Developer!'); + assert.equal(host.getJobEvents(id).events.filter(e=>e.kind==='log').length,3);`; + else action=`assert.deepEqual(await host.executeCommand(plugin.manifest.id,'greet',{name:'Developer'}),{message:'Hello, Developer!',pluginId:plugin.manifest.id}); + await assert.rejects(host.executeCommand(plugin.manifest.id,'greet',{}),{code:'invalid_contract'});`; + return `import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; +import {createPluginHost} from '@altifigence/dds-plugin-sdk'; +import {createSettingsStore} from '@altifigence/dds-plugin-sdk/settings'; +import {serializeThemeXml} from '@altifigence/dds-plugin-sdk/themes'; +import plugin from './plugin.mjs'; +import {settingsDefinition,theme} from './generated/contracts.mjs'; +export async function exercise(){ + const settings=settingsDefinition?createSettingsStore(settingsDefinition):null; + const host=createPluginHost({scope:{projectId:'template-project',sessionId:'template-session'},grants:${JSON.stringify(grants)},jobs:true,settings:settings?{[plugin.manifest.id]:settings}:{}}); + try{await host.activate(plugin); + ${action} + return {template:${JSON.stringify(template)},verified:true}; + }finally{host.dispose();settings?.dispose();} +} +`; +} +const browserHtml=` + +DDS plugin example + +

DDS plugin example

This form executes the local example plugin in this browser.

+
+ + +Ctrl+Enter runs the same command.
+
Ready
+ + +`; +const browserModule=`import {createPluginHost} from '@altifigence/dds-plugin-sdk'; +import {createSettingsStore} from '@altifigence/dds-plugin-sdk/settings'; +import {resolveMessage} from '@altifigence/dds-plugin-sdk/localization'; +import plugin from './plugin.mjs'; +import {settingsDefinition,catalog} from './generated/contracts.mjs'; +const settings=settingsDefinition?createSettingsStore(settingsDefinition):null; +const host=createPluginHost({scope:{projectId:'template-project',sessionId:'browser-session'},grants:settings?['settings.read']:[],settings:settings?{[plugin.manifest.id]:settings}:{}}); +await host.activate(plugin); +const form=document.querySelector('#form'),output=document.querySelector('#result'),button=document.querySelector('#run'),locale=document.querySelector('#locale'); +document.querySelector('#multiplier-label').hidden=!settings;document.querySelector('#locale-label').hidden=!catalog; +function localize(){document.documentElement.lang=locale.value;if(catalog)button.textContent=resolveMessage(catalog,locale.value,'greet').text;} +locale.addEventListener('change',localize);localize();let running=false; +form.addEventListener('submit',async event=>{event.preventDefault();if(running)return;running=true;button.disabled=true; + try{if(settings){const snapshot=settings.read('template-project');settings.update({workspaceId:'template-project',scope:'workspace',expectedRevision:snapshot.revision,values:{multiplier:Number(document.querySelector('#multiplier').value)}});} + const result=await host.executeCommand(plugin.manifest.id,'greet',{name:document.querySelector('#name').value}); + output.textContent=catalog?resolveMessage(catalog,locale.value,'done',{count:result.total}).text:JSON.stringify(result,null,2); + }catch(error){output.textContent='Failed: '+(error.code??'invalid_contract');}finally{running=false;button.disabled=false;} +}); +document.addEventListener('keydown',event=>{if(event.ctrlKey&&event.key==='Enter'){event.preventDefault();form.requestSubmit();}}); +window.addEventListener('pagehide',()=>{host.dispose();settings?.dispose();},{once:true}); +`; +function browserServer(files){return `import {createServer} from 'node:http'; +import {readFile,lstat} from 'node:fs/promises'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; +const root=path.dirname(fileURLToPath(import.meta.url)),sdk=path.join(path.dirname(createRequire(import.meta.url).resolve('@altifigence/dds-plugin-sdk/package.json')),'src'); +const allowed=new Set(${JSON.stringify(files)});let origin; +const server=createServer(async(req,res)=>{ + try{if(req.method!=='GET'||req.headers.host!==new URL(origin).host||req.headers.origin&&req.headers.origin!==origin){res.writeHead(403).end();return;} + const pathname=new URL(req.url,origin).pathname;let filename; + if(/^\\/sdk\\/[a-z][a-z0-9.-]*\\.mjs$/.test(pathname))filename=path.join(sdk,pathname.slice(5)); + else{const relative=pathname==='/'?'browser.html':pathname.slice(1);if(!allowed.has(relative)){res.writeHead(404).end();return;}filename=path.join(root,relative);} + const info=await lstat(filename);if(!info.isFile()||info.isSymbolicLink()||info.size>1_048_576){res.writeHead(404).end();return;} + const body=await readFile(filename);res.writeHead(200,{'Content-Type':filename.endsWith('.html')?'text/html; charset=utf-8':filename.endsWith('.json')?'application/json':'text/javascript; charset=utf-8','Content-Security-Policy':"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",'X-Content-Type-Options':'nosniff','Cache-Control':'no-store'});res.end(body); + }catch{res.writeHead(404).end();} +}); +await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});origin='http://127.0.0.1:'+server.address().port;console.log(origin+'/'); +const stop=()=>{server.closeAllConnections();server.close();};process.once('SIGINT',stop);process.once('SIGTERM',stop); +`; +} +export async function scaffoldContents(directory,options={}) { + devOptions(options,['id','publisher','name','template','license','licenseFile','dryRun']); + if(options.dryRun!==undefined&&typeof options.dryRun!=='boolean')throw devFailure('invalid_options','Invalid dryRun option'); + const target=await newDirectoryPath(directory),template=options.template??'command',license=options.license??'Apache-2.0'; + if(!PLUGIN_TEMPLATES.includes(template))throw devFailure('invalid_options','Unknown plugin template'); + if(!['Apache-2.0','LicenseRef-Proprietary'].includes(license)||license==='LicenseRef-Proprietary'&&typeof options.licenseFile!=='string'||license==='Apache-2.0'&&options.licenseFile!==undefined)throw devFailure('invalid_options','Choose Apache-2.0 or LicenseRef-Proprietary with an explicit licenseFile'); + const language=template==='language',configuration=template==='configuration'; + const manifest=parseManifest({manifestVersion:2,id:options.id??path.basename(target),publisher:options.publisher??'example',name:options.name??'My DDS Plugin',version:'0.1.0',protocolVersion:1,entry:'./plugin.mjs',runtime:language||template==='browser'?'ui':'workspace',capabilities:language?['diagnostics','hover','completion']:configuration?['commands','settings']:['commands'],permissions:language?['document.read','diagnostics.publish','language.provide']:configuration?['settings.read']:[],supportedHosts:['test-host','workspace-host'],license,source:{visibility:license==='Apache-2.0'?'open':'closed',licenseFile:'LICENSE'}}); + if(`@${manifest.publisher}/${manifest.id}`.length>214)throw devFailure('invalid_options','Publisher and plugin ID exceed the package name limit'); + const inheritedLicense=await fs.readFile(new URL('../LICENSE',import.meta.url),'utf8'),notice=await fs.readFile(new URL('../NOTICE',import.meta.url),'utf8'); + const selectedLicense=license==='Apache-2.0'?inheritedLicense:await readDevFile(path.resolve(options.licenseFile),65_536); + if(!selectedLicense.trim()||selectedLicense.includes('\0'))throw devFailure('invalid_license','Provide the actual nonempty publisher license text'); + const definition=definitions(template,manifest.id),generated=createGeneratedContracts(manifest,definition),files=new Map([ + ['plugin.json',devJson(manifest)],['dds-dev.json',devJson(definition)],['plugin.mjs',pluginSource(template)],['exercise.mjs',exerciseSource(template)], + ['run.mjs',"import {exercise} from './exercise.mjs';\nconsole.log(JSON.stringify(await exercise()));\n"], + ['plugin.test.mjs',"import test from 'node:test';\nimport {exercise} from './exercise.mjs';\ntest('the generated plugin satisfies its declared contracts',exercise);\n"], + ['LICENSE',selectedLicense],['LICENSE.sdk',inheritedLicense],['NOTICE',notice],['.gitignore','node_modules/\ndist/\n.dds-generate.lock\n.dds-generation-*/\n'], + ['tsconfig.json',devJson({compilerOptions:{target:'ES2022',module:'NodeNext',moduleResolution:'NodeNext',strict:true,noEmit:true,skipLibCheck:false},include:['typecheck.mts','generated/*.d.mts']})], + ['typecheck.mts',`import {commands,type CommandInputs,type CommandOutputs,type SettingsValues} from './generated/contracts.mjs';\n${definition.commands.length?`const input: CommandInputs[${JSON.stringify(definition.commands[0].id)}] = ${template==='theme'?'{}':JSON.stringify({name:'Developer'})};\nvoid input;`:''}void commands;\nexport type OutputMap = CommandOutputs;\nexport type Settings = SettingsValues;\n`], + ]); + for(const [name,text]of generated.files)files.set('generated/'+name,text); + if(template==='browser'||configuration){files.set('browser.html',browserHtml);files.set('browser.mjs',browserModule);files.set('serve.mjs',browserServer(['browser.html','browser.mjs','plugin.json','plugin.mjs','generated/contracts.json','generated/contracts.mjs']));} + const packageFiles=[...files.keys()].filter(name=>!['.gitignore','plugin.test.mjs','exercise.mjs','run.mjs','tsconfig.json','typecheck.mts','serve.mjs'].includes(name)); + files.set('dds-package.json',devJson({schemaVersion:1,files:packageFiles})); + files.set('disclosure.json',devJson({schemaVersion:1,publisher:manifest.publisher,pluginId:manifest.id,pluginVersion:manifest.version,license:manifest.license,sourceVisibility:manifest.source.visibility,supportUrl:'https://example.com/support',dataUse:language?'Processes the supplied synthetic document in memory and returns literal diagnostics and language suggestions.':configuration?'Uses the supplied name and numbers with an operator-mounted memory setting. No network or file access.':template==='theme'?'Returns bundled theme metadata. The generated XML is imported separately by the user.':'Uses the supplied name for a greeting. No workspace reads, external backends or persisted input.',backends:[]})); + const distribution=JSON.parse(files.get('dds-package.json'));distribution.files.push('disclosure.json');files.set('dds-package.json',devJson(distribution)); + const scripts={test:'node --test plugin.test.mjs',typecheck:'tsc --project tsconfig.json',doctor:'dds-plugin doctor .',generate:'dds-plugin generate .','check:generated':'dds-plugin generate . --check',example:'node run.mjs',dev:'dds-plugin dev . --trust-local-code --watch','validate:plugin':'dds-plugin validate .','pack:plugin':'dds-plugin pack . --out dist'}; + if(template==='browser'||configuration)scripts.browser='node serve.mjs'; + files.set('package.json',devJson({name:`@${manifest.publisher}/${manifest.id}`,version:manifest.version,private:true,type:'module',scripts,dependencies:{'@altifigence/dds-plugin-sdk':`https://github.com/Altifigence/dds-plugin-sdk/releases/download/v${SDK_VERSION}/altifigence-dds-plugin-sdk-${SDK_VERSION}.tgz`},devDependencies:{typescript:'5.9.3'}})); + const command=template==='theme'?'inspect-theme':'greet'; + files.set('README.md',`# ${manifest.name} + +Template: ${template}. Generated with DDS Plugin SDK ${SDK_VERSION}. + +Run these commands deliberately; generation itself installs or executes nothing: + +\`\`\`sh +npm install --ignore-scripts +npm run doctor +npm test +npm run typecheck +npm run example +${template==='browser'||configuration?'npm run browser\n':''}\`\`\` + +${language?'The example grants document.read, diagnostics.publish and language.provide to this local teaching-language plugin. It does not start an external language server.':template==='theme'?'The command inspects the bundled palette. Import generated/theme.xml through the normal DDS theme UI when desired; generating it does not apply a theme.':configuration?'The example explicitly grants settings.read and mounts an in-memory store. Edit its selected user/workspace values in the example host; generation never discovers credentials.':template==='job'?'The example starts a job explicitly and checks its logs, progress and terminal success. It does not launch external tools.':'The example executes a command in a local SDK host with no workspace or backend grants.'} + +${!language&&!configuration?`For trusted local command development: \`npx --no-install dds-plugin dev . --trust-local-code ${template==='job'?'--job ':''}--command ${command} --input '${template==='theme'?'{}':'{"name":"World"}'}'\`.\n`:''} +Edit dds-dev.json for command/schema/settings/catalog/theme metadata. Run \`npm run check:generated\` to detect drift. \`dds-plugin generate . --dry-run\` reports the current and proposed digests. To replace unchanged SDK-generated files, supply their previous digest with \`--expected-digest\`. Edited or additional files inside generated/ are preserved and cause a conflict; save them elsewhere deliberately before regenerating. The generator never merges edits or executes metadata. Add newly generated schemas to the explicit dds-package.json file list; doctor detects missing distribution entries. + +Generated contracts.d.mts describes raw and validated inputs, outputs and effective setting values. Runtime validators still enforce numeric, string, complexity and byte limits. JSON schemas are structural projections; aggregate limits and authorization require SDK checks. + +Review publisher/support information and disclosure.json before distribution. The selected publisher license is in LICENSE; inherited SDK/scaffold Apache-2.0 terms remain in LICENSE.sdk and NOTICE. The proprietary option uses the caller's supplied terms and does not conceal distributed JavaScript. No third-party runtime binaries are copied; TypeScript is a separate pinned development dependency. + +Use \`npm run pack:plugin\` to build the allowlisted plugin archive. Local examples execute trusted code in process; the dev child is not an OS sandbox. Browser examples bind only 127.0.0.1 and serve the declared demo files plus installed public SDK modules. Stop the example with Ctrl+C. +`); + return {target,manifest,template,files:checkedFiles(files),generationDigest:generated.receipt.digest}; +} +export async function planPlugin(directory,options={}) { + const plan=await scaffoldContents(directory,options),exists=await destinationExists(plan.target); + return freezeConfiguration({schemaVersion:1,directory:plan.target,pluginId:plan.manifest.id,template:plan.template,sdkVersion:SDK_VERSION,generationDigest:plan.generationDigest,files:fileInventory(plan.files),conflicts:exists?[{path:'.',reason:'destination_exists'}]:[],writes:false}); +} +export async function initPlugin(directory,options={}) { + if(options&&Object.getOwnPropertyDescriptor(options,'dryRun')?.value===true)return planPlugin(directory,options); + const plan=await scaffoldContents(directory,options);await writeNewDirectory(plan.target,plan.files); + return freezeConfiguration({directory:plan.target,pluginId:plan.manifest.id,template:plan.template,sdkVersion:SDK_VERSION,generationDigest:plan.generationDigest,files:[...plan.files.keys()],next:['npm install --ignore-scripts','npm run doctor','npm test','npm run typecheck','npm run example']}); +} diff --git a/src/dev-worker.mjs b/src/dev-worker.mjs index 3aa161e..26ec305 100644 --- a/src/dev-worker.mjs +++ b/src/dev-worker.mjs @@ -3,14 +3,21 @@ import path from 'node:path'; import {pathToFileURL} from 'node:url'; import {isDeepStrictEqual} from 'node:util'; import {createPluginHost, parseManifest} from './index.mjs'; +import * as inspector from 'node:inspector'; +import {createDiagnosticSession,profileHost} from './diagnostics.mjs'; // Remain alive until the parent terminates this process tree after completion. // This also lets the parent clean up children started during activation. const keepAlive = setInterval(() => {}, 60_000); process.once('disconnect', () => {clearInterval(keepAlive); process.exit(1);}); -process.once('message', async ({directory, command, input, job, timeoutMs}) => { - const host = createPluginHost({jobs: true}); let ok = false; +process.once('message', async ({directory, command, input, job, timeoutMs,profile=false,debug=false,debugWait=false}) => { + const diagnostics=createDiagnosticSession({enabled:profile}),originalHost=createPluginHost({jobs:true}),host=profile?profileHost(originalHost,diagnostics):originalHost;let ok = false; try { + if(debug){ + inspector.open(0,'127.0.0.1',false); + if(process.connected)await new Promise((resolve,reject)=>process.send({type:'debug',url:inspector.url()},error=>error?reject(error):resolve())); + if(debugWait)inspector.waitForDebugger(); + } const manifest = JSON.parse(await readFile(path.join(directory, 'plugin.json'), 'utf8')); const module = await import(pathToFileURL(path.join(directory, manifest.entry)).href); if (!isDeepStrictEqual(module.default?.manifest, parseManifest(manifest))) throw new Error('Entry manifest must match plugin.json'); @@ -31,6 +38,8 @@ process.once('message', async ({directory, command, input, job, timeoutMs}) => { finally { host.dispose(); await Promise.all([new Promise(resolve => process.stdout.write('', resolve)), new Promise(resolve => process.stderr.write('', resolve))]); - if (process.connected) process.send({type: 'complete', ok}); + const report=profile?diagnostics.snapshot():undefined;diagnostics.dispose(); + // The owning parent ends the inspector with this child, including attached or paused sessions. + if (process.connected) process.send({type: 'complete', ok,...(profile?{profile:report}:{})}); } }); diff --git a/src/development-schemas.mjs b/src/development-schemas.mjs new file mode 100644 index 0000000..085e2bd --- /dev/null +++ b/src/development-schemas.mjs @@ -0,0 +1,27 @@ +import {SDK_VERSION} from './version.mjs'; +import {ErrorCode} from './limits.mjs'; +import {DIAGNOSTIC_OPERATIONS,DIAGNOSTIC_RESOURCES,DIAGNOSTIC_LIMITS} from './diagnostics.mjs'; +import {TRACE_OPERATIONS} from './testing-trace.mjs'; + +const base=`https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; +const ref=name=>({$ref:base+name+'.schema.json'}); +const object=(properties,required=Object.keys(properties))=>({type:'object',properties,required,additionalProperties:false}); +const integer=(max=Number.MAX_SAFE_INTEGER,min=0)=>({type:'integer',minimum:min,maximum:max}); +const text=(maxLength=256)=>({type:'string',minLength:1,maxLength}); +const nullable=schema=>({anyOf:[schema,{type:'null'}]}); +const list=(items,maxItems)=>({type:'array',items,maxItems}); +const sha={type:'string',pattern:'^[a-f0-9]{64}$'},version={type:'string',pattern:'^[0-9]+\\.[0-9]+\\.[0-9]+(?:-[a-z0-9.-]+)?$'}; +const template={enum:['command','language','theme','job','browser','configuration']}; +const inventory=object({path:{...text(240),pattern:'^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$'},bytes:integer(1048576),sha256:sha}); +const traceEvent=object({sequence:integer(1024,1),at:integer(),fixtureId:{type:'string',pattern:'^[a-z][a-z0-9-]{0,63}$'},operation:{enum:TRACE_OPERATIONS},inputSha256:sha,outputSha256:sha,previousSha256:sha,sha256:sha}); +const spanId={type:'string',pattern:'^d[1-9][0-9]{0,15}$'},count=integer(DIAGNOSTIC_LIMITS.count); +const diagnosticEvent=object({id:spanId,parentId:nullable(spanId),operation:{enum:DIAGNOSTIC_OPERATIONS},startedAt:{type:'number',minimum:0,maximum:Number.MAX_SAFE_INTEGER},durationMs:{type:'number',minimum:0,maximum:Number.MAX_SAFE_INTEGER},status:{enum:['ok','cancelled','error','disposed']},inputBytes:nullable(count),outputBytes:nullable(count),queueDepth:nullable(count),resources:object(Object.fromEntries(DIAGNOSTIC_RESOURCES.map(key=>[key,count])),[]),errorCode:{enum:Object.values(ErrorCode)}},['id','parentId','operation','startedAt','durationMs','status','inputBytes','outputBytes','queueDepth','resources']); +export const DEVELOPMENT_SCHEMAS=Object.freeze({ + 'development-definition':object({schemaVersion:{const:1},template,commands:list(ref('command'),64),settings:ref('settings-definition'),catalog:ref('locale-catalog'),theme:ref('theme')},['schemaVersion','template','commands']), + 'generation-receipt':object({schemaVersion:{const:1},sdkVersion:version,sourceSha256:sha,files:list(inventory,255),digest:sha}), + 'generation-report':object({schemaVersion:{const:1},directory:text(32768),sdkVersion:version,digest:sha,previousDigest:nullable(sha),current:{type:'boolean'},written:{type:'boolean'},files:list(inventory,256),conflicts:list(text(128),1),ok:{type:'boolean'}},['schemaVersion','directory','sdkVersion','digest','previousDigest','current','written','files','conflicts']), + 'plugin-plan':object({schemaVersion:{const:1},directory:text(32768),pluginId:text(128),template,sdkVersion:version,generationDigest:sha,files:list(inventory,256),conflicts:list(object({path:{const:'.'},reason:{const:'destination_exists'}}),1),writes:{const:false}}), + 'test-trace':object({schemaVersion:{const:1},seed:integer(0xffffffff),fixtureVersion:integer(Number.MAX_SAFE_INTEGER,1),fixtureDigest:sha,events:list(traceEvent,1024),digest:sha}), + 'memory-job-snapshot':object({schemaVersion:{const:1},identity:ref('job-store-identity'),records:list(ref('stored-job'),256),sha256:sha}), + 'diagnostic-report':object({schemaVersion:{const:1},enabled:{type:'boolean'},sampleEvery:integer(1000,1),retentionMs:integer(300000,1),capacity:integer(256,1),attempted:count,sampled:count,dropped:count,pending:integer(64),events:list(diagnosticEvent,256)}), +}); diff --git a/src/devtools.d.mts b/src/devtools.d.mts index 2aba77e..b4650ba 100644 --- a/src/devtools.d.mts +++ b/src/devtools.d.mts @@ -1,7 +1,24 @@ -import type {JsonValue} from './index.mjs'; +import type {JsonValue, PluginManifest, CommandDefinition} from './index.mjs'; +import type {SettingsDefinition} from './settings.mjs'; +import type {LocaleCatalog} from './localization.mjs'; +import type {DdsTheme} from './themes.mjs'; +export type PluginTemplate = 'command'|'language'|'theme'|'job'|'browser'|'configuration'; +export const PLUGIN_TEMPLATES: readonly PluginTemplate[]; +export const DEVTOOLS_LIMITS: Readonly<{definitionBytes:262144;commands:64;generatedFiles:256;generatedBytes:4194304}>; +export interface DevelopmentDefinition {readonly schemaVersion:1;readonly template:PluginTemplate;readonly commands:readonly CommandDefinition[];readonly settings?:SettingsDefinition;readonly catalog?:LocaleCatalog;readonly theme?:DdsTheme;} +export function parseDevelopmentDefinition(input:unknown,manifest?:PluginManifest):DevelopmentDefinition; +export interface InitPluginOptions {readonly id?:string;readonly publisher?:string;readonly name?:string;readonly template?:PluginTemplate;readonly license?:'Apache-2.0'|'LicenseRef-Proprietary';readonly licenseFile?:string;readonly dryRun?:boolean;} +export interface GeneratedFile {readonly path:string;readonly bytes:number;readonly sha256:string;} +export interface PluginPlan {readonly schemaVersion:1;readonly directory:string;readonly pluginId:string;readonly template:PluginTemplate;readonly sdkVersion:string;readonly generationDigest:string;readonly files:readonly GeneratedFile[];readonly conflicts:readonly {readonly path:'.';readonly reason:'destination_exists'}[];readonly writes:false;} +export interface InitializedPlugin {readonly directory:string;readonly pluginId:string;readonly template:PluginTemplate;readonly sdkVersion:string;readonly generationDigest:string;readonly files:readonly string[];readonly next:readonly string[];} +export function planPlugin(directory:string,options?:InitPluginOptions):Promise; +export interface GenerationReport {readonly schemaVersion:1;readonly directory:string;readonly sdkVersion:string;readonly digest:string;readonly previousDigest:string|null;readonly current:boolean;readonly written:boolean;readonly files:readonly GeneratedFile[];readonly conflicts:readonly string[];readonly ok?:boolean;} +export function generatePluginContracts(directory:string,options?:{readonly dryRun?:boolean;readonly check?:boolean;readonly expectedDigest?:string}):Promise; export interface DoctorCheck {readonly id:string;readonly status:'pass'|'warning'|'error';readonly message:string;readonly fix?:string;} export interface DoctorReport {readonly ok:boolean;readonly directory:string;readonly checks:readonly DoctorCheck[];} -export function initPlugin(directory:string, options?:{readonly id?:string;readonly publisher?:string;readonly name?:string}):Promise<{readonly directory:string;readonly pluginId:string;readonly sdkVersion:string;readonly files:readonly string[];readonly next:readonly string[]}>; +export function initPlugin(directory:string, options:InitPluginOptions & {readonly dryRun:true}):Promise; +export function initPlugin(directory:string, options?:InitPluginOptions & {readonly dryRun?:false}):Promise; +export function initPlugin(directory:string, options:InitPluginOptions):Promise; export function doctorPlugin(directory:string):Promise; -export type DevEvent = {readonly type:'start';readonly run:number;readonly pid:number|undefined} | {readonly type:'output';readonly stream:'stdout'|'stderr';readonly text:string} | {readonly type:'validation';readonly ok:false;readonly checks:readonly DoctorCheck[]} | {readonly type:'complete'|'timeout';readonly run:number;readonly ok:boolean}; -export function runPluginDev(directory:string, options:{readonly trustLocalCode:true;readonly watch?:boolean;readonly command?:string;readonly input?:JsonValue;readonly job?:boolean;readonly timeoutMs?:number;readonly signal?:AbortSignal;readonly onEvent?:(event:DevEvent)=>void}):Promise<{readonly ok:boolean;readonly runs:number;readonly stopped:boolean}>; +export type DevEvent = {readonly type:'start';readonly run:number;readonly pid:number|undefined} | {readonly type:'output';readonly stream:'stdout'|'stderr';readonly text:string} | {readonly type:'validation';readonly ok:false;readonly checks:readonly DoctorCheck[]} | {readonly type:'complete'|'timeout';readonly run:number;readonly ok:boolean} | {readonly type:'debug';readonly run:number;readonly pid:number|undefined;readonly url:string;readonly expiresInMs:number} | {readonly type:'profile';readonly run:number;readonly report:import('./diagnostics.mjs').DiagnosticReport}; +export function runPluginDev(directory:string, options:{readonly trustLocalCode:true;readonly watch?:boolean;readonly command?:string;readonly input?:JsonValue;readonly job?:boolean;readonly timeoutMs?:number;readonly profile?:boolean;readonly debug?:boolean;readonly debugWait?:boolean;readonly signal?:AbortSignal;readonly onEvent?:(event:DevEvent)=>void}):Promise<{readonly ok:boolean;readonly runs:number;readonly stopped:boolean}>; diff --git a/src/devtools.mjs b/src/devtools.mjs index 53507c2..c2c6ee1 100644 --- a/src/devtools.mjs +++ b/src/devtools.mjs @@ -7,10 +7,12 @@ import {spawn} from 'node:child_process'; import {parseManifest, parseJsonValue} from './index.mjs'; import {validatePluginPackage} from './publishing.mjs'; import {JOB_LIMITS} from './jobs.mjs'; +import {generatePluginContracts,parseDevelopmentDefinition,DEVTOOLS_LIMITS} from './dev-generation.mjs'; +import {readDevFile,destinationExists} from './dev-files.mjs'; +import {parseDiagnosticReport} from './diagnostics.mjs'; const sdk = JSON.parse(await fs.readFile(new URL('../package.json', import.meta.url), 'utf8')); const sdkLine = sdk.version.split('.').slice(0, 2).join('.'); -const json = value => `${JSON.stringify(value, null, 2)}\n`; const error = message => new Error(message); const nodeSupported = version => /^(?:22|24)\./.test(version); const plainOptions = (options, allowed) => { @@ -28,32 +30,8 @@ async function readJson(file) { return JSON.parse(contents); } -/** Creates only a new directory; never installs dependencies or executes plugin code. */ -export async function initPlugin(directory, options = {}) { - plainOptions(options, ['id', 'publisher', 'name']); - if (typeof directory !== 'string' || !directory) throw error('Provide a new plugin directory'); - const target = path.resolve(directory); - const manifest = parseManifest({manifestVersion: 2, id: options.id ?? path.basename(target), publisher: options.publisher ?? 'example', name: options.name ?? 'My DDS Plugin', version: '0.1.0', protocolVersion: 1, entry: './plugin.mjs', runtime: 'workspace', capabilities: ['commands'], permissions: [], supportedHosts: ['test-host', 'workspace-host'], license: 'Apache-2.0', source: {visibility: 'open', licenseFile: 'LICENSE'}}); - if (`@${manifest.publisher}/${manifest.id}`.length > 214) throw error('Publisher and plugin ID exceed the package name limit'); - // Prepare and validate all contents before claiming the destination. - const files = new Map([ - ['plugin.json', json(manifest)], - ['plugin.mjs', `import {definePlugin} from '@altifigence/dds-plugin-sdk';\nimport manifest from './plugin.json' with {type: 'json'};\n\nexport default definePlugin(manifest, context => context.registerCommand({\n id: 'greet', title: 'Say hello',\n parameters: [{name: 'name', label: 'Your name', type: 'string', required: true}],\n}, (input, {signal, job}) => {\n signal.throwIfAborted();\n job?.reportProgress({completed: 1, total: 1, message: 'Greeting ready'});\n job?.log('info', 'Greeting command completed');\n return {message: 'Hello, ' + input.name + '!', pluginId: context.pluginId};\n}));\n`], - ['plugin.test.mjs', `import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport {createPluginHost} from '@altifigence/dds-plugin-sdk';\nimport plugin from './plugin.mjs';\n\ntest('greet returns the supplied name', async t => {\n const host = createPluginHost();\n t.after(() => host.dispose());\n await host.activate(plugin);\n assert.deepEqual(await host.executeCommand(plugin.manifest.id, 'greet', {name: 'Developer'}), {message: 'Hello, Developer!', pluginId: plugin.manifest.id});\n await assert.rejects(host.executeCommand(plugin.manifest.id, 'greet', {}), {code: 'invalid_contract'});\n});\n`], - ['dds-package.json', json({schemaVersion: 1, files: ['plugin.json', 'plugin.mjs', 'disclosure.json', 'LICENSE', 'NOTICE']})], - ['disclosure.json', json({schemaVersion: 1, publisher: manifest.publisher, pluginId: manifest.id, pluginVersion: manifest.version, license: manifest.license, sourceVisibility: 'open', supportUrl: 'https://example.com/support', dataUse: 'The greet command uses the supplied name to return a greeting. It does not read workspace files, use external backends, or store input.', backends: []})], - ['package.json', json({name: `@${manifest.publisher}/${manifest.id}`, version: manifest.version, private: true, type: 'module', scripts: {test: 'node --test plugin.test.mjs', doctor: 'dds-plugin doctor .', dev: 'dds-plugin dev . --trust-local-code --watch', 'validate:plugin': 'dds-plugin validate .', 'pack:plugin': 'dds-plugin pack . --out dist'}, dependencies: {'@altifigence/dds-plugin-sdk': `https://github.com/Altifigence/dds-plugin-sdk/releases/download/v${sdk.version}/altifigence-dds-plugin-sdk-${sdk.version}.tgz`}})], - ['.gitignore', 'node_modules/\ndist/\n'], - ['README.md', `# ${manifest.name}\n\nInstall the pinned SDK with \`npm install --ignore-scripts\`, then run \`npm run doctor\`.\n\nRun trusted local code with:\n\n\`\`\`sh\nnpx --no-install dds-plugin dev . --trust-local-code --command greet --input '{"name":"World"}'\nnpx --no-install dds-plugin dev . --trust-local-code --job --command greet --input '{"name":"World"}'\nnpm run dev\n\`\`\`\n\nThe dev host has no workspace or backend grants. Watch mode restarts a child process after a declared package file changes; Ctrl+C stops it. This is trusted local execution, not a security sandbox.\n\nBefore distribution, replace example publisher/support metadata, review the Apache-2.0 license and NOTICE inherited from this scaffold, and update disclosure.json for any behavior changes. Add each distributable file to dds-package.json.\n\nRun \`npm run pack:plugin\` to build a release archive. The development package.json is private; the pack command generates inert consumer metadata without install scripts.\n`], - ['LICENSE', await fs.readFile(new URL('../LICENSE', import.meta.url), 'utf8')], - ['NOTICE', await fs.readFile(new URL('../NOTICE', import.meta.url), 'utf8')], - ]); - const parent = await fs.lstat(path.dirname(target)); - if (!parent.isDirectory() || parent.isSymbolicLink()) throw error('Destination parent must be a real existing directory'); - try {await fs.mkdir(target);} catch (failure) {if (failure.code === 'EEXIST') throw error('Destination already exists; choose a new directory'); throw failure;} - for (const [name, text] of files) await fs.writeFile(path.join(target, name), text, {flag: 'wx'}); - return Object.freeze({directory: target, pluginId: manifest.id, sdkVersion: sdk.version, files: Object.freeze([...files.keys()]), next: ['npm install --ignore-scripts', 'npm run doctor', 'npm run dev']}); -} +export {initPlugin,planPlugin} from './dev-scaffolds.mjs'; +export {generatePluginContracts,parseDevelopmentDefinition,PLUGIN_TEMPLATES,DEVTOOLS_LIMITS} from './dev-generation.mjs'; /** Reads metadata only. No import, install, npm script, or plugin entry execution. */ export async function doctorPlugin(directory) { @@ -66,9 +44,29 @@ export async function doctorPlugin(directory) { check('package', 'pass', `${report.pluginId}: manifest, disclosure, license and allowlisted files are valid.`); const manifest = await readJson(path.join(root, 'plugin.json')); const disclosure = await readJson(path.join(root, 'disclosure.json')); + const parsed=parseManifest(manifest); + check('manifest','pass',`${parsed.manifestVersion === 1 ? 'ui' : parsed.runtime} plugin; ${parsed.capabilities.length} capabilities, ${parsed.permissions.length} requested permissions.`); + check('permissions',parsed.permissions.length?'warning':'pass',parsed.permissions.length?`Operator grants are required: ${parsed.permissions.join(', ')}.`:'This plugin requests no host permissions.'); + if(await destinationExists(path.join(root,'dds-dev.json'))){ + const definition=parseDevelopmentDefinition(JSON.parse(await readDevFile(path.join(root,'dds-dev.json'),DEVTOOLS_LIMITS.definitionBytes)),parsed); + check('development-definition','pass',`${definition.template} template; ${definition.commands.length} declared commands.`); + const generated=await generatePluginContracts(root,{check:true}); + check('generated',generated.ok?'pass':'error',generated.ok?'Generated runtime metadata, declarations and schemas match the current SDK/source.':'Generated files are missing, modified or out of date.','Review dds-plugin generate . --dry-run. Preserve manual changes before replacing exact generated output.'); + const packaged=new Set(report.files.map(file=>file.path)); + const missing=generated.files.filter(file=>!packaged.has('generated/'+file.path)); + check('generated-package',missing.length?'error':'pass',missing.length?'Generated files are absent from the package allowlist.':'All generated files are included in the package allowlist.','Review generated output and add its files to dds-package.json before distribution.'); + await readDevFile(path.join(root,'LICENSE.sdk'),65_536);await readDevFile(path.join(root,'NOTICE'),65_536); + check('scaffold-notices','pass','Publisher license and inherited SDK/scaffold notices are present.'); + } if (manifest.publisher === 'example' || new URL(disclosure.supportUrl).hostname === 'example.com') check('publisher', 'warning', 'Example publisher or support metadata is still present.', 'Set your publisher and support URL before distribution.'); else check('publisher', 'pass', 'Publisher and support metadata are customized.'); } catch (failure) {check('package', 'error', failure instanceof Error ? failure.message.slice(0, 2_048) : 'Package metadata is invalid.', 'Correct plugin.json, disclosure.json and dds-package.json; do not add private files.');} + try{ + const project=await readJson(path.join(root,'package.json')); + if(project.dependencies?.[sdk.name]!==`https://github.com/Altifigence/dds-plugin-sdk/releases/download/v${sdk.version}/altifigence-dds-plugin-sdk-${sdk.version}.tgz`)check('dependency-pin','warning','The project does not pin this exact official SDK archive.','Review the selected SDK source and version before installing.'); + else check('dependency-pin','pass','The development dependency pins this exact official SDK archive.'); + if(project.devDependencies?.typescript!==undefined)check('typescript','pass',`The project explicitly declares a separate TypeScript development dependency (${project.devDependencies.typescript}).`); + }catch{check('project','error','Project package.json is missing or invalid.','Provide inert development metadata; doctor does not install or run scripts.');} try { const packagePath = createRequire(path.join(root, 'package.json')).resolve('@altifigence/dds-plugin-sdk/package.json'); const installed = await readJson(packagePath); @@ -111,10 +109,12 @@ function stopTree(child) { /** Runs explicitly trusted plugin code in a replaceable child; not an OS sandbox. */ export async function runPluginDev(directory, options = {}) { - plainOptions(options, ['trustLocalCode', 'watch', 'command', 'input', 'job', 'timeoutMs', 'signal', 'onEvent']); + plainOptions(options, ['trustLocalCode', 'watch', 'command', 'input', 'job', 'timeoutMs', 'signal', 'onEvent','profile','debug','debugWait']); if (options.trustLocalCode !== true) throw error('dev executes local plugin code; pass --trust-local-code for a plugin you trust.'); - for (const key of ['watch', 'job']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw error(`Invalid ${key} option`); - const {watch = false, command, job = false, timeoutMs = 30_000, signal, onEvent = () => {}} = options; + for (const key of ['watch', 'job','profile','debug','debugWait']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw error(`Invalid ${key} option`); + const {watch = false, command, job = false, timeoutMs = 30_000, signal, onEvent = () => {},profile=false,debug=false,debugWait=false} = options; + if(debugWait&&!debug)throw error('debugWait requires debug.'); + if(debug&&timeoutMs>30000)throw error('Debug sessions are limited to 30000 ms per child.'); const input = parseJsonValue(options.input ?? {}); if (command !== undefined && (typeof command !== 'string' || !/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/.test(command))) throw error('Invalid command ID'); if (job && !command) throw error('--job requires --command'); @@ -157,7 +157,8 @@ export async function runPluginDev(directory, options = {}) { await refreshWatches(); if (stopping) return; changing = false; runs++; const owned = spawn(process.execPath, [fileURLToPath(new URL('./dev-worker.mjs', import.meta.url))], {cwd: root, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], detached: process.platform !== 'win32', windowsHide: true, shell: false, env: {...process.env, NODE_OPTIONS: ''}}); - child = owned; let bytes = 0, completed = false; + child = owned; let bytes = 0, completed = false,debugReported=false; + const debugDeadline=Date.now()+timeoutMs; const output = (stream, chunk) => { if (child !== owned || stopping) return; bytes += chunk.length; if (bytes > 262_144) {void finish(error('Development output exceeded 256 KiB')); return;} @@ -166,7 +167,13 @@ export async function runPluginDev(directory, options = {}) { owned.stdout.on('data', chunk => output('stdout', chunk)); owned.stderr.on('data', chunk => output('stderr', chunk)); owned.once('error', failure => {void finish(error(`Development process failed: ${failure.code ?? 'spawn'}`));}); owned.on('message', message => { - if (child !== owned || stopping || completed || !message || message.type !== 'complete' || typeof message.ok !== 'boolean') return; + if (child !== owned || stopping || completed || !message) return; + if(message.type==='debug'&&debug&&!debugReported){ + try{const url=new URL(message.url);if(url.protocol!=='ws:'||url.hostname!=='127.0.0.1'||!url.port||url.username||url.password||url.search||url.hash||!/^\/[a-f0-9-]{36}$/.test(url.pathname))throw error('Invalid development debug endpoint');debugReported=true;emit({type:'debug',run:runs,pid:owned.pid,url:url.href,expiresInMs:Math.max(0,debugDeadline-Date.now())});} + catch(failure){void finish(failure);}return; + } + if(message.type!=='complete'||typeof message.ok!=='boolean')return; + if(profile){try{emit({type:'profile',run:runs,report:parseDiagnosticReport(message.profile)});}catch(failure){void finish(failure);return;}} completed = true; lastOk = message.ok; clearTimeout(deadline); tail = tail.then(async () => {if (child !== owned || stopping) return; await stopTree(owned); emit({type: 'complete', run: runs, ok: lastOk}); if (!watch) await finish();}).catch(finish); }); @@ -176,8 +183,8 @@ export async function runPluginDev(directory, options = {}) { tail = tail.then(async () => {if(child !== owned || stopping)return; await stopTree(owned); emit({type: 'complete', run: runs, ok: false}); if (!watch) await finish();}).catch(finish); } }); - deadline = setTimeout(() => {if (child === owned && !stopping) {lastOk = false; completed = true; tail = tail.then(async () => {if(child !== owned || stopping)return; await stopTree(owned); emit({type: 'timeout', run: runs, ok: false}); if (!watch) await finish();}).catch(finish);}}, timeoutMs + 1_000); - owned.send({directory: root, command, input, job, timeoutMs}, failure => {if (failure && !stopping && !completed) void finish(error('Could not configure the development process'));}); + deadline = setTimeout(() => {if (child === owned && !stopping) {lastOk = false; completed = true; tail = tail.then(async () => {if(child !== owned || stopping)return; await stopTree(owned); emit({type: 'timeout', run: runs, ok: false}); if (!watch) await finish();}).catch(finish);}}, debug?timeoutMs:timeoutMs + 1_000); + owned.send({directory: root, command, input, job, timeoutMs,profile,debug,debugWait}, failure => {if (failure && !stopping && !completed) void finish(error('Could not configure the development process'));}); emit({type: 'start', run: runs, pid: owned.pid}); }; const onAbort = () => {void finish();}; signal?.addEventListener('abort', onAbort, {once: true}); diff --git a/src/diagnostics.d.mts b/src/diagnostics.d.mts new file mode 100644 index 0000000..381c364 --- /dev/null +++ b/src/diagnostics.d.mts @@ -0,0 +1,18 @@ +import type {PluginHost,Disposable,ErrorCode as ErrorCodes} from './index.mjs'; +export type DiagnosticErrorCode=typeof ErrorCodes[keyof typeof ErrorCodes]; +export type DiagnosticOperation='activation'|'command'|'language'|'transport'|'workspace'|'project'|'job'|'binary'|'settings'|'secret'|'backend'|'dispose'; +export type DiagnosticResource='plugins'|'commands'|'registrations'|'pendingActivations'|'pendingOperations'|'providerOperations'|'pendingJobs'|'binaryOperations'|'pendingCheckpoints'|'retainedJobs'|'resolveTokens'|'semanticEntries'|'timers'|'subscriptions'|'fileHandles'|'processes'|'memoryBytes'; +export type DiagnosticStatus='ok'|'cancelled'|'error'|'disposed'; +export const DIAGNOSTIC_OPERATIONS:readonly DiagnosticOperation[]; +export const DIAGNOSTIC_RESOURCES:readonly DiagnosticResource[]; +export const DIAGNOSTIC_LIMITS:Readonly<{events:256;pending:64;exportBytes:262144;retentionMs:300000;sampleEvery:1000;count:1000000000000}>; +export interface DiagnosticStart {readonly parentId?:string;readonly inputBytes?:number;readonly queueDepth?:number;} +export interface DiagnosticEnd {readonly status?:DiagnosticStatus;readonly errorCode?:DiagnosticErrorCode;readonly outputBytes?:number;readonly resources?:Readonly>>;} +export interface DiagnosticSpan {readonly id:string|null;end(metadata?:DiagnosticEnd):void;} +export interface DiagnosticEvent {readonly id:string;readonly parentId:string|null;readonly operation:DiagnosticOperation;readonly startedAt:number;readonly durationMs:number;readonly status:DiagnosticStatus;readonly errorCode?:DiagnosticErrorCode;readonly inputBytes:number|null;readonly outputBytes:number|null;readonly queueDepth:number|null;readonly resources:Readonly>>;} +export interface DiagnosticReport {readonly schemaVersion:1;readonly enabled:boolean;readonly sampleEvery:number;readonly retentionMs:number;readonly capacity:number;readonly attempted:number;readonly sampled:number;readonly dropped:number;readonly pending:number;readonly events:readonly DiagnosticEvent[];} +export interface DiagnosticSession extends Disposable {readonly enabled:boolean;begin(operation:DiagnosticOperation,metadata?:DiagnosticStart):DiagnosticSpan;measure(operation:DiagnosticOperation,callback:(id:string|null)=>T,metadata?:DiagnosticStart):T;snapshot():DiagnosticReport;clear():void;} +export function createDiagnosticSession(options?:{readonly enabled?:boolean;readonly now?:()=>number;readonly sampleEvery?:number;readonly retentionMs?:number;readonly capacity?:number}):DiagnosticSession; +export function parseDiagnosticReport(input:unknown):DiagnosticReport; +export function profileHost(host:PluginHost,session:DiagnosticSession):PluginHost; +export function profileTransportany>(transport:F,session:DiagnosticSession):F; diff --git a/src/diagnostics.mjs b/src/diagnostics.mjs new file mode 100644 index 0000000..489eae2 --- /dev/null +++ b/src/diagnostics.mjs @@ -0,0 +1,75 @@ +import {ErrorCode,PluginSdkError} from './limits.mjs'; +import {configurationObject as object,configurationCopy as copy,configurationInteger as integer,freezeConfiguration as freeze} from './configuration-values.mjs'; + +export const DIAGNOSTIC_LIMITS=Object.freeze({events:256,pending:64,exportBytes:262144,retentionMs:300000,sampleEvery:1000,count:1_000_000_000_000}); +export const DIAGNOSTIC_OPERATIONS=Object.freeze(['activation','command','language','transport','workspace','project','job','binary','settings','secret','backend','dispose']); +export const DIAGNOSTIC_RESOURCES=Object.freeze(['plugins','commands','registrations','pendingActivations','pendingOperations','providerOperations','pendingJobs','binaryOperations','pendingCheckpoints','retainedJobs','resolveTokens','semanticEntries','timers','subscriptions','fileHandles','processes','memoryBytes']); +const statuses=Object.freeze(['ok','cancelled','error','disposed']); +const fail=()=>{throw new PluginSdkError(ErrorCode.INVALID_CONTRACT,'Invalid local diagnostic data');}; +const timestamp=value=>{if(typeof value!=='number'||!Number.isFinite(value)||value<0||value>Number.MAX_SAFE_INTEGER)fail();return value;}; +const spanId=value=>{if(typeof value!=='string'||!/^d[1-9][0-9]{0,15}$/.test(value))fail();}; +const safeCode=error=>{try{const value=error&&typeof error==='object'?Object.getOwnPropertyDescriptor(error,'code')?.value:undefined;return Object.values(ErrorCode).includes(value)?value:ErrorCode.PROVIDER_FAILED;}catch{return ErrorCode.PROVIDER_FAILED;}}; +function metrics(input,ending=false){ + object(input,[],ending?['status','errorCode','outputBytes','resources']:['parentId','inputBytes','queueDepth']); + if(input.parentId!==undefined)spanId(input.parentId); + for(const key of ['inputBytes','outputBytes','queueDepth'])if(input[key]!==undefined)integer(input[key],DIAGNOSTIC_LIMITS.count); + if(input.status!==undefined&&!statuses.includes(input.status)||input.errorCode!==undefined&&!Object.values(ErrorCode).includes(input.errorCode))fail(); + if(input.resources!==undefined){object(input.resources,[],DIAGNOSTIC_RESOURCES);for(const value of Object.values(input.resources))integer(value,DIAGNOSTIC_LIMITS.count);} + return copy(input,8192); +} +export function parseDiagnosticReport(input){ + const value=copy(input,DIAGNOSTIC_LIMITS.exportBytes);object(value,['schemaVersion','enabled','sampleEvery','retentionMs','capacity','attempted','sampled','dropped','pending','events']); + if(value.schemaVersion!==1||typeof value.enabled!=='boolean')fail();integer(value.sampleEvery,DIAGNOSTIC_LIMITS.sampleEvery,1);integer(value.retentionMs,DIAGNOSTIC_LIMITS.retentionMs,1);integer(value.capacity,DIAGNOSTIC_LIMITS.events,1); + for(const key of ['attempted','sampled','dropped'])integer(value[key],DIAGNOSTIC_LIMITS.count);integer(value.pending,DIAGNOSTIC_LIMITS.pending); + if(!Array.isArray(value.events)||value.events.length>value.capacity||value.sampled>value.attempted||value.dropped>value.attempted||!value.enabled&&(value.attempted||value.sampled||value.dropped||value.pending||value.events.length))fail(); + const ids=new Set();for(const event of value.events){object(event,['id','parentId','operation','startedAt','durationMs','status','inputBytes','outputBytes','queueDepth','resources'],['errorCode']);spanId(event.id);if(ids.has(event.id))fail();ids.add(event.id);if(event.parentId!==null)spanId(event.parentId);if(!DIAGNOSTIC_OPERATIONS.includes(event.operation)||!statuses.includes(event.status))fail();timestamp(event.startedAt);timestamp(event.durationMs);for(const key of ['inputBytes','outputBytes','queueDepth'])if(event[key]!==null)integer(event[key],DIAGNOSTIC_LIMITS.count);object(event.resources,[],DIAGNOSTIC_RESOURCES);for(const count of Object.values(event.resources))integer(count,DIAGNOSTIC_LIMITS.count);if(event.errorCode!==undefined&&!Object.values(ErrorCode).includes(event.errorCode))fail();} + return freeze(value); +} +/** Opt-in bounded local metadata. No automatic payload, path, error-body or network collection. */ +export function createDiagnosticSession(options={}){ + object(options,[],['enabled','now','sampleEvery','retentionMs','capacity']);const enabled=options.enabled??false;if(typeof enabled!=='boolean'||options.now!==undefined&&typeof options.now!=='function')fail(); + const sampleEvery=integer(options.sampleEvery??1,DIAGNOSTIC_LIMITS.sampleEvery,1),retentionMs=integer(options.retentionMs??60000,DIAGNOSTIC_LIMITS.retentionMs,1),capacity=integer(options.capacity??128,DIAGNOSTIC_LIMITS.events,1); + const now=options.now??(()=>globalThis.performance?.now()??Date.now()),events=[],live=new Map();let attempted=0,sampled=0,dropped=0,sequence=0,closed=false,lastTime=0; + const count=n=>Math.min(DIAGNOSTIC_LIMITS.count,n+1); + const time=()=>{const result=timestamp(now());lastTime=Math.max(lastTime,result);return lastTime;}; + const prune=at=>{while(events.length&&events[0].finishedAt<=at-retentionMs)events.shift();for(const [id,entry]of live)if(entry.startedAt<=at-retentionMs){live.delete(id);dropped=count(dropped);}}; + const noop=Object.freeze({id:null,end(){}}); + function begin(operation,input={}){ + if(!DIAGNOSTIC_OPERATIONS.includes(operation))fail();const checked=metrics(input);if(!enabled||closed)return noop; + const at=time();prune(at);attempted=count(attempted);if((attempted-1)%sampleEvery)return noop; + if(live.size>=DIAGNOSTIC_LIMITS.pending){dropped=count(dropped);return noop;} + if(checked.parentId!==undefined&&!live.has(checked.parentId))fail(); + const id='d'+(++sequence),entry={id,parentId:checked.parentId??null,operation,startedAt:at,inputBytes:checked.inputBytes??null,queueDepth:checked.queueDepth??null};live.set(id,entry);sampled=count(sampled); + return Object.freeze({id,end(output={}){const checked=metrics(output,true);if(!live.has(id))return;const at=time();prune(at);if(!live.has(id))return;live.delete(id); + const event=freeze({...entry,durationMs:at-entry.startedAt,status:checked.status??'ok',outputBytes:checked.outputBytes??null,resources:checked.resources??{},...(checked.errorCode?{errorCode:checked.errorCode}:{})}); + if(events.length>=capacity)events.shift();events.push({event,finishedAt:at}); + }}); + } + const bestEnd=(span,error)=>{try{const code=error===undefined?undefined:safeCode(error);span.end(error===undefined?{}:{status:code===ErrorCode.CANCELLED?'cancelled':code===ErrorCode.DISPOSED?'disposed':'error',errorCode:code});}catch{/* profiling cannot change a measured operation */}}; + return Object.freeze({enabled,begin, + measure(operation,callback,metadata={}){if(typeof callback!=='function')fail();let span=noop;try{span=begin(operation,metadata);}catch{/* caller can validate metadata through begin */} + let result;try{result=callback(span.id);}catch(e){bestEnd(span,e);throw e;} + try{if(result&&typeof result.then==='function')return Promise.resolve(result).then(value=>{bestEnd(span);return value;},e=>{bestEnd(span,e);throw e;});}catch(e){bestEnd(span,e);throw e;} + bestEnd(span);return result; + }, + snapshot(){if(enabled)prune(time());return parseDiagnosticReport({schemaVersion:1,enabled,sampleEvery,retentionMs,capacity,attempted,sampled,dropped,pending:live.size,events:events.map(item=>item.event)});}, + clear(){events.length=0;}, + dispose(){if(closed)return;closed=true;dropped=Math.min(DIAGNOSTIC_LIMITS.count,dropped+live.size);live.clear();}, + }); +} + +const hostMethods=Object.freeze({activate:'activation',executeCommand:'command',requestDiagnostics:'language',requestLanguage:'language',resolveCompletion:'language',resolveCodeAction:'language',startCommandJob:'job',cancelJob:'job',readJobArtifact:'binary',readJobBinaryArtifactChunk:'binary',readStoredJobArtifactChunk:'binary',dispose:'dispose'}); +/** Explicit adapter, no monkeypatching. Arguments/results pass through without collection. */ +export function profileHost(host,session){ + if(!host||typeof host!=='object'||!session||typeof session.begin!=='function')fail(); + const resources=()=>{const report=host.inspect();return Object.fromEntries(DIAGNOSTIC_RESOURCES.filter(key=>typeof report[key]==='number').map(key=>[key,report[key]]));}; + const wrapper={};for(const name of Object.keys(host)){const descriptor=Object.getOwnPropertyDescriptor(host,name);if(!descriptor||!('value'in descriptor)||typeof descriptor.value!=='function')fail();const original=descriptor.value; + wrapper[name]=!hostMethods[name]?original.bind(host):(...args)=>{ + let span;try{span=session.begin(hostMethods[name],{queueDepth:host.inspect().pendingOperations});}catch{/* diagnostics cannot prevent host execution */} + const end=error=>{try{const code=error===undefined?undefined:safeCode(error);span?.end({status:code===undefined?'ok':code===ErrorCode.CANCELLED?'cancelled':code===ErrorCode.DISPOSED?'disposed':'error',...(code?{errorCode:code}:{}),resources:resources()});}catch{/* no provider data escapes */}}; + let result;try{result=original.apply(host,args);}catch(e){end(e);throw e;} + if(result&&typeof result.then==='function')return Promise.resolve(result).then(value=>{end();return value;},e=>{end(e);throw e;});end();return result; + }; + }return Object.freeze(wrapper); +} +export function profileTransport(transport,session){if(typeof transport!=='function'||!session||typeof session.measure!=='function')fail();return function(...args){return session.measure('transport',()=>transport.apply(this,args));};} diff --git a/src/host-runtime.mjs b/src/host-runtime.mjs new file mode 100644 index 0000000..6d8acb7 --- /dev/null +++ b/src/host-runtime.mjs @@ -0,0 +1,24 @@ +import {ErrorCode,PluginSdkError} from './limits.mjs'; +import {configurationMethod,configurationObject} from './configuration-values.mjs'; + +const known=new WeakSet(); +const fail=()=>{throw new PluginSdkError(ErrorCode.INVALID_CONTRACT,'Invalid trusted host runtime');}; +/** Trusted owner-only clock/entropy port. Never exposed to a plugin. */ +export function hostRuntime(input) { + if(known.has(input))return input; + if(input!==undefined)configurationObject(input,['now','setTimeout','clearTimeout','randomUUID']); + const source=input===undefined?{now:()=>Date.now(),setTimeout:(fn,ms)=>globalThis.setTimeout(fn,ms),clearTimeout:id=>globalThis.clearTimeout(id),randomUUID:()=>globalThis.crypto.randomUUID()}:input; + const now=configurationMethod(source,'now',true),schedule=configurationMethod(source,'setTimeout',true),clear=configurationMethod(source,'clearTimeout',true),uuid=configurationMethod(source,'randomUUID',true); + const timers=new Map();let sequence=0; + const runtime=Object.freeze({ + now(){const value=now();if(!Number.isSafeInteger(value)||value<0)fail();return value;}, + randomUUID(){const value=uuid();if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))fail();return value;}, + setTimeout(callback,delay){ + if(typeof callback!=='function'||!Number.isSafeInteger(delay)||delay<0||timers.size>=4096)fail(); + const ticket=++sequence;timers.set(ticket,undefined); + try{const handle=schedule(()=>{if(!timers.has(ticket))return;timers.delete(ticket);callback();},delay);if(timers.has(ticket))timers.set(ticket,handle);return ticket;}catch(e){timers.delete(ticket);throw e;} + }, + clearTimeout(ticket){if(!timers.has(ticket))return;const handle=timers.get(ticket);clear(handle);timers.delete(ticket);}, + inspect(){return Object.freeze({timers:timers.size});}, + });known.add(runtime);return runtime; +} diff --git a/src/host.mjs b/src/host.mjs index b93befa..99e13be 100644 --- a/src/host.mjs +++ b/src/host.mjs @@ -16,6 +16,7 @@ import {parseCommandOutput} from './contracts.mjs'; import {collectSecretReferences} from './data-schema.mjs'; import {createSecretExecution} from './secrets.mjs'; import {configurationMethod} from './configuration-values.mjs'; +import {hostRuntime} from './host-runtime.mjs'; const hostFailures = new WeakSet(); const error = (code, message) => { @@ -25,7 +26,8 @@ const error = (code, message) => { }; /** Executes trusted local plugins in this process. This developer host is not a sandbox. */ -export function createPluginHost({hostId = 'test-host', scope = {projectId: 'example-project', sessionId: 'example-session'}, grants = [], workspace, backends = {}, jobs = false, binaryArtifacts = false, jobStorage, settings = {}, secrets} = {}) { +export function createPluginHost({hostId = 'test-host', scope = {projectId: 'example-project', sessionId: 'example-session'}, grants = [], workspace, backends = {}, jobs = false, binaryArtifacts = false, jobStorage, settings = {}, secrets, runtime: runtimeInput} = {}) { + const runtime=hostRuntime(runtimeInput); if (!['test-host', 'workspace-host'].includes(hostId)) throw error(ErrorCode.UNSUPPORTED_HOST, 'Unknown plugin host'); scope = parseScope(scope); grants = parseGrants(grants); @@ -52,15 +54,16 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa let diagnosticItems = Object.freeze([]); const plugins = new Map(); const pending = new Set(); + const activations = new Set(); if (typeof binaryArtifacts !== 'boolean') throw error(ErrorCode.INVALID_CONTRACT, 'Invalid binary artifact capability'); - const jobRegistry = createJobRegistry({scope, enabled: jobs, binaryArtifacts: binaryArtifacts && typeof workspace?.captureBinaryFile === 'function', jobStorage}); + const jobRegistry = createJobRegistry({scope, enabled: jobs, binaryArtifacts: binaryArtifacts && typeof workspace?.captureBinaryFile === 'function', jobStorage, runtime}); function isCurrent(request) { return !!document && request.scope.projectId === scope.projectId && request.scope.sessionId === scope.sessionId && ['uri', 'languageId', 'modelVersion', 'workspaceRevision', 'text'].every(key => request.snapshot[key] === document[key]) && (request.kind !== 'code-actions' || request.diagnosticContext.revision === diagnosticRevision); } - const registry = createDiagnosticsRegistry({isCurrent}); - const languages = new Map(LANGUAGE_FEATURES.map(kind => [kind, createLanguageRegistry(kind, {isCurrent})])); + const registry = createDiagnosticsRegistry({isCurrent,runtime}); + const languages = new Map(LANGUAGE_FEATURES.map(kind => [kind, createLanguageRegistry(kind, {isCurrent,runtime})])); function updateDiagnosticContext(items = [], resetSource = false) { diagnosticItems = Object.freeze(items); diagnosticRevision++; if (resetSource) acceptedDiagnosticSequence = ++diagnosticSequence; @@ -97,7 +100,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa state.controller.signal.addEventListener('abort', abortPlugin, {once: true}); let onAbort; const aborted = new Promise((_, reject) => {onAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', onAbort, {once: true});}); - const timer = setTimeout(() => controller.abort(error(ErrorCode.BUDGET_EXCEEDED, 'Plugin operation timed out')), timeoutMs); + const timer = runtime.setTimeout(() => controller.abort(error(ErrorCode.BUDGET_EXCEEDED, 'Plugin operation timed out')), timeoutMs); try { const operationPromise = Promise.resolve().then(() => { assertActive(state); @@ -121,7 +124,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (controller.signal.aborted) throw controller.signal.reason; return validate(result); } finally { - clearTimeout(timer); signal?.removeEventListener('abort', abortCaller); + runtime.clearTimeout(timer); signal?.removeEventListener('abort', abortCaller); state.controller.signal.removeEventListener('abort', abortPlugin); controller.signal.removeEventListener('abort', onAbort); } @@ -148,6 +151,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (!manifest.supportedHosts.includes(hostId)) throw error(ErrorCode.UNSUPPORTED_HOST, 'Plugin does not support this host'); if (plugins.has(manifest.id)) throw error(ErrorCode.INVALID_CONTRACT, 'Plugin ID is already active'); if (plugins.size >= LIMITS.maxRegistrations) throw error(ErrorCode.BUDGET_EXCEEDED, 'Active plugin limit exceeded'); + if (activations.size >= LIMITS.maxRegistrations) throw error(ErrorCode.BUDGET_EXCEEDED, 'Pending activation limit exceeded'); const effectiveGrants = Object.freeze(grants.filter(grant => manifest.permissions.includes(grant))); const state = {active: true, ready: false, manifest, grants: effectiveGrants, commands: new Map(), registrations: new Set(), controller: new AbortController(), disposable: undefined}; plugins.set(manifest.id, state); @@ -252,7 +256,8 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa onAbort = () => reject(signal.reason); signal.addEventListener('abort', onAbort, {once: true}); }); - const timer = setTimeout(() => state.controller.abort(error(ErrorCode.BUDGET_EXCEEDED, 'Plugin activation timed out')), LIMITS.defaultTimeoutMs); + const timer = runtime.setTimeout(() => state.controller.abort(error(ErrorCode.BUDGET_EXCEEDED, 'Plugin activation timed out')), LIMITS.defaultTimeoutMs); + activations.add(state); const operation = Promise.resolve().then(() => { if (signal.aborted) throw signal.reason; return plugin.activate(context); @@ -262,16 +267,17 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (result) { try { result.dispose(); } catch { /* host already closed */ } } throw signal.reason ?? error(ErrorCode.DISPOSED, 'Plugin deactivated during activation'); } + // Own the result before another microtask can dispose the host. The + // activation race may reject before its success continuation runs. + state.disposable = result; return result; - }); + }).finally(()=>activations.delete(state)); try { const result = await Promise.race([operation, aborted]); // Disposal can run between the operation's resolution and this continuation. if (!state.active || disposed || signal.aborted) { - if (result) { try { result.dispose(); } catch { /* host already closed */ } } throw signal.reason ?? error(ErrorCode.DISPOSED, 'Plugin deactivated during activation'); } - state.disposable = result; state.ready = true; return Object.freeze({dispose() { cleanup(state); @@ -283,7 +289,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa if (hostFailures.has(failure)) throw failure; throw error(ErrorCode.PROVIDER_FAILED, 'Plugin activation failed'); } finally { - clearTimeout(timer); + runtime.clearTimeout(timer); signal.removeEventListener('abort', onAbort); } } @@ -301,7 +307,7 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa try { if (refs.length) { permit(state, 'secrets.resolve'); - execution = createSecretExecution(secrets, {pluginId: state.manifest.id, workspaceId: scope.projectId, commandId: entry.command.id, executionId: globalThis.crypto.randomUUID()}, refs, {signal: options.signal}); + execution = createSecretExecution(secrets, {pluginId: state.manifest.id, workspaceId: scope.projectId, commandId: entry.command.id, executionId: runtime.randomUUID()}, refs, {signal: options.signal}); } const api = execution && Object.freeze({async withSecret(...args) { try {return await execution.secrets.withSecret(...args);} @@ -514,6 +520,15 @@ export function createPluginHost({hostId = 'test-host', scope = {projectId: 'exa assertOpen(); const {signal, timeoutMs} = checkedOptions(options); return jobRegistry.readBinaryArtifact(jobId, artifactId, revision, offset, length, signal, timeoutMs); }, + replaceGrants(next) { + assertOpen();next=parseGrants(next); + for(const [id,state]of plugins)if(state.grants.some(grant=>!next.includes(grant))){cleanup(state);plugins.delete(id);} + grants=next; + }, + inspect() { + const diagnostics=registry.inspect(),language=[...languages.values()].map(item=>item.inspect()),job=jobRegistry.inspect(); + return Object.freeze({disposed,plugins:plugins.size,commands:commandCount,registrations:[...plugins.values()].reduce((sum,state)=>sum+state.registrations.size,0),pendingActivations:activations.size,pendingOperations:pending.size,providerOperations:diagnostics.pending+language.reduce((sum,item)=>sum+item.pending,0),pendingJobs:job.pending,binaryOperations:job.binaryOperations,pendingCheckpoints:job.pendingCheckpoints,retainedJobs:job.retained,resolveTokens:language.reduce((sum,item)=>sum+item.tokens,0),semanticEntries:language.reduce((sum,item)=>sum+item.semanticEntries,0),timers:runtime.inspect().timers}); + }, deactivate(pluginId) { assertOpen(); const state = plugins.get(pluginId); diff --git a/src/index.d.mts b/src/index.d.mts index 455c6a4..7617c72 100644 --- a/src/index.d.mts +++ b/src/index.d.mts @@ -142,6 +142,7 @@ export interface Plugin { } export interface RequestOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; } export interface DiagnosticsRegistry extends Disposable { + inspect(): Readonly<{disposed:boolean;registrations:number;pending:number}>; register(pluginId: string, selector: ProviderSelector, provider: DiagnosticsProvider): Disposable; request(request: DiagnosticsRequest, options?: RequestOptions): Promise; invalidate(): void; @@ -159,8 +160,12 @@ export function parseDiagnosticsResult(value: unknown): DiagnosticsResult; export function createDiagnosticsResult(request: DiagnosticsRequest, diagnostics: readonly Diagnostic[]): DiagnosticsResult; export function definePlugin(manifest: PluginManifest, activate: Plugin['activate']): Plugin; /** For trusted host implementers. Authorization and isolation belong to the host. */ -export function createDiagnosticsRegistry(options?: {readonly isCurrent?: (request: DiagnosticsRequest) => boolean}): DiagnosticsRegistry; +export function createDiagnosticsRegistry(options?: {readonly isCurrent?: (request: DiagnosticsRequest) => boolean;readonly runtime?:HostRuntime}): DiagnosticsRegistry; +/** Owner-supplied trusted runtime. Test entropy must never be used for production tokens. */ +export interface HostRuntime {now():number;setTimeout(callback:()=>void,delayMs:number):unknown;clearTimeout(handle:unknown):void;randomUUID():string;} +export interface HostInspection {readonly disposed:boolean;readonly plugins:number;readonly commands:number;readonly registrations:number;readonly pendingActivations:number;readonly pendingOperations:number;readonly providerOperations:number;readonly pendingJobs:number;readonly binaryOperations:number;readonly pendingCheckpoints:number;readonly retainedJobs:number;readonly resolveTokens:number;readonly semanticEntries:number;readonly timers:number;} export interface PluginHostOptions { + readonly runtime?:HostRuntime; readonly secrets?: SecretResolverPort; readonly settings?: Readonly>; readonly jobStorage?: JobStorageOptions; @@ -170,6 +175,10 @@ export interface PluginHostOptions { readonly workspace?: WorkspacePort; readonly backends?: Readonly>; } export interface PluginHost extends Disposable { + /** SDK-owned counts; includes unsettled work after cancellation/disposal. No process-wide tracking. */ + inspect(): HostInspection; + /** Deactivates plugins losing any grant. New grants take effect on the next activation. */ + replaceGrants(grants:readonly Permission[]):void; activate(plugin: Plugin): Promise; setDocument(snapshot: DocumentSnapshot): DocumentSnapshot; requestDiagnostics(options?: RequestOptions): Promise; @@ -341,6 +350,7 @@ export interface LanguageProvider { readonly provideDelta?: K extends 'semantic-tokens' ? (request: LanguageRequest<'semantic-tokens'>, previous: PreviousSemanticTokens, options: {readonly signal: AbortSignal}) => SemanticTokensDelta | null | Promise : never; } export interface LanguageRegistry extends Disposable { + inspect(): Readonly<{disposed:boolean;registrations:number;pending:number;resolving:number;tokens:number;tokenBytes:number;semanticEntries:number;semanticBytes:number}>; register(pluginId: string, selector: ProviderSelector, provider: LanguageProvider, options?: K extends 'completion' ? {readonly resolve?: boolean; readonly snippets?: boolean} : K extends 'code-actions' ? {readonly resolve?: boolean} : K extends 'semantic-tokens' ? {readonly semanticDelta?: boolean} : never): Disposable; request(request: LanguageRequest, options?: RequestOptions): Promise>; resolve(token: string, options?: RequestOptions): Promise>; @@ -355,4 +365,4 @@ export function parseLanguageRequest(value: unknown): LanguageRequest; export function parseLanguageResult(value: unknown): LanguageResult; export function createLanguageResult(request: LanguageRequest, data: LanguageData[K]): LanguageResult; /** Trusted host primitive. Authorization and isolation are enforced by the host. */ -export function createLanguageRegistry(kind: K, options?: {readonly isCurrent?: (request: LanguageRequest) => boolean}): LanguageRegistry; +export function createLanguageRegistry(kind: K, options?: {readonly isCurrent?: (request: LanguageRequest) => boolean;readonly runtime?:HostRuntime}): LanguageRegistry; diff --git a/src/job-history-index.mjs b/src/job-history-index.mjs index b75f0fe..8d4423b 100644 --- a/src/job-history-index.mjs +++ b/src/job-history-index.mjs @@ -1,13 +1,15 @@ import {JOB_HISTORY_LIMITS, parseJobHistoryQuery, parseJobHistoryItem, parseJobHistoryPage} from './job-history.mjs'; import {ErrorCode, PluginSdkError} from './limits.mjs'; +import {hostRuntime} from './host-runtime.mjs'; const failure = code => new PluginSdkError(code, 'Job history operation failed'); /** Snapshot membership/order; current authorization and removal are checked per page. */ -export function createJobHistoryIndex(scope, identity, artifactFor, authorizedRecords, isLive) { +export function createJobHistoryIndex(scope, identity, artifactFor, authorizedRecords, isLive,runtimeInput) { + const runtime=hostRuntime(runtimeInput); const views = new Map(); return (pluginId, query, grants, commands) => { query = parseJobHistoryQuery(query); - const now = Date.now(), {cursor, ...filters} = query; + const now = runtime.now(), {cursor, ...filters} = query; const signature = JSON.stringify(Object.fromEntries(Object.entries(filters).sort(([a],[b]) => a.localeCompare(b)))); for (const [id, view] of views) if (view.expiresAt <= now) views.delete(id); const current = new Map(authorizedRecords(pluginId, grants, commands).map(record => [record.snapshot.jobId, record])); @@ -27,7 +29,7 @@ export function createJobHistoryIndex(scope, identity, artifactFor, authorizedRe } items.sort((a,b) => b.startedAt-a.startedAt || a.jobId.localeCompare(b.jobId)); view = {pluginId, signature, asOf:now, expiresAt:now + JOB_HISTORY_LIMITS.cursorMs, items}; - viewId = globalThis.crypto.randomUUID(); + viewId = runtime.randomUUID(); if (items.length > filters.limit) { if (views.size >= JOB_HISTORY_LIMITS.snapshots) throw failure(ErrorCode.BUDGET_EXCEEDED); views.set(viewId, view); diff --git a/src/job-registry.mjs b/src/job-registry.mjs index b733fb5..9ba24bb 100644 --- a/src/job-registry.mjs +++ b/src/job-registry.mjs @@ -3,6 +3,7 @@ import {ErrorCode, PluginSdkError} from './limits.mjs'; import {JOB_LIMITS, parseJobId, parseJobOptions, parseJobProgress, parseJobArtifact, parseJobEvent, parseJobSnapshot, parseJobEvents} from './jobs.mjs'; import {BINARY_ARTIFACT_LIMITS, parseBinaryArtifact, parseBinaryArtifactSource, parseBinaryArtifactList, parseBinaryArtifactRange, parseBinaryArtifactChunk, decodeBinaryArtifactData} from './artifacts.mjs'; import {createJobStorageSession} from './job-storage-session.mjs'; +import {hostRuntime} from './host-runtime.mjs'; const failure = code => new PluginSdkError(code, 'Job operation failed'); const canonical = value => JSON.stringify(value, function (_key, v) {return v && typeof v === 'object' && !Array.isArray(v) ? Object.fromEntries(Object.keys(v).sort().map(key => [key, v[key]])) : v;}); @@ -18,19 +19,20 @@ function textSource(file){ } /** Host-owned bounded jobs. Cancellation is cooperative; unsettled work keeps its slot. */ -export function createJobRegistry({scope, enabled = false, binaryArtifacts = false, jobStorage}) { +export function createJobRegistry({scope, enabled = false, binaryArtifacts = false, jobStorage,runtime:runtimeInput}) { + const runtime=hostRuntime(runtimeInput); scope = parseScope(scope); if (typeof enabled !== 'boolean' || typeof binaryArtifacts !== 'boolean') throw failure(ErrorCode.INVALID_CONTRACT); const records = new Map(); let closed = false, active = 0, binaryOperations = 0; if (jobStorage !== undefined && !enabled) throw failure(ErrorCode.INVALID_CONTRACT); - const storage = createJobStorageSession(scope, jobStorage, snapshot); + const storage = createJobStorageSession(scope, jobStorage, snapshot,runtime); const assertOpen = () => {if (closed) throw failure(ErrorCode.DISPOSED); if (!enabled) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE);}; - const collect = () => {const cutoff = Date.now() - JOB_LIMITS.retentionMs; for (const [id, record] of records) if (record.settled && record.state !== 'running' && record.updatedAt <= cutoff) {records.delete(id); storage.forget(record);}}; + const collect = () => {const cutoff = runtime.now() - JOB_LIMITS.retentionMs; for (const [id, record] of records) if (record.settled && record.state !== 'running' && record.updatedAt <= cutoff) {records.delete(id); storage.forget(record);}}; const find = id => {assertOpen(); parseJobId(id); collect(); const record = records.get(id); if (!record) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE); record.assertActive(); return record;}; function snapshot(r) { return parseJobSnapshot({protocolVersion: 1, jobId: r.jobId, scope, pluginId: r.pluginId, commandId: r.commandId, state: r.state, startedAt: r.startedAt, updatedAt: r.updatedAt, timeoutMs: r.timeoutMs, progress: r.progress, artifacts: [...r.artifacts.values()], lastSequence: r.sequence, ...(r.state === 'succeeded' ? {result: r.result} : {}), ...(r.error ? {error: {code: r.error}} : {})}); } function emit(r, kind, data) { - const event = parseJobEvent({sequence: r.sequence + 1, at: Math.max(Date.now(), r.updatedAt), kind, data}); + const event = parseJobEvent({sequence: r.sequence + 1, at: Math.max(runtime.now(), r.updatedAt), kind, data}); const bytes = new TextEncoder().encode(JSON.stringify(event)).byteLength; r.sequence++; r.updatedAt = event.at; r.events.push({event, bytes}); r.eventBytes += bytes; while (r.events.length > JOB_LIMITS.events || r.eventBytes > JOB_LIMITS.eventBytes) r.eventBytes -= r.events.shift().bytes; @@ -38,7 +40,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal } function terminal(r, state, code) { if (r.state !== 'running') return; - r.state = state; r.error = code; clearTimeout(r.timer); emit(r, 'state', {state}); + r.state = state; r.error = code; runtime.clearTimeout(r.timer); emit(r, 'state', {state}); } function live(r) { assertOpen(); r.assertActive(); if (r.state !== 'running' || r.controller.signal.aborted) throw failure(ErrorCode.CANCELLED); @@ -63,6 +65,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal r.pendingArtifacts.add(id); } return Object.freeze({ + inspect(){if(!closed)collect();return Object.freeze({disposed:closed,pending:active,retained:records.size,binaryOperations,pendingCheckpoints:storage.inspect().pending});}, capabilities() {if (closed) throw failure(ErrorCode.DISPOSED); return Object.freeze({protocolVersion: 1, enabled, limits: JOB_LIMITS});}, binaryCapabilities() {if (closed) throw failure(ErrorCode.DISPOSED); return Object.freeze({protocolVersion: 1, enabled: enabled && binaryArtifacts, limits: BINARY_ARTIFACT_LIMITS});}, storageCapabilities() {if (closed) throw failure(ErrorCode.DISPOSED); return storage.capabilities();}, @@ -93,7 +96,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal if (storage.has(options.jobId)) throw failure(ErrorCode.CONFLICT); if (active >= JOB_LIMITS.concurrent || records.size >= JOB_LIMITS.retained) throw failure(ErrorCode.BUDGET_EXCEEDED); if (signal.aborted) throw failure(ErrorCode.DISPOSED); - const now = Date.now(), controller = new AbortController(); + const now = runtime.now(), controller = new AbortController(); const r = {...options, attemptOf, pluginId, commandId, signature, assertActive, assertRead, readFile, readBinaryChunk, controller, state: 'running', startedAt: now, updatedAt: now, progress: null, artifacts: new Map(), binaryArtifacts: new Map(), storedArtifacts: new Map(), pendingArtifacts: new Set(), operations: new Set(), events: [], eventBytes: 0, sequence: 0, settled: false}; storage.register(r, grants); records.set(r.jobId, r); active++; emit(r, 'state', {state: 'running'}); @@ -101,7 +104,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal const onAbort = () => {const code = controller.signal.reason instanceof PluginSdkError ? controller.signal.reason.code : ErrorCode.CANCELLED; terminal(r, code === ErrorCode.BUDGET_EXCEEDED ? 'timed_out' : 'cancelled', code);}; signal.addEventListener('abort', onOwnerAbort, {once: true}); controller.signal.addEventListener('abort', onAbort, {once: true}); const unregister = registerController(controller); - r.timer = setTimeout(() => controller.abort(failure(ErrorCode.BUDGET_EXCEEDED)), r.timeoutMs); + r.timer = runtime.setTimeout(() => controller.abort(failure(ErrorCode.BUDGET_EXCEEDED)), r.timeoutMs); const job = Object.freeze({ reportProgress(value) {live(r); const progress = parseJobProgress(value); emit(r, 'progress', progress); r.progress = progress;}, log(level, message) {live(r); emit(r, 'log', {level, message});}, @@ -146,7 +149,7 @@ export function createJobRegistry({scope, enabled = false, binaryArtifacts = fal if (r.state === 'running') terminal(r, 'failed', error instanceof PluginSdkError ? error.code : ErrorCode.PROVIDER_FAILED); }).finally(async () => { await drain(r); - active--; r.settled = true; storage.mark(r); storage.settle(r); clearTimeout(r.timer); signal.removeEventListener('abort', onOwnerAbort); controller.signal.removeEventListener('abort', onAbort); unregister(); + active--; r.settled = true; storage.mark(r); storage.settle(r); runtime.clearTimeout(r.timer); signal.removeEventListener('abort', onOwnerAbort); controller.signal.removeEventListener('abort', onAbort); unregister(); }); return snapshot(r); }, diff --git a/src/job-storage-session.mjs b/src/job-storage-session.mjs index c7601ad..9bb4ba3 100644 --- a/src/job-storage-session.mjs +++ b/src/job-storage-session.mjs @@ -5,15 +5,18 @@ import {JOB_STORE_LIMITS, parseJobStoreIdentity, parseJobStoreLimits, parseStore import {storageObject, storageSha} from './job-storage-validation.mjs'; import {createJobHistoryIndex} from './job-history-index.mjs'; import {createArtifactStorageSession} from './artifact-storage-session.mjs'; +import {hostRuntime} from './host-runtime.mjs'; const failure = code => new PluginSdkError(code, 'Job persistence operation failed'); const hash = async text => [...new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)))].map(value => value.toString(16).padStart(2, '0')).join(''); const noLabel = artifact => {const {label: _label, ...result} = artifact; return result;}; /** Coalesced, bounded asynchronous checkpoints; the existing synchronous v1 API stays live-only. */ -export function createJobStorageSession(scope, options, snapshot) { +export function createJobStorageSession(scope, options, snapshot, runtimeInput) { + const runtime=hostRuntime(runtimeInput); scope = parseScope(scope); if (options === undefined) return Object.freeze({ + inspect:()=>Object.freeze({pending:0}), enabled: false, capabilities: () => parseJobStorageCapabilities({protocolVersion: 1, enabled: false, identity: null, limits: JOB_STORE_LIMITS}), has: () => false, register() {}, mark() {}, settle() {}, forget() {}, beforeExecute: async () => {}, flush: async () => {}, recover() {throw failure(ErrorCode.CAPABILITY_UNAVAILABLE);}, @@ -39,7 +42,7 @@ export function createJobStorageSession(scope, options, snapshot) { if (!Array.isArray(records) || records.length > limits.records) throw failure(ErrorCode.BUDGET_EXCEEDED); return records.map(parseStoredJob).filter(record => record.storeId === identity.storeId && record.workspaceId === scope.projectId && record.workspaceIdentity === identity.workspaceIdentity && record.snapshot.pluginId === pluginId && record.pluginArtifactSha256 === artifactSha256 && record.grants.every(grant => grants.includes(grant)) && commands.has(record.snapshot.commandId)); } - const history = createJobHistoryIndex(scope, identity, pluginId => artifacts[pluginId], authorizedRecords, jobId => entries.has(jobId) && !entries.get(jobId).forgotten); + const history = createJobHistoryIndex(scope, identity, pluginId => artifacts[pluginId], authorizedRecords, jobId => entries.has(jobId) && !entries.get(jobId).forgotten,runtime); function redactValue(entry, kind, value) { if (!redact) return null; @@ -63,7 +66,7 @@ export function createJobStorageSession(scope, options, snapshot) { }); // Capture all mutable state before the first await. An in-flight write can // therefore never claim a later event/settlement was included in its digest. - const savedAt = Math.max(Date.now(), current.updatedAt, entry.savedAt), settled = entry.r.settled; + const savedAt = Math.max(runtime.now(), current.updatedAt, entry.savedAt), settled = entry.r.settled; const binaryArtifacts = [...entry.r.binaryArtifacts.values()].map(value => noLabel(value.artifact)); const retainedArtifacts=[...entry.r.storedArtifacts.values()]; const signature = entry.r.signature; @@ -109,6 +112,7 @@ export function createJobStorageSession(scope, options, snapshot) { return new Promise((resolve, reject) => {entry.waiting.push({version: entry.version, resolve, reject}); void pump();}); } return Object.freeze({ + inspect:()=>Object.freeze({pending:dirty.size+[...entries.values()].filter(entry=>entry.writing).length}), enabled: true, capabilities: () => parseJobStorageCapabilities({protocolVersion: 1, enabled: true, identity, limits}), history, @@ -151,7 +155,7 @@ export function createJobStorageSession(scope, options, snapshot) { record = parseStoredJob(record); if (record.storeId !== identity.storeId || record.workspaceId !== scope.projectId || record.workspaceIdentity !== identity.workspaceIdentity || record.snapshot.pluginId !== pluginId || record.pluginArtifactSha256 !== artifactSha256) throw failure(ErrorCode.CONFLICT); if (!record.grants.every(grant => grants.includes(grant))) throw failure(ErrorCode.PERMISSION_DENIED); - if (record.expiresAt <= Date.now()) return response('expired'); + if (record.expiresAt <= runtime.now()) return response('expired'); return response(record.settled ? 'completed' : live ? 'live' : 'interrupted', record); }, }); diff --git a/src/language-registry.mjs b/src/language-registry.mjs index 8842a26..712a40a 100644 --- a/src/language-registry.mjs +++ b/src/language-registry.mjs @@ -2,6 +2,7 @@ import {parseProviderSelector, parseLanguageRequest, parseLanguageResult, assert import {LANGUAGE_LIMITS, parseCompletionItem, completionInsertion} from './language-assistance.mjs'; import {languageObject, languageInteger, invalidLanguage, freezeLanguage} from './language-values.mjs'; import {ErrorCode, LIMITS, PluginSdkError} from './limits.mjs'; +import {hostRuntime} from './host-runtime.mjs'; import {parseCodeAction, sameCodeActionSelection, validateFormattingEdit} from './language-editing.mjs'; import {LANGUAGE_DISPLAY_LIMITS as displayLimits, parseSemanticTokensDelta, applySemanticTokensDelta} from './language-display.mjs'; @@ -18,7 +19,8 @@ function providerMethod(provider, name) { } /** Trusted host primitive. Plugins remain in-process; grants belong to the owning host. */ -export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { +export function createLanguageRegistry(kind, {isCurrent = () => true,runtime:runtimeInput} = {}) { + const runtime=hostRuntime(runtimeInput); if (!LANGUAGE_FEATURES.includes(kind) || typeof isCurrent !== 'function') invalidLanguage(); const registrations = new Set(), live = new Set(), tokens = new Map(), results = new WeakMap(), semantic = new Map(); const resolvable = kind === 'completion' || kind === 'code-actions'; @@ -29,11 +31,11 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { if (epoch !== expectedEpoch || !isCurrent(request)) throw failure(ErrorCode.STALE_SNAPSHOT, 'Language snapshot is no longer current'); }; function removeToken(id) {const entry = tokens.get(id); if (entry) {tokenBytes -= entry.bytes; tokens.delete(id);}} - function purgeTokens() {const now = Date.now(); for (const [id, entry] of tokens) if (entry.expiresAt <= now || !entry.provider.active) removeToken(id);} + function purgeTokens() {const now = runtime.now(); for (const [id, entry] of tokens) if (entry.expiresAt <= now || !entry.provider.active) removeToken(id);} function clearTokens() {tokens.clear(); tokenBytes = 0;} function removeSemantic(id) {const saved = semantic.get(id); if (saved) {semanticBytes -= saved.bytes; semantic.delete(id);}} function clearSemantic() {semantic.clear(); semanticBytes = 0;} - function purgeSemantic() {const now = Date.now(); for (const [id, saved] of semantic) if (saved.expiresAt <= now || !saved.entry.active) removeSemantic(id);} + function purgeSemantic() {const now = runtime.now(); for (const [id, saved] of semantic) if (saved.expiresAt <= now || !saved.entry.active) removeSemantic(id);} function abortEntry(entry, code) {for (const ticket of live) if (ticket.entry === entry) ticket.controller.abort(failure(code, 'Language provider is no longer active'));} function register(pluginId, selector, provider, options = {}) { open(); @@ -70,7 +72,7 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { signal?.addEventListener('abort', abort, {once: true}); let onAbort; const cancelled = new Promise((_, reject) => {onAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', onAbort, {once: true});}); - const timer = setTimeout(() => controller.abort(failure(ErrorCode.BUDGET_EXCEEDED, 'Language request timed out')), timeoutMs); + const timer = runtime.setTimeout(() => controller.abort(failure(ErrorCode.BUDGET_EXCEEDED, 'Language request timed out')), timeoutMs); const operationPromise = Promise.resolve().then(() => { if (controller.signal.aborted) throw controller.signal.reason; current(request, generation); @@ -86,7 +88,7 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { if (!entry.active) throw failure(ErrorCode.DISPOSED, 'Language provider is no longer active'); return raw; } finally { - clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.signal.removeEventListener('abort', onAbort); + runtime.clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.signal.removeEventListener('abort', onAbort); // An ignored cancellation retains its live slot until the provider actually settles. } } @@ -102,9 +104,9 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { const {resolveData, ...visible} = item; if (resolveData !== undefined && allowTokens) { if (!entry.resolve) throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Language resolver is unavailable'); - if (typeof globalThis.crypto?.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); - const id = globalThis.crypto.randomUUID(), size = new TextEncoder().encode(JSON.stringify({request, item})).byteLength; - additions.push([id, {provider: entry, request, item, epoch, bytes: size, expiresAt: Date.now() + LANGUAGE_LIMITS.resolveTtlMs}]); bytes += size; + if (typeof runtime.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); + const id = runtime.randomUUID(), size = new TextEncoder().encode(JSON.stringify({request, item})).byteLength; + additions.push([id, {provider: entry, request, item, epoch, bytes: size, expiresAt: runtime.now() + LANGUAGE_LIMITS.resolveTtlMs}]); bytes += size; visible.resolveToken = id; } return visible; @@ -164,8 +166,8 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { current(request, generation); const parsed = parseLanguageResult(raw); assertLanguageResultMatchesRequest(parsed, request); data = parsed.data; } - if (typeof globalThis.crypto?.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); - const id = globalThis.crypto.randomUUID(), {text: _text, ...snapshot} = request.snapshot; + if (typeof runtime.randomUUID !== 'function') throw failure(ErrorCode.CAPABILITY_UNAVAILABLE, 'Secure token generation is unavailable'); + const id = runtime.randomUUID(), {text: _text, ...snapshot} = request.snapshot; const tokens = freezeLanguage({resultId: data.resultId, legend: data.legend, data: data.data}); const previous = freezeLanguage({resultId: data.resultId, snapshot, legend: data.legend, data: data.data}); const bytes = new TextEncoder().encode(JSON.stringify({previous, scope: request.scope})).byteLength; @@ -173,11 +175,12 @@ export function createLanguageRegistry(kind, {isCurrent = () => true} = {}) { const result = checkedResult(createLanguageResult(request, {...data, resultId: id, updateKind}), request, entry, false); purgeSemantic(); while (semantic.size >= displayLimits.semanticCacheEntries || semanticBytes + bytes > displayLimits.semanticCacheBytes) removeSemantic(semantic.keys().next().value); - semantic.set(id, {entry, scope: request.scope, previous, tokens, bytes, expiresAt: Date.now() + displayLimits.semanticCacheTtlMs}); semanticBytes += bytes; + semantic.set(id, {entry, scope: request.scope, previous, tokens, bytes, expiresAt: runtime.now() + displayLimits.semanticCacheTtlMs}); semanticBytes += bytes; results.get(result).semanticId = id; return result; } return Object.freeze({ + inspect(){purgeTokens();purgeSemantic();return Object.freeze({disposed,registrations:registrations.size,pending:live.size,resolving,tokens:tokens.size,tokenBytes,semanticEntries:semantic.size,semanticBytes});}, register, validateResult, async request(input, options) { diff --git a/src/lifecycle.mjs b/src/lifecycle.mjs index 0eb2142..8a93bab 100644 --- a/src/lifecycle.mjs +++ b/src/lifecycle.mjs @@ -1,15 +1,16 @@ import { assertResultMatchesRequest, parseDiagnosticsRequest, parseDiagnosticsResult, parseProviderSelector } from './contracts.mjs'; export {createLanguageRegistry} from './language-registry.mjs'; import { ErrorCode, LIMITS, PluginSdkError } from './limits.mjs'; +import {hostRuntime} from './host-runtime.mjs'; const sdkError = (code, message) => new PluginSdkError(code, message); /** A trusted-host lifecycle primitive. It does not load or isolate plugin code. */ -export function createDiagnosticsRegistry({isCurrent = () => true} = {}) { - return createProviderRegistry({isCurrent, parseRequest: parseDiagnosticsRequest, parseResult: parseDiagnosticsResult, assertMatches: assertResultMatchesRequest, method: 'provideDiagnostics'}); +export function createDiagnosticsRegistry({isCurrent = () => true,runtime} = {}) { + return createProviderRegistry({isCurrent,runtime:hostRuntime(runtime), parseRequest: parseDiagnosticsRequest, parseResult: parseDiagnosticsResult, assertMatches: assertResultMatchesRequest, method: 'provideDiagnostics'}); } -function createProviderRegistry({isCurrent, parseRequest, parseResult, assertMatches, method}) { +function createProviderRegistry({isCurrent,runtime, parseRequest, parseResult, assertMatches, method}) { if (typeof isCurrent !== 'function') throw sdkError(ErrorCode.INVALID_CONTRACT, 'isCurrent must be a function'); const registrations = new Set(); const pending = new Set(); @@ -63,7 +64,7 @@ function createProviderRegistry({isCurrent, parseRequest, parseResult, assertMat onAbort = () => reject(controller.signal.reason); controller.signal.addEventListener('abort', onAbort, {once: true}); }); - const timer = setTimeout(() => controller.abort(sdkError(ErrorCode.BUDGET_EXCEEDED, 'Provider request timed out')), timeoutMs); + const timer = runtime.setTimeout(() => controller.abort(sdkError(ErrorCode.BUDGET_EXCEEDED, 'Provider request timed out')), timeoutMs); try { const operation = Promise.resolve().then(() => { if (controller.signal.aborted) throw controller.signal.reason; @@ -72,7 +73,7 @@ function createProviderRegistry({isCurrent, parseRequest, parseResult, assertMat if (controller.signal.aborted) throw controller.signal.reason; // Do not expose provider exception messages, stack traces or private paths. throw sdkError(ErrorCode.PROVIDER_FAILED, 'Provider provider failed'); - }); + }).finally(()=>{pending.delete(controller);entry.pending.delete(controller);}); const raw = await Promise.race([operation, cancelled]); if (controller.signal.aborted) throw controller.signal.reason; if (!isCurrent(request)) throw sdkError(ErrorCode.STALE_SNAPSHOT, 'Requested document snapshot is no longer current'); @@ -80,15 +81,14 @@ function createProviderRegistry({isCurrent, parseRequest, parseResult, assertMat assertMatches(result, request); return result; } finally { - clearTimeout(timer); + runtime.clearTimeout(timer); signal?.removeEventListener('abort', abort); controller.signal.removeEventListener('abort', onAbort); - pending.delete(controller); - entry.pending.delete(controller); } } return Object.freeze({ + inspect(){return Object.freeze({disposed,registrations:registrations.size,pending:pending.size});}, register, request, invalidate() { diff --git a/src/publishing.mjs b/src/publishing.mjs index 9a7e3fd..a0a7833 100644 --- a/src/publishing.mjs +++ b/src/publishing.mjs @@ -163,7 +163,7 @@ function inspectContents(contents, archived = false) { if (archived) { // Only the generated inert npm metadata is accepted, including the tested // SDK line. Additional scripts or dependencies cannot be smuggled into it. - const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0', '>=0.10.0 <0.11.0', '>=0.11.0 <0.12.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; + const accepted = [metadataBytes, ...['>=0.3.0 <0.4.0', '>=0.4.0 <0.5.0', '>=0.5.0 <0.6.0', '>=0.6.0 <0.7.0', '>=0.7.0 <0.8.0', '>=0.8.0 <0.9.0', '>=0.9.0 <0.10.0', '>=0.10.0 <0.11.0', '>=0.11.0 <0.12.0', '>=0.12.0 <0.13.0'].map(range => Buffer.from(`${JSON.stringify({...metadata, peerDependencies: {'@altifigence/dds-plugin-sdk': range}}, null, 2)}\n`))]; if (!accepted.some(bytes => contents.get('package.json').equals(bytes))) fail('package.json must match the generated SDK metadata'); } else { contents.set('package.json', metadataBytes); diff --git a/src/schemas.d.mts b/src/schemas.d.mts index d2e72a8..2307308 100644 --- a/src/schemas.d.mts +++ b/src/schemas.d.mts @@ -1,2 +1,2 @@ // Generated by npm run schemas:write. -export const SCHEMAS: Readonly>>>; +export const SCHEMAS: Readonly>>>; diff --git a/src/schemas.mjs b/src/schemas.mjs index 5d817f6..2c1ff61 100644 --- a/src/schemas.mjs +++ b/src/schemas.mjs @@ -21,6 +21,7 @@ import {FORMATTING_OPTIONS_SCHEMA, CODE_ACTION_SCHEMA, CODE_ACTION_CONTEXT_SCHEM import {LANGUAGE_DISPLAY_SCHEMAS, SEMANTIC_TOKENS_SCHEMA, FOLDING_SCHEMA, INLAY_SCHEMA} from './language-display-schemas.mjs'; import {CONFIGURATION_SCHEMAS, DATA_SCHEMA_CONTRACT, DATA_SCHEMA_DEFINITIONS, DISPLAY_METADATA_SCHEMA} from './data-schema-schemas.mjs'; import {LOCALIZATION_SCHEMAS} from './localization-schemas.mjs'; +import {DEVELOPMENT_SCHEMAS} from './development-schemas.mjs'; const schema = 'https://json-schema.org/draft/2020-12/schema'; const base = `https://github.com/Altifigence/dds-plugin-sdk/blob/v${SDK_VERSION}/schemas/`; @@ -120,6 +121,7 @@ const historyDisposition = {enum:['live','completed','interrupted','expired']}; const historyCursor = {...text(40),pattern:'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:(?:0|[1-9][0-9]{0,2})$'}; const historyItem = object({jobId,commandId:text(128),state:{enum:JOB_STATES},disposition:historyDisposition,startedAt:integer(Number.MAX_SAFE_INTEGER),updatedAt:integer(Number.MAX_SAFE_INTEGER),expiresAt:integer(Number.MAX_SAFE_INTEGER,1),revision:integer(Number.MAX_SAFE_INTEGER,1),attemptOf:{oneOf:[jobId,{type:'null'}]},contentPolicy:{enum:['metadata-only','host-redacted']},artifactCount:integer(JOB_LIMITS.artifacts),snapshotCount:integer(JOB_LIMITS.artifacts),resultAvailability:{enum:['none','source-references','snapshot-references','mixed-references','expired']}}); export const SCHEMAS = Object.freeze({ + ...Object.fromEntries(Object.entries(DEVELOPMENT_SCHEMAS).map(([name,body])=>[name,define(name,body)])), ...Object.fromEntries(Object.entries(CONFIGURATION_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LOCALIZATION_SCHEMAS).map(([name, body]) => [name, define(name, body)])), ...Object.fromEntries(Object.entries(LANGUAGE_DISPLAY_SCHEMAS).map(([name, body]) => [name, define(name, body)])), diff --git a/src/secrets.d.mts b/src/secrets.d.mts index c1b7e46..cbf6ca5 100644 --- a/src/secrets.d.mts +++ b/src/secrets.d.mts @@ -16,6 +16,8 @@ export function createSecretResolver(options: { readonly resolve: (request: {readonly secretId: string; readonly reference: SecretReference; readonly scope: SecretExecutionScope; readonly expiresAt: number}, options: {readonly signal: AbortSignal}) => Uint8Array | Promise; readonly authorize: (request: SecretExecutionScope & {readonly reference: SecretReference; readonly expiresAt: number}) => boolean; readonly now?: () => number; + /** Alternative to now; owner-only deterministic test support. */ + readonly runtime?: import('./index.mjs').HostRuntime; }): SecretResolver; export interface SecretExecutionApi {withSecret(reference: SecretReference, callback: SecretLeaseCallback, options?: RequestOptions): Promise;} export interface SecretExecution extends Disposable {readonly scope: SecretExecutionScope; readonly secrets: SecretExecutionApi;} diff --git a/src/secrets.mjs b/src/secrets.mjs index 987dcbb..e570109 100644 --- a/src/secrets.mjs +++ b/src/secrets.mjs @@ -1,3 +1,4 @@ +import {hostRuntime} from './host-runtime.mjs'; import {parseSecretReference, configurationCopy as copy, configurationObject as object, configurationInteger as integer, configurationText as text, configurationPluginId, configurationMethod, configurationFailure as fail} from './configuration-values.mjs'; import {createConfigurationOperations, configurationOptions, configurationError as error} from './configuration-operations.mjs'; import {ErrorCode} from './limits.mjs'; @@ -23,10 +24,12 @@ function scope(input) { /** Host-owned references. The provider keeps actual values outside settings and metadata. */ export function createSecretResolver(options) { - object(options, ['resolve', 'authorize'], ['now']); + object(options, ['resolve', 'authorize'], ['now','runtime']); if (typeof options.resolve !== 'function' || typeof options.authorize !== 'function' || options.now !== undefined && typeof options.now !== 'function') fail('secret_port'); - const {resolve, authorize, now = Date.now} = options; - const records = new Map(), operations = createConfigurationOperations(SECRET_LIMITS.pending); let closed = false; + const runtime=hostRuntime(options.runtime); + const {resolve, authorize, now = runtime.now} = options; + if(options.now!==undefined&&options.runtime!==undefined)fail('choose_runtime_or_now'); + const records = new Map(), operations = createConfigurationOperations(SECRET_LIMITS.pending,runtime); let closed = false; const open = () => {if (closed) throw error(ErrorCode.DISPOSED);}; const time = () => {let value; try {value = now();} catch {throw error(ErrorCode.PROVIDER_FAILED);} return integer(value, Number.MAX_SAFE_INTEGER - SECRET_LIMITS.maxTtlMs);}; function retire(entry) {entry.revoked = true; records.delete(entry.reference.id); operations.abort(entry);} @@ -48,7 +51,7 @@ export function createSecretResolver(options) { value.commandIds.forEach(name => configurationPluginId(name)); const ttlMs = integer(value.ttlMs ?? SECRET_LIMITS.defaultTtlMs, SECRET_LIMITS.maxTtlMs, 1); if (records.size >= SECRET_LIMITS.references) throw error(ErrorCode.BUDGET_EXCEEDED); - const reference = parseSecretReference({kind: 'dds-secret-reference', id: globalThis.crypto.randomUUID()}); + const reference = parseSecretReference({kind: 'dds-secret-reference', id: runtime.randomUUID()}); records.set(reference.id, {...value, reference, expiresAt: time() + ttlMs, revoked: false}); return reference; }, @@ -77,7 +80,7 @@ export function createSecretResolver(options) { }, requestOptions, {tag: entry, expiresIn: entry.expiresAt - time()}); }, revoke(input) {open(); const reference = parseSecretReference(input), entry = records.get(reference.id); if (!entry) return false; retire(entry); return true;}, - inspect() {open(); prune(); return Object.freeze({references: records.size, pending: operations.pending});}, + inspect() {if(!closed)prune(); return Object.freeze({references: records.size, pending: operations.pending});}, dispose() {if (closed) return; closed = true; for (const entry of records.values()) entry.revoked = true; records.clear(); operations.dispose();}, }); } diff --git a/src/settings.d.mts b/src/settings.d.mts index b1b5fca..f0d89a6 100644 --- a/src/settings.d.mts +++ b/src/settings.d.mts @@ -25,4 +25,4 @@ export interface SettingsStore extends SettingsReadPort, Disposable { export const SETTINGS_LIMITS: Readonly<{definitionBytes: number; keys: number; layerBytes: number; snapshotBytes: number; stateBytes: number; workspaces: number; subscriptions: number; pendingMigrations: number}>; export function parseSettingsDefinition(value: unknown): SettingsDefinition; export function parseSettingsSnapshot(value: unknown): SettingsSnapshot; -export function createSettingsStore(definition: SettingsDefinition, options?: {readonly state?: SettingsState}): SettingsStore; +export function createSettingsStore(definition: SettingsDefinition, options?: {readonly state?: SettingsState;readonly runtime?:import('./index.mjs').HostRuntime}): SettingsStore; diff --git a/src/settings.mjs b/src/settings.mjs index 4182ae6..65936e8 100644 --- a/src/settings.mjs +++ b/src/settings.mjs @@ -67,7 +67,7 @@ export function parseSettingsSnapshot(input) { /** One plugin's operator-owned in-memory settings. The owner retains write authority. */ export function createSettingsStore(input, options = {}) { - object(options, [], ['state']); + object(options, [], ['state','runtime']); let definition = parseSettingsDefinition(input), revision = 0, closed = false, queued = false; let values = layers({user: {}, workspaces: {}}, definition); if (options.state !== undefined) { @@ -77,7 +77,7 @@ export function createSettingsStore(input, options = {}) { revision = integer(state.revision, Number.MAX_SAFE_INTEGER - 1); values = layers({user: state.user, workspaces: state.workspaces}, definition); } - const subscribers = new Set(), operations = createConfigurationOperations(SETTINGS_LIMITS.pendingMigrations); + const subscribers = new Set(), operations = createConfigurationOperations(SETTINGS_LIMITS.pendingMigrations,options.runtime); function open() {if (closed) throw error(ErrorCode.DISPOSED);} function compare(expected) {integer(expected, Number.MAX_SAFE_INTEGER); if (expected !== revision) throw error(ErrorCode.CONFLICT);} function read(id) { @@ -161,7 +161,7 @@ export function createSettingsStore(input, options = {}) { return exportState(); }, {signal: options.signal, timeoutMs: options.timeoutMs}); }, - inspect() {open(); return freezeConfiguration({revision, definitionVersion: definition.version, workspaces: Object.keys(values.workspaces).length, subscriptions: subscribers.size, pendingMigrations: operations.pending});}, + inspect() {return freezeConfiguration({revision, definitionVersion: definition.version, workspaces: values?Object.keys(values.workspaces).length:0, subscriptions: subscribers.size, pendingMigrations: operations.pending});}, dispose() {if (closed) return; closed = true; subscribers.clear(); values = undefined; operations.dispose();}, }); } diff --git a/src/testing-faults.mjs b/src/testing-faults.mjs new file mode 100644 index 0000000..6751f29 --- /dev/null +++ b/src/testing-faults.mjs @@ -0,0 +1,43 @@ +import {ErrorCode} from './limits.mjs'; +import {parseJsonValue} from './contracts.mjs'; +import {configurationObject as object,configurationInteger as integer,configurationKey as key} from './configuration-values.mjs'; +import {hostRuntime} from './host-runtime.mjs'; +import {TEST_LIMITS,testFailure} from './testing-runtime.mjs'; + +export const TEST_FAULTS=Object.freeze(['delay','deny','disconnect','drop','corrupt','exhaust']); +export function createFaultController(runtimeInput){ + const runtime=hostRuntime(runtimeInput),queue=[],pending=new Set();let closed=false; + const open=()=>{if(closed)throw testFailure(ErrorCode.DISPOSED);}; + const wait=(ms,signal)=>new Promise((resolve,reject)=>{ + let timer;const done=()=>{runtime.clearTimeout(timer);signal.removeEventListener('abort',abort);}; + const abort=()=>{done();reject(signal.reason??testFailure(ErrorCode.CANCELLED));}; + signal.addEventListener('abort',abort,{once:true});if(signal.aborted){abort();return;} + if(ms!==null)timer=runtime.setTimeout(()=>{done();resolve();},ms); + }); + return Object.freeze({ + enqueue(input){open();object(input,['operation','kind'],['delayMs','value']);key(input.operation);if(!TEST_FAULTS.includes(input.kind)||queue.length>=TEST_LIMITS.faults)throw testFailure(ErrorCode.INVALID_CONTRACT); + if(input.kind==='delay')integer(input.delayMs,86400000);else if(Object.hasOwn(input,'delayMs'))throw testFailure(ErrorCode.INVALID_CONTRACT); + if(input.kind==='corrupt'&&!Object.hasOwn(input,'value')||input.kind!=='corrupt'&&Object.hasOwn(input,'value'))throw testFailure(ErrorCode.INVALID_CONTRACT); + queue.push(Object.freeze({operation:input.operation,kind:input.kind,...(input.kind==='delay'?{delayMs:input.delayMs}:{}),...(input.kind==='corrupt'?{value:parseJsonValue(input.value)}:{})}));}, + async invoke(operation,callback,options={}){ + open();key(operation);object(options,[],['signal']);if(typeof callback!=='function'||options.signal!==undefined&&!(options.signal instanceof AbortSignal))throw testFailure(ErrorCode.INVALID_CONTRACT); + if(options.signal?.aborted)throw testFailure(ErrorCode.CANCELLED);if(pending.size>=TEST_LIMITS.pending)throw testFailure(ErrorCode.BUDGET_EXCEEDED); + const controller=new AbortController();pending.add(controller);const abort=()=>controller.abort(testFailure(ErrorCode.CANCELLED));options.signal?.addEventListener('abort',abort,{once:true}); + const index=queue.findIndex(item=>item.operation===operation),fault=index<0?null:queue.splice(index,1)[0]; + try{ + if(fault?.kind==='delay')await wait(fault.delayMs,controller.signal); + if(fault?.kind==='drop')await wait(null,controller.signal); + const failures={deny:ErrorCode.PERMISSION_DENIED,disconnect:ErrorCode.CAPABILITY_UNAVAILABLE,exhaust:ErrorCode.BUDGET_EXCEEDED}; + if(fault&&failures[fault.kind])throw testFailure(failures[fault.kind]); + if(controller.signal.aborted)throw controller.signal.reason; + if(fault?.kind==='corrupt')return fault.value; + const result=await callback(controller.signal);if(controller.signal.aborted)throw controller.signal.reason;return result; + }finally{options.signal?.removeEventListener('abort',abort);pending.delete(controller);} + }, + /** Corrupt delivery order, never repeat the actual operation/effect. */ + events(values,options={}){open();object(options,[],['drop','duplicate','reverse']);if(!Array.isArray(values)||values.length>TEST_LIMITS.faults||options.reverse!==undefined&&typeof options.reverse!=='boolean')throw testFailure(ErrorCode.INVALID_CONTRACT);const result=values.map(parseJsonValue);for(const field of ['drop','duplicate'])if(options[field]!==undefined)integer(options[field],values.length-1); + if(options.duplicate!==undefined)result.splice(options.duplicate,0,result[options.duplicate]);if(options.drop!==undefined)result.splice(options.drop,1);if(options.reverse)result.reverse();return Object.freeze(result);}, + inspect:()=>Object.freeze({queued:queue.length,pending:pending.size}), + dispose(){if(closed)return;closed=true;queue.length=0;for(const controller of pending)controller.abort(testFailure(ErrorCode.DISPOSED));}, + }); +} diff --git a/src/testing-job-store.mjs b/src/testing-job-store.mjs new file mode 100644 index 0000000..804d0f3 --- /dev/null +++ b/src/testing-job-store.mjs @@ -0,0 +1,35 @@ +import {ErrorCode} from './limits.mjs'; +import {parseJobId} from './jobs.mjs'; +import {parseJobStoreIdentity,parseStoredJob,JOB_STORE_LIMITS} from './job-storage.mjs'; +import {configurationObject as object,configurationInteger as integer,freezeConfiguration} from './configuration-values.mjs'; +import {createIncrementalSha256} from './sha256-stream.mjs'; +import {testFailure} from './testing-runtime.mjs'; + +const encode=value=>new TextEncoder().encode(JSON.stringify(value)); +const hash=value=>{const h=createIncrementalSha256();h.update(encode(value));return h.digest();}; +/** Synthetic memory checkpoints, not filesystem durability or crash-consistent storage. */ +export function createMemoryJobStore(options){ + object(options,['identity'],['snapshot','faults']);const identity=parseJobStoreIdentity(options.identity),records=new Map(),pins=new Map(),damaged=new Map();let pending=0,closed=false,bytes=0; + const open=()=>{if(closed)throw testFailure(ErrorCode.DISPOSED);}; + function checked(value){const record=parseStoredJob(value);if(['storeId','workspaceId','workspaceIdentity'].some(key=>record[key]!==identity[key]))throw testFailure(ErrorCode.CONFLICT);return record;} + function restore(value){object(value,['schemaVersion','identity','records','sha256']);if(value.schemaVersion!==1)throw testFailure(ErrorCode.VERSION_MISMATCH);if(JSON.stringify(parseJobStoreIdentity(value.identity))!==JSON.stringify(identity)||!Array.isArray(value.records)||value.records.length>JOB_STORE_LIMITS.records)throw testFailure(ErrorCode.CONFLICT); + for(const item of value.records){const record=checked(item);if(records.has(record.snapshot.jobId))throw testFailure(ErrorCode.CONFLICT);records.set(record.snapshot.jobId,record);bytes+=encode(record).length;} + if(bytes>JOB_STORE_LIMITS.storeBytes||value.sha256!==hash({schemaVersion:1,identity,records:[...records.values()]}))throw testFailure(ErrorCode.CONFLICT); + } + if(options.snapshot!==undefined)restore(options.snapshot); + const api=Object.freeze({identity,limits:JOB_STORE_LIMITS, + has(id){open();parseJobId(id);return records.has(id)||damaged.has(id);}, + get(id){open();parseJobId(id);if(damaged.has(id))throw testFailure(damaged.get(id)==='corrupt'?ErrorCode.CONFLICT:ErrorCode.VERSION_MISMATCH);return records.get(id)??null;}, + entries(){open();return Object.freeze([...records.values()].filter(record=>!damaged.has(record.snapshot.jobId)));}, + pin(id){open();parseJobId(id);if(pins.size>=JOB_STORE_LIMITS.records&&!pins.has(id))throw testFailure(ErrorCode.BUDGET_EXCEEDED);pins.set(id,(pins.get(id)??0)+1);let active=true;return()=>{if(!active)return;active=false;const n=(pins.get(id)??0)-1;if(n>0)pins.set(id,n);else pins.delete(id);};}, + async write(value,expectedRevision){open();integer(expectedRevision,Number.MAX_SAFE_INTEGER);const record=checked(value);if(pending>=JOB_STORE_LIMITS.pendingWrites)throw testFailure(ErrorCode.BUDGET_EXCEEDED);pending++; + const commit=()=>{open();const id=record.snapshot.jobId,before=records.get(id);if(damaged.has(id)||(before?.revision??0)!==expectedRevision||record.revision!==expectedRevision+1)throw testFailure(ErrorCode.CONFLICT);const next=bytes-(before?encode(before).length:0)+encode(record).length;if(!before&&records.size>=JOB_STORE_LIMITS.records||next>JOB_STORE_LIMITS.storeBytes)throw testFailure(ErrorCode.BUDGET_EXCEEDED);records.set(id,record);bytes=next;return record;}; + try{return await (options.faults?options.faults.invoke('store.write',commit):Promise.resolve().then(commit));}finally{pending--;} + }, + async flush(){open();if(pending)throw testFailure(ErrorCode.CONFLICT);}, + snapshot(){open();const body={schemaVersion:1,identity,records:[...records.values()]};return freezeConfiguration({...body,sha256:hash(body)});}, + markUnavailable(id,kind){open();parseJobId(id);if(!['corrupt','unsupported'].includes(kind)||!records.has(id))throw testFailure(ErrorCode.INVALID_CONTRACT);damaged.set(id,kind);}, + inspect:()=>Object.freeze({records:records.size,bytes,pins:[...pins.values()].reduce((a,b)=>a+b,0),pending}), + dispose(){closed=true;records.clear();damaged.clear();bytes=0;}, + });return api; +} diff --git a/src/testing-runtime.mjs b/src/testing-runtime.mjs new file mode 100644 index 0000000..a6ae656 --- /dev/null +++ b/src/testing-runtime.mjs @@ -0,0 +1,39 @@ +import {ErrorCode,PluginSdkError} from './limits.mjs'; +import {configurationObject as object,configurationInteger as integer} from './configuration-values.mjs'; + +export const TEST_LIMITS=Object.freeze({timers:4096,steps:10000,pending:64,faults:256,files:256,fileBytes:262144,workspaceBytes:4194304,traceEvents:1024,traceBytes:1048576,resources:1024}); +export const testFailure=code=>new PluginSdkError(code,'Synthetic test operation failed'); +export async function flushTestMicrotasks(turns=32){integer(turns,1024,1);for(let i=0;i{if(closed)throw testFailure(ErrorCode.DISPOSED);}; + const draw=()=>{random=(random+0x6d2b79f5)>>>0;let t=random;t=Math.imul(t^(t>>>15),t|1);t^=t+Math.imul(t^(t>>>7),t|61);return ((t^(t>>>14))>>>0)/4294967296;}; + const runtime=Object.freeze({ + now:()=>now, + randomUUID(){open();const bytes=Array.from({length:16},()=>Math.floor(draw()*256));bytes[6]=(bytes[6]&15)|64;bytes[8]=(bytes[8]&63)|128;const s=bytes.map(b=>b.toString(16).padStart(2,'0')).join('');return [s.slice(0,8),s.slice(8,12),s.slice(12,16),s.slice(16,20),s.slice(20)].join('-');}, + setTimeout(callback,delay){open();integer(delay,86400000);if(typeof callback!=='function')throw testFailure(ErrorCode.INVALID_CONTRACT);if(timers.size>=TEST_LIMITS.timers||now+delay>Number.MAX_SAFE_INTEGER)throw testFailure(ErrorCode.BUDGET_EXCEEDED);const id=++sequence;timers.set(id,{at:now+delay,callback});return id;}, + clearTimeout:id=>{timers.delete(id);}, + }); + const next=()=>[...timers].sort((a,b)=>a[1].at-b[1].at||a[0]-b[0])[0]; + async function drive(target,idle,maxSteps){ + open();if(advancing)throw testFailure(ErrorCode.CONFLICT);integer(maxSteps,TEST_LIMITS.steps,1);advancing=true; + try{await flushTestMicrotasks();let steps=0;for(;;){const item=next();if(!item||!idle&&item[1].at>target)break;if(++steps>maxSteps)throw testFailure(ErrorCode.BUDGET_EXCEEDED);now=item[1].at;timers.delete(item[0]);item[1].callback();await flushTestMicrotasks();}if(!idle)now=target;await flushTestMicrotasks();return Object.freeze({now,timers:timers.size,steps});} + finally{advancing=false;} + } + return Object.freeze({seed,runtime,now:runtime.now,advance(ms,options={}){object(options,[],['maxSteps']);integer(ms,86400000);if(now+ms>Number.MAX_SAFE_INTEGER)throw testFailure(ErrorCode.BUDGET_EXCEEDED);return drive(now+ms,false,options.maxSteps??TEST_LIMITS.steps);},runUntilIdle(options={}){object(options,[],['maxSteps']);return drive(now,true,options.maxSteps??TEST_LIMITS.steps);},inspect:()=>Object.freeze({now,timers:timers.size,disposed:closed}),dispose(){closed=true;timers.clear();}}); +} + +export const TEST_RESOURCE_KINDS=Object.freeze(['file-handle','process','subscription','timer','operation']); +/** Tracks only resources explicitly registered by their owner, never arbitrary OS handles. */ +export function createTestResources(){ + const live=new Map();let sequence=0,closed=false; + const release=id=>{const entry=live.get(id);if(!entry)return Promise.resolve();if(entry.releasing)return entry.releasing;entry.releasing=Promise.resolve().then(entry.dispose).then(()=>{live.delete(id);},e=>{entry.releasing=undefined;throw e;});return entry.releasing;}; + return Object.freeze({ + track(kind,dispose){if(closed)throw testFailure(ErrorCode.DISPOSED);if(!TEST_RESOURCE_KINDS.includes(kind)||typeof dispose!=='function')throw testFailure(ErrorCode.INVALID_CONTRACT);if(live.size>=TEST_LIMITS.resources)throw testFailure(ErrorCode.BUDGET_EXCEEDED);const id=++sequence;live.set(id,{kind,dispose});return Object.freeze({release:()=>release(id)});}, + inspect(){return Object.freeze(Object.fromEntries(TEST_RESOURCE_KINDS.map(kind=>[kind,[...live.values()].filter(item=>item.kind===kind).length])));}, + assertEmpty(){if(live.size)throw testFailure(ErrorCode.CONFLICT);}, + async dispose(){closed=true;const results=await Promise.allSettled([...live.keys()].map(release));const failed=results.find(result=>result.status==='rejected');if(failed)throw failed.reason;}, + }); +} diff --git a/src/testing-scenario.mjs b/src/testing-scenario.mjs new file mode 100644 index 0000000..29111a8 --- /dev/null +++ b/src/testing-scenario.mjs @@ -0,0 +1,33 @@ +import {createPluginHost} from './host.mjs'; +import {parseGrants,parseScope,parseJsonValue} from './contracts.mjs'; +import {ErrorCode} from './limits.mjs'; +import {configurationObject as object,configurationKey as key} from './configuration-values.mjs'; +import {createSettingsStore} from './settings.mjs'; +import {createSecretResolver,SECRET_LIMITS} from './secrets.mjs'; +import {createTestClock,createTestResources,testFailure,flushTestMicrotasks} from './testing-runtime.mjs'; +import {createFaultController} from './testing-faults.mjs'; +import {createMemoryWorkspace} from './testing-workspace.mjs'; + +function entries(value){if(!value||typeof value!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(value)))throw testFailure(ErrorCode.INVALID_CONTRACT);return Reflect.ownKeys(value).map(name=>{const d=Object.getOwnPropertyDescriptor(value,name);if(typeof name!=='string'||!d?.enumerable||!('value'in d))throw testFailure(ErrorCode.INVALID_CONTRACT);key(name);return [name,d.value];});} +/** In-process synthetic fixtures. All host grants and mounted capabilities are explicit. */ +export function createScenarioHost(options={}){ + object(options,[],['seed','start','scope','hostId','grants','files','backends','settings','secrets','jobs','binaryArtifacts','jobStorage']); + let grants=parseGrants(options.grants??[]),closed=false;const scope=parseScope(options.scope??{projectId:'synthetic-project',sessionId:'synthetic-session'}); + const clock=createTestClock({seed:options.seed??1,start:options.start??1700000000000}),faults=createFaultController(clock.runtime),resources=createTestResources(); + const workspace=options.files===undefined?null:createMemoryWorkspace({files:options.files,runtime:clock.runtime,faults,authorize:kind=>grants.includes(kind==='write'?'workspace.write':'workspace.read')}); + const mounted=Object.fromEntries(entries(options.backends??{}).map(([name,value])=>{const result=parseJsonValue(value);return [name,(_input,{signal})=>faults.invoke('backend.'+name,()=>{if(!grants.includes('backend.invoke'))throw testFailure(ErrorCode.PERMISSION_DENIED);return result;},{signal})];})); + if(!Array.isArray(options.settings??[])||(options.settings??[]).length>32)throw testFailure(ErrorCode.INVALID_CONTRACT); + const stores=new Map();for(const definition of options.settings??[]){const store=createSettingsStore(definition,{runtime:clock.runtime});if(stores.has(store.definition.pluginId))throw testFailure(ErrorCode.INVALID_CONTRACT);stores.set(store.definition.pluginId,store);} + const materials=new Map(entries(options.secrets??{}).map(([name,value])=>{if(!(value instanceof Uint8Array)||value.length<1||value.length>SECRET_LIMITS.valueBytes)throw testFailure(ErrorCode.INVALID_CONTRACT);return [name,value.slice()];})); + const secrets=options.secrets===undefined?null:createSecretResolver({runtime:clock.runtime,authorize:()=>grants.includes('secrets.resolve'),resolve:({secretId},{signal})=>faults.invoke('secret.resolve',()=>{const value=materials.get(secretId);if(!value)throw testFailure(ErrorCode.CAPABILITY_UNAVAILABLE);return value;},{signal})}); + const settings=Object.freeze(Object.fromEntries(stores)); + const settingPorts=Object.fromEntries([...stores].map(([id,store])=>[id,{read(workspaceId,{signal}={}){return faults.invoke('settings.read',()=>{if(!grants.includes('settings.read'))throw testFailure(ErrorCode.PERMISSION_DENIED);return store.read(workspaceId);},{signal});},subscribe:(workspaceId,callback)=>store.subscribe(workspaceId,callback)}])); + const host=createPluginHost({hostId:options.hostId??'workspace-host',scope,grants,runtime:clock.runtime,...(workspace?{workspace:workspace.port}:{}),backends:mounted,settings:settingPorts,...(secrets?{secrets}:{}),jobs:options.jobs??false,binaryArtifacts:options.binaryArtifacts??false,...(options.jobStorage!==undefined?{jobStorage:options.jobStorage}:{})}); + const capabilities=Object.freeze({workspace:!!workspace,backends:Object.freeze(Object.keys(mounted)),settings:Object.freeze([...stores.keys()]),secrets:!!secrets,jobs:host.jobCapabilities().enabled,binaryArtifacts:host.binaryArtifactCapabilities().enabled}); + return Object.freeze({host,clock,faults,workspace,settings,secrets,resources,capabilities, + replaceGrants(next){if(closed)throw testFailure(ErrorCode.DISPOSED);next=parseGrants(next);host.replaceGrants(next);grants=next;}, + inspect(){return Object.freeze({host:host.inspect(),clock:clock.inspect(),faults:faults.inspect(),workspace:workspace?.inspect()??null,settings:Object.freeze(Object.fromEntries([...stores].map(([name,store])=>[name,store.inspect()]))),secrets:secrets?.inspect()??null,resources:resources.inspect()});}, + async dispose(){if(!closed){closed=true;host.dispose();faults.dispose();workspace?.dispose();for(const store of stores.values())store.dispose();secrets?.dispose();for(const bytes of materials.values())bytes.fill(0);materials.clear();}await resources.dispose();await flushTestMicrotasks();}, + assertClean(){const h=host.inspect();if(h.plugins||h.commands||h.registrations||h.pendingActivations||h.pendingOperations||h.providerOperations||h.pendingJobs||h.binaryOperations||h.pendingCheckpoints||h.timers||clock.inspect().timers||faults.inspect().pending||workspace?.inspect().subscriptions||workspace?.inspect().handles||[...stores.values()].some(s=>s.inspect().subscriptions||s.inspect().pendingMigrations)||secrets?.inspect().pending)throw testFailure(ErrorCode.CONFLICT);resources.assertEmpty();}, + }); +} diff --git a/src/testing-trace.mjs b/src/testing-trace.mjs new file mode 100644 index 0000000..7fd9d25 --- /dev/null +++ b/src/testing-trace.mjs @@ -0,0 +1,59 @@ +import {ErrorCode} from './limits.mjs'; +import {parseJsonValue} from './contracts.mjs'; +import {configurationObject as object,configurationCopy as copy,configurationInteger as integer,freezeConfiguration as freeze} from './configuration-values.mjs'; +import {createIncrementalSha256} from './sha256-stream.mjs'; +import {TEST_LIMITS,testFailure} from './testing-runtime.mjs'; + +export const TRACE_OPERATIONS=Object.freeze(['command','language','workspace','project','job','binary','settings','secret','backend']); +const stable=value=>JSON.stringify(value,(_key,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item); +const hash=value=>{const h=createIncrementalSha256();h.update(new TextEncoder().encode(stable(value)));return h.digest();}; +const sha=value=>{if(typeof value!=='string'||!/^[a-f0-9]{64}$/.test(value))throw testFailure(ErrorCode.INVALID_CONTRACT);}; +const fixtureId=value=>{if(typeof value!=='string'||!/^[a-z][a-z0-9-]{0,63}$/.test(value))throw testFailure(ErrorCode.INVALID_CONTRACT);}; +function fixtures(options){ + object(options,['synthetic','seed','fixtureVersion','fixtures'],['now']); + if(options.synthetic!==true||options.now!==undefined&&typeof options.now!=='function')throw testFailure(ErrorCode.INVALID_CONTRACT); + integer(options.seed,0xffffffff);integer(options.fixtureVersion,Number.MAX_SAFE_INTEGER,1); + if(!Array.isArray(options.fixtures)||!options.fixtures.length||options.fixtures.length>64)throw testFailure(ErrorCode.INVALID_CONTRACT); + const map=new Map();let bytes=0; + for(const input of options.fixtures){object(input,['id','operation','input','output']);fixtureId(input.id);if(map.has(input.id)||!TRACE_OPERATIONS.includes(input.operation))throw testFailure(ErrorCode.INVALID_CONTRACT); + const value=freeze({id:input.id,operation:input.operation,input:parseJsonValue(input.input),output:parseJsonValue(input.output)});bytes+=new TextEncoder().encode(stable(value)).length;if(bytes>TEST_LIMITS.workspaceBytes)throw testFailure(ErrorCode.BUDGET_EXCEEDED); + map.set(value.id,{...value,inputSha256:hash(value.input),outputSha256:hash(value.output)}); + } + const inventory=[...map.values()].map(({id,operation,inputSha256,outputSha256})=>({id,operation,inputSha256,outputSha256})).sort((a,b)=>a.idTEST_LIMITS.traceEvents)throw testFailure(ErrorCode.INVALID_CONTRACT); + const {events,digest,...header}=value;let previous=hash(header),at=0; + for(const [i,event]of events.entries()){ + object(event,['sequence','at','fixtureId','operation','inputSha256','outputSha256','previousSha256','sha256']);fixtureId(event.fixtureId);integer(event.at,Number.MAX_SAFE_INTEGER);if(event.at0);let previous=hash(header),lastAt=0,closed=false; + return Object.freeze({ + record(id,input,output){if(closed)throw testFailure(ErrorCode.DISPOSED);fixtureId(id);const fixture=map.get(id);if(!fixture||hash(parseJsonValue(input))!==fixture.inputSha256||hash(parseJsonValue(output))!==fixture.outputSha256)throw testFailure(ErrorCode.CONFLICT); + if(events.length>=TEST_LIMITS.traceEvents)throw testFailure(ErrorCode.BUDGET_EXCEEDED);const at=integer(now(),Number.MAX_SAFE_INTEGER);if(atObject.freeze({position,remaining:trace.events.length-position}),assertComplete(){if(position!==trace.events.length)throw testFailure(ErrorCode.CONFLICT);},dispose(){closed=true;map.clear();}}); +} diff --git a/src/testing-workspace.mjs b/src/testing-workspace.mjs new file mode 100644 index 0000000..288cfa5 --- /dev/null +++ b/src/testing-workspace.mjs @@ -0,0 +1,42 @@ +import {ErrorCode} from './limits.mjs'; +import {parseWorkspacePath,parseFileContent,parseExpectedRevision} from './contracts.mjs'; +import {parseBinaryArtifactRange} from './artifacts.mjs'; +import {parseProjectSnapshot} from './project-watch.mjs'; +import {configurationObject as object} from './configuration-values.mjs'; +import {createIncrementalSha256 as createSha256} from './sha256-stream.mjs'; +import {hostRuntime} from './host-runtime.mjs'; +import {TEST_LIMITS,testFailure} from './testing-runtime.mjs'; + +const encode=text=>new TextEncoder().encode(text),hash=bytes=>{const h=createSha256();h.update(bytes);return h.digest();}; +export function createMemoryWorkspace(options={}){ + object(options,[],['files','runtime','faults','authorize']);const runtime=hostRuntime(options.runtime),files=new Map(),subscriptions=new Set();let closed=false,bytes=0,handles=0; + const authorize=options.authorize??(()=>true);if(typeof authorize!=='function')throw testFailure(ErrorCode.INVALID_CONTRACT); + const open=()=>{if(closed)throw testFailure(ErrorCode.DISPOSED);}; + const permission=kind=>{open();if(authorize(kind)!==true)throw testFailure(ErrorCode.PERMISSION_DENIED);}; + function content(value){const data=typeof value==='string'?encode(parseFileContent(value)):value;if(!(data instanceof Uint8Array)||data.length>TEST_LIMITS.fileBytes)throw testFailure(ErrorCode.INVALID_CONTRACT);return data.slice();} + function put(name,value){name=parseWorkspacePath(name);const data=content(value),before=files.get(name),next=bytes-(before?.data.length??0)+data.length; + if(!before&&files.size>=TEST_LIMITS.files||next>TEST_LIMITS.workspaceBytes)throw testFailure(ErrorCode.BUDGET_EXCEEDED); + for(const other of files.keys())if(other!==name&&(other.toLowerCase()===name.toLowerCase()||other.toLowerCase().startsWith(name.toLowerCase()+'/')||name.toLowerCase().startsWith(other.toLowerCase()+'/')))throw testFailure(ErrorCode.CONFLICT); + files.set(name,{data,revision:hash(data)});bytes=next;return files.get(name); + } + const initial=options.files??{};if(!initial||typeof initial!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(initial)))throw testFailure(ErrorCode.INVALID_CONTRACT); + for(const name of Reflect.ownKeys(initial)){const d=Object.getOwnPropertyDescriptor(initial,name);if(typeof name!=='string'||!d?.enumerable||!('value'in d))throw testFailure(ErrorCode.INVALID_CONTRACT);put(name,d.value);} + const request=async(operation,kind,fn,options={})=>{permission(kind);object(options,[],['signal','expectedRevision']);if(options.signal!==undefined&&!(options.signal instanceof AbortSignal))throw testFailure(ErrorCode.INVALID_CONTRACT);if(options.signal?.aborted)throw testFailure(ErrorCode.CANCELLED);handles++; + try{return await (optionsFaults?optionsFaults.invoke(operation,()=>{permission(kind);return fn();},{signal:options.signal}):Promise.resolve().then(()=>{permission(kind);if(options.signal?.aborted)throw testFailure(ErrorCode.CANCELLED);return fn();}));}finally{handles--;}}; + const optionsFaults=options.faults; + const find=name=>{const file=files.get(name);if(!file)throw testFailure(ErrorCode.CAPABILITY_UNAVAILABLE);return file;}; + function snapshot(root=''){ + root=parseWorkspacePath(root,{allowRoot:true});const entries=[],dirs=new Set(); + for(const [name,file]of files){if(root&&!name.startsWith(root+'/'))continue;entries.push({path:name,kind:'file',size:file.data.length,revision:file.revision,fingerprint:file.revision});let parent=name.split('/').slice(0,-1).join('/');while(parent&&parent!==root){dirs.add(parent);parent=parent.split('/').slice(0,-1).join('/');}} + for(const name of dirs)entries.push({path:name,kind:'directory',size:0,revision:null,fingerprint:hash(encode(name))});entries.sort((a,b)=>a.path{for(const entry of [...subscriptions])try{Promise.resolve(entry.callback(snapshot(entry.root))).catch(()=>{});}catch{/* test subscriber cannot roll back a write */}}; + const port=Object.freeze({ + readFile(name,options={}){name=parseWorkspacePath(name);return request('file.read','read',()=>{const file=find(name);let text;try{text=new TextDecoder('utf-8',{fatal:true}).decode(file.data);}catch{throw testFailure(ErrorCode.INVALID_CONTRACT);}return Object.freeze({path:name,content:text,revision:file.revision});},options);}, + writeFile(name,value,options={}){name=parseWorkspacePath(name);value=parseFileContent(value);const expected=parseExpectedRevision(options.expectedRevision);return request('file.write','write',()=>{if((files.get(name)?.revision??null)!==expected)throw testFailure(ErrorCode.CONFLICT);const file=put(name,value);notify();return Object.freeze({path:name,revision:file.revision});},options);}, + listFiles(root='',options={}){root=parseWorkspacePath(root,{allowRoot:true});return request('file.list','read',()=>snapshot(root).entries.map(entry=>Object.freeze({path:entry.path,kind:entry.kind,size:entry.size,...(entry.revision?{revision:entry.revision}:{})})),options);}, + captureBinaryFile(name,options={}){name=parseWorkspacePath(name);return request('file.capture','read',()=>{const file=find(name),data=file.data.slice();return Object.freeze({path:name,revision:file.revision,byteLength:data.length,readChunk(offset,length,options={}){parseBinaryArtifactRange(offset,length,data.length);return request('file.chunk','read',()=>{const bytes=data.subarray(offset,offset+length);let raw='';for(const b of bytes)raw+=String.fromCharCode(b);return Object.freeze({offset,nextOffset:offset+bytes.length,eof:offset+bytes.length===data.length,data:btoa(raw),sha256:hash(bytes)});},options);}});},options);}, + }); + return Object.freeze({port,snapshot(root=''){permission('read');return snapshot(root);},subscribe(root,callback){permission('read');parseWorkspacePath(root,{allowRoot:true});if(typeof callback!=='function'||subscriptions.size>=32)throw testFailure(ErrorCode.INVALID_CONTRACT);const entry={root,callback};subscriptions.add(entry);return Object.freeze({dispose(){subscriptions.delete(entry);}});},setFile(name,value){open();put(name,value);notify();},removeFile(name){open();name=parseWorkspacePath(name);const file=files.get(name);if(file){bytes-=file.data.length;files.delete(name);notify();}return !!file;},inspect:()=>Object.freeze({files:files.size,bytes,subscriptions:subscriptions.size,handles}),dispose(){if(closed)return;closed=true;subscriptions.clear();for(const entry of files.values())entry.data.fill(0);files.clear();bytes=0;}}); +} diff --git a/src/testing.d.mts b/src/testing.d.mts index 622c4a8..93807b5 100644 --- a/src/testing.d.mts +++ b/src/testing.d.mts @@ -1,5 +1,42 @@ -import type { Permission, PluginHost, Scope } from './index.mjs'; +import type { Permission, PluginHost, Scope,HostRuntime,Disposable,WorkspacePort,JsonValue,HostId,HostInspection } from './index.mjs'; +import type {SettingsDefinition,SettingsStore} from './settings.mjs'; +import type {SecretResolver} from './secrets.mjs'; +import type {ProjectSnapshot} from './project-watch.mjs'; +import type {JobStorageOptions,JobStore,JobStoreIdentity,StoredJob} from './job-storage.mjs'; export interface TestHost extends PluginHost {} /** Runs trusted local modules in-process; this developer host is not a sandbox. */ export function createTestHost(options?: {readonly scope?: Scope; readonly grants?: readonly Permission[]}): TestHost; +export const TEST_LIMITS:Readonly<{timers:4096;steps:10000;pending:64;faults:256;files:256;fileBytes:262144;workspaceBytes:4194304;traceEvents:1024;traceBytes:1048576;resources:1024}>; +export interface TestClock extends Disposable {readonly seed:number;readonly runtime:HostRuntime;now():number;advance(ms:number,options?:{readonly maxSteps?:number}):Promise<{readonly now:number;readonly timers:number;readonly steps:number}>;runUntilIdle(options?:{readonly maxSteps?:number}):Promise<{readonly now:number;readonly timers:number;readonly steps:number}>;inspect():Readonly<{now:number;timers:number;disposed:boolean}>;} +/** Only the mounted runtime is virtual. SDK I/O, arbitrary plugins and OS processes remain real. */ +export function createTestClock(options?:{readonly seed?:number;readonly start?:number}):TestClock; +export function flushTestMicrotasks(turns?:number):Promise; +export type TestResourceKind='file-handle'|'process'|'subscription'|'timer'|'operation'; +export const TEST_RESOURCE_KINDS:readonly TestResourceKind[]; +export interface TestResources {track(kind:TestResourceKind,dispose:()=>void|Promise):{release():Promise};inspect():Readonly>;assertEmpty():void;dispose():Promise;} +export function createTestResources():TestResources; +export type TestFault={readonly operation:string}&({readonly kind:'delay';readonly delayMs:number}|{readonly kind:'corrupt';readonly value:JsonValue}|{readonly kind:'deny'|'disconnect'|'drop'|'exhaust'}); +export const TEST_FAULTS:readonly TestFault['kind'][]; +export interface FaultController extends Disposable {enqueue(fault:TestFault):void;invoke(operation:string,callback:(signal:AbortSignal)=>T|Promise,options?:{readonly signal?:AbortSignal}):Promise;events(values:readonly JsonValue[],options?:{readonly drop?:number;readonly duplicate?:number;readonly reverse?:boolean}):readonly JsonValue[];inspect():Readonly<{queued:number;pending:number}>;} +export function createFaultController(runtime?:HostRuntime):FaultController; +export interface MemoryWorkspace extends Disposable {readonly port:Required;snapshot(root?:string):ProjectSnapshot;subscribe(root:string,callback:(snapshot:ProjectSnapshot)=>void|Promise):Disposable;setFile(path:string,content:string|Uint8Array):void;removeFile(path:string):boolean;inspect():Readonly<{files:number;bytes:number;subscriptions:number;handles:number}>;} +export function createMemoryWorkspace(options?:{readonly files?:Readonly>;readonly runtime?:HostRuntime;readonly faults?:FaultController;readonly authorize?:(permission:'read'|'write')=>boolean}):MemoryWorkspace; +export interface MemoryJobSnapshot {readonly schemaVersion:1;readonly identity:JobStoreIdentity;readonly records:readonly StoredJob[];readonly sha256:string;} +export interface MemoryJobStore extends JobStore,Disposable {snapshot():MemoryJobSnapshot;markUnavailable(jobId:string,kind:'corrupt'|'unsupported'):void;inspect():Readonly<{records:number;bytes:number;pins:number;pending:number}>;} +export function createMemoryJobStore(options:{readonly identity:JobStoreIdentity;readonly snapshot?:MemoryJobSnapshot;readonly faults?:FaultController}):MemoryJobStore; +export interface ScenarioOptions {readonly seed?:number;readonly start?:number;readonly hostId?:HostId;readonly scope?:Scope;readonly grants?:readonly Permission[];readonly files?:Readonly>;readonly backends?:Readonly>;readonly settings?:readonly SettingsDefinition[];readonly secrets?:Readonly>;readonly jobs?:boolean;readonly binaryArtifacts?:boolean;readonly jobStorage?:JobStorageOptions;} +export interface ScenarioInspection {readonly host:HostInspection;readonly clock:ReturnType;readonly faults:ReturnType;readonly workspace:ReturnType|null;readonly settings:Readonly>>;readonly secrets:ReturnType|null;readonly resources:ReturnType;} +export interface ScenarioHost {readonly host:PluginHost;readonly clock:TestClock;readonly faults:FaultController;readonly workspace:MemoryWorkspace|null;readonly settings:Readonly>;readonly secrets:SecretResolver|null;readonly resources:TestResources;readonly capabilities:Readonly<{workspace:boolean;backends:readonly string[];settings:readonly string[];secrets:boolean;jobs:boolean;binaryArtifacts:boolean}>;replaceGrants(grants:readonly Permission[]):void;inspect():ScenarioInspection;assertClean():void;dispose():Promise;} +export function createScenarioHost(options?:ScenarioOptions):ScenarioHost; +export type TraceOperation='command'|'language'|'workspace'|'project'|'job'|'binary'|'settings'|'secret'|'backend'; +export const TRACE_OPERATIONS:readonly TraceOperation[]; +export interface TestFixture {readonly id:string;readonly operation:TraceOperation;readonly input:JsonValue;readonly output:JsonValue;} +export interface TestTraceEvent {readonly sequence:number;readonly at:number;readonly fixtureId:string;readonly operation:TraceOperation;readonly inputSha256:string;readonly outputSha256:string;readonly previousSha256:string;readonly sha256:string;} +export interface TestTrace {readonly schemaVersion:1;readonly seed:number;readonly fixtureVersion:number;readonly fixtureDigest:string;readonly events:readonly TestTraceEvent[];readonly digest:string;} +export interface TestTraceOptions {readonly synthetic:true;readonly seed:number;readonly fixtureVersion:number;readonly fixtures:readonly TestFixture[];} +export interface TestRecorder extends Disposable {record(fixtureId:string,input:JsonValue,output:JsonValue):TestTraceEvent;snapshot():TestTrace;} +export interface TestReplay extends Disposable {next(fixtureId:string,input:JsonValue):JsonValue;inspect():Readonly<{position:number;remaining:number}>;assertComplete():void;} +export function parseTestTrace(input:unknown):TestTrace; +export function createTestRecorder(options:TestTraceOptions & {readonly now?:()=>number}):TestRecorder; +export function createTestReplay(trace:TestTrace,options:TestTraceOptions):TestReplay; diff --git a/src/testing.mjs b/src/testing.mjs index 482284d..506fd4e 100644 --- a/src/testing.mjs +++ b/src/testing.mjs @@ -1,4 +1,10 @@ import { createPluginHost } from './host.mjs'; +export {createTestClock,flushTestMicrotasks,createTestResources,TEST_LIMITS,TEST_RESOURCE_KINDS} from './testing-runtime.mjs'; +export {createFaultController,TEST_FAULTS} from './testing-faults.mjs'; +export {createMemoryWorkspace} from './testing-workspace.mjs'; +export {createScenarioHost} from './testing-scenario.mjs'; +export {createMemoryJobStore} from './testing-job-store.mjs'; +export {createTestRecorder,createTestReplay,parseTestTrace,TRACE_OPERATIONS} from './testing-trace.mjs'; /** Executes trusted local plugins in-process with the original diagnostics defaults. */ export function createTestHost({scope, grants = ['document.read', 'diagnostics.publish']} = {}) { diff --git a/src/version.mjs b/src/version.mjs index 3d02bde..55639bb 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -1,3 +1,3 @@ // The release checks compare this value with the package metadata. -export const SDK_VERSION = '0.12.0'; -export const SDK_PLUGIN_PEER_RANGE = '>=0.12.0 <0.13.0'; +export const SDK_VERSION = '0.13.0'; +export const SDK_PLUGIN_PEER_RANGE = '>=0.13.0 <0.14.0'; diff --git a/tests/devtools-templates.test.mjs b/tests/devtools-templates.test.mjs new file mode 100644 index 0000000..a767a9c --- /dev/null +++ b/tests/devtools-templates.test.mjs @@ -0,0 +1,91 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +import {initPlugin,planPlugin,generatePluginContracts,doctorPlugin,PLUGIN_TEMPLATES} from '../src/devtools.mjs'; +import {validatePluginPackage,packPlugin,verifyPluginArchive} from '../src/publishing.mjs'; +import {writeNewDirectory} from '../src/dev-files.mjs'; + +const sdk=fileURLToPath(new URL('../',import.meta.url)),cli=path.join(sdk,'bin/dds-plugin.mjs'); +const run=(args,cwd)=>{const r=spawnSync(process.execPath,args,{cwd,encoding:'utf8',timeout:30_000,windowsHide:true});assert.equal(r.status,0,r.error?.message??r.stderr??r.stdout);return r.stdout;}; +async function fixture(t){ + const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'dds-templates-'))),links=[]; + t.after(async()=>{for(const link of links)await fs.unlink(link);assert.equal(path.dirname(root),await fs.realpath(os.tmpdir()));assert.ok(path.basename(root).startsWith('dds-templates-'));await fs.rm(root,{recursive:true,maxRetries:10,retryDelay:100});}); + return {root,async install(directory){const parent=path.join(directory,'node_modules/@altifigence');await fs.mkdir(parent,{recursive:true});const link=path.join(parent,'dds-plugin-sdk');await fs.symlink(sdk,link,process.platform==='win32'?'junction':'dir');links.push(link);}}; +} +test('all templates run, typecheck and produce independently verifiable plugin archives',async t=>{ + const f=await fixture(t); + for(const template of PLUGIN_TEMPLATES){ + const directory=path.join(f.root,template),plan=await planPlugin(directory,{template,id:'example-'+template}); + assert.equal(plan.writes,false);assert.equal(plan.conflicts.length,0);await assert.rejects(fs.stat(directory),{code:'ENOENT'}); + const generated=await initPlugin(directory,{template,id:'example-'+template});assert.deepEqual(generated.files,plan.files.map(file=>file.path));await f.install(directory); + const doctor=await doctorPlugin(directory);assert.equal(doctor.ok,true,JSON.stringify(doctor.checks));assert.equal((await generatePluginContracts(directory,{check:true})).ok,true); + run(['--test','plugin.test.mjs'],directory);run([path.join(sdk,'node_modules/typescript/bin/tsc'),'--project','tsconfig.json'],directory); + assert.equal(JSON.parse(run(['run.mjs'],directory)).verified,true); + const packed=await packPlugin(directory,{out:path.join(f.root,template+'-dist')});assert.equal((await verifyPluginArchive(packed.archivePath,{expectedSha256:packed.artifact.sha256})).checksumPinned,true); + } +}); +test('dry-run reports an existing destination and never writes or installs',async t=>{ + const f=await fixture(t),directory=path.join(f.root,'my plugin');await fs.mkdir(directory);await fs.writeFile(path.join(directory,'retained.txt'),'preserve'); + const report=await initPlugin(directory,{id:'my-plugin',dryRun:true});assert.deepEqual(report.conflicts,[{path:'.',reason:'destination_exists'}]); + await assert.rejects(initPlugin(directory,{id:'my-plugin'}),{code:'conflict'});assert.deepEqual(await fs.readdir(directory),['retained.txt']); + for(const options of [{template:'unknown'},{id:'../invalid'},{license:'MIT'},{license:'LicenseRef-Proprietary'},{dryRun:'yes'}])await assert.rejects(initPlugin(path.join(f.root,'reject'),options)); + await assert.rejects(initPlugin(path.join(f.root,'CON')),{code:'invalid_path'}); +}); +test('generation is deterministic, requires exact replacement and preserves all manual edits',async t=>{ + const f=await fixture(t),directory=path.join(f.root,'configuration');await initPlugin(directory,{template:'configuration'}); + const before=await generatePluginContracts(directory,{check:true}),noOp=await generatePluginContracts(directory);assert.equal(noOp.written,false); + const definitionPath=path.join(directory,'dds-dev.json'),definition=JSON.parse(await fs.readFile(definitionPath,'utf8')); + definition.commands[0].title='New title';await fs.writeFile(definitionPath,JSON.stringify(definition)); + assert.equal((await generatePluginContracts(directory,{check:true})).ok,false); + const preview=await generatePluginContracts(directory,{dryRun:true});assert.equal(preview.previousDigest,before.digest);assert.notEqual(preview.digest,before.digest); + await assert.rejects(generatePluginContracts(directory),{code:'conflict'});await assert.rejects(generatePluginContracts(directory,{expectedDigest:'a'.repeat(64)}),{code:'conflict'}); + await fs.writeFile(path.join(directory,'unrelated.txt'),'preserve');const after=await generatePluginContracts(directory,{expectedDigest:before.digest});assert.equal(after.written,true);assert.equal(after.digest,preview.digest);assert.equal((await generatePluginContracts(directory,{check:true})).ok,true);assert.equal(await fs.readFile(path.join(directory,'unrelated.txt'),'utf8'),'preserve'); + const edited=path.join(directory,'generated/contracts.d.mts');await fs.appendFile(edited,'// manual edit\n');assert.equal((await generatePluginContracts(directory,{check:true})).ok,false); + await assert.rejects(generatePluginContracts(directory,{expectedDigest:after.digest}),{code:'conflict'});assert.match(await fs.readFile(edited,'utf8'),/manual edit/); + assert.ok(!(await fs.readdir(directory)).some(name=>name.startsWith('.dds-generation')||name==='.dds-generate.lock')); +}); +test('generation rejects linked destinations, extras, stale locks and malformed source without executing it',async t=>{ + const f=await fixture(t),directory=path.join(f.root,'command');await initPlugin(directory);const before=await generatePluginContracts(directory,{check:true}); + await fs.writeFile(path.join(directory,'generated/unrelated.txt'),'preserve');await assert.rejects(generatePluginContracts(directory,{expectedDigest:before.digest}),{code:'conflict'});await fs.unlink(path.join(directory,'generated/unrelated.txt')); + const source=JSON.parse(await fs.readFile(path.join(directory,'dds-dev.json'),'utf8'));source.commands[0].title='Changed';await fs.writeFile(path.join(directory,'dds-dev.json'),JSON.stringify(source)); + await fs.writeFile(path.join(directory,'.dds-generate.lock'),'previous operation');await assert.rejects(generatePluginContracts(directory,{expectedDigest:before.digest}),{code:'conflict'});assert.equal(await fs.readFile(path.join(directory,'.dds-generate.lock'),'utf8'),'previous operation');await fs.unlink(path.join(directory,'.dds-generate.lock')); + source.run='throw new Error()';await fs.writeFile(path.join(directory,'dds-dev.json'),JSON.stringify(source));await assert.rejects(generatePluginContracts(directory,{dryRun:true}),{code:'invalid_contract'}); + const linked=path.join(f.root,'linked');await fs.symlink(directory,linked,process.platform==='win32'?'junction':'dir');try{await assert.rejects(generatePluginContracts(linked,{check:true}),{code:'unsafe_path'});}finally{await fs.unlink(linked);} +}); +test('injected partial write failures remove only owned entries and preserve additional files',async t=>{ + const f=await fixture(t),files=new Map([['a.mjs','one'],['nested/b.mjs','two']]); + for(const preserve of [false,true]){ + const destination=path.join(f.root,preserve?'preserve':'rollback');let calls=0; + await assert.rejects(writeNewDirectory(destination,files,{async write(handle,text){await handle.writeFile(text);if(++calls===2){if(preserve)await fs.writeFile(path.join(destination,'unrelated.txt'),'preserve');const error=new Error('Synthetic disk full');error.code='ENOSPC';throw error;}}}),error=>error.code==='write_failed'&&error.cleanupPreserved===preserve); + if(preserve){assert.deepEqual(await fs.readdir(destination),['unrelated.txt']);assert.equal(await fs.readFile(path.join(destination,'unrelated.txt'),'utf8'),'preserve');}else await assert.rejects(fs.stat(destination),{code:'ENOENT'}); + } +}); +test('failed generation preserves edits to an already-created owned file',async t=>{ + const f=await fixture(t),destination=path.join(f.root,'edited');let calls=0; + await assert.rejects(writeNewDirectory(destination,new Map([['a.txt','original'],['b.txt','second']]),{async write(handle,text){ + await handle.writeFile(text);if(++calls===2){await fs.writeFile(path.join(destination,'a.txt'),'user edit');throw Object.assign(new Error('Synthetic write failure'),{code:'ENOSPC'});} + }}),error=>error.code==='write_failed'&&error.cleanupPreserved===true); + assert.deepEqual(await fs.readdir(destination),['a.txt']);assert.equal(await fs.readFile(path.join(destination,'a.txt'),'utf8'),'user edit'); +}); + +test('caller-selected proprietary terms keep inherited license and notice bytes',async t=>{ + const f=await fixture(t),license=path.join(f.root,'terms.txt'),directory=path.join(f.root,'private-plugin');await fs.writeFile(license,'Synthetic proprietary fixture. No distribution rights are granted.\n'); + await initPlugin(directory,{license:'LicenseRef-Proprietary',licenseFile:license});assert.equal((await validatePluginPackage(directory)).pluginId,'private-plugin'); + const manifest=JSON.parse(await fs.readFile(path.join(directory,'plugin.json'),'utf8'));assert.equal(manifest.source.visibility,'closed');assert.equal(manifest.license,'LicenseRef-Proprietary');assert.equal(await fs.readFile(path.join(directory,'LICENSE'),'utf8'),await fs.readFile(license,'utf8'));assert.equal(await fs.readFile(path.join(directory,'LICENSE.sdk'),'utf8'),await fs.readFile(path.join(sdk,'LICENSE'),'utf8')); +}); +test('CLI generation exposes preview/check and source drift as a nonzero status',async t=>{ + const f=await fixture(t),directory=path.join(f.root,'cli');assert.equal(JSON.parse(run([cli,'init',directory,'--dry-run'],f.root)).writes,false);await assert.rejects(fs.stat(directory),{code:'ENOENT'}); + run([cli,'init',directory,'--template','configuration'],f.root);assert.equal(JSON.parse(run([cli,'generate',directory,'--check'],f.root)).ok,true); + await fs.appendFile(path.join(directory,'generated/contracts.mjs'),'// changed\n');const r=spawnSync(process.execPath,[cli,'generate',directory,'--check'],{encoding:'utf8',windowsHide:true,timeout:10_000});assert.equal(r.status,1);assert.equal(JSON.parse(r.stdout).ok,false); +}); + +test('doctor reports newly generated schemas missing from the distribution allowlist',async t=>{ + const f=await fixture(t),directory=path.join(f.root,'extended');await initPlugin(directory);await f.install(directory);const previous=await generatePluginContracts(directory,{check:true}); + const sourcePath=path.join(directory,'dds-dev.json'),source=JSON.parse(await fs.readFile(sourcePath,'utf8'));source.commands.push({...source.commands[0],id:'another'});await fs.writeFile(sourcePath,JSON.stringify(source));await generatePluginContracts(directory,{expectedDigest:previous.digest}); + const report=await doctorPlugin(directory);assert.equal(report.ok,false);assert.equal(report.checks.find(check=>check.id==='generated-package').status,'error'); + const packagePath=path.join(directory,'dds-package.json'),metadata=JSON.parse(await fs.readFile(packagePath,'utf8'));metadata.files.push('generated/schemas/another-input.schema.json');await fs.writeFile(packagePath,JSON.stringify(metadata));assert.equal((await doctorPlugin(directory)).ok,true); +}); diff --git a/tests/devtools.test.mjs b/tests/devtools.test.mjs index d2ccb38..4c3a1ec 100644 --- a/tests/devtools.test.mjs +++ b/tests/devtools.test.mjs @@ -23,6 +23,37 @@ async function fixture(t,{installed=true}={}) { return{root,directory}; } async function gone(pid) {for(let i=0;i<100;i++){try{process.kill(pid,0);}catch(failure){if(failure.code==='ESRCH')return;throw failure;}await delay(20);}throw Error('Dev child survived cleanup');} + +test('profile reports are opt-in, bounded and exclude command inputs and provider error bodies',async t=>{ + const {directory}=await fixture(t),events=[]; + const normal=await runPluginDev(directory,{trustLocalCode:true,command:'greet',input:{name:'synthetic-input'},onEvent:e=>events.push(e)});assert.equal(normal.ok,true);assert.ok(!events.some(e=>e.type==='profile'));events.length=0; + const profiled=await runPluginDev(directory,{trustLocalCode:true,profile:true,command:'greet',input:{name:'synthetic-private-input'},onEvent:e=>events.push(e)});assert.equal(profiled.ok,true);const report=events.find(e=>e.type==='profile').report; + assert.ok(report.events.some(e=>e.operation==='command'));assert.equal(report.events.at(-1).operation,'dispose');assert.equal(report.events.at(-1).resources.commands,0);assert.ok(!JSON.stringify(report).includes('synthetic-private'));assert.ok(!JSON.stringify(report).includes(directory));for(const e of events.filter(e=>e.type==='start'))await gone(e.pid); +}); + +test('debug binds a fresh loopback inspector, resumes through its actual protocol and closes with the child',async t=>{ + const {directory}=await fixture(t),ready=deferred(),controller=new AbortController(),events=[]; + const running=runPluginDev(directory,{trustLocalCode:true,debug:true,debugWait:true,profile:true,command:'greet',input:{name:'Debug'},timeoutMs:8000,signal:controller.signal,onEvent:e=>{events.push(e);if(e.type==='debug')ready.resolve(e);}});let socket; + try{ + const event=await Promise.race([ready.promise,running.then(()=>{throw Error('Debug child ended before readiness');})]),url=new URL(event.url);assert.equal(url.hostname,'127.0.0.1');assert.ok(Number(url.port)>0);assert.ok(event.expiresInMs<=8000); + const response=await fetch(`http://127.0.0.1:${url.port}/json/list`,{signal:AbortSignal.timeout(2000)}),targets=await response.json();assert.ok(targets.some(target=>target.webSocketDebuggerUrl===event.url)); + socket=new WebSocket(event.url);await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});});socket.send(JSON.stringify({id:1,method:'Runtime.runIfWaitingForDebugger'})); + assert.equal((await running).ok,true);await gone(event.pid);await assert.rejects(fetch(`http://127.0.0.1:${url.port}/json/list`,{signal:AbortSignal.timeout(500)}));assert.ok(events.some(e=>e.type==='profile')); + }finally{socket?.close();controller.abort();await running;} +}); + +test('debug timeout, cancellation and crash clean only owned children and reject external bind options',async t=>{ + const {directory}=await fixture(t); + await assert.rejects(runPluginDev(directory,{trustLocalCode:true,debugWait:true}),/requires debug/); + await assert.rejects(runPluginDev(directory,{trustLocalCode:true,debug:true,debugHost:'0.0.0.0'}),/Invalid development options/); + await assert.rejects(runPluginDev(directory,{trustLocalCode:true,debug:true,timeoutMs:30001}),/30000/); + for(const mode of ['timeout','cancel','crash']){ + const events=[],controller=new AbortController();if(mode==='crash')await fs.writeFile(path.join(directory,'plugin.mjs'),'process.exit(7);\n'); + const result=await runPluginDev(directory,{trustLocalCode:true,debug:true,debugWait:mode!=='crash',timeoutMs:mode==='timeout'?1500:5000,signal:controller.signal,onEvent:e=>{events.push(e);if(e.type==='debug'&&mode==='cancel')controller.abort();}}); + const child=events.find(e=>e.type==='start');await gone(child.pid);if(mode==='cancel')assert.equal(result.stopped,true);else assert.equal(result.ok,false);if(mode==='timeout')assert.ok(events.some(e=>e.type==='timeout')); + const debug=events.find(e=>e.type==='debug');if(debug){const port=new URL(debug.url).port;await assert.rejects(fetch(`http://127.0.0.1:${port}/json/list`,{signal:AbortSignal.timeout(500)}));} + } +}); test('init creates a valid standalone scaffold and never replaces existing content',async t=>{ const {directory}=await fixture(t,{installed:false});const before=await fs.readFile(path.join(directory,'plugin.mjs'),'utf8'); assert.equal((await validatePluginPackage(directory)).pluginId,'sample-plugin'); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs new file mode 100644 index 0000000..36ea5d5 --- /dev/null +++ b/tests/diagnostics.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createDiagnosticSession,parseDiagnosticReport,profileHost,profileTransport,DIAGNOSTIC_RESOURCES} from '../src/diagnostics.mjs'; +import {createScenarioHost,flushTestMicrotasks} from '../src/testing.mjs'; +import {definePlugin,PluginSdkError} from '../src/index.mjs'; +import {deferred} from './fixtures.mjs'; + +const manifest={manifestVersion:2,id:'synthetic-profile',name:'Synthetic profile',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./plugin.mjs',runtime:'workspace',capabilities:['commands'],permissions:[],supportedHosts:['workspace-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}}; + +test('diagnostics are disabled by default and do not collect inputs, output or arbitrary errors',async()=>{ + let calls=0;const off=createDiagnosticSession({now:()=>{throw Error('Clock should not run');}});assert.equal(off.measure('command',()=>++calls),1);assert.equal(off.snapshot().enabled,false);assert.deepEqual(off.snapshot().events,[]); + const local=createDiagnosticSession({enabled:true,now:()=>10});const error=new Error('synthetic secret /private/source customer body');await assert.rejects(local.measure('command',async()=>{throw error;}),e=>e===error);const report=local.snapshot();assert.equal(report.events[0].errorCode,'provider_failed');assert.ok(!JSON.stringify(report).includes('private'));assert.ok(!JSON.stringify(report).includes('secret'));assert.equal(calls,1); + assert.throws(()=>local.begin('command',{input:'private'}));assert.throws(()=>parseDiagnosticReport({...report,secret:'private'})); +}); + +test('manual spans correlate fixed stages and accept bounded byte/resource estimates only',()=>{ + let now=100;const d=createDiagnosticSession({enabled:true,now:()=>now}),command=d.begin('command',{inputBytes:8,queueDepth:2});now=105; + const transport=d.begin('transport',{parentId:command.id,inputBytes:64});now=115;transport.end({outputBytes:128,resources:{memoryBytes:1024}});now=120;command.end({outputBytes:12,resources:{pendingOperations:0}}); + const report=d.snapshot(),[child,parent]=report.events;assert.equal(child.parentId,parent.id);assert.equal(child.durationMs,10);assert.equal(parent.durationMs,20);assert.equal(child.outputBytes,128);assert.equal(parent.queueDepth,2);assert.equal(report.pending,0); + assert.throws(()=>d.begin('arbitrary-path'));assert.throws(()=>d.begin('command',{parentId:'d999'}));assert.throws(()=>d.begin('command',{inputBytes:-1}));const live=d.begin('command');assert.throws(()=>live.end({errorCode:'private error'}));live.end({status:'cancelled',errorCode:'cancelled'}); +}); + +test('ring capacity, sampling, passive TTL, inflight cap and export bounds stay finite',()=>{ + let now=0;const d=createDiagnosticSession({enabled:true,capacity:3,sampleEvery:2,retentionMs:50,now:()=>now});for(let i=0;i<20;i++)d.measure('command',()=>null);assert.equal(d.snapshot().attempted,20);assert.equal(d.snapshot().sampled,10);assert.equal(d.snapshot().events.length,3);now=50;assert.equal(d.snapshot().events.length,0); + const bounded=createDiagnosticSession({enabled:true,now:()=>now});for(let i=0;i<100;i++)bounded.begin('command');assert.equal(bounded.snapshot().pending,64);assert.equal(bounded.snapshot().dropped,36);bounded.dispose();assert.equal(bounded.snapshot().pending,0);assert.equal(bounded.snapshot().dropped,100); + const maximum=createDiagnosticSession({enabled:true,capacity:256,now:()=>now}),resources=Object.fromEntries(DIAGNOSTIC_RESOURCES.map(key=>[key,1_000_000_000_000]));for(let i=0;i<300;i++){const span=maximum.begin('transport',{inputBytes:1_000_000_000_000,queueDepth:1_000_000_000_000});span.end({outputBytes:1_000_000_000_000,resources});}const report=maximum.snapshot();assert.equal(report.events.length,256);assert.ok(Buffer.byteLength(JSON.stringify(report))<262144); +}); + +test('profiled host keeps actual pending resources visible after caller cancellation',async()=>{ + const s=createScenarioHost(),d=createDiagnosticSession({enabled:true,now:s.clock.now}),host=profileHost(s.host,d),gate=deferred();await host.activate(definePlugin(manifest,ctx=>ctx.registerCommand({id:'wait',title:'Wait'},()=>gate.promise))); + const p=host.executeCommand(manifest.id,'wait',{}, {timeoutMs:10}),rejected=assert.rejects(p,{code:'budget_exceeded'});await s.clock.advance(10);await rejected; + const event=d.snapshot().events.find(event=>event.operation==='command');assert.equal(event.durationMs,10);assert.equal(event.resources.pendingOperations,1);assert.equal(event.errorCode,'budget_exceeded');host.dispose();assert.equal(d.snapshot().events.at(-1).resources.commands,0); + gate.resolve(null);await flushTestMicrotasks();await s.dispose();s.assertClean();d.dispose(); +}); + +test('instrumented transport preserves receiver/results and reports safe failures without body reads',async()=>{ + const d=createDiagnosticSession({enabled:true}),response={get body(){throw Error('Must not collect payload');}},owner={response};owner.send=profileTransport(function(_input){return this.response;},d);assert.equal(owner.send({authorization:'synthetic-private'}),response); + const error=new PluginSdkError('cancelled','private body');const rejecting=profileTransport(async()=>{throw error;},d);await assert.rejects(rejecting(),e=>e===error);const report=d.snapshot();assert.equal(report.events[0].operation,'transport');assert.equal(report.events[1].status,'cancelled');assert.ok(!JSON.stringify(report).includes('authorization')); +}); + +test('broken diagnostic clock cannot break a measured operation and hostile metadata hooks never run',()=>{ + const d=createDiagnosticSession({enabled:true,now:()=>{throw Error('Synthetic clock failure');}});assert.equal(d.measure('command',()=>42),42); + let reads=0;const valid=createDiagnosticSession({enabled:true});assert.throws(()=>valid.begin('command',{get inputBytes(){reads++;return 1;}}));assert.equal(reads,0); +}); diff --git a/tests/publishing.test.mjs b/tests/publishing.test.mjs index 5e2d074..b8152a2 100644 --- a/tests/publishing.test.mjs +++ b/tests/publishing.test.mjs @@ -72,7 +72,7 @@ test('open and proprietary packages use explicit files, never execute code, and const npmMetadata = JSON.parse(archive.get('package/package.json')); assert.equal(npmMetadata.name, '@example/hello'); assert.equal(npmMetadata.exports, './plugin.mjs'); - assert.equal(npmMetadata.peerDependencies['@altifigence/dds-plugin-sdk'], '>=0.12.0 <0.13.0'); + assert.equal(npmMetadata.peerDependencies['@altifigence/dds-plugin-sdk'], '>=0.13.0 <0.14.0'); assert.equal(npmMetadata.scripts, undefined); assert.equal(JSON.parse(await readFile(packed.metadataPath, 'utf8')).artifact.sha256, packed.artifact.sha256); } diff --git a/tests/settings.test.mjs b/tests/settings.test.mjs index 50878a2..2cb62ae 100644 --- a/tests/settings.test.mjs +++ b/tests/settings.test.mjs @@ -49,7 +49,8 @@ test('coalesced subscriptions are removable and isolate failing callbacks', asyn store.update({workspaceId: 'w', scope: 'workspace', expectedRevision: 1, values: {count: 4}}); unsubscribe.dispose(); await flush(); assert.equal(first.length, 1); assert.equal(first[0].values.count, 4); assert.equal(second[0].values.count, 3); assert.deepEqual(removed, []); - store.dispose(); assert.throws(() => store.inspect(), hasCode('disposed')); + store.dispose(); assert.deepEqual(store.inspect(), {revision: 2, definitionVersion: 1, workspaces: 0, subscriptions: 0, pendingMigrations: 0}); + assert.throws(() => store.read('w'), hasCode('disposed')); }); test('explicit migrations validate changed/deleted keys and preserve valid state on error', async () => { diff --git a/tests/testing-scenarios.test.mjs b/tests/testing-scenarios.test.mjs new file mode 100644 index 0000000..70adf37 --- /dev/null +++ b/tests/testing-scenarios.test.mjs @@ -0,0 +1,140 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {createPluginHost,definePlugin,createDiagnosticsResult,createLanguageResult} from '../src/index.mjs'; +import {createTestClock,flushTestMicrotasks,createScenarioHost,createFaultController,createMemoryWorkspace,createTestRecorder,createTestReplay,parseTestTrace,createTestResources,createMemoryJobStore,TEST_LIMITS} from '../src/testing.mjs'; +import {createConfigurationPlugin,settingsDefinition} from '../examples/configuration/plugin.mjs'; +import {document,makePlugin,deferred} from './fixtures.mjs'; + +const sha=value=>createHash('sha256').update(value).digest('hex'); +const manifest={manifestVersion:2,id:'synthetic-plugin',name:'Synthetic',publisher:'example',version:'1.0.0',protocolVersion:1,entry:'./plugin.mjs',runtime:'workspace',capabilities:['commands'],permissions:['workspace.read','workspace.write','backend.invoke'],supportedHosts:['test-host','workspace-host'],license:'Apache-2.0',source:{visibility:'open',licenseFile:'LICENSE'}}; +const commandPlugin=handler=>definePlugin(manifest,context=>context.registerCommand({id:'example',title:'Example'},(input,options)=>handler(context,input,options))); +const scenario=async(t,options={})=>{const s=createScenarioHost(options);t.after(async()=>{await s.dispose();s.assertClean();});return s;}; + +test('virtual clocks preserve globals, reproduce IDs/order and reject timer storms and concurrent advancement',async()=>{ + const before=Date.now,timeout=setTimeout,a=createTestClock({seed:42,start:1000}),b=createTestClock({seed:42,start:1000}); + assert.deepEqual(Array.from({length:10},()=>a.runtime.randomUUID()),Array.from({length:10},()=>b.runtime.randomUUID())); + const order=[];a.runtime.setTimeout(()=>order.push('late'),20);a.runtime.setTimeout(()=>{order.push('first');a.runtime.setTimeout(()=>order.push('nested'),0);},10);a.runtime.setTimeout(()=>order.push('second'),10); + await a.advance(10);assert.deepEqual(order,['first','second','nested']);assert.equal(a.now(),1010);assert.equal(a.inspect().timers,1);await a.advance(10);assert.deepEqual(order,['first','second','nested','late']);assert.equal(Date.now,before);assert.equal(setTimeout,timeout); + const running=a.advance(1);await assert.rejects(a.advance(1),{code:'conflict'});await running; + const storm=()=>a.runtime.setTimeout(storm,0);storm();await assert.rejects(a.advance(0,{maxSteps:10}),{code:'budget_exceeded'});a.dispose();b.dispose();assert.equal(a.inspect().timers,0); +}); + +test('clock drives actual host timeout and retains ignored operations through disposal',async()=>{ + const s=createScenarioHost(),gate=deferred();await s.host.activate(commandPlugin(()=>gate.promise)); + const work=s.host.executeCommand(manifest.id,'example',{}, {timeoutMs:20});const rejection=assert.rejects(work,{code:'budget_exceeded'});await s.clock.advance(20);await rejection; + assert.equal(s.host.inspect().pendingOperations,1);assert.equal(s.clock.inspect().timers,0);await s.dispose();assert.throws(()=>s.assertClean(),{code:'conflict'}); + gate.resolve(null);await flushTestMicrotasks();s.assertClean(); +}); + +test('pending activation stays visible after timeout and is released only on actual settlement',async()=>{ + const s=createScenarioHost(),gate=deferred(),work=s.host.activate(definePlugin(manifest,()=>gate.promise));const rejection=assert.rejects(work,{code:'budget_exceeded'});await s.clock.advance(5000);await rejection; + assert.equal(s.host.inspect().pendingActivations,1);await s.dispose();assert.throws(()=>s.assertClean(),{code:'conflict'});gate.resolve();await flushTestMicrotasks();s.assertClean(); +}); + +test('diagnostics and language operations use virtual time and retain ignored cancellation',async()=>{ + const s=createScenarioHost({hostId:'test-host',grants:['document.read','diagnostics.publish','language.provide']}),gate=deferred(); + await s.host.activate(makePlugin(async request=>{await gate.promise;return createDiagnosticsResult(request,[]);}));s.host.setDocument(document); + const requested=s.host.requestDiagnostics({timeoutMs:10}),rejected=assert.rejects(requested,{code:'budget_exceeded'});await s.clock.advance(10);await rejected;assert.equal(s.host.inspect().providerOperations,1); + await s.dispose();assert.throws(()=>s.assertClean(),{code:'conflict'});gate.resolve();await flushTestMicrotasks();s.assertClean(); + const language=createScenarioHost({hostId:'test-host',grants:['document.read','language.provide']}); + await language.host.activate(definePlugin({...manifest,runtime:'ui',capabilities:['completion','completion-resolve'],permissions:['document.read','language.provide']},ctx=>ctx.registerLanguageProvider('completion',{languages:['plaintext']},{provide:r=>createLanguageResult(r,[{label:'example',insertText:'example',resolveData:{index:1}}]),resolve:(_r,item)=>({...item,detail:'Resolved'})}))); + language.host.setDocument(document);const result=await language.host.requestLanguage('completion',{position:{line:0,character:0}});assert.equal(language.host.inspect().resolveTokens,1);assert.ok(result.data[0].resolveToken);await language.clock.advance(60001);assert.equal(language.host.inspect().resolveTokens,0);await language.dispose();language.assertClean(); +}); + +test('explicit grants revoke pending backend work and remain revoked on reactivation',async t=>{ + const s=await scenario(t,{grants:['backend.invoke'],backends:{analyzer:{ok:true}}});const plugin=commandPlugin(ctx=>ctx.backends.invoke('analyzer',{}));await s.host.activate(plugin); + s.faults.enqueue({operation:'backend.analyzer',kind:'delay',delayMs:100});const work=s.host.executeCommand(manifest.id,'example',{}),rejected=assert.rejects(work,{code:'disposed'});await flushTestMicrotasks();s.replaceGrants([]);await rejected;await flushTestMicrotasks();assert.equal(s.faults.inspect().pending,0);assert.equal(s.clock.inspect().timers,0); + await s.host.activate(plugin);await assert.rejects(s.host.executeCommand(manifest.id,'example',{}),{code:'permission_denied'}); +}); + +test('memory workspace enforces CAS, byte budgets, portable paths and emits bounded synthetic snapshots',async t=>{ + const s=await scenario(t,{files:{'src/example.txt':'old'},grants:['workspace.read','workspace.write']}),w=s.workspace; + const before=await w.port.readFile('src/example.txt');assert.equal(before.revision,sha('old')); + const observed=[],subscription=w.subscribe('src',snapshot=>observed.push(snapshot)); + await w.port.writeFile('src/example.txt','new',{expectedRevision:before.revision});assert.equal(observed.length,1);assert.equal(observed[0].entries[0].revision,sha('new')); + await assert.rejects(w.port.writeFile('src/example.txt','stale',{expectedRevision:before.revision}),{code:'conflict'});assert.equal((await w.port.readFile('src/example.txt')).content,'new'); + assert.throws(()=>w.setFile('SRC/EXAMPLE.TXT','bad'),{code:'conflict'});assert.throws(()=>w.setFile('../escape','bad'));assert.throws(()=>w.setFile('large.bin',new Uint8Array(TEST_LIMITS.fileBytes+1)));subscription.dispose();assert.equal(w.inspect().subscriptions,0); + s.replaceGrants([]);await assert.rejects(w.port.readFile('src/example.txt'),{code:'permission_denied'}); +}); + +test('memory binary chunks and job events are deterministic and corruption is surfaced to callers',async t=>{ + const input=new Uint8Array([0,1,2,255]),s=await scenario(t,{seed:88,start:2000,files:{'result.bin':input},grants:['workspace.read'],jobs:true,binaryArtifacts:true}); + const source=await s.workspace.port.captureBinaryFile('result.bin'),chunk=await source.readChunk(0,4);assert.equal(chunk.sha256,sha(input));assert.equal(chunk.data,'AAEC/w=='); + await s.host.activate(commandPlugin(async(_ctx,_input,{job})=>{await job.addBinaryArtifact({id:'result',path:'result.bin'});return {ok:true};}));const id=s.clock.runtime.randomUUID();s.host.startCommandJob(manifest.id,'example',{}, {jobId:id}); + await flushTestMicrotasks(128);assert.equal(s.host.getJob(id).state,'succeeded');assert.ok(s.host.getJobEvents(id).events.every(event=>event.at===2000)); + s.faults.enqueue({operation:'file.chunk',kind:'corrupt',value:{offset:0,nextOffset:4,eof:true,data:'AAEC/w==',sha256:'0'.repeat(64)}}); + await assert.rejects(s.host.readJobBinaryArtifactChunk(id,'result',sha(input),0,4),{code:'conflict'}); +}); + +test('faults delay, reject, disconnect, exhaust and drop cooperatively; event faults do not repeat effects',async()=>{ + const clock=createTestClock(),f=createFaultController(clock.runtime);let effects=0; + for(const [kind,code]of [['deny','permission_denied'],['disconnect','capability_unavailable'],['exhaust','budget_exceeded']]){f.enqueue({operation:'example',kind});await assert.rejects(f.invoke('example',()=>effects++),{code});} + f.enqueue({operation:'example',kind:'drop'});const controller=new AbortController(),dropped=f.invoke('example',()=>effects++,{signal:controller.signal}),rejected=assert.rejects(dropped,{code:'cancelled'});controller.abort();await rejected; + f.enqueue({operation:'example',kind:'delay',delayMs:10});const delayed=f.invoke('example',()=>++effects);await clock.advance(9);assert.equal(effects,0);await clock.advance(1);assert.equal(await delayed,1); + assert.deepEqual(f.events([{sequence:1},{sequence:2}],{duplicate:0,reverse:true}),[{sequence:2},{sequence:1},{sequence:1}]);assert.equal(effects,1);assert.deepEqual(f.inspect(),{queued:0,pending:0});f.dispose();clock.dispose(); +}); + +test('settings, secrets and command jobs share the selected clock and clean subscriptions/leases',async t=>{ + const s=await scenario(t,{settings:[settingsDefinition],secrets:{token:new TextEncoder().encode('synthetic-token-only')},grants:['settings.read','secrets.resolve'],jobs:true}),store=s.settings['configuration-example']; + const reference=s.secrets.issue({secretId:'token',pluginId:'configuration-example',workspaceId:'synthetic-project',commandIds:['summarize'],ttlMs:50});await s.host.activate(createConfigurationPlugin()); + const input={options:{names:['one','two']},token:reference};assert.deepEqual(await s.host.executeCommand('configuration-example','summarize',input),{count:2,total:4,mode:'short',authorized:true}); + store.update({workspaceId:'synthetic-project',scope:'workspace',expectedRevision:0,values:{multiplier:3}});assert.equal((await s.host.executeCommand('configuration-example','summarize',input)).total,6); + await s.clock.advance(50);await assert.rejects(s.host.executeCommand('configuration-example','summarize',input),{code:'permission_denied'});assert.equal(s.secrets.inspect().references,0); +}); + +test('secret timeout erases SDK bytes even when a callback ignores virtual cancellation',async()=>{ + const s=createScenarioHost({secrets:{token:new Uint8Array([1,2,3])},grants:['secrets.resolve']}),gate=deferred();const reference=s.secrets.issue({secretId:'token',pluginId:'synthetic-plugin',workspaceId:'synthetic-project',commandIds:['example']});let borrowed; + const p=s.secrets.withSecret(reference,{pluginId:'synthetic-plugin',workspaceId:'synthetic-project',commandId:'example',executionId:s.clock.runtime.randomUUID()},async bytes=>{borrowed=bytes;await gate.promise;},{timeoutMs:10}),rejected=assert.rejects(p,{code:'budget_exceeded'}); + await s.clock.advance(10);await rejected;assert.deepEqual([...borrowed],[0,0,0]);await s.dispose();assert.throws(()=>s.assertClean(),{code:'conflict'});gate.resolve();await flushTestMicrotasks();s.assertClean(); +}); + +test('trace exports hashes only and replay rejects altered order, seed, fixture version and injected fields',()=>{ + const options={synthetic:true,seed:17,fixtureVersion:1,fixtures:[{id:'greeting',operation:'command',input:{value:'synthetic-input'},output:{value:'synthetic-output'}}]},recorder=createTestRecorder(options); + recorder.record('greeting',{value:'synthetic-input'},{value:'synthetic-output'});recorder.record('greeting',{value:'synthetic-input'},{value:'synthetic-output'});const trace=recorder.snapshot(),json=JSON.stringify(trace);assert.ok(!json.includes('synthetic-input'));assert.ok(!json.includes('synthetic-output')); + const replay=createTestReplay(trace,options);assert.deepEqual(replay.next('greeting',{value:'synthetic-input'}),{value:'synthetic-output'});assert.throws(()=>replay.assertComplete(),{code:'conflict'});replay.next('greeting',{value:'synthetic-input'});replay.assertComplete();assert.throws(()=>replay.next('greeting',{value:'synthetic-input'}),{code:'conflict'}); + for(const key of ['seed','fixtureVersion'])assert.throws(()=>createTestReplay(trace,{...options,[key]:2}),{code:'version_mismatch'}); + const reversed=structuredClone(trace);reversed.events.reverse();assert.throws(()=>parseTestTrace(reversed),{code:'conflict'});const corrupt=structuredClone(trace);corrupt.events[0].outputSha256='0'.repeat(64);assert.throws(()=>parseTestTrace(corrupt),{code:'conflict'}); + assert.throws(()=>parseTestTrace({...trace,rawInput:'private'}));assert.throws(()=>createTestRecorder({...options,synthetic:false}));assert.throws(()=>recorder.record('greeting',{value:'unselected'},{}),{code:'conflict'}); + const same=createTestRecorder(options);same.record('greeting',{value:'synthetic-input'},{value:'synthetic-output'});same.record('greeting',{value:'synthetic-input'},{value:'synthetic-output'});assert.deepEqual(same.snapshot(),trace); +}); + +test('trace event budget remains serializable and replayable at its advertised boundary',()=>{ + const options={synthetic:true,seed:1,fixtureVersion:1,fixtures:[{id:'boundary',operation:'command',input:{},output:{ok:true}}]},recorder=createTestRecorder(options); + for(let i=0;i<1024;i++)recorder.record('boundary',{}, {ok:true}); + const trace=parseTestTrace(JSON.parse(JSON.stringify(recorder.snapshot())));assert.equal(trace.events.length,1024); + assert.throws(()=>recorder.record('boundary',{}, {ok:true}),{code:'budget_exceeded'}); + const replay=createTestReplay(trace,options);for(let i=0;i<1024;i++)assert.deepEqual(replay.next('boundary',{}),{ok:true});replay.assertComplete(); +}); + +test('owner resource ledger keeps failed cleanup visible and repeated host lifecycles leave no owned work',async()=>{ + const resources=createTestResources();let closed=0,fail=true; + const handle=resources.track('file-handle',()=>{if(fail)throw new Error('synthetic failure');closed++;});assert.throws(()=>resources.assertEmpty(),{code:'conflict'});await assert.rejects(handle.release());assert.equal(resources.inspect()['file-handle'],1);fail=false;await handle.release();await handle.release();assert.equal(closed,1);resources.assertEmpty(); + for(let i=0;i<25;i++){const s=createScenarioHost();await s.host.activate(commandPlugin(()=>({ok:true})));await s.host.executeCommand(manifest.id,'example',{});await s.dispose();s.assertClean();} +}); + +test('trusted runtime ports and fixture metadata reject accessor hooks and unsupported inputs',()=>{ + let invoked=0;assert.throws(()=>createPluginHost({runtime:{get now(){invoked++;return()=>0;},setTimeout(){},clearTimeout(){},randomUUID(){}}}));assert.equal(invoked,0); + assert.throws(()=>createTestClock({seed:-1}));assert.throws(()=>createMemoryWorkspace({files:{get example(){invoked++;return'bad';}}}));assert.equal(invoked,0);assert.throws(()=>createScenarioHost({grants:['unknown']})); +}); + +test('memory checkpoints simulate restart without re-execution and reject corrupted journals, origin changes and stale CAS',async()=>{ + const clock=createTestClock({seed:52,start:2000}),identity={schemaVersion:1,storeId:clock.runtime.randomUUID(),workspaceId:'synthetic-project',workspaceIdentity:sha('synthetic-workspace')},store=createMemoryJobStore({identity}); + const storage=store=>({store,workspaceIdentity:identity.workspaceIdentity,pluginArtifacts:{[manifest.id]:sha('synthetic-plugin-artifact')}}); + const first=createScenarioHost({seed:53,start:2000,grants:['workspace.read'],jobs:true,jobStorage:storage(store)}),gate=deferred();let executions=0;const plugin=commandPlugin(async()=>{executions++;await gate.promise;return {ok:true};});await first.host.activate(plugin); + const jobId=first.clock.runtime.randomUUID();first.host.startCommandJob(manifest.id,'example',{}, {jobId});await first.host.flushJobStore();await flushTestMicrotasks();assert.equal(executions,1); + const saved=store.snapshot();assert.equal(saved.records[0].savedAt,2000);const altered=structuredClone(saved);altered.records[0].requestSha256='0'.repeat(64);assert.throws(()=>createMemoryJobStore({identity,snapshot:altered}),{code:'conflict'}); + assert.throws(()=>createMemoryJobStore({identity:{...identity,workspaceIdentity:sha('different')},snapshot:saved}),{code:'conflict'}); + const recoveredStore=createMemoryJobStore({identity,snapshot:saved}),second=createScenarioHost({seed:54,start:2000,grants:['workspace.read'],jobs:true,jobStorage:storage(recoveredStore)});await second.host.activate(plugin); + assert.equal(second.host.recoverJob(manifest.id,jobId).disposition,'interrupted');assert.equal(executions,1);assert.equal(second.host.listJobHistory(manifest.id).asOf,2000); + const record=recoveredStore.get(jobId);await assert.rejects(recoveredStore.write(record,0),{code:'conflict'}); + recoveredStore.markUnavailable(jobId,'corrupt');assert.equal(second.host.recoverJob(manifest.id,jobId).disposition,'corrupt');recoveredStore.markUnavailable(jobId,'unsupported');assert.equal(second.host.recoverJob(manifest.id,jobId).disposition,'unsupported'); + gate.resolve();await flushTestMicrotasks(128);await first.host.flushJobStore();await first.dispose();await first.host.flushJobStore();first.assertClean();assert.equal(store.inspect().pins,0);await second.dispose();second.assertClean();store.dispose();recoveredStore.dispose();clock.dispose(); +}); + +test('virtual settings migration timeout retains unfinished work and grants do not imply unmounted capabilities',async()=>{ + const s=createScenarioHost({settings:[settingsDefinition],grants:['workspace.read']}),store=s.settings['configuration-example'],gate=deferred();await s.host.activate(commandPlugin(ctx=>ctx.workspace.readFile('missing.txt'))); + await assert.rejects(s.host.executeCommand(manifest.id,'example',{}),{code:'capability_unavailable'});assert.equal(s.capabilities.workspace,false); + const pending=store.migrate({...settingsDefinition,version:2},()=>gate.promise,{expectedRevision:0,timeoutMs:10}),rejected=assert.rejects(pending,{code:'budget_exceeded'});await s.clock.advance(10);await rejected;assert.equal(store.inspect().pendingMigrations,1); + await s.dispose();assert.throws(()=>s.assertClean(),{code:'conflict'});gate.resolve({user:{},workspaces:{}});await flushTestMicrotasks();s.assertClean(); +}); diff --git a/tests/types/development.mts b/tests/types/development.mts new file mode 100644 index 0000000..ac9e0e9 --- /dev/null +++ b/tests/types/development.mts @@ -0,0 +1,35 @@ +import {createPluginHost,type HostRuntime,type JsonValue} from '@altifigence/dds-plugin-sdk'; +import {initPlugin,planPlugin,generatePluginContracts,parseDevelopmentDefinition,PLUGIN_TEMPLATES,runPluginDev,type PluginPlan} from '@altifigence/dds-plugin-sdk/devtools'; +import {createScenarioHost,createTestClock,createFaultController,createMemoryWorkspace,createTestResources,createMemoryJobStore,createTestRecorder,createTestReplay,parseTestTrace,type TestTraceOptions} from '@altifigence/dds-plugin-sdk/testing'; +import {createDiagnosticSession,profileHost,profileTransport,parseDiagnosticReport} from '@altifigence/dds-plugin-sdk/diagnostics'; +import {SCHEMAS} from '@altifigence/dds-plugin-sdk/schemas'; + +const clock=createTestClock({seed:17}),runtime:HostRuntime=clock.runtime; +const host=createPluginHost({runtime});host.replaceGrants(['document.read']);void host.inspect().pendingActivations;void host.inspect().pendingCheckpoints; +const planned:Promise=initPlugin('/operator/new-plugin',{template:'language',dryRun:true});void planned; +void initPlugin('/operator/new-plugin',{template:'configuration'}).then(plugin=>plugin.files.map(name=>name.toUpperCase())); +void planPlugin('/operator/new-plugin',{license:'LicenseRef-Proprietary',licenseFile:'/operator/terms.txt'});void generatePluginContracts('/operator/plugin',{expectedDigest:'a'.repeat(64)});void parseDevelopmentDefinition({});void PLUGIN_TEMPLATES; +// @ts-expect-error templates are closed, executable names are not templates +void initPlugin('/operator/new-plugin',{template:'arbitrary-executable'}); +// @ts-expect-error template license must be reviewed and supported +void initPlugin('/operator/new-plugin',{license:'unknown'}); +const scenario=createScenarioHost({files:{'example.txt':'synthetic'},backends:{analyzer:{ok:true}},settings:[],secrets:{token:new Uint8Array([1])}}); +const workspace=createMemoryWorkspace({runtime,files:{'example.txt':'text'}});void workspace.port.readFile('example.txt',{signal:new AbortController().signal});void workspace.snapshot();void scenario.inspect(); +const faults=createFaultController(runtime);faults.enqueue({operation:'file.read',kind:'delay',delayMs:10});faults.enqueue({operation:'file.chunk',kind:'corrupt',value:{}});void faults.invoke('example',()=>({ok:true})); +// @ts-expect-error delays are mandatory for delay faults +faults.enqueue({operation:'file.read',kind:'delay'}); +const resources=createTestResources();void resources.track('process',async()=>{}).release();void resources.dispose(); +// @ts-expect-error resources track fixed owner-selected kinds +resources.track('all-system-processes',()=>{}); +const store=createMemoryJobStore({identity:{schemaVersion:1,storeId:crypto.randomUUID(),workspaceId:'synthetic',workspaceIdentity:'a'.repeat(64)}});void store.snapshot(); +const fixtures:TestTraceOptions={synthetic:true,seed:1,fixtureVersion:1,fixtures:[{id:'synthetic',operation:'command',input:{},output:{ok:true}}]};const recorder=createTestRecorder({...fixtures,now:clock.now});recorder.record('synthetic',{},{ok:true});const trace=parseTestTrace(recorder.snapshot()),replay=createTestReplay(trace,fixtures);const value:JsonValue=replay.next('synthetic',{});void value; +// @ts-expect-error recording requires the caller's explicit synthetic opt-in +createTestRecorder({seed:1,fixtureVersion:1,fixtures:[]}); +const diagnostics=createDiagnosticSession({enabled:true,now:clock.now}),span=diagnostics.begin('command',{inputBytes:20});span.end({outputBytes:10,resources:{memoryBytes:100}});const number:number=diagnostics.measure('command',()=>42);void number; +const asyncResult:Promise=diagnostics.measure('transport',async()=>42);void asyncResult;void profileHost(host,diagnostics);void profileTransport(fetch,diagnostics);void parseDiagnosticReport(diagnostics.snapshot()); +// @ts-expect-error error text is never part of diagnostic metadata +span.end({message:'private'}); +// @ts-expect-error metric names do not allow arbitrary paths +diagnostics.begin('/operator/private'); +void runPluginDev('/operator/plugin',{trustLocalCode:true,debug:true,debugWait:true,profile:true,onEvent(event){if(event.type==='debug')void event.url;if(event.type==='profile')void event.report;}}); +void SCHEMAS['test-trace'];void SCHEMAS['development-definition'];void SCHEMAS['diagnostic-report'];