@juangcarmona/lighthouse-sdlc 0.7.0 → 0.8.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 CHANGED
@@ -8,8 +8,18 @@ It publishes `command-aliases.json` as the versioned, machine-readable mapping f
8
8
 
9
9
  It also provides the `browser-e2e` evidence contract for Playwright journeys. The contract belongs to the SDLC because it records delivery evidence; the tests, journeys and browser configuration remain owned by the adopter.
10
10
 
11
+ It provides a neutral evidence contract for delivery, product and architecture references. `lighthouse-sdlc evidence` validates records or builds a deterministic coverage index; it does not replace ProductShape, ArcShape or a future quality evaluator.
12
+
11
13
  It does not run agents, store transcripts, dispatch work between repositories or replace ProductShape, ArcShape, OpenSpec or an agent host.
12
14
 
15
+ ## gh-aw workflow projection
16
+
17
+ The package includes the six Lighthouse SDLC worker sources, their compiled `.lock.yml` workflows and the router projection. The Lighthouse CLI projects them into `.github/workflows` during initialization, reconciliation or an explicit `lighthouse upgrade --scope sdlc --apply`; package installation alone does not write repository files.
18
+
19
+ The host must provide the runner capability `[self-hosted, linux, lighthouse-sdlc]`, the GitHub and gh-aw credentials required by the compiled workflow, the selected route and stage input, and durable evidence handling for the uploaded stage result. Each worker executes one declared stage and writes `/tmp/gh-aw/agent/stage-result.json`; the host remains responsible for runtime, queues, budgets and model selection. The generic runner capability is a contract, not a SoFa or Bastion identity.
20
+
21
+ The router validates the existing Lighthouse route and `requires` declarations. A normal dispatch runs one stage. Only an explicit `ff` composition chains the selected route, and it stops when a worker fails. The `integrate` worker prepares the configured merge action without merging unless repository policy authorizes it; `review` closes the flow with `gate: null`.
22
+
13
23
  Install it with the Lighthouse CLI or run `lighthouse-sdlc verify` from the package installation to check the distributed contract.
14
24
 
15
25
  Run `lighthouse-sdlc triage --input <file>` to validate one structured triage result and receive its proposed workflow and classification labels. The command is read-only by default; `--apply --labels-file <file>` writes only after the report is ready and the labels file still matches the input.
@@ -18,6 +28,8 @@ Use `lighthouse-sdlc route --route <name>` to inspect a route before running it,
18
28
 
19
29
  Use `lighthouse-sdlc browser-e2e --input <evidence.json>` to validate a Playwright journey result. It is read-only and does not start a browser or write provider state.
20
30
 
31
+ Use `lighthouse-sdlc evidence --input <record-or-index.json>` to validate evidence, or add `--build --output <index.json>` to build an index and its compact record files from full records. The command is read-only unless the output path is explicitly provided.
32
+
21
33
  Use `lighthouse-sdlc github-triage --repo <owner/repo> --issue <number> --assessment <file>` for the first GitHub projection. It reads issue metadata by default; `--apply` is the explicit label mutation boundary and rereads the issue before writing.
22
34
 
23
35
  Use `lighthouse-sdlc github-decompose --repo <owner/repo> --issue <number> --input <file>` to turn a validated slice plan into child-issue operations. The default is a preview; `--apply` creates the child issues in dependency order with the parent link and acceptance evidence in each body.
@@ -0,0 +1,9 @@
1
+ # evidence
2
+
3
+ Validate one evidence record or an evidence index without writing repository or provider state.
4
+
5
+ Use `lighthouse-sdlc evidence --input <record-or-index.json> --format json` to validate an existing artifact.
6
+
7
+ Use `lighthouse-sdlc evidence --input <records.json> --build --output .lighthouse/evidence/index.json` to build a deterministic index from full records and write the compact record files beside it. The input object must contain `records` and may contain `indexId`, `runId`, `repository`, `revision` and `subjects`.
8
+
9
+ The index reports evidence coverage for ProductShape, ArcShape, Lighthouse or other explicitly referenced subjects. It does not decide product scope, architecture ownership or quality policy.
package/contract.json CHANGED
@@ -10,7 +10,7 @@
10
10
  { "name": "integrate", "skill": "skills/integrate/SKILL.md", "command": "commands/integrate.md", "gate": "done" },
11
11
  { "name": "review", "skill": "skills/review/SKILL.md", "command": "commands/review.md", "gate": null }
12
12
  ],
