@juangcarmona/lighthouse 0.5.0 → 0.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.
Files changed (75) hide show
  1. package/README.md +8 -2
  2. package/assets/dotnet-template/Directory.Build.props +22 -0
  3. package/assets/dotnet-template/Directory.Packages.props +16 -0
  4. package/assets/dotnet-template/Lighthouse.Analyzers/BackendContractAnalyzer.cs +72 -0
  5. package/assets/dotnet-template/Lighthouse.Analyzers/Lighthouse.Analyzers.csproj +12 -0
  6. package/assets/dotnet-template/Lighthouse.Analyzers/packages.lock.json +121 -0
  7. package/assets/dotnet-template/Lighthouse.Cqrs/Handlers.cs +11 -0
  8. package/assets/dotnet-template/Lighthouse.Cqrs/Lighthouse.Cqrs.csproj +5 -0
  9. package/assets/dotnet-template/Lighthouse.Cqrs/packages.lock.json +6 -0
  10. package/assets/dotnet-template/Lighthouse.slnx +17 -0
  11. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/CatalogModule.cs +24 -0
  12. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/CreateEntry.cs +47 -0
  13. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/EntryStore.cs +17 -0
  14. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/GetEntry.cs +30 -0
  15. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Lighthouse.Example.Modules.Catalog.csproj +12 -0
  16. package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/packages.lock.json +10 -0
  17. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Lighthouse.Example.Api.csproj +6 -0
  18. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Program.cs +25 -0
  19. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Properties/launchSettings.json +10 -0
  20. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/packages.lock.json +86 -0
  21. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Lighthouse.Example.AppHost.csproj +13 -0
  22. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Program.cs +3 -0
  23. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Properties/launchSettings.json +15 -0
  24. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/aspire.config.json +5 -0
  25. package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/packages.lock.json +689 -0
  26. package/assets/dotnet-template/global.json +6 -0
  27. package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/BackendContractTests.cs +102 -0
  28. package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/Lighthouse.Analyzers.Tests.csproj +9 -0
  29. package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/packages.lock.json +125 -0
  30. package/assets/dotnet-template/tests/Lighthouse.Example.Tests/CatalogTests.cs +111 -0
  31. package/assets/dotnet-template/tests/Lighthouse.Example.Tests/Lighthouse.Example.Tests.csproj +9 -0
  32. package/assets/dotnet-template/tests/Lighthouse.Example.Tests/packages.lock.json +490 -0
  33. package/assets/profiles/catalog.json +1050 -0
  34. package/assets/profiles/dotnet/README.md +32 -0
  35. package/assets/profiles/dotnet/SKILL.md +18 -0
  36. package/assets/profiles/dotnet/adoption.md +39 -0
  37. package/assets/profiles/dotnet/architecture.md +37 -0
  38. package/assets/profiles/dotnet/guardrails.md +32 -0
  39. package/assets/profiles/dotnet/practices.md +36 -0
  40. package/assets/profiles/dotnet/testing-and-development.md +29 -0
  41. package/assets/profiles/dotnet/use-cases.md +29 -0
  42. package/assets/profiles/mobile-expo/README.md +19 -0
  43. package/assets/profiles/mobile-expo/SKILL.md +14 -0
  44. package/assets/profiles/mobile-expo/adoption.md +11 -0
  45. package/assets/profiles/mobile-expo/guardrails.md +14 -0
  46. package/assets/profiles/mobile-expo/testing.md +13 -0
  47. package/assets/profiles/mobile-maui/README.md +19 -0
  48. package/assets/profiles/mobile-maui/SKILL.md +14 -0
  49. package/assets/profiles/mobile-maui/adoption.md +11 -0
  50. package/assets/profiles/mobile-maui/guardrails.md +13 -0
  51. package/assets/profiles/mobile-maui/testing.md +13 -0
  52. package/assets/profiles/pwa-capability/README.md +19 -0
  53. package/assets/profiles/pwa-capability/SKILL.md +14 -0
  54. package/assets/profiles/pwa-capability/adoption.md +9 -0
  55. package/assets/profiles/pwa-capability/guardrails.md +13 -0
  56. package/assets/profiles/pwa-capability/offline.md +11 -0
  57. package/assets/profiles/pwa-capability/testing.md +15 -0
  58. package/assets/profiles/react-web-platform/README.md +24 -0
  59. package/assets/profiles/react-web-platform/SKILL.md +18 -0
  60. package/assets/profiles/react-web-platform/adoption.md +17 -0
  61. package/assets/profiles/react-web-platform/guardrails.md +17 -0
  62. package/assets/profiles/react-web-platform/structure.md +36 -0
  63. package/assets/profiles/react-web-platform/testing.md +15 -0
  64. package/assets/profiles/web-bff/README.md +22 -0
  65. package/assets/profiles/web-bff/SKILL.md +16 -0
  66. package/assets/profiles/web-bff/adoption.md +9 -0
  67. package/assets/profiles/web-bff/security.md +16 -0
  68. package/assets/profiles/web-bff/testing.md +14 -0
  69. package/assets/schemas/lighthouse-manifest.schema.json +10 -7
  70. package/assets/schemas/profile-catalog.schema.json +329 -0
  71. package/package.json +5 -2
  72. package/src/create-dotnet.mjs +112 -0
  73. package/src/lighthouse.mjs +109 -14
  74. package/src/profiles.mjs +206 -0
  75. package/src/verify-dotnet.mjs +85 -0
