@juangcarmona/lighthouse 0.4.0 → 0.6.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 (50) hide show
  1. package/README.md +9 -3
  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/guardrails/catalog.json +10 -0
  34. package/assets/profiles/catalog.json +48 -0
  35. package/assets/profiles/dotnet/README.md +32 -0
  36. package/assets/profiles/dotnet/SKILL.md +18 -0
  37. package/assets/profiles/dotnet/adoption.md +39 -0
  38. package/assets/profiles/dotnet/architecture.md +37 -0
  39. package/assets/profiles/dotnet/guardrails.md +32 -0
  40. package/assets/profiles/dotnet/practices.md +36 -0
  41. package/assets/profiles/dotnet/testing-and-development.md +29 -0
  42. package/assets/profiles/dotnet/use-cases.md +29 -0
  43. package/assets/schemas/lighthouse-manifest.schema.json +11 -7
  44. package/assets/schemas/profile-catalog.schema.json +63 -0
  45. package/package.json +6 -2
  46. package/src/create-dotnet.mjs +112 -0
  47. package/src/guardrails.mjs +17 -2
  48. package/src/lighthouse.mjs +156 -24
  49. package/src/profiles.mjs +133 -0
  50. 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;
@@ -32,6 +33,7 @@ const upgradeStateRelative = '.lighthouse/upgrade.json';
32
33
  const upgradeStatePath = resolve(root, upgradeStateRelative);
33
34
  const layout = ['src', 'docs', 'infra', 'scripts'];
34
35
  const sdlcPackage = { name: '@juangcarmona/lighthouse-sdlc', version: installedPackageVersion('@juangcarmona/lighthouse-sdlc', 'contract.json') ?? '0.1.0' };
36
+ const uiPackage = { name: '@juangcarmona/lighthouse-ui', version: installedPackageVersion('@juangcarmona/lighthouse-ui', 'DESIGN.md') ?? '0.1.0' };
35
37
  const managedPaths = [
36
38
  projection,
37
39
  'scripts/lighthouse-guard.mjs',
@@ -41,16 +43,17 @@ const managedPaths = [
41
43
  '.lighthouse/sdlc.json',
42
44
  upgradeStateRelative,
43
45
  ];
46
+ const profileManagedPaths = Object.freeze({
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
+ });
44
50
  const previousManagedPaths = managedPaths.slice(0, -1);
45
51
  const legacyManagedPaths = [projection];
52
+ const supportedProfiles = ['node-tooling', 'dotnet-backend', 'visual-theme'];
46
53
  const branchPattern = '^(feat|fix|docs|refactor|test|chore|build|ci|perf|revert)/[1-9]\\d*-[a-z0-9]+(?:-[a-z0-9]+)*$';
47
54
  const authorityVersions = { productShape: '0.22.0', arcShape: '0.1.3' };
48
- const upgradeScopes = {
49
- harness: [projection, 'scripts/lighthouse-guard.mjs', '.husky/commit-msg', '.husky/pre-push', '.github/workflows/lighthouse.yml'],
50
- sdlc: ['.lighthouse/sdlc.json'],
51
- };
52
55
  const upgradeSchema = './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-upgrade.schema.json';
53
- const usage = 'Usage: lighthouse <init|authorities|reconcile|upgrade|capabilities|verify> [--name <product>] [--scope <harness|sdlc|all>] [--format <text|json>] [--dry-run] [--apply]';
56
+ 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]';
54
57
 
