First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `replan()` returns one of four decisions: `proceed`, `pivot`, `stop` or
|
||||||
|
`hold`.
|
||||||
|
- A plan with no recorded assumptions returns `hold`, never `proceed`.
|
||||||
|
- An assumption with `observed: null` is NOT ASSESSED and blocks.
|
||||||
|
- A broken assumption whose fallback has no `floor` makes the decision
|
||||||
|
`stop`.
|
||||||
|
- Non-numeric values are never ordered lexically; `equals` works on any
|
||||||
|
value.
|
||||||
|
- `toReport()` and `formatReplan()` produce an evidence-record report.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/pivot-ops.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- A test that cannot fail proves nothing. If you fix a bug, add a test and
|
||||||
|
check that it fails against the unfixed code before you submit.
|
||||||
|
- Measure, don't assume. Two modules with the same line count can still be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new runtime dependency needs a reason.
|
||||||
|
- Explain why in comments, not what. The what is already in the code.
|
||||||
|
- A breaking change needs a major version bump and a note in CHANGELOG.md.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# PivotOps
|
||||||
|
|
||||||
|
Decide whether a plan still holds when conditions change, and name the
|
||||||
|
assumption that broke.
|
||||||
|
|
||||||
|
PivotOps is a pure function over a plan and its current readings. It has no
|
||||||
|
clock and does no I/O, and it executes nothing. It tells you whether to
|
||||||
|
proceed, pivot to a fallback, stop, or hold.
|
||||||
|
|
||||||
|
## What it checks and why
|
||||||
|
|
||||||
|
Conditions changing is not the real failure. The failure is a plan that never
|
||||||
|
wrote down what it assumed, so nobody can tell whether a change matters.
|
||||||
|
PivotOps is built against three versions of that.
|
||||||
|
|
||||||
|
1. A plan whose assumptions are implicit cannot be invalidated. Say a rollout
|
||||||
|
assumes a host has disk headroom, a registry is reachable and a
|
||||||
|
maintenance window is open. If none of that is recorded, a full disk only
|
||||||
|
shows up as the failure at the end. A plan with no recorded assumptions
|
||||||
|
returns `hold`, never `proceed`.
|
||||||
|
2. A stale reading is worse than no reading. Replanning against a condition
|
||||||
|
nobody re-measured gives a confident decision from an old fact. An
|
||||||
|
assumption with `observed: null` is NOT ASSESSED and blocks. "We did not
|
||||||
|
look" is not "it is fine".
|
||||||
|
3. Degradation without a floor is optimism. "Reduce concurrency and continue"
|
||||||
|
is a plan. "Continue at reduced capacity" with no point at which capacity
|
||||||
|
becomes unacceptable is a way of never stopping. A broken assumption whose
|
||||||
|
fallback has no `floor` makes the decision `stop`, not `pivot`.
|
||||||
|
|
||||||
|
## Four decisions, not two
|
||||||
|
|
||||||
|
| Decision | When |
|
||||||
|
|---|---|
|
||||||
|
| `proceed` | every assumption was re-checked and holds |
|
||||||
|
| `pivot` | something broke, and each broken assumption has a fallback with a floor |
|
||||||
|
| `stop` | something broke with no fallback, or with a fallback that has no floor |
|
||||||
|
| `hold` | nothing broke, but at least one condition was never re-measured |
|
||||||
|
|
||||||
|
`hold` gives "we do not know" its own outcome, separate from "carry on" and
|
||||||
|
"abandon". Folding it into either one leads to the two mistakes this library
|
||||||
|
exists to prevent: proceeding on unread conditions, or killing a viable
|
||||||
|
rollout because one probe was missing. A broken assumption outranks an unread
|
||||||
|
one, so `stop` wins over `hold`.
|
||||||
|
|
||||||
|
Non-numeric values are never ordered. An `at-least` comparison of `"closing"`
|
||||||
|
against `"open"` reports NOT ASSESSED instead of comparing the strings, which
|
||||||
|
would pass or fail on alphabetical order. `equals` works on any value.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { replan, formatReplan, type Plan } from '@extant2000/pivot-ops'
|
||||||
|
|
||||||
|
const plan: Plan = {
|
||||||
|
name: 'nightly rollout',
|
||||||
|
assumptions: [
|
||||||
|
{
|
||||||
|
id: 'disk-headroom',
|
||||||
|
description: 'the host has room for the new image',
|
||||||
|
condition: 'disk free bytes',
|
||||||
|
comparison: 'at-least',
|
||||||
|
required: 5e9,
|
||||||
|
observed: 2e9,
|
||||||
|
where: 'df -P /var/lib/docker',
|
||||||
|
fallback: { description: 'reduce concurrency to 1', floor: 'stop below 500 MB free' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'window',
|
||||||
|
description: 'the maintenance window is open',
|
||||||
|
condition: 'window state',
|
||||||
|
comparison: 'equals',
|
||||||
|
required: 'open',
|
||||||
|
observed: 'open',
|
||||||
|
where: 'change calendar',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decision, reason, broken, unverified } = replan(plan)
|
||||||
|
console.log(decision)
|
||||||
|
console.log(reason)
|
||||||
|
|
||||||
|
// The full report, for a terminal or CI log
|
||||||
|
console.log(formatReplan(plan))
|
||||||
|
```
|
||||||
|
|
||||||
|
`toReport(plan)` returns the same result as a `CapabilityReport` from
|
||||||
|
`@extant2000/evidence-record`, where every finding cites the reading it was
|
||||||
|
based on.
|
||||||
|
|
||||||
|
## Sample output
|
||||||
|
|
||||||
|
Produced by running the example above and printing `decision` and `reason`:
|
||||||
|
|
||||||
|
```
|
||||||
|
pivot
|
||||||
|
1 assumption(s) broken, each with a fallback and a floor: disk-headroom → reduce concurrency to 1.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/pivot-ops
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. Copyright (c) 2026 Extant 2000 LLC. See [LICENSE](LICENSE).
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue or merge request for a security problem.
|
||||||
|
|
||||||
|
Include the affected version, a description of the issue, and steps or a test
|
||||||
|
that reproduce it if you have them.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/pivot-ops",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Re-plans a deployment on environmental deviation, preserving OPSEC.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist-cjs/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/pivot-ops.git"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist-cjs/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/pivot-ops",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/pivot-ops/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* PivotOps — decide whether a plan still holds when conditions move, and say
|
||||||
|
* which assumption broke.
|
||||||
|
*
|
||||||
|
* The failure is not that conditions changed. It is that a plan does not
|
||||||
|
* record what it assumed, so nobody can tell whether a change matters. Three
|
||||||
|
* shapes, all seen here:
|
||||||
|
*
|
||||||
|
* 1. **A plan whose assumptions are implicit cannot be invalidated.** A
|
||||||
|
* rollout assumed a host had disk headroom, a registry was reachable and
|
||||||
|
* a maintenance window was open. None of that was written down, so when
|
||||||
|
* the disk filled there was no mechanism to notice the plan was now
|
||||||
|
* wrong — only the failure at the end of it.
|
||||||
|
*
|
||||||
|
* 2. **A stale reading is worse than no reading.** Replanning against a
|
||||||
|
* condition nobody has re-measured produces a confident decision from
|
||||||
|
* an old fact. A condition with no observation is NOT ASSESSED, and it
|
||||||
|
* blocks — because "we did not look" is not "it is fine".
|
||||||
|
*
|
||||||
|
* 3. **Degradation without a floor is just optimism.** "Reduce concurrency
|
||||||
|
* and continue" is a plan; "continue at reduced capacity" without saying
|
||||||
|
* what capacity is unacceptable is a way of never stopping. A fallback
|
||||||
|
* without a floor is reported as a fallback that cannot fail.
|
||||||
|
*
|
||||||
|
* Pure: no clock, no I/O. It executes nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
export type Comparison = 'at-least' | 'at-most' | 'equals'
|
||||||
|
|
||||||
|
export interface Assumption {
|
||||||
|
id: string
|
||||||
|
/** What the plan assumed, in a phrase. */
|
||||||
|
description: string
|
||||||
|
/** The condition being read, e.g. "disk free bytes". */
|
||||||
|
condition: string
|
||||||
|
comparison: Comparison
|
||||||
|
/** The value the plan needs. */
|
||||||
|
required: number | string
|
||||||
|
/**
|
||||||
|
* What was actually measured. `null` when the condition has not been
|
||||||
|
* re-read — never treated as satisfied.
|
||||||
|
*/
|
||||||
|
observed: number | string | null
|
||||||
|
/** Where the observation came from. */
|
||||||
|
where: string
|
||||||
|
/**
|
||||||
|
* What to do if this assumption fails. A fallback without `floor` is a
|
||||||
|
* fallback that cannot fail.
|
||||||
|
*/
|
||||||
|
fallback?: Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Fallback {
|
||||||
|
description: string
|
||||||
|
/**
|
||||||
|
* The point below which the fallback is no longer acceptable and the plan
|
||||||
|
* must stop instead of degrading further.
|
||||||
|
*/
|
||||||
|
floor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function holds(a: Assumption): boolean | null {
|
||||||
|
if (a.observed === null) return null
|
||||||
|
if (a.comparison === 'equals') return a.observed === a.required
|
||||||
|
if (typeof a.observed !== 'number' || typeof a.required !== 'number') {
|
||||||
|
// A non-numeric value cannot be ordered. Reported as unknown rather than
|
||||||
|
// guessed — a string comparison here would silently pass or fail on
|
||||||
|
// lexical order, which is not what anyone meant.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return a.comparison === 'at-least' ? a.observed >= a.required : a.observed <= a.required
|
||||||
|
}
|
||||||
|
|
||||||
|
function assumptionEvidence(a: Assumption) {
|
||||||
|
return typeof a.observed === 'number'
|
||||||
|
? evidence.measurement(a.condition, a.observed, 1, { note: a.where })
|
||||||
|
: evidence.file(a.where, undefined, String(a.observed ?? 'not observed'), a.condition)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingFor(a: Assumption): Finding {
|
||||||
|
const ok = holds(a)
|
||||||
|
const want = `${a.comparison.replace('-', ' ')} ${a.required}`
|
||||||
|
|
||||||
|
if (ok === null) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
summary: `${a.description} — ${a.condition} was not re-measured`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: a.observed === null
|
||||||
|
? 'Replanning against a condition nobody re-read produces a confident decision from an old fact. "We did not look" is not "it is fine".'
|
||||||
|
: `"${a.observed}" cannot be ordered against "${a.required}". Reported as unknown rather than compared lexically, which is not what the plan meant.`,
|
||||||
|
evidence: [assumptionEvidence(a)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
summary: `${a.description} — ${a.condition} is ${a.observed}, needs ${want}`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: [assumptionEvidence(a)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.fallback && !a.fallback.floor) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
summary: `${a.description} BROKEN — fallback "${a.fallback.description}" has no floor`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: 'A fallback without a floor is a fallback that cannot fail. State the point below which degrading further is unacceptable, or this is a way of never stopping.',
|
||||||
|
evidence: [assumptionEvidence(a)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.fallback) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
summary: `${a.description} BROKEN — falling back: ${a.fallback.description}`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'high',
|
||||||
|
detail: `${a.condition} is ${a.observed}, needs ${want}. Floor: ${a.fallback.floor}.`,
|
||||||
|
evidence: [assumptionEvidence(a)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
summary: `${a.description} BROKEN — no fallback defined`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: `${a.condition} is ${a.observed}, needs ${want}. The plan has no alternative for this, so it stops here rather than proceeding on an assumption known to be false.`,
|
||||||
|
evidence: [assumptionEvidence(a)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
name: string
|
||||||
|
assumptions: Assumption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Decision = 'proceed' | 'pivot' | 'stop' | 'hold'
|
||||||
|
|
||||||
|
export interface Replan {
|
||||||
|
decision: Decision
|
||||||
|
reason: string
|
||||||
|
/** Assumptions that no longer hold. */
|
||||||
|
broken: Assumption[]
|
||||||
|
/** Assumptions nobody re-measured. */
|
||||||
|
unverified: Assumption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide what to do with a plan given the current readings.
|
||||||
|
*
|
||||||
|
* `hold` exists so that "we do not know" has an outcome distinct from both
|
||||||
|
* "carry on" and "abandon". Collapsing it into either is the decision this
|
||||||
|
* library is meant to prevent — proceeding on unread conditions, or stopping
|
||||||
|
* a viable rollout because one probe was missing.
|
||||||
|
*/
|
||||||
|
export function replan(plan: Plan): Replan {
|
||||||
|
const list = plan.assumptions ?? []
|
||||||
|
const broken = list.filter(a => holds(a) === false)
|
||||||
|
const unverified = list.filter(a => holds(a) === null)
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return {
|
||||||
|
decision: 'hold',
|
||||||
|
reason: 'The plan records no assumptions, so there is nothing to re-check. A plan that cannot be invalidated cannot be trusted to still hold.',
|
||||||
|
broken, unverified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unrecoverable = broken.filter(a => !a.fallback || !a.fallback.floor)
|
||||||
|
if (unrecoverable.length > 0) {
|
||||||
|
return {
|
||||||
|
decision: 'stop',
|
||||||
|
reason: `${unrecoverable.length} broken assumption(s) with no usable fallback: ${unrecoverable.map(a => a.id).join(', ')}.`,
|
||||||
|
broken, unverified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (broken.length > 0) {
|
||||||
|
return {
|
||||||
|
decision: 'pivot',
|
||||||
|
reason: `${broken.length} assumption(s) broken, each with a fallback and a floor: ${broken.map(a => `${a.id} → ${a.fallback!.description}`).join('; ')}.`,
|
||||||
|
broken, unverified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unverified.length > 0) {
|
||||||
|
return {
|
||||||
|
decision: 'hold',
|
||||||
|
reason: `${unverified.length} assumption(s) unverified: ${unverified.map(a => a.id).join(', ')}. Proceeding would be a decision taken on unread conditions.`,
|
||||||
|
broken, unverified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { decision: 'proceed', reason: `All ${list.length} assumption(s) re-checked and holding.`, broken, unverified }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report the plan's standing against current conditions. */
|
||||||
|
export function toReport(plan: Plan): CapabilityReport {
|
||||||
|
const list = plan.assumptions ?? []
|
||||||
|
const d = replan(plan)
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return {
|
||||||
|
capability: 'PivotOps',
|
||||||
|
scope: `${plan.name} — no assumptions recorded`,
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: [d.reason],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability: 'PivotOps',
|
||||||
|
scope: `${plan.name} — ${list.length} assumption(s)`,
|
||||||
|
examined: list.length,
|
||||||
|
findings: list.map(findingFor),
|
||||||
|
notes: [`decision: ${d.decision.toUpperCase()} — ${d.reason}`],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a replan for a terminal, CI log or operations review. */
|
||||||
|
export function formatReplan(plan: Plan, opts: FormatOptions = {}): string {
|
||||||
|
return renderReport(toReport(plan), opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import { formatReplan, replan, toReport, type Assumption, type Plan } from '../src/index.js'
|
||||||
|
|
||||||
|
const disk = (p: Partial<Assumption> = {}): Assumption => ({
|
||||||
|
id: 'disk-headroom',
|
||||||
|
description: 'the host has room for the new image',
|
||||||
|
condition: 'disk free bytes',
|
||||||
|
comparison: 'at-least',
|
||||||
|
required: 5_000_000_000,
|
||||||
|
observed: 20_000_000_000,
|
||||||
|
where: 'df -P /var/lib/docker',
|
||||||
|
...p,
|
||||||
|
})
|
||||||
|
|
||||||
|
const plan = (assumptions: Assumption[]): Plan => ({ name: 'suite rollout', assumptions })
|
||||||
|
|
||||||
|
describe('a plan that records no assumptions cannot be invalidated', () => {
|
||||||
|
it('holds rather than proceeding', () => {
|
||||||
|
const d = replan(plan([]))
|
||||||
|
expect(d.decision).toBe('hold')
|
||||||
|
expect(d.reason).toContain('cannot be invalidated cannot be trusted')
|
||||||
|
expect(overall(toReport(plan([])))).toBe('not-assessed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unread is not fine', () => {
|
||||||
|
it('holds — not proceeds — when a condition was never re-measured', () => {
|
||||||
|
const d = replan(plan([disk(), disk({ id: 'registry', observed: null })]))
|
||||||
|
expect(d.decision).toBe('hold')
|
||||||
|
expect(d.reason).toContain('decision taken on unread conditions')
|
||||||
|
expect(d.unverified.map(a => a.id)).toEqual(['registry'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the unread condition as not-assessed', () => {
|
||||||
|
const r = toReport(plan([disk({ observed: null })]))
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.findings[0]!.detail).toContain('"We did not look" is not "it is fine"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to order non-numeric values lexically', () => {
|
||||||
|
// A string comparison would silently pass or fail on lexical order,
|
||||||
|
// which is not what the plan meant.
|
||||||
|
const r = toReport(plan([disk({
|
||||||
|
condition: 'window state', comparison: 'at-least',
|
||||||
|
required: 'open', observed: 'closing',
|
||||||
|
})]))
|
||||||
|
expect(r.findings[0]!.determination).toBe('not-assessed')
|
||||||
|
expect(r.findings[0]!.detail).toContain('rather than compared lexically')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compares equality on non-numeric values happily', () => {
|
||||||
|
const ok = disk({ condition: 'window', comparison: 'equals', required: 'open', observed: 'open' })
|
||||||
|
expect(replan(plan([ok])).decision).toBe('proceed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a fallback without a floor cannot fail', () => {
|
||||||
|
const broken = disk({ observed: 1_000_000_000 })
|
||||||
|
|
||||||
|
it('stops on a broken assumption whose fallback has no floor', () => {
|
||||||
|
const d = replan(plan([{ ...broken, fallback: { description: 'reduce concurrency and continue' } }]))
|
||||||
|
expect(d.decision).toBe('stop')
|
||||||
|
const r = toReport(plan([{ ...broken, fallback: { description: 'reduce concurrency and continue' } }]))
|
||||||
|
expect(r.findings[0]!.severity).toBe('critical')
|
||||||
|
expect(r.findings[0]!.detail).toContain('a way of never stopping')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pivots when the fallback states a floor', () => {
|
||||||
|
const d = replan(plan([{
|
||||||
|
...broken,
|
||||||
|
fallback: { description: 'reduce concurrency to 1', floor: 'stop below 500 MB free' },
|
||||||
|
}]))
|
||||||
|
expect(d.decision).toBe('pivot')
|
||||||
|
expect(d.reason).toContain('reduce concurrency to 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops on a broken assumption with no fallback at all', () => {
|
||||||
|
const d = replan(plan([broken]))
|
||||||
|
expect(d.decision).toBe('stop')
|
||||||
|
expect(toReport(plan([broken])).findings[0]!.detail)
|
||||||
|
.toContain('rather than proceeding on an assumption known to be false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a broken assumption outranks an unread one in the decision', () => {
|
||||||
|
// Stop is more urgent than hold.
|
||||||
|
const d = replan(plan([broken, disk({ id: 'x', observed: null })]))
|
||||||
|
expect(d.decision).toBe('stop')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('proceed', () => {
|
||||||
|
it('proceeds only when every assumption was re-checked and holds', () => {
|
||||||
|
const d = replan(plan([disk(), disk({ id: 'registry', condition: 'registry reachable', comparison: 'equals', required: 'yes', observed: 'yes' })]))
|
||||||
|
expect(d.decision).toBe('proceed')
|
||||||
|
expect(overall(toReport(plan([disk()])))).toBe('pass')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('honours at-most comparisons', () => {
|
||||||
|
expect(replan(plan([disk({ comparison: 'at-most', required: 100, observed: 50 })])).decision).toBe('proceed')
|
||||||
|
expect(replan(plan([disk({ comparison: 'at-most', required: 100, observed: 150 })])).decision).toBe('stop')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('prints the decision in the output', () => {
|
||||||
|
expect(formatReplan(plan([disk()]))).toContain('decision: PROCEED')
|
||||||
|
expect(formatReplan(plan([disk({ observed: 1 })]))).toContain('decision: STOP')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
expect(validateReport(toReport(plan([disk(), disk({ id: 'b', observed: 1 })])))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the reading into the citation as a measurement', () => {
|
||||||
|
expect(formatReplan(plan([disk()]), { evidence: 'inline' })).toContain('disk free bytes = 20000000000')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist-cjs",
|
||||||
|
"declaration": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user