genesis-compiler 1.0.0 → 1.2.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/.agents/plugins/marketplace.json +20 -0
- package/README.md +443 -0
- package/bin/genesis.js +15 -0
- package/docs/assurance-model.md +26 -0
- package/docs/prompt-integration.md +98 -0
- package/docs/stack-components.md +304 -0
- package/package.json +57 -7
- package/plugins/genesis/.codex-plugin/plugin.json +19 -0
- package/plugins/genesis/hooks.json +18 -0
- package/prompts/blueprint.txt +9 -0
- package/prompts/describe.txt +17 -0
- package/prompts/deslop.txt +14 -0
- package/prompts/program.txt +12 -0
- package/prompts/reconcile.txt +12 -0
- package/prompts/review.txt +12 -0
- package/prompts/start.txt +30 -0
- package/prompts/work.txt +28 -0
- package/skills/genesis-deslop/SKILL.md +36 -0
- package/skills/genesis-deslop/agents/openai.yaml +4 -0
- package/skills/genesis-program/SKILL.md +66 -0
- package/skills/genesis-program/agents/openai.yaml +4 -0
- package/skills/genesis-project/SKILL.md +53 -0
- package/skills/genesis-project/agents/openai.yaml +4 -0
- package/src/cli.js +276 -0
- package/src/index/agent-skills.js +425 -0
- package/src/index/assets.js +19 -0
- package/src/index/blueprint.js +38 -0
- package/src/index/check.js +102 -0
- package/src/index/code-index.js +283 -0
- package/src/index/code-indexers/ast-grep.js +414 -0
- package/src/index/codex-hooks.js +367 -0
- package/src/index/codex-plugin.js +73 -0
- package/src/index/context.js +137 -0
- package/src/index/environment-files.js +19 -0
- package/src/index/errors.js +26 -0
- package/src/index/git.js +26 -0
- package/src/index/init.js +48 -0
- package/src/index/launch.js +34 -0
- package/src/index/paths.js +10 -0
- package/src/index/process.js +89 -0
- package/src/index/program.js +181 -0
- package/src/index/project-files.js +24 -0
- package/src/index/project-state.js +87 -0
- package/src/index/prompt.js +347 -0
- package/src/index/stack-catalog.js +72 -0
- package/src/index/stack-command.js +65 -0
- package/src/index/stack-composition.js +38 -0
- package/src/index/stack-environment-files.js +83 -0
- package/src/index/stack-launch.js +428 -0
- package/src/index/stack-piece.js +283 -0
- package/src/index/stack-preflight.js +25 -0
- package/src/index/stack-process.js +25 -0
- package/src/index/stack-workspace-setup.js +129 -0
- package/src/index/stack.js +302 -0
- package/src/index/utils.js +85 -0
- package/src/index/verification.js +77 -0
- package/src/index/workspace-setup.js +55 -0
- package/src/index.js +102 -0
- package/stacks/pieces/cpp.md +22 -0
- package/stacks/pieces/csharp.md +22 -0
- package/stacks/pieces/go.md +22 -0
- package/stacks/pieces/java.md +22 -0
- package/stacks/pieces/jskit-mysql.md +37 -0
- package/stacks/pieces/jskit.md +70 -0
- package/stacks/pieces/kotlin.md +22 -0
- package/stacks/pieces/mysql.md +18 -0
- package/stacks/pieces/nodejs.md +25 -0
- package/stacks/pieces/php.md +23 -0
- package/stacks/pieces/python.md +23 -0
- package/stacks/pieces/ruby.md +22 -0
- package/stacks/pieces/rust.md +22 -0
- package/stacks/pieces/shell.md +23 -0
- package/stacks/pieces/vue.md +19 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { GenesisError } from './errors.js';
|
|
2
|
+
import { parseStackCommandLines } from './stack-command.js';
|
|
3
|
+
import { parseStackEnvironmentFileLines } from './stack-environment-files.js';
|
|
4
|
+
import { parseStackLaunchLines } from './stack-launch.js';
|
|
5
|
+
import { parseStackWorkspaceSetupLines } from './stack-workspace-setup.js';
|
|
6
|
+
import { normalizeSource } from './utils.js';
|
|
7
|
+
|
|
8
|
+
const STACK_PIECE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
9
|
+
const STACK_CUSTOMIZATION_TITLE = /^# Stack customization: ([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/u;
|
|
10
|
+
const STACK_CUSTOMIZATION_FIELDS = new Set(['Description', 'Guidance', 'Deslop']);
|
|
11
|
+
|
|
12
|
+
export function normalizeStackPieceId(value, {
|
|
13
|
+
code = 'STACK_PIECE_INVALID',
|
|
14
|
+
field = 'piece',
|
|
15
|
+
} = {}) {
|
|
16
|
+
const id = String(value ?? '').trim();
|
|
17
|
+
if (!STACK_PIECE_ID_PATTERN.test(id)) {
|
|
18
|
+
throw new GenesisError(
|
|
19
|
+
code,
|
|
20
|
+
`Stack ${field} must use lowercase letters, digits, and single hyphens.`,
|
|
21
|
+
{ [field]: value },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function invalid(piecePath, message, details = {}) {
|
|
28
|
+
throw new GenesisError('STACK_PIECE_INVALID', message, { path: piecePath, ...details });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sections(source) {
|
|
32
|
+
const lines = source.split('\n');
|
|
33
|
+
const result = new Map();
|
|
34
|
+
let current = null;
|
|
35
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
36
|
+
const heading = lines[index].match(/^##\s+(.+?)\s*$/u);
|
|
37
|
+
if (heading) {
|
|
38
|
+
current = heading[1];
|
|
39
|
+
if (!result.has(current)) result.set(current, []);
|
|
40
|
+
} else if (current) result.get(current).push(lines[index]);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function textSection(all, name) {
|
|
46
|
+
return (all.get(name) || []).join('\n').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function customizationSections(source, piecePath) {
|
|
50
|
+
const result = { add: {}, override: {} };
|
|
51
|
+
const seenModes = new Set();
|
|
52
|
+
let mode = null;
|
|
53
|
+
let field = null;
|
|
54
|
+
let content = [];
|
|
55
|
+
|
|
56
|
+
function finishField() {
|
|
57
|
+
if (!field) return;
|
|
58
|
+
const value = content.join('\n').trim();
|
|
59
|
+
if (!value) invalid(piecePath, `Stack customization ${mode} ${field} must not be empty.`);
|
|
60
|
+
result[mode.toLowerCase()][field.toLowerCase()] = value;
|
|
61
|
+
field = null;
|
|
62
|
+
content = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const line of source.split('\n')) {
|
|
66
|
+
if (STACK_CUSTOMIZATION_TITLE.test(line)) continue;
|
|
67
|
+
const modeHeading = line.match(/^##\s+(.+?)\s*$/u);
|
|
68
|
+
if (modeHeading) {
|
|
69
|
+
finishField();
|
|
70
|
+
if (!['Add', 'Override'].includes(modeHeading[1]) || seenModes.has(modeHeading[1])) {
|
|
71
|
+
invalid(piecePath, `Unknown or duplicate customization section: ${modeHeading[1]}.`);
|
|
72
|
+
}
|
|
73
|
+
mode = modeHeading[1];
|
|
74
|
+
seenModes.add(mode);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const fieldHeading = line.match(/^###\s+(.+?)\s*$/u);
|
|
78
|
+
if (fieldHeading) {
|
|
79
|
+
finishField();
|
|
80
|
+
if (!mode || !STACK_CUSTOMIZATION_FIELDS.has(fieldHeading[1])) {
|
|
81
|
+
invalid(piecePath, `Unknown or misplaced customization field: ${fieldHeading[1]}.`);
|
|
82
|
+
}
|
|
83
|
+
const key = fieldHeading[1].toLowerCase();
|
|
84
|
+
if (Object.hasOwn(result[mode.toLowerCase()], key)) {
|
|
85
|
+
invalid(piecePath, `Duplicate ${mode} ${fieldHeading[1]} customization.`);
|
|
86
|
+
}
|
|
87
|
+
field = fieldHeading[1];
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (/^#{1,3}\s+/u.test(line)) invalid(piecePath, `Unknown customization heading: ${line.trim()}.`);
|
|
91
|
+
if (field) content.push(line);
|
|
92
|
+
else if (line.trim()) {
|
|
93
|
+
invalid(piecePath, 'Customization content must be below Description, Guidance, or Deslop.');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finishField();
|
|
97
|
+
if (Object.values(result).every((values) => Object.keys(values).length === 0)) {
|
|
98
|
+
invalid(piecePath, 'Stack customization must add or override Description, Guidance, or Deslop.');
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function idList(all, name, piecePath, { required = false } = {}) {
|
|
104
|
+
const lines = (all.get(name) || []).filter((line) => line.trim());
|
|
105
|
+
if (lines.length === 0) {
|
|
106
|
+
if (required) invalid(piecePath, `Stack piece requires ## ${name}.`);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
if (lines.length === 1 && /^- Nothing\.[ \t]*$/u.test(lines[0])) return [];
|
|
110
|
+
const ids = lines.map((line) => {
|
|
111
|
+
const match = line.match(/^- `([a-z0-9]+(?:-[a-z0-9]+)*)`[ \t]*$/u);
|
|
112
|
+
if (!match) invalid(piecePath, `## ${name} must list backticked component ids or \`- Nothing.\`.`);
|
|
113
|
+
return match[1];
|
|
114
|
+
});
|
|
115
|
+
if (new Set(ids).size !== ids.length) invalid(piecePath, `## ${name} contains a duplicate id.`);
|
|
116
|
+
return ids;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function skill(all, piecePath) {
|
|
120
|
+
const lines = (all.get('Skill') || []).filter((line) => line.trim());
|
|
121
|
+
if (lines.length === 0) return null;
|
|
122
|
+
const values = {};
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
const match = line.match(/^- (Package|Path): `([^`\r\n]+)`[ \t]*$/u);
|
|
125
|
+
if (!match) invalid(piecePath, '## Skill entries must be Package or Path.');
|
|
126
|
+
if (match[1] === 'Package') {
|
|
127
|
+
if (values.package || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/u.test(match[2])) {
|
|
128
|
+
invalid(piecePath, '## Skill accepts one npm package name.');
|
|
129
|
+
}
|
|
130
|
+
values.package = match[2];
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
match[2].includes('\0')
|
|
135
|
+
|| match[2].includes('\\')
|
|
136
|
+
|| match[2].startsWith('/')
|
|
137
|
+
|| match[2].split('/').some((part) => !part || ['.', '..'].includes(part))
|
|
138
|
+
) {
|
|
139
|
+
invalid(piecePath, '## Skill accepts one relative directory Path.');
|
|
140
|
+
}
|
|
141
|
+
if (values.path) invalid(piecePath, '## Skill accepts one Path.');
|
|
142
|
+
values.path = match[2];
|
|
143
|
+
}
|
|
144
|
+
if (!values.path) invalid(piecePath, '## Skill requires one Path.');
|
|
145
|
+
return {
|
|
146
|
+
package: values.package || null,
|
|
147
|
+
path: values.path,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resources(all, piecePath) {
|
|
152
|
+
const source = textSection(all, 'Resources');
|
|
153
|
+
if (!source) return [];
|
|
154
|
+
const blocks = [...source.matchAll(/```json genesis-resource\s*\n([\s\S]*?)\n```/gu)];
|
|
155
|
+
const outside = source.replace(/```json genesis-resource\s*\n[\s\S]*?\n```/gu, '').trim();
|
|
156
|
+
if (outside || blocks.length === 0) {
|
|
157
|
+
invalid(piecePath, '## Resources may contain only `json genesis-resource` fenced objects.');
|
|
158
|
+
}
|
|
159
|
+
const parsed = blocks.map((match) => {
|
|
160
|
+
let resource;
|
|
161
|
+
try { resource = JSON.parse(match[1]); } catch (error) {
|
|
162
|
+
invalid(piecePath, `A Stack resource is invalid JSON: ${error.message}`);
|
|
163
|
+
}
|
|
164
|
+
if (
|
|
165
|
+
!resource
|
|
166
|
+
|| typeof resource.id !== 'string'
|
|
167
|
+
|| !STACK_PIECE_ID_PATTERN.test(resource.id)
|
|
168
|
+
|| !Array.isArray(resource.environmentAlternatives)
|
|
169
|
+
|| resource.environmentAlternatives.length === 0
|
|
170
|
+
) {
|
|
171
|
+
invalid(piecePath, 'A Stack resource needs an id and at least one environment alternative.');
|
|
172
|
+
}
|
|
173
|
+
const environmentAlternatives = resource.environmentAlternatives.map((alternative) => {
|
|
174
|
+
const required = alternative?.required;
|
|
175
|
+
const allowEmpty = alternative?.allowEmpty || [];
|
|
176
|
+
if (
|
|
177
|
+
!Array.isArray(required)
|
|
178
|
+
|| !Array.isArray(allowEmpty)
|
|
179
|
+
) {
|
|
180
|
+
invalid(piecePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
|
|
181
|
+
}
|
|
182
|
+
const names = [...required, ...allowEmpty];
|
|
183
|
+
if (
|
|
184
|
+
required.length === 0
|
|
185
|
+
|| names.some((name) => typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name))
|
|
186
|
+
|| allowEmpty.some((name) => !required.includes(name))
|
|
187
|
+
|| new Set(required).size !== required.length
|
|
188
|
+
|| new Set(allowEmpty).size !== allowEmpty.length
|
|
189
|
+
) invalid(piecePath, `Stack resource ${resource.id} has an invalid environment alternative.`);
|
|
190
|
+
return { required: [...required], allowEmpty: [...allowEmpty] };
|
|
191
|
+
});
|
|
192
|
+
return { id: resource.id, environmentAlternatives };
|
|
193
|
+
});
|
|
194
|
+
if (new Set(parsed.map(({ id }) => id)).size !== parsed.length) {
|
|
195
|
+
invalid(piecePath, '## Resources contains a duplicate resource id.');
|
|
196
|
+
}
|
|
197
|
+
return parsed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stack pieces are guidance, not a second programming language. Genesis reads
|
|
202
|
+
* only the few sections it uses and deliberately ignores explanatory sections.
|
|
203
|
+
*/
|
|
204
|
+
export function parseStackPieceSource(value, {
|
|
205
|
+
expectedId,
|
|
206
|
+
path: piecePath = 'stack piece',
|
|
207
|
+
} = {}) {
|
|
208
|
+
const source = normalizeSource(value);
|
|
209
|
+
const title = source.match(/^# Stack piece: ([a-z0-9]+(?:-[a-z0-9]+)*)\s*$/mu);
|
|
210
|
+
if (!title) invalid(piecePath, 'Stack piece needs `# Stack piece: piece-id`.');
|
|
211
|
+
const id = normalizeStackPieceId(title[1]);
|
|
212
|
+
if (expectedId && id !== expectedId) invalid(piecePath, `Stack piece id must be ${expectedId}.`);
|
|
213
|
+
const all = sections(source);
|
|
214
|
+
const description = textSection(all, 'Description');
|
|
215
|
+
const guidance = textSection(all, 'Guidance');
|
|
216
|
+
const deslop = textSection(all, 'Deslop');
|
|
217
|
+
if (!description) invalid(piecePath, 'Stack piece needs Description.');
|
|
218
|
+
const commands = parseStackCommandLines(all.get('Commands') || [], { path: piecePath });
|
|
219
|
+
const environmentFiles = parseStackEnvironmentFileLines(
|
|
220
|
+
all.has('Environment files') ? all.get('Environment files') : undefined,
|
|
221
|
+
{ path: piecePath },
|
|
222
|
+
);
|
|
223
|
+
const launch = parseStackLaunchLines(all.has('Launch') ? all.get('Launch') : undefined, {
|
|
224
|
+
path: piecePath,
|
|
225
|
+
});
|
|
226
|
+
const workspaceSetup = parseStackWorkspaceSetupLines(
|
|
227
|
+
all.has('Workspace setup') ? all.get('Workspace setup') : undefined,
|
|
228
|
+
{ path: piecePath },
|
|
229
|
+
);
|
|
230
|
+
return {
|
|
231
|
+
id,
|
|
232
|
+
description,
|
|
233
|
+
guidance,
|
|
234
|
+
requires: idList(all, 'Requires', piecePath, { required: true }),
|
|
235
|
+
conflicts: idList(all, 'Conflicts', piecePath),
|
|
236
|
+
indexers: idList(all, 'Indexers', piecePath),
|
|
237
|
+
skill: skill(all, piecePath),
|
|
238
|
+
resources: resources(all, piecePath),
|
|
239
|
+
commands: commands.map(({ line: _line, ...command }) => command),
|
|
240
|
+
environmentFiles: environmentFiles || [],
|
|
241
|
+
launchTargets: launch?.targets || [],
|
|
242
|
+
workspaceSetupSteps: workspaceSetup || [],
|
|
243
|
+
deslop,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function parseStackPieceCustomizationSource(value, {
|
|
248
|
+
expectedId,
|
|
249
|
+
path: piecePath = 'stack customization',
|
|
250
|
+
} = {}) {
|
|
251
|
+
const source = normalizeSource(value);
|
|
252
|
+
const titles = source.split('\n')
|
|
253
|
+
.map((line) => line.match(STACK_CUSTOMIZATION_TITLE))
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
if (titles.length !== 1) invalid(piecePath, 'Stack customization needs `# Stack customization: piece-id`.');
|
|
256
|
+
const id = normalizeStackPieceId(titles[0][1]);
|
|
257
|
+
if (expectedId && id !== expectedId) invalid(piecePath, `Stack customization id must be ${expectedId}.`);
|
|
258
|
+
return {
|
|
259
|
+
id,
|
|
260
|
+
...customizationSections(source, piecePath),
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function customizedText(original, override, addition) {
|
|
265
|
+
return [override ?? original, addition].filter(Boolean).join('\n\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function applyStackPieceCustomization(piece, customization) {
|
|
269
|
+
return {
|
|
270
|
+
...piece,
|
|
271
|
+
description: customizedText(
|
|
272
|
+
piece.description,
|
|
273
|
+
customization.override.description,
|
|
274
|
+
customization.add.description,
|
|
275
|
+
),
|
|
276
|
+
guidance: customizedText(
|
|
277
|
+
piece.guidance,
|
|
278
|
+
customization.override.guidance,
|
|
279
|
+
customization.add.guidance,
|
|
280
|
+
),
|
|
281
|
+
deslop: customizedText(piece.deslop, customization.override.deslop, customization.add.deslop),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function present(environment, name, allowEmpty) {
|
|
2
|
+
if (!Object.hasOwn(environment, name) || typeof environment[name] !== 'string') return false;
|
|
3
|
+
const value = environment[name].trim();
|
|
4
|
+
if (value === `$${name}` || value === `\${${name}}`) return false;
|
|
5
|
+
return value.length > 0 || allowEmpty.includes(name);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Core reports declarations; each Stack component defines their meaning. */
|
|
9
|
+
export function missingStackResources({ environment = process.env, resources = [] } = {}) {
|
|
10
|
+
if (!environment || typeof environment !== 'object' || !Array.isArray(resources)) {
|
|
11
|
+
throw new TypeError('Stack resource inspection requires an environment object and resource declarations.');
|
|
12
|
+
}
|
|
13
|
+
return resources.flatMap(({ component, resource }) => {
|
|
14
|
+
const satisfied = resource.environmentAlternatives.some(({ required, allowEmpty }) => (
|
|
15
|
+
required.every((name) => present(environment, name, allowEmpty))
|
|
16
|
+
));
|
|
17
|
+
if (satisfied) return [];
|
|
18
|
+
const alternatives = resource.environmentAlternatives.map(({ required }) => required.join(' + '));
|
|
19
|
+
return [{
|
|
20
|
+
code: 'STACK_RESOURCE_MISSING',
|
|
21
|
+
message: `Stack component ${component} requires ${resource.id}: ${alternatives.join(' OR ')}.`,
|
|
22
|
+
details: { component, resource: resource.id, alternatives },
|
|
23
|
+
}];
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export function parseBacktickedArguments(source) {
|
|
4
|
+
const matches = [...source.matchAll(/`([^`\r\n]+)`/gu)];
|
|
5
|
+
if (matches.length === 0 || matches.map((match) => match[0]).join(' ') !== source) return null;
|
|
6
|
+
return matches.map((match) => match[1]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isSafeProcessExecutable(command, { allowPlaceholders = false } = {}) {
|
|
10
|
+
return typeof command === 'string'
|
|
11
|
+
&& Boolean(command)
|
|
12
|
+
&& !/[\s\0]/u.test(command)
|
|
13
|
+
&& (allowPlaceholders || !/[{}]/u.test(command))
|
|
14
|
+
&& !path.isAbsolute(command)
|
|
15
|
+
&& !command.split(/[\\/]/u).includes('..');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isCanonicalProjectWorkdir(value) {
|
|
19
|
+
return typeof value === 'string'
|
|
20
|
+
&& Boolean(value)
|
|
21
|
+
&& !/[\0\r\n\\]/u.test(value)
|
|
22
|
+
&& !path.posix.isAbsolute(value)
|
|
23
|
+
&& path.posix.normalize(value) === value
|
|
24
|
+
&& !value.split('/').some((part) => !part || part === '..');
|
|
25
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { GenesisError } from './errors.js';
|
|
2
|
+
import {
|
|
3
|
+
isCanonicalProjectWorkdir,
|
|
4
|
+
isSafeProcessExecutable,
|
|
5
|
+
parseBacktickedArguments,
|
|
6
|
+
} from './stack-process.js';
|
|
7
|
+
|
|
8
|
+
const PREPARE_LINE = /^- Prepare `([^`\r\n]+)` with (.+?):[ \t]+(.+)$/u;
|
|
9
|
+
const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
10
|
+
const READY_WHEN_SUFFIX = /^(.*?)[ \t]+when `([^`\r\n]+)` exists$/u;
|
|
11
|
+
const WORKDIR_SUFFIX = /^(.*?)[ \t]+in `([^`\r\n]+)`$/u;
|
|
12
|
+
|
|
13
|
+
function invalid(setupPath, message, line, details = {}) {
|
|
14
|
+
throw new GenesisError('STACK_WORKSPACE_SETUP_INVALID', message, {
|
|
15
|
+
path: setupPath,
|
|
16
|
+
...(line === undefined ? {} : { line }),
|
|
17
|
+
...details,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseRuntimeRequirements(source, setupPath, line) {
|
|
22
|
+
const runtimes = parseBacktickedArguments(source);
|
|
23
|
+
if (!runtimes) {
|
|
24
|
+
invalid(
|
|
25
|
+
setupPath,
|
|
26
|
+
'Workspace setup runtimes must be separate backticked technology ids.',
|
|
27
|
+
line,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const normalized = runtimes.map((runtime) => {
|
|
31
|
+
if (!RUNTIME_ID.test(runtime)) {
|
|
32
|
+
invalid(
|
|
33
|
+
setupPath,
|
|
34
|
+
'Workspace setup runtimes must use lowercase letters, digits, and single hyphens.',
|
|
35
|
+
line,
|
|
36
|
+
{ runtime },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return runtime;
|
|
40
|
+
});
|
|
41
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
42
|
+
invalid(setupPath, 'A Workspace setup step contains a duplicate runtime.', line);
|
|
43
|
+
}
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseStackWorkspaceSetupLines(lines, {
|
|
48
|
+
path: setupPath = 'stack workspace setup',
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (lines === undefined) return null;
|
|
51
|
+
const entries = lines.map((line) => line.trim()).filter(Boolean);
|
|
52
|
+
if (entries.length === 1 && entries[0] === '- Nothing.') return [];
|
|
53
|
+
const steps = [];
|
|
54
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
55
|
+
const source = lines[index].trim();
|
|
56
|
+
if (!source) continue;
|
|
57
|
+
const line = index + 1;
|
|
58
|
+
const entry = source.match(PREPARE_LINE);
|
|
59
|
+
if (!entry) {
|
|
60
|
+
invalid(
|
|
61
|
+
setupPath,
|
|
62
|
+
'Every Workspace setup entry must use `- Prepare `label` with `runtime` [in `workdir`] [when `path` exists]: `command` `argument`...`.',
|
|
63
|
+
line,
|
|
64
|
+
{ observed: source },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const label = entry[1].trim();
|
|
68
|
+
if (!label || /[\0\r\n]/u.test(label)) {
|
|
69
|
+
invalid(setupPath, 'Workspace setup labels must be non-empty single lines.', line);
|
|
70
|
+
}
|
|
71
|
+
const readyEntry = entry[2].match(READY_WHEN_SUFFIX);
|
|
72
|
+
const setupSource = readyEntry ? readyEntry[1] : entry[2];
|
|
73
|
+
const readyWhen = readyEntry ? readyEntry[2] : '';
|
|
74
|
+
if (readyWhen && (readyWhen === '.' || !isCanonicalProjectWorkdir(readyWhen))) {
|
|
75
|
+
invalid(
|
|
76
|
+
setupPath,
|
|
77
|
+
'Workspace setup readiness paths must be canonical project-relative paths.',
|
|
78
|
+
line,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const workdirEntry = setupSource.match(WORKDIR_SUFFIX);
|
|
82
|
+
const runtimeSource = workdirEntry ? workdirEntry[1] : setupSource;
|
|
83
|
+
const workdir = workdirEntry ? workdirEntry[2] : '.';
|
|
84
|
+
if (!isCanonicalProjectWorkdir(workdir)) {
|
|
85
|
+
invalid(setupPath, 'Workspace setup workdir must be a canonical project-relative directory.', line);
|
|
86
|
+
}
|
|
87
|
+
const argv = parseBacktickedArguments(entry[3]);
|
|
88
|
+
if (!argv || argv.some((value) => value.includes('\0')) || !isSafeProcessExecutable(argv[0])) {
|
|
89
|
+
invalid(
|
|
90
|
+
setupPath,
|
|
91
|
+
'Workspace setup command and arguments must be separate non-empty backticked values with a safe executable.',
|
|
92
|
+
line,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
steps.push({
|
|
96
|
+
label,
|
|
97
|
+
argv,
|
|
98
|
+
runtimeRequirements: parseRuntimeRequirements(runtimeSource, setupPath, line),
|
|
99
|
+
workdir,
|
|
100
|
+
...(readyWhen ? { readyWhen } : {}),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (steps.length === 0) {
|
|
104
|
+
invalid(setupPath, '## Workspace setup needs at least one Prepare entry.');
|
|
105
|
+
}
|
|
106
|
+
return steps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function composeStackWorkspaceSetup(components, projectSteps) {
|
|
110
|
+
if (projectSteps !== null) return { source: 'project', steps: projectSteps, diagnostics: [] };
|
|
111
|
+
const declarations = components
|
|
112
|
+
.filter((component) => component.workspaceSetupSteps.length > 0)
|
|
113
|
+
.map((component) => ({
|
|
114
|
+
source: `component:${component.id}`,
|
|
115
|
+
steps: component.workspaceSetupSteps,
|
|
116
|
+
}));
|
|
117
|
+
if (declarations.length === 0) return { source: null, steps: [], diagnostics: [] };
|
|
118
|
+
if (declarations.length === 1) return { ...declarations[0], diagnostics: [] };
|
|
119
|
+
const sources = declarations.map(({ source }) => source).sort();
|
|
120
|
+
return {
|
|
121
|
+
source: null,
|
|
122
|
+
steps: [],
|
|
123
|
+
diagnostics: [{
|
|
124
|
+
code: 'STACK_WORKSPACE_SETUP_AMBIGUOUS',
|
|
125
|
+
message: `Selected Stack components provide competing Workspace setup recipes: ${sources.join(', ')}. Add one project ## Workspace setup section to choose the exact recipe.`,
|
|
126
|
+
details: { sources },
|
|
127
|
+
}],
|
|
128
|
+
};
|
|
129
|
+
}
|