diff --git a/ts/packages/skillgraph/.gitignore b/ts/packages/skillgraph/.gitignore new file mode 100644 index 0000000000..747018cef4 --- /dev/null +++ b/ts/packages/skillgraph/.gitignore @@ -0,0 +1,3 @@ +build/ +*.log +*.fit diff --git a/ts/packages/skillgraph/LICENSE b/ts/packages/skillgraph/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/ts/packages/skillgraph/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/ts/packages/skillgraph/README.md b/ts/packages/skillgraph/README.md new file mode 100644 index 0000000000..88667b88aa --- /dev/null +++ b/ts/packages/skillgraph/README.md @@ -0,0 +1,11 @@ +# @typeagent/skillgraph + +Deterministic semantic graph, checker, and language server for Agent Skills. + +## Trademarks + +This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft +trademarks or logos is subject to and must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. +Any use of third-party trademarks or logos are subject to those third-party's policies. diff --git a/ts/packages/skillgraph/bin/skillgraph.js b/ts/packages/skillgraph/bin/skillgraph.js new file mode 100755 index 0000000000..0a724e9b9f --- /dev/null +++ b/ts/packages/skillgraph/bin/skillgraph.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { main } from "../dist/cli.js"; + +process.exitCode = await main(process.argv.slice(2)); diff --git a/ts/packages/skillgraph/jest.config.cjs b/ts/packages/skillgraph/jest.config.cjs new file mode 100644 index 0000000000..4560001c3f --- /dev/null +++ b/ts/packages/skillgraph/jest.config.cjs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = { + ...require("../../jest.config.js"), +}; diff --git a/ts/packages/skillgraph/package.json b/ts/packages/skillgraph/package.json new file mode 100644 index 0000000000..43938ccb1d --- /dev/null +++ b/ts/packages/skillgraph/package.json @@ -0,0 +1,60 @@ +{ + "name": "@typeagent/skillgraph", + "version": "0.0.1", + "description": "Deterministic semantic graph, checker, and language server for Agent Skills.", + "homepage": "https://github.com/microsoft/TypeAgent#readme", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/TypeAgent.git", + "directory": "ts/packages/skillgraph" + }, + "license": "MIT", + "author": "Microsoft", + "type": "module", + "exports": { + ".": "./dist/index.js" + }, + "types": "./dist/index.d.ts", + "bin": { + "skillgraph": "./bin/skillgraph.js" + }, + "files": [ + "bin", + "dist", + "!dist/test" + ], + "scripts": { + "build": "npm run tsc", + "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "prettier": "prettier --check . --ignore-path ../../.prettierignore", + "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc": "tsc -b" + }, + "dependencies": { + "gpt-tokenizer": "2.9.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-gfm-table": "2.0.0", + "micromark-extension-gfm-table": "2.1.1", + "tree-sitter-bash": "0.25.1", + "tree-sitter-javascript": "0.25.0", + "tree-sitter-powershell": "0.26.4", + "tree-sitter-python": "0.25.0", + "tree-sitter-typescript": "0.23.2", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + "web-tree-sitter": "0.27.1", + "yaml": "^2.8.3" + }, + "devDependencies": { + "@types/jest": "^29.5.7", + "@types/mdast": "^4.0.4", + "@types/node": "^22.0.0", + "jest": "^29.7.0", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "typescript": "~5.4.5" + } +} diff --git a/ts/packages/skillgraph/src/cli.ts b/ts/packages/skillgraph/src/cli.ts new file mode 100644 index 0000000000..921890b4c5 --- /dev/null +++ b/ts/packages/skillgraph/src/cli.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { SkillGraph, expandHome, type BuildResult } from "./graph.js"; +import type { Config, Diagnostic, Severity } from "./types.js"; + +// skillgraph CLI. Same engine as the LSP. +// +// skillgraph check [--root dir]... [--json] [--no-probe] [--only rule] [skill] +// skillgraph graph [--root dir]... [skill] (JSON) +// skillgraph refs [--root dir]... +// skillgraph lsp [--root dir]... (stdio) + +const DEFAULT_ROOT = "~/.config/skillshare/skills"; +const CONFIG_FILE = "skillgraph.json"; +const EXIT_OK = 0; +const EXIT_ERRORS = 1; +const EXIT_USAGE = 2; +const SEVERITY_ORDER: Record = { error: 0, warn: 1, info: 2 }; + +const USAGE = `skillgraph — deterministic semantic graph for Agent Skills + +Usage: + skillgraph check [skill] [--root DIR]... [--json] [--no-probe] [--only RULE] [--quiet] + skillgraph graph [skill] [--root DIR]... + skillgraph refs [--root DIR]... + skillgraph lsp [--root DIR]... + +Roots default to skillgraph.json "roots" in the cwd, else ${DEFAULT_ROOT}. +`; + +interface Args { + cmd: string; + positional: string[]; + roots: string[]; + json: boolean; + probe: boolean; + only?: string; + quiet: boolean; +} + +function parseArgs(argv: string[]): Args | undefined { + const a: Args = { + cmd: argv[0] ?? "", + positional: [], + roots: [], + json: false, + probe: true, + quiet: false, + }; + for (let i = 1; i < argv.length; i++) { + const x = argv[i]; + if (x === "--root") a.roots.push(argv[++i]); + else if (x === "--json") a.json = true; + else if (x === "--no-probe") a.probe = false; + else if (x === "--only") a.only = argv[++i]; + else if (x === "--quiet" || x === "-q") a.quiet = true; + else if (x === "--stdio") + continue; // LSP clients pass this + else if (x.startsWith("-")) return undefined; + else a.positional.push(x); + } + return a; +} + +export function loadConfig( + roots: string[], + probe: boolean, + cwd = process.cwd(), +): Config { + if (roots.length) + return { roots: roots.map((r) => path.resolve(expandHome(r))), probe }; + try { + const file = JSON.parse( + fs.readFileSync(path.join(cwd, CONFIG_FILE), "utf8"), + ); + if (Array.isArray(file.roots)) { + return { + roots: file.roots.map((r: string) => + path.resolve(cwd, expandHome(r)), + ), + probe, + }; + } + } catch {} + return { roots: [expandHome(DEFAULT_ROOT)], probe }; +} + +function display(file: string, res: BuildResult): string { + const skill = res.skills.find( + (s) => file === s.dir || file.startsWith(s.dir + path.sep), + ); + return skill ? `${skill.name}/${path.relative(skill.dir, file)}` : file; +} + +function tag(d: Diagnostic): string { + if (!d.spec) return ""; + const how = + d.spec.source === "ast" + ? "ast" + : (d.spec.probe?.slice(-1)[0] ?? "help"); + return ` [${d.spec.source === "ast" ? d.spec.tool : `${d.spec.tool} ${d.spec.version}`} · ${how}]`; +} + +const COLOR = process.stdout.isTTY && !process.env.NO_COLOR; +const paint = (code: string, s: string) => + COLOR ? `\x1b[${code}m${s}\x1b[0m` : s; +const SEV_COLOR: Record = { + error: "31", + warn: "33", + info: "36", +}; + +export async function main(argv: string[]): Promise { + const args = parseArgs(argv); + if (!args || !["check", "graph", "refs", "lsp"].includes(args.cmd)) { + process.stderr.write(USAGE); + return args?.cmd === "" || + argv.includes("-h") || + argv.includes("--help") + ? EXIT_OK + : EXIT_USAGE; + } + const config = loadConfig(args.roots, args.probe); + if (args.cmd === "lsp") { + const { startServer } = await import("./server.js"); + startServer(config); + return EXIT_OK; + } + const graph = new SkillGraph(config); + const started = Date.now(); + const scope = args.cmd === "refs" ? undefined : args.positional[0]; + const res = config.probe + ? await graph.buildWithProbes(scope) + : graph.build(scope); + + if (args.cmd === "graph") { + const out = { + version: 1, + nodes: res.nodes, + edges: res.edges, + }; + process.stdout.write(JSON.stringify(out, null, 2) + "\n"); + return EXIT_OK; + } + + if (args.cmd === "refs") { + // Target: skill name, rel path ("pr-review/scripts/x.py"), or "tool:gh". + const t = args.positional[0]; + if (!t) { + process.stderr.write(USAGE); + return EXIT_USAGE; + } + const ids = new Set([ + t, + `skill:${t}`, + `doc:${t}`, + `script:${t}`, + `asset:${t}`, + `tool:${t}`, + ]); + // A name can refer to several independently loaded skill scopes. + const targets = new Set([t]); + for (const skill of res.skills) { + if (t === skill.name) targets.add(skill.id); + else if (t.startsWith(`${skill.name}/`)) { + targets.add(`${skill.id}/${t.slice(skill.name.length + 1)}`); + } + } + for (const target of targets) { + for (const kind of ["skill", "doc", "script", "asset"]) { + ids.add(`${kind}:${target}`); + } + } + const hits = res.edges.filter( + (e) => + ids.has(e.to) || + [...targets].some( + (target) => + e.to.startsWith(`heading:${target}#`) || + e.to === `heading:${target}`, + ), + ); + for (const e of hits) { + const extra = [...(e.words ?? []), ...(e.flags ?? [])].join(" "); + process.stdout.write( + `${display(e.at.file, res)}:${e.at.line}:${e.at.col} ${e.kind}${extra ? ` ${extra}` : ""}\n`, + ); + } + return hits.length ? EXIT_OK : EXIT_ERRORS; + } + + let diags = res.diagnostics.filter( + (d) => !args.only || d.rule === args.only, + ); + if (args.quiet) diags = diags.filter((d) => d.severity !== "info"); + diags.sort( + (a, b) => + a.at.file.localeCompare(b.at.file) || + a.at.line - b.at.line || + a.at.col - b.at.col || + SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ); + const errors = diags.filter((d) => d.severity === "error").length; + if (args.json) { + process.stdout.write( + JSON.stringify( + diags.map((d) => ({ + ...d, + at: { ...d.at, display: display(d.at.file, res) }, + })), + null, + 2, + ) + "\n", + ); + } else { + for (const d of diags) { + const loc = `${display(d.at.file, res)}:${d.at.line}:${d.at.col}`; + process.stdout.write( + `${paint("1", loc)} ${paint(SEV_COLOR[d.severity], d.severity.padEnd(5))} ${paint("2", d.rule.padEnd(19))} ${d.message}${paint("2", tag(d))}\n`, + ); + } + const warns = diags.filter((d) => d.severity === "warn").length; + const infos = diags.length - errors - warns; + const docs = res.docs.size; + process.stdout.write( + `\n${errors} errors, ${warns} warnings, ${infos} info · ${res.skills.filter((s) => !scope || s.name === scope).length} skills · ${docs} docs · ${res.edges.length} edges · ${Date.now() - started}ms\n`, + ); + } + return errors ? EXIT_ERRORS : EXIT_OK; +} diff --git a/ts/packages/skillgraph/src/context.ts b/ts/packages/skillgraph/src/context.ts new file mode 100644 index 0000000000..7b57b3236a --- /dev/null +++ b/ts/packages/skillgraph/src/context.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from "node:path"; +import type { ParsedMarkdown } from "./markdown.js"; +import type { SpecStore } from "./specs/store.js"; +import type { CliSpec, Config, Diagnostic, Edge, GraphNode } from "./types.js"; + +// Shared build types. Rules receive a BuildContext and append to BuildResult. + +export interface Skill { + name: string; + dir: string; + entry: string; + root: string; + /** Unique graph identity; same names in separate roots remain separate. */ + id: string; +} + +export interface Probe { + tool: string; + words: string[]; +} + +export interface BuildResult { + nodes: GraphNode[]; + edges: Edge[]; + diagnostics: Diagnostic[]; + skills: Skill[]; + pending: Probe[]; + /** Parsed docs by absolute path, for LSP lookups. */ + docs: Map; + /** Resolved specs by edge target id + words, for hover/definition. */ + specs: Map; +} + +/** Read-only view of the graph that rules need. */ +export interface BuildContext { + config: Config; + store: SpecStore; + /** Editor overlay, else disk; undefined when unreadable. */ + read(file: string): string | undefined; + hasOverlay(file: string): boolean; + /** Frontmatter-name aliases keyed by `${root}\0${name}`. */ + aliases: Map; + /** Skills keyed by `${root}\0${dirName}`. */ + byName: Map; + /** Probe keys already queued this build, e.g. "gh|pr view". */ + pendingKeys: Set; +} + +/** Graph id path for a file inside a skill, e.g. "review/scripts/x.py". */ +export function rel(skill: Skill, file: string) { + return `${skill.id}/${path.relative(skill.dir, file).split(path.sep).join("/")}`; +} diff --git a/ts/packages/skillgraph/src/discover.ts b/ts/packages/skillgraph/src/discover.ts new file mode 100644 index 0000000000..90a7b79312 --- /dev/null +++ b/ts/packages/skillgraph/src/discover.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Skill } from "./context.js"; +import type { Diagnostic } from "./types.js"; + +// Filesystem discovery: roots → skills, skill dir → files. + +export const ENTRY = "SKILL.md"; +export const SKIP_DIRS = new Set([ + "node_modules", + ".git", + "__pycache__", + ".venv", + "dist", + "_artifacts", +]); + +export function expandHome(p: string): string { + return p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; +} + +/** Skills under roots (dir or its children with SKILL.md), deduped by realpath. */ +export function discoverSkills(roots: string[]): { + skills: Skill[]; + aliases: Map; + dupes: Diagnostic[]; +} { + const byReal = new Map(); + const aliases = new Map(); + const dupes: Diagnostic[] = []; + for (const r of roots) { + const root = path.resolve(expandHome(r)); + const candidates: string[] = []; + if (fs.existsSync(path.join(root, ENTRY))) candidates.push(root); + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch {} + for (const e of entries) { + const dir = path.join(root, e.name); + if (SKIP_DIRS.has(e.name)) continue; + if (fs.existsSync(path.join(dir, ENTRY))) candidates.push(dir); + } + for (const dir of candidates.sort()) { + let real: string; + try { + real = fs.realpathSync(dir); + } catch { + continue; + } + if (byReal.has(real)) continue; // same skill via symlinked root + const skill: Skill = { + name: path.basename(dir), + dir, + entry: path.join(dir, ENTRY), + root, + id: path.basename(dir), + }; + byReal.set(real, skill); + // Roots are separate load scopes; a repository scan must validate every copy. + // Also resolve by frontmatter name ("_foo/" dir, name: foo). + const fm = /^---\n[\s\S]*?^name:\s*["']?([\w.-]+)/m.exec( + readHead(skill.entry), + ); + if (fm && fm[1] !== skill.name) + aliases.set(`${root}\0${fm[1]}`, skill); + } + } + const skills = [...byReal.values()]; + for (const skill of skills) { + if ( + skills.some((other) => other !== skill && other.name === skill.name) + ) { + skill.id = skill.entry; + } + } + return { skills, aliases, dupes }; +} + +function readHead(file: string): string { + try { + return fs.readFileSync(file, "utf8").slice(0, 2048); + } catch { + return ""; + } +} + +/** Files under dir (depth ≤ 4), skipping SKIP_DIRS and dotfiles. */ +export function listFiles(dir: string): string[] { + const out: string[] = []; + const walk = (d: string, depth: number) => { + if (depth > 4) return; + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(d, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (SKIP_DIRS.has(e.name) || e.name.startsWith(".")) continue; + const p = path.join(d, e.name); + if (e.isDirectory()) walk(p, depth + 1); + else if (e.isFile()) out.push(p); + } + }; + walk(dir, 0); + return out.sort(); +} diff --git a/ts/packages/skillgraph/src/graph.ts b/ts/packages/skillgraph/src/graph.ts new file mode 100644 index 0000000000..c95c2aca44 --- /dev/null +++ b/ts/packages/skillgraph/src/graph.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import { countTokens } from "gpt-tokenizer"; +import { parseMarkdown } from "./markdown.js"; +import { + type BuildContext, + type BuildResult, + type Skill, + rel, +} from "./context.js"; +import { discoverSkills, listFiles } from "./discover.js"; +import { commandEdges, MAX_SUBCOMMAND_DEPTH } from "./rules/commands.js"; +import { checkFrontmatter } from "./rules/frontmatter.js"; +import { linkEdges } from "./rules/links.js"; +import { checkOrphans } from "./rules/orphans.js"; +import { skillEdges } from "./rules/skills.js"; +import { resetWhich } from "./specs/process.js"; +import { SpecStore } from "./specs/store.js"; +import type { Config, Diagnostic, GraphNode } from "./types.js"; +import { runLimited } from "./util.js"; + +// Builds the skill graph and its diagnostics from disk (plus editor overlays). +// +// roots ─▶ skills (dirs with SKILL.md, deduped by realpath) +// ─▶ docs (*.md) ─▶ links / skill:// / commands +// ─▶ edges + diagnostics; external tools resolved via SpecStore +// +// `build()` never runs a tool. It reports `spec-pending` and returns the +// probes it needs; callers run them (CLI: await, LSP: background) and rebuild. +// +// Modules: discover.ts (fs) · rules/* (one diagnostic family each) · +// specs/* (script AST, --help parsing, probing, cache) · shell.ts (commands). + +export type { BuildResult, Probe, Skill } from "./context.js"; +export { expandHome } from "./discover.js"; + +export class SkillGraph { + readonly store: SpecStore; + private overlays = new Map(); + private aliases = new Map(); + constructor( + readonly config: Config, + store?: SpecStore, + ) { + this.store = store ?? new SpecStore(); + } + + /** Editor buffer contents override disk. */ + setOverlay(file: string, text: string | undefined) { + if (text === undefined) this.overlays.delete(file); + else this.overlays.set(file, text); + } + + private read(file: string): string | undefined { + const o = this.overlays.get(file); + if (o !== undefined) return o; + try { + return fs.readFileSync(file, "utf8"); + } catch { + return undefined; + } + } + + discover(): { skills: Skill[]; dupes: Diagnostic[] } { + const { skills, aliases, dupes } = discoverSkills(this.config.roots); + this.aliases = aliases; + return { skills, dupes }; + } + + build(scope?: string): BuildResult { + resetWhich(); + const { skills, dupes } = this.discover(); + const res: BuildResult = { + nodes: [], + edges: [], + diagnostics: [...dupes], + skills, + pending: [], + docs: new Map(), + specs: new Map(), + }; + const ctx: BuildContext = { + config: this.config, + store: this.store, + read: (f) => this.read(f), + hasOverlay: (f) => this.overlays.has(f), + aliases: this.aliases, + byName: new Map(skills.map((s) => [`${s.root}\0${s.name}`, s])), + pendingKeys: new Set(), + }; + for (const skill of skills) { + if (scope && skill.name !== scope) continue; + this.buildSkill(ctx, skill, res); + } + return res; + } + + private buildSkill(ctx: BuildContext, skill: Skill, res: BuildResult) { + const files = listFiles(skill.dir); + const mdFiles = files.filter((f) => f.endsWith(".md")); + const referenced = new Set([skill.entry]); + // Assets can be loaded by HTML, PowerShell, or other non-Markdown files. + const mentionText = files.map((f) => this.read(f) ?? ""); + const skillId = `skill:${skill.id}`; + + for (const file of mdFiles) { + const text = this.read(file); + if (text === undefined) continue; + const md = parseMarkdown(text); + res.docs.set(file, md); + const isEntry = file === skill.entry; + const docId = isEntry ? skillId : `doc:${rel(skill, file)}`; + const node: GraphNode = { + id: docId, + kind: isEntry ? "skill" : "doc", + path: file, + tokens: countTokens(text), + }; + if (isEntry && typeof md.frontmatter?.description === "string") { + node.description = md.frontmatter.description; + } + res.nodes.push(node); + for (const h of md.headings) { + res.nodes.push({ + id: `heading:${rel(skill, file)}#${h.slug}`, + kind: "heading", + path: file, + line: h.line, + }); + } + if (isEntry) checkFrontmatter(skill, md, res); + linkEdges(ctx, skill, file, docId, md, res, referenced); + skillEdges(ctx, skill, file, docId, md, res); + commandEdges(ctx, skill, file, docId, md, res, referenced); + } + checkOrphans(skill, files, mentionText, referenced, res); + } + + /** Run pending probes, then rebuild until no new probes appear. */ + async buildWithProbes(scope?: string): Promise { + let res = this.build(scope); + for ( + let round = 0; + round <= 2 * MAX_SUBCOMMAND_DEPTH + 1 && res.pending.length; + round++ + ) { + await runLimited(res.pending, 8, (p) => + this.store.probe(p.tool, p.words), + ); + res = this.build(scope); + } + return res; + } +} diff --git a/ts/packages/skillgraph/src/index.ts b/ts/packages/skillgraph/src/index.ts new file mode 100644 index 0000000000..dbeba587ad --- /dev/null +++ b/ts/packages/skillgraph/src/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { SkillGraph } from "./graph.js"; +export type { BuildResult, Skill, Probe } from "./graph.js"; +export { SpecStore, parseHelp, scriptSpec } from "./specs/index.js"; +export { commandsFromCode } from "./shell.js"; +export { parseMarkdown } from "./markdown.js"; +export type * from "./types.js"; diff --git a/ts/packages/skillgraph/src/markdown.ts b/ts/packages/skillgraph/src/markdown.ts new file mode 100644 index 0000000000..612cb20f28 --- /dev/null +++ b/ts/packages/skillgraph/src/markdown.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmTable } from "micromark-extension-gfm-table"; +import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; +import type { Root, Nodes } from "mdast"; +import { parse as parseYaml } from "yaml"; + +// Extracts the explicit references from one Markdown file. Only syntax +// counts: links, headings, code fences, inline code, and `skill://` text. + +export interface MdLink { + url: string; + line: number; + col: number; + endCol: number; +} + +export interface MdHeading { + text: string; + slug: string; + line: number; +} + +export interface MdCode { + /** Fence language, or "inline" for inline code spans. */ + lang: string; + value: string; + /** 1-based line of the first content line. */ + line: number; + col: number; +} + +export interface MdSkillRef { + name: string; + line: number; + col: number; + endCol: number; +} + +export interface ParsedMarkdown { + frontmatter?: Record; + frontmatterError?: string; + /** Number of lines the frontmatter block occupies. */ + frontmatterLines: number; + links: MdLink[]; + headings: MdHeading[]; + code: MdCode[]; + skillRefs: MdSkillRef[]; + text: string; +} + +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; +const SKILL_REF = /skill:\/\/([a-z0-9][a-z0-9_-]*)/g; + +/** GitHub's slugger: lowercase, drop punctuation, spaces → "-", dedupe "-1". */ +export function slugger() { + const seen = new Map(); + return (text: string): string => { + const base = text + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\p{M}\p{Pc} -]/gu, "") + .replace(/ /g, "-"); + const n = seen.get(base); + seen.set(base, (n ?? -1) + 1); + return n === undefined ? base : `${base}-${n + 1}`; + }; +} + +export function parseMarkdown(text: string): ParsedMarkdown { + // Frontmatter is replaced by blank lines so mdast positions stay true. + const fm = FRONTMATTER.exec(text); + let frontmatter: Record | undefined; + let frontmatterError: string | undefined; + let frontmatterLines = 0; + let body = text; + if (fm) { + frontmatterLines = fm[0].split("\n").length - 1; + try { + const value = parseYaml(fm[1]); + frontmatter = + value && typeof value === "object" + ? (value as Record) + : {}; + } catch (e) { + frontmatterError = (e as Error).message.split("\n")[0]; + } + body = "\n".repeat(frontmatterLines) + text.slice(fm[0].length); + } + + // Table cells isolate code spans; plain CommonMark can pair backticks across rows. + const tree: Root = fromMarkdown(body, { + extensions: [gfmTable()], + mdastExtensions: [gfmTableFromMarkdown()], + }); + const out: ParsedMarkdown = { + frontmatterLines, + links: [], + headings: [], + code: [], + skillRefs: [], + text, + }; + if (frontmatter) out.frontmatter = frontmatter; + if (frontmatterError) out.frontmatterError = frontmatterError; + + const slug = slugger(); + // A template may declare data roots for angle-delimited JSON selectors. + const templateRoots = + /Document roots available to the parser:\s*([\s\S]*?)(?:\n\s*\n)/ + .exec(body)?.[1] + .split(/[\s,]+/) + .filter(Boolean) ?? []; + const visit = (node: Nodes) => { + const pos = node.position?.start; + switch (node.type) { + case "link": + case "definition": + if (pos) { + // Point at the URL, e.g. "[x](refs/a.md)" → column of "refs". + const lineText = body.split("\n")[pos.line - 1] ?? ""; + const idx = lineText.indexOf(node.url, pos.column - 1); + const col = idx >= 0 ? idx + 1 : pos.column; + const selector = + /^([a-z_][\w]*)(?:\.[a-z_][\w]*|\[where [a-z_][\w]* == [a-z_][\w]*(?:\.[a-z_][\w]*)*\])+$/.exec( + node.url, + ); + if ( + selector && + templateRoots.includes(selector[1]) && + lineText.slice(idx - 1, idx + node.url.length + 1) === + `<${node.url}>` + ) + break; + out.links.push({ + url: node.url, + line: pos.line, + col, + endCol: col + node.url.length, + }); + } + break; + case "heading": { + let text = toText(node); + const line = pos?.line ?? 1; + // Explicit id: "## Setup {#install}" or "## Setup [#install]" (Fumadocs). + const explicit = /\s*[{[]#([\w-]+)[}\]]\s*$/.exec(text); + if (explicit) { + text = text.slice(0, explicit.index); + out.headings.push({ text, slug: explicit[1], line }); + } + out.headings.push({ text, slug: slug(text), line }); + // Docs sites (Mintlify, Docusaurus) slug "CLAUDE.md" as "claude-md". + const alt = altSlug(text); + if (alt !== out.headings[out.headings.length - 1].slug) { + out.headings.push({ text, slug: alt, line }); + } + break; + } + case "html": { + // , ,