@@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
6
6
  import { basename, dirname, resolve } from 'node:path';
7
7
  import { execFileSync, spawnSync } from 'node:child_process';
8
8
  import { evaluateGuardrails, readGuardrailCatalog } from './guardrails.mjs';
9
+ import { composeProfiles, normalizeApplication, profileCompositionResult, readProfileCatalog } from './profiles.mjs';
9
10
  let sdlcCapabilities;
10
11
  let sdlcReconcile;
11
12
  let sdlcGithub;
@@ -44,14 +45,20 @@ const managedPaths = [
44
45
  ];
45
46
  const profileManagedPaths = Object.freeze({
46
47
  'visual-theme': ['docs/design/DESIGN.md', '.agents/skills/lighthouse-ui/SKILL.md'],
48
+ 'dotnet-backend': ['docs/profiles/dotnet/README.md', 'docs/profiles/dotnet/adoption.md', 'docs/profiles/dotnet/architecture.md', 'docs/profiles/dotnet/guardrails.md', 'docs/profiles/dotnet/practices.md', 'docs/profiles/dotnet/testing-and-development.md', 'docs/profiles/dotnet/use-cases.md', '.agents/skills/lighthouse-dotnet/SKILL.md'],
49
+ 'react-web-platform': ['docs/profiles/react-web-platform/README.md', 'docs/profiles/react-web-platform/adoption.md', 'docs/profiles/react-web-platform/structure.md', 'docs/profiles/react-web-platform/guardrails.md', 'docs/profiles/react-web-platform/testing.md', '.agents/skills/lighthouse-react-web/SKILL.md'],
50
+ 'web-bff': ['docs/profiles/web-bff/README.md', 'docs/profiles/web-bff/adoption.md', 'docs/profiles/web-bff/security.md', 'docs/profiles/web-bff/testing.md', '.agents/skills/lighthouse-web-bff/SKILL.md'],
51
+ 'pwa-capability': ['docs/profiles/pwa-capability/README.md', 'docs/profiles/pwa-capability/adoption.md', 'docs/profiles/pwa-capability/guardrails.md', 'docs/profiles/pwa-capability/offline.md', 'docs/profiles/pwa-capability/testing.md', '.agents/skills/lighthouse-pwa/SKILL.md'],
52
+ 'mobile-expo': ['docs/profiles/mobile-expo/README.md', 'docs/profiles/mobile-expo/adoption.md', 'docs/profiles/mobile-expo/guardrails.md', 'docs/profiles/mobile-expo/testing.md', '.agents/skills/lighthouse-mobile-expo/SKILL.md'],
53
+ 'mobile-maui': ['docs/profiles/mobile-maui/README.md', 'docs/profiles/mobile-maui/adoption.md', 'docs/profiles/mobile-maui/guardrails.md', 'docs/profiles/mobile-maui/testing.md', '.agents/skills/lighthouse-mobile-maui/SKILL.md'],
47
54
  });
48
55
  const previousManagedPaths = managedPaths.slice(0, -1);
49
56
  const legacyManagedPaths = [projection];
50
- const supportedProfiles = ['node-tooling', 'dotnet-backend', 'visual-theme'];
57
+ const supportedProfiles = readProfileCatalog().profiles.map(({ id }) => id);
51
58
  const branchPattern = '^(feat|fix|docs|refactor|test|chore|build|ci|perf|revert)/[1-9]\\d*-[a-z0-9]+(?:-[a-z0-9]+)*$';
52
59
  const authorityVersions = { productShape: '0.22.0', arcShape: '0.1.3' };
53
60
  const upgradeSchema = './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-upgrade.schema.json';
54
- const usage = 'Usage: lighthouse <init|authorities|reconcile|upgrade|capabilities|verify> [--name <product>] [--profile <profile>] [--scope <harness|sdlc|all>] [--format <text|json>] [--dry-run] [--apply]';
61
+ const usage = 'Usage: lighthouse <init|create-dotnet|profiles|authorities|reconcile|upgrade|capabilities|verify> [--name <product>] [--profile <profile>] [--scope <harness|sdlc|all>] [--format <text|json>] [--dry-run] [--apply]';
55
62
 