55
58
  const defaultRepository = () => ({
56
59
  layout: [...layout],
@@ -58,16 +61,69 @@ const defaultRepository = () => ({
58
61
  conventions: { commits: 'conventional', branchPattern },
59
62
  });
60
63
 
61
- function defaultManifest(product) {
64
+ function managedPathsFor(manifest) {
65
+ const profiles = manifest?.profiles ?? [];
66
+ return [...managedPaths, ...profiles.flatMap((profile) => profileManagedPaths[profile] ?? [])];
67
+ }
68
+
69
+ function upgradeScopesFor(manifest) {
70
+ return {
71
+ harness: [projection, 'scripts/lighthouse-guard.mjs', '.husky/commit-msg', '.husky/pre-push', '.github/workflows/lighthouse.yml', ...((manifest?.profiles ?? []).flatMap((profile) => profileManagedPaths[profile] ?? []))],
72
+ sdlc: ['.lighthouse/sdlc.json'],
73
+ };
74
+ }
75
+
76
+ function packageAsset(packageName, entry) {
77
+ const path = packageRequire.resolve(`${packageName}/${entry}`);
78
+ return readFileSync(path, 'utf8');
79
+ }
80
+
81
+ function profileAssets(manifest) {
82
+ const assets = {};
83
+ if ((manifest?.profiles ?? []).includes('visual-theme')) {
84
+ try {
85
+ assets['docs/design/DESIGN.md'] = packageAsset('@juangcarmona/lighthouse-ui', 'DESIGN.md');
86
+ assets['.agents/skills/lighthouse-ui/SKILL.md'] = packageAsset('@juangcarmona/lighthouse-ui', 'skills/ui-design/SKILL.md');
87
+ } catch (error) {
88
+ throw new Error(`Cannot read the installed Lighthouse UI profile: ${error.message}`);
89
+ }
90
+ }
91
+ if ((manifest?.profiles ?? []).includes('dotnet-backend')) {
92
+ const files = ['README.md', 'adoption.md', 'architecture.md', 'guardrails.md', 'practices.md', 'testing-and-development.md', 'use-cases.md', 'SKILL.md'];
93
+ for (const file of files) {
94
+ const target = file === 'SKILL.md' ? '.agents/skills/lighthouse-dotnet/SKILL.md' : `docs/profiles/dotnet/${file}`;
95
+ try { assets[target] = readFileSync(new URL(`../assets/profiles/dotnet/${file}`, import.meta.url), 'utf8'); }
96
+ catch (error) { throw new Error(`Cannot read the installed Lighthouse .NET profile: ${error.message}`); }
97
+ }
98
+ }
99
+ return assets;
100
+ }
101
+
102
+ function profileArguments(args) {
103
+ const selected = [];
104
+ for (let index = 0; index < args.length; index += 1) {
105
+ if (args[index] !== '--profile' && !args[index].startsWith('--profile=')) continue;
106
+ const value = args[index] === '--profile' ? args[++index] : args[index].slice('--profile='.length);
107
+ if (!value || !supportedProfiles.includes(value)) throw new Error(`Unknown profile ${value ?? ''}; use ${supportedProfiles.join(', ')}.`);
108
+ if (!selected.includes(value)) selected.push(value);
109
+ }
110
+ return selected;
111
+ }
112
+
113
+ function defaultManifest(product, profiles = []) {
114
+ const draft = { profiles: [...profiles] };
115
+ const packages = { sdlc: { ...sdlcPackage } };
116
+ if (profiles.includes('visual-theme')) packages.ui = { ...uiPackage };
62
117
  return {
63
118
  $schema: './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-manifest.schema.json',
64
119
  schemaVersion: 1,
65
120
  baseline: '0.1.0',
66
121
  product,
67
- profiles: [],
122
+ application: { shape: 'unknown', stacks: [] },
123
+ profiles: [...profiles],
68
124
  integrations: { productShape: null, arcShape: null },
69
- packages: { sdlc: { ...sdlcPackage } },
70
- managed: [...managedPaths],
125
+ packages,
126
+ managed: managedPathsFor(draft),
71
127
  repository: defaultRepository(),
72
128
  };
73
129
  }
@@ -167,7 +223,9 @@ function render(manifest) {
167
223
  const publicSite = repository.publicSite.enabled ? 'enabled' : 'disabled';
168
224
  const selectedSdlc = normalizeSdlcPackage(manifest);
169
225
  const sdlcLine = selectedSdlc ? `- SDLC package: \`${selectedSdlc.name}@${selectedSdlc.version}\`\n` : '';
170
- return `<!-- Generated by Lighthouse. Edit lighthouse.json and regenerate this file. -->\n\n# ${manifest.product} repository baseline\n\n- Baseline: \`${manifest.baseline}\`\n- Product: \`${manifest.product}\`\n- Profiles: ${manifest.profiles.length ? manifest.profiles.map((x) => `\`${x}\``).join(', ') : 'none'}\n${sdlcLine}- ProductShape: ${manifest.integrations.productShape ?? 'not adopted'}\n- ArcShape: ${manifest.integrations.arcShape ?? 'not adopted'}\n- Public site: \`${publicSite}\`\n- Repository roots: ${repository.layout.map((x) => `\`${x}/\``).join(', ')}\n- Commit convention: \`${repository.conventions.commits}\`\n\n## Delivery contract\n\nProductShape remains the source of product intent and Product Changes. ArcShape remains the source of architecture documentation and decisions. Lighthouse installs the repository baseline and verifies the selected integrations without replacing either authority.\n\nThe repository keeps source code in \`src/\`, documentation in \`docs/\`, infrastructure in \`infra/\` and repository automation in \`scripts/\`. A project may record a narrow, owned exception, but an initializer never overwrites an existing project file.\n\nThe selected SDLC package owns lifecycle skills, commands, meta-commands, routes and gates. The \`.lighthouse/\` control plane records the selected package and host adapters without storing agent runtime data.\n\nWhen the public site is enabled, its publication is allowed only after ProductShape and ArcShape validate successfully. A missing or invalid authority stops the build before deployment and reports the command that can repair the missing input.\n\nThis file is managed. Put repository-specific policy in its own documentation and keep this projection generated from \`lighthouse.json\`.\n`;
226
+ const selectedUi = manifest.packages?.ui;
227
+ const uiLine = selectedUi ? `- UI package: \`${selectedUi.name}@${selectedUi.version}\`\n` : '';
228
+ return `<!-- Generated by Lighthouse. Edit lighthouse.json and regenerate this file. -->\n\n# ${manifest.product} repository baseline\n\n- Baseline: \`${manifest.baseline}\`\n- Product: \`${manifest.product}\`\n- Profiles: ${manifest.profiles.length ? manifest.profiles.map((x) => `\`${x}\``).join(', ') : 'none'}\n${sdlcLine}${uiLine}- ProductShape: ${manifest.integrations.productShape ?? 'not adopted'}\n- ArcShape: ${manifest.integrations.arcShape ?? 'not adopted'}\n- Public site: \`${publicSite}\`\n- Repository roots: ${repository.layout.map((x) => `\`${x}/\``).join(', ')}\n- Commit convention: \`${repository.conventions.commits}\`\n\n## Delivery contract\n\nProductShape remains the source of product intent and Product Changes. ArcShape remains the source of architecture documentation and decisions. Lighthouse installs the repository baseline and verifies the selected integrations without replacing either authority.\n\nThe repository keeps source code in \`src/\`, documentation in \`docs/\`, infrastructure in \`infra/\` and repository automation in \`scripts/\`. A project may record a narrow, owned exception, but an initializer never overwrites an existing project file.\n\nThe selected SDLC package owns lifecycle skills, commands, meta-commands, routes and gates. The \`.lighthouse/\` control plane records the selected package and host adapters without storing agent runtime data.\n\nWhen the public site is enabled, its publication is allowed only after ProductShape and ArcShape validate successfully. A missing or invalid authority stops the build before deployment and reports the command that can repair the missing input.\n\nThis file is managed. Put repository-specific policy in its own documentation and keep this projection generated from \`lighthouse.json\`.\n`;
171
229
  }
