pi-gsd 2.0.1 → 2.0.2
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/dist/pi-gsd-hooks.js +1532 -0
- package/package.json +3 -5
- package/.gsd/extensions/pi-gsd-hooks.ts +0 -973
- package/src/cli.ts +0 -644
- package/src/commands/base.ts +0 -67
- package/src/commands/commit.ts +0 -22
- package/src/commands/config.ts +0 -71
- package/src/commands/frontmatter.ts +0 -51
- package/src/commands/index.ts +0 -76
- package/src/commands/init.ts +0 -43
- package/src/commands/milestone.ts +0 -37
- package/src/commands/phase.ts +0 -92
- package/src/commands/progress.ts +0 -71
- package/src/commands/roadmap.ts +0 -40
- package/src/commands/scaffold.ts +0 -19
- package/src/commands/state.ts +0 -102
- package/src/commands/template.ts +0 -52
- package/src/commands/verify.ts +0 -70
- package/src/commands/workstream.ts +0 -98
- package/src/commands/wxp.ts +0 -65
- package/src/lib/commands.ts +0 -1040
- package/src/lib/config.ts +0 -385
- package/src/lib/core.ts +0 -1167
- package/src/lib/frontmatter.ts +0 -462
- package/src/lib/init.ts +0 -517
- package/src/lib/milestone.ts +0 -290
- package/src/lib/model-profiles.ts +0 -272
- package/src/lib/phase.ts +0 -1012
- package/src/lib/profile-output.ts +0 -237
- package/src/lib/profile-pipeline.ts +0 -556
- package/src/lib/roadmap.ts +0 -378
- package/src/lib/schemas.ts +0 -290
- package/src/lib/security.ts +0 -176
- package/src/lib/state.ts +0 -1175
- package/src/lib/template.ts +0 -246
- package/src/lib/uat.ts +0 -289
- package/src/lib/verify.ts +0 -879
- package/src/lib/workstream.ts +0 -524
- package/src/output.ts +0 -45
- package/src/schemas/pi-gsd-settings.schema.json +0 -80
- package/src/schemas/wxp.xsd +0 -619
- package/src/schemas/wxp.zod.ts +0 -318
- package/src/wxp/__tests__/arguments.test.ts +0 -86
- package/src/wxp/__tests__/conditions.test.ts +0 -106
- package/src/wxp/__tests__/executor.test.ts +0 -95
- package/src/wxp/__tests__/helpers.ts +0 -26
- package/src/wxp/__tests__/integration.test.ts +0 -166
- package/src/wxp/__tests__/new-features.test.ts +0 -222
- package/src/wxp/__tests__/parser.test.ts +0 -159
- package/src/wxp/__tests__/paste.test.ts +0 -66
- package/src/wxp/__tests__/schema.test.ts +0 -120
- package/src/wxp/__tests__/security.test.ts +0 -87
- package/src/wxp/__tests__/shell.test.ts +0 -85
- package/src/wxp/__tests__/string-ops.test.ts +0 -25
- package/src/wxp/__tests__/variables.test.ts +0 -65
- package/src/wxp/arguments.ts +0 -89
- package/src/wxp/conditions.ts +0 -78
- package/src/wxp/executor.ts +0 -191
- package/src/wxp/index.ts +0 -191
- package/src/wxp/parser.ts +0 -198
- package/src/wxp/paste.ts +0 -51
- package/src/wxp/security.ts +0 -102
- package/src/wxp/shell.ts +0 -81
- package/src/wxp/string-ops.ts +0 -44
- package/src/wxp/variables.ts +0 -109
package/src/wxp/executor.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { executeShell, WxpShellError, resolveArgNode } from "./shell.js";
|
|
4
|
-
import { executeStringOp } from "./string-ops.js";
|
|
5
|
-
import { evaluateCondition, evaluateWhere, evaluateCondExprNode, CONDITION_OPS } from "./conditions.js";
|
|
6
|
-
import { resolveTrustedEntry } from "./security.js";
|
|
7
|
-
import type { XmlNode, WxpExecContext } from "../schemas/wxp.zod.js";
|
|
8
|
-
import type { VariableStore } from "./variables.js";
|
|
9
|
-
|
|
10
|
-
export class WxpExecutionError extends Error {
|
|
11
|
-
constructor(
|
|
12
|
-
public readonly cause: Error,
|
|
13
|
-
public readonly variableSnapshot: Record<string, string>,
|
|
14
|
-
message: string,
|
|
15
|
-
) {
|
|
16
|
-
super(message);
|
|
17
|
-
this.name = "WxpExecutionError";
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
// ─── <display> ───────────────────────────────────────────────────────────────
|
|
22
|
-
|
|
23
|
-
function execDisplay(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
|
|
24
|
-
const msg = (node.attrs["msg"] ?? "").replace(
|
|
25
|
-
/\{([^}]+)\}/g,
|
|
26
|
-
(_, name: string) => vars.resolve(name) ?? "",
|
|
27
|
-
);
|
|
28
|
-
const level = node.attrs["level"];
|
|
29
|
-
ctx.onDisplay(msg, level === "warning" || level === "error" ? level : "info");
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ─── <json-parse> ────────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
function execJsonParse(node: XmlNode, vars: VariableStore): void {
|
|
35
|
-
const src = node.attrs["src"] ?? "";
|
|
36
|
-
const out = node.attrs["out"] ?? "";
|
|
37
|
-
const pathStr = node.attrs["path"];
|
|
38
|
-
|
|
39
|
-
const jsonStr = vars.get(src);
|
|
40
|
-
if (jsonStr === undefined) throw new Error(`<json-parse>: source variable '${src}' is not defined`);
|
|
41
|
-
|
|
42
|
-
let parsed: unknown;
|
|
43
|
-
try { parsed = JSON.parse(jsonStr); } catch {
|
|
44
|
-
throw new Error(`<json-parse>: '${src}' does not contain valid JSON`);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (pathStr) {
|
|
48
|
-
const parts = pathStr.replace(/^\$\.?/, "").split(".");
|
|
49
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- JSON traversal
|
|
50
|
-
let cur: any = parsed;
|
|
51
|
-
for (const key of parts) {
|
|
52
|
-
if (cur === null || typeof cur !== "object") throw new Error(`<json-parse>: path '${pathStr}' not found`);
|
|
53
|
-
cur = cur[key];
|
|
54
|
-
}
|
|
55
|
-
parsed = cur;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (Array.isArray(parsed)) {
|
|
59
|
-
vars.setArray(out, parsed.map((item) => typeof item === "string" ? item : JSON.stringify(item)));
|
|
60
|
-
} else if (parsed !== null && typeof parsed === "object") {
|
|
61
|
-
vars.set(out, JSON.stringify(parsed), undefined);
|
|
62
|
-
} else {
|
|
63
|
-
vars.set(out, parsed === undefined || parsed === null ? "" : String(parsed), undefined);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ─── <read-file> ─────────────────────────────────────────────────────────────
|
|
68
|
-
|
|
69
|
-
function execReadFile(node: XmlNode, vars: VariableStore): void {
|
|
70
|
-
const filePath = node.attrs["path"] ?? "";
|
|
71
|
-
const out = node.attrs["out"] ?? "";
|
|
72
|
-
const content = fs.readFileSync(path.resolve(filePath), "utf8");
|
|
73
|
-
vars.set(out, content, undefined);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ─── <write-file> ────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
function execWriteFile(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
|
|
79
|
-
const filePath = node.attrs["path"] ?? "";
|
|
80
|
-
const src = node.attrs["src"] ?? "";
|
|
81
|
-
const resolved = path.resolve(filePath);
|
|
82
|
-
|
|
83
|
-
// Create-only: never overwrite
|
|
84
|
-
if (fs.existsSync(resolved)) {
|
|
85
|
-
throw new Error(`<write-file>: '${filePath}' already exists (create-only, never overwrites)`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Reject writes targeting trusted harness paths
|
|
89
|
-
for (const entry of ctx.config.trustedPaths) {
|
|
90
|
-
const abs = resolveTrustedEntry(entry, ctx.projectRoot, ctx.pkgRoot);
|
|
91
|
-
if (resolved.startsWith(abs + path.sep) || resolved === abs) {
|
|
92
|
-
throw new Error(`<write-file>: cannot write to trusted harness path '${filePath}'`);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const content = vars.get(src) ?? "";
|
|
97
|
-
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
98
|
-
fs.writeFileSync(resolved, content, "utf8");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ─── <for-each> ──────────────────────────────────────────────────────────────
|
|
102
|
-
|
|
103
|
-
function execForEach(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
|
|
104
|
-
const varName = node.attrs["var"] ?? "";
|
|
105
|
-
const itemName = node.attrs["item"] ?? "";
|
|
106
|
-
|
|
107
|
-
const whereNode = node.children.find((c) => c.tag === "where");
|
|
108
|
-
const sortByNode = node.children.find((c) => c.tag === "sort-by");
|
|
109
|
-
const bodyNodes = node.children.filter((c) => c.tag !== "where" && c.tag !== "sort-by");
|
|
110
|
-
|
|
111
|
-
let items = vars.getArray(varName);
|
|
112
|
-
if (!items) return; // Missing array is not an error — may be conditional
|
|
113
|
-
|
|
114
|
-
// Filter
|
|
115
|
-
if (whereNode) {
|
|
116
|
-
items = items.filter((itemJson) => {
|
|
117
|
-
vars.set(itemName, itemJson, undefined);
|
|
118
|
-
return evaluateWhere(whereNode, vars);
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Sort
|
|
123
|
-
if (sortByNode) {
|
|
124
|
-
const key = sortByNode.attrs["key"] ?? "";
|
|
125
|
-
const type = sortByNode.attrs["type"] ?? "string";
|
|
126
|
-
const order = sortByNode.attrs["order"] ?? "asc";
|
|
127
|
-
|
|
128
|
-
items = [...items].sort((aJson, bJson) => {
|
|
129
|
-
vars.set(itemName, aJson, undefined);
|
|
130
|
-
const aVal = vars.resolve(`${itemName}.${key}`) ?? vars.resolve(key) ?? "";
|
|
131
|
-
vars.set(itemName, bJson, undefined);
|
|
132
|
-
const bVal = vars.resolve(`${itemName}.${key}`) ?? vars.resolve(key) ?? "";
|
|
133
|
-
|
|
134
|
-
const cmp = type === "number"
|
|
135
|
-
? Number(aVal) - Number(bVal)
|
|
136
|
-
: aVal.localeCompare(bVal);
|
|
137
|
-
return order === "desc" ? -cmp : cmp;
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
for (const itemJson of items) {
|
|
142
|
-
vars.set(itemName, itemJson, undefined);
|
|
143
|
-
for (const child of bodyNodes) executeNode(child, vars, ctx);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
export function executeNode(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
|
|
150
|
-
switch (node.tag) {
|
|
151
|
-
case "shell": executeShell(node, vars, ctx.config); break;
|
|
152
|
-
case "string-op": executeStringOp(node, vars); break;
|
|
153
|
-
case "json-parse": execJsonParse(node, vars); break;
|
|
154
|
-
case "read-file": execReadFile(node, vars); break;
|
|
155
|
-
case "write-file": execWriteFile(node, vars, ctx); break;
|
|
156
|
-
case "display": execDisplay(node, vars, ctx); break;
|
|
157
|
-
case "for-each": execForEach(node, vars, ctx); break;
|
|
158
|
-
|
|
159
|
-
case "if": {
|
|
160
|
-
const branch = evaluateCondition(node, vars);
|
|
161
|
-
const thenNode = node.children.find((c) => c.tag === "then");
|
|
162
|
-
const elseNode = node.children.find((c) => c.tag === "else");
|
|
163
|
-
const taken = branch ? thenNode : elseNode;
|
|
164
|
-
if (taken) for (const child of taken.children) executeNode(child, vars, ctx);
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
case "gsd-execute":
|
|
169
|
-
executeBlock(node, vars, ctx);
|
|
170
|
-
break;
|
|
171
|
-
|
|
172
|
-
default:
|
|
173
|
-
// paste, arguments, include, version — handled by resolution loop in index.ts
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Execute all children of a container node (<gsd-execute>, <then>, <else>). */
|
|
179
|
-
export function executeBlock(node: XmlNode, vars: VariableStore, ctx: WxpExecContext): void {
|
|
180
|
-
try {
|
|
181
|
-
for (const child of node.children) executeNode(child, vars, ctx);
|
|
182
|
-
} catch (err) {
|
|
183
|
-
if (err instanceof WxpShellError || err instanceof Error) {
|
|
184
|
-
throw new WxpExecutionError(err, vars.snapshot(), `Execution failed: ${err.message}`);
|
|
185
|
-
}
|
|
186
|
-
throw err;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Re-export for conditions used outside executor (index.ts for-each where clause)
|
|
191
|
-
export { evaluateCondExprNode, CONDITION_OPS };
|
package/src/wxp/index.ts
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { extractWxpTags, spliceContent, extractCodeFenceRegions, inDeadZone } from "./parser.js";
|
|
4
|
-
import { createVariableStore } from "./variables.js";
|
|
5
|
-
import { parseArguments } from "./arguments.js";
|
|
6
|
-
import { executeBlock } from "./executor.js";
|
|
7
|
-
import { applyPaste, WxpPasteError } from "./paste.js";
|
|
8
|
-
import { checkTrustedPath } from "./security.js";
|
|
9
|
-
import type {
|
|
10
|
-
WxpSecurityConfig,
|
|
11
|
-
WxpExecContext,
|
|
12
|
-
DisplayLevel,
|
|
13
|
-
DisplayCallback,
|
|
14
|
-
} from "../schemas/wxp.zod.js";
|
|
15
|
-
|
|
16
|
-
export { WxpExecutionError } from "./executor.js";
|
|
17
|
-
export { WxpShellError } from "./shell.js";
|
|
18
|
-
export { WxpPasteError } from "./paste.js";
|
|
19
|
-
export { WxpStringOpError } from "./string-ops.js";
|
|
20
|
-
export { WxpArgumentsError } from "./arguments.js";
|
|
21
|
-
export type { DisplayCallback, DisplayLevel, WxpExecContext };
|
|
22
|
-
|
|
23
|
-
const MAX_ITERATIONS = 50;
|
|
24
|
-
const NOOP_DISPLAY: DisplayCallback = () => {};
|
|
25
|
-
|
|
26
|
-
export class WxpProcessingError extends Error {
|
|
27
|
-
constructor(
|
|
28
|
-
public readonly filePath: string,
|
|
29
|
-
public readonly cause: Error,
|
|
30
|
-
public readonly variableSnapshot: Record<string, string>,
|
|
31
|
-
public readonly pendingOperations: string[],
|
|
32
|
-
public readonly completedOperations: string[],
|
|
33
|
-
) {
|
|
34
|
-
super(
|
|
35
|
-
[
|
|
36
|
-
`WXP Processing Error`,
|
|
37
|
-
`File: ${filePath}`,
|
|
38
|
-
`Error: ${cause.message}`,
|
|
39
|
-
`Variable Namespace: ${JSON.stringify(variableSnapshot, null, 2)}`,
|
|
40
|
-
`Pending Operations: [${pendingOperations.join(", ")}]`,
|
|
41
|
-
`Completed Operations: [${completedOperations.join(", ")}]`,
|
|
42
|
-
].join("\n"),
|
|
43
|
-
);
|
|
44
|
-
this.name = "WxpProcessingError";
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function processWxp(
|
|
49
|
-
content: string,
|
|
50
|
-
filePath: string,
|
|
51
|
-
config: WxpSecurityConfig,
|
|
52
|
-
projectRoot: string,
|
|
53
|
-
pkgRoot: string,
|
|
54
|
-
rawArguments = "",
|
|
55
|
-
onDisplay: DisplayCallback = NOOP_DISPLAY,
|
|
56
|
-
): string {
|
|
57
|
-
const pathCheck = checkTrustedPath(filePath, config, projectRoot, pkgRoot);
|
|
58
|
-
if (!pathCheck.ok) {
|
|
59
|
-
throw new WxpProcessingError(filePath, new Error(pathCheck.reason), {}, [], []);
|
|
60
|
-
}
|
|
61
|
-
return runLoop(content, filePath, config, projectRoot, pkgRoot, rawArguments, onDisplay);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function processWxpTrustedContent(
|
|
65
|
-
content: string,
|
|
66
|
-
virtualFilePath: string,
|
|
67
|
-
config: WxpSecurityConfig,
|
|
68
|
-
projectRoot: string,
|
|
69
|
-
pkgRoot: string,
|
|
70
|
-
rawArguments = "",
|
|
71
|
-
onDisplay: DisplayCallback = NOOP_DISPLAY,
|
|
72
|
-
): string {
|
|
73
|
-
const trusted: WxpSecurityConfig = {
|
|
74
|
-
...config,
|
|
75
|
-
trustedPaths: [
|
|
76
|
-
...config.trustedPaths,
|
|
77
|
-
{ position: "absolute", path: path.dirname(path.resolve(virtualFilePath)) },
|
|
78
|
-
],
|
|
79
|
-
};
|
|
80
|
-
return runLoop(content, virtualFilePath, trusted, projectRoot, pkgRoot, rawArguments, onDisplay);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function runLoop(
|
|
84
|
-
content: string,
|
|
85
|
-
filePath: string,
|
|
86
|
-
config: WxpSecurityConfig,
|
|
87
|
-
projectRoot: string,
|
|
88
|
-
pkgRoot: string,
|
|
89
|
-
rawArguments: string,
|
|
90
|
-
onDisplay: DisplayCallback,
|
|
91
|
-
): string {
|
|
92
|
-
const vars = createVariableStore();
|
|
93
|
-
const done: string[] = [];
|
|
94
|
-
let current = content;
|
|
95
|
-
|
|
96
|
-
const ctx: WxpExecContext = { config, projectRoot, pkgRoot, onDisplay };
|
|
97
|
-
|
|
98
|
-
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
99
|
-
const tags = extractWxpTags(current);
|
|
100
|
-
const active = tags.filter((t) => t.node.tag !== "gsd-version");
|
|
101
|
-
if (active.length === 0) break;
|
|
102
|
-
|
|
103
|
-
const pending = active.map((t) => t.node.tag);
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
let progress = false;
|
|
107
|
-
|
|
108
|
-
// 1. <gsd-include>
|
|
109
|
-
for (const tag of extractWxpTags(current)) {
|
|
110
|
-
if (tag.node.tag !== "gsd-include") continue;
|
|
111
|
-
if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
|
|
112
|
-
|
|
113
|
-
const incPath = tag.node.attrs["path"];
|
|
114
|
-
if (!incPath) continue;
|
|
115
|
-
|
|
116
|
-
const abs = path.resolve(path.dirname(filePath), incPath);
|
|
117
|
-
const check = checkTrustedPath(abs, config, projectRoot, pkgRoot);
|
|
118
|
-
if (!check.ok) throw new Error(`Include rejected: ${check.reason}`);
|
|
119
|
-
|
|
120
|
-
const included = fs.readFileSync(abs, "utf8");
|
|
121
|
-
const stem = path.basename(abs, path.extname(abs));
|
|
122
|
-
|
|
123
|
-
// INC-02: arg mappings from <gsd-arguments> child
|
|
124
|
-
for (const child of tag.node.children) {
|
|
125
|
-
if (child.tag !== "gsd-arguments") continue;
|
|
126
|
-
for (const arg of child.children.filter((c) => c.tag === "arg")) {
|
|
127
|
-
const from = arg.attrs["name"];
|
|
128
|
-
const to = arg.attrs["as"];
|
|
129
|
-
if (from && to) {
|
|
130
|
-
const val = vars.get(from);
|
|
131
|
-
if (val !== undefined) vars.set(to, val, stem);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const appendArgs = "include-arguments" in tag.node.attrs ? `\n${rawArguments}` : "";
|
|
137
|
-
current = spliceContent(current, tag.start, tag.end, included + appendArgs);
|
|
138
|
-
done.push("gsd-include");
|
|
139
|
-
progress = true;
|
|
140
|
-
break;
|
|
141
|
-
}
|
|
142
|
-
if (progress) continue;
|
|
143
|
-
|
|
144
|
-
// 2. <gsd-arguments>
|
|
145
|
-
for (const tag of extractWxpTags(current)) {
|
|
146
|
-
if (tag.node.tag !== "gsd-arguments") continue;
|
|
147
|
-
if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
|
|
148
|
-
|
|
149
|
-
parseArguments(tag.node, rawArguments, vars);
|
|
150
|
-
current = spliceContent(current, tag.start, tag.end, "");
|
|
151
|
-
done.push("gsd-arguments");
|
|
152
|
-
progress = true;
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
if (progress) continue;
|
|
156
|
-
|
|
157
|
-
// 3. <gsd-execute>
|
|
158
|
-
for (const tag of extractWxpTags(current)) {
|
|
159
|
-
if (tag.node.tag !== "gsd-execute") continue;
|
|
160
|
-
if (inDeadZone(tag.start, extractCodeFenceRegions(current))) continue;
|
|
161
|
-
|
|
162
|
-
executeBlock(tag.node, vars, ctx);
|
|
163
|
-
current = spliceContent(current, tag.start, tag.end, "");
|
|
164
|
-
done.push("gsd-execute");
|
|
165
|
-
progress = true;
|
|
166
|
-
break;
|
|
167
|
-
}
|
|
168
|
-
if (progress) continue;
|
|
169
|
-
|
|
170
|
-
// 4. <gsd-paste>
|
|
171
|
-
const after = applyPaste(current, vars);
|
|
172
|
-
if (after !== current) { current = after; done.push("gsd-paste"); continue; }
|
|
173
|
-
|
|
174
|
-
break; // no progress
|
|
175
|
-
} catch (err) {
|
|
176
|
-
if (err instanceof WxpProcessingError) throw err;
|
|
177
|
-
const e = err instanceof Error ? err : new Error(String(err));
|
|
178
|
-
throw new WxpProcessingError(filePath, e, vars.snapshot(), pending, done);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return current;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function readWorkflowVersionTag(
|
|
186
|
-
content: string,
|
|
187
|
-
): { version: string; doNotUpdate: boolean } | null {
|
|
188
|
-
const m = /<gsd-version\s+v="([^"]+)"(\s+do-not-update)?\s*\/>/.exec(content);
|
|
189
|
-
if (!m) return null;
|
|
190
|
-
return { version: m[1], doNotUpdate: Boolean(m[2]) };
|
|
191
|
-
}
|
package/src/wxp/parser.ts
DELETED
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* parser.ts — Recursive-descent XML parser for WXP markdown documents.
|
|
3
|
-
*
|
|
4
|
-
* Parses WXP tags embedded in markdown. Code-fence regions are dead zones
|
|
5
|
-
* where no tags are processed (WXP-01).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { XmlNode } from "../schemas/wxp.zod.js";
|
|
9
|
-
|
|
10
|
-
// ─── Code-fence skip ──────────────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
export function extractCodeFenceRegions(content: string): Array<[number, number]> {
|
|
13
|
-
const regions: Array<[number, number]> = [];
|
|
14
|
-
const re = /^```[^\n]*\n[\s\S]*?^```/gm;
|
|
15
|
-
let m: RegExpExecArray | null;
|
|
16
|
-
while ((m = re.exec(content)) !== null) {
|
|
17
|
-
regions.push([m.index, m.index + m[0].length]);
|
|
18
|
-
}
|
|
19
|
-
return regions;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function inDeadZone(pos: number, regions: Array<[number, number]>): boolean {
|
|
23
|
-
return regions.some(([s, e]) => pos >= s && pos < e);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ─── Attribute parser ─────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Parse XML attribute string into a Record.
|
|
30
|
-
* Handles: key="value", key='value', bare-key (boolean attribute)
|
|
31
|
-
*/
|
|
32
|
-
export function parseAttrs(raw: string): Record<string, string> {
|
|
33
|
-
const attrs: Record<string, string> = {};
|
|
34
|
-
// Match key="val", key='val', or bare key
|
|
35
|
-
const re = /([a-zA-Z0-9_:-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s/>]*)))?/g;
|
|
36
|
-
let m: RegExpExecArray | null;
|
|
37
|
-
while ((m = re.exec(raw)) !== null) {
|
|
38
|
-
const key = m[1];
|
|
39
|
-
const val = m[2] ?? m[3] ?? m[4] ?? ""; // empty string = boolean attribute
|
|
40
|
-
attrs[key] = val;
|
|
41
|
-
}
|
|
42
|
-
return attrs;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// ─── Recursive XML tokeniser ──────────────────────────────────────────────────
|
|
46
|
-
|
|
47
|
-
interface ParseResult {
|
|
48
|
-
node: XmlNode;
|
|
49
|
-
end: number; // index in content after closing tag
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Parse a single XML element starting at `pos` in `content`.
|
|
54
|
-
* Uses a proper attribute-aware regex so self-closing `/>` is correctly detected.
|
|
55
|
-
*/
|
|
56
|
-
function parseElement(content: string, pos: number): ParseResult | null {
|
|
57
|
-
if (content[pos] !== "<") return null;
|
|
58
|
-
|
|
59
|
-
// Proper attribute pattern: each attr is name or name=value (quoted or unquoted)
|
|
60
|
-
// This ensures the `/` in `/>` is NOT consumed by the attrs group.
|
|
61
|
-
const tagRe = /^<([a-zA-Z0-9_:-]+)((?:\s+[a-zA-Z0-9_:-]+(?:=(?:"[^"]*"|'[^']*'|[^\s/>]*))?)*)?\s*(\/??>)/;
|
|
62
|
-
const slice = content.slice(pos);
|
|
63
|
-
const m = tagRe.exec(slice);
|
|
64
|
-
if (!m) return null;
|
|
65
|
-
|
|
66
|
-
const tag = m[1];
|
|
67
|
-
const rawAttrs = (m[2] ?? "").trim();
|
|
68
|
-
const closing = m[3];
|
|
69
|
-
const attrs = parseAttrs(rawAttrs);
|
|
70
|
-
|
|
71
|
-
if (closing === "/>") {
|
|
72
|
-
// Self-closing
|
|
73
|
-
return {
|
|
74
|
-
node: { tag, attrs, children: [], selfClosing: true },
|
|
75
|
-
end: pos + m[0].length,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Opening tag — find matching closing tag, handling nesting
|
|
80
|
-
let cursor = pos + m[0].length;
|
|
81
|
-
const children: XmlNode[] = [];
|
|
82
|
-
const closeTag = `</${tag}>`;
|
|
83
|
-
|
|
84
|
-
while (cursor < content.length) {
|
|
85
|
-
// Look for next < to check if it's a child element or closing tag
|
|
86
|
-
const nextOpen = content.indexOf("<", cursor);
|
|
87
|
-
if (nextOpen === -1) break;
|
|
88
|
-
|
|
89
|
-
// Check for closing tag
|
|
90
|
-
if (content.startsWith(closeTag, nextOpen)) {
|
|
91
|
-
return {
|
|
92
|
-
node: { tag, attrs, children, selfClosing: false },
|
|
93
|
-
end: nextOpen + closeTag.length,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Check for comment <!-- ... -->
|
|
98
|
-
if (content.startsWith("<!--", nextOpen)) {
|
|
99
|
-
const commentEnd = content.indexOf("-->", nextOpen + 4);
|
|
100
|
-
cursor = commentEnd !== -1 ? commentEnd + 3 : content.length;
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Try to parse child element
|
|
105
|
-
const child = parseElement(content, nextOpen);
|
|
106
|
-
if (child) {
|
|
107
|
-
children.push(child.node);
|
|
108
|
-
cursor = child.end;
|
|
109
|
-
} else {
|
|
110
|
-
cursor = nextOpen + 1;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Unclosed tag — return what we have
|
|
115
|
-
return {
|
|
116
|
-
node: { tag, attrs, children, selfClosing: false },
|
|
117
|
-
end: cursor,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ─── Top-level WXP tag extraction ────────────────────────────────────────────
|
|
122
|
-
|
|
123
|
-
const WXP_TOP_TAGS = new Set([
|
|
124
|
-
"gsd-execute",
|
|
125
|
-
"gsd-arguments",
|
|
126
|
-
"gsd-paste",
|
|
127
|
-
"gsd-include",
|
|
128
|
-
"gsd-version",
|
|
129
|
-
]);
|
|
130
|
-
|
|
131
|
-
export interface WxpTagMatch {
|
|
132
|
-
node: XmlNode;
|
|
133
|
-
/** Start index in original content */
|
|
134
|
-
start: number;
|
|
135
|
-
/** End index (exclusive) in original content */
|
|
136
|
-
end: number;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Extract all top-level WXP tags from markdown content.
|
|
141
|
-
* Skips code-fence regions.
|
|
142
|
-
* Returns matches in document order.
|
|
143
|
-
*/
|
|
144
|
-
export function extractWxpTags(content: string): WxpTagMatch[] {
|
|
145
|
-
const deadZones = extractCodeFenceRegions(content);
|
|
146
|
-
const matches: WxpTagMatch[] = [];
|
|
147
|
-
|
|
148
|
-
// Scan for < characters that could start a WXP tag
|
|
149
|
-
const tagStartRe = /<(gsd-[a-zA-Z0-9_-]+)/g;
|
|
150
|
-
let m: RegExpExecArray | null;
|
|
151
|
-
|
|
152
|
-
while ((m = tagStartRe.exec(content)) !== null) {
|
|
153
|
-
const pos = m.index;
|
|
154
|
-
if (inDeadZone(pos, deadZones)) continue;
|
|
155
|
-
|
|
156
|
-
const tagName = m[1];
|
|
157
|
-
if (!WXP_TOP_TAGS.has(tagName)) continue;
|
|
158
|
-
|
|
159
|
-
const result = parseElement(content, pos);
|
|
160
|
-
if (!result) continue;
|
|
161
|
-
|
|
162
|
-
matches.push({ node: result.node, start: pos, end: result.end });
|
|
163
|
-
// Advance regex past this element to avoid re-scanning its contents
|
|
164
|
-
tagStartRe.lastIndex = result.end;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return matches;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// ─── Specific node extractors ─────────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
/** Replace a WXP tag span in content with a replacement string. */
|
|
173
|
-
export function spliceContent(
|
|
174
|
-
content: string,
|
|
175
|
-
start: number,
|
|
176
|
-
end: number,
|
|
177
|
-
replacement: string,
|
|
178
|
-
): string {
|
|
179
|
-
return content.slice(0, start) + replacement + content.slice(end);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** Remove all unprocessed WXP tags from content (final strip step). */
|
|
183
|
-
export function stripWxpTags(content: string): string {
|
|
184
|
-
const deadZones = extractCodeFenceRegions(content);
|
|
185
|
-
const tags = extractWxpTags(content);
|
|
186
|
-
|
|
187
|
-
// Strip right-to-left to preserve indices
|
|
188
|
-
let result = content;
|
|
189
|
-
let offset = 0;
|
|
190
|
-
for (const tag of tags) {
|
|
191
|
-
if (inDeadZone(tag.start, deadZones)) continue;
|
|
192
|
-
const adjustedStart = tag.start + offset;
|
|
193
|
-
const adjustedEnd = tag.end + offset;
|
|
194
|
-
result = result.slice(0, adjustedStart) + result.slice(adjustedEnd);
|
|
195
|
-
offset += -(tag.end - tag.start);
|
|
196
|
-
}
|
|
197
|
-
return result;
|
|
198
|
-
}
|
package/src/wxp/paste.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { extractCodeFenceRegions, inDeadZone } from "./parser.js";
|
|
2
|
-
import type { VariableStore } from "./variables.js";
|
|
3
|
-
|
|
4
|
-
export class WxpPasteError extends Error {
|
|
5
|
-
constructor(
|
|
6
|
-
public readonly variableName: string,
|
|
7
|
-
public readonly variableSnapshot: Record<string, string>,
|
|
8
|
-
) {
|
|
9
|
-
super(
|
|
10
|
-
`<gsd-paste name="${variableName}" /> references undefined variable '${variableName}'`,
|
|
11
|
-
);
|
|
12
|
-
this.name = "WxpPasteError";
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Replace all <gsd-paste name="X" /> tags in content with variable values.
|
|
18
|
-
* - Tags inside code fences are NOT replaced.
|
|
19
|
-
* - If any referenced variable is undefined → throws WxpPasteError (no partial output).
|
|
20
|
-
* - Replacement is right-to-left to preserve indices.
|
|
21
|
-
*/
|
|
22
|
-
export function applyPaste(content: string, vars: VariableStore): string {
|
|
23
|
-
const deadZones = extractCodeFenceRegions(content);
|
|
24
|
-
const pasteRe = /<gsd-paste\s+name="([^"]+)"\s*\/>/g;
|
|
25
|
-
|
|
26
|
-
const matches: Array<{ index: number; full: string; name: string }> = [];
|
|
27
|
-
let m: RegExpExecArray | null;
|
|
28
|
-
while ((m = pasteRe.exec(content)) !== null) {
|
|
29
|
-
if (!inDeadZone(m.index, deadZones)) {
|
|
30
|
-
matches.push({ index: m.index, full: m[0], name: m[1] });
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Validate all before replacing anything (atomic — no partial output)
|
|
35
|
-
for (const match of matches) {
|
|
36
|
-
if (vars.get(match.name) === undefined) {
|
|
37
|
-
throw new WxpPasteError(match.name, vars.snapshot());
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Replace right-to-left
|
|
42
|
-
let result = content;
|
|
43
|
-
for (let i = matches.length - 1; i >= 0; i--) {
|
|
44
|
-
const match = matches[i];
|
|
45
|
-
const value = vars.get(match.name) as string;
|
|
46
|
-
result =
|
|
47
|
-
result.slice(0, match.index) + value + result.slice(match.index + match.full.length);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return result;
|
|
51
|
-
}
|