13
- "utilities": ["status", "aliases", "route", "state", "stage-result", "control-plane", "memory", "launch", "decompose", "stacked", "browser-e2e", "github-triage", "github-decompose", "reconcile-labels", "github-labels"],
13
+ "utilities": ["status", "aliases", "route", "state", "stage-result", "control-plane", "memory", "launch", "decompose", "stacked", "browser-e2e", "evidence", "github-triage", "github-decompose", "reconcile-labels", "github-labels"],
14
14
  "routes": ["supervised", "plan-only", "unattended", "hotfix", "stacked"],
15
15
  "metaCommands": ["ff", "hotfix", "decompose", "stacked"],
16
16
  "gates": ["triaged", "ready", "planned", "done"],
@@ -25,6 +25,8 @@
25
25
  "decompositionSchema": "schemas/decomposition.schema.json",
26
26
  "capabilitiesSchema": "schemas/capabilities.schema.json",
27
27
  "browserE2eSchema": "schemas/browser-e2e.schema.json",
28
+ "evidenceRecordSchema": "schemas/evidence-record.schema.json",
29
+ "evidenceIndexSchema": "schemas/evidence-index.schema.json",
28
30
  "capabilityCatalog": "capabilities.json",
29
31
  "capabilityCatalogSchema": "schemas/capability-catalog.schema.json",
30
32
  "commandAliases": "command-aliases.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  "stages.json",
32
32
  "schemas",
33
33
  "skills",
34
+ "workflows",
34
35
  "src"
35
36
  ],
36
37
  "bin": {
@@ -51,6 +52,7 @@
51
52
  "./stacked": "./src/stacked.mjs",
52
53
  "./workflow": "./src/workflow.mjs",
53
54
  "./browser-e2e": "./src/browser-e2e.mjs",
55
+ "./evidence": "./src/evidence.mjs",
54
56
  "./contract.json": "./contract.json",
55
57
  "./schemas/control-plane.schema.json": "./schemas/control-plane.schema.json",
56
58
  "./schemas/triage.schema.json": "./schemas/triage.schema.json",
@@ -61,8 +63,11 @@
61
63
  "./schemas/capabilities.schema.json": "./schemas/capabilities.schema.json",
62
64
  "./schemas/capability-catalog.schema.json": "./schemas/capability-catalog.schema.json",
63
65
  "./schemas/browser-e2e.schema.json": "./schemas/browser-e2e.schema.json",
66
+ "./schemas/evidence-record.schema.json": "./schemas/evidence-record.schema.json",
67
+ "./schemas/evidence-index.schema.json": "./schemas/evidence-index.schema.json",
64
68
  "./capabilities.json": "./capabilities.json",
65
69
  "./command-aliases.json": "./command-aliases.json",
66
- "./schemas/command-aliases.schema.json": "./schemas/command-aliases.schema.json"
70
+ "./schemas/command-aliases.schema.json": "./schemas/command-aliases.schema.json",
71
+ "./workflows/*": "./workflows/*"
67
72
  }
68
73
  }
