diff --git a/skills/contract-review/SKILL.md b/skills/contract-review/SKILL.md new file mode 100644 index 00000000..9380b38f --- /dev/null +++ b/skills/contract-review/SKILL.md @@ -0,0 +1,81 @@ +--- +name: contract-review +version: 0.1.0 +description: Extract contract clauses, compare them only with a supplied terms playbook, and produce cited redlines plus a human-gated risk summary. +source: + type: cli-tool + command: node + args: + - run.mjs +links: + source: https://github.com/luismireles12/runx/tree/feat/contract-review/skills/contract-review +runx: + category: legal +--- + +# Contract Review + +`contract-review` performs a deterministic, read-only comparison between a +contract and a supplied acceptable-terms playbook. It extracts only clauses +present in the input, cites the exact playbook rule behind every redline, and +returns an analysis artifact for a human reviewer. + +It never signs, accepts, rejects, sends, files, or negotiates a contract and +does not provide a final legal conclusion. + +## Inputs + +- `contract`: an object containing `id` plus either structured `clauses` or + labelled contract `text`. +- `playbook`: an object containing `id` and a non-empty `rules` array. + +Structured clauses should contain `id`, `type`, `title`, and `text`. Rules may +contain: + +- `id` and `clause_type` +- `requirement` +- `severity` +- `max_days` +- `forbidden_terms` +- `required_terms` +- `require_cap` +- `required` +- `proposed_text` + +## Outputs + +- `clauses[]`: only clauses traceable to the contract input. +- `redlines[]`: each item cites its clause and supplied playbook rule, explains + the detected variance, and includes proposed text only when the playbook + supplied it. +- `risk_summary`: counts, severity, input references, and explicit read-only + constraints. + +## Guardrails + +- Refuse non-contract or unparseable input. +- Refuse a playbook without rules. +- Never create clause text that is absent from the contract. +- Never create a policy requirement or proposed replacement that is absent + from the playbook. +- Redact obvious credentials and payment-card numbers from quoted evidence. +- Emit no effects. A human decides whether to accept, negotiate, or escalate. + +## Review procedure + +1. Validate both input objects and the supplied playbook rules. +2. Extract structured clauses or labelled sections from contract text. +3. Reject the run if the input cannot be identified as a contract. +4. Match each rule to a clause by normalized type or supplied keywords. +5. Record missing required clauses without inventing clause text. +6. Evaluate only explicitly encoded rule conditions. +7. Return risk ordered from high to low with exact source citations. + +## Example + +If a termination clause requires 90 days while the supplied rule caps notice +at 30 days, the redline cites the 90-day clause, cites the 30-day playbook +requirement, and uses replacement text only if `proposed_text` was supplied. + +This artifact is suitable for a first-pass commercial review, not a substitute +for advice from qualified counsel. diff --git a/skills/contract-review/X.yaml b/skills/contract-review/X.yaml new file mode 100644 index 00000000..d257f9b9 --- /dev/null +++ b/skills/contract-review/X.yaml @@ -0,0 +1,84 @@ +skill: contract-review +version: "0.1.0" + +catalog: + kind: skill + audience: operator + visibility: public + role: canonical + +harness: + cases: + - name: cited-redlines + inputs: + contract: + id: msa-example + clauses: + - id: term-1 + type: termination + title: Termination + text: Either party may terminate for convenience with 90 days written notice. + - id: liability-1 + type: liability + title: Limitation of Liability + text: Vendor liability is unlimited for every category of damages. + playbook: + id: commercial-terms-v1 + rules: + - id: termination-notice + clause_type: termination + requirement: Notice must not exceed 30 days. + max_days: 30 + severity: medium + proposed_text: Either party may terminate for convenience with 30 days written notice. + - id: liability-cap + clause_type: liability + requirement: Liability must contain an express monetary cap. + require_cap: true + severity: high + proposed_text: Aggregate liability will not exceed fees paid in the preceding 12 months. + expect: + status: sealed + receipt: + schema: runx.receipt.v1 + state: sealed + disposition: closed + + - name: refuse-non-contract + inputs: + contract: + id: lunch-note + text: Soup, salad, and coffee are available today. + playbook: + id: commercial-terms-v1 + rules: + - id: termination-notice + clause_type: termination + requirement: Notice must not exceed 30 days. + expect: + status: failure + +runners: + review: + default: true + type: cli-tool + command: node + args: + - run.mjs + inputs: + contract: + type: json + required: true + description: "Contract text or explicitly supplied clauses." + playbook: + type: json + required: true + description: "The only acceptable-terms rules the review may apply." + outputs: + clauses: array + redlines: array + risk_summary: object + artifacts: + wrap_as: contract_review_packet + packet: runx.contract_review.v1 + diff --git a/skills/contract-review/fixtures/cited-redlines.json b/skills/contract-review/fixtures/cited-redlines.json new file mode 100644 index 00000000..80c6c570 --- /dev/null +++ b/skills/contract-review/fixtures/cited-redlines.json @@ -0,0 +1,41 @@ +{ + "contract": { + "id": "msa-example", + "clauses": [ + { + "id": "term-1", + "type": "termination", + "title": "Termination", + "text": "Either party may terminate for convenience with 90 days written notice." + }, + { + "id": "liability-1", + "type": "liability", + "title": "Limitation of Liability", + "text": "Vendor liability is unlimited for every category of damages." + } + ] + }, + "playbook": { + "id": "commercial-terms-v1", + "rules": [ + { + "id": "termination-notice", + "clause_type": "termination", + "requirement": "Notice must not exceed 30 days.", + "max_days": 30, + "severity": "medium", + "proposed_text": "Either party may terminate for convenience with 30 days written notice." + }, + { + "id": "liability-cap", + "clause_type": "liability", + "requirement": "Liability must contain an express monetary cap.", + "require_cap": true, + "severity": "high", + "proposed_text": "Aggregate liability will not exceed fees paid in the preceding 12 months." + } + ] + } +} + diff --git a/skills/contract-review/fixtures/refuse-non-contract.json b/skills/contract-review/fixtures/refuse-non-contract.json new file mode 100644 index 00000000..8155896c --- /dev/null +++ b/skills/contract-review/fixtures/refuse-non-contract.json @@ -0,0 +1,16 @@ +{ + "contract": { + "id": "lunch-note", + "text": "Soup, salad, and coffee are available today." + }, + "playbook": { + "id": "commercial-terms-v1", + "rules": [ + { + "id": "termination-notice", + "clause_type": "termination", + "requirement": "Notice must not exceed 30 days." + } + ] + } +} diff --git a/skills/contract-review/references/dogfood-receipt.json b/skills/contract-review/references/dogfood-receipt.json new file mode 100644 index 00000000..b191b50b --- /dev/null +++ b/skills/contract-review/references/dogfood-receipt.json @@ -0,0 +1,2 @@ +{"schema":"runx.receipt.v1","id":"sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0","created_at":"2026-06-23T05:44:23.579Z","canonicalization":"runx.receipt.c14n.v1","issuer":{"type":"hosted","kid":"runx-demo-key","public_key_sha256":"sha256:3097e2dee2cb4a34b53840cdb705aed71067c36f68db0e0f559c3f3fa043315f"},"signature":{"alg":"Ed25519","value":"base64:cJu63ccV0U7sqeojnD82WdDJgE3W4kaXVDVYBVUd_jjIrNkjIVDssrGQba7bdCXWwJ7QENxhnx6T8--cktC3BA"},"digest":"sha256:e35292df2c1c8b8c4174dcab5ae831b1e02843810fb8d37d268667955d215ea2","idempotency":{"intent_key":"sha256:run_review_cc9aaecb4870-review-intent","trigger_fingerprint":"sha256:run_review_cc9aaecb4870-review-trigger","content_hash":"sha256:run_review_cc9aaecb4870-review-content"},"subject":{"kind":"skill","ref":{"type":"harness","uri":"hrn_run_review_cc9aaecb4870_review"},"commitments":[]},"authority":{"actor_ref":{"type":"principal","uri":"runx:principal:local_runtime"},"grant_refs":[],"scope_refs":[],"authority_proof_refs":[],"attenuation":{"parent_authority_ref":null,"subset_proof":null},"terms":[],"enforcement":{"profile_hash":"sha256:runtime-skeleton-enforcement","redaction_refs":[],"setup_refs":[],"teardown_refs":[]}},"signals":[],"decisions":[{"decision_id":"dec_review","choice":"open","inputs":{"signal_refs":[],"target_ref":null,"opportunity_refs":[],"selection_ref":null},"proposed_intent":{"purpose":"Open runtime node review","legitimacy":"Local graph execution requested this node","success_criteria":[],"constraints":[],"derived_from":[]},"selected_act_id":"act_review","selected_harness_ref":null,"justification":{"summary":"runtime graph planner selected this node","evidence_refs":[]},"closure":null,"artifact_refs":[]}],"acts":[{"id":"act_review","form":"observation","intent":{"purpose":"Run graph step review","legitimacy":"Runtime graph execution was admitted by the local harness","success_criteria":[{"criterion_id":"process_exit","statement":"cli-tool exits successfully","required":true}],"constraints":[],"derived_from":[]},"summary":"Executed graph step review","criterion_bindings":[{"criterion_id":"process_exit","status":"verified","evidence_refs":[],"verification_refs":[],"summary":"cli-tool exited successfully"}],"source_refs":[],"target_refs":[],"artifact_refs":[],"closure":{"disposition":"closed","reason_code":"process_exit","summary":"cli-tool exited successfully","closed_at":"2026-06-23T05:44:23.579Z"}}],"seal":{"disposition":"closed","reason_code":"process_closed","summary":"cli-tool review completed","closed_at":"2026-06-23T05:44:23.579Z","last_observed_at":"2026-06-23T05:44:23.579Z","criteria":[{"criterion_id":"process_exit","status":"verified","evidence_refs":[],"verification_refs":[],"summary":"cli-tool exited successfully"}]},"lineage":{"children":[],"sync":[]}} + diff --git a/skills/contract-review/references/evidence.json b/skills/contract-review/references/evidence.json new file mode 100644 index 00000000..ad96b940 --- /dev/null +++ b/skills/contract-review/references/evidence.json @@ -0,0 +1,122 @@ +{ + "schema": "frantic.evidence.v1", + "summary": "Published and independently installed contract-review package. The post-publish dogfood run extracted three supplied clauses, produced three playbook-cited redlines, emitted no effects, and generated a sealed receipt that verifies as valid.", + "observations": [ + { + "id": "cli-version", + "command": "runx --version", + "output": "runx-cli 0.6.13", + "details": {"publisher_owner": "luismireles12", "package_name": "contract-review"} + }, + { + "id": "source-provenance", + "command": "gh pr view 128 --repo runxhq/runx", + "output": "Public PR open with the contract-review package and evidence.", + "details": { + "pr_url": "https://github.com/runxhq/runx/pull/128", + "source_url": "https://github.com/luismireles12/runx/tree/feat/contract-review/skills/contract-review", + "x_yaml": "https://raw.githubusercontent.com/luismireles12/runx/feat/contract-review/skills/contract-review/X.yaml", + "skill_md": "https://raw.githubusercontent.com/luismireles12/runx/feat/contract-review/skills/contract-review/SKILL.md", + "verification_json": "https://raw.githubusercontent.com/luismireles12/runx/feat/contract-review/skills/contract-review/references/verification.json" + } + }, + { + "id": "local-harness", + "command": "runx harness skills/contract-review --json", + "output": "passed: 2 cases, 0 assertion errors", + "details": { + "cases": [ + {"name": "cited-redlines", "status": "sealed"}, + {"name": "refuse-non-contract", "status": "refused"} + ] + } + }, + { + "id": "publish", + "command": "runx login --provider github --for publish; runx registry publish ./skills/contract-review/SKILL.md --registry https://api.runx.ai --json", + "output": "published", + "details": { + "version": "sha-2a3aa46f2351", + "registry_ref": "luismireles12/contract-review@sha-2a3aa46f2351", + "public_url": "https://runx.ai/x/luismireles12/contract-review@sha-2a3aa46f2351", + "hosted_harness_status": "passed" + } + }, + { + "id": "registry-read", + "command": "runx registry read luismireles12/contract-review@sha-2a3aa46f2351 --registry https://api.runx.ai --json", + "output": "status: success; owner: luismireles12; trust_tier: community", + "details": { + "digest": "83995c7034d7a9479bae246742b66c1121cc0320614a45e58825ef4dd038d798", + "profile_digest": "5d51769d898f4b35dc9cce4000f71992f5bd1e41978a3800ae47b8b77c457175" + } + }, + { + "id": "clean-install", + "command": "runx add luismireles12/contract-review@sha-2a3aa46f2351 --registry https://api.runx.ai --to ./clean-install --json", + "output": "status: installed", + "details": {"install_count": 1} + }, + { + "id": "dogfood", + "command": "runx skill luismireles12/contract-review@sha-2a3aa46f2351 --registry https://api.runx.ai -R ./published-dogfood --input-json contract= --input-json playbook= --json", + "output": "status: sealed; 3 clauses; 3 redlines; risk level high", + "details": { + "clause_list": ["payment-1", "termination-1", "liability-1"], + "receipt_ref": "runx:receipt:sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0" + } + }, + { + "id": "cited-redlines", + "command": "inspect dogfood structured output", + "output": "Every redline cites a supplied clause and playbook rule.", + "details": { + "redlines": [ + {"clause_id": "liability-1", "rule_id": "liability-cap", "severity": "high", "citation": "Liability must contain an express cap tied to fees."}, + {"clause_id": "payment-1", "rule_id": "payment-net-30", "severity": "medium", "citation": "Invoice terms must not exceed 30 days."}, + {"clause_id": "termination-1", "rule_id": "termination-30", "severity": "medium", "citation": "Termination notice must not exceed 30 days."} + ] + } + }, + { + "id": "risk-and-effects", + "command": "inspect dogfood risk_summary", + "output": "high risk; read_only true; effects_emitted empty", + "details": {"clause_count": 3, "redline_count": 3, "read_only": true, "effects_emitted": []} + }, + { + "id": "receipt-verification", + "command": "runx verify sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0 --receipt-dir ./published-dogfood --json", + "output": "valid: true", + "details": { + "signature_mode": "production", + "findings": [], + "receipt_url": "https://raw.githubusercontent.com/luismireles12/runx/feat/contract-review/skills/contract-review/references/dogfood-receipt.json" + } + }, + { + "id": "new-user-flow", + "command": "install, run, and verify the published package", + "output": "No private context is required.", + "details": { + "steps": [ + "Install the exact registry ref.", + "Run with bounded contract and playbook JSON.", + "Save the emitted receipt.", + "Verify it with runx verify." + ] + } + } + ], + "dogfood": { + "package": "luismireles12/contract-review@sha-2a3aa46f2351", + "input": "Public synthetic cloud order form with payment, termination, and liability clauses plus an explicit commercial playbook.", + "command": "runx skill luismireles12/contract-review@sha-2a3aa46f2351 --registry https://api.runx.ai ... --json", + "receipt_ref": "runx:receipt:sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0", + "verify_verdict": "valid: true", + "harness_cases": [ + {"name": "cited-redlines", "status": "sealed"}, + {"name": "refuse-non-contract", "status": "refused"} + ] + } +} diff --git a/skills/contract-review/references/harness-evidence.json b/skills/contract-review/references/harness-evidence.json new file mode 100644 index 00000000..9baf6459 --- /dev/null +++ b/skills/contract-review/references/harness-evidence.json @@ -0,0 +1,41 @@ +{ + "schema": "runx.contract_review.harness_evidence.v1", + "generated_at": "2026-06-23T05:38:00Z", + "cli_version": "runx-cli 0.6.13", + "command": "runx harness skills/contract-review --receipt-dir ./local-harness --json", + "result": { + "status": "passed", + "case_count": 2, + "assertion_error_count": 0, + "case_names": [ + "cited-redlines", + "refuse-non-contract" + ], + "receipt_ids": [ + "sha256:1d5c524b4feca791d75a4d3d7a66db8310bf83461eb4b730c532ba70d4bf5700", + "sha256:6c64d599c1a815b8514d4402c5e0012a0a13cc652533b7b7c0f0f2d394ea72cd" + ] + }, + "observations": { + "clauses": [ + "term-1", + "liability-1" + ], + "redlines": [ + { + "rule_id": "liability-cap", + "clause_id": "liability-1", + "citation": "Liability must contain an express monetary cap." + }, + { + "rule_id": "termination-notice", + "clause_id": "term-1", + "citation": "Notice must not exceed 30 days." + } + ], + "risk_summary": "high: one high and one medium redline", + "refusal": "A lunch note exits with failure instead of fabricating contract clauses.", + "effects": [] + } +} + diff --git a/skills/contract-review/references/report.md b/skills/contract-review/references/report.md new file mode 100644 index 00000000..c832fe05 --- /dev/null +++ b/skills/contract-review/references/report.md @@ -0,0 +1,24 @@ +# Contract Review verification report + +- Built and tested with `runx-cli 0.6.13`. +- `runx doctor skills/contract-review --json` reports zero errors or warnings. +- Local harness passes `cited-redlines` and `refuse-non-contract`. +- Typed inputs are `contract` and `playbook`. +- Typed outputs are `clauses`, `redlines`, and `risk_summary`. +- Every redline cites the exact input clause and supplied playbook rule. +- Replacement language appears only when supplied as `proposed_text`. +- Non-contract input fails rather than producing invented clauses. +- Missing playbook rules fail rather than applying hidden legal standards. +- The runner performs no network calls, writes, or external effects. +- Obvious credentials and payment-card numbers are redacted from quoted text. +- A human reviewer remains responsible for acceptance, negotiation, or escalation. +- Published registry ref: `luismireles12/contract-review@sha-2a3aa46f2351`. +- Public adoption page: https://runx.ai/x/luismireles12/contract-review@sha-2a3aa46f2351. +- A clean `runx add` installation resolved the same package and profile digests. +- The post-publish dogfood run produced receipt `sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0`. +- `runx verify` returned `valid: true` with no findings. + +The skill gives legal and commercial operators a reproducible first-pass review +packet. A new user can install the published package, pass a bounded contract +and playbook, inspect cited redlines, and independently verify the resulting +receipt without access to private context. diff --git a/skills/contract-review/references/verification.json b/skills/contract-review/references/verification.json new file mode 100644 index 00000000..ae905439 --- /dev/null +++ b/skills/contract-review/references/verification.json @@ -0,0 +1,34 @@ +{ + "schema": "runx.contract_review.verification.v1", + "generated_at": "2026-06-23T05:45:00Z", + "cli_version": "runx-cli 0.6.13", + "doctor": { + "status": "success", + "errors": 0, + "warnings": 0, + "infos": 0 + }, + "local_harness": { + "status": "passed", + "case_count": 2, + "case_names": [ + "cited-redlines", + "refuse-non-contract" + ] + }, + "published_package": { + "ref": "luismireles12/contract-review@sha-2a3aa46f2351", + "public_url": "https://runx.ai/x/luismireles12/contract-review@sha-2a3aa46f2351", + "registry_read": "success", + "clean_install": "success", + "hosted_harness": "passed" + }, + "dogfood": { + "receipt_id": "sha256:35b48f3bb480b8445f1b98ccf9c7866bf71ebcad83245d2e5edc9457584835f0", + "status": "sealed", + "verify_valid": true, + "findings": [] + }, + "read_only": true, + "effects_emitted": [] +} diff --git a/skills/contract-review/run.mjs b/skills/contract-review/run.mjs new file mode 100644 index 00000000..ffee02e0 --- /dev/null +++ b/skills/contract-review/run.mjs @@ -0,0 +1,234 @@ +import fs from "node:fs"; +import crypto from "node:crypto"; + +const inputs = readInputs(); +const contract = object(inputs.contract, "contract"); +const playbook = object(inputs.playbook, "playbook"); +const rules = Array.isArray(playbook.rules) ? playbook.rules.filter(isObject) : []; + +if (rules.length === 0) fail("playbook.rules must contain at least one supplied rule"); + +const clauses = extractClauses(contract); +if (!isContract(contract, clauses)) fail("contract input is non-contract or unparseable"); + +const redlines = []; +for (const rule of rules) { + const normalizedRule = normalizeRule(rule); + const clause = matchClause(clauses, normalizedRule); + + if (!clause) { + if (normalizedRule.required) { + redlines.push(makeRedline(normalizedRule, null, "Required clause is absent.")); + } + continue; + } + + for (const issue of evaluate(clause, normalizedRule)) { + redlines.push(makeRedline(normalizedRule, clause, issue)); + } +} + +redlines.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) + || a.rule_id.localeCompare(b.rule_id) + || (a.clause_id || "").localeCompare(b.clause_id || "")); + +emit({ + clauses, + redlines, + risk_summary: { + schema: "runx.contract_review.v1", + contract_ref: text(contract.id) || "contract:unlabelled", + playbook_ref: text(playbook.id) || "playbook:unlabelled", + level: riskLevel(redlines), + clause_count: clauses.length, + redline_count: redlines.length, + severity_counts: countBySeverity(redlines), + read_only: true, + effects_emitted: [], + human_review_required: true, + constraints: [ + "clauses_cited_from_contract_input_only", + "rules_cited_from_playbook_input_only", + "no_legal_decision_or_contract_effect", + ], + }, +}); + +function extractClauses(value) { + if (Array.isArray(value.clauses)) { + return value.clauses.filter(isObject).map((clause, index) => ({ + id: text(clause.id) || `clause-${index + 1}`, + type: slug(text(clause.type) || text(clause.title) || "general"), + title: text(clause.title) || text(clause.type) || `Clause ${index + 1}`, + text: redact(text(clause.text) || ""), + source_index: index, + })).filter((clause) => clause.text); + } + + const source = text(value.text) || ""; + const found = []; + const pattern = /(?:^|\n)\s*(?:\d+(?:\.\d+)*[.)]?\s*)?([A-Za-z][A-Za-z /&-]{2,40})\s*:\s*([^\n]+)/g; + for (const match of source.matchAll(pattern)) { + found.push({ + id: `section-${found.length + 1}`, + type: slug(match[1]), + title: match[1].trim(), + text: redact(match[2].trim()), + source_index: match.index, + }); + } + return found; +} + +function normalizeRule(rule) { + const id = text(rule.id); + const requirement = text(rule.requirement) || text(rule.description); + if (!id || !requirement) fail("each playbook rule requires id and requirement"); + return { + id, + clause_type: slug(text(rule.clause_type) || ""), + requirement, + severity: normalizeSeverity(rule.severity), + max_days: finite(rule.max_days), + forbidden_terms: stringArray(rule.forbidden_terms), + required_terms: stringArray(rule.required_terms), + require_cap: rule.require_cap === true, + required: rule.required === true, + keywords: stringArray(rule.keywords), + proposed_text: text(rule.proposed_text), + }; +} + +function evaluate(clause, rule) { + const lower = clause.text.toLowerCase(); + const issues = []; + + if (rule.max_days !== null) { + const days = [...lower.matchAll(/\b(\d{1,4})\s+days?\b/g)].map((match) => Number(match[1])); + if (days.length > 0 && Math.max(...days) > rule.max_days) { + issues.push(`Clause permits ${Math.max(...days)} days; playbook maximum is ${rule.max_days}.`); + } + } + + for (const term of rule.forbidden_terms) { + if (lower.includes(term.toLowerCase())) issues.push(`Clause contains forbidden term: ${term}.`); + } + + const missing = rule.required_terms.filter((term) => !lower.includes(term.toLowerCase())); + if (missing.length > 0) issues.push(`Clause omits required term(s): ${missing.join(", ")}.`); + + if (rule.require_cap && !hasExpressCap(lower)) { + issues.push("Clause does not contain an express liability cap."); + } + + return issues; +} + +function makeRedline(rule, clause, issue) { + const seed = `${rule.id}\n${clause?.id || "missing"}\n${issue}`; + return { + redline_id: `redline-${crypto.createHash("sha256").update(seed).digest("hex").slice(0, 12)}`, + rule_id: rule.id, + clause_id: clause?.id || null, + clause_type: rule.clause_type || clause?.type || "unspecified", + severity: rule.severity, + issue, + citation: { + contract: clause ? { clause_id: clause.id, clause_text: clause.text } : { clause_id: null, clause_text: null }, + playbook: { rule_id: rule.id, requirement: rule.requirement }, + }, + proposed_text: rule.proposed_text, + }; +} + +function matchClause(clauses, rule) { + const exact = rule.clause_type && clauses.find((clause) => clause.type === rule.clause_type); + if (exact) return exact; + return clauses.find((clause) => { + const haystack = `${clause.type} ${clause.title} ${clause.text}`.toLowerCase(); + return rule.keywords.some((keyword) => haystack.includes(keyword.toLowerCase())); + }) || null; +} + +function isContract(value, clauses) { + if (clauses.length > 0 && Array.isArray(value.clauses)) return true; + const source = (text(value.text) || "").toLowerCase(); + return clauses.length > 0 && /\b(agreement|party|parties|liability|termination|indemn)/.test(source); +} + +function hasExpressCap(value) { + if (/\b(unlimited|uncapped|without limitation)\b/.test(value)) return false; + return /\b(cap(?:ped)?|limit(?:ed|ation)?)\b/.test(value) + && /(\$|usd|fees paid|months|aggregate)/.test(value); +} + +function riskLevel(values) { + if (values.some((item) => item.severity === "high")) return "high"; + if (values.some((item) => item.severity === "medium")) return "medium"; + return values.length ? "low" : "none"; +} + +function countBySeverity(values) { + return values.reduce((counts, item) => { + counts[item.severity] += 1; + return counts; + }, { high: 0, medium: 0, low: 0 }); +} + +function severityRank(value) { + return { high: 3, medium: 2, low: 1 }[value] || 0; +} + +function normalizeSeverity(value) { + const normalized = (text(value) || "medium").toLowerCase(); + return ["high", "medium", "low"].includes(normalized) ? normalized : "medium"; +} + +function redact(value) { + return value + .replace(/\b(?:\d[ -]*?){13,19}\b/g, "[REDACTED_CARD]") + .replace(/\b(?:api[_ -]?key|password|secret)\s*[:=]\s*\S+/gi, "$1=[REDACTED]"); +} + +function readInputs() { + const raw = process.env.RUNX_INPUTS_PATH + ? fs.readFileSync(process.env.RUNX_INPUTS_PATH, "utf8") + : process.env.RUNX_INPUTS_JSON || "{}"; + return JSON.parse(raw); +} + +function object(value, name) { + if (!isObject(value)) fail(`${name} must be an object`); + return value; +} + +function isObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function text(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function stringArray(value) { + return Array.isArray(value) ? [...new Set(value.map(text).filter(Boolean))] : []; +} + +function finite(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function slug(value) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); +} + +function emit(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(2); +} +