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,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.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.1 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `compare()` ranks build, hosted and self-hosted options by total cost over a
|
||||||
|
horizon of several years.
|
||||||
|
- Blockers are checked before any arithmetic. A blocked option is reported as
|
||||||
|
not applicable, never as a priced one.
|
||||||
|
- A self-hosted option with no listed operational surface is not assessed.
|
||||||
|
- A missing operating cost makes an option uncostable, never free, and a
|
||||||
|
recommendation made while anything is unestimated is labelled provisional.
|
||||||
|
- `toReport()` and `formatComparison()` produce and render a report in the
|
||||||
|
`@extant2000/evidence-record` format.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/build-or-buy.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||||
|
that fails without your fix, and check that it does fail before you submit.
|
||||||
|
- Measure, do not assume. Two modules with the same line count can be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||||
|
- Explain why in comments, not what.
|
||||||
|
|
||||||
|
## 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,90 @@
|
|||||||
|
# BuildOrBuy
|
||||||
|
|
||||||
|
Compares building a capability against adopting one over a multi-year
|
||||||
|
horizon, and checks the constraints that disqualify an option before it does
|
||||||
|
any arithmetic.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
Build-or-buy decisions are usually made as build days against a licence fee.
|
||||||
|
That framing hides the three things that most often decide the answer, so
|
||||||
|
this library models each of them.
|
||||||
|
|
||||||
|
### A prohibited option is not an expensive one
|
||||||
|
|
||||||
|
Suppose a hosted vendor is cheaper, but its terms of service forbid the use
|
||||||
|
you need. No cost comparison can give the right answer, because that option
|
||||||
|
was never available. You record the constraint as a `blocker`. Blockers are
|
||||||
|
checked before any cost is computed, and a blocked option is reported as
|
||||||
|
`not-applicable`. Pricing it would imply it could win.
|
||||||
|
|
||||||
|
### Self-hosting a third-party tool is a build with a buy label
|
||||||
|
|
||||||
|
Running someone else's software yourself is not purchasing it. You take on
|
||||||
|
backups, upgrades, capacity and on-call, and none of that shows up in a
|
||||||
|
licence table. An `adopt-self-hosted` option that has not listed its
|
||||||
|
`operationalSurface` is reported as not assessed and left out of the
|
||||||
|
ranking.
|
||||||
|
|
||||||
|
### The operating cost dominates, and it is the number nobody fills in
|
||||||
|
|
||||||
|
Build cost is paid once and estimated with care. Operating cost is paid
|
||||||
|
every year, for example the upkeep on an adopted tool or a usage bill that
|
||||||
|
keeps running after the project that needed it has ended. Costs are totalled
|
||||||
|
over `horizonYears`, and `operateDaysPerYear: null` makes an option
|
||||||
|
uncostable instead of free. Treating that term as zero is how "adopt" wins
|
||||||
|
on paper.
|
||||||
|
|
||||||
|
A recommendation made while any option is still unestimated is labelled
|
||||||
|
provisional. When most of an option's cost is operating, the finding says
|
||||||
|
so.
|
||||||
|
|
||||||
|
## A cost win is not the whole decision
|
||||||
|
|
||||||
|
The result is a cost comparison, not a verdict. Some reasons to build are
|
||||||
|
not about cost: data custody, keeping a third party out of the critical
|
||||||
|
path, a licence you cannot accept. If such a reason rules an option out,
|
||||||
|
model it as a blocker so the option is removed. If it is a preference, weigh
|
||||||
|
it in the decision made around this output.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { compare, formatComparison, type Candidate } from '@extant2000/build-or-buy'
|
||||||
|
|
||||||
|
const candidates: Candidate[] = [
|
||||||
|
{ name: 'in-house build', approach: 'build',
|
||||||
|
costs: { buildDays: 20, operateDaysPerYear: 2 }, where: 'decision.md' },
|
||||||
|
{ name: 'hosted service', approach: 'buy-hosted',
|
||||||
|
costs: { buildDays: 1, operateDaysPerYear: 1, cashPerYear: 600 }, where: 'decision.md' },
|
||||||
|
{ name: 'self-hosted tool', approach: 'adopt-self-hosted',
|
||||||
|
costs: { buildDays: 3, operateDaysPerYear: 6 },
|
||||||
|
operationalSurface: ['backups', 'upgrades', 'capacity', 'on-call'], where: 'decision.md' },
|
||||||
|
{ name: 'vendor X', approach: 'buy-hosted',
|
||||||
|
costs: { buildDays: 1, operateDaysPerYear: 0, cashPerYear: 300 },
|
||||||
|
blockers: [{ reason: 'terms of service prohibit the intended use', where: 'vendor-terms.md' }],
|
||||||
|
where: 'decision.md' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const cmp = { horizonYears: 3, dayRate: 800 }
|
||||||
|
|
||||||
|
const { winner, viable, disqualified, uncostable, reason } = compare(candidates, cmp)
|
||||||
|
|
||||||
|
// A report in the @extant2000/evidence-record format, rendered as text.
|
||||||
|
console.log(formatComparison(candidates, cmp))
|
||||||
|
```
|
||||||
|
|
||||||
|
`totalCost(candidate, cmp)` returns the horizon total, or `null` when an
|
||||||
|
input is missing. `toReport()` returns the structured report that
|
||||||
|
`formatComparison()` renders. Every finding cites the `where` of its
|
||||||
|
candidate or blocker.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/build-or-buy
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue for a security problem.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1503
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/build-or-buy",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"private": false,
|
||||||
|
"description": "Compares building against buying or self-hosting over a multi-year horizon, checking disqualifying constraints before cost.",
|
||||||
|
"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/build-or-buy.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/build-or-buy",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/build-or-buy/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* BuildOrBuy — compare building a capability against adopting one, on the
|
||||||
|
* terms that actually decide it.
|
||||||
|
*
|
||||||
|
* The comparison is almost always made as build-days against licence cost,
|
||||||
|
* and that is the one framing under which the decision cannot go wrong in an
|
||||||
|
* interesting way. Three corrections:
|
||||||
|
*
|
||||||
|
* 1. **A prohibited option is not an expensive one.** When a vendor's
|
||||||
|
* terms forbid the use you need, no cost comparison can produce the
|
||||||
|
* right answer, because "buy" was never on the table. A blocker makes
|
||||||
|
* an option NOT APPLICABLE, and it is evaluated BEFORE any arithmetic.
|
||||||
|
*
|
||||||
|
* 2. **Self-hosting a third-party tool is a build, not a buy.** Running
|
||||||
|
* someone else's software is not purchasing it; it is taking on
|
||||||
|
* operating it, including capacity the host may not have, which is a
|
||||||
|
* cost that appears in no licence table. An `adopt-self-hosted` option
|
||||||
|
* that has not enumerated what it is operating reports NOT ASSESSED.
|
||||||
|
*
|
||||||
|
* 3. **The operating term dominates and is the one nobody fills in.** A
|
||||||
|
* forgotten credential or a runaway usage meter on an adopted tool
|
||||||
|
* becomes a bill nobody planned for. Build cost is paid once and
|
||||||
|
* estimated carefully; operating cost is paid every year and estimated
|
||||||
|
* never. Comparing over a horizon is the whole reason this is a
|
||||||
|
* calculation and not an opinion.
|
||||||
|
*
|
||||||
|
* Pure: no clock, no I/O.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
export type Approach =
|
||||||
|
/** Write it. All cost is yours, and so is all of the surface. */
|
||||||
|
| 'build'
|
||||||
|
/** Use a hosted third party. Their operational surface, their terms. */
|
||||||
|
| 'buy-hosted'
|
||||||
|
/** Run a third party's software yourself. A build wearing a buy's label. */
|
||||||
|
| 'adopt-self-hosted'
|
||||||
|
|
||||||
|
export interface Blocker {
|
||||||
|
/** Why this option is off the table regardless of cost. */
|
||||||
|
reason: string
|
||||||
|
where: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Costs {
|
||||||
|
/** One-off engineering days to reach parity. `null` when unestimated. */
|
||||||
|
buildDays: number | null
|
||||||
|
/** Engineering days per year to keep it working. The term nobody fills in. */
|
||||||
|
operateDaysPerYear: number | null
|
||||||
|
/** Cash per year — licence, hosting, per-seat. */
|
||||||
|
cashPerYear?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Candidate {
|
||||||
|
name: string
|
||||||
|
approach: Approach
|
||||||
|
costs: Costs
|
||||||
|
/** Hard constraints that disqualify this option before any arithmetic. */
|
||||||
|
blockers?: Blocker[]
|
||||||
|
/**
|
||||||
|
* What you take on operating. Required for `adopt-self-hosted`: an option
|
||||||
|
* that has not enumerated its surface has not been costed.
|
||||||
|
*/
|
||||||
|
operationalSurface?: string[]
|
||||||
|
where: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comparison {
|
||||||
|
/** Years to compare over. Point-in-time comparison is the original error. */
|
||||||
|
horizonYears: number
|
||||||
|
/** Fully-loaded cost of one engineering day. */
|
||||||
|
dayRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scored extends Candidate {
|
||||||
|
/** Total cost over the horizon, `null` when any input is missing. */
|
||||||
|
total: number | null
|
||||||
|
/** Disqualified before arithmetic. */
|
||||||
|
disqualified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total cost over the horizon.
|
||||||
|
*
|
||||||
|
* `null` when any input is missing, and deliberately not defaulted to zero:
|
||||||
|
* an unestimated operating cost is the single largest term in most of these
|
||||||
|
* comparisons, and treating it as free is exactly how "adopt" wins on paper.
|
||||||
|
*/
|
||||||
|
export function totalCost(c: Candidate, cmp: Comparison): number | null {
|
||||||
|
const { buildDays, operateDaysPerYear, cashPerYear } = c.costs
|
||||||
|
if (buildDays === null || operateDaysPerYear === null) return null
|
||||||
|
return buildDays * cmp.dayRate
|
||||||
|
+ operateDaysPerYear * cmp.dayRate * cmp.horizonYears
|
||||||
|
+ (cashPerYear ?? 0) * cmp.horizonYears
|
||||||
|
}
|
||||||
|
|
||||||
|
const money = (n: number): string =>
|
||||||
|
n >= 1000 ? `$${Math.round(n / 1000)}k` : `$${Math.round(n)}`
|
||||||
|
|
||||||
|
function candidateEvidence(c: Candidate) {
|
||||||
|
return evidence.file(c.where, undefined, c.approach,
|
||||||
|
c.costs.operateDaysPerYear === null
|
||||||
|
? 'operating cost not estimated'
|
||||||
|
: `${c.costs.operateDaysPerYear} day(s)/yr to operate`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingFor(c: Candidate, cmp: Comparison): Finding {
|
||||||
|
const id = c.name
|
||||||
|
|
||||||
|
// Checked first, and it is not a cost. An option that is off the table is
|
||||||
|
// not a costly one, and ranking it by price implies it could win.
|
||||||
|
if (c.blockers && c.blockers.length > 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
summary: `${c.name} is disqualified — ${c.blockers[0]!.reason}`,
|
||||||
|
determination: 'not-applicable',
|
||||||
|
severity: 'info',
|
||||||
|
detail: 'Off the table regardless of cost. A prohibited option is not an expensive one, and pricing it implies it could win.',
|
||||||
|
evidence: c.blockers.map(b => evidence.file(b.where, undefined, b.reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.approach === 'adopt-self-hosted' && (c.operationalSurface?.length ?? 0) === 0) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
summary: `${c.name} is self-hosted and has not enumerated what it operates`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Self-hosting a third-party tool is a build wearing a buy\'s label. You are not purchasing it, you are taking on operating it — list the backups, upgrades, capacity and on-call that come with it, or this option has not been costed.',
|
||||||
|
evidence: [candidateEvidence(c)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = totalCost(c, cmp)
|
||||||
|
if (total === null) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
summary: `${c.name} cannot be costed`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: c.costs.operateDaysPerYear === null
|
||||||
|
? 'Operating cost is unestimated. It is the largest term in most of these comparisons and treating it as free is exactly how "adopt" wins on paper.'
|
||||||
|
: 'Build cost is unestimated.',
|
||||||
|
evidence: [candidateEvidence(c)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operate = c.costs.operateDaysPerYear! * cmp.dayRate * cmp.horizonYears
|
||||||
|
const share = Math.round((operate / total) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
summary: `${c.name} — ${money(total)} over ${cmp.horizonYears} year(s), ${share}% of it operating`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
detail: share >= 50
|
||||||
|
? 'Most of this cost is operating, not building. That term is paid every year and estimated never.'
|
||||||
|
: undefined,
|
||||||
|
evidence: [candidateEvidence(c)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
/** Cheapest viable candidate, or `null` when none can be costed. */
|
||||||
|
winner: Scored | null
|
||||||
|
/** Ranked, cheapest first. Excludes disqualified and uncostable options. */
|
||||||
|
viable: Scored[]
|
||||||
|
/** Off the table regardless of cost. */
|
||||||
|
disqualified: Scored[]
|
||||||
|
/** Viable in principle, not costable yet. */
|
||||||
|
uncostable: Scored[]
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare candidates over a horizon and recommend one. */
|
||||||
|
export function compare(candidates: Candidate[], cmp: Comparison): Recommendation {
|
||||||
|
const scored: Scored[] = (candidates ?? []).map(c => ({
|
||||||
|
...c,
|
||||||
|
total: totalCost(c, cmp),
|
||||||
|
disqualified: (c.blockers?.length ?? 0) > 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const disqualified = scored.filter(c => c.disqualified)
|
||||||
|
const rest = scored.filter(c => !c.disqualified)
|
||||||
|
const uncostable = rest.filter(c =>
|
||||||
|
c.total === null
|
||||||
|
|| (c.approach === 'adopt-self-hosted' && (c.operationalSurface?.length ?? 0) === 0))
|
||||||
|
const viable = rest
|
||||||
|
.filter(c => !uncostable.includes(c))
|
||||||
|
.sort((a, b) => a.total! - b.total!)
|
||||||
|
|
||||||
|
if (viable.length === 0) {
|
||||||
|
return {
|
||||||
|
winner: null,
|
||||||
|
viable, disqualified, uncostable,
|
||||||
|
reason: scored.length === 0
|
||||||
|
? 'No candidates were supplied.'
|
||||||
|
: `No candidate could be costed: ${disqualified.length} disqualified, ${uncostable.length} unestimated.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const winner = viable[0]!
|
||||||
|
const runnerUp = viable[1]
|
||||||
|
const margin = runnerUp ? runnerUp.total! - winner.total! : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
winner,
|
||||||
|
viable, disqualified, uncostable,
|
||||||
|
reason: uncostable.length > 0
|
||||||
|
? `${winner.name} is cheapest of those that could be costed (${money(winner.total!)} over ${cmp.horizonYears} years), but ${uncostable.length} option(s) remain unestimated — this is a provisional answer.`
|
||||||
|
: runnerUp
|
||||||
|
? `${winner.name} at ${money(winner.total!)}, ${money(margin!)} cheaper than ${runnerUp.name} over ${cmp.horizonYears} years.`
|
||||||
|
: `${winner.name} at ${money(winner.total!)} — the only costable option.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report a build-or-buy comparison. */
|
||||||
|
export function toReport(candidates: Candidate[], cmp: Comparison): CapabilityReport {
|
||||||
|
const list = candidates ?? []
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
return {
|
||||||
|
capability: 'BuildOrBuy',
|
||||||
|
scope: 'no candidates',
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: ['No options were supplied, so nothing was compared. A decision with one option considered is not a comparison.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = compare(list, cmp)
|
||||||
|
return {
|
||||||
|
capability: 'BuildOrBuy',
|
||||||
|
scope: `${list.length} option(s) over ${cmp.horizonYears} year(s) at ${money(cmp.dayRate)}/day`,
|
||||||
|
examined: list.length,
|
||||||
|
findings: list.map(c => findingFor(c, cmp)),
|
||||||
|
notes: [
|
||||||
|
rec.winner ? `recommendation: ${rec.winner.name} — ${rec.reason}` : `no recommendation — ${rec.reason}`,
|
||||||
|
'Costed over a horizon, not at a point: the operating term is paid every year and is the one nobody fills in.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a comparison for a terminal, a memo or a decision record. */
|
||||||
|
export function formatComparison(
|
||||||
|
candidates: Candidate[],
|
||||||
|
cmp: Comparison,
|
||||||
|
opts: FormatOptions = {},
|
||||||
|
): string {
|
||||||
|
return renderReport(toReport(candidates, cmp), opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import {
|
||||||
|
compare,
|
||||||
|
formatComparison,
|
||||||
|
toReport,
|
||||||
|
totalCost,
|
||||||
|
type Candidate,
|
||||||
|
type Comparison,
|
||||||
|
} from '../src/index.js'
|
||||||
|
|
||||||
|
const cmp: Comparison = { horizonYears: 3, dayRate: 800 }
|
||||||
|
|
||||||
|
const build = (p: Partial<Candidate> = {}): Candidate => ({
|
||||||
|
name: 'in-house build',
|
||||||
|
approach: 'build',
|
||||||
|
costs: { buildDays: 20, operateDaysPerYear: 2 },
|
||||||
|
where: 'decision-record.md',
|
||||||
|
...p,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a prohibited option is not an expensive one', () => {
|
||||||
|
// A provider whose terms forbid the intended use is not a cheap option.
|
||||||
|
// No cost comparison can produce the right answer, because "buy" is not
|
||||||
|
// on the table.
|
||||||
|
const hostedProvider = build({
|
||||||
|
name: 'hosted provider',
|
||||||
|
approach: 'buy-hosted',
|
||||||
|
costs: { buildDays: 1, operateDaysPerYear: 0, cashPerYear: 600 },
|
||||||
|
blockers: [{ reason: 'terms of service prohibit the intended use', where: 'provider-terms.md' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disqualifies before any arithmetic', () => {
|
||||||
|
const r = compare([build(), hostedProvider], cmp)
|
||||||
|
expect(r.disqualified.map(c => c.name)).toEqual(['hosted provider'])
|
||||||
|
expect(r.viable.map(c => c.name)).toEqual(['in-house build'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports it as not-applicable, never as a costly option', () => {
|
||||||
|
const f = toReport([hostedProvider], cmp).findings[0]!
|
||||||
|
expect(f.determination).toBe('not-applicable')
|
||||||
|
expect(f.detail).toContain('pricing it implies it could win')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('would otherwise have been the cheapest — which is the point', () => {
|
||||||
|
// 1 day + $600/yr = $2.6k, against build's $20.8k. Cost was never the
|
||||||
|
// deciding term.
|
||||||
|
const { blockers, ...unblocked } = hostedProvider
|
||||||
|
expect(totalCost(unblocked as Candidate, cmp)!).toBeLessThan(totalCost(build(), cmp)!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cites the terms that disqualified it', () => {
|
||||||
|
expect(formatComparison([hostedProvider], cmp, { evidence: 'full' })).toContain('provider-terms.md')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('self-hosting a third-party tool is a build wearing a buy label', () => {
|
||||||
|
const selfHosted = build({
|
||||||
|
name: 'self-hosted tool',
|
||||||
|
approach: 'adopt-self-hosted',
|
||||||
|
costs: { buildDays: 3, operateDaysPerYear: 6 },
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports not-assessed when the operational surface is not enumerated', () => {
|
||||||
|
const f = toReport([selfHosted], cmp).findings[0]!
|
||||||
|
expect(f.determination).toBe('not-assessed')
|
||||||
|
expect(f.detail).toContain('taking on operating it')
|
||||||
|
expect(overall(toReport([selfHosted], cmp))).toBe('not-assessed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes it from the ranking until it is enumerated', () => {
|
||||||
|
const r = compare([build(), selfHosted], cmp)
|
||||||
|
expect(r.uncostable.map(c => c.name)).toEqual(['self-hosted tool'])
|
||||||
|
expect(r.viable.map(c => c.name)).toEqual(['in-house build'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('costs it once the surface is listed', () => {
|
||||||
|
const listed = { ...selfHosted, operationalSurface: ['backups', 'upgrades', 'capacity', 'on-call'] }
|
||||||
|
const r = compare([build(), listed], cmp)
|
||||||
|
expect(r.uncostable).toHaveLength(0)
|
||||||
|
expect(r.viable).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not demand a surface from a hosted buy', () => {
|
||||||
|
const hosted = build({ name: 'hosted', approach: 'buy-hosted', costs: { buildDays: 1, operateDaysPerYear: 0, cashPerYear: 1200 } })
|
||||||
|
expect(toReport([hosted], cmp).findings[0]!.determination).toBe('pass')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the operating term is the one nobody fills in', () => {
|
||||||
|
it('refuses to treat an unestimated operating cost as zero', () => {
|
||||||
|
// Treating it as free is exactly how "adopt" wins on paper.
|
||||||
|
const vague = build({ name: 'vague', costs: { buildDays: 5, operateDaysPerYear: null } })
|
||||||
|
expect(totalCost(vague, cmp)).toBeNull()
|
||||||
|
const f = toReport([vague], cmp).findings[0]!
|
||||||
|
expect(f.determination).toBe('not-assessed')
|
||||||
|
expect(f.detail).toContain('how "adopt" wins on paper')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('costs over the horizon, not at a point', () => {
|
||||||
|
// 20 build days + 2 op days/yr x 3 yrs = 26 days = $20,800
|
||||||
|
expect(totalCost(build(), cmp)).toBe(20_800)
|
||||||
|
expect(totalCost(build(), { ...cmp, horizonYears: 1 })).toBe(17_600)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags when most of the cost is operating', () => {
|
||||||
|
const opHeavy = build({ name: 'op-heavy', costs: { buildDays: 2, operateDaysPerYear: 10 } })
|
||||||
|
const f = toReport([opHeavy], cmp).findings[0]!
|
||||||
|
expect(f.summary).toContain('% of it operating')
|
||||||
|
expect(f.detail).toContain('paid every year and estimated never')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes cash cost across the horizon', () => {
|
||||||
|
const paid = build({ costs: { buildDays: 0, operateDaysPerYear: 0, cashPerYear: 1000 } })
|
||||||
|
expect(totalCost(paid, cmp)).toBe(3000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recommendation', () => {
|
||||||
|
it('names the margin over the runner-up', () => {
|
||||||
|
const r = compare([
|
||||||
|
build(),
|
||||||
|
build({ name: 'cheap', costs: { buildDays: 1, operateDaysPerYear: 1 } }),
|
||||||
|
], cmp)
|
||||||
|
expect(r.winner!.name).toBe('cheap')
|
||||||
|
expect(r.reason).toContain('cheaper than in-house build')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls the answer provisional while anything is unestimated', () => {
|
||||||
|
const r = compare([build(), build({ name: 'unknown', costs: { buildDays: null, operateDaysPerYear: null } })], cmp)
|
||||||
|
expect(r.reason).toContain('this is a provisional answer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes no recommendation when nothing can be costed', () => {
|
||||||
|
const r = compare([build({ costs: { buildDays: null, operateDaysPerYear: null } })], cmp)
|
||||||
|
expect(r.winner).toBeNull()
|
||||||
|
expect(r.reason).toContain('unestimated')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('no candidates is not-assessed, and says why', () => {
|
||||||
|
expect(overall(toReport([], cmp))).toBe('not-assessed')
|
||||||
|
expect(formatComparison([], cmp))
|
||||||
|
.toContain('A decision with one option considered is not a comparison')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
expect(validateReport(toReport([
|
||||||
|
build(),
|
||||||
|
build({ name: 'x', approach: 'adopt-self-hosted', operationalSurface: ['backups'] }),
|
||||||
|
], cmp))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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