genesis-compiler 1.5.2 → 1.7.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.
@@ -1,18 +1,10 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
- import bashLanguage from '@ast-grep/lang-bash';
5
- import cLanguage from '@ast-grep/lang-c';
6
- import cppLanguage from '@ast-grep/lang-cpp';
7
- import csharpLanguage from '@ast-grep/lang-csharp';
8
- import goLanguage from '@ast-grep/lang-go';
9
- import javaLanguage from '@ast-grep/lang-java';
10
- import kotlinLanguage from '@ast-grep/lang-kotlin';
11
- import phpLanguage from '@ast-grep/lang-php';
12
- import pythonLanguage from '@ast-grep/lang-python';
13
- import rubyLanguage from '@ast-grep/lang-ruby';
14
- import rustLanguage from '@ast-grep/lang-rust';
15
- import { parseAsync, registerDynamicLanguage } from '@ast-grep/napi';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { asDiagnostic } from '../errors.js';
6
+ import { runProcess } from '../process.js';
7
+ import { parserAbortSignal, prepareCodeParsers } from './parsers.js';
16
8
 
17
9
  const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
18
10
  const OPENING_PARAMETER_DELIMITERS = '([{<';
@@ -22,20 +14,6 @@ const EXCLUDED_DIRECTORIES = new Set([
22
14
  'node_modules', 'storage', 'target', 'vendor',
23
15
  ]);
24
16
 
