Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ The internal packages, private and bundled into `process-graph`:
| [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. |
| [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. |
| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs, and the inverses that turn an edited view into a diff against the model. |
| [`graph-export`](packages/core/graph/export/README.md) | Text exports of a projected view: Mermaid and Graphviz DOT for every notation and the projection as JSON, with every label from a source made safe to show; the one writer the command line and the web app share. |
| [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. |
| [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. |
| [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, typed filters and scope listings, with an in-memory fake and a conformance kit. |
| [`graph-source-github-issues`](packages/adapters/graph/source-github-issues/README.md) | The GitHub issues adapter for the `GraphSource` port: issues, `blockedBy` and `blocking`, sub-issues and assignees across organisations, repositories and Projects v2 boards, with server-side filters, a scope listing and per-owner tokens. |
| [`graph-store-contract`](packages/core/graph/store-contract/README.md) | The persistence port as an oRPC contract: save, load and delete models, set run state and the facts a run reads (provided artefacts, condition values), upsert items and relations from a source, ready, blocked-chain, impact and hierarchy queries, a change feed; with an in-memory fake and a conformance kit. |
| [`graph-sync`](packages/core/graph/sync/README.md) | Syncs `GraphSource`s into a `GraphStore` page by page: participants, bound activities, transitions, relations and run state, resumable from a checkpoint and narrowed by a typed filter; the one sync the command line and the web app share. |
| [`graph-store-sqlite`](packages/adapters/graph/store-sqlite/README.md) | The store port on SQLite through Drizzle: one table definition, committed drizzle-kit migrations applied the same way in every runtime, recursive SQL for the structural queries, against Drizzle's async SQLite database so any driver plugs in. |
| [`graph-sqlite-node`](packages/adapters/graph/sqlite-node/README.md) | The SQLite store on Node's built-in `node:sqlite` through `drizzle-orm/node-sqlite`, for the command line and servers. |
| [`graph-sqlite-wasm`](packages/adapters/graph/sqlite-wasm/README.md) | The SQLite store in the browser: SQLite wasm in a dedicated worker on OPFS `opfs-sahpool`, shared between tabs under a Web Lock with no special headers, served to the page over oRPC. |
Expand Down
20 changes: 6 additions & 14 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,12 @@ const config: KnipConfig = {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
},
// The contracts' Zod schemas embed the model's, which are built on trilean's expression and JSON schemas, so their emitted declarations import trilean although no source file names it, and knip traces source alone.
...Object.fromEntries(
[
"packages/core/graph/source-contract",
"packages/core/graph/store-contract",
].map((workspace) => [
workspace,
{
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
ignoreDependencies: ["trilean"],
},
]),
),
// The source contract's Zod schemas embed the model's, which are built on trilean's expression and JSON schemas, so its emitted declarations import trilean although no source file names it, and knip traces source alone. The store contract names trilean in its own source, so knip sees that use.
"packages/core/graph/source-contract": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
ignoreDependencies: ["trilean"],
},
"packages/adapters/*/*": {
entry: ["src/index.{ts,tsx}"],
project: ["src/**/*.{ts,tsx}!"],
Expand Down
1 change: 1 addition & 0 deletions packages/core/graph/export/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/export/CLAUDE.md
40 changes: 40 additions & 0 deletions packages/core/graph/export/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# @exadev/process-graph-export

[![GitHub](https://img.shields.io/badge/GitHub-181717?logo=github&logoColor=white)](https://github.com/ExaDev/process-graph) [![CI](https://img.shields.io/github/actions/workflow/status/ExaDev/process-graph/ci.yml?branch=main)](https://github.com/ExaDev/process-graph/actions)

A private workspace package, not published on its own: it is bundled into [`process-graph`](../../../targets/process-graph/README.md), which exposes it as its exports at the root entry, `process-graph`.

Text exports of a view projected by [`graph-projection`](../projection/README.md): Mermaid and Graphviz DOT for the flowchart, swimlane, state machine and sequence diagram, and the projection itself as JSON. The command line's `show` and the web app's downloads both write through `viewText`, so the same view gives the same text in both. `src/` imports nothing Node-only.

```ts
import { project } from "@exadev/process-graph-projection";
import { viewText } from "@exadev/process-graph-export";

const projected = project(model, { id: "lanes", notation: "swimlane" });
viewText(model, projected, "mermaid"); // flowchart LR ... with a subgraph per lane
viewText(model, projected, "dot"); // digraph "swimlane" { ... } with a cluster per lane
viewText(model, projected, "json"); // the swimlane projection, indented
```

## Functions

| Export | What it is |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `viewText(model, projected, format)` | A `ProjectedView` as `mermaid`, `dot` or `json` text; the model gives a sequence diagram's participant labels, an undeclared actor shown by its identifier. |
| `exportFormats`, `ExportFormat` | The formats `viewText` writes. |
| `flowchartToMermaid`, `swimlaneToMermaid`, `stateMachineToMermaid`, `sequenceToMermaid` | Each notation as Mermaid source, ending with a line break. |
| `flowchartToDot`, `swimlaneToDot`, `stateMachineToDot`, `sequenceToDot` | Each notation as Graphviz DOT source, ending with a line break. |
| `terminalText(text)`, `terminalJson(value)`, `isUnsafeCharacter(character)` | Text and JSON made safe to show: every control, format, separator, unassigned and private-use character written as a visible escape of its code point. |

## Safe labels

A label from a source (an issue title, a repository name) can carry characters that break a diagram or act on whatever shows it. The Mermaid emitters name nodes by their position in the view, since Mermaid does not accept identifiers such as `github:org/repo#12`, and write every character Mermaid reads as syntax as its entity code. The DOT emitters keep the model identifiers and quote every one. In both, every character in the Unicode general categories `Cc`, `Cf`, `Zl`, `Zp`, `Cn` and `Co` is written as a visible escape such as `\u{1b}`, apart from line feed, tab and the zero-width joiner and non-joiner, which text in many scripts needs. Hand-offs are thick (Mermaid) or bold (DOT) edges, and DOT, which has no sequence notation, draws a sequence diagram as a grid of lifelines with one numbered row per message.

## Commands

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests
pnpm test:smoke # the built dist/ exercised as a real artefact
pnpm test:mutation # Stryker
```
9 changes: 9 additions & 0 deletions packages/core/graph/export/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
}),
);
70 changes: 70 additions & 0 deletions packages/core/graph/export/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@exadev/process-graph-export",
"version": "0.0.0",
"description": "Text exports of a projected view: Mermaid and Graphviz DOT for every notation, and the projection as JSON, with every label from a source made safe to show.",
"private": true,
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/core/graph/export"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/export#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only"
},
"keywords": [
"process-model",
"mermaid",
"graphviz",
"dot",
"export"
],
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@stryker-mutator/core": "catalog:",
"@stryker-mutator/typescript-checker": "catalog:",
"@stryker-mutator/vitest-runner": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"publint": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"@exadev/process-graph-projection": "workspace:*",
"@exadev/process-graph-schema": "workspace:*"
}
}
21 changes: 21 additions & 0 deletions packages/core/graph/export/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Text exports of a projected view: Mermaid and Graphviz DOT for the flowchart, swimlane, state machine and sequence diagram, and the projection itself as JSON. Every label from a source is made safe to show: escaped for the notation, and with every control and invisible format character written as a visible escape. The command line's `show` and the web app's downloads both write through `viewText`.
*/
export { exportFormats, viewText, type ExportFormat } from "./view.ts";
export {
flowchartToMermaid,
sequenceToMermaid,
stateMachineToMermaid,
swimlaneToMermaid,
} from "./mermaid.ts";
export {
flowchartToDot,
sequenceToDot,
stateMachineToDot,
swimlaneToDot,
} from "./dot.ts";
export {
isUnsafeCharacter,
terminalJson,
terminalText,
} from "./terminal-text.ts";
98 changes: 98 additions & 0 deletions packages/core/graph/export/src/terminal-text.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, test } from "vitest";
import {
isUnsafeCharacter,
terminalJson,
terminalText,
} from "./terminal-text.ts";

/** A title as a hostile source could write it: clear the screen (CSI 2J), set the clipboard (OSC 52, ended by BEL), reverse the rest of the line (a right-to-left override), and a NUL. */
const hostile =
"Fix\u001b[2J\u001b]52;c;aGVsbG8=\u0007 \u202Egnp.exe\u0000 now";
/** The same title as the command line writes it. */
const escaped =
"Fix\\u{1b}[2J\\u{1b}]52;c;aGVsbG8=\\u{7} \\u{202e}gnp.exe\\u{0} now";
/** Ordinary text from anywhere: accents, CJK and an emoji, none of which is changed. */
const ordinary = "Café 日本語 🚀";

/** The characters of an output that should never be written raw; empty for safe output. */
const unsafeIn = (output: string): string[] =>
Array.from(output).filter((character) => isUnsafeCharacter(character));

/** One example of each kind of character the general-category rule escapes beyond the C0 and C1 controls, with the escape it is written as. */
const categoryExamples: readonly (readonly [string, string, string])[] = [
["the Arabic letter mark (Cf)", "\u061C", "\\u{61c}"],
["the left-to-right mark (Cf)", "\u200E", "\\u{200e}"],
["the right-to-left mark (Cf)", "\u200F", "\\u{200f}"],
["the line separator (Zl)", "\u2028", "\\u{2028}"],
["the paragraph separator (Zp)", "\u2029", "\\u{2029}"],
["the soft hyphen (Cf)", "\u00AD", "\\u{ad}"],
["a tag character (Cf)", "\u{E0041}", "\\u{e0041}"],
["the interlinear annotation anchor (Cf)", "\uFFF9", "\\u{fff9}"],
["a private-use character (Co)", "\uE000", "\\u{e000}"],
["an unassigned code point (Cn)", "\u2065", "\\u{2065}"],
];

/** Text that must pass through unchanged: a family emoji joined by zero-width joiners, CJK, accents, Arabic, and Persian with its zero-width non-joiner. */
const scripts = [
"\u{1F468}\u200D\u{1F469}\u200D\u{1F467}",
"日本語の文",
"crème brûlée",
"مرحبا بالعالم",
"\u0645\u06CC\u200C\u062E\u0648\u0627\u0647\u0645",
];

describe("terminalText", () => {
test("escapes every control and format character class it guards, visibly and by code point", () => {
expect(
terminalText(
"\u0000\u0008\u000B\u001F\u007F\u0080\u009F\u200B\u200F\u202A\u202E\u2060\u2064\u2066\u2069\uFEFF",
),
).toBe(
"\\u{0}\\u{8}\\u{b}\\u{1f}\\u{7f}\\u{80}\\u{9f}\\u{200b}\\u{200f}\\u{202a}\\u{202e}\\u{2060}\\u{2064}\\u{2066}\\u{2069}\\u{feff}",
);
});

test("leaves line feed, tab and ordinary text in any script exactly as written, joiners included", () => {
const text = `a\tb\n${ordinary}${scripts.join(" ")}`;

expect(terminalText(text)).toBe(text);
});

test.each(categoryExamples)(
"escapes %s visibly by its code point",
(_name, character, escape) => {
expect(terminalText(`a${character}b`)).toBe(`a${escape}b`);
},
);

test("escapes a hostile title", () => {
expect(terminalText(hostile)).toBe(escaped);
});
});

describe("terminalJson", () => {
test.each(categoryExamples)(
"writes %s as a JSON escape that parses back to it",
(_name, character) => {
const json = terminalJson({ title: `a${character}b` });

expect(unsafeIn(json)).toEqual([]);
expect(json).toMatch(/\\u[0-9a-f]{4}/u);
expect(JSON.parse(json)).toEqual({ title: `a${character}b` });
},
);

test("writes ordinary text in any script, joiners included, as it is", () => {
const value = { scripts };

expect(terminalJson(value)).toBe(`${JSON.stringify(value, null, 2)}\n`);
});

test("writes no unsafe character raw, and parses back to the same value", () => {
const value = { title: hostile, c1: "\u009B2J", del: "\u007F", ordinary };
const json = terminalJson(value);

expect(unsafeIn(json)).toEqual([]);
expect(JSON.parse(json)).toEqual(value);
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { terminalText } from "../terminal-text.ts";
import { terminalText } from "./terminal-text.ts";

/**
* The Mermaid entity code for a character that would end or change the meaning of a label: quotes, statement and edge-label delimiters, the entity marker itself and HTML.
Expand Down
Loading
Loading