@renzrollon/interlock 1.0.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
package/lib/risk.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Deterministic risk classifier for one OpenSpec change.
|
|
2
|
+
//
|
|
3
|
+
// The continuity path (WS-F) can skip the human checkpoint between spec and
|
|
4
|
+
// ship. Whether it *may* skip is a blast-radius question — "does this change
|
|
5
|
+
// touch auth, money, schemas, or data we cannot get back?" — and that question
|
|
6
|
+
// was previously answered nowhere, or in prose inside a skill, which means it
|
|
7
|
+
// was answered differently on every run by whichever model happened to read
|
|
8
|
+
// the proposal. A gate that can bypass a human cannot rest on a re-derived
|
|
9
|
+
// judgement. So the rules live here, in code, and every classification carries
|
|
10
|
+
// the signals that produced it.
|
|
11
|
+
//
|
|
12
|
+
// Two properties are load-bearing:
|
|
13
|
+
//
|
|
14
|
+
// 1. FAIL CLOSED. Absence of evidence is never evidence of safety. When the
|
|
15
|
+
// caller supplies nothing usable, the class is UNCLASSIFIABLE_CLASS
|
|
16
|
+
// ('high') — not 'low'. When paths are present but nothing matched, the
|
|
17
|
+
// floor is 'medium', not 'low': "no rule fired" means "we did not
|
|
18
|
+
// recognise it", not "it is safe". 'low' is only ever reached by a
|
|
19
|
+
// positive signal (every changed path is docs or tests). An
|
|
20
|
+
// unclassifiable change must never come out 'low', because 'low' is the
|
|
21
|
+
// class that lets a machine skip a person.
|
|
22
|
+
//
|
|
23
|
+
// 2. MAXIMUM, NEVER AVERAGE. The final class is the maximum over every
|
|
24
|
+
// matched signal. A change that is 90% docs and 10% payment code is a
|
|
25
|
+
// payment change. Averaging risk is how a critical signal gets diluted by
|
|
26
|
+
// volume.
|
|
27
|
+
//
|
|
28
|
+
// Signals come from two independent sources — changed (or planned) paths, and
|
|
29
|
+
// the raw text of the change artifacts. Text matching is deliberately
|
|
30
|
+
// generous: a false positive costs one human read, a false negative ships an
|
|
31
|
+
// auth change nobody looked at.
|
|
32
|
+
//
|
|
33
|
+
// Pure: no fs, no git, no agent, no I/O, no clock. Input is never mutated.
|
|
34
|
+
// Paths are repo-relative; '\' is normalized to '/'.
|
|
35
|
+
//
|
|
36
|
+
// Exposed to skills as `interlock risk --change <name> --json`.
|
|
37
|
+
|
|
38
|
+
// Ordered least to most severe. Order is the API: callers compare by index.
|
|
39
|
+
export const RISK_CLASSES = Object.freeze(['low', 'medium', 'high', 'critical'])
|
|
40
|
+
|
|
41
|
+
const RANK = { low: 0, medium: 1, high: 2, critical: 3 }
|
|
42
|
+
|
|
43
|
+
// §4.14 "balanced": a change may auto-continue past the human checkpoint at
|
|
44
|
+
// `low` or `medium` only. This is THE continuity policy knob — re-tuning §4.14
|
|
45
|
+
// to strict (['low']) or loose (['low','medium','high']) is this one edit.
|
|
46
|
+
export const CONTINUITY_ALLOWED_CLASSES = Object.freeze(['low', 'medium'])
|
|
47
|
+
|
|
48
|
+
// Where an input with no usable signal at all lands. Deliberately outside
|
|
49
|
+
// CONTINUITY_ALLOWED_CLASSES: "we could not tell" must stop the machine.
|
|
50
|
+
export const UNCLASSIFIABLE_CLASS = 'high'
|
|
51
|
+
|
|
52
|
+
// Floor for any change that touches at least one non-docs, non-test path but
|
|
53
|
+
// matched no severity rule. Above 'low' on purpose — see fail-closed, above.
|
|
54
|
+
export const UNMATCHED_SOURCE_CLASS = 'medium'
|
|
55
|
+
|
|
56
|
+
/** Higher of two risk classes. Unknown values are treated as the maximum. */
|
|
57
|
+
export function maxClass(a, b) {
|
|
58
|
+
const ra = RANK[a] ?? RANK.critical
|
|
59
|
+
const rb = RANK[b] ?? RANK.critical
|
|
60
|
+
return ra >= rb ? (RISK_CLASSES[ra] ?? 'critical') : (RISK_CLASSES[rb] ?? 'critical')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- rule tables ---------------------------------------------------------
|
|
64
|
+
//
|
|
65
|
+
// One named constant per signal group so the rules stay greppable: when a
|
|
66
|
+
// classification surprises someone, they should be able to find the exact
|
|
67
|
+
// regex from the signal name printed in the output.
|
|
68
|
+
|
|
69
|
+
// critical — irreversible, or money.
|
|
70
|
+
const DATA_DESTRUCTION_PATH =
|
|
71
|
+
/(^|\/)(migrations?|db|database|sql)\/[^/]*(drop|delete|purge|truncate|destroy|prune|backfill)/
|
|
72
|
+
const DATA_DESTRUCTION_TEXT =
|
|
73
|
+
/\b(drop\s+(table|column|database|index)|delete\s+from|truncate\s+table|hard[\s-]?delete\w*|purge\w*|cascade\s+delete|destructive\s+migration|data\s+loss|irreversibl\w+|permanently\s+(delete|remove)\w*|wipe\w*)\b/
|
|
74
|
+
|
|
75
|
+
const PAYMENT_PATH =
|
|
76
|
+
/(^|\/)(payments?|billing|checkout|invoices?|stripe|paypal|braintree|subscriptions?|pricing|wallet|payouts?)([/\-_.]|$)/
|
|
77
|
+
const PAYMENT_TEXT =
|
|
78
|
+
/\b(payment\w*|billing|checkout|invoice\w*|refund\w*|payout\w*|charge\s+the\s+(card|customer)|stripe|paypal|braintree|price\s+in\s+cents|credit\s+card|money\s+transfer)\b/
|
|
79
|
+
|
|
80
|
+
const IDEMPOTENCY_TEXT =
|
|
81
|
+
/\b(idempoten\w*|exactly[\s-]once|at[\s-]least[\s-]once|de[\s-]?duplicat\w*|retry\s+semantics|replay\s+(protection|attack)|double[\s-](charge|submit|write)\w*)\b/
|
|
82
|
+
|
|
83
|
+
// high — auth/tenancy, and anything that changes a contract or a schema.
|
|
84
|
+
const AUTH_PATH =
|
|
85
|
+
/(^|\/)(auth|authn|authz|oauth|session|sessions|login|logout|signin|signup|password\w*|credentials?|permissions?|roles?|rbac|acl|policy|policies|tenant|tenants|tenancy|security)([/\-_.]|$)/
|
|
86
|
+
const AUTH_TEXT =
|
|
87
|
+
/\b(authenticat\w+|authoriz\w+|authn|authz|sign[\s-]?(in|up|out)|log\s?in\b|logout|session\s+(token|cookie|store|fixation|id)|password\w*|credential\w*|api\s+keys?|jwt|oauth\d?|saml|sso|permission\w*|role[\s-]based|rbac\b|acl\b|access\s+control|multi[\s-]?tenan\w*|tenant\s+(boundary|isolation|id)|impersonat\w+|csrf|privilege\s+escalation)\b/
|
|
88
|
+
|
|
89
|
+
const API_SCHEMA_PATH =
|
|
90
|
+
/(^|\/)(migrations?|migrate|schemas?|openapi|swagger|proto|prisma)([/\-_.]|$)|(^|\/)schema\.(prisma|graphql|sql|json|ts)$|\.(sql|proto)$|(^|\/)openapi\.(ya?ml|json)$/
|
|
91
|
+
const API_SCHEMA_TEXT =
|
|
92
|
+
/\b(new\s+(public\s+)?(api|endpoint|route)|public\s+api|api\s+contract|breaking\s+change|backwards?[\s-]incompatible|database\s+(migration|schema)|migration\b|migrat(e|ing)\s+(the\s+)?(data|table|column|schema)|schema\s+change|alter\s+table|add\s+column|drop\s+index|new\s+table|openapi|graphql\s+schema|wire\s+format|serialization\s+format)\b/
|
|
93
|
+
|
|
94
|
+
// medium — the invariant-sweep / shared-value-transform floor from §F3.
|
|
95
|
+
const SHARED_TRANSFORM_PATH =
|
|
96
|
+
/(^|\/)(shared|common)\/|(^|\/)(utils?|helpers?|lib)\/[^/]*(format|currency|money|amount|date|time|timezone|tz|unit|round|serializ|transform|convert|normaliz)/
|
|
97
|
+
const SHARED_TRANSFORM_TEXT =
|
|
98
|
+
/\b(invariant\s+sweep|invariant\w*|every\s+call\s?site|all\s+call\s?sites|all\s+consumers|every\s+consumer|cross[\s-]cutting|repo[\s-]wide|codebase[\s-]wide|shared\s+(helper|util\w*|value|transform\w*|format\w*|constant)|rename\s+across|sweep\s+(all|every))\b/
|
|
99
|
+
|
|
100
|
+
// low — the only positive route to 'low'.
|
|
101
|
+
export const DOC_PATH = /\.(md|mdx|txt|rst|adoc)$|(^|\/)(docs?|openspec|\.claude)\//
|
|
102
|
+
const TEST_PATH =
|
|
103
|
+
/(^|\/)(tests?|__tests__|e2e|cypress|playwright|fixtures|__mocks__)\/|\.(test|spec)\.[a-z]+$/
|
|
104
|
+
|
|
105
|
+
/** Documentation path — shared with the wave checkpoint skip and verify plan. */
|
|
106
|
+
export function isDocsPath(path) {
|
|
107
|
+
const lower = String(path || '').replace(/\\/g, '/').toLowerCase()
|
|
108
|
+
return DOC_PATH.test(lower)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Evaluated in this order; output is sorted by severity afterwards.
|
|
112
|
+
const SIGNALS = Object.freeze([
|
|
113
|
+
{ signal: 'shared-value-transform', class: 'medium', path: SHARED_TRANSFORM_PATH, text: SHARED_TRANSFORM_TEXT },
|
|
114
|
+
{ signal: 'api-schema-migration', class: 'high', path: API_SCHEMA_PATH, text: API_SCHEMA_TEXT },
|
|
115
|
+
{ signal: 'auth-session-permissions-tenancy', class: 'high', path: AUTH_PATH, text: AUTH_TEXT },
|
|
116
|
+
{ signal: 'data-destruction', class: 'critical', path: DATA_DESTRUCTION_PATH, text: DATA_DESTRUCTION_TEXT },
|
|
117
|
+
{ signal: 'payment', class: 'critical', path: PAYMENT_PATH, text: PAYMENT_TEXT },
|
|
118
|
+
{ signal: 'idempotency', class: 'critical', path: null, text: IDEMPOTENCY_TEXT }
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
// How many matches to name per signal before collapsing to a count.
|
|
122
|
+
const MAX_EVIDENCE = 3
|
|
123
|
+
|
|
124
|
+
// --- helpers -------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The one canonical form of a repo-relative path.
|
|
128
|
+
*
|
|
129
|
+
* Every module that compares two paths — the wave planner's collision key, its
|
|
130
|
+
* changed-file dedup, the docs-only test, the review evidence gate — reads this
|
|
131
|
+
* and nothing else. The transform used to be private here and `lib/waves.mjs`
|
|
132
|
+
* simply did not have it, which is how `src/a.ts` and `./src/a.ts` became two
|
|
133
|
+
* keys for one file and two implementers ended up writing it concurrently.
|
|
134
|
+
*
|
|
135
|
+
* Lexical on purpose. A predicted path names a file that may not exist yet, so
|
|
136
|
+
* `realpath` and `existsSync` are both the wrong instrument.
|
|
137
|
+
*
|
|
138
|
+
* Absolute paths and paths that escape the repository root are **rejected**
|
|
139
|
+
* rather than rewritten. Rewriting `../outside/a.ts` into something in scope
|
|
140
|
+
* would silently attribute an edit to a file the task never named; returning
|
|
141
|
+
* null lets the caller report it as unusable, which is what the spec asks for.
|
|
142
|
+
*
|
|
143
|
+
* @param {unknown} entry
|
|
144
|
+
* @returns {string|null} the canonical form, or null when the input cannot
|
|
145
|
+
* name a file inside this repository
|
|
146
|
+
*/
|
|
147
|
+
export function canonicalizePath(entry) {
|
|
148
|
+
if (typeof entry !== 'string') return null
|
|
149
|
+
const trimmed = entry.trim().replace(/\\/g, '/')
|
|
150
|
+
if (!trimmed) return null
|
|
151
|
+
// Rejected, never normalized into scope: POSIX absolute, Windows drive, UNC.
|
|
152
|
+
if (trimmed.startsWith('/') || /^[a-zA-Z]:\//.test(trimmed)) return null
|
|
153
|
+
|
|
154
|
+
// posixNormalize collapses '.', '..' and duplicate separators lexically.
|
|
155
|
+
const collapsed = posixNormalize(trimmed).replace(/\/+$/, '')
|
|
156
|
+
if (!collapsed || collapsed === '.' || collapsed === '..') return null
|
|
157
|
+
if (collapsed.startsWith('../')) return null
|
|
158
|
+
return collapsed
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// `node:path`'s posix.normalize, inlined so this module keeps its "pure, no
|
|
162
|
+
// imports beyond what it needs" shape and behaves identically on Windows.
|
|
163
|
+
function posixNormalize(path) {
|
|
164
|
+
const absolute = path.startsWith('/')
|
|
165
|
+
const out = []
|
|
166
|
+
for (const segment of path.split('/')) {
|
|
167
|
+
if (!segment || segment === '.') continue
|
|
168
|
+
if (segment === '..') {
|
|
169
|
+
if (out.length && out[out.length - 1] !== '..') out.pop()
|
|
170
|
+
else if (!absolute) out.push('..')
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
out.push(segment)
|
|
174
|
+
}
|
|
175
|
+
const joined = out.join('/')
|
|
176
|
+
return absolute ? `/${joined}` : joined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Tolerate junk entries: non-strings drop out, surrounding space is trimmed,
|
|
180
|
+
// leading './' is stripped, '\' becomes '/'.
|
|
181
|
+
//
|
|
182
|
+
// Deliberately *not* `canonicalizePath`. Risk classification and collision
|
|
183
|
+
// keying want opposite things from an unusable path: keying must reject it,
|
|
184
|
+
// because a path we cannot place in the repo cannot be an identity, while
|
|
185
|
+
// classification must keep it, because dropping `/srv/payments/charge.ts`
|
|
186
|
+
// would lower the blast radius of a change that touches payments. Fail closed
|
|
187
|
+
// means the opposite thing in each place, so each gets its own reader.
|
|
188
|
+
function normalizePath(entry) {
|
|
189
|
+
if (typeof entry !== 'string') return ''
|
|
190
|
+
const canonical = canonicalizePath(entry)
|
|
191
|
+
if (canonical) return canonical
|
|
192
|
+
let p = entry.trim().replace(/\\/g, '/')
|
|
193
|
+
while (p.startsWith('./')) p = p.slice(2)
|
|
194
|
+
return p
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizePaths(input) {
|
|
198
|
+
const list = Array.isArray(input) ? input : []
|
|
199
|
+
const out = []
|
|
200
|
+
for (const entry of list) {
|
|
201
|
+
const path = normalizePath(entry)
|
|
202
|
+
if (path) out.push({ path, lower: path.toLowerCase() })
|
|
203
|
+
}
|
|
204
|
+
return out
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// artifacts is { 'proposal.md': '<text>', ... } — whatever the caller read.
|
|
208
|
+
// Non-string and blank values are dropped rather than throwing: a caller that
|
|
209
|
+
// could not read design.md still deserves a classification.
|
|
210
|
+
function normalizeArtifacts(input) {
|
|
211
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return []
|
|
212
|
+
const out = []
|
|
213
|
+
for (const name of Object.keys(input)) {
|
|
214
|
+
const value = input[name]
|
|
215
|
+
if (typeof value !== 'string') continue
|
|
216
|
+
if (!value.trim()) continue
|
|
217
|
+
out.push({ name, text: value, lower: value.toLowerCase() })
|
|
218
|
+
}
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function snippet(text, index, length) {
|
|
223
|
+
const start = Math.max(0, index - 24)
|
|
224
|
+
const end = Math.min(text.length, index + length + 24)
|
|
225
|
+
let s = text.slice(start, end).replace(/\s+/g, ' ').trim()
|
|
226
|
+
if (start > 0) s = `…${s}`
|
|
227
|
+
if (end < text.length) s = `${s}…`
|
|
228
|
+
return s
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function joinEvidence(items) {
|
|
232
|
+
if (items.length <= MAX_EVIDENCE) return items.join('; ')
|
|
233
|
+
return `${items.slice(0, MAX_EVIDENCE).join('; ')} (+${items.length - MAX_EVIDENCE} more)`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- public API ----------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Classify one change from its changed (or planned) paths plus artifact text.
|
|
240
|
+
*
|
|
241
|
+
* @param {{ changedPaths?: string[], artifacts?: Record<string,string> }} input
|
|
242
|
+
* @param {{ allowedClasses?: string[] }} [opts]
|
|
243
|
+
* @returns {{
|
|
244
|
+
* riskClass: 'low'|'medium'|'high'|'critical',
|
|
245
|
+
* signals: Array<{signal: string, class: string, evidence: string}>,
|
|
246
|
+
* continuityAllowed: boolean,
|
|
247
|
+
* reasons: string[],
|
|
248
|
+
* basis: 'no-input'|'signals',
|
|
249
|
+
* pathCount: number,
|
|
250
|
+
* artifactNames: string[]
|
|
251
|
+
* }}
|
|
252
|
+
*/
|
|
253
|
+
export function classifyRisk(input, opts = {}) {
|
|
254
|
+
const source = input && typeof input === 'object' ? input : {}
|
|
255
|
+
const allowed = Array.isArray(opts.allowedClasses) ? opts.allowedClasses : CONTINUITY_ALLOWED_CLASSES
|
|
256
|
+
|
|
257
|
+
const paths = normalizePaths(source.changedPaths)
|
|
258
|
+
const artifacts = normalizeArtifacts(source.artifacts)
|
|
259
|
+
|
|
260
|
+
const signals = []
|
|
261
|
+
const reasons = []
|
|
262
|
+
|
|
263
|
+
// 1. Nothing usable at all. Fail closed, loudly.
|
|
264
|
+
if (paths.length === 0 && artifacts.length === 0) {
|
|
265
|
+
signals.push({
|
|
266
|
+
signal: 'no-input',
|
|
267
|
+
class: UNCLASSIFIABLE_CLASS,
|
|
268
|
+
evidence: 'no changed paths and no artifact text were supplied'
|
|
269
|
+
})
|
|
270
|
+
reasons.push(
|
|
271
|
+
`unclassifiable: no signals available, so the class fails closed to ${UNCLASSIFIABLE_CLASS} rather than low`
|
|
272
|
+
)
|
|
273
|
+
return finish(UNCLASSIFIABLE_CLASS, signals, reasons, allowed, 'no-input', paths, artifacts)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 2. Severity signals over both sources. Every group is evaluated — the
|
|
277
|
+
// result is the maximum, so an early match never short-circuits a later,
|
|
278
|
+
// worse one.
|
|
279
|
+
for (const def of SIGNALS) {
|
|
280
|
+
const evidence = []
|
|
281
|
+
if (def.path) {
|
|
282
|
+
for (const p of paths) {
|
|
283
|
+
if (def.path.test(p.lower)) evidence.push(`path ${p.path}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (def.text) {
|
|
287
|
+
for (const a of artifacts) {
|
|
288
|
+
const m = def.text.exec(a.lower)
|
|
289
|
+
if (m) evidence.push(`${a.name}: "${snippet(a.text, m.index, m[0].length)}"`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (evidence.length) {
|
|
293
|
+
signals.push({ signal: def.signal, class: def.class, evidence: joinEvidence(evidence) })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 3. Baseline floor. This is where fail-closed does its real work: the floor
|
|
298
|
+
// is only 'low' when a positive docs/tests-only signal fired.
|
|
299
|
+
const docLike = paths.filter(p => isDocsPath(p.path) || TEST_PATH.test(p.lower))
|
|
300
|
+
if (paths.length > 0 && docLike.length === paths.length) {
|
|
301
|
+
signals.push({
|
|
302
|
+
signal: 'docs-tests-only',
|
|
303
|
+
class: 'low',
|
|
304
|
+
evidence: `all ${paths.length} changed path(s) are docs or tests: ${joinEvidence(docLike.map(p => p.path))}`
|
|
305
|
+
})
|
|
306
|
+
reasons.push('every changed path is documentation or test-only')
|
|
307
|
+
} else if (paths.length > 0) {
|
|
308
|
+
const sourcePaths = paths.filter(p => !isDocsPath(p.path) && !TEST_PATH.test(p.lower))
|
|
309
|
+
signals.push({
|
|
310
|
+
signal: 'unmatched-source-change',
|
|
311
|
+
class: UNMATCHED_SOURCE_CLASS,
|
|
312
|
+
evidence: `${sourcePaths.length} non-docs path(s) with no matching risk rule: ${joinEvidence(sourcePaths.map(p => p.path))}`
|
|
313
|
+
})
|
|
314
|
+
reasons.push(
|
|
315
|
+
`source paths changed with no rule match — floored at ${UNMATCHED_SOURCE_CLASS}, not low (unrecognised is not safe)`
|
|
316
|
+
)
|
|
317
|
+
} else {
|
|
318
|
+
// Artifact text only. Blast radius is unbounded because nobody told us
|
|
319
|
+
// which files move, so the floor stays above 'low'.
|
|
320
|
+
signals.push({
|
|
321
|
+
signal: 'no-changed-paths',
|
|
322
|
+
class: UNMATCHED_SOURCE_CLASS,
|
|
323
|
+
evidence: `artifact text only (${artifacts.map(a => a.name).join(', ')}); no changed paths supplied to bound the blast radius`
|
|
324
|
+
})
|
|
325
|
+
reasons.push(
|
|
326
|
+
`no changed paths supplied — blast radius unbounded, floored at ${UNMATCHED_SOURCE_CLASS}`
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 4. Maximum, never average.
|
|
331
|
+
let riskClass = 'low'
|
|
332
|
+
for (const s of signals) riskClass = maxClass(riskClass, s.class)
|
|
333
|
+
|
|
334
|
+
const driving = signals.filter(s => s.class === riskClass).map(s => s.signal)
|
|
335
|
+
reasons.push(`class ${riskClass} = max over ${signals.length} signal(s), driven by: ${driving.join(', ')}`)
|
|
336
|
+
|
|
337
|
+
return finish(riskClass, signals, reasons, allowed, 'signals', paths, artifacts)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function finish(riskClass, signals, reasons, allowed, basis, paths, artifacts) {
|
|
341
|
+
const continuityAllowed = allowed.includes(riskClass)
|
|
342
|
+
reasons.push(
|
|
343
|
+
continuityAllowed
|
|
344
|
+
? `continuity allowed: ${riskClass} ∈ {${allowed.join(', ')}} (§4.14 balanced)`
|
|
345
|
+
: `continuity refused: ${riskClass} ∉ {${allowed.join(', ')}} (§4.14 balanced) — human checkpoint required`
|
|
346
|
+
)
|
|
347
|
+
// Severity-first so the driving signal reads first.
|
|
348
|
+
const ordered = signals.slice().sort((a, b) => (RANK[b.class] ?? 3) - (RANK[a.class] ?? 3))
|
|
349
|
+
return {
|
|
350
|
+
riskClass,
|
|
351
|
+
signals: ordered,
|
|
352
|
+
continuityAllowed,
|
|
353
|
+
reasons,
|
|
354
|
+
basis,
|
|
355
|
+
pathCount: paths.length,
|
|
356
|
+
artifactNames: artifacts.map(a => a.name)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Human-readable classification, for a skill to echo before it decides. */
|
|
361
|
+
export function formatRisk(result) {
|
|
362
|
+
const lines = []
|
|
363
|
+
lines.push(
|
|
364
|
+
`RISK ${result.riskClass.toUpperCase()} — continuity ${result.continuityAllowed ? 'allowed' : 'NOT allowed'} ` +
|
|
365
|
+
`(${result.pathCount} path(s), artifacts: ${result.artifactNames.length ? result.artifactNames.join(', ') : 'none'})`
|
|
366
|
+
)
|
|
367
|
+
for (const s of result.signals) lines.push(` [${s.class}] ${s.signal}: ${s.evidence}`)
|
|
368
|
+
for (const r of result.reasons) lines.push(` reason: ${r}`)
|
|
369
|
+
return lines.join('\n') + '\n'
|
|
370
|
+
}
|