genesis-compiler 1.2.1 → 1.2.3

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.
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
 
4
4
  import { astGrepCodeIndexers } from './code-indexers/ast-grep.js';
5
5
  import { asDiagnostic } from './errors.js';
6
+ import { buildCityPresentation } from './city-presentation.js';
7
+ import { cityPathExcluded } from './stack-city-presentation.js';
6
8
  import { gitContext } from './git.js';
7
9
  import { isProjectContentPath } from './paths.js';
8
10
  import { inspectProgram } from './program.js';
@@ -128,8 +130,14 @@ function mergeIndexedFiles(contributions) {
128
130
  };
129
131
  }
130
132
 
131
- function machineCity({ components, contributions, diagnostics, indexers }) {
133
+ function machineCity({ cityRegions, components, contributions, diagnostics, indexers }) {
132
134
  const merged = mergeIndexedFiles(contributions);
135
+ const structuralDistricts = directoryRecords(merged.files.map(({ path: filePath }) => filePath));
136
+ const presentation = buildCityPresentation({
137
+ districts: structuralDistricts,
138
+ files: merged.files,
139
+ regions: cityRegions,
140
+ });
133
141
  const visibility = new Map(merged.functions.map((entry) => [entry.id, entry.visibility]));
134
142
  const codeHash = sha256(stableJson(merged.files.map(({ path: filePath, hash, mode }) => ({
135
143
  path: filePath,
@@ -141,13 +149,19 @@ function machineCity({ components, contributions, diagnostics, indexers }) {
141
149
  schemaVersion: 1,
142
150
  status: indexers.length === 0 ? 'unconfigured' : diagnostics.length > 0 ? 'completed-with-warning' : 'current',
143
151
  codeHash,
152
+ presentationRegions: presentation.regions,
153
+ presentationCampuses: presentation.campuses,
144
154
  stackComponents: components,
145
155
  indexers,
146
156
  diagnostics,
147
157
  functions: merged.functions,
148
- districts: directoryRecords(merged.files.map(({ path: filePath }) => filePath)),
158
+ districts: structuralDistricts.map((district) => ({
159
+ ...district,
160
+ ...presentation.placementForPath(district.path),
161
+ })),
149
162
  buildings: merged.files.map((file) => ({
150
163
  ...file,
164
+ ...presentation.placementForPath(file.path),
151
165
  districtId: directoryId(parentDirectory(file.path)),
152
166
  title: path.posix.basename(file.path),
153
167
  publicFunctionCount: file.functionIds.filter((id) => visibility.get(id) === 'public').length,
@@ -229,7 +243,13 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
229
243
  const stack = await readStack(root);
230
244
  const states = await gitVisibleFileStates(root, { includePath: isProjectContentPath });
231
245
  const files = [...states]
232
- .filter(([, state]) => state.exists && !state.symlink && !state.special && state.hash)
246
+ .filter(([filePath, state]) => (
247
+ state.exists
248
+ && !state.symlink
249
+ && !state.special
250
+ && state.hash
251
+ && !cityPathExcluded(filePath, stack.cityExclusions)
252
+ ))
233
253
  .map(([filePath, state]) => ({ path: filePath, hash: state.hash, mode: state.mode }));
234
254
  const indexers = [...new Set(stack.components.flatMap((component) => component.indexers || []))].sort();
235
255
  const contributions = [];
@@ -249,6 +269,7 @@ export async function buildProjectIndex({ projectRoot, queries = [], write = tru
249
269
  }
250
270
  }
251
271
  const machine = machineCity({
272
+ cityRegions: stack.cityRegions,
252
273
  components: stack.components.map(({ id }) => id),
253
274
  contributions,
254
275
  diagnostics,
@@ -0,0 +1,31 @@
1
+ import { gitContext } from './git.js';
2
+ import { readStack } from './stack.js';
3
+ import { sha256, stableJson, uniqueSorted } from './utils.js';
4
+
5
+ /** Read the Stack's production recipe without provisioning or publishing anything. */
6
+ export async function inspectProjectDeployment({ projectRoot } = {}) {
7
+ const root = (await gitContext(projectRoot)).repositoryRoot;
8
+ const stack = await readStack(root);
9
+ const diagnostics = [...stack.deployment.diagnostics];
10
+ let status = 'unconfigured';
11
+ if (diagnostics.length > 0) status = 'blocked';
12
+ else if (stack.deployment.steps.length > 0) status = 'ready';
13
+ const recipe = {
14
+ version: stack.deployment.version,
15
+ workdir: stack.deployment.workdir,
16
+ runtimeRequirements: stack.deployment.runtimeRequirements,
17
+ readiness: stack.deployment.readiness,
18
+ steps: stack.deployment.steps,
19
+ };
20
+ return {
21
+ status,
22
+ stackHash: stack.identityHash,
23
+ recipeHash: status === 'ready' ? sha256(stableJson(recipe)) : '',
24
+ components: stack.components.map(({ id }) => id),
25
+ source: stack.deployment.source,
26
+ runtimeRequirements: uniqueSorted(stack.deployment.runtimeRequirements),
27
+ resources: stack.resources,
28
+ ...recipe,
29
+ diagnostics,
30
+ };
31
+ }
@@ -1,17 +1,24 @@
1
1
  import { gitContext } from './git.js';
2
2
  import { readStack } from './stack.js';
3
+ import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
3
4
  import { missingStackResources } from './stack-preflight.js';
4
5
 
5
- /** Inspect Stack environment requirements without returning or writing values. */
6
+ /** Inspect Stack environment requirements without returning supplied host values. */
6
7
  export async function inspectProjectEnvironment({ environment = process.env, projectRoot } = {}) {
7
8
  const root = (await gitContext(projectRoot)).repositoryRoot;
8
9
  const stack = await readStack(root);
9
- const diagnostics = missingStackResources({ environment, resources: stack.resources });
10
- const configured = stack.environmentFiles.length > 0 || stack.resources.length > 0;
10
+ const diagnostics = missingStackResources({
11
+ environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
12
+ resources: stack.resources,
13
+ });
14
+ const configured = stack.environmentDefaults.length > 0
15
+ || stack.environmentFiles.length > 0
16
+ || stack.resources.length > 0;
11
17
  return {
12
18
  status: !configured ? 'unconfigured' : diagnostics.length > 0 ? 'missing-inputs' : 'ready',
13
19
  stackHash: stack.identityHash,
14
20
  components: stack.components.map(({ id }) => id),
21
+ environmentDefaults: stack.environmentDefaults,
15
22
  files: stack.environmentFiles,
16
23
  resources: stack.resources,
17
24
  diagnostics,
package/src/index/init.js CHANGED
@@ -12,7 +12,7 @@ async function createIfMissing(projectRoot, relativePath, source) {
12
12
  const location = path.join(projectRoot, relativePath);
13
13
  await mkdir(path.dirname(location), { recursive: true });
14
14
  try {
15
- await writeFile(location, source, { flag: 'wx', mode: 0o644 });
15
+ await writeFile(location, source, { flag: 'wx', mode: 0o666 });
16
16
  return relativePath;
17
17
  } catch (error) {
18
18
  if (error?.code === 'EEXIST') return null;
@@ -1,5 +1,6 @@
1
1
  import { gitContext } from './git.js';
2
2
  import { missingStackResources } from './stack-preflight.js';
3
+ import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
3
4
  import { readStack } from './stack.js';
4
5
  import { uniqueSorted } from './utils.js';
5
6
 
@@ -10,7 +11,10 @@ export async function inspectProjectLaunch({
10
11
  } = {}) {
11
12
  const root = (await gitContext(projectRoot)).repositoryRoot;
12
13
  const stack = await readStack(root);
13
- const diagnostics = missingStackResources({ environment, resources: stack.resources });
14
+ const diagnostics = missingStackResources({
15
+ environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
16
+ resources: stack.resources,
17
+ });
14
18
  const disabledReason = diagnostics.length === 0
15
19
  ? null
16
20
  : diagnostics.map(({ message }) => message).join(' ');
@@ -26,6 +30,7 @@ export async function inspectProjectLaunch({
26
30
  status,
27
31
  stackHash: stack.identityHash,
28
32
  components: stack.components.map(({ id }) => id),
33
+ environmentDefaults: stack.environmentDefaults,
29
34
  runtimeRequirements: uniqueSorted(targets.flatMap((target) => target.runtimeRequirements)),
30
35
  resources: stack.resources,
31
36
  targets,
@@ -9,6 +9,7 @@ import { gitContext } from './git.js';
9
9
  import { inspectProgram } from './program.js';
10
10
  import { inspectVerification } from './project-state.js';
11
11
  import { missingStackResources } from './stack-preflight.js';
12
+ import { withStackEnvironmentDefaults } from './stack-environment-defaults.js';
12
13
  import { stableJson } from './utils.js';
13
14
  import { gitVisibleFileStates } from './project-files.js';
14
15
  import { isProjectContentPath } from './paths.js';
@@ -305,7 +306,10 @@ export async function generateProjectPrompt({
305
306
  buildProjectIndex({ projectRoot: root, write: false }),
306
307
  inspectProjectSkills({ projectRoot: root, stack }),
307
308
  ]);
308
- const missing = missingStackResources({ environment, resources: stack.resources });
309
+ const missing = missingStackResources({
310
+ environment: withStackEnvironmentDefaults(environment, stack.environmentDefaults),
311
+ resources: stack.resources,
312
+ });
309
313
  const warnings = [
310
314
  ...(program.diagnostic ? [program.diagnostic] : []),
311
315
  ...missing,
@@ -63,7 +63,10 @@ export async function listBuiltinStackPieces() {
63
63
  guidance: piece.guidance,
64
64
  requires: piece.requires,
65
65
  conflicts: piece.conflicts,
66
+ cityExclusions: piece.cityExclusions,
67
+ cityRegions: piece.cityRegions,
66
68
  indexers: piece.indexers,
69
+ environmentDefaults: piece.environmentDefaults,
67
70
  launchTargets: piece.launchTargets.map(({ id }) => id),
68
71
  workspaceSetup: piece.workspaceSetupSteps,
69
72
  skill: piece.skill === null ? null : piece.skill.path,
@@ -0,0 +1,178 @@
1
+ import { GenesisError } from './errors.js';
2
+
3
+ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
4
+ const IGNORE_LINE = /^- Ignore `([^`]+)`[ \t]*$/u;
5
+ const MATCH_LINE = /^- Match `([^`]+)` as `([^`]+)`: `([^`]+)\/\*\*`[ \t]*$/u;
6
+ const FALLBACK_LINE = /^- Fallback `([^`]+)` as `([^`]+)`[ \t]*$/u;
7
+ const REGEXP_SPECIAL = /[.+^${}()|[\]\\]/u;
8
+ const compiledGlobPatterns = new Map();
9
+
10
+ function invalid(piecePath, message) {
11
+ throw new GenesisError('STACK_PIECE_INVALID', message, { path: piecePath });
12
+ }
13
+
14
+ function normalizedPrefix(value, piecePath) {
15
+ const prefix = String(value || '').trim();
16
+ if (
17
+ !prefix
18
+ || prefix.startsWith('/')
19
+ || prefix.includes('\\')
20
+ || /[*?\[\]{}!]/u.test(prefix)
21
+ || prefix.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
22
+ ) {
23
+ invalid(piecePath, '## City regions match paths must be canonical project-relative prefixes.');
24
+ }
25
+ return prefix;
26
+ }
27
+
28
+ function normalizedGlob(value, piecePath) {
29
+ const pattern = String(value || '').trim();
30
+ if (
31
+ !pattern
32
+ || pattern.startsWith('/')
33
+ || pattern.includes('\\')
34
+ || pattern.includes('***')
35
+ || /[?\[\]{}!]/u.test(pattern)
36
+ || pattern.split('/').some((segment) => !segment || ['.', '..'].includes(segment))
37
+ ) {
38
+ invalid(
39
+ piecePath,
40
+ '## City regions ignore patterns must be canonical project-relative globs using only * and ** wildcards.',
41
+ );
42
+ }
43
+ return pattern;
44
+ }
45
+
46
+ function regionIdentity(id, title, piecePath) {
47
+ if (!ID_PATTERN.test(id)) {
48
+ invalid(piecePath, '## City regions ids must use lowercase letters, digits, and single hyphens.');
49
+ }
50
+ if (!title.trim()) invalid(piecePath, '## City regions titles must not be empty.');
51
+ return { id, title: title.trim() };
52
+ }
53
+
54
+ function globExpression(pattern) {
55
+ if (compiledGlobPatterns.has(pattern)) return compiledGlobPatterns.get(pattern);
56
+ let expression = '^';
57
+ for (let index = 0; index < pattern.length; index += 1) {
58
+ const character = pattern[index];
59
+ if (character === '*' && pattern[index + 1] === '*') {
60
+ if (pattern[index + 2] === '/') {
61
+ expression += '(?:[^/]+/)*';
62
+ index += 2;
63
+ } else {
64
+ expression += '.*';
65
+ index += 1;
66
+ }
67
+ } else if (character === '*') {
68
+ expression += '[^/]*';
69
+ } else {
70
+ expression += REGEXP_SPECIAL.test(character) ? `\\${character}` : character;
71
+ }
72
+ }
73
+ const compiled = new RegExp(`${expression}$`, 'u');
74
+ compiledGlobPatterns.set(pattern, compiled);
75
+ return compiled;
76
+ }
77
+
78
+ export function cityPathExcluded(filePath, exclusions = []) {
79
+ return exclusions.some((pattern) => globExpression(pattern).test(filePath));
80
+ }
81
+
82
+ /**
83
+ * A City presentation declaration is one complete, ordered contract. Ignore
84
+ * globs remove non-product files before Machine City is emitted. Matching
85
+ * prefixes are explicit Stack facts; one final fallback owns every remaining
86
+ * path. Genesis resolves both region and campus placement, so renderers never
87
+ * classify or filter repository paths.
88
+ */
89
+ export function parseStackCityPresentationLines(lines, { path = 'stack piece' } = {}) {
90
+ if (lines === undefined) return { exclusions: [], regions: [] };
91
+ const content = lines.filter((line) => line.trim());
92
+ if (content.length === 0 || (content.length === 1 && content[0].trim() === '- Nothing.')) {
93
+ return { exclusions: [], regions: [] };
94
+ }
95
+ const exclusions = [];
96
+ const regions = [];
97
+ let regionDeclarationStarted = false;
98
+ content.forEach((line, index) => {
99
+ const ignore = line.match(IGNORE_LINE);
100
+ if (ignore) {
101
+ if (regionDeclarationStarted) {
102
+ invalid(path, '## City regions Ignore entries must precede Match and Fallback entries.');
103
+ }
104
+ exclusions.push(normalizedGlob(ignore[1], path));
105
+ return;
106
+ }
107
+ regionDeclarationStarted = true;
108
+ const match = line.match(MATCH_LINE);
109
+ if (match) {
110
+ const identity = regionIdentity(match[1], match[2], path);
111
+ regions.push({
112
+ ...identity,
113
+ fallback: false,
114
+ pathPrefix: normalizedPrefix(match[3], path),
115
+ });
116
+ return;
117
+ }
118
+ const fallback = line.match(FALLBACK_LINE);
119
+ if (fallback) {
120
+ if (index !== content.length - 1) {
121
+ invalid(path, '## City regions fallback must be the final declaration.');
122
+ }
123
+ regions.push({
124
+ ...regionIdentity(fallback[1], fallback[2], path),
125
+ fallback: true,
126
+ pathPrefix: null,
127
+ });
128
+ return;
129
+ }
130
+ invalid(
131
+ path,
132
+ '## City regions entries must use Ignore with a glob, Match with an id, title, and path/**, or one final Fallback.',
133
+ );
134
+ });
135
+ if (new Set(exclusions).size !== exclusions.length) {
136
+ invalid(path, '## City regions contains a duplicate ignore pattern.');
137
+ }
138
+ if (new Set(regions.map(({ id }) => id)).size !== regions.length) {
139
+ invalid(path, '## City regions contains a duplicate region id.');
140
+ }
141
+ const matches = regions.filter(({ fallback }) => !fallback);
142
+ if (new Set(matches.map(({ pathPrefix }) => pathPrefix)).size !== matches.length) {
143
+ invalid(path, '## City regions contains a duplicate path prefix.');
144
+ }
145
+ if (regions.filter(({ fallback }) => fallback).length !== 1) {
146
+ invalid(path, '## City regions requires exactly one final fallback.');
147
+ }
148
+ for (const [index, left] of matches.entries()) {
149
+ if (matches.slice(index + 1).some((right) => (
150
+ left.pathPrefix.startsWith(`${right.pathPrefix}/`)
151
+ || right.pathPrefix.startsWith(`${left.pathPrefix}/`)
152
+ ))) {
153
+ invalid(path, '## City regions match prefixes must not overlap.');
154
+ }
155
+ }
156
+ return { exclusions, regions };
157
+ }
158
+
159
+ export function composeStackCityPresentation(components = []) {
160
+ const owners = components.filter((component) => (
161
+ component.cityRegions.length > 0 || component.cityExclusions.length > 0
162
+ ));
163
+ if (owners.length === 0) return { exclusions: [], regions: [] };
164
+ if (owners.length > 1) {
165
+ throw new GenesisError(
166
+ 'STACK_CITY_PRESENTATION_AMBIGUOUS',
167
+ `Stack components ${owners.map(({ id }) => id).join(', ')} declare competing City presentation contracts.`,
168
+ { components: owners.map(({ id }) => id) },
169
+ );
170
+ }
171
+ return {
172
+ exclusions: [...owners[0].cityExclusions],
173
+ regions: owners[0].cityRegions.map((region) => ({
174
+ ...region,
175
+ component: owners[0].id,
176
+ })),
177
+ };
178
+ }
@@ -0,0 +1,204 @@
1
+ import { GenesisError } from './errors.js';
2
+ import {
3
+ isCanonicalProjectWorkdir,
4
+ isSafeProcessExecutable,
5
+ parseBacktickedArguments,
6
+ } from './stack-process.js';
7
+
8
+ const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
9
+ const STEP_LINE = /^- (Prepare|Build|Migrate|Serve) `([^`\r\n]+)`:[ \t]+(.+)$/u;
10
+ const STEP_ROLE_ORDER = Object.freeze({
11
+ prepare: 0,
12
+ build: 1,
13
+ migrate: 2,
14
+ serve: 3,
15
+ });
16
+
17
+ function invalid(deploymentPath, message, line, details = {}) {
18
+ throw new GenesisError('STACK_DEPLOYMENT_INVALID', message, {
19
+ path: deploymentPath,
20
+ ...(line === undefined ? {} : { line }),
21
+ ...details,
22
+ });
23
+ }
24
+
25
+ function oneToken(source, prefix, deploymentPath, label, line) {
26
+ const values = parseBacktickedArguments(source.slice(prefix.length));
27
+ if (!values || values.length !== 1) {
28
+ invalid(deploymentPath, `${label} accepts exactly one backticked value.`, line);
29
+ }
30
+ return values[0];
31
+ }
32
+
33
+ function parseRuntimes(source, deploymentPath, line) {
34
+ const values = parseBacktickedArguments(source.slice('- Runtimes: '.length));
35
+ if (!values || values.some((value) => !RUNTIME_ID.test(value))) {
36
+ invalid(
37
+ deploymentPath,
38
+ 'Deployment runtimes must be separate backticked technology ids.',
39
+ line,
40
+ );
41
+ }
42
+ if (new Set(values).size !== values.length) {
43
+ invalid(deploymentPath, 'Deployment contains a duplicate runtime.', line);
44
+ }
45
+ return values;
46
+ }
47
+
48
+ function parseUrlPath(value, deploymentPath, line) {
49
+ const normalized = String(value || '').trim();
50
+ if (!normalized.startsWith('/') || normalized.startsWith('//') || /[\\?#]/u.test(normalized)) {
51
+ invalid(
52
+ deploymentPath,
53
+ 'Deployment readiness path must begin with one slash and contain no query or fragment.',
54
+ line,
55
+ );
56
+ }
57
+ return normalized;
58
+ }
59
+
60
+ function parseReadiness(source, deploymentPath, line) {
61
+ const match = source.match(/^- Ready when: `GET` `([^`\r\n]+)` returns `([0-9]{3})`$/u);
62
+ if (!match) {
63
+ invalid(
64
+ deploymentPath,
65
+ 'Deployment readiness must use `- Ready when: `GET` `/path` returns `200``.',
66
+ line,
67
+ );
68
+ }
69
+ const status = Number(match[2]);
70
+ if (!Number.isInteger(status) || status < 200 || status > 399) {
71
+ invalid(deploymentPath, 'Deployment readiness status must be from 200 through 399.', line);
72
+ }
73
+ return {
74
+ kind: 'http',
75
+ method: 'GET',
76
+ path: parseUrlPath(match[1], deploymentPath, line),
77
+ status,
78
+ };
79
+ }
80
+
81
+ function parseStep(match, deploymentPath, line) {
82
+ const label = match[2].trim();
83
+ const argv = parseBacktickedArguments(match[3]);
84
+ if (!label || /[\0\r\n]/u.test(label)) {
85
+ invalid(deploymentPath, 'Deployment step labels must be non-empty single lines.', line);
86
+ }
87
+ if (!argv || argv.some((value) => value.includes('\0')) || !isSafeProcessExecutable(argv[0])) {
88
+ invalid(
89
+ deploymentPath,
90
+ 'Deployment command and arguments must be separate non-empty backticked values with a safe executable.',
91
+ line,
92
+ );
93
+ }
94
+ return {
95
+ label,
96
+ argv,
97
+ role: match[1].toLowerCase(),
98
+ };
99
+ }
100
+
101
+ export function parseStackDeploymentLines(lines, {
102
+ path: deploymentPath = 'stack deployment',
103
+ } = {}) {
104
+ if (lines === undefined) return null;
105
+ const entries = lines.map((line) => line.trim()).filter(Boolean);
106
+ if (entries.length === 1 && entries[0] === '- Nothing.') return { version: 1, steps: [] };
107
+
108
+ const result = {
109
+ version: 1,
110
+ workdir: '.',
111
+ runtimeRequirements: [],
112
+ readiness: null,
113
+ steps: [],
114
+ };
115
+ const seen = new Set();
116
+ for (let index = 0; index < lines.length; index += 1) {
117
+ const source = lines[index].trim();
118
+ if (!source) continue;
119
+ const line = index + 1;
120
+ if (source.startsWith('- Workdir: ')) {
121
+ if (seen.has('workdir')) invalid(deploymentPath, 'Duplicate Deployment Workdir.', line);
122
+ seen.add('workdir');
123
+ const workdir = oneToken(source, '- Workdir: ', deploymentPath, 'Deployment Workdir', line);
124
+ if (!isCanonicalProjectWorkdir(workdir)) {
125
+ invalid(deploymentPath, 'Deployment workdir must be a canonical project-relative directory.', line);
126
+ }
127
+ result.workdir = workdir;
128
+ continue;
129
+ }
130
+ if (source.startsWith('- Runtimes: ')) {
131
+ if (seen.has('runtimes')) invalid(deploymentPath, 'Duplicate Deployment Runtimes.', line);
132
+ seen.add('runtimes');
133
+ result.runtimeRequirements = parseRuntimes(source, deploymentPath, line);
134
+ continue;
135
+ }
136
+ if (source.startsWith('- Ready when: ')) {
137
+ if (seen.has('readiness')) invalid(deploymentPath, 'Duplicate Deployment Ready when.', line);
138
+ seen.add('readiness');
139
+ result.readiness = parseReadiness(source, deploymentPath, line);
140
+ continue;
141
+ }
142
+ const step = source.match(STEP_LINE);
143
+ if (step) {
144
+ result.steps.push(parseStep(step, deploymentPath, line));
145
+ continue;
146
+ }
147
+ invalid(deploymentPath, `Unknown Deployment entry: ${source}.`, line);
148
+ }
149
+ if (result.steps.length === 0) {
150
+ invalid(deploymentPath, '## Deployment needs at least one command or exactly `- Nothing.`.');
151
+ }
152
+ const serveSteps = result.steps.filter(({ role }) => role === 'serve');
153
+ if (serveSteps.length !== 1 || result.steps.at(-1).role !== 'serve') {
154
+ invalid(deploymentPath, 'Deployment needs exactly one final Serve step.');
155
+ }
156
+ for (let index = 1; index < result.steps.length; index += 1) {
157
+ if (STEP_ROLE_ORDER[result.steps[index].role] < STEP_ROLE_ORDER[result.steps[index - 1].role]) {
158
+ invalid(
159
+ deploymentPath,
160
+ 'Deployment steps must be ordered Prepare, Build, Migrate, then Serve.',
161
+ );
162
+ }
163
+ }
164
+ if (!result.readiness) {
165
+ invalid(deploymentPath, 'Deployment needs one Ready when entry.');
166
+ }
167
+ return result;
168
+ }
169
+
170
+ export function composeStackDeployment(components, projectDeployment) {
171
+ if (projectDeployment) return { ...projectDeployment, source: 'project', diagnostics: [] };
172
+ const declarations = components
173
+ .filter((component) => component.deployment?.steps?.length > 0)
174
+ .map((component) => ({
175
+ ...component.deployment,
176
+ source: `component:${component.id}`,
177
+ }));
178
+ if (declarations.length === 0) {
179
+ return {
180
+ version: 1,
181
+ workdir: '.',
182
+ runtimeRequirements: [],
183
+ readiness: null,
184
+ steps: [],
185
+ source: null,
186
+ diagnostics: [],
187
+ };
188
+ }
189
+ if (declarations.length === 1) return { ...declarations[0], diagnostics: [] };
190
+ const sources = declarations.map(({ source }) => source).sort();
191
+ return {
192
+ version: 1,
193
+ workdir: '.',
194
+ runtimeRequirements: [],
195
+ readiness: null,
196
+ steps: [],
197
+ source: null,
198
+ diagnostics: [{
199
+ code: 'STACK_DEPLOYMENT_AMBIGUOUS',
200
+ message: `Selected Stack components provide competing Deployment recipes: ${sources.join(', ')}. Add one project ## Deployment section to choose the exact recipe.`,
201
+ details: { sources },
202
+ }],
203
+ };
204
+ }
@@ -0,0 +1,79 @@
1
+ import { GenesisError } from './errors.js';
2
+
3
+ const DEFAULT_LINE = /^- Default `([A-Za-z_][A-Za-z0-9_]*)`: `([^`\r\n]+)`[ \t]*$/u;
4
+
5
+ function invalid(defaultsPath, message, line, details = {}) {
6
+ throw new GenesisError('STACK_ENVIRONMENT_DEFAULTS_INVALID', message, {
7
+ path: defaultsPath,
8
+ ...(line === undefined ? {} : { line }),
9
+ ...details,
10
+ });
11
+ }
12
+
13
+ export function parseStackEnvironmentDefaultLines(lines, {
14
+ path: defaultsPath = 'stack environment defaults',
15
+ } = {}) {
16
+ if (lines === undefined) return [];
17
+ const defaults = [];
18
+ for (let index = 0; index < lines.length; index += 1) {
19
+ const source = lines[index].trim();
20
+ if (!source) continue;
21
+ const entry = source.match(DEFAULT_LINE);
22
+ if (!entry) {
23
+ invalid(
24
+ defaultsPath,
25
+ 'Every Environment defaults entry must be a Default bullet with a backticked name and value.',
26
+ index + 1,
27
+ { observed: source },
28
+ );
29
+ }
30
+ defaults.push({ name: entry[1], value: entry[2] });
31
+ }
32
+ if (defaults.length === 0) {
33
+ invalid(defaultsPath, '## Environment defaults needs at least one Default entry.');
34
+ }
35
+ if (new Set(defaults.map(({ name }) => name)).size !== defaults.length) {
36
+ invalid(defaultsPath, '## Environment defaults contains a duplicate name.');
37
+ }
38
+ return defaults;
39
+ }
40
+
41
+ export function composeStackEnvironmentDefaults(components) {
42
+ const defaults = new Map();
43
+ for (const component of components) {
44
+ for (const declaration of component.environmentDefaults) {
45
+ const existing = defaults.get(declaration.name);
46
+ if (existing && existing.value !== declaration.value) {
47
+ invalid(
48
+ `component:${component.id}`,
49
+ `Stack components declare incompatible defaults for ${declaration.name}.`,
50
+ undefined,
51
+ {
52
+ name: declaration.name,
53
+ sources: [...existing.sources, `component:${component.id}`],
54
+ },
55
+ );
56
+ }
57
+ if (existing) {
58
+ existing.sources.push(`component:${component.id}`);
59
+ } else {
60
+ defaults.set(declaration.name, {
61
+ ...declaration,
62
+ sources: [`component:${component.id}`],
63
+ });
64
+ }
65
+ }
66
+ }
67
+ return [...defaults.values()].sort((left, right) => left.name.localeCompare(right.name));
68
+ }
69
+
70
+ /** Apply public Stack constants beneath explicit host or user environment. */
71
+ export function withStackEnvironmentDefaults(environment, defaults = []) {
72
+ if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
73
+ throw new TypeError('Stack environment resolution requires an environment object.');
74
+ }
75
+ return {
76
+ ...Object.fromEntries(defaults.map(({ name, value }) => [name, value])),
77
+ ...environment,
78
+ };
79
+ }