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