172
230
 
173
231
  function guardrailScript(manifest) {
@@ -259,6 +317,7 @@ function managedFiles(manifest) {
259
317
  '.husky/pre-push': prePushHook(),
260
318
  '.github/workflows/lighthouse.yml': workflow(),
261
319
  };
320
+ Object.assign(files, profileAssets(manifest));
262
321
  const controlPlane = sdlcControlPlane(manifest);
263
322
  if (controlPlane) files['.lighthouse/sdlc.json'] = controlPlane;
264
323
  return files;
@@ -269,9 +328,10 @@ function contentHash(content) {
269
328
  }
270
329
 
271
330
  function upgradeState(manifest, files = managedFiles(manifest), previous = null) {
331
+ const scopes = upgradeScopesFor(manifest);
272
332
  const entries = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, {
273
333
  hash: contentHash(content),
274
- scope: Object.entries(upgradeScopes).find(([, paths]) => paths.includes(path))?.[0] ?? 'harness',
334
+ scope: Object.entries(scopes).find(([, paths]) => paths.includes(path))?.[0] ?? 'harness',
275
335
  }]));
276
336
  return {
277
337
  $schema: upgradeSchema,
@@ -309,7 +369,8 @@ function stateProblems(state) {
309
369
  return problems;
310
370
  }
311
371
 
312
- function scopesFromArgs(args) {
372
+ function scopesFromArgs(args, manifest) {
373
+ const upgradeScopes = upgradeScopesFor(manifest);
313
374
  const selected = new Set();
314
375
  for (let index = 0; index < args.length; index += 1) {
315
376
  if (args[index] !== '--scope' && !args[index].startsWith('--scope=')) continue;
@@ -325,7 +386,8 @@ function scopesFromArgs(args) {
325
386
  return selected.size ? selected : new Set(Object.keys(upgradeScopes));
326
387
  }
327
388
 
328
- function selectedPaths(scopes) {
389
+ function selectedPaths(scopes, manifest) {
390
+ const upgradeScopes = upgradeScopesFor(manifest);
329
391
  return new Set([...scopes].flatMap((scope) => upgradeScopes[scope]));
330
392
  }
331
393
 
@@ -343,7 +405,8 @@ async function planUpgrade(manifest, scopes) {
343
405
  const state = await readUpgradeState();
344
406
  const problems = stateProblems(state);
345
407
  const files = managedFiles(manifest);
346
- const paths = selectedPaths(scopes);
408
+ const paths = selectedPaths(scopes, manifest);
409
+ const upgradeScopes = upgradeScopesFor(manifest);
347
410
  const changes = [];
348
411
  for (const path of Object.keys(files).filter((candidate) => paths.has(candidate))) {
349
412
  const target = resolve(root, path);
@@ -407,7 +470,7 @@ function printUpgradePlan(plan) {
407
470
  function validate(manifest) {
408
471
  const problems = [];
409
472
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return ['manifest must be an object'];
410
- const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
473
+ const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'application', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
411
474
  for (const key of Object.keys(manifest)) if (!allowed.includes(key)) problems.push(`unknown field ${key}`);
412
475
  for (const key of ['schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed']) {
413
476
  if (!(key in manifest)) problems.push(`missing ${key}`);
@@ -418,7 +481,16 @@ function validate(manifest) {
418
481
  if (typeof manifest.product !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(manifest.product)) {
419
482
  problems.push('product must be a readable name of 1–64 characters');
420
483
  }
421
- const profiles = ['node-tooling', 'dotnet-backend', 'visual-theme'];
484
+ if ('application' in manifest) {
485
+ const application = manifest.application;
486
+ if (!application || typeof application !== 'object' || Array.isArray(application)
487
+ || !['unknown', 'frontend', 'backend', 'full-stack', 'service', 'library', 'tooling', 'cli', 'worker', 'mobile'].includes(application.shape)
488
+ || !Array.isArray(application.stacks) || application.stacks.some((stack) => typeof stack !== 'string' || !stack)
489
+ || new Set(application.stacks).size !== application.stacks.length) {
490
+ problems.push('application must declare a supported shape and unique stack names');
491
+ }
492
+ }
493
+ const profiles = supportedProfiles;
422
494
  if (!Array.isArray(manifest.profiles) || manifest.profiles.some((p) => !profiles.includes(p))
423
495
  || new Set(manifest.profiles).size !== manifest.profiles.length) {
424
496
  problems.push('profiles must be unique supported profile names');
@@ -435,26 +507,42 @@ function validate(manifest) {
435
507
  if ('packages' in manifest) {
436
508
  const packages = manifest.packages;
437
509
  const selected = packages?.sdlc;
510
+ const selectedUi = packages?.ui;
511
+ const hasVisualProfile = manifest.profiles?.includes('visual-theme');
438
512
  if (!packages || typeof packages !== 'object' || Array.isArray(packages)
439
- || Object.keys(packages).length !== 1
513
+ || !Object.keys(packages).every((key) => ['sdlc', 'ui'].includes(key))
514
+ || Object.keys(packages).length < 1 || Object.keys(packages).length > 2
440
515
  || !selected || typeof selected !== 'object' || Array.isArray(selected)
441
516
  || Object.keys(selected).length !== 2
442
517
  || selected.name !== sdlcPackage.name
443
518
  || typeof selected.version !== 'string' || !pin.test(selected.version)) {
444
519
  problems.push(`packages.sdlc must declare ${sdlcPackage.name} and an exact version or commit hash`);
445
520
  }
521
+ if (hasVisualProfile && (!selectedUi || typeof selectedUi !== 'object' || Array.isArray(selectedUi)
522
+ || Object.keys(selectedUi).length !== 2
523
+ || selectedUi.name !== uiPackage.name
524
+ || typeof selectedUi.version !== 'string' || !pin.test(selectedUi.version))) {
525
+ problems.push(`packages.ui must declare ${uiPackage.name} and an exact version or commit hash when visual-theme is selected`);
526
+ }
527
+ if (!hasVisualProfile && selectedUi) problems.push('packages.ui requires the visual-theme profile');
528
+ } else if (manifest.profiles?.includes('visual-theme')) {
529
+ problems.push(`packages must declare ${sdlcPackage.name} and ${uiPackage.name} when visual-theme is selected`);
446
530
  }
447
531
  const legacyManaged = Array.isArray(manifest.managed)
448
532
  && manifest.managed.length === legacyManagedPaths.length
449
533
  && manifest.managed.every((entry, index) => entry === legacyManagedPaths[index]);
534
+ const expectedManagedPaths = managedPathsFor(manifest);
450
535
  const completeManaged = Array.isArray(manifest.managed)
536
+ && manifest.managed.length === expectedManagedPaths.length
537
+ && manifest.managed.every((entry, index) => entry === expectedManagedPaths[index]);
538
+ const profilelessManaged = Array.isArray(manifest.managed)
451
539
  && manifest.managed.length === managedPaths.length
452
540
  && manifest.managed.every((entry, index) => entry === managedPaths[index]);
453
541
  const previousCompleteManaged = Array.isArray(manifest.managed)
454
542
  && manifest.managed.length === previousManagedPaths.length
455
543
  && manifest.managed.every((entry, index) => entry === previousManagedPaths[index]);
456
- if (!legacyManaged && !previousCompleteManaged && !completeManaged) {
457
- problems.push(`managed must be ${legacyManagedPaths.join(', ')}, the previous Lighthouse guardrail set or the current complete set`);
544
+ if (!legacyManaged && !profilelessManaged && !previousCompleteManaged && !completeManaged) {
545
+ problems.push(`managed must be ${legacyManagedPaths.join(', ')}, the previous Lighthouse guardrail set or the selected profile set`);
458
546
  }
459
547
  if ('repository' in manifest) {
460
548
  const repository = manifest.repository;
@@ -563,7 +651,12 @@ function printReconciliationPlan(plan) {
563
651
  async function initialize(args) {
564
652
  let manifest = await readManifest();
565
653
  const manifestMissing = !manifest;
566
- if (!manifest) manifest = defaultManifest(productNameFromArgs(args));
654
+ const selectedProfiles = profileArguments(args);
655
+ if (!manifest) manifest = defaultManifest(productNameFromArgs(args), selectedProfiles);
656
+ else if (selectedProfiles.some((profile) => !(manifest.profiles ?? []).includes(profile))) {
657
+ const missing = selectedProfiles.filter((profile) => !(manifest.profiles ?? []).includes(profile));
658
+ throw new Error(`The existing lighthouse.json does not select ${missing.join(', ')}; edit its profiles and rerun lighthouse init.`);
659
+ }
567
660
  const problems = validate(manifest);
568
661
  if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
569
662
  const plan = await planInitialization(manifest, manifestMissing);
@@ -682,7 +775,7 @@ async function upgrade(args) {
682
775
  if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
683
776
  const problems = validate(manifest);
684
777
  if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
685
- const plan = await planUpgrade(manifest, scopesFromArgs(args));
778
+ const plan = await planUpgrade(manifest, scopesFromArgs(args, manifest));
686
779
  const format = formatFromArgs(args);
687
780
  const apply = args.includes('--apply');
688
781
  const dryRun = args.includes('--dry-run') || !apply;
@@ -849,7 +942,13 @@ async function verify(manifest, format = 'text') {
849
942
  readFile,
850
943
  validateGitConventions,
851
944
  branch: gitBranch(),
945
+ uiTokens: manifest?.profiles?.includes('visual-theme') ? packageAsset('@juangcarmona/lighthouse-ui', 'tokens.css') : null,
852
946
  });
947
+ const profileComposition = composeProfiles(readProfileCatalog(), manifest);
948
+ report.profiles = profileComposition;
949
+ const profileResult = profileCompositionResult(profileComposition);
950
+ report.results.push(profileResult);
951
+ report.summary[profileResult.status] = (report.summary[profileResult.status] ?? 0) + 1;
853
952
  const adoption = Object.values(capabilityAdoption(await readUpgradeState()));
854
953
  for (const item of adoption) {
855
954
  if (item.status !== 'adopted' || !item.recordPath || await exists(resolve(root, item.recordPath))) continue;
@@ -883,6 +982,20 @@ async function verify(manifest, format = 'text') {
883
982
  console.log('Lighthouse manifest, repository boundaries and managed projection are valid.');
884
983
  }
885
984
 
985
+ async function profiles(manifest, format = 'text') {
986
+ const composition = composeProfiles(readProfileCatalog(), manifest);
987
+ if (format === 'json') {
988
+ console.log(JSON.stringify(composition, null, 2));
989
+ if (composition.status === 'failed') process.exitCode = 1;
990
+ return;
991
+ }
992
+ console.log(`Lighthouse profile composition: ${composition.status}`);
993
+ console.log(` application ${composition.application.shape}${composition.application.stacks.length ? ` (${composition.application.stacks.join(', ')})` : ''}`);
994
+ for (const profile of composition.selected) console.log(` ${profile.id}: ${profile.status}${profile.nextAction ? `; next: ${profile.nextAction}` : ''}`);
995
+ if (!composition.selected.length) console.log(' no profiles selected');
996
+ if (composition.status === 'failed') process.exitCode = 1;
997
+ }
998
+
886
999
  function planHash(plan) {
887
1000
  const { authorization, ...payload } = plan;
888
1001
  return contentHash(JSON.stringify(payload));
@@ -1044,6 +1157,11 @@ function formatFromArgs(args) {
1044
1157
  const args = process.argv.slice(2);
1045
1158
  const command = args[0];
1046
1159
  const commandArgs = args.slice(1);
1160
+ if (command === 'create-dotnet') {
1161
+ process.argv.splice(2, 1);
1162
+ await import('./create-dotnet.mjs');
1163
+ process.exit(0);
1164
+ }
1047
1165
  let invalidArgument = false;
1048
1166
  for (let index = 0; index < commandArgs.length; index += 1) {
1049
1167
  const arg = commandArgs[index];
@@ -1052,13 +1170,22 @@ for (let index = 0; index < commandArgs.length; index += 1) {
1052
1170
  if (arg === '--apply') { if (!['authorities', 'reconcile', 'upgrade'].includes(command)) invalidArgument = true; continue; }
1053
1171
  if (arg === '--name') { if (!commandArgs[index + 1]) invalidArgument = true; else index += 1; continue; }
1054
1172
  if (arg.startsWith('--name=') && arg.length > '--name='.length) continue;
1173
+ if (arg === '--profile') {
1174
+ if (command !== 'init' || !commandArgs[index + 1]) invalidArgument = true;
1175
+ else index += 1;
1176
+ continue;
1177
+ }
1178
+ if (arg.startsWith('--profile=') && arg.length > '--profile='.length) {
1179
+ if (command !== 'init') invalidArgument = true;
1180
+ continue;
1181
+ }
1055
1182
  if (arg === '--format') {
1056
- if (!['verify', 'upgrade', 'capabilities'].includes(command) || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
1183
+ if (!['profiles', 'verify', 'upgrade', 'capabilities'].includes(command) || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
1057
1184
  else index += 1;
1058
1185
  continue;
1059
1186
  }
1060
1187
  if (arg.startsWith('--format=') && ['text', 'json'].includes(arg.slice('--format='.length))) {
1061
- if (!['verify', 'upgrade', 'capabilities'].includes(command)) invalidArgument = true;
1188
+ if (!['profiles', 'verify', 'upgrade', 'capabilities'].includes(command)) invalidArgument = true;
1062
1189
  continue;
1063
1190
  }
1064
1191
  if (arg === '--scope') {
@@ -1078,7 +1205,7 @@ for (let index = 0; index < commandArgs.length; index += 1) {
1078
1205
  if (['--id=', '--provider=', '--repo=', '--input=', '--plan-file=', '--authorize='].some((prefix) => arg.startsWith(prefix) && arg.length > prefix.length)) continue;
1079
1206
  invalidArgument = true;
1080
1207
  }
1081
- if (!['init', 'authorities', 'reconcile', 'upgrade', 'capabilities', 'verify'].includes(command) || invalidArgument) {
1208
+ if (!['init', 'profiles', 'authorities', 'reconcile', 'upgrade', 'capabilities', 'verify'].includes(command) || invalidArgument) {
1082
1209
  console.error(usage);
1083
1210
  process.exit(2);
1084
1211
  }
@@ -1088,6 +1215,11 @@ try {
1088
1215
  else if (command === 'reconcile') await reconcile(args.slice(1));
1089
1216
  else if (command === 'upgrade') await upgrade(args.slice(1));
1090
1217
  else if (command === 'capabilities') await capabilities(args.slice(1));
1218
+ else if (command === 'profiles') {
1219
+ const manifest = await readManifest();
1220
+ if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
1221
+ await profiles(manifest, formatFromArgs(commandArgs));
1222
+ }
1091
1223
  else {
1092
1224
  const manifest = await readManifest();
1093
1225
  if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
@@ -0,0 +1,133 @@
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
+
11
+ function profileProblems(profile) {
12
+ const problems = [];
13
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return ['profile must be an object'];
14
+ for (const key of ['id', 'owner', 'purpose', 'appliesTo', 'surfaces', 'verification']) if (!(key in profile)) problems.push(`missing ${key}`);
15
+ if (typeof profile.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(profile.id)) problems.push('id must use kebab-case');
16
+ if (typeof profile.owner !== 'string' || !profile.owner) problems.push('owner must be a non-empty string');
17
+ if (typeof profile.purpose !== 'string' || !profile.purpose) problems.push('purpose must be a non-empty string');
18
+ const applicability = profile.appliesTo;
19
+ if (!applicability || typeof applicability !== 'object' || Array.isArray(applicability)
20
+ || !Array.isArray(applicability.shapes) || !Array.isArray(applicability.stacks)
21
+ || applicability.shapes.length === 0 || applicability.shapes.some((shape) => typeof shape !== 'string' || !allowedShapes.has(shape))) {
22
+ problems.push('appliesTo must declare supported application shapes');
23
+ }
24
+ if (Array.isArray(applicability?.shapes) && new Set(applicability.shapes).size !== applicability.shapes.length) problems.push('appliesTo.shapes must be unique');
25
+ 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');
26
+ 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');
27
+ const layers = profile.verification?.layers;
28
+ if (!Array.isArray(layers) || layers.length === 0) problems.push('verification.layers must contain at least one layer');
29
+ const ids = new Set();
30
+ for (const layer of layers ?? []) {
31
+ if (!layer || typeof layer !== 'object' || Array.isArray(layer)) { problems.push('verification layer must be an object'); continue; }
32
+ for (const key of ['id', 'level', 'kind', 'verification']) if (!(key in layer)) problems.push(`verification layer missing ${key}`);
33
+ if (!allowedLayers.has(layer.id)) problems.push(`unsupported verification layer ${layer.id}`);
34
+ if (!allowedLevels.has(layer.level)) problems.push(`verification layer ${layer.id} has an invalid level`);
35
+ if (!allowedKinds.has(layer.kind)) problems.push(`verification layer ${layer.id} has an invalid kind`);
36
+ if (!allowedVerification.has(layer.verification)) problems.push(`verification layer ${layer.id} has an invalid verification method`);
37
+ 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`);
38
+ if (ids.has(layer.id)) problems.push(`duplicate verification layer ${layer.id}`);
39
+ ids.add(layer.id);
40
+ }
41
+ return problems;
42
+ }
43
+
44
+ function catalogProblems(catalog) {
45
+ if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return ['catalog must be an object'];
46
+ const problems = [];
47
+ if (catalog.schemaVersion !== 1) problems.push('schemaVersion must be 1');
48
+ if (!Array.isArray(catalog.profiles)) problems.push('profiles must be an array');
49
+ const ids = new Set();
50
+ for (const profile of catalog.profiles ?? []) {
51
+ problems.push(...profileProblems(profile).map((problem) => `${profile?.id ?? '<unknown>'}: ${problem}`));
52
+ if (profile?.id && ids.has(profile.id)) problems.push(`${profile.id}: duplicate id`);
53
+ if (profile?.id) ids.add(profile.id);
54
+ }
55
+ return problems;
56
+ }
57
+
58
+ export function readProfileCatalog() {
59
+ const catalog = JSON.parse(readFileSync(catalogPath, 'utf8'));
60
+ const problems = catalogProblems(catalog);
61
+ if (problems.length) throw new Error(`Invalid profile catalog:\n${problems.join('\n')}`);
62
+ return catalog;
63
+ }
64
+
65
+ export function normalizeApplication(application) {
66
+ if (!application || typeof application !== 'object' || Array.isArray(application)) return { shape: 'unknown', stacks: [] };
67
+ return {
68
+ shape: typeof application.shape === 'string' ? application.shape : 'unknown',
69
+ stacks: Array.isArray(application.stacks) ? [...application.stacks] : [],
70
+ };
71
+ }
72
+
73
+ function profileStatus(profile, application) {
74
+ const shapeKnown = application.shape !== 'unknown';
75
+ const stacksKnown = application.stacks.length > 0;
76
+ const shapeMatches = !shapeKnown || profile.appliesTo.shapes.includes('any') || profile.appliesTo.shapes.includes(application.shape);
77
+ const stackMatches = !stacksKnown || profile.appliesTo.stacks.length === 0 || profile.appliesTo.stacks.includes('any') || application.stacks.some((stack) => profile.appliesTo.stacks.includes(stack));
78
+ if (!shapeMatches || !stackMatches) return 'failed';
79
+ if ((!shapeKnown && profile.appliesTo.shapes.length > 0) || (profile.appliesTo.stacks.length > 0 && !stacksKnown)) return 'unconfigured';
80
+ return 'passed';
81
+ }
82
+
83
+ export function composeProfiles(catalog, manifest) {
84
+ const application = normalizeApplication(manifest?.application);
85
+ const selected = (manifest?.profiles ?? []).map((id) => {
86
+ const profile = catalog.profiles.find((item) => item.id === id);
87
+ const status = profile ? profileStatus(profile, application) : 'failed';
88
+ const problems = profile
89
+ ? status === 'failed'
90
+ ? [`${id} does not apply to application shape '${application.shape}' and stacks '${application.stacks.join(', ') || 'none'}'.`]
91
+ : status === 'unconfigured'
92
+ ? [`${id} needs application.shape and${profile.appliesTo.stacks.length ? ' application.stacks' : ''} in lighthouse.json.`]
93
+ : []
94
+ : [`${id} is not declared by the installed profile catalog.`];
95
+ return {
96
+ id,
97
+ owner: profile?.owner ?? '@juangcarmona/lighthouse',
98
+ purpose: profile?.purpose ?? null,
99
+ status,
100
+ appliesTo: profile?.appliesTo ?? null,
101
+ surfaces: profile?.surfaces ?? [],
102
+ verification: profile?.verification ?? { layers: [] },
103
+ problems,
104
+ nextAction: problems.length
105
+ ? status === 'unconfigured'
106
+ ? 'Declare application.shape and application.stacks, then run lighthouse profiles --format json.'
107
+ : 'Choose a compatible profile or correct application.shape and application.stacks.'
108
+ : null,
109
+ };
110
+ });
111
+ const status = selected.length === 0
112
+ ? 'not-applicable'
113
+ : selected.some((profile) => profile.status === 'failed')
114
+ ? 'failed'
115
+ : selected.some((profile) => profile.status === 'unconfigured')
116
+ ? 'unconfigured'
117
+ : 'passed';
118
+ return { schemaVersion: 1, application, selected, available: catalog.profiles.map(({ id, owner, purpose, appliesTo, surfaces, verification }) => ({ id, owner, purpose, appliesTo, surfaces, verification })), status };
119
+ }
120
+
121
+ export function profileCompositionResult(composition) {
122
+ return {
123
+ id: 'foundation.profile-composition',
124
+ owner: '@juangcarmona/lighthouse',
125
+ level: 'recommended',
126
+ kind: 'hard',
127
+ verification: 'deterministic',
128
+ status: composition.status,
129
+ evidence: composition.selected.map((profile) => ({ id: profile.id, status: profile.status, layers: profile.verification.layers.map((layer) => layer.id) })),
130
+ problems: composition.selected.flatMap((profile) => profile.problems),
131
+ nextAction: composition.selected.find((profile) => profile.nextAction)?.nextAction ?? null,
132
+ };
133
+ }
@@ -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 }); }