56
63
  const defaultRepository = () => ({
57
64
  layout: [...layout],
@@ -77,15 +84,64 @@ function packageAsset(packageName, entry) {
77
84
  }
78
85
 
79
86
  function profileAssets(manifest) {
80
- if (!(manifest?.profiles ?? []).includes('visual-theme')) return {};
81
- try {
82
- return {
83
- 'docs/design/DESIGN.md': packageAsset('@juangcarmona/lighthouse-ui', 'DESIGN.md'),
84
- '.agents/skills/lighthouse-ui/SKILL.md': packageAsset('@juangcarmona/lighthouse-ui', 'skills/ui-design/SKILL.md'),
85
- };
86
- } catch (error) {
87
- throw new Error(`Cannot read the installed Lighthouse UI profile: ${error.message}`);
87
+ const assets = {};
88
+ if ((manifest?.profiles ?? []).includes('visual-theme')) {
89
+ try {
90
+ assets['docs/design/DESIGN.md'] = packageAsset('@juangcarmona/lighthouse-ui', 'DESIGN.md');
91
+ assets['.agents/skills/lighthouse-ui/SKILL.md'] = packageAsset('@juangcarmona/lighthouse-ui', 'skills/ui-design/SKILL.md');
92
+ } catch (error) {
93
+ throw new Error(`Cannot read the installed Lighthouse UI profile: ${error.message}`);
94
+ }
95
+ }
96
+ if ((manifest?.profiles ?? []).includes('dotnet-backend')) {
97
+ const files = ['README.md', 'adoption.md', 'architecture.md', 'guardrails.md', 'practices.md', 'testing-and-development.md', 'use-cases.md', 'SKILL.md'];
98
+ for (const file of files) {
99
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-dotnet/SKILL.md' : `docs/profiles/dotnet/${file}`;
100
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/dotnet/${file}`, import.meta.url), 'utf8'); }
101
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse .NET profile: ${error.message}`); }
102
+ }
103
+ }
104
+ if ((manifest?.profiles ?? []).includes('react-web-platform')) {
105
+ const files = ['README.md', 'adoption.md', 'structure.md', 'guardrails.md', 'testing.md', 'SKILL.md'];
106
+ for (const file of files) {
107
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-react-web/SKILL.md' : `docs/profiles/react-web-platform/${file}`;
108
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/react-web-platform/${file}`, import.meta.url), 'utf8'); }
109
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse React web profile: ${error.message}`); }
110
+ }
111
+ }
112
+ if ((manifest?.profiles ?? []).includes('web-bff')) {
113
+ const files = ['README.md', 'adoption.md', 'security.md', 'testing.md', 'SKILL.md'];
114
+ for (const file of files) {
115
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-web-bff/SKILL.md' : `docs/profiles/web-bff/${file}`;
116
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/web-bff/${file}`, import.meta.url), 'utf8'); }
117
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse web BFF profile: ${error.message}`); }
118
+ }
119
+ }
120
+ if ((manifest?.profiles ?? []).includes('pwa-capability')) {
121
+ const files = ['README.md', 'adoption.md', 'guardrails.md', 'offline.md', 'testing.md', 'SKILL.md'];
122
+ for (const file of files) {
123
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-pwa/SKILL.md' : `docs/profiles/pwa-capability/${file}`;
124
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/pwa-capability/${file}`, import.meta.url), 'utf8'); }
125
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse PWA capability: ${error.message}`); }
126
+ }
88
127
  }
128
+ if ((manifest?.profiles ?? []).includes('mobile-expo')) {
129
+ const files = ['README.md', 'adoption.md', 'guardrails.md', 'testing.md', 'SKILL.md'];
130
+ for (const file of files) {
131
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-mobile-expo/SKILL.md' : `docs/profiles/mobile-expo/${file}`;
132
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/mobile-expo/${file}`, import.meta.url), 'utf8'); }
133
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse Expo mobile profile: ${error.message}`); }
134
+ }
135
+ }
136
+ if ((manifest?.profiles ?? []).includes('mobile-maui')) {
137
+ const files = ['README.md', 'adoption.md', 'guardrails.md', 'testing.md', 'SKILL.md'];
138
+ for (const file of files) {
139
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-mobile-maui/SKILL.md' : `docs/profiles/mobile-maui/${file}`;
140
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/mobile-maui/${file}`, import.meta.url), 'utf8'); }
141
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse .NET MAUI mobile profile: ${error.message}`); }
142
+ }
143
+ }
144
+ return assets;
89
145
  }
90
146
 
