diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index aa14d78..f10e125 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -12,5 +12,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 + - run: npm ci - run: npm run check + - run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3e70b41 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "@gsm-foundation/graphite", + "version": "0.0.0-incubating", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@gsm-foundation/graphite", + "version": "0.0.0-incubating", + "devDependencies": { + "ajv": "^8.20.0", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/package.json b/package.json index e96febb..b126c5a 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,14 @@ "description": "ontology-based server communications bootstrap kit", "type": "module", "scripts": { - "check": "node scripts/check.mjs" + "check": "node scripts/check.mjs", + "test": "node --test test/*.test.mjs" }, "engines": { "node": ">=18" + }, + "devDependencies": { + "ajv": "^8.20.0", + "yaml": "^2.9.0" } } diff --git a/test/check.test.mjs b/test/check.test.mjs new file mode 100644 index 0000000..fc7b604 --- /dev/null +++ b/test/check.test.mjs @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const exec = promisify(execFile); +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const checkScript = resolve(root, "scripts/check.mjs"); + +const REQUIRED_FILES = [ + "README.md", + "spec/graphite.protocol.md", + "spec/adoption_gate.md", + "spec/non_adoption_boundary.md", + "ontology/graphite.yaml", + "protocol/envelope.schema.json", + "docs/github_repo_plan.md", +]; + +describe("scripts/check.mjs", () => { + it("should list all required files in the script source", async () => { + const src = await readFile(checkScript, "utf8"); + for (const file of REQUIRED_FILES) { + assert.ok(src.includes(file), `script should reference ${file}`); + } + }); + + it("should exit 0 when all required files are present", async () => { + const { stdout } = await exec("node", [checkScript], { cwd: root }); + assert.ok(stdout.includes("graphite incubator check passed")); + for (const file of REQUIRED_FILES) { + assert.ok(stdout.includes(`ok ${file}`), `should report ok for ${file}`); + } + }); + + it("should not report any missing files in a valid repo", async () => { + const { stdout } = await exec("node", [checkScript], { cwd: root }); + assert.ok(!stdout.includes("missing"), "no files should be missing"); + }); + + it("should verify each required file is readable", async () => { + for (const file of REQUIRED_FILES) { + const content = await readFile(resolve(root, file), "utf8"); + assert.ok(content.length > 0, `${file} should not be empty`); + } + }); + + it("should use ES module syntax", async () => { + const src = await readFile(checkScript, "utf8"); + assert.ok(src.includes("import"), "should use ES module imports"); + assert.ok( + src.includes("import.meta.url"), + "should reference import.meta.url for path resolution" + ); + }); +}); diff --git a/test/envelope.test.mjs b/test/envelope.test.mjs new file mode 100644 index 0000000..24aa1b4 --- /dev/null +++ b/test/envelope.test.mjs @@ -0,0 +1,187 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const schemaPath = resolve(root, "protocol/envelope.schema.json"); + +async function loadSchema() { + const raw = await readFile(schemaPath, "utf8"); + return JSON.parse(raw); +} + +describe("protocol/envelope.schema.json", () => { + it("should be valid JSON", async () => { + const raw = await readFile(schemaPath, "utf8"); + assert.doesNotThrow(() => JSON.parse(raw)); + }); + + it("should reference JSON Schema 2020-12 draft", async () => { + const schema = await loadSchema(); + assert.equal( + schema.$schema, + "https://json-schema.org/draft/2020-12/schema" + ); + }); + + it("should have the correct title", async () => { + const schema = await loadSchema(); + assert.equal(schema.title, "Graphite Envelope Draft"); + }); + + it("should require protocol, kind, created_at, and payload", async () => { + const schema = await loadSchema(); + assert.deepEqual(schema.required.sort(), [ + "created_at", + "kind", + "payload", + "protocol", + ]); + }); + + it("should define protocol as a string with pattern", async () => { + const schema = await loadSchema(); + const prop = schema.properties.protocol; + assert.equal(prop.type, "string"); + assert.ok(prop.pattern, "protocol should have a regex pattern"); + }); + + it("should allow additional properties", async () => { + const schema = await loadSchema(); + assert.equal(schema.additionalProperties, true); + }); + + describe("envelope validation", () => { + let validate; + + it("should compile into a valid AJV validator", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + assert.equal(typeof validate, "function"); + }); + + it("should accept a valid envelope", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/0.0", + kind: "message.text", + created_at: "2026-06-26T00:00:00.000Z", + payload: { text: "hello" }, + }); + assert.ok(valid, `validation errors: ${JSON.stringify(validate.errors)}`); + }); + + it("should accept an envelope with an optional gate", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/1.0", + kind: "governance.decision", + created_at: "2026-06-26T12:00:00.000Z", + gate: { signer: "foundation", decision: "approve" }, + payload: { ref: "adopt-graphite-1-0" }, + }); + assert.ok(valid, `validation errors: ${JSON.stringify(validate.errors)}`); + }); + + it("should accept an envelope with additional properties", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/0.0", + kind: "claim.source", + created_at: "2026-06-26T00:00:00.000Z", + payload: {}, + origin: "lab-alpha", + tags: ["experiment"], + }); + assert.ok(valid, `validation errors: ${JSON.stringify(validate.errors)}`); + }); + + it("should reject an envelope missing protocol", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + kind: "message.text", + created_at: "2026-06-26T00:00:00.000Z", + payload: {}, + }); + assert.ok(!valid, "should be invalid without protocol"); + }); + + it("should reject an envelope missing payload", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/0.0", + kind: "message.text", + created_at: "2026-06-26T00:00:00.000Z", + }); + assert.ok(!valid, "should be invalid without payload"); + }); + + it("should reject an envelope missing kind", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/0.0", + created_at: "2026-06-26T00:00:00.000Z", + payload: {}, + }); + assert.ok(!valid, "should be invalid without kind"); + }); + + it("should reject an envelope missing created_at", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "graphite/0.0", + kind: "message.text", + payload: {}, + }); + assert.ok(!valid, "should be invalid without created_at"); + }); + + it("should reject a protocol value not matching the pattern", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({ + protocol: "not-graphite", + kind: "message.text", + created_at: "2026-06-26T00:00:00.000Z", + payload: {}, + }); + assert.ok(!valid, "should reject malformed protocol string"); + }); + + it("should reject a completely empty object", async () => { + const schema = await loadSchema(); + const ajv = new Ajv2020(); + validate = ajv.compile(schema); + + const valid = validate({}); + assert.ok(!valid, "empty object should be invalid"); + }); + }); +}); diff --git a/test/ontology.test.mjs b/test/ontology.test.mjs new file mode 100644 index 0000000..2a31669 --- /dev/null +++ b/test/ontology.test.mjs @@ -0,0 +1,142 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { parse } from "yaml"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const ontologyPath = resolve(root, "ontology/graphite.yaml"); + +async function loadOntology() { + const raw = await readFile(ontologyPath, "utf8"); + return parse(raw); +} + +describe("ontology/graphite.yaml", () => { + it("should be valid YAML", async () => { + const raw = await readFile(ontologyPath, "utf8"); + assert.doesNotThrow(() => parse(raw)); + }); + + it("should have name set to graphite", async () => { + const doc = await loadOntology(); + assert.equal(doc.name, "graphite"); + }); + + it("should have status set to incubating_pre_1_0", async () => { + const doc = await loadOntology(); + assert.equal(doc.status, "incubating_pre_1_0"); + }); + + it("should have a non-empty definition string", async () => { + const doc = await loadOntology(); + assert.equal(typeof doc.definition, "string"); + assert.ok(doc.definition.length > 0, "definition should not be empty"); + }); + + describe("adoption section", () => { + it("should not be foundation-wide", async () => { + const doc = await loadOntology(); + assert.equal(doc.adoption.foundation_wide, false); + }); + + it("should require version >=1.0.0", async () => { + const doc = await loadOntology(); + assert.equal(doc.adoption.requires_version, ">=1.0.0"); + }); + + it("should require signed decision adopt-graphite-1-0", async () => { + const doc = await loadOntology(); + assert.equal( + doc.adoption.requires_signed_decision, + "adopt-graphite-1-0" + ); + }); + + it("should require external developer support", async () => { + const doc = await loadOntology(); + assert.equal(doc.adoption.requires_external_developer_support, true); + }); + }); + + describe("properties section", () => { + const EXPECTED_PROPERTIES = [ + "safe", + "local", + "secure", + "learning", + "graphable", + ]; + + it("should define all expected properties", async () => { + const doc = await loadOntology(); + for (const prop of EXPECTED_PROPERTIES) { + assert.ok(doc.properties[prop], `should define property: ${prop}`); + } + }); + + it("should have a meaning string for each property", async () => { + const doc = await loadOntology(); + for (const prop of EXPECTED_PROPERTIES) { + const entry = doc.properties[prop]; + assert.equal( + typeof entry.meaning, + "string", + `${prop}.meaning should be a string` + ); + assert.ok( + entry.meaning.length > 0, + `${prop}.meaning should not be empty` + ); + } + }); + + it("should have a status for each property", async () => { + const doc = await loadOntology(); + for (const prop of EXPECTED_PROPERTIES) { + const entry = doc.properties[prop]; + assert.equal( + typeof entry.status, + "string", + `${prop}.status should be a string` + ); + } + }); + + it("should have all properties in candidate status", async () => { + const doc = await loadOntology(); + for (const prop of EXPECTED_PROPERTIES) { + assert.equal( + doc.properties[prop].status, + "candidate", + `${prop} should be in candidate status` + ); + } + }); + }); + + describe("non_adoption_boundary section", () => { + it("should be a non-empty array", async () => { + const doc = await loadOntology(); + assert.ok(Array.isArray(doc.non_adoption_boundary)); + assert.ok(doc.non_adoption_boundary.length > 0); + }); + + it("should contain boundary about public foundation language", async () => { + const doc = await loadOntology(); + const match = doc.non_adoption_boundary.some((b) => + b.includes("public foundation language") + ); + assert.ok(match, "should mention public foundation language boundary"); + }); + + it("should contain boundary about stable api", async () => { + const doc = await loadOntology(); + const match = doc.non_adoption_boundary.some((b) => + b.includes("stable api") + ); + assert.ok(match, "should mention stable api boundary"); + }); + }); +});