@juangcarmona/lighthouse-sdlc 0.7.0 → 0.9.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,28 @@ 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
+ ## External host contract
16
+
17
+ `src/host-contract.mjs` validates a supervised `triage` or `refine` request against a trusted host policy and validates the resulting stage/effect envelope. It is exported as `@juangcarmona/lighthouse-sdlc/host-contract`. See [the wire contract](host-contract.md) for fields and host obligations. Execution, credentials, admission, cancellation and publication remain host responsibilities.
18
+
19
+ Print the opt-in GitHub adapter with `node node_modules/@juangcarmona/lighthouse-sdlc/src/host-contract.mjs workflow`. It expects a provisioned `lighthouse-host-bridge` on a repository runner labelled `lighthouse-host`. Installing the package does not enable that adapter. The existing gh-aw projection remains a separate execution path.
20
+
21
+ ## gh-aw workflow projection
22
+
23
+ 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.
24
+
25
+ Workers resolve instructions and validators from the installed SDLC package and reject a version mismatch with `.lighthouse/sdlc.json` before execution. Consumers need a committed npm lockfile.
26
+
27
+ 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.
28
+
29
+ The router validates the existing Lighthouse route and `requires` declarations. A normal dispatch runs one stage. The current worker host cannot verify human gate decisions, so it rejects `ff` for every bundled route. Use individual stage dispatches under host supervision until an approval adapter exists. The `integrate` worker prepares the configured merge action without merging unless repository policy authorizes it; `review` closes the flow with `gate: null`.
30
+
31
+ Workers require `stage-result --require-complete --stage <stage> --run-id <id>` before reporting success. This checks the dispatched identity, the gate in `stages.json`, `status: passed`, empty blocker arrays and resolved evidence statuses. Plain `stage-result --input <file>` still validates the structure of reports, including blocked reports. Completion validation does not verify evidence provenance or grant approval to cross a human gate; the host owns those checks.
32
+
13
33
  Install it with the Lighthouse CLI or run `lighthouse-sdlc verify` from the package installation to check the distributed contract.
14
34
 
15
35
  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 +38,8 @@ Use `lighthouse-sdlc route --route <name>` to inspect a route before running it,
18
38
 
19
39
  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
40
 
41
+ 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.
42
+
21
43
  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
44
 
23
45
  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.
@@ -1,3 +1,3 @@
1
1
  # `/lighthouse:stage-result`
2
2
 
