@juangcarmona/lighthouse 0.5.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 (48) 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 +48 -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/schemas/lighthouse-manifest.schema.json +10 -7
  43. package/assets/schemas/profile-catalog.schema.json +63 -0
  44. package/package.json +5 -2
  45. package/src/create-dotnet.mjs +112 -0
  46. package/src/lighthouse.mjs +63 -13
  47. package/src/profiles.mjs +133 -0
  48. package/src/verify-dotnet.mjs +85 -0
@@ -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 }); }