@@ -0,0 +1,65 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/juangcarmona/lighthouse/schemas/lighthouse-evidence-index.schema.json",
4
+ "title": "Lighthouse evidence index",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "indexId", "runId", "records", "coverage"],
8
+ "properties": {
9
+ "schemaVersion": { "const": 1 },
10
+ "indexId": { "type": "string", "minLength": 1 },
11
+ "runId": { "type": "string", "minLength": 1 },
12
+ "repository": { "type": "string", "minLength": 1 },
13
+ "revision": { "type": "string", "minLength": 1 },
14
+ "records": { "type": "array", "items": { "$ref": "#/$defs/recordRef" } },
15
+ "coverage": { "type": "array", "items": { "$ref": "#/$defs/coverage" } }
16
+ },
17
+ "$defs": {
18
+ "subject": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["type", "id"],
22
+ "properties": {
23
+ "type": { "type": "string", "minLength": 1 },
24
+ "id": { "type": "string", "minLength": 1 }
25
+ }
26
+ },
27
+ "authorityRef": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["authority", "id"],
31
+ "properties": {
32
+ "authority": { "type": "string", "minLength": 1 },
33
+ "id": { "type": "string", "minLength": 1 },
34
+ "revision": { "type": "string", "minLength": 1 },
35
+ "path": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" },
36
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
37
+ }
38
+ },
39
+ "recordRef": {
40
+ "type": "object",
41
+ "additionalProperties": false,
42
+ "required": ["id", "path", "sha256", "kind", "status"],
43
+ "properties": {
44
+ "id": { "type": "string", "minLength": 1 },
45
+ "path": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" },
46
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
47
+ "kind": { "type": "string", "minLength": 1 },
48
+ "status": { "enum": ["passed", "failed", "unavailable", "unconfigured", "pending-review", "not-applicable"] },
49
+ "subject": { "$ref": "#/$defs/subject" },
50
+ "authorityRefs": { "type": "array", "items": { "$ref": "#/$defs/authorityRef" } }
51
+ }
52
+ },
53
+ "coverage": {
54
+ "type": "object",
55
+ "additionalProperties": false,
56
+ "required": ["subject", "status", "evidenceIds"],
57
+ "properties": {
58
+ "subject": { "$ref": "#/$defs/subject" },
59
+ "status": { "enum": ["unverified", "observed", "verified", "incomplete", "conflict", "not-applicable"] },
60
+ "evidenceIds": { "type": "array", "items": { "type": "string", "minLength": 1 } },
61
+ "nextAction": { "type": "string", "minLength": 1 }
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/juangcarmona/lighthouse/schemas/lighthouse-evidence-record.schema.json",
4
+ "title": "Lighthouse evidence record",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schemaVersion", "id", "runId", "kind", "scope", "status", "producer", "payload"],
8
+ "properties": {
9
+ "schemaVersion": { "const": 1 },
10
+ "id": { "type": "string", "minLength": 1 },
11
+ "runId": { "type": "string", "minLength": 1 },
12
+ "kind": { "type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$" },
13
+ "scope": { "enum": ["product", "architecture", "delivery", "release", "run"] },
14
+ "status": { "enum": ["passed", "failed", "unavailable", "unconfigured", "pending-review", "not-applicable"] },
15
+ "producer": { "type": "string", "minLength": 1 },
16
+ "subject": { "$ref": "#/$defs/subject" },
17
+ "authorityRefs": { "type": "array", "items": { "$ref": "#/$defs/authorityRef" } },
18
+ "source": { "$ref": "#/$defs/source" },
19
+ "observedAt": { "type": "string", "format": "date-time" },
20
+ "artifacts": { "type": "array", "items": { "$ref": "#/$defs/artifact" } },
21
+ "payload": { "type": "object" },
22
+ "nextAction": { "type": "string", "minLength": 1 }
23
+ },
24
+ "$defs": {
25
+ "subject": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["type", "id"],
29
+ "properties": {
30
+ "type": { "type": "string", "minLength": 1 },
31
+ "id": { "type": "string", "minLength": 1 }
32
+ }
33
+ },
34
+ "authorityRef": {
35
+ "type": "object",
36
+ "additionalProperties": false,
37
+ "required": ["authority", "id"],
38
+ "properties": {
39
+ "authority": { "type": "string", "minLength": 1 },
40
+ "id": { "type": "string", "minLength": 1 },
41
+ "revision": { "type": "string", "minLength": 1 },
42
+ "path": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" },
43
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
44
+ }
45
+ },
46
+ "source": {
47
+ "type": "object",
48
+ "additionalProperties": false,
49
+ "properties": {
50
+ "format": { "type": "string", "minLength": 1 },
51
+ "path": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" },
52
+ "command": { "type": "string", "minLength": 1 }
53
+ }
54
+ },
55
+ "artifact": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["type", "path"],
59
+ "properties": {
60
+ "id": { "type": "string", "minLength": 1 },
61
+ "type": { "type": "string", "minLength": 1 },
62
+ "path": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+" },
63
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
64
+ }
65
+ }
66
+ }
67
+ }
@@ -17,7 +17,7 @@
17
17
  "assets": {
18
18
  "type": "object",
19
19
  "additionalProperties": false,
20
- "required": ["routes", "gates", "stages", "controlPlaneSchema", "triageSchema", "stageResultSchema", "stateSchema", "decompositionSchema", "capabilitiesSchema"],
20
+ "required": ["routes", "gates", "stages", "controlPlaneSchema", "triageSchema", "stageResultSchema", "stateSchema", "decompositionSchema", "capabilitiesSchema", "evidenceRecordSchema", "evidenceIndexSchema"],
21
21
  "properties": {
22
22
  "routes": { "type": "string" },
23
23
  "gates": { "type": "string" },
@@ -27,7 +27,9 @@
27
27
  "stageResultSchema": { "type": "string" },
28
28
  "stateSchema": { "type": "string" },
29
29
  "decompositionSchema": { "type": "string" },
30
- "capabilitiesSchema": { "type": "string" }
30
+ "capabilitiesSchema": { "type": "string" },
31
+ "evidenceRecordSchema": { "type": "string" },
32
+ "evidenceIndexSchema": { "type": "string" }
31
33
  }
32
34
  }
