@monosmith/plugin-sdk 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -2
- package/build-dir/build-dir.algorithm.d.ts +16 -0
- package/build-dir/build-dir.algorithm.js +26 -0
- package/build-dir/index.d.ts +1 -0
- package/build-dir/index.js +1 -0
- package/ci/ci-contribution.algorithm.d.ts +28 -0
- package/ci/ci-contribution.algorithm.js +19 -0
- package/ci/index.d.ts +2 -0
- package/ci/index.js +1 -0
- package/codec/encode-by-schema.algorithm.d.ts +12 -0
- package/codec/encode-by-schema.algorithm.js +105 -0
- package/codec/encode-error.error.d.ts +4 -0
- package/codec/encode-error.error.js +7 -0
- package/codec/encode-json-value.algorithm.d.ts +13 -0
- package/codec/encode-json-value.algorithm.js +51 -0
- package/codec/index.d.ts +6 -0
- package/codec/index.js +5 -0
- package/codec/message-parse.error.d.ts +4 -0
- package/codec/message-parse.error.js +7 -0
- package/codec/method-codec.algorithm.d.ts +8 -0
- package/codec/method-codec.algorithm.js +31 -0
- package/codec/normalize-by-schema.algorithm.d.ts +14 -0
- package/codec/normalize-by-schema.algorithm.js +103 -0
- package/codec/params-error.error.d.ts +4 -0
- package/codec/params-error.error.js +7 -0
- package/codec/schema-lookup.algorithm.d.ts +13 -0
- package/codec/schema-lookup.algorithm.js +56 -0
- package/codec/wire-message.mapper.d.ts +25 -0
- package/codec/wire-message.mapper.js +58 -0
- package/conformance/check-generation.use-case.d.ts +12 -0
- package/conformance/check-generation.use-case.js +91 -0
- package/conformance/check-plugin.use-case.d.ts +24 -0
- package/conformance/check-plugin.use-case.js +78 -0
- package/conformance/conformance-report.model.d.ts +22 -0
- package/conformance/conformance-report.model.js +23 -0
- package/conformance/find-manifests.algorithm.d.ts +7 -0
- package/conformance/find-manifests.algorithm.js +34 -0
- package/conformance/go-list.mapper.d.ts +4 -0
- package/conformance/go-list.mapper.js +8 -0
- package/conformance/index.d.ts +5 -0
- package/conformance/index.js +3 -0
- package/framing/frame-message.algorithm.d.ts +5 -0
- package/framing/frame-message.algorithm.js +8 -0
- package/framing/frame-reader.store.d.ts +15 -0
- package/framing/frame-reader.store.js +59 -0
- package/framing/framing.error.d.ts +4 -0
- package/framing/framing.error.js +7 -0
- package/framing/index.d.ts +3 -0
- package/framing/index.js +3 -0
- package/host/connect-plugin.use-case.d.ts +26 -0
- package/host/connect-plugin.use-case.js +44 -0
- package/host/host-options.contract.d.ts +28 -0
- package/host/host-options.contract.js +2 -0
- package/host/index.d.ts +7 -0
- package/host/index.js +4 -0
- package/host/plugin-client.client.d.ts +42 -0
- package/host/plugin-client.client.js +166 -0
- package/host/start-plugin.use-case.d.ts +10 -0
- package/host/start-plugin.use-case.js +57 -0
- package/index.d.ts +15 -0
- package/index.js +20 -0
- package/jsondoc/indent-raw-json.algorithm.d.ts +8 -0
- package/jsondoc/indent-raw-json.algorithm.js +60 -0
- package/jsondoc/index.d.ts +2 -0
- package/jsondoc/index.js +2 -0
- package/jsondoc/json-document.error.d.ts +4 -0
- package/jsondoc/json-document.error.js +7 -0
- package/jsondoc/json-document.model.d.ts +38 -0
- package/jsondoc/json-document.model.js +113 -0
- package/jsondoc/scan-json-object.algorithm.d.ts +17 -0
- package/jsondoc/scan-json-object.algorithm.js +132 -0
- package/package.json +34 -4
- package/plugin-refs/index.d.ts +1 -0
- package/plugin-refs/index.js +1 -0
- package/plugin-refs/plugin-ref.policy.d.ts +9 -0
- package/plugin-refs/plugin-ref.policy.js +22 -0
- package/plugin-roles/index.d.ts +1 -0
- package/plugin-roles/index.js +1 -0
- package/plugin-roles/plugin-role.policy.d.ts +16 -0
- package/plugin-roles/plugin-role.policy.js +30 -0
- package/projects/audit-settings.policy.d.ts +5 -0
- package/projects/audit-settings.policy.js +8 -0
- package/projects/index.d.ts +6 -0
- package/projects/index.js +6 -0
- package/projects/manifest-match.algorithm.d.ts +7 -0
- package/projects/manifest-match.algorithm.js +38 -0
- package/projects/project-kind.policy.d.ts +9 -0
- package/projects/project-kind.policy.js +28 -0
- package/projects/publishes.policy.d.ts +7 -0
- package/projects/publishes.policy.js +8 -0
- package/projects/set-toolchain.algorithm.d.ts +7 -0
- package/projects/set-toolchain.algorithm.js +13 -0
- package/projects/stack-plugins.algorithm.d.ts +3 -0
- package/projects/stack-plugins.algorithm.js +4 -0
- package/protocol/index.d.ts +4 -0
- package/protocol/index.js +3 -0
- package/protocol/protocol-schema.contract.d.ts +3 -0
- package/protocol/protocol-schema.contract.js +1925 -0
- package/protocol/protocol-types.contract.d.ts +1017 -0
- package/protocol/protocol-types.contract.js +36 -0
- package/protocol/rpc-error.error.d.ts +9 -0
- package/protocol/rpc-error.error.js +13 -0
- package/protocol/schema-document.contract.d.ts +55 -0
- package/protocol/schema-document.contract.js +1 -0
- package/scaffold/index.d.ts +3 -0
- package/scaffold/index.js +2 -0
- package/scaffold/scaffold-data.mapper.d.ts +15 -0
- package/scaffold/scaffold-data.mapper.js +24 -0
- package/scaffold/scaffold-project.use-case.d.ts +20 -0
- package/scaffold/scaffold-project.use-case.js +13 -0
- package/serve/capabilities-of.algorithm.d.ts +13 -0
- package/serve/capabilities-of.algorithm.js +28 -0
- package/serve/claim-stdout.use-case.d.ts +10 -0
- package/serve/claim-stdout.use-case.js +14 -0
- package/serve/dispatch-request.use-case.d.ts +14 -0
- package/serve/dispatch-request.use-case.js +56 -0
- package/serve/index.d.ts +4 -0
- package/serve/index.js +2 -0
- package/serve/initialize-plugin.use-case.d.ts +9 -0
- package/serve/initialize-plugin.use-case.js +16 -0
- package/serve/plugin-members.config.d.ts +16 -0
- package/serve/plugin-members.config.js +25 -0
- package/serve/plugin.contract.d.ts +65 -0
- package/serve/plugin.contract.js +1 -0
- package/serve/protocol-streams.contract.d.ts +13 -0
- package/serve/protocol-streams.contract.js +1 -0
- package/serve/request-outcome.contract.d.ts +9 -0
- package/serve/request-outcome.contract.js +1 -0
- package/serve/serve.use-case.d.ts +27 -0
- package/serve/serve.use-case.js +93 -0
- package/tasks/contribute-tasks.algorithm.d.ts +15 -0
- package/tasks/contribute-tasks.algorithm.js +22 -0
- package/tasks/index.d.ts +2 -0
- package/tasks/index.js +2 -0
- package/tasks/task.algorithm.d.ts +18 -0
- package/tasks/task.algorithm.js +23 -0
- package/templating/file-exists.error.d.ts +6 -0
- package/templating/file-exists.error.js +10 -0
- package/templating/index.d.ts +4 -0
- package/templating/index.js +4 -0
- package/templating/parse-template.algorithm.d.ts +11 -0
- package/templating/parse-template.algorithm.js +197 -0
- package/templating/render-template.algorithm.d.ts +7 -0
- package/templating/render-template.algorithm.js +87 -0
- package/templating/template-functions.algorithm.d.ts +10 -0
- package/templating/template-functions.algorithm.js +104 -0
- package/templating/template-syntax.contract.d.ts +33 -0
- package/templating/template-syntax.contract.js +1 -0
- package/templating/template.error.d.ts +4 -0
- package/templating/template.error.js +7 -0
- package/templating/tokenize-action.algorithm.d.ts +20 -0
- package/templating/tokenize-action.algorithm.js +57 -0
- package/templating/write-templates.use-case.d.ts +11 -0
- package/templating/write-templates.use-case.js +44 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Follows `$ref` to the definition it names. */
|
|
2
|
+
export function resolveNode(schema, node) {
|
|
3
|
+
let current = node;
|
|
4
|
+
for (let hops = 0; current.$ref !== undefined; hops++) {
|
|
5
|
+
const name = current.$ref.slice('#/$defs/'.length);
|
|
6
|
+
const target = schema.$defs[name];
|
|
7
|
+
if (target === undefined || hops > 16)
|
|
8
|
+
throw new Error(`the schema has no definition ${current.$ref}`);
|
|
9
|
+
current = target;
|
|
10
|
+
}
|
|
11
|
+
return current;
|
|
12
|
+
}
|
|
13
|
+
/** Whether `null` is a value of the node: `["integer", "null"]`, or an `anyOf` with null. */
|
|
14
|
+
export function isNullable(node) {
|
|
15
|
+
return Array.isArray(node.type) ? node.type.includes('null') : node.anyOf?.some(branch => branch.type === 'null') ?? false;
|
|
16
|
+
}
|
|
17
|
+
/** The node without its `null` alternative. */
|
|
18
|
+
export function withoutNull(schema, node) {
|
|
19
|
+
if (Array.isArray(node.type)) {
|
|
20
|
+
const types = node.type.filter(t => t !== 'null');
|
|
21
|
+
return { ...node, type: types.length === 1 ? types[0] : types };
|
|
22
|
+
}
|
|
23
|
+
const branches = node.anyOf?.filter(branch => branch.type !== 'null');
|
|
24
|
+
return branches !== undefined && branches.length === 1 ? resolveNode(schema, branches[0]) : node;
|
|
25
|
+
}
|
|
26
|
+
/** The kind of value a node describes, after `$ref` and null are set aside. */
|
|
27
|
+
export function kindOf(schema, node) {
|
|
28
|
+
const base = resolveNode(schema, withoutNull(schema, node));
|
|
29
|
+
switch (base.type) {
|
|
30
|
+
case 'string': {
|
|
31
|
+
return 'string';
|
|
32
|
+
}
|
|
33
|
+
case 'integer': {
|
|
34
|
+
return 'integer';
|
|
35
|
+
}
|
|
36
|
+
case 'number': {
|
|
37
|
+
return 'number';
|
|
38
|
+
}
|
|
39
|
+
case 'boolean': {
|
|
40
|
+
return 'boolean';
|
|
41
|
+
}
|
|
42
|
+
case 'array': {
|
|
43
|
+
return 'array';
|
|
44
|
+
}
|
|
45
|
+
case 'object': {
|
|
46
|
+
return base.properties === undefined ? 'map' : 'struct';
|
|
47
|
+
}
|
|
48
|
+
default: {
|
|
49
|
+
return 'any';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The node as the codec walks it: nullability removed, references followed. */
|
|
54
|
+
export function baseNode(schema, node) {
|
|
55
|
+
return resolveNode(schema, withoutNull(schema, node));
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** A request or notification as it was received. `id` is `null` for a notification. */
|
|
2
|
+
export interface ReceivedRequest {
|
|
3
|
+
id: number | null;
|
|
4
|
+
method: string;
|
|
5
|
+
params: unknown;
|
|
6
|
+
}
|
|
7
|
+
/** A response as it was received. */
|
|
8
|
+
export interface ReceivedResponse {
|
|
9
|
+
id: number | null;
|
|
10
|
+
result: unknown;
|
|
11
|
+
error: {
|
|
12
|
+
code: number;
|
|
13
|
+
message: string;
|
|
14
|
+
} | null;
|
|
15
|
+
}
|
|
16
|
+
/** A request as the Go SDK writes it: `jsonrpc`, `id`, `method`, `params`, the params left out when there are none. */
|
|
17
|
+
export declare function writeRequest(id: number, method: string, paramsJson: string | undefined): string;
|
|
18
|
+
/** A successful response: `id` is `null` only when the request could not be read. */
|
|
19
|
+
export declare function writeResult(id: number | null, resultJson: string): string;
|
|
20
|
+
/** An error response. */
|
|
21
|
+
export declare function writeFailure(id: number | null, code: number, message: string): string;
|
|
22
|
+
/** Reads a request frame. A `null` or missing id makes it a notification. */
|
|
23
|
+
export declare function parseRequest(body: string): ReceivedRequest;
|
|
24
|
+
/** Reads a response frame. */
|
|
25
|
+
export declare function parseResponse(body: string): ReceivedResponse;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { encodeJsonString } from './encode-json-value.algorithm.js';
|
|
2
|
+
import { MessageParseError } from './message-parse.error.js';
|
|
3
|
+
/** A request as the Go SDK writes it: `jsonrpc`, `id`, `method`, `params`, the params left out when there are none. */
|
|
4
|
+
export function writeRequest(id, method, paramsJson) {
|
|
5
|
+
const params = paramsJson === undefined ? '' : `,"params":${paramsJson}`;
|
|
6
|
+
return `{"jsonrpc":"2.0","id":${id},"method":${encodeJsonString(method)}${params}}`;
|
|
7
|
+
}
|
|
8
|
+
/** A successful response: `id` is `null` only when the request could not be read. */
|
|
9
|
+
export function writeResult(id, resultJson) {
|
|
10
|
+
return `{"jsonrpc":"2.0","id":${id === null ? 'null' : id},"result":${resultJson}}`;
|
|
11
|
+
}
|
|
12
|
+
/** An error response. */
|
|
13
|
+
export function writeFailure(id, code, message) {
|
|
14
|
+
return `{"jsonrpc":"2.0","id":${id === null ? 'null' : id},"error":{"code":${code},"message":${encodeJsonString(message)}}}`;
|
|
15
|
+
}
|
|
16
|
+
function parseObject(body) {
|
|
17
|
+
let value;
|
|
18
|
+
try {
|
|
19
|
+
value = JSON.parse(body);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw new MessageParseError(error instanceof Error ? error.message : String(error));
|
|
23
|
+
}
|
|
24
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
25
|
+
throw new MessageParseError('a message is a JSON object');
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function readId(message) {
|
|
29
|
+
const id = message.id;
|
|
30
|
+
if (id === undefined || id === null)
|
|
31
|
+
return null;
|
|
32
|
+
if (typeof id !== 'number' || !Number.isSafeInteger(id))
|
|
33
|
+
throw new MessageParseError('id must be an integer');
|
|
34
|
+
return id;
|
|
35
|
+
}
|
|
36
|
+
/** Reads a request frame. A `null` or missing id makes it a notification. */
|
|
37
|
+
export function parseRequest(body) {
|
|
38
|
+
const message = parseObject(body);
|
|
39
|
+
const id = readId(message);
|
|
40
|
+
const method = message.method ?? '';
|
|
41
|
+
if (typeof method !== 'string')
|
|
42
|
+
throw new MessageParseError('method must be a string');
|
|
43
|
+
return { id, method, params: message.params };
|
|
44
|
+
}
|
|
45
|
+
/** Reads a response frame. */
|
|
46
|
+
export function parseResponse(body) {
|
|
47
|
+
const message = parseObject(body);
|
|
48
|
+
const id = readId(message);
|
|
49
|
+
const error = message.error;
|
|
50
|
+
if (error === undefined || error === null)
|
|
51
|
+
return { id, result: message.result, error: null };
|
|
52
|
+
if (typeof error !== 'object' || Array.isArray(error))
|
|
53
|
+
throw new MessageParseError('error must be an object');
|
|
54
|
+
const { code, message: text } = error;
|
|
55
|
+
if (typeof code !== 'number' || typeof text !== 'string')
|
|
56
|
+
throw new MessageParseError('error needs a numeric code and a message');
|
|
57
|
+
return { id, result: undefined, error: { code, message: text } };
|
|
58
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PluginClient } from '../host/index.ts';
|
|
2
|
+
import type { ConformanceReport } from './conformance-report.model.ts';
|
|
3
|
+
/** The stack name generated projects are asked to record. */
|
|
4
|
+
export declare const CONFORMANCE_STACK = "conformance";
|
|
5
|
+
/**
|
|
6
|
+
* Asks the plugin for one project of each kind, checks the files it reports
|
|
7
|
+
* exist, and, for a language plugin, that its own `discover` returns what it
|
|
8
|
+
* made, with the kind and the stack it was asked for, tasks with commands, and
|
|
9
|
+
* that the dry runs of `version/write`, `remove`, `publish` and `published`
|
|
10
|
+
* answer.
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkGeneration(client: PluginClient, workspace: string, report: ConformanceReport): Promise<void>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { effectiveRoles } from '../plugin-roles/index.js';
|
|
4
|
+
import { projectKindDirectory } from '../projects/index.js';
|
|
5
|
+
import { findManifests } from './find-manifests.algorithm.js';
|
|
6
|
+
import { goList, goQuote } from './go-list.mapper.js';
|
|
7
|
+
/** The stack name generated projects are asked to record. */
|
|
8
|
+
export const CONFORMANCE_STACK = 'conformance';
|
|
9
|
+
const LONG_MS = 20 * 60 * 1000;
|
|
10
|
+
function article(kind) {
|
|
11
|
+
return kind === 'app' ? 'an app' : `a ${kind}`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Asks the plugin for one project of each kind, checks the files it reports
|
|
15
|
+
* exist, and, for a language plugin, that its own `discover` returns what it
|
|
16
|
+
* made, with the kind and the stack it was asked for, tasks with commands, and
|
|
17
|
+
* that the dry runs of `version/write`, `remove`, `publish` and `published`
|
|
18
|
+
* answer.
|
|
19
|
+
*/
|
|
20
|
+
export async function checkGeneration(client, workspace, report) {
|
|
21
|
+
const language = effectiveRoles(client.info).includes('language');
|
|
22
|
+
const made = [];
|
|
23
|
+
for (const kind of ['app', 'lib', 'package']) {
|
|
24
|
+
const name = `c${kind}`;
|
|
25
|
+
const params = {
|
|
26
|
+
kind,
|
|
27
|
+
name,
|
|
28
|
+
directory: `${projectKindDirectory(kind)}/${name}`,
|
|
29
|
+
stack: CONFORMANCE_STACK,
|
|
30
|
+
workspace: { name: 'conformance', scope: '@conformance', modulePrefix: 'example.com/conformance' },
|
|
31
|
+
};
|
|
32
|
+
let files;
|
|
33
|
+
try {
|
|
34
|
+
const generated = await client.call('generate', params, { timeoutMs: LONG_MS });
|
|
35
|
+
files = generated.files;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
report.add(`generates ${article(kind)}`, false, messageOf(error));
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const missing = files.filter(file => !existsSync(join(workspace, ...file.split('/'))));
|
|
42
|
+
report.add(`generates ${article(kind)}`, files.length > 0 && missing.length === 0, `files reported but not written: ${goList(missing)} (of ${goList(files)})`);
|
|
43
|
+
made.push(params);
|
|
44
|
+
}
|
|
45
|
+
if (!language || made.length === 0)
|
|
46
|
+
return;
|
|
47
|
+
let projects;
|
|
48
|
+
try {
|
|
49
|
+
const discovered = await client.call('discover', { manifests: findManifests(workspace, client.info.manifests ?? []) }, { timeoutMs: LONG_MS });
|
|
50
|
+
projects = discovered.projects;
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
report.add('discovers what it generated', false, messageOf(error));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const found = new Map(projects.map(project => [project.root, project]));
|
|
57
|
+
for (const params of made) {
|
|
58
|
+
const project = found.get(params.directory);
|
|
59
|
+
report.add(`discovers the generated ${params.kind}`, project !== undefined, `no project at ${params.directory} among ${projects.length} discovered`);
|
|
60
|
+
if (project === undefined)
|
|
61
|
+
continue;
|
|
62
|
+
report.add(`the generated ${params.kind} keeps its kind`, project.kind === params.kind, `discovered as ${goQuote(project.kind)}`);
|
|
63
|
+
report.add(`the generated ${params.kind} records its stack`, project.stack === CONFORMANCE_STACK, `discovered with stack ${goQuote(project.stack ?? '')}; generate must record params.stack in the manifest`);
|
|
64
|
+
const empty = Object.entries(project.tasks ?? {}).filter(([, task]) => task.command.length === 0).map(([name]) => name).sort((a, b) => a.localeCompare(b));
|
|
65
|
+
report.add(`the generated ${params.kind}'s tasks have commands`, empty.length === 0, `tasks without a command: ${goList(empty)}`);
|
|
66
|
+
if (client.supports('version/write')) {
|
|
67
|
+
await attempt(report, `writes the ${params.kind}'s version (dry run)`, () => client.call('version/write', { project, version: '1.2.3', dryRun: true }, { timeoutMs: 60_000 }));
|
|
68
|
+
}
|
|
69
|
+
if (client.supports('remove')) {
|
|
70
|
+
await attempt(report, `would remove the ${params.kind} (dry run)`, () => client.call('remove', { project, dryRun: true }, { timeoutMs: 60_000 }));
|
|
71
|
+
}
|
|
72
|
+
if (params.kind === 'package' && client.supports('publish')) {
|
|
73
|
+
await attempt(report, 'publishes the package (dry run)', () => client.call('publish', { project, version: '1.2.3', dryRun: true }, { timeoutMs: 5 * 60_000 }));
|
|
74
|
+
}
|
|
75
|
+
if (params.kind === 'package' && client.supports('published')) {
|
|
76
|
+
await attempt(report, 'says what the registry has of the package', () => client.call('published', { project }, { timeoutMs: 60_000 }), ' (an unreachable registry is a result, not an error)');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function attempt(report, check, call, hint = '') {
|
|
81
|
+
try {
|
|
82
|
+
await call();
|
|
83
|
+
report.add(check, true, '');
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
report.add(check, false, `${messageOf(error)}${hint}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function messageOf(thrown) {
|
|
90
|
+
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
91
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginClient } from '../host/index.ts';
|
|
2
|
+
import { ConformanceReport } from './conformance-report.model.ts';
|
|
3
|
+
/** Options of {@link checkPlugin}. */
|
|
4
|
+
export interface CheckOptions {
|
|
5
|
+
/**
|
|
6
|
+
* An empty directory the plugin was started in (its `workspaceRoot`). When
|
|
7
|
+
* set, a plugin that generates projects is asked to generate an app, a lib
|
|
8
|
+
* and a package there, which a language plugin must then discover as
|
|
9
|
+
* generated. Generation may install dependencies, so it is opt-in.
|
|
10
|
+
*/
|
|
11
|
+
workspace?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Runs the checks of `smith plugin check` against a connected plugin: it names
|
|
15
|
+
* itself, has a known kind and known roles, its roles match what it
|
|
16
|
+
* implements, it names the slots it fills, its references and stacks are
|
|
17
|
+
* well-formed, every advertised method answers a request with nothing in it,
|
|
18
|
+
* and, with `workspace`, the projects it generates are found again by its own
|
|
19
|
+
* `discover`. A port of the Go SDK's `conformance.Check`, with the same checks
|
|
20
|
+
* under the same names. (`smith plugin check` adds one more, "shuts down when
|
|
21
|
+
* asked", after closing the plugin: call {@link PluginClient.close} and add it
|
|
22
|
+
* to the report if you want it.)
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkPlugin(client: PluginClient, options?: CheckOptions): Promise<ConformanceReport>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describeRef, isEmptyRef } from '../plugin-refs/index.js';
|
|
2
|
+
import { ROLES, effectiveRoles, isKnownRole } from '../plugin-roles/index.js';
|
|
3
|
+
import { checkGeneration } from './check-generation.use-case.js';
|
|
4
|
+
import { ConformanceReport } from './conformance-report.model.js';
|
|
5
|
+
import { goList, goQuote } from './go-list.mapper.js';
|
|
6
|
+
const CALL_TIMEOUT_MS = 20 * 60 * 1000;
|
|
7
|
+
/**
|
|
8
|
+
* Runs the checks of `smith plugin check` against a connected plugin: it names
|
|
9
|
+
* itself, has a known kind and known roles, its roles match what it
|
|
10
|
+
* implements, it names the slots it fills, its references and stacks are
|
|
11
|
+
* well-formed, every advertised method answers a request with nothing in it,
|
|
12
|
+
* and, with `workspace`, the projects it generates are found again by its own
|
|
13
|
+
* `discover`. A port of the Go SDK's `conformance.Check`, with the same checks
|
|
14
|
+
* under the same names. (`smith plugin check` adds one more, "shuts down when
|
|
15
|
+
* asked", after closing the plugin: call {@link PluginClient.close} and add it
|
|
16
|
+
* to the report if you want it.)
|
|
17
|
+
*/
|
|
18
|
+
export async function checkPlugin(client, options = {}) {
|
|
19
|
+
const info = client.info;
|
|
20
|
+
const report = new ConformanceReport(info.plugin);
|
|
21
|
+
report.add('names itself', info.plugin.name !== '' && info.plugin.version !== '', `initialize returned name ${goQuote(info.plugin.name)}, version ${goQuote(info.plugin.version)}; both are required`);
|
|
22
|
+
report.add('has a known kind', info.kind === 'language' || info.kind === 'convention', `kind ${goQuote(info.kind)} is neither language nor convention`);
|
|
23
|
+
const roles = effectiveRoles(info);
|
|
24
|
+
for (const role of roles)
|
|
25
|
+
report.add(`plays a known role: ${role}`, isKnownRole(role), `roles are ${goList(ROLES)}`);
|
|
26
|
+
const caps = info.capabilities;
|
|
27
|
+
if (roles.includes('language')) {
|
|
28
|
+
report.add('a language plugin discovers projects', caps.discover === true && (info.manifests ?? []).length > 0, 'it must implement discover and name its manifest files');
|
|
29
|
+
}
|
|
30
|
+
if (roles.includes('scaffold'))
|
|
31
|
+
report.add('a scaffold generates projects', caps.generate === true, 'it must implement generate');
|
|
32
|
+
for (const role of ['toolchain', 'structure', 'deploy']) {
|
|
33
|
+
if (roles.includes(role) && !roles.includes('language')) {
|
|
34
|
+
report.add(`a ${role} plugin does something`, caps.contribute === true || caps.generate === true || caps.ci === true || caps.doctor === true, 'it implements none of contribute, generate, ci and doctor');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const provides = info.provides ?? [];
|
|
38
|
+
if (provides.length > 0)
|
|
39
|
+
report.add('only a toolchain provides tasks', roles.includes('toolchain'), `it provides ${goList(provides)} without the toolchain role`);
|
|
40
|
+
if (info.kind === 'convention' && roles.includes('toolchain'))
|
|
41
|
+
report.add('a toolchain names the slots it fills', provides.length > 0, 'a toolchain plugin must list the tasks it provides');
|
|
42
|
+
const requires = info.requires ?? [];
|
|
43
|
+
const conflicts = info.conflicts ?? [];
|
|
44
|
+
const stacks = info.stacks ?? [];
|
|
45
|
+
for (const ref of requires)
|
|
46
|
+
report.add(`requires ${describeRef(ref)}: a known reference`, isValidRef(ref), 'a reference sets a known level, a name or a slot');
|
|
47
|
+
for (const ref of conflicts) {
|
|
48
|
+
report.add(`conflicts with ${describeRef(ref)}: a known reference`, isValidRef(ref) && ref.name !== info.plugin.name, 'a reference sets a known level, a name or a slot, and is not the plugin itself');
|
|
49
|
+
}
|
|
50
|
+
const names = new Set();
|
|
51
|
+
for (const stack of stacks) {
|
|
52
|
+
report.add(`stack ${stack.name} is complete`, stack.name !== '' && stack.language !== '' && !names.has(stack.name), 'a stack needs a unique name and a language plugin');
|
|
53
|
+
names.add(stack.name);
|
|
54
|
+
}
|
|
55
|
+
const answers = async (check, method, params) => {
|
|
56
|
+
if (!client.supports(method))
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
await client.call(method, params, { timeoutMs: CALL_TIMEOUT_MS });
|
|
60
|
+
report.add(check, true, '');
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
report.add(check, false, error instanceof Error ? error.message : String(error));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
await answers('discover answers with no manifests', 'discover', { manifests: [] });
|
|
67
|
+
await answers('contribute answers with no projects', 'contribute', { projects: [] });
|
|
68
|
+
await answers('ci answers with no projects', 'ci', { projects: [] });
|
|
69
|
+
await answers('doctor answers with no projects', 'doctor', { projects: [] });
|
|
70
|
+
if (options.workspace !== undefined && caps.generate === true)
|
|
71
|
+
await checkGeneration(client, options.workspace, report);
|
|
72
|
+
return report;
|
|
73
|
+
}
|
|
74
|
+
/** Whether a reference names something: a known level, a name or a slot. */
|
|
75
|
+
function isValidRef(ref) {
|
|
76
|
+
const level = ref.level ?? '';
|
|
77
|
+
return !isEmptyRef(ref) && (level === '' || isKnownRole(level));
|
|
78
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Peer } from '../protocol/index.ts';
|
|
2
|
+
/** One check: its name, whether it held, and, when it did not, what is wrong. */
|
|
3
|
+
export interface CheckResult {
|
|
4
|
+
check: string;
|
|
5
|
+
ok: boolean;
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Every check run against one plugin. `JSON.stringify(report)` gives the shape
|
|
10
|
+
* `smith plugin check --json` prints: `{ plugin, results }`.
|
|
11
|
+
*/
|
|
12
|
+
export declare class ConformanceReport {
|
|
13
|
+
readonly plugin: Peer;
|
|
14
|
+
readonly results: CheckResult[];
|
|
15
|
+
constructor(plugin: Peer);
|
|
16
|
+
/** Whether every check passed. */
|
|
17
|
+
get ok(): boolean;
|
|
18
|
+
/** The failed checks, one per line: `check: detail`. */
|
|
19
|
+
failures(): string;
|
|
20
|
+
/** Records a check. The detail is kept only for a check that failed. */
|
|
21
|
+
add(check: string, ok: boolean, detail: string): void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every check run against one plugin. `JSON.stringify(report)` gives the shape
|
|
3
|
+
* `smith plugin check --json` prints: `{ plugin, results }`.
|
|
4
|
+
*/
|
|
5
|
+
export class ConformanceReport {
|
|
6
|
+
plugin;
|
|
7
|
+
results = [];
|
|
8
|
+
constructor(plugin) {
|
|
9
|
+
this.plugin = plugin;
|
|
10
|
+
}
|
|
11
|
+
/** Whether every check passed. */
|
|
12
|
+
get ok() {
|
|
13
|
+
return this.results.every(result => result.ok);
|
|
14
|
+
}
|
|
15
|
+
/** The failed checks, one per line: `check: detail`. */
|
|
16
|
+
failures() {
|
|
17
|
+
return this.results.filter(result => !result.ok).map(result => `${result.check}: ${result.detail ?? ''}`).join('\n');
|
|
18
|
+
}
|
|
19
|
+
/** Records a check. The detail is kept only for a check that failed. */
|
|
20
|
+
add(check, ok, detail) {
|
|
21
|
+
this.results.push(ok ? { check, ok } : { check, ok, detail });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manifests of a plugin below a workspace, workspace-relative with forward
|
|
3
|
+
* slashes: files named like `names` (see isManifest), skipping dot-folders and
|
|
4
|
+
* dependency folders, and files at the root itself, as the Go conformance
|
|
5
|
+
* suite does.
|
|
6
|
+
*/
|
|
7
|
+
export declare function findManifests(workspace: string, names: readonly string[]): string[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join, posix } from 'node:path';
|
|
3
|
+
import { isManifest } from '../projects/index.js';
|
|
4
|
+
const SKIPPED_FOLDERS = new Set(['node_modules', '__pycache__']);
|
|
5
|
+
/**
|
|
6
|
+
* The manifests of a plugin below a workspace, workspace-relative with forward
|
|
7
|
+
* slashes: files named like `names` (see isManifest), skipping dot-folders and
|
|
8
|
+
* dependency folders, and files at the root itself, as the Go conformance
|
|
9
|
+
* suite does.
|
|
10
|
+
*/
|
|
11
|
+
export function findManifests(workspace, names) {
|
|
12
|
+
const found = [];
|
|
13
|
+
const walk = (relative) => {
|
|
14
|
+
const folder = join(workspace, ...relative.split('/').filter(part => part !== ''));
|
|
15
|
+
const entries = readdirSync(folder, { withFileTypes: true });
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const path = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
18
|
+
if (entry.isDirectory()) {
|
|
19
|
+
if (!entry.name.startsWith('.') && !SKIPPED_FOLDERS.has(entry.name))
|
|
20
|
+
walk(path);
|
|
21
|
+
}
|
|
22
|
+
else if (isManifest(names, entry.name) && posix.dirname(path) !== '.') {
|
|
23
|
+
found.push(path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
walk('');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// An unreadable folder ends the walk; what was found stays.
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** A list as Go's `%v` writes it, `[a b]`, which the details of the Go checks use; the TypeScript checks keep their text the same. */
|
|
2
|
+
export declare function goList(items: readonly unknown[]): string;
|
|
3
|
+
/** A string as Go's `%q` writes it. */
|
|
4
|
+
export declare function goQuote(text: string): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A list as Go's `%v` writes it, `[a b]`, which the details of the Go checks use; the TypeScript checks keep their text the same. */
|
|
2
|
+
export function goList(items) {
|
|
3
|
+
return `[${items.map(String).join(' ')}]`;
|
|
4
|
+
}
|
|
5
|
+
/** A string as Go's `%q` writes it. */
|
|
6
|
+
export function goQuote(text) {
|
|
7
|
+
return JSON.stringify(text);
|
|
8
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { checkPlugin } from './check-plugin.use-case.ts';
|
|
2
|
+
export type { CheckOptions } from './check-plugin.use-case.ts';
|
|
3
|
+
export { CONFORMANCE_STACK } from './check-generation.use-case.ts';
|
|
4
|
+
export { ConformanceReport } from './conformance-report.model.ts';
|
|
5
|
+
export type { CheckResult } from './conformance-report.model.ts';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One message as the protocol frames it: `Content-Length: <bytes>`, a blank
|
|
3
|
+
* line (CRLF CRLF), then the body. The length counts bytes of UTF-8, not characters.
|
|
4
|
+
*/
|
|
5
|
+
export function frameMessage(body) {
|
|
6
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
7
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${bytes.length}\r\n\r\n`, 'ascii'), bytes]);
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts a byte stream into the bodies of its frames. Bytes arrive in chunks of
|
|
3
|
+
* any size; {@link FrameReader.push} returns every body a chunk completed.
|
|
4
|
+
* Headers end at a blank line (CRLF or LF alone, as the Go SDK reads them),
|
|
5
|
+
* `Content-Length` is matched without regard to case, and a header that is not
|
|
6
|
+
* `Name: value`, a length that is not a number from 0 to 64 MiB, or a header
|
|
7
|
+
* block without a length is a {@link FramingError}.
|
|
8
|
+
*/
|
|
9
|
+
export declare class FrameReader {
|
|
10
|
+
#private;
|
|
11
|
+
/** Bytes received that are not part of a complete frame yet. */
|
|
12
|
+
get pending(): number;
|
|
13
|
+
/** Adds the bytes that arrived and returns the body of every frame they completed, in order. */
|
|
14
|
+
push(chunk: Uint8Array): string[];
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { MAX_MESSAGE_BYTES } from '../protocol/index.js';
|
|
2
|
+
import { FramingError } from './framing.error.js';
|
|
3
|
+
/**
|
|
4
|
+
* Cuts a byte stream into the bodies of its frames. Bytes arrive in chunks of
|
|
5
|
+
* any size; {@link FrameReader.push} returns every body a chunk completed.
|
|
6
|
+
* Headers end at a blank line (CRLF or LF alone, as the Go SDK reads them),
|
|
7
|
+
* `Content-Length` is matched without regard to case, and a header that is not
|
|
8
|
+
* `Name: value`, a length that is not a number from 0 to 64 MiB, or a header
|
|
9
|
+
* block without a length is a {@link FramingError}.
|
|
10
|
+
*/
|
|
11
|
+
export class FrameReader {
|
|
12
|
+
#buffer = Buffer.alloc(0);
|
|
13
|
+
/** The length and where the body starts, or null while the header is incomplete. */
|
|
14
|
+
#readHeader() {
|
|
15
|
+
let length = -1;
|
|
16
|
+
let position = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const newline = this.#buffer.indexOf(0x0A, position);
|
|
19
|
+
if (newline === -1)
|
|
20
|
+
return null;
|
|
21
|
+
const line = this.#buffer.subarray(position, newline).toString('ascii').replace(/[\r\n]+$/, '');
|
|
22
|
+
position = newline + 1;
|
|
23
|
+
if (line === '')
|
|
24
|
+
break;
|
|
25
|
+
const colon = line.indexOf(':');
|
|
26
|
+
if (colon === -1)
|
|
27
|
+
throw new FramingError(`malformed header "${line}"`);
|
|
28
|
+
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length')
|
|
29
|
+
continue;
|
|
30
|
+
const text = line.slice(colon + 1).trim();
|
|
31
|
+
const parsed = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
32
|
+
if (!Number.isSafeInteger(parsed) || parsed > MAX_MESSAGE_BYTES)
|
|
33
|
+
throw new FramingError(`invalid Content-Length "${line.slice(colon + 1)}"`);
|
|
34
|
+
length = parsed;
|
|
35
|
+
}
|
|
36
|
+
if (length < 0)
|
|
37
|
+
throw new FramingError('message without Content-Length');
|
|
38
|
+
return { length, bodyStart: position };
|
|
39
|
+
}
|
|
40
|
+
/** Bytes received that are not part of a complete frame yet. */
|
|
41
|
+
get pending() {
|
|
42
|
+
return this.#buffer.length;
|
|
43
|
+
}
|
|
44
|
+
/** Adds the bytes that arrived and returns the body of every frame they completed, in order. */
|
|
45
|
+
push(chunk) {
|
|
46
|
+
this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]);
|
|
47
|
+
const bodies = [];
|
|
48
|
+
for (;;) {
|
|
49
|
+
const header = this.#readHeader();
|
|
50
|
+
if (header === null)
|
|
51
|
+
return bodies;
|
|
52
|
+
const end = header.bodyStart + header.length;
|
|
53
|
+
if (this.#buffer.length < end)
|
|
54
|
+
return bodies;
|
|
55
|
+
bodies.push(this.#buffer.subarray(header.bodyStart, end).toString('utf8'));
|
|
56
|
+
this.#buffer = this.#buffer.subarray(end);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The bytes on the stream are not a sequence of frames: a malformed header, a missing or invalid Content-Length. The stream cannot be trusted after it. */
|
|
2
|
+
export class FramingError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'FramingError';
|
|
6
|
+
}
|
|
7
|
+
}
|
package/framing/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SmithPlugin } from '../serve/index.ts';
|
|
2
|
+
import type { HostOptions } from './host-options.contract.ts';
|
|
3
|
+
import { PluginClient } from './plugin-client.client.ts';
|
|
4
|
+
/** The streams of a plugin that is somewhere else: its responses to read, its requests to write. */
|
|
5
|
+
export interface PluginStreams {
|
|
6
|
+
/** What the plugin writes (its standard output). */
|
|
7
|
+
input: AsyncIterable<Uint8Array>;
|
|
8
|
+
/** What the plugin reads (its standard input). */
|
|
9
|
+
output: {
|
|
10
|
+
write: (chunk: Buffer, callback: (error?: Error | null) => void) => unknown;
|
|
11
|
+
end: () => unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Performs the handshake with a plugin reachable through a pair of streams.
|
|
16
|
+
* Closing the client shuts the plugin down and ends the streams; there is no
|
|
17
|
+
* process to kill.
|
|
18
|
+
*/
|
|
19
|
+
export declare function connectPlugin(streams: PluginStreams, options: HostOptions): Promise<PluginClient>;
|
|
20
|
+
/**
|
|
21
|
+
* Serves a plugin object in this process over pipes and connects to it, the way
|
|
22
|
+
* the Go SDK's tests serve a plugin with `serve.Serve` over `io.Pipe`: the
|
|
23
|
+
* real framing, handshake and dispatch, no process. Closing the client waits
|
|
24
|
+
* for the serve loop to end.
|
|
25
|
+
*/
|
|
26
|
+
export declare function connectInProcess(plugin: SmithPlugin, options: HostOptions): Promise<PluginClient>;
|