25
- registerDynamicLanguage({
26
- bash: bashLanguage,
27
- c: cLanguage,
28
- cpp: cppLanguage,
29
- csharp: csharpLanguage,
30
- go: goLanguage,
31
- java: javaLanguage,
32
- kotlin: kotlinLanguage,
33
- php: phpLanguage,
34
- python: pythonLanguage,
35
- ruby: rubyLanguage,
36
- rust: rustLanguage,
37
- });
38
-
39
17
  function javascriptParser(filePath) {
40
18
  if (/\.tsx$/u.test(filePath)) return 'Tsx';
41
19
  if (/\.ts$/u.test(filePath)) return 'TypeScript';
@@ -350,7 +328,7 @@ function createIndexer(id) {
350
328
  const definition = DEFINITIONS[id];
351
329
  return {
352
330
  id,
353
- async extract({ files, projectRoot }) {
331
+ async extract({ files, projectRoot, parseAsync, parserErrors = {} }) {
354
332
  const indexedFiles = [];
355
333
  const diagnostics = [];
356
334
  for (const file of files.filter((candidate) => isCandidate(candidate, definition))) {
@@ -377,6 +355,10 @@ function createIndexer(id) {
377
355
  const functions = [];
378
356
  for (const unit of scriptUnits(source, file.path, definition)) {
379
357
  try {
358
+ if (parserErrors[unit.parser]) {
359
+ diagnostics.push({ ...parserErrors[unit.parser], path: file.path });
360
+ continue;
361
+ }
380
362
  const parsed = await parseAsync(unit.parser, unit.source);
381
363
  const root = parsed.root();
382
364
  functions.push(...functionsInTree(root, definition, {
@@ -386,8 +368,9 @@ function createIndexer(id) {
386
368
  }));
387
369
  diagnostics.push(...syntaxDiagnostics(root, file.path, unit.lineOffset));
388
370
  } catch (error) {
371
+ if (error.code === 'PROCESS_ABORTED') throw error;
389
372
  diagnostics.push({
390
- code: 'CODE_INDEX_PARSE_FAILED',
373
+ code: error.code?.startsWith('PARSER_') ? error.code : 'CODE_INDEX_PARSE_FAILED',
391
374
  message: error.message,
392
375
  path: file.path,
393
376
  });
@@ -412,3 +395,32 @@ function createIndexer(id) {
412
395
  export const astGrepCodeIndexers = new Map(
413
396
  Object.keys(DEFINITIONS).map((id) => [id, createIndexer(id)]),
414
397
  );
398
+
399
+ // ast-grep permits one registration per process. A finite indexing child loads
400
+ // this request's languages once and releases every native parser when it exits.
401
+ export async function extractCodeIndexes({ indexers, files, projectRoot }) {
402
+ const selected = indexers.filter(id => astGrepCodeIndexers.has(id));
403
+ const diagnostics = indexers.filter(id => !astGrepCodeIndexers.has(id)).map(id => ({
404
+ code: 'CODE_INDEXER_UNAVAILABLE', message: `No installed code indexer exists for ${id}.`,
405
+ }));
406
+ const languages = selected.flatMap(id => files.filter(file => isCandidate(file, DEFINITIONS[id]))
407
+ .map(file => typeof DEFINITIONS[id].parser === 'function'
408
+ ? DEFINITIONS[id].parser(file.path) : DEFINITIONS[id].parser));
409
+ if (languages.length === 0) return { contributions: [], diagnostics };
410
+ const { registrations, errors } = await prepareCodeParsers(languages);
411
+ let contributions;
412
+ try {
413
+ const result = await runProcess(process.execPath, [fileURLToPath(new URL('./worker.js', import.meta.url))], {
414
+ input: JSON.stringify({ indexers: selected, files, projectRoot, registrations, errors }),
415
+ signal: parserAbortSignal(), timeoutMs: 180_000, code: 'CODE_INDEXER_FAILED',
416
+ });
417
+ contributions = JSON.parse(result.stdout.toString('utf8'));
418
+ } catch (error) {
419
+ if (error.code === 'PROCESS_ABORTED') throw error;
420
+ return { contributions: [], diagnostics: [...diagnostics, {
421
+ ...asDiagnostic(error), code: 'CODE_INDEXER_FAILED',
422
+ }] };
423
+ }
424
+ return { contributions, diagnostics: [...diagnostics, ...contributions.flatMap(value =>
425
+ value.diagnostics.map(diagnostic => ({ ...diagnostic, extractor: value.extractor }))) ] };
426
+ }
@@ -0,0 +1,231 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { access, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import { GenesisError, fail } from '../errors.js';
8
+ import { runProcess } from '../process.js';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const PACKAGES = Object.freeze({
12
+ bash: ['0.0.8', 'function example() { echo hello; }'],
13
+ c: ['0.0.6', 'int example(void) { return 1; }'],
14
+ cpp: ['0.0.6', 'int example() { return 1; }'],
15
+ csharp: ['0.0.6', 'class Example { public int Run() { return 1; } }'],
16
+ go: ['0.0.6', 'package example\nfunc Example() int { return 1 }'],
17
+ java: ['0.0.7', 'class Example { public int run() { return 1; } }'],
18
+ kotlin: ['0.0.7', 'fun example(): Int { return 1 }'],
19
+ php: ['0.0.7', '<?php function example() { return 1; }'],
20
+ python: ['0.0.6', 'def example():\n return 1\n'],
21
+ ruby: ['0.0.7', 'def example\n 1\nend'],
22
+ rust: ['0.0.7', 'fn example() -> i32 { 1 }'],
23
+ });
24
+ const installations = new Map();
25
+ const cancellation = new AsyncLocalStorage();
26
+
27
+ export function parserAbortSignal() {
28
+ return cancellation.getStore();
29
+ }
30
+
31
+ export function withParserCancellation(signal, operation) {
32
+ return cancellation.run(signal, operation);
33
+ }
34
+
35
+ function parserDefinition(language) {
36
+ if (language === 'javascript') return { language, package: '@ast-grep/napi', version: require('@ast-grep/napi/package.json').version, sample: 'function example() { return 1; }' };
37
+ if (!Object.hasOwn(PACKAGES, language)) {
38
+ fail('PARSER_UNKNOWN', `Unknown parser: ${language}. Run genesis parsers list.`);
39
+ }
40
+ const [version, sample] = PACKAGES[language];
41
+ return { language, package: `@ast-grep/lang-${language}`, version, sample };
42
+ }
43
+
44
+ function parserRoot(directory, environment = process.env) {
45
+ const root = directory || environment.GENESIS_PARSER_ROOT
46
+ || path.join(environment.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'genesis', 'parsers');
47
+ if (!path.isAbsolute(root)) fail('PARSER_ROOT_INVALID', 'The parser directory must be an absolute path.');
48
+ return root;
49
+ }
50
+
51
+ export function parserEnvironment({ directory, autoInstall, environment = process.env } = {}) {
52
+ const automatic = autoInstall === undefined ? environment.GENESIS_PARSER_AUTO_INSTALL ?? '1' : autoInstall ? '1' : '0';
53
+ if (!['0', '1'].includes(automatic)) fail('PARSER_POLICY_INVALID', 'GENESIS_PARSER_AUTO_INSTALL must be 0 or 1.');
54
+ return { GENESIS_PARSER_ROOT: parserRoot(directory, environment), GENESIS_PARSER_AUTO_INSTALL: automatic };
55
+ }
56
+
57
+ function parserLocation(definition, root) {
58
+ return path.join(root, definition.language, definition.version, `${process.platform}-${process.arch}`);
59
+ }
60
+
61
+ async function installedParser(definition, directory) {
62
+ if (definition.language === 'javascript') return { entry: '', packageRoot: null };
63
+ try {
64
+ await access(directory);
65
+ } catch (error) {
66
+ if (error.code === 'ENOENT') return null;
67
+ throw error;
68
+ }
69
+ const packageRoot = path.join(directory, 'node_modules', definition.package);
70
+ try {
71
+ const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8'));
72
+ if (manifest.name !== definition.package || manifest.version !== definition.version) {
73
+ throw new Error(`Expected ${definition.package}@${definition.version}.`);
74
+ }
75
+ const entry = path.join(packageRoot, 'index.js');
76
+ await access(entry);
77
+ return { entry, packageRoot };
78
+ } catch (error) {
79
+ throw new GenesisError('PARSER_INSTALL_INVALID', `Parser installation is invalid at ${directory}: ${error.message}`, { directory });
80
+ }
81
+ }
82
+
83
+ // Validate in a child so its native library is closed before moving the install,
84
+ // and preparing all languages never loads them into the hosting process.
85
+ async function verifyParser(definition, installation, signal) {
86
+ const source = `
87
+ const { createRequire } = await import('node:module');
88
+ const require = createRequire(import.meta.url);
89
+ const [core, entry, language, sample] = process.argv.slice(1);
90
+ const { registerDynamicLanguage, parseAsync } = require(core);
91
+ const definition = entry ? require(entry) : null;
92
+ if (definition) registerDynamicLanguage({ [language]: definition });
93
+ const root = (await parseAsync(definition ? language : 'JavaScript', sample)).root();
94
+ if (root.findAll({ rule: { kind: 'ERROR' } }).length) throw new Error('Parser smoke check failed.');
95
+ process.stdout.write(definition?.libraryPath || '');
96
+ `;
97
+ const result = await runProcess(process.execPath, [
98
+ '--input-type=module', '-e', source,
99
+ require.resolve('@ast-grep/napi'), installation.entry, definition.language, definition.sample,
100
+ ], { signal, timeoutMs: 30_000, code: 'PARSER_VERIFY_FAILED' });
101
+ return result.stdout.toString('utf8');
102
+ }
103
+
104
+ async function installParser(definition, root, { signal, onProgress, environment = process.env }) {
105
+ const destination = parserLocation(definition, root);
106
+ const existing = await installedParser(definition, destination);
107
+ if (existing) return existing;
108
+ onProgress?.(`Preparing ${definition.language} code indexing (${definition.package}@${definition.version})…`);
109
+ await mkdir(path.dirname(destination), { recursive: true });
110
+ const staging = await mkdtemp(`${destination}.install-`);
111
+ try {
112
+ await writeFile(path.join(staging, 'package.json'), JSON.stringify({ private: true }));
113
+ // Windows cannot spawn npm.cmd without a shell; invoke npm's JavaScript
114
+ // entry with Node so cache paths are never interpreted as shell code.
115
+ const npmCli = process.platform === 'win32'
116
+ ? environment.npm_execpath || path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js')
117
+ : null;
118
+ await runProcess(npmCli ? process.execPath : 'npm', [
119
+ ...(npmCli ? [npmCli] : []),
120
+ 'install', '--prefix', staging, '--ignore-scripts', '--no-audit', '--no-fund',
121
+ '--omit=dev', '--omit=optional', '--package-lock=false', '--save-exact',
122
+ '--registry=https://registry.npmjs.org', `${definition.package}@${definition.version}`,
123
+ ], { cwd: staging, env: environment, signal, timeoutMs: 180_000, code: 'PARSER_INSTALL_FAILED' });
124
+ const installation = await installedParser(definition, staging);
125
+ const libraryPath = await verifyParser(definition, installation, signal);
126
+ const prebuilds = path.join(installation.packageRoot, 'prebuilds');
127
+ const selected = path.dirname(libraryPath);
128
+ if (path.dirname(selected) !== prebuilds) {
129
+ fail('PARSER_PLATFORM_UNSUPPORTED', `No packaged parser binary is available for ${process.platform}-${process.arch}.`);
130
+ }
131
+ // The prepared cache is for this host. Keep upstream metadata and loader,
132
+ // but omit build sources and binaries for other operating systems.
133
+ await rm(path.join(installation.packageRoot, 'src'), { recursive: true, force: true });
134
+ for (const entry of await readdir(prebuilds)) {
135
+ if (path.join(prebuilds, entry) !== selected) {
136
+ await rm(path.join(prebuilds, entry), { recursive: true, force: true });
137
+ }
138
+ }
139
+ await verifyParser(definition, installation, signal);
140
+ try {
141
+ await rename(staging, destination);
142
+ } catch (error) {
143
+ // Concurrent processes may prepare the same immutable version. Only a
144
+ // complete, independently verified directory can win the atomic rename.
145
+ if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error;
146
+ await installedParser(definition, destination);
147
+ }
148
+ return await installedParser(definition, destination);
149
+ } finally {
150
+ await rm(staging, { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ function ensureInstalled(definition, root, options) {
155
+ const key = parserLocation(definition, root);
156
+ if (!installations.has(key)) {
157
+ const pending = installParser(definition, root, options).finally(() => installations.delete(key));
158
+ installations.set(key, pending);
159
+ }
160
+ return installations.get(key);
161
+ }
162
+
163
+ function selectedParsers(languages, all) {
164
+ if (all && languages.length) fail('PARSER_SELECTION_INVALID', 'Choose parser names or --all, not both.');
165
+ if (!all && !languages.length) fail('PARSER_SELECTION_REQUIRED', 'Name a parser or use --all.');
166
+ return [...new Set(all ? ['javascript', ...Object.keys(PACKAGES)] : languages)].map(parserDefinition);
167
+ }
168
+
169
+ export async function listParsers({ directory, environment = process.env } = {}) {
170
+ const root = parserRoot(directory, environment);
171
+ const parsers = [{ language: 'javascript', status: 'built-in', package: '@ast-grep/napi' }];
172
+ for (const language of Object.keys(PACKAGES)) {
173
+ const definition = parserDefinition(language);
174
+ try {
175
+ const installed = await installedParser(definition, parserLocation(definition, root));
176
+ parsers.push({ language, package: definition.package, version: definition.version, status: installed ? 'installed' : 'missing' });
177
+ } catch (error) {
178
+ parsers.push({ language, package: definition.package, version: definition.version, status: 'invalid', message: error.message });
179
+ }
180
+ }
181
+ return { status: 'ready', directory: root, parsers };
182
+ }
183
+
184
+ export async function installParsers({ languages = [], all = false, directory, environment = process.env, signal, onProgress } = {}) {
185
+ const definitions = selectedParsers(languages, all);
186
+ const root = parserRoot(directory, environment);
187
+ for (const definition of definitions) {
188
+ const installation = await ensureInstalled(definition, root, { signal, onProgress, environment });
189
+ await verifyParser(definition, installation, signal);
190
+ }
191
+ return { status: 'ready', directory: root, languages: definitions.map(({ language }) => language) };
192
+ }
193
+
194
+ export async function verifyParsers({ languages = [], all = false, directory, environment = process.env, signal } = {}) {
195
+ const definitions = selectedParsers(languages, all);
196
+ const root = parserRoot(directory, environment);
197
+ for (const definition of definitions) {
198
+ const installation = await installedParser(definition, parserLocation(definition, root));
199
+ if (!installation) fail('PARSER_NOT_INSTALLED', `${definition.language} is not installed in ${root}. Run genesis parsers install ${definition.language}.`);
200
+ await verifyParser(definition, installation, signal);
201
+ }
202
+ return { status: 'ready', directory: root, languages: definitions.map(({ language }) => language) };
203
+ }
204
+
205
+ export async function prepareCodeParsers(languages, { environment = process.env, signal = cancellation.getStore() } = {}) {
206
+ const policy = parserEnvironment({ environment });
207
+ const root = policy.GENESIS_PARSER_ROOT;
208
+ const registrations = {};
209
+ const errors = {};
210
+ for (const language of new Set(languages)) {
211
+ if (['JavaScript', 'TypeScript', 'Tsx'].includes(language)) continue;
212
+ try {
213
+ const definition = parserDefinition(language);
214
+ let installation = await installedParser(definition, parserLocation(definition, root));
215
+ if (!installation) {
216
+ if (policy.GENESIS_PARSER_AUTO_INSTALL === '0') {
217
+ fail('PARSER_NOT_INSTALLED', `${language} is not installed in ${root}; automatic parser installation is disabled.`);
218
+ }
219
+ installation = await ensureInstalled(definition, root, {
220
+ signal, environment, onProgress: (message) => process.stderr.write(`[genesis] ${message}\n`),
221
+ });
222
+ }
223
+ // Read the upstream loader's data without loading the native library.
224
+ registrations[language] = { ...require(installation.entry) };
225
+ } catch (error) {
226
+ if (error.code === 'PROCESS_ABORTED') throw error;
227
+ errors[language] = { code: error.code || 'PARSER_LOAD_FAILED', message: error.message };
228
+ }
229
+ }
230
+ return { registrations, errors };
231
+ }
@@ -0,0 +1,20 @@
1
+ import { parseAsync, registerDynamicLanguage } from '@ast-grep/napi';
2
+ import { astGrepCodeIndexers } from './ast-grep.js';
3
+
4
+ try {
5
+ const chunks = [];
6
+ for await (const chunk of process.stdin) chunks.push(chunk);
7
+ const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
8
+ if (Object.keys(input.registrations).length) registerDynamicLanguage(input.registrations);
9
+ const contributions = [];
10
+ for (const id of input.indexers) {
11
+ const result = await astGrepCodeIndexers.get(id).extract({
12
+ files: input.files, projectRoot: input.projectRoot, parseAsync, parserErrors: input.errors,
13
+ });
14
+ contributions.push({ ...result, extractor: id });
15
+ }
16
+ process.stdout.write(JSON.stringify(contributions));
17
+ } catch (error) {
18
+ process.stderr.write(`${error.stack || error}\n`);
19
+ process.exitCode = 1;
20
+ }
@@ -111,6 +111,11 @@ export async function contextForProjectPaths({ paths, projectRoot, stackPackages
111
111
  '',
112
112
  'Use Program as a concise, fallible explanation. Code, tests, and runtime behavior remain the evidence.',
113
113
  '',
114
+ '## Relevant subsystems',
115
+ '',
116
+ ...(['missing', 'empty'].includes(program.subsystemMap?.status) ? ['The subsystem map needs authoring. Before implementation, create genesis/subsystems.md from source, schema, and existing Program; preserve existing explanations and do not infer ownership from folders.'] : []),
117
+ ...((program.subsystemMap?.subsystems || []).filter((entry) => targets.includes('') || modules.some((module) => module.subsystem === entry.id)).map((entry) => JSON.stringify(entry, null, 2))),
118
+ '',
114
119
  '## Relevant Program',
115
120
  '',
116
121
  ...programDetails,
@@ -147,6 +152,7 @@ export async function contextForProjectPaths({ paths, projectRoot, stackPackages
147
152
  status: 'ready',
148
153
  paths: targets,
149
154
  modules: moduleSources.map(({ source: _source, ...module }) => module),
155
+ subsystems: (program.subsystemMap?.subsystems || []).filter((entry) => targets.includes('') || modules.some((module) => module.subsystem === entry.id)),
150
156
  components: stack.components.map(({ id }) => id),
151
157
  engineeringProfile: engineering.profile.id,
152
158
  verificationCommands: stack.verificationCommands.map(({ label, argv }) => ({ label, argv })),
@@ -1,4 +1,5 @@
1
1
  export const GENESIS_CONTRACTS = Object.freeze({
2
+ subsystems: 'genesis.subsystems.v0',
2
3
  projectInspection: 'genesis.project-inspection.v1',
3
4
  templates: 'genesis.templates.v1',
4
5
  templateApplication: 'genesis.template-application.v1',
package/src/index/init.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { SUBSYSTEMS_PATH, EMPTY_SUBSYSTEMS_SOURCE } from './subsystems.js';
1
2
  import { mkdir, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
 
@@ -40,6 +41,7 @@ export async function initializeProject({ projectRoot, stackPackages = [] } = {}
40
41
  const projectFormat = await inspectProjectFormatAtRoot(root);
41
42
  requireCurrentProjectFormat(projectFormat, { allowUninitialized: true });
42
43
  const created = (await Promise.all([
44
+ createIfMissing(root, SUBSYSTEMS_PATH, EMPTY_SUBSYSTEMS_SOURCE),
43
45
  createIfMissing(root, BLUEPRINT_PATH, BLUEPRINT_SKELETON_SOURCE),
44
46
  createIfMissing(root, COLLABORATION_PATH, COLLABORATION_SKELETON_SOURCE),
45
47
  createIfMissing(root, ENGINEERING_PATH, ENGINEERING_SKELETON_SOURCE),
@@ -38,6 +38,7 @@ function signalProcessTree(child, signal) {
38
38
 
39
39
  function executeFile(command, args, {
40
40
  maxBuffer,
41
+ input,
41
42
  signal,
42
43
  terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS,
43
44
  timeoutMs,
@@ -130,7 +131,10 @@ function executeFile(command, args, {
130
131
  )), timeoutMs);
131
132
  timeoutTimer.unref?.();
132
133
  }
133
- child.stdin?.end();
134
+ child.stdin?.on('error', (error) => {
135
+ if (error.code !== 'EPIPE') spawnError = error;
136
+ });
137
+ child.stdin?.end(input);
134
138
  });
135
139
  }
136
140
 
@@ -156,6 +160,7 @@ export async function runProcess(command, args, {
156
160
  env = process.env,
157
161
  maxBytes = 32 * 1024 * 1024,
158
162
  code = 'PROCESS_EXEC_FAILED',
163
+ input,
159
164
  signal,
160
165
  terminationGraceMs,
161
166
  timeoutMs,
@@ -168,6 +173,7 @@ export async function runProcess(command, args, {
168
173
  encoding: 'buffer',
169
174
  maxBuffer: maxBytes,
170
175
  shell: false,
176
+ input,
171
177
  signal,
172
178
  terminationGraceMs,
173
179
  timeoutMs,
@@ -1,6 +1,7 @@
1
1
  import { readFile, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { inspectSubsystems } from './subsystems.js';
4
5
  import { GenesisError } from './errors.js';
5
6
  import { isProjectContentPath, PROGRAM_ROOT } from './paths.js';
6
7
  import { normalizeRelative } from './utils.js';
@@ -55,16 +56,15 @@ function moduleIdentity(programPath) {
55
56
  const relative = programPath.slice(`${PROGRAM_ROOT}/`.length, -3);
56
57
  const normalized = normalizeRelative(relative);
57
58
  const segments = normalized.split('/');
58
- if (segments.length < 2 || segments.some((segment) => !CONCEPT_NAME.test(segment))) {
59
+ if (segments.some((segment) => !CONCEPT_NAME.test(segment))) {
59
60
  throw new GenesisError(
60
61
  'PROGRAM_INVALID',
61
- `Program module needs lowercase conceptual subsystem and operation names: ${programPath}.`,
62
+ `Program module needs lowercase path names: ${programPath}.`,
62
63
  { path: programPath },
63
64
  );
64
65
  }
65
66
  return {
66
67
  name: segments.at(-1),
67
- subsystem: segments.slice(0, -1).join('/'),
68
68
  };
69
69
  }
70
70
 
@@ -93,13 +93,12 @@ function moduleContents(source, programPath) {
93
93
  const sources = sourceLines.map((line) => line.match(SOURCE_LINE)?.[1]);
94
94
  const nextHeading = headings.find(({ index }) => index > contractHeading[0].index);
95
95
  const contractLines = lines
96
- .slice(contractHeading[0].index + 1, nextHeading?.index ?? lines.length)
97
- .filter((line) => line.trim());
96
+ .slice(contractHeading[0].index + 1, nextHeading?.index ?? lines.length);
98
97
  if (
99
98
  sources.length === 0
100
99
  || sources.some((sourcePath) => sourcePath === undefined)
101
100
  || new Set(sources).size !== sources.length
102
- || contractLines.length === 0
101
+ || !contractLines.some((line) => line.trim())
103
102
  ) {
104
103
  throw new GenesisError(
105
104
  'PROGRAM_INVALID',
@@ -136,8 +135,13 @@ function moduleContents(source, programPath) {
136
135
 
137
136
  export async function inspectProgram(projectRoot) {
138
137
  const { files, found } = await markdownFiles(projectRoot);
138
+ const subsystemMap = await inspectSubsystems({ projectRoot });
139
+ const assignments = new Map(subsystemMap.subsystems.flatMap((entry) => entry.program.map((file) => [file, entry.id])));
140
+ for (const file of assignments.keys()) {
141
+ if (!files.includes(file)) throw new GenesisError('SUBSYSTEMS_PROGRAM_MISSING', `Subsystem cites missing Program module: ${file}.`);
142
+ }
139
143
  if (!found || files.length === 0) {
140
- return { status: 'missing', files: [], modules: [], subsystems: [] };
144
+ return { status: subsystemMap.status === 'valid' ? 'valid' : 'missing', files: [], modules: [], subsystems: subsystemMap.subsystems.map(({ id }) => id), subsystemMap };
141
145
  }
142
146
 
143
147
  const modules = [];
@@ -150,6 +154,7 @@ export async function inspectProgram(projectRoot) {
150
154
  }
151
155
  const identity = moduleIdentity(programPath);
152
156
  const contents = moduleContents(source, programPath);
157
+ if (subsystemMap.status === 'valid' && !assignments.has(programPath)) throw new GenesisError('SUBSYSTEMS_PROGRAM_UNASSIGNED', `Program module has no declared subsystem: ${programPath}.`);
153
158
  const { sources } = contents;
154
159
  for (const sourcePath of sources) {
155
160
  if (!sourcePath || !isProjectContentPath(sourcePath) || !await sourceExists(projectRoot, sourcePath)) {
@@ -164,7 +169,7 @@ export async function inspectProgram(projectRoot) {
164
169
  path: programPath,
165
170
  name: identity.name,
166
171
  sources,
167
- subsystem: identity.subsystem,
172
+ subsystem: assignments.get(programPath) ?? null,
168
173
  title: contents.title,
169
174
  description: contents.description,
170
175
  publicContract: contents.publicContract,
@@ -176,6 +181,7 @@ export async function inspectProgram(projectRoot) {
176
181
  status: 'valid',
177
182
  files,
178
183
  modules,
179
- subsystems: [...new Set(modules.map(({ subsystem }) => subsystem))].sort(),
184
+ subsystems: subsystemMap.subsystems.map(({ id }) => id),
185
+ subsystemMap,
180
186
  };
181
187
  }
@@ -16,6 +16,7 @@ import { readStack } from './stack.js';
16
16
  export const GENESIS_BOOTSTRAP_PATHS = Object.freeze([
17
17
  'genesis/version',
18
18
  'genesis/blueprint.md',
19
+ 'genesis/subsystems.md',
19
20
  'genesis/stack.md',
20
21
  'genesis/collaboration.md',
21
22
  'genesis/engineering.md',
@@ -74,6 +74,7 @@ function programContext(program) {
74
74
  return {
75
75
  status: program.status,
76
76
  files: program.files,
77
+ subsystemMap: program.subsystemMap,
77
78
  subsystems: program.subsystems,
78
79
  modules: program.modules.map(({ path, name, sources, subsystem }) => ({
79
80
  path,
@@ -90,6 +91,7 @@ function startProgramContext(program) {
90
91
  status: program.status,
91
92
  subsystems: program.subsystems,
92
93
  moduleCount: program.modules.length,
94
+ subsystemMapStatus: program.subsystemMap?.status,
93
95
  ...(program.diagnostic ? { diagnostic: program.diagnostic } : {}),
94
96
  };
95
97
  }
@@ -149,6 +149,8 @@ export async function projectSessionContext({
149
149
  ...skills.diagnostics.map(({ message }) => `- Agent Skill maintenance: ${message}`),
150
150
  ...(skills.diagnostics.length > 0 ? ['- Skill inspection is read-only. Synchronization is an explicit source change; follow the current task authorization and host write boundary before running it.'] : []),
151
151
  '- After locating source, run `genesis context <path...>`; before adding a helper or public operation, run `genesis index <name-or-path...>` and reuse an existing owner.',
152
+ '- If genesis/subsystems.md is missing or empty in an existing explained application, author it from source, schema and existing Program before implementation. This is ordinary explanatory adoption, not a startup blocker; do not run tests or application commands merely to create the map.',
153
+ '- Read genesis/subsystems.md for declared subsystem responsibilities, Program membership, and data ownership/use. Maintain it in the same implementation turn when those facts change; reuse existing boundaries, verify source/schema, and never infer membership from folders.',
152
154
  '- Program is fallible explanation; code, tests, and runtime behavior remain evidence.',
153
155
  '- Keep Blueprint and affected Program explanations aligned with intentional observable product behavior in the same implementation turn. Private restructuring may need only source citations or no explanatory change.',
154
156
  '- Before reporting completion, compare the requested observable behavior, required inputs and resources, declared project operations, and focused evidence with what actually exists. State anything not proven.',
@@ -0,0 +1,109 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { gitContext } from './git.js';
4
+ import { readStack } from './stack.js';
5
+ import { GenesisError } from './errors.js';
6
+ import { normalizeRelative, sha256, stableJson } from './utils.js';
7
+
8
+ export const SUBSYSTEMS_PATH = 'genesis/subsystems.md';
9
+ export const EMPTY_SUBSYSTEMS_SOURCE = '# Subsystems\n\n- Nothing.\n';
10
+
11
+ // Table identities are declarations, never database queries or SQL identifiers.
12
+ export async function inspectSubsystems({ projectRoot = process.cwd() } = {}) {
13
+ projectRoot = (await gitContext(projectRoot)).repositoryRoot;
14
+ let source;
15
+ try { source = await readFile(path.join(projectRoot, SUBSYSTEMS_PATH), 'utf8'); }
16
+ catch (error) {
17
+ if (error.code !== 'ENOENT') throw error;
18
+ return { contract: 'genesis.subsystems.v0', status: 'missing', path: SUBSYSTEMS_PATH, subsystems: [] };
19
+ }
20
+ const invalid = (message) => { throw new GenesisError('SUBSYSTEMS_INVALID', `${SUBSYSTEMS_PATH}: ${message}`); };
21
+ const lines = source.replace(/\r\n?/gu, '\n').trim().split('\n');
22
+ if (lines.shift() !== '# Subsystems') invalid('Expected # Subsystems.');
23
+ const subsystems = [];
24
+ let current;
25
+ let section;
26
+ const sections = new Set();
27
+ const finish = () => {
28
+ if (!current) return;
29
+ if (!current.description.trim() || sections.size !== 3) invalid(`Subsystem ${current.id} needs a responsibility and Program, Data owned, Data used sections.`);
30
+ current.description = current.description.trim();
31
+ subsystems.push(current);
32
+ };
33
+ const empty = lines.join('\n').trim() === '- Nothing.';
34
+ if (!empty && !lines.some((line) => line.trim())) invalid('An empty map must say - Nothing.');
35
+ if (!empty) for (const line of lines) {
36
+ if (!line.trim()) {
37
+ if (current && !section) current.description += '\n';
38
+ continue;
39
+ }
40
+ const heading = line.match(/^## `([a-z0-9]+(?:-[a-z0-9]+)*)` (\S.*)$/u);
41
+ if (heading) {
42
+ finish();
43
+ current = { id: heading[1], title: heading[2], description: '', program: [], dataOwned: [], dataUsed: [] };
44
+ section = null;
45
+ sections.clear();
46
+ continue;
47
+ }
48
+ if (!current) invalid('Expected a subsystem heading or - Nothing.');
49
+ const sectionHeading = line.match(/^### (Program|Data owned|Data used)$/u);
50
+ if (sectionHeading) {
51
+ section = { Program: 'program', 'Data owned': 'dataOwned', 'Data used': 'dataUsed' }[sectionHeading[1]];
52
+ if (sections.has(section)) invalid(`Duplicate ${sectionHeading[1]} section.`);
53
+ sections.add(section);
54
+ continue;
55
+ }
56
+ if (line.startsWith('#')) invalid(`Unexpected heading: ${line}`);
57
+ if (!section) { current.description += `${line}\n`; continue; }
58
+ if (line === '- Nothing.') {
59
+ if (current[section].length || current[`${section}Empty`]) invalid('Nothing must be the only section entry.');
60
+ current[`${section}Empty`] = true;
61
+ continue;
62
+ }
63
+ if (current[`${section}Empty`]) invalid('Nothing must be the only section entry.');
64
+ if (section === 'program') {
65
+ const match = line.match(/^- `([^`]+)`$/u);
66
+ if (!match || !/^genesis\/program\/[a-z0-9-]+(?:\/[a-z0-9-]+)*\.md$/u.test(match[1]) || normalizeRelative(match[1]) !== match[1]) invalid(`Invalid Program reference: ${line}`);
67
+ current.program.push(match[1]);
68
+ } else {
69
+ const match = line.match(/^- Table `([^`]+)` `([^`]+)` `([^`]+)`$/u);
70
+ if (!match) invalid(`Expected Table resource, schema, and table: ${line}`);
71
+ current[section].push({ resource: match[1], schema: match[2], table: match[3] });
72
+ }
73
+ }
74
+ finish();
75
+ const ids = new Set();
76
+ const programs = new Set();
77
+ const owners = new Map();
78
+ for (const subsystem of subsystems) {
79
+ if (ids.has(subsystem.id)) invalid(`Duplicate subsystem ${subsystem.id}.`);
80
+ ids.add(subsystem.id);
81
+ for (const key of ['program', 'dataOwned', 'dataUsed']) {
82
+ if (!subsystem[key].length && !subsystem[`${key}Empty`]) invalid(`${subsystem.id}: empty sections must say - Nothing.`);
83
+ delete subsystem[`${key}Empty`];
84
+ const entries = subsystem[key].map((entry) => JSON.stringify(entry));
85
+ if (new Set(entries).size !== entries.length) invalid(`${subsystem.id}: duplicate ${key} entry.`);
86
+ }
87
+ for (const file of subsystem.program) {
88
+ if (programs.has(file)) invalid(`Program operation has multiple owners: ${file}.`);
89
+ programs.add(file);
90
+ }
91
+ for (const table of subsystem.dataOwned) {
92
+ const key = JSON.stringify(table);
93
+ if (owners.has(key)) invalid(`Table has multiple owners: ${key}.`);
94
+ owners.set(key, subsystem.id);
95
+ }
96
+ }
97
+ const tables = subsystems.flatMap((entry) => [...entry.dataOwned, ...entry.dataUsed]);
98
+ if (tables.length) {
99
+ const stack = await readStack(projectRoot);
100
+ const resources = new Set(stack.resources.map(({ resource }) => resource.id));
101
+ for (const table of tables) if (!resources.has(table.resource)) invalid(`Undeclared Stack resource: ${table.resource}.`);
102
+ }
103
+ for (const subsystem of subsystems) for (const table of subsystem.dataUsed) {
104
+ const owner = owners.get(JSON.stringify(table));
105
+ if (!owner || owner === subsystem.id) invalid(`${subsystem.id}: Data used must reference a table owned by another declared subsystem.`);
106
+ }
107
+ return { contract: 'genesis.subsystems.v0', status: subsystems.length ? 'valid' : 'empty', path: SUBSYSTEMS_PATH,
108
+ identity: sha256(stableJson(subsystems)), subsystems };
109
+ }