anchors. + for (const m of node.value.matchAll( + /<[a-z][\w-]*\s[^>]*?\b(?:id|name)=["']([^"']+)["']/g, + )) { + out.headings.push({ + text: m[1], + slug: m[1].toLowerCase(), + line: pos?.line ?? 1, + }); + } + break; + } + case "code": + if (pos) { + out.code.push({ + lang: (node.lang ?? "").toLowerCase(), + value: node.value, + line: pos.line + 1, + col: pos.column, + }); + } + return; + case "inlineCode": + if (pos) { + out.code.push({ + lang: "inline", + value: node.value, + line: pos.line, + col: pos.column + 1, + }); + } + break; + } + if ("children" in node) { + for (const child of node.children) visit(child as Nodes); + } + }; + visit(tree); + + // skill:// can appear in prose, code, or link text, so scan raw lines. + // Fenced code is included: a fence that names skill://x still references x. + body.split("\n").forEach((lineText, i) => { + for (const m of lineText.matchAll(SKILL_REF)) { + const col = (m.index ?? 0) + 1; + out.skillRefs.push({ + name: m[1], + line: i + 1, + col, + endCol: col + m[0].length, + }); + } + }); + return out; +} + +function altSlug(text: string): string { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, ""); +} + +function toText(node: Nodes): string { + if ("value" in node && typeof node.value === "string") return node.value; + if ("children" in node) { + return (node.children as Nodes[]).map(toText).join(""); + } + return ""; +} diff --git a/ts/packages/skillgraph/src/parsers.ts b/ts/packages/skillgraph/src/parsers.ts new file mode 100644 index 0000000000..90c495e26c --- /dev/null +++ b/ts/packages/skillgraph/src/parsers.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createRequire } from "node:module"; +import { Language, Parser, type Node } from "web-tree-sitter"; + +// Tree-sitter grammars: the same parsers bash-language-server and editors use. +// Loaded once (WASM, no native build) so shell and script parsing stay sync. +// +// "gh pr view 1 | jq ." ──bash──▶ (program (pipeline (command …) (command …))) + +const require = createRequire(import.meta.url); + +export type Grammar = + | "bash" + | "powershell" + | "python" + | "javascript" + | "typescript"; + +/** npm package and WASM file per grammar. */ +const WASM: Record = { + bash: "tree-sitter-bash/tree-sitter-bash.wasm", + powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm", + python: "tree-sitter-python/tree-sitter-python.wasm", + javascript: "tree-sitter-javascript/tree-sitter-javascript.wasm", + typescript: "tree-sitter-typescript/tree-sitter-typescript.wasm", +}; + +await Parser.init(); +const parsers = new Map(); +for (const [name, file] of Object.entries(WASM) as [Grammar, string][]) { + const parser = new Parser(); + parser.setLanguage(await Language.load(require.resolve(file))); + parsers.set(name, parser); +} + +/** Parse `text`; the caller reads the tree synchronously, then it is freed. */ +export function parse( + grammar: Grammar, + text: string, + read: (root: Node) => T, +): T { + const tree = parsers.get(grammar)!.parse(text); + try { + return read(tree!.rootNode); + } finally { + tree?.delete(); + } +} + +/** Source text of a string literal node without its quotes. */ +export function unquote(node: Node): string { + const t = node.text; + return /^(['"`]).*\1$/s.test(t) ? t.slice(1, -1) : t; +} + +export type { Node }; diff --git a/ts/packages/skillgraph/src/rules/commands.ts b/ts/packages/skillgraph/src/rules/commands.ts new file mode 100644 index 0000000000..93629dc1dc --- /dev/null +++ b/ts/packages/skillgraph/src/rules/commands.ts @@ -0,0 +1,497 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + rel, + type BuildContext, + type BuildResult, + type Skill, +} from "../context.js"; +import type { ParsedMarkdown } from "../markdown.js"; +import { commandsFromCode, type Command } from "../shell.js"; +import { sameHelp } from "../specs/help.js"; +import { which } from "../specs/process.js"; +import { scriptSpec } from "../specs/script.js"; +import type { CliSpec, Loc, SpecRef } from "../types.js"; +import { nearest } from "../util.js"; + +// Code spans → commands → bundled script or external tool checks. +// `gh pr view --jsn` ──walk subcommands──▶ flag-unknown (did you mean --json?) + +export const MAX_SUBCOMMAND_DEPTH = 3; +/** Skill-root placeholders used in script paths, e.g. "{baseDir}/scripts/x.py". */ +const BASE_PLACEHOLDER = + /^(?:\{baseDir\}|\$\{?CLAUDE_SKILL_DIR\}?|\$\{?SKILL_DIR\}?|)\//; +/** Subcommand words we are willing to append to a `--help` probe. */ +const SUBCOMMAND_WORD = /^[a-z][a-z0-9-]*$/; +/** Arguments after these belong to another program (open --args, ssh host cmd). */ +const PASSTHROUGH = new Set(["--", "--args"]); +/** Flags nearly every CLI accepts even when help omits them. */ +const UNIVERSAL_FLAGS = new Set(["--help", "--version"]); + +export function commandEdges( + ctx: BuildContext, + skill: Skill, + file: string, + docId: string, + md: ParsedMarkdown, + res: BuildResult, + referenced: Set, +) { + const allowed = allowedBash(md.frontmatter?.["allowed-tools"]); + for (const code of md.code) { + for (const cmd of commandsFromCode( + code.lang, + code.value, + code.line, + code.col, + )) { + // Inline prose is only a call when its executable is known. + // Missing tools remain reportable inside explicit shell fences. + if ( + code.lang === "inline" && + !cmd.isPath && + !ctx.store.isProbeable(cmd.tool) + ) + continue; + const at: Loc = { + file, + line: cmd.line, + col: cmd.toolCol, + endCol: cmd.toolCol + cmd.tool.length, + }; + if (allowed && !allowed.some((p) => p(cmd))) { + res.diagnostics.push({ + rule: "tool-not-allowed", + severity: "info", + at, + message: `\`${cmd.raw.split(" ").slice(0, 2).join(" ")}\` not covered by allowed-tools`, + }); + } + if (cmd.isPath) { + scriptCall(ctx, skill, file, docId, cmd, at, res, referenced); + } else { + toolCall(ctx, docId, cmd, at, res); + } + } + } +} + +export function scriptCall( + ctx: BuildContext, + skill: Skill, + file: string, + docId: string, + cmd: Command, + at: Loc, + res: BuildResult, + referenced: Set, +) { + const resolved = resolveScript(skill, file, cmd.tool); + if ( + resolved && + file !== skill.entry && + path.relative(skill.dir, path.dirname(file)).split(path.sep).length > 1 + ) { + resolved.strict = false; // deep reference trees are often vendored docs + } + if (!resolved) return; // absolute or repo-relative path: not ours to check + if (TEMPLATE_PATH.test(cmd.tool)) return; // "scripts/*.py", "scripts/.sh", "scripts/" + const { target, strict } = resolved; + if (!fs.existsSync(target)) { + if (cmd.tool.startsWith("../") && strict) { + // Relative command paths depend on runtime cwd, unlike Markdown hrefs. + res.diagnostics.push({ + rule: "script-cwd-unknown", + severity: "info", + at, + message: `${cmd.tool} is absent from the skill/document base; runtime working directory is not declared`, + }); + return; + } + if (strict) { + res.edges.push({ + from: docId, + to: `script:${rel(skill, target)}`, + kind: "invokes", + at, + resolved: false, + }); + res.diagnostics.push({ + rule: "script-missing", + severity: "error", + at, + message: `${cmd.tool} does not exist in skill ${skill.name}`, + }); + } + return; + } + referenced.add(target); + const id = `script:${rel(skill, target)}`; + const flags = cmd.args.filter( + (a, i) => a.startsWith("-") && !cmd.opaque.has(i), + ); + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: true, + flags, + }); + const spec = scriptSpec(target); + if (!spec) return; + res.specs.set(id, spec); + const ref: SpecRef = { + tool: rel(skill, target), + version: spec.version, + source: "ast", + }; + checkFlags(ctx, cmd, 0, spec, [spec], ref, file, res); +} + +export function toolCall( + ctx: BuildContext, + docId: string, + cmd: Command, + at: Loc, + res: BuildResult, +) { + const id = `tool:${cmd.tool}`; + if (!ctx.store.isProbeable(cmd.tool)) { + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: false, + }); + res.diagnostics.push({ + rule: "tool-unknown", + severity: "info", + at, + message: `${cmd.tool} is not on PATH; call not checked`, + }); + return; + } + // Walk subcommands: gh → gh pr → gh pr view, probing each level. + const words: string[] = []; + const chain: CliSpec[] = []; + let i = 0; + for (let depth = 0; depth <= MAX_SUBCOMMAND_DEPTH; depth++) { + const spec = ctx.store.get(cmd.tool, words); + if (spec === undefined) { + const key = `${cmd.tool}|${words.join(" ")}`; + if (ctx.config.probe && !ctx.pendingKeys.has(key)) { + ctx.pendingKeys.add(key); + res.pending.push({ tool: cmd.tool, words: [...words] }); + } + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: true, + words: [...words], + }); + if (ctx.config.probe) { + res.diagnostics.push({ + rule: "spec-pending", + severity: "info", + at, + message: `probing \`${[cmd.tool, ...words, "--help"].join(" ")}\``, + }); + } + return; + } + if (spec === null) break; // no usable help at this level + // "get text --help" echoing "get --help": text is a leaf, stop walking. + if (chain.length && sameHelp(chain[chain.length - 1], spec)) break; + chain.push(spec); + const next = cmd.args[i]; + if ( + next === undefined || + next.startsWith("-") || + cmd.opaque.has(i) || + !spec.expectsSubcommand || + depth === MAX_SUBCOMMAND_DEPTH + ) { + break; + } + if (!spec.subcommands.includes(next)) { + if (!SUBCOMMAND_WORD.test(next)) break; + // git-style external commands: `git story` runs `git-story`. + if (!words.length && which(`${cmd.tool}-${next}`)) break; + // Help lists often show only common commands (git, agent-browser). + // Ask the child: a real subcommand prints its own help. + const child = ctx.store.get(cmd.tool, [...words, next]); + if (child === undefined) { + const key = `${cmd.tool}|${[...words, next].join(" ")}`; + if (ctx.config.probe && !ctx.pendingKeys.has(key)) { + ctx.pendingKeys.add(key); + res.pending.push({ + tool: cmd.tool, + words: [...words, next], + }); + } + if (ctx.config.probe) { + res.diagnostics.push({ + rule: "spec-pending", + severity: "info", + at, + message: `probing \`${[cmd.tool, ...words, next, "--help"].join(" ")}\``, + }); + } + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: true, + words: [...words, next], + }); + return; + } + // Unknown words often fall back to the parent's or root's help. + // null = no usable help (error exit): unknown, so claim nothing. + if (child === null) break; + if (!chain.some((c) => sameHelp(c, child))) { + words.push(next); + i++; + continue; + } + const close = nearest(next, spec.subcommands); + res.diagnostics.push({ + rule: "subcommand-unknown", + severity: "error", + at: { + file: at.file, + line: cmd.argLines[i], + col: cmd.argCols[i], + endCol: cmd.argCols[i] + next.length, + }, + message: `${[cmd.tool, ...words, next].join(" ")}${close ? ` → did you mean "${close}"?` : ""}`, + spec: specRef(spec), + }); + res.specs.set(`${id} ${[...words, next].join(" ")}`, spec); // hover shows the parent's commands + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: false, + words: [...words, next], + }); + return; + } + words.push(next); + i++; + } + const leaf = chain[chain.length - 1]; + // "tool --x" where help listed no subcommands: may be an + // unlisted subcommand with its own flags, so claim nothing. + const word = + cmd.args[i] !== undefined && + !cmd.opaque.has(i) && + /^[A-Za-z][\w-]*$/.test(cmd.args[i]); + // Global options before a subcommand (`npm --prefix x run`): flags after + // `run` belong to a level we did not walk. + const later = cmd.args + .slice(i) + .some( + (a, j) => + !cmd.opaque.has(i + j) && + (leaf?.subcommands.includes(a) || + (leaf?.expectsSubcommand && SUBCOMMAND_WORD.test(a))), + ); + const unwalked = + leaf && + ((word && (leaf.expectsSubcommand || !leaf.subcommands.length)) || + later); + const flags = cmd.args + .slice(i) + .filter((a, j) => a.startsWith("-") && !cmd.opaque.has(i + j)); + res.edges.push({ + from: docId, + to: id, + kind: "invokes", + at, + resolved: true, + words, + flags, + }); + if (leaf && !unwalked) { + res.specs.set(`${id} ${words.join(" ")}`.trim(), leaf); + checkFlags(ctx, cmd, i, leaf, chain, specRef(leaf), at.file, res); + } +} + +/** Flag names in `cmd.args[from..]` must exist in the spec chain. */ +export function checkFlags( + ctx: BuildContext, + cmd: Command, + from: number, + leaf: CliSpec, + chain: CliSpec[], + ref: SpecRef, + file: string, + res: BuildResult, +) { + if (!leaf.flags.length || leaf.partial) return; // nothing (fully) known → nothing claimed + const known = new Set(chain.flatMap((s) => s.flags.map((f) => f.name))); + // Terse help that lists no long flags (BSD grep, npm) claims nothing about them. + if ( + !leaf.pwsh && + ![...known].some((n) => n.startsWith("--") && n !== "--help") + ) + return; + for (let j = from; j < cmd.args.length; j++) { + const a = cmd.args[j]; + if (PASSTHROUGH.has(a)) break; + if (cmd.opaque.has(j) || !/^--?[A-Za-z]/.test(a)) continue; + const name = a.split("=")[0]; + if (leaf.pwsh) { + // `-dry` matches `-DryRun`: PowerShell binds unique prefixes, any case. + const n = name.split(":")[0].toLowerCase(); + const hits = [...known].filter((k) => + k.toLowerCase().startsWith(n), + ); + if (hits.length || UNIVERSAL_FLAGS.has(n)) continue; + const close = nearest(name, [...known]); + res.diagnostics.push({ + rule: "flag-unknown", + severity: "error", + at: { + file, + line: cmd.argLines[j], + col: cmd.argCols[j], + endCol: cmd.argCols[j] + name.length, + }, + message: `${name} not defined by ${ref.tool}${close ? ` → did you mean ${close}?` : ""}`, + spec: ref, + }); + continue; + } + if (!name.startsWith("--") && name.length > 2) continue; // -abc bundles + // Help text often omits short flags (git log -n); only claim long ones. + if (leaf.source === "help" && !name.startsWith("--")) continue; + if (known.has(name) || UNIVERSAL_FLAGS.has(name)) continue; + if (name.startsWith("--no-") && known.has(`--${name.slice(5)}`)) + continue; + // Help hides some flags (clap `hide = true`: `mytool check --json`). + // Ask the binary: `tool sub --flag --help` printing the same help + // means the parser accepted the flag. + if (leaf.source === "help" && ctx.config.probe) { + const words = [...(leaf.probe?.slice(0, -1) ?? []), name]; + const probed = ctx.store.get(cmd.tool, words); + if (probed === undefined) { + const key = `${cmd.tool}|${words.join(" ")}`; + if (!ctx.pendingKeys.has(key)) { + ctx.pendingKeys.add(key); + res.pending.push({ tool: cmd.tool, words }); + } + continue; + } + if (probed && sameHelp(leaf, probed)) continue; + } + const close = nearest(name, [...known]); + res.diagnostics.push({ + rule: "flag-unknown", + severity: "error", + at: { + file, + line: cmd.argLines[j], + col: cmd.argCols[j], + endCol: cmd.argCols[j] + name.length, + }, + message: `${name} not defined by ${[ref.tool, ...(ref.probe ? ref.probe.slice(0, -1) : [])].join(" ")}${close ? ` → did you mean ${close}?` : ""}`, + spec: ref, + }); + } +} + +function specRef(spec: CliSpec): SpecRef { + return { + tool: spec.tool, + version: spec.version, + source: spec.source, + probe: spec.probe, + }; +} + +/** Paths that are patterns, not files. */ +const TEMPLATE_PATH = /[*?<>{}]|\/$/; + +function resolveScript( + skill: Skill, + doc: string, + p: string, +): { target: string; strict: boolean } | undefined { + if (BASE_PLACEHOLDER.test(p)) { + return { + target: path.join(skill.dir, p.replace(BASE_PLACEHOLDER, "")), + strict: true, + }; + } + if (path.isAbsolute(p) || p.startsWith("~") || p.startsWith("$")) + return undefined; + const fromDoc = path.resolve(path.dirname(doc), p); + if (fs.existsSync(fromDoc)) return { target: fromDoc, strict: true }; + const fromRoot = path.resolve(skill.dir, p); + if (fs.existsSync(fromRoot)) return { target: fromRoot, strict: true }; + // Harness installs may relocate the same skill under .claude/skills/. + const installed = /^\.claude\/skills\/([^/]+)\/(.+)$/.exec(p); + if (installed?.[1] === skill.name) { + return { target: path.resolve(skill.dir, installed[2]), strict: true }; + } + // An explicit agent runtime cwd resolves remaining relative command paths. + for ( + let dir = skill.dir; + path.dirname(dir) !== dir; + dir = path.dirname(dir) + ) { + const instructions = path.join(dir, "agent.md"); + if (!fs.existsSync(instructions)) continue; + const full = fs.readFileSync(instructions, "utf8"); + if ( + /working directory[^\n]*one level up from `workspace\/`/.test(full) + ) { + return { target: path.resolve(dir, "workspace", p), strict: true }; + } + } + // Only claim a missing path when its first segment exists in the skill, + // e.g. "scripts/gone.py" with a scripts/ dir. Others may be repo paths. + const first = p.replace(/^\.\//, "").split("/")[0]; + const strict = + p.includes("/") && fs.existsSync(path.join(skill.dir, first)); + return { target: fromRoot, strict }; +} + +/** `allowed-tools` Bash patterns → predicates; undefined when no Bash allowlist. */ +function allowedBash(value: unknown): ((c: Command) => boolean)[] | undefined { + const items = + typeof value === "string" + ? value.split(/,(?![^(]*\))/).map((s) => s.trim()) + : Array.isArray(value) + ? value.map(String) + : []; + const preds: ((c: Command) => boolean)[] = []; + let sawBash = false; + for (const item of items) { + if (item === "Bash") return undefined; // all commands allowed + const m = /^Bash\((.*)\)$/.exec(item); + if (!m) continue; + sawBash = true; + const pat = m[1].trim(); + const prefix = pat.replace(/:?\*$/, "").trim(); + const exact = !/\*$/.test(pat); + preds.push((c) => + exact + ? c.raw === prefix + : c.raw === prefix || c.raw.startsWith(`${prefix} `), + ); + } + return sawBash ? preds : undefined; +} diff --git a/ts/packages/skillgraph/src/rules/frontmatter.ts b/ts/packages/skillgraph/src/rules/frontmatter.ts new file mode 100644 index 0000000000..78ce7515b7 --- /dev/null +++ b/ts/packages/skillgraph/src/rules/frontmatter.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Skill } from "../context.js"; +import type { ParsedMarkdown } from "../markdown.js"; +import type { BuildResult } from "../context.js"; +import type { Diagnostic, Loc } from "../types.js"; + +// SKILL.md frontmatter: present, valid YAML, name matches dir, description bounded. + +const DESCRIPTION_MAX = 1024; + +export function checkFrontmatter( + skill: Skill, + md: ParsedMarkdown, + res: BuildResult, +) { + const at = (line = 1): Loc => ({ + file: skill.entry, + line, + col: 1, + endCol: 4, + }); + const push = ( + rule: string, + severity: Diagnostic["severity"], + message: string, + line = 1, + ) => res.diagnostics.push({ rule, severity, at: at(line), message }); + if (md.frontmatterError) + return push("frontmatter-invalid", "error", md.frontmatterError); + const fm = md.frontmatter; + if (!fm) + return push( + "frontmatter-missing", + "error", + "SKILL.md has no YAML frontmatter", + ); + const lineOf = (key: string) => { + const i = md.text.split("\n").findIndex((l) => l.startsWith(`${key}:`)); + return i >= 0 ? i + 1 : 1; + }; + if (typeof fm.name !== "string") + push("name-missing", "error", "frontmatter has no `name`"); + else if (fm.name !== skill.name) { + push( + "name-mismatch", + "warn", + `name "${fm.name}" does not match directory "${skill.name}"`, + lineOf("name"), + ); + } + if (typeof fm.description !== "string" || !fm.description.trim()) { + push( + "description-missing", + "error", + "frontmatter has no `description`", + ); + } else if (fm.description.length > DESCRIPTION_MAX) { + push( + "description-too-long", + "warn", + `${fm.description.length} > ${DESCRIPTION_MAX} chars`, + lineOf("description"), + ); + } +} diff --git a/ts/packages/skillgraph/src/rules/links.ts b/ts/packages/skillgraph/src/rules/links.ts new file mode 100644 index 0000000000..d13fb9874f --- /dev/null +++ b/ts/packages/skillgraph/src/rules/links.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + rel, + type BuildContext, + type BuildResult, + type Skill, +} from "../context.js"; +import { parseMarkdown, type ParsedMarkdown } from "../markdown.js"; +import type { Loc } from "../types.js"; + +// Markdown links: file exists, #anchor matches a heading. + +const SCRIPT_EXT = /\.(py|js|mjs|cjs|ts|mts|sh|bash|zsh|rb|pl|ps1)$/; + +export function linkEdges( + ctx: BuildContext, + skill: Skill, + file: string, + docId: string, + md: ParsedMarkdown, + res: BuildResult, + referenced: Set, +) { + for (const l of md.links) { + // External URLs and site-root links ("/docs/en/x" in mirrored docs). + if (/^[a-z][a-z0-9+.-]*:/i.test(l.url) || l.url.startsWith("/")) + continue; + if (/[<>{}$*]/.test(l.url)) continue; // templates and globs + const at: Loc = { + file, + line: l.line, + col: l.col, + endCol: l.endCol, + }; + const [rawPath, anchor] = splitAnchor(l.url); + const target = rawPath + ? path.resolve(path.dirname(file), decodeURIComponent(rawPath)) + : file; + // Links that climb out of the skill into a missing directory + // ("../../security/advisories/new") are site routes, not files. + if ( + path.relative(skill.dir, target).startsWith("..") && + !fs.existsSync(path.dirname(target)) + ) + continue; + if (!fs.existsSync(target) && !ctx.hasOverlay(target)) { + res.edges.push({ + from: docId, + to: `file:${target}`, + kind: "links", + at, + resolved: false, + }); + res.diagnostics.push({ + rule: "link-broken", + severity: "error", + at, + message: `${rawPath} does not exist`, + }); + continue; + } + referenced.add(target); + const inSkill = + target.startsWith(skill.dir + path.sep) || target === skill.dir; + const targetId = + target === skill.entry + ? `skill:${skill.id}` + : inSkill + ? `${target.endsWith(".md") ? "doc" : SCRIPT_EXT.test(target) ? "script" : "asset"}:${rel(skill, target)}` + : `file:${target}`; + if (anchor && target.endsWith(".md")) { + const text = ctx.read(target) ?? ""; + const headings = (target === file ? md : parseMarkdown(text)) + .headings; + const ok = headings.some((h) => h.slug === anchor.toLowerCase()); + res.edges.push({ + from: docId, + to: `heading:${inSkill ? rel(skill, target) : target}#${anchor}`, + kind: "links", + at, + resolved: ok, + }); + if (!ok) { + const have = headings + .slice(0, 6) + .map((h) => `#${h.slug}`) + .join(", "); + res.diagnostics.push({ + rule: "anchor-missing", + severity: "error", + at, + message: `#${anchor} not found in ${rawPath || path.basename(file)}${have ? ` (have: ${have})` : ""}`, + }); + } + } else { + res.edges.push({ + from: docId, + to: targetId, + kind: "links", + at, + resolved: true, + }); + } + } +} + +function splitAnchor(url: string): [string, string | undefined] { + const i = url.indexOf("#"); + return i < 0 ? [url, undefined] : [url.slice(0, i), url.slice(i + 1)]; +} diff --git a/ts/packages/skillgraph/src/rules/orphans.ts b/ts/packages/skillgraph/src/rules/orphans.ts new file mode 100644 index 0000000000..0273828dc0 --- /dev/null +++ b/ts/packages/skillgraph/src/rules/orphans.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from "node:path"; +import { rel, type BuildResult, type Skill } from "../context.js"; + +// Orphans: unreferenced files beside referenced ones (info only — +// scripts load assets through string literals we don't model). +// Only folders the docs already use count as skill content, e.g. +// scripts/old.py next to a linked scripts/fetch.py. Folders nothing +// references (crates/, tests/, the skill root itself) are other +// tooling's inputs, not skill content. +export function checkOrphans( + skill: Skill, + files: string[], + mentionText: string[], + referenced: Set, + res: BuildResult, +) { + const usedDirs = new Set( + [...referenced] + .map((r) => path.dirname(r)) + .filter((d) => d !== skill.dir), + ); + for (const f of files) { + if (referenced.has(f) || !usedDirs.has(path.dirname(f))) continue; + const base = path.basename(f); + if (base.startsWith(".")) continue; + if (mentionText.some((t, i) => files[i] !== f && t.includes(base))) + continue; + res.diagnostics.push({ + rule: "orphan", + severity: "info", + at: { file: f, line: 1, col: 1, endCol: 1 }, + message: `${rel(skill, f)} is not referenced by any doc or script`, + }); + } +} diff --git a/ts/packages/skillgraph/src/rules/skills.ts b/ts/packages/skillgraph/src/rules/skills.ts new file mode 100644 index 0000000000..1fda9c041d --- /dev/null +++ b/ts/packages/skillgraph/src/rules/skills.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { BuildContext, BuildResult, Skill } from "../context.js"; +import type { ParsedMarkdown } from "../markdown.js"; +import type { Loc } from "../types.js"; + +// skill://name references resolve within configured roots. + +export function skillEdges( + ctx: BuildContext, + skill: Skill, + file: string, + docId: string, + md: ParsedMarkdown, + res: BuildResult, +) { + for (const r of md.skillRefs) { + const at: Loc = { + file, + line: r.line, + col: r.col, + endCol: r.endCol, + }; + const localKey = `${skill.root}\0${r.name}`; + const matches = [ + ...ctx.byName.values(), + ...ctx.aliases.values(), + ].filter( + (s) => + s.name === r.name || + ctx.aliases.get(`${s.root}\0${r.name}`) === s, + ); + const unique = [...new Set(matches)]; + const target = + ctx.byName.get(localKey) ?? + ctx.aliases.get(localKey) ?? + (unique.length === 1 ? unique[0] : undefined); + const ok = target !== undefined; + res.edges.push({ + from: docId, + to: `skill:${target?.id ?? r.name}`, + kind: "uses", + at, + resolved: ok, + }); + if (!ok) { + res.diagnostics.push({ + rule: "skill-unknown", + severity: "error", + at, + message: `skill://${r.name} not found in configured roots`, + }); + } + } +} diff --git a/ts/packages/skillgraph/src/server.ts b/ts/packages/skillgraph/src/server.ts new file mode 100644 index 0000000000..999ef77c16 --- /dev/null +++ b/ts/packages/skillgraph/src/server.ts @@ -0,0 +1,301 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { + createConnection, + DiagnosticSeverity, + ProposedFeatures, + TextDocuments, + TextDocumentSyncKind, + type Diagnostic as LspDiagnostic, + type Location, +} from "vscode-languageserver/node.js"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { SkillGraph, type BuildResult } from "./graph.js"; +import type { Config, Diagnostic, Edge, Severity } from "./types.js"; + +// LSP over stdio. Each edit rebuilds the graph from overlays (cheap: no tool +// runs). Missing CLI specs are probed in the background; diagnostics are +// republished when probes finish. +// +// didChange ─▶ build() ─▶ publish ─▶ pending probes? ─▶ probe ─▶ build() ─▶ publish + +const SEVERITY: Record = { + error: DiagnosticSeverity.Error, + warn: DiagnosticSeverity.Warning, + info: DiagnosticSeverity.Information, +}; +const REBUILD_DEBOUNCE_MS = 150; +const SOURCE = "skillgraph"; + +export function startServer(config: Config) { + // stdio always: `skillgraph lsp` works with or without a --stdio flag. + const connection = createConnection( + ProposedFeatures.all, + process.stdin, + process.stdout, + ); + const documents = new TextDocuments(TextDocument); + const graph = new SkillGraph(config); + let res: BuildResult = graph.build(); + let published = new Set(); + let timer: NodeJS.Timeout | undefined; + let probing = false; + + const publish = () => { + const byFile = new Map(); + for (const d of res.diagnostics) { + const list = byFile.get(d.at.file) ?? []; + list.push(toLsp(d)); + byFile.set(d.at.file, list); + } + for (const f of published) if (!byFile.has(f)) byFile.set(f, []); + for (const [file, diagnostics] of byFile) { + connection.sendDiagnostics({ + uri: pathToFileURL(file).href, + diagnostics, + }); + } + published = new Set( + [...byFile.keys()].filter((f) => byFile.get(f)!.length), + ); + }; + + const rebuild = () => { + res = graph.build(); + publish(); + if (res.pending.length && !probing && config.probe) { + probing = true; + const pending = res.pending; + Promise.all(pending.map((p) => graph.store.probe(p.tool, p.words))) + .catch(() => undefined) + .finally(() => { + probing = false; + rebuild(); + }); + } + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(rebuild, REBUILD_DEBOUNCE_MS); + }; + + connection.onInitialize(() => ({ + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + definitionProvider: true, + referencesProvider: true, + hoverProvider: true, + inlayHintProvider: true, + }, + serverInfo: { name: SOURCE, version: "0.0.1" }, + })); + connection.onInitialized(() => rebuild()); + + documents.onDidChangeContent((e) => { + graph.setOverlay(fileURLToPath(e.document.uri), e.document.getText()); + schedule(); + }); + documents.onDidClose((e) => { + graph.setOverlay(fileURLToPath(e.document.uri), undefined); + schedule(); + }); + connection.onDidChangeWatchedFiles(schedule); + + /** Edge whose source range contains the cursor. */ + const edgeAt = ( + uri: string, + line: number, + character: number, + ): Edge | undefined => { + const file = fileURLToPath(uri); + return res.edges.find( + (e) => + e.at.file === file && + e.at.line === line + 1 && + character + 1 >= e.at.col && + character + 1 <= e.at.endCol, + ); + }; + + connection.onDefinition((p) => { + const e = edgeAt( + p.textDocument.uri, + p.position.line, + p.position.character, + ); + return e ? targetLocation(e.to, res) : null; + }); + + connection.onReferences((p) => { + const file = fileURLToPath(p.textDocument.uri); + const e = edgeAt( + p.textDocument.uri, + p.position.line, + p.position.character, + ); + // On a reference: find all refs to the same target. Elsewhere: refs to this file. + const ids = new Set(e ? [e.to] : []); + if (!e) { + for (const n of res.nodes) + if (n.path === file && n.kind !== "heading") ids.add(n.id); + const md = res.docs.get(file); + const h = md?.headings.find((x) => x.line === p.position.line + 1); + if (h) { + for (const n of res.nodes) { + if ( + n.kind === "heading" && + n.path === file && + n.line === h.line + ) + ids.add(n.id); + } + } + } + return res.edges + .filter((x) => ids.has(x.to)) + .map( + (x): Location => ({ + uri: pathToFileURL(x.at.file).href, + range: range(x.at), + }), + ); + }); + + connection.onHover((p) => { + const e = edgeAt( + p.textDocument.uri, + p.position.line, + p.position.character, + ); + if (!e) return null; + const node = res.nodes.find((n) => n.id === e.to); + const lines: string[] = [ + `**${e.to}** · ${e.kind}${e.resolved ? "" : " · unresolved"}`, + ]; + if (node?.description) lines.push("", node.description); + if (node?.tokens) lines.push("", `~${node.tokens} tokens`); + const spec = + res.specs.get(e.to) ?? + res.specs.get(`${e.to} ${(e.words ?? []).join(" ")}`.trim()); + if (spec) { + lines.push( + "", + `\`${spec.source === "ast" ? path.basename(spec.tool) : `${spec.tool} ${spec.version}`}\` · ${spec.source}${spec.probe.length ? ` · \`${spec.probe.join(" ")}\`` : ""}`, + ); + if (spec.summary) lines.push("", spec.summary); + if (spec.subcommands.length) + lines.push( + "", + `commands: ${spec.subcommands + .slice(0, 20) + .map((c) => `\`${c}\``) + .join(" ")}`, + ); + const flags = spec.flags + .filter((f) => f.name.startsWith("--")) + .slice(0, 12); + if (flags.length) + lines.push( + "", + flags + .map( + (f) => + `\`${f.name}\`${f.help ? ` — ${f.help}` : ""}`, + ) + .join(" \n"), + ); + } + return { contents: { kind: "markdown", value: lines.join("\n") } }; + }); + + connection.languages.inlayHint.on((p) => { + const file = fileURLToPath(p.textDocument.uri); + return res.edges + .filter( + (e) => e.at.file === file && e.kind === "links" && e.resolved, + ) + .flatMap((e) => { + const target = e.to.startsWith("heading:") + ? e.to.replace(/^heading:/, "doc:").replace(/#.*$/, "") + : e.to; + const n = res.nodes.find((x) => x.id === target); + if (!n?.tokens) return []; + return [ + { + position: { + line: e.at.line - 1, + character: e.at.endCol - 1, + }, + label: ` ~${fmtTokens(n.tokens)} tok`, + paddingLeft: true, + }, + ]; + }); + }); + + documents.listen(connection); + connection.listen(); +} + +function fmtTokens(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`; +} + +function range(at: { line: number; col: number; endCol: number }) { + return { + start: { line: at.line - 1, character: at.col - 1 }, + end: { line: at.line - 1, character: at.endCol - 1 }, + }; +} + +function toLsp(d: Diagnostic): LspDiagnostic { + const tag = d.spec + ? ` [${d.spec.source === "ast" ? d.spec.tool : `${d.spec.tool} ${d.spec.version}`} · ${d.spec.source}]` + : ""; + return { + range: range(d.at), + severity: SEVERITY[d.severity], + code: d.rule, + source: SOURCE, + message: d.message + tag, + }; +} + +function targetLocation(id: string, res: BuildResult): Location | null { + const node = res.nodes.find((n) => n.id === id); + if (node?.path) { + const line = (node.line ?? 1) - 1; + return { + uri: pathToFileURL(node.path).href, + range: { + start: { line, character: 0 }, + end: { line, character: 0 }, + }, + }; + } + if (id.startsWith("script:")) { + const spec = res.specs.get(id); + if (spec?.path) + return { + uri: pathToFileURL(spec.path).href, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }; + } + if (id.startsWith("file:")) { + const p = id.slice(5); + return { + uri: pathToFileURL(p).href, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + }; + } + return null; +} diff --git a/ts/packages/skillgraph/src/shell.ts b/ts/packages/skillgraph/src/shell.ts new file mode 100644 index 0000000000..d6978b305e --- /dev/null +++ b/ts/packages/skillgraph/src/shell.ts @@ -0,0 +1,411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { parse, unquote, type Grammar, type Node } from "./parsers.js"; + +// Turns code fences and inline code into simple commands, using the bash and +// PowerShell tree-sitter grammars. Conservative: commands with syntax errors +// are skipped, never guessed. +// +// "$ rtk proxy gh pr view --json comments | jq ." +// → [{ argv: ["gh","pr","view","","--json","comments"], opaque: {3} }, +// { argv: ["jq","."] }] + +/** Fence languages treated as shell. `console` requires `$ ` prompts. */ +const SHELL_LANGS = new Set(["bash", "sh", "shell", "zsh"]); +const PWSH_LANGS = new Set(["powershell", "pwsh", "ps1", "ps"]); +const CONSOLE_LANGS = new Set(["console", "shell-session"]); + +type Dialect = "posix" | "pwsh"; +/** Transcript prompts, e.g. `$ gh pr view` and `PS C:\> ./x.ps1`. */ +const PROMPT: Record = { + posix: /^\s*\$ /, + pwsh: /^\s*PS(?: [^>]*)?> /, +}; +const GRAMMAR: Record = { posix: "bash", pwsh: "powershell" }; +/** Line-continuation character: a prompted line ending in it continues. */ +const CONTINUATION: Record = { posix: "\\", pwsh: "`" }; +/** PowerShell cmdlets are Verb-Noun (Get-ChildItem): not PATH tools. */ +const CMDLET = /^[A-Z][a-z]+-[A-Z][A-Za-z]*$/; +/** Doc placeholders, e.g. ``, `{baseDir}`, `[flags]`, `...`. */ +const PLACEHOLDER = /^<.*>$|^\{[^}]*\}$|\.\.\.|^\[.*\]$|^…$/; +/** + * Doc placeholders that are shell syntax: `` (redirection, pipe) and + * `{baseDir}` (brace group). Masked innermost first before parsing. + */ +const MASKED_PLACEHOLDER = /<[^<>\s]+>|\{\w+\}/g; +/** HTTP examples (`GET /api/items`) are not shell, even in a mislabeled fence. */ +const HTTP_METHODS = new Set([ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "CONNECT", + "TRACE", +]); +/** Bash nodes whose value is only known at run time. */ +const BASH_EXPANSIONS = [ + "simple_expansion", + "expansion", + "command_substitution", + "process_substitution", + "arithmetic_expansion", +]; +/** PowerShell nodes whose value is only known at run time. */ +const PWSH_EXPANSIONS = ["variable", "sub_expression"]; +/** Interpreter options whose value is inline code, not a script path. */ +const INLINE_CODE_FLAGS = new Set([ + "-m", + "-c", + "-e", + "-command", + "-encodedcommand", +]); + +/** Wrapper prefixes stripped before the real command (longest match first). */ +const WRAPPERS: string[][] = [ + ["rtk", "proxy"], + ["uv", "run"], + ["pnpm", "exec"], + ["pnpm", "dlx"], + ["npm", "exec"], + ["npx"], + ["uvx"], + ["bunx"], + ["sudo"], + ["time"], + ["exec"], + ["command"], + ["timeout"], + ["nohup"], +]; + +/** Interpreters whose first non-flag argument is the real script. */ +const INTERPRETERS = new Set([ + "python", + "python3", + "node", + "bash", + "sh", + "zsh", + "bun", + "tsx", + "ts-node", + "deno", + "perl", + "ruby", + "pwsh", + "powershell", +]); + +/** Shell builtins and keywords: never treated as tools. */ +const BUILTINS = new Set([ + "cd", + "export", + "source", + ".", + "echo", + "printf", + "set", + "unset", + "if", + "then", + "else", + "elif", + "fi", + "for", + "do", + "done", + "while", + "case", + "esac", + "function", + "return", + "local", + "read", + "eval", + "trap", + "true", + "false", + "test", + "[", + "[[", + "alias", + "exit", + "shift", + "wait", + "pushd", + "popd", +]); + +export interface Command { + /** Original simple command text, e.g. "python scripts/x.py --pr 1". */ + raw: string; + /** Tool word after unwrapping, e.g. "gh" or "scripts/fetch.py". */ + tool: string; + /** True when `tool` came from an interpreter argument or looks like a path. */ + isPath: boolean; + /** Arguments after the tool. */ + args: string[]; + /** Indexes into `args` that are placeholders or expansions. */ + opaque: Set; + line: number; + /** 1-based column of each arg and of the tool, for diagnostics. */ + toolCol: number; + argCols: number[]; + argLines: number[]; +} + +interface Token { + text: string; + line: number; + col: number; + opaque: boolean; +} + +export function isShellLang(lang: string): boolean { + return ( + SHELL_LANGS.has(lang) || PWSH_LANGS.has(lang) || CONSOLE_LANGS.has(lang) + ); +} + +/** Extract simple commands from a fence; `inline` spans need ≥2 words. */ +export function commandsFromCode( + lang: string, + value: string, + line: number, + col: number, +): Command[] { + if (lang === "inline") { + return parseCommands(value, line, col, "posix", true).filter( + (c) => c.args.length > 0, + ); + } + if (!isShellLang(lang)) return []; + const dialect: Dialect = PWSH_LANGS.has(lang) ? "pwsh" : "posix"; + return parseCommands( + transcriptToScript(value, dialect, CONSOLE_LANGS.has(lang)), + line, + col, + dialect, + false, + ); +} + +/** + * A fence with prompts is a transcript: keep prompted lines (and their + * continuations), blank everything else. Same length, so positions hold. + * "$ gh pr list\n#1 fix" → " gh pr list\n " + */ +function transcriptToScript( + text: string, + dialect: Dialect, + consoleMode: boolean, +): string { + const lines = text.split("\n"); + if (!consoleMode && !lines.some((l) => PROMPT[dialect].test(l))) + return text; + let continues = false; + return lines + .map((l) => { + const prompt = PROMPT[dialect].exec(l); + const keep = prompt !== null || continues; + continues = keep && l.trimEnd().endsWith(CONTINUATION[dialect]); + if (!keep) return " ".repeat(l.length); + return prompt + ? " ".repeat(prompt[0].length) + l.slice(prompt[0].length) + : l; + }) + .join("\n"); +} + +/** Parse with the dialect's grammar and convert each simple command. */ +function parseCommands( + text: string, + line: number, + col: number, + dialect: Dialect, + strict: boolean, +): Command[] { + // `tab >` → `tab ____________`: same length, so tokens map + // back to the original `<…>` text. + let masked = text; + for (let prev = ""; prev !== masked; ) { + prev = masked; + masked = masked.replace(MASKED_PLACEHOLDER, (m) => + "_".repeat(m.length), + ); + } + return parse(GRAMMAR[dialect], masked, (root) => { + // Inline prose that is not valid shell is not a command. + if (strict && root.hasError) return []; + const out: Command[] = []; + for (const node of root.descendantsOfType("command")) { + if (insideError(node)) continue; // claim nothing about broken syntax + const tokens = + dialect === "posix" + ? bashTokens(node, text) + : pwshTokens(node, text); + const at = (t: Omit, n: Node): Token => ({ + ...t, + line: line + n.startPosition.row, + col: col + n.startPosition.column, + }); + if (!tokens.length || HTTP_METHODS.has(tokens[0].node.text)) + continue; + const cmd = toCommand( + tokens.map((t) => at(t, t.node)), + dialect, + ); + if (cmd) out.push(cmd); + } + return out; + }); +} + +/** The command has a syntax error, or the parser recovered it from one. */ +function insideError(node: Node): boolean { + if (node.hasError) return true; + for (let n = node.parent; n; n = n.parent) { + if (n.type === "ERROR") return true; + } + return false; +} + +type NodeToken = Omit & { node: Node }; + +/** bash `command`: name + arguments; assignments (`FOO=1 cmd`) are fields we skip. */ +function bashTokens(cmd: Node, original: string): NodeToken[] { + const name = cmd.childForFieldName("name"); + if (!name) return []; + return [name, ...cmd.childrenForFieldName("argument")].map((n) => + token(n, original, bashValue(n), BASH_EXPANSIONS), + ); +} + +/** PowerShell `command`: name (or `& path`) + elements, minus separators. */ +function pwshTokens(cmd: Node, original: string): NodeToken[] { + const name = cmd.childForFieldName("command_name"); + if (!name) return []; + const elements = + cmd + .childForFieldName("command_elements") + ?.namedChildren.filter((n) => n.type !== "command_argument_sep") ?? + []; + return [name, ...elements].map((n) => { + const str = n.descendantsOfType("string_literal")[0]; + const value = str && str.text === n.text ? unquote(n) : n.text; + return token(n, original, value, PWSH_EXPANSIONS); + }); +} + +/** Word value with quotes removed: `--since="$D"` → `--since=$D`. */ +function bashValue(n: Node): string { + if ( + n.type === "string" || + n.type === "raw_string" || + n.type === "ansi_c_string" + ) + return unquote(n).replace(/^\$?['"]|['"]$/g, ""); + if (n.type === "concatenation") + return n.namedChildren.map(bashValue).join(""); + return n.text; +} + +function token( + n: Node, + original: string, + value: string, + expansions: string[], +): NodeToken { + // Unmasked source text for placeholders like ``. + const raw = original.slice(n.startIndex, n.endIndex); + const text = raw !== n.text ? raw : value; + const opaque = + PLACEHOLDER.test(text) || n.descendantsOfType(expansions).length > 0; + return { node: n, text, opaque }; +} + +function toCommand(tokens: Token[], dialect: Dialect): Command | undefined { + const raw = tokens.map((x) => x.text).join(" "); + let t = [...tokens]; + if (t[0]?.text === "env") { + t.shift(); + while (t.length && /^[A-Za-z_][A-Za-z0-9_]*=|^-/.test(t[0].text)) { + t.shift(); + } + } + let changed = true; + while (changed && t.length) { + changed = false; + for (const w of WRAPPERS) { + if (w.every((word, i) => t[i]?.text === word)) { + // `command -v x` is a lookup, not an invocation. + if (w[0] === "command" && /^-[vV]$/.test(t[1]?.text ?? "")) + return undefined; + t = t.slice(w.length); + if (w[0] === "timeout" && /^\d/.test(t[0]?.text ?? "")) + t = t.slice(1); + // `uv run --with x` style options belong to the wrapper. + while (t.length && t[0].text.startsWith("-")) t.shift(); + changed = true; + break; + } + } + } + if (!t.length || t[0].opaque) return undefined; + let head = t[0]; + let rest = t.slice(1); + let isPath = + /[/\\]/.test(head.text) || + /\.(py|js|mjs|cjs|ts|sh|ps1)$/.test(head.text); + if (INTERPRETERS.has(head.text)) { + if (head.text === "deno" && rest[0]?.text === "run") + rest = rest.slice(1); + // `pwsh -ExecutionPolicy Bypass -File x.ps1`: the path follows -File. + const pwsh = head.text === "pwsh" || head.text === "powershell"; + const file = pwsh + ? rest.findIndex((x) => /^-f(?:ile)?$/i.test(x.text)) + : -1; + const idx = + file >= 0 + ? file + 1 + : rest.findIndex((x) => !x.text.startsWith("-")); + if (idx < 0 || idx >= rest.length) return undefined; + // `python -m pkg`, `bash -c "..."`, `perl -e '...'` are not script invocations. + if ( + rest + .slice(0, idx) + .some((x) => INLINE_CODE_FLAGS.has(x.text.toLowerCase())) + ) { + return undefined; + } + if (!/[/\\]/.test(rest[idx].text) && !/\.\w+$/.test(rest[idx].text)) { + return undefined; + } + head = rest[idx]; + rest = rest.slice(idx + 1); + isPath = true; + } + if (head.opaque && !isPath) return undefined; + if (BUILTINS.has(head.text)) return undefined; + if (dialect === "pwsh" && CMDLET.test(head.text)) return undefined; + if (!isPath && !/^[A-Za-z0-9][\w.+-]*$/.test(head.text)) return undefined; + const opaque = new Set(); + rest.forEach((x, i) => x.opaque && opaque.add(i)); + return { + raw, + // `.\\scripts\\x.ps1` → `./scripts/x.ps1` so paths resolve on any OS. + tool: isPath ? head.text.replace(/\\/g, "/") : head.text, + isPath, + args: rest.map((x) => x.text), + opaque, + line: head.line, + toolCol: head.col, + argCols: rest.map((x) => x.col), + argLines: rest.map((x) => x.line), + }; +} diff --git a/ts/packages/skillgraph/src/specs/hash.ts b/ts/packages/skillgraph/src/specs/hash.ts new file mode 100644 index 0000000000..b09afb17d3 --- /dev/null +++ b/ts/packages/skillgraph/src/specs/hash.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash } from "node:crypto"; + +/** First 8 hex chars of sha256; short content ids for specs and versions. */ +export function sha8(s: string | Buffer): string { + return createHash("sha256").update(s).digest("hex").slice(0, 8); +} diff --git a/ts/packages/skillgraph/src/specs/help.ts b/ts/packages/skillgraph/src/specs/help.ts new file mode 100644 index 0000000000..9c5322b80c --- /dev/null +++ b/ts/packages/skillgraph/src/specs/help.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { CliSpec, FlagDef } from "../types.js"; + +// `--help` text → flags + subcommands. Pure; no process execution. + +/** Help that admits it is a subset, e.g. curl: "This is not the full help". */ +const PARTIAL_HELP = + /not the full help|--help (?:--)?all|help |use "?\S+ help -a/i; +/** Usage line that declares a subcommand slot, e.g. "gh pr [flags]". */ +const USAGE_SUBCOMMAND = + /<(?:sub)?command>|\[command\]|\bCOMMANDS?\b|^Commands:||\{[a-z][\w-]*(?:,[a-z][\w-]*)+\}/; +/** Subcommand row: " pr: Manage…", " ls, list List…", " ado [options] [tool] Azure…". */ +const SUBCOMMAND_ROW = + /^\s{2,}([a-z][\w-]*(?:,\s*[a-z][\w-]*)*)(?::?(?:\s{2,}\S|\s*$)|\s+[<[][^\n]*?\s{2,}\S)/; +/** Unindented section header: "Commands:", "CORE COMMANDS", "Options". */ +const SECTION_HEADER = /^[A-Z][A-Za-z :&-]*:?$/; +const COMMANDS_HEADER = /commands?:?$/i; +/** Option row: " -R, --repo string Select…", " --[no-]quiet". */ +const OPTION_ROW = + /^\s*((?:-{1,2}(?:\[no-\])?[A-Za-z0-9][\w-]*(?:\[?[= ][<[]?[\w|.:,-]+[>\]]?\]?)?(?:,\s*|\s+|$))+)/; + +/** Parse help text into flags and subcommands. Pure; exported for tests. */ +export function parseHelp(text: string): { + flags: FlagDef[]; + subcommands: string[]; + expectsSubcommand: boolean; + partial: boolean; +} { + // Strip ANSI colors and man-page overstrike ("N\bNA\bAM\bME"). + const clean = text.replace(/\x1b\[[0-9;]*m/g, "").replace(/.\x08/g, ""); + const lines = clean.split("\n"); + const usage = lines.slice(0, 12).join("\n"); + // Commander/cobra put the slot in a section header ("Commands:", "CORE COMMANDS"). + const expectsSubcommand = + USAGE_SUBCOMMAND.test(usage) || + /^[A-Za-z :&-]*\bcommands:?$/im.test(clean); + const flags = new Map(); + const subcommands = new Set(); + let optionRows = 0; + // Subcommand rows count only under a "Commands" header; rows under + // "Arguments:"/"Options" are positionals or examples (`cmd "message"`). + let inCommands = true; + for (const raw of lines) { + const line = raw.replace(/\s+$/, ""); + if (!line.trim()) continue; + if (SECTION_HEADER.test(line)) { + inCommands = COMMANDS_HEADER.test(line); + continue; + } + const opt = OPTION_ROW.exec(line); + if (opt) { + optionRows++; + const help = line.slice(opt[0].length).trim(); + for (const f of opt[1].matchAll( + /(?:^|[\s,])(-{1,2})(\[no-\])?([A-Za-z0-9][\w-]*)/g, + )) { + const names = [`${f[1]}${f[3]}`]; + if (f[2]) names.push(`--no-${f[3]}`); + for (const name of names) { + if (!flags.has(name)) + flags.set(name, help ? { name, help } : { name }); + } + } + continue; + } + if (expectsSubcommand && inCommands) { + const sub = SUBCOMMAND_ROW.exec(line); + if (sub) for (const w of sub[1].split(/,\s*/)) subcommands.add(w); + } + } + // Man pages and usage lines mention flags outside option rows + // ("--staged is a synonym for --cached", "[--no-pager]"). Any mention counts. + for (const m of clean.matchAll(/(?:^|[\s[(|,`'"])(--[A-Za-z0-9][\w-]*)/g)) { + if (!flags.has(m[1])) flags.set(m[1], { name: m[1] }); + } + // Usage-only help (BSD grep, tar) lists a subset of flags. + const partial = PARTIAL_HELP.test(clean) || optionRows === 0; + return { + flags: [...flags.values()], + subcommands: [...subcommands], + expectsSubcommand, + partial, + }; +} + +/** Remove the probed words so "gh pr comments --help" ≈ "gh pr --help". */ +export function normalizeHelp(out: string, words: string[]): string { + let s = out; + for (const w of words) s = s.split(w).join(""); + return s.replace(/\s+/g, " ").trim(); +} + +export function parseVersion(text: string): string | undefined { + // "gh version 2.97.0 (…)", "multica v0.6.0-2849-g5a04abde4" → first x.y[.z][-pre] + return /(?:^|[^\w.])v?(\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)/.exec(text)?.[1]; +} + +/** Same help text, or same parsed shape (help with probed words echoed back). */ +export function sameHelp(a: CliSpec, b: CliSpec): boolean { + if (a.helpHash === b.helpHash) return true; + const shape = (s: CliSpec) => + JSON.stringify([s.subcommands, s.flags.map((f) => f.name)]); + return shape(a) === shape(b); +} diff --git a/ts/packages/skillgraph/src/specs/index.ts b/ts/packages/skillgraph/src/specs/index.ts new file mode 100644 index 0000000000..bb8ccfa5ba --- /dev/null +++ b/ts/packages/skillgraph/src/specs/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// CLI specs: script AST (script.ts), --help parsing (help.ts), +// process probing (process.ts), and the probe cache (store.ts). + +export { parseHelp, parseVersion } from "./help.js"; +export { scriptSpec } from "./script.js"; +export { SpecStore } from "./store.js"; diff --git a/ts/packages/skillgraph/src/specs/process.ts b/ts/packages/skillgraph/src/specs/process.ts new file mode 100644 index 0000000000..32ddb0a090 --- /dev/null +++ b/ts/packages/skillgraph/src/specs/process.ts @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// PATH lookup, binary identity, and sandboxed execution for probes. + +const PROBE_TIMEOUT_MS = 5_000; +const PROBE_OUTPUT_LIMIT = 64 * 1024; + +export interface Identity { + real: string; + size: number; + mtimeMs: number; +} + +const whichMemo = new Map(); + +/** Clear PATH lookups; called at the start of each build. */ +export function resetWhich() { + whichMemo.clear(); +} + +export function which(tool: string): string | undefined { + if (whichMemo.has(tool)) return whichMemo.get(tool); + const found = whichUncached(tool); + whichMemo.set(tool, found); + return found; +} + +function whichUncached(tool: string): string | undefined { + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + const p = path.join(dir, tool); + try { + fs.accessSync(p, fs.constants.X_OK); + if (fs.statSync(p).isFile()) return p; + } catch {} + } + return undefined; +} + +/** Version-manager shims: one binary multiplexes many tools (mise → pytest, eslint). */ +const SHIM_TARGETS = new Set([ + "mise", + "asdf", + "rustup", + "proto", + "volta", + "pyenv", + "rbenv", + "nodenv", +]); + +export function isShim(bin: string): boolean { + try { + const target = path.basename(fs.realpathSync(bin)); + return SHIM_TARGETS.has(target) && path.basename(bin) !== target; + } catch { + return false; + } +} + +export function identity(bin: string): Identity | undefined { + try { + const real = fs.realpathSync(bin); + const st = fs.statSync(real); + return { real, size: st.size, mtimeMs: Math.floor(st.mtimeMs) }; + } catch { + return undefined; + } +} + +export interface RunResult { + out: string; + code: number | null; + timedOut: boolean; +} + +/** Run with no stdin, a scrubbed env, a timeout, and an output cap. */ +export function run(bin: string, args: string[]): Promise { + return new Promise((resolve) => { + const env: Record = { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? os.homedir(), + LANG: "C", + NO_COLOR: "1", + TERM: "dumb", + PAGER: "cat", + GH_PAGER: "cat", + GIT_PAGER: "cat", + CI: "1", + }; + const child = spawn(bin, args, { + stdio: ["ignore", "pipe", "pipe"], + env, + }); + let out = ""; + let timedOut = false; + const onData = (d: Buffer) => { + if (out.length < PROBE_OUTPUT_LIMIT) out += d.toString("utf8"); + if (out.length >= PROBE_OUTPUT_LIMIT) child.kill("SIGKILL"); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, PROBE_TIMEOUT_MS); + child.on("error", () => { + clearTimeout(timer); + resolve({ out: "", code: -1, timedOut: false }); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve({ out: out.slice(0, PROBE_OUTPUT_LIMIT), code, timedOut }); + }); + }); +} diff --git a/ts/packages/skillgraph/src/specs/script.ts b/ts/packages/skillgraph/src/specs/script.ts new file mode 100644 index 0000000000..76c71c6b3f --- /dev/null +++ b/ts/packages/skillgraph/src/specs/script.ts @@ -0,0 +1,321 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { CliSpec, FlagDef } from "../types.js"; +import { parse, type Grammar, type Node } from "../parsers.js"; +import { sha8 } from "./hash.js"; + +// Bundled scripts: static flag extraction (never executed), via tree-sitter. +// scripts/fetch.py ──argparse/click/commander/param()──▶ CliSpec{source:"ast"} + +type Lang = "py" | "js" | "ts" | "sh" | "ps1"; +const GRAMMAR: Record = { + py: "python", + js: "javascript", + ts: "typescript", + sh: "bash", + ps1: "powershell", +}; +const EXT: Record = { + ".py": "py", + ".js": "js", + ".mjs": "js", + ".cjs": "js", + ".ts": "ts", + ".mts": "ts", + ".sh": "sh", + ".bash": "sh", + ".ps1": "ps1", +}; +/** Shebang interpreter → language: `#!/usr/bin/env python3` → py. */ +const SHEBANG: [RegExp, Lang][] = [ + [/python/, "py"], + [/node|deno|bun/, "js"], + [/pwsh/, "ps1"], + [/\b(ba|z)?sh\b/, "sh"], +]; +/** Python calls whose string args are flags: argparse, click, typer. */ +const PY_FLAG_CALLS = new Set(["add_argument", "option", "Option"]); +/** Python calls whose first string arg is a subcommand. */ +const PY_SUBCOMMAND_CALLS = new Set(["add_parser", "command"]); +/** JS calls: commander/yargs `.option("-x, --xx ")`, `.command("go")`. */ +const JS_FLAG_CALLS = new Set(["option", "requiredOption"]); +const JS_SUBCOMMAND_CALLS = new Set(["command"]); +/** Runtime argv sources for hand-rolled parsing. */ +const ARGV = new Set(["process.argv", "Bun.argv", "Deno.args"]); +const FLAG = /^-{1,2}[A-Za-z][\w-]*$/; +/** about_CommonParameters: added to every [CmdletBinding()] script. */ +const PS_COMMON_PARAMS = [ + "Debug", + "ErrorAction", + "ErrorVariable", + "InformationAction", + "InformationVariable", + "OutBuffer", + "OutVariable", + "PipelineVariable", + "ProgressAction", + "Verbose", + "WarningAction", + "WarningVariable", + "WhatIf", + "Confirm", +]; +const LICENSE_LINE = /^(Copyright|Licensed|-\*-)/; + +function detect(file: string, src: string): Lang | undefined { + const byExt = EXT[path.extname(file)]; + if (byExt) return byExt; + const shebang = src.startsWith("#!") ? src.slice(0, src.indexOf("\n")) : ""; + return SHEBANG.find(([re]) => re.test(shebang))?.[1]; +} + +/** String literal contents, or undefined for non-literals (`f"{x}"` counts). */ +function literal(n: Node | null): string | undefined { + if (!n || (n.type !== "string" && n.type !== "template_string")) + return undefined; + if (n.descendantsOfType(["interpolation", "template_substitution"]).length) + return undefined; + return n.namedChildren + .filter( + (c) => c.type === "string_content" || c.type === "string_fragment", + ) + .map((c) => c.text) + .join(""); +} + +/** `a.b.name(...)` → "name"; `name(...)` → "name". */ +function callee(call: Node): string | undefined { + const fn = call.childForFieldName("function"); + if (!fn) return undefined; + if (fn.type === "identifier") return fn.text; + return ( + fn.childForFieldName("attribute") ?? fn.childForFieldName("property") + )?.text; +} + +function args(call: Node): Node[] { + return call.childForFieldName("arguments")?.namedChildren ?? []; +} + +interface Found { + flags: [string, Node][]; + subcommands: string[]; + /** A real argv parser was found; otherwise flag rules are skipped. */ + structured: boolean; +} + +function readPython(root: Node): Found { + const out: Found = { flags: [], subcommands: [], structured: false }; + for (const call of root.descendantsOfType("call")) { + const name = callee(call) ?? ""; + const strings = args(call).map(literal); + if (PY_FLAG_CALLS.has(name)) { + out.structured = true; + for (const s of strings) + if (s && FLAG.test(s)) out.flags.push([s, call]); + } else if (PY_SUBCOMMAND_CALLS.has(name)) { + // add_parser("go") or @cli.command(name="go") + const kw = args(call).find( + (a) => + a.type === "keyword_argument" && + a.childForFieldName("name")?.text === "name", + ); + const s = kw ? literal(kw.childForFieldName("value")) : strings[0]; + if (s) out.subcommands.push(s); + } + } + return out; +} + +function readJs(root: Node): Found { + const out: Found = { flags: [], subcommands: [], structured: false }; + const calls = root.descendantsOfType("call_expression"); + for (const call of calls) { + const name = callee(call) ?? ""; + const [first, second] = args(call); + const s = literal(first); + if (!s) continue; + if (JS_FLAG_CALLS.has(name)) { + out.structured = true; + if (second?.type === "object" && !s.startsWith("-")) { + // yargs: .option("verbose", {...}) / .option("v", {...}) + out.flags.push([s.length === 1 ? `-${s}` : `--${s}`, call]); + } else { + // commander: "-x, --xx " + for (const part of s.split(/[\s,|]+/)) + if (FLAG.test(part)) out.flags.push([part, call]); + } + } else if (JS_SUBCOMMAND_CALLS.has(name)) { + out.subcommands.push(s.split(" ")[0]); + } + } + if (out.structured) return out; + // util.parseArgs({ options: { foo: { type: "string", short: "f" } } }) + for (const call of calls) { + if (callee(call) !== "parseArgs") continue; + for (const pair of call.descendantsOfType("pair")) { + const key = pair.childForFieldName("key"); + const value = pair.childForFieldName("value"); + if (key?.text !== "options" || value?.type !== "object") continue; + out.structured = true; + for (const opt of value.namedChildren) { + if (opt.type !== "pair") continue; + const k = opt.childForFieldName("key")!; + out.flags.push([`--${literal(k) ?? k.text}`, opt]); + for (const p of opt.childForFieldName("value")?.namedChildren ?? + []) + if ( + p.type === "pair" && + p.childForFieldName("key")?.text === "short" + ) { + const short = literal(p.childForFieldName("value")); + if (short) out.flags.push([`-${short}`, p]); + } + } + } + } + if (out.structured) return out; + // Hand-rolled argv loops: every "--flag" literal compared against argv. + const readsArgv = root + .descendantsOfType("member_expression") + .some((m) => ARGV.has(m.text)); + if (!readsArgv) return out; + for (const s of root.descendantsOfType(["string", "template_string"])) { + const v = literal(s); + if (v?.startsWith("--") && FLAG.test(v)) { + out.structured = true; + out.flags.push([v, s]); + } + } + return out; +} + +function readPowerShell(root: Node): Found { + const out: Found = { flags: [], subcommands: [], structured: false }; + // Only the script-level block; function param blocks are nested deeper. + const block = root.namedChildren.find((c) => c.type === "param_block"); + if (!block) return out; + out.structured = true; + for (const p of block.descendantsOfType("script_parameter")) { + const v = p.namedChildren.find((c) => c.type === "variable"); + if (v) out.flags.push([`-${v.text.replace(/^\$/, "")}`, p]); + } + const cmdletBinding = block + .descendantsOfType("type_name") + .some((t) => t.text.toLowerCase() === "cmdletbinding"); + if (cmdletBinding) + for (const name of PS_COMMON_PARAMS) + out.flags.push([`-${name}`, block]); + return out; +} + +function readBash(root: Node): Found { + const out: Found = { flags: [], subcommands: [], structured: false }; + // case "$1" in -a|--all) … esac + for (const item of root.descendantsOfType("case_item")) { + for (const v of item.childrenForFieldName("value")) { + const s = v.text.replace(/^["']|["']$/g, ""); + if (FLAG.test(s)) { + out.structured = true; + out.flags.push([s, v]); + } + } + } + return out; +} + +const READERS: Record Found> = { + py: readPython, + js: readJs, + ts: readJs, + sh: readBash, + ps1: readPowerShell, +}; + +/** First doc block: Python docstring, JSDoc, `<# #>`, or leading `#`/`//` lines. */ +function summaryOf(root: Node): string | undefined { + const docs: Node[] = []; + for (const c of root.namedChildren) { + if (c.type === "hash_bang_line") continue; + if (c.type === "comment") { + if (!c.text.startsWith("#!")) docs.push(c); + } else if ( + !docs.length && + c.type === "expression_statement" && + // Python docstring only; JS "use strict" is also a string statement. + c.firstNamedChild?.firstNamedChild?.type === "string_start" + ) + return firstLine(literal(c.firstNamedChild) ?? ""); + else break; + } + return firstLine(docs.map((d) => d.text).join("\n")); +} + +function firstLine(text: string): string | undefined { + return text + .split("\n") + .map((l) => + l + .trim() + .replace(/^(<#|#>|\/\*\*?|\*\/|\*|\/\/|#)/, "") + .replace(/(#>|\*\/)$/, "") + .trim(), + ) + .find((l) => l && !LICENSE_LINE.test(l)); +} + +/** Local imports may parse argv too (kit/audit.mjs reads --audit). */ +function hasLocalImport(root: Node): boolean { + return root + .descendantsOfType(["import_statement", "call_expression"]) + .some((n) => { + const src = + n.type === "import_statement" + ? n.childForFieldName("source") + : callee(n) === "require" + ? args(n)[0] + : null; + return literal(src ?? null)?.startsWith(".") ?? false; + }); +} + +export function scriptSpec(file: string): CliSpec | undefined { + let src: string; + try { + src = fs.readFileSync(file, "utf8"); + } catch { + return undefined; + } + const lang = detect(file, src); + const { found, summary, partial } = lang + ? parse(GRAMMAR[lang], src, (root) => ({ + found: READERS[lang](root), + summary: summaryOf(root), + partial: hasLocalImport(root), + })) + : { found: undefined, summary: undefined, partial: false }; + const flags = new Map(); + for (const [name, node] of found?.flags ?? []) + if (!flags.has(name)) + flags.set(name, { name, line: node.startPosition.row + 1 }); + const spec: CliSpec = { + tool: file, + version: `sha256:${sha8(src)}`, + source: "ast", + probe: [], + path: file, + // No parser found → no flag knowledge; the checker skips flag rules. + flags: found?.structured + ? [...flags.values(), { name: "--help" }, { name: "-h" }] + : [], + subcommands: [...new Set(found?.subcommands)], + expectsSubcommand: false, + }; + if (lang === "ps1") spec.pwsh = true; + if (summary) spec.summary = summary; + if (partial) spec.partial = true; + return spec; +} diff --git a/ts/packages/skillgraph/src/specs/store.ts b/ts/packages/skillgraph/src/specs/store.ts new file mode 100644 index 0000000000..886fe0b942 --- /dev/null +++ b/ts/packages/skillgraph/src/specs/store.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { createHash } from "node:crypto"; +import type { CliSpec } from "../types.js"; +import { sha8 } from "./hash.js"; +import { normalizeHelp, parseHelp, parseVersion } from "./help.js"; +import { identity, isShim, run, which, type Identity } from "./process.js"; + +// CLI specs for invoked commands. +// +// bundled script ──static scan──▶ CliSpec{source:"ast"} (never executed) +// external tool ──--help probe─▶ CliSpec{source:"help"} (cached by binary identity) +// +// Probe pattern follows flyline: subcommand-aware, bounded, cached by +// realpath+size+mtime(+version), failures cached 1h, timeouts never cached. + +const NEGATIVE_TTL_MS = 60 * 60 * 1000; +const CACHE_VERSION = 1; +/** Tools where `-h` is not help (human-readable sizes, etc.). */ +const NO_SHORT_HELP = new Set(["ls", "df", "du", "sort", "free", "grep"]); +/** Tools never executed, even with --help. */ +const NEVER_PROBE = new Set(["rm", "dd", "mkfs", "shutdown", "reboot", "kill"]); + +interface CacheRecord { + v: number; + key: string; + at: number; + spec?: CliSpec; +} + +/** + * Cache of probed specs. In-memory map in front of an on-disk JSON store at + * ~/.cache/skillgraph/specs-v1/.json. `get` never blocks; `probe` does. + */ +export class SpecStore { + private memory = new Map(); + private versions = new Map>(); + private inflight = new Map>(); + constructor( + private dir = process.env.SKILLGRAPH_CACHE ?? + path.join( + os.homedir(), + ".cache", + "skillgraph", + `specs-v${CACHE_VERSION}`, + ), + ) {} + + /** Probe key, e.g. "/opt/homebrew/Cellar/gh/2.62.0/bin/gh|123|456|pr view". */ + private key(id: Identity, words: string[]): string { + return `${id.real}|${id.size}|${id.mtimeMs}|${words.join(" ")}`; + } + + /** Cached spec, or undefined (unknown) / null (probed: no help). */ + get(tool: string, words: string[]): CliSpec | null | undefined { + const bin = which(tool); + const id = bin && identity(bin); + if (!id) return null; + const key = this.key(id, words); + const rec = this.memory.get(key) ?? this.readDisk(key); + if (!rec) return undefined; + if (!rec.spec && Date.now() - rec.at > NEGATIVE_TTL_MS) + return undefined; + return rec.spec ?? null; + } + + isProbeable(tool: string): boolean { + const bin = which(tool); + return !NEVER_PROBE.has(tool) && bin !== undefined && !isShim(bin); + } + + probe(tool: string, words: string[]): Promise { + const bin = which(tool); + const id = bin && identity(bin); + if (!bin || !id || NEVER_PROBE.has(tool) || isShim(bin)) + return Promise.resolve(undefined); + const key = this.key(id, words); + const existing = this.inflight.get(key); + if (existing) return existing; + const p = this.doProbe(tool, bin, id, words, key).finally(() => + this.inflight.delete(key), + ); + this.inflight.set(key, p); + return p; + } + + private async doProbe( + tool: string, + bin: string, + id: Identity, + words: string[], + key: string, + ): Promise { + const version = await this.version(bin, id); + const argSets = NO_SHORT_HELP.has(tool) + ? [["--help"]] + : [["--help"], ["-h"]]; + let sawTimeout = false; + for (const a of argSets) { + const r = await run(bin, [...words, ...a]); + if (r.timedOut) sawTimeout = true; + const parsed = parseHelp(r.out); + if (parsed.flags.length || parsed.subcommands.length) { + const spec: CliSpec = { + tool, + version, + source: "help", + probe: [...words, ...a], + flags: parsed.flags, + subcommands: parsed.subcommands, + expectsSubcommand: + parsed.expectsSubcommand && + parsed.subcommands.length > 0, + helpHash: sha8(normalizeHelp(r.out, words)), + }; + if (parsed.partial) spec.partial = true; + this.put(key, spec); + return spec; + } + } + if (!sawTimeout) this.put(key, undefined); + return undefined; + } + + /** `--version`, then `-v`; first semver token, else binary hash. */ + private version(bin: string, id: Identity): Promise { + const vkey = `${id.real}|${id.size}|${id.mtimeMs}`; + let p = this.versions.get(vkey); + if (!p) { + p = (async () => { + for (const a of [["--version"], ["-v"]]) { + const r = await run(bin, a); + const v = parseVersion( + r.out.split("\n").slice(0, 3).join(" "), + ); + if (v) return v; + } + const buf = fs.readFileSync(id.real); + return `sha256:${sha8(buf)}`; + })(); + this.versions.set(vkey, p); + } + return p; + } + + private file(key: string) { + return path.join( + this.dir, + `${createHash("sha256").update(key).digest("hex").slice(0, 32)}.json`, + ); + } + + private readDisk(key: string): CacheRecord | undefined { + try { + const rec = JSON.parse( + fs.readFileSync(this.file(key), "utf8"), + ) as CacheRecord; + if (rec.v !== CACHE_VERSION || rec.key !== key) return undefined; + this.memory.set(key, rec); + return rec; + } catch { + return undefined; + } + } + + private put(key: string, spec: CliSpec | undefined) { + const rec: CacheRecord = { v: CACHE_VERSION, key, at: Date.now() }; + if (spec) rec.spec = spec; + this.memory.set(key, rec); + try { + fs.mkdirSync(this.dir, { recursive: true }); + fs.writeFileSync(this.file(key), JSON.stringify(rec)); + } catch {} + } +} diff --git a/ts/packages/skillgraph/src/tsconfig.json b/ts/packages/skillgraph/src/tsconfig.json new file mode 100644 index 0000000000..0db064ce33 --- /dev/null +++ b/ts/packages/skillgraph/src/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist" + }, + "include": ["./**/*.ts"] +} diff --git a/ts/packages/skillgraph/src/types.ts b/ts/packages/skillgraph/src/types.ts new file mode 100644 index 0000000000..7be02bc2dd --- /dev/null +++ b/ts/packages/skillgraph/src/types.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Core contract shared by the CLI, the LSP, and agents (`--json`). +// +// SKILL.md ──links──▶ references/x.md#anchor +// │ └──uses────▶ skill://other +// └──invokes───▶ scripts/fetch.py (flags checked against static spec) +// └──invokes───▶ gh pr view (flags checked against --help probe) + +/** 1-based line/column position inside a file (absolute path). */ +export interface Loc { + file: string; + line: number; + col: number; + endCol: number; +} + +export type NodeKind = + | "skill" + | "doc" + | "heading" + | "script" + | "tool" + | "asset"; +export type EdgeKind = "links" | "uses" | "invokes"; + +export interface GraphNode { + id: string; + kind: NodeKind; + /** Absolute path for files; undefined for external tools. */ + path?: string; + line?: number; + /** Approximate cl100k token count for docs and skills. */ + tokens?: number; + description?: string; +} + +export interface Edge { + from: string; + to: string; + kind: EdgeKind; + at: Loc; + resolved: boolean; + /** Flags used at an invocation site, e.g. ["--pr", "--since"]. */ + flags?: string[]; + /** For tool edges: subcommand words, e.g. ["pr", "view"]. */ + words?: string[]; +} + +export type Severity = "error" | "warn" | "info"; + +/** Where a CLI spec came from; printed on every CLI diagnostic. */ +export interface SpecRef { + tool: string; + version: string; + source: "ast" | "help"; + probe?: string[]; +} + +export interface Diagnostic { + rule: string; + severity: Severity; + at: Loc; + message: string; + spec?: SpecRef; +} + +/** A known flag plus where it was defined (script line or help text). */ +export interface FlagDef { + name: string; + line?: number; + help?: string; +} + +/** Normalized CLI surface for one command level. */ +export interface CliSpec { + tool: string; + version: string; + source: "ast" | "help"; + probe: string[]; + /** Absolute path of the bundled script, for AST specs. */ + path?: string; + summary?: string; + flags: FlagDef[]; + subcommands: string[]; + /** True when help shows a subcommand slot, so unknown words are errors. */ + expectsSubcommand: boolean; + /** Help says it lists only some options (e.g. curl "--help all"). */ + partial?: boolean; + /** PowerShell parameters: `-Name`, case-insensitive, unique prefixes allowed. */ + pwsh?: boolean; + /** Hash of the help output; a child equal to its parent is not a real subcommand. */ + helpHash?: string; +} + +export interface Config { + roots: string[]; + /** Run `--help` / `--version` probes for external tools. */ + probe: boolean; +} diff --git a/ts/packages/skillgraph/src/util.ts b/ts/packages/skillgraph/src/util.ts new file mode 100644 index 0000000000..b6d9b459e5 --- /dev/null +++ b/ts/packages/skillgraph/src/util.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Small pure helpers shared across modules. + +/** Closest option within edit distance ~len/3, for "did you mean". */ +export function nearest(word: string, options: string[]): string | undefined { + let best: string | undefined; + let bestD = Math.max(2, Math.floor(word.length / 3)) + 1; + for (const o of options) { + const d = levenshtein(word, o); + if (d < bestD) { + best = o; + bestD = d; + } + } + return best; +} + +function levenshtein(a: string, b: string): number { + const dp = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + let prev = dp[0]; + dp[0] = i; + for (let j = 1; j <= b.length; j++) { + const tmp = dp[j]; + dp[j] = Math.min( + dp[j] + 1, + dp[j - 1] + 1, + prev + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + prev = tmp; + } + } + return dp[b.length]; +} + +/** Run `fn` over items with at most `limit` in flight. */ +export async function runLimited( + items: T[], + limit: number, + fn: (t: T) => Promise, +) { + let next = 0; + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) await fn(items[next++]); + }), + ); +} diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/SKILL.md b/ts/packages/skillgraph/test/fixtures/skills/pr-review/SKILL.md new file mode 100644 index 0000000000..f0de0e5689 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/SKILL.md @@ -0,0 +1,24 @@ +--- +name: pr-review +description: Review a PR, address comments, and reply to reviewers. +--- + +# PR review + +1. Fetch open comments: + + ```bash + python scripts/fetch_comments.py --pr 123 --since 7d + ``` + +2. Apply [the checklist](references/checklist.md#correctness). +3. Draft replies per [replies](references/replies.md) using skill://sounds-human. +4. Post: `node scripts/post_reply.js --pr 123 --body-file reply.md` +5. Confirm state: + + ```bash + gh pr comments 123 + git log --oneline --graph -n 5 + ``` + +6. Polish tone with skill://tone-police. diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/checklist.md b/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/checklist.md new file mode 100644 index 0000000000..1ff3d05fa4 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/checklist.md @@ -0,0 +1,9 @@ +# Checklist + +## Security + +- No secrets in diffs. + +## Tests + +- New behavior has a test. diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/replies.md b/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/replies.md new file mode 100644 index 0000000000..4ab69bb052 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/references/replies.md @@ -0,0 +1,4 @@ +# Replies + +Fetch first with `python ../scripts/fetch_comments.py --pr `. +See [security](checklist.md#security). diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/fetch_comments.py b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/fetch_comments.py new file mode 100644 index 0000000000..39bb4c9779 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/fetch_comments.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Fetch unresolved review comments for a PR.""" +import argparse + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--pr", type=int, required=True, help="PR number") + p.add_argument("--since", default="30d", help="Age window, e.g. 7d") + print(p.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/old_fetch.py b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/old_fetch.py new file mode 100644 index 0000000000..6a47ec45d6 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/old_fetch.py @@ -0,0 +1,4 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Superseded by fetch_comments.py.""" diff --git a/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/post_reply.js b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/post_reply.js new file mode 100644 index 0000000000..7430a00b97 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/pr-review/scripts/post_reply.js @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Post a reply to a PR review thread. +import { program } from "commander"; + +program + .option("--pr ", "PR number") + .option("--body ", "Reply body"); +program.parse(); diff --git a/ts/packages/skillgraph/test/fixtures/skills/sounds-human/SKILL.md b/ts/packages/skillgraph/test/fixtures/skills/sounds-human/SKILL.md new file mode 100644 index 0000000000..e5badae262 --- /dev/null +++ b/ts/packages/skillgraph/test/fixtures/skills/sounds-human/SKILL.md @@ -0,0 +1,8 @@ +--- +name: sounds-human +description: Rewrite prose so it reads like a person wrote it. +--- + +# Sounds human + +Cut filler. Prefer short words. diff --git a/ts/packages/skillgraph/test/graph.spec.ts b/ts/packages/skillgraph/test/graph.spec.ts new file mode 100644 index 0000000000..0a5bb467a6 --- /dev/null +++ b/ts/packages/skillgraph/test/graph.spec.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { SkillGraph } from "../src/graph.js"; +import { parseHelp, SpecStore } from "../src/specs/index.js"; +import { commandsFromCode } from "../src/shell.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +// dist/test → test/fixtures (fixtures are not compiled). +const root = path.resolve(here, "../../test/fixtures/skills"); + +describe("skillgraph check on fixture skills", () => { + it("checks commands without treating examples or separate skill scopes as errors", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skillgraph-noise-")); + try { + const roots = ["one", "two"].map((name) => path.join(dir, name)); + for (const root of roots) { + const skill = path.join(root, "review"); + fs.mkdirSync(path.join(skill, "scripts"), { recursive: true }); + const text = + '---\nname: review\ndescription: Review\n---\n`var x = "CANARY";` `GET /api/items HTTP/1.1`\n`../references/missing.md` `../../etc/passwd`\n| Example | Value |\n| --- | --- |\n| `x` | `[example]()` |\n[missing]()\n```bash\npython3 -c "\nfrom pathlib import Path\ndoc = {}\n"\npython scripts/missing.py\n```'; + fs.writeFileSync(path.join(skill, "SKILL.md"), text); + } + const result = new SkillGraph({ roots, probe: false }).build(); + expect(result.skills).toHaveLength(2); + expect(result.diagnostics.map((d) => d.rule).sort()).toEqual([ + "link-broken", + "link-broken", + "script-missing", + "script-missing", + ]); + const shell = + "python3 <<'PY'\nfrom pathlib import Path\nPY\ngh pr view 1 --json \"a # b\""; + const commands = commandsFromCode("bash", shell, 1, 1); + expect(commands.map((c) => c.tool)).toEqual(["gh"]); + expect(commands[0].args).toContain("a # b"); + const comment = commandsFromCode( + "bash", + 'gh pr list;# "\ngh pr view 1', + 1, + 1, + ); + expect(comment).toHaveLength(2); + const multiline = commandsFromCode( + "bash", + 'gh pr view 1 --body "a\nb" --missing', + 1, + 1, + )[0]; + expect([ + multiline.argLines.at(-1), + multiline.argCols.at(-1), + ]).toEqual([2, 4]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("reports each broken reference once, with the spec version", async () => { + // Fake `gh` on PATH so the help probe is hermetic. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "skillgraph-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/bin/sh +case "$*" in + --version) echo "gh version 2.62.0" ;; + "pr --help") printf 'USAGE\\n gh pr [flags]\\n\\nCOMMANDS\\n comment: Add a comment\\n view: View a PR\\n' ;; + *) printf 'USAGE\\n gh \\n\\nCOMMANDS\\n pr: Pull requests\\n' ;; +esac +`, + { mode: 0o755 }, + ); + process.env.PATH = `${bin}${path.delimiter}${process.env.PATH}`; + const store = new SpecStore( + path.join(os.tmpdir(), `skillgraph-test-${process.pid}`), + ); + const graph = new SkillGraph( + { roots: [root, root], probe: true }, + store, + ); + const res = await graph.buildWithProbes(); + const got = res.diagnostics + .filter((d) => d.severity !== "info") + .map((d) => `${path.basename(d.at.file)}:${d.at.line} ${d.rule}`) + .sort(); + expect(got).toEqual([ + "SKILL.md:14 anchor-missing", + "SKILL.md:16 flag-unknown", + "SKILL.md:20 subcommand-unknown", + "SKILL.md:24 skill-unknown", + ]); + const sub = res.diagnostics.find( + (d) => d.rule === "subcommand-unknown", + ); + expect(sub?.message).toContain('did you mean "comment"'); + expect(sub?.spec?.version).toBe("2.62.0"); + expect( + res.diagnostics.some( + (d) => + d.rule === "orphan" && d.at.file.endsWith("old_fetch.py"), + ), + ).toBe(true); + }); + + it("reads help sections and transcripts without hardcoded tool lists", () => { + // Rows under "Arguments:"/"Options" are not subcommands (codex mcp add). + const help = parseHelp( + "Usage: x mcp add [OPTIONS] -- ...\n\nArguments:\n servers Server names\n\nOptions:\n --url URL\n", + ); + expect(help.subcommands).toEqual([]); + // `$ ` prompts mark a transcript: unprompted lines are output (mytool receipt). + const cmds = commandsFromCode( + "bash", + "$ mytool receipt last\n Domains referenced: x.sh", + 1, + 1, + ); + expect(cmds.map((c) => c.tool)).toEqual(["mytool"]); + // PowerShell: PS prompt, `&` call operator, backtick continuation, + // cmdlets skipped, `pwsh -File` unwrapped. + const ps = commandsFromCode( + "powershell", + "PS C:\\> & .\\scripts\\sync.ps1 -Name a `\n -dry\nGet-ChildItem -Recurse\nPS> pwsh -NoProfile -File scripts/sync.ps1 -Nmae b", + 1, + 1, + ); + expect(ps.map((c) => [c.tool, c.args])).toEqual([ + ["./scripts/sync.ps1", ["-Name", "a", "-dry"]], + ["scripts/sync.ps1", ["-Nmae", "b"]], + ]); + }); +}); diff --git a/ts/packages/skillgraph/test/tsconfig.json b/ts/packages/skillgraph/test/tsconfig.json new file mode 100644 index 0000000000..9e22629873 --- /dev/null +++ b/ts/packages/skillgraph/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*.ts"], + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/skillgraph/tsconfig.json b/ts/packages/skillgraph/tsconfig.json new file mode 100644 index 0000000000..1073bd66cd --- /dev/null +++ b/ts/packages/skillgraph/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true + }, + "include": [], + "references": [{ "path": "./src" }, { "path": "./test" }] +} diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 66c5e20ec2..06a17dd286 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -178,7 +178,7 @@ importers: version: 8.18.1 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + version: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -6476,6 +6476,70 @@ importers: specifier: ~5.4.5 version: 5.4.5 + packages/skillgraph: + dependencies: + gpt-tokenizer: + specifier: 2.9.0 + version: 2.9.0 + mdast-util-from-markdown: + specifier: ^2.0.2 + version: 2.1.0 + mdast-util-gfm-table: + specifier: 2.0.0 + version: 2.0.0 + micromark-extension-gfm-table: + specifier: 2.1.1 + version: 2.1.1 + tree-sitter-bash: + specifier: 0.25.1 + version: 0.25.1 + tree-sitter-javascript: + specifier: 0.25.0 + version: 0.25.0 + tree-sitter-powershell: + specifier: 0.26.4 + version: 0.26.4 + tree-sitter-python: + specifier: 0.25.0 + version: 0.25.0 + tree-sitter-typescript: + specifier: 0.23.2 + version: 0.23.2 + vscode-languageserver: + specifier: ^9.0.1 + version: 9.0.1 + vscode-languageserver-textdocument: + specifier: ^1.0.12 + version: 1.0.12 + web-tree-sitter: + specifier: 0.27.1 + version: 0.27.1 + yaml: + specifier: ^2.8.3 + version: 2.9.0 + devDependencies: + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/node': + specifier: ^22.0.0 + version: 22.20.5 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) + prettier: + specifier: ^3.5.3 + version: 3.5.3 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + typescript: + specifier: ~5.4.5 + version: 5.4.5 + packages/studio-service: dependencies: '@typeagent/agent-rpc': @@ -10239,12 +10303,14 @@ packages: '@opentelemetry/sdk-trace-base@2.10.0': resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/sdk-trace-node@2.10.0': resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -13656,9 +13722,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decode-named-character-reference@1.1.0: - resolution: {integrity: sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==} - decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -16268,12 +16331,6 @@ packages: mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - mdast-util-from-markdown@2.1.0: resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} @@ -16393,9 +16450,6 @@ packages: micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-space@2.1.0: resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} @@ -16453,9 +16507,6 @@ packages: micromark-util-types@2.0.3: resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromark@4.0.3: resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} @@ -16733,6 +16784,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.9.2: + resolution: {integrity: sha1-23rJShP/2bVebLBFhL1e+OHXSxg=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-addon-api/-/node-addon-api-8.9.2.tgz} + engines: {node: ^18 || ^20 || >= 21} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -16762,6 +16817,10 @@ packages: encoding: optional: true + node-gyp-build@4.8.4: + resolution: {integrity: sha1-inDuhUZK5SMndyqQ1mxgd6kAz8g=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-gyp-build/-/node-gyp-build-4.8.4.tgz} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -18795,6 +18854,54 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + tree-sitter-bash@0.25.1: + resolution: {integrity: sha1-eEmez4kw21e/5GWB+Ug4fteKBHA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-bash/-/tree-sitter-bash-0.25.1.tgz} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-javascript@0.23.1: + resolution: {integrity: sha1-Tkn6OtX5lrOsXPT3jWxd2HZ4j68=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-javascript/-/tree-sitter-javascript-0.23.1.tgz} + peerDependencies: + tree-sitter: ^0.21.1 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-javascript@0.25.0: + resolution: {integrity: sha1-LjNrjxKOboVAHrZ2Z9/YfPzxU+g=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-javascript/-/tree-sitter-javascript-0.25.0.tgz} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-powershell@0.26.4: + resolution: {integrity: sha1-90hKC4+iZrA7Gf9WR5y4zW3cIrc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-powershell/-/tree-sitter-powershell-0.26.4.tgz} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-python@0.25.0: + resolution: {integrity: sha1-wcRgkVy4g/6y2ezv65izeU66hfU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-python/-/tree-sitter-python-0.25.0.tgz} + peerDependencies: + tree-sitter: ^0.25.0 + peerDependenciesMeta: + tree-sitter: + optional: true + + tree-sitter-typescript@0.23.2: + resolution: {integrity: sha1-cLxhWi9mSo6ch8UzOCvspYfyirM=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tree-sitter-typescript/-/tree-sitter-typescript-0.23.2.tgz} + peerDependencies: + tree-sitter: ^0.21.0 + peerDependenciesMeta: + tree-sitter: + optional: true + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -19343,6 +19450,9 @@ packages: resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} engines: {node: '>= 14'} + web-tree-sitter@0.27.1: + resolution: {integrity: sha512-qtx0VyUyNRHtL3ze7+v0hCKB/sLcAmY7ZAVp/ZK/bCj0yED8ptvNrr7Vd7DwDyKDWN9baqcUx4uaD2r2ql+6IA==, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/web-tree-sitter/-/web-tree-sitter-0.27.1.tgz} + webcrypto-core@1.9.2: resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} @@ -22592,7 +22702,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -22605,14 +22715,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22640,14 +22750,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22675,14 +22785,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22710,14 +22820,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22745,14 +22855,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22780,14 +22890,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22815,14 +22925,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -22847,7 +22957,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -22865,7 +22975,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 22.15.18 + '@types/node': 22.20.5 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -22887,7 +22997,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 26.6.5 + '@types/node': 22.20.5 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -24746,7 +24856,7 @@ snapshots: '@secretlint/resolver': 9.3.2 '@secretlint/types': 9.3.2 ajv: 8.20.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) rc-config-loader: 4.1.3 transitivePeerDependencies: - supports-color @@ -24755,7 +24865,7 @@ snapshots: dependencies: '@secretlint/profiler': 9.3.2 '@secretlint/types': 9.3.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) structured-source: 4.0.0 transitivePeerDependencies: - supports-color @@ -24768,7 +24878,7 @@ snapshots: '@textlint/module-interop': 14.7.1 '@textlint/types': 14.7.1 chalk: 4.1.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) pluralize: 8.0.0 strip-ansi: 6.0.1 table: 6.9.0 @@ -25241,7 +25351,7 @@ snapshots: '@textlint/resolver': 14.7.1 '@textlint/types': 14.7.1 chalk: 4.1.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) js-yaml: 3.15.2 lodash: 4.18.1 pluralize: 2.0.0 @@ -25292,7 +25402,7 @@ snapshots: '@types/accepts@1.3.7': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/async@3.2.24': {} @@ -25335,11 +25445,11 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/bonjour@3.5.13': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/bootstrap@5.2.11': dependencies: @@ -25380,7 +25490,7 @@ snapshots: '@types/co-body@6.1.3': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/qs': 6.15.1 '@types/command-line-args@5.2.3': {} @@ -25390,11 +25500,11 @@ snapshots: '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.8 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/connect@3.4.38': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/content-disposition@0.5.9': {} @@ -25405,7 +25515,7 @@ snapshots: '@types/connect': 3.4.38 '@types/express': 5.0.6 '@types/keygrip': 1.0.6 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/cors@2.8.18': dependencies: @@ -25566,21 +25676,21 @@ snapshots: '@types/express-serve-static-core@4.17.41': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 '@types/express-serve-static-core@5.1.3': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/qs': 6.15.1 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -25637,7 +25747,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 22.15.18 + '@types/node': 22.20.5 '@types/graceful-fs@4.1.9': dependencies: @@ -25665,7 +25775,7 @@ snapshots: '@types/http-proxy@1.17.17': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/istanbul-lib-coverage@2.0.6': {} @@ -25698,7 +25808,7 @@ snapshots: '@types/jsdom@20.0.1': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.20.5 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -25713,7 +25823,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/jsonpath@0.2.4': {} @@ -25738,7 +25848,7 @@ snapshots: '@types/http-errors': 2.0.5 '@types/keygrip': 1.0.6 '@types/koa-compose': 3.2.9 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/linkify-it@5.0.0': {} @@ -25792,7 +25902,7 @@ snapshots: '@types/node-fetch@2.6.12': dependencies: - '@types/node': 22.15.18 + '@types/node': 22.20.5 form-data: 4.0.6 '@types/node@18.19.130': @@ -25871,16 +25981,16 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/send@1.2.1': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/serve-index@1.9.4': dependencies: @@ -25889,19 +25999,19 @@ snapshots: '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/send': 0.17.6 '@types/serve-static@1.15.5': dependencies: '@types/http-errors': 2.0.4 '@types/mime': 3.0.4 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/sinon-chai@3.2.12': dependencies: @@ -25918,7 +26028,7 @@ snapshots: '@types/sockjs@0.3.36': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/spotify-api@0.0.25': {} @@ -25948,7 +26058,7 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/ws@8.18.1': dependencies: @@ -25956,7 +26066,7 @@ snapshots: '@types/ws@8.18.2': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 '@types/xml2js@0.4.14': dependencies: @@ -25970,7 +26080,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 optional: true '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3))(eslint@10.6.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.3)': @@ -27531,7 +27641,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -27968,13 +28078,13 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)): + create-jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -28392,10 +28502,6 @@ snapshots: decimal.js@10.6.0: {} - decode-named-character-reference@1.1.0: - dependencies: - character-entities: 2.0.2 - decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -29930,7 +30036,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.5 serialize-error: 7.0.1 optional: true @@ -30343,7 +30449,7 @@ snapshots: https-proxy-agent@5.0.1(supports-color@8.1.1): dependencies: agent-base: 6.0.2(supports-color@8.1.1) - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -30827,7 +30933,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.0 @@ -30942,16 +31048,16 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)): + jest-cli@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + create-jest: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + jest-config: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.3 @@ -31173,7 +31279,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31199,12 +31305,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.20.1 - ts-node: 10.9.2(@types/node@26.6.5)(typescript@5.4.5) + ts-node: 10.9.2(@types/node@22.20.5)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31230,12 +31336,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.20.5 - ts-node: 10.9.2(@types/node@22.20.1)(typescript@5.4.5) + ts-node: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31261,43 +31367,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.20.5 - ts-node: 10.9.2(@types/node@22.20.5)(typescript@5.4.5) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@20.19.40)(typescript@5.4.5)): - dependencies: - '@babel/core': 7.29.7 - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.7) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@20.19.40)(typescript@5.4.5) + ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31322,13 +31397,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@20.19.43)(typescript@5.4.5) + '@types/node': 22.20.5 + ts-node: 10.9.2(@types/node@22.15.18)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.15.18)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31353,13 +31428,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@22.15.18)(typescript@5.4.5) + '@types/node': 22.20.5 + ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.19.19)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31384,13 +31459,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@22.19.19)(typescript@5.4.5) + '@types/node': 22.20.5 + ts-node: 10.9.2(@types/node@22.20.1)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.20.1)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31415,13 +31490,13 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@22.20.1)(typescript@5.4.5) + '@types/node': 22.20.5 + ts-node: 10.9.2(@types/node@22.20.5)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@26.6.5)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): + jest-config@29.7.0(@types/node@22.20.5)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -31446,8 +31521,8 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 26.6.5 - ts-node: 10.9.2(@types/node@22.20.5)(typescript@5.4.5) + '@types/node': 22.20.5 + ts-node: 10.9.2(@types/node@26.6.5)(typescript@5.4.5) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -31522,7 +31597,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -31571,7 +31646,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -31606,7 +31681,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -31634,7 +31709,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -31699,7 +31774,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 26.6.5 + '@types/node': 22.20.5 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -31708,7 +31783,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.6.5 + '@types/node': 22.20.5 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -31779,12 +31854,12 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)): + jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@26.6.5)(typescript@5.4.5)) + jest-cli: 29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@types/node@22.20.5)(typescript@5.4.5)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -32536,40 +32611,6 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.1.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.3 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.3 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - mdast-util-from-markdown@2.1.0: dependencies: '@types/mdast': 4.0.4 @@ -32577,7 +32618,7 @@ snapshots: decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.3 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -32599,8 +32640,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color @@ -32608,8 +32649,8 @@ snapshots: mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color @@ -32618,8 +32659,8 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color @@ -32627,20 +32668,20 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.1.0 mdast-util-gfm-autolink-literal: 2.0.1 mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 + mdast-util-to-markdown: 2.2.0 transitivePeerDependencies: - supports-color @@ -32762,7 +32803,7 @@ snapshots: micromark-core-commonmark@2.0.3: dependencies: - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-factory-destination: 2.0.1 micromark-factory-label: 2.0.1 @@ -32777,14 +32818,14 @@ snapshots: micromark-util-resolve-all: 2.0.1 micromark-util-subtokenize: 2.1.0 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-autolink-literal@2.1.0: dependencies: micromark-util-character: 2.1.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-footnote@2.1.0: dependencies: @@ -32795,7 +32836,7 @@ snapshots: micromark-util-normalize-identifier: 2.0.1 micromark-util-sanitize-uri: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-strikethrough@2.1.0: dependencies: @@ -32804,7 +32845,7 @@ snapshots: micromark-util-classify-character: 2.0.1 micromark-util-resolve-all: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-table@2.1.1: dependencies: @@ -32812,11 +32853,11 @@ snapshots: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-tagfilter@2.0.0: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm-task-list-item@2.1.0: dependencies: @@ -32824,7 +32865,7 @@ snapshots: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-gfm@3.0.0: dependencies: @@ -32835,14 +32876,14 @@ snapshots: micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-extension-math@3.1.0: dependencies: '@types/katex': 0.16.7 devlop: 1.1.0 katex: 0.16.22 - micromark-factory-space: 2.0.1 + micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.3 @@ -32851,38 +32892,33 @@ snapshots: dependencies: micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 micromark-util-types: 2.0.3 micromark-factory-space@2.1.0: dependencies: micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-title@2.0.1: dependencies: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-factory-whitespace@2.0.1: dependencies: micromark-factory-space: 2.1.0 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-character@2.1.1: dependencies: @@ -32902,7 +32938,7 @@ snapshots: micromark-util-combine-extensions@2.0.1: dependencies: micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-decode-numeric-character-reference@2.0.2: dependencies: @@ -32910,7 +32946,7 @@ snapshots: micromark-util-decode-string@2.0.1: dependencies: - decode-named-character-reference: 1.1.0 + decode-named-character-reference: 1.3.0 micromark-util-character: 2.1.1 micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-symbol: 2.0.1 @@ -32929,7 +32965,7 @@ snapshots: micromark-util-resolve-all@2.0.1: dependencies: - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-sanitize-uri@2.0.1: dependencies: @@ -32942,7 +32978,7 @@ snapshots: devlop: 1.1.0 micromark-util-chunked: 2.0.1 micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 + micromark-util-types: 2.0.3 micromark-util-symbol@2.0.1: {} @@ -32950,32 +32986,10 @@ snapshots: micromark-util-types@2.0.3: {} - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@8.1.1) - decode-named-character-reference: 1.1.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.1.0 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - micromark@4.0.3: dependencies: - '@types/debug': 4.1.12 - debug: 4.4.1(supports-color@8.1.1) + '@types/debug': 4.1.13 + debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -33250,15 +33264,17 @@ snapshots: node-abi@4.37.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-addon-api@4.3.0: {} node-addon-api@7.1.1: {} + node-addon-api@8.9.2: {} + node-api-version@0.2.1: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-diff3@3.2.0: {} @@ -33282,6 +33298,8 @@ snapshots: optionalDependencies: encoding: 0.1.13 + node-gyp-build@4.8.4: {} + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -33289,7 +33307,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.29.0 @@ -34077,7 +34095,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 26.6.5 + '@types/node': 22.20.5 long: 5.3.2 protocol-buffers-schema@3.6.1: {} @@ -34445,7 +34463,7 @@ snapshots: rc-config-loader@4.1.3: dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) js-yaml: 4.3.2 json5: 2.2.3 require-from-string: 2.0.2 @@ -34612,7 +34630,7 @@ snapshots: remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.1.0 micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -35409,7 +35427,7 @@ snapshots: spdy-transport@3.0.0(supports-color@8.1.1): dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) detect-node: 2.1.0 hpack.js: 2.1.6 obuf: 1.1.2 @@ -35611,7 +35629,7 @@ snapshots: sumchecker@3.0.1(supports-color@8.1.1): dependencies: - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -35930,6 +35948,37 @@ snapshots: tree-kill@1.2.2: {} + tree-sitter-bash@0.25.1: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + tree-sitter-javascript@0.23.1: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + tree-sitter-javascript@0.25.0: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + tree-sitter-powershell@0.26.4: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + tree-sitter-python@0.25.0: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + tree-sitter-typescript@0.23.2: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + tree-sitter-javascript: 0.23.1 + trough@2.2.0: {} truncate-utf8-bytes@1.0.2: @@ -36653,6 +36702,8 @@ snapshots: web-streams-polyfill@4.0.0-beta.3: {} + web-tree-sitter@0.27.1: {} + webcrypto-core@1.9.2: dependencies: '@peculiar/asn1-schema': 2.11.0 diff --git a/ts/pnpm-workspace.yaml b/ts/pnpm-workspace.yaml index 4ecf9246c9..50c6b3bc44 100644 --- a/ts/pnpm-workspace.yaml +++ b/ts/pnpm-workspace.yaml @@ -52,6 +52,12 @@ allowBuilds: protobufjs: false puppeteer: false sharp: true + # Grammars ship prebuilt .wasm; skillgraph never loads their native addons. + tree-sitter-bash: false + tree-sitter-javascript: false + tree-sitter-powershell: false + tree-sitter-python: false + tree-sitter-typescript: false # Moved out of the deprecated `pnpm` field in package.json (pnpm v11 no longer reads it). peerDependencyRules: