runward 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -3
- package/dist/cli.js +27 -2
- package/dist/commands/check.js +63 -21
- package/dist/commands/compliance.js +20 -8
- package/dist/commands/init.js +9 -2
- package/dist/commands/manifest.js +72 -0
- package/dist/commands/rules.js +76 -0
- package/dist/lib/compliance.js +40 -50
- package/dist/lib/conformance.js +61 -15
- package/dist/lib/constants.js +1 -1
- package/dist/lib/evidence.js +222 -0
- package/dist/lib/manifest-sync.js +111 -0
- package/dist/lib/paths.js +2 -0
- package/dist/lib/regimes.js +32 -0
- package/dist/lib/rules.js +44 -0
- package/dist/lib/tools.js +1 -1
- package/dist/lib/verify-findings.js +24 -0
- package/examples/request-triage/code/README.md +74 -0
- package/examples/request-triage/code/package.json +19 -0
- package/examples/request-triage/code/src/adapters/hardcoded-account-registry.adapter.ts +23 -0
- package/examples/request-triage/code/src/adapters/in-memory-routing.adapter.ts +79 -0
- package/examples/request-triage/code/src/adapters/in-memory-triage-log.adapter.ts +42 -0
- package/examples/request-triage/code/src/adapters/keyword-model.adapter.ts +57 -0
- package/examples/request-triage/code/src/core/application/triage-request.usecase.ts +195 -0
- package/examples/request-triage/code/src/core/domain/guard.ts +121 -0
- package/examples/request-triage/code/src/core/domain/triage.ts +88 -0
- package/examples/request-triage/code/src/core/ports/account-registry.port.ts +15 -0
- package/examples/request-triage/code/src/core/ports/model-provider.port.ts +26 -0
- package/examples/request-triage/code/src/core/ports/routing.port.ts +32 -0
- package/examples/request-triage/code/src/core/ports/triage-log.port.ts +33 -0
- package/examples/request-triage/code/src/demo.ts +80 -0
- package/examples/request-triage/code/test/triage.test.ts +243 -0
- package/examples/request-triage/code/tsconfig.json +14 -0
- package/examples/request-triage/runward/adr/ADR-0003-port-placement-and-sovereignty.md +44 -0
- package/examples/request-triage/runward/architecture.md +4 -3
- package/examples/request-triage/runward/decision-matrix.md +1 -1
- package/examples/request-triage/runward/execution-topology.md +3 -3
- package/examples/request-triage/runward/floor.md +4 -4
- package/package.json +20 -8
- package/regimes/eu-ai-act@2024-1689.json +31 -0
- package/regimes/iso-42001@2023.json +22 -0
- package/regimes/nist-ai-rmf@1.0.json +19 -0
- package/templates/adapters/README.md +4 -0
- package/templates/adapters/gitlab-ci.yml +18 -0
- package/templates/mission/architecture.md +2 -0
- package/templates/mission/execution-topology.md +2 -0
- package/templates/mission/floor.md +2 -0
- package/templates/mission/threat-model.md +2 -0
- package/templates/rules/frontier-deterministic-boundary.md +1 -0
- package/templates/targets/AGENTS.md +1 -1
- package/templates/workflows/floor.md +1 -1
- package/templates/workflows/verify.md +2 -2
package/dist/lib/compliance.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { parseManifest } from "./conformance.js";
|
|
4
|
+
import { parseManifest, GATED_DELIVERABLES } from "./conformance.js";
|
|
5
5
|
import { TEMPLATES } from "./paths.js";
|
|
6
|
+
import { regimeLensId } from "./regimes.js";
|
|
6
7
|
/**
|
|
7
8
|
* Deterministic, read-only assembly of a regime-framed compliance evidence pack (ADR-0016).
|
|
8
9
|
* It reads the mission's real artifacts at rest — the rule→OWASP ASI mapping, the rule-conformance
|
|
@@ -51,16 +52,10 @@ function readRules(missionDir) {
|
|
|
51
52
|
}
|
|
52
53
|
return out;
|
|
53
54
|
}
|
|
54
|
-
/** The three build-phase deliverables that carry a `## Rule conformance` manifest (as `check --strict`). */
|
|
55
|
-
const CONFORMANCE_DELIVERABLES = [
|
|
56
|
-
["Architect", "architecture.md"],
|
|
57
|
-
["Topology", "execution-topology.md"],
|
|
58
|
-
["Floor", "floor.md"],
|
|
59
|
-
["Govern", "governance/threat-model.md"],
|
|
60
|
-
];
|
|
61
55
|
function readConformance(missionDir) {
|
|
62
56
|
const out = [];
|
|
63
|
-
|
|
57
|
+
// The same gated (phase, deliverable) pairs `check --strict` verifies — one source (conformance.ts).
|
|
58
|
+
for (const { label, deliverable } of GATED_DELIVERABLES) {
|
|
64
59
|
const p = join(missionDir, deliverable);
|
|
65
60
|
if (!existsSync(p))
|
|
66
61
|
continue;
|
|
@@ -118,8 +113,10 @@ export function gatherComplianceInputs(missionDir) {
|
|
|
118
113
|
evalRubric: existsSync(join(missionDir, "governance", "evaluation-rubric.md")),
|
|
119
114
|
};
|
|
120
115
|
}
|
|
121
|
-
/** Render the ISO/IEC 42001 assessment-readiness draft — the technical-evidence layer + the human-gap
|
|
122
|
-
|
|
116
|
+
/** Render the ISO/IEC 42001 assessment-readiness draft — the technical-evidence layer + the human-gap
|
|
117
|
+
* list. The clause references and the operator-required list come from the versioned lens (ADR-0022). */
|
|
118
|
+
export function renderIso42001Readiness(inputs, generatedAt, lens) {
|
|
119
|
+
const cl = lens.clauses ?? {};
|
|
123
120
|
const L = [];
|
|
124
121
|
const counts = { applied: 0, deviated: 0, "n/a": 0 };
|
|
125
122
|
for (const r of inputs.conformance)
|
|
@@ -132,10 +129,11 @@ export function renderIso42001Readiness(inputs, generatedAt) {
|
|
|
132
129
|
L.push("> **technical-evidence layer and its index**; the applicability, risk-acceptance, policy and management sign-off it");
|
|
133
130
|
L.push("> cannot invent are listed under \"Required from the operator\". This is **supporting evidence**, never certification —");
|
|
134
131
|
L.push("> only an accredited body certifies an AI management system. Verify the current ISO/IEC 42001 text before an audit.");
|
|
132
|
+
L.push(`> Lens: ${lens.label} (mapping version ${lens.version}) — \`${regimeLensId(lens)}\`.`);
|
|
135
133
|
L.push("");
|
|
136
134
|
L.push("## 1. Agentic-risk coverage (OWASP ASI → your rules)");
|
|
137
135
|
L.push("");
|
|
138
|
-
L.push(
|
|
136
|
+
L.push(`Feeds the ISO 42001 risk assessment (${cl.riskAssessment}) and control selection (${cl.controlSelection}): which agentic-security risks are addressed by named engineering rules.`);
|
|
139
137
|
L.push("");
|
|
140
138
|
L.push("| ASI | Risk | Rules addressing it |");
|
|
141
139
|
L.push("|---|---|---|");
|
|
@@ -146,7 +144,7 @@ export function renderIso42001Readiness(inputs, generatedAt) {
|
|
|
146
144
|
L.push("");
|
|
147
145
|
L.push("## 2. Control-implementation status (rule conformance)");
|
|
148
146
|
L.push("");
|
|
149
|
-
L.push(`Feeds the Statement of Applicability's implementation-status + evidence columns (
|
|
147
|
+
L.push(`Feeds the Statement of Applicability's implementation-status + evidence columns (${cl.controlSelection}). From your mission's manifests: **${counts.applied} applied · ${counts.deviated} deviated · ${counts["n/a"]} n/a** across ${inputs.conformance.length} accounted rule(s).`);
|
|
150
148
|
L.push("");
|
|
151
149
|
if (inputs.conformance.length === 0) {
|
|
152
150
|
L.push("_No filled `Rule conformance` manifest found yet — fill the architect/floor/govern deliverables (see `runward check --strict`)._");
|
|
@@ -160,7 +158,7 @@ export function renderIso42001Readiness(inputs, generatedAt) {
|
|
|
160
158
|
L.push("");
|
|
161
159
|
L.push("## 3. Design decisions (ADR journal)");
|
|
162
160
|
L.push("");
|
|
163
|
-
L.push(
|
|
161
|
+
L.push(`The "key design choices, alternatives, and re-evaluation triggers" an ISO 42001 auditor expects (records under ${cl.annexControls} control groups).`);
|
|
164
162
|
L.push("");
|
|
165
163
|
if (inputs.adrs.length === 0) {
|
|
166
164
|
L.push("_No ratified ADR found in `runward/adr/`._");
|
|
@@ -174,22 +172,17 @@ export function renderIso42001Readiness(inputs, generatedAt) {
|
|
|
174
172
|
L.push("");
|
|
175
173
|
L.push("## 4. Risk & impact inputs (presence)");
|
|
176
174
|
L.push("");
|
|
177
|
-
L.push(`- Threat model (feeds risk assessment
|
|
175
|
+
L.push(`- Threat model (feeds risk assessment ${cl.riskAssessment}): ${inputs.threatModel ? "**present** — confirm it is filled, not a raw template" : "**missing**"}`);
|
|
178
176
|
L.push(`- Evaluation rubric (feeds impact/validation analysis): ${inputs.evalRubric ? "**present** — confirm it is filled" : "**missing**"}`);
|
|
179
177
|
L.push("");
|
|
180
178
|
L.push("## Required from the operator / organization (runward cannot produce this)");
|
|
181
179
|
L.push("");
|
|
182
180
|
L.push("These sections are managerial, legal or organizational — no tool can assemble them from engineering artifacts:");
|
|
183
181
|
L.push("");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
L.push("- **Risk methodology, acceptance criteria and risk-acceptance decisions** (6.1.2, 8.3).");
|
|
187
|
-
L.push("- **AI system impact assessment report and deployment authorization** (6.1.4).");
|
|
188
|
-
L.push("- **Objectives and targets** (6.2), **roles and competence** (A.3, 7.2).");
|
|
189
|
-
L.push("- **Internal audit** (9.2) and **management review** minutes (9.3).");
|
|
190
|
-
L.push("- **Runtime AI event logs** (A.6.2.8) — produced by the running system, not by runward.");
|
|
182
|
+
for (const item of lens.operatorRequired)
|
|
183
|
+
L.push(`- ${item}`);
|
|
191
184
|
L.push("");
|
|
192
|
-
L.push(
|
|
185
|
+
L.push(`_Regime mapping is dated engineering framing, not legal advice; ${lens.disclaimerTail}_`);
|
|
193
186
|
L.push("");
|
|
194
187
|
return L.join("\n") + "\n";
|
|
195
188
|
}
|
|
@@ -226,8 +219,9 @@ function adrTableLines(inputs) {
|
|
|
226
219
|
return L;
|
|
227
220
|
}
|
|
228
221
|
/** Render the NIST AI RMF assessment-readiness draft — an ASI↔AI-RMF crosswalk, the MEASURE/TEVV
|
|
229
|
-
* documentation, and the design decisions; GOVERN and risk-tolerance stay the operator's.
|
|
230
|
-
|
|
222
|
+
* documentation, and the design decisions; GOVERN and risk-tolerance stay the operator's.
|
|
223
|
+
* The crosswalk targets and the operator-required functions come from the versioned lens (ADR-0022). */
|
|
224
|
+
export function renderNistAiRmf(inputs, generatedAt, lens) {
|
|
231
225
|
const c = confCounts(inputs);
|
|
232
226
|
const L = [];
|
|
233
227
|
L.push("# NIST AI RMF — assessment-readiness draft");
|
|
@@ -236,16 +230,17 @@ export function renderNistAiRmf(inputs, generatedAt) {
|
|
|
236
230
|
L.push("> deterministically from ratified engineering artifacts (no model call). The AI RMF is **voluntary guidance**");
|
|
237
231
|
L.push("> with no pass/fail and no certification; this populates the MEASURE/documentation evidence and an ASI crosswalk,");
|
|
238
232
|
L.push("> while GOVERN, risk tolerance and go/no-go stay the operator's. Verify the current AI RMF text before use.");
|
|
233
|
+
L.push(`> Lens: ${lens.label} (mapping version ${lens.version}) — \`${regimeLensId(lens)}\`.`);
|
|
239
234
|
L.push("");
|
|
240
235
|
L.push("## 1. Agentic-risk crosswalk (OWASP ASI → AI RMF)");
|
|
241
236
|
L.push("");
|
|
242
|
-
L.push(
|
|
237
|
+
L.push(`An indicative engineering crosswalk (not NIST-endorsed): ${lens.crosswalk?.primary}. Confirm subcategory selection against ${lens.crosswalk?.confirmAgainst}.`);
|
|
243
238
|
L.push("");
|
|
244
239
|
L.push(...asiTableLines(inputs));
|
|
245
240
|
L.push("");
|
|
246
241
|
L.push("## 2. MEASURE / TEVV documentation");
|
|
247
242
|
L.push("");
|
|
248
|
-
L.push(`Feeds
|
|
243
|
+
L.push(`Feeds ${lens.measureRef} — documented, repeatable test methodology and results. From your mission: **${c.applied} applied · ${c.deviated} deviated · ${c["n/a"]} n/a** across ${inputs.conformance.length} rule(s).`);
|
|
249
244
|
L.push(`- Evaluation rubric (test sets, metrics, tooling): ${inputs.evalRubric ? "**present** — confirm it is filled" : "**missing**"}`);
|
|
250
245
|
L.push(`- Threat model (adversarial / risk-source analysis): ${inputs.threatModel ? "**present** — confirm it is filled" : "**missing**"}`);
|
|
251
246
|
L.push("");
|
|
@@ -257,41 +252,34 @@ export function renderNistAiRmf(inputs, generatedAt) {
|
|
|
257
252
|
L.push("");
|
|
258
253
|
L.push("## Required from the operator / organization (runward cannot produce this)");
|
|
259
254
|
L.push("");
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
L.push("- **MANAGE** — the **go/no-go acceptance** decision, resourcing, response planning.");
|
|
263
|
-
L.push("- **Profiles** — Current/Target selection, prioritization, the risk-tolerance choices behind them.");
|
|
255
|
+
for (const item of lens.operatorRequired)
|
|
256
|
+
L.push(`- ${item}`);
|
|
264
257
|
L.push("");
|
|
265
|
-
L.push(
|
|
258
|
+
L.push(`_Indicative engineering framing, not legal advice; ${lens.disclaimerTail}_`);
|
|
266
259
|
L.push("");
|
|
267
260
|
return L.join("\n") + "\n";
|
|
268
261
|
}
|
|
269
262
|
/** Render the EU AI Act Annex IV assessment-readiness draft — strong on Point 2 (design/architecture/
|
|
270
263
|
* validation) and the design-rationale/change history (the ADR journal); RMS, standards, declaration
|
|
271
|
-
* and post-market plan stay the provider's.
|
|
272
|
-
|
|
264
|
+
* and post-market plan stay the provider's. The applicability date, article references, Annex IV
|
|
265
|
+
* rows and provider-required list come from the versioned lens (ADR-0022). */
|
|
266
|
+
export function renderEuAiAct(inputs, generatedAt, lens) {
|
|
273
267
|
const L = [];
|
|
274
268
|
L.push("# EU AI Act — Annex IV technical documentation — assessment-readiness draft");
|
|
275
269
|
L.push("");
|
|
276
270
|
L.push(`> **Draft, incomplete — not a conformity assessment.** Assembled by \`runward compliance eu-ai-act\` on ${generatedAt},`);
|
|
277
271
|
L.push("> deterministically from ratified engineering artifacts (no model call). High-risk obligations bind from");
|
|
278
|
-
L.push(
|
|
279
|
-
L.push(
|
|
272
|
+
L.push(`> **${lens.highRisk?.bindFrom}** (${lens.highRisk?.scope}). This populates Annex IV Point 2 (design & validation) and the design-rationale`);
|
|
273
|
+
L.push(`> history; it does **not** satisfy ${lens.articles?.runtimeLogging} runtime logging, and it is not a signed declaration of conformity.`);
|
|
280
274
|
L.push("> Verify against the Official Journal text before filing.");
|
|
275
|
+
L.push(`> Lens: ${lens.label} (mapping version ${lens.version}) — \`${regimeLensId(lens)}\`.`);
|
|
281
276
|
L.push("");
|
|
282
277
|
L.push("## Annex IV coverage map");
|
|
283
278
|
L.push("");
|
|
284
279
|
L.push("| Annex IV point | runward supplies | Required from the provider |");
|
|
285
280
|
L.push("|---|---|---|");
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
L.push("| 3. Monitoring & control | accuracy characterization, input-data specs, oversight tooling | fundamental-rights / discrimination risk sourcing |");
|
|
289
|
-
L.push("| 4. Performance metrics | metric-choice justification (rubric) | — |");
|
|
290
|
-
L.push("| 5. Risk management (art. 9) | technical inputs (threat model, testing) | **risk acceptance / RMS governance** |");
|
|
291
|
-
L.push("| 6. Lifecycle changes | engineering change record (ADR journal) | release/change-management governance |");
|
|
292
|
-
L.push("| 7. Harmonised standards | technical notes | **standards selection** (compliance strategy) |");
|
|
293
|
-
L.push("| 8. Declaration of conformity | — | **signed legal act (art. 47)** |");
|
|
294
|
-
L.push("| 9. Post-market monitoring | telemetry/logging backbone | **post-market monitoring plan (art. 72)** |");
|
|
281
|
+
for (const r of lens.annexIv ?? [])
|
|
282
|
+
L.push(`| ${r.point} | ${r.supplies} | ${r.provider} |`);
|
|
295
283
|
L.push("");
|
|
296
284
|
L.push("## Point 2 — design decisions (ADR journal, near-verbatim to the Annex IV requirement)");
|
|
297
285
|
L.push("");
|
|
@@ -307,11 +295,10 @@ export function renderEuAiAct(inputs, generatedAt) {
|
|
|
307
295
|
L.push("");
|
|
308
296
|
L.push("## Required from the provider (runward cannot produce this)");
|
|
309
297
|
L.push("");
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
L.push("- **Art. 12 runtime event logs** — produced by the running system, not by runward.");
|
|
298
|
+
for (const item of lens.operatorRequired)
|
|
299
|
+
L.push(`- ${item}`);
|
|
313
300
|
L.push("");
|
|
314
|
-
L.push(
|
|
301
|
+
L.push(`_Engineering framing, not legal advice; ${lens.disclaimerTail}_`);
|
|
315
302
|
L.push("");
|
|
316
303
|
return L.join("\n") + "\n";
|
|
317
304
|
}
|
|
@@ -332,8 +319,10 @@ const ASI_CATALOG = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-a
|
|
|
332
319
|
* universal machine-readable layer. Each ASI category is a control whose implementation-status is
|
|
333
320
|
* derived from the mission's conformance manifest; evidence links back to the readiness draft. It is
|
|
334
321
|
* labelled a DRAFT / supporting evidence in the metadata remarks — never a compliance claim.
|
|
322
|
+
* `lensId` stamps which regime mapping version framed the pack (metadata prop, ADR-0022); the
|
|
323
|
+
* control structure itself stays regime-neutral.
|
|
335
324
|
*/
|
|
336
|
-
export function renderOscal(inputs, missionName, generatedAt) {
|
|
325
|
+
export function renderOscal(inputs, missionName, generatedAt, lensId) {
|
|
337
326
|
const ns = missionName || "mission";
|
|
338
327
|
const irs = Object.keys(ASI_LABELS).map((id) => {
|
|
339
328
|
const slugs = inputs.asiCoverage.get(id) ?? [];
|
|
@@ -361,6 +350,7 @@ export function renderOscal(inputs, missionName, generatedAt) {
|
|
|
361
350
|
"last-modified": `${generatedAt}T00:00:00Z`,
|
|
362
351
|
version: generatedAt,
|
|
363
352
|
"oscal-version": OSCAL_VERSION,
|
|
353
|
+
...(lensId ? { props: [{ name: "runward-regime-lens", value: lensId }] } : {}),
|
|
364
354
|
remarks: "Assessment-readiness DRAFT, assembled deterministically by runward from ratified engineering artifacts (rule to OWASP ASI mapping, conformance manifest, ADR journal). Supporting evidence only — NOT a compliance claim, NOT a certification, NOT a conformity assessment. Applicability, risk acceptance and management sign-off are the operator's.",
|
|
365
355
|
},
|
|
366
356
|
components: [{
|
package/dist/lib/conformance.js
CHANGED
|
@@ -3,6 +3,14 @@ import { join, dirname } from "node:path";
|
|
|
3
3
|
import { TEMPLATES } from "./paths.js";
|
|
4
4
|
import { EXPECTED_MAPPED } from "./constants.js";
|
|
5
5
|
import { RULE_MIGRATIONS } from "./rule-migrations.js";
|
|
6
|
+
/** The gated (phase, deliverable) pairs — the single source `check --strict`, the evidence
|
|
7
|
+
* layer and the compliance assembler all read (ADR-0001/0016/0017). */
|
|
8
|
+
export const GATED_DELIVERABLES = [
|
|
9
|
+
{ phase: "architect", deliverable: "architecture.md", label: "Architect" },
|
|
10
|
+
{ phase: "topology", deliverable: "execution-topology.md", label: "Topology" },
|
|
11
|
+
{ phase: "floor", deliverable: "floor.md", label: "Floor" },
|
|
12
|
+
{ phase: "govern", deliverable: "governance/threat-model.md", label: "Govern" },
|
|
13
|
+
];
|
|
6
14
|
const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
|
|
7
15
|
const VALID_STATUS = new Set(["applied", "deviated", "n/a"]);
|
|
8
16
|
/** An n/a reason must be more than a placeholder: real length, not a bracketed template token. */
|
|
@@ -15,15 +23,41 @@ function parseRuleMeta(content) {
|
|
|
15
23
|
const impact = (fm.match(/^impact:\s*(.+)$/m)?.[1] ?? "").trim();
|
|
16
24
|
const phasesRaw = fm.match(/^phases:\s*\[(.*)\]/m)?.[1] ?? "";
|
|
17
25
|
const phases = phasesRaw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
18
|
-
|
|
26
|
+
const signature = (fm.match(/^signature:\s*(.+)$/m)?.[1] ?? "").trim();
|
|
27
|
+
return { impact, phases, signature };
|
|
28
|
+
}
|
|
29
|
+
/** The rule directory the gate reads: the mission's own copy when present, else the package's. */
|
|
30
|
+
export function rulesDir(missionDir) {
|
|
31
|
+
const missionRules = join(missionDir, "rules");
|
|
32
|
+
return existsSync(missionRules) ? missionRules : join(TEMPLATES, "rules");
|
|
33
|
+
}
|
|
34
|
+
/** Evidence signatures (ADR-0020): rule slug → the regex source its applied evidence must match. */
|
|
35
|
+
export function ruleSignatures(missionDir) {
|
|
36
|
+
const dir = rulesDir(missionDir);
|
|
37
|
+
if (!existsSync(dir))
|
|
38
|
+
return {};
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const f of readdirSync(dir)) {
|
|
41
|
+
if (!f.endsWith(".md"))
|
|
42
|
+
continue;
|
|
43
|
+
let sig = "";
|
|
44
|
+
try {
|
|
45
|
+
sig = parseRuleMeta(readFileSync(join(dir, f), "utf8")).signature;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (sig)
|
|
51
|
+
out[f.replace(/\.md$/, "")] = sig;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
19
54
|
}
|
|
20
55
|
/** CRITICAL/HIGH rules mapped to a phase — the set that must be accounted for.
|
|
21
56
|
* The mapping is a property of the rule definitions: read the mission's own
|
|
22
57
|
* `runward/rules/` when present, else fall back to the package rules (the
|
|
23
58
|
* authoritative source — covers missions predating rules-in-mission). */
|
|
24
59
|
export function expectedRules(missionDir, phaseId) {
|
|
25
|
-
const
|
|
26
|
-
const dir = existsSync(missionRules) ? missionRules : join(TEMPLATES, "rules");
|
|
60
|
+
const dir = rulesDir(missionDir);
|
|
27
61
|
if (!existsSync(dir))
|
|
28
62
|
return [];
|
|
29
63
|
return readdirSync(dir)
|
|
@@ -37,8 +71,7 @@ export function expectedRules(missionDir, phaseId) {
|
|
|
37
71
|
}
|
|
38
72
|
/** Every rule slug in the set (mission's own, else the package) — the universe a manifest row must belong to. */
|
|
39
73
|
export function allRules(missionDir) {
|
|
40
|
-
const
|
|
41
|
-
const dir = existsSync(missionRules) ? missionRules : join(TEMPLATES, "rules");
|
|
74
|
+
const dir = rulesDir(missionDir);
|
|
42
75
|
if (!existsSync(dir))
|
|
43
76
|
return [];
|
|
44
77
|
return readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => f.replace(/\.md$/, ""));
|
|
@@ -68,18 +101,21 @@ export function parseManifest(content) {
|
|
|
68
101
|
}
|
|
69
102
|
return rows;
|
|
70
103
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
/** True when an ADR with exactly this id (e.g. "ADR-3") exists in runward/adr/.
|
|
105
|
+
* Anchored on a digit boundary so ADR-1 is not satisfied by ADR-10 / ADR-12
|
|
106
|
+
* when filenames are unpadded. */
|
|
107
|
+
export function adrIdExists(missionDir, id) {
|
|
75
108
|
const dir = join(missionDir, "adr");
|
|
76
|
-
|
|
77
|
-
// ADR-10 / ADR-12 when filenames are unpadded.
|
|
109
|
+
const u0 = id.toUpperCase();
|
|
78
110
|
return existsSync(dir) && readdirSync(dir).some((f) => {
|
|
79
111
|
const u = f.toUpperCase();
|
|
80
|
-
return u.startsWith(
|
|
112
|
+
return u.startsWith(u0) && !/[0-9]/.test(u.charAt(u0.length));
|
|
81
113
|
});
|
|
82
114
|
}
|
|
115
|
+
function adrExists(missionDir, evidence) {
|
|
116
|
+
const id = evidence.match(/ADR-\d+/i)?.[0];
|
|
117
|
+
return id ? adrIdExists(missionDir, id) : false;
|
|
118
|
+
}
|
|
83
119
|
/**
|
|
84
120
|
* The reconstruction lifecycle (ADR-0013/0014). A retroactively reconstructed decision is a
|
|
85
121
|
* *hypothesis* until the operator ratifies it — writes the real *why*, sets a re-evaluation
|
|
@@ -129,7 +165,13 @@ export function decisionCoverage(missionDir) {
|
|
|
129
165
|
}
|
|
130
166
|
// A path token: a file with a known code/doc extension (excludes version numbers like v1.0, "§2").
|
|
131
167
|
const PATH_TOKEN = /[\w./-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|md|json|ya?ml|toml|go|rs|java|rb|php|sql|sh|css|scss|html|txt)\b/g;
|
|
132
|
-
/**
|
|
168
|
+
/** File-path tokens carried by an evidence cell — the drift/evidence layer's shared extraction. */
|
|
169
|
+
export function evidencePathTokens(evidence) {
|
|
170
|
+
return evidence.match(PATH_TOKEN) ?? [];
|
|
171
|
+
}
|
|
172
|
+
/** Drift (ADR-0004, blocking under --strict since ADR-0021): applied pointers whose file path no
|
|
173
|
+
* longer resolves. Existence only. Rows carrying typed pointers are diagnosed by the evidence
|
|
174
|
+
* layer (ADR-0019) instead — one diagnosis per row, never two. */
|
|
133
175
|
export function driftReport(missionDir, deliverable) {
|
|
134
176
|
const path = join(missionDir, deliverable);
|
|
135
177
|
if (!existsSync(path))
|
|
@@ -139,12 +181,14 @@ export function driftReport(missionDir, deliverable) {
|
|
|
139
181
|
for (const row of parseManifest(readFileSync(path, "utf8"))) {
|
|
140
182
|
if (row.status !== "applied")
|
|
141
183
|
continue;
|
|
184
|
+
if (/\b(?:file|test|adr):\S/.test(row.evidence))
|
|
185
|
+
continue; // typed — the evidence layer owns it
|
|
142
186
|
const tokens = row.evidence.match(PATH_TOKEN) ?? [];
|
|
143
187
|
if (tokens.length === 0)
|
|
144
188
|
continue; // pure prose reference — the operator's judgment
|
|
145
189
|
const resolves = tokens.some((t) => bases.some((b) => existsSync(join(b, t))));
|
|
146
190
|
if (!resolves)
|
|
147
|
-
out.push({ rule: row.rule, problem: `applied pointer does not resolve (drift
|
|
191
|
+
out.push({ rule: row.rule, problem: `applied pointer does not resolve (drift): ${row.evidence} — update the pointer, mark the row deviated with its ADR, or remove it` });
|
|
148
192
|
}
|
|
149
193
|
return out;
|
|
150
194
|
}
|
|
@@ -189,7 +233,9 @@ export function conformance(missionDir, phaseId, deliverable) {
|
|
|
189
233
|
continue;
|
|
190
234
|
}
|
|
191
235
|
if (!VALID_STATUS.has(row.status)) {
|
|
192
|
-
violations.push({ rule, problem:
|
|
236
|
+
violations.push({ rule, problem: row.status === ""
|
|
237
|
+
? "status not set — a scaffolded row is not a decision: choose applied | deviated | n/a and fill the Evidence column"
|
|
238
|
+
: `invalid status "${row.status}" (use applied | deviated | n/a)` });
|
|
193
239
|
continue;
|
|
194
240
|
}
|
|
195
241
|
if (row.status === "applied" && !row.evidence)
|
package/dist/lib/constants.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Total number of craft rules shipped under templates/rules/. */
|
|
2
2
|
export const EXPECTED_RULES = 60;
|
|
3
3
|
/** Gate adapters shipped under templates/adapters/ (ADR-0012): one per harness seam, plus the port-contract README. */
|
|
4
|
-
export const EXPECTED_ADAPTERS =
|
|
4
|
+
export const EXPECTED_ADAPTERS = 5;
|
|
5
5
|
/** Routed-count floor: minimum CRITICAL/HIGH rules mapped to each build phase (ADR-0002).
|
|
6
6
|
* Lowering a floor is a deliberate, tracked edit — the `phases:` mapping cannot be silently
|
|
7
7
|
* stripped to make `check --strict` pass vacuously. */
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
4
|
+
import { parseManifest, evidencePathTokens, adrIdExists, GATED_DELIVERABLES } from "./conformance.js";
|
|
5
|
+
const POINTER_PREFIX = /\b(file|test|adr):(\S.*)$/;
|
|
6
|
+
/** Parse the typed pointers out of an Evidence cell. One pointer per `;`-separated segment. */
|
|
7
|
+
export function parseEvidencePointers(evidence) {
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const segment of evidence.split(";")) {
|
|
10
|
+
const m = segment.trim().match(POINTER_PREFIX);
|
|
11
|
+
if (!m)
|
|
12
|
+
continue;
|
|
13
|
+
const kind = m[1];
|
|
14
|
+
const rest = m[2];
|
|
15
|
+
const raw = `${kind}:${rest.split(/\s/)[0]}`;
|
|
16
|
+
if (kind === "adr") {
|
|
17
|
+
const id = rest.match(/^(\d{1,6})\b/)?.[1];
|
|
18
|
+
if (id)
|
|
19
|
+
out.push({ kind, raw: `adr:${id}`, adrId: id });
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (kind === "test") {
|
|
23
|
+
const sep = rest.indexOf("::");
|
|
24
|
+
const firstWs = rest.search(/\s/);
|
|
25
|
+
if (sep !== -1 && (firstWs === -1 || sep < firstWs)) {
|
|
26
|
+
const name = rest.slice(sep + 2).trim().replace(/^["']|["']$/g, "");
|
|
27
|
+
out.push({ kind, raw, path: clean(rest.slice(0, sep)), testName: name || undefined });
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
out.push({ kind, raw, path: clean(rest.split(/\s/)[0]) });
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// file:PATH[:LINE][#SYMBOL]
|
|
35
|
+
let token = clean(rest.split(/\s/)[0]);
|
|
36
|
+
let symbol;
|
|
37
|
+
const hash = token.indexOf("#");
|
|
38
|
+
if (hash !== -1) {
|
|
39
|
+
symbol = token.slice(hash + 1) || undefined;
|
|
40
|
+
token = token.slice(0, hash);
|
|
41
|
+
}
|
|
42
|
+
let line;
|
|
43
|
+
const ln = token.match(/:(\d+)$/);
|
|
44
|
+
if (ln) {
|
|
45
|
+
line = Number(ln[1]);
|
|
46
|
+
token = token.slice(0, -ln[0].length);
|
|
47
|
+
}
|
|
48
|
+
out.push({ kind, raw, path: token, line, symbol });
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
/** Strip the trailing punctuation prose leaves on a token (`src/x.ts),` → `src/x.ts`). */
|
|
53
|
+
function clean(token) {
|
|
54
|
+
return token.replace(/[),.:]+$/, "");
|
|
55
|
+
}
|
|
56
|
+
/** The same three resolution bases as the drift pass (ADR-0004). */
|
|
57
|
+
export function resolutionBases(missionDir, deliverable) {
|
|
58
|
+
return [dirname(missionDir), missionDir, dirname(join(missionDir, deliverable))];
|
|
59
|
+
}
|
|
60
|
+
function resolveFile(p, bases) {
|
|
61
|
+
if (isAbsolute(p))
|
|
62
|
+
return existsSync(p) ? p : null;
|
|
63
|
+
for (const b of bases) {
|
|
64
|
+
const abs = join(b, p);
|
|
65
|
+
if (existsSync(abs))
|
|
66
|
+
return abs;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function isRegularFile(abs) {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(abs).isFile();
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Blocking evidence checks for one gated deliverable (ADR-0019/0020):
|
|
80
|
+
* typed pointers verified per type, resolvable pointed files non-empty,
|
|
81
|
+
* signed rules matched against their signature.
|
|
82
|
+
*/
|
|
83
|
+
export function evidenceReport(missionDir, deliverable, signatures) {
|
|
84
|
+
const path = join(missionDir, deliverable);
|
|
85
|
+
if (!existsSync(path))
|
|
86
|
+
return [];
|
|
87
|
+
const bases = resolutionBases(missionDir, deliverable);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const row of parseManifest(readFileSync(path, "utf8"))) {
|
|
90
|
+
if (row.status !== "applied")
|
|
91
|
+
continue;
|
|
92
|
+
if (/^\[.*\]$/.test(row.rule))
|
|
93
|
+
continue; // template placeholder row
|
|
94
|
+
const pointers = parseEvidencePointers(row.evidence);
|
|
95
|
+
const resolvedFiles = new Map(); // abs path → content (read once)
|
|
96
|
+
for (const p of pointers) {
|
|
97
|
+
if (p.kind === "adr") {
|
|
98
|
+
if (!adrIdExists(missionDir, `ADR-${p.adrId}`)) {
|
|
99
|
+
out.push({ rule: row.rule, problem: `typed pointer adr:${p.adrId} — no matching ADR in runward/adr/` });
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const abs = p.path ? resolveFile(p.path, bases) : null;
|
|
104
|
+
if (!abs) {
|
|
105
|
+
out.push({ rule: row.rule, problem: `typed pointer does not resolve: ${p.raw} — update it or remove the row` });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!isRegularFile(abs)) {
|
|
109
|
+
out.push({ rule: row.rule, problem: `typed pointer resolves to a directory, not a file: ${p.raw}` });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const content = readFileSync(abs, "utf8");
|
|
113
|
+
resolvedFiles.set(abs, content);
|
|
114
|
+
if (!/\S/.test(content)) {
|
|
115
|
+
out.push({ rule: row.rule, problem: `typed pointer resolves to an empty file: ${p.raw} — an empty file is not evidence` });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (p.line !== undefined && content.split("\n").length < p.line) {
|
|
119
|
+
out.push({ rule: row.rule, problem: `typed pointer ${p.raw} — the file has fewer than ${p.line} lines` });
|
|
120
|
+
}
|
|
121
|
+
if (p.symbol !== undefined && !content.includes(p.symbol)) {
|
|
122
|
+
out.push({ rule: row.rule, problem: `typed pointer ${p.raw} — symbol "${p.symbol}" not found in the file (moved or renamed? update the pointer)` });
|
|
123
|
+
}
|
|
124
|
+
if (p.testName !== undefined && !content.includes(p.testName)) {
|
|
125
|
+
out.push({ rule: row.rule, problem: `typed pointer ${p.raw} — test named "${p.testName}" not found in the file` });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Non-vacuity for every row (ADR-0019): a path token that resolves must resolve to real content.
|
|
129
|
+
for (const t of evidencePathTokens(row.evidence)) {
|
|
130
|
+
const abs = resolveFile(t, bases);
|
|
131
|
+
if (!abs || !isRegularFile(abs) || resolvedFiles.has(abs))
|
|
132
|
+
continue;
|
|
133
|
+
const content = readFileSync(abs, "utf8");
|
|
134
|
+
resolvedFiles.set(abs, content);
|
|
135
|
+
if (!/\S/.test(content)) {
|
|
136
|
+
out.push({ rule: row.rule, problem: `evidence points at an empty file: ${t} — an empty file is not evidence` });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Signature (ADR-0020): a signed rule's applied evidence must contain the rule's shape.
|
|
140
|
+
const sig = signatures[row.rule];
|
|
141
|
+
if (sig) {
|
|
142
|
+
let re;
|
|
143
|
+
try {
|
|
144
|
+
re = new RegExp(sig, "i");
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
out.push({ rule: row.rule, problem: `invalid signature regex in the rule file: /${sig}/ — fix runward/rules/${row.rule}.md` });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (resolvedFiles.size === 0) {
|
|
151
|
+
out.push({ rule: row.rule, problem: `this rule declares an evidence signature — point the applied evidence at a file (file: or test:) whose content matches /${sig}/i` });
|
|
152
|
+
}
|
|
153
|
+
else if (![...resolvedFiles.values()].some((c) => re.test(c))) {
|
|
154
|
+
out.push({ rule: row.rule, problem: `evidence does not match the rule's signature /${sig}/i — the pointed content lacks the rule's shape (cited, not applied?)` });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
// ── Evidence sealing (ADR-0021) — an opt-in SHA-256 seal of a green gate ──
|
|
161
|
+
export const EVIDENCE_LOCK = "evidence-lock.json";
|
|
162
|
+
function sha256(abs) {
|
|
163
|
+
return createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
164
|
+
}
|
|
165
|
+
/** Every evidence file that resolves across the gated manifests, keyed by project-root-relative path. */
|
|
166
|
+
export function collectSealableEvidence(missionDir) {
|
|
167
|
+
const root = dirname(missionDir);
|
|
168
|
+
const files = new Map();
|
|
169
|
+
for (const { deliverable } of GATED_DELIVERABLES) {
|
|
170
|
+
const path = join(missionDir, deliverable);
|
|
171
|
+
if (!existsSync(path))
|
|
172
|
+
continue;
|
|
173
|
+
const bases = resolutionBases(missionDir, deliverable);
|
|
174
|
+
for (const row of parseManifest(readFileSync(path, "utf8"))) {
|
|
175
|
+
if (row.status !== "applied" || /^\[.*\]$/.test(row.rule))
|
|
176
|
+
continue;
|
|
177
|
+
const candidates = [
|
|
178
|
+
...parseEvidencePointers(row.evidence).map((p) => p.path).filter((p) => !!p),
|
|
179
|
+
...evidencePathTokens(row.evidence),
|
|
180
|
+
];
|
|
181
|
+
for (const t of candidates) {
|
|
182
|
+
const abs = resolveFile(t, bases);
|
|
183
|
+
if (abs && isRegularFile(abs))
|
|
184
|
+
files.set(relative(root, abs), "");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const rel of [...files.keys()].sort())
|
|
190
|
+
out[rel] = sha256(join(root, rel));
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
/** The lock file content — stable order, byte-idempotent on unchanged evidence. */
|
|
194
|
+
export function renderEvidenceLock(missionDir, sealedAt) {
|
|
195
|
+
return JSON.stringify({ version: 1, sealedAt, files: collectSealableEvidence(missionDir) }, null, 2) + "\n";
|
|
196
|
+
}
|
|
197
|
+
/** Verify an existing seal. No lock file → no seal check (opt-in by construction). */
|
|
198
|
+
export function verifyEvidenceLock(missionDir) {
|
|
199
|
+
const lockPath = join(missionDir, EVIDENCE_LOCK);
|
|
200
|
+
if (!existsSync(lockPath))
|
|
201
|
+
return { present: false, count: 0, violations: [] };
|
|
202
|
+
const root = dirname(missionDir);
|
|
203
|
+
let lock;
|
|
204
|
+
try {
|
|
205
|
+
lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return { present: true, count: 0, violations: [{ rule: "(seal)", problem: `runward/${EVIDENCE_LOCK} is not valid JSON — re-seal with \`runward check --freeze\` or remove it` }] };
|
|
209
|
+
}
|
|
210
|
+
const files = lock.files ?? {};
|
|
211
|
+
const violations = [];
|
|
212
|
+
for (const [rel, hash] of Object.entries(files)) {
|
|
213
|
+
const abs = join(root, rel);
|
|
214
|
+
if (!existsSync(abs) || !isRegularFile(abs)) {
|
|
215
|
+
violations.push({ rule: "(seal)", problem: `sealed evidence missing: ${rel} — the file the gate crossed on is gone. Re-verify the pointer, then re-seal with \`runward check --freeze\`` });
|
|
216
|
+
}
|
|
217
|
+
else if (sha256(abs) !== hash) {
|
|
218
|
+
violations.push({ rule: "(seal)", problem: `sealed evidence changed: ${rel} — re-read the pointer, confirm the evidence still holds, then re-seal with \`runward check --freeze\`` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { present: true, sealedAt: lock.sealedAt, count: Object.keys(files).length, violations };
|
|
222
|
+
}
|