@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.
- package/README.md +9 -3
- package/assets/dotnet-template/Directory.Build.props +22 -0
- package/assets/dotnet-template/Directory.Packages.props +16 -0
- package/assets/dotnet-template/Lighthouse.Analyzers/BackendContractAnalyzer.cs +72 -0
- package/assets/dotnet-template/Lighthouse.Analyzers/Lighthouse.Analyzers.csproj +12 -0
- package/assets/dotnet-template/Lighthouse.Analyzers/packages.lock.json +121 -0
- package/assets/dotnet-template/Lighthouse.Cqrs/Handlers.cs +11 -0
- package/assets/dotnet-template/Lighthouse.Cqrs/Lighthouse.Cqrs.csproj +5 -0
- package/assets/dotnet-template/Lighthouse.Cqrs/packages.lock.json +6 -0
- package/assets/dotnet-template/Lighthouse.slnx +17 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/CatalogModule.cs +24 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/CreateEntry.cs +47 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/EntryStore.cs +17 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Features/Entries/GetEntry.cs +30 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/Lighthouse.Example.Modules.Catalog.csproj +12 -0
- package/assets/dotnet-template/examples/src/modules/Lighthouse.Example.Modules.Catalog/packages.lock.json +10 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Lighthouse.Example.Api.csproj +6 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Program.cs +25 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/Properties/launchSettings.json +10 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.Api/packages.lock.json +86 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Lighthouse.Example.AppHost.csproj +13 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Program.cs +3 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/Properties/launchSettings.json +15 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/aspire.config.json +5 -0
- package/assets/dotnet-template/examples/src/root/Lighthouse.Example.AppHost/packages.lock.json +689 -0
- package/assets/dotnet-template/global.json +6 -0
- package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/BackendContractTests.cs +102 -0
- package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/Lighthouse.Analyzers.Tests.csproj +9 -0
- package/assets/dotnet-template/tests/Lighthouse.Analyzers.Tests/packages.lock.json +125 -0
- package/assets/dotnet-template/tests/Lighthouse.Example.Tests/CatalogTests.cs +111 -0
- package/assets/dotnet-template/tests/Lighthouse.Example.Tests/Lighthouse.Example.Tests.csproj +9 -0
- package/assets/dotnet-template/tests/Lighthouse.Example.Tests/packages.lock.json +490 -0
- package/assets/guardrails/catalog.json +10 -0
- package/assets/profiles/catalog.json +48 -0
- package/assets/profiles/dotnet/README.md +32 -0
- package/assets/profiles/dotnet/SKILL.md +18 -0
- package/assets/profiles/dotnet/adoption.md +39 -0
- package/assets/profiles/dotnet/architecture.md +37 -0
- package/assets/profiles/dotnet/guardrails.md +32 -0
- package/assets/profiles/dotnet/practices.md +36 -0
- package/assets/profiles/dotnet/testing-and-development.md +29 -0
- package/assets/profiles/dotnet/use-cases.md +29 -0
- package/assets/schemas/lighthouse-manifest.schema.json +11 -7
- package/assets/schemas/profile-catalog.schema.json +63 -0
- package/package.json +6 -2
- package/src/create-dotnet.mjs +112 -0
- package/src/guardrails.mjs +17 -2
- package/src/lighthouse.mjs +156 -24
- package/src/profiles.mjs +133 -0
- package/src/verify-dotnet.mjs +85 -0
package/src/lighthouse.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
+
application: { shape: 'unknown', stacks: [] },
|
|
123
|
+
profiles: [...profiles],
|
|
68
124
|
integrations: { productShape: null, arcShape: null },
|
|
69
|
-
packages
|
|
70
|
-
managed:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
-
|
|
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>.');
|
package/src/profiles.mjs
ADDED
|
@@ -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('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
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 }); }
|