33
35
  },
@@ -0,0 +1,204 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { EVIDENCE_STATUSES, validateStageResult } from './contracts.mjs';
5
+ import { validateBrowserEvidence } from './browser-e2e.mjs';
6
+
7
+ export const EVIDENCE_SCOPES = Object.freeze(['product', 'architecture', 'delivery', 'release', 'run']);
8
+ export const COVERAGE_STATUSES = Object.freeze(['unverified', 'observed', 'verified', 'incomplete', 'conflict', 'not-applicable']);
9
+
10
+ const evidenceStatusSet = new Set(EVIDENCE_STATUSES);
11
+ const scopeSet = new Set(EVIDENCE_SCOPES);
12
+ const coverageStatusSet = new Set(COVERAGE_STATUSES);
13
+
14
+ function issue(code, message, field) {
15
+ return field ? { code, message, field } : { code, message };
16
+ }
17
+
18
+ function record(value) {
19
+ return value && typeof value === 'object' && !Array.isArray(value);
20
+ }
21
+
22
+ function text(value) {
23
+ return typeof value === 'string' && value.trim().length > 0;
24
+ }
25
+
26
+ function relativePath(value) {
27
+ return text(value) && !value.startsWith('/') && !value.split('/').includes('..');
28
+ }
29
+
30
+ function validSubject(value) {
31
+ return record(value) && text(value.type) && text(value.id);
32
+ }
33
+
34
+ function subjectKey(value) {
35
+ return `${value.type}:${value.id}`;
36
+ }
37
+
38
+ function validateAuthorityRefs(value, errors, field = 'authorityRefs') {
39
+ if (value === undefined) return;
40
+ if (!Array.isArray(value)) {
41
+ errors.push(issue('evidence.authority-refs', 'authorityRefs must be an array', field));
42
+ return;
43
+ }
44
+ for (const [index, item] of value.entries()) {
45
+ if (!record(item) || !text(item.authority) || !text(item.id)) errors.push(issue('evidence.authority-ref', `authorityRefs[${index}] needs authority and id`, `${field}[${index}]`));
46
+ if (item.path !== undefined && !relativePath(item.path)) errors.push(issue('evidence.authority-path', `authorityRefs[${index}].path must be relative`, `${field}[${index}].path`));
47
+ }
48
+ }
49
+
50
+ function validateArtifacts(value, errors, field = 'artifacts') {
51
+ if (value === undefined) return;
52
+ if (!Array.isArray(value)) {
53
+ errors.push(issue('evidence.artifacts', 'artifacts must be an array', field));
54
+ return;
55
+ }
56
+ const ids = new Set();
57
+ for (const [index, item] of value.entries()) {
58
+ if (!record(item) || !text(item.type) || !relativePath(item.path)) {
59
+ errors.push(issue('evidence.artifact', `artifacts[${index}] needs a type and relative path`, `${field}[${index}]`));
60
+ continue;
61
+ }
62
+ if (item.id !== undefined) {
63
+ if (!text(item.id) || ids.has(item.id)) errors.push(issue('evidence.artifact-id', `artifacts[${index}].id must be unique`, `${field}[${index}].id`));
64
+ ids.add(item.id);
65
+ }
66
+ if (item.sha256 !== undefined && !/^[a-f0-9]{64}$/u.test(item.sha256)) errors.push(issue('evidence.artifact-hash', `artifacts[${index}].sha256 must be a lowercase SHA-256`, `${field}[${index}].sha256`));
67
+ }
68
+ }
69
+
70
+ export function validateEvidenceRecord(value) {
71
+ const errors = [];
72
+ if (!record(value)) return [issue('evidence.invalid', 'evidence record must be an object')];
73
+ if (value.schemaVersion !== 1) errors.push(issue('evidence.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
74
+ for (const field of ['id', 'runId', 'kind', 'producer']) if (!text(value[field]) && field !== 'producer') errors.push(issue(`evidence.${field}`, `${field} must be a non-empty string`, field));
75
+ if (!text(value.producer)) errors.push(issue('evidence.producer', 'producer must be a non-empty string', 'producer'));
76
+ if (!scopeSet.has(value.scope)) errors.push(issue('evidence.scope', `scope must be one of ${EVIDENCE_SCOPES.join(', ')}`, 'scope'));
77
+ if (!evidenceStatusSet.has(value.status)) errors.push(issue('evidence.status', `status must be one of ${EVIDENCE_STATUSES.join(', ')}`, 'status'));
78
+ if (!record(value.payload)) errors.push(issue('evidence.payload', 'payload must be an object', 'payload'));
79
+ if (value.subject !== undefined && !validSubject(value.subject)) errors.push(issue('evidence.subject', 'subject needs a type and id', 'subject'));
80
+ validateAuthorityRefs(value.authorityRefs, errors);
81
+ validateArtifacts(value.artifacts, errors);
82
+ if (value.source !== undefined && (!record(value.source) || (value.source.path !== undefined && !relativePath(value.source.path)))) errors.push(issue('evidence.source', 'source must be an object with a relative path', 'source'));
83
+ if (value.observedAt !== undefined && (typeof value.observedAt !== 'string' || Number.isNaN(Date.parse(value.observedAt)))) errors.push(issue('evidence.observed-at', 'observedAt must be an ISO date-time', 'observedAt'));
84
+ if (['failed', 'unavailable', 'unconfigured', 'pending-review'].includes(value.status) && !text(value.nextAction)) errors.push(issue('evidence.next-action', `status ${value.status} needs nextAction`, 'nextAction'));
85
+ if (value.nextAction !== undefined && !text(value.nextAction)) errors.push(issue('evidence.next-action', 'nextAction must be a non-empty string when present', 'nextAction'));
86
+ return errors;
87
+ }
88
+
89
+ function clean(value) {
90
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
91
+ }
92
+
93
+ function recordFrom(value, { id, kind, scope = 'delivery', producer, subject, authorityRefs, source, artifacts, nextAction } = {}) {
94
+ const status = kind === 'stage-result'
95
+ ? ({ passed: 'passed', failed: 'failed', blocked: 'pending-review', ready: 'pending-review' }[value.status] ?? 'unconfigured')
96
+ : value.status;
97
+ return clean({
98
+ schemaVersion: 1,
99
+ id: id ?? `${kind}:${value.runId ?? value.journey?.id ?? 'unknown'}`,
100
+ runId: value.runId ?? `${kind}:${value.journey?.id ?? 'unknown'}`,
101
+ kind,
102
+ scope,
103
+ status,
104
+ producer,
105
+ subject,
106
+ authorityRefs,
107
+ source,
108
+ observedAt: value.observedAt,
109
+ artifacts: artifacts ?? value.artifacts ?? [],
110
+ payload: value,
111
+ nextAction: nextAction ?? value.nextAction,
112
+ });
113
+ }
114
+
115
+ export function adaptStageResult(value, options = {}) {
116
+ const errors = validateStageResult(value);
117
+ return { record: errors.length ? null : recordFrom(value, { ...options, kind: 'stage-result', producer: options.producer ?? 'lighthouse-sdlc' }), errors };
118
+ }
119
+
120
+ export function adaptBrowserEvidence(value, options = {}) {
121
+ const errors = validateBrowserEvidence(value);
122
+ return { record: errors.length ? null : recordFrom(value, { ...options, kind: 'browser-e2e', producer: options.producer ?? 'lighthouse-sdlc' }), errors };
123
+ }
124
+
125
+ function hash(value) {
126
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
127
+ }
128
+
129
+ function coverageStatus(records) {
130
+ if (!records.length) return 'unverified';
131
+ const statuses = new Set(records.map((item) => item.status));
132
+ if (statuses.size === 1 && statuses.has('passed')) return 'verified';
133
+ if (statuses.has('failed') && statuses.size > 1) return 'conflict';
134
+ if (statuses.has('failed') || statuses.has('unavailable') || statuses.has('unconfigured') || statuses.has('pending-review')) return 'incomplete';
135
+ if (statuses.size === 1 && statuses.has('not-applicable')) return 'not-applicable';
136
+ return 'observed';
137
+ }
138
+
139
+ export function buildEvidenceIndex(records, { indexId = 'evidence-index', runId, repository, revision, subjects = [], recordPaths = {} } = {}) {
140
+ const errors = [];
141
+ if (!Array.isArray(records) || records.length === 0) errors.push(issue('evidence.index-records', 'records must contain at least one evidence record', 'records'));
142
+ const validRecords = Array.isArray(records) ? records : [];
143
+ for (const [index, item] of validRecords.entries()) for (const error of validateEvidenceRecord(item)) errors.push({ ...error, field: error.field ? `records[${index}].${error.field}` : `records[${index}]` });
144
+ const ids = new Set();
145
+ for (const [index, item] of validRecords.entries()) {
146
+ if (ids.has(item.id)) errors.push(issue('evidence.duplicate-id', `records[${index}] duplicates evidence id ${item.id}`, `records[${index}].id`));
147
+ ids.add(item.id);
148
+ }
149
+ const effectiveRunId = runId ?? validRecords[0]?.runId;
150
+ if (!text(effectiveRunId)) errors.push(issue('evidence.index-run-id', 'runId must be provided or present on every record', 'runId'));
151
+ const expected = [...subjects, ...validRecords.filter((item) => item.subject).map((item) => item.subject)];
152
+ const subjectMap = new Map();
153
+ for (const item of expected) if (validSubject(item)) subjectMap.set(subjectKey(item), item);
154
+ const refs = validRecords.map((item) => clean({ id: item.id, path: recordPaths[item.id] ?? `records/${item.id}.json`, sha256: hash(item), kind: item.kind, status: item.status, subject: item.subject, authorityRefs: item.authorityRefs }));
155
+ const coverage = [...subjectMap.entries()].map(([key, subject]) => {
156
+ const related = validRecords.filter((item) => item.subject && subjectKey(item.subject) === key);
157
+ const status = coverageStatus(related);
158
+ return clean({ subject, status, evidenceIds: related.map((item) => item.id), nextAction: status === 'verified' || status === 'not-applicable' ? undefined : `Collect or resolve evidence for ${key}.` });
159
+ });
160
+ if (errors.length) return { index: null, errors };
161
+ return {
162
+ index: clean({ schemaVersion: 1, indexId, runId: effectiveRunId, repository, revision, records: refs, coverage }),
163
+ errors: [],
164
+ };
165
+ }
166
+
167
+ export function validateEvidenceIndex(value) {
168
+ const errors = [];
169
+ if (!record(value)) return [issue('evidence-index.invalid', 'evidence index must be an object')];
170
+ if (value.schemaVersion !== 1) errors.push(issue('evidence-index.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
171
+ for (const field of ['indexId', 'runId']) if (!text(value[field])) errors.push(issue(`evidence-index.${field}`, `${field} must be a non-empty string`, field));
172
+ if (!Array.isArray(value.records)) errors.push(issue('evidence-index.records', 'records must be an array', 'records'));
173
+ else {
174
+ const ids = new Set();
175
+ for (const [index, item] of value.records.entries()) {
176
+ if (!record(item) || !text(item.id) || !relativePath(item.path) || !/^[a-f0-9]{64}$/u.test(item.sha256) || !text(item.kind) || !evidenceStatusSet.has(item.status)) errors.push(issue('evidence-index.record-ref', `records[${index}] needs id, relative path, SHA-256, kind and status`, `records[${index}]`));
177
+ if (item?.id && ids.has(item.id)) errors.push(issue('evidence-index.duplicate-id', `records[${index}] duplicates evidence id ${item.id}`, `records[${index}].id`));
178
+ if (item?.id) ids.add(item.id);
179
+ if (item?.subject !== undefined && !validSubject(item.subject)) errors.push(issue('evidence-index.subject', `records[${index}].subject needs a type and id`, `records[${index}].subject`));
180
+ validateAuthorityRefs(item?.authorityRefs, errors, `records[${index}].authorityRefs`);
181
+ }
182
+ }
183
+ if (!Array.isArray(value.coverage)) errors.push(issue('evidence-index.coverage', 'coverage must be an array', 'coverage'));
184
+ else {
185
+ const subjects = new Set();
186
+ for (const [index, item] of value.coverage.entries()) {
187
+ if (!record(item) || !validSubject(item.subject) || !coverageStatusSet.has(item.status) || !Array.isArray(item.evidenceIds)) errors.push(issue('evidence-index.coverage-entry', `coverage[${index}] needs subject, status and evidenceIds`, `coverage[${index}]`));
188
+ const key = item?.subject && validSubject(item.subject) ? subjectKey(item.subject) : null;
189
+ if (key && subjects.has(key)) errors.push(issue('evidence-index.coverage-duplicate', `coverage[${index}] duplicates subject ${key}`, `coverage[${index}].subject`));
190
+ if (key) subjects.add(key);
191
+ }
192
+ }
193
+ return errors;
194
+ }
195
+
196
+ export function evidenceReport(value) {
197
+ const errors = value?.kind ? validateEvidenceRecord(value) : validateEvidenceIndex(value);
198
+ return { schemaVersion: 1, status: errors.length ? 'invalid' : 'valid', valid: errors.length === 0, errors };
199
+ }
200
+
201
+ export async function readEvidenceIndex(path, cwd = process.cwd()) {
202
+ const value = JSON.parse(await readFile(resolve(cwd, path), 'utf8'));
203
+ return { value, errors: validateEvidenceIndex(value) };
204
+ }
package/src/sdlc.mjs CHANGED
@@ -14,6 +14,7 @@ import { validateMemoryRecord } from './memory.mjs';
14
14
  import { replayWorkflow } from './workflow.mjs';
15
15
  import { validateStageResult } from './contracts.mjs';
16
16
  import { browserEvidenceReport } from './browser-e2e.mjs';
17
+ import { buildEvidenceIndex, evidenceReport, validateEvidenceIndex, validateEvidenceRecord } from './evidence.mjs';
17
18
  import { readCapabilityCatalog, validateCapabilityCatalog } from './capabilities.mjs';
18
19
  import { readCommandAliases, validateCommandAliases, verifyCommandAliases } from './aliases.mjs';
19
20
 
@@ -41,10 +42,10 @@ async function verifyContract() {
41
42
  for (const stage of stages) {
42
43
  for (const asset of [stage.skill, stage.command]) if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing stage asset ${asset}`);
43
44
  }
44
- for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.stages, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema, contract.assets?.stageResultSchema, contract.assets?.stateSchema, contract.assets?.decompositionSchema, contract.assets?.capabilitiesSchema, contract.assets?.capabilityCatalog, contract.assets?.capabilityCatalogSchema, contract.assets?.browserE2eSchema, contract.assets?.commandAliases, contract.assets?.commandAliasesSchema]) {
45
+ for (const asset of [contract.assets?.routes, contract.assets?.gates, contract.assets?.stages, contract.assets?.controlPlaneSchema, contract.assets?.triageSchema, contract.assets?.stageResultSchema, contract.assets?.stateSchema, contract.assets?.decompositionSchema, contract.assets?.capabilitiesSchema, contract.assets?.capabilityCatalog, contract.assets?.capabilityCatalogSchema, contract.assets?.browserE2eSchema, contract.assets?.evidenceRecordSchema, contract.assets?.evidenceIndexSchema, contract.assets?.commandAliases, contract.assets?.commandAliasesSchema]) {
45
46
  if (!asset || !(await exists(resolve(packageRoot, asset)))) problems.push(`missing contract asset ${asset ?? '(empty)'}`);
46
47
  }
47
- for (const asset of ['routes.json', 'gates.json', 'stages.json', 'command-aliases.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/stages.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'schemas/stage-result.schema.json', 'schemas/workflow-state.schema.json', 'schemas/decomposition.schema.json', 'schemas/capabilities.schema.json', 'schemas/capability-catalog.schema.json', 'schemas/browser-e2e.schema.json', 'schemas/command-aliases.schema.json', 'src/aliases.mjs', 'src/browser-e2e.mjs', 'src/capabilities.mjs', 'src/contracts.mjs', 'src/control-plane.mjs', 'src/decompose.mjs', 'src/github.mjs', 'src/memory.mjs', 'src/reconcile.mjs', 'src/routes.mjs', 'src/stacked.mjs', 'src/triage.mjs', 'src/workflow.mjs']) {
48
+ for (const asset of ['routes.json', 'gates.json', 'stages.json', 'command-aliases.json', 'schemas/routes.schema.json', 'schemas/gates.schema.json', 'schemas/stages.schema.json', 'schemas/control-plane.schema.json', 'schemas/triage.schema.json', 'schemas/stage-result.schema.json', 'schemas/workflow-state.schema.json', 'schemas/decomposition.schema.json', 'schemas/capabilities.schema.json', 'schemas/capability-catalog.schema.json', 'schemas/browser-e2e.schema.json', 'schemas/evidence-record.schema.json', 'schemas/evidence-index.schema.json', 'schemas/command-aliases.schema.json', 'src/aliases.mjs', 'src/browser-e2e.mjs', 'src/evidence.mjs', 'src/capabilities.mjs', 'src/contracts.mjs', 'src/control-plane.mjs', 'src/decompose.mjs', 'src/github.mjs', 'src/memory.mjs', 'src/reconcile.mjs', 'src/routes.mjs', 'src/stacked.mjs', 'src/triage.mjs', 'src/workflow.mjs']) {
48
49
  if (!(await exists(resolve(packageRoot, asset)))) problems.push(`missing package asset ${asset}`);
49
50
  }
50
51
  try {
@@ -190,6 +191,44 @@ async function runBrowserE2e(args) {
190
191
  if (!report.valid || report.status !== 'passed') process.exit(1);
191
192
  }
192
193
 
194
+ async function runEvidence(args) {
195
+ const inputPath = option(args, '--input');
196
+ const outputPath = option(args, '--output');
197
+ const format = option(args, '--format') ?? 'json';
198
+ const build = args.includes('--build');
199
+ if (!inputPath || !['json', 'text'].includes(format) || (build && !outputPath)) {
200
+ console.error('Usage: lighthouse-sdlc evidence --input <record|index|records.json> [--build --output <index.json>] [--format <json|text>]');
201
+ process.exit(2);
202
+ }
203
+ const input = await readJson(resolve(process.cwd(), inputPath));
204
+ let report;
205
+ if (build) {
206
+ const result = buildEvidenceIndex(input.records, input);
207
+ report = { schemaVersion: 1, status: result.errors.length ? 'invalid' : 'valid', valid: result.errors.length === 0, index: result.index, errors: result.errors };
208
+ if (result.index) {
209
+ const output = resolve(process.cwd(), outputPath);
210
+ await mkdir(dirname(output), { recursive: true });
211
+ for (const reference of result.index.records) {
212
+ const record = input.records.find((item) => item.id === reference.id);
213
+ const recordPath = resolve(dirname(output), reference.path);
214
+ await mkdir(dirname(recordPath), { recursive: true });
215
+ await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`);
216
+ }
217
+ await writeFile(output, `${JSON.stringify(result.index, null, 2)}\n`);
218
+ }
219
+ } else {
220
+ const errors = input?.kind ? validateEvidenceRecord(input) : validateEvidenceIndex(input);
221
+ report = { ...evidenceReport(input), errors };
222
+ }
223
+ if (format === 'json') output(report, format);
224
+ else {
225
+ console.log(`Evidence: ${report.status}`);
226
+ for (const error of report.errors) console.error(` ${error.code}: ${error.message}`);
227
+ if (report.index) console.log(` Coverage entries: ${report.index.coverage.length}`);
228
+ }
229
+ if (!report.valid) process.exit(1);
230
+ }
231
+
193
232
  async function runMemory(args) {
194
233
  const inputPath = option(args, '--input');
195
234
  const policyPath = option(args, '--policy');
@@ -348,8 +387,8 @@ async function runGithubLabels(args) {
348
387
  }
349
388
 
350
389
  const command = process.argv[2] ?? 'verify';
351
- if (!['contract', 'verify', 'aliases', 'triage', 'state', 'stage-result', 'browser-e2e', 'control-plane', 'memory', 'launch', 'decompose', 'stacked', 'route', 'github-triage', 'github-decompose', 'reconcile-labels', 'github-labels'].includes(command)) {
352
- console.error('Usage: lighthouse-sdlc <contract|verify|aliases|triage|state|stage-result|browser-e2e|control-plane|memory|launch|decompose|stacked|route|github-triage|github-decompose|reconcile-labels|github-labels>');
390
+ if (!['contract', 'verify', 'aliases', 'triage', 'state', 'stage-result', 'browser-e2e', 'evidence', 'control-plane', 'memory', 'launch', 'decompose', 'stacked', 'route', 'github-triage', 'github-decompose', 'reconcile-labels', 'github-labels'].includes(command)) {
391
+ console.error('Usage: lighthouse-sdlc <contract|verify|aliases|triage|state|stage-result|browser-e2e|evidence|control-plane|memory|launch|decompose|stacked|route|github-triage|github-decompose|reconcile-labels|github-labels>');
353
392
  process.exit(2);
354
393
  }
355
394
  if (command === 'contract') {
@@ -366,6 +405,8 @@ if (command === 'contract') {
366
405
  await runStageResult(process.argv.slice(3));
367
406
  } else if (command === 'browser-e2e') {
368
407
  await runBrowserE2e(process.argv.slice(3));
408
+ } else if (command === 'evidence') {
409
+ await runEvidence(process.argv.slice(3));
369
410
  } else if (command === 'memory') {
370
411
  await runMemory(process.argv.slice(3));
371
412
  } else if (command === 'launch') {
@@ -393,4 +434,4 @@ if (command === 'contract') {
393
434
  console.log('Lighthouse SDLC package contract is valid.');
394
435
  }
395
436
 
396
- export { contractPath, runTriage, verifyContract, runAliases, runState, runStageResult, runBrowserE2e, runControlPlane, runMemory, runLaunch, runDecompose, runRoute, runStacked, runGithubTriage, runGithubDecompose, runReconcileLabels, runGithubLabels };
437
+ export { contractPath, runTriage, verifyContract, runAliases, runState, runStageResult, runBrowserE2e, runEvidence, runControlPlane, runMemory, runLaunch, runDecompose, runRoute, runStacked, runGithubTriage, runGithubDecompose, runReconcileLabels, runGithubLabels };