workproof 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +363 -85
- package/README.tr.md +270 -77
- package/dist/src/analyse.d.ts +39 -2
- package/dist/src/analyse.js +98 -12
- package/dist/src/attest.d.ts +62 -0
- package/dist/src/attest.js +72 -0
- package/dist/src/badge.d.ts +13 -0
- package/dist/src/badge.js +20 -0
- package/dist/src/canonical.d.ts +9 -0
- package/dist/src/canonical.js +27 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +141 -14
- package/dist/src/exclusions.d.ts +24 -0
- package/dist/src/exclusions.js +80 -0
- package/dist/src/figures/authorship.d.ts +53 -0
- package/dist/src/figures/authorship.js +190 -0
- package/dist/src/figures/footprint.d.ts +5 -1
- package/dist/src/figures/footprint.js +16 -10
- package/dist/src/figures/surviving.d.ts +38 -2
- package/dist/src/figures/surviving.js +82 -11
- package/dist/src/git.d.ts +24 -2
- package/dist/src/git.js +70 -13
- package/dist/src/index.d.ts +10 -4
- package/dist/src/index.js +7 -3
- package/dist/src/report.d.ts +5 -1
- package/dist/src/report.js +35 -13
- package/dist/src/schema.d.ts +4 -0
- package/dist/src/schema.js +66 -0
- package/dist/src/summary.d.ts +11 -0
- package/dist/src/summary.js +47 -0
- package/dist/src/verify.d.ts +29 -3
- package/dist/src/verify.js +38 -6
- package/package.json +67 -12
- package/schema/report.schema.json +74 -0
package/dist/src/analyse.js
CHANGED
|
@@ -1,17 +1,35 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import {
|
|
4
|
+
import { access } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { listCommits, listTags, rootCommit, headSha, remoteUrl, assertRepository, gitVersion, checkAttr, listHeadFiles } from "./git.js";
|
|
7
|
+
import { isBot, excludedSet } from "./exclusions.js";
|
|
8
|
+
// surviving-lines ships plain ESM JavaScript without type declarations.
|
|
9
|
+
// @ts-ignore
|
|
10
|
+
import { globToRegExp } from "surviving-lines/bin/surviving-lines.js";
|
|
5
11
|
import { resolveIdentity } from "./figures/identity.js";
|
|
6
12
|
import { tenure, commitShare } from "./figures/commits.js";
|
|
7
13
|
import { cadence } from "./figures/cadence.js";
|
|
8
14
|
import { footprint, testsAndDocs } from "./figures/footprint.js";
|
|
9
|
-
import { survivingLines } from "./figures/surviving.js";
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
import { survivingLines, blameFlags } from "./figures/surviving.js";
|
|
16
|
+
import { filesAuthored, majorContributor, commitSize, coAuthored, absenceFactor, aiAssisted, survivalByCohort } from "./figures/authorship.js";
|
|
17
|
+
/** A fresh 16-byte fingerprint key as hex. Printed once, stored nowhere. */
|
|
18
|
+
export const newFingerprintKey = () => randomBytes(16).toString("hex");
|
|
19
|
+
const NOREPLY = /@users\.noreply\.github\.com$/i;
|
|
20
|
+
/** GitHub noreply addresses carry the login in the local part, so they are never written out. */
|
|
21
|
+
export const publicEmail = (email) => (NOREPLY.test(email) ? "(github noreply)" : email);
|
|
22
|
+
/**
|
|
23
|
+
* Identifies a repository without naming it. Keyed: HMAC-SHA256 under a per-report key of
|
|
24
|
+
* the root commit and the normalised remote, so a reader cannot look a public repository
|
|
25
|
+
* up from its fingerprint. Unkeyed (no key given): plain sha256, kept for callers that
|
|
26
|
+
* want a stable public identifier.
|
|
27
|
+
*/
|
|
28
|
+
export function fingerprint(root, remote, key) {
|
|
12
29
|
let r = remote.trim().toLowerCase().replace(/\.git$/, "");
|
|
13
30
|
r = r.replace(/^[a-z+]+:\/\//, "").replace(/^git@([^:]+):/, "$1/");
|
|
14
|
-
|
|
31
|
+
const text = `${root}\n${r}`;
|
|
32
|
+
return key === undefined ? createHash("sha256").update(text).digest("hex") : createHmac("sha256", Buffer.from(key, "hex")).update(text).digest("hex");
|
|
15
33
|
}
|
|
16
34
|
const require = createRequire(import.meta.url);
|
|
17
35
|
const survivingVersion = () => {
|
|
@@ -22,19 +40,70 @@ const survivingVersion = () => {
|
|
|
22
40
|
return "unknown";
|
|
23
41
|
}
|
|
24
42
|
};
|
|
43
|
+
async function ignoreRevsFor(cwd, params) {
|
|
44
|
+
if (params.ignoreRevsFile)
|
|
45
|
+
return params.ignoreRevsFile;
|
|
46
|
+
try {
|
|
47
|
+
await access(join(cwd, ".git-blame-ignore-revs"));
|
|
48
|
+
return ".git-blame-ignore-revs";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Bot commits out, excluded paths out of every commit's file list; the untouched copy stays for the share. */
|
|
55
|
+
async function applyExclusions(cwd, all, params) {
|
|
56
|
+
const enabled = params.exclusions !== false;
|
|
57
|
+
const extra = (params.exclude ?? []).map((g) => globToRegExp(g));
|
|
58
|
+
const botCommits = enabled ? all.filter(isBot).length : 0;
|
|
59
|
+
const human = enabled ? all.filter((c) => !isBot(c)) : all;
|
|
60
|
+
const headFiles = await listHeadFiles(cwd);
|
|
61
|
+
const paths = new Set(headFiles);
|
|
62
|
+
for (const c of human)
|
|
63
|
+
for (const f of c.files)
|
|
64
|
+
paths.add(f.path);
|
|
65
|
+
const attrs = enabled ? await checkAttr(cwd, [...paths]) : new Map();
|
|
66
|
+
const excluded = excludedSet(paths, attrs, extra);
|
|
67
|
+
if (!enabled)
|
|
68
|
+
for (const p of [...excluded])
|
|
69
|
+
if (!extra.some((re) => re.test(p)))
|
|
70
|
+
excluded.delete(p);
|
|
71
|
+
const commits = human.map((c) => ({ ...c, files: c.files.filter((f) => !excluded.has(f.path)) }));
|
|
72
|
+
return { commits, raw: human, excluded, botCommits, enabled, headFiles: new Set(headFiles.filter((p) => !excluded.has(p))) };
|
|
73
|
+
}
|
|
25
74
|
export async function analyseRepo(cwd, params, hooks = {}) {
|
|
26
75
|
const say = hooks.progress ?? (() => { });
|
|
27
76
|
await assertRepository(cwd);
|
|
77
|
+
const key = params.fingerprintKey ?? newFingerprintKey();
|
|
78
|
+
if (!params.fingerprintKey)
|
|
79
|
+
say(`fingerprint key ${key} (keep it to compare reports; it is not stored)`);
|
|
28
80
|
say(`${basename(cwd)}: reading history${params.maxCommits ? ` (newest ${params.maxCommits} commits)` : ""}...`);
|
|
29
|
-
const
|
|
30
|
-
say(`${basename(cwd)}: ${
|
|
81
|
+
const everything = await listCommits(cwd, params.maxCommits ? { max: params.maxCommits } : {});
|
|
82
|
+
say(`${basename(cwd)}: ${everything.length.toLocaleString("en-US")} commits read`);
|
|
83
|
+
const ex = await applyExclusions(cwd, everything, params);
|
|
84
|
+
const all = ex.commits;
|
|
31
85
|
const id = await resolveIdentity(all, params.author, cwd);
|
|
32
86
|
const t = tenure(all, id, { ...(params.since ? { since: params.since } : {}), ...(params.until ? { until: params.until } : {}) });
|
|
33
87
|
const start = new Date(t.value.first + "T00:00:00Z");
|
|
34
88
|
const end = new Date(t.value.last + "T23:59:59Z");
|
|
35
89
|
const inTenure = all.filter((c) => c.date >= start && c.date <= end);
|
|
90
|
+
let addedAll = 0;
|
|
91
|
+
let addedExcluded = 0;
|
|
92
|
+
for (const c of ex.raw) {
|
|
93
|
+
if (c.date < start || c.date > end || c.parents > 1)
|
|
94
|
+
continue;
|
|
95
|
+
for (const f of c.files) {
|
|
96
|
+
if (f.added === null)
|
|
97
|
+
continue;
|
|
98
|
+
addedAll += f.added;
|
|
99
|
+
if (ex.excluded.has(f.path))
|
|
100
|
+
addedExcluded += f.added;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
36
103
|
const tags = await listTags(cwd);
|
|
37
104
|
const sample = params.sample ?? (all.reduce((n, c) => n + c.files.length, 0) > 50000 ? 7 : 1);
|
|
105
|
+
const ignoreRevs = await ignoreRevsFor(cwd, params);
|
|
106
|
+
const blame = blameFlags(params.copies ?? false, ignoreRevs);
|
|
38
107
|
const fp = footprint(inTenure, id, { depth: params.depth, threshold: params.threshold, minCommits: params.minCommits });
|
|
39
108
|
if (!params.paths) {
|
|
40
109
|
fp.value = { ...fp.value, ownedDirectories: fp.value.ownedDirectories.map((d) => ({ ...d, path: "(hidden; run with --paths)" })) };
|
|
@@ -45,16 +114,33 @@ export async function analyseRepo(cwd, params, hooks = {}) {
|
|
|
45
114
|
cadence(inTenure, tags, id, { first: t.value.first, last: t.value.last }),
|
|
46
115
|
fp,
|
|
47
116
|
testsAndDocs(inTenure, id),
|
|
117
|
+
filesAuthored(all, id, ex.headFiles),
|
|
118
|
+
majorContributor(inTenure, id, params.depth),
|
|
119
|
+
commitSize(inTenure, id),
|
|
120
|
+
coAuthored(inTenure, id),
|
|
121
|
+
absenceFactor(inTenure, id),
|
|
122
|
+
aiAssisted(inTenure, id),
|
|
48
123
|
];
|
|
49
124
|
say(`${basename(cwd)}: blaming files (1 in ${sample} sample)...`);
|
|
50
|
-
const surviving = await survivingLines(cwd, id, {
|
|
125
|
+
const surviving = await survivingLines(cwd, id, {
|
|
126
|
+
sample,
|
|
127
|
+
seed: params.seed ?? "",
|
|
128
|
+
exclude: params.exclude ?? [],
|
|
129
|
+
copies: params.copies ?? false,
|
|
130
|
+
ignoreRevsFile: ignoreRevs,
|
|
131
|
+
excluded: ex.excluded,
|
|
132
|
+
version: survivingVersion(),
|
|
133
|
+
});
|
|
51
134
|
say(`${basename(cwd)}: blamed ${surviving.value.filesSampled} of ${surviving.value.filesTotal} files`);
|
|
52
|
-
figures.push(surviving);
|
|
135
|
+
figures.push(surviving, survivalByCohort(surviving.value.byYear, sample));
|
|
53
136
|
return {
|
|
54
137
|
name: basename(cwd),
|
|
55
138
|
head: await headSha(cwd),
|
|
56
|
-
fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd)),
|
|
57
|
-
|
|
139
|
+
fingerprint: fingerprint(await rootCommit(cwd), await remoteUrl(cwd), key),
|
|
140
|
+
fingerprintKeyed: true,
|
|
141
|
+
identity: { emails: params.emails ? [...new Set(id.emails.map(publicEmail))] : [], names: id.names, count: id.emails.length },
|
|
142
|
+
environment: { git: await gitVersion(cwd), blame, ignoreRevs, seed: params.seed ?? "" },
|
|
143
|
+
excluded: { botCommits: ex.botCommits, files: ex.excluded.size, linesAddedShare: addedAll ? addedExcluded / addedAll : 0, enabled: ex.enabled },
|
|
58
144
|
figures,
|
|
59
145
|
};
|
|
60
146
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Report } from "./report.js";
|
|
2
|
+
export declare const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
|
|
3
|
+
export declare const PREDICATE_TYPE = "https://workproof.dev/attestation/v1";
|
|
4
|
+
export declare const PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
|
5
|
+
export interface InTotoStatement {
|
|
6
|
+
_type: typeof STATEMENT_TYPE;
|
|
7
|
+
subject: {
|
|
8
|
+
name: string;
|
|
9
|
+
digest: {
|
|
10
|
+
sha256: string;
|
|
11
|
+
};
|
|
12
|
+
}[];
|
|
13
|
+
predicateType: typeof PREDICATE_TYPE;
|
|
14
|
+
predicate: Predicate;
|
|
15
|
+
}
|
|
16
|
+
export interface Predicate {
|
|
17
|
+
tool: {
|
|
18
|
+
name: "workproof";
|
|
19
|
+
version: string;
|
|
20
|
+
};
|
|
21
|
+
generatedAt: string;
|
|
22
|
+
params: Record<string, unknown>;
|
|
23
|
+
repositories: {
|
|
24
|
+
head: string;
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
fingerprintKeyed: boolean;
|
|
27
|
+
identity: {
|
|
28
|
+
names: string[];
|
|
29
|
+
};
|
|
30
|
+
environment: Report["repositories"][number]["environment"];
|
|
31
|
+
excluded: Report["repositories"][number]["excluded"];
|
|
32
|
+
}[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The predicate says what was measured and under which environment, nothing more: no
|
|
36
|
+
* figures (they are behind the subject digest), no remote URL, no paths of any kind.
|
|
37
|
+
*/
|
|
38
|
+
export declare function predicateFor(report: Report): Predicate;
|
|
39
|
+
/** An in-toto v1 Statement whose subject is the report's own hash. */
|
|
40
|
+
export declare function statementFor(report: Report): InTotoStatement;
|
|
41
|
+
/** Writes <basename>.intoto.json and <basename>.predicate.json next to the report. */
|
|
42
|
+
export declare function writeStatement(reportPath: string): Promise<{
|
|
43
|
+
statement: string;
|
|
44
|
+
predicate: string;
|
|
45
|
+
}>;
|
|
46
|
+
export interface DsseEnvelope {
|
|
47
|
+
payloadType: typeof PAYLOAD_TYPE;
|
|
48
|
+
payload: string;
|
|
49
|
+
signatures: {
|
|
50
|
+
keyid: string;
|
|
51
|
+
sig: string;
|
|
52
|
+
}[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Signs the statement with an SSH key (ssh-keygen -Y sign, namespace "workproof"), keeps
|
|
56
|
+
* the detached .sig ssh-keygen wrote, and wraps both in a DSSE envelope as
|
|
57
|
+
* <basename>.dsse.json. The signature covers the statement bytes as written.
|
|
58
|
+
*/
|
|
59
|
+
export declare function signLocal(statementPath: string, keyPath: string): Promise<{
|
|
60
|
+
signature: string;
|
|
61
|
+
envelope: string;
|
|
62
|
+
}>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { checkReport } from "./verify.js";
|
|
5
|
+
/** ssh-keygen asks on stdin before overwriting a signature; stdin is closed so it can never wait. */
|
|
6
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
7
|
+
const child = execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => (err ? reject(new Error(`${cmd} ${args[0]} ${args[1]} failed: ${String(stderr || err.message).trim()}`)) : resolve(stdout)));
|
|
8
|
+
child.stdin?.end();
|
|
9
|
+
});
|
|
10
|
+
export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
|
|
11
|
+
export const PREDICATE_TYPE = "https://workproof.dev/attestation/v1";
|
|
12
|
+
export const PAYLOAD_TYPE = "application/vnd.in-toto+json";
|
|
13
|
+
/**
|
|
14
|
+
* The predicate says what was measured and under which environment, nothing more: no
|
|
15
|
+
* figures (they are behind the subject digest), no remote URL, no paths of any kind.
|
|
16
|
+
*/
|
|
17
|
+
export function predicateFor(report) {
|
|
18
|
+
const { ignoreRevsFile: _ignore, ...params } = report.params;
|
|
19
|
+
return {
|
|
20
|
+
tool: { name: "workproof", version: report.version },
|
|
21
|
+
generatedAt: report.generatedAt,
|
|
22
|
+
params,
|
|
23
|
+
repositories: report.repositories.map((r) => ({
|
|
24
|
+
head: r.head,
|
|
25
|
+
fingerprint: r.fingerprint,
|
|
26
|
+
fingerprintKeyed: r.fingerprintKeyed === true,
|
|
27
|
+
identity: { names: r.identity.names },
|
|
28
|
+
environment: r.environment,
|
|
29
|
+
excluded: r.excluded,
|
|
30
|
+
})),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** An in-toto v1 Statement whose subject is the report's own hash. */
|
|
34
|
+
export function statementFor(report) {
|
|
35
|
+
const integrity = checkReport(report);
|
|
36
|
+
if (!integrity.ok)
|
|
37
|
+
throw new Error(`refusing to attest a report that does not check: ${integrity.problems[0]}`);
|
|
38
|
+
return {
|
|
39
|
+
_type: STATEMENT_TYPE,
|
|
40
|
+
subject: [{ name: "workproof-report", digest: { sha256: report.hash } }],
|
|
41
|
+
predicateType: PREDICATE_TYPE,
|
|
42
|
+
predicate: predicateFor(report),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const stem = (reportPath) => join(dirname(reportPath), basename(reportPath).replace(/\.json$/, ""));
|
|
46
|
+
/** Writes <basename>.intoto.json and <basename>.predicate.json next to the report. */
|
|
47
|
+
export async function writeStatement(reportPath) {
|
|
48
|
+
const report = JSON.parse(await readFile(reportPath, "utf8"));
|
|
49
|
+
const statement = statementFor(report);
|
|
50
|
+
const paths = { statement: `${stem(reportPath)}.intoto.json`, predicate: `${stem(reportPath)}.predicate.json` };
|
|
51
|
+
await writeFile(paths.statement, JSON.stringify(statement, null, 2) + "\n");
|
|
52
|
+
await writeFile(paths.predicate, JSON.stringify(statement.predicate, null, 2) + "\n");
|
|
53
|
+
return paths;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Signs the statement with an SSH key (ssh-keygen -Y sign, namespace "workproof"), keeps
|
|
57
|
+
* the detached .sig ssh-keygen wrote, and wraps both in a DSSE envelope as
|
|
58
|
+
* <basename>.dsse.json. The signature covers the statement bytes as written.
|
|
59
|
+
*/
|
|
60
|
+
export async function signLocal(statementPath, keyPath) {
|
|
61
|
+
const signature = `${statementPath}.sig`;
|
|
62
|
+
await rm(signature, { force: true });
|
|
63
|
+
await run("ssh-keygen", ["-Y", "sign", "-f", keyPath, "-n", "workproof", statementPath]);
|
|
64
|
+
const envelope = {
|
|
65
|
+
payloadType: PAYLOAD_TYPE,
|
|
66
|
+
payload: (await readFile(statementPath)).toString("base64"),
|
|
67
|
+
signatures: [{ keyid: "ssh", sig: (await readFile(signature)).toString("base64") }],
|
|
68
|
+
};
|
|
69
|
+
const envelopePath = statementPath.replace(/\.intoto\.json$/, ".dsse.json");
|
|
70
|
+
await writeFile(envelopePath, JSON.stringify(envelope, null, 2) + "\n");
|
|
71
|
+
return { signature, envelope: envelopePath };
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Report } from "./report.js";
|
|
2
|
+
/** shields.io endpoint document: https://shields.io/badges/endpoint-badge */
|
|
3
|
+
export interface Badge {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
label: string;
|
|
6
|
+
message: string;
|
|
7
|
+
color: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A badge for the first repository in the report. It is a claim, not evidence:
|
|
11
|
+
* the JSON report next to it is what a reader verifies.
|
|
12
|
+
*/
|
|
13
|
+
export declare function badgeFor(report: Report): Badge;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
2
|
+
/**
|
|
3
|
+
* A badge for the first repository in the report. It is a claim, not evidence:
|
|
4
|
+
* the JSON report next to it is what a reader verifies.
|
|
5
|
+
*/
|
|
6
|
+
export function badgeFor(report) {
|
|
7
|
+
const repo = report.repositories[0];
|
|
8
|
+
if (!repo)
|
|
9
|
+
throw new Error("the report has no repositories");
|
|
10
|
+
const surviving = repo.figures.find((f) => f.id === "survivingLines");
|
|
11
|
+
const tenure = repo.figures.find((f) => f.id === "tenure");
|
|
12
|
+
if (!surviving || !tenure)
|
|
13
|
+
throw new Error("the report has no surviving-lines or tenure figure");
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
label: "workproof",
|
|
17
|
+
message: `${pct(surviving.value.share)} surviving lines · ${Number(tenure.value.days).toLocaleString("en-US")} days`,
|
|
18
|
+
color: "1f3fbf",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8785 (JSON Canonicalization Scheme) serialisation, without a dependency.
|
|
3
|
+
*
|
|
4
|
+
* Object keys are sorted by UTF-16 code units, numbers use the shortest round-trip
|
|
5
|
+
* form JSON.stringify already produces, strings are escaped as JSON.stringify does,
|
|
6
|
+
* and there is no whitespace. Properties whose value is undefined are skipped, as
|
|
7
|
+
* JSON.stringify skips them. Non-finite numbers have no JSON form and throw.
|
|
8
|
+
*/
|
|
9
|
+
export declare function canonicalize(value: unknown): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8785 (JSON Canonicalization Scheme) serialisation, without a dependency.
|
|
3
|
+
*
|
|
4
|
+
* Object keys are sorted by UTF-16 code units, numbers use the shortest round-trip
|
|
5
|
+
* form JSON.stringify already produces, strings are escaped as JSON.stringify does,
|
|
6
|
+
* and there is no whitespace. Properties whose value is undefined are skipped, as
|
|
7
|
+
* JSON.stringify skips them. Non-finite numbers have no JSON form and throw.
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalize(value) {
|
|
10
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
11
|
+
return JSON.stringify(value);
|
|
12
|
+
if (typeof value === "number") {
|
|
13
|
+
if (!Number.isFinite(value))
|
|
14
|
+
throw new Error(`cannot canonicalise a non-finite number (${value})`);
|
|
15
|
+
return JSON.stringify(value);
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return `[${value.map((v) => (v === undefined ? "null" : canonicalize(v))).join(",")}]`;
|
|
19
|
+
if (typeof value === "object") {
|
|
20
|
+
const record = value;
|
|
21
|
+
const keys = Object.keys(record)
|
|
22
|
+
.filter((k) => record[k] !== undefined)
|
|
23
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
24
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(record[k])}`).join(",")}}`;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`cannot canonicalise a value of type ${typeof value}`);
|
|
27
|
+
}
|
package/dist/src/cli.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import type { Params } from "./index.js";
|
|
3
|
+
type OutputFormat = "both" | "markdown" | "json";
|
|
3
4
|
interface Cli {
|
|
4
5
|
params: Params;
|
|
5
6
|
repos: string[];
|
|
6
7
|
out: string;
|
|
8
|
+
format: OutputFormat;
|
|
7
9
|
json: boolean;
|
|
8
10
|
doNarrate: boolean;
|
|
11
|
+
badge: boolean;
|
|
9
12
|
verifyFile: string | undefined;
|
|
13
|
+
checkFile: string | undefined;
|
|
14
|
+
attestFile: string | undefined;
|
|
15
|
+
localKey: string | undefined;
|
|
10
16
|
}
|
|
11
17
|
export declare function parse(argv: string[]): Cli;
|
|
12
18
|
export {};
|
package/dist/src/cli.js
CHANGED
|
@@ -3,9 +3,11 @@ import { readFile, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import { analyseRepo, buildReport, renderMarkdown, verifyReport, narrate } from "./index.js";
|
|
6
|
+
import { analyseRepo, buildReport, renderMarkdown, verifyReport, checkReport, narrate, badgeFor, newFingerprintKey, writeStatement, signLocal } from "./index.js";
|
|
7
7
|
const HELP = `usage: workproof [options] [--repo <dir>]...
|
|
8
|
-
workproof
|
|
8
|
+
workproof check <report.json>
|
|
9
|
+
workproof verify <report.json> [--repo <dir>]... [--fingerprint-key <hex>]
|
|
10
|
+
workproof attest <report.json> [--local <ssh-key>]
|
|
9
11
|
|
|
10
12
|
Turn a git repository into a verifiable engineering report for one author, without showing code.
|
|
11
13
|
|
|
@@ -15,20 +17,61 @@ Turn a git repository into a verifiable engineering report for one author, witho
|
|
|
15
17
|
--sample <n> blame every n-th file (default: 1, or 7 for very large repositories)
|
|
16
18
|
--max-commits <n> read only the newest n commits (escape hatch for enormous histories)
|
|
17
19
|
--depth <n> directory depth for ownership (default: 2)
|
|
20
|
+
--no-exclusions count bot commits and generated, vendored, lock and snapshot files
|
|
21
|
+
--exclude <glob> also drop files matching the glob (repeatable)
|
|
22
|
+
--seed <text> salt for the blame file sample
|
|
23
|
+
--copies pass -C to git blame so copied lines follow their origin
|
|
24
|
+
--ignore-revs-file <f> blame ignore-revs file (default: .git-blame-ignore-revs at the root)
|
|
25
|
+
--fingerprint-key <hex> reuse a fingerprint key so two reports of one repository match
|
|
18
26
|
--paths include directory paths in the report (off by default)
|
|
19
27
|
--emails include author emails in the report (off by default)
|
|
20
28
|
--narrate append a model-written paragraph; needs WORKPROOF_API_URL, WORKPROOF_API_KEY, WORKPROOF_MODEL
|
|
29
|
+
--badge also write <out>.badge.json, a shields.io endpoint document
|
|
21
30
|
--out <basename> output basename (default: workproof-report)
|
|
22
|
-
--
|
|
23
|
-
|
|
31
|
+
--format <mode> output markdown, json, or both (default: both)
|
|
32
|
+
--json print the JSON to stdout instead of writing files (legacy alias)
|
|
33
|
+
-h, --help this text
|
|
34
|
+
--version print the version
|
|
35
|
+
|
|
36
|
+
check validates the document and recomputes its hash offline; verify does that, then
|
|
37
|
+
compares the fingerprint and HEAD and recomputes every figure in the repository; attest
|
|
38
|
+
writes an in-toto statement whose subject is the report hash, and with --local signs it
|
|
39
|
+
with ssh-keygen -Y sign (namespace workproof) into a DSSE envelope.`;
|
|
24
40
|
export function parse(argv) {
|
|
25
|
-
const params = { depth: 2, threshold: 0.5, minCommits: 5, paths: false, emails: false };
|
|
41
|
+
const params = { depth: 2, threshold: 0.5, minCommits: 5, paths: false, emails: false, exclusions: true, exclude: [], seed: "", copies: false };
|
|
26
42
|
const repos = [];
|
|
27
43
|
const authors = [];
|
|
28
44
|
let out = "workproof-report";
|
|
45
|
+
let format = "both";
|
|
29
46
|
let json = false;
|
|
30
47
|
let doNarrate = false;
|
|
48
|
+
let badge = false;
|
|
31
49
|
let verifyFile;
|
|
50
|
+
let checkFile;
|
|
51
|
+
let attestFile;
|
|
52
|
+
let localKey;
|
|
53
|
+
if (argv[0] === "attest") {
|
|
54
|
+
attestFile = argv[1];
|
|
55
|
+
if (!attestFile)
|
|
56
|
+
throw new Error("attest needs a report.json");
|
|
57
|
+
argv = argv.slice(2);
|
|
58
|
+
if (argv[0] === "--local") {
|
|
59
|
+
localKey = argv[1];
|
|
60
|
+
if (!localKey)
|
|
61
|
+
throw new Error("--local needs an ssh private key path");
|
|
62
|
+
argv = argv.slice(2);
|
|
63
|
+
}
|
|
64
|
+
if (argv.length)
|
|
65
|
+
throw new Error(`unknown option ${argv[0]} (attest takes only --local <ssh-key>)`);
|
|
66
|
+
}
|
|
67
|
+
if (argv[0] === "check") {
|
|
68
|
+
checkFile = argv[1];
|
|
69
|
+
if (!checkFile)
|
|
70
|
+
throw new Error("check needs a report.json");
|
|
71
|
+
if (argv.length > 2)
|
|
72
|
+
throw new Error("check takes only the report; it never reads a repository");
|
|
73
|
+
argv = [];
|
|
74
|
+
}
|
|
32
75
|
if (argv[0] === "verify") {
|
|
33
76
|
verifyFile = argv[1];
|
|
34
77
|
if (!verifyFile)
|
|
@@ -60,16 +103,38 @@ export function parse(argv) {
|
|
|
60
103
|
}
|
|
61
104
|
else if (a === "--depth")
|
|
62
105
|
params.depth = Number(next());
|
|
106
|
+
else if (a === "--no-exclusions")
|
|
107
|
+
params.exclusions = false;
|
|
108
|
+
else if (a === "--exclude")
|
|
109
|
+
params.exclude.push(next());
|
|
110
|
+
else if (a === "--seed")
|
|
111
|
+
params.seed = next();
|
|
112
|
+
else if (a === "--copies")
|
|
113
|
+
params.copies = true;
|
|
114
|
+
else if (a === "--ignore-revs-file")
|
|
115
|
+
params.ignoreRevsFile = next();
|
|
116
|
+
else if (a === "--fingerprint-key")
|
|
117
|
+
params.fingerprintKey = next();
|
|
63
118
|
else if (a === "--paths")
|
|
64
119
|
params.paths = true;
|
|
65
120
|
else if (a === "--emails")
|
|
66
121
|
params.emails = true;
|
|
67
122
|
else if (a === "--narrate")
|
|
68
123
|
doNarrate = true;
|
|
124
|
+
else if (a === "--badge")
|
|
125
|
+
badge = true;
|
|
69
126
|
else if (a === "--out")
|
|
70
127
|
out = next();
|
|
71
|
-
else if (a === "--
|
|
128
|
+
else if (a === "--format") {
|
|
129
|
+
const value = next();
|
|
130
|
+
if (value !== "both" && value !== "markdown" && value !== "json")
|
|
131
|
+
throw new Error("--format must be markdown, json, or both");
|
|
132
|
+
format = value;
|
|
133
|
+
}
|
|
134
|
+
else if (a === "--json") {
|
|
72
135
|
json = true;
|
|
136
|
+
format = "json";
|
|
137
|
+
}
|
|
73
138
|
else if (a === "-h" || a === "--help") {
|
|
74
139
|
console.log(HELP);
|
|
75
140
|
process.exit(0);
|
|
@@ -81,24 +146,75 @@ export function parse(argv) {
|
|
|
81
146
|
params.author = authors;
|
|
82
147
|
if (!repos.length)
|
|
83
148
|
repos.push(process.cwd());
|
|
84
|
-
return { params, repos, out, json, doNarrate, verifyFile };
|
|
149
|
+
return { params, repos, out, format, json, doNarrate, badge, verifyFile, checkFile, attestFile, localKey };
|
|
85
150
|
}
|
|
86
151
|
async function main() {
|
|
152
|
+
if (process.argv.includes("--version")) {
|
|
153
|
+
console.log(createRequire(import.meta.url)("../../package.json").version);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
87
156
|
const started = Date.now();
|
|
88
|
-
const { params, repos, out, json, doNarrate, verifyFile } = parse(process.argv.slice(2));
|
|
157
|
+
const { params, repos, out, format, json, doNarrate, badge, verifyFile, checkFile, attestFile, localKey } = parse(process.argv.slice(2));
|
|
89
158
|
const progress = (m) => process.stderr.write(`${m}\n`);
|
|
159
|
+
const printIntegrity = (i) => {
|
|
160
|
+
const schemaProblems = i.problems.filter((p) => !p.startsWith("hash mismatch"));
|
|
161
|
+
console.log(schemaProblems.length ? `schema: ${schemaProblems.length} problem${schemaProblems.length === 1 ? "" : "s"}` : "schema ok");
|
|
162
|
+
for (const p of schemaProblems)
|
|
163
|
+
console.log(` ${p}`);
|
|
164
|
+
if (!schemaProblems.length)
|
|
165
|
+
console.log(i.ok ? `hash ok ${i.hash.computed}` : i.problems.find((p) => p.startsWith("hash mismatch")));
|
|
166
|
+
};
|
|
167
|
+
if (attestFile) {
|
|
168
|
+
const { statement, predicate } = await writeStatement(attestFile);
|
|
169
|
+
const files = [statement, predicate];
|
|
170
|
+
if (localKey) {
|
|
171
|
+
const { signature, envelope } = await signLocal(statement, localKey);
|
|
172
|
+
files.push(signature, envelope);
|
|
173
|
+
}
|
|
174
|
+
console.log(`wrote ${files.slice(0, -1).join(", ")} and ${files[files.length - 1]}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (checkFile) {
|
|
178
|
+
const result = checkReport(JSON.parse(await readFile(checkFile, "utf8")));
|
|
179
|
+
printIntegrity(result);
|
|
180
|
+
process.exit(result.ok ? 0 : 1);
|
|
181
|
+
}
|
|
90
182
|
if (verifyFile) {
|
|
91
183
|
const report = JSON.parse(await readFile(verifyFile, "utf8"));
|
|
92
|
-
const result = await verifyReport(report, repos);
|
|
184
|
+
const result = await verifyReport(report, repos, params.fingerprintKey ? { fingerprintKey: params.fingerprintKey } : {});
|
|
185
|
+
printIntegrity(result.integrity);
|
|
186
|
+
if (!result.integrity.ok) {
|
|
187
|
+
console.log("the report was edited or damaged after it was written; figures were not recomputed");
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
for (const f of result.fingerprints) {
|
|
191
|
+
if (!f.compared)
|
|
192
|
+
console.log(`${f.repo}: fingerprint not compared (pass --fingerprint-key)`);
|
|
193
|
+
else if (!f.match)
|
|
194
|
+
console.log(`${f.repo}: fingerprint differs; this is a different repository, figures were not recomputed`);
|
|
195
|
+
else
|
|
196
|
+
console.log(`${f.repo}: fingerprint ok`);
|
|
197
|
+
}
|
|
93
198
|
for (const h of result.headMoved)
|
|
94
199
|
console.log(`HEAD moved: ${h}`);
|
|
95
200
|
for (const r of result.rows)
|
|
96
201
|
if (!r.match)
|
|
97
202
|
console.log(`mismatch ${r.repo}/${r.figure}\n report: ${r.expected}\n repository: ${r.actual}`);
|
|
98
|
-
|
|
203
|
+
const differ = result.rows.filter((r) => !r.match).length;
|
|
204
|
+
if (result.fingerprints.some((f) => f.compared && !f.match))
|
|
205
|
+
process.exit(1);
|
|
206
|
+
console.log(result.ok ? "all figures reproduce" : `${differ} figures differ`);
|
|
207
|
+
console.log(result.ok
|
|
208
|
+
? "\nWhat this proves: every figure in the report was recomputed from this repository just now and came out the same, and the document has not been edited since it was written.\nWhat it does not prove: that the repository itself is honest history, that the figures measure anything worth measuring, or that the work was good. A repository whose history was rewritten before the report was made reproduces perfectly."
|
|
209
|
+
: "\nA figure that differs is not proof of dishonesty. HEAD moves, and every figure except tenure is computed at HEAD; check the HEAD line above before concluding anything.");
|
|
99
210
|
process.exit(result.ok ? 0 : 1);
|
|
100
211
|
}
|
|
101
212
|
const version = createRequire(import.meta.url)("../../package.json").version;
|
|
213
|
+
if (!params.fingerprintKey) {
|
|
214
|
+
// One key per report, so the repositories in a combined report share it; printed once, stored nowhere.
|
|
215
|
+
params.fingerprintKey = newFingerprintKey();
|
|
216
|
+
progress(`fingerprint key ${params.fingerprintKey} (keep it to compare reports or to verify the fingerprint; it is not stored)`);
|
|
217
|
+
}
|
|
102
218
|
const repositories = [];
|
|
103
219
|
for (const dir of repos)
|
|
104
220
|
repositories.push(await analyseRepo(dir, params, { progress }));
|
|
@@ -112,13 +228,24 @@ async function main() {
|
|
|
112
228
|
throw new Error("--narrate needs WORKPROOF_API_URL, WORKPROOF_API_KEY and WORKPROOF_MODEL");
|
|
113
229
|
narrative = await narrate(report, { url, key, model });
|
|
114
230
|
}
|
|
115
|
-
if (json) {
|
|
231
|
+
if (format === "json") {
|
|
116
232
|
console.log(JSON.stringify(report, null, 2));
|
|
117
233
|
return;
|
|
118
234
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
235
|
+
const written = [];
|
|
236
|
+
if (format === "markdown" || format === "both") {
|
|
237
|
+
await writeFile(`${out}.md`, renderMarkdown(report, narrative));
|
|
238
|
+
written.push(`${out}.md`);
|
|
239
|
+
}
|
|
240
|
+
if (format === "both") {
|
|
241
|
+
await writeFile(`${out}.json`, JSON.stringify(report, null, 2));
|
|
242
|
+
written.push(`${out}.json`);
|
|
243
|
+
}
|
|
244
|
+
if (badge) {
|
|
245
|
+
await writeFile(`${out}.badge.json`, JSON.stringify(badgeFor(report), null, 2));
|
|
246
|
+
written.push(`${out}.badge.json`);
|
|
247
|
+
}
|
|
248
|
+
console.log(`wrote ${written.join(" and ")} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
|
122
249
|
}
|
|
123
250
|
const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
124
251
|
if (entry === import.meta.url || entry.endsWith("/workproof")) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What leaves every denominator before a figure is computed.
|
|
3
|
+
*
|
|
4
|
+
* Bots are recognised by the two patterns GitHub uses for app identities, nothing
|
|
5
|
+
* cleverer: a name ending in "[bot]" or the "<id>+<name>[bot]@users.noreply.github.com"
|
|
6
|
+
* address. Generated, vendored, lock and snapshot files follow the subset of
|
|
7
|
+
* github-linguist's generated.rb and vendor.yml that moves line counts, plus whatever a
|
|
8
|
+
* repository marks with linguist-generated or linguist-vendored in .gitattributes.
|
|
9
|
+
*/
|
|
10
|
+
export declare const isBot: (c: {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
}) => boolean;
|
|
14
|
+
/** True for paths the built-in lists treat as generated, vendored, lock or snapshot files. */
|
|
15
|
+
export declare function isExcludedPath(path: string): boolean;
|
|
16
|
+
export interface PathAttributes {
|
|
17
|
+
generated: boolean;
|
|
18
|
+
vendored: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The set of paths to drop, from the built-in lists, the repository's .gitattributes,
|
|
22
|
+
* and the user's --exclude globs (already compiled to RegExp).
|
|
23
|
+
*/
|
|
24
|
+
export declare function excludedSet(paths: Iterable<string>, attrs: Map<string, PathAttributes>, extra: RegExp[]): Set<string>;
|