@wkronmiller/lisa 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +407 -0
- package/bin/lisa-runtime.js +8797 -0
- package/bin/lisa.js +21 -0
- package/completion.ts +58 -0
- package/install.ps1 +51 -0
- package/install.sh +93 -0
- package/lisa.ts +6 -0
- package/package.json +66 -0
- package/skills/README.md +28 -0
- package/skills/claude-code/CLAUDE.md +151 -0
- package/skills/codex/AGENTS.md +151 -0
- package/skills/gemini/GEMINI.md +151 -0
- package/skills/opencode/AGENTS.md +152 -0
- package/src/cli.ts +85 -0
- package/src/harness/base-adapter.ts +47 -0
- package/src/harness/claude-code.ts +106 -0
- package/src/harness/codex.ts +80 -0
- package/src/harness/command.ts +173 -0
- package/src/harness/gemini.ts +74 -0
- package/src/harness/opencode.ts +84 -0
- package/src/harness/registry.ts +29 -0
- package/src/harness/runner.ts +19 -0
- package/src/harness/types.ts +73 -0
- package/src/output-mode.ts +32 -0
- package/src/skill/artifacts.ts +174 -0
- package/src/skill/cli.ts +29 -0
- package/src/skill/install.ts +317 -0
- package/src/spec/agent-guidance.ts +466 -0
- package/src/spec/cli.ts +151 -0
- package/src/spec/commands/check.ts +1 -0
- package/src/spec/commands/config.ts +146 -0
- package/src/spec/commands/diff.ts +1 -0
- package/src/spec/commands/generate.ts +1 -0
- package/src/spec/commands/guide.ts +1 -0
- package/src/spec/commands/harness-list.ts +36 -0
- package/src/spec/commands/implement.ts +1 -0
- package/src/spec/commands/import.ts +1 -0
- package/src/spec/commands/init.ts +1 -0
- package/src/spec/commands/status.ts +87 -0
- package/src/spec/config.ts +63 -0
- package/src/spec/diff.ts +791 -0
- package/src/spec/extensions/benchmark.ts +347 -0
- package/src/spec/extensions/registry.ts +59 -0
- package/src/spec/extensions/types.ts +56 -0
- package/src/spec/grammar/index.ts +14 -0
- package/src/spec/grammar/parser.ts +443 -0
- package/src/spec/grammar/types.ts +70 -0
- package/src/spec/grammar/validator.ts +104 -0
- package/src/spec/loader.ts +174 -0
- package/src/spec/local-config.ts +59 -0
- package/src/spec/parser.ts +226 -0
- package/src/spec/path-utils.ts +73 -0
- package/src/spec/planner.ts +299 -0
- package/src/spec/prompt-renderer.ts +318 -0
- package/src/spec/skill-content.ts +119 -0
- package/src/spec/types.ts +239 -0
- package/src/spec/validator.ts +443 -0
- package/src/spec/workflows/check.ts +1534 -0
- package/src/spec/workflows/diff.ts +209 -0
- package/src/spec/workflows/generate.ts +1270 -0
- package/src/spec/workflows/guide.ts +190 -0
- package/src/spec/workflows/implement.ts +797 -0
- package/src/spec/workflows/import.ts +986 -0
- package/src/spec/workflows/init.ts +548 -0
- package/src/spec/workflows/status.ts +22 -0
- package/src/spec/workspace.ts +541 -0
- package/uninstall.ps1 +21 -0
- package/uninstall.sh +22 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
2
|
+
import { join, relative } from "path";
|
|
3
|
+
|
|
4
|
+
import { parseSpecConfig, parseSpecDocument, parseSpecEnvironmentConfig } from "./parser";
|
|
5
|
+
import type { LoadedSpecWorkspace, ValidationIssue } from "./types";
|
|
6
|
+
import { validateEnvironmentConfig, validateLoadedSpecWorkspace, validateSpecConfig, validateSpecDocument } from "./validator";
|
|
7
|
+
import { listEffectiveWorkspaceFiles, resolveWorkspaceLayout, resolveWorkspaceRoot } from "./workspace";
|
|
8
|
+
|
|
9
|
+
function decodeOutput(bytes: Uint8Array<ArrayBufferLike>): string {
|
|
10
|
+
return new TextDecoder().decode(bytes);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function loadDirectoryFiles(path: string, predicate: (entry: string) => boolean): string[] {
|
|
14
|
+
if (!existsSync(path)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return readdirSync(path).filter(predicate).sort().map((entry) => join(path, entry));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseFileError(path: string, error: unknown): ValidationIssue {
|
|
22
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
23
|
+
return {
|
|
24
|
+
path,
|
|
25
|
+
severity: "error",
|
|
26
|
+
message,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runGitCommand(
|
|
31
|
+
workspacePath: string,
|
|
32
|
+
args: string[],
|
|
33
|
+
options: { allowFailure?: boolean } = {},
|
|
34
|
+
): { stdout: string; stderr: string; exitCode: number } {
|
|
35
|
+
const proc = Bun.spawnSync({
|
|
36
|
+
cmd: ["git", ...args],
|
|
37
|
+
cwd: workspacePath,
|
|
38
|
+
stdout: "pipe",
|
|
39
|
+
stderr: "pipe",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const stdout = decodeOutput(proc.stdout);
|
|
43
|
+
const stderr = decodeOutput(proc.stderr);
|
|
44
|
+
if (proc.exitCode !== 0 && !options.allowFailure) {
|
|
45
|
+
throw new Error(stderr.trim() || `git ${args.join(" ")} failed with exit code ${proc.exitCode}.`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
stdout,
|
|
50
|
+
stderr,
|
|
51
|
+
exitCode: proc.exitCode,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function listSpecFilesAtRef(workspacePath: string, ref: string): string[] {
|
|
56
|
+
const result = runGitCommand(workspacePath, ["ls-tree", "-r", "--name-only", ref, "--", ".specs"], { allowFailure: true });
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result.stdout
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map((entry) => entry.trim())
|
|
64
|
+
.filter((entry) => entry.length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readSpecFileAtRef(workspacePath: string, ref: string, relativePath: string): string | undefined {
|
|
68
|
+
const result = runGitCommand(workspacePath, ["show", `${ref}:${relativePath}`], { allowFailure: true });
|
|
69
|
+
return result.exitCode === 0 ? result.stdout : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function loadWorkspaceFromPaths(
|
|
73
|
+
layout: ReturnType<typeof resolveWorkspaceLayout>,
|
|
74
|
+
configPath: string,
|
|
75
|
+
documentPaths: string[],
|
|
76
|
+
environmentPaths: string[],
|
|
77
|
+
readFile: (path: string) => string | undefined,
|
|
78
|
+
): LoadedSpecWorkspace {
|
|
79
|
+
const issues: ValidationIssue[] = [];
|
|
80
|
+
let config: LoadedSpecWorkspace["config"];
|
|
81
|
+
|
|
82
|
+
const configContent = readFile(configPath);
|
|
83
|
+
if (configContent !== undefined) {
|
|
84
|
+
try {
|
|
85
|
+
config = parseSpecConfig(configPath, configContent);
|
|
86
|
+
issues.push(...validateSpecConfig(config));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
issues.push(parseFileError(configPath, error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const documents: LoadedSpecWorkspace["documents"] = [];
|
|
93
|
+
for (const filePath of documentPaths.sort()) {
|
|
94
|
+
try {
|
|
95
|
+
const content = readFile(filePath);
|
|
96
|
+
if (content === undefined) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const document = parseSpecDocument(filePath, content);
|
|
100
|
+
documents.push(document);
|
|
101
|
+
issues.push(...validateSpecDocument(document));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
issues.push(parseFileError(filePath, error));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const environments: LoadedSpecWorkspace["environments"] = [];
|
|
108
|
+
for (const filePath of environmentPaths.sort()) {
|
|
109
|
+
try {
|
|
110
|
+
const content = readFile(filePath);
|
|
111
|
+
if (content === undefined) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const environment = parseSpecEnvironmentConfig(filePath, content);
|
|
115
|
+
environments.push(environment);
|
|
116
|
+
issues.push(...validateEnvironmentConfig(environment));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
issues.push(parseFileError(filePath, error));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
issues.push(...validateLoadedSpecWorkspace({ workspacePath: layout.workspacePath, config, documents, environments }));
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
workspacePath: layout.workspacePath,
|
|
126
|
+
storageMode: layout.storageMode,
|
|
127
|
+
specRoot: layout.repoSpecRoot,
|
|
128
|
+
repoSpecRoot: layout.repoSpecRoot,
|
|
129
|
+
worktreeSpecRoot: layout.worktreeSpecRoot,
|
|
130
|
+
configPath,
|
|
131
|
+
localConfigRoot: layout.localConfigRoot,
|
|
132
|
+
runtimeRoot: layout.runtimeRoot,
|
|
133
|
+
snapshotRoot: layout.snapshotRoot,
|
|
134
|
+
metadataPath: layout.metadataPath,
|
|
135
|
+
globalPackRoots: layout.globalPackRoots,
|
|
136
|
+
config,
|
|
137
|
+
environments,
|
|
138
|
+
documents,
|
|
139
|
+
issues,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function loadSpecWorkspace(cwd = process.cwd()): LoadedSpecWorkspace {
|
|
144
|
+
const layout = resolveWorkspaceLayout(cwd);
|
|
145
|
+
const effective = listEffectiveWorkspaceFiles(layout);
|
|
146
|
+
return loadWorkspaceFromPaths(
|
|
147
|
+
layout,
|
|
148
|
+
effective.configPath ?? layout.repoConfigPath,
|
|
149
|
+
effective.documentPaths,
|
|
150
|
+
effective.environmentPaths,
|
|
151
|
+
(filePath) => (existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function loadSpecWorkspaceAtRef(ref: string, cwd = process.cwd()): LoadedSpecWorkspace {
|
|
156
|
+
const layout = resolveWorkspaceLayout(cwd);
|
|
157
|
+
if (layout.storageMode === "external") {
|
|
158
|
+
throw new Error("External Lisa workspaces do not support loading specs by git revision yet.");
|
|
159
|
+
}
|
|
160
|
+
const workspacePath = resolveWorkspaceRoot(cwd);
|
|
161
|
+
const paths = listSpecFilesAtRef(workspacePath, ref);
|
|
162
|
+
|
|
163
|
+
return loadWorkspaceFromPaths(
|
|
164
|
+
layout,
|
|
165
|
+
layout.repoConfigPath,
|
|
166
|
+
paths
|
|
167
|
+
.filter((path) => path.startsWith(".specs/backend/") || path.startsWith(".specs/frontend/"))
|
|
168
|
+
.map((path) => join(workspacePath, path)),
|
|
169
|
+
paths
|
|
170
|
+
.filter((path) => path.startsWith(".specs/environments/") && (path.endsWith(".yaml") || path.endsWith(".yml")))
|
|
171
|
+
.map((path) => join(workspacePath, path)),
|
|
172
|
+
(filePath) => readSpecFileAtRef(workspacePath, ref, relative(workspacePath, filePath).split("\\").join("/")),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface LocalConfig {
|
|
5
|
+
harness?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const LOCAL_CONFIG_FILENAME = "config.local.yaml";
|
|
9
|
+
|
|
10
|
+
export function localConfigPath(specRoot: string): string {
|
|
11
|
+
return join(specRoot, LOCAL_CONFIG_FILENAME);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadLocalConfig(specRoot: string): LocalConfig {
|
|
15
|
+
const path = localConfigPath(specRoot);
|
|
16
|
+
if (!existsSync(path)) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const raw = Bun.YAML.parse(readFileSync(path, "utf8"));
|
|
21
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const record = raw as Record<string, unknown>;
|
|
26
|
+
return {
|
|
27
|
+
harness: typeof record.harness === "string" ? record.harness : undefined,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveLocalConfig(specRoot: string, config: LocalConfig): void {
|
|
32
|
+
const path = localConfigPath(specRoot);
|
|
33
|
+
if (!config.harness) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
mkdirSync(specRoot, { recursive: true });
|
|
38
|
+
writeFileSync(path, Bun.YAML.stringify({ harness: config.harness }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resetLocalConfig(specRoot: string): boolean {
|
|
42
|
+
const path = localConfigPath(specRoot);
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { rmSync } = require("fs");
|
|
48
|
+
rmSync(path);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveLocalOverrides(specRoot: string): LocalConfig {
|
|
53
|
+
const envHarness = process.env.LISA_HARNESS;
|
|
54
|
+
const local = loadLocalConfig(specRoot);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
harness: envHarness || local.harness,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { basename } from "path";
|
|
2
|
+
|
|
3
|
+
import { resolveExtensionKindForPath } from "./extensions/registry";
|
|
4
|
+
import type {
|
|
5
|
+
ParsedAgentGuidanceConfig,
|
|
6
|
+
ParsedEnvironmentConfig,
|
|
7
|
+
ParsedHarnessConfig,
|
|
8
|
+
ParsedMarkdownSection,
|
|
9
|
+
ParsedSpecConfig,
|
|
10
|
+
ParsedSpecDocument,
|
|
11
|
+
ParsedStageProfile,
|
|
12
|
+
SpecArea,
|
|
13
|
+
} from "./types";
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeNewlines(content: string): string {
|
|
20
|
+
return content.replace(/\r\n/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseYamlObject(source: string, path: string): Record<string, unknown> {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = Bun.YAML.parse(source);
|
|
26
|
+
if (parsed === null) {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!isRecord(parsed)) {
|
|
31
|
+
throw new Error("YAML document must parse to an object.");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return parsed;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw new Error(`Failed to parse YAML in ${path}: ${message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inferSpecArea(path: string): SpecArea | undefined {
|
|
42
|
+
const match = path.match(/[\\/]\.specs[\\/](backend|frontend)[\\/]/);
|
|
43
|
+
return match?.[1] as SpecArea | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isStringArray(value: unknown): value is string[] {
|
|
47
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseStageProfile(value: unknown): ParsedStageProfile {
|
|
51
|
+
const raw = isRecord(value) ? value : {};
|
|
52
|
+
return {
|
|
53
|
+
harness: typeof raw.harness === "string" ? raw.harness : undefined,
|
|
54
|
+
model: typeof raw.model === "string" ? raw.model : undefined,
|
|
55
|
+
allowEdits: typeof raw.allow_edits === "boolean" ? raw.allow_edits : undefined,
|
|
56
|
+
raw,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseHarnessConfig(value: unknown): ParsedHarnessConfig {
|
|
61
|
+
const raw = isRecord(value) ? value : {};
|
|
62
|
+
return {
|
|
63
|
+
command: typeof raw.command === "string" ? raw.command : undefined,
|
|
64
|
+
args: isStringArray(raw.args) ? raw.args : [],
|
|
65
|
+
raw,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseAgentGuidanceConfig(value: unknown): ParsedAgentGuidanceConfig | undefined {
|
|
70
|
+
if (value === undefined) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const raw = isRecord(value) ? value : {};
|
|
75
|
+
const targets = isStringArray(raw.targets) ? raw.targets : undefined;
|
|
76
|
+
return {
|
|
77
|
+
enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined,
|
|
78
|
+
target: typeof raw.target === "string" ? raw.target : undefined,
|
|
79
|
+
targets,
|
|
80
|
+
raw,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractFrontmatter(document: string, path: string): { frontmatter: Record<string, unknown>; body: string } {
|
|
85
|
+
if (!document.startsWith("---\n")) {
|
|
86
|
+
return { frontmatter: {}, body: document.trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const match = document.match(/^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/);
|
|
90
|
+
if (!match) {
|
|
91
|
+
throw new Error(`Failed to parse YAML frontmatter in ${path}: missing closing delimiter.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
frontmatter: parseYamlObject(match[1] ?? "", path),
|
|
96
|
+
body: (match[2] ?? "").trim(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseMarkdownSections(body: string): ParsedMarkdownSection[] {
|
|
101
|
+
const normalized = normalizeNewlines(body);
|
|
102
|
+
const lines = normalized.split("\n");
|
|
103
|
+
const sections: ParsedMarkdownSection[] = [];
|
|
104
|
+
let current: { title: string; level: number; lines: string[] } | undefined;
|
|
105
|
+
let fenceState: { marker: string; length: number } | undefined;
|
|
106
|
+
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const fenceMatch = line.match(/^([`~]{3,})(.*)$/);
|
|
109
|
+
if (fenceMatch) {
|
|
110
|
+
const fence = fenceMatch[1];
|
|
111
|
+
const marker = fence[0];
|
|
112
|
+
const trailingText = fenceMatch[2].trim();
|
|
113
|
+
if (!fenceState) {
|
|
114
|
+
fenceState = { marker, length: fence.length };
|
|
115
|
+
} else if (fenceState.marker === marker && fence.length >= fenceState.length && trailingText.length === 0) {
|
|
116
|
+
fenceState = undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (current) {
|
|
120
|
+
current.lines.push(line);
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (fenceState) {
|
|
126
|
+
if (current) {
|
|
127
|
+
current.lines.push(line);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
133
|
+
if (headingMatch) {
|
|
134
|
+
if (current) {
|
|
135
|
+
sections.push({
|
|
136
|
+
title: current.title,
|
|
137
|
+
level: current.level,
|
|
138
|
+
content: current.lines.join("\n").trim(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
current = {
|
|
143
|
+
title: headingMatch[2],
|
|
144
|
+
level: headingMatch[1].length,
|
|
145
|
+
lines: [],
|
|
146
|
+
};
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (current) {
|
|
151
|
+
current.lines.push(line);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (current) {
|
|
156
|
+
sections.push({
|
|
157
|
+
title: current.title,
|
|
158
|
+
level: current.level,
|
|
159
|
+
content: current.lines.join("\n").trim(),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return sections;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function parseSpecDocument(path: string, content: string): ParsedSpecDocument {
|
|
167
|
+
const normalized = normalizeNewlines(content);
|
|
168
|
+
const { frontmatter, body } = extractFrontmatter(normalized, path);
|
|
169
|
+
const filename = basename(path);
|
|
170
|
+
const extensionKind = resolveExtensionKindForPath(filename);
|
|
171
|
+
const kind = !extensionKind ? "base" : extensionKind === "benchmark" ? "benchmark" : "extension";
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
path,
|
|
175
|
+
filename,
|
|
176
|
+
area: inferSpecArea(path),
|
|
177
|
+
kind,
|
|
178
|
+
extensionKind,
|
|
179
|
+
id: typeof frontmatter.id === "string" ? frontmatter.id : undefined,
|
|
180
|
+
status: typeof frontmatter.status === "string" ? frontmatter.status : undefined,
|
|
181
|
+
frontmatter,
|
|
182
|
+
body,
|
|
183
|
+
sections: parseMarkdownSections(body),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function parseSpecConfig(path: string, content: string): ParsedSpecConfig {
|
|
188
|
+
const raw = parseYamlObject(normalizeNewlines(content), path);
|
|
189
|
+
const defaults = isRecord(raw.default_stage_profiles) ? raw.default_stage_profiles : {};
|
|
190
|
+
const profilesRaw = isRecord(raw.profiles) ? raw.profiles : {};
|
|
191
|
+
const harnessesRaw = isRecord(raw.harnesses) ? raw.harnesses : {};
|
|
192
|
+
|
|
193
|
+
const defaultStageProfiles = Object.fromEntries(
|
|
194
|
+
Object.entries(defaults).filter(([, value]) => typeof value === "string"),
|
|
195
|
+
) as ParsedSpecConfig["defaultStageProfiles"];
|
|
196
|
+
|
|
197
|
+
const profiles = Object.fromEntries(
|
|
198
|
+
Object.entries(profilesRaw).map(([name, value]) => [name, parseStageProfile(value)]),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const harnesses = Object.fromEntries(
|
|
202
|
+
Object.entries(harnessesRaw).map(([name, value]) => [name, parseHarnessConfig(value)]),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
path,
|
|
207
|
+
raw,
|
|
208
|
+
defaultStageProfiles,
|
|
209
|
+
profiles,
|
|
210
|
+
harnesses,
|
|
211
|
+
agentGuidance: parseAgentGuidanceConfig(raw.agent_guidance),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function parseSpecEnvironmentConfig(path: string, content: string): ParsedEnvironmentConfig {
|
|
216
|
+
const raw = parseYamlObject(normalizeNewlines(content), path);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
path,
|
|
220
|
+
name: typeof raw.name === "string" ? raw.name : undefined,
|
|
221
|
+
runtime: isRecord(raw.runtime) ? raw.runtime : undefined,
|
|
222
|
+
resources: isRecord(raw.resources) ? raw.resources : undefined,
|
|
223
|
+
notes: typeof raw.notes === "string" ? raw.notes : undefined,
|
|
224
|
+
raw,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function normalizeRelativePath(path: string): string {
|
|
2
|
+
return path.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+/g, "/").replace(/^\//, "");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function hasGlobMagic(pattern: string): boolean {
|
|
6
|
+
return /[*?[]/.test(pattern);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isSafeContextPattern(pattern: string): boolean {
|
|
10
|
+
const normalized = normalizeRelativePath(pattern);
|
|
11
|
+
if (pattern.startsWith("/") || pattern.startsWith("\\") || normalized.startsWith("//") || /^[A-Za-z]:\//.test(normalized)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return !normalized.split("/").some((segment) => segment === "..");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function escapeRegExp(text: string): string {
|
|
19
|
+
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function globToRegExp(pattern: string): RegExp {
|
|
23
|
+
const normalized = normalizeRelativePath(pattern);
|
|
24
|
+
let source = "^";
|
|
25
|
+
|
|
26
|
+
for (let index = 0; index < normalized.length; index += 1) {
|
|
27
|
+
const character = normalized[index];
|
|
28
|
+
const next = normalized[index + 1];
|
|
29
|
+
|
|
30
|
+
if (character === "*") {
|
|
31
|
+
if (next === "*") {
|
|
32
|
+
if (normalized[index + 2] === "/") {
|
|
33
|
+
source += "(?:.*/)?";
|
|
34
|
+
index += 2;
|
|
35
|
+
} else {
|
|
36
|
+
source += ".*";
|
|
37
|
+
index += 1;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
source += "[^/]*";
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (character === "?") {
|
|
46
|
+
source += "[^/]";
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (character === "[") {
|
|
51
|
+
const closingIndex = normalized.indexOf("]", index + 1);
|
|
52
|
+
if (closingIndex > index + 1) {
|
|
53
|
+
let classContent = normalized.slice(index + 1, closingIndex);
|
|
54
|
+
let negate = false;
|
|
55
|
+
if (classContent.startsWith("!") || classContent.startsWith("^")) {
|
|
56
|
+
negate = true;
|
|
57
|
+
classContent = classContent.slice(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (classContent.length > 0) {
|
|
61
|
+
source += `${negate ? "[^" : "["}${classContent.replace(/\\/g, "\\\\")}]`;
|
|
62
|
+
index = closingIndex;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
source += escapeRegExp(character);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
source += "$";
|
|
72
|
+
return new RegExp(source);
|
|
73
|
+
}
|