3
- Validate the structured result of a lifecycle stage before a route consumes its evidence or advances its gate.
3
+ Use `lighthouse-sdlc stage-result --input <file>` to validate a report's structure, including unresolved reports. Before reporting worker success, add `--require-complete --stage <stage> --run-id <id>` to require the dispatched identity, the declared gate, a passed status and no unresolved evidence or blockers. Host approval remains necessary to cross human gates.
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",
@@ -0,0 +1,38 @@
1
+ # Supervised external host contract
2
+
3
+ Schema version 1 supports one triage or refinement stage. The executable validator in `src/host-contract.mjs` is authoritative; unknown request and effect fields fail validation. Hosts install an exact package version outside the execution workspace.
4
+
5
+ ## Request
6
+
7
+ | Field | Meaning |
8
+ | --- | --- |
9
+ | `schemaVersion` | `1`. |
10
+ | `runId` | SHA-256 event identity, stable across delivery retries. |
11
+ | `repository`, `issue` | Registered repository and positive issue number. |
12
+ | `stage`, `route` | `triage` or `refine`, with route `supervised`. |
13
+ | `sourceRevision` | Immutable source commit. |
14
+ | `workflowRevision`, `workflowRunId` | Trusted workflow commit and positive GitHub Actions run identifier. |
15
+ | `packageVersion`, `policyDigest` | Exact installed SDLC version and SHA-256 of the host policy file bytes. |
16
+ | `deadline` | Future timestamp within the host's `maxSeconds` limit (at most one hour). |
17
+ | `writeScope` | Empty array for these read-only stages. |
18
+ | `predecessor`, `approvalCommentId` | Both null for triage. Refine requires `{runId, resultDigest}` and a positive owner decision comment identifier. |
19
+
20
+ A host policy declares `repository`, `owner`, `stages`, `workflowRevision`, `packageVersion` and `maxSeconds`. Policy is provisioned by the operator and is never supplied by the engine. The host must authenticate the caller and verify workflow identity, current source, policy and owner decision with their authoritative providers. Passing local JSON validation alone does not establish provenance.
21
+
22
+ ## Result and effect
23
+
24
+ The engine writes `{schemaVersion: 1, result, effect}`. `result` follows the existing stage-result contract and must match the admitted run, stage and gate. A `passed` result must also pass completion validation. `ready`, `blocked` and `failed` remain distinct reports; none approves a human gate.
25
+
26
+ The only effect is `{type: "issue-comment", body: "..."}` with 1–16000 characters. The engine cannot select a repository, issue, credential or merge action. The publisher reserves `<!-- lighthouse-run:` markers and derives the destination from admission. It may publish clarification or blocked-stage reports but must never treat those as completed stages.
27
+
28
+ Before applying an effect, the host rechecks authority and work-item freshness, persists publication intent and fences the publisher. It retains an immutable receipt or an explicit uncertain outcome. A retry must reconcile that outcome before emitting another effect. A completed stage waits for a verifiable owner decision; the envelope itself grants no approval.
29
+
30
+ ## Commands
31
+
32
+ ```sh
33
+ node node_modules/@juangcarmona/lighthouse-sdlc/src/host-contract.mjs request request.json policy.json
34
+ node node_modules/@juangcarmona/lighthouse-sdlc/src/host-contract.mjs context request.json issue.json predecessor.json
35
+ node node_modules/@juangcarmona/lighthouse-sdlc/src/host-contract.mjs result request.json envelope.json
36
+ ```
37
+
38
+ Omit `predecessor.json` for triage. Context contains trusted stage instructions plus untrusted issue/source references; the host keeps source read-only and supplies only an output directory. The result command does not execute or publish anything.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juangcarmona/lighthouse-sdlc",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Provider-neutral SDLC skills, commands and route contracts for Lighthouse adopters",
6
6
  "repository": {
@@ -27,10 +27,12 @@
27
27
  "gates.json",
28
28
  "meta-commands",
29
29
  "README.md",
30
+ "host-contract.md",
30
31
  "routes.json",
31
32
  "stages.json",
32
33
  "schemas",
33
34
  "skills",
35
+ "workflows",
34
36
  "src"
35
37
  ],
36
38
  "bin": {
@@ -51,6 +53,7 @@
51
53
  "./stacked": "./src/stacked.mjs",
52
54
  "./workflow": "./src/workflow.mjs",
53
55
  "./browser-e2e": "./src/browser-e2e.mjs",
56
+ "./evidence": "./src/evidence.mjs",
54
57
  "./contract.json": "./contract.json",
55
58
  "./schemas/control-plane.schema.json": "./schemas/control-plane.schema.json",
56
59
  "./schemas/triage.schema.json": "./schemas/triage.schema.json",
@@ -61,8 +64,12 @@
61
64
  "./schemas/capabilities.schema.json": "./schemas/capabilities.schema.json",
62
65
  "./schemas/capability-catalog.schema.json": "./schemas/capability-catalog.schema.json",
63
66
  "./schemas/browser-e2e.schema.json": "./schemas/browser-e2e.schema.json",
67
+ "./schemas/evidence-record.schema.json": "./schemas/evidence-record.schema.json",
68
+ "./schemas/evidence-index.schema.json": "./schemas/evidence-index.schema.json",
64
69
  "./capabilities.json": "./capabilities.json",
65
70
  "./command-aliases.json": "./command-aliases.json",
66
- "./schemas/command-aliases.schema.json": "./schemas/command-aliases.schema.json"
71
+ "./schemas/command-aliases.schema.json": "./schemas/command-aliases.schema.json",
72
+ "./workflows/*": "./workflows/*",
73
+ "./host-contract": "./src/host-contract.mjs"
67
74
  }
68
75
  }
@@ -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
  },
package/src/contracts.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import stageContract from '../stages.json' with { type: 'json' };
2
+ import resultSchema from '../schemas/stage-result.schema.json' with { type: 'json' };
3
+
1
4
  export const STAGES = Object.freeze(['triage', 'refine', 'propose', 'implement', 'integrate', 'review']);
2
5
  export const GATES = Object.freeze(['triaged', 'ready', 'planned', 'done']);
3
6
  export const EVIDENCE_STATUSES = Object.freeze(['passed', 'failed', 'unavailable', 'unconfigured', 'pending-review', 'not-applicable']);
