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.
|
||||||
|
|
||||||
|
- `rank()` orders capabilities by readiness gained per unit of effort,
|
||||||
|
`(100 - score) × weight ÷ effort`, not by score.
|
||||||
|
- Unassessed capabilities (`score: null`) are listed separately and never
|
||||||
|
ranked.
|
||||||
|
- `effort: 'unknown'` weighs the same as `medium`.
|
||||||
|
- Every entry carries its binding constraint and next action; an entry with
|
||||||
|
neither is reported as "a position, not advice".
|
||||||
|
- `rankingTable()` prints in rank order; `toReport()` and `formatRanking()`
|
||||||
|
produce an evidence-record report.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/portfolio-advisor.
|
||||||
|
|
||||||
|
## 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,97 @@
|
|||||||
|
# PortfolioAdvisor
|
||||||
|
|
||||||
|
Rank a portfolio of capabilities by transition readiness, and say what would
|
||||||
|
move each one.
|
||||||
|
|
||||||
|
PortfolioAdvisor takes a list of scored capabilities and returns them ordered
|
||||||
|
by the best next move. It is designed to consume
|
||||||
|
`@extant2000/sustain-score` output, but it takes a plain shape, so any source
|
||||||
|
of scores can feed it. It is pure, with no clock and no I/O.
|
||||||
|
|
||||||
|
## What it is built against
|
||||||
|
|
||||||
|
A bad ranking is not the main risk. The risk is a ranking that reads as
|
||||||
|
authoritative when the numbers behind it are not comparable. There are three
|
||||||
|
ways that happens.
|
||||||
|
|
||||||
|
1. Unassessed capabilities sort as bad. A capability nobody scored lands at
|
||||||
|
the bottom next to one that was scored and found weak. Those are opposite
|
||||||
|
situations: one needs work, the other needs an assessment. A single
|
||||||
|
ordered list cannot hold both, so unassessed capabilities are listed
|
||||||
|
separately and never interleaved. A `null` score is not zero.
|
||||||
|
2. A composite hides its binding constraint. "Reports: 6" tells you where
|
||||||
|
something sits, not what to do. Every entry carries its binding
|
||||||
|
constraint and next action. An entry with neither is reported as "a
|
||||||
|
position, not advice".
|
||||||
|
3. Effort is invisible in a score. Two capabilities at 45, one needing a key
|
||||||
|
rotation and one needing a rewrite, are not equally worth doing next.
|
||||||
|
|
||||||
|
So the ranking is by readiness gained per unit of effort, not by score.
|
||||||
|
Each entry gets a `leverage` value:
|
||||||
|
|
||||||
|
```
|
||||||
|
(100 - score) × weight ÷ effort
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the headroom left, scaled by how much the capability matters, divided
|
||||||
|
by what the work costs. Effort weights are `trivial` 1, `small` 2, `medium` 4
|
||||||
|
and `large` 8. `unknown` counts as `medium`, not `trivial`. An unestimated
|
||||||
|
task is not a cheap one, and defaulting optimistically is how an unscoped item
|
||||||
|
reaches the top of a plan. Only a capability scoring 90 or more passes; the
|
||||||
|
rest are reported as work outstanding.
|
||||||
|
|
||||||
|
## Two orders
|
||||||
|
|
||||||
|
`formatRanking()` prints the ranked table first and then the findings
|
||||||
|
worst-first. The two answer different questions ("what should I do next" and
|
||||||
|
"what is most wrong"), and they are not the same order. A ranking whose
|
||||||
|
display order contradicts its own numbering is worse than no numbering, so the
|
||||||
|
table always prints #1 first.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { rank, rankingTable, formatRanking, type CapabilityStanding } from '@extant2000/portfolio-advisor'
|
||||||
|
|
||||||
|
const portfolio: CapabilityStanding[] = [
|
||||||
|
{ name: 'docs', score: 90, effort: 'small', bindingConstraint: 'none material' },
|
||||||
|
{ name: 'gateway', score: 85, effort: 'small', bindingConstraint: 'retry fix unmerged', nextAction: 'merge the retry fix' },
|
||||||
|
{ name: 'reports', score: 60, effort: 'large', bindingConstraint: 'scheduled exports promised, nothing runs them', nextAction: 'build the scheduler' },
|
||||||
|
{ name: 'billing', score: 80, effort: 'trivial', bindingConstraint: 'debug logging left on', nextAction: 'turn off debug logging' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { ranked, unassessed } = rank(portfolio)
|
||||||
|
console.log(rankingTable(portfolio))
|
||||||
|
|
||||||
|
// Table plus the evidence-record findings
|
||||||
|
console.log(formatRanking(portfolio))
|
||||||
|
```
|
||||||
|
|
||||||
|
`toReport(portfolio)` returns a `CapabilityReport` from
|
||||||
|
`@extant2000/evidence-record`. Each entry can also carry a `weight` (default
|
||||||
|
1) and a `where` that is cited as the source of the score.
|
||||||
|
|
||||||
|
## Sample output
|
||||||
|
|
||||||
|
Produced by running `rankingTable()` on the example above:
|
||||||
|
|
||||||
|
```
|
||||||
|
#1 billing 80/100 leverage 20 turn off debug logging
|
||||||
|
#2 gateway 85/100 leverage 7.5 merge the retry fix
|
||||||
|
#3 reports 60/100 leverage 5 build the scheduler
|
||||||
|
#4 docs 90/100 leverage 5 none material
|
||||||
|
```
|
||||||
|
|
||||||
|
The weakest capability is third. Ordering by score alone would have put the
|
||||||
|
large rebuild ahead of a change that is close to free. A capability with
|
||||||
|
`score: null` would appear below the table, unnumbered, as unassessed.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/portfolio-advisor
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/portfolio-advisor",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Ranks in-flight capabilities by transition readiness.",
|
||||||
|
"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/portfolio-advisor.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/portfolio-advisor",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/portfolio-advisor/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* PortfolioAdvisor — rank capabilities by transition readiness, and say what
|
||||||
|
* would move each one.
|
||||||
|
*
|
||||||
|
* The failure this is built against is not a bad ranking. It is a ranking
|
||||||
|
* that reads as authoritative when the numbers behind it are not comparable:
|
||||||
|
*
|
||||||
|
* 1. **Unassessed capabilities sort as bad.** A capability nobody scored
|
||||||
|
* lands at the bottom next to one that was scored and found weak. Those
|
||||||
|
* are opposite situations — one needs work, the other needs an
|
||||||
|
* assessment — and a single ordered list cannot hold both. Unassessed
|
||||||
|
* items are therefore ranked SEPARATELY and never interleaved.
|
||||||
|
*
|
||||||
|
* 2. **A composite hides its own binding constraint.** "Reports: 6" tells you
|
||||||
|
* where it sits, not what to do. A ranking that cannot name the one
|
||||||
|
* thing holding each capability down is a leaderboard, not advice.
|
||||||
|
*
|
||||||
|
* 3. **Effort is invisible in a score.** The capability at 45 that needs a
|
||||||
|
* key rotation and the one at 45 that needs a rewrite are not equally
|
||||||
|
* worth doing next. Ordering by score alone systematically recommends
|
||||||
|
* the expensive one.
|
||||||
|
*
|
||||||
|
* Designed to consume `@extant2000/sustain-score` output, but takes a plain
|
||||||
|
* shape so anything can feed it.
|
||||||
|
*
|
||||||
|
* Pure: no clock, no I/O.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
/** How much work the binding constraint represents. */
|
||||||
|
export type Effort = 'trivial' | 'small' | 'medium' | 'large' | 'unknown'
|
||||||
|
|
||||||
|
const EFFORT_WEIGHT: Record<Effort, number> = {
|
||||||
|
trivial: 1,
|
||||||
|
small: 2,
|
||||||
|
medium: 4,
|
||||||
|
large: 8,
|
||||||
|
// Unknown effort is weighted as medium rather than as trivial: an
|
||||||
|
// unestimated task is not a cheap one, and defaulting optimistically is
|
||||||
|
// how an unscoped item reaches the top of a plan.
|
||||||
|
unknown: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityStanding {
|
||||||
|
name: string
|
||||||
|
/**
|
||||||
|
* Readiness 0–100, or `null` when the capability was never assessed.
|
||||||
|
* Null is NOT zero — see `rank()`.
|
||||||
|
*/
|
||||||
|
score: number | null
|
||||||
|
/** The single thing holding it down. */
|
||||||
|
bindingConstraint?: string
|
||||||
|
/** The highest-leverage next action. */
|
||||||
|
nextAction?: string
|
||||||
|
effort?: Effort
|
||||||
|
/** Strategic weight: how much this capability matters to the portfolio. */
|
||||||
|
weight?: number
|
||||||
|
/** Where the score came from — a scorecard, a report, a run. */
|
||||||
|
where?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ranked extends Omit<CapabilityStanding, 'score'> {
|
||||||
|
/** Never null: an unassessed capability is not ranked at all. */
|
||||||
|
score: number
|
||||||
|
/** Readiness gain per unit of effort. Higher is a better next move. */
|
||||||
|
leverage: number
|
||||||
|
rank: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Ranking {
|
||||||
|
/** Assessed capabilities, best next move first. */
|
||||||
|
ranked: Ranked[]
|
||||||
|
/** Never assessed. Listed separately, never interleaved. */
|
||||||
|
unassessed: CapabilityStanding[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank by leverage: how much readiness a unit of effort buys.
|
||||||
|
*
|
||||||
|
* `(100 - score) * weight / effort` — the headroom that remains, scaled by
|
||||||
|
* how much the capability matters, divided by what it costs. A capability
|
||||||
|
* already at 90 has little headroom left however cheap the work is, and a
|
||||||
|
* large task on a low-weight capability sinks even with lots of headroom.
|
||||||
|
*
|
||||||
|
* Unassessed capabilities are NOT ranked. They have no score to compute
|
||||||
|
* headroom from, and slotting them in at zero would put "we never looked at
|
||||||
|
* this" above "we looked and it is genuinely weak" — which is exactly
|
||||||
|
* backwards as advice.
|
||||||
|
*/
|
||||||
|
export function rank(standings: CapabilityStanding[]): Ranking {
|
||||||
|
const list = standings ?? []
|
||||||
|
const unassessed = list.filter(c => c.score === null)
|
||||||
|
const assessed = list.filter((c): c is CapabilityStanding & { score: number } => c.score !== null)
|
||||||
|
|
||||||
|
const ranked = assessed
|
||||||
|
.map(c => {
|
||||||
|
const effort = EFFORT_WEIGHT[c.effort ?? 'unknown']
|
||||||
|
const weight = c.weight ?? 1
|
||||||
|
return { ...c, leverage: Math.round(((100 - c.score) * weight / effort) * 10) / 10 }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.leverage - a.leverage || a.score - b.score || a.name.localeCompare(b.name))
|
||||||
|
.map((c, i) => ({ ...c, rank: i + 1 }))
|
||||||
|
|
||||||
|
return { ranked, unassessed }
|
||||||
|
}
|
||||||
|
|
||||||
|
function standingEvidence(c: CapabilityStanding) {
|
||||||
|
return c.where
|
||||||
|
? evidence.measurement(`${c.name} readiness`, c.score, c.score === null ? 0 : 1, { note: c.where })
|
||||||
|
: evidence.measurement(`${c.name} readiness`, c.score, c.score === null ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a report from a ranking.
|
||||||
|
*
|
||||||
|
* Every entry carries its binding constraint, because a ranking that cannot
|
||||||
|
* say what is holding each capability down is a leaderboard rather than
|
||||||
|
* advice. An entry with no constraint recorded says so rather than appearing
|
||||||
|
* complete.
|
||||||
|
*/
|
||||||
|
export function toReport(standings: CapabilityStanding[]): CapabilityReport {
|
||||||
|
const list = standings ?? []
|
||||||
|
if (list.length === 0) {
|
||||||
|
return {
|
||||||
|
capability: 'PortfolioAdvisor',
|
||||||
|
scope: 'no capabilities supplied',
|
||||||
|
examined: 0,
|
||||||
|
findings: [],
|
||||||
|
notes: ['Nothing was supplied to rank.'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ranked, unassessed } = rank(list)
|
||||||
|
|
||||||
|
const rankedFindings: Finding[] = ranked.map(c => ({
|
||||||
|
id: c.name,
|
||||||
|
summary: `#${c.rank} ${c.name} — ${c.score}/100, leverage ${c.leverage}${c.effort ? ` (${c.effort} effort)` : ''}`,
|
||||||
|
// A ranking entry is advice, not a verdict on the capability. Only a
|
||||||
|
// genuinely finished capability passes; everything else is work
|
||||||
|
// outstanding, which is a `fail` against the readiness criterion.
|
||||||
|
determination: c.score >= 90 ? 'pass' : 'fail',
|
||||||
|
severity: c.score < 40 ? 'critical' : c.score < 70 ? 'high' : 'info',
|
||||||
|
detail: c.bindingConstraint
|
||||||
|
? `Binding constraint: ${c.bindingConstraint}${c.nextAction ? ` → ${c.nextAction}` : ''}`
|
||||||
|
: 'No binding constraint recorded. A rank without one is a position, not advice — it does not say what to do.',
|
||||||
|
evidence: [standingEvidence(c)],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unassessedFindings: Finding[] = unassessed.map(c => ({
|
||||||
|
id: c.name,
|
||||||
|
summary: `${c.name} has never been assessed`,
|
||||||
|
determination: 'not-assessed',
|
||||||
|
severity: 'high',
|
||||||
|
detail: 'Not ranked. An unassessed capability has no headroom to compute, and placing it at zero would rank "nobody looked" above "we looked and it is weak" — backwards, as advice.',
|
||||||
|
evidence: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability: 'PortfolioAdvisor',
|
||||||
|
scope: `${list.length} capabilit${list.length === 1 ? 'y' : 'ies'}`,
|
||||||
|
examined: list.length,
|
||||||
|
findings: [...unassessedFindings, ...rankedFindings],
|
||||||
|
notes: [
|
||||||
|
`${ranked.length} ranked by leverage (headroom x weight / effort), ${unassessed.length} unassessed and excluded from the ranking`,
|
||||||
|
ranked.length > 0
|
||||||
|
? `best next move: ${ranked[0]!.name} — ${ranked[0]!.nextAction ?? 'no action recorded'}`
|
||||||
|
: 'no capability could be ranked',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ranking itself, in rank order.
|
||||||
|
*
|
||||||
|
* Printed separately from the findings because the shared renderer sorts
|
||||||
|
* worst-first, which is right for findings and wrong for a ranked list — it
|
||||||
|
* would show #2 above #1 whenever #2 scored lower. A ranking whose display
|
||||||
|
* order contradicts its own numbering is worse than no numbering.
|
||||||
|
*/
|
||||||
|
export function rankingTable(standings: CapabilityStanding[]): string {
|
||||||
|
const { ranked, unassessed } = rank(standings ?? [])
|
||||||
|
const out: string[] = []
|
||||||
|
|
||||||
|
for (const c of ranked) {
|
||||||
|
const action = c.nextAction ?? c.bindingConstraint ?? 'no action recorded'
|
||||||
|
out.push(` #${String(c.rank).padEnd(2)} ${c.name.padEnd(16)} ${String(c.score).padStart(3)}/100 leverage ${String(c.leverage).padStart(5)} ${action}`)
|
||||||
|
}
|
||||||
|
for (const c of unassessed) {
|
||||||
|
out.push(` -- ${c.name.padEnd(16)} —/100 ${'unassessed'.padStart(14)} no score to rank from`)
|
||||||
|
}
|
||||||
|
return out.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a portfolio ranking for a terminal or a briefing.
|
||||||
|
*
|
||||||
|
* The rank-ordered table first, then the findings worst-first. They answer
|
||||||
|
* different questions — "what should I do next" and "what is most wrong" —
|
||||||
|
* and they are not the same order.
|
||||||
|
*/
|
||||||
|
export function formatRanking(standings: CapabilityStanding[], opts: FormatOptions = {}): string {
|
||||||
|
const table = rankingTable(standings)
|
||||||
|
const report = renderReport(toReport(standings), opts)
|
||||||
|
return table ? `${table}\n\n${report}` : report
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EFFORT_WEIGHT }
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import { formatRanking, rank, toReport, type CapabilityStanding } from '../src/index.js'
|
||||||
|
|
||||||
|
// An example portfolio of four services, scored 0-100.
|
||||||
|
const suite: CapabilityStanding[] = [
|
||||||
|
{ name: 'docs', score: 90, effort: 'small', bindingConstraint: 'none material', where: 'quarterly scorecard' },
|
||||||
|
{ name: 'gateway', score: 85, effort: 'small', bindingConstraint: 'retry fix written but unmerged', nextAction: 'merge the retry fix', where: 'quarterly scorecard' },
|
||||||
|
{ name: 'reports', score: 60, effort: 'large', bindingConstraint: 'scheduled exports promised, nothing runs them', nextAction: 'build the scheduler', where: 'quarterly scorecard' },
|
||||||
|
{ name: 'billing', score: 80, effort: 'trivial', bindingConstraint: 'debug logging left on', nextAction: 'turn off debug logging', where: 'quarterly scorecard' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('unassessed is not the bottom of the list', () => {
|
||||||
|
it('ranks unassessed capabilities separately, never interleaved', () => {
|
||||||
|
// "Nobody looked at this" and "we looked and it is weak" are opposite
|
||||||
|
// situations. One ordered list cannot hold both.
|
||||||
|
const r = rank([...suite, { name: 'newservice', score: null }])
|
||||||
|
expect(r.ranked.map(c => c.name)).not.toContain('newservice')
|
||||||
|
expect(r.unassessed.map(c => c.name)).toEqual(['newservice'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an unassessed capability as not-assessed and says why it is unranked', () => {
|
||||||
|
const r = toReport([...suite, { name: 'newservice', score: null }])
|
||||||
|
const f = r.findings.find(x => x.id === 'newservice')!
|
||||||
|
expect(f.determination).toBe('not-assessed')
|
||||||
|
expect(f.detail).toContain('backwards, as advice')
|
||||||
|
// The roll-up is `fail`, not `not-assessed`: a real shortfall takes
|
||||||
|
// precedence over an unknown, which is the standard's defined order.
|
||||||
|
expect(overall(r)).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls up to not-assessed when the ONLY entries are unassessed', () => {
|
||||||
|
expect(overall(toReport([{ name: 'newservice', score: null }]))).toBe('not-assessed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a null score sort as zero', () => {
|
||||||
|
const r = rank([{ name: 'unknown', score: null }, { name: 'weak', score: 10, effort: 'small' }])
|
||||||
|
expect(r.ranked[0]!.name).toBe('weak')
|
||||||
|
expect(r.ranked).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('leverage, not score', () => {
|
||||||
|
it('prefers a cheap fix on a mid capability over an expensive one on a weak capability', () => {
|
||||||
|
// billing: (100-80)/1 = 20. reports: (100-60)/8 = 5.
|
||||||
|
// Ordering by score alone would recommend the rewrite.
|
||||||
|
const r = rank(suite)
|
||||||
|
expect(r.ranked[0]!.name).toBe('billing')
|
||||||
|
expect(r.ranked[0]!.leverage).toBeGreaterThan(r.ranked.find(c => c.name === 'reports')!.leverage!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales by strategic weight', () => {
|
||||||
|
const r = rank([
|
||||||
|
{ name: 'minor', score: 50, effort: 'small', weight: 1 },
|
||||||
|
{ name: 'major', score: 50, effort: 'small', weight: 3 },
|
||||||
|
])
|
||||||
|
expect(r.ranked[0]!.name).toBe('major')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats unknown effort as medium, not as free', () => {
|
||||||
|
// An unestimated task is not a cheap one. Defaulting optimistically is
|
||||||
|
// how an unscoped item reaches the top of a plan.
|
||||||
|
const r = rank([
|
||||||
|
{ name: 'unscoped', score: 50 },
|
||||||
|
{ name: 'trivial-known', score: 50, effort: 'trivial' },
|
||||||
|
])
|
||||||
|
expect(r.ranked[0]!.name).toBe('trivial-known')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives a near-finished capability little leverage however cheap the work', () => {
|
||||||
|
const r = rank([
|
||||||
|
{ name: 'nearly-done', score: 98, effort: 'trivial' },
|
||||||
|
{ name: 'midway', score: 60, effort: 'medium' },
|
||||||
|
])
|
||||||
|
expect(r.ranked[0]!.name).toBe('midway')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a rank without a constraint is not advice', () => {
|
||||||
|
it('names the binding constraint and next action on each entry', () => {
|
||||||
|
const text = formatRanking(suite)
|
||||||
|
expect(text).toContain('debug logging left on')
|
||||||
|
expect(text).toContain('turn off debug logging')
|
||||||
|
expect(text).toContain('best next move: billing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when no constraint was recorded', () => {
|
||||||
|
const f = toReport([{ name: 'x', score: 50 }]).findings[0]!
|
||||||
|
expect(f.detail).toContain('a position, not advice')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only a genuinely finished capability passes', () => {
|
||||||
|
expect(toReport([{ name: 'done', score: 95, effort: 'trivial' }]).findings[0]!.determination).toBe('pass')
|
||||||
|
expect(toReport([{ name: 'nearly', score: 89, effort: 'trivial' }]).findings[0]!.determination).toBe('fail')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
it('an empty portfolio is not-assessed, never clean', () => {
|
||||||
|
expect(overall(toReport([]))).toBe('not-assessed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
expect(validateReport(toReport(suite))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the score into the citation as a measurement', () => {
|
||||||
|
const text = formatRanking(suite, { evidence: 'inline' })
|
||||||
|
expect(text).toContain('billing readiness = 80')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('the ranking prints in rank order', () => {
|
||||||
|
it('shows #1 first, even when a lower-ranked entry is more severe', () => {
|
||||||
|
// The shared renderer sorts worst-first, which is right for findings and
|
||||||
|
// wrong for a ranking. A ranking whose display order contradicts its own
|
||||||
|
// numbering is worse than no numbering.
|
||||||
|
const text = formatRanking(suite)
|
||||||
|
const table = text.split('\n\n')[0]!
|
||||||
|
expect(table.indexOf('#1')).toBeLessThan(table.indexOf('#2'))
|
||||||
|
expect(table.indexOf('#2')).toBeLessThan(table.indexOf('#3'))
|
||||||
|
expect(table.split('\n')[0]).toContain('billing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists unassessed capabilities under the ranked ones, without a number', () => {
|
||||||
|
const table = formatRanking([...suite, { name: 'newservice', score: null }]).split('\n\n')[0]!
|
||||||
|
expect(table).toContain('newservice')
|
||||||
|
expect(table).toContain('no score to rank from')
|
||||||
|
expect(table.indexOf('#1')).toBeLessThan(table.indexOf('newservice'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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