@juangcarmona/lighthouse 0.1.0 → 0.3.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 +37 -7
- package/assets/guardrails/catalog.json +46 -0
- package/assets/schemas/guardrails.schema.json +41 -0
- package/assets/schemas/lighthouse-manifest.schema.json +23 -2
- package/assets/schemas/lighthouse-upgrade.schema.json +35 -0
- package/package.json +13 -2
- package/src/guardrails.mjs +140 -0
- package/src/lighthouse.mjs +831 -39
- package/upgrades/0.3.0.md +26 -0
package/src/lighthouse.mjs
CHANGED
|
@@ -1,60 +1,852 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
2
|
+
import { access, chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { constants, readFileSync } from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
7
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
8
|
+
import { evaluateGuardrails, readGuardrailCatalog } from './guardrails.mjs';
|
|
4
9
|
|
|
5
10
|
const root = process.cwd();
|
|
11
|
+
const packageRequire = createRequire(import.meta.url);
|
|
12
|
+
const cliPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
13
|
+
function installedPackageVersion(name, entry) {
|
|
14
|
+
try {
|
|
15
|
+
const entryPath = packageRequire.resolve(`${name}/${entry}`);
|
|
16
|
+
return JSON.parse(readFileSync(resolve(dirname(entryPath), 'package.json'), 'utf8')).version;
|
|
17
|
+
} catch { return null; }
|
|
18
|
+
}
|
|
6
19
|
const manifestPath = resolve(root, 'lighthouse.json');
|
|
7
20
|
const projection = 'LIGHTHOUSE.md';
|
|
8
|
-
const
|
|
21
|
+
const upgradeStateRelative = '.lighthouse/upgrade.json';
|
|
22
|
+
const upgradeStatePath = resolve(root, upgradeStateRelative);
|
|
23
|
+
const layout = ['src', 'docs', 'infra', 'scripts'];
|
|
24
|
+
const sdlcPackage = { name: '@juangcarmona/lighthouse-sdlc', version: installedPackageVersion('@juangcarmona/lighthouse-sdlc', 'contract.json') ?? '0.1.0' };
|
|
25
|
+
const managedPaths = [
|
|
26
|
+
projection,
|
|
27
|
+
'scripts/lighthouse-guard.mjs',
|
|
28
|
+
'.husky/commit-msg',
|
|
29
|
+
'.husky/pre-push',
|
|
30
|
+
'.github/workflows/lighthouse.yml',
|
|
31
|
+
'.lighthouse/sdlc.json',
|
|
32
|
+
upgradeStateRelative,
|
|
33
|
+
];
|
|
34
|
+
const previousManagedPaths = managedPaths.slice(0, -1);
|
|
35
|
+
const legacyManagedPaths = [projection];
|
|
36
|
+
const branchPattern = '^(feat|fix|docs|refactor|test|chore|build|ci|perf|revert)/[1-9]\\d*-[a-z0-9]+(?:-[a-z0-9]+)*$';
|
|
37
|
+
const authorityVersions = { productShape: '0.22.0', arcShape: '0.1.3' };
|
|
38
|
+
const upgradeScopes = {
|
|
39
|
+
harness: [projection, 'scripts/lighthouse-guard.mjs', '.husky/commit-msg', '.husky/pre-push', '.github/workflows/lighthouse.yml'],
|
|
40
|
+
sdlc: ['.lighthouse/sdlc.json'],
|
|
41
|
+
};
|
|
42
|
+
const upgradeSchema = './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-upgrade.schema.json';
|
|
43
|
+
const usage = 'Usage: lighthouse <init|authorities|reconcile|upgrade|verify> [--name <product>] [--scope <harness|sdlc|all>] [--format <text|json>] [--dry-run] [--apply]';
|
|
44
|
+
|
|
45
|
+
const defaultRepository = () => ({
|
|
46
|
+
layout: [...layout],
|
|
47
|
+
publicSite: { enabled: true },
|
|
48
|
+
conventions: { commits: 'conventional', branchPattern },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function defaultManifest(product) {
|
|
52
|
+
return {
|
|
53
|
+
$schema: './node_modules/@juangcarmona/lighthouse/assets/schemas/lighthouse-manifest.schema.json',
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
baseline: '0.1.0',
|
|
56
|
+
product,
|
|
57
|
+
profiles: [],
|
|
58
|
+
integrations: { productShape: null, arcShape: null },
|
|
59
|
+
packages: { sdlc: { ...sdlcPackage } },
|
|
60
|
+
managed: [...managedPaths],
|
|
61
|
+
repository: defaultRepository(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeRepository(repository) {
|
|
66
|
+
const value = repository && typeof repository === 'object' && !Array.isArray(repository) ? repository : {};
|
|
67
|
+
return {
|
|
68
|
+
layout: Array.isArray(value.layout) ? value.layout : [...layout],
|
|
69
|
+
publicSite: value.publicSite && typeof value.publicSite === 'object'
|
|
70
|
+
? { enabled: value.publicSite.enabled !== false }
|
|
71
|
+
: { enabled: true },
|
|
72
|
+
conventions: value.conventions && typeof value.conventions === 'object'
|
|
73
|
+
? {
|
|
74
|
+
commits: value.conventions.commits ?? 'conventional',
|
|
75
|
+
branchPattern: value.conventions.branchPattern ?? branchPattern,
|
|
76
|
+
}
|
|
77
|
+
: { commits: 'conventional', branchPattern },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeSdlcPackage(manifest) {
|
|
82
|
+
const value = manifest?.packages?.sdlc;
|
|
83
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
84
|
+
return { name: value.name, version: value.version };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sdlcContract() {
|
|
88
|
+
try {
|
|
89
|
+
const path = packageRequire.resolve('@juangcarmona/lighthouse-sdlc/contract.json');
|
|
90
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
routes: ['supervised', 'plan-only', 'unattended', 'hotfix', 'stacked'],
|
|
94
|
+
gates: ['triaged', 'ready', 'planned', 'done'],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sdlcControlPlane(manifest) {
|
|
100
|
+
const selected = normalizeSdlcPackage(manifest);
|
|
101
|
+
if (!selected) return null;
|
|
102
|
+
const contract = sdlcContract();
|
|
103
|
+
return `${JSON.stringify({
|
|
104
|
+
$schema: './node_modules/@juangcarmona/lighthouse-sdlc/schemas/control-plane.schema.json',
|
|
105
|
+
schemaVersion: 1,
|
|
106
|
+
package: selected,
|
|
107
|
+
routes: contract.routes,
|
|
108
|
+
gates: contract.gates,
|
|
109
|
+
state: { provider: 'agent-host' },
|
|
110
|
+
memory: { provider: 'agent-host', scopes: ['run', 'project'] },
|
|
111
|
+
roles: {},
|
|
112
|
+
adapters: {},
|
|
113
|
+
}, null, 2)}\n`;
|
|
114
|
+
}
|
|
9
115
|
|
|
10
116
|
function render(manifest) {
|
|
11
|
-
|
|
117
|
+
const repository = normalizeRepository(manifest.repository);
|
|
118
|
+
const publicSite = repository.publicSite.enabled ? 'enabled' : 'disabled';
|
|
119
|
+
const selectedSdlc = normalizeSdlcPackage(manifest);
|
|
120
|
+
const sdlcLine = selectedSdlc ? `- SDLC package: \`${selectedSdlc.name}@${selectedSdlc.version}\`\n` : '';
|
|
121
|
+
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`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function guardrailScript(manifest) {
|
|
125
|
+
const pattern = JSON.stringify(normalizeRepository(manifest.repository).conventions.branchPattern);
|
|
126
|
+
return `#!/usr/bin/env node
|
|
127
|
+
import { readFileSync } from 'node:fs';
|
|
128
|
+
import { execFileSync } from 'node:child_process';
|
|
129
|
+
|
|
130
|
+
const mode = process.argv[2];
|
|
131
|
+
const branchPattern = new RegExp(${pattern}, 'u');
|
|
132
|
+
function fail(message) { console.error(message); process.exit(1); }
|
|
133
|
+
function branch() {
|
|
134
|
+
try { return execFileSync('git', ['branch', '--show-current'], { encoding: 'utf8' }).trim(); }
|
|
135
|
+
catch { return ''; }
|
|
136
|
+
}
|
|
137
|
+
if (mode === 'commit-msg') {
|
|
138
|
+
const file = process.argv[3];
|
|
139
|
+
if (!file) fail('Usage: lighthouse-guard commit-msg <message-file>');
|
|
140
|
+
const message = readFileSync(file, 'utf8').split('\\n').find((line) => line.trim() && !line.startsWith('#'))?.trim() ?? '';
|
|
141
|
+
if (!/^(?:feat|fix|docs|refactor|test|chore|build|ci|perf|revert)(?:\\([^)]+\\))?!?: .+/u.test(message)) fail('Commit message must follow Conventional Commits.');
|
|
142
|
+
} else if (mode === 'branch' || mode === 'verify') {
|
|
143
|
+
const current = branch();
|
|
144
|
+
if (current && !['main', 'master'].includes(current) && !branchPattern.test(current)) fail('Branch ' + current + ' does not match ' + branchPattern.source + '.');
|
|
145
|
+
} else fail('Usage: lighthouse-guard <commit-msg|branch|verify>');
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function commitHook() {
|
|
150
|
+
return '#!/usr/bin/env sh\nset -eu\nnode scripts/lighthouse-guard.mjs commit-msg "$1"\n';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function prePushHook() {
|
|
154
|
+
return '#!/usr/bin/env sh\nset -eu\nnode scripts/lighthouse-guard.mjs branch\n';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function workflow() {
|
|
158
|
+
return `name: Lighthouse repository guardrails
|
|
159
|
+
|
|
160
|
+
on:
|
|
161
|
+
pull_request:
|
|
162
|
+
paths:
|
|
163
|
+
- "lighthouse.json"
|
|
164
|
+
- "LIGHTHOUSE.md"
|
|
165
|
+
- "src/**"
|
|
166
|
+
- "docs/**"
|
|
167
|
+
- "infra/**"
|
|
168
|
+
- "scripts/**"
|
|
169
|
+
- ".husky/**"
|
|
170
|
+
- ".github/workflows/lighthouse.yml"
|
|
171
|
+
push:
|
|
172
|
+
branches: [main]
|
|
173
|
+
paths:
|
|
174
|
+
- "lighthouse.json"
|
|
175
|
+
- "LIGHTHOUSE.md"
|
|
176
|
+
- "src/**"
|
|
177
|
+
- "docs/**"
|
|
178
|
+
- "infra/**"
|
|
179
|
+
- "scripts/**"
|
|
180
|
+
- ".husky/**"
|
|
181
|
+
- ".github/workflows/lighthouse.yml"
|
|
182
|
+
workflow_dispatch:
|
|
183
|
+
|
|
184
|
+
permissions:
|
|
185
|
+
contents: read
|
|
186
|
+
|
|
187
|
+
jobs:
|
|
188
|
+
verify:
|
|
189
|
+
name: Verify Lighthouse foundation
|
|
190
|
+
runs-on: ubuntu-latest
|
|
191
|
+
timeout-minutes: 10
|
|
192
|
+
steps:
|
|
193
|
+
- uses: actions/checkout@v4
|
|
194
|
+
- uses: actions/setup-node@v4
|
|
195
|
+
with:
|
|
196
|
+
node-version: 22
|
|
197
|
+
cache: npm
|
|
198
|
+
- run: npm ci --ignore-scripts
|
|
199
|
+
- run: |
|
|
200
|
+
if [ -f scripts/lighthouse.mjs ]; then node scripts/lighthouse.mjs verify; else npx --no-install lighthouse verify; fi
|
|
201
|
+
- run: node scripts/lighthouse-guard.mjs verify
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function managedFiles(manifest) {
|
|
206
|
+
const files = {
|
|
207
|
+
[projection]: render(manifest),
|
|
208
|
+
'scripts/lighthouse-guard.mjs': guardrailScript(manifest),
|
|
209
|
+
'.husky/commit-msg': commitHook(),
|
|
210
|
+
'.husky/pre-push': prePushHook(),
|
|
211
|
+
'.github/workflows/lighthouse.yml': workflow(),
|
|
212
|
+
};
|
|
213
|
+
const controlPlane = sdlcControlPlane(manifest);
|
|
214
|
+
if (controlPlane) files['.lighthouse/sdlc.json'] = controlPlane;
|
|
215
|
+
return files;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function contentHash(content) {
|
|
219
|
+
return createHash('sha256').update(content).digest('hex');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function upgradeState(manifest, files = managedFiles(manifest), previous = null) {
|
|
223
|
+
const entries = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, {
|
|
224
|
+
hash: contentHash(content),
|
|
225
|
+
scope: Object.entries(upgradeScopes).find(([, paths]) => paths.includes(path))?.[0] ?? 'harness',
|
|
226
|
+
}]));
|
|
227
|
+
return {
|
|
228
|
+
$schema: upgradeSchema,
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
package: { name: cliPackage.name, version: cliPackage.version },
|
|
231
|
+
baseline: manifest.baseline,
|
|
232
|
+
capabilities: { harness: '1', sdlc: files['.lighthouse/sdlc.json'] ? '1' : null },
|
|
233
|
+
projections: { harness: '1', sdlc: files['.lighthouse/sdlc.json'] ? '1' : null },
|
|
234
|
+
lastAppliedMigration: previous?.lastAppliedMigration ?? 'foundation-init-1',
|
|
235
|
+
files: entries,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readUpgradeState() {
|
|
240
|
+
try { return JSON.parse(await readFile(upgradeStatePath, 'utf8')); }
|
|
241
|
+
catch (error) {
|
|
242
|
+
if (error.code === 'ENOENT') return null;
|
|
243
|
+
throw new Error(`Cannot read ${upgradeStateRelative}: ${error.message}`);
|
|
244
|
+
}
|
|
12
245
|
}
|
|
13
246
|
|
|
14
|
-
function
|
|
247
|
+
async function writeUpgradeState(manifest, previous = null, files = managedFiles(manifest)) {
|
|
248
|
+
const state = upgradeState(manifest, files, previous);
|
|
249
|
+
await mkdir(dirname(upgradeStatePath), { recursive: true });
|
|
250
|
+
await writeFile(upgradeStatePath, `${JSON.stringify(state, null, 2)}\n`, { flag: 'w' });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function stateProblems(state) {
|
|
254
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return [`${upgradeStateRelative} is missing; run lighthouse reconcile --apply to establish ownership evidence.`];
|
|
255
|
+
const problems = [];
|
|
256
|
+
if (state.schemaVersion !== 1) problems.push(`${upgradeStateRelative} has unsupported schemaVersion ${state.schemaVersion}.`);
|
|
257
|
+
if (!state.package || state.package.name !== cliPackage.name || typeof state.package.version !== 'string') problems.push(`${upgradeStateRelative} does not identify the installed Lighthouse package.`);
|
|
258
|
+
if (!state.files || typeof state.files !== 'object' || Array.isArray(state.files)) problems.push(`${upgradeStateRelative} does not contain file ownership evidence.`);
|
|
259
|
+
return problems;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function scopesFromArgs(args) {
|
|
263
|
+
const selected = new Set();
|
|
264
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
265
|
+
if (args[index] !== '--scope' && !args[index].startsWith('--scope=')) continue;
|
|
266
|
+
const value = args[index] === '--scope' ? args[index + 1] : args[index].slice('--scope='.length);
|
|
267
|
+
if (args[index] === '--scope') index += 1;
|
|
268
|
+
if (!value) throw new Error('--scope requires harness, sdlc or all');
|
|
269
|
+
for (const scope of value.split(',')) {
|
|
270
|
+
if (scope === 'all') { selected.add('harness'); selected.add('sdlc'); }
|
|
271
|
+
else if (scope === 'harness' || scope === 'sdlc') selected.add(scope);
|
|
272
|
+
else throw new Error(`Unknown upgrade scope ${scope}; use harness, sdlc or all.`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return selected.size ? selected : new Set(Object.keys(upgradeScopes));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function selectedPaths(scopes) {
|
|
279
|
+
return new Set([...scopes].flatMap((scope) => upgradeScopes[scope]));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function classifyUpgradeFile(path, current, expected, recordedHash) {
|
|
283
|
+
if (current === null) return { classification: 'additive', action: 'add' };
|
|
284
|
+
const currentHash = contentHash(current);
|
|
285
|
+
if (current === expected && currentHash === recordedHash) return { classification: 'unchanged', action: 'keep' };
|
|
286
|
+
if (current === expected && currentHash !== recordedHash) return { classification: 'adoptable', action: 'record' };
|
|
287
|
+
if (recordedHash && currentHash === recordedHash) return { classification: 'adoptable', action: 'update' };
|
|
288
|
+
if (recordedHash) return { classification: 'customized', action: 'preserve', reason: 'current content differs from the recorded projection' };
|
|
289
|
+
return { classification: 'conflicting', action: 'review', reason: 'ownership is not recorded for this path' };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function planUpgrade(manifest, scopes) {
|
|
293
|
+
const state = await readUpgradeState();
|
|
294
|
+
const problems = stateProblems(state);
|
|
295
|
+
const files = managedFiles(manifest);
|
|
296
|
+
const paths = selectedPaths(scopes);
|
|
297
|
+
const changes = [];
|
|
298
|
+
for (const path of Object.keys(files).filter((candidate) => paths.has(candidate))) {
|
|
299
|
+
const target = resolve(root, path);
|
|
300
|
+
const current = await exists(target) ? await readFile(target, 'utf8') : null;
|
|
301
|
+
const expected = files[path];
|
|
302
|
+
const recordedHash = state?.files?.[path]?.hash;
|
|
303
|
+
const result = classifyUpgradeFile(path, current, expected, recordedHash);
|
|
304
|
+
changes.push({ path, scope: Object.entries(upgradeScopes).find(([, values]) => values.includes(path))?.[0], ...result });
|
|
305
|
+
}
|
|
306
|
+
if (state?.baseline && state.baseline !== manifest.baseline) {
|
|
307
|
+
changes.unshift({ path: 'baseline', scope: 'harness', classification: 'breaking', action: 'review', reason: `baseline ${state.baseline} is not compatible with ${manifest.baseline}` });
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
source: {
|
|
311
|
+
package: state?.package ?? null,
|
|
312
|
+
baseline: state?.baseline ?? null,
|
|
313
|
+
capabilities: state?.capabilities ?? null,
|
|
314
|
+
projections: state?.projections ?? null,
|
|
315
|
+
lastAppliedMigration: state?.lastAppliedMigration ?? null,
|
|
316
|
+
},
|
|
317
|
+
target: {
|
|
318
|
+
package: { name: cliPackage.name, version: cliPackage.version },
|
|
319
|
+
baseline: manifest.baseline,
|
|
320
|
+
capabilities: { harness: '1', sdlc: files['.lighthouse/sdlc.json'] ? '1' : null },
|
|
321
|
+
projections: { harness: '1', sdlc: files['.lighthouse/sdlc.json'] ? '1' : null },
|
|
322
|
+
},
|
|
323
|
+
scopes: [...scopes],
|
|
324
|
+
prerequisites: problems.length ? [{ status: 'failed', message: problems.join(' ') }] : [{ status: 'passed', message: 'Ownership metadata is readable.' }],
|
|
325
|
+
changes,
|
|
326
|
+
verification: ['lighthouse verify'],
|
|
327
|
+
recovery: 'Restore the pre-update commit or rerun lighthouse upgrade after resolving the reported ownership conflict.',
|
|
328
|
+
problems,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function printUpgradePlan(plan) {
|
|
333
|
+
console.log('Lighthouse upgrade plan:');
|
|
334
|
+
console.log(` package ${plan.source.package?.version ?? 'unknown'} -> ${plan.target.package.version}`);
|
|
335
|
+
console.log(` baseline ${plan.source.baseline ?? 'unknown'} -> ${plan.target.baseline}`);
|
|
336
|
+
console.log(` scopes ${plan.scopes.join(', ')}`);
|
|
337
|
+
for (const prerequisite of plan.prerequisites) console.log(` prerequisite ${prerequisite.status}: ${prerequisite.message}`);
|
|
338
|
+
for (const change of plan.changes) console.log(` ${change.classification} ${change.action} ${change.path}${change.reason ? ` (${change.reason})` : ''}`);
|
|
339
|
+
if (plan.problems.length) for (const problem of plan.problems) console.error(` adoption gap ${problem}`);
|
|
340
|
+
console.log(` verify ${plan.verification[0]}`);
|
|
341
|
+
console.log(` recovery ${plan.recovery}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function validate(manifest) {
|
|
15
345
|
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 (
|
|
346
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return ['manifest must be an object'];
|
|
347
|
+
const allowed = ['$schema', 'schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'packages', 'managed', 'repository'];
|
|
348
|
+
for (const key of Object.keys(manifest)) if (!allowed.includes(key)) problems.push(`unknown field ${key}`);
|
|
349
|
+
for (const key of ['schemaVersion', 'baseline', 'product', 'profiles', 'integrations', 'managed']) {
|
|
350
|
+
if (!(key in manifest)) problems.push(`missing ${key}`);
|
|
351
|
+
}
|
|
352
|
+
if (manifest.schemaVersion !== 1) problems.push('schemaVersion must be 1');
|
|
353
|
+
if (manifest.baseline !== '0.1.0') problems.push('this verifier supports baseline 0.1.0');
|
|
354
|
+
if ('$schema' in manifest && typeof manifest.$schema !== 'string') problems.push('$schema must be a string');
|
|
355
|
+
if (typeof manifest.product !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$/.test(manifest.product)) {
|
|
356
|
+
problems.push('product must be a readable name of 1–64 characters');
|
|
357
|
+
}
|
|
24
358
|
const profiles = ['node-tooling', 'dotnet-backend', 'visual-theme'];
|
|
25
|
-
if (!Array.isArray(
|
|
26
|
-
|
|
27
|
-
|
|
359
|
+
if (!Array.isArray(manifest.profiles) || manifest.profiles.some((p) => !profiles.includes(p))
|
|
360
|
+
|| new Set(manifest.profiles).size !== manifest.profiles.length) {
|
|
361
|
+
problems.push('profiles must be unique supported profile names');
|
|
362
|
+
}
|
|
363
|
+
const integrations = manifest.integrations;
|
|
364
|
+
const keys = ['productShape', 'arcShape'];
|
|
28
365
|
const pin = /^(?:\d+\.\d+\.\d+|[a-f0-9]{40})$/;
|
|
29
366
|
if (!integrations || typeof integrations !== 'object' || Array.isArray(integrations)
|
|
30
367
|
|| Object.keys(integrations).length !== keys.length
|
|
31
|
-
|| keys.some((
|
|
32
|
-
|
|
33
|
-
|
|
368
|
+
|| keys.some((key) => !(key in integrations)
|
|
369
|
+
|| (integrations[key] !== null && (typeof integrations[key] !== 'string' || !pin.test(integrations[key]))))) {
|
|
370
|
+
problems.push('integrations must declare productShape and arcShape as exact versions, commit hashes or null');
|
|
371
|
+
}
|
|
372
|
+
if ('packages' in manifest) {
|
|
373
|
+
const packages = manifest.packages;
|
|
374
|
+
const selected = packages?.sdlc;
|
|
375
|
+
if (!packages || typeof packages !== 'object' || Array.isArray(packages)
|
|
376
|
+
|| Object.keys(packages).length !== 1
|
|
377
|
+
|| !selected || typeof selected !== 'object' || Array.isArray(selected)
|
|
378
|
+
|| Object.keys(selected).length !== 2
|
|
379
|
+
|| selected.name !== sdlcPackage.name
|
|
380
|
+
|| typeof selected.version !== 'string' || !pin.test(selected.version)) {
|
|
381
|
+
problems.push(`packages.sdlc must declare ${sdlcPackage.name} and an exact version or commit hash`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const legacyManaged = Array.isArray(manifest.managed)
|
|
385
|
+
&& manifest.managed.length === legacyManagedPaths.length
|
|
386
|
+
&& manifest.managed.every((entry, index) => entry === legacyManagedPaths[index]);
|
|
387
|
+
const completeManaged = Array.isArray(manifest.managed)
|
|
388
|
+
&& manifest.managed.length === managedPaths.length
|
|
389
|
+
&& manifest.managed.every((entry, index) => entry === managedPaths[index]);
|
|
390
|
+
const previousCompleteManaged = Array.isArray(manifest.managed)
|
|
391
|
+
&& manifest.managed.length === previousManagedPaths.length
|
|
392
|
+
&& manifest.managed.every((entry, index) => entry === previousManagedPaths[index]);
|
|
393
|
+
if (!legacyManaged && !previousCompleteManaged && !completeManaged) {
|
|
394
|
+
problems.push(`managed must be ${legacyManagedPaths.join(', ')}, the previous Lighthouse guardrail set or the current complete set`);
|
|
395
|
+
}
|
|
396
|
+
if ('repository' in manifest) {
|
|
397
|
+
const repository = manifest.repository;
|
|
398
|
+
if (!repository || typeof repository !== 'object' || Array.isArray(repository)) {
|
|
399
|
+
problems.push('repository must be an object');
|
|
400
|
+
} else {
|
|
401
|
+
if (!Array.isArray(repository.layout) || repository.layout.length !== layout.length
|
|
402
|
+
|| repository.layout.some((entry, index) => entry !== layout[index])) {
|
|
403
|
+
problems.push(`repository.layout must be ${layout.map((entry) => `'${entry}'`).join(', ')}`);
|
|
404
|
+
}
|
|
405
|
+
if (!repository.publicSite || typeof repository.publicSite.enabled !== 'boolean') {
|
|
406
|
+
problems.push('repository.publicSite.enabled must be a boolean');
|
|
407
|
+
}
|
|
408
|
+
if (!repository.conventions || repository.conventions.commits !== 'conventional'
|
|
409
|
+
|| typeof repository.conventions.branchPattern !== 'string') {
|
|
410
|
+
problems.push('repository.conventions must declare conventional commits and a branchPattern');
|
|
411
|
+
} else {
|
|
412
|
+
try { new RegExp(repository.conventions.branchPattern, 'u'); }
|
|
413
|
+
catch { problems.push('repository.conventions.branchPattern must be a valid regular expression'); }
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
34
417
|
return problems;
|
|
35
418
|
}
|
|
36
419
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
420
|
+
async function exists(path) {
|
|
421
|
+
try { await access(path, constants.F_OK); return true; } catch { return false; }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function isDirectory(path) {
|
|
425
|
+
try { return (await stat(path)).isDirectory(); } catch { return false; }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function readManifest() {
|
|
429
|
+
try { return JSON.parse(await readFile(manifestPath, 'utf8')); }
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (error.code === 'ENOENT') return null;
|
|
432
|
+
throw new Error(`Cannot read lighthouse.json: ${error.message}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function productNameFromArgs(args) {
|
|
437
|
+
const index = args.indexOf('--name');
|
|
438
|
+
const value = index === -1 ? args.find((arg) => arg.startsWith('--name='))?.slice('--name='.length) : args[index + 1];
|
|
439
|
+
if (index === -1 && !value) return basename(root).replace(/[^A-Za-z0-9 ._-]/gu, '').slice(0, 64) || 'adopter';
|
|
440
|
+
if (!value) throw new Error('--name requires a product name');
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function planInitialization(manifest, manifestMissing) {
|
|
445
|
+
const operations = [];
|
|
446
|
+
const conflicts = [];
|
|
447
|
+
if (manifestMissing) operations.push({ path: 'lighthouse.json', action: 'create' });
|
|
448
|
+
const files = managedFiles(manifest);
|
|
449
|
+
const directories = new Set(normalizeRepository(manifest.repository).layout);
|
|
450
|
+
for (const file of Object.keys(files)) {
|
|
451
|
+
let directory = dirname(file);
|
|
452
|
+
while (directory !== '.') {
|
|
453
|
+
directories.add(directory);
|
|
454
|
+
directory = dirname(directory);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const directory of [...directories].sort((left, right) => left.split('/').length - right.split('/').length)) {
|
|
458
|
+
const path = resolve(root, directory);
|
|
459
|
+
if (!(await exists(path))) operations.push({ path: `${directory}/`, action: 'create' });
|
|
460
|
+
else if (!(await isDirectory(path))) conflicts.push(`${directory} exists but is not a directory`);
|
|
461
|
+
}
|
|
462
|
+
for (const file of Object.keys(files)) {
|
|
463
|
+
if (await exists(resolve(root, file))) conflicts.push(`${file} already exists; refusing to overwrite it`);
|
|
464
|
+
else operations.push({ path: file, action: 'create' });
|
|
465
|
+
}
|
|
466
|
+
if (await exists(upgradeStatePath)) conflicts.push(`${upgradeStateRelative} already exists; refusing to overwrite it`);
|
|
467
|
+
else operations.push({ path: upgradeStateRelative, action: 'create' });
|
|
468
|
+
return { operations, conflicts };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function planReconciliation(manifest) {
|
|
472
|
+
const operations = [];
|
|
473
|
+
const conflicts = [];
|
|
474
|
+
for (const [path, expected] of Object.entries(managedFiles(manifest))) {
|
|
475
|
+
const target = resolve(root, path);
|
|
476
|
+
if (!(await exists(target))) {
|
|
477
|
+
operations.push({ path, action: 'create', content: expected });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const current = await readFile(target, 'utf8');
|
|
482
|
+
if (current === expected) operations.push({ path, action: 'unchanged' });
|
|
483
|
+
else conflicts.push(`${path} exists with different content; refusing to overwrite it`);
|
|
484
|
+
} catch {
|
|
485
|
+
conflicts.push(`${path} exists but is not a readable file; refusing to overwrite it`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (!(await exists(upgradeStatePath))) {
|
|
489
|
+
operations.push({ path: upgradeStateRelative, action: 'create', content: `${JSON.stringify(upgradeState(manifest), null, 2)}\n` });
|
|
490
|
+
}
|
|
491
|
+
return { operations, conflicts };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function printReconciliationPlan(plan) {
|
|
495
|
+
console.log('Lighthouse reconciliation plan:');
|
|
496
|
+
for (const operation of plan.operations) console.log(` ${operation.action} ${operation.path}`);
|
|
497
|
+
for (const conflict of plan.conflicts) console.error(` conflict ${conflict}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function initialize(args) {
|
|
501
|
+
let manifest = await readManifest();
|
|
502
|
+
const manifestMissing = !manifest;
|
|
503
|
+
if (!manifest) manifest = defaultManifest(productNameFromArgs(args));
|
|
504
|
+
const problems = validate(manifest);
|
|
505
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
506
|
+
const plan = await planInitialization(manifest, manifestMissing);
|
|
507
|
+
console.log('Lighthouse initialization plan:');
|
|
508
|
+
for (const operation of plan.operations) console.log(` ${operation.action} ${operation.path}`);
|
|
509
|
+
for (const conflict of plan.conflicts) console.error(` conflict ${conflict}`);
|
|
510
|
+
if (normalizeRepository(manifest.repository).publicSite.enabled) {
|
|
511
|
+
if (manifest.integrations.productShape === null) console.log(' adoption gap ProductShape: review lighthouse authorities --dry-run, then run lighthouse authorities --apply');
|
|
512
|
+
if (manifest.integrations.arcShape === null) console.log(' adoption gap ArcShape: review lighthouse authorities --dry-run, then run lighthouse authorities --apply');
|
|
513
|
+
}
|
|
514
|
+
if (args.includes('--dry-run')) {
|
|
515
|
+
if (plan.conflicts.length) process.exitCode = 1;
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (plan.conflicts.length) throw new Error('Initialization stopped; resolve the reported conflicts and run lighthouse init again.');
|
|
519
|
+
if (manifestMissing) await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
520
|
+
for (const operation of plan.operations.filter((item) => item.path.endsWith('/'))) {
|
|
521
|
+
await mkdir(resolve(root, operation.path), { recursive: true });
|
|
522
|
+
}
|
|
523
|
+
const files = managedFiles(manifest);
|
|
524
|
+
for (const [file, content] of Object.entries(files)) {
|
|
525
|
+
await writeFile(resolve(root, file), content, { flag: 'wx', mode: file.startsWith('.husky/') ? 0o755 : 0o644 });
|
|
526
|
+
if (file.startsWith('.husky/')) await chmod(resolve(root, file), 0o755);
|
|
527
|
+
}
|
|
528
|
+
await writeUpgradeState(manifest, null, files);
|
|
52
529
|
try {
|
|
53
|
-
const
|
|
54
|
-
if (
|
|
530
|
+
const configured = execFileSync('git', ['config', '--get', 'core.hooksPath'], { cwd: root, encoding: 'utf8' }).trim();
|
|
531
|
+
if (!configured) execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: root, stdio: 'ignore' });
|
|
532
|
+
} catch { /* A non-git target can adopt the hooks when it creates its repository. */ }
|
|
533
|
+
console.log(`Initialized ${manifest.product}. Review lighthouse.json and the managed guardrails, then run lighthouse verify.`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function reconcile(args) {
|
|
537
|
+
const manifest = await readManifest();
|
|
538
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
539
|
+
const problems = validate(manifest);
|
|
540
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
541
|
+
const plan = await planReconciliation(manifest);
|
|
542
|
+
printReconciliationPlan(plan);
|
|
543
|
+
const apply = args.includes('--apply');
|
|
544
|
+
const dryRun = args.includes('--dry-run') || !apply;
|
|
545
|
+
if (args.includes('--apply') && args.includes('--dry-run')) throw new Error('--apply and --dry-run cannot be used together.');
|
|
546
|
+
if (plan.conflicts.length) {
|
|
547
|
+
if (dryRun) process.exitCode = 1;
|
|
548
|
+
else throw new Error('Reconciliation stopped; resolve the reported conflicts and run lighthouse reconcile again.');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (dryRun) {
|
|
552
|
+
console.log('Dry run: nothing was changed. Re-run with --apply to create missing managed paths.');
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
for (const operation of plan.operations.filter((item) => item.action === 'create')) {
|
|
556
|
+
await mkdir(dirname(resolve(root, operation.path)), { recursive: true });
|
|
557
|
+
try {
|
|
558
|
+
await writeFile(resolve(root, operation.path), operation.content, { flag: 'wx', mode: operation.path.startsWith('.husky/') ? 0o755 : 0o644 });
|
|
559
|
+
if (operation.path.startsWith('.husky/')) await chmod(resolve(root, operation.path), 0o755);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (error.code === 'EEXIST') throw new Error(`Reconciliation stopped; ${operation.path} appeared during the plan.`);
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
console.log('Reconciled missing Lighthouse-managed paths. Run lighthouse verify to check the complete projection.');
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function writeTextAtomically(path, content) {
|
|
569
|
+
const temporary = `${path}.tmp-${process.pid}`;
|
|
570
|
+
await writeFile(temporary, content, { flag: 'wx' });
|
|
571
|
+
await rename(temporary, path);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function applyUpgrade(manifest, plan) {
|
|
575
|
+
if (plan.problems.length) throw new Error(`Upgrade stopped; ${plan.problems.join(' ')}`);
|
|
576
|
+
const blocked = plan.changes.filter((change) => ['conflicting', 'breaking'].includes(change.classification));
|
|
577
|
+
if (blocked.length) throw new Error(`Upgrade stopped; resolve ownership conflicts for: ${blocked.map((change) => change.path).join(', ')}.`);
|
|
578
|
+
const files = managedFiles(manifest);
|
|
579
|
+
const previous = await readUpgradeState();
|
|
580
|
+
const snapshots = new Map();
|
|
581
|
+
const updates = plan.changes.filter((change) => ['add', 'update', 'record'].includes(change.action));
|
|
582
|
+
for (const change of updates) {
|
|
583
|
+
const target = resolve(root, change.path);
|
|
584
|
+
snapshots.set(change.path, await exists(target) ? await readFile(target, 'utf8') : null);
|
|
585
|
+
}
|
|
586
|
+
const stateSnapshot = await readFile(upgradeStatePath, 'utf8');
|
|
587
|
+
try {
|
|
588
|
+
for (const change of updates) {
|
|
589
|
+
const target = resolve(root, change.path);
|
|
590
|
+
await mkdir(dirname(target), { recursive: true });
|
|
591
|
+
await writeTextAtomically(target, files[change.path]);
|
|
592
|
+
}
|
|
593
|
+
const next = upgradeState(manifest, files, { ...previous, lastAppliedMigration: `upgrade-${cliPackage.version}` });
|
|
594
|
+
for (const path of Object.keys(next.files)) {
|
|
595
|
+
const selected = plan.changes.find((change) => change.path === path);
|
|
596
|
+
if (!selected || selected.action === 'preserve' || selected.action === 'review') {
|
|
597
|
+
if (previous.files?.[path]) next.files[path] = previous.files[path];
|
|
598
|
+
else delete next.files[path];
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
next.lastAppliedMigration = `upgrade-${cliPackage.version}`;
|
|
602
|
+
await writeTextAtomically(upgradeStatePath, `${JSON.stringify(next, null, 2)}\n`);
|
|
55
603
|
} catch (error) {
|
|
56
|
-
|
|
57
|
-
|
|
604
|
+
for (const [path, content] of snapshots) {
|
|
605
|
+
const target = resolve(root, path);
|
|
606
|
+
if (content === null) await rm(target, { force: true });
|
|
607
|
+
else await writeFile(target, content);
|
|
608
|
+
}
|
|
609
|
+
await writeFile(upgradeStatePath, stateSnapshot);
|
|
610
|
+
throw new Error(`Upgrade rolled back: ${error.message}`);
|
|
611
|
+
}
|
|
612
|
+
const preserved = plan.changes.filter((change) => change.classification === 'customized').map((change) => change.path);
|
|
613
|
+
console.log(`Applied Lighthouse upgrade ${cliPackage.version}. Run lighthouse verify to check the result.`);
|
|
614
|
+
if (preserved.length) console.log(`Preserved customized paths for review: ${preserved.join(', ')}`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function upgrade(args) {
|
|
618
|
+
const manifest = await readManifest();
|
|
619
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
620
|
+
const problems = validate(manifest);
|
|
621
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
622
|
+
const plan = await planUpgrade(manifest, scopesFromArgs(args));
|
|
623
|
+
const format = formatFromArgs(args);
|
|
624
|
+
const apply = args.includes('--apply');
|
|
625
|
+
const dryRun = args.includes('--dry-run') || !apply;
|
|
626
|
+
if (args.includes('--apply') && args.includes('--dry-run')) throw new Error('--apply and --dry-run cannot be used together.');
|
|
627
|
+
if (format === 'json' && apply) throw new Error('--format json is available for dry-run upgrade plans; use text when applying.');
|
|
628
|
+
if (format === 'json') console.log(JSON.stringify(plan, null, 2));
|
|
629
|
+
else printUpgradePlan(plan);
|
|
630
|
+
if (dryRun) {
|
|
631
|
+
if (format !== 'json') console.log('Dry run: nothing was changed. Re-run with --apply after reviewing the plan.');
|
|
632
|
+
if (plan.problems.length) process.exitCode = 1;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await applyUpgrade(manifest, plan);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const authorityCommands = [
|
|
639
|
+
{
|
|
640
|
+
label: 'ProductShape',
|
|
641
|
+
packageName: '@prodshape/cli',
|
|
642
|
+
command: 'prodshape',
|
|
643
|
+
initArgs: ['init', '--full', '--sdd', 'none', '--gitignore'],
|
|
644
|
+
repair: 'prodshape init --full --sdd none --gitignore',
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
label: 'ArcShape',
|
|
648
|
+
packageName: '@arcshape/cli',
|
|
649
|
+
command: 'arcshape',
|
|
650
|
+
initArgs: ['init', '--no-agents'],
|
|
651
|
+
applyArgs: ['init', '--no-agents', '--yes'],
|
|
652
|
+
repair: 'arcshape init --no-agents --yes',
|
|
653
|
+
},
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
function providerExecutable(authority) {
|
|
657
|
+
const packageJson = packageRequire.resolve(`${authority.packageName}/package.json`);
|
|
658
|
+
const metadata = JSON.parse(readFileSync(packageJson, 'utf8'));
|
|
659
|
+
const binary = typeof metadata.bin === 'string' ? metadata.bin : metadata.bin?.[authority.command];
|
|
660
|
+
if (!binary) throw new Error(`${authority.packageName} does not declare a ${authority.command} executable`);
|
|
661
|
+
return resolve(dirname(packageJson), binary);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function providerHasConflict(output) {
|
|
665
|
+
return output.split('\n').some((line) => /^\s*conflict\b/i.test(line))
|
|
666
|
+
|| /Conflicts\s*\(\s*[1-9]\d*\s*\)/i.test(output);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function runAuthorityCommand(authority, dryRun) {
|
|
670
|
+
const initArgs = dryRun ? authority.initArgs : (authority.applyArgs ?? authority.initArgs);
|
|
671
|
+
const args = [providerExecutable(authority), ...initArgs];
|
|
672
|
+
if (dryRun) args.push('--dry-run');
|
|
673
|
+
const result = spawnSync(process.execPath, args, {
|
|
674
|
+
cwd: root,
|
|
675
|
+
encoding: 'utf8',
|
|
676
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
677
|
+
});
|
|
678
|
+
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
|
679
|
+
console.log(`\n${authority.label} ${dryRun ? 'preflight' : 'apply'}:`);
|
|
680
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
681
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
682
|
+
return {
|
|
683
|
+
ok: result.status === 0 && !providerHasConflict(output),
|
|
684
|
+
output,
|
|
685
|
+
status: result.status,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function writeManifestAtomically(manifest) {
|
|
690
|
+
const temporary = `${manifestPath}.tmp-${process.pid}`;
|
|
691
|
+
await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx' });
|
|
692
|
+
await rename(temporary, manifestPath);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function ensureProjectionReady(manifest) {
|
|
696
|
+
const target = resolve(root, projection);
|
|
697
|
+
try {
|
|
698
|
+
const current = await readFile(target, 'utf8');
|
|
699
|
+
if (current !== render(manifest)) {
|
|
700
|
+
throw new Error(`${projection} has drifted; regenerate it from lighthouse.json before running lighthouse authorities --apply.`);
|
|
701
|
+
}
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error.code !== 'ENOENT') throw error;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function writeProjection(manifest) {
|
|
708
|
+
const target = resolve(root, projection);
|
|
709
|
+
await writeFile(target, render(manifest), { flag: 'w' });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async function authorities(args) {
|
|
713
|
+
const manifest = await readManifest();
|
|
714
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
715
|
+
const problems = validate(manifest);
|
|
716
|
+
if (problems.length) throw new Error(problems.map((problem) => `Invalid lighthouse.json: ${problem}`).join('\n'));
|
|
717
|
+
const apply = args.includes('--apply');
|
|
718
|
+
const dryRun = args.includes('--dry-run') || !apply;
|
|
719
|
+
if (args.includes('--apply') && args.includes('--dry-run')) throw new Error('--apply and --dry-run cannot be used together.');
|
|
720
|
+
console.log('Lighthouse authority plan:');
|
|
721
|
+
console.log(' ProductShape: initialize the full ProductShape layout without an SDD integration');
|
|
722
|
+
console.log(' ArcShape: initialize arc42 documentation without installing agent capabilities');
|
|
723
|
+
console.log(' Provider-owned files remain under provider control; Lighthouse records only exact versions.');
|
|
724
|
+
const preflights = authorityCommands.map((authority) => ({ authority, result: runAuthorityCommand(authority, true) }));
|
|
725
|
+
const failed = preflights.filter(({ result }) => !result.ok);
|
|
726
|
+
if (failed.length) {
|
|
727
|
+
throw new Error(failed.map(({ authority }) => `${authority.label} preflight failed; repair with: ${authority.repair}`).join('\n'));
|
|
728
|
+
}
|
|
729
|
+
if (dryRun) {
|
|
730
|
+
console.log('Dry run: nothing was changed. Re-run with --apply to apply both provider plans.');
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
await ensureProjectionReady(manifest);
|
|
734
|
+
for (const authority of authorityCommands) {
|
|
735
|
+
const result = runAuthorityCommand(authority, false);
|
|
736
|
+
if (!result.ok) throw new Error(`${authority.label} apply failed; repair with: ${authority.repair}`);
|
|
737
|
+
}
|
|
738
|
+
const updated = {
|
|
739
|
+
...manifest,
|
|
740
|
+
integrations: { ...manifest.integrations, ...authorityVersions },
|
|
741
|
+
};
|
|
742
|
+
await writeManifestAtomically(updated);
|
|
743
|
+
await writeProjection(updated);
|
|
744
|
+
console.log(`Recorded ProductShape ${authorityVersions.productShape} and ArcShape ${authorityVersions.arcShape} in lighthouse.json.`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function gitBranch() {
|
|
748
|
+
try {
|
|
749
|
+
return execFileSync('git', ['branch', '--show-current'], {
|
|
750
|
+
cwd: root,
|
|
751
|
+
encoding: 'utf8',
|
|
752
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
753
|
+
}).trim();
|
|
754
|
+
}
|
|
755
|
+
catch { return ''; }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function validateGitConventions(repository) {
|
|
759
|
+
const problems = [];
|
|
760
|
+
const branch = gitBranch();
|
|
761
|
+
if (branch && !['main', 'master'].includes(branch) && !new RegExp(repository.conventions.branchPattern, 'u').test(branch)) {
|
|
762
|
+
problems.push(`branch ${branch} does not match ${repository.conventions.branchPattern}`);
|
|
763
|
+
}
|
|
764
|
+
const message = process.env.LIGHTHOUSE_COMMIT_MESSAGE;
|
|
765
|
+
if (message && repository.conventions.commits === 'conventional'
|
|
766
|
+
&& !/^(?:feat|fix|docs|refactor|test|chore|build|ci|perf|revert)(?:\([^)]+\))?!?: .+/u.test(message.trim())) {
|
|
767
|
+
problems.push('commit message does not follow Conventional Commits');
|
|
768
|
+
}
|
|
769
|
+
return problems;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async function verify(manifest, format = 'text') {
|
|
773
|
+
const manifestProblems = validate(manifest);
|
|
774
|
+
const validManifest = manifestProblems.length === 0;
|
|
775
|
+
const repository = normalizeRepository(manifest?.repository);
|
|
776
|
+
const managed = validManifest ? managedFiles(manifest) : {};
|
|
777
|
+
const report = await evaluateGuardrails({
|
|
778
|
+
catalog: readGuardrailCatalog(),
|
|
779
|
+
manifest,
|
|
780
|
+
manifestProblems,
|
|
781
|
+
repository,
|
|
782
|
+
managedFiles: managed,
|
|
783
|
+
resolve: (path) => resolve(root, path),
|
|
784
|
+
exists,
|
|
785
|
+
isDirectory,
|
|
786
|
+
readFile,
|
|
787
|
+
validateGitConventions,
|
|
788
|
+
branch: gitBranch(),
|
|
789
|
+
});
|
|
790
|
+
if (format === 'json') {
|
|
791
|
+
console.log(JSON.stringify(report, null, 2));
|
|
792
|
+
if (report.problems.length) process.exitCode = 1;
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (report.problems.length) throw new Error(report.problems.join('\n'));
|
|
796
|
+
console.log('Lighthouse manifest, repository boundaries and managed projection are valid.');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function formatFromArgs(args) {
|
|
800
|
+
const index = args.indexOf('--format');
|
|
801
|
+
if (index !== -1) return args[index + 1];
|
|
802
|
+
return args.find((arg) => arg.startsWith('--format='))?.slice('--format='.length) ?? 'text';
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const args = process.argv.slice(2);
|
|
806
|
+
const command = args[0];
|
|
807
|
+
const commandArgs = args.slice(1);
|
|
808
|
+
let invalidArgument = false;
|
|
809
|
+
for (let index = 0; index < commandArgs.length; index += 1) {
|
|
810
|
+
const arg = commandArgs[index];
|
|
811
|
+
if (arg === '--dry-run') continue;
|
|
812
|
+
if (arg === '--apply') { if (!['authorities', 'reconcile', 'upgrade'].includes(command)) invalidArgument = true; continue; }
|
|
813
|
+
if (arg === '--name') { if (!commandArgs[index + 1]) invalidArgument = true; else index += 1; continue; }
|
|
814
|
+
if (arg.startsWith('--name=') && arg.length > '--name='.length) continue;
|
|
815
|
+
if (arg === '--format') {
|
|
816
|
+
if (!['verify', 'upgrade'].includes(command) || !['text', 'json'].includes(commandArgs[index + 1])) invalidArgument = true;
|
|
817
|
+
else index += 1;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (arg.startsWith('--format=') && ['text', 'json'].includes(arg.slice('--format='.length))) {
|
|
821
|
+
if (!['verify', 'upgrade'].includes(command)) invalidArgument = true;
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
if (arg === '--scope') {
|
|
825
|
+
if (command !== 'upgrade' || !commandArgs[index + 1]) invalidArgument = true;
|
|
826
|
+
else index += 1;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (arg.startsWith('--scope=') && arg.length > '--scope='.length) {
|
|
830
|
+
if (command !== 'upgrade') invalidArgument = true;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
invalidArgument = true;
|
|
834
|
+
}
|
|
835
|
+
if (!['init', 'authorities', 'reconcile', 'upgrade', 'verify'].includes(command) || invalidArgument) {
|
|
836
|
+
console.error(usage);
|
|
837
|
+
process.exit(2);
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
if (command === 'init') await initialize(args.slice(1));
|
|
841
|
+
else if (command === 'authorities') await authorities(args.slice(1));
|
|
842
|
+
else if (command === 'reconcile') await reconcile(args.slice(1));
|
|
843
|
+
else if (command === 'upgrade') await upgrade(args.slice(1));
|
|
844
|
+
else {
|
|
845
|
+
const manifest = await readManifest();
|
|
846
|
+
if (!manifest) throw new Error('Cannot read lighthouse.json: file does not exist; run lighthouse init --name <product>.');
|
|
847
|
+
await verify(manifest, formatFromArgs(commandArgs));
|
|
58
848
|
}
|
|
59
|
-
|
|
849
|
+
} catch (error) {
|
|
850
|
+
console.error(error.message);
|
|
851
|
+
process.exitCode = 1;
|
|
60
852
|
}
|