Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
12
extensions/oc-path/cli-metadata.ts
Normal file
12
extensions/oc-path/cli-metadata.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// OC Path module implements cli metadata behavior.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerOcPathCli } from "./cli-registration.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "oc-path",
|
||||
name: "OC Path",
|
||||
description: "Adds the openclaw path CLI for oc:// workspace file addressing.",
|
||||
register(api) {
|
||||
registerOcPathCli(api);
|
||||
},
|
||||
});
|
||||
20
extensions/oc-path/cli-registration.ts
Normal file
20
extensions/oc-path/cli-registration.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// OC Path module implements cli registration behavior.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
export function registerOcPathCli(api: OpenClawPluginApi): void {
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerPathCli } = await import("./src/cli.js");
|
||||
registerPathCli(program);
|
||||
},
|
||||
{
|
||||
descriptors: [
|
||||
{
|
||||
name: "path",
|
||||
description: "Inspect and edit workspace files via oc:// paths",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
12
extensions/oc-path/index.ts
Normal file
12
extensions/oc-path/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// OC Path plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerOcPathCli } from "./cli-registration.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "oc-path",
|
||||
name: "OC Path",
|
||||
description: "Adds the openclaw path CLI for oc:// workspace file addressing.",
|
||||
register(api) {
|
||||
registerOcPathCli(api);
|
||||
},
|
||||
});
|
||||
20
extensions/oc-path/openclaw.plugin.json
Normal file
20
extensions/oc-path/openclaw.plugin.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"id": "oc-path",
|
||||
"name": "OC Path",
|
||||
"description": "Adds the openclaw path CLI for oc:// workspace file addressing.",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
"onCommands": ["path"]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "path",
|
||||
"kind": "cli"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
30
extensions/oc-path/package.json
Normal file
30
extensions/oc-path/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@openclaw/oc-path",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw oc:// workspace path plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"commander": "15.0.0",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"markdown-it": "14.3.0",
|
||||
"yaml": "2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
434
extensions/oc-path/src/cli.test.ts
Normal file
434
extensions/oc-path/src/cli.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Smoke tests for the `openclaw path` CLI handlers.
|
||||
*
|
||||
* Tests invoke each subcommand handler directly with a capturing
|
||||
* `OutputRuntimeEnv` — no commander wiring, no child process spawn.
|
||||
* Assertions inspect captured stdout/stderr and the exit code the
|
||||
* handler set on the runtime.
|
||||
*/
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type OutputRuntimeEnv,
|
||||
formatUnifiedDiff,
|
||||
pathEmitCommand,
|
||||
pathFindCommand,
|
||||
pathResolveCommand,
|
||||
pathSetCommand,
|
||||
pathValidateCommand,
|
||||
} from "./cli.js";
|
||||
|
||||
interface TestRuntime extends OutputRuntimeEnv {
|
||||
readonly stdout: string[];
|
||||
readonly stderr: string[];
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
function createTestRuntime(): TestRuntime {
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
const runtime: TestRuntime = {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: 0,
|
||||
error: (value) => {
|
||||
stderr.push(value);
|
||||
},
|
||||
writeStdout: (value) => {
|
||||
stdout.push(value);
|
||||
},
|
||||
exit: (code) => {
|
||||
runtime.exitCode = code;
|
||||
},
|
||||
};
|
||||
return runtime;
|
||||
}
|
||||
|
||||
const stdoutText = (rt: TestRuntime): string => rt.stdout.join("\n");
|
||||
const stderrText = (rt: TestRuntime): string => rt.stderr.join("\n");
|
||||
|
||||
describe("openclaw path CLI", () => {
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workspaceDir = mkdtempSync(join(tmpdir(), "oc-path-cli-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
// mkdtemp leaves a small dir; OS will GC it. Skip cleanup to keep
|
||||
// the test deterministic on Windows where rmdir flakes.
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("CLI-V01 accepts a well-formed path with --json", () => {
|
||||
const rt = createTestRuntime();
|
||||
pathValidateCommand("oc://AGENTS.md/Tools/-1", { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.valid).toBe(true);
|
||||
expect(out.structure.file).toBe("AGENTS.md");
|
||||
expect(out.structure.section).toBe("Tools");
|
||||
});
|
||||
|
||||
it("CLI-V02 rejects a malformed path with code 1", () => {
|
||||
const rt = createTestRuntime();
|
||||
pathValidateCommand("oc://X/a\x00b", { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("CLI-V03 missing argument returns 2", () => {
|
||||
const rt = createTestRuntime();
|
||||
pathValidateCommand(undefined, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(2);
|
||||
expect(stderrText(rt)).toContain("missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolve", () => {
|
||||
it("CLI-R01 finds a leaf in jsonc and prints it", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathResolveCommand("oc://gateway.jsonc/version", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.resolved).toBe(true);
|
||||
expect(out.match.kind).toBe("leaf");
|
||||
expect(out.match.valueText).toBe("1.0");
|
||||
});
|
||||
|
||||
it("CLI-R04 finds a leaf in yaml and prints it", async () => {
|
||||
const filePath = join(workspaceDir, "workflow.yaml");
|
||||
writeFileSync(filePath, "name: inbox-triage\nsteps:\n - id: fetch\n", "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathResolveCommand(
|
||||
"oc://workflow.yaml/steps/0/id",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.resolved).toBe(true);
|
||||
expect(out.match.kind).toBe("leaf");
|
||||
expect(out.match.valueText).toBe("fetch");
|
||||
});
|
||||
|
||||
it("CLI-R02 returns 1 for not-found path", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathResolveCommand("oc://gateway.jsonc/missing", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.resolved).toBe(false);
|
||||
});
|
||||
|
||||
it("CLI-R03 missing argument returns 2", async () => {
|
||||
const rt = createTestRuntime();
|
||||
await pathResolveCommand(undefined, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(2);
|
||||
expect(stderrText(rt)).toContain("missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("set", () => {
|
||||
it("CLI-S01 writes new bytes when path resolves", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/version",
|
||||
"2.0",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const after = readFileSync(filePath, "utf-8");
|
||||
expect(after).toContain('"2.0"');
|
||||
});
|
||||
|
||||
it("CLI-S02 --dry-run does not write to disk", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
const before = '{ "version": "1.0" }';
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/version",
|
||||
"2.0",
|
||||
{ cwd: workspaceDir, json: true, dryRun: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.dryRun).toBe(true);
|
||||
expect(out.bytes).toContain('"2.0"');
|
||||
// File on disk unchanged.
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-S05 --dry-run --diff prints a unified diff", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
const before = '{\n "version": "1.0",\n "enabled": true\n}\n';
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/version",
|
||||
"2.0",
|
||||
{ cwd: workspaceDir, human: true, dryRun: true, diff: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = stdoutText(rt);
|
||||
expect(out).toContain("--- ");
|
||||
expect(out).toContain("+++ ");
|
||||
expect(out).toContain('- "version": "1.0",');
|
||||
expect(out).toContain('+ "version": "2.0",');
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-S05b --dry-run --diff shows final newline-only byte changes", () => {
|
||||
const out = formatUnifiedDiff(
|
||||
"## Boundaries\n\n- timeout: 5\n",
|
||||
"## Boundaries\n\n- timeout: 5",
|
||||
"AGENTS.md",
|
||||
);
|
||||
expect(out).toContain("--- AGENTS.md");
|
||||
expect(out).toContain("@@ -1,4 +1,3 @@");
|
||||
expect(out).toContain("\n-\n");
|
||||
});
|
||||
|
||||
it("CLI-S05c --dry-run --diff shows line-ending-only byte changes", async () => {
|
||||
const filePath = join(workspaceDir, "AGENTS.md");
|
||||
const before = "---\r\nname: x\r\n---\r\n";
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://AGENTS.md/[frontmatter]/name",
|
||||
"x",
|
||||
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.diff).toContain("-name: x\r");
|
||||
expect(out.diff).toContain("+name: x");
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-S06 --dry-run --diff includes diff in JSON output", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "version": "1.0" }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/version",
|
||||
"2.0",
|
||||
{ cwd: workspaceDir, json: true, dryRun: true, diff: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.dryRun).toBe(true);
|
||||
expect(out.bytes).toContain('"2.0"');
|
||||
expect(out.diff).toContain('-{ "version": "1.0" }');
|
||||
expect(out.diff).toContain('+{ "version": "2.0" }');
|
||||
});
|
||||
|
||||
it("CLI-S07 rejects --diff without --dry-run", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
const before = '{ "version": "1.0" }';
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/version",
|
||||
"2.0",
|
||||
{ cwd: workspaceDir, json: true, diff: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
expect(JSON.parse(stdoutText(rt))).toMatchObject({
|
||||
ok: false,
|
||||
reason: "--diff requires --dry-run",
|
||||
});
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-S08 sets slash-deep JSONC paths and parsed JSON values", async () => {
|
||||
const filePath = join(workspaceDir, "openclaw.json");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] }, "gateway": { "auth": { "token": "${TOKEN}" } } }\n',
|
||||
"utf-8",
|
||||
);
|
||||
const rt = createTestRuntime();
|
||||
|
||||
await pathSetCommand(
|
||||
"oc://openclaw.json/gateway/auth/token",
|
||||
'{"source":"file","provider":"secrets","id":"/test"}',
|
||||
{ cwd: workspaceDir, json: true, valueJson: true },
|
||||
rt,
|
||||
);
|
||||
|
||||
expect(rt.exitCode).toBe(0);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf8")).gateway.auth.token).toEqual({
|
||||
source: "file",
|
||||
provider: "secrets",
|
||||
id: "/test",
|
||||
});
|
||||
|
||||
const rt2 = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://openclaw.json/agents/list/0/tools/exec/security",
|
||||
"allowlist",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt2,
|
||||
);
|
||||
|
||||
expect(rt2.exitCode).toBe(0);
|
||||
expect(JSON.parse(readFileSync(filePath, "utf8")).agents.list[0].tools.exec.security).toBe(
|
||||
"allowlist",
|
||||
);
|
||||
});
|
||||
|
||||
it("CLI-S03 sentinel-bearing value is refused at emit", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, '{ "token": "x" }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
// The sentinel-bearing value is accepted into the AST by setOcPath,
|
||||
// but `emitForKind` refuses to serialize it (defense-in-depth at
|
||||
// the per-kind emit boundary). The CLI handler must catch that
|
||||
// refusal and route it through the structured error boundary —
|
||||
// a thrown error escaping commander would print raw `String(err)`
|
||||
// and bypass our JSON/human scrubbing. Pin the structured shape:
|
||||
// exit code 1, stable code OC_EMIT_SENTINEL, message scrubbed.
|
||||
await pathSetCommand(
|
||||
"oc://gateway.jsonc/token",
|
||||
"__OPENCLAW_REDACTED__",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
expect(stderrText(rt)).toContain("OC_EMIT_SENTINEL");
|
||||
// F13 — file context in sentinel error. Without fileNameForGuard
|
||||
// plumbing through emitForKind, the message would carry the
|
||||
// empty-slot fallback (`oc:///[raw]`); now it carries the actual
|
||||
// file (`oc://gateway.jsonc/[raw]`). Forensics + audit pipelines
|
||||
// rely on this — without the file context, "sentinel rejected
|
||||
// somewhere" doesn't tell you WHICH file was involved.
|
||||
expect(stderrText(rt)).toContain("gateway.jsonc");
|
||||
});
|
||||
|
||||
it("CLI-S04 missing args returns 2", async () => {
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(undefined, undefined, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(2);
|
||||
expect(stderrText(rt)).toContain("requires");
|
||||
});
|
||||
|
||||
it("CLI-S05 malformed yaml returns structured parse-error", async () => {
|
||||
const filePath = join(workspaceDir, "workflow.yaml");
|
||||
const before = "key: value\n bad indent: oops\n";
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathSetCommand(
|
||||
"oc://workflow.yaml/key",
|
||||
"new-value",
|
||||
{ cwd: workspaceDir, json: true },
|
||||
rt,
|
||||
);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out).toMatchObject({ ok: false, reason: "parse-error" });
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("find", () => {
|
||||
it("CLI-F01 enumerates wildcard matches", async () => {
|
||||
const filePath = join(workspaceDir, "config.jsonc");
|
||||
writeFileSync(filePath, '{ "items": [ { "id": "a" }, { "id": "b" } ] }', "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathFindCommand("oc://config.jsonc/items/*/id", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.count).toBe(2);
|
||||
});
|
||||
|
||||
it("CLI-F02 returns 1 when zero matches", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
writeFileSync(filePath, "{}", "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathFindCommand("oc://gateway.jsonc/nope/*", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("CLI-F03 file-slot wildcard rejected with clear error (no ENOENT)", async () => {
|
||||
// Closes Galin P3 (round 8): `find` resolves `pattern.file` to one
|
||||
// literal path, so `oc://*.jsonc/...` would silently ENOENT during
|
||||
// fs.readFile. The CLI now surfaces a clear error before touching
|
||||
// the filesystem, with stable code OC_PATH_FILE_WILDCARD_UNSUPPORTED.
|
||||
const rt = createTestRuntime();
|
||||
await pathFindCommand("oc://*.jsonc/items", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(2);
|
||||
expect(stderrText(rt)).toContain("OC_PATH_FILE_WILDCARD_UNSUPPORTED");
|
||||
expect(stderrText(rt)).toContain("file-slot wildcards are not supported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("emit", () => {
|
||||
it("CLI-E01 round-trips jsonc bytes verbatim (byte-fidelity proof)", async () => {
|
||||
const filePath = join(workspaceDir, "gateway.jsonc");
|
||||
const before = '// keep this comment\n{\n "v": 1\n}\n';
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathEmitCommand(filePath, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.kind).toBe("jsonc");
|
||||
expect(out.bytes).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-E02 round-trips md verbatim", async () => {
|
||||
const filePath = join(workspaceDir, "AGENTS.md");
|
||||
const before = "## Tools\n- gh\n## Boundaries\n- never rm -rf\n";
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathEmitCommand(filePath, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.kind).toBe("md");
|
||||
expect(out.bytes).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-E04 round-trips yaml verbatim", async () => {
|
||||
const filePath = join(workspaceDir, "workflow.yaml");
|
||||
const before = "# keep comment\nname: inbox-triage\nsteps:\n - id: fetch\n";
|
||||
writeFileSync(filePath, before, "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
await pathEmitCommand(filePath, { json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.kind).toBe("yaml");
|
||||
expect(out.bytes).toBe(before);
|
||||
});
|
||||
|
||||
it("CLI-E03 emit --cwd resolves <file> against the supplied directory", async () => {
|
||||
// Closes round-10 finding F2: emit advertises --cwd / --file in
|
||||
// the docs but the handler resolved <file> against process.cwd()
|
||||
// ignoring both. Pin the new wiring: a relative <file> resolves
|
||||
// against --cwd, not against process.cwd().
|
||||
const filePath = join(workspaceDir, "AGENTS.md");
|
||||
writeFileSync(filePath, "## Tools\n- gh\n", "utf-8");
|
||||
const rt = createTestRuntime();
|
||||
// Pass a RELATIVE filename + explicit --cwd. If the handler
|
||||
// ignored --cwd, loadAst would ENOENT against process.cwd().
|
||||
await pathEmitCommand("AGENTS.md", { cwd: workspaceDir, json: true }, rt);
|
||||
expect(rt.exitCode).toBe(0);
|
||||
const out = JSON.parse(stdoutText(rt));
|
||||
expect(out.kind).toBe("md");
|
||||
expect(out.bytes).toBe("## Tools\n- gh\n");
|
||||
});
|
||||
});
|
||||
});
|
||||
591
extensions/oc-path/src/cli.ts
Normal file
591
extensions/oc-path/src/cli.ts
Normal file
@@ -0,0 +1,591 @@
|
||||
/**
|
||||
* `openclaw path` — shell access to the OcPath substrate verbs.
|
||||
*
|
||||
* Subcommands: `resolve` / `set` / `find` / `validate` / `emit`.
|
||||
* TTY-aware output: human when interactive, JSON when piped; `--json`
|
||||
* / `--human` override.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "node:fs";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
OcEmitSentinelError,
|
||||
OcPathError,
|
||||
REDACTED_SENTINEL,
|
||||
emitJsonc,
|
||||
emitJsonl,
|
||||
emitMd,
|
||||
emitYaml,
|
||||
findOcPaths,
|
||||
formatOcPath,
|
||||
inferKind,
|
||||
parseJsonc,
|
||||
parseJsonl,
|
||||
parseMd,
|
||||
parseOcPath,
|
||||
parseYaml,
|
||||
resolveOcPath,
|
||||
setOcPath,
|
||||
type OcAst,
|
||||
type OcMatch,
|
||||
type OcPath,
|
||||
} from "./oc-path/index.js";
|
||||
|
||||
export type OutputRuntimeEnv = {
|
||||
writeStdout(value: string): void;
|
||||
error(value: string): void;
|
||||
exit(code: number): void;
|
||||
};
|
||||
|
||||
export interface PathCommandOptions {
|
||||
readonly json?: boolean;
|
||||
readonly human?: boolean;
|
||||
readonly valueJson?: boolean;
|
||||
readonly cwd?: string;
|
||||
readonly file?: string;
|
||||
readonly dryRun?: boolean;
|
||||
readonly diff?: boolean;
|
||||
}
|
||||
|
||||
type OutputMode = "human" | "json";
|
||||
|
||||
const SCRUB_PLACEHOLDER = "[REDACTED]";
|
||||
|
||||
const defaultRuntime: OutputRuntimeEnv = {
|
||||
writeStdout(value) {
|
||||
process.stdout.write(value);
|
||||
},
|
||||
error(value) {
|
||||
process.stderr.write(`${value}\n`);
|
||||
},
|
||||
exit(code) {
|
||||
process.exitCode = code;
|
||||
},
|
||||
};
|
||||
|
||||
// Defense-in-depth: replace the redaction sentinel with `[REDACTED]`
|
||||
// before writing, even if upstream emits it.
|
||||
export function scrubSentinel(s: string): string {
|
||||
if (!s.includes(REDACTED_SENTINEL)) {
|
||||
return s;
|
||||
}
|
||||
return s.split(REDACTED_SENTINEL).join(SCRUB_PLACEHOLDER);
|
||||
}
|
||||
|
||||
function detectMode(options: PathCommandOptions): OutputMode {
|
||||
if (options.json === true) {
|
||||
return "json";
|
||||
}
|
||||
if (options.human === true) {
|
||||
return "human";
|
||||
}
|
||||
return process.stdout.isTTY ? "human" : "json";
|
||||
}
|
||||
|
||||
function emit(
|
||||
runtime: OutputRuntimeEnv,
|
||||
mode: OutputMode,
|
||||
value: unknown,
|
||||
humanFallback: () => string,
|
||||
): void {
|
||||
if (mode === "json") {
|
||||
runtime.writeStdout(scrubSentinel(JSON.stringify(value, null, 2)));
|
||||
return;
|
||||
}
|
||||
runtime.writeStdout(scrubSentinel(humanFallback()));
|
||||
}
|
||||
|
||||
function emitError(
|
||||
runtime: OutputRuntimeEnv,
|
||||
mode: OutputMode,
|
||||
message: string,
|
||||
code = "ERR",
|
||||
): void {
|
||||
const scrubbed = scrubSentinel(message);
|
||||
if (mode === "json") {
|
||||
runtime.error(JSON.stringify({ error: { code, message: scrubbed } }));
|
||||
return;
|
||||
}
|
||||
runtime.error(`${code}: ${scrubbed}`);
|
||||
}
|
||||
|
||||
/** Bail with usage error if a required arg is missing. */
|
||||
function requireArg<T>(
|
||||
value: T | undefined,
|
||||
usage: string,
|
||||
runtime: OutputRuntimeEnv,
|
||||
mode: OutputMode,
|
||||
): value is T extends undefined ? never : T {
|
||||
if (value === undefined) {
|
||||
emitError(runtime, mode, usage);
|
||||
runtime.exit(2);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Parse an oc-path string; emit structured error and return null on failure. */
|
||||
function tryParse(pathStr: string, runtime: OutputRuntimeEnv, mode: OutputMode): OcPath | null {
|
||||
try {
|
||||
return parseOcPath(pathStr);
|
||||
} catch (err) {
|
||||
if (err instanceof OcPathError) {
|
||||
emitError(runtime, mode, `parse failed: ${err.message}`, err.code);
|
||||
runtime.exit(2);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Catch OcEmitSentinelError so it goes through the structured error
|
||||
// path; otherwise commander prints `String(err)` raw and bypasses the
|
||||
// `--json` scrubbed-error boundary.
|
||||
function catchSentinel<T>(
|
||||
label: string,
|
||||
runtime: OutputRuntimeEnv,
|
||||
mode: OutputMode,
|
||||
fn: () => T,
|
||||
): T | null {
|
||||
try {
|
||||
return fn();
|
||||
} catch (err) {
|
||||
if (err instanceof OcEmitSentinelError) {
|
||||
emitError(runtime, mode, `${label} refused: ${err.message}`, "OC_EMIT_SENTINEL");
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAst(absPath: string, fileName: string): Promise<OcAst> {
|
||||
const raw = await fs.readFile(absPath, "utf-8");
|
||||
const kind = inferKind(fileName);
|
||||
if (kind === "jsonc") {
|
||||
return parseJsonc(raw).ast;
|
||||
}
|
||||
if (kind === "jsonl") {
|
||||
return parseJsonl(raw).ast;
|
||||
}
|
||||
if (kind === "yaml") {
|
||||
return parseYaml(raw).ast;
|
||||
}
|
||||
return parseMd(raw).ast;
|
||||
}
|
||||
|
||||
function emitForKind(ast: OcAst, fileName?: string): string {
|
||||
// Plumb fileName so sentinel errors carry file context.
|
||||
const opts = fileName !== undefined ? { fileNameForGuard: fileName } : {};
|
||||
switch (ast.kind) {
|
||||
case "jsonc":
|
||||
return emitJsonc(ast, opts);
|
||||
case "jsonl":
|
||||
return emitJsonl(ast, opts);
|
||||
case "md":
|
||||
return emitMd(ast, opts);
|
||||
case "yaml":
|
||||
return emitYaml(ast, opts);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveFsPath(path: OcPath, options: PathCommandOptions): string {
|
||||
if (options.file !== undefined) {
|
||||
return resolvePath(options.file);
|
||||
}
|
||||
return resolvePath(options.cwd ?? process.cwd(), path.file);
|
||||
}
|
||||
|
||||
function formatMatchHuman(match: OcMatch): string {
|
||||
if (match.kind === "leaf") {
|
||||
return `leaf @ L${match.line}: ${JSON.stringify(match.valueText)} (${match.leafType})`;
|
||||
}
|
||||
if (match.kind === "node") {
|
||||
return `node @ L${match.line} [${match.descriptor}]`;
|
||||
}
|
||||
if (match.kind === "insertion-point") {
|
||||
return `insertion-point @ L${match.line} [${match.container}]`;
|
||||
}
|
||||
return `root @ L${match.line}`;
|
||||
}
|
||||
|
||||
function splitDiffLines(s: string): readonly string[] {
|
||||
return s === "" ? [] : s.split("\n");
|
||||
}
|
||||
|
||||
export function formatUnifiedDiff(oldBytes: string, newBytes: string, fsPath: string): string {
|
||||
if (oldBytes === newBytes) {
|
||||
return "";
|
||||
}
|
||||
const oldLines = splitDiffLines(oldBytes);
|
||||
const newLines = splitDiffLines(newBytes);
|
||||
let prefix = 0;
|
||||
while (
|
||||
prefix < oldLines.length &&
|
||||
prefix < newLines.length &&
|
||||
oldLines[prefix] === newLines[prefix]
|
||||
) {
|
||||
prefix++;
|
||||
}
|
||||
|
||||
let oldSuffix = oldLines.length - 1;
|
||||
let newSuffix = newLines.length - 1;
|
||||
while (
|
||||
oldSuffix >= prefix &&
|
||||
newSuffix >= prefix &&
|
||||
oldLines[oldSuffix] === newLines[newSuffix]
|
||||
) {
|
||||
oldSuffix--;
|
||||
newSuffix--;
|
||||
}
|
||||
|
||||
const context = 3;
|
||||
const hunkStart = Math.max(0, prefix - context);
|
||||
const hunkOldEnd = Math.min(oldLines.length - 1, oldSuffix + context);
|
||||
const hunkNewEnd = Math.min(newLines.length - 1, newSuffix + context);
|
||||
const oldCount = Math.max(0, hunkOldEnd - hunkStart + 1);
|
||||
const newCount = Math.max(0, hunkNewEnd - hunkStart + 1);
|
||||
const lines = [
|
||||
`--- ${fsPath}`,
|
||||
`+++ ${fsPath}`,
|
||||
`@@ -${hunkStart + 1},${oldCount} +${hunkStart + 1},${newCount} @@`,
|
||||
];
|
||||
|
||||
for (let i = hunkStart; i < prefix; i++) {
|
||||
lines.push(` ${oldLines[i] ?? ""}`);
|
||||
}
|
||||
for (let i = prefix; i <= oldSuffix; i++) {
|
||||
lines.push(`-${oldLines[i] ?? ""}`);
|
||||
}
|
||||
for (let i = prefix; i <= newSuffix; i++) {
|
||||
lines.push(`+${newLines[i] ?? ""}`);
|
||||
}
|
||||
for (let i = Math.max(oldSuffix + 1, prefix); i <= hunkOldEnd; i++) {
|
||||
lines.push(` ${oldLines[i] ?? ""}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// ---------- Commands -----------------------------------------------------
|
||||
|
||||
export async function pathResolveCommand(
|
||||
pathStr: string | undefined,
|
||||
options: PathCommandOptions,
|
||||
runtime: OutputRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const mode = detectMode(options);
|
||||
if (!requireArg(pathStr, "resolve: missing <oc-path> argument", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
const ocPath = tryParse(pathStr, runtime, mode);
|
||||
if (ocPath === null) {
|
||||
return;
|
||||
}
|
||||
const ast = await loadAst(resolveFsPath(ocPath, options), ocPath.file);
|
||||
let match: OcMatch | null;
|
||||
try {
|
||||
match = resolveOcPath(ast, ocPath);
|
||||
} catch (err) {
|
||||
if (err instanceof OcPathError) {
|
||||
// resolveOcPath throws on wildcard patterns — point at find.
|
||||
emitError(runtime, mode, `resolve refused: ${err.message}`, err.code);
|
||||
runtime.exit(2);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (match === null) {
|
||||
emit(runtime, mode, { resolved: false, ocPath: pathStr }, () => `not found: ${pathStr}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
emit(runtime, mode, { resolved: true, ocPath: pathStr, match }, () => formatMatchHuman(match));
|
||||
}
|
||||
|
||||
export async function pathSetCommand(
|
||||
pathStr: string | undefined,
|
||||
value: string | undefined,
|
||||
options: PathCommandOptions,
|
||||
runtime: OutputRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const mode = detectMode(options);
|
||||
if (!requireArg(pathStr, "set: requires <oc-path> <value>", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
if (!requireArg(value, "set: requires <oc-path> <value>", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
if (options.diff === true && options.dryRun !== true) {
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{ ok: false, reason: "--diff requires --dry-run" },
|
||||
() => "set failed: --diff requires --dry-run",
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const ocPath = tryParse(pathStr, runtime, mode);
|
||||
if (ocPath === null) {
|
||||
return;
|
||||
}
|
||||
const fsPath = resolveFsPath(ocPath, options);
|
||||
const oldBytes = await fs.readFile(fsPath, "utf-8");
|
||||
const ast = await loadAst(fsPath, ocPath.file);
|
||||
|
||||
const result = catchSentinel("set", runtime, mode, () =>
|
||||
setOcPath(ast, ocPath, value, { valueJson: options.valueJson === true }),
|
||||
);
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
const detail = "detail" in result ? result.detail : undefined;
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{ ok: false, reason: result.reason, detail },
|
||||
() => `set failed: ${result.reason}${detail !== undefined ? ` — ${detail}` : ""}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
// Per-kind emit can still refuse the sentinel even after set succeeds.
|
||||
const newBytes = catchSentinel("emit", runtime, mode, () => emitForKind(result.ast, ocPath.file));
|
||||
if (newBytes === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun === true) {
|
||||
const diff = options.diff === true ? formatUnifiedDiff(oldBytes, newBytes, fsPath) : undefined;
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{ ok: true, dryRun: true, bytes: newBytes, ...(diff !== undefined ? { diff } : {}) },
|
||||
() =>
|
||||
diff !== undefined
|
||||
? diff || `--dry-run: no byte changes for ${fsPath}`
|
||||
: `--dry-run: would write ${newBytes.length} bytes to ${fsPath}\n${newBytes}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(fsPath, newBytes, "utf-8");
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{ ok: true, dryRun: false, bytesWritten: newBytes.length, fsPath },
|
||||
() => `wrote ${newBytes.length} bytes to ${fsPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function pathFindCommand(
|
||||
patternStr: string | undefined,
|
||||
options: PathCommandOptions,
|
||||
runtime: OutputRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const mode = detectMode(options);
|
||||
if (!requireArg(patternStr, "find: missing <pattern> argument", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
const pattern = tryParse(patternStr, runtime, mode);
|
||||
if (pattern === null) {
|
||||
return;
|
||||
}
|
||||
// File-slot wildcards would silently ENOENT during readFile; reject.
|
||||
if (/[*?]/.test(pattern.file)) {
|
||||
emitError(
|
||||
runtime,
|
||||
mode,
|
||||
`find: file-slot wildcards are not supported (got "${pattern.file}"). ` +
|
||||
`Pass a concrete file path; multi-file globbing is a follow-up feature.`,
|
||||
"OC_PATH_FILE_WILDCARD_UNSUPPORTED",
|
||||
);
|
||||
runtime.exit(2);
|
||||
return;
|
||||
}
|
||||
const ast = await loadAst(resolveFsPath(pattern, options), pattern.file);
|
||||
const matches = findOcPaths(ast, pattern);
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{
|
||||
pattern: patternStr,
|
||||
count: matches.length,
|
||||
matches: matches.map((m) => ({ path: formatOcPath(m.path), match: m.match })),
|
||||
},
|
||||
() => {
|
||||
if (matches.length === 0) {
|
||||
return `0 matches for ${patternStr}`;
|
||||
}
|
||||
const plural = matches.length === 1 ? "" : "es";
|
||||
const lines = [`${matches.length} match${plural} for ${patternStr}:`];
|
||||
for (const m of matches) {
|
||||
lines.push(` ${formatOcPath(m.path)} → ${formatMatchHuman(m.match)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
runtime.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function pathValidateCommand(
|
||||
pathStr: string | undefined,
|
||||
options: PathCommandOptions,
|
||||
runtime: OutputRuntimeEnv,
|
||||
): void {
|
||||
const mode = detectMode(options);
|
||||
if (!requireArg(pathStr, "validate: missing <oc-path> argument", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ocPath = parseOcPath(pathStr);
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{
|
||||
valid: true,
|
||||
ocPath: pathStr,
|
||||
formatted: formatOcPath(ocPath),
|
||||
structure: {
|
||||
file: ocPath.file,
|
||||
section: ocPath.section,
|
||||
item: ocPath.item,
|
||||
field: ocPath.field,
|
||||
session: ocPath.session,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
const lines = [`valid: ${pathStr}`, ` file: ${ocPath.file}`];
|
||||
if (ocPath.section !== undefined) {
|
||||
lines.push(` section: ${ocPath.section}`);
|
||||
}
|
||||
if (ocPath.item !== undefined) {
|
||||
lines.push(` item: ${ocPath.item}`);
|
||||
}
|
||||
if (ocPath.field !== undefined) {
|
||||
lines.push(` field: ${ocPath.field}`);
|
||||
}
|
||||
if (ocPath.session !== undefined) {
|
||||
lines.push(` session: ${ocPath.session}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof OcPathError) {
|
||||
emit(
|
||||
runtime,
|
||||
mode,
|
||||
{ valid: false, code: err.code, message: err.message },
|
||||
() => `INVALID: ${err.code}: ${err.message}`,
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function pathEmitCommand(
|
||||
fileArg: string | undefined,
|
||||
options: PathCommandOptions,
|
||||
runtime: OutputRuntimeEnv,
|
||||
): Promise<void> {
|
||||
const mode = detectMode(options);
|
||||
if (!requireArg(fileArg, "emit: missing <file> argument", runtime, mode)) {
|
||||
return;
|
||||
}
|
||||
const fsPath =
|
||||
options.file !== undefined
|
||||
? resolvePath(options.file)
|
||||
: resolvePath(options.cwd ?? process.cwd(), fileArg);
|
||||
const fileName = fsPath.split(/[\\/]/).pop() ?? fileArg;
|
||||
const ast = await loadAst(fsPath, fileName);
|
||||
const bytes = catchSentinel("emit", runtime, mode, () => emitForKind(ast, fileName));
|
||||
if (bytes === null) {
|
||||
return;
|
||||
}
|
||||
if (mode === "json") {
|
||||
runtime.writeStdout(scrubSentinel(JSON.stringify({ ok: true, kind: ast.kind, bytes })));
|
||||
return;
|
||||
}
|
||||
runtime.writeStdout(bytes);
|
||||
}
|
||||
|
||||
// ---------- Commander wiring ---------------------------------------------
|
||||
|
||||
function withCommonOpts(cmd: Command): Command {
|
||||
return cmd
|
||||
.option("--json", "Force JSON output")
|
||||
.option("--human", "Force human output")
|
||||
.option("--cwd <dir>", "Resolve file slot against this directory")
|
||||
.option("--file <file>", "Override the file slot's resolved path");
|
||||
}
|
||||
|
||||
export function registerPathCli(program: Command): void {
|
||||
const path = program
|
||||
.command("path")
|
||||
.description("Inspect and edit workspace files via the oc:// addressing scheme")
|
||||
.addHelpText("after", "\nDocs: https://docs.openclaw.ai/cli/path\n");
|
||||
|
||||
withCommonOpts(
|
||||
path
|
||||
.command("resolve")
|
||||
.description("Print the match at an oc:// path")
|
||||
.argument("<oc-path>", "oc:// path to resolve"),
|
||||
).action(async (pathStr: string, opts: PathCommandOptions) => {
|
||||
await pathResolveCommand(pathStr, opts, defaultRuntime);
|
||||
});
|
||||
|
||||
withCommonOpts(
|
||||
path
|
||||
.command("find")
|
||||
.description("Enumerate matches for a wildcard / predicate oc:// pattern")
|
||||
.argument("<pattern>", "oc:// pattern"),
|
||||
).action(async (patternStr: string, opts: PathCommandOptions) => {
|
||||
await pathFindCommand(patternStr, opts, defaultRuntime);
|
||||
});
|
||||
|
||||
withCommonOpts(
|
||||
path
|
||||
.command("set")
|
||||
.description("Write a leaf value at an oc:// path")
|
||||
.argument("<oc-path>", "oc:// path to write")
|
||||
.argument("<value>", "string value to write")
|
||||
.option("--value-json", "Parse <value> as JSON for JSON/JSONC/JSONL leaf replacement")
|
||||
.option("--dry-run", "Print bytes without writing")
|
||||
.option("--diff", "With --dry-run, print a unified diff instead of full bytes"),
|
||||
).action(async (pathStr: string, value: string, opts: PathCommandOptions) => {
|
||||
await pathSetCommand(pathStr, value, opts, defaultRuntime);
|
||||
});
|
||||
|
||||
path
|
||||
.command("validate")
|
||||
.description("Parse an oc:// path and print its slot structure")
|
||||
.argument("<oc-path>", "oc:// path to validate")
|
||||
.option("--json", "Force JSON output")
|
||||
.option("--human", "Force human output")
|
||||
.action((pathStr: string, opts: PathCommandOptions) => {
|
||||
pathValidateCommand(pathStr, opts, defaultRuntime);
|
||||
});
|
||||
|
||||
withCommonOpts(
|
||||
path
|
||||
.command("emit")
|
||||
.description("Round-trip a file through parse + emit")
|
||||
.argument("<file>", "Path to a workspace file"),
|
||||
).action(async (fileArg: string, opts: PathCommandOptions) => {
|
||||
await pathEmitCommand(fileArg, opts, defaultRuntime);
|
||||
});
|
||||
|
||||
// Bare `openclaw path` prints help and exits 0 (matches the core
|
||||
// applyParentDefaultHelpAction contract — see openclaw#73077).
|
||||
path.action(() => {
|
||||
path.outputHelp();
|
||||
process.exitCode = 0;
|
||||
});
|
||||
}
|
||||
64
extensions/oc-path/src/oc-path/ast.ts
Normal file
64
extensions/oc-path/src/oc-path/ast.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Markdown AST — addressing index for workspace files.
|
||||
*
|
||||
* Pure addressing structure; no per-file opinions (those live in lint
|
||||
* rules). Byte-fidelity: `emitMd(parse(raw)) === raw`; `raw` on the
|
||||
* root preserves the original bytes for round-trip.
|
||||
*
|
||||
* @module @openclaw/oc-path/ast
|
||||
*/
|
||||
|
||||
/** Parser diagnostic. Severity `warning` for recoverable input; never throws. */
|
||||
export interface Diagnostic {
|
||||
readonly line: number;
|
||||
readonly message: string;
|
||||
readonly severity: "info" | "warning" | "error";
|
||||
readonly code?: string;
|
||||
}
|
||||
|
||||
/** Frontmatter entry. Values unquoted (`"`/`'` stripped) but otherwise verbatim. */
|
||||
export interface FrontmatterEntry {
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly line: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bullet item. `slug` is the addressing key (kv.key when present, else
|
||||
* item text). `kv` is populated for `- key: value` bullets.
|
||||
*/
|
||||
export interface AstItem {
|
||||
readonly text: string;
|
||||
readonly slug: string;
|
||||
readonly line: number;
|
||||
readonly kv?: { readonly key: string; readonly value: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* H2-delimited block. `bodyText` is the verbatim prose between this
|
||||
* heading and the next; `items` are extracted for addressing.
|
||||
*
|
||||
* Tables and code blocks aren't first-class — addressing into them is
|
||||
* out of scope. Lint rules re-tokenize `bodyText` if needed.
|
||||
*/
|
||||
export interface AstBlock {
|
||||
readonly heading: string;
|
||||
readonly slug: string;
|
||||
readonly line: number;
|
||||
readonly bodyText: string;
|
||||
readonly items: readonly AstItem[];
|
||||
}
|
||||
|
||||
/** Root AST. `raw` carries the original bytes for byte-identical round-trip. */
|
||||
export interface MdAst {
|
||||
readonly kind: "md";
|
||||
readonly raw: string;
|
||||
readonly frontmatter: readonly FrontmatterEntry[];
|
||||
readonly preamble: string;
|
||||
readonly blocks: readonly AstBlock[];
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
readonly ast: MdAst;
|
||||
readonly diagnostics: readonly Diagnostic[];
|
||||
}
|
||||
34
extensions/oc-path/src/oc-path/dispatch.ts
Normal file
34
extensions/oc-path/src/oc-path/dispatch.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Cross-kind utilities. `inferKind` is a convention helper for callers
|
||||
* who want to map filename to the parser they should use before calling
|
||||
* the universal verbs (`resolveOcPath`, `findOcPaths`, `setOcPath`).
|
||||
*
|
||||
* Encoding remains per-kind (`parseMd`, `parseJsonc`, `parseJsonl`),
|
||||
* while addressing and mutation dispatch are universal once callers
|
||||
* have an AST carrying its `kind` discriminator.
|
||||
*
|
||||
* @module @openclaw/oc-path/dispatch
|
||||
*/
|
||||
|
||||
export type OcKind = "md" | "jsonc" | "jsonl" | "yaml";
|
||||
|
||||
/**
|
||||
* Recommend a kind from a filename. Pure convention helper — returns
|
||||
* the substrate's default mapping. Consumers can override.
|
||||
*/
|
||||
export function inferKind(filename: string): OcKind | null {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith(".md")) {
|
||||
return "md";
|
||||
}
|
||||
if (lower.endsWith(".jsonl") || lower.endsWith(".ndjson")) {
|
||||
return "jsonl";
|
||||
}
|
||||
if (lower.endsWith(".jsonc") || lower.endsWith(".json")) {
|
||||
return "jsonc";
|
||||
}
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml") || lower.endsWith(".lobster")) {
|
||||
return "yaml";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
151
extensions/oc-path/src/oc-path/edit.ts
Normal file
151
extensions/oc-path/src/oc-path/edit.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Mutate `MdAst` at an OcPath. Returns a new AST; original unchanged.
|
||||
*
|
||||
* oc://FILE/[frontmatter]/key → frontmatter value
|
||||
* oc://FILE/section/item/field → item.kv.value
|
||||
*
|
||||
* Section bodies aren't writable through this primitive.
|
||||
*
|
||||
* @module @openclaw/oc-path/edit
|
||||
*/
|
||||
|
||||
import type { AstBlock, AstItem, FrontmatterEntry, MdAst } from "./ast.js";
|
||||
import { formatOcPath, type OcPath } from "./oc-path.js";
|
||||
import { guardSentinel } from "./sentinel.js";
|
||||
|
||||
export type MdEditResult =
|
||||
| { readonly ok: true; readonly ast: MdAst }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason: "unresolved" | "not-writable" | "no-item-kv";
|
||||
};
|
||||
|
||||
// Sentinel guard at the boundary keeps md symmetric with jsonc/jsonl,
|
||||
// which both reject sentinel values before they reach the AST.
|
||||
export function setMdOcPath(ast: MdAst, path: OcPath, newValue: string): MdEditResult {
|
||||
guardSentinel(newValue, formatOcPath(path));
|
||||
if (path.section === "[frontmatter]") {
|
||||
const key = path.item ?? path.field;
|
||||
if (key === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const idx = ast.frontmatter.findIndex((e) => e.key === key);
|
||||
if (idx === -1) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const existing = ast.frontmatter[idx];
|
||||
if (existing === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const newEntry: FrontmatterEntry = { ...existing, value: newValue };
|
||||
const newFm = ast.frontmatter.slice();
|
||||
newFm[idx] = newEntry;
|
||||
return finalize({ ...ast, frontmatter: newFm });
|
||||
}
|
||||
|
||||
if (path.section === undefined || path.item === undefined || path.field === undefined) {
|
||||
return { ok: false, reason: "not-writable" };
|
||||
}
|
||||
|
||||
const sectionSlug = path.section.toLowerCase();
|
||||
const blockIdx = ast.blocks.findIndex((b) => b.slug === sectionSlug);
|
||||
if (blockIdx === -1) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const block = ast.blocks[blockIdx];
|
||||
if (block === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const itemSlug = path.item.toLowerCase();
|
||||
const itemIdx = block.items.findIndex((i) => i.slug === itemSlug);
|
||||
if (itemIdx === -1) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const item = block.items[itemIdx];
|
||||
if (item === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
if (item.kv === undefined) {
|
||||
return { ok: false, reason: "no-item-kv" };
|
||||
}
|
||||
if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const newItem: AstItem = { ...item, kv: { key: item.kv.key, value: newValue } };
|
||||
const newItems = block.items.slice();
|
||||
newItems[itemIdx] = newItem;
|
||||
const newBlock: AstBlock = {
|
||||
...block,
|
||||
items: newItems,
|
||||
bodyText: rebuildBlockBody(block, newItems),
|
||||
};
|
||||
const newBlocks = ast.blocks.slice();
|
||||
newBlocks[blockIdx] = newBlock;
|
||||
return finalize({ ...ast, blocks: newBlocks });
|
||||
}
|
||||
|
||||
// In-place substitution on `bodyText` so round-trip emit reflects the
|
||||
// edit. Items without a matching bullet line are skipped (render mode
|
||||
// uses structural fields anyway).
|
||||
function rebuildBlockBody(block: AstBlock, newItems: readonly AstItem[]): string {
|
||||
let body = block.bodyText;
|
||||
for (let i = 0; i < newItems.length; i++) {
|
||||
const newItem = newItems[i];
|
||||
const oldItem = block.items[i];
|
||||
if (newItem === undefined || oldItem === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (newItem.kv === undefined || oldItem.kv === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (newItem.kv.value === oldItem.kv.value) {
|
||||
continue;
|
||||
}
|
||||
const re = new RegExp(`^(\\s*-\\s*${escapeRegex(oldItem.kv.key)}\\s*:\\s*).*$`, "m");
|
||||
body = body.replace(re, `$1${newItem.kv.value}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function finalize(ast: MdAst): MdEditResult {
|
||||
const parts: string[] = [];
|
||||
if (ast.frontmatter.length > 0) {
|
||||
parts.push("---");
|
||||
for (const fm of ast.frontmatter) {
|
||||
parts.push(`${fm.key}: ${formatFrontmatterValue(fm.value)}`);
|
||||
}
|
||||
parts.push("---");
|
||||
}
|
||||
if (ast.preamble.length > 0) {
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(ast.preamble);
|
||||
}
|
||||
for (const block of ast.blocks) {
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(`## ${block.heading}`);
|
||||
if (block.bodyText.length > 0) {
|
||||
parts.push(block.bodyText);
|
||||
}
|
||||
}
|
||||
return { ok: true, ast: { ...ast, raw: parts.join("\n") } };
|
||||
}
|
||||
|
||||
function formatFrontmatterValue(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return '""';
|
||||
}
|
||||
if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
130
extensions/oc-path/src/oc-path/emit.ts
Normal file
130
extensions/oc-path/src/oc-path/emit.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Emit an AST back to bytes.
|
||||
*
|
||||
* **Two modes**:
|
||||
*
|
||||
* 1. **Round-trip** — the AST hasn't been mutated since `parseMd`
|
||||
* produced it. Returns `ast.raw` verbatim. Byte-identical.
|
||||
*
|
||||
* 2. **Mutation-aware** — the AST has been modified (frontmatter
|
||||
* entry edited, item kv.value changed, block reordered). Returns
|
||||
* a freshly-rendered representation. **Not** byte-identical to a
|
||||
* hypothetical "perfect" rewrite — we render canonical forms
|
||||
* (LF endings, single space after `:` in frontmatter, etc.).
|
||||
* Callers needing byte-fidelity for partial edits should patch
|
||||
* `raw` directly instead of mutating the AST.
|
||||
*
|
||||
* In both modes, every emitted leaf flows through `guardSentinel` so a
|
||||
* `__OPENCLAW_REDACTED__` literal anywhere in the output throws
|
||||
* `OcEmitSentinelError`. This is the substrate guard: callers can't
|
||||
* accidentally write a redacted view to disk through this emitter.
|
||||
*
|
||||
* @module @openclaw/oc-path/emit
|
||||
*/
|
||||
|
||||
import type { FrontmatterEntry, MdAst } from "./ast.js";
|
||||
import { guardSentinel } from "./sentinel.js";
|
||||
|
||||
/**
|
||||
* Emit options. `mode: 'roundtrip'` (default) returns `ast.raw` if
|
||||
* present and not flagged as dirty; `mode: 'render'` always
|
||||
* re-renders.
|
||||
*/
|
||||
export interface EmitOptions {
|
||||
readonly mode?: "roundtrip" | "render";
|
||||
/**
|
||||
* When provided, the emitter walks every emitted leaf string through
|
||||
* `guardSentinel(value, ocPath)`. Default uses the file name
|
||||
* (`oc://<file>`) when the field-precise path can't be determined.
|
||||
* Callers that want richer error context can supply `ocPathFor` to
|
||||
* compute a path per leaf.
|
||||
*/
|
||||
readonly fileNameForGuard?: string;
|
||||
/**
|
||||
* See `JsoncEmitOptions.acceptPreExistingSentinel` for the rationale.
|
||||
* Default `true` — round-trip echoes parsed bytes without scanning
|
||||
* for the sentinel. Render mode scans every leaf regardless.
|
||||
*/
|
||||
readonly acceptPreExistingSentinel?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the AST. In render mode, throws `OcEmitSentinelError` if any
|
||||
* leaf string matches `REDACTED_SENTINEL`. In round-trip mode, echoes
|
||||
* `ast.raw` verbatim (does not scan unless caller opts in via
|
||||
* `acceptPreExistingSentinel: false`).
|
||||
*/
|
||||
export function emitMd(ast: MdAst, opts: EmitOptions = {}): string {
|
||||
const mode = opts.mode ?? "roundtrip";
|
||||
const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : "oc://";
|
||||
const acceptPreExisting = opts.acceptPreExistingSentinel ?? true;
|
||||
|
||||
if (mode === "roundtrip") {
|
||||
// Round-trip trusts parsed bytes — see emit-policy comment in
|
||||
// jsonc/emit.ts. A markdown file legitimately containing the
|
||||
// sentinel literal (in a code block, in a pasted error log) would
|
||||
// otherwise become a workspace-wide emit DoS.
|
||||
if (!acceptPreExisting && ast.raw.includes("__OPENCLAW_REDACTED__")) {
|
||||
guardSentinel("__OPENCLAW_REDACTED__", `${guardPath}/[raw]`);
|
||||
}
|
||||
return ast.raw;
|
||||
}
|
||||
|
||||
// Render mode: rebuild from structural fields. This loses
|
||||
// formatting details (extra blank lines, custom whitespace, etc.)
|
||||
// but is correct.
|
||||
const parts: string[] = [];
|
||||
|
||||
if (ast.frontmatter.length > 0) {
|
||||
parts.push("---");
|
||||
for (const fm of ast.frontmatter) {
|
||||
guardSentinel(fm.value, `${guardPath}/[frontmatter]/${fm.key}`);
|
||||
parts.push(`${fm.key}: ${formatFrontmatterValue(fm.value)}`);
|
||||
}
|
||||
parts.push("---");
|
||||
}
|
||||
|
||||
if (ast.preamble.length > 0) {
|
||||
guardSentinel(ast.preamble, `${guardPath}/[preamble]`);
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(ast.preamble);
|
||||
}
|
||||
|
||||
for (const block of ast.blocks) {
|
||||
if (parts.length > 0) {
|
||||
parts.push("");
|
||||
}
|
||||
parts.push(`## ${block.heading}`);
|
||||
if (block.bodyText.length > 0) {
|
||||
// Walk items + frontmatter-key value strings for sentinels;
|
||||
// body text is also walked as one big string in case of any raw
|
||||
// sentinel.
|
||||
guardSentinel(block.bodyText, `${guardPath}/${block.slug}/[body]`);
|
||||
for (const item of block.items) {
|
||||
if (item.kv) {
|
||||
guardSentinel(item.kv.value, `${guardPath}/${block.slug}/${item.slug}/${item.kv.key}`);
|
||||
}
|
||||
}
|
||||
parts.push(block.bodyText);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function formatFrontmatterValue(value: string): string {
|
||||
// Frontmatter is yaml-ish; quote values with structural chars.
|
||||
if (value.length === 0) {
|
||||
return '""';
|
||||
}
|
||||
if (/[:#&*?|<>=!%@`,[\]{}\r\n]/.test(value)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Re-export the frontmatter type for convenience so tests don't need
|
||||
// to import from ast.ts.
|
||||
export type { FrontmatterEntry };
|
||||
817
extensions/oc-path/src/oc-path/find.ts
Normal file
817
extensions/oc-path/src/oc-path/find.ts
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* `findOcPaths` — multi-match verb. `*` matches one sub-segment;
|
||||
* `**` matches zero or more (recursive). Returns concrete OcPaths
|
||||
* preserving the input pattern's slot shape, so each result is
|
||||
* pipeable into `resolveOcPath` / `setOcPath`.
|
||||
*
|
||||
* @module @openclaw/oc-path/find
|
||||
*/
|
||||
|
||||
import { isMap, isScalar, isSeq, type Node, type Pair } from "yaml";
|
||||
import type { MdAst } from "./ast.js";
|
||||
import type { JsoncValue } from "./jsonc/ast.js";
|
||||
import type { JsonlAst, JsonlLine } from "./jsonl/ast.js";
|
||||
import { pickJsonlLine } from "./jsonl/line.js";
|
||||
import type { OcPath } from "./oc-path.js";
|
||||
import {
|
||||
MAX_TRAVERSAL_DEPTH,
|
||||
OcPathError,
|
||||
WILDCARD_RECURSIVE,
|
||||
WILDCARD_SINGLE,
|
||||
evaluatePredicate,
|
||||
isOrdinalSeg,
|
||||
isPositionalSeg,
|
||||
isPredicateSeg,
|
||||
isQuotedSeg,
|
||||
isUnionSeg,
|
||||
parseArrayIndexSegment,
|
||||
parseOrdinalSeg,
|
||||
parsePredicateSeg,
|
||||
parseUnionSeg,
|
||||
quoteSeg,
|
||||
resolvePositionalSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "./oc-path.js";
|
||||
import type { PredicateSpec } from "./oc-path.js";
|
||||
import type { OcAst, OcMatch } from "./universal.js";
|
||||
import { resolveOcPath } from "./universal.js";
|
||||
|
||||
// ---------- Public types ---------------------------------------------------
|
||||
|
||||
/** A find result: a concrete (wildcard-free) path plus its match info. */
|
||||
export interface OcPathMatch {
|
||||
readonly path: OcPath;
|
||||
readonly match: OcMatch;
|
||||
}
|
||||
|
||||
type Slot = "section" | "item" | "field";
|
||||
interface SlotSub {
|
||||
readonly slot: Slot;
|
||||
readonly value: string;
|
||||
}
|
||||
interface PatternSub {
|
||||
readonly slot: Slot;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
type OnMatch = (subs: readonly SlotSub[]) => void;
|
||||
|
||||
// ---------- Public verb ----------------------------------------------------
|
||||
|
||||
export function findOcPaths(ast: OcAst, pattern: OcPath): readonly OcPathMatch[] {
|
||||
const subs = patternSubs(pattern);
|
||||
// Fast-path: no expansion needed — pure literals just resolve.
|
||||
const needsExpansion = subs.some(
|
||||
(s) =>
|
||||
s.value === WILDCARD_SINGLE ||
|
||||
s.value === WILDCARD_RECURSIVE ||
|
||||
isPositionalSeg(s.value) ||
|
||||
isUnionSeg(s.value) ||
|
||||
isPredicateSeg(s.value),
|
||||
);
|
||||
if (!needsExpansion) {
|
||||
const m = resolveOcPath(ast, pattern);
|
||||
return m === null ? [] : [{ path: pattern, match: m }];
|
||||
}
|
||||
|
||||
const concretePaths: OcPath[] = [];
|
||||
const onMatch: OnMatch = (slotSubs) => {
|
||||
concretePaths.push(repackSlotSubs(pattern, slotSubs));
|
||||
};
|
||||
switch (ast.kind) {
|
||||
case "jsonc":
|
||||
if (ast.root !== null) {
|
||||
walkJsonc(ast.root, subs, 0, [], onMatch);
|
||||
}
|
||||
break;
|
||||
case "jsonl":
|
||||
walkJsonl(ast, subs, 0, [], onMatch);
|
||||
break;
|
||||
case "md":
|
||||
walkMd({ kind: "root", ast }, subs, 0, [], onMatch);
|
||||
break;
|
||||
case "yaml":
|
||||
if (ast.doc.contents !== null) {
|
||||
walkYaml(ast.doc.contents, subs, 0, [], onMatch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const out: OcPathMatch[] = [];
|
||||
for (const concrete of concretePaths) {
|
||||
const m = resolveOcPath(ast, concrete);
|
||||
if (m !== null) {
|
||||
out.push({ path: concrete, match: m });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Pattern unpacking ---------------------------------------------
|
||||
|
||||
function patternSubs(pattern: OcPath): readonly PatternSub[] {
|
||||
const out: PatternSub[] = [];
|
||||
// Bracket-aware split so dots inside `[k=1.0]` or `{a.b,c}` aren't
|
||||
// treated as sub-segment delimiters.
|
||||
if (pattern.section !== undefined) {
|
||||
for (const v of splitRespectingBrackets(pattern.section, ".")) {
|
||||
out.push({ slot: "section", value: v });
|
||||
}
|
||||
}
|
||||
if (pattern.item !== undefined) {
|
||||
for (const v of splitRespectingBrackets(pattern.item, ".")) {
|
||||
out.push({ slot: "item", value: v });
|
||||
}
|
||||
}
|
||||
if (pattern.field !== undefined) {
|
||||
for (const v of splitRespectingBrackets(pattern.field, ".")) {
|
||||
out.push({ slot: "field", value: v });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function repackSlotSubs(pattern: OcPath, slotSubs: readonly SlotSub[]): OcPath {
|
||||
const sectionSubs: string[] = [];
|
||||
const itemSubs: string[] = [];
|
||||
const fieldSubs: string[] = [];
|
||||
for (const s of slotSubs) {
|
||||
if (s.slot === "section") {
|
||||
sectionSubs.push(s.value);
|
||||
} else if (s.slot === "item") {
|
||||
itemSubs.push(s.value);
|
||||
} else {
|
||||
fieldSubs.push(s.value);
|
||||
}
|
||||
}
|
||||
return {
|
||||
file: pattern.file,
|
||||
...(sectionSubs.length > 0 ? { section: sectionSubs.join(".") } : {}),
|
||||
...(itemSubs.length > 0 ? { item: itemSubs.join(".") } : {}),
|
||||
...(fieldSubs.length > 0 ? { field: fieldSubs.join(".") } : {}),
|
||||
...(pattern.session !== undefined ? { session: pattern.session } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Shared dispatch ----------------------------------------------
|
||||
|
||||
// Per-kind ops the dispatcher uses to drive recursion. Each kind's
|
||||
// walker fills these in; the dispatcher handles every segment shape.
|
||||
interface WalkOps<T> {
|
||||
enumerate(node: T): Iterable<{ keySub: string; child: T }>;
|
||||
lookup(node: T, key: string): { keySub: string; child: T } | null;
|
||||
positional(node: T, seg: string): { keySub: string; child: T } | null;
|
||||
predicate(node: T, pred: PredicateSpec): Iterable<{ keySub: string; child: T }>;
|
||||
walk(
|
||||
node: T,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void;
|
||||
}
|
||||
|
||||
function checkDepth(walked: readonly SlotSub[]): void {
|
||||
if (walked.length > MAX_TRAVERSAL_DEPTH) {
|
||||
throw new OcPathError(
|
||||
`findOcPaths exceeded MAX_TRAVERSAL_DEPTH (${MAX_TRAVERSAL_DEPTH}) — likely a pathological pattern`,
|
||||
"",
|
||||
"OC_PATH_DEPTH_EXCEEDED",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchSeg<T>(
|
||||
node: T,
|
||||
ops: WalkOps<T>,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
const cur = subs[i];
|
||||
|
||||
if (isUnionSeg(cur.value)) {
|
||||
const alts = parseUnionSeg(cur.value);
|
||||
if (alts === null) {
|
||||
return;
|
||||
}
|
||||
for (const alt of alts) {
|
||||
const altSubs = subs.slice();
|
||||
altSubs[i] = { slot: cur.slot, value: alt };
|
||||
ops.walk(node, altSubs, i, walked, onMatch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPredicateSeg(cur.value)) {
|
||||
const pred = parsePredicateSeg(cur.value);
|
||||
if (pred === null) {
|
||||
return;
|
||||
}
|
||||
for (const m of ops.predicate(node, pred)) {
|
||||
ops.walk(m.child, subs, i + 1, [...walked, { slot: cur.slot, value: m.keySub }], onMatch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.value === WILDCARD_RECURSIVE) {
|
||||
// `**` — descend with `**` consumed (i+1) AND retained (i) so
|
||||
// deeper structures still match. Emit if no subs remain.
|
||||
if (i + 1 >= subs.length) {
|
||||
onMatch(walked);
|
||||
}
|
||||
for (const m of ops.enumerate(node)) {
|
||||
const nextWalked: readonly SlotSub[] = [...walked, { slot: cur.slot, value: m.keySub }];
|
||||
ops.walk(m.child, subs, i + 1, nextWalked, onMatch);
|
||||
ops.walk(m.child, subs, i, nextWalked, onMatch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cur.value === WILDCARD_SINGLE) {
|
||||
for (const m of ops.enumerate(node)) {
|
||||
ops.walk(m.child, subs, i + 1, [...walked, { slot: cur.slot, value: m.keySub }], onMatch);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPositionalSeg(cur.value)) {
|
||||
const m = ops.positional(node, cur.value);
|
||||
if (m === null) {
|
||||
return;
|
||||
}
|
||||
ops.walk(m.child, subs, i + 1, [...walked, { slot: cur.slot, value: m.keySub }], onMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
const m = ops.lookup(node, cur.value);
|
||||
if (m === null) {
|
||||
return;
|
||||
}
|
||||
ops.walk(m.child, subs, i + 1, [...walked, { slot: cur.slot, value: m.keySub }], onMatch);
|
||||
}
|
||||
|
||||
// ---------- JSONC walker ---------------------------------------------------
|
||||
|
||||
function walkJsonc(
|
||||
node: JsoncValue,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
checkDepth(walked);
|
||||
if (i >= subs.length) {
|
||||
onMatch(walked);
|
||||
return;
|
||||
}
|
||||
dispatchSeg(node, jsoncOps, subs, i, walked, onMatch);
|
||||
}
|
||||
|
||||
const jsoncOps: WalkOps<JsoncValue> = {
|
||||
*enumerate(node) {
|
||||
if (node.kind === "object") {
|
||||
for (const e of node.entries) {
|
||||
yield { keySub: quoteSeg(e.key), child: e.value };
|
||||
}
|
||||
} else if (node.kind === "array") {
|
||||
for (let idx = 0; idx < node.items.length; idx++) {
|
||||
yield { keySub: String(idx), child: node.items[idx] };
|
||||
}
|
||||
}
|
||||
},
|
||||
lookup(node, key) {
|
||||
if (node.kind === "object") {
|
||||
// Entry keys are unquoted in the AST; strip quotes from a quoted
|
||||
// path key so the walker matches the resolver's behavior.
|
||||
const lookupKey = isQuotedSeg(key) ? unquoteSeg(key) : key;
|
||||
const e = node.entries.find((entry) => entry.key === lookupKey);
|
||||
return e === undefined ? null : { keySub: key, child: e.value };
|
||||
}
|
||||
if (node.kind === "array") {
|
||||
const idx = parseArrayIndexSegment(key, node.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
return { keySub: key, child: node.items[idx] };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
positional(node, seg) {
|
||||
const concrete = positionalForJsoncNode(node, seg);
|
||||
if (concrete === null) {
|
||||
return null;
|
||||
}
|
||||
return jsoncOps.lookup(node, concrete);
|
||||
},
|
||||
*predicate(node, pred) {
|
||||
if (node.kind === "object") {
|
||||
for (const e of node.entries) {
|
||||
if (jsoncChildMatchesPredicate(e.value, pred)) {
|
||||
yield { keySub: quoteSeg(e.key), child: e.value };
|
||||
}
|
||||
}
|
||||
} else if (node.kind === "array") {
|
||||
for (let idx = 0; idx < node.items.length; idx++) {
|
||||
if (jsoncChildMatchesPredicate(node.items[idx], pred)) {
|
||||
yield { keySub: String(idx), child: node.items[idx] };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
walk: walkJsonc,
|
||||
};
|
||||
|
||||
function positionalForJsoncNode(node: JsoncValue, seg: string): string | null {
|
||||
if (node.kind === "object") {
|
||||
const keys = node.entries.map((e) => e.key);
|
||||
return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (node.kind === "array") {
|
||||
return resolvePositionalSeg(seg, { indexable: true, size: node.items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- JSONL walker ---------------------------------------------------
|
||||
|
||||
// First slot is a line address; subsequent slots descend into the
|
||||
// line's jsonc value via jsonlOps.walk's holder unwrap.
|
||||
function walkJsonl(
|
||||
ast: JsonlAst,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
checkDepth(walked);
|
||||
if (i >= subs.length) {
|
||||
onMatch(walked);
|
||||
return;
|
||||
}
|
||||
if (walked.length === 0) {
|
||||
dispatchSeg(ast, jsonlOps, subs, i, walked, onMatch);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonlOps: WalkOps<JsonlAst> = {
|
||||
*enumerate(ast) {
|
||||
for (const l of ast.lines) {
|
||||
if (l.kind === "value") {
|
||||
yield { keySub: `L${l.line}`, child: lineHolder(ast, l) };
|
||||
}
|
||||
}
|
||||
},
|
||||
lookup(ast, key) {
|
||||
const line = pickJsonlLine(ast, key);
|
||||
if (line === null) {
|
||||
return null;
|
||||
}
|
||||
const concreteAddr = line.kind === "value" ? `L${line.line}` : key;
|
||||
return { keySub: concreteAddr, child: lineHolder(ast, line) };
|
||||
},
|
||||
positional(ast, seg) {
|
||||
return jsonlOps.lookup(ast, seg);
|
||||
},
|
||||
*predicate(ast, pred) {
|
||||
for (const l of ast.lines) {
|
||||
if (l.kind !== "value") {
|
||||
continue;
|
||||
}
|
||||
const actual = topLevelLeafText(l.value, pred.key);
|
||||
if (evaluatePredicate(actual, pred)) {
|
||||
yield { keySub: `L${l.line}`, child: lineHolder(ast, l) };
|
||||
}
|
||||
}
|
||||
},
|
||||
// After the line slot is consumed, descend into the line's jsonc
|
||||
// value via the holder's WeakMap-tagged line. Otherwise this is a
|
||||
// top-level walkJsonl entry — go through line-slot dispatch.
|
||||
walk(child, subs, i, walked, onMatch) {
|
||||
const line = unwrapHolder(child);
|
||||
if (line === null) {
|
||||
walkJsonl(child, subs, i, walked, onMatch);
|
||||
return;
|
||||
}
|
||||
if (i >= subs.length) {
|
||||
onMatch(walked);
|
||||
return;
|
||||
}
|
||||
if (line.kind !== "value") {
|
||||
return;
|
||||
}
|
||||
walkJsonc(line.value, subs, i, walked, onMatch);
|
||||
},
|
||||
};
|
||||
|
||||
// JsonlAst-typed wrapper around a single line so jsonlOps.walk can
|
||||
// distinguish "top-level ast (descend the line slot)" from "we
|
||||
// already picked a line, walk inside it." A WeakMap keeps the wrapping
|
||||
// structural (no JsonlAst surface change).
|
||||
const lineByHolder = new WeakMap<object, JsonlLine>();
|
||||
function lineHolder(ast: JsonlAst, line: JsonlLine): JsonlAst {
|
||||
// Synthesize a tagged JsonlAst that carries the chosen line. The
|
||||
// outer structure is preserved (kind, raw, lines) so type checks
|
||||
// remain happy; the WeakMap holds the per-line tag.
|
||||
const holder: JsonlAst = { kind: "jsonl", raw: ast.raw, lines: ast.lines };
|
||||
lineByHolder.set(holder, line);
|
||||
return holder;
|
||||
}
|
||||
function unwrapHolder(holder: JsonlAst): JsonlLine | null {
|
||||
return lineByHolder.get(holder) ?? null;
|
||||
}
|
||||
|
||||
function topLevelLeafText(value: JsoncValue, key: string): string | null {
|
||||
if (value.kind !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entry = value.entries.find((e) => e.key === key);
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
const v = entry.value;
|
||||
if (v.kind === "string") {
|
||||
return v.value;
|
||||
}
|
||||
if (v.kind === "number" || v.kind === "boolean") {
|
||||
return String(v.value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- YAML walker ----------------------------------------------------
|
||||
|
||||
function walkYaml(
|
||||
node: Node,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
checkDepth(walked);
|
||||
if (i >= subs.length) {
|
||||
onMatch(walked);
|
||||
return;
|
||||
}
|
||||
dispatchSeg(node, yamlOps, subs, i, walked, onMatch);
|
||||
}
|
||||
|
||||
const yamlOps: WalkOps<Node> = {
|
||||
*enumerate(node) {
|
||||
if (isMap(node)) {
|
||||
for (const p of (node as { items: readonly Pair[] }).items) {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
if (p.value !== null) {
|
||||
yield { keySub: quoteSeg(String(k)), child: p.value as Node };
|
||||
}
|
||||
}
|
||||
} else if (isSeq(node)) {
|
||||
for (let idx = 0; idx < node.items.length; idx++) {
|
||||
const child = node.items[idx];
|
||||
if (child !== null) {
|
||||
yield { keySub: String(idx), child: child as Node };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
lookup(node, key) {
|
||||
if (isMap(node)) {
|
||||
const lookupKey = isQuotedSeg(key) ? unquoteSeg(key) : key;
|
||||
const pair = (node as { items: readonly Pair[] }).items.find((p) => {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
return String(k) === lookupKey;
|
||||
});
|
||||
return pair?.value === undefined || pair.value === null
|
||||
? null
|
||||
: { keySub: key, child: pair.value as Node };
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
const idx = parseArrayIndexSegment(key, node.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
const child = node.items[idx];
|
||||
if (child === null) {
|
||||
return null;
|
||||
}
|
||||
return { keySub: key, child: child as Node };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
positional(node, seg) {
|
||||
const concrete = positionalForYamlNode(node, seg);
|
||||
return concrete === null ? null : yamlOps.lookup(node, concrete);
|
||||
},
|
||||
*predicate(node, pred) {
|
||||
if (isMap(node)) {
|
||||
for (const p of (node as { items: readonly Pair[] }).items) {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
if (p.value !== null && yamlChildMatchesPredicate(p.value as Node, pred)) {
|
||||
yield { keySub: quoteSeg(String(k)), child: p.value as Node };
|
||||
}
|
||||
}
|
||||
} else if (isSeq(node)) {
|
||||
for (let idx = 0; idx < node.items.length; idx++) {
|
||||
const child = node.items[idx];
|
||||
if (child !== null && yamlChildMatchesPredicate(child as Node, pred)) {
|
||||
yield { keySub: String(idx), child: child as Node };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
walk: walkYaml,
|
||||
};
|
||||
|
||||
function positionalForYamlNode(node: Node, seg: string): string | null {
|
||||
if (isMap(node)) {
|
||||
const keys = (node as { items: readonly Pair[] }).items.map((p) =>
|
||||
String(isScalar(p.key) ? p.key.value : p.key),
|
||||
);
|
||||
return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
return resolvePositionalSeg(seg, { indexable: true, size: node.items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function yamlChildMatchesPredicate(node: Node, pred: PredicateSpec): boolean {
|
||||
return evaluatePredicate(yamlChildFieldText(node, pred.key), pred);
|
||||
}
|
||||
|
||||
function yamlChildFieldText(node: Node, key: string): string | null {
|
||||
if (!isMap(node)) {
|
||||
return null;
|
||||
}
|
||||
const pair = (node as { items: readonly Pair[] }).items.find((p) => {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
return String(k) === key;
|
||||
});
|
||||
if (pair === undefined || pair.value === null) {
|
||||
return null;
|
||||
}
|
||||
return yamlScalarToText(pair.value);
|
||||
}
|
||||
|
||||
function yamlScalarToText(value: unknown): string | null {
|
||||
if (!isScalar(value)) {
|
||||
return null;
|
||||
}
|
||||
const scalar = value.value;
|
||||
if (typeof scalar === "string") {
|
||||
return scalar;
|
||||
}
|
||||
if (typeof scalar === "number" || typeof scalar === "boolean") {
|
||||
return String(scalar);
|
||||
}
|
||||
if (scalar === null) {
|
||||
return "null";
|
||||
}
|
||||
if (typeof scalar === "bigint" || typeof scalar === "symbol") {
|
||||
return scalar.toString();
|
||||
}
|
||||
if (scalar instanceof Date) {
|
||||
return scalar.toISOString();
|
||||
}
|
||||
return JSON.stringify(scalar) ?? null;
|
||||
}
|
||||
|
||||
// ---------- Markdown walker -----------------------------------------------
|
||||
|
||||
type MdItem = MdAst["blocks"][number]["items"][number];
|
||||
type MdBlock = MdAst["blocks"][number];
|
||||
|
||||
type MdLevel =
|
||||
| { readonly kind: "root"; readonly ast: MdAst }
|
||||
| { readonly kind: "block"; readonly block: MdBlock; readonly ast: MdAst }
|
||||
| { readonly kind: "item"; readonly item: MdItem; readonly ast: MdAst };
|
||||
|
||||
function walkMd(
|
||||
level: MdLevel,
|
||||
subs: readonly PatternSub[],
|
||||
i: number,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
if (i >= subs.length) {
|
||||
onMatch(walked);
|
||||
return;
|
||||
}
|
||||
const cur = subs[i];
|
||||
|
||||
// Frontmatter sentinel short-circuits regular dispatch.
|
||||
if (level.kind === "root" && walked.length === 0 && cur.value === "[frontmatter]") {
|
||||
const next = subs[i + 1];
|
||||
if (next === undefined) {
|
||||
onMatch([{ slot: cur.slot, value: cur.value }]);
|
||||
return;
|
||||
}
|
||||
if (next.value === WILDCARD_SINGLE || next.value === WILDCARD_RECURSIVE) {
|
||||
for (const fm of level.ast.frontmatter) {
|
||||
onMatch([
|
||||
{ slot: cur.slot, value: cur.value },
|
||||
{ slot: next.slot, value: fm.key },
|
||||
]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const fmKey = isQuotedSeg(next.value) ? unquoteSeg(next.value) : next.value;
|
||||
const entry = level.ast.frontmatter.find((e) => e.key === fmKey);
|
||||
if (entry === undefined) {
|
||||
return;
|
||||
}
|
||||
onMatch([
|
||||
{ slot: cur.slot, value: cur.value },
|
||||
{ slot: next.slot, value: next.value },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Item-level field slot is terminal — descending would loop.
|
||||
if (level.kind === "item") {
|
||||
walkMdItemField(level.item, cur, walked, onMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchSeg(level, mdOps, subs, i, walked, onMatch);
|
||||
}
|
||||
|
||||
function walkMdItemField(
|
||||
item: MdItem,
|
||||
cur: PatternSub,
|
||||
walked: readonly SlotSub[],
|
||||
onMatch: OnMatch,
|
||||
): void {
|
||||
if (item.kv === undefined) {
|
||||
return;
|
||||
}
|
||||
const key = item.kv.key;
|
||||
const emit = (value: string): void => {
|
||||
onMatch([...walked, { slot: cur.slot, value }]);
|
||||
};
|
||||
if (isUnionSeg(cur.value)) {
|
||||
const alts = parseUnionSeg(cur.value);
|
||||
if (alts === null) {
|
||||
return;
|
||||
}
|
||||
for (const alt of alts) {
|
||||
if (alt.toLowerCase() === key.toLowerCase()) {
|
||||
emit(key);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isPredicateSeg(cur.value)) {
|
||||
const pred = parsePredicateSeg(cur.value);
|
||||
if (pred !== null && mdItemMatchesPredicate(item, pred)) {
|
||||
emit(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cur.value === WILDCARD_SINGLE || cur.value === WILDCARD_RECURSIVE) {
|
||||
emit(key);
|
||||
return;
|
||||
}
|
||||
if (key.toLowerCase() === cur.value.toLowerCase()) {
|
||||
emit(cur.value);
|
||||
}
|
||||
}
|
||||
|
||||
function blockSlugCounts(items: readonly MdItem[]): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
counts.set(item.slug, (counts.get(item.slug) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// `mdOps` only handles root / block levels. Item-level dispatch is
|
||||
// terminal and runs inline in `walkMd` (see `walkMdItemField`).
|
||||
const mdOps: WalkOps<MdLevel> = {
|
||||
*enumerate(level) {
|
||||
if (level.kind === "root") {
|
||||
for (const block of level.ast.blocks) {
|
||||
yield { keySub: block.slug, child: { kind: "block", block, ast: level.ast } };
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (level.kind === "block") {
|
||||
// Disambiguate duplicate slugs via `#N` ordinal so each emitted
|
||||
// path round-trips through resolveOcPath to its own item.
|
||||
const counts = blockSlugCounts(level.block.items);
|
||||
for (let idx = 0; idx < level.block.items.length; idx++) {
|
||||
const item = level.block.items[idx];
|
||||
const seg = (counts.get(item.slug) ?? 0) > 1 ? `#${idx}` : item.slug;
|
||||
yield { keySub: seg, child: { kind: "item", item, ast: level.ast } };
|
||||
}
|
||||
}
|
||||
},
|
||||
lookup(level, key) {
|
||||
if (level.kind === "root") {
|
||||
const target = key.toLowerCase();
|
||||
const block = level.ast.blocks.find((b) => b.slug === target);
|
||||
return block === undefined
|
||||
? null
|
||||
: { keySub: key, child: { kind: "block", block, ast: level.ast } };
|
||||
}
|
||||
if (level.kind === "block") {
|
||||
// Ordinal `#N` short-circuits slug lookup.
|
||||
if (isOrdinalSeg(key)) {
|
||||
const n = parseOrdinalSeg(key);
|
||||
if (n === null || n < 0 || n >= level.block.items.length) {
|
||||
return null;
|
||||
}
|
||||
return { keySub: key, child: { kind: "item", item: level.block.items[n], ast: level.ast } };
|
||||
}
|
||||
const target = key.toLowerCase();
|
||||
const item = level.block.items.find((it) => it.slug === target);
|
||||
return item === undefined
|
||||
? null
|
||||
: { keySub: key, child: { kind: "item", item, ast: level.ast } };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
positional(level, seg) {
|
||||
if (level.kind !== "block") {
|
||||
return null;
|
||||
}
|
||||
const concrete = resolvePositionalSeg(seg, {
|
||||
indexable: true,
|
||||
size: level.block.items.length,
|
||||
});
|
||||
if (concrete === null) {
|
||||
return null;
|
||||
}
|
||||
// Preserve the positional token in keySub so the resolver
|
||||
// re-evaluates positionally on round-trip.
|
||||
const item = level.block.items[Number(concrete)];
|
||||
return { keySub: seg, child: { kind: "item", item, ast: level.ast } };
|
||||
},
|
||||
*predicate(level, pred) {
|
||||
if (level.kind === "root") {
|
||||
for (const block of level.ast.blocks) {
|
||||
if (mdBlockHasMatchingItem(block, pred)) {
|
||||
yield { keySub: block.slug, child: { kind: "block", block, ast: level.ast } };
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (level.kind === "block") {
|
||||
const counts = blockSlugCounts(level.block.items);
|
||||
for (let idx = 0; idx < level.block.items.length; idx++) {
|
||||
const item = level.block.items[idx];
|
||||
if (mdItemMatchesPredicate(item, pred)) {
|
||||
const seg = (counts.get(item.slug) ?? 0) > 1 ? `#${idx}` : item.slug;
|
||||
yield { keySub: seg, child: { kind: "item", item, ast: level.ast } };
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
walk: walkMd,
|
||||
};
|
||||
|
||||
function mdItemMatchesPredicate(item: MdItem, pred: PredicateSpec): boolean {
|
||||
if (item.kv === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (item.kv.key.toLowerCase() !== pred.key.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return evaluatePredicate(item.kv.value, pred);
|
||||
}
|
||||
|
||||
function mdBlockHasMatchingItem(block: MdBlock, pred: PredicateSpec): boolean {
|
||||
for (const item of block.items) {
|
||||
if (mdItemMatchesPredicate(item, pred)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function jsoncChildMatchesPredicate(node: JsoncValue, pred: PredicateSpec): boolean {
|
||||
return evaluatePredicate(jsoncChildFieldText(node, pred.key), pred);
|
||||
}
|
||||
|
||||
function jsoncChildFieldText(node: JsoncValue, key: string): string | null {
|
||||
if (node.kind !== "object") {
|
||||
return null;
|
||||
}
|
||||
const e = node.entries.find((entry) => entry.key === key);
|
||||
if (e === undefined) {
|
||||
return null;
|
||||
}
|
||||
const v = e.value;
|
||||
if (v.kind === "string") {
|
||||
return v.value;
|
||||
}
|
||||
if (v.kind === "number" || v.kind === "boolean") {
|
||||
return String(v.value);
|
||||
}
|
||||
if (v.kind === "null") {
|
||||
return "null";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
111
extensions/oc-path/src/oc-path/index.ts
Normal file
111
extensions/oc-path/src/oc-path/index.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `@openclaw/oc-path` — substrate package public surface.
|
||||
*
|
||||
* **Strategic frame**: workspace files are byte-stable and addressable
|
||||
* via the `oc://` scheme — the addressing scheme is universal across
|
||||
* file kinds (md / jsonc / jsonl / yaml). Encoding (parse/emit) is per-kind;
|
||||
* addressing (resolve/set) is universal.
|
||||
*
|
||||
* **Public verbs**:
|
||||
* - One `resolveOcPath(ast, path)` - concrete, kind-dispatched
|
||||
* - One `findOcPaths(ast, pattern)` - multi-match, kind-dispatched
|
||||
* - One `setOcPath(ast, path, value)` - concrete mutation / insertion
|
||||
* - Per-kind `parseXxx` / `emitXxx` (parsing is per-kind by nature)
|
||||
*
|
||||
* `setOcPath` accepts a string value; the substrate coerces based on
|
||||
* AST shape at the path location. The OcPath syntax encodes the
|
||||
* operation: plain path = leaf set, `+` suffix = insertion.
|
||||
*
|
||||
* Per-kind set/resolve helpers exist as internal implementation; they
|
||||
* aren't on the public surface. Callers don't need to pick a kind -
|
||||
* the AST carries its `kind` discriminator and the universal verbs
|
||||
* dispatch internally.
|
||||
*
|
||||
* @module @openclaw/oc-path
|
||||
*/
|
||||
|
||||
// AST types
|
||||
export type { AstBlock, AstItem, Diagnostic, FrontmatterEntry, ParseResult, MdAst } from "./ast.js";
|
||||
export type { JsoncAst, JsoncEntry, JsoncValue } from "./jsonc/ast.js";
|
||||
export type { JsonlAst, JsonlLine } from "./jsonl/ast.js";
|
||||
export type { YamlAst } from "./yaml/ast.js";
|
||||
|
||||
// OcPath types + parser/formatter
|
||||
export type { OcPath, PathSegmentLayout, PositionalContainer, PredicateSpec } from "./oc-path.js";
|
||||
// Public OcPath surface — what plugin authors and callers use.
|
||||
export {
|
||||
MAX_PATH_LENGTH,
|
||||
MAX_SUB_SEGMENTS_PER_SLOT,
|
||||
MAX_TRAVERSAL_DEPTH,
|
||||
OcPathError,
|
||||
POS_FIRST,
|
||||
POS_LAST,
|
||||
WILDCARD_RECURSIVE,
|
||||
WILDCARD_SINGLE,
|
||||
formatOcPath,
|
||||
hasWildcard,
|
||||
isOrdinalSeg,
|
||||
isPattern,
|
||||
isPositionalSeg,
|
||||
isPredicateSeg,
|
||||
isQuotedSeg,
|
||||
isUnionSeg,
|
||||
isValidOcPath,
|
||||
parseOcPath,
|
||||
} from "./oc-path.js";
|
||||
|
||||
// `evaluatePredicate`, `getPathLayout`, `parseOrdinalSeg`,
|
||||
// `parsePredicateSeg`, `parseUnionSeg`, `quoteSeg`, `unquoteSeg`,
|
||||
// `resolvePositionalSeg`, `splitRespectingBrackets`
|
||||
// were exported from earlier prototypes. They're substrate-internal
|
||||
// helpers — used by `find.ts`, the per-kind resolvers, and the parser
|
||||
// itself, but not part of the upstream-portable public surface.
|
||||
// Callers that need their behavior should round-trip through
|
||||
// `parseOcPath` / `formatOcPath` / `findOcPaths`.
|
||||
|
||||
// Per-kind parse / emit (encoding is genuinely per-kind)
|
||||
export { parseMd } from "./parse.js";
|
||||
export { parseJsonc } from "./jsonc/parse.js";
|
||||
export { parseJsonl } from "./jsonl/parse.js";
|
||||
export { parseYaml } from "./yaml/parse.js";
|
||||
export type { JsoncParseResult } from "./jsonc/parse.js";
|
||||
export type { JsonlParseResult } from "./jsonl/parse.js";
|
||||
export type { YamlParseResult } from "./yaml/parse.js";
|
||||
|
||||
export type { EmitOptions } from "./emit.js";
|
||||
export { emitMd } from "./emit.js";
|
||||
export type { JsoncEmitOptions } from "./jsonc/emit.js";
|
||||
export { emitJsonc } from "./jsonc/emit.js";
|
||||
export type { JsonlEmitOptions } from "./jsonl/emit.js";
|
||||
export { emitJsonl } from "./jsonl/emit.js";
|
||||
export type { YamlEmitOptions } from "./yaml/emit.js";
|
||||
export { emitYaml } from "./yaml/emit.js";
|
||||
|
||||
// Universal verbs — the only public resolve / set on the surface.
|
||||
export type {
|
||||
OcAst,
|
||||
OcMatch,
|
||||
LeafType,
|
||||
NodeDescriptor,
|
||||
ContainerKind,
|
||||
SetResult,
|
||||
InsertionInfo,
|
||||
} from "./universal.js";
|
||||
export { resolveOcPath, setOcPath, detectInsertion } from "./universal.js";
|
||||
|
||||
// Multi-match search verb — the wildcard-accepting cousin of resolve.
|
||||
export type { OcPathMatch } from "./find.js";
|
||||
export { findOcPaths } from "./find.js";
|
||||
|
||||
// Cross-kind utility — filename → kind hint.
|
||||
export { inferKind } from "./dispatch.js";
|
||||
export type { OcKind } from "./dispatch.js";
|
||||
|
||||
// Sentinel guard
|
||||
export { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "./sentinel.js";
|
||||
|
||||
// Slug helper
|
||||
export { slugify } from "./slug.js";
|
||||
|
||||
// Workspace manifest is a separate concern (filesystem classifier);
|
||||
// it's not part of this PR's scope.
|
||||
49
extensions/oc-path/src/oc-path/jsonc/ast.ts
Normal file
49
extensions/oc-path/src/oc-path/jsonc/ast.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* JSONC AST types — the addressing skeleton for JSONC files (gateway
|
||||
* config, plugin manifests, JSON-with-comments artifacts).
|
||||
*
|
||||
* **Per-kind discriminator**: every AST in this substrate carries a
|
||||
* `kind` field. The OcPath resolver dispatches on `kind` so md / jsonc
|
||||
* / json / jsonl can share one resolver entry point.
|
||||
*
|
||||
* **Byte-fidelity**: `raw` is preserved on the root for round-trip
|
||||
* emit. The minimal prototype parser doesn't preserve every formatting
|
||||
* detail in the structural tree — for production, a fuller
|
||||
* comment-preserving parser ports from `openclaw-workspace`.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonc/ast
|
||||
*/
|
||||
|
||||
/** The root JSONC AST. `raw` round-trips byte-identical via emit. */
|
||||
export interface JsoncAst {
|
||||
readonly kind: "jsonc";
|
||||
readonly raw: string;
|
||||
/** Parsed value tree, or `null` if the file is empty / unparseable. */
|
||||
readonly root: JsoncValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSONC value node — discriminated union over the standard JSON kinds.
|
||||
*
|
||||
* `line` is the 1-based line where the value's literal token starts
|
||||
* (the `{`, `[`, opening `"`, or first digit). The parser always sets
|
||||
* it; synthetic constructions (mutations, fixtures) may omit it and
|
||||
* consumers fall back to 1 / parent line. Optional rather than
|
||||
* required so test fixtures and externally-constructed values stay
|
||||
* concise.
|
||||
*/
|
||||
export type JsoncValue =
|
||||
| { readonly kind: "object"; readonly entries: readonly JsoncEntry[]; readonly line?: number }
|
||||
| { readonly kind: "array"; readonly items: readonly JsoncValue[]; readonly line?: number }
|
||||
| { readonly kind: "string"; readonly value: string; readonly line?: number }
|
||||
| { readonly kind: "number"; readonly value: number; readonly line?: number }
|
||||
| { readonly kind: "boolean"; readonly value: boolean; readonly line?: number }
|
||||
| { readonly kind: "null"; readonly line?: number };
|
||||
|
||||
/** Object key/value entry. Keys are unquoted; quoting happens at emit. */
|
||||
export interface JsoncEntry {
|
||||
readonly key: string;
|
||||
readonly value: JsoncValue;
|
||||
/** 1-based line number of the key. */
|
||||
readonly line: number;
|
||||
}
|
||||
146
extensions/oc-path/src/oc-path/jsonc/edit.ts
Normal file
146
extensions/oc-path/src/oc-path/jsonc/edit.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// OC Path module implements edit behavior.
|
||||
import { applyEdits, modify } from "jsonc-parser/lib/esm/main.js";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import {
|
||||
isPositionalSeg,
|
||||
isQuotedSeg,
|
||||
parseArrayIndexSegment,
|
||||
resolvePositionalSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "../oc-path.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../sentinel.js";
|
||||
import type { JsoncAst, JsoncValue } from "./ast.js";
|
||||
import { parseJsonc } from "./parse.js";
|
||||
|
||||
type JsoncEditPath = Array<string | number>;
|
||||
|
||||
export type JsoncEditResult =
|
||||
| { readonly ok: true; readonly ast: JsoncAst }
|
||||
| { readonly ok: false; readonly reason: "unresolved" | "no-root" };
|
||||
|
||||
export function setJsoncOcPath(ast: JsoncAst, path: OcPath, newValue: JsoncValue): JsoncEditResult {
|
||||
if (ast.root === null) {
|
||||
return { ok: false, reason: "no-root" };
|
||||
}
|
||||
|
||||
const segments = resolveEditSegments(ast.root, pathSegments(path));
|
||||
if (segments === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
guardSentinel(newValue, `oc://${path.file}/${segments.join("/")}`);
|
||||
|
||||
const edits = modify(ast.raw, segments, jsoncValueToJson(newValue), {
|
||||
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
||||
isArrayInsertion: false,
|
||||
});
|
||||
if (edits.length === 0) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const nextRaw = applyEdits(ast.raw, edits);
|
||||
const reparsed = parseJsonc(nextRaw);
|
||||
if (reparsed.ast.root === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
return { ok: true, ast: reparsed.ast };
|
||||
}
|
||||
|
||||
function guardSentinel(value: JsoncValue, guardPath: string): void {
|
||||
if (value.kind === "string") {
|
||||
if (value.value.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(guardPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.kind === "array") {
|
||||
value.items.forEach((item, index) => guardSentinel(item, `${guardPath}/${index}`));
|
||||
return;
|
||||
}
|
||||
if (value.kind === "object") {
|
||||
value.entries.forEach((entry) => guardSentinel(entry.value, `${guardPath}/${entry.key}`));
|
||||
}
|
||||
}
|
||||
|
||||
function pathSegments(path: OcPath): string[] {
|
||||
const out: string[] = [];
|
||||
const collect = (slot: string | undefined) => {
|
||||
if (slot === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const segment of splitRespectingBrackets(slot, ".")) {
|
||||
out.push(isQuotedSeg(segment) ? unquoteSeg(segment) : segment);
|
||||
}
|
||||
};
|
||||
collect(path.section);
|
||||
collect(path.item);
|
||||
collect(path.field);
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveEditSegments(root: JsoncValue, segments: readonly string[]): JsoncEditPath | null {
|
||||
const out: JsoncEditPath = [];
|
||||
let current: JsoncValue = root;
|
||||
for (let segment of segments) {
|
||||
if (segment.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (isPositionalSeg(segment)) {
|
||||
const concrete = positionalForJsonc(current, segment);
|
||||
if (concrete !== null) {
|
||||
segment = concrete;
|
||||
}
|
||||
}
|
||||
if (current.kind === "object") {
|
||||
const entry = current.entries.find((candidate) => candidate.key === segment);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
out.push(segment);
|
||||
current = entry.value;
|
||||
continue;
|
||||
}
|
||||
if (current.kind === "array") {
|
||||
const index = parseArrayIndexSegment(segment, current.items.length);
|
||||
if (index === null) {
|
||||
return null;
|
||||
}
|
||||
out.push(index);
|
||||
current = current.items[index]!;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function positionalForJsonc(node: JsoncValue, segment: string): string | null {
|
||||
if (node.kind === "object") {
|
||||
const keys = node.entries.map((entry) => entry.key);
|
||||
return resolvePositionalSeg(segment, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (node.kind === "array") {
|
||||
return resolvePositionalSeg(segment, { indexable: true, size: node.items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsoncValueToJson(value: JsoncValue): unknown {
|
||||
switch (value.kind) {
|
||||
case "object":
|
||||
return Object.fromEntries(
|
||||
value.entries.map((entry) => [entry.key, jsoncValueToJson(entry.value)]),
|
||||
);
|
||||
case "array":
|
||||
return value.items.map(jsoncValueToJson);
|
||||
case "string":
|
||||
return value.value;
|
||||
case "number":
|
||||
return value.value;
|
||||
case "boolean":
|
||||
return value.value;
|
||||
case "null":
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
68
extensions/oc-path/src/oc-path/jsonc/emit.ts
Normal file
68
extensions/oc-path/src/oc-path/jsonc/emit.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Emit a `JsoncAst` to bytes.
|
||||
*
|
||||
* Round-trip (default) echoes `ast.raw` verbatim — preserves comments
|
||||
* and formatting. Sentinel guard fires only in render mode by default;
|
||||
* round-trip trusts parsed bytes so a workspace file legitimately
|
||||
* containing the sentinel literal isn't a global emit DoS. Callers
|
||||
* that need pre-existing detection opt in via
|
||||
* `acceptPreExistingSentinel: false`.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonc/emit
|
||||
*/
|
||||
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../sentinel.js";
|
||||
import type { JsoncAst, JsoncValue } from "./ast.js";
|
||||
|
||||
export interface JsoncEmitOptions {
|
||||
readonly mode?: "roundtrip" | "render";
|
||||
readonly fileNameForGuard?: string;
|
||||
readonly acceptPreExistingSentinel?: boolean;
|
||||
}
|
||||
|
||||
export function emitJsonc(ast: JsoncAst, opts: JsoncEmitOptions = {}): string {
|
||||
const mode = opts.mode ?? "roundtrip";
|
||||
const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : "oc://";
|
||||
const acceptPreExisting = opts.acceptPreExistingSentinel ?? true;
|
||||
|
||||
if (mode === "roundtrip") {
|
||||
if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/[raw]`);
|
||||
}
|
||||
return ast.raw;
|
||||
}
|
||||
|
||||
// Render mode loses comments; walks leaves for caller-injected sentinel.
|
||||
if (ast.root === null) {
|
||||
return "";
|
||||
}
|
||||
return renderValue(ast.root, guardPath, []);
|
||||
}
|
||||
|
||||
function renderValue(value: JsoncValue, guardPath: string, walked: readonly string[]): string {
|
||||
switch (value.kind) {
|
||||
case "object": {
|
||||
const parts = value.entries.map(
|
||||
(e) => `${JSON.stringify(e.key)}: ${renderValue(e.value, guardPath, [...walked, e.key])}`,
|
||||
);
|
||||
return `{ ${parts.join(", ")} }`;
|
||||
}
|
||||
case "array": {
|
||||
const parts = value.items.map((v, i) => renderValue(v, guardPath, [...walked, String(i)]));
|
||||
return `[ ${parts.join(", ")} ]`;
|
||||
}
|
||||
case "string":
|
||||
// Substring match: embedded sentinel leaks marker bytes too.
|
||||
if (value.value.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/${walked.join("/")}`);
|
||||
}
|
||||
return JSON.stringify(value.value);
|
||||
case "number":
|
||||
return String(value.value);
|
||||
case "boolean":
|
||||
return String(value.value);
|
||||
case "null":
|
||||
return "null";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
189
extensions/oc-path/src/oc-path/jsonc/parse.ts
Normal file
189
extensions/oc-path/src/oc-path/jsonc/parse.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// OC Path module implements parse behavior.
|
||||
import { type ParseError, parseTree, printParseErrorCode } from "jsonc-parser/lib/esm/main.js";
|
||||
import type { Diagnostic } from "../ast.js";
|
||||
import type { JsoncAst, JsoncEntry, JsoncValue } from "./ast.js";
|
||||
|
||||
export const MAX_PARSE_DEPTH = 256;
|
||||
|
||||
/**
|
||||
* Hard cap on jsonc input size. `parseTree` is iterative and stack-safe
|
||||
* but allocates a tree node per token regardless of depth — a 16 MiB
|
||||
* input expanding to millions of nodes hits memory pressure long before
|
||||
* `nodeToJsoncValue`'s `MAX_PARSE_DEPTH` walk would notice. Cap at the
|
||||
* source level so allocation is bounded by file size, not token count.
|
||||
*
|
||||
* 16 MiB is well past every workspace-jsonc shape we care about
|
||||
* (gateway.jsonc / openclaw.json / .openclaw/* are all <100 KiB in
|
||||
* practice; the largest LKG-tracked configs we've seen sit at single-
|
||||
* digit MB). Operators with legitimate larger inputs can lift the cap
|
||||
* by patching this constant — no SDK affordance because it isn't a
|
||||
* supported configuration.
|
||||
*/
|
||||
export const MAX_JSONC_INPUT_BYTES = 16 * 1024 * 1024;
|
||||
const JSONC_PARSE_INVALID_SYMBOL = 1;
|
||||
const JSONC_PARSE_END_OF_FILE_EXPECTED = 9;
|
||||
|
||||
export interface JsoncParseResult {
|
||||
readonly ast: JsoncAst;
|
||||
readonly diagnostics: readonly Diagnostic[];
|
||||
}
|
||||
|
||||
type LineMap = {
|
||||
lineForOffset(offset: number): number;
|
||||
};
|
||||
|
||||
type JsoncParserNode = {
|
||||
readonly type: "array" | "boolean" | "null" | "number" | "object" | "property" | "string";
|
||||
readonly offset: number;
|
||||
readonly length: number;
|
||||
readonly value?: unknown;
|
||||
readonly children?: readonly JsoncParserNode[];
|
||||
};
|
||||
|
||||
export function parseJsonc(raw: string): JsoncParseResult {
|
||||
if (raw.trim().length === 0) {
|
||||
return { ast: { kind: "jsonc", raw, root: null }, diagnostics: [] };
|
||||
}
|
||||
|
||||
// Pre-parse byte-length cap. Symmetric with the post-parse depth cap
|
||||
// at `nodeToJsoncValue`. Without this, `parseTree` would allocate the
|
||||
// full tree before our walker noticed; bounding at the source keeps
|
||||
// memory pressure proportional to input size.
|
||||
if (raw.length > MAX_JSONC_INPUT_BYTES) {
|
||||
return {
|
||||
ast: { kind: "jsonc", raw, root: null },
|
||||
diagnostics: [
|
||||
{
|
||||
line: 1,
|
||||
message: `input exceeds MAX_JSONC_INPUT_BYTES (${MAX_JSONC_INPUT_BYTES} bytes; got ${raw.length})`,
|
||||
severity: "error",
|
||||
code: "OC_JSONC_INPUT_TOO_LARGE",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const parseSource = raw.startsWith("\uFEFF") ? raw.slice(1) : raw;
|
||||
const errors: ParseError[] = [];
|
||||
const tree = parseTree(parseSource, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
allowEmptyContent: true,
|
||||
}) as JsoncParserNode | undefined;
|
||||
const lineMap = createLineMap(raw);
|
||||
const diagnostics = errors.map((error) => toDiagnostic(error, lineMap, tree));
|
||||
let root: JsoncValue | null = null;
|
||||
if (tree && diagnostics.every((d) => d.severity !== "error")) {
|
||||
try {
|
||||
root = nodeToJsoncValue(tree, lineMap, 0);
|
||||
} catch (err) {
|
||||
diagnostics.push({
|
||||
line: 1,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
severity: "error",
|
||||
code: "OC_JSONC_DEPTH_EXCEEDED",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ast: {
|
||||
kind: "jsonc",
|
||||
raw,
|
||||
root: diagnostics.every((d) => d.severity !== "error") ? root : null,
|
||||
},
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function toDiagnostic(
|
||||
error: ParseError,
|
||||
lineMap: LineMap,
|
||||
tree: JsoncParserNode | undefined,
|
||||
): Diagnostic {
|
||||
const treeEnd = tree ? tree.offset + tree.length : 0;
|
||||
const errorCode: number = error.error;
|
||||
const isTrailingInput =
|
||||
errorCode === JSONC_PARSE_END_OF_FILE_EXPECTED ||
|
||||
(tree !== undefined && errorCode === JSONC_PARSE_INVALID_SYMBOL && error.offset >= treeEnd);
|
||||
return {
|
||||
line: lineMap.lineForOffset(error.offset),
|
||||
message: printParseErrorCode(error.error),
|
||||
severity: isTrailingInput ? "warning" : "error",
|
||||
code: isTrailingInput ? "OC_JSONC_TRAILING_INPUT" : "OC_JSONC_PARSE_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
function nodeToJsoncValue(node: JsoncParserNode, lineMap: LineMap, depth: number): JsoncValue {
|
||||
if (depth > MAX_PARSE_DEPTH) {
|
||||
throw new Error(`structural depth exceeded MAX_PARSE_DEPTH (${MAX_PARSE_DEPTH})`);
|
||||
}
|
||||
const line = lineMap.lineForOffset(node.offset);
|
||||
switch (node.type) {
|
||||
case "object":
|
||||
return {
|
||||
kind: "object",
|
||||
line,
|
||||
entries: (node.children ?? []).flatMap((child): JsoncEntry[] => {
|
||||
if (child.type !== "property") {
|
||||
return [];
|
||||
}
|
||||
const keyNode = child.children?.[0];
|
||||
const valueNode = child.children?.[1];
|
||||
if (!keyNode || !valueNode) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: String(keyNode.value),
|
||||
line: lineMap.lineForOffset(keyNode.offset),
|
||||
value: nodeToJsoncValue(valueNode, lineMap, depth + 1),
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
case "array":
|
||||
return {
|
||||
kind: "array",
|
||||
line,
|
||||
items: (node.children ?? []).map((child) => nodeToJsoncValue(child, lineMap, depth + 1)),
|
||||
};
|
||||
case "string":
|
||||
return { kind: "string", value: String(node.value), line };
|
||||
case "number":
|
||||
return { kind: "number", value: Number(node.value), line };
|
||||
case "boolean":
|
||||
return { kind: "boolean", value: Boolean(node.value), line };
|
||||
case "null":
|
||||
return { kind: "null", line };
|
||||
default:
|
||||
return { kind: "null", line };
|
||||
}
|
||||
}
|
||||
|
||||
function createLineMap(raw: string): LineMap {
|
||||
const starts = [0];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
if (raw[i] === "\n") {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
lineForOffset(offset) {
|
||||
let low = 0;
|
||||
let high = starts.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const start = starts[mid] ?? 0;
|
||||
if (start <= offset) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return Math.max(1, high + 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { Diagnostic };
|
||||
72
extensions/oc-path/src/oc-path/jsonc/resolve-value.ts
Normal file
72
extensions/oc-path/src/oc-path/jsonc/resolve-value.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// OC Path module implements resolve value behavior.
|
||||
import { isPositionalSeg, parseArrayIndexSegment, resolvePositionalSeg } from "../oc-path.js";
|
||||
import type { JsoncEntry, JsoncValue } from "./ast.js";
|
||||
|
||||
export type JsoncValueOcPathMatch =
|
||||
| { readonly kind: "value"; readonly node: JsoncValue; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly kind: "object-entry";
|
||||
readonly node: JsoncEntry;
|
||||
readonly path: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveJsoncValueOcPath(
|
||||
root: JsoncValue,
|
||||
segments: readonly string[],
|
||||
): JsoncValueOcPathMatch | null {
|
||||
let current: JsoncValue = root;
|
||||
let lastEntry: JsoncEntry | null = null;
|
||||
const walked: string[] = [];
|
||||
|
||||
for (let seg of segments) {
|
||||
if (seg.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (isPositionalSeg(seg)) {
|
||||
const concrete = positionalForJsonc(current, seg);
|
||||
if (concrete !== null) {
|
||||
seg = concrete;
|
||||
}
|
||||
}
|
||||
walked.push(seg);
|
||||
if (current.kind === "object") {
|
||||
const entry = current.entries.find((e) => e.key === seg);
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
lastEntry = entry;
|
||||
current = entry.value;
|
||||
continue;
|
||||
}
|
||||
if (current.kind === "array") {
|
||||
const idx = parseArrayIndexSegment(seg, current.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
lastEntry = null;
|
||||
const item = current.items[idx];
|
||||
if (item === undefined) {
|
||||
return null;
|
||||
}
|
||||
current = item;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lastEntry !== null && current === lastEntry.value) {
|
||||
return { kind: "object-entry", node: lastEntry, path: walked };
|
||||
}
|
||||
return { kind: "value", node: current, path: walked };
|
||||
}
|
||||
|
||||
function positionalForJsonc(node: JsoncValue, seg: string): string | null {
|
||||
if (node.kind === "object") {
|
||||
const keys = node.entries.map((e) => e.key);
|
||||
return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (node.kind === "array") {
|
||||
return resolvePositionalSeg(seg, { indexable: true, size: node.items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
46
extensions/oc-path/src/oc-path/jsonc/resolve.ts
Normal file
46
extensions/oc-path/src/oc-path/jsonc/resolve.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Resolve `OcPath` against `JsoncAst`. Slot segments concat as if
|
||||
* dotted; segments are bracket/quote-aware-split so quoted keys
|
||||
* containing `/` or `.` round-trip cleanly.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonc/resolve
|
||||
*/
|
||||
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import { isQuotedSeg, splitRespectingBrackets, unquoteSeg } from "../oc-path.js";
|
||||
import type { JsoncAst, JsoncEntry, JsoncValue } from "./ast.js";
|
||||
import { resolveJsoncValueOcPath } from "./resolve-value.js";
|
||||
|
||||
export type JsoncOcPathMatch =
|
||||
| { readonly kind: "root"; readonly node: JsoncAst }
|
||||
| { readonly kind: "value"; readonly node: JsoncValue; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly kind: "object-entry";
|
||||
readonly node: JsoncEntry;
|
||||
readonly path: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveJsoncOcPath(ast: JsoncAst, path: OcPath): JsoncOcPathMatch | null {
|
||||
if (ast.root === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
const collect = (slot: string | undefined): void => {
|
||||
if (slot === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const s of splitRespectingBrackets(slot, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
};
|
||||
collect(path.section);
|
||||
collect(path.item);
|
||||
collect(path.field);
|
||||
|
||||
if (segments.length === 0) {
|
||||
return { kind: "root", node: ast };
|
||||
}
|
||||
|
||||
return resolveJsoncValueOcPath(ast.root, segments);
|
||||
}
|
||||
49
extensions/oc-path/src/oc-path/jsonl/ast.ts
Normal file
49
extensions/oc-path/src/oc-path/jsonl/ast.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* JSONL AST types — JSON-Lines: one JSON value per line, separated by
|
||||
* `\n`. The shape used by openclaw session-event logs, audit trails,
|
||||
* and LKG checkpoints (which is why JSONL is part of the universal
|
||||
* OcPath addressing scheme).
|
||||
*
|
||||
* **Per-kind discriminator**: every AST in this substrate carries a
|
||||
* `kind` field. The OcPath resolver dispatches on `kind`.
|
||||
*
|
||||
* **Byte-fidelity**: `raw` is preserved on the root for round-trip
|
||||
* emit. JSONL is line-oriented, so blank lines and per-line comments
|
||||
* (we don't strip them in render mode either — we preserve them as
|
||||
* "raw" line entries) live in the AST.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonl/ast
|
||||
*/
|
||||
|
||||
import type { JsoncValue } from "../jsonc/ast.js";
|
||||
|
||||
/** The root JSONL AST. `raw` round-trips byte-identical via emit. */
|
||||
export interface JsonlAst {
|
||||
readonly kind: "jsonl";
|
||||
readonly raw: string;
|
||||
readonly lines: readonly JsonlLine[];
|
||||
/**
|
||||
* Line-ending convention detected at parse time. Used by render mode
|
||||
* to reconstruct the original convention (Windows-authored datasets
|
||||
* use CRLF; Unix uses LF). Optional for back-compat with synthetic
|
||||
* ASTs that don't track this — render mode falls back to LF when
|
||||
* undefined.
|
||||
*/
|
||||
readonly lineEnding?: "\r\n" | "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* One line of a JSONL file. Either a parsed JSON value, a blank line
|
||||
* (preserved for round-trip), or a malformed line (emit verbatim;
|
||||
* emit-time sentinel guard still scans).
|
||||
*/
|
||||
export type JsonlLine =
|
||||
| {
|
||||
readonly kind: "value";
|
||||
readonly line: number;
|
||||
readonly value: JsoncValue;
|
||||
/** The original line text (without trailing newline). */
|
||||
readonly raw: string;
|
||||
}
|
||||
| { readonly kind: "blank"; readonly line: number; readonly raw: string }
|
||||
| { readonly kind: "malformed"; readonly line: number; readonly raw: string };
|
||||
227
extensions/oc-path/src/oc-path/jsonl/edit.ts
Normal file
227
extensions/oc-path/src/oc-path/jsonl/edit.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Mutate a `JsonlAst` at an OcPath. Append uses `appendJsonlOcPath`;
|
||||
* `setJsonlOcPath` only edits existing addresses.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonl/edit
|
||||
*/
|
||||
|
||||
import type { JsoncEntry, JsoncValue } from "../jsonc/ast.js";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import {
|
||||
isPositionalSeg,
|
||||
isQuotedSeg,
|
||||
parseArrayIndexSegment,
|
||||
resolvePositionalSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "../oc-path.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
import { emitJsonl } from "./emit.js";
|
||||
|
||||
export type JsonlEditResult =
|
||||
| { readonly ok: true; readonly ast: JsonlAst }
|
||||
| { readonly ok: false; readonly reason: "unresolved" | "not-a-value-line" };
|
||||
|
||||
export function setJsonlOcPath(ast: JsonlAst, path: OcPath, newValue: JsoncValue): JsonlEditResult {
|
||||
const head = path.section;
|
||||
if (head === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const lineIdx = pickLineIndex(ast, head);
|
||||
if (lineIdx === -1) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const target = ast.lines[lineIdx];
|
||||
if (target === undefined) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
// No item/field — replace the whole line. Requires an existing value line.
|
||||
if (path.item === undefined && path.field === undefined) {
|
||||
if (target.kind !== "value") {
|
||||
return { ok: false, reason: "not-a-value-line" };
|
||||
}
|
||||
const newLine: JsonlLine = {
|
||||
kind: "value",
|
||||
line: target.line,
|
||||
value: newValue,
|
||||
raw: target.raw,
|
||||
};
|
||||
return finalize(ast, lineIdx, newLine, path.file);
|
||||
}
|
||||
|
||||
if (target.kind !== "value") {
|
||||
return { ok: false, reason: "not-a-value-line" };
|
||||
}
|
||||
|
||||
// Quote-aware split keeps edit symmetric with resolveJsonlOcPath.
|
||||
const segments: string[] = [];
|
||||
if (path.item !== undefined) {
|
||||
segments.push(...splitRespectingBrackets(path.item, "."));
|
||||
}
|
||||
if (path.field !== undefined) {
|
||||
segments.push(...splitRespectingBrackets(path.field, "."));
|
||||
}
|
||||
|
||||
const replaced = replaceAt(target.value, segments, 0, newValue);
|
||||
if (replaced === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const newLine: JsonlLine = {
|
||||
kind: "value",
|
||||
line: target.line,
|
||||
value: replaced,
|
||||
raw: target.raw,
|
||||
};
|
||||
return finalize(ast, lineIdx, newLine, path.file);
|
||||
}
|
||||
|
||||
function replaceAt(
|
||||
current: JsoncValue,
|
||||
segments: readonly string[],
|
||||
i: number,
|
||||
newValue: JsoncValue,
|
||||
): JsoncValue | null {
|
||||
const seg = segments[i];
|
||||
if (seg === undefined) {
|
||||
return newValue;
|
||||
}
|
||||
if (seg.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (current.kind === "object") {
|
||||
// Positional tokens resolve against the entries' ordered key list;
|
||||
// quoted segments are unquoted before literal-key comparison.
|
||||
let segNorm = seg;
|
||||
if (isPositionalSeg(seg)) {
|
||||
const resolved = resolvePositionalSeg(seg, {
|
||||
indexable: false,
|
||||
size: current.entries.length,
|
||||
keys: current.entries.map((e) => e.key),
|
||||
});
|
||||
if (resolved === null) {
|
||||
return null;
|
||||
}
|
||||
segNorm = resolved;
|
||||
}
|
||||
const lookupKey = isQuotedSeg(segNorm) ? unquoteSeg(segNorm) : segNorm;
|
||||
const idx = current.entries.findIndex((e) => e.key === lookupKey);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const child = current.entries[idx];
|
||||
if (child === undefined) {
|
||||
return null;
|
||||
}
|
||||
const replacedChild = replaceAt(child.value, segments, i + 1, newValue);
|
||||
if (replacedChild === null) {
|
||||
return null;
|
||||
}
|
||||
const newEntry: JsoncEntry = { ...child, value: replacedChild };
|
||||
const newEntries = current.entries.slice();
|
||||
newEntries[idx] = newEntry;
|
||||
return {
|
||||
kind: "object",
|
||||
entries: newEntries,
|
||||
...(current.line !== undefined ? { line: current.line } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (current.kind === "array") {
|
||||
let segNorm = seg;
|
||||
if (isPositionalSeg(seg)) {
|
||||
const resolved = resolvePositionalSeg(seg, {
|
||||
indexable: true,
|
||||
size: current.items.length,
|
||||
});
|
||||
if (resolved === null) {
|
||||
return null;
|
||||
}
|
||||
segNorm = resolved;
|
||||
}
|
||||
const idx = parseArrayIndexSegment(segNorm, current.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
const child = current.items[idx];
|
||||
if (child === undefined) {
|
||||
return null;
|
||||
}
|
||||
const replacedChild = replaceAt(child, segments, i + 1, newValue);
|
||||
if (replacedChild === null) {
|
||||
return null;
|
||||
}
|
||||
const newItems = current.items.slice();
|
||||
newItems[idx] = replacedChild;
|
||||
return {
|
||||
kind: "array",
|
||||
items: newItems,
|
||||
...(current.line !== undefined ? { line: current.line } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickLineIndex(ast: JsonlAst, addr: string): number {
|
||||
if (addr === "$first") {
|
||||
return ast.lines.findIndex((line) => line.kind === "value");
|
||||
}
|
||||
if (addr === "$last") {
|
||||
for (let i = ast.lines.length - 1; i >= 0; i--) {
|
||||
if (ast.lines[i]?.kind === "value") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
const m = /^L(\d+)$/.exec(addr);
|
||||
if (m === null || m[1] === undefined) {
|
||||
return -1;
|
||||
}
|
||||
const target = Number(m[1]);
|
||||
return ast.lines.findIndex((l) => l.line === target);
|
||||
}
|
||||
|
||||
function finalize(
|
||||
ast: JsonlAst,
|
||||
lineIdx: number,
|
||||
newLine: JsonlLine,
|
||||
fileName?: string,
|
||||
): JsonlEditResult {
|
||||
const newLines = ast.lines.slice();
|
||||
newLines[lineIdx] = newLine;
|
||||
const next: JsonlAst = {
|
||||
kind: "jsonl",
|
||||
raw: "",
|
||||
lines: newLines,
|
||||
...(ast.lineEnding !== undefined ? { lineEnding: ast.lineEnding } : {}),
|
||||
};
|
||||
const opts =
|
||||
fileName !== undefined
|
||||
? { mode: "render" as const, fileNameForGuard: fileName }
|
||||
: { mode: "render" as const };
|
||||
const rendered = emitJsonl(next, opts);
|
||||
return { ok: true, ast: { ...next, raw: rendered } };
|
||||
}
|
||||
|
||||
/** Append a value as the next line. Line numbers are substrate-assigned. */
|
||||
export function appendJsonlOcPath(ast: JsonlAst, value: JsoncValue): JsonlAst {
|
||||
const nextLineNo = ast.lines.length === 0 ? 1 : (ast.lines[ast.lines.length - 1]?.line ?? 0) + 1;
|
||||
const newLine: JsonlLine = {
|
||||
kind: "value",
|
||||
line: nextLineNo,
|
||||
value,
|
||||
raw: "",
|
||||
};
|
||||
const next: JsonlAst = {
|
||||
kind: "jsonl",
|
||||
raw: "",
|
||||
lines: [...ast.lines, newLine],
|
||||
...(ast.lineEnding !== undefined ? { lineEnding: ast.lineEnding } : {}),
|
||||
};
|
||||
const rendered = emitJsonl(next, { mode: "render" });
|
||||
return { ...next, raw: rendered };
|
||||
}
|
||||
73
extensions/oc-path/src/oc-path/jsonl/emit.ts
Normal file
73
extensions/oc-path/src/oc-path/jsonl/emit.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Emit a `JsonlAst` to bytes. Round-trip echoes `ast.raw`; render mode
|
||||
* rebuilds from line entries (preserves blank/malformed lines verbatim).
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonl/emit
|
||||
*/
|
||||
|
||||
import type { JsoncValue } from "../jsonc/ast.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../sentinel.js";
|
||||
import type { JsonlAst } from "./ast.js";
|
||||
|
||||
export interface JsonlEmitOptions {
|
||||
readonly mode?: "roundtrip" | "render";
|
||||
readonly fileNameForGuard?: string;
|
||||
readonly acceptPreExistingSentinel?: boolean;
|
||||
}
|
||||
|
||||
export function emitJsonl(ast: JsonlAst, opts: JsonlEmitOptions = {}): string {
|
||||
const mode = opts.mode ?? "roundtrip";
|
||||
const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : "oc://";
|
||||
const acceptPreExisting = opts.acceptPreExistingSentinel ?? true;
|
||||
|
||||
if (mode === "roundtrip") {
|
||||
if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/[raw]`);
|
||||
}
|
||||
return ast.raw;
|
||||
}
|
||||
|
||||
const out: string[] = [];
|
||||
for (const ln of ast.lines) {
|
||||
if (ln.kind === "blank" || ln.kind === "malformed") {
|
||||
if (!acceptPreExisting && ln.raw.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/L${ln.line}`);
|
||||
}
|
||||
out.push(ln.raw);
|
||||
continue;
|
||||
}
|
||||
// Value lines always scan leaves so caller-injected sentinel is rejected.
|
||||
out.push(renderValue(ln.value, `${guardPath}/L${ln.line}`, []));
|
||||
}
|
||||
// Preserve line-ending convention; otherwise CRLF input edited via
|
||||
// setJsonlOcPath would emit mixed endings (silent corruption on Windows).
|
||||
return out.join(ast.lineEnding ?? "\n");
|
||||
}
|
||||
|
||||
function renderValue(value: JsoncValue, guardPath: string, walked: readonly string[]): string {
|
||||
switch (value.kind) {
|
||||
case "object": {
|
||||
const parts = value.entries.map(
|
||||
(e) => `${JSON.stringify(e.key)}:${renderValue(e.value, guardPath, [...walked, e.key])}`,
|
||||
);
|
||||
return `{${parts.join(",")}}`;
|
||||
}
|
||||
case "array": {
|
||||
const parts = value.items.map((v, i) => renderValue(v, guardPath, [...walked, String(i)]));
|
||||
return `[${parts.join(",")}]`;
|
||||
}
|
||||
case "string":
|
||||
// Substring match: embedded sentinel leaks marker bytes too.
|
||||
if (value.value.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/${walked.join("/")}`);
|
||||
}
|
||||
return JSON.stringify(value.value);
|
||||
case "number":
|
||||
return String(value.value);
|
||||
case "boolean":
|
||||
return String(value.value);
|
||||
case "null":
|
||||
return "null";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
33
extensions/oc-path/src/oc-path/jsonl/line.ts
Normal file
33
extensions/oc-path/src/oc-path/jsonl/line.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { POS_FIRST, POS_LAST } from "../oc-path.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
|
||||
export function pickJsonlLine(ast: JsonlAst, addr: string): JsonlLine | null {
|
||||
if (addr === POS_FIRST) {
|
||||
for (const line of ast.lines) {
|
||||
if (line.kind === "value") {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (addr === POS_LAST) {
|
||||
for (let index = ast.lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = ast.lines[index];
|
||||
if (line !== undefined && line.kind === "value") {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const match = /^L(\d+)$/.exec(addr);
|
||||
if (match === null || match[1] === undefined) {
|
||||
return null;
|
||||
}
|
||||
const target = Number(match[1]);
|
||||
for (const line of ast.lines) {
|
||||
if (line.line === target) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
73
extensions/oc-path/src/oc-path/jsonl/parse.ts
Normal file
73
extensions/oc-path/src/oc-path/jsonl/parse.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* JSONL parser — splits on `\n`, parses each non-empty line as JSONC
|
||||
* (allowing comments/trailing-comma is harmless and matches what
|
||||
* openclaw session logs actually emit). Soft-error policy: malformed
|
||||
* lines surface as `kind: 'malformed'` AST entries plus a diagnostic.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonl/parse
|
||||
*/
|
||||
|
||||
import type { Diagnostic } from "../ast.js";
|
||||
import { parseJsonc } from "../jsonc/parse.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
|
||||
export interface JsonlParseResult {
|
||||
readonly ast: JsonlAst;
|
||||
readonly diagnostics: readonly Diagnostic[];
|
||||
}
|
||||
|
||||
export function parseJsonl(raw: string): JsonlParseResult {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
// Detect the line-ending convention from the input. Windows-authored
|
||||
// datasets use CRLF; Unix and most cross-platform tooling use LF. We
|
||||
// count CRLF occurrences and call CRLF if the majority of newlines
|
||||
// are CRLF — this handles mixed-ending files (e.g., a Unix log
|
||||
// edited once on Windows) by picking the dominant convention.
|
||||
// Without this, `setJsonlOcPath` rebuilds a CRLF input via render
|
||||
// mode which joins with `\n`, producing mixed endings on a
|
||||
// previously-CRLF file.
|
||||
const crlfCount = (raw.match(/\r\n/g) ?? []).length;
|
||||
const lfCount = (raw.match(/\n/g) ?? []).length;
|
||||
const lineEnding: "\r\n" | "\n" = crlfCount > 0 && crlfCount * 2 >= lfCount ? "\r\n" : "\n";
|
||||
|
||||
// Trim trailing newline so we don't fabricate a blank line at EOF
|
||||
// for files that end with `\n` (which is most of them).
|
||||
let body = raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
||||
// Normalize line endings to LF for consistent splitting; per-line
|
||||
// `raw` is stored without the trailing `\r`, and render mode
|
||||
// restores the original convention via `lineEnding`.
|
||||
body = body.replace(/\r\n/g, "\n");
|
||||
const lines: JsonlLine[] = [];
|
||||
|
||||
if (body.length === 0) {
|
||||
return { ast: { kind: "jsonl", raw, lines, lineEnding }, diagnostics };
|
||||
}
|
||||
|
||||
const parts = body.split("\n");
|
||||
parts.forEach((lineText, idx) => {
|
||||
const lineNo = idx + 1;
|
||||
if (lineText.trim().length === 0) {
|
||||
lines.push({ kind: "blank", line: lineNo, raw: lineText });
|
||||
return;
|
||||
}
|
||||
const r = parseJsonc(lineText);
|
||||
if (r.ast.root === null) {
|
||||
lines.push({ kind: "malformed", line: lineNo, raw: lineText });
|
||||
diagnostics.push({
|
||||
line: lineNo,
|
||||
message: `line ${lineNo} could not be parsed as JSON`,
|
||||
severity: "warning",
|
||||
code: "OC_JSONL_LINE_MALFORMED",
|
||||
});
|
||||
return;
|
||||
}
|
||||
lines.push({
|
||||
kind: "value",
|
||||
line: lineNo,
|
||||
value: r.ast.root,
|
||||
raw: lineText,
|
||||
});
|
||||
});
|
||||
|
||||
return { ast: { kind: "jsonl", raw, lines, lineEnding }, diagnostics };
|
||||
}
|
||||
87
extensions/oc-path/src/oc-path/jsonl/resolve.ts
Normal file
87
extensions/oc-path/src/oc-path/jsonl/resolve.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Resolve an `OcPath` against a `JsonlAst`.
|
||||
*
|
||||
* Convention for JSONL OcPaths:
|
||||
*
|
||||
* oc://session-events/L42 → entire line 42 value
|
||||
* oc://session-events/L42/result → field on line 42's value
|
||||
* oc://session-events/L42/result.detail → dotted descent
|
||||
* oc://session-events/$last → final non-blank value
|
||||
*
|
||||
* `Lnnn` (line address) and `$last` are the addressing primitives
|
||||
* unique to JSONL — they're how forensics / replay refers to a
|
||||
* specific entry without committing to a content key.
|
||||
*
|
||||
* @module @openclaw/oc-path/jsonl/resolve
|
||||
*/
|
||||
|
||||
import type { JsoncEntry, JsoncValue } from "../jsonc/ast.js";
|
||||
import { resolveJsoncValueOcPath } from "../jsonc/resolve-value.js";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import { isQuotedSeg, splitRespectingBrackets, unquoteSeg } from "../oc-path.js";
|
||||
import type { JsonlAst, JsonlLine } from "./ast.js";
|
||||
import { pickJsonlLine } from "./line.js";
|
||||
|
||||
export type JsonlOcPathMatch =
|
||||
| { readonly kind: "root"; readonly node: JsonlAst }
|
||||
| { readonly kind: "line"; readonly node: JsonlLine }
|
||||
| {
|
||||
readonly kind: "value";
|
||||
readonly node: JsoncValue;
|
||||
readonly line: number;
|
||||
readonly path: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "object-entry";
|
||||
readonly node: JsoncEntry;
|
||||
readonly line: number;
|
||||
readonly path: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatch | null {
|
||||
// The first non-file segment is the line address (Lnnn or $last).
|
||||
const head = path.section;
|
||||
if (head === undefined) {
|
||||
return { kind: "root", node: ast };
|
||||
}
|
||||
|
||||
const lineEntry = pickJsonlLine(ast, head);
|
||||
if (lineEntry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// No further descent — return the line entry itself.
|
||||
if (path.item === undefined && path.field === undefined) {
|
||||
return { kind: "line", node: lineEntry };
|
||||
}
|
||||
|
||||
if (lineEntry.kind !== "value") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
if (path.item !== undefined) {
|
||||
for (const s of splitRespectingBrackets(path.item, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
}
|
||||
if (path.field !== undefined) {
|
||||
for (const s of splitRespectingBrackets(path.field, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
}
|
||||
|
||||
const match = resolveJsoncValueOcPath(lineEntry.value, segments);
|
||||
if (match === null) {
|
||||
return null;
|
||||
}
|
||||
if (match.kind === "object-entry") {
|
||||
return {
|
||||
kind: "object-entry",
|
||||
node: match.node,
|
||||
line: lineEntry.line,
|
||||
path: match.path,
|
||||
};
|
||||
}
|
||||
return { kind: "value", node: match.node, line: lineEntry.line, path: match.path };
|
||||
}
|
||||
820
extensions/oc-path/src/oc-path/oc-path.ts
Normal file
820
extensions/oc-path/src/oc-path/oc-path.ts
Normal file
@@ -0,0 +1,820 @@
|
||||
/**
|
||||
* `oc://` path syntax — universal addressing for the OpenClaw workspace.
|
||||
*
|
||||
* oc://{file}[/{section}[/{item}[/{field}]]][?session={id}]
|
||||
*
|
||||
* Canonical round-trip contract: `formatOcPath(parseOcPath(s)) === s`
|
||||
* for canonical paths. Extra query parameters are ignored except for
|
||||
* the first non-empty `session=` value.
|
||||
*
|
||||
* @module @openclaw/oc-path/oc-path
|
||||
*/
|
||||
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "./sentinel.js";
|
||||
|
||||
const OC_SCHEME = "oc://";
|
||||
|
||||
// Hard caps bound resource use under pathological / hostile input.
|
||||
export const MAX_PATH_LENGTH = 4096;
|
||||
export const MAX_SUB_SEGMENTS_PER_SLOT = 64;
|
||||
export const MAX_TRAVERSAL_DEPTH = 256;
|
||||
|
||||
const BOM = "";
|
||||
|
||||
// Walk by char code rather than regex — the no-control-regex lint rule
|
||||
// rejects character classes covering U+0000–U+001F + U+007F.
|
||||
function hasControlChar(s: string): boolean {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const cc = s.charCodeAt(i);
|
||||
if (cc <= 0x1f || cc === 0x7f) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const RESERVED_CHARS_RE = /[?&%]/;
|
||||
|
||||
/** Render with `\xNN` escapes so error output is readable for invisible chars. */
|
||||
function printable(s: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const cc = s.charCodeAt(i);
|
||||
if (cc <= 0x1f || cc === 0x7f) {
|
||||
out += `\\x${cc.toString(16).padStart(2, "0")}`;
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `oc://` path. Components nest strictly: `item` implies
|
||||
* `section`, `field` implies `item`. `field` directly under file
|
||||
* addresses a frontmatter key; under item it addresses the value of a
|
||||
* `- key: value` bullet. `session` is an opaque raw scope string; it is
|
||||
* not percent-decoded and cannot contain control characters or reserved
|
||||
* query delimiters (`?`, `&`, `%`).
|
||||
*/
|
||||
export interface OcPath {
|
||||
readonly file: string;
|
||||
readonly section?: string;
|
||||
readonly item?: string;
|
||||
readonly field?: string;
|
||||
readonly session?: string;
|
||||
}
|
||||
|
||||
/** `code` is the stable machine-readable tag; consumers match on `code`, not `message`. */
|
||||
export class OcPathError extends Error {
|
||||
readonly code: string;
|
||||
readonly input: string;
|
||||
|
||||
constructor(message: string, input: string, code: string) {
|
||||
super(message);
|
||||
this.name = "OcPathError";
|
||||
this.input = input;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message: string, input: string, code: string): never {
|
||||
throw new OcPathError(message, input, code);
|
||||
}
|
||||
|
||||
// Reject absolute paths, parent-dir escapes, and control chars at every
|
||||
// entry point so a hostile struct can't smuggle a filesystem traversal.
|
||||
function validateFileSlot(file: string, contextInput: string): void {
|
||||
if (file.startsWith("/") || file.startsWith("\\") || /^[a-zA-Z]:/.test(file)) {
|
||||
fail(
|
||||
`Absolute file slot not allowed (oc:// paths are workspace-relative): ${printable(contextInput)}`,
|
||||
contextInput,
|
||||
"OC_PATH_ABSOLUTE_FILE",
|
||||
);
|
||||
}
|
||||
if (file.split(/[\\/]/).some((seg) => seg === "..")) {
|
||||
fail(
|
||||
`Parent-directory segment ('..') not allowed in oc:// file slot: ${printable(contextInput)}`,
|
||||
contextInput,
|
||||
"OC_PATH_PARENT_TRAVERSAL",
|
||||
);
|
||||
}
|
||||
if (hasControlChar(file)) {
|
||||
fail(
|
||||
`Control character in oc:// file slot: ${printable(contextInput)}`,
|
||||
contextInput,
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateSessionSlot(session: string, contextInput: string): void {
|
||||
if (hasControlChar(session)) {
|
||||
fail(
|
||||
`Control character in oc:// session query: ${printable(contextInput)}`,
|
||||
contextInput,
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
}
|
||||
if (RESERVED_CHARS_RE.test(session)) {
|
||||
fail(
|
||||
`Reserved character (\`?\` / \`&\` / \`%\`) in oc:// session query: ${printable(contextInput)}`,
|
||||
contextInput,
|
||||
"OC_PATH_RESERVED_CHAR",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse an `oc://` path string into a structured `OcPath`. */
|
||||
export function parseOcPath(input: string): OcPath {
|
||||
if (typeof input !== "string") {
|
||||
fail("oc:// path must be a string", String(input), "OC_PATH_NOT_STRING");
|
||||
}
|
||||
|
||||
if (input.length > MAX_PATH_LENGTH) {
|
||||
fail(
|
||||
`oc:// path exceeds ${MAX_PATH_LENGTH} bytes (length: ${input.length})`,
|
||||
input.slice(0, 80) + "…",
|
||||
"OC_PATH_TOO_LONG",
|
||||
);
|
||||
}
|
||||
|
||||
// NFC normalization keeps cross-platform equality (macOS HFS+ NFD vs
|
||||
// Unix/Windows NFC). NFC can grow the string, so re-check the cap.
|
||||
let normalized = input.startsWith(BOM) ? input.slice(BOM.length) : input;
|
||||
normalized = normalized.normalize("NFC");
|
||||
|
||||
if (normalized.length > MAX_PATH_LENGTH) {
|
||||
fail(
|
||||
`oc:// path exceeds ${MAX_PATH_LENGTH} bytes after NFC (length: ${normalized.length})`,
|
||||
input.slice(0, 80) + "…",
|
||||
"OC_PATH_TOO_LONG",
|
||||
);
|
||||
}
|
||||
if (!normalized.startsWith(OC_SCHEME)) {
|
||||
fail(`Missing oc:// scheme: ${printable(input)}`, input, "OC_PATH_MISSING_SCHEME");
|
||||
}
|
||||
if (hasControlChar(normalized)) {
|
||||
fail(`Control character in oc:// path: ${printable(input)}`, input, "OC_PATH_CONTROL_CHAR");
|
||||
}
|
||||
|
||||
const afterScheme = normalized.slice(OC_SCHEME.length);
|
||||
// Top-level split skips quoted keys so `"foo?bar"` isn't broken.
|
||||
const queryIndex = indexOfTopLevel(afterScheme, "?");
|
||||
const pathPart = queryIndex === -1 ? afterScheme : afterScheme.slice(0, queryIndex);
|
||||
const queryPart = queryIndex === -1 ? "" : afterScheme.slice(queryIndex + 1);
|
||||
|
||||
if (pathPart.length === 0) {
|
||||
fail(`Empty oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY");
|
||||
}
|
||||
|
||||
const rawSegments = splitRespectingBrackets(pathPart, "/", input);
|
||||
for (const seg of rawSegments) {
|
||||
if (seg.length === 0) {
|
||||
fail(`Empty segment in oc:// path: ${printable(input)}`, input, "OC_PATH_EMPTY_SEGMENT");
|
||||
}
|
||||
}
|
||||
const fileSeg = rawSegments[0];
|
||||
const file = isQuotedSeg(fileSeg) ? unquoteSeg(fileSeg) : fileSeg;
|
||||
validateFileSlot(file, input);
|
||||
|
||||
const segments = normalizeDeepJsonPathSegments(rawSegments, file, input);
|
||||
if (segments.length > 4) {
|
||||
fail(`Too many segments in oc:// path (max 4): ${printable(input)}`, input, "OC_PATH_TOO_DEEP");
|
||||
}
|
||||
|
||||
for (const seg of segments) {
|
||||
validateBrackets(seg, input);
|
||||
const subs = splitRespectingBrackets(seg, ".", input);
|
||||
if (subs.length > MAX_SUB_SEGMENTS_PER_SLOT) {
|
||||
fail(
|
||||
`Sub-segment count exceeds ${MAX_SUB_SEGMENTS_PER_SLOT} in segment "${seg}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_TOO_DEEP",
|
||||
);
|
||||
}
|
||||
for (const sub of subs) {
|
||||
validateSubSegment(sub, input);
|
||||
}
|
||||
}
|
||||
|
||||
const session = extractSession(queryPart, input);
|
||||
return {
|
||||
file,
|
||||
...(segments[1] !== undefined ? { section: segments[1] } : {}),
|
||||
...(segments[2] !== undefined ? { item: segments[2] } : {}),
|
||||
...(segments[3] !== undefined ? { field: segments[3] } : {}),
|
||||
...(session !== undefined ? { session } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function isJsonPathFile(file: string): boolean {
|
||||
const lower = file.toLowerCase();
|
||||
return lower.endsWith(".json") || lower.endsWith(".jsonc");
|
||||
}
|
||||
|
||||
function normalizeDeepJsonPathSegments(
|
||||
segments: readonly string[],
|
||||
file: string,
|
||||
input: string,
|
||||
): readonly string[] {
|
||||
if (segments.length <= 4 || !isJsonPathFile(file)) {
|
||||
return segments;
|
||||
}
|
||||
const pathSegments = segments.slice(1);
|
||||
if (pathSegments.length > MAX_TRAVERSAL_DEPTH) {
|
||||
fail(
|
||||
`JSON oc:// path exceeds ${MAX_TRAVERSAL_DEPTH} nested segments: ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_TOO_DEEP",
|
||||
);
|
||||
}
|
||||
const section = pathSegments.slice(0, -2).join(".");
|
||||
const item = pathSegments[pathSegments.length - 2];
|
||||
const field = pathSegments[pathSegments.length - 1];
|
||||
return [segments[0], section, item, field];
|
||||
}
|
||||
|
||||
/** Format an `OcPath` struct into its canonical string form. */
|
||||
export function formatOcPath(path: OcPath): string {
|
||||
if (!path.file || path.file.length === 0) {
|
||||
fail("oc:// path requires a file", "", "OC_PATH_FILE_REQUIRED");
|
||||
}
|
||||
validateFileSlot(path.file, path.file);
|
||||
if (path.item !== undefined && path.section === undefined) {
|
||||
fail("Structural nesting violation: item requires section", path.file, "OC_PATH_NESTING");
|
||||
}
|
||||
if (path.field !== undefined && path.item === undefined) {
|
||||
fail("Structural nesting violation: field requires item", path.file, "OC_PATH_NESTING");
|
||||
}
|
||||
|
||||
// Round-trip requires raw sub-segments to be quoted before
|
||||
// concatenation, OR passed through if already in structural form
|
||||
// (quoted, predicate, union, sentinel). Plain concatenation would
|
||||
// silently split a raw `foo/bar` slot into two segments at parse.
|
||||
const formatSubSegment = (sub: string): string => {
|
||||
if (isQuotedSeg(sub)) {
|
||||
return sub;
|
||||
}
|
||||
if (sub.startsWith("[") && sub.endsWith("]")) {
|
||||
return sub;
|
||||
}
|
||||
if (sub.startsWith("{") && sub.endsWith("}")) {
|
||||
return sub;
|
||||
}
|
||||
return quoteSeg(sub);
|
||||
};
|
||||
const validateSubForFormat = (sub: string, slotName: string): void => {
|
||||
if (sub.length === 0) {
|
||||
fail(
|
||||
`Empty dotted sub-segment in OcPath.${slotName}`,
|
||||
path.file,
|
||||
"OC_PATH_EMPTY_SUB_SEGMENT",
|
||||
);
|
||||
}
|
||||
if (hasControlChar(sub)) {
|
||||
fail(
|
||||
`Control character in OcPath.${slotName} sub-segment "${printable(sub)}"`,
|
||||
path.file,
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
}
|
||||
};
|
||||
const formatSlot = (slot: string, slotName: string): string => {
|
||||
const subs = splitRespectingBrackets(slot, ".");
|
||||
for (const sub of subs) {
|
||||
validateSubForFormat(sub, slotName);
|
||||
}
|
||||
return subs.map(formatSubSegment).join(".");
|
||||
};
|
||||
|
||||
// File slot uses lighter quoting than section/item/field: dots are
|
||||
// normal in filenames (`AGENTS.md`); only quote when the file
|
||||
// contains chars that would parse as structure (primarily `/`).
|
||||
const fileNeedsQuote = /[/[\]{}?&%"\s]/.test(path.file);
|
||||
const formattedFile = fileNeedsQuote ? quoteSeg(path.file) : path.file;
|
||||
let out = OC_SCHEME + formattedFile;
|
||||
if (path.section !== undefined) {
|
||||
out += "/" + formatSlot(path.section, "section");
|
||||
}
|
||||
if (path.item !== undefined) {
|
||||
out += "/" + formatSlot(path.item, "item");
|
||||
}
|
||||
if (path.field !== undefined) {
|
||||
out += "/" + formatSlot(path.field, "field");
|
||||
}
|
||||
if (path.session !== undefined) {
|
||||
validateSessionSlot(path.session, path.file);
|
||||
out += "?session=" + path.session;
|
||||
}
|
||||
|
||||
if (out.length > MAX_PATH_LENGTH) {
|
||||
fail(
|
||||
`Formatted oc:// exceeds ${MAX_PATH_LENGTH} bytes (length: ${out.length})`,
|
||||
out.slice(0, 80) + "…",
|
||||
"OC_PATH_TOO_LONG",
|
||||
);
|
||||
}
|
||||
// Path strings flow into telemetry / audit / error messages — refuse
|
||||
// the redaction sentinel here so it can't slip past consumers.
|
||||
if (out.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True iff `input` is a string `parseOcPath` would accept. */
|
||||
export function isValidOcPath(input: unknown): input is string {
|
||||
if (typeof input !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
parseOcPath(input);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Positional tokens: `$first` / `$last` resolve to the first / last
|
||||
* index or declared key. They pick exactly one element, so they don't
|
||||
* trigger wildcard guards.
|
||||
*/
|
||||
export const POS_FIRST = "$first";
|
||||
export const POS_LAST = "$last";
|
||||
|
||||
export function isPositionalSeg(seg: string): boolean {
|
||||
return seg === POS_FIRST || seg === POS_LAST;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordinal addressing — `#N` targets the Nth item by document order.
|
||||
* Earns its keep on slug-addressed kinds (md items can share a slug
|
||||
* via `- foo: a` / `- foo: b`); `#0`/`#1` distinguish them.
|
||||
*/
|
||||
export function isOrdinalSeg(seg: string): boolean {
|
||||
return /^#\d+$/.test(seg);
|
||||
}
|
||||
|
||||
export function parseOrdinalSeg(seg: string): number | null {
|
||||
const m = /^#(\d+)$/.exec(seg);
|
||||
return m === null || m[1] === undefined ? null : Number(m[1]);
|
||||
}
|
||||
|
||||
export function parseArrayIndexSegment(seg: string, length: number): number | null {
|
||||
if (!/^(0|[1-9]\d*)$/.test(seg)) {
|
||||
return null;
|
||||
}
|
||||
const index = Number(seg);
|
||||
return Number.isSafeInteger(index) && index >= 0 && index < length ? index : null;
|
||||
}
|
||||
|
||||
/** Indexable containers provide `size`; keyed containers provide ordered `keys`. */
|
||||
export interface PositionalContainer {
|
||||
readonly indexable: boolean;
|
||||
readonly size: number;
|
||||
readonly keys?: readonly string[];
|
||||
}
|
||||
|
||||
// Resolve `$first` / `$last` against a container; null when empty.
|
||||
export function resolvePositionalSeg(seg: string, container: PositionalContainer): string | null {
|
||||
if (container.size === 0) {
|
||||
return null;
|
||||
}
|
||||
if (seg === POS_FIRST) {
|
||||
if (!container.indexable) {
|
||||
return container.keys?.[0] ?? null;
|
||||
}
|
||||
return "0";
|
||||
}
|
||||
if (seg === POS_LAST) {
|
||||
if (!container.indexable) {
|
||||
return container.keys?.[container.keys.length - 1] ?? null;
|
||||
}
|
||||
return String(container.size - 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wildcard tokens permitted in `findOcPaths` patterns.
|
||||
* `*` matches one sub-segment; `**` matches zero or more (recursive).
|
||||
* Reject in resolve/set via `hasWildcard`.
|
||||
*/
|
||||
export const WILDCARD_SINGLE = "*";
|
||||
export const WILDCARD_RECURSIVE = "**";
|
||||
|
||||
/**
|
||||
* True iff any sub-segment is a multi-match pattern (`*`, `**`,
|
||||
* union `{a,b,c}`, or predicate `[k=v]`). Single-match verbs reject
|
||||
* these; only `findOcPaths` consumes them.
|
||||
*/
|
||||
export function isPattern(path: OcPath): boolean {
|
||||
for (const slot of [path.section, path.item, path.field]) {
|
||||
if (slot === undefined) {
|
||||
continue;
|
||||
}
|
||||
// Quote-aware split — `slot.split('.')` would shred quoted keys
|
||||
// containing literal `*` and falsely flag them as wildcards.
|
||||
for (const sub of splitRespectingBrackets(slot, ".")) {
|
||||
if (sub === WILDCARD_SINGLE || sub === WILDCARD_RECURSIVE) {
|
||||
return true;
|
||||
}
|
||||
if (isUnionSeg(sub)) {
|
||||
return true;
|
||||
}
|
||||
if (isPredicateSeg(sub)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @deprecated v1 — use {@link isPattern}. Behaviorally identical. */
|
||||
export const hasWildcard = isPattern;
|
||||
|
||||
/** Union segment `{a,b,c}` matches each comma-separated alternative. */
|
||||
export function isUnionSeg(seg: string): boolean {
|
||||
return seg.length >= 2 && seg.startsWith("{") && seg.endsWith("}");
|
||||
}
|
||||
|
||||
export function parseUnionSeg(seg: string): readonly string[] | null {
|
||||
if (!isUnionSeg(seg)) {
|
||||
return null;
|
||||
}
|
||||
const inner = seg.slice(1, -1);
|
||||
if (inner.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const alts = inner.split(",");
|
||||
if (alts.some((a) => a.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
return alts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value predicate `[key<op>value]`. Operators: `=` `!=` (string),
|
||||
* `<` `<=` `>` `>=` (numeric). Multi-char tried before single-char.
|
||||
*/
|
||||
export type PredicateOp = "=" | "!=" | "<" | "<=" | ">" | ">=";
|
||||
|
||||
const PREDICATE_OPS: readonly PredicateOp[] = ["!=", "<=", ">=", "<", ">", "="];
|
||||
|
||||
export function isPredicateSeg(seg: string): boolean {
|
||||
if (seg.length < 4 || !seg.startsWith("[") || !seg.endsWith("]")) {
|
||||
return false;
|
||||
}
|
||||
const inner = new Set(seg.slice(1, -1));
|
||||
return PREDICATE_OPS.some((op) => inner.has(op));
|
||||
}
|
||||
|
||||
export interface PredicateSpec {
|
||||
readonly key: string;
|
||||
readonly op: PredicateOp;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export function parsePredicateSeg(seg: string): PredicateSpec | null {
|
||||
if (seg.length < 4 || !seg.startsWith("[") || !seg.endsWith("]")) {
|
||||
return null;
|
||||
}
|
||||
const inner = seg.slice(1, -1);
|
||||
// Leftmost operator wins; at each position, multi-char beats single
|
||||
// (so `[a<=b]` parses as op=`<=`, not op=`<`).
|
||||
for (let i = 1; i < inner.length; i++) {
|
||||
for (const op of PREDICATE_OPS) {
|
||||
if (!inner.startsWith(op, i)) {
|
||||
continue;
|
||||
}
|
||||
if (i + op.length >= inner.length) {
|
||||
continue;
|
||||
} // empty value
|
||||
return { key: inner.slice(0, i), op, value: inner.slice(i + op.length) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric ops require both sides to coerce to finite numbers.
|
||||
export function evaluatePredicate(actual: string | null, pred: PredicateSpec): boolean {
|
||||
if (actual === null) {
|
||||
return false;
|
||||
}
|
||||
switch (pred.op) {
|
||||
case "=":
|
||||
return actual === pred.value;
|
||||
case "!=":
|
||||
return actual !== pred.value;
|
||||
case "<":
|
||||
case "<=":
|
||||
case ">":
|
||||
case ">=": {
|
||||
const a = Number(actual);
|
||||
const b = Number(pred.value);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
||||
return false;
|
||||
}
|
||||
if (pred.op === "<") {
|
||||
return a < b;
|
||||
}
|
||||
if (pred.op === "<=") {
|
||||
return a <= b;
|
||||
}
|
||||
if (pred.op === ">") {
|
||||
return a > b;
|
||||
}
|
||||
return a >= b;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the path into a concrete sub-segment list plus slot offsets,
|
||||
* so a caller can reconstruct an `OcPath` from a concrete walk by
|
||||
* re-packing sub-segments back into their original slots.
|
||||
*/
|
||||
export interface PathSegmentLayout {
|
||||
readonly subs: readonly string[];
|
||||
readonly sectionLen: number;
|
||||
readonly itemLen: number;
|
||||
readonly fieldLen: number;
|
||||
}
|
||||
|
||||
export function getPathLayout(path: OcPath): PathSegmentLayout {
|
||||
// Quote-aware split — `.split('.')` would shred a quoted segment
|
||||
// containing a literal `.` (e.g. `"a.b"`).
|
||||
const sectionSubs = path.section === undefined ? [] : splitRespectingBrackets(path.section, ".");
|
||||
const itemSubs = path.item === undefined ? [] : splitRespectingBrackets(path.item, ".");
|
||||
const fieldSubs = path.field === undefined ? [] : splitRespectingBrackets(path.field, ".");
|
||||
return {
|
||||
subs: [...sectionSubs, ...itemSubs, ...fieldSubs],
|
||||
sectionLen: sectionSubs.length,
|
||||
itemLen: itemSubs.length,
|
||||
fieldLen: fieldSubs.length,
|
||||
};
|
||||
}
|
||||
|
||||
function extractSession(queryPart: string, input: string): string | undefined {
|
||||
if (queryPart.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (const pair of queryPart.split("&")) {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
const key = pair.slice(0, eqIndex);
|
||||
const value = pair.slice(eqIndex + 1);
|
||||
if (key === "session" && value.length > 0) {
|
||||
validateSessionSlot(value, input);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Walk `s` respecting `[...]`/`{...}`/`"..."` regions. Quoted regions
|
||||
// are byte-literal. `onChar` returns "stop" to short-circuit;
|
||||
// `onUnbalanced` (must throw) fires on bracket/brace/quote imbalance.
|
||||
type ScanCallback = (c: string, i: number, atTop: boolean) => "stop" | void;
|
||||
function scanBracketAware(s: string, onChar: ScanCallback, onUnbalanced: () => never): void {
|
||||
let depthBracket = 0;
|
||||
let depthBrace = 0;
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s[i];
|
||||
if (inQuote) {
|
||||
if (c === '"') {
|
||||
inQuote = false;
|
||||
}
|
||||
if (onChar(c, i, false) === "stop") {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inQuote = true;
|
||||
if (onChar(c, i, false) === "stop") {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === "[") {
|
||||
depthBracket++;
|
||||
} else if (c === "]") {
|
||||
depthBracket--;
|
||||
} else if (c === "{") {
|
||||
depthBrace++;
|
||||
} else if (c === "}") {
|
||||
depthBrace--;
|
||||
}
|
||||
if (depthBracket < 0 || depthBrace < 0) {
|
||||
onUnbalanced();
|
||||
}
|
||||
if (onChar(c, i, depthBracket === 0 && depthBrace === 0) === "stop") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (depthBracket !== 0 || depthBrace !== 0 || inQuote) {
|
||||
onUnbalanced();
|
||||
}
|
||||
}
|
||||
|
||||
/** First top-level occurrence of `ch` in `s`; -1 when absent. */
|
||||
export function indexOfTopLevel(s: string, ch: string): number {
|
||||
let result = -1;
|
||||
const failLocal = (): never => {
|
||||
throw new OcPathError(`Unbalanced bracket/brace in oc:// path: ${s}`, s, "OC_PATH_UNBALANCED");
|
||||
};
|
||||
scanBracketAware(
|
||||
s,
|
||||
(c, i, atTop) => {
|
||||
if (atTop && c === ch) {
|
||||
result = i;
|
||||
return "stop";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
failLocal,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function splitRespectingBrackets(
|
||||
s: string,
|
||||
delim: string,
|
||||
originalInput?: string,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
let buf = "";
|
||||
const ctx = originalInput ?? s;
|
||||
const onUnbalanced = (): never => {
|
||||
fail(`Unbalanced bracket/brace in oc:// path: ${ctx}`, ctx, "OC_PATH_UNBALANCED");
|
||||
};
|
||||
scanBracketAware(
|
||||
s,
|
||||
(c, _i, atTop) => {
|
||||
if (atTop && c === delim) {
|
||||
out.push(buf);
|
||||
buf = "";
|
||||
return;
|
||||
}
|
||||
buf += c;
|
||||
},
|
||||
onUnbalanced,
|
||||
);
|
||||
out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True iff `seg` is `"..."`. */
|
||||
export function isQuotedSeg(seg: string): boolean {
|
||||
return seg.length >= 2 && seg.startsWith('"') && seg.endsWith('"');
|
||||
}
|
||||
|
||||
/** Strip surrounding quotes. Content is byte-literal. */
|
||||
export function unquoteSeg(seg: string): string {
|
||||
return isQuotedSeg(seg) ? seg.slice(1, -1) : seg;
|
||||
}
|
||||
|
||||
// Refuses values with `"` or `\` — no escape mechanism.
|
||||
export function quoteSeg(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return '""';
|
||||
}
|
||||
if (value.includes('"') || value.includes("\\")) {
|
||||
fail(
|
||||
`Cannot quote value containing '"' or '\\\\': ${printable(value)}`,
|
||||
value,
|
||||
"OC_PATH_UNQUOTABLE",
|
||||
);
|
||||
}
|
||||
return /[/.[\]{}?&%\s]/.test(value) ? `"${value}"` : value;
|
||||
}
|
||||
|
||||
// Defense-in-depth — the splitter validates segments it splits; this
|
||||
// catches stray unmatched brackets in unsplit ones.
|
||||
function validateBrackets(seg: string, input: string): void {
|
||||
scanBracketAware(
|
||||
seg,
|
||||
() => undefined,
|
||||
() => {
|
||||
fail(
|
||||
`Unbalanced bracket/brace in segment "${seg}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_UNBALANCED",
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validateSubSegment(sub: string, input: string): void {
|
||||
if (sub.length === 0) {
|
||||
fail(
|
||||
`Empty dotted sub-segment in oc:// path: ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_EMPTY_SUB_SEGMENT",
|
||||
);
|
||||
}
|
||||
if (hasControlChar(sub)) {
|
||||
fail(
|
||||
`Control character in oc:// segment "${printable(sub)}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
}
|
||||
// Quoted content is byte-literal but can't contain `"` or `\`.
|
||||
if (isQuotedSeg(sub)) {
|
||||
const inner = new Set(sub.slice(1, -1));
|
||||
if (inner.has('"') || inner.has("\\")) {
|
||||
fail(
|
||||
`Quoted segment cannot contain '"' or '\\\\': ${printable(sub)}`,
|
||||
input,
|
||||
"OC_PATH_UNQUOTABLE",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Reserved characters used by the path grammar itself (`?`/`&`/`%`).
|
||||
// Allowed inside predicate / union segments — those are content.
|
||||
if (!sub.startsWith("[") && !sub.startsWith("{")) {
|
||||
if (RESERVED_CHARS_RE.test(sub)) {
|
||||
fail(
|
||||
`Reserved character (\`?\` / \`&\` / \`%\`) in oc:// segment "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_RESERVED_CHAR",
|
||||
);
|
||||
}
|
||||
if (sub !== sub.trim() || /\s/.test(sub)) {
|
||||
fail(
|
||||
`Whitespace in oc:// segment "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_WHITESPACE",
|
||||
);
|
||||
}
|
||||
}
|
||||
// `[...]` is either a predicate `[k<op>v]` or a literal sentinel
|
||||
// (e.g. `[frontmatter]`). Mismatched brackets are rejected.
|
||||
const startsBracket = sub.startsWith("[");
|
||||
const endsBracket = sub.endsWith("]");
|
||||
if (startsBracket !== endsBracket) {
|
||||
fail(
|
||||
`Mismatched bracket in segment "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_PREDICATE",
|
||||
);
|
||||
}
|
||||
if (startsBracket && endsBracket) {
|
||||
const inner = sub.slice(1, -1);
|
||||
if (inner.length === 0) {
|
||||
fail(
|
||||
`Empty bracket segment "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_PREDICATE",
|
||||
);
|
||||
}
|
||||
const hasOp = ["!=", "<=", ">=", "<", ">", "="].some((op) => inner.includes(op));
|
||||
if (hasOp) {
|
||||
const parsed = parsePredicateSeg(sub);
|
||||
if (parsed === null || parsed.key.length === 0 || parsed.value.length === 0) {
|
||||
fail(
|
||||
`Malformed predicate "${sub}" — must be \`[key<op>value]\` with non-empty key and value: ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_PREDICATE",
|
||||
);
|
||||
}
|
||||
}
|
||||
// Op-less brackets are literal sentinel segments (back-compat).
|
||||
}
|
||||
const startsBrace = sub.startsWith("{");
|
||||
const endsBrace = sub.endsWith("}");
|
||||
if (startsBrace !== endsBrace) {
|
||||
fail(
|
||||
`Mismatched brace in segment "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_UNION",
|
||||
);
|
||||
}
|
||||
if (startsBrace && endsBrace) {
|
||||
const inner = sub.slice(1, -1);
|
||||
if (inner.length === 0) {
|
||||
fail(
|
||||
`Empty union "${sub}" — must contain at least one alternative: ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_UNION",
|
||||
);
|
||||
}
|
||||
if (inner.split(",").some((a) => a.length === 0)) {
|
||||
fail(
|
||||
`Empty alternative in union "${sub}": ${printable(input)}`,
|
||||
input,
|
||||
"OC_PATH_MALFORMED_UNION",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
177
extensions/oc-path/src/oc-path/parse.ts
Normal file
177
extensions/oc-path/src/oc-path/parse.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Markdown parser for workspace files: frontmatter + preamble + H2
|
||||
* blocks (with bullet items as the only addressable structural child).
|
||||
* Tokenization via markdown-it; frontmatter handled here.
|
||||
*
|
||||
* Grammar opinions (indented `##`, empty `## `, ordered lists, nested
|
||||
* sub-bullets) live in lint rules, not the parser.
|
||||
*
|
||||
* Byte-fidelity: `emitMd(parse(raw)) === raw`.
|
||||
*
|
||||
* @module @openclaw/oc-path/parse
|
||||
*/
|
||||
|
||||
import MarkdownIt from "markdown-it";
|
||||
import type { AstBlock, AstItem, Diagnostic, FrontmatterEntry, ParseResult } from "./ast.js";
|
||||
import { slugify } from "./slug.js";
|
||||
|
||||
type Token = ReturnType<MarkdownIt["parse"]>[number];
|
||||
|
||||
const FENCE = "---";
|
||||
const BOM = "";
|
||||
const KV_RE = /^([^:]+?)\s*:\s*(.+)$/;
|
||||
|
||||
const md = new MarkdownIt({ html: true });
|
||||
|
||||
export function parseMd(raw: string): ParseResult {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const withoutBom = raw.startsWith(BOM) ? raw.slice(BOM.length) : raw;
|
||||
const lines = withoutBom.split(/\r?\n/);
|
||||
|
||||
const fm = detectFrontmatter(lines, diagnostics);
|
||||
const bodyStartIdx = fm === null ? 0 : fm.endLine + 1;
|
||||
const bodyLines = lines.slice(bodyStartIdx);
|
||||
const bodyFileLine = bodyStartIdx + 1;
|
||||
|
||||
const tokens = md.parse(bodyLines.join("\n"), {});
|
||||
const { preamble, blocks } = walkBlocks(tokens, bodyLines, bodyFileLine);
|
||||
|
||||
return {
|
||||
ast: { kind: "md", raw, frontmatter: fm?.entries ?? [], preamble, blocks },
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- Frontmatter ---------------------------------------------------
|
||||
|
||||
interface FrontmatterRange {
|
||||
readonly entries: readonly FrontmatterEntry[];
|
||||
/** 0-based line index of the closing `---`. */
|
||||
readonly endLine: number;
|
||||
}
|
||||
|
||||
function detectFrontmatter(
|
||||
lines: readonly string[],
|
||||
diagnostics: Diagnostic[],
|
||||
): FrontmatterRange | null {
|
||||
if (lines.length < 2 || lines[0] !== FENCE) {
|
||||
return null;
|
||||
}
|
||||
let closeIndex = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i] === FENCE) {
|
||||
closeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeIndex === -1) {
|
||||
diagnostics.push({
|
||||
line: 1,
|
||||
message: "frontmatter opens with --- but never closes",
|
||||
severity: "warning",
|
||||
code: "OC_FRONTMATTER_UNCLOSED",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const entries: FrontmatterEntry[] = [];
|
||||
for (let i = 1; i < closeIndex; i++) {
|
||||
const m = /^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:\s*(.*)$/.exec(lines[i]);
|
||||
if (m !== null) {
|
||||
entries.push({ key: m[1], value: unquote(m[2].trim()), line: i + 1 });
|
||||
}
|
||||
}
|
||||
return { entries, endLine: closeIndex };
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2) {
|
||||
const f = value.charCodeAt(0);
|
||||
const l = value.charCodeAt(value.length - 1);
|
||||
if (f === l && (f === 34 || f === 39)) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ---------- H2 block walker -----------------------------------------------
|
||||
|
||||
function walkBlocks(
|
||||
tokens: readonly Token[],
|
||||
bodyLines: readonly string[],
|
||||
bodyFileLine: number,
|
||||
): { preamble: string; blocks: AstBlock[] } {
|
||||
// Match atx `##` only; setext h2 has `markup: "-"`.
|
||||
const h2: { tokenIdx: number; lineIdx: number; text: string }[] = [];
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t.type === "heading_open" && t.tag === "h2" && t.markup === "##" && t.map !== null) {
|
||||
const inline = tokens[i + 1];
|
||||
h2.push({ tokenIdx: i, lineIdx: t.map[0], text: inline?.content ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (h2.length === 0) {
|
||||
return { preamble: bodyLines.join("\n"), blocks: [] };
|
||||
}
|
||||
|
||||
const preamble = bodyLines.slice(0, h2[0].lineIdx).join("\n");
|
||||
const blocks: AstBlock[] = [];
|
||||
|
||||
for (let h = 0; h < h2.length; h++) {
|
||||
const start = h2[h].lineIdx;
|
||||
const end = h + 1 < h2.length ? h2[h + 1].lineIdx : bodyLines.length;
|
||||
// Slice by INDEX so unmapped descendants (cells, markers, inline)
|
||||
// ride along with their parent. h2 = open + inline + close = 3.
|
||||
const tokenStart = h2[h].tokenIdx + 3;
|
||||
const tokenEnd = h + 1 < h2.length ? h2[h + 1].tokenIdx : tokens.length;
|
||||
const blockTokens = tokens.slice(tokenStart, tokenEnd);
|
||||
blocks.push({
|
||||
heading: h2[h].text,
|
||||
slug: slugify(h2[h].text),
|
||||
line: bodyFileLine + start,
|
||||
bodyText: bodyLines.slice(start + 1, end).join("\n"),
|
||||
items: extractItems(blockTokens, bodyFileLine),
|
||||
});
|
||||
}
|
||||
|
||||
return { preamble, blocks };
|
||||
}
|
||||
|
||||
// ---------- Item extraction ----------------------------------------------
|
||||
|
||||
// Every list_item_open becomes an item (bullets, numbered, nested
|
||||
// sub-bullets); lint rules flag depth / duplicate-slug collisions.
|
||||
function extractItems(tokens: readonly Token[], bodyFileLine: number): AstItem[] {
|
||||
const items: AstItem[] = [];
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const t = tokens[i];
|
||||
if (t.type !== "list_item_open" || t.map === null) {
|
||||
continue;
|
||||
}
|
||||
// First inline at the item's own depth is the item text.
|
||||
let nestedDepth = 0;
|
||||
let text = "";
|
||||
for (let j = i + 1; j < tokens.length; j++) {
|
||||
const x = tokens[j];
|
||||
if (x.type === "list_item_close" && nestedDepth === 0) {
|
||||
break;
|
||||
}
|
||||
if (x.type === "bullet_list_open" || x.type === "ordered_list_open") {
|
||||
nestedDepth++;
|
||||
} else if (x.type === "bullet_list_close" || x.type === "ordered_list_close") {
|
||||
nestedDepth--;
|
||||
} else if (x.type === "inline" && nestedDepth === 0 && text === "") {
|
||||
text = x.content;
|
||||
}
|
||||
}
|
||||
const kvMatch = KV_RE.exec(text);
|
||||
items.push({
|
||||
text,
|
||||
slug: kvMatch ? slugify(kvMatch[1]) : slugify(text),
|
||||
line: bodyFileLine + t.map[0],
|
||||
...(kvMatch !== null ? { kv: { key: kvMatch[1].trim(), value: kvMatch[2].trim() } } : {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
95
extensions/oc-path/src/oc-path/resolve.ts
Normal file
95
extensions/oc-path/src/oc-path/resolve.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* OcPath → MdAst node. Walks an in-memory AST; the file slot is
|
||||
* informational (callers verify file matching upstream).
|
||||
*
|
||||
* { file } → root
|
||||
* { file, section } → block
|
||||
* { file, section, item } → item
|
||||
* { file, section, item, field } → kv.value
|
||||
*
|
||||
* @module @openclaw/oc-path/resolve
|
||||
*/
|
||||
|
||||
import type { AstBlock, AstItem, FrontmatterEntry, MdAst } from "./ast.js";
|
||||
import type { OcPath } from "./oc-path.js";
|
||||
import { isOrdinalSeg, isPositionalSeg, parseOrdinalSeg, resolvePositionalSeg } from "./oc-path.js";
|
||||
|
||||
export type OcPathMatch =
|
||||
| { readonly kind: "root"; readonly node: MdAst }
|
||||
| { readonly kind: "frontmatter"; readonly node: FrontmatterEntry }
|
||||
| { readonly kind: "block"; readonly node: AstBlock }
|
||||
| { readonly kind: "item"; readonly node: AstItem; readonly block: AstBlock }
|
||||
| {
|
||||
readonly kind: "item-field";
|
||||
readonly node: AstItem;
|
||||
readonly block: AstBlock;
|
||||
/** The kv.value string, surfaced for convenience. */
|
||||
readonly value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve. Slugs match case-insensitively. `[frontmatter]` is a
|
||||
* literal section sentinel; the frontmatter key sits at `item` (or
|
||||
* `field` for 4-segment callers).
|
||||
*/
|
||||
export function resolveMdOcPath(ast: MdAst, path: OcPath): OcPathMatch | null {
|
||||
if (path.section === "[frontmatter]") {
|
||||
const key = path.item ?? path.field;
|
||||
if (key === undefined) {
|
||||
return null;
|
||||
}
|
||||
const entry = ast.frontmatter.find((e) => e.key === key);
|
||||
if (entry === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "frontmatter", node: entry };
|
||||
}
|
||||
|
||||
if (path.section === undefined) {
|
||||
return { kind: "root", node: ast };
|
||||
}
|
||||
|
||||
const block = ast.blocks.find((b) => b.slug === path.section!.toLowerCase());
|
||||
if (block === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (path.item === undefined) {
|
||||
return { kind: "block", node: block };
|
||||
}
|
||||
|
||||
// Item dispatch: ordinal (#N) > positional ($last) > slug.
|
||||
// Ordinal uses document order so duplicate-slug items stay distinct.
|
||||
let item: AstItem | undefined;
|
||||
if (isOrdinalSeg(path.item)) {
|
||||
const n = parseOrdinalSeg(path.item);
|
||||
if (n === null || n < 0 || n >= block.items.length) {
|
||||
return null;
|
||||
}
|
||||
item = block.items[n];
|
||||
} else if (isPositionalSeg(path.item)) {
|
||||
const concrete = resolvePositionalSeg(path.item, {
|
||||
indexable: true,
|
||||
size: block.items.length,
|
||||
});
|
||||
if (concrete === null) {
|
||||
return null;
|
||||
}
|
||||
item = block.items[Number(concrete)];
|
||||
} else {
|
||||
item = block.items.find((i) => i.slug === path.item!.toLowerCase());
|
||||
}
|
||||
if (item === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (path.field === undefined) {
|
||||
return { kind: "item", node: item, block };
|
||||
}
|
||||
|
||||
if (item.kv === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (item.kv.key.toLowerCase() !== path.field.toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
return { kind: "item-field", node: item, block, value: item.kv.value };
|
||||
}
|
||||
33
extensions/oc-path/src/oc-path/sentinel.ts
Normal file
33
extensions/oc-path/src/oc-path/sentinel.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Redaction-sentinel guard. Throws at emit boundaries so every write
|
||||
* path is covered, not just audited consumers.
|
||||
*
|
||||
* @module @openclaw/oc-path/sentinel
|
||||
*/
|
||||
|
||||
/** Literal marking a redacted secret. Writing it to disk is always a bug. */
|
||||
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
|
||||
|
||||
/**
|
||||
* Thrown when emit detects the sentinel in output bytes. Fail-closed:
|
||||
* stripping would silently corrupt the file. `path` is the closest
|
||||
* OcPath-shaped pointer to the violation.
|
||||
*/
|
||||
export class OcEmitSentinelError extends Error {
|
||||
readonly code = "OC_EMIT_SENTINEL";
|
||||
readonly path: string;
|
||||
|
||||
constructor(path: string) {
|
||||
super(`emit refused to write "${REDACTED_SENTINEL}" sentinel literal at ${path}`);
|
||||
this.name = "OcEmitSentinelError";
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
// Substring match (not equality) — `prefix__OPENCLAW_REDACTED__suffix`
|
||||
// still leaks the marker. No-op on non-string input.
|
||||
export function guardSentinel(value: unknown, ocPath: string): void {
|
||||
if (typeof value === "string" && value.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(ocPath);
|
||||
}
|
||||
}
|
||||
20
extensions/oc-path/src/oc-path/slug.ts
Normal file
20
extensions/oc-path/src/oc-path/slug.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Slug derivation: kebab-case lowercase, deterministic, idempotent.
|
||||
* Used by parse + resolve for section/item addressing.
|
||||
*
|
||||
* @module @openclaw/oc-path/slug
|
||||
*/
|
||||
|
||||
const NON_SLUG_CHARS = /[^a-z0-9-]+/g;
|
||||
const COLLAPSE_HYPHENS = /-+/g;
|
||||
const TRIM_HYPHENS = /^-+|-+$/g;
|
||||
|
||||
/** Empty string for input with no slug-valid chars; callers treat as not matchable. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-")
|
||||
.replace(NON_SLUG_CHARS, "-")
|
||||
.replace(COLLAPSE_HYPHENS, "-")
|
||||
.replace(TRIM_HYPHENS, "");
|
||||
}
|
||||
108
extensions/oc-path/src/oc-path/tests/edit.test.ts
Normal file
108
extensions/oc-path/src/oc-path/tests/edit.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// OC Path tests cover edit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setMdOcPath as setOcPath } from "../edit.js";
|
||||
import { parseOcPath } from "../oc-path.js";
|
||||
import { parseMd } from "../parse.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../sentinel.js";
|
||||
|
||||
describe("setOcPath — frontmatter", () => {
|
||||
it("replaces a frontmatter value", () => {
|
||||
const raw = `---
|
||||
name: github
|
||||
description: old desc
|
||||
---
|
||||
|
||||
Body.
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/[frontmatter]/description"), "new desc");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("description: new desc");
|
||||
expect(r.ast.raw).not.toContain("old desc");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved when the key is missing", () => {
|
||||
const { ast } = parseMd("---\nname: x\n---\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/[frontmatter]/nope"), "x");
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
|
||||
it("quotes frontmatter values containing structural chars", () => {
|
||||
const { ast } = parseMd("---\nx: a\n---\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/[frontmatter]/x"), "has: colon");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain('x: "has: colon"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — item kv field", () => {
|
||||
it("replaces an item kv value and reflects it in the rebuilt body", () => {
|
||||
const raw = `## Boundaries
|
||||
|
||||
- enabled: true
|
||||
- timeout: 5
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/boundaries/timeout/timeout"), "30");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("- timeout: 30");
|
||||
expect(r.ast.raw).toContain("- enabled: true");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports no-item-kv for an item without kv shape", () => {
|
||||
const raw = `## Boundaries
|
||||
|
||||
- plain bullet
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://AGENTS.md/boundaries/plain-bullet/plain-bullet"),
|
||||
"x",
|
||||
);
|
||||
expect(r).toEqual({ ok: false, reason: "no-item-kv" });
|
||||
});
|
||||
|
||||
it("reports unresolved when section/item is missing", () => {
|
||||
const { ast } = parseMd("## Other\n\n- foo: bar\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/missing/foo/foo"), "x");
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
|
||||
it("reports not-writable for section-only addresses", () => {
|
||||
const { ast } = parseMd("## Boundaries\n\n- enabled: true\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://AGENTS.md/boundaries"), "x");
|
||||
expect(r).toEqual({ ok: false, reason: "not-writable" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — sentinel guard (defense-in-depth)", () => {
|
||||
// The JSONC + JSONL paths reject sentinel-bearing values at the
|
||||
// substrate boundary; the md path was deferring entirely to round-trip
|
||||
// echo through emitMd, which acceptPreExistingSentinel:true skips.
|
||||
// Closing the gap keeps F9 (formatter sentinel guard) symmetric across
|
||||
// all three kinds.
|
||||
it("rejects bare sentinel on frontmatter value", () => {
|
||||
const { ast } = parseMd("---\nname: x\n---\n");
|
||||
expect(() =>
|
||||
setOcPath(ast, parseOcPath("oc://AGENTS.md/[frontmatter]/name"), REDACTED_SENTINEL),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("rejects substring-embedded sentinel on item kv", () => {
|
||||
const { ast } = parseMd("## Boundaries\n\n- enabled: true\n");
|
||||
expect(() =>
|
||||
setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://AGENTS.md/boundaries/enabled/enabled"),
|
||||
`prefix${REDACTED_SENTINEL}suffix`,
|
||||
),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
107
extensions/oc-path/src/oc-path/tests/emit.test.ts
Normal file
107
extensions/oc-path/src/oc-path/tests/emit.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// OC Path tests cover emit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../emit.js";
|
||||
import { parseMd } from "../parse.js";
|
||||
import { OcEmitSentinelError } from "../sentinel.js";
|
||||
|
||||
describe("emit — round-trip mode (default)", () => {
|
||||
it("returns the raw bytes byte-for-byte", () => {
|
||||
const raw = `---\nname: x\n---\n\n## Sec\n\n- a\n- b\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trips CRLF line endings", () => {
|
||||
const raw = "## Heading\r\n\r\n- item\r\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trips a file with no frontmatter and no sections", () => {
|
||||
const raw = "Just preamble. No structure.\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("echoes raw bytes containing the sentinel by default; strict mode rejects", () => {
|
||||
// Round-trip trusts parsed bytes — see emit.ts policy comment.
|
||||
// Strict mode (acceptPreExistingSentinel: false) is the opt-in
|
||||
// path for callers that want LKG-style fingerprint verification.
|
||||
const raw = "## Section\n\n- token: __OPENCLAW_REDACTED__\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emit — render mode", () => {
|
||||
it("renders frontmatter + blocks", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [
|
||||
{ key: "name", value: "github", line: 2 },
|
||||
{ key: "description", value: "gh CLI", line: 3 },
|
||||
],
|
||||
preamble: "",
|
||||
blocks: [
|
||||
{
|
||||
heading: "Tools",
|
||||
slug: "tools",
|
||||
line: 5,
|
||||
bodyText: "- gh: GitHub",
|
||||
items: [{ text: "gh: GitHub", slug: "gh", line: 7, kv: { key: "gh", value: "GitHub" } }],
|
||||
tables: [],
|
||||
codeBlocks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const output = emitMd(ast, { mode: "render" });
|
||||
expect(output).toContain("name: github");
|
||||
expect(output).toContain("description: gh CLI");
|
||||
expect(output).toContain("## Tools");
|
||||
expect(output).toContain("- gh: GitHub");
|
||||
});
|
||||
|
||||
it("quotes frontmatter values containing special chars", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [{ key: "title", value: "a: b", line: 2 }],
|
||||
preamble: "",
|
||||
blocks: [],
|
||||
};
|
||||
const output = emitMd(ast, { mode: "render" });
|
||||
expect(output).toContain('title: "a: b"');
|
||||
});
|
||||
|
||||
it("throws if a kv item value matches the sentinel", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [],
|
||||
preamble: "",
|
||||
blocks: [
|
||||
{
|
||||
heading: "Secrets",
|
||||
slug: "secrets",
|
||||
line: 1,
|
||||
bodyText: "- token: __OPENCLAW_REDACTED__",
|
||||
items: [
|
||||
{
|
||||
text: "token: __OPENCLAW_REDACTED__",
|
||||
slug: "token",
|
||||
line: 2,
|
||||
kv: { key: "token", value: "__OPENCLAW_REDACTED__" },
|
||||
},
|
||||
],
|
||||
tables: [],
|
||||
codeBlocks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitMd(ast, { mode: "render", fileNameForGuard: "AGENTS.md" })).toThrow(
|
||||
OcEmitSentinelError,
|
||||
);
|
||||
});
|
||||
});
|
||||
594
extensions/oc-path/src/oc-path/tests/find.test.ts
Normal file
594
extensions/oc-path/src/oc-path/tests/find.test.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
// OC Path tests cover find plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findOcPaths } from "../find.js";
|
||||
import { parseJsonc } from "../jsonc/parse.js";
|
||||
import { parseJsonl } from "../jsonl/parse.js";
|
||||
import { formatOcPath, hasWildcard, OcPathError, parseOcPath } from "../oc-path.js";
|
||||
import { parseMd } from "../parse.js";
|
||||
import { resolveOcPath, setOcPath } from "../universal.js";
|
||||
|
||||
describe("hasWildcard", () => {
|
||||
it("detects single-segment * in any slot", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/*/y"))).toBe(true);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a/*"))).toBe(true);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a/b/*"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects ** in any slot", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/**"))).toBe(true);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a/**/c"))).toBe(true);
|
||||
});
|
||||
|
||||
it("detects wildcards inside dotted sub-segments", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/a.*.c"))).toBe(true);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a.**.c"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for plain paths", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/a/b/c"))).toBe(false);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a.b.c"))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats `*` inside an identifier as literal", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/foo*bar"))).toBe(false);
|
||||
expect(hasWildcard(parseOcPath("oc://X/a*"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wildcard guard", () => {
|
||||
const ast = parseJsonc('{"steps":[{"id":"a","command":"foo"}]}').ast;
|
||||
|
||||
it("resolveOcPath throws OcPathError for wildcard pattern", () => {
|
||||
expect(() => resolveOcPath(ast, parseOcPath("oc://wf/steps/*/command"))).toThrow(/findOcPaths/);
|
||||
try {
|
||||
resolveOcPath(ast, parseOcPath("oc://wf/**"));
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcPathError);
|
||||
expect((err as OcPathError).code).toBe("OC_PATH_WILDCARD_IN_RESOLVE");
|
||||
}
|
||||
});
|
||||
|
||||
it("setOcPath returns wildcard-not-allowed for wildcard pattern", () => {
|
||||
const r = setOcPath(ast, parseOcPath("oc://wf/steps/*/command"), "bar");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("wildcard-not-allowed");
|
||||
}
|
||||
});
|
||||
|
||||
it("setOcPath wildcard guard reason carries actionable detail", () => {
|
||||
const r = setOcPath(ast, parseOcPath("oc://wf/**"), "bar");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.detail).toContain("findOcPaths");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — non-wildcard fast-path", () => {
|
||||
it("wraps resolveOcPath result for plain path", () => {
|
||||
const ast = parseJsonc('{"name":"x"}').ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://wf/name"));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].match.kind).toBe("leaf");
|
||||
expect(formatOcPath(out[0].path)).toBe("oc://wf/name");
|
||||
});
|
||||
|
||||
it("returns empty for unresolved plain path", () => {
|
||||
const ast = parseJsonc('{"name":"x"}').ast;
|
||||
expect(findOcPaths(ast, parseOcPath("oc://wf/missing"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — JSONC kind", () => {
|
||||
const jsonc = parseJsonc(
|
||||
"{\n" +
|
||||
' "plugins": {\n' +
|
||||
' "github": {"enabled": true},\n' +
|
||||
' "gitlab": {"enabled": false},\n' +
|
||||
' "slack": {"enabled": true}\n' +
|
||||
" }\n" +
|
||||
"}\n",
|
||||
).ast;
|
||||
|
||||
it("* in item slot enumerates each plugin", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled"));
|
||||
expect(out).toHaveLength(3);
|
||||
const keys = out.map((m) => m.path.item);
|
||||
expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual([
|
||||
"github",
|
||||
"gitlab",
|
||||
"slack",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns boolean leaves with leafType", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/*/enabled"));
|
||||
for (const m of out) {
|
||||
expect(m.match.kind).toBe("leaf");
|
||||
if (m.match.kind === "leaf") {
|
||||
expect(m.match.leafType).toBe("boolean");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — slash-deep JSONC paths", () => {
|
||||
const jsonc = parseJsonc(
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
github: { env: { GITHUB_TOKEN: "gh-token" } },
|
||||
gitlab: { env: { GITHUB_TOKEN: "gl-token" } },
|
||||
},
|
||||
},
|
||||
agents: [
|
||||
{ id: "coder", tools: { exec: { security: "deny" } } },
|
||||
{ id: "reviewer", tools: { exec: { security: "allowlist" } } },
|
||||
],
|
||||
}),
|
||||
).ast;
|
||||
|
||||
it("expands * in a slash-deep JSON object path", () => {
|
||||
const out = findOcPaths(
|
||||
jsonc,
|
||||
parseOcPath("oc://openclaw.json/mcp/servers/*/env/GITHUB_TOKEN"),
|
||||
);
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
|
||||
});
|
||||
|
||||
it("expands * in a slash-deep JSON array path", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["allowlist", "deny"]);
|
||||
});
|
||||
|
||||
it("expands predicates in slash-deep JSON array paths", () => {
|
||||
const out = findOcPaths(
|
||||
jsonc,
|
||||
parseOcPath("oc://openclaw.json/agents/[id=reviewer]/tools/exec/security"),
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]?.match.kind === "leaf" && out[0].match.valueText).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("expands ** in slash-deep JSON paths", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/mcp/**/GITHUB_TOKEN"));
|
||||
expect(out).toHaveLength(2);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["gh-token", "gl-token"]);
|
||||
});
|
||||
|
||||
it("returns slash-deep JSON matches as concrete paths that resolve", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://openclaw.json/agents/*/tools/exec/security"));
|
||||
for (const m of out) {
|
||||
expect(resolveOcPath(jsonc, m.path)?.kind).toBe("leaf");
|
||||
expect(formatOcPath(m.path)).not.toContain("*");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — JSONL kind", () => {
|
||||
const jsonl = parseJsonl(
|
||||
'{"event":"start","userId":"u1"}\n' +
|
||||
'{"event":"action","userId":"u1"}\n' +
|
||||
'{"event":"end","userId":"u1"}\n',
|
||||
).ast;
|
||||
|
||||
it("* in section slot enumerates each value line", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event"));
|
||||
expect(out).toHaveLength(3);
|
||||
const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(events).toEqual(["start", "action", "end"]);
|
||||
});
|
||||
|
||||
it("preserves Lnnn line addresses in concrete paths", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/*/event"));
|
||||
for (const m of out) {
|
||||
expect(m.path.section).toMatch(/^L\d+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("union {L1,L2} at line slot enumerates each alternative", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,L3}/event"));
|
||||
expect(out).toHaveLength(2);
|
||||
const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(events).toEqual(["start", "end"]);
|
||||
});
|
||||
|
||||
it("union of positional + literal line addresses works", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/{L1,$last}/event"));
|
||||
expect(out).toHaveLength(2);
|
||||
const events = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(events).toEqual(["start", "end"]);
|
||||
});
|
||||
|
||||
it("predicate [event=action] at line slot filters by top-level field", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=action]/userId"));
|
||||
expect(out).toHaveLength(1);
|
||||
if (out[0]?.match.kind === "leaf") {
|
||||
expect(out[0].match.valueText).toBe("u1");
|
||||
}
|
||||
});
|
||||
|
||||
it("predicate [event=missing] at line slot matches zero lines (silent zero is correct)", () => {
|
||||
const out = findOcPaths(jsonl, parseOcPath("oc://session/[event=missing]/userId"));
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("positional primitives — $first / $last", () => {
|
||||
it("$first picks first array element", () => {
|
||||
const jsonc = parseJsonc('{"items":[10,20,30]}').ast;
|
||||
const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$first"));
|
||||
expect(m?.kind === "leaf" && m.valueText).toBe("10");
|
||||
});
|
||||
|
||||
it("$last picks last array element", () => {
|
||||
const jsonc = parseJsonc('{"items":[10,20,30]}').ast;
|
||||
const m = resolveOcPath(jsonc, parseOcPath("oc://config/items/$last"));
|
||||
expect(m?.kind === "leaf" && m.valueText).toBe("30");
|
||||
});
|
||||
|
||||
it("$first picks first value line on jsonl", () => {
|
||||
const jsonl = parseJsonl('{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n').ast;
|
||||
const m = resolveOcPath(jsonl, parseOcPath("oc://session/$first/event"));
|
||||
expect(m?.kind === "leaf" && m.valueText).toBe("start");
|
||||
});
|
||||
|
||||
it("$last picks last value line on jsonl", () => {
|
||||
const jsonl = parseJsonl('{"event":"start"}\n{"event":"step"}\n{"event":"end"}\n').ast;
|
||||
const m = resolveOcPath(jsonl, parseOcPath("oc://session/$last/event"));
|
||||
expect(m?.kind === "leaf" && m.valueText).toBe("end");
|
||||
});
|
||||
|
||||
it("hasWildcard returns false for positional tokens", () => {
|
||||
expect(hasWildcard(parseOcPath("oc://X/$first/id"))).toBe(false);
|
||||
expect(hasWildcard(parseOcPath("oc://X/$last/id"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted segments (v1.0)", () => {
|
||||
const jsonc = parseJsonc(
|
||||
'{"agents":{"defaults":{"models":{' +
|
||||
'"anthropic/claude-opus-4-7":{"alias":"opus47","contextWindow":1000000},' +
|
||||
'"github-copilot/claude-opus-4.7-1m-internal":{"alias":"copilot-opus-1m","contextWindow":1000000},' +
|
||||
'"plain":{"alias":"p","contextWindow":200000}' +
|
||||
"}}}}",
|
||||
).ast;
|
||||
|
||||
it("resolveOcPath — quoted segment with literal slash", () => {
|
||||
const m = resolveOcPath(
|
||||
jsonc,
|
||||
parseOcPath('oc://config/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'),
|
||||
);
|
||||
expect(m?.kind).toBe("leaf");
|
||||
if (m?.kind === "leaf") {
|
||||
expect(m.valueText).toBe("opus47");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolveOcPath — quoted segment with literal slash AND dot", () => {
|
||||
const m = resolveOcPath(
|
||||
jsonc,
|
||||
parseOcPath(
|
||||
'oc://config/agents.defaults.models/"github-copilot/claude-opus-4.7-1m-internal"/alias',
|
||||
),
|
||||
);
|
||||
expect(m?.kind).toBe("leaf");
|
||||
if (m?.kind === "leaf") {
|
||||
expect(m.valueText).toBe("copilot-opus-1m");
|
||||
}
|
||||
});
|
||||
|
||||
it("quoted segment with whitespace", () => {
|
||||
const ast = parseJsonc('{"prompts":{"hello world":"value"}}').ast;
|
||||
const m = resolveOcPath(ast, parseOcPath('oc://X/prompts/"hello world"'));
|
||||
expect(m?.kind).toBe("leaf");
|
||||
if (m?.kind === "leaf") {
|
||||
expect(m.valueText).toBe("value");
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects quoted segments containing `"` or `\\` (no escape support)', () => {
|
||||
expect(() => parseOcPath('oc://X/keys/"a\\\\b"')).toThrow(/Quoted segment cannot contain/);
|
||||
});
|
||||
|
||||
it("findOcPaths — wildcard returns paths with quoted keys when needed", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias"));
|
||||
expect(out).toHaveLength(3);
|
||||
const items = out.map((m) => m.path.item);
|
||||
expect(items.some((s) => s === "plain")).toBe(true);
|
||||
expect(items.some((s) => s === '"anthropic/claude-opus-4-7"')).toBe(true);
|
||||
expect(items.some((s) => s === '"github-copilot/claude-opus-4.7-1m-internal"')).toBe(true);
|
||||
});
|
||||
|
||||
it("findOcPaths — emitted paths round-trip through resolveOcPath", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://config/agents.defaults.models/*/alias"));
|
||||
for (const m of out) {
|
||||
const r = resolveOcPath(jsonc, m.path);
|
||||
expect(r?.kind).toBe("leaf");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unbalanced quotes at parse time", () => {
|
||||
expect(() => parseOcPath('oc://X/"unterminated')).toThrow(/Unbalanced/);
|
||||
});
|
||||
|
||||
it("control characters still rejected inside quotes", () => {
|
||||
expect(() => parseOcPath('oc://X/"\x00"')).toThrow(/Control character/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value predicates — numeric operators (v1.1)", () => {
|
||||
const jsonc = parseJsonc(
|
||||
'{"models":{"providers":{"anthropic":{"models":[' +
|
||||
'{"id":"claude-sonnet-4-6","contextWindow":1000000,"maxTokens":128000},' +
|
||||
'{"id":"claude-opus-4-7","contextWindow":1000000,"maxTokens":240000},' +
|
||||
'{"id":"claude-sonnet-4-7","contextWindow":200000,"maxTokens":64000}' +
|
||||
"]}}}}",
|
||||
).ast;
|
||||
|
||||
const PREFIX = "oc://config/models.providers.anthropic.models";
|
||||
|
||||
it("> finds models exceeding the per-request output cap", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>128000]/id`));
|
||||
expect(out).toHaveLength(1);
|
||||
if (out[0].match.kind === "leaf") {
|
||||
expect(out[0].match.valueText).toBe("claude-opus-4-7");
|
||||
}
|
||||
});
|
||||
|
||||
it(">= matches the boundary", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>=128000]/id`));
|
||||
const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(ids.toSorted()).toEqual(["claude-opus-4-7", "claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
it("< filters small context windows", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<500000]/id`));
|
||||
expect(out).toHaveLength(1);
|
||||
if (out[0].match.kind === "leaf") {
|
||||
expect(out[0].match.valueText).toBe("claude-sonnet-4-7");
|
||||
}
|
||||
});
|
||||
|
||||
it("<= matches the boundary", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[contextWindow<=200000]/id`));
|
||||
const ids = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(ids).toEqual(["claude-sonnet-4-7"]);
|
||||
});
|
||||
|
||||
it("numeric operator rejects non-numeric leaves silently", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[id>5]/id`));
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects numeric predicate value that is not a number", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath(`${PREFIX}/[maxTokens>foo]/id`));
|
||||
expect(out).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("value predicates — jsonc", () => {
|
||||
const jsonc = parseJsonc(
|
||||
'{"plugins":{"github":{"enabled":true,"role":"vcs"},"slack":{"enabled":false,"role":"chat"},"jira":{"enabled":true,"role":"tracker"}}}',
|
||||
).ast;
|
||||
|
||||
it("[enabled=true] filters by sibling boolean", () => {
|
||||
const out = findOcPaths(jsonc, parseOcPath("oc://config/plugins/[enabled=true]/role"));
|
||||
expect(out).toHaveLength(2);
|
||||
const roles = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(roles.toSorted()).toEqual(["tracker", "vcs"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ordinal addressing — md", () => {
|
||||
// Two items share slug `foo` after slugify.
|
||||
const md = parseMd("## Tools\n\n- foo: a\n- foo: b\n- bar: c\n").ast;
|
||||
|
||||
it("#0 picks the first item by document order", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#0/foo"));
|
||||
expect(m?.kind).toBe("leaf");
|
||||
if (m?.kind === "leaf") {
|
||||
expect(m.valueText).toBe("a");
|
||||
}
|
||||
});
|
||||
|
||||
it("#1 picks the second item — distinct from #0 even though slug collides", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#1/foo"));
|
||||
expect(m?.kind).toBe("leaf");
|
||||
if (m?.kind === "leaf") {
|
||||
expect(m.valueText).toBe("b");
|
||||
}
|
||||
});
|
||||
|
||||
it("out-of-range #N returns null", () => {
|
||||
expect(resolveOcPath(md, parseOcPath("oc://AGENTS.md/tools/#99/foo"))).toBeNull();
|
||||
});
|
||||
|
||||
it("findOcPaths disambiguates duplicate-slug items via #N", () => {
|
||||
const out = findOcPaths(md, parseOcPath("oc://AGENTS.md/tools/*/foo"));
|
||||
expect(out).toHaveLength(2);
|
||||
const items = out.map((m) => m.path.item);
|
||||
expect(items).toEqual(["#0", "#1"]);
|
||||
const values = out.map((m) => (m.match.kind === "leaf" ? m.match.valueText : ""));
|
||||
expect(values.toSorted()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("non-duplicate slug keeps slug form (back-compat)", () => {
|
||||
const md2 = parseMd("## Tools\n\n- foo: a\n- bar: b\n").ast;
|
||||
const out = findOcPaths(md2, parseOcPath("oc://AGENTS.md/tools/*"));
|
||||
const items = out.map((m) => m.path.item);
|
||||
expect(items.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["bar", "foo"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — Markdown kind", () => {
|
||||
const md = parseMd(
|
||||
"---\nname: drafter\nrole: writer\n---\n\n" +
|
||||
"## Tools\n\n" +
|
||||
"- send_email: enabled\n" +
|
||||
"- search: enabled\n" +
|
||||
"- read_email: disabled\n",
|
||||
).ast;
|
||||
|
||||
it("* in field slot enumerates frontmatter keys", () => {
|
||||
const out = findOcPaths(md, parseOcPath("oc://SOUL.md/[frontmatter]/*"));
|
||||
expect(out).toHaveLength(2);
|
||||
const keys = out.map((m) => m.path.item ?? m.path.field);
|
||||
expect(keys.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""))).toEqual(["name", "role"]);
|
||||
});
|
||||
|
||||
it("* in field slot enumerates each item kv key", () => {
|
||||
const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/send-email/*"));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].match.kind).toBe("leaf");
|
||||
if (out[0].match.kind === "leaf") {
|
||||
expect(out[0].match.valueText).toBe("enabled");
|
||||
}
|
||||
});
|
||||
|
||||
it("* in item slot + matching field returns each item whose kv key matches", () => {
|
||||
const out = findOcPaths(md, parseOcPath("oc://SKILL.md/Tools/*/send_email"));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].path.item).toBe("send-email");
|
||||
});
|
||||
|
||||
it("** at section slot matches items at every depth (cross-kind symmetry)", () => {
|
||||
// The retain-i branch on `**` keeps the wildcard active across
|
||||
// descent — without it, multi-block md files match only the
|
||||
// immediate-block layer.
|
||||
const multiBlock = parseMd(
|
||||
"## Boundaries\n\n" +
|
||||
"- never: rm -rf\n\n" +
|
||||
"## Tools\n\n" +
|
||||
"- send_email: enabled\n" +
|
||||
"- search: enabled\n",
|
||||
).ast;
|
||||
const out = findOcPaths(multiBlock, parseOcPath("oc://SOUL.md/**/send-email"));
|
||||
expect(out.length).toBeGreaterThanOrEqual(1);
|
||||
const items = out.map((m) => m.path.item).filter((v): v is string => v !== undefined);
|
||||
expect(items).toContain("send-email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOcPaths — quoted segments survive expansion", () => {
|
||||
it("finds keys with slashes when the path quotes them and a sibling wildcards", () => {
|
||||
const raw = `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"models": {
|
||||
"github-copilot/claude-opus-4-7": {
|
||||
"alias": "opus-internal",
|
||||
"contextWindow": 200000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const { ast } = parseJsonc(raw);
|
||||
const out = findOcPaths(
|
||||
ast,
|
||||
parseOcPath(
|
||||
'oc://config.jsonc/agents.defaults.models/"github-copilot/claude-opus-4-7"/{alias,contextWindow}',
|
||||
),
|
||||
);
|
||||
expect(out.length).toBe(2);
|
||||
const fields = out
|
||||
.map((m) => m.path.field)
|
||||
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
||||
expect(fields).toEqual(["alias", "contextWindow"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("union segments — md", () => {
|
||||
const RAW = `## Boundaries
|
||||
|
||||
- enabled: true
|
||||
- timeout: 5
|
||||
|
||||
## Limits
|
||||
|
||||
- max-tokens: 4096
|
||||
- alias: claude-3
|
||||
`;
|
||||
|
||||
it("expands {a,b} at the section slot", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/{boundaries,limits}/*/*"));
|
||||
expect(out.length).toBe(4);
|
||||
const sections = out
|
||||
.map((m) => m.path.section)
|
||||
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
||||
expect(sections).toEqual(["boundaries", "boundaries", "limits", "limits"]);
|
||||
});
|
||||
|
||||
it("expands {a,b} at the item slot", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/limits/{max-tokens,alias}/*"));
|
||||
expect(out.length).toBe(2);
|
||||
const items = out.map((m) => m.path.item).toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
||||
expect(items).toEqual(["alias", "max-tokens"]);
|
||||
});
|
||||
|
||||
it("expands {a,b} at the field slot — md items have one kv, so at most one alt", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/limits/alias/{alias,nope}"));
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.path.field).toBe("alias");
|
||||
});
|
||||
});
|
||||
|
||||
describe("predicate segments — md", () => {
|
||||
const RAW = `## Boundaries
|
||||
|
||||
- enabled: true
|
||||
- timeout: 5
|
||||
|
||||
## Limits
|
||||
|
||||
- enabled: false
|
||||
- max-tokens: 4096
|
||||
`;
|
||||
|
||||
it("matches sections that contain an item satisfying the predicate", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/[enabled=true]/*/*"));
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
for (const m of out) {
|
||||
expect(m.path.section).toBe("boundaries");
|
||||
}
|
||||
});
|
||||
|
||||
it("matches items whose kv pair satisfies the predicate", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/limits/[enabled=false]/*"));
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.path.item).toBe("enabled");
|
||||
});
|
||||
|
||||
it("matches the kv pair at the field slot", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/limits/max-tokens/[max-tokens=4096]"));
|
||||
expect(out.length).toBe(1);
|
||||
expect(out[0]?.path.field).toBe("max-tokens");
|
||||
});
|
||||
|
||||
it("returns empty when no section's item matches", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/[enabled=maybe]/*/*"));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when no item matches the predicate", () => {
|
||||
const ast = parseMd(RAW).ast;
|
||||
const out = findOcPaths(ast, parseOcPath("oc://X.md/limits/[enabled=true]/*"));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
17
extensions/oc-path/src/oc-path/tests/fixtures/real/AGENTS.md
vendored
Normal file
17
extensions/oc-path/src/oc-path/tests/fixtures/real/AGENTS.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
## Roles
|
||||
|
||||
- planner: breaks down user goals into tasks
|
||||
- executor: runs the planned tasks one at a time
|
||||
- reviewer: checks output before user-visible writes
|
||||
|
||||
## Tools
|
||||
|
||||
- gh: GitHub CLI for issues, PRs, CI
|
||||
- curl: HTTP client
|
||||
- rg: ripgrep — fast file content search
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never edit /etc, /usr, or system paths
|
||||
- always confirm before destructive operations
|
||||
- read SOUL.md before each session for persona context
|
||||
17
extensions/oc-path/src/oc-path/tests/fixtures/real/BOOTSTRAP.md
vendored
Normal file
17
extensions/oc-path/src/oc-path/tests/fixtures/real/BOOTSTRAP.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Workspace bootstrap
|
||||
|
||||
This is the first thing the agent reads on a fresh workspace. Once
|
||||
the user finishes setup (filling in SOUL.md, USER.md, etc.),
|
||||
BOOTSTRAP.md gets removed and the workspace is "live."
|
||||
|
||||
## Setup checklist
|
||||
|
||||
- review SOUL.md and add personal context
|
||||
- review USER.md and add role/preferences
|
||||
- run `openclaw doctor` to verify config + workspace are valid
|
||||
- confirm the gateway can reach your providers
|
||||
|
||||
## Removing this file
|
||||
|
||||
When the checklist is complete, delete BOOTSTRAP.md. The runtime
|
||||
detects its absence as "setup complete."
|
||||
16
extensions/oc-path/src/oc-path/tests/fixtures/real/HEARTBEAT.md
vendored
Normal file
16
extensions/oc-path/src/oc-path/tests/fixtures/real/HEARTBEAT.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Every 30m wake
|
||||
|
||||
- check unread Slack DMs in #incidents
|
||||
- summarize new PR review comments since last wake
|
||||
- if any test fails on main, surface to user immediately
|
||||
|
||||
## Every 4h wake
|
||||
|
||||
- compile a brief status summary of in-flight tasks
|
||||
- check Linear for new high-priority issues
|
||||
- update the daily log entry
|
||||
|
||||
## On user-presence wake
|
||||
|
||||
- briefly orient on what changed since last user interaction
|
||||
- prioritize incoming items by urgency
|
||||
19
extensions/oc-path/src/oc-path/tests/fixtures/real/IDENTITY.md
vendored
Normal file
19
extensions/oc-path/src/oc-path/tests/fixtures/real/IDENTITY.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## Organization
|
||||
|
||||
Example Org / Platform Team
|
||||
|
||||
## Team
|
||||
|
||||
OpenClaw infrastructure & tooling
|
||||
|
||||
## Trust Level
|
||||
|
||||
internal-trusted
|
||||
|
||||
## Region
|
||||
|
||||
us-west
|
||||
|
||||
## Compliance scope
|
||||
|
||||
SOC 2 Type II + FedRAMP Moderate (in audit)
|
||||
18
extensions/oc-path/src/oc-path/tests/fixtures/real/MEMORY.md
vendored
Normal file
18
extensions/oc-path/src/oc-path/tests/fixtures/real/MEMORY.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
scope: project
|
||||
---
|
||||
|
||||
## User prefers async communication
|
||||
|
||||
The user has mentioned twice (sessions 2026-04-15 and 2026-04-22) that
|
||||
they prefer Slack DMs over meetings for short questions.
|
||||
|
||||
## Project uses TypeScript with strict mode
|
||||
|
||||
The codebase enforces `strict: true` and `noUncheckedIndexedAccess`.
|
||||
Avoid `any`; prefer `unknown` with narrowing.
|
||||
|
||||
## Deploy on Tuesdays only
|
||||
|
||||
Production deploys happen Tue 9am-12pm Pacific. Outside that window,
|
||||
deploys go to staging and wait for the next Tuesday window.
|
||||
38
extensions/oc-path/src/oc-path/tests/fixtures/real/SKILL.md
vendored
Normal file
38
extensions/oc-path/src/oc-path/tests/fixtures/real/SKILL.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: github
|
||||
description: Use gh for GitHub issues, PR status, CI/logs, comments, reviews, releases, and API queries.
|
||||
tier: T1
|
||||
tools:
|
||||
- gh
|
||||
- bash
|
||||
trigger_phrases:
|
||||
- github
|
||||
- pr
|
||||
- issue
|
||||
- workflow
|
||||
metadata: { "openclaw": { "emoji": "🐙", "requires": { "bins": ["gh"] } } }
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# When to use
|
||||
|
||||
Use this skill when the user asks anything about GitHub: issues, pull
|
||||
requests, CI runs, releases, comments, code review, or organizational
|
||||
metadata. Prefer the `gh` CLI over web URLs — `gh` handles auth,
|
||||
pagination, and structured output natively.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
gh pr view 123 # view PR details
|
||||
gh pr checks 123 # CI status
|
||||
gh issue list --state open # list open issues
|
||||
gh run list -L 5 # last 5 workflow runs
|
||||
gh release create v1.2.3 # cut a release
|
||||
```
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- The user's repo is on a non-GitHub forge (GitLab, Gitea, Bitbucket).
|
||||
Use the appropriate CLI instead.
|
||||
- Operations that require admin permissions the agent doesn't have.
|
||||
17
extensions/oc-path/src/oc-path/tests/fixtures/real/SOUL.md
vendored
Normal file
17
extensions/oc-path/src/oc-path/tests/fixtures/real/SOUL.md
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Persona
|
||||
|
||||
I'm a thoughtful, methodical assistant. I ask clarifying questions
|
||||
when the user's request is ambiguous, and I'd rather be slightly
|
||||
slower than confidently wrong.
|
||||
|
||||
## Voice
|
||||
|
||||
- terse and direct
|
||||
- no filler words
|
||||
- code snippets > prose when explaining technical things
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never write to /etc or system paths
|
||||
- always confirm before deleting files
|
||||
- redact secrets from logs and audit trails
|
||||
21
extensions/oc-path/src/oc-path/tests/fixtures/real/TOOLS.md
vendored
Normal file
21
extensions/oc-path/src/oc-path/tests/fixtures/real/TOOLS.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
## Tool Guidance
|
||||
|
||||
| tool | guidance |
|
||||
| ---- | ------------------------------------------------------------- |
|
||||
| gh | Use for GitHub operations (issues, PRs, CI). Prefer over web. |
|
||||
| curl | HTTP client. Use --silent for clean output. |
|
||||
| rg | ripgrep — content search. Faster than grep for code. |
|
||||
| fd | find replacement. Use over `find` when available. |
|
||||
|
||||
## Allow / Deny
|
||||
|
||||
- enabled: gh
|
||||
- enabled: curl
|
||||
- enabled: rg
|
||||
- enabled: fd
|
||||
- disabled: legacy-tool
|
||||
|
||||
## Notes
|
||||
|
||||
The agent reads this file at session start; runtime tool gates honor
|
||||
the `enabled` flags.
|
||||
16
extensions/oc-path/src/oc-path/tests/fixtures/real/USER.md
vendored
Normal file
16
extensions/oc-path/src/oc-path/tests/fixtures/real/USER.md
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
## Role
|
||||
|
||||
Senior PM working on AI runtime + governance layers. Reports to a VP-level
|
||||
stakeholder; coordinates across 4-6 engineering teams.
|
||||
|
||||
## Preferences
|
||||
|
||||
- async-first communication (Slack DMs > meetings)
|
||||
- terse responses; avoid filler
|
||||
- code snippets > prose for technical detail
|
||||
- always include repo:file:line citations for code claims
|
||||
|
||||
## Working hours
|
||||
|
||||
- Mon-Fri 9am-6pm Pacific
|
||||
- occasional evening for sync with EU teams
|
||||
156
extensions/oc-path/src/oc-path/tests/jsonc/edit.test.ts
Normal file
156
extensions/oc-path/src/oc-path/tests/jsonc/edit.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// OC Path tests cover edit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setJsoncOcPath } from "../../jsonc/edit.js";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
|
||||
describe("setJsoncOcPath — value replacement", () => {
|
||||
const config = `{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"github": {
|
||||
"token": "old"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
it("replaces a leaf string value", () => {
|
||||
const { ast } = parseJsonc(config);
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/plugins.entries.github.token"), {
|
||||
kind: "string",
|
||||
value: "new",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitJsonc(r.ast);
|
||||
expect(JSON.parse(out)).toEqual({
|
||||
plugins: { entries: { github: { token: "new" } } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces nested objects", () => {
|
||||
const { ast } = parseJsonc(config);
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/plugins.entries"), {
|
||||
kind: "object",
|
||||
entries: [{ key: "gitlab", line: 0, value: { kind: "string", value: "tok" } }],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({
|
||||
plugins: { entries: { gitlab: "tok" } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces an array element by index", () => {
|
||||
const { ast } = parseJsonc('{ "limits": [10, 20, 30] }');
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/limits.1"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({ limits: [10, 99, 30] });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved for noncanonical array indexes", () => {
|
||||
const { ast } = parseJsonc('{ "limits": [10, 20, 30] }');
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/limits.01"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
|
||||
it("reports unresolved when a key is missing", () => {
|
||||
const { ast } = parseJsonc(config);
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/plugins.entries.gitlab"), {
|
||||
kind: "string",
|
||||
value: "x",
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
|
||||
it("reports no-root on empty AST", () => {
|
||||
const { ast } = parseJsonc("");
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/x"), {
|
||||
kind: "string",
|
||||
value: "y",
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "no-root" });
|
||||
});
|
||||
|
||||
it("does not mutate the original AST", () => {
|
||||
const { ast } = parseJsonc(config);
|
||||
const before = JSON.stringify(ast);
|
||||
setJsoncOcPath(ast, parseOcPath("oc://config/plugins.entries.github.token"), {
|
||||
kind: "string",
|
||||
value: "new",
|
||||
});
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJsoncOcPath — $last positional", () => {
|
||||
it("edits the last array element via $last", () => {
|
||||
const { ast } = parseJsonc('{ "items": [10, 20, 30] }');
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config.jsonc/items/$last"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({ items: [10, 20, 99] });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved for $last against an empty array", () => {
|
||||
const { ast } = parseJsonc('{ "items": [] }');
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config.jsonc/items/$last"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJsoncOcPath — quoted segments (regression: resolve↔edit symmetry)", () => {
|
||||
it("edits a key containing slashes via quoted segment", () => {
|
||||
// The provider/model alias key contains a `/`; without quoting
|
||||
// it would be split as two segments. `resolveJsoncOcPath` handles
|
||||
// this; `setJsoncOcPath` MUST handle it the same way or the path
|
||||
// becomes resolve-only. Closes ClawSweeper P2 on PR #78678.
|
||||
const raw = `{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-7": { "alias": "opus" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const { ast } = parseJsonc(raw);
|
||||
const r = setJsoncOcPath(
|
||||
ast,
|
||||
parseOcPath('oc://config.jsonc/agents.defaults.models/"anthropic/claude-opus-4-7"/alias'),
|
||||
{ kind: "string", value: "big-opus" },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "big-opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
88
extensions/oc-path/src/oc-path/tests/jsonc/emit.test.ts
Normal file
88
extensions/oc-path/src/oc-path/tests/jsonc/emit.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// OC Path tests cover emit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js";
|
||||
|
||||
describe("emitJsonc — round-trip", () => {
|
||||
it("returns raw bytes verbatim by default", () => {
|
||||
const raw = `{
|
||||
// comment is preserved on round-trip
|
||||
"x": 1,
|
||||
"y": [/* inline */ 2, 3],
|
||||
}
|
||||
`;
|
||||
const { ast } = parseJsonc(raw);
|
||||
expect(emitJsonc(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("echoes pre-existing sentinel bytes by default; strict mode rejects", () => {
|
||||
// Round-trip trusts parsed bytes — workspace files legitimately
|
||||
// containing the sentinel (in code blocks, pasted error logs)
|
||||
// would otherwise become a workspace-wide emit DoS. Strict mode
|
||||
// is the opt-in path.
|
||||
const raw = `{ "x": "${REDACTED_SENTINEL}" }`;
|
||||
const { ast } = parseJsonc(raw);
|
||||
expect(emitJsonc(ast)).toBe(raw);
|
||||
expect(() =>
|
||||
emitJsonc(ast, { fileNameForGuard: "config", acceptPreExistingSentinel: false }),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitJsonc — render mode", () => {
|
||||
it("re-stringifies the structural tree (no comments)", () => {
|
||||
const { ast } = parseJsonc('{ /* drop me */ "x": 1, "y": [2, 3] }');
|
||||
const out = emitJsonc(ast, { mode: "render" });
|
||||
expect(out).not.toContain("drop me");
|
||||
expect(JSON.parse(out)).toEqual({ x: 1, y: [2, 3] });
|
||||
});
|
||||
|
||||
it("throws OcEmitSentinelError when a leaf string is the sentinel", () => {
|
||||
const ast = parseJsonc('{ "x": "ok" }').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
root: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "x",
|
||||
line: 1,
|
||||
value: { kind: "string" as const, value: REDACTED_SENTINEL },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(() => emitJsonc(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("throws when a leaf string EMBEDS the sentinel (prefix/suffix wrap)", () => {
|
||||
// Regression: prior to this fix, render mode used `value.value === SENTINEL`
|
||||
// (exact match), so `prefix__OPENCLAW_REDACTED__suffix` slipped through.
|
||||
// The roundtrip path always used `.includes()` for the same reason —
|
||||
// render must too. Catches the sentinel-guard bypass class.
|
||||
const ast = parseJsonc('{ "x": "ok" }').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
root: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "x",
|
||||
line: 1,
|
||||
value: {
|
||||
kind: "string" as const,
|
||||
value: `prefix-${REDACTED_SENTINEL}-suffix`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(() => emitJsonc(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("renders empty AST as empty string", () => {
|
||||
const { ast } = parseJsonc("");
|
||||
expect(emitJsonc(ast, { mode: "render" })).toBe("");
|
||||
});
|
||||
});
|
||||
165
extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts
Normal file
165
extensions/oc-path/src/oc-path/tests/jsonc/parse.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
// OC Path tests cover parse plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_JSONC_INPUT_BYTES, parseJsonc } from "../../jsonc/parse.js";
|
||||
|
||||
describe("parseJsonc — basic shapes", () => {
|
||||
it("parses an empty object", () => {
|
||||
const { ast, diagnostics } = parseJsonc("{}");
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.kind).toBe("jsonc");
|
||||
expect(ast.root).toEqual({ kind: "object", entries: [], line: 1 });
|
||||
});
|
||||
|
||||
it("parses an empty array", () => {
|
||||
const { ast, diagnostics } = parseJsonc("[]");
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toEqual({ kind: "array", items: [], line: 1 });
|
||||
});
|
||||
|
||||
it("parses an empty input as null root", () => {
|
||||
const { ast, diagnostics } = parseJsonc("");
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toBeNull();
|
||||
});
|
||||
|
||||
it("parses scalars", () => {
|
||||
expect(parseJsonc("42").ast.root).toEqual({ kind: "number", value: 42, line: 1 });
|
||||
expect(parseJsonc("-3.14").ast.root).toEqual({ kind: "number", value: -3.14, line: 1 });
|
||||
expect(parseJsonc("1e3").ast.root).toEqual({ kind: "number", value: 1000, line: 1 });
|
||||
expect(parseJsonc('"hello"').ast.root).toEqual({ kind: "string", value: "hello", line: 1 });
|
||||
expect(parseJsonc("true").ast.root).toEqual({ kind: "boolean", value: true, line: 1 });
|
||||
expect(parseJsonc("false").ast.root).toEqual({ kind: "boolean", value: false, line: 1 });
|
||||
expect(parseJsonc("null").ast.root).toEqual({ kind: "null", line: 1 });
|
||||
});
|
||||
|
||||
it("parses nested object/array", () => {
|
||||
const raw = '{ "plugins": { "entries": ["a", "b"] } }';
|
||||
const { ast, diagnostics } = parseJsonc(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toEqual({
|
||||
kind: "object",
|
||||
line: 1,
|
||||
entries: [
|
||||
{
|
||||
key: "plugins",
|
||||
line: 1,
|
||||
value: {
|
||||
kind: "object",
|
||||
line: 1,
|
||||
entries: [
|
||||
{
|
||||
key: "entries",
|
||||
line: 1,
|
||||
value: {
|
||||
kind: "array",
|
||||
line: 1,
|
||||
items: [
|
||||
{ kind: "string", value: "a", line: 1 },
|
||||
{ kind: "string", value: "b", line: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves raw on the AST root for byte-fidelity emit", () => {
|
||||
const raw = '{\n "x": 1\n}\n';
|
||||
const { ast } = parseJsonc(raw);
|
||||
expect(ast.raw).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonc — JSONC extensions", () => {
|
||||
it("skips line comments", () => {
|
||||
const raw = `{
|
||||
// comment
|
||||
"x": 1 // trailing comment
|
||||
}`;
|
||||
const { ast, diagnostics } = parseJsonc(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toEqual({
|
||||
kind: "object",
|
||||
line: 1,
|
||||
entries: [{ key: "x", value: { kind: "number", value: 1, line: 3 }, line: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips block comments", () => {
|
||||
const raw = '{ /* hi */ "x": /* mid */ 1 }';
|
||||
const { ast, diagnostics } = parseJsonc(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toEqual({
|
||||
kind: "object",
|
||||
line: 1,
|
||||
entries: [{ key: "x", value: { kind: "number", value: 1, line: 1 }, line: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates trailing commas in objects", () => {
|
||||
const { ast, diagnostics } = parseJsonc('{ "x": 1, }');
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root).toEqual({
|
||||
kind: "object",
|
||||
line: 1,
|
||||
entries: [{ key: "x", value: { kind: "number", value: 1, line: 1 }, line: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("tolerates trailing commas in arrays", () => {
|
||||
const { ast } = parseJsonc("[1, 2, 3,]");
|
||||
expect(ast.root).toEqual({
|
||||
kind: "array",
|
||||
line: 1,
|
||||
items: [
|
||||
{ kind: "number", value: 1, line: 1 },
|
||||
{ kind: "number", value: 2, line: 1 },
|
||||
{ kind: "number", value: 3, line: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("handles escape sequences in strings", () => {
|
||||
const { ast } = parseJsonc('"a\\nb\\tc\\u0041"');
|
||||
expect(ast.root).toEqual({ kind: "string", value: "a\nb\tcA", line: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseJsonc — soft errors", () => {
|
||||
it("returns null root + error diagnostic on unrecoverable input", () => {
|
||||
const { ast, diagnostics } = parseJsonc('{ "x" 1 }');
|
||||
expect(ast.root).toBeNull();
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("warns on trailing input after a valid value", () => {
|
||||
const { diagnostics } = parseJsonc("1 garbage");
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.severity).toBe("warning");
|
||||
expect(diagnostics[0]?.code).toBe("OC_JSONC_TRAILING_INPUT");
|
||||
});
|
||||
|
||||
it("rejects input larger than MAX_JSONC_INPUT_BYTES with a typed diagnostic", () => {
|
||||
// Construct an input one byte over the cap. We don't allocate the
|
||||
// full 16 MiB+ string in memory; `String#repeat` on a one-byte unit
|
||||
// is enough to push past the threshold without exercising the
|
||||
// expensive `parseTree` path (the cap fires before parse runs).
|
||||
const oversized = "a".repeat(MAX_JSONC_INPUT_BYTES + 1);
|
||||
const { ast, diagnostics } = parseJsonc(oversized);
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.severity).toBe("error");
|
||||
expect(diagnostics[0]?.code).toBe("OC_JSONC_INPUT_TOO_LARGE");
|
||||
expect(ast.root).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts input up to the cap", () => {
|
||||
// Reasonable-shape JSON well within the cap parses normally.
|
||||
const { diagnostics, ast } = parseJsonc('{"key": "value"}');
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.root?.kind).toBe("object");
|
||||
});
|
||||
});
|
||||
87
extensions/oc-path/src/oc-path/tests/jsonc/resolve.test.ts
Normal file
87
extensions/oc-path/src/oc-path/tests/jsonc/resolve.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// OC Path tests cover resolve plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { resolveJsoncOcPath } from "../../jsonc/resolve.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
|
||||
function rs(raw: string, ocPath: string) {
|
||||
const { ast } = parseJsonc(raw);
|
||||
const path = parseOcPath(ocPath);
|
||||
return resolveJsoncOcPath(ast, path);
|
||||
}
|
||||
|
||||
describe("resolveJsoncOcPath", () => {
|
||||
const config = `{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"github": {
|
||||
"token": "secret",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"limits": [10, 20, 30]
|
||||
}`;
|
||||
|
||||
it("resolves the root when no segments are given", () => {
|
||||
const m = rs(config, "oc://config");
|
||||
expect(m?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("walks dotted section paths", () => {
|
||||
const m = rs(config, "oc://config/plugins.entries.github.token");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.key).toBe("token");
|
||||
expect(m.node.value.kind).toBe("string");
|
||||
if (m.node.value.kind === "string") {
|
||||
expect(m.node.value.value).toBe("secret");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("walks 4-segment slash paths up to OcPath depth limit", () => {
|
||||
const m = rs(config, "oc://config/plugins/entries/github");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.key).toBe("github");
|
||||
}
|
||||
});
|
||||
|
||||
it("walks mixed dotted+slash paths", () => {
|
||||
const m = rs(config, "oc://config/plugins/entries.github.token");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("indexes into arrays via numeric segments", () => {
|
||||
const m = rs(config, "oc://config/limits.1");
|
||||
expect(m?.kind).toBe("value");
|
||||
if (m?.kind === "value") {
|
||||
expect(m.node.kind).toBe("number");
|
||||
if (m.node.kind === "number") {
|
||||
expect(m.node.value).toBe(20);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for missing keys", () => {
|
||||
expect(rs(config, "oc://config/plugins.entries.gitlab")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for out-of-bounds array indexes", () => {
|
||||
expect(rs(config, "oc://config/limits.99")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for noncanonical array indexes", () => {
|
||||
expect(rs(config, "oc://config/limits.01")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when descending past a primitive", () => {
|
||||
expect(rs(config, "oc://config/plugins.entries.github.token.x")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on empty AST", () => {
|
||||
const { ast } = parseJsonc("");
|
||||
expect(resolveJsoncOcPath(ast, parseOcPath("oc://config/x"))).toBeNull();
|
||||
});
|
||||
});
|
||||
169
extensions/oc-path/src/oc-path/tests/jsonl/edit.test.ts
Normal file
169
extensions/oc-path/src/oc-path/tests/jsonl/edit.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// OC Path tests cover edit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appendJsonlOcPath, setJsonlOcPath } from "../../jsonl/edit.js";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
|
||||
describe("setJsonlOcPath — value replacement", () => {
|
||||
const log = '{"event":"start"}\n{"event":"step","n":1}\n{"event":"end"}\n';
|
||||
|
||||
it("replaces a field on a specific line", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/L2/n"), {
|
||||
kind: "number",
|
||||
value: 42,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const lines = emitJsonl(r.ast).split("\n");
|
||||
expect(JSON.parse(lines[1] ?? "")).toEqual({ event: "step", n: 42 });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces an entire line value", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/L2"), {
|
||||
kind: "object",
|
||||
entries: [{ key: "event", line: 0, value: { kind: "string", value: "replaced" } }],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const lines = emitJsonl(r.ast).split("\n");
|
||||
expect(JSON.parse(lines[1] ?? "")).toEqual({ event: "replaced" });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves $last and edits the most recent value line", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/$last/event"), {
|
||||
kind: "string",
|
||||
value: "final",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const lines = emitJsonl(r.ast).split("\n");
|
||||
expect(JSON.parse(lines[2] ?? "")).toEqual({ event: "final" });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved for unknown line addresses", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/L99/x"), {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
|
||||
it("reports not-a-value-line when targeting a blank line", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\n\n{"b":2}\n');
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/L2"), {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "not-a-value-line" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendJsonlOcPath — session checkpointing primitive", () => {
|
||||
it("appends to an empty file", () => {
|
||||
const { ast } = parseJsonl("");
|
||||
const next = appendJsonlOcPath(ast, {
|
||||
kind: "object",
|
||||
entries: [{ key: "event", line: 0, value: { kind: "string", value: "start" } }],
|
||||
});
|
||||
expect(emitJsonl(next)).toBe('{"event":"start"}');
|
||||
});
|
||||
|
||||
it("appends to an existing log preserving prior lines", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\n');
|
||||
const next = appendJsonlOcPath(ast, {
|
||||
kind: "object",
|
||||
entries: [{ key: "b", line: 0, value: { kind: "number", value: 2 } }],
|
||||
});
|
||||
const out = emitJsonl(next).split("\n");
|
||||
expect(out).toHaveLength(2);
|
||||
expect(JSON.parse(out[1] ?? "")).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it("preserves CRLF line endings when appending", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\r\n');
|
||||
const next = appendJsonlOcPath(ast, {
|
||||
kind: "object",
|
||||
entries: [{ key: "b", line: 0, value: { kind: "number", value: 2 } }],
|
||||
});
|
||||
expect(emitJsonl(next)).toBe('{"a":1}\r\n{"b":2}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJsonlOcPath — $last line address", () => {
|
||||
const log = '{"event":"start","n":1}\n{"event":"step","n":2}\n{"event":"end","n":3}\n';
|
||||
|
||||
it("writes under $last line address", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/$last/n"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const lines = emitJsonl(r.ast).split("\n");
|
||||
expect(JSON.parse(lines[2] ?? "")).toEqual({ event: "end", n: 99 });
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved for $last against an empty log", () => {
|
||||
const { ast } = parseJsonl("");
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/$last/n"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r).toEqual({ ok: false, reason: "unresolved" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJsonlOcPath — $last positional field tokens", () => {
|
||||
const log = '{"items":[10,20,30],"events":{"a":1,"b":2}}\n';
|
||||
|
||||
it("edits the last array item on a line via $last", () => {
|
||||
const { ast } = parseJsonl(log);
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://session-events/L1/items/$last"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const firstLine =
|
||||
emitJsonl(r.ast)
|
||||
.split("\n")
|
||||
.find((l) => l.length > 0) ?? "";
|
||||
expect(JSON.parse(firstLine)).toEqual({
|
||||
items: [10, 20, 99],
|
||||
events: { a: 1, b: 2 },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setJsonlOcPath — quoted field segments", () => {
|
||||
it("edits a field key containing a slash via quoted segment", () => {
|
||||
const raw = `{"event":"start","detail":{"github/repo":"old"}}\n`;
|
||||
const { ast } = parseJsonl(raw);
|
||||
const r = setJsonlOcPath(ast, parseOcPath('oc://x.jsonl/L1/detail/"github/repo"'), {
|
||||
kind: "string",
|
||||
value: "new",
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const lines = emitJsonl(r.ast)
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0] ?? "")).toEqual({
|
||||
event: "start",
|
||||
detail: { "github/repo": "new" },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
95
extensions/oc-path/src/oc-path/tests/jsonl/emit.test.ts
Normal file
95
extensions/oc-path/src/oc-path/tests/jsonl/emit.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// OC Path tests cover emit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js";
|
||||
|
||||
describe("emitJsonl — round-trip", () => {
|
||||
it("returns raw bytes verbatim by default", () => {
|
||||
const raw = '{"a":1}\n\n{"b":2}\nthis is malformed\n';
|
||||
const { ast } = parseJsonl(raw);
|
||||
expect(emitJsonl(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("echoes pre-existing sentinel bytes by default; strict mode rejects", () => {
|
||||
const raw = `{"a":"${REDACTED_SENTINEL}"}\n`;
|
||||
const { ast } = parseJsonl(raw);
|
||||
expect(emitJsonl(ast)).toBe(raw);
|
||||
expect(() =>
|
||||
emitJsonl(ast, {
|
||||
fileNameForGuard: "session-events",
|
||||
acceptPreExistingSentinel: false,
|
||||
}),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitJsonl — render mode", () => {
|
||||
it("rebuilds value lines via JSON-stringify", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\n{"b":2}\n');
|
||||
const out = emitJsonl(ast, { mode: "render" });
|
||||
expect(out.split("\n")).toEqual(['{"a":1}', '{"b":2}']);
|
||||
});
|
||||
|
||||
it("preserves blank and malformed lines verbatim in render mode", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\n\nbroken\n{"b":2}\n');
|
||||
const out = emitJsonl(ast, { mode: "render" });
|
||||
expect(out.split("\n")).toEqual(['{"a":1}', "", "broken", '{"b":2}']);
|
||||
});
|
||||
|
||||
it("throws when a value-leaf is the sentinel under render mode", () => {
|
||||
const ast = parseJsonl('{"a":"ok"}\n').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
lines: [
|
||||
{
|
||||
kind: "value" as const,
|
||||
line: 1,
|
||||
raw: '{"a":"ok"}',
|
||||
value: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "a",
|
||||
line: 1,
|
||||
value: { kind: "string" as const, value: REDACTED_SENTINEL },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitJsonl(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("throws when a value-leaf EMBEDS the sentinel (prefix/suffix wrap)", () => {
|
||||
// Regression: prior to this fix, render mode used exact-match
|
||||
// (`value.value === SENTINEL`), so `prefix__OPENCLAW_REDACTED__suffix`
|
||||
// slipped through. The contains-check is the right invariant.
|
||||
const ast = parseJsonl('{"a":"ok"}\n').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
lines: [
|
||||
{
|
||||
kind: "value" as const,
|
||||
line: 1,
|
||||
raw: '{"a":"ok"}',
|
||||
value: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "a",
|
||||
line: 1,
|
||||
value: {
|
||||
kind: "string" as const,
|
||||
value: `wrap-${REDACTED_SENTINEL}-end`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitJsonl(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
44
extensions/oc-path/src/oc-path/tests/jsonl/parse.test.ts
Normal file
44
extensions/oc-path/src/oc-path/tests/jsonl/parse.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// OC Path tests cover parse plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
|
||||
describe("parseJsonl", () => {
|
||||
it("parses an empty file as zero lines", () => {
|
||||
const { ast, diagnostics } = parseJsonl("");
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses each line as a JSON value", () => {
|
||||
const raw = `{"event":"start"}
|
||||
{"event":"step","n":1}
|
||||
{"event":"end"}
|
||||
`;
|
||||
const { ast, diagnostics } = parseJsonl(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.lines).toHaveLength(3);
|
||||
expect(ast.lines[0]?.kind).toBe("value");
|
||||
expect(ast.lines[2]?.kind).toBe("value");
|
||||
});
|
||||
|
||||
it("preserves blank lines as blank entries", () => {
|
||||
const raw = '{"a":1}\n\n{"b":2}\n';
|
||||
const { ast, diagnostics } = parseJsonl(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.lines.map((l) => l.kind)).toEqual(["value", "blank", "value"]);
|
||||
});
|
||||
|
||||
it("flags malformed lines as warnings without aborting", () => {
|
||||
const raw = '{"a":1}\nthis is not json\n{"b":2}\n';
|
||||
const { ast, diagnostics } = parseJsonl(raw);
|
||||
expect(ast.lines.map((l) => l.kind)).toEqual(["value", "malformed", "value"]);
|
||||
expect(diagnostics).toHaveLength(1);
|
||||
expect(diagnostics[0]?.code).toBe("OC_JSONL_LINE_MALFORMED");
|
||||
});
|
||||
|
||||
it("preserves raw on the AST root for byte-fidelity emit", () => {
|
||||
const raw = '{"a":1}\n{"b":2}\n';
|
||||
const { ast } = parseJsonl(raw);
|
||||
expect(ast.raw).toBe(raw);
|
||||
});
|
||||
});
|
||||
113
extensions/oc-path/src/oc-path/tests/jsonl/resolve.test.ts
Normal file
113
extensions/oc-path/src/oc-path/tests/jsonl/resolve.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
// OC Path tests cover resolve plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findOcPaths } from "../../find.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { resolveJsonlOcPath } from "../../jsonl/resolve.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
import { resolveOcPath } from "../../universal.js";
|
||||
|
||||
const log = `{"event":"start","ts":1}
|
||||
{"event":"step","n":1,"result":{"ok":true,"detail":"a"}}
|
||||
|
||||
{"event":"end","ts":99}
|
||||
`;
|
||||
|
||||
function rs(ocPath: string) {
|
||||
const { ast } = parseJsonl(log);
|
||||
return resolveJsonlOcPath(ast, parseOcPath(ocPath));
|
||||
}
|
||||
|
||||
describe("resolveJsonlOcPath", () => {
|
||||
it("returns root when no segments are given", () => {
|
||||
expect(rs("oc://session-events")?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("addresses an entire line by line number", () => {
|
||||
const m = rs("oc://session-events/L1");
|
||||
expect(m?.kind).toBe("line");
|
||||
});
|
||||
|
||||
it("addresses fields under a line via item segment", () => {
|
||||
const m = rs("oc://session-events/L2/event");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.value.kind).toBe("string");
|
||||
if (m.node.value.kind === "string") {
|
||||
expect(m.node.value.value).toBe("step");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("descends via dotted item paths", () => {
|
||||
const m = rs("oc://session-events/L2/result.ok");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.value.kind).toBe("boolean");
|
||||
if (m.node.value.kind === "boolean") {
|
||||
expect(m.node.value.value).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves $last to the most recent value line", () => {
|
||||
const m = rs("oc://session-events/$last/event");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.value.kind).toBe("string");
|
||||
if (m.node.value.kind === "string") {
|
||||
expect(m.node.value.value).toBe("end");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for unknown line addresses", () => {
|
||||
expect(rs("oc://session-events/L99")).toBeNull();
|
||||
expect(rs("oc://session-events/garbage")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when descending into a blank line", () => {
|
||||
expect(rs("oc://session-events/L3/anything")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveJsonlToUniversal — file-relative line metadata (regression)", () => {
|
||||
// Regression: surfaced via the openclaw-path CLI scenario run on
|
||||
// a multi-line session.jsonl. Every match returned `line: 1`
|
||||
// because the inside-line jsonc parser numbers from 1 within each
|
||||
// line's bytes; the universal resolve was preferring that local
|
||||
// number over the JsonlLine's file-relative line.
|
||||
|
||||
const logLocal = [
|
||||
'{"event":"start"}', // line 1
|
||||
'{"event":"step","n":1}', // line 2
|
||||
'{"event":"step","n":2}', // line 3
|
||||
'{"event":"end"}', // line 4
|
||||
"", // line 5 (blank)
|
||||
].join("\n");
|
||||
|
||||
it("resolves L2/event with line=2 (not 1)", () => {
|
||||
const { ast } = parseJsonl(logLocal);
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L2/event"));
|
||||
if (m === null) {
|
||||
throw new Error("expected L2/event match");
|
||||
}
|
||||
expect(m.line).toBe(2);
|
||||
});
|
||||
|
||||
it("resolves L4/event with line=4", () => {
|
||||
const { ast } = parseJsonl(logLocal);
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://session.jsonl/L4/event"));
|
||||
if (m === null) {
|
||||
throw new Error("expected L4/event match");
|
||||
}
|
||||
expect(m.line).toBe(4);
|
||||
});
|
||||
|
||||
it("findOcPaths over wildcard surfaces correct file-relative lines", () => {
|
||||
const { ast } = parseJsonl(logLocal);
|
||||
const matches = findOcPaths(ast, parseOcPath("oc://session.jsonl/*/event"));
|
||||
expect(matches).toHaveLength(4);
|
||||
const lines = matches.map((m) => m.match.line);
|
||||
expect(lines).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
164
extensions/oc-path/src/oc-path/tests/oc-path.test.ts
Normal file
164
extensions/oc-path/src/oc-path/tests/oc-path.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// OC Path tests cover oc path plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OcPathError, formatOcPath, isValidOcPath, parseOcPath } from "../oc-path.js";
|
||||
|
||||
describe("parseOcPath", () => {
|
||||
it("parses file-only path", () => {
|
||||
expect(parseOcPath("oc://SOUL.md")).toEqual({ file: "SOUL.md" });
|
||||
});
|
||||
|
||||
it("parses file + section", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/Boundaries")).toEqual({
|
||||
file: "SOUL.md",
|
||||
section: "Boundaries",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file + section + item", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/Boundaries/deny-rule-1")).toEqual({
|
||||
file: "SOUL.md",
|
||||
section: "Boundaries",
|
||||
item: "deny-rule-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses file + section + item + field", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/Boundaries/deny-rule-1/risk")).toEqual({
|
||||
file: "SOUL.md",
|
||||
section: "Boundaries",
|
||||
item: "deny-rule-1",
|
||||
field: "risk",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses session query", () => {
|
||||
expect(parseOcPath("oc://SOUL.md?session=daily-cron")).toEqual({
|
||||
file: "SOUL.md",
|
||||
session: "daily-cron",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reserved chars in session query values", () => {
|
||||
expectOcPathError(
|
||||
() => parseOcPath("oc://SOUL.md?session=cron%2Fdaily"),
|
||||
"OC_PATH_RESERVED_CHAR",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects control chars in session query values", () => {
|
||||
expectOcPathError(
|
||||
() => parseOcPath("oc://SOUL.md?session=daily\x00cron"),
|
||||
"OC_PATH_CONTROL_CHAR",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects control chars in ignored query values", () => {
|
||||
expectOcPathError(() => parseOcPath("oc://SOUL.md?ignored=\x00"), "OC_PATH_CONTROL_CHAR");
|
||||
});
|
||||
|
||||
it("rejects missing scheme", () => {
|
||||
expectOcPathError(() => parseOcPath("SOUL.md"), "OC_PATH_MISSING_SCHEME");
|
||||
});
|
||||
|
||||
it("rejects empty path after scheme", () => {
|
||||
expectOcPathError(() => parseOcPath("oc://"), "OC_PATH_EMPTY");
|
||||
});
|
||||
|
||||
it("rejects empty segment", () => {
|
||||
expectOcPathError(() => parseOcPath("oc://SOUL.md//deny-rule-1"), "OC_PATH_EMPTY_SEGMENT");
|
||||
});
|
||||
|
||||
it("rejects too-deep nesting", () => {
|
||||
expectOcPathError(() => parseOcPath("oc://SOUL.md/a/b/c/d/e"), "OC_PATH_TOO_DEEP");
|
||||
});
|
||||
|
||||
it("normalizes deep JSON paths into dotted subsegments", () => {
|
||||
expect(parseOcPath("oc://openclaw.json/agents/list/8/tools/exec/security")).toEqual({
|
||||
file: "openclaw.json",
|
||||
section: "agents.list.8.tools",
|
||||
item: "exec",
|
||||
field: "security",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-string input", () => {
|
||||
expectOcPathError(() => parseOcPath(123 as unknown as string), "OC_PATH_NOT_STRING");
|
||||
});
|
||||
});
|
||||
|
||||
function expectOcPathError(fn: () => unknown, expectedCode: string): void {
|
||||
try {
|
||||
fn();
|
||||
expect.fail(`expected OcPathError with code "${expectedCode}" but no error thrown`);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcPathError);
|
||||
expect((err as OcPathError).code).toBe(expectedCode);
|
||||
}
|
||||
}
|
||||
|
||||
describe("formatOcPath", () => {
|
||||
it("round-trips file-only", () => {
|
||||
expect(formatOcPath({ file: "SOUL.md" })).toBe("oc://SOUL.md");
|
||||
});
|
||||
|
||||
it("round-trips full nesting", () => {
|
||||
expect(
|
||||
formatOcPath({
|
||||
file: "SOUL.md",
|
||||
section: "Boundaries",
|
||||
item: "deny-rule-1",
|
||||
field: "risk",
|
||||
}),
|
||||
).toBe("oc://SOUL.md/Boundaries/deny-rule-1/risk");
|
||||
});
|
||||
|
||||
it("round-trips session", () => {
|
||||
expect(formatOcPath({ file: "SOUL.md", session: "cron" })).toBe("oc://SOUL.md?session=cron");
|
||||
});
|
||||
|
||||
it("rejects reserved chars in formatted session values", () => {
|
||||
expectOcPathError(
|
||||
() => formatOcPath({ file: "SOUL.md", session: "cron&scope=daily" }),
|
||||
"OC_PATH_RESERVED_CHAR",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects empty file", () => {
|
||||
expectOcPathError(() => formatOcPath({ file: "" }), "OC_PATH_FILE_REQUIRED");
|
||||
});
|
||||
|
||||
it("rejects item without section", () => {
|
||||
expectOcPathError(() => formatOcPath({ file: "F.md", item: "i" }), "OC_PATH_NESTING");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
const cases = [
|
||||
"oc://SOUL.md",
|
||||
"oc://SOUL.md/Boundaries",
|
||||
"oc://SOUL.md/Boundaries/deny-rule-1",
|
||||
"oc://SOUL.md/Boundaries/deny-rule-1/risk",
|
||||
"oc://SOUL.md?session=daily",
|
||||
"oc://AGENTS.md/Tools/gh/risk",
|
||||
];
|
||||
for (const input of cases) {
|
||||
it(`formatOcPath(parseOcPath("${input}")) === "${input}"`, () => {
|
||||
expect(formatOcPath(parseOcPath(input))).toBe(input);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("isValidOcPath", () => {
|
||||
it("returns true for valid paths", () => {
|
||||
expect(isValidOcPath("oc://SOUL.md")).toBe(true);
|
||||
expect(isValidOcPath("oc://SOUL.md/Boundaries")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid paths", () => {
|
||||
expect(isValidOcPath("SOUL.md")).toBe(false);
|
||||
expect(isValidOcPath("oc://")).toBe(false);
|
||||
expect(isValidOcPath(null)).toBe(false);
|
||||
expect(isValidOcPath(undefined)).toBe(false);
|
||||
expect(isValidOcPath(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
164
extensions/oc-path/src/oc-path/tests/parse.test.ts
Normal file
164
extensions/oc-path/src/oc-path/tests/parse.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// OC Path tests cover parse plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../parse.js";
|
||||
|
||||
describe("parseMd — frontmatter", () => {
|
||||
it("parses simple frontmatter", () => {
|
||||
const raw = `---
|
||||
name: github
|
||||
description: gh CLI for issues, PRs, runs
|
||||
---
|
||||
|
||||
Body text.
|
||||
`;
|
||||
const { ast, diagnostics } = parseMd(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(ast.frontmatter).toEqual([
|
||||
{ key: "name", value: "github", line: 2 },
|
||||
{ key: "description", value: "gh CLI for issues, PRs, runs", line: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles no frontmatter", () => {
|
||||
const raw = `## First section\n\nContent.\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
expect(ast.preamble).toBe("");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits diagnostic for unclosed frontmatter", () => {
|
||||
const raw = `---
|
||||
name: github
|
||||
description: never closes
|
||||
|
||||
Body.
|
||||
`;
|
||||
const { diagnostics } = parseMd(raw);
|
||||
expect(diagnostics).toStrictEqual([
|
||||
{
|
||||
line: 1,
|
||||
message: "frontmatter opens with --- but never closes",
|
||||
severity: "warning",
|
||||
code: "OC_FRONTMATTER_UNCLOSED",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips quotes from values", () => {
|
||||
const raw = `---
|
||||
title: "Hello world"
|
||||
hint: 'quoted'
|
||||
---
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.frontmatter[0]?.value).toBe("Hello world");
|
||||
expect(ast.frontmatter[1]?.value).toBe("quoted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMd — H2 blocks", () => {
|
||||
it("splits sections", () => {
|
||||
const raw = `Preamble text.
|
||||
|
||||
## First
|
||||
|
||||
Body of first.
|
||||
|
||||
## Second
|
||||
|
||||
Body of second.
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.preamble.trim()).toBe("Preamble text.");
|
||||
expect(ast.blocks.length).toBe(2);
|
||||
expect(ast.blocks[0]?.heading).toBe("First");
|
||||
expect(ast.blocks[0]?.slug).toBe("first");
|
||||
expect(ast.blocks[1]?.heading).toBe("Second");
|
||||
});
|
||||
|
||||
it("preserves line numbers (1-based)", () => {
|
||||
const raw = `Line 1
|
||||
## Heading at line 2
|
||||
Line 3
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.line).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT split on `## ` inside fenced code blocks", () => {
|
||||
const raw = `## Real section
|
||||
|
||||
\`\`\`md
|
||||
## Not a heading
|
||||
content
|
||||
\`\`\`
|
||||
|
||||
## Another section
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["Real section", "Another section"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMd — items", () => {
|
||||
it("extracts plain bullet items", () => {
|
||||
const raw = `## Boundaries
|
||||
|
||||
- never write to /etc
|
||||
- always confirm before deleting
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.items.length).toBe(2);
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("never write to /etc");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts kv items", () => {
|
||||
const raw = `## Tools
|
||||
|
||||
- gh: GitHub CLI
|
||||
- curl: HTTP client
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: "gh", value: "GitHub CLI" });
|
||||
expect(ast.blocks[0]?.items[0]?.slug).toBe("gh");
|
||||
expect(ast.blocks[0]?.items[1]?.kv).toEqual({ key: "curl", value: "HTTP client" });
|
||||
});
|
||||
|
||||
it("does NOT extract bullets inside fenced code", () => {
|
||||
const raw = `## Section
|
||||
|
||||
\`\`\`
|
||||
- not a bullet
|
||||
\`\`\`
|
||||
|
||||
- real bullet
|
||||
`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.items.length).toBe(1);
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("real bullet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMd — byte-fidelity", () => {
|
||||
it("preserves raw on the AST", () => {
|
||||
const raw = `---\nname: x\n---\n\n## Sec\n\n- a\n- b\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.raw).toBe(raw);
|
||||
});
|
||||
|
||||
it("preserves BOM in raw but ignores it for parsing", () => {
|
||||
const raw = "## Heading\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.raw).toBe(raw);
|
||||
expect(ast.blocks[0]?.heading).toBe("Heading");
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
const raw = "## Heading\r\n\r\n- item\r\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.heading).toBe("Heading");
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("item");
|
||||
});
|
||||
});
|
||||
101
extensions/oc-path/src/oc-path/tests/resolve.test.ts
Normal file
101
extensions/oc-path/src/oc-path/tests/resolve.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// OC Path tests cover resolve plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../parse.js";
|
||||
import { resolveMdOcPath as resolveOcPath } from "../resolve.js";
|
||||
|
||||
const SAMPLE = `---
|
||||
name: github
|
||||
description: gh CLI
|
||||
---
|
||||
|
||||
Preamble.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never write to /etc
|
||||
- deny: secrets
|
||||
|
||||
## Tools
|
||||
|
||||
- gh: GitHub CLI
|
||||
- curl: HTTP client
|
||||
`;
|
||||
|
||||
describe("resolveOcPath", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
|
||||
it("resolves root", () => {
|
||||
const m = resolveOcPath(ast, { file: "AGENTS.md" });
|
||||
expect(m?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("resolves block by slug", () => {
|
||||
const m = resolveOcPath(ast, { file: "AGENTS.md", section: "boundaries" });
|
||||
expect(m?.kind).toBe("block");
|
||||
if (m?.kind === "block") {
|
||||
expect(m.node.heading).toBe("Boundaries");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves item by slug", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "AGENTS.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
});
|
||||
expect(m?.kind).toBe("item");
|
||||
if (m?.kind === "item") {
|
||||
expect(m.node.kv?.value).toBe("GitHub CLI");
|
||||
expect(m.block.heading).toBe("Tools");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves item-field via kv", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "AGENTS.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
field: "gh",
|
||||
});
|
||||
expect(m?.kind).toBe("item-field");
|
||||
if (m?.kind === "item-field") {
|
||||
expect(m.value).toBe("GitHub CLI");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves frontmatter via [frontmatter] sentinel section", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "AGENTS.md",
|
||||
section: "[frontmatter]",
|
||||
field: "name",
|
||||
});
|
||||
expect(m?.kind).toBe("frontmatter");
|
||||
if (m?.kind === "frontmatter") {
|
||||
expect(m.node.value).toBe("github");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null for unknown section", () => {
|
||||
const m = resolveOcPath(ast, { file: "AGENTS.md", section: "nonexistent" });
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown item", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "AGENTS.md",
|
||||
section: "tools",
|
||||
item: "nonexistent",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for field on non-kv item", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "AGENTS.md",
|
||||
section: "boundaries",
|
||||
item: "never-write-to-etc",
|
||||
field: "risk",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// OC Path tests cover append multi agent plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsoncValue } from "../../jsonc/ast.js";
|
||||
import { appendJsonlOcPath } from "../../jsonl/edit.js";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
|
||||
function event(name: string, n: number): JsoncValue {
|
||||
return {
|
||||
kind: "object",
|
||||
entries: [
|
||||
{ key: "event", line: 0, value: { kind: "string", value: name } },
|
||||
{ key: "n", line: 0, value: { kind: "number", value: n } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("jsonl append + multi-agent session sim", () => {
|
||||
it("single agent appends 100 events in order", () => {
|
||||
let ast = parseJsonl("").ast;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ast = appendJsonlOcPath(ast, event("step", i));
|
||||
}
|
||||
const lines = emitJsonl(ast)
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(100);
|
||||
expect(JSON.parse(lines[0] ?? "")).toEqual({ event: "step", n: 0 });
|
||||
expect(JSON.parse(lines[99] ?? "")).toEqual({ event: "step", n: 99 });
|
||||
});
|
||||
|
||||
it("two agents alternating appends preserve interleave order", () => {
|
||||
let ast = parseJsonl("").ast;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const agent = i % 2 === 0 ? "a" : "b";
|
||||
ast = appendJsonlOcPath(ast, event(agent, i));
|
||||
}
|
||||
const lines = emitJsonl(ast)
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(10);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const expected = i % 2 === 0 ? "a" : "b";
|
||||
expect(JSON.parse(lines[i] ?? "").event).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("append after a malformed line preserves both", () => {
|
||||
let ast = parseJsonl('{"a":1}\nbroken\n').ast;
|
||||
ast = appendJsonlOcPath(ast, event("start", 1));
|
||||
const out = emitJsonl(ast);
|
||||
expect(out).toContain("broken");
|
||||
expect(out).toContain('"event":"start"');
|
||||
});
|
||||
|
||||
it("append to empty file produces a single value line", () => {
|
||||
let ast = parseJsonl("").ast;
|
||||
ast = appendJsonlOcPath(ast, event("first", 0));
|
||||
const out = emitJsonl(ast);
|
||||
expect(JSON.parse(out)).toEqual({ event: "first", n: 0 });
|
||||
});
|
||||
|
||||
it("append assigns line numbers monotonically", () => {
|
||||
let ast = parseJsonl("").ast;
|
||||
ast = appendJsonlOcPath(ast, event("a", 0));
|
||||
ast = appendJsonlOcPath(ast, event("b", 1));
|
||||
ast = appendJsonlOcPath(ast, event("c", 2));
|
||||
expect(ast.lines.map((l) => l.line)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("append after blank lines preserves line-number gaps correctly", () => {
|
||||
let ast = parseJsonl('{"a":1}\n\n\n').ast;
|
||||
ast = appendJsonlOcPath(ast, event("after", 0));
|
||||
// Existing lines: L1 value, L2 blank, L3 blank. Appended line is L4.
|
||||
expect(ast.lines.length).toBe(4);
|
||||
expect(ast.lines[3]?.line).toBe(4);
|
||||
});
|
||||
|
||||
it("1000-event session sim is deterministic", () => {
|
||||
let ast = parseJsonl("").ast;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
ast = appendJsonlOcPath(ast, event("e", i));
|
||||
}
|
||||
const lines = emitJsonl(ast)
|
||||
.split("\n")
|
||||
.filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(1000);
|
||||
expect(JSON.parse(lines[999] ?? "").n).toBe(999);
|
||||
});
|
||||
|
||||
it("append is non-mutating on the input AST", () => {
|
||||
const ast = parseJsonl('{"a":1}\n').ast;
|
||||
const before = JSON.stringify(ast);
|
||||
appendJsonlOcPath(ast, event("x", 0));
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
|
||||
it("append preserves prior raw bytes (renders new tail)", () => {
|
||||
let ast = parseJsonl('{"a":1}\n').ast;
|
||||
ast = appendJsonlOcPath(ast, event("b", 1));
|
||||
const out = emitJsonl(ast);
|
||||
const lines = out.split("\n");
|
||||
// First line content unchanged.
|
||||
expect(lines[0]).toContain('"a":1');
|
||||
// Second line is the new event.
|
||||
expect(JSON.parse(lines[1] ?? "")).toEqual({ event: "b", n: 1 });
|
||||
});
|
||||
|
||||
it("deterministic line-number assignment after malformed lines", () => {
|
||||
let ast = parseJsonl('{"a":1}\nbroken\n{"b":2}\n').ast;
|
||||
ast = appendJsonlOcPath(ast, event("c", 2));
|
||||
expect(ast.lines.map((l) => l.line)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// OC Path tests cover byte fidelity plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
function roundTrip(raw: string): string {
|
||||
const { ast } = parseMd(raw);
|
||||
return emitMd(ast);
|
||||
}
|
||||
|
||||
describe("byte-fidelity", () => {
|
||||
it("empty file", () => {
|
||||
expect(roundTrip("")).toBe("");
|
||||
});
|
||||
|
||||
it("whitespace-only file", () => {
|
||||
expect(roundTrip(" \n\n \n")).toBe(" \n\n \n");
|
||||
});
|
||||
|
||||
it("single newline", () => {
|
||||
expect(roundTrip("\n")).toBe("\n");
|
||||
});
|
||||
|
||||
it("file without trailing newline", () => {
|
||||
expect(roundTrip("## H\n- item")).toBe("## H\n- item");
|
||||
});
|
||||
|
||||
it("file with trailing newline", () => {
|
||||
expect(roundTrip("## H\n- item\n")).toBe("## H\n- item\n");
|
||||
});
|
||||
|
||||
it("file with multiple trailing newlines", () => {
|
||||
expect(roundTrip("## H\n- item\n\n\n")).toBe("## H\n- item\n\n\n");
|
||||
});
|
||||
|
||||
it("BOM at start", () => {
|
||||
const raw = "## Heading\n- item\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("CRLF line endings", () => {
|
||||
const raw = "## H\r\n\r\n- item\r\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("mixed line endings (CRLF + LF)", () => {
|
||||
const raw = "## H\r\n- item\n- another\r\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("tabs preserved in body", () => {
|
||||
const raw = "## H\n\n\tindented body\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("trailing whitespace on lines preserved", () => {
|
||||
const raw = "## Heading \n- item \n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("multiple consecutive blank lines preserved", () => {
|
||||
const raw = "## H\n\n\n\n- item\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("frontmatter only, no body", () => {
|
||||
const raw = "---\nname: x\n---\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("body only, no frontmatter, no headings", () => {
|
||||
const raw = "Just some prose.\nNo structure.\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("frontmatter + body + multiple sections", () => {
|
||||
const raw = `---
|
||||
name: github
|
||||
description: gh CLI
|
||||
---
|
||||
|
||||
Preamble.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never write to /etc
|
||||
|
||||
## Tools
|
||||
|
||||
- gh: GitHub CLI
|
||||
- curl: HTTP client
|
||||
`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("unicode content preserved", () => {
|
||||
const raw = "## Café Section\n\n- résumé item\n- 日本語\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("emoji preserved", () => {
|
||||
const raw = "## 🚀 Launch\n\n- ✅ ready\n- 🔒 secure\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("frontmatter with special chars in values", () => {
|
||||
const raw = `---\nurl: https://example.com:443/path?q=1&a=2\n---\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("file with mixed bullet markers (-, *, +)", () => {
|
||||
const raw = "## H\n\n- dash\n* star\n+ plus\n";
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("raw === parse(raw).raw === emitMd(parse(raw)) for 50 random shapes", () => {
|
||||
const inputs = [
|
||||
"",
|
||||
"\n",
|
||||
"## A\n",
|
||||
"## A\n## B\n",
|
||||
"---\n---\n",
|
||||
"---\nk: v\n---\n",
|
||||
"---\nk: v\n---\nbody\n",
|
||||
"## H\n- a\n- b\n## I\n- c\n",
|
||||
"\n",
|
||||
"\r\n",
|
||||
"\t\n",
|
||||
"plain\n",
|
||||
"`code`\n",
|
||||
"```\nfence\n```\n",
|
||||
"```ts\nconst x = 1;\n```\n",
|
||||
"| a | b |\n| - | - |\n| 1 | 2 |\n",
|
||||
"> quote\n",
|
||||
"# H1 not split\n## H2 split\n",
|
||||
"preamble\n## block\nbody\n",
|
||||
"preamble\n## block\nbody\n## block2\nbody2\n",
|
||||
"## h\n\n\n\n",
|
||||
" ## indented heading (not parsed)\n",
|
||||
"##NoSpace\n",
|
||||
"## With trailing spaces \n- item\n",
|
||||
"## H\n- nested\n - sub\n",
|
||||
"## H\n\n```md\n## inside code\n```\n",
|
||||
"---\na: 1\nb: \"two\"\nc: 'three'\n---\n",
|
||||
"---\nopen\nbut no close\n\nbody\n",
|
||||
"mixed\r\nline\nendings\r\n",
|
||||
"---\nname: bom\n---\nbody\n",
|
||||
"## h\n- k: v\n- k2: v2\n- plain\n",
|
||||
"## h\n\n| a | b |\n|---|---|\n",
|
||||
"## h\n```sql\nSELECT 1\n```\n",
|
||||
"## h\n\n- url: http://x.example.com:80/p?q=1\n",
|
||||
"## h\n\n- key: value with: colons\n",
|
||||
'## h\n\n- key: "quoted: value"\n',
|
||||
"## h\n\n- a-b: c-d\n",
|
||||
"## h with `inline code`\n",
|
||||
"no blocks\nat all\n",
|
||||
"No body or section\n\n\n\n",
|
||||
" \n \n",
|
||||
"## h\n## h2\n## h3\n",
|
||||
"##\n", // empty heading
|
||||
"## \n", // heading whitespace only
|
||||
"\n\n## h\n\n\n",
|
||||
"---\n\n---\n",
|
||||
"## h\n- \n", // empty bullet
|
||||
"## h\n\n\n```\nempty fence body\n```\n",
|
||||
"## h\n```\nunclosed fence",
|
||||
"## empty section\n## next\n",
|
||||
"0\n",
|
||||
];
|
||||
for (const raw of inputs) {
|
||||
expect(roundTrip(raw), `failed on: ${JSON.stringify(raw.slice(0, 60))}`).toBe(raw);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// OC Path tests cover cross cutting plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { formatOcPath, parseOcPath } from "../../oc-path.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { resolveMdOcPath as resolveOcPath } from "../../resolve.js";
|
||||
|
||||
const SAMPLE = `---
|
||||
name: github
|
||||
description: gh CLI
|
||||
---
|
||||
|
||||
Preamble.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never write to /etc
|
||||
- always confirm
|
||||
|
||||
## Tools
|
||||
|
||||
- gh: GitHub CLI
|
||||
- curl: HTTP client
|
||||
`;
|
||||
|
||||
describe("cross-cutting", () => {
|
||||
it("parse → resolve → emit pipeline (block)", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
const m = resolveOcPath(ast, { file: "AGENTS.md", section: "boundaries" });
|
||||
expect(m?.kind).toBe("block");
|
||||
expect(emitMd(ast)).toBe(SAMPLE);
|
||||
});
|
||||
|
||||
it("OcPath round-trip via AST: parse + resolve + format", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
for (const block of ast.blocks) {
|
||||
const path = parseOcPath(`oc://AGENTS.md/${block.slug}`);
|
||||
const m = resolveOcPath(ast, path);
|
||||
expect(m?.kind, `block ${block.slug} should resolve`).toBe("block");
|
||||
// Format the same path back; slug → URI shape should be stable.
|
||||
expect(formatOcPath(path)).toBe(`oc://AGENTS.md/${block.slug}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("every item in every block is OcPath-addressable", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
for (const block of ast.blocks) {
|
||||
for (const item of block.items) {
|
||||
const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}`);
|
||||
const m = resolveOcPath(ast, path);
|
||||
expect(m?.kind, `${block.slug}/${item.slug} should resolve`).toBe("item");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every kv item field is OcPath-addressable", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
for (const block of ast.blocks) {
|
||||
for (const item of block.items) {
|
||||
if (!item.kv) {
|
||||
continue;
|
||||
}
|
||||
const path = parseOcPath(`oc://AGENTS.md/${block.slug}/${item.slug}/${item.kv.key}`);
|
||||
const m = resolveOcPath(ast, path);
|
||||
expect(m?.kind).toBe("item-field");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("every frontmatter entry is OcPath-addressable", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
for (const fm of ast.frontmatter) {
|
||||
const path = parseOcPath(`oc://AGENTS.md/[frontmatter]/${fm.key}`);
|
||||
const m = resolveOcPath(ast, path);
|
||||
expect(m?.kind).toBe("frontmatter");
|
||||
}
|
||||
});
|
||||
|
||||
it("slugs are stable across re-parses (deterministic)", () => {
|
||||
const a1 = parseMd(SAMPLE).ast;
|
||||
const a2 = parseMd(SAMPLE).ast;
|
||||
expect(a1.blocks.map((b) => b.slug)).toEqual(a2.blocks.map((b) => b.slug));
|
||||
expect(a1.blocks.map((b) => b.items.map((i) => i.slug))).toEqual(
|
||||
a2.blocks.map((b) => b.items.map((i) => i.slug)),
|
||||
);
|
||||
});
|
||||
|
||||
it("modifying raw + re-parse produces consistent AST shape", () => {
|
||||
const a1 = parseMd(SAMPLE).ast;
|
||||
const modified = SAMPLE.replace("GitHub CLI", "GitHub command-line interface");
|
||||
const a2 = parseMd(modified).ast;
|
||||
// Block + item count + slugs unchanged.
|
||||
expect(a2.blocks.length).toBe(a1.blocks.length);
|
||||
const a1Tools = a1.blocks.find((b) => b.slug === "tools");
|
||||
const a2Tools = a2.blocks.find((b) => b.slug === "tools");
|
||||
expect(a2Tools?.items.length).toBe(a1Tools?.items.length);
|
||||
// KV value reflects the change.
|
||||
const ghItem = a2Tools?.items.find((i) => i.kv?.key === "gh");
|
||||
expect(ghItem?.kv?.value).toBe("GitHub command-line interface");
|
||||
});
|
||||
|
||||
it("unknown OcPath returns null without affecting subsequent valid resolves", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
expect(resolveOcPath(ast, { file: "X.md", section: "nonexistent" })).toBeNull();
|
||||
expect(resolveOcPath(ast, { file: "X.md", section: "tools" })?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("resolve does not depend on file segment matching", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
const a = resolveOcPath(ast, { file: "A.md", section: "tools" });
|
||||
const b = resolveOcPath(ast, { file: "B.md", section: "tools" });
|
||||
expect(a?.kind).toBe(b?.kind);
|
||||
});
|
||||
|
||||
it("round-trip across all 9 valid OcPath shapes", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
const cases = [
|
||||
{ file: "X.md" },
|
||||
{ file: "X.md", section: "tools" },
|
||||
{ file: "X.md", section: "tools", item: "gh" },
|
||||
{ file: "X.md", section: "tools", item: "gh", field: "gh" },
|
||||
{ file: "X.md", section: "[frontmatter]", field: "name" },
|
||||
{ file: "X.md", section: "boundaries" },
|
||||
{ file: "X.md", section: "boundaries", item: "never-write-to-etc" },
|
||||
{ file: "X.md", section: "boundaries", item: "always-confirm" },
|
||||
{ file: "X.md", section: "[frontmatter]", field: "description" },
|
||||
];
|
||||
for (const path of cases) {
|
||||
const m = resolveOcPath(ast, path);
|
||||
if (m === null) {
|
||||
throw new Error(`failed for ${JSON.stringify(path)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// OC Path tests cover cross kind properties plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferKind } from "../../dispatch.js";
|
||||
import { setMdOcPath } from "../../edit.js";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { setJsoncOcPath } from "../../jsonc/edit.js";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { resolveJsoncOcPath } from "../../jsonc/resolve.js";
|
||||
import { setJsonlOcPath } from "../../jsonl/edit.js";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { resolveJsonlOcPath } from "../../jsonl/resolve.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { resolveMdOcPath } from "../../resolve.js";
|
||||
|
||||
describe("cross-kind property invariants", () => {
|
||||
const mdRaw = "---\nname: x\n---\n\n## Boundaries\n\n- enabled: true\n";
|
||||
const jsoncRaw = '// h\n{ "k": 1, "n": [1,2,3] }\n';
|
||||
const jsonlRaw = '{"a":1}\n\nbroken\n{"b":2}\n';
|
||||
|
||||
it("round-trip parse → emit is byte-stable across all kinds", () => {
|
||||
expect(emitMd(parseMd(mdRaw).ast)).toBe(mdRaw);
|
||||
expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(jsoncRaw);
|
||||
expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(jsonlRaw);
|
||||
});
|
||||
|
||||
it("resolve is non-mutating across all kinds", () => {
|
||||
const md = parseMd(mdRaw).ast;
|
||||
let before = JSON.stringify(md);
|
||||
resolveMdOcPath(md, parseOcPath("oc://X/[frontmatter]/name"));
|
||||
resolveMdOcPath(md, parseOcPath("oc://X/boundaries"));
|
||||
expect(JSON.stringify(md)).toBe(before);
|
||||
|
||||
const jsonc = parseJsonc(jsoncRaw).ast;
|
||||
before = JSON.stringify(jsonc);
|
||||
resolveJsoncOcPath(jsonc, parseOcPath("oc://X/k"));
|
||||
resolveJsoncOcPath(jsonc, parseOcPath("oc://X/n.0"));
|
||||
expect(JSON.stringify(jsonc)).toBe(before);
|
||||
|
||||
const jsonl = parseJsonl(jsonlRaw).ast;
|
||||
before = JSON.stringify(jsonl);
|
||||
resolveJsonlOcPath(jsonl, parseOcPath("oc://X/L1"));
|
||||
resolveJsonlOcPath(jsonl, parseOcPath("oc://X/$last"));
|
||||
expect(JSON.stringify(jsonl)).toBe(before);
|
||||
});
|
||||
|
||||
it("unresolvable set never throws across all kinds", () => {
|
||||
const ocPath = parseOcPath("oc://X/totally.missing.path");
|
||||
expect(() => setMdOcPath(parseMd(mdRaw).ast, ocPath, "x")).not.toThrow();
|
||||
expect(() =>
|
||||
setJsoncOcPath(parseJsonc(jsoncRaw).ast, ocPath, {
|
||||
kind: "string",
|
||||
value: "x",
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
setJsonlOcPath(parseJsonl(jsonlRaw).ast, ocPath, {
|
||||
kind: "string",
|
||||
value: "x",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("inferKind aligns with the parser actually used", () => {
|
||||
expect(inferKind("AGENTS.md")).toBe("md");
|
||||
expect(inferKind("SOUL.md")).toBe("md");
|
||||
expect(inferKind("config.jsonc")).toBe("jsonc");
|
||||
expect(inferKind("plugins.json")).toBe("jsonc");
|
||||
expect(inferKind("events.jsonl")).toBe("jsonl");
|
||||
expect(inferKind("audit.ndjson")).toBe("jsonl");
|
||||
});
|
||||
|
||||
it("parse → emit → parse is fixpoint across all kinds", () => {
|
||||
const md1 = emitMd(parseMd(mdRaw).ast);
|
||||
const md2 = emitMd(parseMd(md1).ast);
|
||||
expect(md1).toBe(md2);
|
||||
|
||||
const jc1 = emitJsonc(parseJsonc(jsoncRaw).ast);
|
||||
const jc2 = emitJsonc(parseJsonc(jc1).ast);
|
||||
expect(jc1).toBe(jc2);
|
||||
|
||||
const jl1 = emitJsonl(parseJsonl(jsonlRaw).ast);
|
||||
const jl2 = emitJsonl(parseJsonl(jl1).ast);
|
||||
expect(jl1).toBe(jl2);
|
||||
});
|
||||
|
||||
it("hostile inputs do not throw at parse time across all kinds", () => {
|
||||
const hostile = [
|
||||
"\x00\x01\x02 binary garbage",
|
||||
'{ "unclosed":',
|
||||
"## heading without anything",
|
||||
"\n\n\n\n\n",
|
||||
];
|
||||
for (const raw of hostile) {
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
expect(() => parseJsonc(raw)).not.toThrow();
|
||||
expect(() => parseJsonl(raw)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolver returns null for paths past valid kinds (no throw)", () => {
|
||||
const overlong = parseOcPath("oc://X/a/b/c.d.e.f.g.h");
|
||||
expect(() => resolveMdOcPath(parseMd(mdRaw).ast, overlong)).not.toThrow();
|
||||
expect(() => resolveJsoncOcPath(parseJsonc(jsoncRaw).ast, overlong)).not.toThrow();
|
||||
expect(() => resolveJsonlOcPath(parseJsonl(jsonlRaw).ast, overlong)).not.toThrow();
|
||||
});
|
||||
|
||||
it("set-then-resolve produces the value just written (jsonc)", () => {
|
||||
const ast = parseJsonc('{ "k": 1 }').ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://X/k"), {
|
||||
kind: "number",
|
||||
value: 42,
|
||||
});
|
||||
if (r.ok) {
|
||||
const m = resolveJsoncOcPath(r.ast, parseOcPath("oc://X/k"));
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.value.kind).toBe("number");
|
||||
if (m.node.value.kind === "number") {
|
||||
expect(m.node.value.value).toBe(42);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("verbs are deterministic — same input twice produces same output", () => {
|
||||
expect(emitMd(parseMd(mdRaw).ast)).toBe(emitMd(parseMd(mdRaw).ast));
|
||||
expect(emitJsonc(parseJsonc(jsoncRaw).ast)).toBe(emitJsonc(parseJsonc(jsoncRaw).ast));
|
||||
expect(emitJsonl(parseJsonl(jsonlRaw).ast)).toBe(emitJsonl(parseJsonl(jsonlRaw).ast));
|
||||
});
|
||||
|
||||
it("inferKind returns null for unknown extensions", () => {
|
||||
expect(inferKind("binary.bin")).toBeNull();
|
||||
expect(inferKind("no-ext")).toBeNull();
|
||||
expect(inferKind("archive.tar.gz")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
// OC Path tests cover edit emit roundtrip plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setMdOcPath } from "../../edit.js";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { setJsoncOcPath } from "../../jsonc/edit.js";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { resolveJsoncOcPath } from "../../jsonc/resolve.js";
|
||||
import { setJsonlOcPath } from "../../jsonl/edit.js";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
describe("edit-then-emit round-trip", () => {
|
||||
it("md frontmatter edit re-parses to the new value", () => {
|
||||
const md = parseMd("---\nname: old\n---\n\n## Body\n").ast;
|
||||
const r = setMdOcPath(md, parseOcPath("oc://AGENTS.md/[frontmatter]/name"), "new");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const reparsed = parseMd(r.ast.raw).ast;
|
||||
expect(reparsed.frontmatter.find((e) => e.key === "name")?.value).toBe("new");
|
||||
}
|
||||
});
|
||||
|
||||
it("md item kv edit re-parses to the new value", () => {
|
||||
const md = parseMd("## Boundaries\n\n- timeout: 5\n").ast;
|
||||
const r = setMdOcPath(md, parseOcPath("oc://AGENTS.md/boundaries/timeout/timeout"), "60");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const reparsed = parseMd(emitMd(r.ast)).ast;
|
||||
const block = reparsed.blocks.find((b) => b.slug === "boundaries");
|
||||
expect(block?.items[0]?.kv?.value).toBe("60");
|
||||
}
|
||||
});
|
||||
|
||||
it("jsonc value edit re-parses to the new value", () => {
|
||||
const ast = parseJsonc('{ "k": 1 }').ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/k"), {
|
||||
kind: "number",
|
||||
value: 42,
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({ k: 42 });
|
||||
}
|
||||
});
|
||||
|
||||
it("jsonc nested edit preserves untouched siblings", () => {
|
||||
const ast = parseJsonc('{ "a": 1, "b": { "c": 2, "d": 3 }, "e": 4 }').ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/b.c"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
if (r.ok) {
|
||||
expect(JSON.parse(emitJsonc(r.ast))).toEqual({
|
||||
a: 1,
|
||||
b: { c: 99, d: 3 },
|
||||
e: 4,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("jsonl line edit re-parses to the new value at the same line", () => {
|
||||
const ast = parseJsonl('{"a":1}\n{"a":2}\n{"a":3}\n').ast;
|
||||
const r = setJsonlOcPath(ast, parseOcPath("oc://log/L2/a"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
if (r.ok) {
|
||||
const reparsed = parseJsonl(emitJsonl(r.ast)).ast;
|
||||
const line2 = reparsed.lines[1];
|
||||
expect(line2?.kind).toBe("value");
|
||||
if (line2?.kind === "value" && line2.value.kind === "object") {
|
||||
const entry = line2.value.entries.find((e) => e.key === "a");
|
||||
expect(entry?.value.kind).toBe("number");
|
||||
if (entry?.value.kind === "number") {
|
||||
expect(entry.value.value).toBe(99);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("jsonc edit composes: two sequential edits both land", () => {
|
||||
let ast = parseJsonc('{ "a": 1, "b": 2 }').ast;
|
||||
let r = setJsoncOcPath(ast, parseOcPath("oc://config/a"), {
|
||||
kind: "number",
|
||||
value: 10,
|
||||
});
|
||||
if (r.ok) {
|
||||
ast = r.ast;
|
||||
}
|
||||
r = setJsoncOcPath(ast, parseOcPath("oc://config/b"), {
|
||||
kind: "number",
|
||||
value: 20,
|
||||
});
|
||||
if (r.ok) {
|
||||
ast = r.ast;
|
||||
}
|
||||
expect(JSON.parse(emitJsonc(ast))).toEqual({ a: 10, b: 20 });
|
||||
});
|
||||
|
||||
it("missing path returns structured failure (not throw)", () => {
|
||||
const ast = parseJsonc('{ "a": 1 }').ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/missing"), {
|
||||
kind: "number",
|
||||
value: 99,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
|
||||
it("each per-kind verb takes its own AST type — no cross-kind leakage", () => {
|
||||
// Type-level guarantee: each setter only accepts its kind's AST.
|
||||
// Caller picks based on the AST they have. This is the design.
|
||||
const md = parseMd("---\nx: 1\n---\n").ast;
|
||||
const jsonc = parseJsonc('{"x":1}').ast;
|
||||
const jsonl = parseJsonl('{"x":1}\n').ast;
|
||||
|
||||
const a = setMdOcPath(md, parseOcPath("oc://X/[frontmatter]/x"), "2");
|
||||
const b = setJsoncOcPath(jsonc, parseOcPath("oc://X/x"), {
|
||||
kind: "number",
|
||||
value: 2,
|
||||
});
|
||||
const c = setJsonlOcPath(jsonl, parseOcPath("oc://X/L1/x"), {
|
||||
kind: "number",
|
||||
value: 2,
|
||||
});
|
||||
|
||||
expect(a.ok).toBe(true);
|
||||
expect(b.ok).toBe(true);
|
||||
expect(c.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("jsonc parser-backed edit preserves comments", () => {
|
||||
const raw = '{\n "k": 1 // comment\n}\n';
|
||||
const ast = parseJsonc(raw).ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/k"), {
|
||||
kind: "number",
|
||||
value: 2,
|
||||
});
|
||||
if (r.ok) {
|
||||
expect(emitJsonc(r.ast)).toContain("// comment");
|
||||
const reparsed = resolveJsoncOcPath(r.ast, parseOcPath("oc://config/k"));
|
||||
expect(reparsed?.kind).toBe("object-entry");
|
||||
if (reparsed?.kind === "object-entry") {
|
||||
expect(reparsed.node.value.kind).toBe("number");
|
||||
if (reparsed.node.value.kind === "number") {
|
||||
expect(reparsed.node.value.value).toBe(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("edit on empty AST surfaces no-root", () => {
|
||||
const ast = parseJsonc("").ast;
|
||||
const r = setJsoncOcPath(ast, parseOcPath("oc://config/x"), {
|
||||
kind: "number",
|
||||
value: 1,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("no-root");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// OC Path tests cover frontmatter edges plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
describe("frontmatter-edges", () => {
|
||||
it("simple kv pairs", () => {
|
||||
const { ast } = parseMd("---\nname: x\ndescription: y\n---\n");
|
||||
expect(ast.frontmatter.map((e) => [e.key, e.value])).toEqual([
|
||||
["name", "x"],
|
||||
["description", "y"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("unclosed frontmatter emits diagnostic, treats as preamble", () => {
|
||||
const { ast, diagnostics } = parseMd("---\nname: x\nno close fence\nbody\n");
|
||||
expect(diagnostics.some((d) => d.code === "OC_FRONTMATTER_UNCLOSED")).toBe(true);
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("empty frontmatter (just open + close)", () => {
|
||||
const { ast } = parseMd("---\n---\n");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("frontmatter only, file has no other content", () => {
|
||||
const { ast } = parseMd("---\nk: v\n---\n");
|
||||
expect(ast.frontmatter).toEqual([{ key: "k", value: "v", line: 2 }]);
|
||||
expect(ast.preamble).toBe("");
|
||||
expect(ast.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("double-quoted value", () => {
|
||||
const { ast } = parseMd('---\ntitle: "Hello, world"\n---\n');
|
||||
expect(ast.frontmatter[0]?.value).toBe("Hello, world");
|
||||
});
|
||||
|
||||
it("single-quoted value", () => {
|
||||
const { ast } = parseMd("---\ntitle: 'Hello, world'\n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("Hello, world");
|
||||
});
|
||||
|
||||
it("unquoted value with internal colons preserved", () => {
|
||||
const { ast } = parseMd("---\nurl: https://example.com:443/p\n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("https://example.com:443/p");
|
||||
});
|
||||
|
||||
it("empty value", () => {
|
||||
const { ast } = parseMd("---\nk:\n---\n");
|
||||
expect(ast.frontmatter[0]).toEqual({ key: "k", value: "", line: 2 });
|
||||
});
|
||||
|
||||
it("value with leading/trailing whitespace trimmed", () => {
|
||||
const { ast } = parseMd("---\nk: spaced \n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("spaced");
|
||||
});
|
||||
|
||||
it("list-style continuations are silently dropped (substrate stays opinion-free)", () => {
|
||||
const { ast } = parseMd("---\ntools:\n - gh\n - curl\n---\n");
|
||||
// The `tools:` key has an empty inline value; the list continuation
|
||||
// lines ` - gh` and ` - curl` don't match the kv regex and are
|
||||
// skipped. Lint rules can do their own structural reading of
|
||||
// frontmatter; the substrate does not.
|
||||
expect(ast.frontmatter.map((e) => e.key)).toEqual(["tools"]);
|
||||
expect(ast.frontmatter[0]?.value).toBe("");
|
||||
});
|
||||
|
||||
it("line numbers are 1-based and accurate", () => {
|
||||
const { ast } = parseMd("---\nk1: v1\nk2: v2\nk3: v3\n---\n");
|
||||
expect(ast.frontmatter.map((e) => [e.key, e.line])).toEqual([
|
||||
["k1", 2],
|
||||
["k2", 3],
|
||||
["k3", 4],
|
||||
]);
|
||||
});
|
||||
|
||||
it("dash-key allowed", () => {
|
||||
const { ast } = parseMd("---\nuser-invocable: true\n---\n");
|
||||
expect(ast.frontmatter[0]?.key).toBe("user-invocable");
|
||||
});
|
||||
|
||||
it("underscore-key allowed", () => {
|
||||
const { ast } = parseMd("---\nparam_set: foo\n---\n");
|
||||
expect(ast.frontmatter[0]?.key).toBe("param_set");
|
||||
});
|
||||
|
||||
it("number-only value preserved as string", () => {
|
||||
const { ast } = parseMd("---\ntimeout: 15000\n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("15000");
|
||||
});
|
||||
|
||||
it("boolean-like value preserved as string", () => {
|
||||
const { ast } = parseMd("---\nenabled: true\n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("true");
|
||||
});
|
||||
|
||||
it("blank lines inside frontmatter are skipped", () => {
|
||||
const { ast } = parseMd("---\n\nk1: v1\n\nk2: v2\n\n---\n");
|
||||
expect(ast.frontmatter.map((e) => e.key)).toEqual(["k1", "k2"]);
|
||||
});
|
||||
|
||||
it("frontmatter with same key twice — both retained (no dedup)", () => {
|
||||
const { ast } = parseMd("---\nk: v1\nk: v2\n---\n");
|
||||
expect(ast.frontmatter).toEqual([
|
||||
{ key: "k", value: "v1", line: 2 },
|
||||
{ key: "k", value: "v2", line: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("frontmatter must be at start — leading blank line breaks detection", () => {
|
||||
const { ast } = parseMd("\n---\nk: v\n---\n");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("frontmatter must be at start — leading text breaks detection", () => {
|
||||
const { ast } = parseMd("intro\n\n---\nk: v\n---\n");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("BOM before frontmatter open is tolerated", () => {
|
||||
const { ast } = parseMd("---\nname: bom\n---\n");
|
||||
expect(ast.frontmatter[0]?.value).toBe("bom");
|
||||
});
|
||||
|
||||
it("single-line file with `---` and `---` is empty frontmatter", () => {
|
||||
const { ast } = parseMd("---\n---");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("hash-prefixed lines skipped (don't match kv regex)", () => {
|
||||
const { ast } = parseMd("---\n# comment\nk: v\n---\n");
|
||||
expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
// OC Path tests cover h2 block split plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
describe("h2-block-split", () => {
|
||||
it("no headings → no blocks, all preamble", () => {
|
||||
const raw = "Just prose, no headings.\nMore prose.\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks).toEqual([]);
|
||||
expect(ast.preamble).toBe("Just prose, no headings.\nMore prose.\n");
|
||||
});
|
||||
|
||||
it("single heading splits preamble + one block", () => {
|
||||
const { ast } = parseMd("preamble\n## Section\nbody\n");
|
||||
expect(ast.preamble.trim()).toBe("preamble");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("Section");
|
||||
expect(ast.blocks[0]?.bodyText.trim()).toBe("body");
|
||||
});
|
||||
|
||||
it("multiple headings produce blocks in order", () => {
|
||||
const { ast } = parseMd("## A\nbody-a\n## B\nbody-b\n## C\nbody-c\n");
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("H1 does NOT split", () => {
|
||||
const { ast } = parseMd("# H1 heading\n## H2 heading\n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("H2 heading");
|
||||
expect(ast.preamble).toContain("# H1 heading");
|
||||
});
|
||||
|
||||
it("H3 does NOT split", () => {
|
||||
const { ast } = parseMd("## H2\nbody\n### H3\nstill in H2 block\n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.bodyText).toContain("### H3");
|
||||
});
|
||||
|
||||
it("`## ` inside fenced code block does NOT split", () => {
|
||||
const raw = "## Real\n\n```md\n## Inside code\n```\n\n## Another real\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["Real", "Another real"]);
|
||||
});
|
||||
|
||||
it("`##` without trailing space — does NOT match (regex requires \\s+)", () => {
|
||||
const { ast } = parseMd("##NoSpace\n## With space\n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("With space");
|
||||
});
|
||||
|
||||
it("leading whitespace before `##` — recognized as heading (CommonMark)", () => {
|
||||
const { ast } = parseMd(" ## indented\n## not indented\n");
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["indented", "not indented"]);
|
||||
});
|
||||
|
||||
it("trailing whitespace on heading — trimmed in heading text", () => {
|
||||
const { ast } = parseMd("## Trailing \n");
|
||||
expect(ast.blocks[0]?.heading).toBe("Trailing");
|
||||
expect(ast.blocks[0]?.slug).toBe("trailing");
|
||||
});
|
||||
|
||||
it("inline code in heading preserved", () => {
|
||||
const { ast } = parseMd("## Use `gh` for GitHub\n");
|
||||
expect(ast.blocks[0]?.heading).toBe("Use `gh` for GitHub");
|
||||
});
|
||||
|
||||
it("markdown formatting in heading preserved", () => {
|
||||
const { ast } = parseMd("## **Bold** *italic*\n");
|
||||
expect(ast.blocks[0]?.heading).toBe("**Bold** *italic*");
|
||||
});
|
||||
|
||||
it("immediately after frontmatter", () => {
|
||||
const { ast } = parseMd("---\nk: v\n---\n## Section\nbody\n");
|
||||
expect(ast.blocks[0]?.heading).toBe("Section");
|
||||
expect(ast.preamble).toBe("");
|
||||
});
|
||||
|
||||
it("H2 at end of file (no body)", () => {
|
||||
const { ast } = parseMd("preamble\n## End\n");
|
||||
expect(ast.blocks[0]?.heading).toBe("End");
|
||||
expect(ast.blocks[0]?.bodyText).toBe("");
|
||||
});
|
||||
|
||||
it("two consecutive H2s — empty body block between", () => {
|
||||
const { ast } = parseMd("## A\n## B\n");
|
||||
expect(ast.blocks[0]?.bodyText).toBe("");
|
||||
expect(ast.blocks[1]?.heading).toBe("B");
|
||||
});
|
||||
|
||||
it("line numbers are 1-based and track through frontmatter", () => {
|
||||
const { ast } = parseMd("---\nk: v\n---\n## At line 4\n");
|
||||
expect(ast.blocks[0]?.line).toBe(4);
|
||||
});
|
||||
|
||||
it("line numbers track through preamble", () => {
|
||||
const { ast } = parseMd("line 1\nline 2\n## At line 3\n");
|
||||
expect(ast.blocks[0]?.line).toBe(3);
|
||||
});
|
||||
|
||||
it("nested fenced code blocks (~~~ vs ```) — only ``` is detected", () => {
|
||||
const raw = "## H\n\n~~~md\n~~~\n\n## Next\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["H", "Next"]);
|
||||
});
|
||||
|
||||
it("setext-style heading (`Heading\\n========\\n`) is NOT recognized", () => {
|
||||
const raw = "Heading\n=======\n## Real\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("Real");
|
||||
});
|
||||
|
||||
it("empty heading text (`## `)", () => {
|
||||
const { ast } = parseMd("## \n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("");
|
||||
expect(ast.blocks[0]?.slug).toBe("");
|
||||
});
|
||||
|
||||
it("heading with only whitespace (`## `)", () => {
|
||||
const { ast } = parseMd("## \n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("");
|
||||
});
|
||||
|
||||
it("heading-shaped text inside multi-line bullet body — does split", () => {
|
||||
const raw = "## Section\n- item starts\n continues\n## Next\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["Section", "Next"]);
|
||||
});
|
||||
});
|
||||
134
extensions/oc-path/src/oc-path/tests/scenarios/items.test.ts
Normal file
134
extensions/oc-path/src/oc-path/tests/scenarios/items.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// OC Path tests cover items plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
describe("items", () => {
|
||||
it("plain dash bullets", () => {
|
||||
const { ast } = parseMd("## H\n- a\n- b\n- c\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("star bullets", () => {
|
||||
const { ast } = parseMd("## H\n* a\n* b\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("plus bullets", () => {
|
||||
const { ast } = parseMd("## H\n+ a\n+ b\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("mixed bullet markers in same section", () => {
|
||||
const { ast } = parseMd("## H\n- dash\n* star\n+ plus\n");
|
||||
expect(ast.blocks[0]?.items.length).toBe(3);
|
||||
});
|
||||
|
||||
it("kv-shape items populate kv", () => {
|
||||
const { ast } = parseMd("## H\n- gh: GitHub CLI\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: "gh", value: "GitHub CLI" });
|
||||
});
|
||||
|
||||
it("plain item has no kv", () => {
|
||||
const { ast } = parseMd("## H\n- plain text\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("multiple colons — first colon is the kv split", () => {
|
||||
const { ast } = parseMd("## H\n- url: http://x.com:80/p\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toEqual({
|
||||
key: "url",
|
||||
value: "http://x.com:80/p",
|
||||
});
|
||||
});
|
||||
|
||||
it("colon with no space after is still kv", () => {
|
||||
const { ast } = parseMd("## H\n- key:value\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toEqual({ key: "key", value: "value" });
|
||||
});
|
||||
|
||||
it("quoted value preserved verbatim (no unquote at item layer)", () => {
|
||||
const { ast } = parseMd('## H\n- title: "quoted: value"\n');
|
||||
expect(ast.blocks[0]?.items[0]?.kv?.value).toBe('"quoted: value"');
|
||||
});
|
||||
|
||||
it("slug from kv key when kv present", () => {
|
||||
const { ast } = parseMd("## H\n- The Tool: description\n");
|
||||
expect(ast.blocks[0]?.items[0]?.slug).toBe("the-tool");
|
||||
});
|
||||
|
||||
it("slug from item text when no kv", () => {
|
||||
const { ast } = parseMd("## H\n- The Plain Item\n");
|
||||
expect(ast.blocks[0]?.items[0]?.slug).toBe("the-plain-item");
|
||||
});
|
||||
|
||||
it("items inside fenced code block are NOT extracted", () => {
|
||||
const raw = "## H\n```\n- not a bullet\n- still not\n```\n- real bullet\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.items.length).toBe(1);
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("real bullet");
|
||||
});
|
||||
|
||||
it("line numbers track through block body", () => {
|
||||
const { ast } = parseMd("## H\n- first\n- second\n- third\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.line)).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("trailing whitespace on bullet trimmed in text", () => {
|
||||
const { ast } = parseMd("## H\n- spaced \n");
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("spaced");
|
||||
});
|
||||
|
||||
it("empty bullet — recognized with empty text/slug", () => {
|
||||
const { ast } = parseMd("## H\n- \n- real\n");
|
||||
expect(ast.blocks[0]?.items.length).toBe(2);
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["", "real"]);
|
||||
});
|
||||
|
||||
it("indented bullet (sub-bullet) — recognized as item alongside parent", () => {
|
||||
const { ast } = parseMd("## H\n- top\n - sub\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["top", "sub"]);
|
||||
});
|
||||
|
||||
it("numbered list (1. item) — recognized as items", () => {
|
||||
const { ast } = parseMd("## H\n1. first\n2. second\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("items in a section with no body before — first item line is heading+1", () => {
|
||||
const { ast } = parseMd("## H\n- a\n");
|
||||
expect(ast.blocks[0]?.items[0]?.line).toBe(2);
|
||||
});
|
||||
|
||||
it("items spread across blocks are scoped to their block", () => {
|
||||
const { ast } = parseMd("## A\n- a1\n## B\n- b1\n- b2\n");
|
||||
expect(ast.blocks[0]?.items.length).toBe(1);
|
||||
expect(ast.blocks[1]?.items.length).toBe(2);
|
||||
expect(ast.blocks[1]?.items.map((i) => i.text)).toEqual(["b1", "b2"]);
|
||||
});
|
||||
|
||||
it("item with only-symbol kv key still parses", () => {
|
||||
const { ast } = parseMd("## H\n- API_KEY: secret-value\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toEqual({
|
||||
key: "API_KEY",
|
||||
value: "secret-value",
|
||||
});
|
||||
expect(ast.blocks[0]?.items[0]?.slug).toBe("api-key");
|
||||
});
|
||||
|
||||
it("item with empty kv value falls through to plain item", () => {
|
||||
const { ast } = parseMd("## H\n- key:\n");
|
||||
expect(ast.blocks[0]?.items[0]?.kv).toBeUndefined();
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("key:");
|
||||
});
|
||||
|
||||
it("bullet in preamble (before first H2) is NOT in any block", () => {
|
||||
const { ast } = parseMd("- preamble bullet\n## H\n- block bullet\n");
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["block bullet"]);
|
||||
expect(ast.preamble).toContain("- preamble bullet");
|
||||
});
|
||||
|
||||
it("bullet with internal markdown (italics, code) preserved in text", () => {
|
||||
const { ast } = parseMd("## H\n- use *gh* and `curl`\n");
|
||||
expect(ast.blocks[0]?.items[0]?.text).toBe("use *gh* and `curl`");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// OC Path tests cover jsonc byte fidelity plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsoncValue } from "../../jsonc/ast.js";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
|
||||
function rt(raw: string): string {
|
||||
return emitJsonc(parseJsonc(raw).ast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the parser actually produced a structural tree (not just a
|
||||
* `null` root with echoed `raw`). Without this, a parser that
|
||||
* delegated everything to `raw` would pass the byte-fidelity test
|
||||
* trivially. Returns the parsed root for follow-up structural asserts.
|
||||
*/
|
||||
function assertParseable(raw: string): JsoncValue {
|
||||
const result = parseJsonc(raw);
|
||||
if (result.ast.root === null) {
|
||||
throw new Error("expected parseable JSONC root");
|
||||
}
|
||||
return result.ast.root;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complement: malformed input round-trips bytes verbatim AND
|
||||
* emits an error diagnostic. JC-17 needs this — without the
|
||||
* diagnostic check, the test would pass even if the parser silently
|
||||
* dropped malformed content.
|
||||
*/
|
||||
function assertNotParseable(raw: string): void {
|
||||
const result = parseJsonc(raw);
|
||||
expect(result.ast.root).toBeNull();
|
||||
expect(result.diagnostics.some((d) => d.severity === "error")).toBe(true);
|
||||
}
|
||||
|
||||
describe("jsonc byte-fidelity", () => {
|
||||
it("empty file", () => {
|
||||
expect(rt("")).toBe("");
|
||||
});
|
||||
|
||||
it("whitespace-only", () => {
|
||||
expect(rt(" \n\n \n")).toBe(" \n\n \n");
|
||||
});
|
||||
|
||||
it("empty object", () => {
|
||||
expect(rt("{}")).toBe("{}");
|
||||
const root = assertParseable("{}");
|
||||
expect(root.kind).toBe("object");
|
||||
if (root.kind === "object") {
|
||||
expect(root.entries).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("empty array", () => {
|
||||
expect(rt("[]")).toBe("[]");
|
||||
const root = assertParseable("[]");
|
||||
expect(root.kind).toBe("array");
|
||||
if (root.kind === "array") {
|
||||
expect(root.items).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("trivial scalar root", () => {
|
||||
expect(rt("42")).toBe("42");
|
||||
expect(rt('"x"')).toBe('"x"');
|
||||
expect(rt("true")).toBe("true");
|
||||
expect(rt("null")).toBe("null");
|
||||
expect(assertParseable("42").kind).toBe("number");
|
||||
expect(assertParseable('"x"').kind).toBe("string");
|
||||
expect(assertParseable("true").kind).toBe("boolean");
|
||||
expect(assertParseable("null").kind).toBe("null");
|
||||
});
|
||||
|
||||
it("line comments preserved", () => {
|
||||
const raw = '// a leading comment\n{ "x": 1 } // trailing\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(assertParseable(raw).kind).toBe("object");
|
||||
});
|
||||
|
||||
it("block comments preserved", () => {
|
||||
const raw = '/* header */\n{\n /* inline */\n "x": 1\n}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
expect(root.kind).toBe("object");
|
||||
});
|
||||
|
||||
it("trailing commas preserved", () => {
|
||||
const raw = '{\n "x": 1,\n "y": 2,\n}';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
if (root.kind === "object") {
|
||||
expect(root.entries).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("mixed CRLF + LF preserved", () => {
|
||||
const raw = '{\r\n "x": 1,\n "y": 2\r\n}';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
if (root.kind === "object") {
|
||||
expect(root.entries.map((e) => e.key)).toEqual(["x", "y"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("BOM preserved on raw, stripped for parse", () => {
|
||||
const raw = '{ "x": 1 }';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(assertParseable(raw).kind).toBe("object");
|
||||
});
|
||||
|
||||
it("deeply nested structures preserved", () => {
|
||||
const raw = '{ "a": { "b": { "c": { "d": [1, [2, [3, [4]]]] } } } }';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(assertParseable(raw).kind).toBe("object");
|
||||
});
|
||||
|
||||
it("string with escape sequences preserved (parsed value has decoded chars)", () => {
|
||||
const raw = '{ "s": "a\\nb\\tc\\u0041\\\\d\\"e" }';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
if (root.kind === "object") {
|
||||
const s = root.entries[0]?.value;
|
||||
if (s?.kind === "string") {
|
||||
expect(s.value).toBe('a\nb\tcA\\d"e');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers in scientific / negative / decimal forms preserved", () => {
|
||||
const raw = "[ 0, -0, 1.5, -3.14, 1e3, -2.5e-10, 1E+5 ]";
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
if (root.kind === "array") {
|
||||
expect(root.items).toHaveLength(7);
|
||||
expect(root.items.every((v) => v.kind === "number")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("unicode characters preserved verbatim", () => {
|
||||
const raw = '{ "name": "héllo 世界 🎉" }';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
const root = assertParseable(raw);
|
||||
if (root.kind === "object") {
|
||||
const v = root.entries[0]?.value;
|
||||
if (v?.kind === "string") {
|
||||
expect(v.value).toBe("héllo 世界 🎉");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("idiosyncratic whitespace preserved", () => {
|
||||
const raw = '{ "x" : 1 ,\n "y": 2}';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(assertParseable(raw).kind).toBe("object");
|
||||
});
|
||||
|
||||
it("file-level trailing whitespace preserved", () => {
|
||||
const raw = '{ "x": 1 }\n\n\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(assertParseable(raw).kind).toBe("object");
|
||||
});
|
||||
|
||||
it("malformed input still emits raw verbatim AND emits a diagnostic", () => {
|
||||
const raw = '{ broken json with "key": value }';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
assertNotParseable(raw);
|
||||
});
|
||||
|
||||
it("comments-only file preserved", () => {
|
||||
const raw = "// just a comment\n/* and a block */\n";
|
||||
expect(rt(raw)).toBe(raw);
|
||||
expect(parseJsonc(raw).ast.root).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// OC Path tests cover jsonc resolver edges plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { resolveJsoncOcPath } from "../../jsonc/resolve.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
|
||||
function rs(raw: string, ocPath: string) {
|
||||
return resolveJsoncOcPath(parseJsonc(raw).ast, parseOcPath(ocPath));
|
||||
}
|
||||
|
||||
describe("jsonc resolver edges", () => {
|
||||
it("root resolves on empty object", () => {
|
||||
expect(rs("{}", "oc://config")?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("root resolves on scalar root", () => {
|
||||
expect(rs("42", "oc://config")?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("root resolves on array root", () => {
|
||||
expect(rs("[1,2,3]", "oc://config")?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("deep dotted descent within section", () => {
|
||||
const m = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("missing intermediate key returns null", () => {
|
||||
expect(rs('{"a":{"b":1}}', "oc://config/a.x.b")).toBeNull();
|
||||
});
|
||||
|
||||
it("numeric segment indexes into array", () => {
|
||||
const m = rs('{"items":["a","b","c"]}', "oc://config/items.1");
|
||||
expect(m?.kind).toBe("value");
|
||||
if (m?.kind === "value") {
|
||||
expect(m.node.kind).toBe("string");
|
||||
if (m.node.kind === "string") {
|
||||
expect(m.node.value).toBe("b");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("out-of-bounds array index returns null", () => {
|
||||
expect(rs('{"x":[1,2]}', "oc://config/x.99")).toBeNull();
|
||||
});
|
||||
|
||||
it("non-integer index returns null (no NaN coercion)", () => {
|
||||
expect(rs('{"x":[1,2]}', "oc://config/x.foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("null AST root returns null on any path", () => {
|
||||
expect(rs("", "oc://config/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("descending past a primitive returns null", () => {
|
||||
expect(rs('{"x":42}', "oc://config/x.y")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty segment in dotted path throws OcPathError", () => {
|
||||
// v1 invariant: malformed paths fail loud at parse time, not silently null.
|
||||
expect(() => rs('{"x":1}', "oc://config/x..y")).toThrow(/Empty dotted sub-segment/);
|
||||
});
|
||||
|
||||
it("string value at leaf surfaces via object-entry shape", () => {
|
||||
const m = rs('{"k":"v"}', "oc://config/k");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.key).toBe("k");
|
||||
}
|
||||
});
|
||||
|
||||
it("boolean and null values resolve", () => {
|
||||
const m1 = rs('{"k":true}', "oc://config/k");
|
||||
expect(m1?.kind).toBe("object-entry");
|
||||
const m2 = rs('{"k":null}', "oc://config/k");
|
||||
expect(m2?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("mixed slash + dot segments resolve identically", () => {
|
||||
const a = rs('{"a":{"b":{"c":1}}}', "oc://config/a.b.c");
|
||||
const b = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b.c");
|
||||
const c = rs('{"a":{"b":{"c":1}}}', "oc://config/a/b/c");
|
||||
expect(a?.kind).toBe(b?.kind);
|
||||
expect(b?.kind).toBe(c?.kind);
|
||||
});
|
||||
|
||||
it("keys with special characters resolve", () => {
|
||||
const m = rs('{"a-b_c":{"x":1}}', "oc://config/a-b_c.x");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("unicode keys resolve", () => {
|
||||
const m = rs('{"héllo":1}', "oc://config/héllo");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("large nested structure (depth 20) resolves to leaf", () => {
|
||||
let json = '"leaf"';
|
||||
const segs: string[] = [];
|
||||
for (let i = 19; i >= 0; i--) {
|
||||
json = `{"k${i}":${json}}`;
|
||||
segs.unshift(`k${i}`);
|
||||
}
|
||||
const m = rs(json, `oc://config/${segs.join(".")}`);
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expect(m.node.value.kind).toBe("string");
|
||||
if (m.node.value.kind === "string") {
|
||||
expect(m.node.value.value).toBe("leaf");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolver is non-mutating across calls", () => {
|
||||
const { ast } = parseJsonc('{"x":{"y":1}}');
|
||||
const before = JSON.stringify(ast);
|
||||
rs('{"x":{"y":1}}', "oc://config/x.y");
|
||||
rs('{"x":{"y":1}}', "oc://config/x");
|
||||
rs('{"x":{"y":1}}', "oc://config/missing");
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
|
||||
it("hostile input shapes do not throw", () => {
|
||||
expect(() => rs("{garbage}", "oc://config/x")).not.toThrow();
|
||||
expect(() => rs('{"a":', "oc://config/a")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// OC Path tests cover jsonl byte fidelity plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
|
||||
function rt(raw: string): string {
|
||||
return emitJsonl(parseJsonl(raw).ast);
|
||||
}
|
||||
|
||||
describe("jsonl byte-fidelity", () => {
|
||||
it("empty file", () => {
|
||||
expect(rt("")).toBe("");
|
||||
});
|
||||
|
||||
it("single line no trailing newline", () => {
|
||||
expect(rt('{"a":1}')).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it("single line with trailing newline", () => {
|
||||
expect(rt('{"a":1}\n')).toBe('{"a":1}\n');
|
||||
});
|
||||
|
||||
it("multiple lines preserved", () => {
|
||||
const raw = '{"a":1}\n{"b":2}\n{"c":3}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("blank line in the middle preserved", () => {
|
||||
const raw = '{"a":1}\n\n{"b":2}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("multiple blank lines preserved", () => {
|
||||
const raw = '{"a":1}\n\n\n{"b":2}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("malformed line round-trips verbatim", () => {
|
||||
const raw = '{"a":1}\nthis is not json\n{"b":2}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("entirely malformed file round-trips", () => {
|
||||
const raw = "header\nbody\nfooter\n";
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("leading + trailing blanks preserved", () => {
|
||||
const raw = '\n\n{"a":1}\n\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("file ending without final newline preserved", () => {
|
||||
const raw = '{"a":1}\n{"b":2}';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("nested object lines preserved", () => {
|
||||
const raw = '{"a":{"b":{"c":1}}}\n{"x":[1,[2,[3]]]}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("unicode in a value line preserved", () => {
|
||||
const raw = '{"name":"héllo 世界 🎉"}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("idiosyncratic whitespace inside a line preserved", () => {
|
||||
const raw = '{ "a" : 1 }\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("single blank line file preserved", () => {
|
||||
const raw = "\n";
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("large log (1000 lines) preserved", () => {
|
||||
const lines = Array.from({ length: 1000 }, (_, i) => `{"i":${i}}`);
|
||||
const raw = lines.join("\n") + "\n";
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("mixed value + malformed + blank preserved", () => {
|
||||
const raw = '{"a":1}\n{not json}\n\n{"b":2}\nstill not json\n{"c":3}\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
// F10 — CRLF preservation. Without lineEnding tracking on the AST,
|
||||
// a CRLF input edited via setJsonlOcPath rebuilds raw via render
|
||||
// which joins with `\n`, mixing endings on Windows-authored datasets.
|
||||
it("CRLF input round-trips byte-identical via the default emit", () => {
|
||||
const raw = '{"a":1}\r\n{"b":2}\r\n{"c":3}\r\n';
|
||||
expect(rt(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("CRLF input preserves CRLF after a structural edit (render mode)", () => {
|
||||
const raw = '{"a":1}\r\n{"b":2}\r\n';
|
||||
const { ast } = parseJsonl(raw);
|
||||
const rendered = emitJsonl(ast, { mode: "render" });
|
||||
expect(rendered).toBe('{"a":1}\r\n{"b":2}');
|
||||
expect((rendered.match(/\r\n/g) ?? []).length).toBe(1);
|
||||
expect((rendered.match(/(?<!\r)\n/g) ?? []).length).toBe(0);
|
||||
});
|
||||
|
||||
it("LF input preserves LF after a structural edit (render mode)", () => {
|
||||
// Symmetric: a Unix-authored log doesn't mysteriously gain CRLF.
|
||||
const raw = '{"a":1}\n{"b":2}\n';
|
||||
const { ast } = parseJsonl(raw);
|
||||
const rendered = emitJsonl(ast, { mode: "render" });
|
||||
expect(rendered).toBe('{"a":1}\n{"b":2}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// OC Path tests cover jsonl resolver edges plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { JsoncValue } from "../../jsonc/ast.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { resolveJsonlOcPath } from "../../jsonl/resolve.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
|
||||
function rs(raw: string, ocPath: string) {
|
||||
return resolveJsonlOcPath(parseJsonl(raw).ast, parseOcPath(ocPath));
|
||||
}
|
||||
|
||||
function expectNumberValue(node: JsoncValue, value: number) {
|
||||
expect(node.kind).toBe("number");
|
||||
if (node.kind === "number") {
|
||||
expect(node.value).toBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectStringValue(node: JsoncValue, value: string) {
|
||||
expect(node.kind).toBe("string");
|
||||
if (node.kind === "string") {
|
||||
expect(node.value).toBe(value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("jsonl resolver edges", () => {
|
||||
it("root resolves with no segments", () => {
|
||||
expect(rs('{"a":1}\n', "oc://log")?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("L1 resolves to a value line", () => {
|
||||
const m = rs('{"a":1}\n', "oc://log/L1");
|
||||
expect(m?.kind).toBe("line");
|
||||
});
|
||||
|
||||
it("L99 unknown line returns null", () => {
|
||||
expect(rs('{"a":1}\n', "oc://log/L99")).toBeNull();
|
||||
});
|
||||
|
||||
it("$last picks the most recent value line", () => {
|
||||
const m = rs('{"a":1}\n{"a":2}\n{"a":3}\n', "oc://log/$last/a");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expectNumberValue(m.node.value, 3);
|
||||
}
|
||||
});
|
||||
|
||||
it("$last skips trailing blank lines", () => {
|
||||
const m = rs('{"a":1}\n\n\n', "oc://log/$last/a");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expectNumberValue(m.node.value, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("$last skips trailing malformed lines", () => {
|
||||
const m = rs('{"a":1}\nbroken\n', "oc://log/$last/a");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
});
|
||||
|
||||
it("$last on empty file returns null", () => {
|
||||
expect(rs("", "oc://log/$last/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("$last on all-blank file returns null", () => {
|
||||
expect(rs("\n\n\n", "oc://log/$last/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("$last on all-malformed file returns null", () => {
|
||||
expect(rs("a\nb\nc\n", "oc://log/$last/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("garbage line address returns null", () => {
|
||||
expect(rs('{"a":1}\n', "oc://log/garbage")).toBeNull();
|
||||
expect(rs('{"a":1}\n', "oc://log/L")).toBeNull();
|
||||
expect(rs('{"a":1}\n', "oc://log/Labc")).toBeNull();
|
||||
});
|
||||
|
||||
it("descent into a blank line returns null", () => {
|
||||
expect(rs('{"a":1}\n\n{"b":2}\n', "oc://log/L2/anything")).toBeNull();
|
||||
});
|
||||
|
||||
it("descent into a malformed line returns null", () => {
|
||||
expect(rs('{"a":1}\nbroken\n{"b":2}\n', "oc://log/L2/anything")).toBeNull();
|
||||
});
|
||||
|
||||
it("missing field on a value line returns null", () => {
|
||||
expect(rs('{"a":1}\n', "oc://log/L1/missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("dotted descent through line value resolves", () => {
|
||||
const m = rs('{"r":{"ok":true,"d":"x"}}\n', "oc://log/L1/r.d");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expectStringValue(m.node.value, "x");
|
||||
}
|
||||
});
|
||||
|
||||
it("array index inside a line resolves", () => {
|
||||
const m = rs('{"items":["a","b","c"]}\n', "oc://log/L1/items.2");
|
||||
expect(m?.kind).toBe("value");
|
||||
if (m?.kind === "value") {
|
||||
expectStringValue(m.node, "c");
|
||||
}
|
||||
});
|
||||
|
||||
it("line numbers are 1-indexed", () => {
|
||||
const m = rs('{"a":1}\n{"a":2}\n', "oc://log/L1/a");
|
||||
if (m?.kind === "object-entry") {
|
||||
expectNumberValue(m.node.value, 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("line numbers preserved across blank/malformed entries", () => {
|
||||
const m = rs('{"a":1}\n\nbroken\n{"a":4}\n', "oc://log/L4/a");
|
||||
expect(m?.kind).toBe("object-entry");
|
||||
if (m?.kind === "object-entry") {
|
||||
expectNumberValue(m.node.value, 4);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolver is non-mutating", () => {
|
||||
const { ast } = parseJsonl('{"a":1}\n{"b":2}\n');
|
||||
const before = JSON.stringify(ast);
|
||||
rs('{"a":1}\n{"b":2}\n', "oc://log/L1");
|
||||
rs('{"a":1}\n{"b":2}\n', "oc://log/$last");
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
|
||||
it("hostile inputs do not throw", () => {
|
||||
expect(() => rs("not json\n", "oc://log/L1")).not.toThrow();
|
||||
expect(() => rs("", "oc://log/$last")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// OC Path tests cover malformed input plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
describe("malformed-input", () => {
|
||||
it("truncated mid-frontmatter (no close fence)", () => {
|
||||
const raw = "---\nname: github\n";
|
||||
const { ast, diagnostics } = parseMd(raw);
|
||||
expect(diagnostics.some((d) => d.code === "OC_FRONTMATTER_UNCLOSED")).toBe(true);
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("truncated mid-section", () => {
|
||||
const raw = "## H\n- item\nmid-line";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("only `---` (single fence, no content)", () => {
|
||||
expect(() => parseMd("---\n")).not.toThrow();
|
||||
});
|
||||
|
||||
it("only `---\\n---`", () => {
|
||||
const { ast } = parseMd("---\n---");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
});
|
||||
|
||||
it("binary-ish bytes (non-ASCII control chars)", () => {
|
||||
const raw = "## H\n\x00\x01\x02\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("very long single line (10k chars)", () => {
|
||||
const raw = `## H\n${"x".repeat(10_000)}\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.heading).toBe("H");
|
||||
});
|
||||
|
||||
it("deeply repeated headings (1000 H2 blocks)", () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
lines.push(`## H${i}`);
|
||||
lines.push(`- item ${i}`);
|
||||
}
|
||||
const raw = lines.join("\n") + "\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.length).toBe(1000);
|
||||
});
|
||||
|
||||
it("bullet shape that isn't actually a bullet (`-not-a-bullet`)", () => {
|
||||
const { ast } = parseMd("## H\n-not-a-bullet\n- real\n");
|
||||
expect(ast.blocks[0]?.items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("unclosed code fence", () => {
|
||||
const raw = "## H\n```\nbody\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("mismatched fence (open with ``` close with ~~~)", () => {
|
||||
const raw = "## H\n```\nbody\n~~~\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("nested fences (treated linearly, not nested)", () => {
|
||||
const raw = "## H\n```\n```\nstill-in-second\n```\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("empty file", () => {
|
||||
const { ast, diagnostics } = parseMd("");
|
||||
expect(ast.raw).toBe("");
|
||||
expect(ast.frontmatter).toEqual([]);
|
||||
expect(ast.blocks).toEqual([]);
|
||||
expect(diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("single character file", () => {
|
||||
const { ast } = parseMd("x");
|
||||
expect(ast.preamble).toBe("x");
|
||||
expect(ast.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("single newline file", () => {
|
||||
const { ast } = parseMd("\n");
|
||||
expect(ast.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("file with mixed indentation extremes (tabs, spaces, mixed)", () => {
|
||||
const raw = "## H\n\t- tabbed\n - spaced\n\t - mixed\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("frontmatter with frontmatter-shaped content inside (---)", () => {
|
||||
const raw = "---\nk: v\n---\n\n---\nshould not parse as second frontmatter\n---\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.frontmatter.map((e) => e.key)).toEqual(["k"]);
|
||||
// Second `---` block becomes part of preamble/body (it's not at file start).
|
||||
expect(ast.preamble).toContain("---");
|
||||
});
|
||||
|
||||
it("lines starting with `#` but not heading (raw `#` chars in body)", () => {
|
||||
const raw = "## H\n\n# This is text starting with #\n#### h4 not parsed as block\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.bodyText).toContain("# This is text");
|
||||
});
|
||||
|
||||
it("lines starting with multiple ## but malformed (####, ######)", () => {
|
||||
const { ast } = parseMd("## Real\n#### Not block\n###### Not block\n");
|
||||
expect(ast.blocks.length).toBe(1);
|
||||
expect(ast.blocks[0]?.heading).toBe("Real");
|
||||
});
|
||||
|
||||
it("file with just whitespace", () => {
|
||||
expect(() => parseMd(" \n\t\n \n")).not.toThrow();
|
||||
});
|
||||
|
||||
it("file with only BOM", () => {
|
||||
const { ast } = parseMd("");
|
||||
expect(ast.raw).toBe("");
|
||||
});
|
||||
|
||||
it("file mixing BOM + frontmatter + body + sections", () => {
|
||||
const raw = "---\nk: v\n---\n\nbody\n## Section\n- item\n";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.frontmatter[0]?.value).toBe("v");
|
||||
expect(ast.blocks[0]?.heading).toBe("Section");
|
||||
});
|
||||
|
||||
it("line endings: legacy CR-only (Mac classic)", () => {
|
||||
// Our regex /\r?\n/ doesn't split on CR-only. Treats whole as one line.
|
||||
const raw = "line1\rline2\r## Heading\r";
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
|
||||
it("100 KB file", () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
lines.push("## H" + i);
|
||||
for (let j = 0; j < 5; j++) {
|
||||
lines.push(`- item-${i}-${j}: value with some text content here`);
|
||||
}
|
||||
}
|
||||
const raw = lines.join("\n");
|
||||
expect(() => parseMd(raw)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// OC Path tests cover oc path parse edges plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
OcPathError,
|
||||
formatOcPath,
|
||||
getPathLayout,
|
||||
isPattern,
|
||||
isValidOcPath,
|
||||
parseOcPath,
|
||||
} from "../../oc-path.js";
|
||||
|
||||
function expectErr(fn: () => unknown, code: string): void {
|
||||
try {
|
||||
fn();
|
||||
expect.fail(`expected OcPathError code ${code}`);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcPathError);
|
||||
expect((err as OcPathError).code).toBe(code);
|
||||
}
|
||||
}
|
||||
|
||||
describe("oc-path-parse-edges", () => {
|
||||
it("file-only", () => {
|
||||
expect(parseOcPath("oc://SOUL.md")).toEqual({ file: "SOUL.md" });
|
||||
});
|
||||
|
||||
it("file + section", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/Boundaries").section).toBe("Boundaries");
|
||||
});
|
||||
|
||||
it("file + section + item", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/Boundaries/deny-rule-1").item).toBe("deny-rule-1");
|
||||
});
|
||||
|
||||
it("file + section + item + field", () => {
|
||||
expect(parseOcPath("oc://SOUL.md/B/deny-1/risk").field).toBe("risk");
|
||||
});
|
||||
|
||||
it("session query parameter", () => {
|
||||
expect(parseOcPath("oc://X.md?session=daily").session).toBe("daily");
|
||||
});
|
||||
|
||||
it("session with full path", () => {
|
||||
const p = parseOcPath("oc://X.md/sec/item/field?session=cron");
|
||||
expect(p).toEqual({
|
||||
file: "X.md",
|
||||
section: "sec",
|
||||
item: "item",
|
||||
field: "field",
|
||||
session: "cron",
|
||||
});
|
||||
});
|
||||
|
||||
it("unknown query parameters silently ignored", () => {
|
||||
const p = parseOcPath("oc://X.md?foo=bar&session=s&baz=qux");
|
||||
expect(p.session).toBe("s");
|
||||
});
|
||||
|
||||
it("session= with empty value drops session", () => {
|
||||
const p = parseOcPath("oc://X.md?session=");
|
||||
expect(p.session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("query without `=` ignored", () => {
|
||||
const p = parseOcPath("oc://X.md?nokeyhere");
|
||||
expect(p.session).toBeUndefined();
|
||||
});
|
||||
|
||||
it("missing scheme throws", () => {
|
||||
expectErr(() => parseOcPath("SOUL.md"), "OC_PATH_MISSING_SCHEME");
|
||||
});
|
||||
|
||||
it("wrong scheme throws", () => {
|
||||
expectErr(() => parseOcPath("https://x.com"), "OC_PATH_MISSING_SCHEME");
|
||||
});
|
||||
|
||||
it("empty after scheme throws", () => {
|
||||
expectErr(() => parseOcPath("oc://"), "OC_PATH_EMPTY");
|
||||
});
|
||||
|
||||
it("empty segment throws", () => {
|
||||
expectErr(() => parseOcPath("oc://X.md//item"), "OC_PATH_EMPTY_SEGMENT");
|
||||
});
|
||||
|
||||
it("too-deep nesting throws", () => {
|
||||
expectErr(() => parseOcPath("oc://X.md/a/b/c/d/e"), "OC_PATH_TOO_DEEP");
|
||||
});
|
||||
|
||||
it("non-string throws", () => {
|
||||
expectErr(() => parseOcPath(42 as unknown as string), "OC_PATH_NOT_STRING");
|
||||
});
|
||||
|
||||
it("round-trip canonical forms", () => {
|
||||
const cases = [
|
||||
"oc://SOUL.md",
|
||||
"oc://SOUL.md/Boundaries",
|
||||
"oc://SOUL.md/Boundaries/deny-rule-1",
|
||||
"oc://SOUL.md/Boundaries/deny-rule-1/risk",
|
||||
"oc://SOUL.md?session=daily",
|
||||
"oc://X.md/a/b/c?session=s",
|
||||
"oc://skills/email-drafter/[frontmatter]/name",
|
||||
"oc://config/plugins.entries.foo.token",
|
||||
];
|
||||
for (const c of cases) {
|
||||
expect(formatOcPath(parseOcPath(c)), `round-trip failed for ${c}`).toBe(c);
|
||||
}
|
||||
});
|
||||
|
||||
it("isValidOcPath true positives", () => {
|
||||
expect(isValidOcPath("oc://X.md")).toBe(true);
|
||||
expect(isValidOcPath("oc://X.md/sec/item/field")).toBe(true);
|
||||
});
|
||||
|
||||
it("isValidOcPath true negatives", () => {
|
||||
expect(isValidOcPath("")).toBe(false);
|
||||
expect(isValidOcPath("X.md")).toBe(false);
|
||||
expect(isValidOcPath("oc://")).toBe(false);
|
||||
expect(isValidOcPath("oc://x//y")).toBe(false);
|
||||
expect(isValidOcPath(null)).toBe(false);
|
||||
expect(isValidOcPath({})).toBe(false);
|
||||
});
|
||||
|
||||
it("file segment with special chars (file with dots/slashes)", () => {
|
||||
const p = parseOcPath("oc://config/plugins.entries.foo.token");
|
||||
expect(p.file).toBe("config");
|
||||
expect(p.section).toBe("plugins.entries.foo.token");
|
||||
});
|
||||
|
||||
it("section segment with hyphens / underscores / numbers", () => {
|
||||
const p = parseOcPath("oc://X.md/Multi-Tenant_Section_2");
|
||||
expect(p.section).toBe("Multi-Tenant_Section_2");
|
||||
});
|
||||
|
||||
it("[frontmatter] sentinel is just a section name", () => {
|
||||
const p = parseOcPath("oc://X.md/[frontmatter]/name");
|
||||
expect(p.section).toBe("[frontmatter]");
|
||||
expect(p.item).toBe("name");
|
||||
});
|
||||
|
||||
it("formatOcPath rejects empty file", () => {
|
||||
expectErr(() => formatOcPath({ file: "" }), "OC_PATH_FILE_REQUIRED");
|
||||
});
|
||||
|
||||
it("formatOcPath rejects item without section", () => {
|
||||
expectErr(() => formatOcPath({ file: "X.md", item: "i" }), "OC_PATH_NESTING");
|
||||
});
|
||||
|
||||
it("formatOcPath quotes raw slot values containing special chars", () => {
|
||||
const constructed = formatOcPath({
|
||||
file: "config.jsonc",
|
||||
section: "agents.defaults.models",
|
||||
item: "github-copilot/claude-opus-4-7",
|
||||
field: "alias",
|
||||
});
|
||||
expect(constructed).toBe(
|
||||
'oc://config.jsonc/agents.defaults.models/"github-copilot/claude-opus-4-7"/alias',
|
||||
);
|
||||
const parsed = parseOcPath(constructed);
|
||||
expect(parsed.item).toBe('"github-copilot/claude-opus-4-7"');
|
||||
});
|
||||
|
||||
it("parseOcPath finds query separator outside quoted keys", () => {
|
||||
const parsed = parseOcPath('oc://config.jsonc/"foo?bar"?session=daily');
|
||||
expect(parsed.section).toBe('"foo?bar"');
|
||||
expect(parsed.session).toBe("daily");
|
||||
});
|
||||
|
||||
it("file slot with `/` round-trips via quoting", () => {
|
||||
const constructed = formatOcPath({
|
||||
file: "skills/email-drafter",
|
||||
section: "Tools",
|
||||
item: "-1",
|
||||
});
|
||||
expect(constructed).toBe('oc://"skills/email-drafter"/Tools/-1');
|
||||
const parsed = parseOcPath(constructed);
|
||||
expect(parsed.file).toBe("skills/email-drafter");
|
||||
expect(parsed.section).toBe("Tools");
|
||||
expect(parsed.item).toBe("-1");
|
||||
});
|
||||
|
||||
it("file slot with dot extension does NOT get quoted", () => {
|
||||
expect(formatOcPath({ file: "AGENTS.md" })).toBe("oc://AGENTS.md");
|
||||
expect(formatOcPath({ file: "gateway.jsonc", section: "version" })).toBe(
|
||||
"oc://gateway.jsonc/version",
|
||||
);
|
||||
});
|
||||
|
||||
it("formatOcPath rejects field without item or section", () => {
|
||||
expect(() => formatOcPath({ file: "X", field: "name" })).toThrow(OcPathError);
|
||||
try {
|
||||
formatOcPath({ file: "X", field: "name" });
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcPathError);
|
||||
expect((err as OcPathError).code).toBe("OC_PATH_NESTING");
|
||||
}
|
||||
});
|
||||
|
||||
it("isPattern is quote-aware (literal `*` inside quoted segment)", () => {
|
||||
const concrete = parseOcPath('oc://config.jsonc/"items.*.glob"');
|
||||
expect(isPattern(concrete)).toBe(false);
|
||||
const wildcard = parseOcPath("oc://config.jsonc/items/*");
|
||||
expect(isPattern(wildcard)).toBe(true);
|
||||
});
|
||||
|
||||
it("getPathLayout is quote-aware", () => {
|
||||
const path = parseOcPath('oc://config.jsonc/"github.com"/repos');
|
||||
const layout = getPathLayout(path);
|
||||
expect(layout.sectionLen).toBe(1);
|
||||
expect(layout.subs[0]).toBe('"github.com"');
|
||||
expect(layout.itemLen).toBe(1);
|
||||
expect(layout.subs[1]).toBe("repos");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// OC Path tests cover oc path resolver edges plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { resolveMdOcPath as resolveOcPath } from "../../resolve.js";
|
||||
|
||||
const SAMPLE = `---
|
||||
name: github
|
||||
description: gh CLI
|
||||
url: https://example.com
|
||||
---
|
||||
|
||||
Preamble prose.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- never write to /etc
|
||||
- always confirm before deleting
|
||||
|
||||
## Tools
|
||||
|
||||
- gh: GitHub CLI
|
||||
- curl: HTTP client
|
||||
- The Tool: with caps and spaces
|
||||
|
||||
## Multi-Word Section
|
||||
|
||||
- item one
|
||||
`;
|
||||
|
||||
describe("oc-path-resolver-edges", () => {
|
||||
const { ast } = parseMd(SAMPLE);
|
||||
|
||||
it("root resolves to AST", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md" });
|
||||
expect(m?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("block by exact slug", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "boundaries" });
|
||||
expect(m?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("block by case-mismatched slug (Boundaries → boundaries)", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "Boundaries" });
|
||||
expect(m?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("block by uppercased slug", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "BOUNDARIES" });
|
||||
expect(m?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("multi-word section by slug", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "multi-word-section" });
|
||||
expect(m?.kind).toBe("block");
|
||||
if (m?.kind === "block") {
|
||||
expect(m.node.heading).toBe("Multi-Word Section");
|
||||
}
|
||||
});
|
||||
|
||||
it("multi-word section by exact heading text (case-folded)", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "Multi-Word Section" });
|
||||
// The OcPath section is matched case-insensitively against block.slug.
|
||||
// Block.slug for "Multi-Word Section" is "multi-word-section", and
|
||||
// path.section.toLowerCase() = "multi-word section" which does NOT
|
||||
// match "multi-word-section". Documented limit — callers must
|
||||
// pass slug form, not heading text. This is intentional.
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("unknown section returns null", () => {
|
||||
const m = resolveOcPath(ast, { file: "X.md", section: "unknown" });
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("item by slug under known section", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
});
|
||||
expect(m?.kind).toBe("item");
|
||||
});
|
||||
|
||||
it('R-09 item slug for KV uses kv.key (gh, not "gh-github-cli")', () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
});
|
||||
if (m === null) {
|
||||
throw new Error("expected tools item match");
|
||||
}
|
||||
if (m.kind === "item") {
|
||||
expect(m.node.kv?.value).toBe("GitHub CLI");
|
||||
}
|
||||
});
|
||||
|
||||
it("item slug for plain bullet uses text", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "boundaries",
|
||||
item: "never-write-to-etc",
|
||||
});
|
||||
expect(m?.kind).toBe("item");
|
||||
});
|
||||
|
||||
it("item slug case-insensitive", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "GH",
|
||||
});
|
||||
expect(m?.kind).toBe("item");
|
||||
});
|
||||
|
||||
it("item with spaces in key (slugified)", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "the-tool",
|
||||
});
|
||||
expect(m?.kind).toBe("item");
|
||||
if (m?.kind === "item") {
|
||||
expect(m.node.kv?.value).toBe("with caps and spaces");
|
||||
}
|
||||
});
|
||||
|
||||
it("unknown item returns null", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "nonexistent",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("item-field matches kv.key (case-insensitive)", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
field: "gh",
|
||||
});
|
||||
expect(m?.kind).toBe("item-field");
|
||||
});
|
||||
|
||||
it("field on plain (non-kv) item returns null", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "boundaries",
|
||||
item: "never-write-to-etc",
|
||||
field: "risk",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("field that does not match kv.key returns null", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "tools",
|
||||
item: "gh",
|
||||
field: "nonexistent",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("frontmatter via [frontmatter] sentinel section", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "[frontmatter]",
|
||||
field: "name",
|
||||
});
|
||||
expect(m?.kind).toBe("frontmatter");
|
||||
if (m?.kind === "frontmatter") {
|
||||
expect(m.node.value).toBe("github");
|
||||
}
|
||||
});
|
||||
|
||||
it("frontmatter unknown key returns null", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "[frontmatter]",
|
||||
field: "nonexistent",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("frontmatter without field returns null", () => {
|
||||
const m = resolveOcPath(ast, {
|
||||
file: "X.md",
|
||||
section: "[frontmatter]",
|
||||
});
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("multiple frontmatter keys with same name — first match wins", () => {
|
||||
// Build an AST manually to test
|
||||
const dupeAst = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [
|
||||
{ key: "k", value: "first", line: 2 },
|
||||
{ key: "k", value: "second", line: 3 },
|
||||
],
|
||||
preamble: "",
|
||||
blocks: [],
|
||||
};
|
||||
const m = resolveOcPath(dupeAst, {
|
||||
file: "X.md",
|
||||
section: "[frontmatter]",
|
||||
field: "k",
|
||||
});
|
||||
expect(m?.kind).toBe("frontmatter");
|
||||
if (m?.kind === "frontmatter") {
|
||||
expect(m.node.value).toBe("first");
|
||||
}
|
||||
});
|
||||
|
||||
it("empty AST resolves root only", () => {
|
||||
const empty = { kind: "md" as const, raw: "", frontmatter: [], preamble: "", blocks: [] };
|
||||
expect(resolveOcPath(empty, { file: "X.md" })?.kind).toBe("root");
|
||||
expect(resolveOcPath(empty, { file: "X.md", section: "any" })).toBeNull();
|
||||
});
|
||||
|
||||
it("resolver does not mutate the AST", () => {
|
||||
const before = JSON.stringify(ast);
|
||||
resolveOcPath(ast, { file: "X.md", section: "tools", item: "gh", field: "gh" });
|
||||
const after = JSON.stringify(ast);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("file segment is informational — resolver doesn't check it", () => {
|
||||
// The file name in OcPath is metadata; resolver assumes the AST
|
||||
// matches. Callers verify file mapping before passing the AST.
|
||||
const m1 = resolveOcPath(ast, { file: "SOUL.md", section: "tools" });
|
||||
const m2 = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" });
|
||||
expect(m1?.kind).toBe(m2?.kind);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
// OC Path tests cover perf determinism plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { resolveMdOcPath as resolveOcPath } from "../../resolve.js";
|
||||
|
||||
const perfBudgetMultiplier = process.env.CI ? 4 : 1;
|
||||
|
||||
function expectWithinPerfBudget(elapsedMs: number, localBudgetMs: number) {
|
||||
expect(elapsedMs).toBeLessThan(localBudgetMs * perfBudgetMultiplier);
|
||||
}
|
||||
|
||||
describe("perf + determinism", () => {
|
||||
it("parses 100 KB file within the parser budget", () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
lines.push("## H" + i);
|
||||
for (let j = 0; j < 5; j++) {
|
||||
lines.push(`- key${i}-${j}: value with content`);
|
||||
}
|
||||
}
|
||||
const raw = lines.join("\n");
|
||||
const start = performance.now();
|
||||
parseMd(raw);
|
||||
const elapsed = performance.now() - start;
|
||||
expectWithinPerfBudget(elapsed, 200);
|
||||
});
|
||||
|
||||
it("parses 1000 small files in under 500 ms", () => {
|
||||
const raw = `## H\n- a\n- b: c\n## I\n- d\n`;
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
parseMd(raw);
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
expectWithinPerfBudget(elapsed, 500);
|
||||
});
|
||||
|
||||
it("100k OcPath resolutions on parsed AST in under 500 ms", () => {
|
||||
const raw = `## A\n- a1\n- a2\n## B\n- b1\n- b2\n## C\n- c1: cv\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
const path = { file: "X.md", section: "b", item: "b1" };
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < 100_000; i++) {
|
||||
resolveOcPath(ast, path);
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
expectWithinPerfBudget(elapsed, 500);
|
||||
});
|
||||
|
||||
it("same input → byte-identical AST.raw across runs", () => {
|
||||
const raw = `---\nb: 2\na: 1\n---\n## Z\n- z\n## A\n- a\n`;
|
||||
const a1 = parseMd(raw).ast;
|
||||
const a2 = parseMd(raw).ast;
|
||||
expect(a1.raw).toBe(a2.raw);
|
||||
expect(a1.frontmatter).toEqual(a2.frontmatter);
|
||||
expect(a1.blocks).toEqual(a2.blocks);
|
||||
});
|
||||
|
||||
it("resolveOcPath is non-mutating", () => {
|
||||
const raw = `## A\n- a: x\n## B\n- b\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
const before = JSON.stringify(ast);
|
||||
resolveOcPath(ast, { file: "X.md", section: "a", item: "a", field: "a" });
|
||||
resolveOcPath(ast, { file: "X.md", section: "b" });
|
||||
resolveOcPath(ast, { file: "X.md", section: "unknown" });
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
|
||||
it("AST is JSON-serializable (no functions, no cycles)", () => {
|
||||
const raw = `---\nk: v\n---\n## A\n- a\n\`\`\`ts\nx\n\`\`\`\n| h |\n| - |\n| 1 |\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
const serialized = JSON.stringify(ast);
|
||||
const parsed = JSON.parse(serialized);
|
||||
expect(parsed.raw).toBe(ast.raw);
|
||||
expect(parsed.blocks.length).toBe(ast.blocks.length);
|
||||
});
|
||||
|
||||
it("emit is non-mutating", () => {
|
||||
const raw = `## A\n- a\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
const before = JSON.stringify(ast);
|
||||
emitMd(ast);
|
||||
emitMd(ast);
|
||||
emitMd(ast);
|
||||
expect(JSON.stringify(ast)).toBe(before);
|
||||
});
|
||||
|
||||
it("frontmatter ordering is preserved (insertion order, not alphabetical)", () => {
|
||||
const raw = `---\nz: 1\nm: 2\na: 3\n---\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.frontmatter.map((e) => e.key)).toEqual(["z", "m", "a"]);
|
||||
});
|
||||
|
||||
it("block ordering is document order, not alphabetical", () => {
|
||||
const raw = `## Z\n## A\n## M\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks.map((b) => b.heading)).toEqual(["Z", "A", "M"]);
|
||||
});
|
||||
|
||||
it("item ordering within block is document order", () => {
|
||||
const raw = `## H\n- z\n- a\n- m\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(ast.blocks[0]?.items.map((i) => i.text)).toEqual(["z", "a", "m"]);
|
||||
});
|
||||
|
||||
it("large fixture round-trip stays under 100 ms", () => {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 500; i++) {
|
||||
lines.push(`## Section ${i}`);
|
||||
lines.push("");
|
||||
for (let j = 0; j < 10; j++) {
|
||||
lines.push(`- item-${i}-${j}: with some prose value content here`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
const raw = lines.join("\n");
|
||||
const start = performance.now();
|
||||
const { ast } = parseMd(raw);
|
||||
const out = emitMd(ast);
|
||||
const elapsed = performance.now() - start;
|
||||
expect(out).toBe(raw);
|
||||
expectWithinPerfBudget(elapsed, 100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// OC Path tests cover real world fixtures plugin behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { resolveMdOcPath as resolveOcPath } from "../../resolve.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(HERE, "..", "fixtures", "real");
|
||||
|
||||
function load(name: string): string {
|
||||
return readFileSync(join(FIXTURES, name), "utf-8");
|
||||
}
|
||||
|
||||
describe("real-world-fixtures", () => {
|
||||
it("SOUL.md parses + round-trips", () => {
|
||||
const raw = load("SOUL.md");
|
||||
const { ast, diagnostics } = parseMd(raw);
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
// Has at least one H2 block.
|
||||
expect(ast.blocks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("AGENTS.md parses + resolves Tools section", () => {
|
||||
const raw = load("AGENTS.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const tools = resolveOcPath(ast, { file: "AGENTS.md", section: "tools" });
|
||||
expect(tools?.kind).toBe("block");
|
||||
if (tools?.kind === "block") {
|
||||
expect(tools.node.items.some((i) => i.kv?.key === "gh")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("MEMORY.md frontmatter scope resolves via [frontmatter]", () => {
|
||||
const raw = load("MEMORY.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const scope = resolveOcPath(ast, {
|
||||
file: "MEMORY.md",
|
||||
section: "[frontmatter]",
|
||||
field: "scope",
|
||||
});
|
||||
expect(scope?.kind).toBe("frontmatter");
|
||||
if (scope?.kind === "frontmatter") {
|
||||
expect(scope.node.value).toBe("project");
|
||||
}
|
||||
});
|
||||
|
||||
it("TOOLS.md tool-guidance section resolves by slug", () => {
|
||||
const raw = load("TOOLS.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const guidance = resolveOcPath(ast, {
|
||||
file: "TOOLS.md",
|
||||
section: "tool-guidance",
|
||||
});
|
||||
expect(guidance?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("IDENTITY.md sections resolvable by slug", () => {
|
||||
const raw = load("IDENTITY.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const trust = resolveOcPath(ast, {
|
||||
file: "IDENTITY.md",
|
||||
section: "trust-level",
|
||||
});
|
||||
expect(trust?.kind).toBe("block");
|
||||
});
|
||||
|
||||
it("USER.md Preferences items extracted", () => {
|
||||
const raw = load("USER.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const prefs = resolveOcPath(ast, {
|
||||
file: "USER.md",
|
||||
section: "preferences",
|
||||
});
|
||||
expect(prefs?.kind).toBe("block");
|
||||
if (prefs?.kind === "block") {
|
||||
expect(prefs.node.items.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("HEARTBEAT.md schedules — H2 sections as triggers", () => {
|
||||
const raw = load("HEARTBEAT.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(ast.blocks.length).toBeGreaterThanOrEqual(3);
|
||||
const slugs = ast.blocks.map((b) => b.slug);
|
||||
expect(slugs).toContain("every-30m-wake");
|
||||
expect(slugs).toContain("every-4h-wake");
|
||||
});
|
||||
|
||||
it("SKILL.md frontmatter has name + description + tier", () => {
|
||||
const raw = load("SKILL.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
const fmKeys = ast.frontmatter.map((e) => e.key);
|
||||
expect(fmKeys).toContain("name");
|
||||
expect(fmKeys).toContain("description");
|
||||
expect(fmKeys).toContain("tier");
|
||||
});
|
||||
|
||||
it("BOOTSTRAP.md round-trips", () => {
|
||||
const raw = load("BOOTSTRAP.md");
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
});
|
||||
|
||||
it("all 8 fixtures combined round-trip-clean (sanity)", () => {
|
||||
const names = [
|
||||
"SOUL.md",
|
||||
"AGENTS.md",
|
||||
"MEMORY.md",
|
||||
"TOOLS.md",
|
||||
"IDENTITY.md",
|
||||
"USER.md",
|
||||
"HEARTBEAT.md",
|
||||
"SKILL.md",
|
||||
"BOOTSTRAP.md",
|
||||
];
|
||||
for (const name of names) {
|
||||
const raw = load(name);
|
||||
expect(emitMd(parseMd(raw).ast), `${name} failed round-trip`).toBe(raw);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
// OC Path tests cover roundtrip property plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
|
||||
function roundTrip(raw: string): string {
|
||||
return emitMd(parseMd(raw).ast);
|
||||
}
|
||||
|
||||
describe("roundtrip-property", () => {
|
||||
it("byte-fidelity over 100 generated shapes", () => {
|
||||
const inputs = generateCorpus(100);
|
||||
for (const raw of inputs) {
|
||||
try {
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
} catch (e) {
|
||||
// Surface which input failed for debugging.
|
||||
throw new Error(
|
||||
`round-trip failed for input (length ${raw.length}):\n${JSON.stringify(raw.slice(0, 200))}\nError: ${(e as Error).message}`,
|
||||
{ cause: e },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("parser idempotence (parse → emit → parse → identical AST shape)", () => {
|
||||
const inputs = generateCorpus(50);
|
||||
for (const raw of inputs) {
|
||||
const a = parseMd(raw).ast;
|
||||
const a2 = parseMd(emitMd(a)).ast;
|
||||
// Compare structural fields; raw will of course be identical.
|
||||
expect(a2.frontmatter).toEqual(a.frontmatter);
|
||||
expect(a2.preamble).toEqual(a.preamble);
|
||||
expect(a2.blocks.map(stripDerived)).toEqual(a.blocks.map(stripDerived));
|
||||
}
|
||||
});
|
||||
|
||||
it("stable output for identical input", () => {
|
||||
const raw = `---\nname: x\n---\n\n## A\n- a\n## B\n- b: c\n`;
|
||||
const out1 = roundTrip(raw);
|
||||
const out2 = roundTrip(raw);
|
||||
const out3 = roundTrip(raw);
|
||||
expect(out1).toBe(out2);
|
||||
expect(out2).toBe(out3);
|
||||
});
|
||||
|
||||
it("ordering deterministic (no Object.keys / Set ordering surprises)", () => {
|
||||
const raw = `---\nb: 2\na: 1\nc: 3\n---\n## Z\n- z\n## A\n- a\n`;
|
||||
const a1 = parseMd(raw).ast;
|
||||
const a2 = parseMd(raw).ast;
|
||||
expect(a1.frontmatter.map((e) => e.key)).toEqual(a2.frontmatter.map((e) => e.key));
|
||||
expect(a1.blocks.map((b) => b.heading)).toEqual(a2.blocks.map((b) => b.heading));
|
||||
});
|
||||
|
||||
it("round-trip preserves comment-like lines (no comment recognition at substrate)", () => {
|
||||
const raw = `## H\n\n<!-- a comment -->\n- bullet\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trip preserves indented blocks (substrate doesn't reflow)", () => {
|
||||
const raw = `## H\n\n indented code-ish block\n more indented\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trip preserves blockquotes", () => {
|
||||
const raw = `## H\n\n> quoted line 1\n> quoted line 2\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trip preserves images / links", () => {
|
||||
const raw = `## H\n\n\n[link](http://example.com)\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trip preserves HTML", () => {
|
||||
const raw = `## H\n\n<details><summary>x</summary>body</details>\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
|
||||
it("round-trip preserves consecutive headings with no body between", () => {
|
||||
const raw = `## A\n## B\n## C\n`;
|
||||
expect(roundTrip(raw)).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
function generateCorpus(count: number): string[] {
|
||||
const corpus: string[] = [];
|
||||
// Deterministic seed so flaky failures don't surface differently each run.
|
||||
let seed = 42;
|
||||
const rand = () => {
|
||||
seed = (seed * 1664525 + 1013904223) % 2 ** 32;
|
||||
return seed / 2 ** 32;
|
||||
};
|
||||
const choose = <T>(arr: readonly T[]): T => arr[Math.floor(rand() * arr.length)];
|
||||
|
||||
const headings = ["Boundaries", "Tools", "Memory", "Identity", "User", "Heartbeat", "Skills"];
|
||||
const fmKeys = ["name", "description", "tier", "enabled", "timeout", "url"];
|
||||
const fmValues = ["github", "gh CLI", "T1", "true", "15000", "https://example.com"];
|
||||
const itemTexts = ["never write to /etc", "always confirm", "gh: GitHub CLI", "curl: HTTP"];
|
||||
const eols = ["\n", "\r\n"];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const eol = choose(eols);
|
||||
const parts: string[] = [];
|
||||
|
||||
if (rand() < 0.5) {
|
||||
parts.push("---");
|
||||
const fmCount = Math.floor(rand() * 4);
|
||||
for (let k = 0; k < fmCount; k++) {
|
||||
parts.push(`${choose(fmKeys)}: ${choose(fmValues)}`);
|
||||
}
|
||||
parts.push("---");
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
if (rand() < 0.3) {
|
||||
parts.push("Some preamble.");
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
const blockCount = Math.floor(rand() * 3) + 1;
|
||||
for (let b = 0; b < blockCount; b++) {
|
||||
parts.push(`## ${choose(headings)}`);
|
||||
parts.push("");
|
||||
const itemCount = Math.floor(rand() * 4);
|
||||
for (let itLocal = 0; itLocal < itemCount; itLocal++) {
|
||||
parts.push(`- ${choose(itemTexts)}`);
|
||||
}
|
||||
if (rand() < 0.2) {
|
||||
parts.push("```");
|
||||
parts.push("code");
|
||||
parts.push("```");
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
corpus.push(parts.join(eol));
|
||||
}
|
||||
return corpus;
|
||||
}
|
||||
|
||||
function stripDerived(b: { heading: string; slug: string; bodyText: string }): {
|
||||
heading: string;
|
||||
slug: string;
|
||||
} {
|
||||
return { heading: b.heading, slug: b.slug };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// OC Path tests cover security and limits plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAX_PATH_LENGTH,
|
||||
MAX_TRAVERSAL_DEPTH,
|
||||
OcPathError,
|
||||
findOcPaths,
|
||||
formatOcPath,
|
||||
parseOcPath,
|
||||
resolveOcPath,
|
||||
setOcPath,
|
||||
} from "../../index.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
|
||||
describe("encoding edges", () => {
|
||||
it("strips leading UTF-8 BOM from path string", () => {
|
||||
expect(parseOcPath("oc://X/Y").file).toBe("X");
|
||||
});
|
||||
|
||||
it("normalizes path segments to NFC", () => {
|
||||
const nfc = "café";
|
||||
const nfd = "café"; // decomposed
|
||||
expect(parseOcPath(`oc://X/${nfd}`)).toEqual(parseOcPath(`oc://X/${nfc}`));
|
||||
});
|
||||
|
||||
it("rejects whitespace inside identifier-shaped segments", () => {
|
||||
expect(() => parseOcPath("oc://X/foo /bar")).toThrow(OcPathError);
|
||||
expect(() => parseOcPath("oc://X/foo\tbar")).toThrow(OcPathError);
|
||||
});
|
||||
|
||||
it("rejects control characters and NUL bytes anywhere in the path", () => {
|
||||
expect(() => parseOcPath("oc://X/\x00")).toThrow(/Control character/);
|
||||
expect(() => parseOcPath("oc://X/foo\x01bar")).toThrow(/Control character/);
|
||||
expect(() => parseOcPath("oc://X/foo\x7Fbar")).toThrow(/Control character/);
|
||||
expect(() => parseOcPath("oc://X.md/items/[k=a\x00b]")).toThrow(OcPathError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("file-slot containment", () => {
|
||||
it("rejects absolute POSIX file slot", () => {
|
||||
expect(() => parseOcPath("oc:///etc/passwd")).toThrow(/Empty segment/);
|
||||
expect(() => parseOcPath('oc://"/etc/passwd"/section')).toThrow(/Absolute file slot/);
|
||||
});
|
||||
|
||||
it("rejects Windows drive-letter file slot", () => {
|
||||
expect(() => parseOcPath('oc://"C:/Windows/System32/foo"/section')).toThrow(
|
||||
/Absolute file slot/,
|
||||
);
|
||||
// `\` inside quoted segments is rejected outright (no escape support).
|
||||
expect(() => parseOcPath('oc://"C:\\\\Windows\\\\System32"/section')).toThrow(OcPathError);
|
||||
});
|
||||
|
||||
it("rejects leading-backslash UNC path", () => {
|
||||
expect(() => parseOcPath('oc://"\\\\srv\\\\share\\\\foo"/section')).toThrow(OcPathError);
|
||||
});
|
||||
|
||||
it("rejects parent-directory escapes", () => {
|
||||
expect(() => parseOcPath('oc://"../foo"/section')).toThrow(/Parent-directory/);
|
||||
expect(() => parseOcPath('oc://"foo/../bar"/section')).toThrow(/Parent-directory/);
|
||||
});
|
||||
|
||||
it("does not URL-decode `%2E%2E` — substrate isn't an HTTP layer", () => {
|
||||
expect(parseOcPath('oc://"%2E%2E/foo"/section').file).toBe("%2E%2E/foo");
|
||||
});
|
||||
|
||||
it("formatOcPath rejects absolute and parent-directory file slots", () => {
|
||||
expect(() => formatOcPath({ file: "/etc/passwd" })).toThrow(/Absolute file slot/);
|
||||
expect(() => formatOcPath({ file: "C:/Windows" })).toThrow(/Absolute file slot/);
|
||||
expect(() => formatOcPath({ file: ".." })).toThrow(/Parent-directory/);
|
||||
expect(() => formatOcPath({ file: "foo/../bar" })).toThrow(/Parent-directory/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path-string and traversal caps", () => {
|
||||
it("parseOcPath rejects strings longer than MAX_PATH_LENGTH", () => {
|
||||
expect(() => parseOcPath("oc://X/" + "a".repeat(MAX_PATH_LENGTH))).toThrow(/exceeds .* bytes/);
|
||||
});
|
||||
|
||||
it("parseOcPath accepts a path right at the cap", () => {
|
||||
const justUnder = "oc://X/" + "a".repeat(MAX_PATH_LENGTH - "oc://X/".length);
|
||||
expect(() => parseOcPath(justUnder)).not.toThrow();
|
||||
});
|
||||
|
||||
it("formatOcPath enforces the same cap on output", () => {
|
||||
expect(() => formatOcPath({ file: "X", section: "a".repeat(MAX_PATH_LENGTH) })).toThrow(
|
||||
/Formatted oc:\/\/ exceeds/,
|
||||
);
|
||||
});
|
||||
|
||||
it("walker depth cap fires on synthetic deeply-nested AST", () => {
|
||||
// Bypasses parser depth cap so the walker defense fires in isolation.
|
||||
type V = import("../../jsonc/ast.js").JsoncValue;
|
||||
let leaf: V = { kind: "string", value: "x", line: 1 };
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) {
|
||||
leaf = { kind: "object", entries: [{ key: "a", value: leaf, line: 1 }], line: 1 };
|
||||
}
|
||||
const ast = {
|
||||
kind: "jsonc" as const,
|
||||
raw: "",
|
||||
root: { kind: "object" as const, entries: [{ key: "root", value: leaf, line: 1 }], line: 1 },
|
||||
};
|
||||
expect(() => findOcPaths(ast, parseOcPath("oc://X/**"))).toThrow(/MAX_TRAVERSAL_DEPTH/);
|
||||
});
|
||||
|
||||
it("jsonc parser surfaces a structured diagnostic on pathological nesting", () => {
|
||||
const open = "[".repeat(MAX_TRAVERSAL_DEPTH + 100);
|
||||
const close = "]".repeat(MAX_TRAVERSAL_DEPTH + 100);
|
||||
const result = parseJsonc(`${open}0${close}`);
|
||||
expect(result.ast.root).toBeNull();
|
||||
expect(result.diagnostics.some((d) => d.code === "OC_JSONC_DEPTH_EXCEEDED")).toBe(true);
|
||||
});
|
||||
|
||||
it("jsonl per-line parser flags malformed deeply-nested values", () => {
|
||||
let nested = '"x"';
|
||||
for (let i = 0; i < MAX_TRAVERSAL_DEPTH + 50; i++) {
|
||||
nested = `{"a":${nested}}`;
|
||||
}
|
||||
const { diagnostics } = parseJsonl(nested + "\n");
|
||||
expect(diagnostics.some((d) => d.code === "OC_JSONL_LINE_MALFORMED")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sentinel literal at format boundary", () => {
|
||||
it("formatOcPath rejects a struct carrying the redaction sentinel", () => {
|
||||
expect(() => formatOcPath({ file: "AGENTS.md", section: "__OPENCLAW_REDACTED__" })).toThrow(
|
||||
/sentinel literal/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("numeric segments dispatch by node kind", () => {
|
||||
it("negative numeric key on object resolves as literal key (openclaw#59934)", () => {
|
||||
// Telegram supergroup IDs are negative numbers used as map keys.
|
||||
const ast = parseJsonc(
|
||||
'{"channels":{"telegram":{"groups":{"-5028303500":{"requireMention":false}}}}}',
|
||||
).ast;
|
||||
const m = resolveOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://config/channels.telegram.groups.-5028303500.requireMention"),
|
||||
);
|
||||
expect(m?.kind).toBe("leaf");
|
||||
});
|
||||
|
||||
it("`$last` literal key on an object is shadowed by the positional sentinel", () => {
|
||||
const ast = parseJsonc('{"$last":"literal-value","foo":"bar"}').ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://X/$last"));
|
||||
expect(m?.kind === "leaf" && m.valueText).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath value coercion is locale-independent and exact-match", () => {
|
||||
it("number coercion accepts `1.5`, refuses `1,5`", () => {
|
||||
const ast = parseJsonc('{"x":1.0}').ast;
|
||||
expect(setOcPath(ast, parseOcPath("oc://X/x"), "1.5").ok).toBe(true);
|
||||
const r = setOcPath(ast, parseOcPath("oc://X/x"), "1,5");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("boolean coercion accepts `true` / `false` only", () => {
|
||||
const ast = parseJsonc('{"x":true}').ast;
|
||||
expect(setOcPath(ast, parseOcPath("oc://X/x"), "false").ok).toBe(true);
|
||||
expect(setOcPath(ast, parseOcPath("oc://X/x"), "False").ok).toBe(false);
|
||||
expect(setOcPath(ast, parseOcPath("oc://X/x"), "TRUE").ok).toBe(false);
|
||||
expect(setOcPath(ast, parseOcPath("oc://X/x"), "yes").ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("predicate-value injection is contained", () => {
|
||||
it("regex metacharacters in predicate value match literally, not as regex", () => {
|
||||
const ast = parseJsonc('{"items":[{"name":"a.*"},{"name":"abc"}]}').ast;
|
||||
const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[name=a.*]"));
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("equals-sign in predicate value is treated as part of the value", () => {
|
||||
const ast = parseJsonc('{"items":[{"k":"a=b"},{"k":"c"}]}').ast;
|
||||
const matches = findOcPaths(ast, parseOcPath("oc://X.jsonc/items/[k=a=b]"));
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("predicate-shaped bracket without operator is a literal sentinel", () => {
|
||||
expect(parseOcPath("oc://X.jsonc/items/[name]").item).toBe("[name]");
|
||||
});
|
||||
|
||||
it("rejects empty predicate body and empty key/value", () => {
|
||||
expect(() => parseOcPath("oc://X.jsonc/items/[]")).toThrow(OcPathError);
|
||||
expect(() => parseOcPath("oc://X/[=foo]")).toThrow(/Malformed predicate/);
|
||||
expect(() => parseOcPath("oc://X/[id=]")).toThrow(/Malformed predicate/);
|
||||
});
|
||||
|
||||
it("predicate value containing `/` round-trips and matches literally", () => {
|
||||
const p = parseOcPath("oc://X/[id=foo/bar]/cmd");
|
||||
expect(p.section).toBe("[id=foo/bar]");
|
||||
const ast = parseJsonc('{"steps":[{"id":"foo/bar","cmd":"x"},{"id":"baz","cmd":"y"}]}').ast;
|
||||
const matches = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=foo/bar]/cmd"));
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("predicate value containing `.` round-trips and matches literally", () => {
|
||||
const ast = parseJsonc('{"steps":[{"id":"1.0","cmd":"x"},{"id":"2.0","cmd":"y"}]}').ast;
|
||||
const matches = findOcPaths(ast, parseOcPath("oc://wf/steps/[id=1.0]/cmd"));
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("structural rejection", () => {
|
||||
it("rejects mismatched brackets and braces", () => {
|
||||
expect(() => parseOcPath("oc://X/[unclosed")).toThrow(OcPathError);
|
||||
expect(() => parseOcPath("oc://X/closed]")).toThrow(OcPathError);
|
||||
expect(() => parseOcPath("oc://X/{a,b")).toThrow(OcPathError);
|
||||
});
|
||||
|
||||
it("rejects empty union and empty alternative", () => {
|
||||
expect(() => parseOcPath("oc://X/{}")).toThrow(/Empty union/);
|
||||
expect(() => parseOcPath("oc://X/{a,,b}")).toThrow(/Empty alternative/);
|
||||
});
|
||||
|
||||
it("rejects empty dotted sub-segment in formatOcPath output", () => {
|
||||
expect(() => formatOcPath({ file: "a.md", section: "foo." })).toThrow(/Empty dotted/);
|
||||
expect(() => formatOcPath({ file: "a.md", section: ".foo" })).toThrow(/Empty dotted/);
|
||||
expect(() => formatOcPath({ file: "a.md", section: "foo..bar" })).toThrow(/Empty dotted/);
|
||||
});
|
||||
|
||||
it("rejects unescaped `&` and `%` in segments", () => {
|
||||
expect(() => parseOcPath("oc://X.md/a&b")).toThrow(OcPathError);
|
||||
expect(() => parseOcPath("oc://X.md/a%b")).toThrow(OcPathError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
// OC Path tests cover sentinel cross kind plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { setJsoncOcPath } from "../../jsonc/edit.js";
|
||||
import { emitJsonc } from "../../jsonc/emit.js";
|
||||
import { parseJsonc } from "../../jsonc/parse.js";
|
||||
import { emitJsonl } from "../../jsonl/emit.js";
|
||||
import { parseJsonl } from "../../jsonl/parse.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js";
|
||||
|
||||
describe("sentinel guard cross-kind", () => {
|
||||
it("jsonc round-trip echoes safely when raw contains pre-existing sentinel", () => {
|
||||
// Pre-existing sentinel bytes are trusted — see emit-policy comment
|
||||
// in jsonc/emit.ts. The strict mode below is the opt-in path for
|
||||
// callers who want LKG-style fingerprint verification.
|
||||
const raw = `{ "x": "${REDACTED_SENTINEL}" }`;
|
||||
const ast = parseJsonc(raw).ast;
|
||||
expect(emitJsonc(ast)).toBe(raw);
|
||||
// Strict mode still rejects pre-existing sentinel for callers who
|
||||
// explicitly opt in.
|
||||
expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("jsonl round-trip echoes safely; strict mode rejects", () => {
|
||||
const raw = `{"x":"${REDACTED_SENTINEL}"}\n`;
|
||||
const ast = parseJsonl(raw).ast;
|
||||
expect(emitJsonl(ast)).toBe(raw);
|
||||
expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("md round-trip echoes safely; strict mode rejects", () => {
|
||||
const raw = `## Body\n\n- ${REDACTED_SENTINEL}\n`;
|
||||
const ast = parseMd(raw).ast;
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("jsonc render mode walks every leaf for sentinel", () => {
|
||||
const ast = parseJsonc('{ "x": "ok" }').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
root: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "x",
|
||||
line: 1,
|
||||
value: { kind: "string" as const, value: REDACTED_SENTINEL },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(() => emitJsonc(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("jsonl render mode walks every value-line leaf", () => {
|
||||
const ast = parseJsonl('{"a":"ok"}\n').ast;
|
||||
const tampered = {
|
||||
...ast,
|
||||
lines: [
|
||||
{
|
||||
kind: "value" as const,
|
||||
line: 1,
|
||||
raw: '{"a":"ok"}',
|
||||
value: {
|
||||
kind: "object" as const,
|
||||
entries: [
|
||||
{
|
||||
key: "a",
|
||||
line: 1,
|
||||
value: { kind: "string" as const, value: REDACTED_SENTINEL },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitJsonl(tampered, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("setJsoncOcPath itself throws when the new value contains the sentinel", () => {
|
||||
// The substrate guard fires at write-time: setJsoncOcPath rebuilds
|
||||
// raw via render mode emit, which scans every leaf. Defense-in-depth
|
||||
// — even if a caller forgets to call emit afterward, the sentinel
|
||||
// can't make it into an in-memory AST that pretends to be valid.
|
||||
const ast = parseJsonc('{ "x": "ok" }').ast;
|
||||
expect(() =>
|
||||
setJsoncOcPath(ast, parseOcPath("oc://config/x"), {
|
||||
kind: "string",
|
||||
value: REDACTED_SENTINEL,
|
||||
}),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("sentinel embedded in deep nesting — render mode catches the leaf", () => {
|
||||
// Round-trip echoes the pre-existing bytes (the workspace contract:
|
||||
// a parsed file containing the sentinel as data is not "writing" it
|
||||
// on emit). Render mode walks every leaf and rejects this caller-
|
||||
// injected pattern — and a `setOcPath` followed by emit lands here.
|
||||
const raw = JSON.stringify({ a: { b: { c: REDACTED_SENTINEL } } });
|
||||
const ast = parseJsonc(raw).ast;
|
||||
expect(emitJsonc(ast)).toBe(raw); // round-trip echo
|
||||
expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("sentinel inside an array element triggers guard in render mode", () => {
|
||||
const raw = JSON.stringify({ arr: ["ok", REDACTED_SENTINEL, "ok"] });
|
||||
const ast = parseJsonc(raw).ast;
|
||||
expect(() => emitJsonc(ast, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("sentinel as object key in raw — strict mode catches it", () => {
|
||||
const raw = `{ "${REDACTED_SENTINEL}": 1 }`;
|
||||
const ast = parseJsonc(raw).ast;
|
||||
expect(emitJsonc(ast)).toBe(raw); // default-mode echo
|
||||
expect(() => emitJsonc(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("sentinel in jsonl malformed line — strict mode catches it", () => {
|
||||
const raw = `${REDACTED_SENTINEL}\n`;
|
||||
const ast = parseJsonl(raw).ast;
|
||||
expect(emitJsonl(ast)).toBe(raw); // round-trip echoes verbatim
|
||||
expect(() => emitJsonl(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("partial sentinel substring does NOT trigger guard", () => {
|
||||
const raw = '{ "x": "OPENCLAW_REDACTED" }';
|
||||
const ast = parseJsonc(raw).ast;
|
||||
expect(() => emitJsonc(ast)).not.toThrow();
|
||||
});
|
||||
|
||||
it("sentinel guard error message includes the OcPath context (render mode)", () => {
|
||||
// Render mode is the path that actually rejects caller-injected
|
||||
// sentinel — round-trip just echoes, so the error context surfaces
|
||||
// when render walks the offending leaf and constructs the path.
|
||||
const raw = `{ "secret": "${REDACTED_SENTINEL}" }`;
|
||||
const ast = parseJsonc(raw).ast;
|
||||
try {
|
||||
emitJsonc(ast, { mode: "render", fileNameForGuard: "config" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(OcEmitSentinelError);
|
||||
expect(String(e)).toContain("oc://");
|
||||
expect(String(e)).toContain("config");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
// OC Path tests cover sentinel guard plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../../emit.js";
|
||||
import { parseMd } from "../../parse.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../../sentinel.js";
|
||||
|
||||
describe("sentinel-guard", () => {
|
||||
it("sentinel constant matches the literal", () => {
|
||||
expect(REDACTED_SENTINEL).toBe("__OPENCLAW_REDACTED__");
|
||||
});
|
||||
|
||||
it("guardSentinel passes normal strings", () => {
|
||||
expect(() => guardSentinel("safe", "oc://X.md")).not.toThrow();
|
||||
});
|
||||
|
||||
it("guardSentinel passes non-string types", () => {
|
||||
expect(() => guardSentinel(42, "oc://X.md")).not.toThrow();
|
||||
expect(() => guardSentinel(null, "oc://X.md")).not.toThrow();
|
||||
expect(() => guardSentinel(undefined, "oc://X.md")).not.toThrow();
|
||||
expect(() => guardSentinel({}, "oc://X.md")).not.toThrow();
|
||||
});
|
||||
|
||||
it("guardSentinel throws on exact match", () => {
|
||||
expect(() => guardSentinel(REDACTED_SENTINEL, "oc://X.md")).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("guardSentinel throws on substring matches (sentinel embedded in larger string)", () => {
|
||||
// Substring scan — the sentinel anywhere in the value is a leak,
|
||||
// not just exact equality. A hostile caller smuggling
|
||||
// `prefix__OPENCLAW_REDACTED__suffix` would have bypassed the old
|
||||
// equality check; substring scan closes the gap.
|
||||
expect(() => guardSentinel(`prefix${REDACTED_SENTINEL}suffix`, "oc://X.md")).toThrow(
|
||||
OcEmitSentinelError,
|
||||
);
|
||||
});
|
||||
|
||||
it("error attaches the OcPath context", () => {
|
||||
try {
|
||||
guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcEmitSentinelError);
|
||||
const e = err as OcEmitSentinelError;
|
||||
expect(e.path).toBe("oc://config/plugins.entries.foo.token");
|
||||
expect(e.code).toBe("OC_EMIT_SENTINEL");
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trip echoes pre-existing sentinel; strict mode rejects", () => {
|
||||
const raw = "## Section\n\n- token: __OPENCLAW_REDACTED__\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("round-trip emit allows sentinel-free content", () => {
|
||||
const raw = "## Section\n\n- token: redacted-but-not-sentinel\n";
|
||||
const { ast } = parseMd(raw);
|
||||
expect(() => emitMd(ast)).not.toThrow();
|
||||
});
|
||||
|
||||
it("render mode catches sentinel in frontmatter", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }],
|
||||
preamble: "",
|
||||
blocks: [],
|
||||
};
|
||||
expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("render mode catches sentinel in preamble", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [],
|
||||
preamble: REDACTED_SENTINEL,
|
||||
blocks: [],
|
||||
};
|
||||
expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("render mode catches sentinel in block bodyText", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [],
|
||||
preamble: "",
|
||||
blocks: [
|
||||
{
|
||||
heading: "Sec",
|
||||
slug: "sec",
|
||||
line: 1,
|
||||
bodyText: REDACTED_SENTINEL,
|
||||
items: [],
|
||||
tables: [],
|
||||
codeBlocks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitMd(ast, { mode: "render" })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("render mode catches sentinel in item kv.value", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [],
|
||||
preamble: "",
|
||||
blocks: [
|
||||
{
|
||||
heading: "S",
|
||||
slug: "s",
|
||||
line: 1,
|
||||
bodyText: "- t: x",
|
||||
items: [
|
||||
{
|
||||
text: "t: x",
|
||||
slug: "t",
|
||||
line: 2,
|
||||
kv: { key: "t", value: REDACTED_SENTINEL },
|
||||
},
|
||||
],
|
||||
tables: [],
|
||||
codeBlocks: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(() => emitMd(ast, { mode: "render", fileNameForGuard: "AGENTS.md" })).toThrow(
|
||||
OcEmitSentinelError,
|
||||
);
|
||||
});
|
||||
|
||||
it("sentinel-as-substring in raw — strict mode catches it", () => {
|
||||
const raw = `Some prose ${REDACTED_SENTINEL} more prose.\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("multiple sentinel occurrences in raw — strict mode catches them", () => {
|
||||
const raw = `## A\n${REDACTED_SENTINEL}\n${REDACTED_SENTINEL}\n`;
|
||||
const { ast } = parseMd(raw);
|
||||
expect(emitMd(ast)).toBe(raw);
|
||||
expect(() => emitMd(ast, { acceptPreExistingSentinel: false })).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("fileNameForGuard appears in the error path", () => {
|
||||
const ast = {
|
||||
kind: "md" as const,
|
||||
raw: "",
|
||||
frontmatter: [{ key: "token", value: REDACTED_SENTINEL, line: 2 }],
|
||||
preamble: "",
|
||||
blocks: [],
|
||||
};
|
||||
try {
|
||||
emitMd(ast, { mode: "render", fileNameForGuard: "config" });
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect((err as OcEmitSentinelError).path).toContain("config");
|
||||
}
|
||||
});
|
||||
});
|
||||
33
extensions/oc-path/src/oc-path/tests/sentinel.test.ts
Normal file
33
extensions/oc-path/src/oc-path/tests/sentinel.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// OC Path tests cover sentinel plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL, guardSentinel } from "../sentinel.js";
|
||||
|
||||
describe("guardSentinel", () => {
|
||||
it("passes through normal strings", () => {
|
||||
expect(() => guardSentinel("normal value", "oc://SOUL.md")).not.toThrow();
|
||||
});
|
||||
|
||||
it("passes through non-string values", () => {
|
||||
expect(() => guardSentinel(42, "oc://SOUL.md")).not.toThrow();
|
||||
expect(() => guardSentinel(null, "oc://SOUL.md")).not.toThrow();
|
||||
expect(() => guardSentinel(undefined, "oc://SOUL.md")).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws on the sentinel literal", () => {
|
||||
expect(() => guardSentinel(REDACTED_SENTINEL, "oc://SOUL.md/[fm]/token")).toThrow(
|
||||
OcEmitSentinelError,
|
||||
);
|
||||
});
|
||||
|
||||
it("attaches the OcPath in the error", () => {
|
||||
try {
|
||||
guardSentinel(REDACTED_SENTINEL, "oc://config/plugins.entries.foo.token");
|
||||
expect.fail("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OcEmitSentinelError);
|
||||
const e = err as OcEmitSentinelError;
|
||||
expect(e.path).toBe("oc://config/plugins.entries.foo.token");
|
||||
expect(e.code).toBe("OC_EMIT_SENTINEL");
|
||||
}
|
||||
});
|
||||
});
|
||||
51
extensions/oc-path/src/oc-path/tests/slug.test.ts
Normal file
51
extensions/oc-path/src/oc-path/tests/slug.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// OC Path tests cover slug plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { slugify } from "../slug.js";
|
||||
|
||||
describe("slugify", () => {
|
||||
it("lowercases", () => {
|
||||
expect(slugify("Boundaries")).toBe("boundaries");
|
||||
});
|
||||
|
||||
it("replaces underscores with hyphens", () => {
|
||||
expect(slugify("API_KEY")).toBe("api-key");
|
||||
});
|
||||
|
||||
it("collapses multi-word headings", () => {
|
||||
expect(slugify("Tool Guidance")).toBe("tool-guidance");
|
||||
});
|
||||
|
||||
it("preserves existing kebab-case", () => {
|
||||
expect(slugify("deny-rule-1")).toBe("deny-rule-1");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace + non-slug chars", () => {
|
||||
expect(slugify(" Restricted Data ")).toBe("restricted-data");
|
||||
});
|
||||
|
||||
it("handles colon + space patterns", () => {
|
||||
expect(slugify("deny: secrets")).toBe("deny-secrets");
|
||||
});
|
||||
|
||||
it("collapses repeated hyphens", () => {
|
||||
expect(slugify("foo----bar")).toBe("foo-bar");
|
||||
});
|
||||
|
||||
it("returns empty for non-slug-valid input", () => {
|
||||
expect(slugify("!!")).toBe("");
|
||||
expect(slugify(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
const inputs = ["Tool Guidance", "API_KEY", "deny-rule-1", "Multi-tenant isolation"];
|
||||
for (const input of inputs) {
|
||||
expect(slugify(slugify(input))).toBe(slugify(input));
|
||||
}
|
||||
});
|
||||
|
||||
it("handles unicode by stripping (current ASCII-only policy)", () => {
|
||||
// Caveat: unicode in headings becomes empty/lossy. Document as a
|
||||
// known limit; lint rules can flag non-ASCII headings if needed.
|
||||
expect(slugify("Café")).toBe("caf");
|
||||
});
|
||||
});
|
||||
548
extensions/oc-path/src/oc-path/tests/universal.test.ts
Normal file
548
extensions/oc-path/src/oc-path/tests/universal.test.ts
Normal file
@@ -0,0 +1,548 @@
|
||||
// OC Path tests cover universal plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { emitMd } from "../emit.js";
|
||||
import { emitJsonc } from "../jsonc/emit.js";
|
||||
import { parseJsonc } from "../jsonc/parse.js";
|
||||
import { emitJsonl } from "../jsonl/emit.js";
|
||||
import { parseJsonl } from "../jsonl/parse.js";
|
||||
import { parseOcPath } from "../oc-path.js";
|
||||
import { parseMd } from "../parse.js";
|
||||
import { detectInsertion, resolveOcPath, setOcPath } from "../universal.js";
|
||||
import { parseYaml } from "../yaml/parse.js";
|
||||
|
||||
function expectLeaf(
|
||||
match: ReturnType<typeof resolveOcPath>,
|
||||
expected: { leafType: string; valueText: string },
|
||||
) {
|
||||
expect(match?.kind).toBe("leaf");
|
||||
if (match?.kind === "leaf") {
|
||||
expect(match.leafType).toBe(expected.leafType);
|
||||
expect(match.valueText).toBe(expected.valueText);
|
||||
}
|
||||
}
|
||||
|
||||
function expectNode(match: ReturnType<typeof resolveOcPath>, descriptor: string) {
|
||||
expect(match?.kind).toBe("node");
|
||||
if (match?.kind === "node") {
|
||||
expect(match.descriptor).toBe(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function expectInsertionPoint(match: ReturnType<typeof resolveOcPath>, container: string) {
|
||||
expect(match?.kind).toBe("insertion-point");
|
||||
if (match?.kind === "insertion-point") {
|
||||
expect(match.container).toBe(container);
|
||||
}
|
||||
}
|
||||
|
||||
describe("detectInsertion", () => {
|
||||
it("returns null for plain paths", () => {
|
||||
expect(detectInsertion(parseOcPath("oc://X.md/section/item/field"))).toBeNull();
|
||||
});
|
||||
|
||||
it("detects bare `+` end-insertion at section", () => {
|
||||
const info = detectInsertion(parseOcPath("oc://X.md/tools/+"));
|
||||
expect(info?.marker).toBe("+");
|
||||
expect(info?.parentPath.section).toBe("tools");
|
||||
expect(info?.parentPath.item).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects `+key` keyed insertion", () => {
|
||||
const info = detectInsertion(parseOcPath("oc://config/plugins/+gitlab"));
|
||||
expect(info?.marker).toEqual({ kind: "keyed", key: "gitlab" });
|
||||
});
|
||||
|
||||
it("detects `+nnn` indexed insertion", () => {
|
||||
const info = detectInsertion(parseOcPath("oc://config/items/+2"));
|
||||
expect(info?.marker).toEqual({ kind: "indexed", index: 2 });
|
||||
});
|
||||
|
||||
it("detects file-root insertion", () => {
|
||||
const info = detectInsertion(parseOcPath("oc://session.jsonl/+"));
|
||||
expect(info?.marker).toBe("+");
|
||||
expect(info?.parentPath.section).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcPath — md AST", () => {
|
||||
const md = parseMd("---\nname: github\n---\n\n## Boundaries\n\n- enabled: true\n").ast;
|
||||
|
||||
it("returns leaf with valueText for frontmatter entry", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"));
|
||||
expectLeaf(m, { valueText: "github", leafType: "string" });
|
||||
});
|
||||
|
||||
it("returns leaf for item-field", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/boundaries/enabled/enabled"));
|
||||
expectLeaf(m, { valueText: "true", leafType: "string" });
|
||||
});
|
||||
|
||||
it("returns node for block", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/boundaries"));
|
||||
expectNode(m, "md-block");
|
||||
});
|
||||
|
||||
it("returns root for file-only path", () => {
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md"));
|
||||
expect(m?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("returns null for unresolved", () => {
|
||||
expect(resolveOcPath(md, parseOcPath("oc://X.md/missing"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcPath — jsonc AST", () => {
|
||||
const ast = parseJsonc('{ "k": 42, "s": "x", "b": true, "n": null, "arr": [1,2,3] }').ast;
|
||||
|
||||
it("returns leaf:number for numeric value", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/k"));
|
||||
expectLeaf(m, { valueText: "42", leafType: "number" });
|
||||
});
|
||||
|
||||
it("returns leaf:string for string value", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/s"));
|
||||
expectLeaf(m, { valueText: "x", leafType: "string" });
|
||||
});
|
||||
|
||||
it("returns leaf:boolean for bool value", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/b"));
|
||||
expectLeaf(m, { valueText: "true", leafType: "boolean" });
|
||||
});
|
||||
|
||||
it("returns leaf:null for null value", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/n"));
|
||||
expectLeaf(m, { valueText: "null", leafType: "null" });
|
||||
});
|
||||
|
||||
it("returns node:jsonc-array for array value", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/arr"));
|
||||
expectNode(m, "jsonc-array");
|
||||
});
|
||||
|
||||
it("returns leaf at array index", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/arr.1"));
|
||||
expectLeaf(m, { valueText: "2", leafType: "number" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcPath — jsonl AST", () => {
|
||||
const ast = parseJsonl('{"event":"start","n":1}\n{"event":"step","n":2}\n').ast;
|
||||
|
||||
it("returns node:jsonl-line for line address", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://log/L1"));
|
||||
expectNode(m, "jsonl-line");
|
||||
});
|
||||
|
||||
it("returns leaf for field on line", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://log/L2/event"));
|
||||
expectLeaf(m, { valueText: "step", leafType: "string" });
|
||||
});
|
||||
|
||||
it("returns leaf:number for $last/n", () => {
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://log/$last/n"));
|
||||
expectLeaf(m, { valueText: "2", leafType: "number" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcPath — insertion-point detection", () => {
|
||||
it("returns insertion-point for md section append", () => {
|
||||
const md = parseMd("## Tools\n").ast;
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/tools/+"));
|
||||
expectInsertionPoint(m, "md-section");
|
||||
});
|
||||
|
||||
it("returns insertion-point for md file-level", () => {
|
||||
const md = parseMd("## Tools\n").ast;
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/+"));
|
||||
expectInsertionPoint(m, "md-file");
|
||||
});
|
||||
|
||||
it("returns insertion-point for md frontmatter +key", () => {
|
||||
const md = parseMd("---\nname: x\n---\n").ast;
|
||||
const m = resolveOcPath(md, parseOcPath("oc://X.md/[frontmatter]/+description"));
|
||||
expectInsertionPoint(m, "md-frontmatter");
|
||||
});
|
||||
|
||||
it("returns insertion-point for jsonc array +", () => {
|
||||
const ast = parseJsonc('{ "items": [1,2,3] }').ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/items/+"));
|
||||
expectInsertionPoint(m, "jsonc-array");
|
||||
});
|
||||
|
||||
it("returns insertion-point for jsonc object +key", () => {
|
||||
const ast = parseJsonc('{ "plugins": {} }').ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/plugins/+gitlab"));
|
||||
expectInsertionPoint(m, "jsonc-object");
|
||||
});
|
||||
|
||||
it("returns insertion-point for jsonl file-root +", () => {
|
||||
const ast = parseJsonl("").ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://log/+"));
|
||||
expectInsertionPoint(m, "jsonl-file");
|
||||
});
|
||||
|
||||
it("returns null when insertion target is not a container", () => {
|
||||
const ast = parseJsonc('{ "k": 42 }').ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://config/k/+"));
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOcPath — yaml AST", () => {
|
||||
it("preserves source line lookup for numeric map keys", () => {
|
||||
const ast = parseYaml("name: x\n1: one\n").ast;
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://workflow.yaml/1"));
|
||||
|
||||
expectLeaf(m, { valueText: "one", leafType: "string" });
|
||||
expect(m?.line).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — md leaf", () => {
|
||||
it("replaces frontmatter value", () => {
|
||||
const md = parseMd("---\nname: old\n---\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "new");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.kind === "md" && r.ast.frontmatter[0]?.value).toBe("new");
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces item kv value", () => {
|
||||
const md = parseMd("## Boundaries\n\n- timeout: 5\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/boundaries/timeout/timeout"), "60");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitMd(r.ast as Parameters<typeof emitMd>[0]);
|
||||
expect(out).toContain("- timeout: 60");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns unresolved for missing path", () => {
|
||||
const md = parseMd("").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/missing/x/x"), "v");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — jsonc leaf with coercion", () => {
|
||||
it("replaces string leaf with string value", () => {
|
||||
const ast = parseJsonc('{ "k": "old" }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/k"), "new");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: "new" });
|
||||
}
|
||||
});
|
||||
|
||||
it("coerces value to number when leaf was number", () => {
|
||||
const ast = parseJsonc('{ "k": 1 }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/k"), "42");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: 42 });
|
||||
}
|
||||
});
|
||||
|
||||
it('coerces "true"/"false" when leaf was boolean', () => {
|
||||
const ast = parseJsonc('{ "k": true }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/k"), "false");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ k: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-numeric string for number leaf", () => {
|
||||
const ast = parseJsonc('{ "k": 1 }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/k"), "not-a-number");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-bool string for boolean leaf", () => {
|
||||
const ast = parseJsonc('{ "k": true }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/k"), "maybe");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves slash-deep JSONC paths", () => {
|
||||
const ast = parseJsonc(
|
||||
'{ "agents": { "list": [{ "tools": { "exec": { "security": "deny" } } }] } }',
|
||||
).ast;
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://openclaw.json/agents/list/0/tools/exec/security"),
|
||||
"allowlist",
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
agents: { list: [{ tools: { exec: { security: "allowlist" } } }] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps JSON-looking strings as strings by default", () => {
|
||||
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/token"), '{"source":"file"}');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ token: '{"source":"file"}' });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a JSONC leaf with parsed JSON when requested", () => {
|
||||
const ast = parseJsonc('{ "token": "${TOKEN}" }').ast;
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://openclaw.json/token"),
|
||||
'{"source":"file","provider":"secrets","id":"/test"}',
|
||||
{ valueJson: true },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
token: { source: "file", provider: "secrets", id: "/test" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-finite parsed JSON replacement values", () => {
|
||||
const ast = parseJsonc('{ "limit": 1 }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://openclaw.json/limit"), "1e999", {
|
||||
valueJson: true,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — jsonl leaf", () => {
|
||||
it("replaces field on a value line with coercion", () => {
|
||||
const ast = parseJsonl('{"event":"start","n":1}\n').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/L1/n"), "42");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitJsonl(r.ast as Parameters<typeof emitJsonl>[0]);
|
||||
expect(JSON.parse(out.split("\n")[0])).toEqual({ event: "start", n: 42 });
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces whole line via JSON value", () => {
|
||||
const ast = parseJsonl('{"event":"start"}\n').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/L1"), '{"event":"replaced"}');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitJsonl(r.ast as Parameters<typeof emitJsonl>[0]);
|
||||
expect(JSON.parse(out.split("\n")[0])).toEqual({ event: "replaced" });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed JSON for whole-line replacement", () => {
|
||||
const ast = parseJsonl('{"event":"start"}\n').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/L1"), "not json");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — md insertion", () => {
|
||||
it("appends item to section with `+`", () => {
|
||||
const md = parseMd("## Tools\n\n- gh: GitHub CLI\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/tools/+"), "docker: container CLI");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitMd(r.ast as Parameters<typeof emitMd>[0]);
|
||||
expect(out).toContain("- gh: GitHub CLI");
|
||||
expect(out).toContain("- docker: container CLI");
|
||||
}
|
||||
});
|
||||
|
||||
it("appends new section at file root with `+`", () => {
|
||||
const md = parseMd("## Existing\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/+"), "New Section");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitMd(r.ast as Parameters<typeof emitMd>[0]);
|
||||
expect(out).toContain("## Existing");
|
||||
expect(out).toContain("## New Section");
|
||||
}
|
||||
});
|
||||
|
||||
it("adds new frontmatter key with +key", () => {
|
||||
const md = parseMd("---\nname: x\n---\n").ast;
|
||||
const r = setOcPath(
|
||||
md,
|
||||
parseOcPath("oc://X.md/[frontmatter]/+description"),
|
||||
"a new description",
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitMd(r.ast as Parameters<typeof emitMd>[0]);
|
||||
expect(out).toContain("description: a new description");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate frontmatter key on insertion", () => {
|
||||
const md = parseMd("---\nname: x\n---\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/+name"), "y");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("type-mismatch");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — jsonc insertion", () => {
|
||||
it("appends to array with `+`", () => {
|
||||
const ast = parseJsonc('{ "items": [1, 2] }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/items/+"), "3");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ items: [1, 2, 3] });
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts at index with `+nnn`", () => {
|
||||
const ast = parseJsonc('{ "items": [1, 3] }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/items/+1"), "2");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({ items: [1, 2, 3] });
|
||||
}
|
||||
});
|
||||
|
||||
it("adds object key with `+key`", () => {
|
||||
const ast = parseJsonc('{ "plugins": { "github": "tok" } }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/plugins/+gitlab"), '"new-tok"');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
plugins: { github: "tok", gitlab: "new-tok" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate object key", () => {
|
||||
const ast = parseJsonc('{ "plugins": { "github": "x" } }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/plugins/+github"), '"y"');
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects +key on array", () => {
|
||||
const ast = parseJsonc('{ "items": [1, 2] }').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/items/+abc"), "3");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("type-mismatch");
|
||||
}
|
||||
});
|
||||
|
||||
it("inserts complex object via JSON value", () => {
|
||||
const ast = parseJsonc('{ "plugins": {} }').ast;
|
||||
const r = setOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://config/plugins/+gitlab"),
|
||||
'{"token":"xyz","enabled":true}',
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const ast2 = r.ast as Parameters<typeof emitJsonc>[0];
|
||||
expect(JSON.parse(emitJsonc(ast2))).toEqual({
|
||||
plugins: { gitlab: { token: "xyz", enabled: true } },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — jsonl insertion (session append)", () => {
|
||||
it("appends a JSON line with `+`", () => {
|
||||
const ast = parseJsonl('{"event":"start"}\n').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/+"), '{"event":"step","n":1}');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
const out = emitJsonl(r.ast as Parameters<typeof emitJsonl>[0]);
|
||||
const lines = out.split("\n").filter((l) => l.length > 0);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(JSON.parse(lines[1])).toEqual({ event: "step", n: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed JSON value", () => {
|
||||
const ast = parseJsonl("").ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/+"), "not json");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-root insertion target", () => {
|
||||
const ast = parseJsonl('{"a":1}\n').ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://log/L1/+"), "{}");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setOcPath — cross-cutting properties", () => {
|
||||
it("is non-mutating across all kinds", () => {
|
||||
const md = parseMd("---\nname: x\n---\n").ast;
|
||||
const before = JSON.stringify(md);
|
||||
setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "new");
|
||||
expect(JSON.stringify(md)).toBe(before);
|
||||
|
||||
const jsonc = parseJsonc('{ "k": 1 }').ast;
|
||||
const before2 = JSON.stringify(jsonc);
|
||||
setOcPath(jsonc, parseOcPath("oc://config/k"), "99");
|
||||
expect(JSON.stringify(jsonc)).toBe(before2);
|
||||
|
||||
const jsonl = parseJsonl('{"a":1}\n').ast;
|
||||
const before3 = JSON.stringify(jsonl);
|
||||
setOcPath(jsonl, parseOcPath("oc://log/L1/a"), "99");
|
||||
expect(JSON.stringify(jsonl)).toBe(before3);
|
||||
});
|
||||
|
||||
it("returns ok-tagged result with new ast on success", () => {
|
||||
const md = parseMd("---\nname: x\n---\n").ast;
|
||||
const r = setOcPath(md, parseOcPath("oc://X.md/[frontmatter]/name"), "y");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.kind).toBe("md");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns failure-tagged result with reason on unresolved", () => {
|
||||
const ast = parseJsonc("{}").ast;
|
||||
const r = setOcPath(ast, parseOcPath("oc://config/missing"), "v");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
});
|
||||
333
extensions/oc-path/src/oc-path/tests/yaml/yaml-kind.test.ts
Normal file
333
extensions/oc-path/src/oc-path/tests/yaml/yaml-kind.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
// OC Path tests cover yaml kind plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inferKind } from "../../dispatch.js";
|
||||
import { parseOcPath } from "../../oc-path.js";
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../../sentinel.js";
|
||||
import { resolveOcPath, setOcPath } from "../../universal.js";
|
||||
import { insertYamlOcPath, setYamlOcPath } from "../../yaml/edit.js";
|
||||
import { emitYaml } from "../../yaml/emit.js";
|
||||
import { parseYaml } from "../../yaml/parse.js";
|
||||
import { resolveYamlOcPath } from "../../yaml/resolve.js";
|
||||
|
||||
const LOBSTER = `name: inbox-triage
|
||||
description: A simple example workflow
|
||||
|
||||
steps:
|
||||
- id: fetch
|
||||
command: gog.gmail.search --query 'newer_than:1d' --max 20
|
||||
|
||||
- id: classify
|
||||
command: openclaw.invoke --tool llm-task --action json
|
||||
stdin: $fetch.stdout
|
||||
`;
|
||||
|
||||
describe("parseYaml — round-trip", () => {
|
||||
it("preserves bytes verbatim on round-trip", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
expect(emitYaml(ast)).toBe(LOBSTER);
|
||||
});
|
||||
|
||||
it("exposes kind: yaml discriminator", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
expect(ast.kind).toBe("yaml");
|
||||
});
|
||||
|
||||
it("handles empty file", () => {
|
||||
const { ast } = parseYaml("");
|
||||
expect(ast.kind).toBe("yaml");
|
||||
expect(emitYaml(ast)).toBe("");
|
||||
});
|
||||
|
||||
it("reports errors as diagnostics, not throws", () => {
|
||||
const { diagnostics } = parseYaml("key: value\n bad indent: oops\n");
|
||||
expect(diagnostics.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveYamlOcPath — direct", () => {
|
||||
it("resolves top-level scalar", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveYamlOcPath(ast, parseOcPath("oc://workflow.lobster/name"));
|
||||
expect(m?.kind).toBe("pair");
|
||||
if (m?.kind === "pair") {
|
||||
expect(m.value).toBe("inbox-triage");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves into a sequence by index", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveYamlOcPath(ast, parseOcPath("oc://workflow.lobster/steps.0.id"));
|
||||
expect(m?.kind).toBe("pair");
|
||||
if (m?.kind === "pair") {
|
||||
expect(m.value).toBe("fetch");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not resolve noncanonical sequence indexes", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
expect(resolveYamlOcPath(ast, parseOcPath("oc://workflow.lobster/steps.01.id"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns root when no segments", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveYamlOcPath(ast, parseOcPath("oc://workflow.lobster"));
|
||||
expect(m?.kind).toBe("root");
|
||||
});
|
||||
|
||||
it("returns null for unresolved paths", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
expect(resolveYamlOcPath(ast, parseOcPath("oc://workflow.lobster/missing"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setYamlOcPath — direct", () => {
|
||||
it("replaces a scalar value", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://workflow.lobster/name"), "new-name");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("name: new-name");
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces a nested scalar", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://workflow.lobster/steps.0.id"), "fetch-renamed");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("id: fetch-renamed");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports unresolved for noncanonical sequence indexes", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://workflow.lobster/steps.01.id"), "nope");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns unresolved for missing path", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://workflow.lobster/missing"), "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns parse-error before editing a malformed document", () => {
|
||||
const { ast } = parseYaml("key: value\n bad indent: oops\n");
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://workflow.yaml/key"), "new-value");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns parse-error before inserting into a malformed document", () => {
|
||||
const { ast } = parseYaml("key: value\n bad indent: oops\n");
|
||||
const r = insertYamlOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://workflow.yaml"),
|
||||
{ kind: "keyed", key: "next" },
|
||||
"x",
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("setYamlOcPath — positional tokens", () => {
|
||||
it("edits the first seq element via $first", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://workflow.lobster/steps/$first/id"),
|
||||
"fetch-renamed",
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("id: fetch-renamed");
|
||||
}
|
||||
});
|
||||
|
||||
it("edits the last seq element via $last", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setYamlOcPath(
|
||||
ast,
|
||||
parseOcPath("oc://workflow.lobster/steps/$last/id"),
|
||||
"classify-renamed",
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("id: classify-renamed");
|
||||
}
|
||||
});
|
||||
|
||||
it("edits the first map entry via $first", () => {
|
||||
const { ast } = parseYaml("config:\n a: 1\n b: 2\n c: 3\n");
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://x.yaml/config/$first"), 99);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("a: 99");
|
||||
}
|
||||
});
|
||||
|
||||
it("edits the last map entry via $last", () => {
|
||||
const { ast } = parseYaml("config:\n a: 1\n b: 2\n c: 3\n");
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://x.yaml/config/$last"), 99);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.ast.raw).toContain("c: 99");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns unresolved for $last against an empty seq", () => {
|
||||
const { ast } = parseYaml("items: []\n");
|
||||
const r = setYamlOcPath(ast, parseOcPath("oc://x.yaml/items/$last"), "x");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("unresolved");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferKind — yaml extensions", () => {
|
||||
it("maps .yaml / .yml / .lobster to yaml", () => {
|
||||
expect(inferKind("workflow.yaml")).toBe("yaml");
|
||||
expect(inferKind("config.yml")).toBe("yaml");
|
||||
expect(inferKind("inbox-triage.lobster")).toBe("yaml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("universal verbs — yaml dispatch", () => {
|
||||
it("resolveOcPath returns kind-agnostic match for yaml leaf", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://workflow.lobster/name"));
|
||||
expect(m).toMatchObject({ kind: "leaf", valueText: "inbox-triage", leafType: "string" });
|
||||
});
|
||||
|
||||
it("resolveOcPath returns node:yaml-map for top-level seq item", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://workflow.lobster/steps.0"));
|
||||
expect(m).toMatchObject({ kind: "node", descriptor: "yaml-map" });
|
||||
});
|
||||
|
||||
it("resolveOcPath returns node:yaml-seq for sequence root", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://workflow.lobster/steps"));
|
||||
expect(m).toMatchObject({ kind: "node", descriptor: "yaml-seq" });
|
||||
});
|
||||
|
||||
it("resolveOcPath returns yaml-map insertion for map root", () => {
|
||||
const { ast } = parseYaml("name: inbox\n");
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://workflow.yaml/+owner"));
|
||||
expect(m).toMatchObject({ kind: "insertion-point", container: "yaml-map" });
|
||||
});
|
||||
|
||||
it("resolveOcPath returns yaml-seq insertion for sequence root", () => {
|
||||
const { ast } = parseYaml("- a\n");
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://items.yaml/+"));
|
||||
expect(m).toMatchObject({ kind: "insertion-point", container: "yaml-seq" });
|
||||
});
|
||||
|
||||
it("resolveOcPath rejects insertion under scalar root", () => {
|
||||
const { ast } = parseYaml("hello\n");
|
||||
const m = resolveOcPath(ast, parseOcPath("oc://value.yaml/+"));
|
||||
expect(m).toBeNull();
|
||||
});
|
||||
|
||||
it("setOcPath replaces a yaml scalar via universal verb", () => {
|
||||
const { ast } = parseYaml(LOBSTER);
|
||||
const r = setOcPath(ast, parseOcPath("oc://workflow.lobster/name"), "updated");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.ast.kind === "yaml") {
|
||||
expect(r.ast.raw).toContain("name: updated");
|
||||
}
|
||||
});
|
||||
|
||||
it("setOcPath coerces numeric string to number for number leaf", () => {
|
||||
const { ast } = parseYaml("count: 5\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/count"), "42");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.ast.kind === "yaml") {
|
||||
expect(r.ast.raw).toContain("count: 42");
|
||||
}
|
||||
});
|
||||
|
||||
it("setOcPath returns parse-error for invalid coercion", () => {
|
||||
const { ast } = parseYaml("count: 5\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/count"), "abc");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.reason).toBe("parse-error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("universal verbs — yaml insertion", () => {
|
||||
it("appends to a yaml seq with `+`", () => {
|
||||
const { ast } = parseYaml("items:\n - a\n - b\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/items/+"), '"c"');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.ast.kind === "yaml") {
|
||||
expect(r.ast.raw).toContain("- c");
|
||||
}
|
||||
});
|
||||
|
||||
it("appends to an empty yaml seq with `+`", () => {
|
||||
const { ast } = parseYaml("items: []\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/items/+"), '"a"');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.ast.kind === "yaml") {
|
||||
expect(r.ast.raw).toContain("items: [ a ]");
|
||||
}
|
||||
});
|
||||
|
||||
it("adds key to yaml map with `+key`", () => {
|
||||
const { ast } = parseYaml("config:\n a: 1\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/config/+b"), "2");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok && r.ast.kind === "yaml") {
|
||||
expect(r.ast.raw).toContain("b: 2");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate map key on insertion", () => {
|
||||
const { ast } = parseYaml("config:\n a: 1\n");
|
||||
const r = setOcPath(ast, parseOcPath("oc://x.yaml/config/+a"), "99");
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects sentinel-bearing yaml replacements before raw emit", () => {
|
||||
const { ast } = parseYaml("token: safe\n");
|
||||
expect(() => setOcPath(ast, parseOcPath("oc://x.yaml/token"), REDACTED_SENTINEL)).toThrow(
|
||||
OcEmitSentinelError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects sentinel-bearing yaml insertions before raw emit", () => {
|
||||
const { ast } = parseYaml("items: []\n");
|
||||
expect(() =>
|
||||
setOcPath(ast, parseOcPath("oc://x.yaml/items/+"), `{"token":"${REDACTED_SENTINEL}"}`),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("rejects sentinel-bearing yaml insertion keys before raw emit", () => {
|
||||
const { ast } = parseYaml("config:\n safe: 1\n");
|
||||
expect(() =>
|
||||
setOcPath(ast, parseOcPath(`oc://x.yaml/config/+${REDACTED_SENTINEL}`), "2"),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
|
||||
it("rejects sentinel-bearing yaml object keys before raw emit", () => {
|
||||
const { ast } = parseYaml("items: []\n");
|
||||
expect(() =>
|
||||
setOcPath(ast, parseOcPath("oc://x.yaml/items/+"), `{"${REDACTED_SENTINEL}":"2"}`),
|
||||
).toThrow(OcEmitSentinelError);
|
||||
});
|
||||
});
|
||||
1005
extensions/oc-path/src/oc-path/universal.ts
Normal file
1005
extensions/oc-path/src/oc-path/universal.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
extensions/oc-path/src/oc-path/yaml/ast.ts
Normal file
9
extensions/oc-path/src/oc-path/yaml/ast.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// OC Path module implements ast behavior.
|
||||
import type { Document, LineCounter } from "yaml";
|
||||
|
||||
export interface YamlAst {
|
||||
readonly kind: "yaml";
|
||||
readonly raw: string;
|
||||
readonly doc: Document.Parsed;
|
||||
readonly lineCounter: LineCounter;
|
||||
}
|
||||
219
extensions/oc-path/src/oc-path/yaml/edit.ts
Normal file
219
extensions/oc-path/src/oc-path/yaml/edit.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// OC Path module implements edit behavior.
|
||||
import {
|
||||
Document,
|
||||
isMap,
|
||||
isScalar,
|
||||
isSeq,
|
||||
LineCounter,
|
||||
parseDocument,
|
||||
type Node,
|
||||
type Pair,
|
||||
} from "yaml";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import {
|
||||
formatOcPath,
|
||||
isPositionalSeg,
|
||||
isQuotedSeg,
|
||||
parseArrayIndexSegment,
|
||||
resolvePositionalSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "../oc-path.js";
|
||||
import { guardSentinel } from "../sentinel.js";
|
||||
import type { YamlAst } from "./ast.js";
|
||||
|
||||
export type YamlEditResult =
|
||||
| { readonly ok: true; readonly ast: YamlAst }
|
||||
| {
|
||||
readonly ok: false;
|
||||
readonly reason: "unresolved" | "no-root" | "parse-error";
|
||||
};
|
||||
|
||||
export function setYamlOcPath(ast: YamlAst, path: OcPath, newValue: unknown): YamlEditResult {
|
||||
if (hasYamlParseErrors(ast)) {
|
||||
return { ok: false, reason: "parse-error" };
|
||||
}
|
||||
if (ast.doc.contents === null) {
|
||||
return { ok: false, reason: "no-root" };
|
||||
}
|
||||
guardYamlSentinel(newValue, formatOcPath(path));
|
||||
|
||||
const rawSegments = pathSegments(path);
|
||||
if (rawSegments.length === 0) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const segments = resolvePositionalSegments(ast.doc.contents as Node, rawSegments);
|
||||
if (segments === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
if (!ast.doc.hasIn(segments)) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
const { doc: cloned, lineCounter } = cloneDoc(ast.doc);
|
||||
cloned.setIn(segments, newValue);
|
||||
return { ok: true, ast: { kind: "yaml", raw: cloned.toString(), doc: cloned, lineCounter } };
|
||||
}
|
||||
|
||||
export function insertYamlOcPath(
|
||||
ast: YamlAst,
|
||||
parentPath: OcPath,
|
||||
marker: "+" | { kind: "keyed"; key: string } | { kind: "indexed"; index: number },
|
||||
newValue: unknown,
|
||||
): YamlEditResult {
|
||||
if (hasYamlParseErrors(ast)) {
|
||||
return { ok: false, reason: "parse-error" };
|
||||
}
|
||||
if (ast.doc.contents === null) {
|
||||
return { ok: false, reason: "no-root" };
|
||||
}
|
||||
guardYamlSentinel(newValue, `${formatOcPath(parentPath)}/${formatInsertionMarker(marker)}`);
|
||||
|
||||
const rawParentSegments = pathSegments(parentPath);
|
||||
const segments =
|
||||
rawParentSegments.length === 0
|
||||
? rawParentSegments
|
||||
: resolvePositionalSegments(ast.doc.contents as Node, rawParentSegments);
|
||||
if (segments === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
const { doc: cloned, lineCounter } = cloneDoc(ast.doc);
|
||||
|
||||
const parent = segments.length === 0 ? cloned.contents : cloned.getIn(segments, false);
|
||||
if (parent === undefined || parent === null) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
if (isMap(parent)) {
|
||||
if (typeof marker !== "object" || marker.kind !== "keyed") {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
guardSentinel(marker.key, `${formatOcPath(parentPath)}/${formatInsertionMarker(marker)}`);
|
||||
if (cloned.hasIn([...segments, marker.key])) {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
cloned.setIn([...segments, marker.key], newValue);
|
||||
return { ok: true, ast: { kind: "yaml", raw: cloned.toString(), doc: cloned, lineCounter } };
|
||||
}
|
||||
|
||||
if (isSeq(parent)) {
|
||||
if (typeof marker === "object" && marker.kind === "keyed") {
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
if (marker === "+") {
|
||||
cloned.addIn(segments, newValue);
|
||||
} else if (typeof marker === "object" && marker.kind === "indexed") {
|
||||
const idx = Math.min(marker.index, parent.items.length);
|
||||
parent.items.splice(idx, 0, cloned.createNode(newValue) as Node);
|
||||
}
|
||||
return { ok: true, ast: { kind: "yaml", raw: cloned.toString(), doc: cloned, lineCounter } };
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unresolved" };
|
||||
}
|
||||
|
||||
function resolvePositionalSegments(root: Node, segments: readonly string[]): string[] | null {
|
||||
const out: string[] = [];
|
||||
let node: Node | null = root;
|
||||
for (const seg of segments) {
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
let segNorm = seg;
|
||||
if (isPositionalSeg(seg)) {
|
||||
const concrete = positionalForYamlNode(node, seg);
|
||||
if (concrete === null) {
|
||||
return null;
|
||||
}
|
||||
segNorm = concrete;
|
||||
}
|
||||
out.push(segNorm);
|
||||
if (isMap(node)) {
|
||||
const pairs: readonly Pair[] = (node as { items: readonly Pair[] }).items;
|
||||
const pair: Pair | undefined = pairs.find((p) => {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
return String(k) === segNorm;
|
||||
});
|
||||
node = (pair?.value as Node | undefined) ?? null;
|
||||
continue;
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
const idx = parseArrayIndexSegment(segNorm, node.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
node = (node.items[idx] as Node | null) ?? null;
|
||||
continue;
|
||||
}
|
||||
node = null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function positionalForYamlNode(node: Node, seg: string): string | null {
|
||||
if (isMap(node)) {
|
||||
const pairs: readonly Pair[] = (node as { items: readonly Pair[] }).items;
|
||||
const keys: readonly string[] = pairs.map((p) => String(isScalar(p.key) ? p.key.value : p.key));
|
||||
return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
const items: readonly Node[] = (node as { items: readonly Node[] }).items;
|
||||
return resolvePositionalSeg(seg, { indexable: true, size: items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pathSegments(path: OcPath): string[] {
|
||||
const segs: string[] = [];
|
||||
const collect = (slot: string | undefined) => {
|
||||
if (slot === undefined) {
|
||||
return;
|
||||
}
|
||||
for (const sub of splitRespectingBrackets(slot, ".")) {
|
||||
segs.push(isQuotedSeg(sub) ? unquoteSeg(sub) : sub);
|
||||
}
|
||||
};
|
||||
collect(path.section);
|
||||
collect(path.item);
|
||||
collect(path.field);
|
||||
return segs;
|
||||
}
|
||||
|
||||
function formatInsertionMarker(
|
||||
marker: "+" | { kind: "keyed"; key: string } | { kind: "indexed"; index: number },
|
||||
): string {
|
||||
if (marker === "+") {
|
||||
return "+";
|
||||
}
|
||||
return marker.kind === "keyed" ? `+${marker.key}` : `+${marker.index}`;
|
||||
}
|
||||
|
||||
function guardYamlSentinel(value: unknown, ocPath: string): void {
|
||||
guardSentinel(value, ocPath);
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => guardYamlSentinel(item, `${ocPath}/${index}`));
|
||||
return;
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
guardSentinel(key, `${ocPath}/${key}`);
|
||||
guardYamlSentinel(child, `${ocPath}/${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasYamlParseErrors(ast: YamlAst): boolean {
|
||||
return ast.doc.errors.length > 0;
|
||||
}
|
||||
|
||||
function cloneDoc(doc: Document.Parsed): { doc: Document.Parsed; lineCounter: LineCounter } {
|
||||
const lineCounter = new LineCounter();
|
||||
const cloned = parseDocument(doc.toString(), {
|
||||
keepSourceTokens: true,
|
||||
prettyErrors: false,
|
||||
lineCounter,
|
||||
});
|
||||
return { doc: cloned, lineCounter };
|
||||
}
|
||||
28
extensions/oc-path/src/oc-path/yaml/emit.ts
Normal file
28
extensions/oc-path/src/oc-path/yaml/emit.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// OC Path module implements emit behavior.
|
||||
import { OcEmitSentinelError, REDACTED_SENTINEL } from "../sentinel.js";
|
||||
import type { YamlAst } from "./ast.js";
|
||||
|
||||
export interface YamlEmitOptions {
|
||||
readonly mode?: "roundtrip" | "render";
|
||||
readonly fileNameForGuard?: string;
|
||||
readonly acceptPreExistingSentinel?: boolean;
|
||||
}
|
||||
|
||||
export function emitYaml(ast: YamlAst, opts: YamlEmitOptions = {}): string {
|
||||
const mode = opts.mode ?? "roundtrip";
|
||||
const guardPath = opts.fileNameForGuard ? `oc://${opts.fileNameForGuard}` : "oc://";
|
||||
const acceptPreExisting = opts.acceptPreExistingSentinel ?? true;
|
||||
|
||||
if (mode === "roundtrip") {
|
||||
if (!acceptPreExisting && ast.raw.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/[raw]`);
|
||||
}
|
||||
return ast.raw;
|
||||
}
|
||||
|
||||
const rendered = ast.doc.toString();
|
||||
if (rendered.includes(REDACTED_SENTINEL)) {
|
||||
throw new OcEmitSentinelError(`${guardPath}/[rendered]`);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
36
extensions/oc-path/src/oc-path/yaml/parse.ts
Normal file
36
extensions/oc-path/src/oc-path/yaml/parse.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// OC Path module implements parse behavior.
|
||||
import { LineCounter, parseDocument } from "yaml";
|
||||
import type { Diagnostic } from "../ast.js";
|
||||
import type { YamlAst } from "./ast.js";
|
||||
|
||||
export interface YamlParseResult {
|
||||
readonly ast: YamlAst;
|
||||
readonly diagnostics: readonly Diagnostic[];
|
||||
}
|
||||
|
||||
export function parseYaml(raw: string): YamlParseResult {
|
||||
const lineCounter = new LineCounter();
|
||||
const doc = parseDocument(raw, {
|
||||
keepSourceTokens: true,
|
||||
prettyErrors: false,
|
||||
lineCounter,
|
||||
});
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
for (const w of doc.warnings) {
|
||||
diagnostics.push({
|
||||
line: w.linePos?.[0]?.line ?? 1,
|
||||
message: w.message,
|
||||
severity: "warning",
|
||||
code: "OC_YAML_WARN",
|
||||
});
|
||||
}
|
||||
for (const e of doc.errors) {
|
||||
diagnostics.push({
|
||||
line: e.linePos?.[0]?.line ?? 1,
|
||||
message: e.message,
|
||||
severity: "error",
|
||||
code: "OC_YAML_PARSE_FAILED",
|
||||
});
|
||||
}
|
||||
return { ast: { kind: "yaml", raw, doc, lineCounter }, diagnostics };
|
||||
}
|
||||
143
extensions/oc-path/src/oc-path/yaml/resolve.ts
Normal file
143
extensions/oc-path/src/oc-path/yaml/resolve.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// OC Path module implements resolve behavior.
|
||||
import { isMap, isScalar, isSeq, type Node, type Pair } from "yaml";
|
||||
import type { OcPath } from "../oc-path.js";
|
||||
import {
|
||||
isPositionalSeg,
|
||||
isQuotedSeg,
|
||||
parseArrayIndexSegment,
|
||||
resolvePositionalSeg,
|
||||
splitRespectingBrackets,
|
||||
unquoteSeg,
|
||||
} from "../oc-path.js";
|
||||
import type { YamlAst } from "./ast.js";
|
||||
|
||||
export type YamlOcPathMatch =
|
||||
| { readonly kind: "root"; readonly node: YamlAst }
|
||||
| { readonly kind: "scalar"; readonly value: unknown; readonly path: readonly string[] }
|
||||
| {
|
||||
readonly kind: "map";
|
||||
readonly path: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "seq";
|
||||
readonly path: readonly string[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "pair";
|
||||
readonly key: string;
|
||||
readonly value: unknown;
|
||||
readonly path: readonly string[];
|
||||
};
|
||||
|
||||
export function resolveYamlOcPath(ast: YamlAst, path: OcPath): YamlOcPathMatch | null {
|
||||
const segments: string[] = [];
|
||||
if (path.section !== undefined) {
|
||||
for (const s of splitRespectingBrackets(path.section, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
}
|
||||
if (path.item !== undefined) {
|
||||
for (const s of splitRespectingBrackets(path.item, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
}
|
||||
if (path.field !== undefined) {
|
||||
for (const s of splitRespectingBrackets(path.field, ".")) {
|
||||
segments.push(isQuotedSeg(s) ? unquoteSeg(s) : s);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
return { kind: "root", node: ast };
|
||||
}
|
||||
|
||||
const root = ast.doc.contents;
|
||||
if (root === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return walkNode(root, segments, 0, []);
|
||||
}
|
||||
|
||||
function walkNode(
|
||||
node: Node | null,
|
||||
segments: readonly string[],
|
||||
i: number,
|
||||
walked: readonly string[],
|
||||
): YamlOcPathMatch | null {
|
||||
if (node === null) {
|
||||
return null;
|
||||
}
|
||||
let seg = segments[i];
|
||||
|
||||
if (seg === undefined) {
|
||||
if (isMap(node)) {
|
||||
return { kind: "map", path: walked };
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
return { kind: "seq", path: walked };
|
||||
}
|
||||
if (isScalar(node)) {
|
||||
return { kind: "scalar", value: node.value, path: walked };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (seg.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isPositionalSeg(seg)) {
|
||||
const concrete = positionalForYaml(node, seg);
|
||||
if (concrete !== null) {
|
||||
seg = concrete;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMap(node)) {
|
||||
const pair = (node as { items: Pair[] }).items.find((p) => {
|
||||
const k = isScalar(p.key) ? p.key.value : p.key;
|
||||
return String(k) === seg;
|
||||
});
|
||||
if (pair === undefined) {
|
||||
return null;
|
||||
}
|
||||
const childWalked = [...walked, seg];
|
||||
if (i === segments.length - 1) {
|
||||
const child = pair.value;
|
||||
if (isScalar(child)) {
|
||||
return {
|
||||
kind: "pair",
|
||||
key: seg,
|
||||
value: child.value,
|
||||
path: childWalked,
|
||||
};
|
||||
}
|
||||
return walkNode(child as Node, segments, i + 1, childWalked);
|
||||
}
|
||||
return walkNode(pair.value as Node, segments, i + 1, childWalked);
|
||||
}
|
||||
|
||||
if (isSeq(node)) {
|
||||
const idx = parseArrayIndexSegment(seg, node.items.length);
|
||||
if (idx === null) {
|
||||
return null;
|
||||
}
|
||||
const child = node.items[idx];
|
||||
return walkNode(child as Node, segments, i + 1, [...walked, seg]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function positionalForYaml(node: Node, seg: string): string | null {
|
||||
if (isMap(node)) {
|
||||
const pairs = (node as { items: Pair[] }).items;
|
||||
const keys = pairs.map((p) => String(isScalar(p.key) ? p.key.value : p.key));
|
||||
return resolvePositionalSeg(seg, { indexable: false, size: keys.length, keys });
|
||||
}
|
||||
if (isSeq(node)) {
|
||||
const items = (node as { items: Node[] }).items;
|
||||
return resolvePositionalSeg(seg, { indexable: true, size: items.length });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user