workproof 0.1.3 → 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 +322 -101
- 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.js +4 -4
- package/dist/src/canonical.d.ts +9 -0
- package/dist/src/canonical.js +27 -0
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +110 -8
- 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 +14 -9
- 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 +8 -4
- package/dist/src/index.js +6 -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 +32 -8
- package/schema/report.schema.json +74 -0
package/dist/src/index.d.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
export { analyseRepo, fingerprint } from "./analyse.js";
|
|
1
|
+
export { analyseRepo, fingerprint, newFingerprintKey, publicEmail } from "./analyse.js";
|
|
2
2
|
export type { Params, RepoReport, AnalyseHooks } from "./analyse.js";
|
|
3
|
-
export { buildReport, renderMarkdown } from "./report.js";
|
|
3
|
+
export { buildReport, renderMarkdown, hashOf } from "./report.js";
|
|
4
|
+
export { canonicalize } from "./canonical.js";
|
|
5
|
+
export { validateReport } from "./schema.js";
|
|
4
6
|
export type { Report } from "./report.js";
|
|
5
|
-
export { verifyReport } from "./verify.js";
|
|
6
|
-
export type { VerifyRow } from "./verify.js";
|
|
7
|
+
export { verifyReport, checkReport } from "./verify.js";
|
|
8
|
+
export type { VerifyRow, VerifyResult, Integrity } from "./verify.js";
|
|
7
9
|
export { narrate } from "./narrate.js";
|
|
8
10
|
export type { Figure, Identity } from "./figures/types.js";
|
|
9
11
|
export { badgeFor } from "./badge.js";
|
|
10
12
|
export type { Badge } from "./badge.js";
|
|
13
|
+
export { statementFor, predicateFor, writeStatement, signLocal, PREDICATE_TYPE, STATEMENT_TYPE } from "./attest.js";
|
|
14
|
+
export type { InTotoStatement, Predicate, DsseEnvelope } from "./attest.js";
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export { analyseRepo, fingerprint } from "./analyse.js";
|
|
2
|
-
export { buildReport, renderMarkdown } from "./report.js";
|
|
3
|
-
export {
|
|
1
|
+
export { analyseRepo, fingerprint, newFingerprintKey, publicEmail } from "./analyse.js";
|
|
2
|
+
export { buildReport, renderMarkdown, hashOf } from "./report.js";
|
|
3
|
+
export { canonicalize } from "./canonical.js";
|
|
4
|
+
export { validateReport } from "./schema.js";
|
|
5
|
+
export { verifyReport, checkReport } from "./verify.js";
|
|
4
6
|
export { narrate } from "./narrate.js";
|
|
5
7
|
export { badgeFor } from "./badge.js";
|
|
8
|
+
export { statementFor, predicateFor, writeStatement, signLocal, PREDICATE_TYPE, STATEMENT_TYPE } from "./attest.js";
|
package/dist/src/report.d.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { Params, RepoReport } from "./analyse.js";
|
|
2
2
|
export interface Report {
|
|
3
3
|
tool: "workproof";
|
|
4
|
+
/** Shape of the document; check and verify refuse other versions. */
|
|
5
|
+
schemaVersion: 2;
|
|
4
6
|
version: string;
|
|
5
7
|
generatedAt: string;
|
|
6
8
|
params: Params;
|
|
7
9
|
repositories: RepoReport[];
|
|
8
|
-
/** sha256 of the canonical JSON of { params, repositories }. */
|
|
10
|
+
/** sha256 of the RFC 8785 canonical JSON of { params, repositories }. */
|
|
9
11
|
hash: string;
|
|
10
12
|
}
|
|
13
|
+
/** The hash a report with these parameters and repositories must carry. */
|
|
14
|
+
export declare const hashOf: (params: unknown, repositories: unknown) => string;
|
|
11
15
|
export declare function buildReport(repositories: RepoReport[], params: Params, meta: {
|
|
12
16
|
version: string;
|
|
13
17
|
generatedAt: string;
|
package/dist/src/report.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/**
|
|
2
|
+
import { canonicalize } from "./canonical.js";
|
|
3
|
+
import { publicEmail } from "./analyse.js";
|
|
4
|
+
import { plainSummary } from "./summary.js";
|
|
5
|
+
/** The hash a report with these parameters and repositories must carry. */
|
|
6
|
+
export const hashOf = (params, repositories) => createHash("sha256").update(canonicalize({ params, repositories })).digest("hex");
|
|
7
|
+
/**
|
|
8
|
+
* What of the parameters the report may carry: never the fingerprint key, never a GitHub
|
|
9
|
+
* noreply login, and without --emails no author given as an email address.
|
|
10
|
+
*/
|
|
6
11
|
function publicParams(params) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
const { fingerprintKey: _key, ...rest } = params;
|
|
13
|
+
if (!rest.author)
|
|
14
|
+
return rest;
|
|
15
|
+
return { ...rest, author: rest.author.map((a) => (a.includes("@") ? (params.emails ? publicEmail(a) : "(email hidden)") : a)) };
|
|
10
16
|
}
|
|
11
17
|
export function buildReport(repositories, params, meta) {
|
|
12
18
|
const shown = publicParams(params);
|
|
13
|
-
|
|
14
|
-
return { tool: "workproof", version: meta.version, generatedAt: meta.generatedAt, params: shown, repositories, hash };
|
|
19
|
+
return { tool: "workproof", schemaVersion: 2, version: meta.version, generatedAt: meta.generatedAt, params: shown, repositories, hash: hashOf(shown, repositories) };
|
|
15
20
|
}
|
|
16
21
|
const pct = (x) => `${(x * 100).toFixed(1)}%`;
|
|
17
22
|
const n = (x) => x.toLocaleString("en-US");
|
|
23
|
+
const plural = (x, one, many) => `${n(x)} ${x === 1 ? one : many}`;
|
|
18
24
|
function figureLines(f) {
|
|
19
25
|
const v = f.value;
|
|
20
26
|
switch (f.id) {
|
|
@@ -37,9 +43,23 @@ function figureLines(f) {
|
|
|
37
43
|
];
|
|
38
44
|
}
|
|
39
45
|
case "testsAndDocs":
|
|
40
|
-
return [`${n(v.testChangesAuthor)} of ${n(v.testChangesTotal)} test-file changes, ${pct(v.testShare)}`, `${n(v.
|
|
46
|
+
return [`${n(v.testChangesAuthor)} of ${n(v.testChangesTotal)} test-file changes, ${pct(v.testShare)}`, `${n(v.docsCreated)} documents created`];
|
|
47
|
+
case "filesAuthored":
|
|
48
|
+
return [`${n(v.authored)} of ${n(v.total)} files alive at HEAD, ${pct(v.share)} (degree of authorship)`];
|
|
49
|
+
case "majorContributor":
|
|
50
|
+
return [`major contributor in ${n(v.major)} of ${n(v.dirs)} directories (at least ${v.threshold * 100}% of commits)`];
|
|
51
|
+
case "commitSize":
|
|
52
|
+
return [`median ${n(v.median)} lines, 90th percentile ${n(v.p90)}, ${plural(v.huge, "commit", "commits")} over 10,000 lines`];
|
|
53
|
+
case "coAuthored":
|
|
54
|
+
return [`${plural(v.trailerCommits, "commit", "commits")} by others naming the author in a Co-authored-by trailer`];
|
|
55
|
+
case "absenceFactor":
|
|
56
|
+
return [`${n(v.authorsToHalf)} author${v.authorsToHalf === 1 ? "" : "s"} cover half the commits; the author ranks ${n(v.authorRank)} of ${n(v.authors)} by commit count`];
|
|
57
|
+
case "aiAssisted":
|
|
58
|
+
return [`${plural(v.commits, "commit declares", "commits declare")} an AI tool in a trailer, ${pct(v.share)} of the author's commits`];
|
|
59
|
+
case "survivalByCohort":
|
|
60
|
+
return v.length ? v.map((c) => `${c.year}: ${n(c.lines)} lines`) : ["no surviving lines in the sample"];
|
|
41
61
|
case "survivingLines":
|
|
42
|
-
return [`${n(v.lines)} of ${n(v.linesAttributed)} surviving lines, ${pct(v.share)} (files ${v.filesSampled}/${v.filesTotal}, sample 1 in ${v.sample})`];
|
|
62
|
+
return [`${n(v.lines)} of ${n(v.linesAttributed)} surviving lines, ${pct(v.share)} (files ${v.filesSampled}/${v.filesTotal}, sample 1 in ${v.sample}${v.seed ? `, seed "${v.seed}"` : ""})`];
|
|
43
63
|
default:
|
|
44
64
|
return [JSON.stringify(v)];
|
|
45
65
|
}
|
|
@@ -52,7 +72,9 @@ export function renderMarkdown(report, narrative) {
|
|
|
52
72
|
``,
|
|
53
73
|
];
|
|
54
74
|
for (const repo of report.repositories) {
|
|
55
|
-
out.push(`## ${repo.name}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`,
|
|
75
|
+
out.push(`## ${repo.name}`, ``, `HEAD \`${repo.head.slice(0, 12)}\` · fingerprint \`${repo.fingerprint.slice(0, 16)}\` · identities: ${repo.identity.names.join(", ")}${repo.identity.emails.length ? ` (${repo.identity.emails.join(", ")})` : ""}`, ``, repo.excluded.enabled
|
|
76
|
+
? `excluded ${plural(repo.excluded.botCommits, "bot commit", "bot commits")} and ${plural(repo.excluded.files, "generated, vendored or lock file", "generated, vendored or lock files")} (${pct(repo.excluded.linesAddedShare)} of lines added)`
|
|
77
|
+
: `exclusions off (--no-exclusions): bot commits and generated files are counted`, ``, `### In plain language`, ``, plainSummary(repo), ``, `That paragraph is assembled from the figures below by a fixed rule, with no model involved, so it says nothing the numbers do not.`, ``);
|
|
56
78
|
for (const f of repo.figures) {
|
|
57
79
|
out.push(`### ${f.title}`, ``);
|
|
58
80
|
for (const line of figureLines(f))
|
|
@@ -60,7 +82,7 @@ export function renderMarkdown(report, narrative) {
|
|
|
60
82
|
out.push(``, `How: \`${f.command}\``, ``, `What this cannot show: ${f.limits.join(" ")}`, ``);
|
|
61
83
|
}
|
|
62
84
|
}
|
|
63
|
-
out.push(`## Integrity`, ``, `Report hash \`${report.hash}\` (sha256 of parameters and figures). Repository fingerprints are hashes of the root commit and remote; they identify a repository without naming it.`, ``);
|
|
85
|
+
out.push(`## Integrity`, ``, `Report hash \`${report.hash}\` (sha256 over the RFC 8785 canonical JSON of parameters and figures; \`npx workproof check\` recomputes it offline). Repository fingerprints are keyed hashes of the root commit and remote; they identify a repository without naming it, and only someone holding the key printed when the report was made can compare them.`, ``);
|
|
64
86
|
if (narrative) {
|
|
65
87
|
out.push(`## Generated narrative (not verified)`, ``, `The paragraph below was produced by a language model from the figures above and is not part of the hash.`, ``, narrative.trim(), ``);
|
|
66
88
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape check for a report, done by hand so `check` needs no dependency. The same shape
|
|
3
|
+
* is published as JSON Schema in schema/report.schema.json for other tools.
|
|
4
|
+
*/
|
|
5
|
+
const hex = (n) => new RegExp(`^[0-9a-f]{${n}}$`);
|
|
6
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
7
|
+
function expect(problems, path, ok, what) {
|
|
8
|
+
if (!ok)
|
|
9
|
+
problems.push(`${path}: expected ${what}`);
|
|
10
|
+
return ok;
|
|
11
|
+
}
|
|
12
|
+
function checkFigure(problems, path, f) {
|
|
13
|
+
if (!expect(problems, path, isObject(f), "object"))
|
|
14
|
+
return;
|
|
15
|
+
const fig = f;
|
|
16
|
+
expect(problems, `${path}.id`, typeof fig.id === "string" && fig.id.length > 0, "non-empty string");
|
|
17
|
+
expect(problems, `${path}.title`, typeof fig.title === "string", "string");
|
|
18
|
+
expect(problems, `${path}.value`, fig.value !== undefined, "a value");
|
|
19
|
+
expect(problems, `${path}.command`, typeof fig.command === "string", "string");
|
|
20
|
+
expect(problems, `${path}.limits`, Array.isArray(fig.limits) && fig.limits.every((l) => typeof l === "string"), "array of strings");
|
|
21
|
+
}
|
|
22
|
+
function checkRepository(problems, path, r) {
|
|
23
|
+
if (!expect(problems, path, isObject(r), "object"))
|
|
24
|
+
return;
|
|
25
|
+
const repo = r;
|
|
26
|
+
expect(problems, `${path}.name`, typeof repo.name === "string", "string");
|
|
27
|
+
expect(problems, `${path}.head`, typeof repo.head === "string" && hex(40).test(repo.head), "40 hex characters");
|
|
28
|
+
expect(problems, `${path}.fingerprint`, typeof repo.fingerprint === "string" && hex(64).test(repo.fingerprint), "64 hex characters");
|
|
29
|
+
if (expect(problems, `${path}.identity`, isObject(repo.identity), "object")) {
|
|
30
|
+
const id = repo.identity;
|
|
31
|
+
expect(problems, `${path}.identity.emails`, Array.isArray(id.emails), "array");
|
|
32
|
+
expect(problems, `${path}.identity.names`, Array.isArray(id.names), "array");
|
|
33
|
+
expect(problems, `${path}.identity.count`, typeof id.count === "number", "number");
|
|
34
|
+
}
|
|
35
|
+
if (expect(problems, `${path}.environment`, isObject(repo.environment), "object")) {
|
|
36
|
+
const env = repo.environment;
|
|
37
|
+
expect(problems, `${path}.environment.git`, typeof env.git === "string", "string");
|
|
38
|
+
expect(problems, `${path}.environment.blame`, Array.isArray(env.blame), "array");
|
|
39
|
+
}
|
|
40
|
+
if (expect(problems, `${path}.excluded`, isObject(repo.excluded), "object")) {
|
|
41
|
+
const ex = repo.excluded;
|
|
42
|
+
expect(problems, `${path}.excluded.botCommits`, typeof ex.botCommits === "number", "number");
|
|
43
|
+
expect(problems, `${path}.excluded.files`, typeof ex.files === "number", "number");
|
|
44
|
+
expect(problems, `${path}.excluded.linesAddedShare`, typeof ex.linesAddedShare === "number", "number");
|
|
45
|
+
}
|
|
46
|
+
if (expect(problems, `${path}.figures`, Array.isArray(repo.figures) && repo.figures.length > 0, "non-empty array")) {
|
|
47
|
+
repo.figures.forEach((f, i) => checkFigure(problems, `${path}.figures[${i}]`, f));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Returns the list of problems; an empty list means the report has the 0.2 shape. */
|
|
51
|
+
export function validateReport(value) {
|
|
52
|
+
const problems = [];
|
|
53
|
+
if (!expect(problems, "report", isObject(value), "object"))
|
|
54
|
+
return problems;
|
|
55
|
+
const r = value;
|
|
56
|
+
expect(problems, "tool", r.tool === "workproof", '"workproof"');
|
|
57
|
+
expect(problems, "schemaVersion", r.schemaVersion === 2, "2");
|
|
58
|
+
expect(problems, "version", typeof r.version === "string", "string");
|
|
59
|
+
expect(problems, "generatedAt", typeof r.generatedAt === "string" && !Number.isNaN(Date.parse(r.generatedAt)), "ISO date");
|
|
60
|
+
expect(problems, "params", isObject(r.params), "object");
|
|
61
|
+
expect(problems, "hash", typeof r.hash === "string" && hex(64).test(r.hash), "64 hex characters");
|
|
62
|
+
if (expect(problems, "repositories", Array.isArray(r.repositories) && r.repositories.length > 0, "non-empty array")) {
|
|
63
|
+
r.repositories.forEach((repo, i) => checkRepository(problems, `repositories[${i}]`, repo));
|
|
64
|
+
}
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The report in plain language, for the person who will read it and does not
|
|
3
|
+
* write software: a recruiter, a hiring manager, a caseworker.
|
|
4
|
+
*
|
|
5
|
+
* Built from the figures with no model call, so it is deterministic and adds
|
|
6
|
+
* nothing that is not already in the numbers above it. It states no opinion
|
|
7
|
+
* about quality, keeps the two shares apart, and ends with how to check it.
|
|
8
|
+
* The paragraph is derived from the hashed figures, not part of the hash.
|
|
9
|
+
*/
|
|
10
|
+
import type { RepoReport } from "./analyse.js";
|
|
11
|
+
export declare function plainSummary(repo: RepoReport): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
2
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
3
|
+
const value = (repo, id) => repo.figures.find((f) => f.id === id)?.value;
|
|
4
|
+
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
5
|
+
/** "2026-01-05" to "5 January 2026". Dates only; the report carries no times. */
|
|
6
|
+
function longDate(iso) {
|
|
7
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
8
|
+
if (!y || !m || !d)
|
|
9
|
+
return iso;
|
|
10
|
+
return `${d} ${MONTHS[m - 1]} ${y}`;
|
|
11
|
+
}
|
|
12
|
+
export function plainSummary(repo) {
|
|
13
|
+
const who = repo.identity.names[0] ?? "This author";
|
|
14
|
+
const tenure = value(repo, "tenure");
|
|
15
|
+
const commits = value(repo, "commitShare");
|
|
16
|
+
const surviving = value(repo, "survivingLines");
|
|
17
|
+
const cadence = value(repo, "cadence");
|
|
18
|
+
const files = value(repo, "filesAuthored");
|
|
19
|
+
const absence = value(repo, "absenceFactor");
|
|
20
|
+
const sentences = [];
|
|
21
|
+
if (tenure) {
|
|
22
|
+
sentences.push(`${who} worked in ${repo.name} from ${longDate(tenure.first)} to ${longDate(tenure.last)}, a span of ${n(tenure.days)} days.`);
|
|
23
|
+
}
|
|
24
|
+
if (commits && surviving) {
|
|
25
|
+
sentences.push(`They made ${n(commits.author)} of the ${n(commits.total)} changes recorded in that period (${pct(commits.share)}), and ${n(surviving.lines)} of the ${n(surviving.linesAttributed)} lines of code still in the project today are theirs (${pct(surviving.share)}).`);
|
|
26
|
+
sentences.push(commits.share >= surviving.share
|
|
27
|
+
? `The second number is the one that lasts: it counts the work that survived everything written since.`
|
|
28
|
+
: `The second number is the one that lasts, and here it is the higher of the two: their work survived everything written since better than the count of changes suggests.`);
|
|
29
|
+
}
|
|
30
|
+
if (files) {
|
|
31
|
+
sentences.push(`${n(files.authored)} of the ${n(files.total)} files in the project were started by them (${pct(files.share)}).`);
|
|
32
|
+
}
|
|
33
|
+
if (cadence) {
|
|
34
|
+
sentences.push(`They were active in ${n(cadence.activeWeeks)} of the ${n(cadence.weeksInTenure)} weeks in that period.`);
|
|
35
|
+
}
|
|
36
|
+
if (absence && absence.authors > 1) {
|
|
37
|
+
sentences.push(`${n(absence.authors)} people wrote code in this project; by share of surviving lines they rank ${n(absence.authorRank)}.`);
|
|
38
|
+
}
|
|
39
|
+
if (repo.excluded.enabled) {
|
|
40
|
+
sentences.push(`Before any of this was counted, ${n(repo.excluded.botCommits)} automated ${repo.excluded.botCommits === 1 ? "change" : "changes"} and ${n(repo.excluded.files)} machine-written or copied ${repo.excluded.files === 1 ? "file" : "files"} were removed, so none of them inflate the figures.`);
|
|
41
|
+
}
|
|
42
|
+
sentences.push(surviving && surviving.sample > 1
|
|
43
|
+
? `The line figures come from a fixed sample of one file in ${n(surviving.sample)}, chosen by a rule that anyone re-running this gets the same way.`
|
|
44
|
+
: `Every figure above names the exact command that produced it.`);
|
|
45
|
+
sentences.push(`Anyone with a copy of this project can recompute all of it with \`npx workproof verify\`, and the report will not match if a number was edited.`);
|
|
46
|
+
return sentences.join(" ");
|
|
47
|
+
}
|
package/dist/src/verify.d.ts
CHANGED
|
@@ -6,9 +6,35 @@ export interface VerifyRow {
|
|
|
6
6
|
expected: string;
|
|
7
7
|
actual: string;
|
|
8
8
|
}
|
|
9
|
-
|
|
10
|
-
export declare function verifyReport(report: Report, repoDirs: string[]): Promise<{
|
|
9
|
+
export interface Integrity {
|
|
11
10
|
ok: boolean;
|
|
11
|
+
problems: string[];
|
|
12
|
+
hash: {
|
|
13
|
+
stated: string;
|
|
14
|
+
computed: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Offline: does the document have the 0.2 shape, and does its content hash to the hash it
|
|
19
|
+
* states? No git, no repository. An edited report fails here before anything is recomputed.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkReport(report: unknown): Integrity;
|
|
22
|
+
export interface VerifyResult {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
integrity: Integrity;
|
|
25
|
+
/** Per repository: whether the fingerprint was compared, and whether it matched. */
|
|
26
|
+
fingerprints: {
|
|
27
|
+
repo: string;
|
|
28
|
+
compared: boolean;
|
|
29
|
+
match: boolean;
|
|
30
|
+
}[];
|
|
12
31
|
rows: VerifyRow[];
|
|
13
32
|
headMoved: string[];
|
|
14
|
-
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* check first, then the repository: fingerprint and HEAD, then every figure recomputed
|
|
36
|
+
* and compared. A fingerprint mismatch is a different repository and stops before figures.
|
|
37
|
+
*/
|
|
38
|
+
export declare function verifyReport(report: Report, repoDirs: string[], opts?: {
|
|
39
|
+
fingerprintKey?: string;
|
|
40
|
+
}): Promise<VerifyResult>;
|
package/dist/src/verify.js
CHANGED
|
@@ -1,18 +1,49 @@
|
|
|
1
|
-
import { analyseRepo } from "./analyse.js";
|
|
2
|
-
import { headSha } from "./git.js";
|
|
1
|
+
import { analyseRepo, fingerprint } from "./analyse.js";
|
|
2
|
+
import { headSha, rootCommit, remoteUrl } from "./git.js";
|
|
3
|
+
import { hashOf } from "./report.js";
|
|
4
|
+
import { validateReport } from "./schema.js";
|
|
3
5
|
const show = (v) => JSON.stringify(v);
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Offline: does the document have the 0.2 shape, and does its content hash to the hash it
|
|
8
|
+
* states? No git, no repository. An edited report fails here before anything is recomputed.
|
|
9
|
+
*/
|
|
10
|
+
export function checkReport(report) {
|
|
11
|
+
const problems = validateReport(report);
|
|
12
|
+
const r = report;
|
|
13
|
+
const stated = typeof r?.hash === "string" ? r.hash : "";
|
|
14
|
+
let computed = "";
|
|
15
|
+
if (problems.length === 0) {
|
|
16
|
+
computed = hashOf(r.params, r.repositories);
|
|
17
|
+
if (computed !== stated)
|
|
18
|
+
problems.push(`hash mismatch: report says ${stated}, content hashes to ${computed}`);
|
|
19
|
+
}
|
|
20
|
+
return { ok: problems.length === 0, problems, hash: { stated, computed } };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* check first, then the repository: fingerprint and HEAD, then every figure recomputed
|
|
24
|
+
* and compared. A fingerprint mismatch is a different repository and stops before figures.
|
|
25
|
+
*/
|
|
26
|
+
export async function verifyReport(report, repoDirs, opts = {}) {
|
|
27
|
+
const integrity = checkReport(report);
|
|
6
28
|
const rows = [];
|
|
7
29
|
const headMoved = [];
|
|
30
|
+
const fingerprints = [];
|
|
31
|
+
if (!integrity.ok)
|
|
32
|
+
return { ok: false, integrity, fingerprints, rows, headMoved };
|
|
8
33
|
for (const [i, expected] of report.repositories.entries()) {
|
|
9
34
|
const dir = repoDirs[i] ?? repoDirs[0] ?? process.cwd();
|
|
35
|
+
const key = opts.fingerprintKey ?? report.params.fingerprintKey;
|
|
36
|
+
const compared = !expected.fingerprintKeyed || key !== undefined;
|
|
37
|
+
const match = compared ? fingerprint(await rootCommit(dir), await remoteUrl(dir), expected.fingerprintKeyed ? key : undefined) === expected.fingerprint : false;
|
|
38
|
+
fingerprints.push({ repo: expected.name, compared, match });
|
|
39
|
+
if (compared && !match)
|
|
40
|
+
continue;
|
|
10
41
|
const head = await headSha(dir);
|
|
11
42
|
if (head !== expected.head)
|
|
12
43
|
headMoved.push(`${expected.name}: report at ${expected.head.slice(0, 12)}, repository at ${head.slice(0, 12)}`);
|
|
13
44
|
// Emails are hidden from the report by default, so resolve the identity from what it does carry.
|
|
14
45
|
const author = expected.identity.emails.length ? expected.identity.emails : expected.identity.names;
|
|
15
|
-
const actual = await analyseRepo(dir, { ...report.params, author });
|
|
46
|
+
const actual = await analyseRepo(dir, { ...report.params, author, ...(key ? { fingerprintKey: key } : {}) });
|
|
16
47
|
for (const f of expected.figures) {
|
|
17
48
|
const a = actual.figures.find((x) => x.id === f.id);
|
|
18
49
|
const e = show(f.value);
|
|
@@ -20,5 +51,6 @@ export async function verifyReport(report, repoDirs) {
|
|
|
20
51
|
rows.push({ repo: expected.name, figure: f.id, match: e === g, expected: e, actual: g });
|
|
21
52
|
}
|
|
22
53
|
}
|
|
23
|
-
|
|
54
|
+
const ok = fingerprints.every((f) => !f.compared || f.match) && rows.every((r) => r.match) && headMoved.length === 0;
|
|
55
|
+
return { ok, integrity, fingerprints, rows, headMoved };
|
|
24
56
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workproof",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn a private git repository into a verifiable engineering report for one author, without showing any code.",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Turn a private git repository into a verifiable engineering report for one author, without showing any code: thirteen figures from git, a hash anyone can recompute offline, verify, and an in-toto attestation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
7
7
|
"types": "./dist/src/index.d.ts",
|
|
@@ -12,30 +12,47 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
-
"workproof": "
|
|
15
|
+
"workproof": "dist/src/cli.js"
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist/src",
|
|
19
|
+
"schema",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
23
|
+
"sideEffects": false,
|
|
22
24
|
"scripts": {
|
|
23
25
|
"build": "tsc -p tsconfig.json",
|
|
24
26
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
25
|
-
"test": "npm run build && node --test dist/test/figures.test.js dist/test/report.test.js dist/test/cli.test.js",
|
|
27
|
+
"test": "npm run build && node --test dist/test/figures.test.js dist/test/exclusions.test.js dist/test/adversarial.test.js dist/test/integrity.test.js dist/test/attest.test.js dist/test/report.test.js dist/test/summary.test.js dist/test/cli.test.js dist/test/action.test.js test/release.test.mjs",
|
|
26
28
|
"prepublishOnly": "npm test",
|
|
27
|
-
"examples": "npm run build && node examples/basic.mjs"
|
|
29
|
+
"examples": "npm run build && node examples/basic.mjs",
|
|
30
|
+
"release": "node scripts/release.mjs",
|
|
31
|
+
"release-gate": "npm run build && node scripts/release-gate.mjs",
|
|
32
|
+
"bench:adversarial": "npm run build && node bench/adversarial.mjs"
|
|
28
33
|
},
|
|
29
34
|
"engines": {
|
|
30
35
|
"node": ">=20"
|
|
31
36
|
},
|
|
32
37
|
"keywords": [
|
|
33
38
|
"git",
|
|
34
|
-
"
|
|
39
|
+
"git-blame",
|
|
40
|
+
"engineering-report",
|
|
41
|
+
"verifiable",
|
|
35
42
|
"proof-of-work",
|
|
43
|
+
"portfolio",
|
|
44
|
+
"resume",
|
|
45
|
+
"hiring",
|
|
36
46
|
"career",
|
|
37
|
-
"
|
|
38
|
-
"
|
|
47
|
+
"authorship",
|
|
48
|
+
"code-ownership",
|
|
49
|
+
"surviving-lines",
|
|
50
|
+
"engineering-evidence",
|
|
51
|
+
"in-toto",
|
|
52
|
+
"sigstore",
|
|
53
|
+
"attestation",
|
|
54
|
+
"github-action",
|
|
55
|
+
"cli"
|
|
39
56
|
],
|
|
40
57
|
"author": "Efe Genc",
|
|
41
58
|
"license": "MIT",
|
|
@@ -44,6 +61,13 @@
|
|
|
44
61
|
"url": "git+https://github.com/Bubblegunn/workproof.git"
|
|
45
62
|
},
|
|
46
63
|
"homepage": "https://github.com/Bubblegunn/workproof#readme",
|
|
64
|
+
"bugs": {
|
|
65
|
+
"url": "https://github.com/Bubblegunn/workproof/issues"
|
|
66
|
+
},
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public",
|
|
69
|
+
"provenance": true
|
|
70
|
+
},
|
|
47
71
|
"dependencies": {
|
|
48
72
|
"surviving-lines": "^0.1.1"
|
|
49
73
|
},
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://workproof.dev/schema/report-2.json",
|
|
4
|
+
"title": "workproof report",
|
|
5
|
+
"description": "A verifiable engineering report for one author. hash is sha256 over the RFC 8785 canonical JSON of { params, repositories }.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["tool", "schemaVersion", "version", "generatedAt", "params", "repositories", "hash"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"tool": { "const": "workproof" },
|
|
10
|
+
"schemaVersion": { "const": 2 },
|
|
11
|
+
"version": { "type": "string" },
|
|
12
|
+
"generatedAt": { "type": "string", "format": "date-time" },
|
|
13
|
+
"params": { "type": "object" },
|
|
14
|
+
"hash": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
|
15
|
+
"repositories": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"minItems": 1,
|
|
18
|
+
"items": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"required": ["name", "head", "fingerprint", "identity", "environment", "excluded", "figures"],
|
|
21
|
+
"properties": {
|
|
22
|
+
"name": { "type": "string" },
|
|
23
|
+
"head": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
|
24
|
+
"fingerprint": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
|
25
|
+
"fingerprintKeyed": { "type": "boolean" },
|
|
26
|
+
"identity": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["emails", "names", "count"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"emails": { "type": "array", "items": { "type": "string" } },
|
|
31
|
+
"names": { "type": "array", "items": { "type": "string" } },
|
|
32
|
+
"count": { "type": "integer", "minimum": 0 }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"environment": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"required": ["git", "blame"],
|
|
38
|
+
"properties": {
|
|
39
|
+
"git": { "type": "string" },
|
|
40
|
+
"blame": { "type": "array", "items": { "type": "string" } },
|
|
41
|
+
"ignoreRevs": { "type": ["string", "null"] },
|
|
42
|
+
"seed": { "type": "string" }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"excluded": {
|
|
46
|
+
"type": "object",
|
|
47
|
+
"required": ["botCommits", "files", "linesAddedShare"],
|
|
48
|
+
"properties": {
|
|
49
|
+
"botCommits": { "type": "integer", "minimum": 0 },
|
|
50
|
+
"files": { "type": "integer", "minimum": 0 },
|
|
51
|
+
"linesAddedShare": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
52
|
+
"enabled": { "type": "boolean" }
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"figures": {
|
|
56
|
+
"type": "array",
|
|
57
|
+
"minItems": 1,
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "object",
|
|
60
|
+
"required": ["id", "title", "value", "command", "limits"],
|
|
61
|
+
"properties": {
|
|
62
|
+
"id": { "type": "string", "minLength": 1 },
|
|
63
|
+
"title": { "type": "string" },
|
|
64
|
+
"value": {},
|
|
65
|
+
"command": { "type": "string" },
|
|
66
|
+
"limits": { "type": "array", "items": { "type": "string" } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|