From 8b30afb328fc8fe0c4d2d0b348d53bdbcb009acc Mon Sep 17 00:00:00 2001 From: Forge Date: Tue, 22 Sep 2026 02:45:03 +0000 Subject: [PATCH] Add public extension author SDK, starter and graph test harness --- CHANGELOG.md | 3 +- README.md | 4 + __tests__/extension-author.test.ts | 81 ++++++++++ docs/extensions/authoring.md | 189 ++++++++++++++++++++++++ package.json | 1 + scripts/pack-npm.sh | 4 +- scripts/validation/extension-author.cjs | 109 ++++++++++++++ src/index.ts | 6 + src/plugins/author-template.ts | 76 ++++++++++ src/plugins/author.ts | 139 +++++++++++++++++ src/plugins/cli.ts | 32 ++-- src/plugins/loader.ts | 5 +- src/plugins/package.ts | 33 ++++- 13 files changed, 658 insertions(+), 24 deletions(-) create mode 100644 __tests__/extension-author.test.ts create mode 100644 docs/extensions/authoring.md create mode 100644 scripts/validation/extension-author.cjs create mode 100644 src/plugins/author-template.ts create mode 100644 src/plugins/author.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d3344e..6c8bee63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Add project-scoped framework and semantic extensions, managed package installation, worker loading, and graph refresh on install/update/enable/disable/remove. Failed activation preserves the previous project configuration and graph. - Add an external Drupal extension for declared routes/forms, services, hooks, plugins and event subscribers. Literal route paths can be explore endpoints; extension edges retain their labels and registration sites. Comments and quoted examples do not count as PHP invocations. -- Add a local marketplace preview with signed publisher submissions, immutable releases, publisher ownership checks and a companion connection for project selection. Mobile detail pages retain version, compatibility and source information. Hosting, compatible-version selection and a complete authoring kit remain follow-up work; this is not a release announcement. +- Add a local marketplace preview with signed publisher submissions, immutable releases, publisher ownership checks and a companion connection for project selection. Mobile detail pages retain version, compatibility and source information. Hosting and compatible-version selection remain follow-up work; this is not a release announcement. +- Extension authors can generate a starter with `codegraph extensions create`, test real graph contributions and removal with `codegraph extensions test`, and use the public SDK types and author guide without private core imports. ### Highlights diff --git a/README.md b/README.md index 693fcd48..8b23b0d5 100644 --- a/README.md +++ b/README.md @@ -707,6 +707,10 @@ ranking. It is *not* a filter: unlike the built-in `example/`, `sample/`, from some result sets outright — `deprioritize` only ever changes rank. Reach for `exclude` when you want something gone. +### Framework extension authoring (development preview) + +The development branch includes a [public extension author guide](docs/extensions/authoring.md), a starter generator, and a graph compatibility test harness. These commands are not yet in a published npm release. + ### Custom file extensions If your project uses a non-standard extension for a [supported diff --git a/__tests__/extension-author.test.ts b/__tests__/extension-author.test.ts new file mode 100644 index 00000000..8d0a9d9a --- /dev/null +++ b/__tests__/extension-author.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +// Public package surface only. Authors must not need private loader imports. +import { createExtensionProject, defineExtension, EXTENSION_API_VERSION, packExtension, testExtension, type CodeGraphPlugin } from '../src'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => fs.rmSync(root, { recursive: true, force: true }))); +function starter(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-author-tests-')); + roots.push(root); + const author = path.join(root, 'author'); + createExtensionProject(author, 'python-events'); + return author; +} +function changeJSON(root: string, file: string, change: (data: any) => void): void { + const target = path.join(root, file), data = JSON.parse(fs.readFileSync(target, 'utf8')); + change(data); fs.writeFileSync(target, JSON.stringify(data)); +} + +describe('external extension author kit', () => { + it('generates and tests a second framework using only public API, including negative graph expectations and removal', async () => { + expect(EXTENSION_API_VERSION).toBe(1); + const factory: CodeGraphPlugin = () => ({ frameworks: [] }); + expect(defineExtension(factory)).toBe(factory); + const author = starter(); + expect(fs.readFileSync(path.join(author, 'index.cjs'), 'utf8')).not.toMatch(/require\(|\/src\/|\/dist\//); + const report = await testExtension(author); + expect(report.cases).toHaveLength(2); + expect(report.cases.every(c => c.deterministic && c.removalClean)).toBe(true); + expect(report.cases.reduce((sum, c) => sum + c.assertions, 0)).toBe(8); + expect(packExtension(author)).toEqual(packExtension(author)); + }, 90_000); + + it('does not overwrite existing author work and rejects malformed IDs', () => { + const author = starter(), before = fs.readFileSync(path.join(author, 'index.cjs')); + expect(() => createExtensionProject(author, 'other')).toThrow(/EEXIST/); + expect(fs.readFileSync(path.join(author, 'index.cjs'))).toEqual(before); + expect(() => createExtensionProject(path.join(author, 'bad'), '../escape')).toThrow(/manifest/); + expect(fs.existsSync(path.join(author, 'bad'))).toBe(false); + }); + + it('reports API and engine compatibility before evaluating author code', async () => { + const author = starter(); + fs.writeFileSync(path.join(author, 'index.cjs'), "throw new Error('MUST NOT EVALUATE')"); + changeJSON(author, 'package.json', p => { p.codegraph.apiVersion = 99; }); + await expect(testExtension(author)).rejects.toThrow(/unsupported API version 99.*supports 1/); + changeJSON(author, 'package.json', p => { p.codegraph.apiVersion = 1; p.codegraph.engines = '>=999'; }); + await expect(testExtension(author)).rejects.toThrow(/requires CodeGraph >=999.*running/); + changeJSON(author, 'package.json', p => { delete p.codegraph.id; }); + expect(() => packExtension(author)).toThrow(/codegraph.id/); + }); + + it.each([ + ["module.exports=()=>({frameworks:[{name:'bad',detect:()=>true,resolve:()=>null,extract:()=>({nodes:42,references:[]})}]})", /extract must return/], + ["module.exports=()=>({frameworks:[{name:'bad',detect:()=>true,resolve:()=>null,extract:()=>({nodes:[{id:'not-owned'}],references:[]})}]})", /Invalid contributed node: use a plugin:python-events:/], + ["module.exports=()=>({synthPasses:[{name:'bad',async run(graph){const n=graph.getNodesByName('send_receipt')[0];return [{source:n.id,target:n.id,kind:'calls'}]}}]})", /missing label.*metadata.label/], + ])('reports malformed author output with the failing case and contract requirement', async (source, message) => { + const author = starter(); + changeJSON(author, 'package.json', p => { p.codegraph.capabilities = ['frameworks', 'synthPasses']; }); + fs.writeFileSync(path.join(author, 'index.cjs'), source); + await expect(testExtension(author)).rejects.toThrow(message); + }, 90_000); + + it('fails useful graph assertions instead of accepting an extension that emits nothing', async () => { + const author = starter(); + fs.writeFileSync(path.join(author, 'index.cjs'), 'module.exports=()=>({frameworks:[]})'); + await expect(testExtension(author)).rejects.toThrow(/Author case.*Missing contributed node/); + }, 90_000); + + it('rejects vacuous expectations, traversal and reserved project configuration before executing code', async () => { + const author = starter(); + for (const file of ['../outside.py', '.codegraph/codegraph.db', 'package.json', 'codegraph.json']) { + changeJSON(author, 'extension.test.json', p => { p.cases[0].files = { [file]: 'bad' }; }); + await expect(testExtension(author)).rejects.toThrow(/unsafe or reserved/); + } + changeJSON(author, 'extension.test.json', p => { p.cases[0].files = { 'app.py': 'pass' }; p.cases[0].expect = {}; }); + await expect(testExtension(author)).rejects.toThrow(/at least one graph assertion/); + }); +}); diff --git a/docs/extensions/authoring.md b/docs/extensions/authoring.md new file mode 100644 index 00000000..b3ed3c80 --- /dev/null +++ b/docs/extensions/authoring.md @@ -0,0 +1,189 @@ +# Write a CodeGraph extension (API v1 preview) + +Use an extension to add framework extraction, reference resolution or semantic +edges for a language CodeGraph already understands. The API does not add parsers, +query-time tools, PHP branch guards or browser screen navigation automatically. +Extension code runs with the user's permissions in the main process and workers; +it is trusted executable code, not a sandbox. Installation never runs npm scripts. + +These commands are in this development branch, **not a published npm release**. +Use a build from the draft PR. From a source checkout, run `npm ci`, `npm run build`, +then put `dist/bin` on PATH (or invoke `node /absolute/path/dist/bin/codegraph.js` +where this guide says `codegraph`). Embedded SDK consumers need Node 22.5–24. + +## Start outside the core repository + +Run these commands from a separate working directory with an existing parent: + +```sh +codegraph extensions create ./python-events --id python-events +codegraph extensions test ./python-events +codegraph extensions pack ./python-events --out ./python-events-0.1.0.cgext +``` + +`create` refuses an existing directory. It produces `package.json`, `index.cjs`, +`extension.test.json`, and a standalone README. There is no runtime dependency, +private core import, source-copying step or bundler requirement for this starter. +Keep the `.cgext` output outside the source directory. Packages contain UTF-8 +JavaScript/JSON/documentation; bundle third-party dependencies before packing. +Native addons, symlinks, arbitrary file extensions and packages over 8 MiB are +rejected. This template keeps its Python/YAML test sources inside fixture JSON. + +The starter describes a small Python event-map framework. A `*.events.yaml` file +has one unquoted `event.name: python_function_name` per line. The extension adds +event route nodes and labelled links to unique indexed Python function nodes. +Computed, unknown and ambiguous handlers remain unresolved. It is an example +contract for authors to adapt, not support for every Python event library. + +## Install into a non-Node application + +Create a separate directory `python-app` with these two files: + +`checkout.events.yaml`: + +```yaml +order.created: send_receipt +``` + +`handlers.py`: + +```python +def send_receipt(): + return "sent" +``` + +```sh +codegraph extensions install ./python-events-0.1.0.cgext --path ./python-app +codegraph extensions list --path ./python-app +codegraph extensions remove python-events --path ./python-app +``` + +The managed installer initializes/indexes the target and pins the package bytes. +The target needs no `package.json`, npm install, Python execution or Python library. +While installed, inspect the `event:order.created` route and its `Python event +dispatch` edge to `send_receipt` in the graph. The author test command below checks +these actual nodes/edges through the public graph API and verifies removal. + +## Public types and factory + +All author types and helpers are exported from **`@colbymchenry/codegraph`**. +Do not import `src/`, `dist/plugins/`, database classes, or private registries. +For local editor/type checking before release, make a development package with +`npm pack --ignore-scripts --pack-destination /absolute/path/to/artifacts` in the +built core checkout, then in the author project run +`npm install --save-dev /absolute/path/to/artifacts/colbymchenry-codegraph-1.6.0.tgz`. +This is local packaging, not npm publication. The installed editor dependency is +excluded when packing the extension. + +The CommonJS starter uses a JSDoc `CodeGraphPlugin` type and `module.exports`. +A TypeScript author can use `import type { CodeGraphPlugin } from +'@colbymchenry/codegraph'`, compile to CommonJS JavaScript, and set `main` to that +compiled file. ESM factories are also supported. Bundle runtime imports; a managed +package does not inherit the target project's npm dependencies. + +| Public contract | Purpose | +|---|---| +| `CodeGraphPlugin` / `defineExtension(factory)` | Factory returning `PluginContributions`, optionally asynchronously | +| `PluginContext` | Absolute project root, cloned options, engine version, warning/debug logger | +| `PluginManifest` / `EXTENSION_API_VERSION` | Package ID, API version, engine range and declared capabilities; current API is 1 | +| `FrameworkResolver` | Synchronous detection, extraction, reference resolution and optional finalization | +| `ResolutionContext` | Read-only indexed node/file lookups; optional helpers must be checked before use | +| `SynthPass` | Async whole-graph pass returning semantic edges; optional language gate | +| `Node`, `Edge`, `NodeKind`, `EdgeKind`, `Language` | Graph output shapes and supported kinds/languages | +| `createExtensionProject`, `packExtension`, `testExtension` | The same starter, packer and test harness exposed by the CLI | +| `AuthorFixtureCase`, `AuthorTestReport` | Typed fixture assertions and test receipts | + +Factories must create independent per-project state. They can be called in the +main process and separate parse/resolver workers; do not rely on shared globals, +one call per index, worker order, network results, or mutable external state. +Use deterministic IDs and output. Keep synchronous hooks synchronous. + +Framework hook contract: + +- `detect(graph)` returns whether the framework applies. `languages` gates + per-file extraction. The starter detects `*.events.yaml` files. +- `extract(relativeFile, source)` returns `{ nodes: [], references: [] }`. + Every contributed ID starts with `plugin::`. Use a relative file + path, supported kind/language, 1-based lines and 0-based columns. References + must originate at a node returned by that extraction call. +- `claimsReference(name)` admits framework-specific reference names through the + core prefilter. `resolve(ref, graph)` returns a labelled `ResolvedRef` or null. + Targets must exist; confidence is between 0 and 1. Never fabricate an endpoint + when a name is missing or ambiguous. +- `postExtract(graph)` can update previously emitted nodes, preserving both ID + and qualified name. It cannot introduce new nodes. + +For `synthPasses`, declare that capability in the manifest and return objects with +`name`, optional `languages`, and `async run(graph, yieldToLoop)`. Return `Edge[]` +whose endpoints exist, kind is supported and `metadata.label` is meaningful +(1–160 characters). Include `metadata.registeredAt` as `relative/file:line` when +known. For long loops call the supplied yield function. Core stamps +`provenance: 'heuristic'` and `metadata.synthesizedBy`; core owns persistence and +deterministic merging. Do not open SQLite or write graph files from a plugin. + +## Author harness and compatibility + +`codegraph extensions test ./python-events` packs and validates the real artifact, +checks the current engine against `codegraph.engines`, then installs it into fresh +disposable projects through the managed installer. It checks the declared graph +expectations, clean rebuild determinism and removal cleanup. It closes graph +handles and removes its temporary directories on success and ordinary failures. +It executes your code with your permissions. It is not a security sandbox or a +substitute for testing large real applications, process kills and each platform. + +Fixtures use `format: "codegraph-extension-tests-1"` and a nonempty `cases` array. +Each case has `name`, `files` (relative paths to UTF-8 source strings) and `expect`: + +```json +{ + "nodes": [{"name": "event:order.created", "kind": "route"}], + "edges": [{"source": "event:order.created", "target": "send_receipt", "label": "Python event dispatch"}], + "absentNodes": [{"name": "event:computed.event"}], + "absentEdges": [{"source": "event:order.missing"}] +} +``` + +Node assertions apply only to this extension's contributions; optional `kind` +and `filePath` narrow matches. Edge assertions apply only to edges attributed to +this extension. Positive edges require source name, target name and label. +Negative edges can omit target/label to reject any outgoing contribution from +that source. At least one assertion is required per case. Traversal, hidden files, +`codegraph.json` and a root `package.json` are refused. Use `--fixtures other.json` +to select another fixture file inside the extension directory. + +Add both successful flows and examples that must remain unresolved. Change an +expected handler or label to verify that the harness fails; it must not pass just +because an extension loads. Nonzero exit messages name the failing case and +assertion or output contract. API versions other than 1 fail before evaluation; +incompatible engine ranges report both the required and running versions. +Malformed extraction outputs, node IDs and missing edge labels report the field +requirements. Correct the extension rather than widening compatibility blindly. + +For a compatibility matrix, run the same fixture command using each intended +CodeGraph build and actual operating system, preserving JSON output, engine +version and exit. `engines` is an assertion about those builds, not proof by itself. +The starter's range defaults to the current preview version through `<2`; narrow +it if you have not validated that range. Native Windows/macOS validation is still +pending for this preview. + +## Publish through the local marketplace + +In the core preview checkout, run `node scripts/build-extensions.mjs`, then +`node marketplace/server/dev.cjs`. Open the printed loopback URL, select **Publish +an extension**, upload the `.cgext`, and fill in the listing and your HTTPS source +repository URL. The browser signs the submission with its local publisher identity; +retain that browser profile to publish later versions of the same ID. Releases +are immutable. This is publication into your local development registry only. + +Use `codegraph extensions connect /absolute/path/python-app --marketplace ` +to connect the selected target. Open the printed connection link, allow the local +connection, select the project, and install your extension. Alternatively install +the local registry's `/api/download//` URL using the CLI. Remove it +with the managed removal command above. Existing signed publishing and browser +origin/window checks are shared with ordinary extensions. + +The hosted HTTPS marketplace and durable production registry are unfinished. +Do not deploy or publish to npm as part of this author exercise. Other preview +gaps remain: compatible-version fallback, crash/stale-lock recovery and broader +Drupal accuracy/performance review. The prior Drupal sample measured about +22–30% rebuild overhead versus the built-in resolver; it is not a general SLA. diff --git a/package.json b/package.json index e92986dc..537152c9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "files": [ "dist", "scripts", + "docs/extensions", "README.md" ], "workspaces": [ diff --git a/scripts/pack-npm.sh b/scripts/pack-npm.sh index d2e1768d..b31c8972 100755 --- a/scripts/pack-npm.sh +++ b/scripts/pack-npm.sh @@ -87,6 +87,8 @@ done cp "$ROOT/scripts/npm-shim.js" "$NPM/main/npm-shim.js" cp "$ROOT/scripts/npm-sdk.js" "$NPM/main/npm-sdk.js" [ -f "$ROOT/README.md" ] && cp "$ROOT/README.md" "$NPM/main/README.md" +mkdir -p "$NPM/main/docs" +cp -R "$ROOT/docs/extensions" "$NPM/main/docs/extensions" # Ship the type declarations so `types`/`exports.types` resolve. Built from this # same release, so they can't skew from the runtime npm-sdk.js re-exports. @@ -117,7 +119,7 @@ VERSION="$VERSION" SCOPE="$SCOPE" TARGETS="${targets[*]}" \ "./package.json": "./package.json" }, optionalDependencies: opt, - files: ["npm-shim.js","npm-sdk.js","dist","README.md"], + files: ["npm-shim.js","npm-sdk.js","dist","README.md","docs/extensions"], license: "MIT", repository: { type: "git", url: "git+https://github.com/colbymchenry/codegraph.git" } }, null, 2) + "\n"); diff --git a/scripts/validation/extension-author.cjs b/scripts/validation/extension-author.cjs new file mode 100644 index 00000000..7eb8d373 --- /dev/null +++ b/scripts/validation/extension-author.cjs @@ -0,0 +1,109 @@ +// Build core first. Creates a new author project OUTSIDE core using only the CLI. +// PLAYWRIGHT_MODULE=/absolute/path/to/playwright node scripts/validation/extension-author.cjs +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { createHash } = require('node:crypto'); +const { createRequire } = require('node:module'); +const { spawn } = require('node:child_process'); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const { startMarketplaceServer } = require('../../dist/plugins/marketplace'); +const repo = path.resolve(__dirname, '../..'); +const out = path.join(repo, '.qa/recovery/author-e2e'); +fs.mkdirSync(out, { recursive: true }); +const lab = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-external-author-')); +const author = path.join(lab, 'python-events'); +const project = path.join(lab, 'python-app'); +const commands = []; +const env = { ...process.env, npm_config_cache: path.join(lab, 'npm-cache'), CODEGRAPH_TELEMETRY: '0', CODEGRAPH_PARSE_WORKERS: '2', CODEGRAPH_RESOLVE_WORKERS: '2', CODEGRAPH_PARALLEL_RESOLVE_MIN: '0' }; +async function run(command, args, cwd = lab) { + const start = Date.now(); + // Keep the event loop responsive while the CLI downloads from our local registry. + const result = await new Promise(resolve => { + const child = spawn(command, args, { cwd, env, timeout: 180000 }); + let stdout = '', stderr = '', error; + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', data => { stdout += data; }); + child.stderr.on('data', data => { stderr += data; }); + child.on('error', value => { error = value; }); + child.on('close', status => resolve({ status, stdout, stderr, error })); + }); + commands.push({ command: [command, ...args], cwd, exit: result.status, seconds: (Date.now() - start) / 1000 }); + const prefix = path.join(out, `command-${commands.length}`); + fs.writeFileSync(prefix + '.log', (result.stdout || '') + (result.stderr || '')); + fs.writeFileSync(path.join(out, 'commands.json'), JSON.stringify(commands, null, 2)); + assert.equal(result.status, 0, `${command} failed: ${result.error || result.stderr || result.stdout}`); + return result.stdout; +} +let server, browser; +(async () => { + await run(process.execPath, [path.join(repo, 'dist/bin/codegraph.js'), 'extensions', 'create', author, '--id', 'python-events']); + const packed = JSON.parse(await run('npm', ['pack', '--ignore-scripts', '--json', '--pack-destination', lab], repo))[0]; + assert.ok(packed.files.some(f => f.path === 'docs/extensions/authoring.md'), 'guide ships in the development package'); + await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--save-dev', path.join(lab, packed.filename)], author); + const requireAuthor = createRequire(path.join(author, 'package.json')); + const sdk = requireAuthor('@colbymchenry/codegraph'); + assert.equal(sdk.EXTENSION_API_VERSION, 1); + assert.equal(typeof sdk.testExtension, 'function'); + const cli = path.join(author, 'node_modules/@colbymchenry/codegraph/dist/bin/codegraph.js'); + await run(process.execPath, [path.join(repo, 'node_modules/typescript/bin/tsc'), '--allowJs', '--checkJs', '--noEmit', '--skipLibCheck', '--target', 'ES2022', '--module', 'commonjs', path.join(author, 'index.cjs')], author); + const harness = await run(process.execPath, [cli, 'extensions', 'test', '.'], author); + fs.writeFileSync(path.join(out, 'generated-tests.log'), harness); + const artifact = path.join(lab, 'python-events-0.1.0.cgext'); + await run(process.execPath, [cli, 'extensions', 'pack', '.', '--out', artifact], author); + const bytes = fs.readFileSync(artifact); + fs.copyFileSync(artifact, path.join(out, 'python-events-0.1.0.cgext')); + const source = fs.readFileSync(path.join(author, 'index.cjs'), 'utf8'); + assert.ok(!source.includes('require('), 'generated extension has no runtime dependency'); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'checkout.events.yaml'), 'order.created: send_receipt\n'); + fs.writeFileSync(path.join(project, 'handlers.py'), 'def send_receipt():\n return "sent"\n'); + server = await startMarketplaceServer({ database: path.join(lab, 'registry.db'), publicDirectory: path.join(repo, 'marketplace/public') }); + const origin = `http://127.0.0.1:${server.port}`; + browser = await chromium.launch({ executablePath: process.env.CHROME_PATH || '/opt/google/chrome/chrome', headless: true, args: ['--no-sandbox'] }); + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.goto(origin + '/publish'); + await page.locator('#artifact').setInputFiles(artifact); + for (const [id, value] of Object.entries({ name: 'Python events author starter', publisher: 'Local author validation', description: 'Generated framework extension tested outside the core repo.', source: 'https://github.com/colbymchenry/codegraph', readme: fs.readFileSync(path.join(author, 'README.md'), 'utf8') })) { + await page.locator('#' + id).fill(value); + } + await page.getByRole('button', { name: 'Publish release' }).click(); + await page.waitForURL('**/extensions/python-events'); + await page.getByText('0.1.0', { exact: true }).waitFor(); + await page.screenshot({ path: path.join(out, 'published.png'), fullPage: true }); + const listing = (await (await fetch(origin + '/api/extensions')).json())[0]; + assert.equal(listing.id, 'python-events'); assert.equal(listing.official, false); + assert.equal(listing.integrity, createHash('sha256').update(bytes).digest('hex')); + await browser.close(); browser = undefined; + await run(process.execPath, [cli, 'extensions', 'install', origin + '/api/download/python-events/0.1.0', '--path', project]); + let graph = await sdk.CodeGraph.open(project); + let routes, edges; + try { + routes = graph.getNodesByKind('route'); + assert.equal(routes.length, 1); assert.equal(routes[0].name, 'event:order.created'); + edges = graph.getOutgoingEdges(routes[0].id).filter(e => e.metadata?.synthesizedBy === 'python-events'); + assert.equal(edges.length, 1); assert.equal(edges[0].metadata.label, 'Python event dispatch'); + assert.equal(graph.getNode(edges[0].target).name, 'send_receipt'); + } finally { graph.close(); } + assert.ok(!fs.existsSync(path.join(project, 'package.json')), 'target remains a non-Node project'); + await run(process.execPath, [cli, 'extensions', 'remove', 'python-events', '--path', project]); + graph = await sdk.CodeGraph.open(project); + try { assert.deepEqual(graph.getNodesByKind('route'), []); assert.deepEqual(graph.getOutgoingEdges(routes[0].id), []); } finally { graph.close(); } + assert.deepEqual(errors, []); + const result = { sourceRevision: (await run('git', ['rev-parse', 'HEAD'], repo)).trim(), platform: process.platform, + authorOutsideCore: !author.startsWith(repo + path.sep), generatedId: 'python-events', + publicSdk: '@colbymchenry/codegraph', artifactSha256: listing.integrity, + checks: ['generated CLI starter', 'fresh local npm package installation', 'public JSDoc typecheck', 'generated positive and negative fixture harness', + 'signed browser publisher submission', 'managed install from local release URL', 'actual event-to-Python-handler edge', 'non-Node target', 'managed removal clears contribution'], + routes, edges, commands, browserErrors: errors }; + fs.writeFileSync(path.join(out, 'result.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ checks: result.checks, artifactSha256: result.artifactSha256 })); +})().catch(error => { + fs.writeFileSync(path.join(out, 'failure.json'), JSON.stringify({ message: error.message, commands }, null, 2)); + console.error(error); process.exitCode = 1; +}).finally(async () => { + await browser?.close(); await server?.close(); + fs.rmSync(lab, { recursive: true, force: true }); +}); diff --git a/src/index.ts b/src/index.ts index 04c464b6..876682ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,12 @@ export { export { Mutex, FileLock, processInBatches, debounce, throttle, MemoryMonitor } from './utils'; export { FileWatcher, WatchOptions, PendingFile, LockUnavailableError } from './sync'; export { MCPServer } from './mcp'; +// Public author API: available through the source package and published SDK shim. +export { defineExtension, EXTENSION_API_VERSION, createExtensionProject, testExtension } from './plugins/author'; +export type { AuthorFixtureCase, AuthorTestReport, AuthorNodeExpectation, AuthorEdgeExpectation } from './plugins/author'; +export { packExtension } from './plugins/package'; +export type { CodeGraphPlugin, PluginContext, PluginManifest, PluginContributions, SynthPass } from './plugins/api'; +export type { FrameworkResolver, ResolutionContext, UnresolvedRef, ResolvedRef } from './resolution/types'; /** * Options for initializing a new CodeGraph project diff --git a/src/plugins/author-template.ts b/src/plugins/author-template.ts new file mode 100644 index 00000000..3aa22b4a --- /dev/null +++ b/src/plugins/author-template.ts @@ -0,0 +1,76 @@ +/** The starter is emitted by the CLI, so it ships inside the compiled bundle. */ +export function extensionTemplate(id: string): string { + return `'use strict'; +// Editor types come from the public SDK, with no runtime import or dependency. +/** @type {import('@colbymchenry/codegraph').CodeGraphPlugin} */ +module.exports = () => ({ + frameworks: [{ + name: 'python-event-map', + languages: ['yaml'], + detect: graph => graph.getAllFiles().some(file => file.endsWith('.events.yaml')), + claimsReference: name => name.startsWith('${id}:'), + extract(file, source) { + const nodes = [], references = []; + if (!file.endsWith('.events.yaml')) return { nodes, references }; + // Deliberately narrow syntax: one literal event: handler per line. + // Nested YAML, aliases, quoted/computed values and comments do not match. + source.split(/\\r?\\n/).forEach((text, index) => { + const match = /^([a-z][a-z0-9_.-]*):[ \\t]+([A-Za-z_]\\w*)[ \\t]*$/.exec(text); + if (!match) return; + const [, event, handler] = match, line = index + 1; + const nodeId = 'plugin:${id}:' + file + ':' + line; + nodes.push({ id: nodeId, kind: 'route', name: 'event:' + event, + qualifiedName: file + '::event:' + event + ':' + line, filePath: file, + language: 'yaml', startLine: line, endLine: line, + startColumn: 0, endColumn: text.length, updatedAt: 0 }); + references.push({ fromNodeId: nodeId, referenceName: '${id}:' + handler, + referenceKind: 'calls', filePath: file, language: 'yaml', line, column: 0 }); + }); + return { nodes, references }; + }, + resolve(ref, graph) { + if (!ref.referenceName.startsWith('${id}:')) return null; + const name = ref.referenceName.slice('${id}:'.length); + const targets = graph.getNodesByName(name).filter(node => + node.language === 'python' && node.kind === 'function'); + // Ambiguity and missing handlers stay unresolved; never guess an endpoint. + if (targets.length !== 1) return null; + return { original: ref, targetNodeId: targets[0].id, confidence: 1, + resolvedBy: 'framework', edgeKind: 'calls', metadata: { + label: 'Python event dispatch', registeredAt: ref.filePath + ':' + ref.line + } }; + } + }] +}); +`; +} + +export function extensionFixtures(): object { + return { + format: 'codegraph-extension-tests-1', + cases: [ + { + name: 'literal event reaches its Python handler; unknown and computed targets do not', + files: { + 'checkout.events.yaml': 'order.created: send_receipt\norder.missing: missing_handler\n# ignored.event: send_receipt\ncomputed.event: ${handler}\n', + 'handlers.py': 'def send_receipt():\n return "sent"\n', + }, + expect: { + nodes: [{ name: 'event:order.created', kind: 'route' }, { name: 'event:order.missing', kind: 'route' }], + edges: [{ source: 'event:order.created', target: 'send_receipt', label: 'Python event dispatch' }], + absentNodes: [{ name: 'event:ignored.event' }, { name: 'event:computed.event' }], + absentEdges: [{ source: 'event:order.missing' }], + }, + }, + { + name: 'ambiguous Python handlers stay unresolved', + files: { + 'checkout.events.yaml': 'order.created: send_receipt\n', + 'one.py': 'def send_receipt():\n pass\n', + 'two.py': 'def send_receipt():\n pass\n', + }, + expect: { nodes: [{ name: 'event:order.created' }], absentEdges: [{ source: 'event:order.created' }] }, + }, + ], + }; +} diff --git a/src/plugins/author.ts b/src/plugins/author.ts new file mode 100644 index 00000000..c7b72009 --- /dev/null +++ b/src/plugins/author.ts @@ -0,0 +1,139 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import * as assert from 'node:assert/strict'; +import { version as engineVersion } from '../../package.json'; +import type { CodeGraphPlugin } from './api'; +import type { Node } from '../types'; +import { NODE_KINDS } from '../types'; +import { ExtensionManager } from './manager'; +import { inside, packExtension, parsePackage, sha256, validateManifest } from './package'; +import { extensionTemplate, extensionFixtures } from './author-template'; + +/** Typed identity helper; a factory is invoked independently in each worker. */ +export function defineExtension(factory: CodeGraphPlugin): CodeGraphPlugin { return factory; } +export const EXTENSION_API_VERSION = 1 as const; + +/** Creates a new directory only; never overwrites an author's existing work. */ +export function createExtensionProject(directory: string, id: string): void { + const manifest = { id, apiVersion: EXTENSION_API_VERSION, engines: `>=${engineVersion} <2`, capabilities: ['frameworks'] }; + validateManifest(manifest, engineVersion); + const root = path.resolve(directory); + fs.mkdirSync(root); // EEXIST is intentional, including symlinks and empty directories. + const pkg = { name: `codegraph-extension-${id}`, version: '0.1.0', private: true, + description: 'Python event-map framework extension', main: 'index.cjs', license: 'MIT', codegraph: manifest, + scripts: { test: 'codegraph extensions test .', 'pack:extension': `codegraph extensions pack . --out ../${id}-0.1.0.cgext` } }; + fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify(pkg, null, 2) + '\n'); + fs.writeFileSync(path.join(root, 'index.cjs'), extensionTemplate(id)); + fs.writeFileSync(path.join(root, 'extension.test.json'), JSON.stringify(extensionFixtures(), null, 2) + '\n'); + fs.writeFileSync(path.join(root, 'README.md'), `# ${id}\n\nGenerated CodeGraph API v1 example for a Python event-map framework.\n\nFrom this directory, with the preview CodeGraph CLI on PATH:\n\n\`\`\`sh\ncodegraph extensions test .\ncodegraph extensions pack . --out ../${id}-0.1.0.cgext\ncodegraph extensions install ../${id}-0.1.0.cgext --path /path/to/python-project\ncodegraph extensions remove ${id} --path /path/to/python-project\n\`\`\`\n\nCreate a file named checkout.events.yaml in the Python project containing\n\`order.created: send_receipt\` and a Python function named \`send_receipt\`.\nThe graph gains a labelled event-to-handler link. The target project needs no\npackage.json, npm install, or Python dependencies. The example supports only\nunquoted literal event and function names on a single YAML line. Unknown or\nambiguous handlers remain unresolved. It is a small reference framework, not\na claim of support for arbitrary Python event libraries.\n\nEdit index.cjs and add positive/negative cases in extension.test.json. The test\ncommand executes your code in disposable projects using the managed installer,\nchecks declared graph assertions, deterministic rebuild and removal cleanup.\nIt does not sandbox extension code. Keep pack output outside this directory.\n\nTypes: import CodeGraphPlugin, PluginContext, FrameworkResolver, ResolutionContext,\nSynthPass, Node and Edge from @colbymchenry/codegraph (public package root).\nThe JSDoc type in index.cjs needs the preview package as a development dependency\nfor editor/type checking; the installed extension has no runtime SDK import.\n\nFull author guide: docs/extensions/authoring.md in the CodeGraph source checkout.\nThis preview has not been released to npm. Do not install a public release and\nassume these commands exist.\n`); +} + +export interface AuthorNodeExpectation { name: string; kind?: Node['kind']; filePath?: string } +export interface AuthorEdgeExpectation { source: string; target?: string; label?: string } +export interface AuthorFixtureCase { + name: string; + files: Record; + expect: { + nodes?: AuthorNodeExpectation[]; + absentNodes?: AuthorNodeExpectation[]; + edges?: AuthorEdgeExpectation[]; + absentEdges?: AuthorEdgeExpectation[]; + }; +} +export interface AuthorTestReport { + apiVersion: 1; + engineVersion: string; + artifactSha256: string; + cases: { name: string; assertions: number; deterministic: true; removalClean: true }[]; +} + +function readFixtures(file: string): AuthorFixtureCase[] { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + if (data?.format !== 'codegraph-extension-tests-1' || !Array.isArray(data.cases) || !data.cases.length) { + throw new Error('Author fixtures require format codegraph-extension-tests-1 and at least one case'); + } + for (const c of data.cases) { + if (!c || typeof c.name !== 'string' || !c.name.trim() || !c.files || typeof c.files !== 'object' || Array.isArray(c.files) || + !Object.keys(c.files).length || !c.expect || typeof c.expect !== 'object' || Array.isArray(c.expect)) throw new Error('Each author case needs name, files and nonempty expect assertions'); + for (const [file, text] of Object.entries(c.files)) { + if (typeof text !== 'string' || path.isAbsolute(file) || file.includes('\\') || file.includes(':') || + file.split('/').some(p => !p || p.startsWith('.')) || ['package.json', 'codegraph.json'].includes(file)) { + throw new Error(`Author fixture ${c.name}: unsafe or reserved file ${file}; use relative source files only`); + } + } + let count = 0; + for (const [key, values] of Object.entries(c.expect)) { + if (!['nodes', 'absentNodes', 'edges', 'absentEdges'].includes(key) || !Array.isArray(values)) throw new Error(`Author fixture ${c.name}: unknown or invalid expect.${key}`); + for (const value of values) { + if (!value || typeof value !== 'object') throw new Error(`Author fixture ${c.name}: assertions must be objects`); + const allowed = key.endsWith('Nodes') || key === 'nodes' ? ['name', 'kind', 'filePath'] : ['source', 'target', 'label']; + const required = allowed[0]!; + if (typeof value[required] !== 'string' || !value[required].trim() || Object.keys(value).some(k => !allowed.includes(k) || typeof value[k] !== 'string') || + ('kind' in value && !NODE_KINDS.includes(value.kind))) throw new Error(`Author fixture ${c.name}: invalid ${key} assertion`); + if (key === 'edges' && (!value.target || !value.label)) throw new Error(`Author fixture ${c.name}: expected edges need source, target and label`); + count++; + } + } + if (!count) throw new Error(`Author fixture ${c.name}: provide at least one graph assertion`); + } + return data.cases; +} + +/** Runs actual compiled/runtime contributions, never a mock-only author contract. */ +export async function testExtension(directory: string, options: { fixtures?: string } = {}): Promise { + const root = fs.realpathSync(directory); + const bytes = packExtension(root); + const pkg = parsePackage(bytes, engineVersion); + const fixtureFile = fs.realpathSync(path.resolve(root, options.fixtures ?? 'extension.test.json')); + if (!inside(root, fixtureFile)) throw new Error('Author fixture file must be inside the extension directory'); + const cases = readFixtures(fixtureFile); + const report: AuthorTestReport = { apiVersion: 1, engineVersion, artifactSha256: sha256(bytes), cases: [] }; + const { CodeGraph } = await import('../index'); + for (const fixture of cases) { + const project = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-author-')); + try { + for (const [file, content] of Object.entries(fixture.files)) { + const destination = path.join(project, file); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, content); + } + const manager = new ExtensionManager(project); + await manager.install({ bytes }); + const snapshot = async () => { + const graph = await CodeGraph.open(project); + try { + const nodes = NODE_KINDS.flatMap(kind => graph.getNodesByKind(kind)); + const edges = graph.getOutgoingEdgesFrom(nodes.map(node => node.id)).filter(edge => edge.metadata?.synthesizedBy === pkg.package.codegraph.id); + const contributions = nodes.filter(node => node.id.startsWith(`plugin:${pkg.package.codegraph.id}:`)); + return { nodes, edges, contributions }; + } finally { graph.close(); } + }; + const before = await snapshot(); + const hasNode = (query: AuthorNodeExpectation) => before.contributions.some(node => Object.entries(query).every(([key, value]) => node[key as keyof Node] === value)); + const names = new Map(before.nodes.map(node => [node.id, node.name])); + const hasEdge = (query: AuthorEdgeExpectation) => before.edges.some(edge => names.get(edge.source) === query.source && + (query.target === undefined || names.get(edge.target) === query.target) && (query.label === undefined || edge.metadata?.label === query.label)); + let assertions = 0; + for (const q of fixture.expect.nodes ?? []) { assert.ok(hasNode(q), `Missing contributed node ${JSON.stringify(q)}`); assertions++; } + for (const q of fixture.expect.absentNodes ?? []) { assert.ok(!hasNode(q), `Unexpected contributed node ${JSON.stringify(q)}`); assertions++; } + for (const q of fixture.expect.edges ?? []) { assert.ok(hasEdge(q), `Missing labelled extension edge ${JSON.stringify(q)}`); assertions++; } + for (const q of fixture.expect.absentEdges ?? []) { assert.ok(!hasEdge(q), `Unexpected extension edge ${JSON.stringify(q)}`); assertions++; } + const canonical = (data: Awaited>) => JSON.stringify({ + nodes: data.contributions.map(({ updatedAt: _time, ...node }) => node).sort((a, b) => a.id.localeCompare(b.id)), + edges: [...data.edges].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }); + const graph = await CodeGraph.open(project); + try { await graph.refreshPluginIndex(); } finally { graph.close(); } + assert.equal(canonical(await snapshot()), canonical(before), 'Extension contribution changed after clean reindex'); + await manager.remove(pkg.package.codegraph.id); + const after = await snapshot(); + assert.equal(after.contributions.length, 0, 'Contributed nodes remain after removal'); + assert.equal(after.edges.length, 0, 'Contributed edges remain after removal'); + report.cases.push({ name: fixture.name, assertions, deterministic: true, removalClean: true }); + } catch (error) { + throw new Error(`Author case "${fixture.name}" failed: ${error instanceof Error ? error.message : String(error)}. Check your factory output and extension.test.json expectations.`, { cause: error }); + } finally { fs.rmSync(project, { recursive: true, force: true }); } + } + return report; +} diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 838b0da0..fc28892d 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -3,10 +3,22 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { ExtensionManager, downloadPackage } from './manager'; import { startExtensionBridge } from './bridge'; -import { parsePackage, type ExtensionPackage } from './package'; +import { packExtension } from './package'; +import { createExtensionProject, testExtension } from './author'; export function registerExtensionCommands(program: Command): void { const extensions = program.command('extensions').description('Install and manage framework extensions'); + extensions.command('create ').description('Create a portable Python event-map extension starter and author tests') + .requiredOption('--id ', 'Unique lowercase extension id') + .action((directory, options) => { + createExtensionProject(directory, options.id); + console.log(`Created ${path.resolve(directory)}. Next: codegraph extensions test ${directory}`); + }); + extensions.command('test ').description('Check compatibility, graph assertions, determinism and removal in disposable projects; executes your extension') + .option('--fixtures ', 'Author fixture JSON relative to extension directory', 'extension.test.json') + .action(async (directory, options) => { + console.log(JSON.stringify(await testExtension(directory, { fixtures: options.fixtures }), null, 2)); + }); extensions.command('list').option('--path ', 'Project directory', '.').action(options => { console.log(JSON.stringify(new ExtensionManager(options.path).list(), null, 2)); }); @@ -34,21 +46,7 @@ export function registerExtensionCommands(program: Command): void { }); extensions.command('pack ').description('Bundle an extension directory; validates metadata without executing code') .requiredOption('--out ', 'Output .cgext file').action((directory, options) => { - const root = path.resolve(directory); - const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - const files: Record = {}; - function walk(dir: string): void { - for (const file of fs.readdirSync(dir, { withFileTypes: true })) { - if (file.name.startsWith('.') || file.name === 'node_modules' || (dir === root && file.name === 'package.json')) continue; - const absolute = path.join(dir, file.name); - if (file.isSymbolicLink()) throw new Error('Package symlinks are unsupported'); - if (file.isDirectory()) walk(absolute); - else files[path.relative(root, absolute).split(path.sep).join('/')] = fs.readFileSync(absolute, 'utf8'); - } - } - walk(root); - const artifact: ExtensionPackage = { format: 'codegraph-extension-1', package: pkg, files }; - const bytes = Buffer.from(JSON.stringify(artifact)); parsePackage(bytes); - fs.writeFileSync(options.out, bytes); console.log(`Packed ${pkg.name}@${pkg.version} into ${options.out}`); + const bytes = packExtension(directory); + fs.writeFileSync(options.out, bytes); console.log(`Packed extension into ${options.out}`); }); } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 10034bda..529dd1cc 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -131,12 +131,12 @@ function validateNode(node: Node, id: string, root: string): Node { if (!node || typeof node.id !== 'string' || !node.id.startsWith(`plugin:${id}:`) || !node.name || !node.qualifiedName || !NODE_KINDS.includes(node.kind) || !LANGUAGES.includes(node.language) || !validPath(root, node.filePath) || !Number.isInteger(node.startLine) || node.startLine < 1 || !Number.isInteger(node.endLine) || node.endLine < node.startLine || - !Number.isInteger(node.startColumn) || node.startColumn < 0 || !Number.isInteger(node.endColumn) || node.endColumn < 0) throw new Error('Invalid contributed node'); + !Number.isInteger(node.startColumn) || node.startColumn < 0 || !Number.isInteger(node.endColumn) || node.endColumn < 0) throw new Error(`Invalid contributed node: use a plugin:${id}: id, supported kind/language, relative filePath and valid 1-based lines / 0-based columns`); return { ...node, updatedAt: 0 }; } function validateEdge(edge: Edge, id: string): Edge { if (!edge || !edge.source || !edge.target || !EDGE_KINDS.includes(edge.kind) || - typeof edge.metadata?.label !== 'string' || !edge.metadata.label.trim() || edge.metadata.label.length > 160) throw new Error('Invalid contributed edge or missing label'); + typeof edge.metadata?.label !== 'string' || !edge.metadata.label.trim() || edge.metadata.label.length > 160) throw new Error('Invalid contributed edge or missing label: provide source/target node IDs, a supported kind and metadata.label (1–160 characters)'); return { ...edge, provenance: 'heuristic', metadata: { ...edge.metadata, synthesizedBy: id } }; } function guardFramework(fw: FrameworkResolver, p: ResolvedPlugin, diag: PluginDiagnostic, root: string): FrameworkResolver { @@ -159,6 +159,7 @@ function guardFramework(fw: FrameworkResolver, p: ResolvedPlugin, diag: PluginDi }), extract: fw.extract ? (file, source) => call('extract', { nodes: [], references: [] }, () => { const out = fw.extract!(file, source); + if (!out || !Array.isArray(out.nodes) || !Array.isArray(out.references)) throw new Error('extract must return { nodes: [], references: [] }; both fields must be arrays'); const nodes = out.nodes.map(n => validateNode(n, id, root)); const references = out.references.map((r: UnresolvedRef) => { if (!r.fromNodeId.startsWith(`plugin:${id}:`) || !nodes.some(n => n.id === r.fromNodeId) || !r.referenceName || diff --git a/src/plugins/package.ts b/src/plugins/package.ts index 6905c30f..121740c9 100644 --- a/src/plugins/package.ts +++ b/src/plugins/package.ts @@ -22,13 +22,40 @@ export function safeFile(file: string): boolean { } export function validateManifest(raw: unknown, engineVersion?: string): PluginManifest { const m = raw as PluginManifest; - if (!m || typeof m !== 'object' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(m.id) || m.apiVersion !== 1 || + if (!m || typeof m !== 'object' || Array.isArray(m)) throw new Error('package.json must contain a codegraph manifest object'); + if (m.apiVersion !== 1) throw new Error(`Extension uses unsupported API version ${String(m.apiVersion)}; this engine supports 1. Set codegraph.apiVersion to 1 and use the v1 author guide.`); + if (typeof m.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(m.id) || !Array.isArray(m.capabilities) || m.capabilities.length === 0 || - m.capabilities.some(c => c !== 'frameworks' && c !== 'synthPasses')) throw new Error('Invalid extension manifest or unsupported API version'); + m.capabilities.some(c => c !== 'frameworks' && c !== 'synthPasses')) throw new Error('Invalid extension manifest: codegraph.id must use lowercase letters/digits/hyphens; capabilities must contain frameworks and/or synthPasses'); if (m.engines !== undefined && (typeof m.engines !== 'string' || !semver.validRange(m.engines))) throw new Error('Invalid CodeGraph version range'); - if (engineVersion && m.engines && !semver.satisfies(engineVersion, m.engines)) throw new Error(`Extension requires CodeGraph ${m.engines}; running ${engineVersion}`); + if (engineVersion && m.engines && !semver.satisfies(engineVersion, m.engines)) throw new Error(`Extension requires CodeGraph ${m.engines}; running ${engineVersion}. Use a compatible engine or correct codegraph.engines after testing against that engine.`); return m; } + +/** Package author source without evaluating it or running install scripts. */ +export function packExtension(directory: string): Buffer { + const root = fs.realpathSync(directory); + const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); + const files: Record = {}; + function walk(dir: string): void { + for (const file of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (file.name.startsWith('.') || file.name === 'node_modules' || (dir === root && file.name === 'package.json')) continue; + const absolute = path.join(dir, file.name); + if (file.isSymbolicLink()) throw new Error('Package symlinks are unsupported'); + if (file.isDirectory()) walk(absolute); + else { + const bytes = fs.readFileSync(absolute); + const content = bytes.toString('utf8'); + if (!Buffer.from(content).equals(bytes)) throw new Error(`Package file must be UTF-8: ${file.name}`); + files[path.relative(root, absolute).split(path.sep).join('/')] = content; + } + } + } + walk(root); + const bytes = Buffer.from(JSON.stringify({ format: 'codegraph-extension-1', package: pkg, files })); + parsePackage(bytes); + return bytes; +} export function parsePackage(bytes: Buffer, engineVersion?: string): ExtensionPackage { if (bytes.length > MAX_PACKAGE_BYTES) throw new Error('Extension package exceeds 8 MiB'); const p = JSON.parse(bytes.toString('utf8')) as ExtensionPackage;