mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
Add public extension author SDK, starter and graph test harness
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<AuthorTestReport> {
|
||||
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<ReturnType<typeof snapshot>>) => 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;
|
||||
}
|
||||
+15
-17
@@ -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 <directory>').description('Create a portable Python event-map extension starter and author tests')
|
||||
.requiredOption('--id <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 <directory>').description('Check compatibility, graph assertions, determinism and removal in disposable projects; executes your extension')
|
||||
.option('--fixtures <file>', '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 <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 <directory>').description('Bundle an extension directory; validates metadata without executing code')
|
||||
.requiredOption('--out <file>', '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<string, string> = {};
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
+30
-3
@@ -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<string, string> = {};
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user