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,13 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `compareImplementations()` compares a named function across any number of
|
||||||
|
copies, ignoring comments and whitespace, and groups them by behaviour.
|
||||||
|
- A copy where the function cannot be found is a failure, never a pass.
|
||||||
|
- Fewer than two located copies, with none missing, is not assessed.
|
||||||
|
- `toReport()` and `formatDrift()` produce and render an
|
||||||
|
`@extant2000/evidence-record` report that cites every copy.
|
||||||
|
- `extractFunction()`, `codeOnly()` and `fingerprint()` are exported.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/blast-radius.
|
||||||
|
|
||||||
|
## 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,75 @@
|
|||||||
|
# BlastRadius
|
||||||
|
|
||||||
|
Detects when copies of the same function in different codebases drift apart,
|
||||||
|
and names which copies disagree. It is used in Hitt Hosting products.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
When the same logic lives in more than one place (two services, a vendored
|
||||||
|
copy, a CommonJS and a TypeScript version), a fix tends to land in only one of
|
||||||
|
them. Each copy looks fine on its own. Only a comparison shows that they no
|
||||||
|
longer agree, and by then the unfixed copy has been running for a while.
|
||||||
|
|
||||||
|
BlastRadius takes the source text of each copy and the name of the function to
|
||||||
|
compare. It extracts the function by brace matching, strips comments and
|
||||||
|
normalises whitespace, fingerprints the result, and groups the copies by
|
||||||
|
fingerprint. More than one group means drift. The largest group comes first,
|
||||||
|
which usually tells you which side has the fix and which side missed it.
|
||||||
|
|
||||||
|
Comments and formatting are ignored because copies may be documented
|
||||||
|
differently but must behave the same. Comparing raw text would fire on a typo
|
||||||
|
fix and train people to update the pinned value without reading, which brings
|
||||||
|
back the drift the check exists to catch. The extraction is not a parser, so
|
||||||
|
it works across CommonJS, ESM and TypeScript without a toolchain per dialect.
|
||||||
|
|
||||||
|
**A copy where the function cannot be found is a failure, not a pass.** It may
|
||||||
|
have been renamed, split or deleted, and a guard that cannot find its subject
|
||||||
|
is not passing. This holds even when only one copy was located.
|
||||||
|
|
||||||
|
Fewer than two located copies, with none missing, is reported as not
|
||||||
|
assessed: one copy cannot disagree with itself, and calling that "in sync"
|
||||||
|
would let a deleted sibling read as a clean result. An in-sync pass cites the
|
||||||
|
copies it compared, because a pass that cannot name what it compared is only
|
||||||
|
asserted.
|
||||||
|
|
||||||
|
The library is pure and has no filesystem access; you read the files. Reports
|
||||||
|
use the `@extant2000/evidence-record` format, and every copy is cited by its
|
||||||
|
path when you give one, or by its label.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { compareImplementations, toReport, formatDrift } from '@extant2000/blast-radius'
|
||||||
|
|
||||||
|
const withCheck = 'function canSend(c) {\n if (!c.postalAddress) return false\n return true\n}'
|
||||||
|
const withoutCheck = 'function canSend(c) {\n return true\n}'
|
||||||
|
|
||||||
|
const result = compareImplementations([
|
||||||
|
{ label: 'web', path: 'web/lib/send.ts', source: withCheck },
|
||||||
|
{ label: 'worker', path: 'worker/lib/send.ts', source: withCheck },
|
||||||
|
{ label: 'cli', path: 'cli/lib/send.ts', source: withoutCheck },
|
||||||
|
], 'canSend')
|
||||||
|
|
||||||
|
result.inSync // false
|
||||||
|
result.groups.map(g => g.labels) // [['web', 'worker'], ['cli']]
|
||||||
|
result.missing // []
|
||||||
|
|
||||||
|
const report = toReport(result) // evidence-record report
|
||||||
|
console.log(formatDrift(result, { evidence: 'full' }))
|
||||||
|
```
|
||||||
|
|
||||||
|
The report has one critical finding, "canSend has drifted across 2 variants",
|
||||||
|
citing all three files with the variant each belongs to (2 of 3 and 1 of 3).
|
||||||
|
|
||||||
|
`extractFunction()`, `codeOnly()` and `fingerprint()` (FNV-1a) are exported
|
||||||
|
for use on their own.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/blast-radius
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/blast-radius",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Shows which systems a change to shared code will affect.",
|
||||||
|
"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/blast-radius.git"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/blast-radius",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/blast-radius/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
+244
@@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Detect when duplicated implementations of the same logic drift apart.
|
||||||
|
*
|
||||||
|
* The failure it guards against: the same logic exists in two places, and a
|
||||||
|
* fix (a compliance check, say) lands in only one of them. Each copy looks
|
||||||
|
* fine on its own; only a comparison shows that they no longer agree.
|
||||||
|
*
|
||||||
|
* Design:
|
||||||
|
* - **Pure.** It takes sources instead of reading files, so it is testable
|
||||||
|
* without a filesystem and usable on any pair of strings.
|
||||||
|
* - **N-way, not a single hash.** This compares any number of copies and
|
||||||
|
* reports WHICH ones diverge — a pinned constant tells you something
|
||||||
|
* moved, not what.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
export interface Implementation {
|
||||||
|
/** How to identify this copy in output, e.g. a repo-relative path. */
|
||||||
|
label: string
|
||||||
|
source: string
|
||||||
|
/**
|
||||||
|
* Filesystem path this copy was read from, when the caller knows it.
|
||||||
|
* Carried into citations so a drift finding points at a file a reader can
|
||||||
|
* open, rather than at a label they have to resolve themselves.
|
||||||
|
*/
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftGroup {
|
||||||
|
/** Normalised-code fingerprint shared by every member. */
|
||||||
|
fingerprint: string
|
||||||
|
labels: string[]
|
||||||
|
/** Label + path per member, so each copy can be cited individually. */
|
||||||
|
sources: Array<{ label: string, path?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DriftReport {
|
||||||
|
functionName: string
|
||||||
|
/** True when every copy that was found is behaviourally identical. */
|
||||||
|
inSync: boolean
|
||||||
|
/** One entry per distinct implementation. More than one means drift. */
|
||||||
|
groups: DriftGroup[]
|
||||||
|
/** Copies where the function could not be located at all. */
|
||||||
|
missing: string[]
|
||||||
|
/** The same copies with their paths, for citation. */
|
||||||
|
missingSources: Array<{ label: string, path?: string }>
|
||||||
|
/**
|
||||||
|
* True when fewer than two copies were found. Reported separately from
|
||||||
|
* `inSync`: one copy cannot disagree with itself, and calling that "in sync"
|
||||||
|
* would let a deleted or renamed sibling read as a clean result.
|
||||||
|
*/
|
||||||
|
vacuous: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a named function body by brace matching.
|
||||||
|
*
|
||||||
|
* Deliberately not a parser: the point is to work across CommonJS, ESM and
|
||||||
|
* TypeScript copies of the same logic without a toolchain per dialect.
|
||||||
|
*/
|
||||||
|
export function extractFunction(src: string, name: string): string | null {
|
||||||
|
const re = new RegExp(`^(?:export )?(?:async )?function ${name}\\s*\\([^)]*\\)\\s*\\{`, 'm')
|
||||||
|
const m = re.exec(src)
|
||||||
|
if (!m) return null
|
||||||
|
const start = src.indexOf('{', m.index)
|
||||||
|
let depth = 0
|
||||||
|
for (let i = start; i < src.length; i++) {
|
||||||
|
if (src[i] === '{') depth++
|
||||||
|
else if (src[i] === '}') {
|
||||||
|
depth--
|
||||||
|
if (depth === 0) return src.slice(m.index, i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip comments and normalise whitespace.
|
||||||
|
*
|
||||||
|
* Comments and formatting are ignored on purpose: two copies are allowed to be
|
||||||
|
* documented differently, but must BEHAVE identically. Comparing raw text would
|
||||||
|
* make the guard fire on a typo fix and train people to bump the hash without
|
||||||
|
* reading — which reintroduces exactly the drift it exists to prevent.
|
||||||
|
*/
|
||||||
|
export function codeOnly(block: string): string {
|
||||||
|
return block
|
||||||
|
.split('\n')
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(l => l && !l.startsWith('//'))
|
||||||
|
.map(l => l.replace(/\s*\/\/.*$/, '').replace(/\s+/g, ' '))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a — dependency-free and sufficient for equality grouping. */
|
||||||
|
export function fingerprint(code: string): string {
|
||||||
|
let h = 0x811c9dc5
|
||||||
|
for (let i = 0; i < code.length; i++) {
|
||||||
|
h ^= code.charCodeAt(i)
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0
|
||||||
|
}
|
||||||
|
return h.toString(16).padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare a named function across several copies.
|
||||||
|
*
|
||||||
|
* `missing` is kept distinct from a drift group. A copy where the function
|
||||||
|
* cannot be found is not "different" — it may have been renamed, split, or
|
||||||
|
* deleted — and silently treating it as absent-therefore-fine is how a guard
|
||||||
|
* stops guarding.
|
||||||
|
*/
|
||||||
|
export function compareImplementations(
|
||||||
|
impls: Implementation[],
|
||||||
|
functionName: string,
|
||||||
|
): DriftReport {
|
||||||
|
const byPrint = new Map<string, Array<{ label: string, path?: string }>>()
|
||||||
|
const missing: string[] = []
|
||||||
|
const missingSources: Array<{ label: string, path?: string }> = []
|
||||||
|
|
||||||
|
for (const impl of impls) {
|
||||||
|
const fn = extractFunction(impl.source, functionName)
|
||||||
|
if (fn == null) {
|
||||||
|
missing.push(impl.label)
|
||||||
|
missingSources.push({ label: impl.label, path: impl.path })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const fp = fingerprint(codeOnly(fn))
|
||||||
|
const entry = { label: impl.label, path: impl.path }
|
||||||
|
const list = byPrint.get(fp)
|
||||||
|
if (list) list.push(entry)
|
||||||
|
else byPrint.set(fp, [entry])
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups: DriftGroup[] = [...byPrint.entries()]
|
||||||
|
.map(([fp, sources]) => ({ fingerprint: fp, labels: sources.map(s => s.label), sources }))
|
||||||
|
.sort((a, b) => b.labels.length - a.labels.length)
|
||||||
|
|
||||||
|
const found = groups.reduce((n, g) => n + g.labels.length, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
functionName,
|
||||||
|
inSync: groups.length <= 1 && missing.length === 0,
|
||||||
|
groups,
|
||||||
|
missing,
|
||||||
|
missingSources,
|
||||||
|
vacuous: found < 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a drift comparison into the portfolio-standard report shape.
|
||||||
|
*
|
||||||
|
* Every copy is cited individually — the path when the caller knows it, the
|
||||||
|
* label otherwise — so a DRIFTED verdict names the files that disagree rather
|
||||||
|
* than asserting that they do.
|
||||||
|
*/
|
||||||
|
export function toReport(report: DriftReport): CapabilityReport {
|
||||||
|
const findings: Finding[] = []
|
||||||
|
const compared = report.groups.reduce((n, g) => n + g.labels.length, 0)
|
||||||
|
|
||||||
|
if (report.missing.length) {
|
||||||
|
findings.push({
|
||||||
|
id: 'missing',
|
||||||
|
summary: `${report.functionName} could not be found in ${report.missing.length} of ${compared + report.missing.length} copies`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: 'Renamed, split, or deleted? A guard that cannot find its subject is not passing.',
|
||||||
|
evidence: report.missingSources.map(s =>
|
||||||
|
s.path
|
||||||
|
? evidence.file(s.path, undefined, undefined, `${report.functionName} not found`)
|
||||||
|
: evidence.document(s.label, undefined, undefined, `${report.functionName} not found`)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.groups.length > 1) {
|
||||||
|
findings.push({
|
||||||
|
id: 'drift',
|
||||||
|
summary: `${report.functionName} has drifted across ${report.groups.length} variants`,
|
||||||
|
determination: 'fail',
|
||||||
|
severity: 'critical',
|
||||||
|
detail: 'Apply the same change to every copy — do not bump a pinned hash on one side.',
|
||||||
|
// Majority variant first: the sort in compareImplementations already
|
||||||
|
// puts it there, and naming it first tells a reader which side is
|
||||||
|
// probably the fix and which is probably the miss.
|
||||||
|
evidence: report.groups.flatMap(g => g.sources.map(s =>
|
||||||
|
s.path
|
||||||
|
? evidence.file(s.path, undefined, undefined, `variant ${g.fingerprint} (${g.labels.length} of ${compared})`)
|
||||||
|
: evidence.document(s.label, undefined, undefined, `variant ${g.fingerprint} (${g.labels.length} of ${compared})`))),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (report.inSync && !report.vacuous) {
|
||||||
|
const g = report.groups[0]!
|
||||||
|
findings.push({
|
||||||
|
id: 'in-sync',
|
||||||
|
summary: `${report.functionName}: ${g.labels.length} copies identical`,
|
||||||
|
determination: 'pass',
|
||||||
|
severity: 'info',
|
||||||
|
evidence: g.sources.map(s =>
|
||||||
|
s.path
|
||||||
|
? evidence.file(s.path, undefined, undefined, `fingerprint ${g.fingerprint}`)
|
||||||
|
: evidence.document(s.label, undefined, undefined, `fingerprint ${g.fingerprint}`)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability: 'BlastRadius',
|
||||||
|
scope: `${report.functionName} across ${compared + report.missing.length} copies`,
|
||||||
|
// Fewer than two LOCATED copies means nothing was compared, which drives
|
||||||
|
// the report to NOT ASSESSED without a special case. One copy cannot
|
||||||
|
// disagree with itself, and calling that "in sync" would let a deleted
|
||||||
|
// sibling read as a clean result.
|
||||||
|
//
|
||||||
|
// But `vacuous` counts located copies only, so one copy plus one where
|
||||||
|
// the function is MISSING is vacuous by that definition — and a missing
|
||||||
|
// subject is a finding, not an absence of one. It stays assessed.
|
||||||
|
examined: report.vacuous && report.missing.length === 0
|
||||||
|
? 0
|
||||||
|
: compared + report.missing.length,
|
||||||
|
findings,
|
||||||
|
notes: report.vacuous && report.missing.length === 0
|
||||||
|
? ['Fewer than two copies were found, so nothing was compared. One copy cannot disagree with itself.']
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a drift report for a CI log.
|
||||||
|
*
|
||||||
|
* Delegates to the shared renderer. What used to print VACUOUS now prints
|
||||||
|
* NOT ASSESSED plus the sentence explaining it — same distinction, no
|
||||||
|
* glossary.
|
||||||
|
*/
|
||||||
|
export function formatDrift(report: DriftReport, opts: FormatOptions = {}): string {
|
||||||
|
return renderReport(toReport(report), opts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
import {
|
||||||
|
codeOnly,
|
||||||
|
toReport,
|
||||||
|
compareImplementations,
|
||||||
|
extractFunction,
|
||||||
|
fingerprint,
|
||||||
|
formatDrift,
|
||||||
|
} from '../src/index.js'
|
||||||
|
|
||||||
|
const A = `
|
||||||
|
// header comment
|
||||||
|
function evaluateSendRules(c) {
|
||||||
|
if (!c.postalAddress) return { ok: false }
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
module.exports = { evaluateSendRules }
|
||||||
|
`
|
||||||
|
|
||||||
|
// Same behaviour, different comments and formatting — must NOT be drift.
|
||||||
|
const B = `
|
||||||
|
/* different doc */
|
||||||
|
function evaluateSendRules(c) {
|
||||||
|
// a comment the other copy does not have
|
||||||
|
if (!c.postalAddress) return { ok: false }
|
||||||
|
|
||||||
|
return { ok: true } // trailing note
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// A real behavioural change — the postal-address check is gone.
|
||||||
|
const C = `
|
||||||
|
function evaluateSendRules(c) {
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('extractFunction', () => {
|
||||||
|
it('extracts a function by brace matching', () => {
|
||||||
|
expect(extractFunction(A, 'evaluateSendRules')).toContain('postalAddress')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles nested braces without stopping early', () => {
|
||||||
|
const src = 'function f() { if (x) { g({ a: 1 }) } return 2 }'
|
||||||
|
expect(extractFunction(src, 'f')).toBe(src)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds async and exported declarations', () => {
|
||||||
|
expect(extractFunction('async function f() { return 1 }', 'f')).toBeTruthy()
|
||||||
|
expect(extractFunction('export function f() { return 1 }', 'f')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the function is absent', () => {
|
||||||
|
expect(extractFunction(A, 'nope')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('codeOnly', () => {
|
||||||
|
it('ignores comments and whitespace so docs may differ', () => {
|
||||||
|
expect(codeOnly(extractFunction(A, 'evaluateSendRules')!))
|
||||||
|
.toBe(codeOnly(extractFunction(B, 'evaluateSendRules')!))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT ignore a behavioural change', () => {
|
||||||
|
expect(codeOnly(extractFunction(A, 'evaluateSendRules')!))
|
||||||
|
.not.toBe(codeOnly(extractFunction(C, 'evaluateSendRules')!))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('compareImplementations', () => {
|
||||||
|
it('reports identical copies as in sync', () => {
|
||||||
|
const r = compareImplementations(
|
||||||
|
[{ label: 'a', source: A }, { label: 'b', source: B }],
|
||||||
|
'evaluateSendRules',
|
||||||
|
)
|
||||||
|
expect(r.inSync).toBe(true)
|
||||||
|
expect(r.groups).toHaveLength(1)
|
||||||
|
expect(r.vacuous).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('catches the one-sided fix — the failure this exists for', () => {
|
||||||
|
// A CAN-SPAM guard removed in one repo only.
|
||||||
|
const r = compareImplementations(
|
||||||
|
[{ label: 'hosting', source: A }, { label: 'crm', source: C }],
|
||||||
|
'evaluateSendRules',
|
||||||
|
)
|
||||||
|
expect(r.inSync).toBe(false)
|
||||||
|
expect(r.groups).toHaveLength(2)
|
||||||
|
expect(formatDrift(r)).toContain('has drifted across')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('groups three copies as 2-vs-1 and names which side is odd', () => {
|
||||||
|
const r = compareImplementations(
|
||||||
|
[{ label: 'a', source: A }, { label: 'b', source: B }, { label: 'c', source: C }],
|
||||||
|
'evaluateSendRules',
|
||||||
|
)
|
||||||
|
// Majority group first.
|
||||||
|
expect(r.groups[0]!.labels).toEqual(['a', 'b'])
|
||||||
|
expect(r.groups[1]!.labels).toEqual(['c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a MISSING function as not-found, never as passing', () => {
|
||||||
|
// Renamed or deleted is not "no drift" — a guard that cannot find its
|
||||||
|
// subject is not passing.
|
||||||
|
const r = compareImplementations(
|
||||||
|
[{ label: 'a', source: A }, { label: 'gone', source: 'function other() {}' }],
|
||||||
|
'evaluateSendRules',
|
||||||
|
)
|
||||||
|
expect(r.missing).toEqual(['gone'])
|
||||||
|
expect(r.inSync).toBe(false)
|
||||||
|
expect(formatDrift(r)).toContain('could not be found')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a single copy as NOT ASSESSED, not in sync', () => {
|
||||||
|
// One copy cannot disagree with itself; calling that a pass would let a
|
||||||
|
// deleted sibling read as clean.
|
||||||
|
const r = compareImplementations([{ label: 'a', source: A }], 'evaluateSendRules')
|
||||||
|
expect(r.vacuous).toBe(true)
|
||||||
|
const text = formatDrift(r)
|
||||||
|
expect(text).toContain('NOT ASSESSED')
|
||||||
|
expect(text).toContain('One copy cannot disagree with itself')
|
||||||
|
// The word it replaced needed a glossary.
|
||||||
|
expect(text).not.toContain('VACUOUS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('an empty input set is vacuous', () => {
|
||||||
|
expect(compareImplementations([], 'f').vacuous).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fingerprint', () => {
|
||||||
|
it('is stable and differs on changed code', () => {
|
||||||
|
expect(fingerprint('abc')).toBe(fingerprint('abc'))
|
||||||
|
expect(fingerprint('abc')).not.toBe(fingerprint('abd'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('conformance with the evidence-record standard', () => {
|
||||||
|
const A = 'function evaluateSendRules(c) {\n if (c.unsub) return false\n return true\n}'
|
||||||
|
const B = 'function evaluateSendRules(c) {\n return true\n}'
|
||||||
|
|
||||||
|
it('cites the file of every copy that disagrees', () => {
|
||||||
|
const r = compareImplementations([
|
||||||
|
{ label: 'crm', path: 'service-a/lib/send.js', source: A },
|
||||||
|
{ label: 'hosting', path: 'service-b/lib/send.js', source: B },
|
||||||
|
], 'evaluateSendRules')
|
||||||
|
const text = formatDrift(r, { evidence: 'full' })
|
||||||
|
expect(text).toContain('service-a/lib/send.js')
|
||||||
|
expect(text).toContain('service-b/lib/send.js')
|
||||||
|
expect(overall(toReport(r))).toBe('fail')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cites the copies behind an in-sync pass', () => {
|
||||||
|
// A pass that cannot name what it compared is an asserted result.
|
||||||
|
const r = compareImplementations([
|
||||||
|
{ label: 'crm', path: 'a/send.js', source: A },
|
||||||
|
{ label: 'hosting', path: 'b/send.js', source: A },
|
||||||
|
], 'evaluateSendRules')
|
||||||
|
expect(overall(toReport(r))).toBe('pass')
|
||||||
|
expect(formatDrift(r, { evidence: 'full' })).toContain('a/send.js')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
const r = compareImplementations([
|
||||||
|
{ label: 'crm', path: 'a/send.js', source: A },
|
||||||
|
{ label: 'hosting', path: 'b/send.js', source: B },
|
||||||
|
], 'evaluateSendRules')
|
||||||
|
expect(validateReport(toReport(r))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('a missing subject is a finding, not an absence of one', () => {
|
||||||
|
it('stays assessed when one copy is present and one is missing', () => {
|
||||||
|
// `vacuous` counts LOCATED copies, so this pair is vacuous by that
|
||||||
|
// definition. Reporting it as NOT ASSESSED would hide a deleted or
|
||||||
|
// renamed sibling behind "nothing was compared" — the exact failure this
|
||||||
|
// library exists to catch.
|
||||||
|
const r = compareImplementations(
|
||||||
|
[
|
||||||
|
{ label: 'a', path: 'a/send.js', source: 'function evaluateSendRules(c) { return true }' },
|
||||||
|
{ label: 'gone', path: 'b/send.js', source: 'function other() {}' },
|
||||||
|
],
|
||||||
|
'evaluateSendRules',
|
||||||
|
)
|
||||||
|
expect(r.vacuous).toBe(true)
|
||||||
|
expect(overall(toReport(r))).toBe('fail')
|
||||||
|
expect(formatDrift(r)).toContain('could not be found')
|
||||||
|
expect(formatDrift(r)).not.toContain('NOT ASSESSED')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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