@@ -19,6 +22,7 @@ function isRecord(value) {
19
22
  export function validateStageResult(value) {
20
23
  const errors = [];
21
24
  if (!isRecord(value)) return [issue('stage-result.invalid', 'stage result must be an object')];
25
+ for (const key of Object.keys(value)) if (!(Object.hasOwn(resultSchema.properties, key))) errors.push(issue('stage-result.unknown-field', `${key} is unsupported`, key));
22
26
  if (value.schemaVersion !== 1) errors.push(issue('stage-result.schema-version', 'schemaVersion must be 1', 'schemaVersion'));
23
27
  for (const field of ['runId', 'nextAction']) if (typeof value[field] !== 'string' || !value[field].trim()) errors.push(issue(`stage-result.${field}`, `${field} must be a non-empty string`, field));
24
28
  if (!stageSet.has(value.stage)) errors.push(issue('stage-result.stage', `stage must be one of ${STAGES.join(', ')}`, 'stage'));
@@ -26,6 +30,10 @@ export function validateStageResult(value) {
26
30
  if (value.gate !== null && !gateSet.has(value.gate)) errors.push(issue('stage-result.gate', `gate must be one of ${GATES.join(', ')} or null`, 'gate'));
27
31
  if (!Array.isArray(value.evidence)) errors.push(issue('stage-result.evidence', 'evidence must be an array', 'evidence'));
28
32
  else for (const [index, evidence] of value.evidence.entries()) {
33
+ if (isRecord(evidence)) {
34
+ for (const key of Object.keys(evidence)) if (!(Object.hasOwn(resultSchema.properties.evidence.items.properties, key))) errors.push(issue('stage-result.evidence-field', `evidence[${index}].${key} is unsupported`, `evidence[${index}]`));
35
+ for (const key of ['source', 'reason']) if (evidence[key] !== undefined && typeof evidence[key] !== 'string') errors.push(issue('stage-result.evidence-field', `evidence[${index}].${key} must be a string`, `evidence[${index}]`));
36
+ }
29
37
  if (!isRecord(evidence) || typeof evidence.id !== 'string' || !evidence.id.trim() || !evidenceStatusSet.has(evidence.status)) errors.push(issue('stage-result.evidence-item', `evidence[${index}] needs an id and a supported status`, `evidence[${index}]`));
30
38
  }
31
39
  for (const field of ['missingEvidence', 'unavailableChecks', 'pendingReview']) if (!Array.isArray(value[field]) || value[field].some((item) => typeof item !== 'string' || !item.trim())) errors.push(issue(`stage-result.${field}`, `${field} must be an array of names`, field));
@@ -37,3 +45,22 @@ export function validateStageResult(value) {
37
45
  export function isValidStageResult(value) {
38
46
  return validateStageResult(value).length === 0;
39
47
  }
48
+
49
+ export function validateStageCompletion(value, { stage, runId } = {}) {
50
+ const errors = validateStageResult(value);
51
+ const expected = stageContract.stages.find((item) => item.id === stage);
52
+ if (!expected) errors.push(issue('stage-completion.expected-stage', 'a declared expected stage is required'));
53
+ if (typeof runId !== 'string' || !runId.trim()) errors.push(issue('stage-completion.expected-run', 'a non-empty expected runId is required'));
54
+ if (!isRecord(value)) return errors;
55
+ if (value.stage !== stage) errors.push(issue('stage-completion.stage', 'result stage does not match the dispatched stage', 'stage'));
56
+ if (expected && value.gate !== expected.gate) errors.push(issue('stage-completion.gate', 'result gate does not match stages.json', 'gate'));
57
+ if (value.runId !== runId) errors.push(issue('stage-completion.run', 'result runId does not match the dispatched run', 'runId'));
58
+ if (value.status !== 'passed') errors.push(issue('stage-completion.status', 'only a passed result can complete a worker', 'status'));
59
+ for (const field of ['missingEvidence', 'unavailableChecks', 'pendingReview']) {
60
+ if (Array.isArray(value[field]) && value[field].length) errors.push(issue(`stage-completion.${field}`, `${field} must be empty to complete a worker`, field));
61
+ }
62
+ if (Array.isArray(value.evidence)) for (const [index, evidence] of value.evidence.entries()) {
63
+ if (!['passed', 'not-applicable'].includes(evidence?.status)) errors.push(issue('stage-completion.evidence', 'unresolved evidence prevents worker completion', `evidence[${index}]`));
64
+ }
65
+ return errors;
66
+ }
@@ -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
+ }