From 408d0b30664bd5477b0604a75933561f8bbb4695 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 10 Oct 2026 21:30:02 +0000 Subject: [PATCH] Initial commit: omp-ast DSH plugin (oh-my-pi port) with docs and tests --- .gitignore | 5 + README.md | 73 +++++++++++++ cordis.patch.yml | 6 ++ index.js | 266 ++++++++++++++++++++++++++++++++++++++++++++++ lib/sg.js | 60 +++++++++++ locale/en.json | 1 + locale/zh.json | 1 + package.json | 12 +++ test/ast.test.mjs | 64 +++++++++++ 9 files changed, 488 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 lib/sg.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 package.json create mode 100644 test/ast.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2f1f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.bak +*.orig +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..866131e --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# omp-ast + +[oh-my-pi](https://github.com/can1357/oh-my-pi)'s `ast_grep` and `ast_edit` tools for the DeepSeek Harness (DSH): **structural code search** and **structural rewrites that are previewed before they are applied**. Both are backed by [ast-grep](https://ast-grep.github.io) (tree-sitter grammars for JS/TS/TSX, Python, Go, Rust, Java, C/C++, C#, Kotlin, Swift, Ruby, PHP, Lua, Bash, HTML, CSS, JSON, YAML, …). + +## Tools + +### `ast_grep` + +| Field | Description | +| --- | --- | +| `pat` (required) | One AST pattern, e.g. `console.log($$$ARGS)`, `def $F($$$): $$$BODY` | +| `path` | File, directory or glob; several separated by `;` (default: session workspace) | +| `lang` | Language override (`ts`, `tsx`, `python`, `cpp`, …) | +| `globs` | Include/exclude globs (`!` prefix excludes) | +| `selector` | AST kind to select inside the pattern (advanced) | +| `skip` | Paging offset | + +Pattern grammar: +- `$NAME` captures one node; `$$$NAME` captures zero or more nodes. +- `$_` and `$$$` match without binding. +- Names are UPPERCASE and stand for whole nodes. + +Output is grouped per file as `LINE:TEXT`, with the captured metavariables: + +``` +2 matches in 1 file +src/a.js +2: console.log(x) + ↳ $$$ARGS=[x] +3: console.log(x, 2) + ↳ $$$ARGS=[x, 2] +``` + +### `ast_edit` + +1. **Preview:** `{ops: [{pat, out}, …], paths: [...]}`. + - Captures from `pat` are substituted into `out`; an empty `out` deletes the node. + - Ops run in order, and each sees the result of the previous one. + - The call returns a diff preview and a proposal id. **No file is modified.** +2. **Apply:** `{apply: ""}` writes the proposal. It's refused if any file changed since the preview. +3. **Discard:** `{reject: ""}` drops the proposal. + +Applied writes go through DSH's filesystem service (sandbox policy, observation tracking), and the Web UI shows a diff card. Proposals expire after `proposalTtlMinutes`. + +## Requirements + +```bash +npm i -g @ast-grep/cli # or: npm i -g --prefix ~/.local @ast-grep/cli +``` + +The plugin looks for the `ast-grep` binary on `PATH` and in `~/.local/bin`. It ignores `sg`, which collides with util-linux on many distros. + +## Installation + +```bash +dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-ast.git +``` + +Or in the Web UI, go to **Settings → Plugins → Install** and paste the same URL. For development, clone the repo and run `dsh plugin --profile web add link:/path/to/omp-ast`. Restart DSH afterwards (`systemctl --user restart dsh`). + +## Configuration + +```yaml +- id: omp-ast + config: + maxMatches: 200 # ast_grep page size + maxFiles: 1000 # ast_edit safety cap + proposalTtlMinutes: 30 +``` + +## Tests + +`npm test` runs 4 tests against the real ast-grep CLI and DSH's fs backend. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..0c00034 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,6 @@ +- insert: + - id: omp-ast + name: '@local/omp-ast' + config: + maxMatches: 200 + proposalTtlMinutes: 30 diff --git a/index.js b/index.js new file mode 100644 index 0000000..6eec4a8 --- /dev/null +++ b/index.js @@ -0,0 +1,266 @@ +/** + * @local/omp-ast — oh-my-pi's `ast_grep` and `ast_edit` for DSH, backed by the ast-grep CLI. + * + * ast_grep structural search: `$NAME` captures one node, `$$$NAME` zero-or-more, `$_`/`$$$` + * match without binding. Results are grouped per file with line numbers and captures. + * ast_edit structural rewrite, preview first: returns a diff preview and a proposal id; calling + * again with `apply: ""` writes it (refused if any file changed since the preview). + * `reject: ""` drops it. + */ +import crypto from 'node:crypto' +import path from 'node:path' +import { findAstGrep, search, applyReplacements } from './lib/sg.js' + +export const name = 'omp-ast' +export const inject = ['tools', 'fs'] + +const PATTERN_HELP = 'Pattern grammar: real code for the target language with metavariables — `$NAME` captures one AST node, `$$$NAME` captures zero or more nodes, `$_` / `$$$` match without binding. Names are UPPERCASE and stand for whole nodes (not parts of identifiers or strings); reusing a name requires identical code. The pattern must parse as one valid node (C/C++/Java statements need their trailing `;`).' + +export function apply(ctx, config = {}) { + const cfg = { maxMatches: 200, maxFiles: 1000, proposalTtlMinutes: 30, ...config } + const cwdOf = (exec) => exec?.agent?.session?.header?.cwd ?? process.cwd() + const bin = () => { + const b = findAstGrep(cfg.binary ? [path.dirname(cfg.binary)] : []) + if (!b) throw new Error('ast-grep is not installed. Install it with `npm i -g @ast-grep/cli` (or `npm i -g --prefix ~/.local @ast-grep/cli`).') + return b + } + const splitPaths = (p) => (Array.isArray(p) ? p : String(p ?? '').split(';')).map((s) => String(s).trim()).filter(Boolean) + const rel = (f, cwd) => { const r = path.relative(cwd, path.resolve(cwd, f)); return r && !r.startsWith('..') ? r : f } + + /** Sandbox fence for writes: same rule as DSH workspace-write (inside workspace root). */ + const policyFor = (exec) => { try { return ctx.get('sandboxPolicy')?.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) }) } catch { return undefined } } + + // ---------------------------------------------------------------- ast_grep + ctx.tools.register({ + name: 'ast_grep', + description: `Structural code search with ast-grep over tree-sitter grammars (js/ts/tsx, python, go, rust, java, c/cpp, c#, kotlin, swift, ruby, php, lua, bash, html, css, json, yaml, …). Use it instead of regex grep when the shape of the code matters (calls with any arguments, functions with a decorator, a specific nesting). ${PATTERN_HELP} Results are grouped per file with \`LINE:TEXT\` and the captured metavariables.`, + parameters: { + type: 'object', + additionalProperties: false, + required: ['pat'], + properties: { + pat: { type: 'string', description: 'One AST pattern, e.g. `console.log($$$ARGS)` or `def $F($$$): $$$BODY`.' }, + path: { type: 'string', description: 'File, directory or glob; several separated by `;`. Defaults to the session workspace.' }, + lang: { type: 'string', description: 'Language override (e.g. ts, tsx, python, cpp). Inferred from file extensions when omitted.' }, + globs: { type: 'array', items: { type: 'string' }, description: 'Include/exclude globs (prefix ! to exclude), e.g. ["src/**", "!**/*.test.ts"].' }, + selector: { type: 'string', description: 'AST kind to select inside the pattern (advanced; e.g. `pair` for a JSON key pattern).' }, + skip: { type: 'integer', description: 'Skip this many matches (paging).' }, + }, + }, + output: { schema: { type: 'object', additionalProperties: true, properties: { text: { type: 'string' } } }, render: (_a, v) => [{ type: 'text', text: v.text }] }, + timeoutMs: 150_000, + isConcurrencySafe: () => true, + async execute(args, exec) { + const pat = String(args.pat ?? '').trim() + if (!pat) throw new Error('pat must be a non-empty AST pattern') + const cwd = cwdOf(exec) + const { matches, warnings } = await search({ bin: bin(), pattern: pat, lang: args.lang, paths: splitPaths(args.path), globs: args.globs, selector: args.selector, cwd, signal: exec?.signal }) + const skip = Math.max(0, Math.floor(args.skip ?? 0)) + const page = matches.slice(skip, skip + cfg.maxMatches) + const byFile = new Map() + for (const m of page) { if (!byFile.has(m.file)) byFile.set(m.file, []); byFile.get(m.file).push(m) } + const out = [] + for (const [file, ms] of byFile) { + out.push(rel(file, cwd)) + for (const m of ms) { + const start = m.range.start.line + 1 + const lines = String(m.lines ?? m.text).split('\n') + lines.forEach((l, i) => out.push(`${start + i}:${l.length > 300 ? `${l.slice(0, 300)}…` : l}`)) + const caps = captures(m) + if (caps) out.push(` ↳ ${caps}`) + } + } + const files = new Set(matches.map((m) => m.file)).size + let header = matches.length ? `${matches.length} match${matches.length === 1 ? '' : 'es'} in ${files} file${files === 1 ? '' : 's'}` : 'No matches.' + if (matches.length > skip + cfg.maxMatches) header += ` (showing ${skip + 1}-${skip + page.length}; use skip=${skip + page.length} for more)` + const warn = warnings.filter((w) => !/^$/.test(w)) + return { text: [header, ...out, ...(warn.length ? ['', 'ast-grep:', ...warn.map((w) => ` ${w}`)] : [])].join('\n'), count: matches.length } + }, + }) + + // ---------------------------------------------------------------- ast_edit + /** @type {Map, summary:string, owner:any}>} */ + const proposals = new Map() + const gc = () => { const ttl = cfg.proposalTtlMinutes * 60_000; for (const [id, p] of proposals) if (Date.now() - p.at > ttl) proposals.delete(id) } + + ctx.tools.register({ + name: 'ast_edit', + description: `Structural rewrite with ast-grep, previewed before it is applied. Pass \`ops\` (each {pat, out}: captures from \`pat\` are substituted into \`out\`; empty \`out\` deletes the node) and \`paths\`. The call returns a diff preview and a proposal id WITHOUT modifying files; call ast_edit again with \`apply: ""\` to write it (refused if a file changed since the preview) or \`reject: ""\` to drop it. Each rewrite is a 1:1 node substitution. ${PATTERN_HELP}`, + parameters: { + type: 'object', + additionalProperties: false, + properties: { + ops: { + type: 'array', + description: 'Rewrite rules applied in order.', + items: { type: 'object', additionalProperties: false, required: ['pat', 'out'], properties: { pat: { type: 'string' }, out: { type: 'string' } } }, + }, + paths: { type: 'array', items: { type: 'string' }, description: 'Files, directories or globs to rewrite.' }, + lang: { type: 'string', description: 'Language override.' }, + globs: { type: 'array', items: { type: 'string' }, description: 'Include/exclude globs.' }, + apply: { type: 'string', description: 'Proposal id from a previous preview: write it.' }, + reject: { type: 'string', description: 'Proposal id to discard.' }, + }, + }, + output: { + schema: { type: 'object', additionalProperties: true, properties: { text: { type: 'string' } } }, + render: (_a, v) => [{ type: 'text', text: v.text }], + presentationMeta: (_a, v) => ({ diffs: Array.isArray(v.diffs) ? v.diffs : [] }), + }, + presentResult(args, result) { + if (result.isError) return undefined + const diffs = result.meta?.diffs + if (!Array.isArray(diffs) || !diffs.length) return undefined + return { card: 'diff', title: args.apply ? 'AST edit (applied)' : 'AST edit (preview)', diffs } + }, + timeoutMs: 300_000, + async execute(args, exec) { + gc() + const cwd = cwdOf(exec) + if (args.reject) { + const ok = proposals.delete(args.reject) + return { text: ok ? `Proposal ${args.reject} discarded; no files were changed.` : `No pending proposal ${args.reject}.` } + } + if (args.apply) return applyProposal(args.apply, exec, cwd) + const ops = Array.isArray(args.ops) ? args.ops : [] + if (!ops.length) throw new Error('ops must contain at least one {pat, out} rewrite (or pass apply/reject with a proposal id)') + const seen = new Set() + for (const [i, o] of ops.entries()) { + if (!o || typeof o.pat !== 'string' || !o.pat.trim()) throw new Error(`ops[${i}].pat must be a non-empty pattern`) + if (typeof o.out !== 'string') throw new Error(`ops[${i}].out must be a string (empty deletes)`) + if (seen.has(o.pat)) throw new Error(`ops[${i}] duplicates pattern ${JSON.stringify(o.pat)}`) + seen.add(o.pat) + } + const paths = splitPaths(args.paths) + if (!paths.length) throw new Error('paths must list at least one file, directory or glob') + const fsp = await import('node:fs/promises') + // Apply ops sequentially in memory: each op's rewrite is computed on the result of the previous. + const files = new Map() // abs -> {before, after} + const counts = [] + const warnings = [] + for (const op of ops) { + const { matches, warnings: w } = await search({ bin: bin(), pattern: op.pat, rewrite: op.out, lang: args.lang, paths, globs: args.globs, cwd, signal: exec?.signal }) + warnings.push(...w) + // ast-grep reads from disk. Files an earlier op already rewrote are re-searched on a temp copy + // of their in-memory version; the rest use this op's disk matches. + const rep = (m) => ({ start: m.replacementOffsets?.start ?? m.range.byteOffset.start, end: m.replacementOffsets?.end ?? m.range.byteOffset.end, text: m.replacement ?? '' }) + const byFile = new Map() + for (const m of matches) { const abs = path.resolve(cwd, m.file); if (!byFile.has(abs)) byFile.set(abs, []); byFile.get(abs).push(m) } + let n = 0 + for (const [abs, entry] of files) { + if (entry.after === entry.before) continue + byFile.delete(abs) + const tmp = path.join(path.dirname(abs), `.omp-ast-${crypto.randomBytes(4).toString('hex')}${path.extname(abs)}`) + await fsp.writeFile(tmp, entry.after) + try { + const r = await search({ bin: bin(), pattern: op.pat, rewrite: op.out, lang: args.lang, paths: [tmp], cwd, signal: exec?.signal }) + if (r.matches.length) { entry.after = applyReplacements(entry.after, r.matches.map(rep)); n += r.matches.length } + } finally { await fsp.rm(tmp, { force: true }) } + } + for (const [abs, ms] of byFile) { + let entry = files.get(abs) + if (!entry) { const b = await fsp.readFile(abs); entry = { before: b, after: b }; files.set(abs, entry) } + entry.after = applyReplacements(entry.after, ms.map(rep)) + n += ms.length + } + counts.push(`${JSON.stringify(op.pat)} → ${n} replacement${n === 1 ? '' : 's'}`) + } + for (const [abs, e] of files) if (Buffer.compare(e.before, e.after) === 0) files.delete(abs) + if (files.size > cfg.maxFiles) throw new Error(`rewrite touches ${files.size} files (max ${cfg.maxFiles}); narrow paths/globs`) + if (!files.size) return { text: ['No changes: no pattern matched.', ...counts, ...(warnings.length ? ['ast-grep:', ...warnings] : [])].join('\n') } + const id = crypto.randomBytes(3).toString('hex') + proposals.set(id, { at: Date.now(), files, owner: exec?.agent?.session }) + const diffs = [] + const lines = [] + for (const [abs, e] of files) { + const d = lineDiff(e.before.toString('utf8'), e.after.toString('utf8')) + diffs.push(...d.map((h) => ({ path: rel(abs, cwd), ...h }))) + lines.push(`${rel(abs, cwd)}:`) + for (const h of d.slice(0, 20)) { + for (const l of (h.oldText ?? '').split('\n')) lines.push(`- ${l}`) + for (const l of h.newText.split('\n')) lines.push(`+ ${l}`) + } + } + const shown = lines.length > 400 ? [...lines.slice(0, 400), `… ${lines.length - 400} more preview lines`] : lines + return { + text: [ + `Staged as proposal ${id} — files NOT modified yet. ${files.size} file${files.size === 1 ? '' : 's'}:`, + ...counts, '', ...shown, '', + `Apply: ast_edit {"apply": "${id}"} · Discard: ast_edit {"reject": "${id}"}`, + ...(warnings.length ? ['', 'ast-grep:', ...warnings.map((w) => ` ${w}`)] : []), + ].join('\n'), + proposal: id, + diffs, + } + }, + }) + + async function applyProposal(id, exec, cwd) { + const p = proposals.get(id) + if (!p) throw new Error(`No pending proposal ${id} (expired after ${cfg.proposalTtlMinutes} min, already applied, or never issued). Run the preview again.`) + if (p.owner && exec?.agent?.session && p.owner !== exec.agent.session) throw new Error(`Proposal ${id} belongs to another session.`) + const fsp = await import('node:fs/promises') + // Verify nothing changed since the preview, then write all. + for (const [abs, e] of p.files) { + const now = await fsp.readFile(abs).catch(() => null) + if (!now || Buffer.compare(now, e.before) !== 0) throw new Error(`${rel(abs, cwd)} changed since the preview; proposal ${id} not applied. Run ast_edit again.`) + } + const policy = policyFor(exec) + const written = [] + const diffs = [] + for (const [abs, e] of p.files) { + const target = await ctx.fs.resolve(abs, { signal: exec?.signal }) + const outcome = await ctx.fs.writeText(target, e.after.toString('utf8'), undefined, exec?.signal, policy) + ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) + written.push(rel(abs, cwd)) + diffs.push(...lineDiff(e.before.toString('utf8'), e.after.toString('utf8')).map((h) => ({ path: rel(abs, cwd), ...h }))) + } + proposals.delete(id) + return { text: `Applied proposal ${id} to ${written.length} file${written.length === 1 ? '' : 's'}:\n${written.join('\n')}`, results: written.map((f) => ({ path: f, operation: 'update' })), diffs } + } + + ctx.effect(() => () => proposals.clear(), 'omp-ast: proposals') +} + +function captures(m) { + const mv = m.metaVariables + if (!mv) return '' + const parts = [] + for (const [k, v] of Object.entries(mv.single ?? {})) parts.push(`$${k}=${short(v.text)}`) + for (const [k, v] of Object.entries(mv.multi ?? {})) parts.push(`$$$${k}=[${v.filter((x) => !/^[,;]$/.test(x.text)).map((x) => short(x.text)).join(', ')}]`) + return parts.join(' ') +} +const short = (s) => { const t = String(s).replace(/\s+/g, ' '); return t.length > 60 ? `${t.slice(0, 60)}…` : t } + +/** Minimal line diff → hunks with 2 lines of context (LCS on lines). */ +export function lineDiff(a, b, context = 2) { + const A = a.split('\n'); const B = b.split('\n') + let s = 0 + while (s < A.length && s < B.length && A[s] === B[s]) s++ + let ea = A.length; let eb = B.length + while (ea > s && eb > s && A[ea - 1] === B[eb - 1]) { ea--; eb-- } + if (s === ea && s === eb) return [] + // Split the middle into hunks by common lines (simple LCS for moderate sizes). + const midA = A.slice(s, ea); const midB = B.slice(s, eb) + const n = midA.length; const m = midB.length + const hunks = [] + if (n * m <= 1_000_000) { + const dp = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1)) + for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = midA[i] === midB[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]) + let i = 0; let j = 0; let cur = null + const flush = () => { if (cur) { hunks.push(cur); cur = null } } + while (i < n || j < m) { + if (i < n && j < m && midA[i] === midB[j]) { flush(); i++; j++; continue } + cur ??= { a0: s + i, b0: s + j, old: [], neu: [] } + if (j < m && (i >= n || dp[i][j + 1] >= dp[i + 1][j])) cur.neu.push(midB[j++]) + else cur.old.push(midA[i++]) + } + flush() + } else hunks.push({ a0: s, b0: s, old: midA, neu: midB }) + return hunks.map((h) => { + const pre = A.slice(Math.max(0, h.a0 - context), h.a0) + const postStart = h.a0 + h.old.length + const post = A.slice(postStart, postStart + context) + return { oldText: [...pre, ...h.old, ...post].join('\n') || null, newText: [...pre, ...h.neu, ...post].join('\n') } + }) +} diff --git a/lib/sg.js b/lib/sg.js new file mode 100644 index 0000000..b557c1e --- /dev/null +++ b/lib/sg.js @@ -0,0 +1,60 @@ +// ast-grep CLI wrapper. +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' + +let sgPath +export function findAstGrep(extra = []) { + if (sgPath !== undefined) return sgPath + const dirs = [...extra, ...(process.env.PATH || '').split(path.delimiter), path.join(process.env.HOME || '', '.local/bin'), '/usr/local/bin'] + sgPath = null + for (const d of dirs) { + if (!d) continue + const p = path.join(d, 'ast-grep') // `sg` collides with util-linux's setgroups on many distros + try { fs.accessSync(p, fs.constants.X_OK); sgPath = p; break } catch {} + } + return sgPath +} + +export function run(bin, args, { cwd, signal, timeoutMs = 120_000, maxBuffer = 64 * 1024 * 1024 } = {}) { + return new Promise((resolve) => { + execFile(bin, args, { cwd, signal, timeout: timeoutMs, maxBuffer, encoding: 'utf8' }, (error, stdout, stderr) => { + resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: stdout ?? '', stderr: stderr ?? '', error }) + }) + }) +} + +/** + * Run a pattern (optionally with rewrite) and return parsed matches. + * @returns {{matches: object[], warnings: string[]}} + */ +export async function search({ bin, pattern, rewrite, lang, paths, cwd, globs, strictness, selector, signal }) { + const args = ['run', '--pattern', pattern, '--json=compact', '--color=never'] + if (rewrite !== undefined) args.push('--rewrite', rewrite) + if (lang) args.push('--lang', lang) + if (strictness) args.push('--strictness', strictness) + if (selector) args.push('--selector', selector) + for (const g of globs ?? []) args.push('--globs', g) + args.push('--', ...(paths?.length ? paths : ['.'])) + const r = await run(bin, args, { cwd, signal }) + const warnings = r.stderr.split('\n').map((l) => l.trim()).filter((l) => l && !/^See also|^Help: .*playground/i.test(l)) + if (r.code !== 0 && !r.stdout.trim().startsWith('[')) { + throw new Error(`ast-grep failed: ${warnings.join(' ') || r.error?.message || `exit ${r.code}`}`) + } + let matches = [] + try { matches = JSON.parse(r.stdout || '[]') } catch (e) { throw new Error(`ast-grep returned malformed JSON: ${e.message}`) } + return { matches, warnings } +} + +/** Apply replacements (byte offsets) to file contents; returns new text. */ +export function applyReplacements(buf, reps) { + const sorted = [...reps].sort((a, b) => b.start - a.start) + let out = buf + let lastStart = Infinity + for (const r of sorted) { + if (r.end > lastStart) continue // overlapping: keep the outermost-first earlier one + out = Buffer.concat([out.subarray(0, r.start), Buffer.from(r.text, 'utf8'), out.subarray(r.end)]) + lastStart = r.start + } + return out +} diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..362b77d --- /dev/null +++ b/locale/en.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP AST", "description": "ast_grep structural search and ast_edit previewed structural rewrites (ast-grep, 25+ languages)." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..70c03cc --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP AST", "description": "ast_grep 结构化代码搜索与 ast_edit 先预览后应用的结构化重写(ast-grep)。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd6ccc1 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@local/omp-ast", + "version": "1.0.0", + "description": "oh-my-pi style ast_grep and ast_edit tools for DSH: structural code search and previewed structural rewrites via ast-grep.", + "private": true, + "type": "module", + "license": "MIT", + "exports": { ".": "./index.js", "./package.json": "./package.json", "./locale/*.json": "./locale/*.json" }, + "files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"], + "scripts": { "test": "node --test test/*.test.mjs" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/test/ast.test.mjs b/test/ast.test.mjs new file mode 100644 index 0000000..7f9895f --- /dev/null +++ b/test/ast.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import * as plugin from '../index.js' +import { findAstGrep } from '../lib/sg.js' + +const DSH = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai' + +async function harness() { + const { default: LocalFileSystem } = await import(`${DSH}/dsh-fs-local/lib/index.js`) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-ast-')) + const fsImpl = Object.create(LocalFileSystem.prototype) + Object.assign(fsImpl, { config: { cwd: dir, diffBasisMaxBytes: 1 << 24 }, internals: {}, locks: new Map() }) + const tools = new Map() + const ctx = { fs: fsImpl, tools: { register(d) { tools.set(d.name, d); return () => {} } }, emit() {}, effect() {}, get() {} } + plugin.apply(ctx, {}) + const exec = { agent: { session: { header: { cwd: dir } } }, signal: new AbortController().signal } + const call = async (name, args) => { const t = tools.get(name); const v = await t.execute(args, exec); return v } + return { dir, call, w: (f, s) => fs.writeFileSync(path.join(dir, f), s), r: (f) => fs.readFileSync(path.join(dir, f), 'utf8') } +} + +const skip = !findAstGrep() + +test('ast_grep finds calls with captures', { skip }, async () => { + const h = await harness() + h.w('a.js', 'function f(x) {\n console.log(x)\n console.log(x, 2)\n log(x)\n}\n') + const v = await h.call('ast_grep', { pat: 'console.log($$$ARGS)' }) + assert.match(v.text, /^2 matches in 1 file/) + assert.match(v.text, /^2: console\.log\(x\)$/m) + assert.match(v.text, /\$\$\$ARGS=\[x, 2\]/) +}) + +test('ast_edit previews, then applies by id; multiple ops chain', { skip }, async () => { + const h = await harness() + h.w('a.py', 'def f(a):\n print(a)\n print(a, 1)\n') + const p = await h.call('ast_edit', { ops: [{ pat: 'print($$$A)', out: 'log($$$A)' }, { pat: 'log($X, 1)', out: 'log($X)' }], paths: ['a.py'] }) + assert.match(p.text, /Staged as proposal (\w+)/) + assert.equal(h.r('a.py'), 'def f(a):\n print(a)\n print(a, 1)\n') // untouched + const id = p.proposal + const a = await h.call('ast_edit', { apply: id }) + assert.match(a.text, /Applied proposal/) + assert.equal(h.r('a.py'), 'def f(a):\n log(a)\n log(a)\n') + await assert.rejects(h.call('ast_edit', { apply: id }), /No pending proposal/) +}) + +test('ast_edit refuses stale proposal', { skip }, async () => { + const h = await harness() + h.w('b.ts', 'const a = foo(1)\n') + const p = await h.call('ast_edit', { ops: [{ pat: 'foo($A)', out: 'bar($A)' }], paths: ['b.ts'] }) + h.w('b.ts', 'const a = foo(2)\n') + await assert.rejects(h.call('ast_edit', { apply: p.proposal }), /changed since the preview/) +}) + +test('ast_edit reject and no-match', { skip }, async () => { + const h = await harness() + h.w('c.go', 'package main\nfunc main() { println("x") }\n') + const n = await h.call('ast_edit', { ops: [{ pat: 'nothing($A)', out: 'x' }], paths: ['.'] }) + assert.match(n.text, /No changes/) + const p = await h.call('ast_edit', { ops: [{ pat: 'println($A)', out: 'fmt.Println($A)' }], paths: ['c.go'] }) + const r = await h.call('ast_edit', { reject: p.proposal }) + assert.match(r.text, /discarded/) +})