@juangcarmona/lighthouse 0.1.0 → 0.2.1
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 +33 -7
- package/assets/guardrails/catalog.json +46 -0
- package/assets/schemas/guardrails.schema.json +41 -0
- package/assets/schemas/lighthouse-manifest.schema.json +22 -2
- package/package.json +12 -2
- package/src/guardrails.mjs +140 -0
- package/src/lighthouse.mjs +599 -39
package/README.md
CHANGED
|
@@ -4,17 +4,25 @@ This package is the core Lighthouse harness. It provides the `lighthouse` comman
|
|
|
4
4
|
|
|
5
5
|
## Initialize a repository
|
|
6
6
|
|
|
7
|
-
Install the exact Lighthouse package
|
|
7
|
+
Install the exact Lighthouse package artifact selected for the release and run `lighthouse init --name <product>` in a new repository. Lighthouse creates the manifest, the `src/`, `docs/`, `infra/` and `scripts/` boundaries, the repository guardrails and the generated `LIGHTHOUSE.md`. Inspect the plan and projection, then run `lighthouse verify` before adding project-specific policy or exceptions.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Use `lighthouse reconcile` to preview missing Lighthouse-managed paths in an existing adoption. It does not write by default; add `--apply` only after reviewing the plan. Existing files whose content differs from the generated projection are conflicts and are never overwritten by reconciliation.
|
|
10
|
+
|
|
11
|
+
The harness package owns the baseline command and verification contract. The SDLC package owns lifecycle skills, commands, metacommands, routes and gates, and `lighthouse init` records its selected version in `.lighthouse/sdlc.json`. The UI package owns visual tokens, native recipes and `DESIGN.md`. Stack profiles own their source templates, guidance and checks.
|
|
10
12
|
|
|
11
13
|
ProductShape, ArcShape and OpenSpec keep their own files and commands. Add them through their supported integration routes when the project needs them, and keep their versions and managed paths visible in the project configuration.
|
|
12
14
|
|
|
15
|
+
Use `lighthouse authorities` to prepare the two baseline authorities through their own CLIs. The command previews both plans by default; `--apply` runs ProductShape's full model initialization and ArcShape's architecture initialization without installing agent capabilities. Provider-owned files remain owned by the provider, and Lighthouse records the exact adopted versions in `lighthouse.json` only after both applications succeed.
|
|
16
|
+
|
|
17
|
+
The generated manifest enables the public-site route for greenfield repositories. Its build validates ProductShape and ArcShape before creating either snapshot; if the authorities are not ready, the build stops with the repair command instead of publishing a partial site.
|
|
18
|
+
|
|
19
|
+
Initialization reports missing ProductShape and ArcShape authority inputs as adoption gaps. It does not copy or replace their provider-owned files. The SDLC package remains provider-neutral; an agent host decides where its skills and commands are projected.
|
|
20
|
+
|
|
13
21
|
## New repository
|
|
14
22
|
|
|
15
|
-
1.
|
|
16
|
-
2.
|
|
17
|
-
3.
|
|
23
|
+
1. Install the harness and the profile artifacts required by the application.
|
|
24
|
+
2. Run `lighthouse init --name <product>` and review the proposed repository boundaries.
|
|
25
|
+
3. Review the generated manifest and `LIGHTHOUSE.md`, then run `lighthouse verify`.
|
|
18
26
|
4. Add the selected profile checks to the repository verification command and CI workflow.
|
|
19
27
|
5. Record project-owned exceptions with an owner and review date.
|
|
20
28
|
|
|
@@ -22,12 +30,30 @@ ProductShape, ArcShape and OpenSpec keep their own files and commands. Add them
|
|
|
22
30
|
|
|
23
31
|
1. Inventory the current source layout, tools, managed files and CI before editing.
|
|
24
32
|
2. Map existing files to the selected Lighthouse contracts and identify collisions.
|
|
25
|
-
3. Adopt the manifest and verifier first;
|
|
33
|
+
3. Adopt the manifest and verifier first; the initializer reports conflicts and preserves product-owned guidance and generated outputs.
|
|
26
34
|
4. Add profiles one at a time and measure their checks before making a workflow status required.
|
|
27
35
|
5. Record each exception in the project-owned documentation and remove it when the product is ready.
|
|
28
36
|
|
|
29
37
|
## Current command scope
|
|
30
38
|
|
|
31
|
-
The
|
|
39
|
+
The command supports `init`, `authorities`, `reconcile` and `verify`. It checks the manifest, repository boundaries, Conventional Commits metadata, branch naming, the selected SDLC package projection and generated content. Lifecycle stages, tracker operations, agent runs and CodeGraph checks require their own configured integrations.
|
|
40
|
+
|
|
41
|
+
Initialization installs `.husky/commit-msg`, `.husky/pre-push`, `scripts/lighthouse-guard.mjs` and `.github/workflows/lighthouse.yml` when those paths are free. Existing files remain project-owned and are reported as conflicts. Reconciliation can add missing managed paths after adoption, but it does not overwrite a path that has drifted or belongs to another tool.
|
|
32
42
|
|
|
33
43
|
The package does not contain the repository gallery, provider-owned integrations or reference source. Those remain with the source repository or the profile that owns them.
|
|
44
|
+
|
|
45
|
+
## Guardrail catalog
|
|
46
|
+
|
|
47
|
+
The CLI package owns a versioned catalog under `assets/guardrails/`; it describes the guardrails already present in the Lighthouse baseline and their applicability, level, kind, verification method and repair hint.
|
|
48
|
+
|
|
49
|
+
`level` and `kind` are independent: `level` is `required`, `recommended` or `optional`, while `kind` is `hard` or `soft`; neither dimension selects the verification method.
|
|
50
|
+
|
|
51
|
+
`lighthouse verify` keeps its existing human-readable success and failure output; `lighthouse verify --format json` exposes the same run for agents and CI without changing the default route.
|
|
52
|
+
|
|
53
|
+
The catalog uses `passed` when evidence satisfies a check, `failed` when a deterministic check finds a problem, `drift` when managed content differs, `unavailable` when a declared checker cannot run or produce evidence, `unconfigured` when an applicable check or integration is not enabled, `pending-review` when semantic or human judgement is still required, and `not-applicable` when the selected baseline or profiles do not include the guardrail.
|
|
54
|
+
|
|
55
|
+
Only `passed` and `not-applicable` satisfy a required guardrail; `pending-review` is not evidence of compliance and is distinct from both `unconfigured` and `unavailable`.
|
|
56
|
+
|
|
57
|
+
To add a future guardrail, the catalog owner adds a JSON entry under the packaged `assets/guardrails/` surface, validates it against `assets/schemas/guardrails.schema.json`, declares its profile applicability and repair hint, and uses an existing checker or an explicit semantic or human verification method; in this delivery the surface belongs to the CLI package, and the CLI core does not change.
|
|
58
|
+
|
|
59
|
+
A new deterministic checker mechanism or a new engineering practice is outside this catalog and requires a separate Product Change.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../schemas/guardrails.schema.json",
|
|
3
|
+
"schemaVersion": 1,
|
|
4
|
+
"guardrails": [
|
|
5
|
+
{
|
|
6
|
+
"id": "foundation.manifest",
|
|
7
|
+
"owner": "lighthouse",
|
|
8
|
+
"appliesTo": { "baseline": true, "profiles": [] },
|
|
9
|
+
"level": "required",
|
|
10
|
+
"kind": "hard",
|
|
11
|
+
"verification": "deterministic",
|
|
12
|
+
"check": "manifest",
|
|
13
|
+
"failureHint": "Repair lighthouse.json and run lighthouse verify again."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "repository.layout",
|
|
17
|
+
"owner": "lighthouse",
|
|
18
|
+
"appliesTo": { "baseline": true, "profiles": [] },
|
|
19
|
+
"level": "required",
|
|
20
|
+
"kind": "hard",
|
|
21
|
+
"verification": "deterministic",
|
|
22
|
+
"check": "layout",
|
|
23
|
+
"failureHint": "Create the declared src/, docs/, infra/ and scripts/ repository boundaries."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "repository.managed-projection",
|
|
27
|
+
"owner": "lighthouse",
|
|
28
|
+
"appliesTo": { "baseline": true, "profiles": [] },
|
|
29
|
+
"level": "required",
|
|
30
|
+
"kind": "hard",
|
|
31
|
+
"verification": "deterministic",
|
|
32
|
+
"check": "managed-projection",
|
|
33
|
+
"failureHint": "Run lighthouse reconcile after reviewing the managed path plan."
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "repository.branch-convention",
|
|
37
|
+
"owner": "lighthouse",
|
|
38
|
+
"appliesTo": { "baseline": true, "profiles": [] },
|
|
39
|
+
"level": "required",
|
|
40
|
+
"kind": "hard",
|
|
41
|
+
"verification": "deterministic",
|
|
42
|
+
"check": "branch",
|
|
43
|
+
"failureHint": "Use the branch pattern declared in lighthouse.json."
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/juangcarmona/lighthouse/schemas/guardrails.schema.json",
|
|
4
|
+
"title": "Lighthouse guardrail catalog",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["$schema", "schemaVersion", "guardrails"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"$schema": { "type": "string" },
|
|
10
|
+
"schemaVersion": { "const": 1 },
|
|
11
|
+
"guardrails": {
|
|
12
|
+
"type": "array",
|
|
13
|
+
"items": { "$ref": "#/$defs/guardrail" }
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"$defs": {
|
|
17
|
+
"guardrail": {
|
|
18
|
+
"type": "object",
|
|
19
|
+
"additionalProperties": false,
|
|
20
|
+
"required": ["id", "owner", "appliesTo", "level", "kind", "verification", "check", "failureHint"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:[.-][a-z0-9]+)*$" },
|
|
23
|
+
"owner": { "type": "string", "minLength": 1 },
|
|
24
|
+
"appliesTo": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"required": ["baseline", "profiles"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"baseline": { "type": "boolean" },
|
|
30
|
+
"profiles": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"level": { "enum": ["required", "recommended", "optional"] },
|
|
34
|
+
"kind": { "enum": ["hard", "soft"] },
|
|
35
|
+
"verification": { "enum": ["deterministic", "semantic", "human"] },
|
|
36
|
+
"check": { "type": "string", "minLength": 1 },
|
|
37
|
+
"failureHint": { "type": "string", "minLength": 1 }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -11,8 +11,28 @@
|
|
|
11
11
|
"baseline": {"const": "0.1.0"},
|
|
12
12
|
"product": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$"},
|
|
13
13
|
"profiles": {"type": "array", "items": {"enum": ["node-tooling", "dotnet-backend", "visual-theme"]}, "uniqueItems": true},
|
|
14
|
-
"integrations": {"type": "object", "additionalProperties": false, "required": ["productShape", "arcShape"
|
|
15
|
-
"
|
|
14
|
+
"integrations": {"type": "object", "additionalProperties": false, "required": ["productShape", "arcShape"], "properties": {"productShape": {"$ref": "#/$defs/pin"}, "arcShape": {"$ref": "#/$defs/pin"}}},
|
|
15
|
+
"packages": {"type": "object", "additionalProperties": false, "required": ["sdlc"], "properties": {"sdlc": {"type": "object", "additionalProperties": false, "required": ["name", "version"], "properties": {"name": {"const": "@juangcarmona/lighthouse-sdlc"}, "version": {"$ref": "#/$defs/pin"}}}}},
|
|
16
|
+
"managed": {"anyOf": [
|
|
17
|
+
{"const": ["LIGHTHOUSE.md"]},
|
|
18
|
+
{"const": ["LIGHTHOUSE.md", "scripts/lighthouse-guard.mjs", ".husky/commit-msg", ".husky/pre-push", ".github/workflows/lighthouse.yml"]},
|
|
19
|
+
{"const": ["LIGHTHOUSE.md", "scripts/lighthouse-guard.mjs", ".husky/commit-msg", ".husky/pre-push", ".github/workflows/lighthouse.yml", ".lighthouse/sdlc.json"]}
|
|
20
|
+
]},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"required": ["layout", "publicSite", "conventions"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"layout": {"const": ["src", "docs", "infra", "scripts"]},
|
|
27
|
+
"publicSite": {"type": "object", "additionalProperties": false, "required": ["enabled"], "properties": {"enabled": {"type": "boolean"}}},
|
|
28
|
+
"conventions": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"required": ["commits", "branchPattern"],
|
|
32
|
+
"properties": {"commits": {"const": "conventional"}, "branchPattern": {"type": "string", "minLength": 1}}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
16
36
|
},
|
|
17
37
|
"$defs": {
|
|
18
38
|
"pin": {"type": ["string", "null"], "pattern": "^(?:\\d+\\.\\d+\\.\\d+|[a-f0-9]{40})$"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juangcarmona/lighthouse",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lighthouse adoption and verification CLI",
|
|
6
6
|
"repository": {
|
|
@@ -19,7 +19,17 @@
|
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=22"
|
|
21
21
|
},
|
|
22
|
-
"
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@arcshape/cli": "0.1.3",
|
|
24
|
+
"@prodshape/cli": "0.22.0",
|
|
25
|
+
"@juangcarmona/lighthouse-sdlc": "0.2.0"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src/lighthouse.mjs",
|
|
29
|
+
"src/guardrails.mjs",
|
|
30
|
+
"assets",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
23
33
|
"bin": {
|
|
24
34
|
"lighthouse": "./src/lighthouse.mjs"
|
|
25
35
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const catalogDirectory = fileURLToPath(new URL('../assets/guardrails/', import.meta.url));
|
|
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 allowedStatuses = new Set(['passed', 'failed', 'drift', 'unavailable', 'unconfigured', 'pending-review', 'not-applicable']);
|
|
10
|
+
|
|
11
|
+
function descriptorProblems(descriptor) {
|
|
12
|
+
const problems = [];
|
|
13
|
+
if (!descriptor || typeof descriptor !== 'object' || Array.isArray(descriptor)) return ['descriptor must be an object'];
|
|
14
|
+
for (const key of ['id', 'owner', 'appliesTo', 'level', 'kind', 'verification', 'check', 'failureHint']) {
|
|
15
|
+
if (!(key in descriptor)) problems.push(`missing ${key}`);
|
|
16
|
+
}
|
|
17
|
+
if (typeof descriptor.id !== 'string' || !/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/u.test(descriptor.id)) problems.push('id must use lowercase dot or hyphen notation');
|
|
18
|
+
if (typeof descriptor.owner !== 'string' || !descriptor.owner) problems.push('owner must be a non-empty string');
|
|
19
|
+
if (!descriptor.appliesTo || typeof descriptor.appliesTo !== 'object' || Array.isArray(descriptor.appliesTo)
|
|
20
|
+
|| typeof descriptor.appliesTo.baseline !== 'boolean' || !Array.isArray(descriptor.appliesTo.profiles)
|
|
21
|
+
|| descriptor.appliesTo.profiles.some((profile) => typeof profile !== 'string')) {
|
|
22
|
+
problems.push('appliesTo must declare baseline and profile names');
|
|
23
|
+
}
|
|
24
|
+
if (!allowedLevels.has(descriptor.level)) problems.push('level must be required, recommended or optional');
|
|
25
|
+
if (!allowedKinds.has(descriptor.kind)) problems.push('kind must be hard or soft');
|
|
26
|
+
if (!allowedVerification.has(descriptor.verification)) problems.push('verification must be deterministic, semantic or human');
|
|
27
|
+
if (typeof descriptor.check !== 'string' || !descriptor.check) problems.push('check must be a non-empty string');
|
|
28
|
+
if (typeof descriptor.failureHint !== 'string' || !descriptor.failureHint) problems.push('failureHint must be a non-empty string');
|
|
29
|
+
return problems;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function catalogProblems(catalog) {
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) return ['catalog must be an object'];
|
|
35
|
+
if (catalog.schemaVersion !== 1) problems.push('schemaVersion must be 1');
|
|
36
|
+
if (!Array.isArray(catalog.guardrails)) problems.push('guardrails must be an array');
|
|
37
|
+
const ids = new Set();
|
|
38
|
+
for (const descriptor of catalog.guardrails ?? []) {
|
|
39
|
+
problems.push(...descriptorProblems(descriptor).map((problem) => `${descriptor?.id ?? '<unknown>'}: ${problem}`));
|
|
40
|
+
if (descriptor?.id && ids.has(descriptor.id)) problems.push(`${descriptor.id}: duplicate id`);
|
|
41
|
+
if (descriptor?.id) ids.add(descriptor.id);
|
|
42
|
+
}
|
|
43
|
+
return problems;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readGuardrailCatalog() {
|
|
47
|
+
const files = readdirSync(catalogDirectory).filter((file) => file.endsWith('.json')).sort();
|
|
48
|
+
const catalogs = files.map((file) => JSON.parse(readFileSync(join(catalogDirectory, file), 'utf8')));
|
|
49
|
+
const catalog = {
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
guardrails: catalogs.flatMap((item) => item.guardrails ?? []),
|
|
52
|
+
};
|
|
53
|
+
const problems = catalogProblems(catalog);
|
|
54
|
+
if (problems.length) throw new Error(`Invalid guardrail catalog:\n${problems.join('\n')}`);
|
|
55
|
+
return catalog;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function initialReviewStatus(descriptor) {
|
|
59
|
+
return descriptor.verification === 'deterministic' ? 'unavailable' : 'pending-review';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function appliesTo(descriptor, manifest) {
|
|
63
|
+
const selectedProfiles = new Set(manifest.profiles ?? []);
|
|
64
|
+
return descriptor.appliesTo.baseline || descriptor.appliesTo.profiles.some((profile) => selectedProfiles.has(profile));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function result(descriptor, status, evidence = [], problems = []) {
|
|
68
|
+
return {
|
|
69
|
+
id: descriptor.id,
|
|
70
|
+
owner: descriptor.owner,
|
|
71
|
+
level: descriptor.level,
|
|
72
|
+
kind: descriptor.kind,
|
|
73
|
+
verification: descriptor.verification,
|
|
74
|
+
status,
|
|
75
|
+
evidence,
|
|
76
|
+
problems,
|
|
77
|
+
nextAction: status === 'passed' || status === 'not-applicable' ? null : descriptor.failureHint,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runDeterministicCheck(descriptor, context) {
|
|
82
|
+
if (descriptor.check === 'manifest') return result(descriptor, 'passed', [{ path: 'lighthouse.json' }]);
|
|
83
|
+
if (descriptor.check === 'layout') {
|
|
84
|
+
const missing = [];
|
|
85
|
+
for (const directory of context.repository.layout) {
|
|
86
|
+
if (!(await context.isDirectory(context.resolve(directory)))) missing.push(`missing repository boundary ${directory}/; run lighthouse init`);
|
|
87
|
+
}
|
|
88
|
+
return result(descriptor, missing.length ? 'failed' : 'passed', context.repository.layout.map((directory) => ({ path: `${directory}/` })), missing);
|
|
89
|
+
}
|
|
90
|
+
if (descriptor.check === 'managed-projection') {
|
|
91
|
+
const missing = [];
|
|
92
|
+
const drifted = [];
|
|
93
|
+
for (const [file, expected] of Object.entries(context.managedFiles)) {
|
|
94
|
+
const target = context.resolve(file);
|
|
95
|
+
if (!(await context.exists(target))) missing.push(`${file} is missing; run lighthouse init after reviewing the plan`);
|
|
96
|
+
else if (await context.readFile(target, 'utf8') !== expected) drifted.push(`${file} has drifted; regenerate it from lighthouse.json`);
|
|
97
|
+
}
|
|
98
|
+
const problems = [...missing, ...drifted];
|
|
99
|
+
return result(descriptor, drifted.length ? 'drift' : missing.length ? 'failed' : 'passed', Object.keys(context.managedFiles).map((path) => ({ path })), problems);
|
|
100
|
+
}
|
|
101
|
+
if (descriptor.check === 'branch') {
|
|
102
|
+
const problems = context.validateGitConventions(context.repository);
|
|
103
|
+
return result(descriptor, problems.length ? 'failed' : 'passed', [{ value: context.branch ?? '' }, { pattern: context.repository.conventions.branchPattern }], problems);
|
|
104
|
+
}
|
|
105
|
+
return result(descriptor, 'unavailable', [], [`No deterministic checker is registered for ${descriptor.id}.`]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function evaluateGuardrails({ catalog, manifest, manifestProblems, repository, managedFiles, resolve, exists, isDirectory, readFile, validateGitConventions, branch }) {
|
|
109
|
+
if (manifestProblems.length) {
|
|
110
|
+
const descriptor = catalog.guardrails.find((item) => item.check === 'manifest') ?? catalog.guardrails[0];
|
|
111
|
+
const failed = result(descriptor, 'failed', [{ path: 'lighthouse.json' }], manifestProblems.map((problem) => `Invalid lighthouse.json: ${problem}`));
|
|
112
|
+
return report([failed]);
|
|
113
|
+
}
|
|
114
|
+
const context = { manifest, repository, managedFiles, resolve, exists, isDirectory, readFile, validateGitConventions, branch };
|
|
115
|
+
const results = [];
|
|
116
|
+
for (const descriptor of catalog.guardrails) {
|
|
117
|
+
if (!appliesTo(descriptor, manifest)) {
|
|
118
|
+
results.push(result(descriptor, 'not-applicable'));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (descriptor.verification !== 'deterministic') {
|
|
122
|
+
results.push(result(descriptor, initialReviewStatus(descriptor)));
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
results.push(await runDeterministicCheck(descriptor, context));
|
|
126
|
+
}
|
|
127
|
+
return report(results);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function report(results) {
|
|
131
|
+
const blocking = results.filter((item) => item.level === 'required' && !['passed', 'not-applicable'].includes(item.status));
|
|
132
|
+
const summary = Object.fromEntries([...allowedStatuses].map((status) => [status, results.filter((item) => item.status === status).length]));
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
results,
|
|
136
|
+
summary,
|
|
137
|
+
blocking: blocking.map((item) => item.id),
|
|
138
|
+
problems: blocking.flatMap((item) => item.problems),
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/lighthouse.mjs
CHANGED
|
@@ -1,60 +1,620 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { access, chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { constants, readFileSync } from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
6
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { evaluateGuardrails, readGuardrailCatalog } from './guardrails.mjs';
|
|
4
8
|
|
|
5
9
|
const root = process.cwd();
|
|
10
|
+
const packageRequire = createRequire(import.meta.url);
|
|
6
11
|
const manifestPath = resolve(root, 'lighthouse.json');
|
|
7
12
|
const projection = 'LIGHTHOUSE.md';
|
|
8
|
-
const
|
|
13
|
+
const layout = ['src', 'docs', 'infra', 'scripts'];
|
|
14
|
+
const sdlcPackage = { name: '@juangcarmona/lighthouse-sdlc', version: '0.1.0' };
|
|
15
|
+
const managedPaths = [
|
|
16
|
+
projection,
|
|
17
|
+
'scripts/lighthouse-guard.mjs',
|
|
18
|
+
'.husky/commit-msg',
|
|
19
|
+
'.husky/pre-push',
|
|
20
|
+
'.github/workflows/lighthouse.yml',
|
|
21
|
+
'.lighthouse/sdlc.json',
|
|
22
|
+
];
|
|
23
|
+
const legacyManagedPaths = [projection];
|
|
24
|
+
const branchPattern = '^(feat|fix|docs|refactor|test|chore|build|ci|perf|revert)/[1-9]\\d*-[a-z0-9]+(?:-[a-z0-9]+)*$';
|
|
25
|
+
const authorityVersions = { productShape: '0.22.0', arcShape: '0.1.3' };
|
|
26
|
+
const usage = 'Usage: lighthouse <init|authorities|reconcile|verify> [--name <product>] [--format <text|json>] [--dry-run] [--apply]';
|
|
27
|
+
|
|
28
|
+
const defaultRepository = () => ({
|
|
29
|
+
layout: [...layout],
|
|
30
|
+
publicSite: { enabled: true },
|
|
31
|
+
conventions: { commits: 'conventional', branchPattern },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function defaultManifest(product) {
|
|
35
|
+
return {
|
|
36
|
+
$schema: './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-manifest.schema.json',
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
baseline: '0.1.0',
|
|
39
|
+
product,
|
|
40
|
+
profiles: [],
|
|
41
|
+
integrations: { productShape: null, arcShape: null },
|
|
42
|
+
packages: { sdlc: { ...sdlcPackage } },
|
|
43
|
+
managed: [...managedPaths],
|
|
44
|
+
repository: defaultRepository(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeRepository(repository) {
|
|
49
|
+
const value = repository && typeof repository === 'object' && !Array.isArray(repository) ? repository : {};
|
|
50
|
+
return {
|
|
51
|
+
layout: Array.isArray(value.layout) ? value.layout : [...layout],
|
|
52
|
+
publicSite: value.publicSite && typeof value.publicSite === 'object'
|
|
53
|
+
? { enabled: value.publicSite.enabled !== false }
|
|
54
|
+
: { enabled: true },
|
|
55
|
+
conventions: value.conventions && typeof value.conventions === 'object'
|
|
56
|
+
? {
|
|
57
|
+
commits: value.conventions.commits ?? 'conventional',
|
|
58
|
+
branchPattern: value.conventions.branchPattern ?? branchPattern,
|
|
59
|
+
}
|
|
60
|
+
: { commits: 'conventional', branchPattern },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeSdlcPackage(manifest) {
|
|
65
|
+
const value = manifest?.packages?.sdlc;
|
|
66
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
67
|
+
return { name: value.name, version: value.version };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sdlcContract() {
|
|
71
|
+
try {
|
|
72
|
+
const path = packageRequire.resolve('@juangcarmona/lighthouse-sdlc/contract.json');
|
|
73
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return {
|
|
76
|
+
routes: ['supervised', 'plan-only', 'unattended', 'hotfix', 'stacked'],
|
|
77
|
+
gates: ['triaged', 'ready', 'planned', 'done'],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sdlcControlPlane(manifest) {
|
|
83
|
+
const selected = normalizeSdlcPackage(manifest);
|
|
84
|
+
if (!selected) return null;
|
|
85
|
+
const contract = sdlcContract();
|
|
86
|
+
return `${JSON.stringify({
|
|
87
|
+
$schema: './node_modules/@juangcarmona/lighthouse-sdlc/schemas/control-plane.schema.json',
|
|
88
|
+
schemaVersion: 1,
|
|
89
|
+
package: selected,
|
|
90
|
+
routes: contract.routes,
|
|
91
|
+
gates: contract.gates,
|
|
92
|
+
state: { provider: 'agent-host' },
|
|
93
|
+
memory: { provider: 'agent-host', scopes: ['run', 'project'] },
|
|
94
|
+
roles: {},
|
|
95
|
+
adapters: {},
|
|
96
|
+
}, null, 2)}\n`;
|
|
97
|
+
}
|
|
9
98
|
|
|
10
99
|
function render(manifest) {
|
|
11
|
-
|
|
100
|
+
const repository = normalizeRepository(manifest.repository);
|
|
101
|
+
const publicSite = repository.publicSite.enabled ? 'enabled' : 'disabled';
|
|
102
|
+
const selectedSdlc = normalizeSdlcPackage(manifest);
|
|
103
|
+
const sdlcLine = selectedSdlc ? `- SDLC package: \`${selectedSdlc.name}@${selectedSdlc.version}\`\n` : '';
|
|
104
|
+
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`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function guardrailScript(manifest) {
|
|
108
|
+
const pattern = JSON.stringify(normalizeRepository(manifest.repository).conventions.branchPattern);
|
|
109
|
+
return `#!/usr/bin/env node
|
|
110
|
+
import { readFileSync } from 'node:fs';
|
|
111
|
+
import { execFileSync } from 'node:child_process';
|
|
112
|
+
|
|
113
|
+
const mode = process.argv[2];
|
|
114
|
+
const branchPattern = new RegExp(${pattern}, 'u');
|
|
115
|
+
function fail(message) { console.error(message); process.exit(1); }
|
|
116
|
+
function branch() {
|
|
117
|
+
try { return execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim(); }
|
|
118
|
+
catch { return ''; }
|
|
119
|
+
}
|
|
120
|
+
if (mode === 'commit-msg') {
|
|
121
|
+
const file = process.argv[3];
|
|
122
|
+
if (!file) fail('Usage: lighthouse-guard commit-msg <message-file>');
|
|
123
|
+
const message = readFileSync(file, 'utf8').split('\\n').find((line) => line.trim() && !line.startsWith('#'))?.trim() ?? '';
|
|
124
|
+
if (!/^(?:feat|fix|docs|refactor|test|chore|build|ci|perf|revert)(?:\\([^)]+\\))?!?: .+/u.test(message)) fail('Commit message must follow Conventional Commits.');
|
|
125
|
+
} else if (mode === 'branch' || mode === 'verify') {
|
|
126
|
+
const current = branch();
|
|
127
|
+
if (current && !['main', 'master'].includes(current) && !branchPattern.test(current)) fail('Branch ' + current + ' does not match ' + branchPattern.source + '.');
|
|
128
|
+
} else fail('Usage: lighthouse-guard <commit-msg|branch|verify>');
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function commitHook() {
|
|
133
|
+
return '#!/usr/bin/env sh\nset -eu\nnode scripts/lighthouse-guard.mjs commit-msg "$1"\n';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function prePushHook() {
|
|
137
|
+
return '#!/usr/bin/env sh\nset -eu\nnode scripts/lighthouse-guard.mjs branch\n';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function workflow() {
|
|
141
|
+
return `name: Lighthouse repository guardrails
|
|
142
|
+
|
|
143
|
+
on:
|
|
144
|
+
pull_request:
|
|
145
|
+
paths:
|
|
146
|
+
- "lighthouse.json"
|
|
147
|
+
- "LIGHTHOUSE.md"
|
|
148
|
+
- "src/**"
|
|
149
|
+
- "docs/**"
|
|
150
|
+
- "infra/**"
|
|
151
|
+
- "scripts/**"
|
|
152
|
+
- ".husky/**"
|
|
153
|
+
- ".github/workflows/lighthouse.yml"
|
|
154
|
+
push:
|
|
155
|
+
branches: [main]
|
|
156
|
+
paths:
|
|
157
|
+
- "lighthouse.json"
|
|
158
|
+
- "LIGHTHOUSE.md"
|
|
159
|
+
- "src/**"
|
|
160
|
+
- "docs/**"
|
|
161
|
+
- "infra/**"
|
|
162
|
+
- "scripts/**"
|
|
163
|
+
- ".husky/**"
|
|
164
|
+
- ".github/workflows/lighthouse.yml"
|
|
165
|
+
workflow_dispatch:
|
|
166
|
+
|
|
167
|
+
permissions:
|
|
168
|
+
contents: read
|
|
169
|
+
|
|
170
|
+
jobs:
|
|
171
|
+
verify:
|
|
172
|
+
name: Verify Lighthouse foundation
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
timeout-minutes: 10
|
|
175
|
+
steps:
|
|
176
|
+
- uses: actions/checkout@v4
|
|
177
|
+
- uses: actions/setup-node@v4
|
|
178
|
+
with:
|
|
179
|
+
node-version: 22
|
|
180
|
+
cache: npm
|
|
181
|
+
- run: npm ci --ignore-scripts
|
|
182
|
+
- run: |
|
|
183
|
+
if [ -f scripts/lighthouse.mjs ]; then node scripts/lighthouse.mjs verify; else npx --no-install lighthouse verify; fi
|
|
184
|
+
- run: node scripts/lighthouse-guard.mjs verify
|
|
185
|
+
`;
|
|
12
186
|
}
|
|
13
187
|
|
|
14
|
-
function
|
|
188
|
+
function managedFiles(manifest) {
|
|
189
|
+
const files = {
|
|
190
|
+
[projection]: render(manifest),
|
|
191
|
+
'scripts/lighthouse-guard.mjs': guardrailScript(manifest),
|
|
192
|
+
'.husky/commit-msg': commitHook(),
|
|
193
|
+
'.husky/pre-push': prePushHook(),
|
|
194
|
+
'.github/workflows/lighthouse.yml': workflow(),
|
|
195
|
+
};
|
|
196
|
+
const controlPlane = sdlcControlPlane(manifest);
|
|
197
|
+
if (controlPlane) files['.lighthouse/sdlc.json'] = controlPlane;
|
|
198
|
+
return files;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validate(manifest) {
|
|
15
202
|
const problems = [];
|
|
16
|
-
if (!
|
|
17
|
-
const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed'];
|
|
18
|
-
for (const key of Object.keys(
|
|
19
|
-
for (const key of ['schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed'])
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
if (
|
|
203
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return ['manifest must be an object'];
|
|
204
|
+
const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
|
|
205
|
+
for (const key of Object.keys(manifest)) if (!allowed.includes(key)) problems.push(`unknown field ${key}`);
|
|
206
|
+
for (const key of ['schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed']) {
|
|
207
|
+
if (!(key in manifest)) problems.push(`missing ${key}`);
|
|
208
|
+
}
|
|
209
|
+
if (manifest.schemaVersion !== 1) problems.push('schemaVersion must be 1');
|
|
210
|
+
if (manifest.baseline !== '0.1.0') problems.push('this verifier supports baseline 0.1.0');
|
|
211
|
+
if ('$schema' in manifest && typeof manifest.$schema !== 'string') problems.push('$schema must be a string');
|
|
212
|
+
if (typeof manifest.product !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(manifest.product)) {
|
|
213
|
+
problems.push('product must be a readable name of 1–64 characters');
|
|
214
|
+
}
|
|
24
215
|
const profiles = ['node-tooling', 'dotnet-backend', 'visual-theme'];
|
|
25
|
-
if (!Array.isArray(
|
|
26
|
-
|
|
27
|
-
|
|
216
|
+
if (!Array.isArray(manifest.profiles) || manifest.profiles.some((p) => !profiles.includes(p))
|
|
217
|
+
|| new Set(manifest.profiles).size !== manifest.profiles.length) {
|
|
218
|
+
problems.push('profiles must be unique supported profile names');
|
|
219
|
+
}
|
|
220
|
+
const integrations = manifest.integrations;
|
|
221
|
+
const keys = ['productShape', 'arcShape'];
|
|
28
222
|
const pin = /^(?:\d+\.\d+\.\d+|[a-f0-9]{40})$/;
|
|
29
223
|
if (!integrations || typeof integrations !== 'object' || Array.isArray(integrations)
|
|
30
224
|
|| Object.keys(integrations).length !== keys.length
|
|
31
|
-
|| keys.some((
|
|
32
|
-
|
|
33
|
-
|
|
225
|
+
|| keys.some((key) => !(key in integrations)
|
|
226
|
+
|| (integrations[key] !== null && (typeof integrations[key] !== 'string' || !pin.test(integrations[key]))))) {
|
|
227
|
+
problems.push('integrations must declare productShape and arcShape as exact versions, commit hashes or null');
|
|
228
|
+
}
|
|
229
|
+
if ('packages' in manifest) {
|
|
230
|
+
const packages = manifest.packages;
|
|
231
|
+
const selected = packages?.sdlc;
|
|
232
|
+
if (!packages || typeof packages !== 'object' || Array.isArray(packages)
|
|
233
|
+
|| Object.keys(packages).length !== 1
|
|
234
|
+
|| !selected || typeof selected !== 'object' || Array.isArray(selected)
|
|
235
|
+
|| Object.keys(selected).length !== 2
|
|
236
|
+
|| selected.name !== sdlcPackage.name
|
|
237
|
+
|| typeof selected.version !== 'string' || !pin.test(selected.version)) {
|
|
238
|
+
problems.push(`packages.sdlc must declare ${sdlcPackage.name} and an exact version or commit hash`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const legacyManaged = Array.isArray(manifest.managed)
|
|
242
|
+
&& manifest.managed.length === legacyManagedPaths.length
|
|
243
|
+
&& manifest.managed.every((entry, index) => entry === legacyManagedPaths[index]);
|
|
244
|
+
const completeManaged = Array.isArray(manifest.managed)
|
|
245
|
+
&& manifest.managed.length === managedPaths.length
|
|
246
|
+
&& manifest.managed.every((entry, index) => entry === managedPaths[index]);
|
|
247
|
+
if (!legacyManaged && !completeManaged) {
|
|
248
|
+
problems.push(`managed must be ${legacyManagedPaths.join(', ')} or the complete Lighthouse guardrail set`);
|
|
249
|
+
}
|
|
250
|
+
if ('repository' in manifest) {
|
|
251
|
+
const repository = manifest.repository;
|
|
252
|
+
if (!repository || typeof repository !== 'object' || Array.isArray(repository)) {
|
|
253
|
+
problems.push('repository must be an object');
|
|
254
|
+
} else {
|
|
255
|
+
if (!Array.isArray(repository.layout) || repository.layout.length !== layout.length
|
|
256
|
+
|| repository.layout.some((entry, index) => entry !== layout[index])) {
|
|
257
|
+
problems.push(`repository.layout must be ${layout.map((entry) => `'${entry}'`).join(', ')}`);
|
|
258
|
+
}
|
|
259
|
+
if (!repository.publicSite || typeof repository.publicSite.enabled !== 'boolean') {
|
|
260
|
+
problems.push('repository.publicSite.enabled must be a boolean');
|
|
261
|
+
}
|
|
262
|
+
if (!repository.conventions || repository.conventions.commits !== 'conventional'
|
|
263
|
+
|| typeof repository.conventions.branchPattern !== 'string') {
|
|
264
|
+
problems.push('repository.conventions must declare conventional commits and a branchPattern');
|
|
265
|
+
} else {
|
|
266
|
+
try { new RegExp(repository.conventions.branchPattern, 'u'); }
|
|
267
|
+
catch { problems.push('repository.conventions.branchPattern must be a valid regular expression'); }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
34
271
|
return problems;
|
|
35
272
|
}
|
|
36
273
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
274
|
+
async function exists(path) {
|
|
275
|
+
try { await access(path, constants.F_OK); return true; } catch { return false; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function isDirectory(path) {
|
|
279
|
+
try { return (await stat(path)).isDirectory(); } catch { return false; }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readManifest() {
|
|
283
|
+
try { return JSON.parse(await readFile(manifestPath, 'utf8')); }
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (error.code === 'ENOENT') return null;
|
|
286
|
+
throw new Error(`Cannot read lighthouse.json: ${error.message}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function productNameFromArgs(args) {
|
|
291
|
+
const index = args.indexOf('--name');
|
|
292
|
+
const value = index === -1 ? args.find((arg) => arg.startsWith('--name='))?.slice('--name='.length) : args[index + 1];
|
|
293
|
+
if (index === -1 && !value) return basename(root).replace(/[^A-Za-z0-9 ._-]/gu, '').slice(0, 64) || 'adopter';
|
|
294
|
+
if (!value) throw new Error('--name requires a product name');
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function planInitialization(manifest, manifestMissing) {
|
|
299
|
+
const operations = [];
|
|
300
|
+
const conflicts = [];
|
|
301
|
+
if (manifestMissing) operations.push({ path: 'lighthouse.json', action: 'create' });
|
|
302
|
+
const files = managedFiles(manifest);
|
|
303
|
+
const directories = new Set(normalizeRepository(manifest.repository).layout);
|
|
304
|
+
for (const file of Object.keys(files)) {
|
|
305
|
+
let directory = dirname(file);
|
|
306
|
+
while (directory !== '.') {
|
|
307
|
+
directories.add(directory);
|
|
308
|
+
directory = dirname(directory);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const directory of [...directories].sort((left, right) => left.split('/').length - right.split('/').length)) {
|
|
312
|
+
const path = resolve(root, directory);
|
|
313
|
+
if (!(await exists(path))) operations.push({ path: `${directory}/`, action: 'create' });
|
|
314
|
+
else if (!(await isDirectory(path))) conflicts.push(`${directory} exists but is not a directory`);
|
|
315
|
+
}
|
|
316
|
+
for (const file of Object.keys(files)) {
|
|
317
|
+
if (await exists(resolve(root, file))) conflicts.push(`${file} already exists; refusing to overwrite it`);
|
|
318
|
+
else operations.push({ path: file, action: 'create' });
|
|
319
|
+
}
|
|
320
|
+
return { operations, conflicts };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function planReconciliation(manifest) {
|
|
324
|
+
const operations = [];
|
|
325
|
+
const conflicts = [];
|
|
326
|
+
for (const [path, expected] of Object.entries(managedFiles(manifest))) {
|
|
327
|
+
const target = resolve(root, path);
|
|
328
|
+
if (!(await exists(target))) {
|
|
329
|
+
operations.push({ path, action: 'create', content: expected });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const current = await readFile(target, 'utf8');
|
|
334
|
+
if (current === expected) operations.push({ path, action: 'unchanged' });
|
|
335
|
+
else conflicts.push(`${path} exists with different content; refusing to overwrite it`);
|
|
336
|
+
} catch {
|
|
337
|
+
conflicts.push(`${path} exists but is not a readable file; refusing to overwrite it`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { operations, conflicts };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function printReconciliationPlan(plan) {
|
|
344
|
+
console.log('Lighthouse reconciliation plan:');
|
|
345
|
+
for (const operation of plan.operations) console.log(` ${operation.action} ${operation.path}`);
|
|
346
|
+
for (const conflict of plan.conflicts) console.error(` conflict ${conflict}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function initialize(args) {
|
|
350
|
+
let manifest = await readManifest();
|
|
351
|
+
const manifestMissing = !manifest;
|
|
352
|
+
if (!manifest) manifest = defaultManifest(productNameFromArgs(args));
|
|
353
|
+
const problems = validate(manifest);
|
|
354
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
355
|
+
const plan = await planInitialization(manifest, manifestMissing);
|
|
356
|
+
console.log('Lighthouse initialization plan:');
|
|
357
|
+
for (const operation of plan.operations) console.log(` ${operation.action} ${operation.path}`);
|
|
358
|
+
for (const conflict of plan.conflicts) console.error(` conflict ${conflict}`);
|
|
359
|
+
if (normalizeRepository(manifest.repository).publicSite.enabled) {
|
|
360
|
+
if (manifest.integrations.productShape === null) console.log(' adoption gap ProductShape: review lighthouse authorities --dry-run, then run lighthouse authorities --apply');
|
|
361
|
+
if (manifest.integrations.arcShape === null) console.log(' adoption gap ArcShape: review lighthouse authorities --dry-run, then run lighthouse authorities --apply');
|
|
362
|
+
}
|
|
363
|
+
if (args.includes('--dry-run')) {
|
|
364
|
+
if (plan.conflicts.length) process.exitCode = 1;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (plan.conflicts.length) throw new Error('Initialization stopped; resolve the reported conflicts and run lighthouse init again.');
|
|
368
|
+
if (manifestMissing) await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
369
|
+
for (const operation of plan.operations.filter((item) => item.path.endsWith('/'))) {
|
|
370
|
+
await mkdir(resolve(root, operation.path), { recursive: true });
|
|
371
|
+
}
|
|
372
|
+
const files = managedFiles(manifest);
|
|
373
|
+
for (const [file, content] of Object.entries(files)) {
|
|
374
|
+
await writeFile(resolve(root, file), content, { flag: 'wx', mode: file.startsWith('.husky/') ? 0o755 : 0o644 });
|
|
375
|
+
if (file.startsWith('.husky/')) await chmod(resolve(root, file), 0o755);
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const configured = execFileSync('git', ['config', '--get', 'core.hooksPath'], { cwd: root, encoding: 'utf8' }).trim();
|
|
379
|
+
if (!configured) execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: root, stdio: 'ignore' });
|
|
380
|
+
} catch { /* A non-git target can adopt the hooks when it creates its repository. */ }
|
|
381
|
+
console.log(`Initialized ${manifest.product}. Review lighthouse.json and the managed guardrails, then run lighthouse verify.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function reconcile(args) {
|
|
385
|
+
const manifest = await readManifest();
|
|
386
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
387
|
+
const problems = validate(manifest);
|
|
388
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
389
|
+
const plan = await planReconciliation(manifest);
|
|
390
|
+
printReconciliationPlan(plan);
|
|
391
|
+
const apply = args.includes('--apply');
|
|
392
|
+
const dryRun = args.includes('--dry-run') || !apply;
|
|
393
|
+
if (args.includes('--apply') && args.includes('--dry-run')) throw new Error('--apply and --dry-run cannot be used together.');
|
|
394
|
+
if (plan.conflicts.length) {
|
|
395
|
+
if (dryRun) process.exitCode = 1;
|
|
396
|
+
else throw new Error('Reconciliation stopped; resolve the reported conflicts and run lighthouse reconcile again.');
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (dryRun) {
|
|
400
|
+
console.log('Dry run: nothing was changed. Re-run with --apply to create missing managed paths.');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
for (const operation of plan.operations.filter((item) => item.action === 'create')) {
|
|
404
|
+
await mkdir(dirname(resolve(root, operation.path)), { recursive: true });
|
|
405
|
+
try {
|
|
406
|
+
await writeFile(resolve(root, operation.path), operation.content, { flag: 'wx', mode: operation.path.startsWith('.husky/') ? 0o755 : 0o644 });
|
|
407
|
+
if (operation.path.startsWith('.husky/')) await chmod(resolve(root, operation.path), 0o755);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error.code === 'EEXIST') throw new Error(`Reconciliation stopped; ${operation.path} appeared during the plan.`);
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
console.log('Reconciled missing Lighthouse-managed paths. Run lighthouse verify to check the complete projection.');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const authorityCommands = [
|
|
417
|
+
{
|
|
418
|
+
label: 'ProductShape',
|
|
419
|
+
packageName: '@prodshape/cli',
|
|
420
|
+
command: 'prodshape',
|
|
421
|
+
initArgs: ['init', '--full', '--sdd', 'none', '--gitignore'],
|
|
422
|
+
repair: 'prodshape init --full --sdd none --gitignore',
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
label: 'ArcShape',
|
|
426
|
+
packageName: '@arcshape/cli',
|
|
427
|
+
command: 'arcshape',
|
|
428
|
+
initArgs: ['init', '--no-agents'],
|
|
429
|
+
applyArgs: ['init', '--no-agents', '--yes'],
|
|
430
|
+
repair: 'arcshape init --no-agents --yes',
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
function providerExecutable(authority) {
|
|
435
|
+
const packageJson = packageRequire.resolve(`${authority.packageName}/package.json`);
|
|
436
|
+
const metadata = JSON.parse(readFileSync(packageJson, 'utf8'));
|
|
437
|
+
const binary = typeof metadata.bin === 'string' ? metadata.bin : metadata.bin?.[authority.command];
|
|
438
|
+
if (!binary) throw new Error(`${authority.packageName} does not declare a ${authority.command} executable`);
|
|
439
|
+
return resolve(dirname(packageJson), binary);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function providerHasConflict(output) {
|
|
443
|
+
return output.split('\n').some((line) => /^\s*conflict\b/i.test(line))
|
|
444
|
+
|| /Conflicts\s*\(\s*[1-9]\d*\s*\)/i.test(output);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function runAuthorityCommand(authority, dryRun) {
|
|
448
|
+
const initArgs = dryRun ? authority.initArgs : (authority.applyArgs ?? authority.initArgs);
|
|
449
|
+
const args = [providerExecutable(authority), ...initArgs];
|
|
450
|
+
if (dryRun) args.push('--dry-run');
|
|
451
|
+
const result = spawnSync(process.execPath, args, {
|
|
452
|
+
cwd: root,
|
|
453
|
+
encoding: 'utf8',
|
|
454
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
455
|
+
});
|
|
456
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
457
|
+
console.log(`\n${authority.label} ${dryRun ? 'preflight' : 'apply'}:`);
|
|
458
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
459
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
460
|
+
return {
|
|
461
|
+
ok: result.status === 0 && !providerHasConflict(output),
|
|
462
|
+
output,
|
|
463
|
+
status: result.status,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function writeManifestAtomically(manifest) {
|
|
468
|
+
const temporary = `${manifestPath}.tmp-${process.pid}`;
|
|
469
|
+
await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
470
|
+
await rename(temporary, manifestPath);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async function ensureProjectionReady(manifest) {
|
|
474
|
+
const target = resolve(root, projection);
|
|
52
475
|
try {
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
476
|
+
const current = await readFile(target, 'utf8');
|
|
477
|
+
if (current !== render(manifest)) {
|
|
478
|
+
throw new Error(`${projection} has drifted; regenerate it from lighthouse.json before running lighthouse authorities --apply.`);
|
|
479
|
+
}
|
|
55
480
|
} catch (error) {
|
|
56
|
-
if (error.code
|
|
57
|
-
|
|
481
|
+
if (error.code !== 'ENOENT') throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function writeProjection(manifest) {
|
|
486
|
+
const target = resolve(root, projection);
|
|
487
|
+
await writeFile(target, render(manifest), { flag: 'w' });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function authorities(args) {
|
|
491
|
+
const manifest = await readManifest();
|
|
492
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
493
|
+
const problems = validate(manifest);
|
|
494
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
495
|
+
const apply = args.includes('--apply');
|
|
496
|
+
const dryRun = args.includes('--dry-run') || !apply;
|
|
497
|
+
if (args.includes('--apply') && args.includes('--dry-run')) throw new Error('--apply and --dry-run cannot be used together.');
|
|
498
|
+
console.log('Lighthouse authority plan:');
|
|
499
|
+
console.log(' ProductShape: initialize the full ProductShape layout without an SDD integration');
|
|
500
|
+
console.log(' ArcShape: initialize arc42 documentation without installing agent capabilities');
|
|
501
|
+
console.log(' Provider-owned files remain under provider control; Lighthouse records only exact versions.');
|
|
502
|
+
const preflights = authorityCommands.map((authority) => ({ authority, result: runAuthorityCommand(authority, true) }));
|
|
503
|
+
const failed = preflights.filter(({ result }) => !result.ok);
|
|
504
|
+
if (failed.length) {
|
|
505
|
+
throw new Error(failed.map(({ authority }) => `${authority.label} preflight failed; repair with: ${authority.repair}`).join('\n'));
|
|
506
|
+
}
|
|
507
|
+
if (dryRun) {
|
|
508
|
+
console.log('Dry run: nothing was changed. Re-run with --apply to apply both provider plans.');
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
await ensureProjectionReady(manifest);
|
|
512
|
+
for (const authority of authorityCommands) {
|
|
513
|
+
const result = runAuthorityCommand(authority, false);
|
|
514
|
+
if (!result.ok) throw new Error(`${authority.label} apply failed; repair with: ${authority.repair}`);
|
|
515
|
+
}
|
|
516
|
+
const updated = {
|
|
517
|
+
...manifest,
|
|
518
|
+
integrations: { ...manifest.integrations, ...authorityVersions },
|
|
519
|
+
};
|
|
520
|
+
await writeManifestAtomically(updated);
|
|
521
|
+
await writeProjection(updated);
|
|
522
|
+
console.log(`Recorded ProductShape ${authorityVersions.productShape} and ArcShape ${authorityVersions.arcShape} in lighthouse.json.`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function gitBranch() {
|
|
526
|
+
try {
|
|
527
|
+
return execFileSync('git', ['branch', '--show-current'], {
|
|
528
|
+
cwd: root,
|
|
529
|
+
encoding: 'utf8',
|
|
530
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
531
|
+
}).trim();
|
|
532
|
+
}
|
|
533
|
+
catch { return ''; }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function validateGitConventions(repository) {
|
|
537
|
+
const problems = [];
|
|
538
|
+
const branch = gitBranch();
|
|
539
|
+
if (branch && !['main', 'master'].includes(branch) && !new RegExp(repository.conventions.branchPattern, 'u').test(branch)) {
|
|
540
|
+
problems.push(`branch ${branch} does not match ${repository.conventions.branchPattern}`);
|
|
541
|
+
}
|
|
542
|
+
const message = process.env.LIGHTHOUSE_COMMIT_MESSAGE;
|
|
543
|
+
if (message && repository.conventions.commits === 'conventional'
|
|
544
|
+
&& !/^(?:feat|fix|docs|refactor|test|chore|build|ci|perf|revert)(?:\([^)]+\))?!?: .+/u.test(message.trim())) {
|
|
545
|
+
problems.push('commit message does not follow Conventional Commits');
|
|
546
|
+
}
|
|
547
|
+
return problems;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function verify(manifest, format = 'text') {
|
|
551
|
+
const manifestProblems = validate(manifest);
|
|
552
|
+
const validManifest = manifestProblems.length === 0;
|
|
553
|
+
const repository = normalizeRepository(manifest?.repository);
|
|
554
|
+
const managed = validManifest ? managedFiles(manifest) : {};
|
|
555
|
+
const report = await evaluateGuardrails({
|
|
556
|
+
catalog: readGuardrailCatalog(),
|
|
557
|
+
manifest,
|
|
558
|
+
manifestProblems,
|
|
559
|
+
repository,
|
|
560
|
+
managedFiles: managed,
|
|
561
|
+
resolve: (path) => resolve(root, path),
|
|
562
|
+
exists,
|
|
563
|
+
isDirectory,
|
|
564
|
+
readFile,
|
|
565
|
+
validateGitConventions,
|
|
566
|
+
branch: gitBranch(),
|
|
567
|
+
});
|
|
568
|
+
if (format === 'json') {
|
|
569
|
+
console.log(JSON.stringify(report, null, 2));
|
|
570
|
+
if (report.problems.length) process.exitCode = 1;
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (report.problems.length) throw new Error(report.problems.join('\n'));
|
|
574
|
+
console.log('Lighthouse manifest, repository boundaries and managed projection are valid.');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function formatFromArgs(args) {
|
|
578
|
+
const index = args.indexOf('--format');
|
|
579
|
+
if (index !== -1) return args[index + 1];
|
|
580
|
+
return args.find((arg) => arg.startsWith('--format='))?.slice('--format='.length) ?? 'text';
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const args = process.argv.slice(2);
|
|
584
|
+
const command = args[0];
|
|
585
|
+
const commandArgs = args.slice(1);
|
|
586
|
+
let invalidArgument = false;
|
|
587
|
+
for (let index = 0; index < commandArgs.length; index += 1) {
|
|
588
|
+
const arg = commandArgs[index];
|
|
589
|
+
if (arg === '--dry-run') continue;
|
|
590
|
+
if (arg === '--apply') { if (!['authorities', 'reconcile'].includes(command)) invalidArgument = true; continue; }
|
|
591
|
+
if (arg === '--name') { if (!commandArgs[index + 1]) invalidArgument = true; else index += 1; continue; }
|
|
592
|
+
if (arg.startsWith('--name=') && arg.length > '--name='.length) continue;
|
|
593
|
+
if (arg === '--format') {
|
|
594
|
+
if (command !== 'verify' || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
|
|
595
|
+
else index += 1;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
if (arg.startsWith('--format=') && ['text', 'json'].includes(arg.slice('--format='.length))) {
|
|
599
|
+
if (command !== 'verify') invalidArgument = true;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
invalidArgument = true;
|
|
603
|
+
}
|
|
604
|
+
if (!['init', 'authorities', 'reconcile', 'verify'].includes(command) || invalidArgument) {
|
|
605
|
+
console.error(usage);
|
|
606
|
+
process.exit(2);
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
if (command === 'init') await initialize(args.slice(1));
|
|
610
|
+
else if (command === 'authorities') await authorities(args.slice(1));
|
|
611
|
+
else if (command === 'reconcile') await reconcile(args.slice(1));
|
|
612
|
+
else {
|
|
613
|
+
const manifest = await readManifest();
|
|
614
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
615
|
+
await verify(manifest, formatFromArgs(commandArgs));
|
|
58
616
|
}
|
|
59
|
-
|
|
617
|
+
} catch (error) {
|
|
618
|
+
console.error(error.message);
|
|
619
|
+
process.exitCode = 1;
|
|
60
620
|
}
|