91
147
  function profileArguments(args) {
@@ -108,6 +164,7 @@ function defaultManifest(product, profiles = []) {
108
164
  schemaVersion: 1,
109
165
  baseline: '0.1.0',
110
166
  product,
167
+ application: { shape: 'unknown', stacks: [] },
111
168
  profiles: [...profiles],
112
169
  integrations: { productShape: null, arcShape: null },
113
170
  packages,
@@ -458,7 +515,7 @@ function printUpgradePlan(plan) {
458
515
  function validate(manifest) {
459
516
  const problems = [];
460
517
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return ['manifest must be an object'];
461
- const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
518
+ const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'application', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
462
519
  for (const key of Object.keys(manifest)) if (!allowed.includes(key)) problems.push(`unknown field ${key}`);
463
520
  for (const key of ['schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed']) {
464
521
  if (!(key in manifest)) problems.push(`missing ${key}`);
@@ -469,6 +526,15 @@ function validate(manifest) {
469
526
  if (typeof manifest.product !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(manifest.product)) {
470
527
  problems.push('product must be a readable name of 1–64 characters');
471
528
  }
529
+ if ('application' in manifest) {
530
+ const application = manifest.application;
531
+ if (!application || typeof application !== 'object' || Array.isArray(application)
532
+ || !['unknown', 'frontend', 'backend', 'full-stack', 'service', 'library', 'tooling', 'cli', 'worker', 'mobile'].includes(application.shape)
533
+ || !Array.isArray(application.stacks) || application.stacks.some((stack) => typeof stack !== 'string' || !stack)
534
+ || new Set(application.stacks).size !== application.stacks.length) {
535
+ problems.push('application must declare a supported shape and unique stack names');
536
+ }
537
+ }
472
538
  const profiles = supportedProfiles;
473
539
  if (!Array.isArray(manifest.profiles) || manifest.profiles.some((p) => !profiles.includes(p))
474
540
  || new Set(manifest.profiles).size !== manifest.profiles.length) {
@@ -923,6 +989,11 @@ async function verify(manifest, format = 'text') {
923
989
  branch: gitBranch(),
924
990
  uiTokens: manifest?.profiles?.includes('visual-theme') ? packageAsset('@juangcarmona/lighthouse-ui', 'tokens.css') : null,
925
991
  });
992
+ const profileComposition = composeProfiles(readProfileCatalog(), manifest);
993
+ report.profiles = profileComposition;
994
+ const profileResult = profileCompositionResult(profileComposition);
995
+ report.results.push(profileResult);
996
+ report.summary[profileResult.status] = (report.summary[profileResult.status] ?? 0) + 1;
926
997
  const adoption = Object.values(capabilityAdoption(await readUpgradeState()));
927
998
  for (const item of adoption) {
928
999
  if (item.status !== 'adopted' || !item.recordPath || await exists(resolve(root, item.recordPath))) continue;
@@ -956,6 +1027,20 @@ async function verify(manifest, format = 'text') {
956
1027
  console.log('Lighthouse manifest, repository boundaries and managed projection are valid.');
957
1028
  }
958
1029
 
1030
+ async function profiles(manifest, format = 'text') {
1031
+ const composition = composeProfiles(readProfileCatalog(), manifest);
1032
+ if (format === 'json') {
1033
+ console.log(JSON.stringify(composition, null, 2));
1034
+ if (composition.status === 'failed') process.exitCode = 1;
1035
+ return;
1036
+ }
1037
+ console.log(`Lighthouse profile composition: ${composition.status}`);
1038
+ console.log(` application ${composition.application.shape}${composition.application.stacks.length ? ` (${composition.application.stacks.join(', ')})` : ''}`);
1039
+ for (const profile of composition.selected) console.log(` ${profile.id}: ${profile.status}${profile.nextAction ? `; next: ${profile.nextAction}` : ''}`);
1040
+ if (!composition.selected.length) console.log(' no profiles selected');
1041
+ if (composition.status === 'failed') process.exitCode = 1;
1042
+ }
1043
+
959
1044
  function planHash(plan) {
960
1045
  const { authorization, ...payload } = plan;
961
1046
  return contentHash(JSON.stringify(payload));
@@ -1117,6 +1202,11 @@ function formatFromArgs(args) {
1117
1202
  const args = process.argv.slice(2);
1118
1203
  const command = args[0];
1119
1204
  const commandArgs = args.slice(1);
1205
+ if (command === 'create-dotnet') {
1206
+ process.argv.splice(2, 1);
1207
+ await import('./create-dotnet.mjs');
1208
+ process.exit(0);
1209
+ }
1120
1210
  let invalidArgument = false;
1121
1211
  for (let index = 0; index < commandArgs.length; index += 1) {
1122
1212
  const arg = commandArgs[index];
@@ -1135,12 +1225,12 @@ for (let index = 0; index < commandArgs.length; index += 1) {
1135
1225
  continue;
1136
1226
  }
1137
1227
  if (arg === '--format') {
1138
- if (!['verify', 'upgrade', 'capabilities'].includes(command) || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
1228
+ if (!['profiles', 'verify', 'upgrade', 'capabilities'].includes(command) || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
1139
1229
  else index += 1;
1140
1230
  continue;
1141
1231
  }
1142
1232
  if (arg.startsWith('--format=') && ['text', 'json'].includes(arg.slice('--format='.length))) {
1143
- if (!['verify', 'upgrade', 'capabilities'].includes(command)) invalidArgument = true;
1233
+ if (!['profiles', 'verify', 'upgrade', 'capabilities'].includes(command)) invalidArgument = true;
1144
1234
  continue;
1145
1235
  }
1146
1236
  if (arg === '--scope') {
@@ -1160,7 +1250,7 @@ for (let index = 0; index < commandArgs.length; index += 1) {
1160
1250
  if (['--id=', '--provider=', '--repo=', '--input=', '--plan-file=', '--authorize='].some((prefix) => arg.startsWith(prefix) && arg.length > prefix.length)) continue;
1161
1251
  invalidArgument = true;
1162
1252
  }
1163
- if (!['init', 'authorities', 'reconcile', 'upgrade', 'capabilities', 'verify'].includes(command) || invalidArgument) {
1253
+ if (!['init', 'profiles', 'authorities', 'reconcile', 'upgrade', 'capabilities', 'verify'].includes(command) || invalidArgument) {
1164
1254
  console.error(usage);
1165
1255
  process.exit(2);
1166
1256
  }
@@ -1170,6 +1260,11 @@ try {
1170
1260
  else if (command === 'reconcile') await reconcile(args.slice(1));
1171
1261
  else if (command === 'upgrade') await upgrade(args.slice(1));
1172
1262
  else if (command === 'capabilities') await capabilities(args.slice(1));
1263
+ else if (command === 'profiles') {
1264
+ const manifest = await readManifest();
1265
+ if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
1266
+ await profiles(manifest, formatFromArgs(commandArgs));
1267
+ }
1173
1268
  else {
1174
1269
  const manifest = await readManifest();
1175
1270
  if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
@@ -0,0 +1,206 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ const catalogPath = fileURLToPath(new URL('../assets/profiles/catalog.json', import.meta.url));
5
+ const allowedShapes = new Set(['unknown', 'frontend', 'backend', 'full-stack', 'service', 'library', 'tooling', 'cli', 'worker', 'mobile']);
6
+ const allowedLevels = new Set(['required', 'recommended', 'optional']);
7
+ const allowedKinds = new Set(['hard', 'soft']);
8
+ const allowedVerification = new Set(['deterministic', 'semantic', 'human']);
9
+ const allowedLayers = new Set(['static', 'unit', 'architecture', 'integration', 'functional', 'browser-e2e', 'operational']);
10
+ const profileIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
11
+
12
+ function stringArrayProblems(value, name, { required = false } = {}) {
13
+ if (!Array.isArray(value) || (required && value.length === 0) || value.some((item) => typeof item !== 'string' || !item)) {
14
+ return [`${name} must contain strings`];
15
+ }
16
+ return new Set(value).size === value.length ? [] : [`${name} must be unique`];
17
+ }
18
+
19
+ function profileReferenceProblems(value, name) {
20
+ return stringArrayProblems(value, name).concat((value ?? []).filter((id) => !profileIdPattern.test(id)).map((id) => `${name} contains invalid profile id ${id}`));
21
+ }
22
+
23
+ function guardrailProblems(guardrail) {
24
+ if (!guardrail || typeof guardrail !== 'object' || Array.isArray(guardrail)) return ['guardrail must be an object'];
25
+ const problems = [];
26
+ for (const key of ['id', 'level', 'kind', 'verification', 'command', 'description']) if (!(key in guardrail)) problems.push(`guardrail missing ${key}`);
27
+ if (typeof guardrail.id !== 'string' || !guardrail.id) problems.push('guardrail id must be a non-empty string');
28
+ if (!allowedLevels.has(guardrail.level)) problems.push(`guardrail ${guardrail.id} has an invalid level`);
29
+ if (!allowedKinds.has(guardrail.kind)) problems.push(`guardrail ${guardrail.id} has an invalid kind`);
30
+ if (!allowedVerification.has(guardrail.verification)) problems.push(`guardrail ${guardrail.id} has an invalid verification method`);
31
+ if (guardrail.command !== null && (typeof guardrail.command !== 'string' || !guardrail.command)) problems.push(`guardrail ${guardrail.id} command must be a string or null`);
32
+ if (typeof guardrail.description !== 'string' || !guardrail.description) problems.push(`guardrail ${guardrail.id} description must be a non-empty string`);
33
+ return problems;
34
+ }
35
+
36
+ function profileProblems(profile) {
37
+ const problems = [];
38
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return ['profile must be an object'];
39
+ for (const key of ['id', 'owner', 'purpose', 'appliesTo', 'surfaces', 'verification']) if (!(key in profile)) problems.push(`missing ${key}`);
40
+ if (typeof profile.id !== 'string' || !profileIdPattern.test(profile.id)) problems.push('id must use kebab-case');
41
+ if (typeof profile.owner !== 'string' || !profile.owner) problems.push('owner must be a non-empty string');
42
+ if (typeof profile.purpose !== 'string' || !profile.purpose) problems.push('purpose must be a non-empty string');
43
+ const applicability = profile.appliesTo;
44
+ if (!applicability || typeof applicability !== 'object' || Array.isArray(applicability)
45
+ || !Array.isArray(applicability.shapes) || !Array.isArray(applicability.stacks)
46
+ || applicability.shapes.length === 0 || applicability.shapes.some((shape) => typeof shape !== 'string' || !allowedShapes.has(shape))) {
47
+ problems.push('appliesTo must declare supported application shapes');
48
+ }
49
+ if (Array.isArray(applicability?.shapes) && new Set(applicability.shapes).size !== applicability.shapes.length) problems.push('appliesTo.shapes must be unique');
50
+ if (Array.isArray(applicability?.stacks) && (applicability.stacks.some((stack) => typeof stack !== 'string') || new Set(applicability.stacks).size !== applicability.stacks.length)) problems.push('appliesTo.stacks must contain unique strings');
51
+ if (!Array.isArray(profile.surfaces) || profile.surfaces.length === 0 || profile.surfaces.some((surface) => typeof surface !== 'string' || !surface)) problems.push('surfaces must contain non-empty strings');
52
+ for (const [name, value] of [['prerequisites', profile.prerequisites], ['libraries', profile.libraries], ['structure', profile.structure], ['commands', profile.commands], ['repairHints', profile.repairHints], ['exceptions', profile.exceptions]]) problems.push(...stringArrayProblems(value, name, { required: ['repairHints', 'exceptions'].includes(name)}));
53
+ for (const [name, value] of [['dependencies', profile.dependencies], ['incompatibleWith', profile.incompatibleWith]]) problems.push(...profileReferenceProblems(value, name));
54
+ if (typeof profile.failureConsequence !== 'string' || !profile.failureConsequence) problems.push('failureConsequence must be a non-empty string');
55
+ if (typeof profile.consumerFixture !== 'string' || !profile.consumerFixture) problems.push('consumerFixture must be a non-empty string');
56
+ if (typeof profile.upgradeStrategy !== 'string' || !profile.upgradeStrategy) problems.push('upgradeStrategy must be a non-empty string');
57
+ if (!Array.isArray(profile.guardrails) || profile.guardrails.length === 0) problems.push('guardrails must contain at least one guardrail');
58
+ const guardrailIds = new Set();
59
+ for (const guardrail of profile.guardrails ?? []) {
60
+ problems.push(...guardrailProblems(guardrail));
61
+ if (guardrail?.id && guardrailIds.has(guardrail.id)) problems.push(`duplicate guardrail ${guardrail.id}`);
62
+ if (guardrail?.id) guardrailIds.add(guardrail.id);
63
+ }
64
+ if (!Array.isArray(profile.combinations)) problems.push('combinations must be an array');
65
+ for (const combination of profile.combinations ?? []) {
66
+ if (!combination || typeof combination !== 'object' || Array.isArray(combination)) { problems.push('combination must be an object'); continue; }
67
+ if (!Array.isArray(combination.with) || combination.with.length === 0 || combination.with.some((id) => typeof id !== 'string' || !profileIdPattern.test(id))) problems.push('combination.with must contain profile ids');
68
+ if (typeof combination.purpose !== 'string' || !combination.purpose) problems.push('combination.purpose must be a non-empty string');
69
+ }
70
+ const layers = profile.verification?.layers;
71
+ if (!Array.isArray(layers) || layers.length === 0) problems.push('verification.layers must contain at least one layer');
72
+ const ids = new Set();
73
+ for (const layer of layers ?? []) {
74
+ if (!layer || typeof layer !== 'object' || Array.isArray(layer)) { problems.push('verification layer must be an object'); continue; }
75
+ for (const key of ['id', 'level', 'kind', 'verification']) if (!(key in layer)) problems.push(`verification layer missing ${key}`);
76
+ if (!allowedLayers.has(layer.id)) problems.push(`unsupported verification layer ${layer.id}`);
77
+ if (!allowedLevels.has(layer.level)) problems.push(`verification layer ${layer.id} has an invalid level`);
78
+ if (!allowedKinds.has(layer.kind)) problems.push(`verification layer ${layer.id} has an invalid kind`);
79
+ if (!allowedVerification.has(layer.verification)) problems.push(`verification layer ${layer.id} has an invalid verification method`);
80
+ if (layer.command !== undefined && layer.command !== null && (typeof layer.command !== 'string' || !layer.command)) problems.push(`verification layer ${layer.id} command must be a string or null`);
81
+ if (ids.has(layer.id)) problems.push(`duplicate verification layer ${layer.id}`);
82
+ ids.add(layer.id);
83
+ }
84
+ return problems;
85
+ }
86
+
87
+ function catalogProblems(catalog) {
88
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return ['catalog must be an object'];
89
+ const problems = [];
90
+ if (catalog.schemaVersion !== 1) problems.push('schemaVersion must be 1');
91
+ if (!Array.isArray(catalog.profiles)) problems.push('profiles must be an array');
92
+ const ids = new Set();
93
+ for (const profile of catalog.profiles ?? []) {
94
+ problems.push(...profileProblems(profile).map((problem) => `${profile?.id ?? '<unknown>'}: ${problem}`));
95
+ if (profile?.id && ids.has(profile.id)) problems.push(`${profile.id}: duplicate id`);
96
+ if (profile?.id) ids.add(profile.id);
97
+ }
98
+ for (const profile of catalog.profiles ?? []) {
99
+ for (const reference of [...(profile.dependencies ?? []), ...(profile.incompatibleWith ?? []), ...(profile.combinations ?? []).flatMap((combination) => combination.with ?? [])]) {
100
+ if (!ids.has(reference)) problems.push(`${profile.id}: references undeclared profile ${reference}`);
101
+ }
102
+ }
103
+ return problems;
104
+ }
105
+
106
+ export function readProfileCatalog() {
107
+ const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
108
+ const problems = catalogProblems(catalog);
109
+ if (problems.length) throw new Error(`Invalid profile catalog:\n${problems.join('\n')}`);
110
+ return catalog;
111
+ }
112
+
113
+ export function normalizeApplication(application) {
114
+ if (!application || typeof application !== 'object' || Array.isArray(application)) return { shape: 'unknown', stacks: [] };
115
+ return {
116
+ shape: typeof application.shape === 'string' ? application.shape : 'unknown',
117
+ stacks: Array.isArray(application.stacks) ? [...application.stacks] : [],
118
+ };
119
+ }
120
+
121
+ function profileStatus(profile, application) {
122
+ const shapeKnown = application.shape !== 'unknown';
123
+ const stacksKnown = application.stacks.length > 0;
124
+ const shapeMatches = !shapeKnown || profile.appliesTo.shapes.includes('any') || profile.appliesTo.shapes.includes(application.shape);
125
+ const stackMatches = !stacksKnown || profile.appliesTo.stacks.length === 0 || profile.appliesTo.stacks.includes('any') || application.stacks.some((stack) => profile.appliesTo.stacks.includes(stack));
126
+ if (!shapeMatches || !stackMatches) return 'failed';
127
+ if ((!shapeKnown && profile.appliesTo.shapes.length > 0) || (profile.appliesTo.stacks.length > 0 && !stacksKnown)) return 'unconfigured';
128
+ return 'passed';
129
+ }
130
+
131
+ function profileCompositionProblems(profile, selectedIds) {
132
+ const problems = [];
133
+ for (const dependency of profile.dependencies ?? []) if (!selectedIds.has(dependency)) problems.push(`${profile.id} requires the ${dependency} profile.`);
134
+ for (const incompatible of profile.incompatibleWith ?? []) if (selectedIds.has(incompatible)) problems.push(`${profile.id} is incompatible with the ${incompatible} profile.`);
135
+ return problems;
136
+ }
137
+
138
+ export function composeProfiles(catalog, manifest) {
139
+ const application = normalizeApplication(manifest?.application);
140
+ const selectedIds = new Set(manifest?.profiles ?? []);
141
+ const selected = (manifest?.profiles ?? []).map((id) => {
142
+ const profile = catalog.profiles.find((item) => item.id === id);
143
+ const status = profile ? profileStatus(profile, application) : 'failed';
144
+ const compositionProblems = profile ? profileCompositionProblems(profile, selectedIds) : [];
145
+ const problems = profile
146
+ ? status === 'failed'
147
+ ? [`${id} does not apply to application shape '${application.shape}' and stacks '${application.stacks.join(', ') || 'none'}'.`]
148
+ : status === 'unconfigured'
149
+ ? [`${id} needs application.shape and${profile.appliesTo.stacks.length ? ' application.stacks' : ''} in lighthouse.json.`]
150
+ : compositionProblems
151
+ : [`${id} is not declared by the installed profile catalog.`];
152
+ const effectiveStatus = status === 'failed' || compositionProblems.length ? 'failed' : status;
153
+ return {
154
+ id,
155
+ owner: profile?.owner ?? '@juangcarmona/lighthouse',
156
+ purpose: profile?.purpose ?? null,
157
+ appliesTo: profile?.appliesTo ?? null,
158
+ surfaces: profile?.surfaces ?? [],
159
+ prerequisites: profile?.prerequisites ?? [],
160
+ dependencies: profile?.dependencies ?? [],
161
+ incompatibleWith: profile?.incompatibleWith ?? [],
162
+ libraries: profile?.libraries ?? [],
163
+ structure: profile?.structure ?? [],
164
+ commands: profile?.commands ?? [],
165
+ guardrails: profile?.guardrails ?? [],
166
+ failureConsequence: profile?.failureConsequence ?? null,
167
+ repairHints: profile?.repairHints ?? [],
168
+ exceptions: profile?.exceptions ?? [],
169
+ combinations: profile?.combinations ?? [],
170
+ consumerFixture: profile?.consumerFixture ?? null,
171
+ upgradeStrategy: profile?.upgradeStrategy ?? null,
172
+ verification: profile?.verification ?? { layers: [] },
173
+ problems,
174
+ status: effectiveStatus,
175
+ nextAction: problems.length
176
+ ? status === 'unconfigured'
177
+ ? 'Declare application.shape and application.stacks, then run lighthouse profiles --format json.'
178
+ : compositionProblems.length
179
+ ? 'Select the required profiles or remove the incompatible selection, then rerun lighthouse profiles --format json.'
180
+ : 'Choose a compatible profile or correct application.shape and application.stacks.'
181
+ : null,
182
+ };
183
+ });
184
+ const status = selected.length === 0
185
+ ? 'not-applicable'
186
+ : selected.some((profile) => profile.status === 'failed')
187
+ ? 'failed'
188
+ : selected.some((profile) => profile.status === 'unconfigured')
189
+ ? 'unconfigured'
190
+ : 'passed';
191
+ return { schemaVersion: 1, application, selected, available: catalog.profiles.map((profile) => ({ ...profile })), status };
192
+ }
193
+
194
+ export function profileCompositionResult(composition) {
195
+ return {
196
+ id: 'foundation.profile-composition',
197
+ owner: '@juangcarmona/lighthouse',
198
+ level: 'recommended',
199
+ kind: 'hard',
200
+ verification: 'deterministic',
201
+ status: composition.status,
202
+ evidence: composition.selected.map((profile) => ({ id: profile.id, status: profile.status, layers: profile.verification.layers.map((layer) => layer.id) })),
203
+ problems: composition.selected.flatMap((profile) => profile.problems),
204
+ nextAction: composition.selected.find((profile) => profile.nextAction)?.nextAction ?? null,
205
+ };
206
+ }
@@ -0,0 +1,85 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readdir, readFile, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ const root = process.cwd();
7
+ const nestedSolution = resolve(root, 'src/dotnet/Lighthouse.slnx');
8
+ const rootSolutions = (await readdir(root)).filter((name) => name.endsWith('.slnx'));
9
+ const solutionPath = await readFile(nestedSolution).then(() => nestedSolution).catch(() => {
10
+ if (rootSolutions.length !== 1) throw new Error('Expected src/dotnet/Lighthouse.slnx or one generated root solution.');
11
+ return resolve(root, rootSolutions[0]);
12
+ });
13
+ const dotnetRoot = dirname(solutionPath);
14
+ const lighthouseSourceRoot = solutionPath === nestedSolution ? dotnetRoot : resolve(root, 'src/dotnet');
15
+ const solutionName = solutionPath.slice(dotnetRoot.length + 1);
16
+ function run(args, options = {}) {
17
+ const result = spawnSync('dotnet', args, { cwd: dotnetRoot, encoding: 'utf8', ...options });
18
+ if (result.error) throw result.error;
19
+ return result;
20
+ }
21
+ for (const args of [
22
+ ['restore', solutionName, '--locked-mode'],
23
+ ['build', solutionName, '--no-restore', '-c', 'Release'],
24
+ ['test', solutionName, '--no-build', '--no-restore', '-c', 'Release']
25
+ ]) {
26
+ const result = run(args, { stdio: 'inherit' });
27
+ if (result.status !== 0) process.exit(result.status ?? 1);
28
+ }
29
+
30
+ const sourceFiles = [];
31
+ async function collectSource(folder) {
32
+ for (const entry of await readdir(resolve(dotnetRoot, folder), { withFileTypes: true })) {
33
+ if (['bin', 'obj', 'node_modules'].includes(entry.name)) continue;
34
+ const path = folder === '.' ? entry.name : `${folder}/${entry.name}`;
35
+ if (entry.isDirectory()) await collectSource(path);
36
+ else if (entry.isFile() && entry.name.endsWith('.cs')) sourceFiles.push(path);
37
+ }
38
+ }
39
+ await collectSource('.');
40
+ const formatted = run(['format', 'whitespace', '.', '--folder', '--verify-no-changes',
41
+ '--include', ...sourceFiles, '--verbosity', 'quiet'], { stdio: 'inherit' });
42
+ if (formatted.status !== 0) process.exit(formatted.status ?? 1);
43
+
44
+ const solution = await readFile(solutionPath, 'utf8');
45
+ for (const match of solution.matchAll(/<Project Path="([^"]+\.csproj)"/g)) {
46
+ const result = run(['msbuild', resolve(dotnetRoot, match[1]), '-getProperty:AssemblyName,LighthouseModule', '-getItem:ProjectReference']);
47
+ if (result.status !== 0) throw new Error(`Cannot evaluate project policy: ${match[1]}`);
48
+ const evaluated = JSON.parse(result.stdout);
49
+ const assembly = evaluated.Properties.AssemblyName;
50
+ if (!assembly.includes('.Modules.') || assembly.endsWith('.Contracts') || assembly.endsWith('Tests')) continue;
51
+ const active = evaluated.Properties.LighthouseModule === 'true';
52
+ const attached = evaluated.Items.ProjectReference.some((item) => item.Identity.endsWith('Lighthouse.Analyzers.csproj')
53
+ && item.OutputItemType === 'Analyzer' && item.ReferenceOutputAssembly === 'false');
54
+ if (!active || !attached) throw new Error(`Module ${assembly} must activate and reference the Lighthouse analyzer.`);
55
+ }
56
+ process.stdout.write('Every implementation module in the solution activates the analyzer.\n');
57
+
58
+ const probe = await mkdtemp(join(tmpdir(), 'lighthouse-rejection-'));
59
+ const xml = (value) => value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;');
60
+ try {
61
+ await mkdir(resolve(probe, 'Sibling'));
62
+ await mkdir(resolve(probe, 'Invalid'));
63
+ await writeFile(resolve(probe, 'Sibling/Sibling.csproj'), '<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><AssemblyName>JuanGCarmona.Probe.Modules.Sibling</AssemblyName></PropertyGroup></Project>');
64
+ await writeFile(resolve(probe, 'Sibling/Sibling.cs'), 'namespace JuanGCarmona.Probe.Modules.Sibling; public sealed class SiblingExport {}');
65
+ await writeFile(resolve(probe, 'Invalid/Invalid.csproj'), `<Project Sdk="Microsoft.NET.Sdk">
66
+ <PropertyGroup><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><AssemblyName>JuanGCarmona.Probe.Modules.Invalid</AssemblyName><LighthouseModule>true</LighthouseModule></PropertyGroup>
67
+ <ItemGroup>
68
+ <CompilerVisibleProperty Include="LighthouseModule" />
69
+ <ProjectReference Include="../Sibling/Sibling.csproj" />
70
+ <ProjectReference Include="${xml(resolve(lighthouseSourceRoot, 'Lighthouse.Cqrs/Lighthouse.Cqrs.csproj'))}" />
71
+ <ProjectReference Include="${xml(resolve(lighthouseSourceRoot, 'Lighthouse.Analyzers/Lighthouse.Analyzers.csproj'))}" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
72
+ </ItemGroup></Project>`);
73
+ await writeFile(resolve(probe, 'Invalid/Invalid.cs'), `using JuanGCarmona.Lighthouse.Cqrs;
74
+ namespace JuanGCarmona.Probe.Modules.Invalid;
75
+ public class ExposedHandler : ICommandHandler<string, string> {
76
+ public Task<string> Handle(string request, CancellationToken token) => Task.FromResult(request);
77
+ }`);
78
+ const result = run(['build', resolve(probe, 'Invalid/Invalid.csproj'), '-c', 'Release']);
79
+ const output = result.stdout + result.stderr;
80
+ if (result.status === 0 || !/error LH1001\b/.test(output) || !/error LH1002\b/.test(output)) {
81
+ process.stderr.write(output);
82
+ throw new Error('The real build must reject both the exposed handler and sibling implementation reference.');
83
+ }
84
+ process.stdout.write('Build rejection rehearsal passed: LH1001 and LH1002 blocked the invalid module.\n');
85
+ } finally { await rm(probe, { recursive: true, force: true }); }