First public release

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 14:49:00 -04:00
co-authored by Claude Opus 5.5
commit e83b6b08ff
14 changed files with 2158 additions and 0 deletions
+12
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
*.log
.DS_Store
.env*
!.env.example
.npmrc
dist-cjs/
+6
View File
@@ -0,0 +1,6 @@
stages: [test]
test:
stage: test
image: node:22-alpine
script: [npm ci, npm run build, npm test]
+15
View File
@@ -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.
+22
View File
@@ -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
```
+21
View File
@@ -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.
+90
View File
@@ -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).
+6
View File
@@ -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.
+1503
View File
File diff suppressed because it is too large Load Diff
+46
View File
@@ -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
View File
@@ -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)
}
+154
View File
@@ -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([])
})
})
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"outDir": "dist-cjs",
"declaration": false
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
"declaration": true, "outDir": "dist", "rootDir": "src",
"strict": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}