@ecoma-io/archkeep 0.14.0 → 0.15.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 +9 -3
- package/cli.mjs +447 -59
- package/commands.mjs +51 -0
- package/package.json +3 -1
- package/src/analysis/typescript.mjs +2 -1
- package/src/commands/README.md +52 -1
- package/src/commands/change-intent.mjs +461 -0
- package/src/commands/change.mjs +612 -0
- package/src/commands/check.mjs +2 -1
- package/src/commands/context.mjs +40 -2
- package/src/commands/custom-rules.mjs +286 -2
- package/src/commands/delta-classify.mjs +195 -33
- package/src/commands/delta-snapshot.mjs +156 -1
- package/src/commands/delta.mjs +142 -17
- package/src/commands/diff.mjs +41 -13
- package/src/commands/evolution.mjs +473 -0
- package/src/commands/history.mjs +130 -103
- package/src/commands/policy.mjs +57 -0
- package/src/commands/trajectory.mjs +437 -0
- package/src/path-util.mjs +40 -0
- package/src/report/change-text.mjs +148 -0
- package/src/report/delta-text.mjs +82 -1
- package/src/report/evolution-text.mjs +83 -0
- package/src/report/history-text.mjs +4 -114
- package/src/report/sarif.mjs +255 -0
- package/src/report/snapshot-text.mjs +123 -0
- package/src/report/trajectory-text.mjs +143 -0
- package/src/tsconfig-paths.mjs +3 -2
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* Sections render only when they have content — introduced (with waived
|
|
7
7
|
* annotations), resolved, unchanged (with the occurrences-reduced note where
|
|
8
8
|
* one applies), unknown (with the reason each identity could not be stated),
|
|
9
|
-
*
|
|
9
|
+
* the unresolvable-import block, and the custom-rules block (only when the
|
|
10
|
+
* delta computed one — `../commands/delta.mjs` keeps it absent for a
|
|
11
|
+
* workspace where neither side declares custom rules) — and the summary line
|
|
12
|
+
* always states what
|
|
10
13
|
* was compared: base and head identity, record and project counts, and the
|
|
11
14
|
* bucket totals. "No introduced violations" is a claim about a comparison the
|
|
12
15
|
* reader can verify, never silence (`../../../../AGENTS.md`).
|
|
@@ -59,6 +62,71 @@ function unknownLines(entry) {
|
|
|
59
62
|
return [` ? ${entry.reason}`];
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/** One classified custom-finding entry as its report lines. */
|
|
66
|
+
function customFindingLines(entry) {
|
|
67
|
+
const where = entry.project === null ? "" : ` in ${entry.project}`;
|
|
68
|
+
const counts = `${entry.baseCount} at base, ${entry.headCount} at head`;
|
|
69
|
+
const lines = [` ${entry.ruleId}${where} (${counts})`];
|
|
70
|
+
if (entry.message !== undefined) lines.push(` ${entry.message}`);
|
|
71
|
+
if (entry.note !== undefined) lines.push(` ${entry.note}`);
|
|
72
|
+
const sites = entry.headSites.length > 0 ? entry.headSites : entry.baseSites;
|
|
73
|
+
for (const site of sites) {
|
|
74
|
+
// A custom finding states a position only when its rule stated one — a
|
|
75
|
+
// whole-workspace finding has no file, and no line is printed for it.
|
|
76
|
+
if (site.file !== undefined) lines.push(` at ${site.file}:${site.line}:${site.column}`);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One unknown custom entry — the rule name plus the mandatory reason. */
|
|
82
|
+
function customUnknownLines(entry) {
|
|
83
|
+
return [` ? ${entry.rule}: ${entry.reason}`];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The custom-rules block, rendered only when the delta computed one — a
|
|
88
|
+
* workspace where neither side declares custom rules keeps the exact report
|
|
89
|
+
* it already had.
|
|
90
|
+
*
|
|
91
|
+
* @param {{judged: object[], skipped: object[], removed: string[],
|
|
92
|
+
* findings: object}} customRules
|
|
93
|
+
* @param {{customFindings: {introduced: number, resolved: number,
|
|
94
|
+
* unchanged: number, unknown: number}}} summary
|
|
95
|
+
* @returns {string[]}
|
|
96
|
+
*/
|
|
97
|
+
function customRulesSections(customRules, summary) {
|
|
98
|
+
const { judged, skipped, removed, findings } = customRules;
|
|
99
|
+
const counts = summary.customFindings;
|
|
100
|
+
const lines = [
|
|
101
|
+
`custom rules (${judged.length} judged, ${skipped.length} skipped, ${removed.length} removed)`,
|
|
102
|
+
];
|
|
103
|
+
lines.push(
|
|
104
|
+
...section(
|
|
105
|
+
` ⚠ ${counts.introduced} introduced custom finding${counts.introduced === 1 ? "" : "s"}`,
|
|
106
|
+
findings.introduced.map(customFindingLines),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
lines.push(
|
|
110
|
+
...section(
|
|
111
|
+
` ✔ ${counts.resolved} resolved custom finding${counts.resolved === 1 ? "" : "s"}`,
|
|
112
|
+
findings.resolved.map(customFindingLines),
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
lines.push(
|
|
116
|
+
...section(
|
|
117
|
+
` = ${counts.unchanged} unchanged custom finding${counts.unchanged === 1 ? "" : "s"}`,
|
|
118
|
+
findings.unchanged.map(customFindingLines),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
lines.push(
|
|
122
|
+
...section(
|
|
123
|
+
` ? ${counts.unknown} unclassifiable custom item${counts.unknown === 1 ? "" : "s"}`,
|
|
124
|
+
findings.unknown.map(customUnknownLines),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
|
|
62
130
|
/**
|
|
63
131
|
* One classification bucket as a section, or nothing when it is empty.
|
|
64
132
|
*
|
|
@@ -162,6 +230,10 @@ export function formatDeltaReport({ delta, coverage }) {
|
|
|
162
230
|
);
|
|
163
231
|
}
|
|
164
232
|
|
|
233
|
+
if (delta.customRules !== undefined) {
|
|
234
|
+
sections.push(...customRulesSections(delta.customRules, summary));
|
|
235
|
+
}
|
|
236
|
+
|
|
165
237
|
// The closing claim always states what was compared, so an empty delta is a
|
|
166
238
|
// verifiable statement rather than silence.
|
|
167
239
|
const compared =
|
|
@@ -178,6 +250,15 @@ export function formatDeltaReport({ delta, coverage }) {
|
|
|
178
250
|
`waived — ${compared}`,
|
|
179
251
|
);
|
|
180
252
|
}
|
|
253
|
+
// The custom gate's own closing claim, so a delta whose only introduction is
|
|
254
|
+
// a custom finding does not end on a line reading clean.
|
|
255
|
+
if (delta.customRules !== undefined && summary.customFindings.introduced > 0) {
|
|
256
|
+
const count = summary.customFindings.introduced;
|
|
257
|
+
sections.push(
|
|
258
|
+
`⚠ ${count} introduced custom finding${count === 1 ? "" : "s"} — custom findings have ` +
|
|
259
|
+
`no waiver lane, every one gates`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
181
262
|
|
|
182
263
|
return sections.join("\n");
|
|
183
264
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `evolution` command: the selected revisions in
|
|
3
|
+
* history order, each transition classified, and what the whole record can
|
|
4
|
+
* and cannot say.
|
|
5
|
+
*
|
|
6
|
+
* Like `history-text.mjs`, counts end every section so a reader never decides
|
|
7
|
+
* whether an omission is content or silence — and the summary line names the
|
|
8
|
+
* range the record is a claim ABOUT, because "how the architecture evolved"
|
|
9
|
+
* without naming the compared revisions is not a reproducible claim.
|
|
10
|
+
*
|
|
11
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
12
|
+
* wearing a formatter's name (`../README.md`); the transition formatters are
|
|
13
|
+
* shared with `history-text.mjs` (`./snapshot-text.mjs`) so both commands
|
|
14
|
+
* render one classification the same way.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { formatChanges, sanitize, transitionKind } from "./snapshot-text.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The whole evolution report.
|
|
21
|
+
*
|
|
22
|
+
* @param {{result: {base: string, head: string,
|
|
23
|
+
* revisions: {commit: string, id: string}[],
|
|
24
|
+
* transitions: {from: string, to: string, architectureChanged: boolean,
|
|
25
|
+
* changes: object|null, policyChanged: boolean|null, providerChanged: boolean,
|
|
26
|
+
* codeDrift: boolean, notes: string[]}[]}, coverage: object}} input
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
export function formatEvolutionReport({ result, coverage }) {
|
|
30
|
+
const sections = [];
|
|
31
|
+
|
|
32
|
+
const transitionWord = result.transitions.length === 1 ? "transition" : "transitions";
|
|
33
|
+
const revisionWord = result.revisions.length === 1 ? "revision" : "revisions";
|
|
34
|
+
const inspected = `${coverage.imports} import${
|
|
35
|
+
coverage.imports === 1 ? "" : "s"
|
|
36
|
+
} in ${coverage.analyzedFiles} file${
|
|
37
|
+
coverage.analyzedFiles === 1 ? "" : "s"
|
|
38
|
+
} across ${coverage.projects} project${coverage.projects === 1 ? "" : "s"}`;
|
|
39
|
+
|
|
40
|
+
sections.push(
|
|
41
|
+
`evolution ${sanitize(result.base.slice(0, 12))}..${sanitize(result.head.slice(0, 12))}`,
|
|
42
|
+
);
|
|
43
|
+
sections.push(
|
|
44
|
+
`${result.revisions.length} ${revisionWord}, ${result.transitions.length} ${transitionWord} (${inspected})`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
for (const [i, revision] of result.revisions.entries()) {
|
|
48
|
+
sections.push(`${i} ${sanitize(revision.commit)} ${revision.id.slice(0, 8)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The footer counts true architectural change only — the same discipline as
|
|
52
|
+
// `history-text.mjs`: a policy or provider transition is a change to how the
|
|
53
|
+
// record reads, not to the architecture itself.
|
|
54
|
+
let changed = 0;
|
|
55
|
+
for (const transition of result.transitions) {
|
|
56
|
+
if (transition.architectureChanged) changed += 1;
|
|
57
|
+
const kind = transitionKind(transition);
|
|
58
|
+
sections.push(`~ ${sanitize(transition.from)} → ${sanitize(transition.to)} (${kind})`);
|
|
59
|
+
if (transition.changes) {
|
|
60
|
+
for (const line of formatChanges(transition.changes)) sections.push(` ${line}`);
|
|
61
|
+
}
|
|
62
|
+
for (const note of transition.notes) {
|
|
63
|
+
sections.push(` ${note}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (changed === 0) {
|
|
68
|
+
const anySignal = result.transitions.some(
|
|
69
|
+
(t) => t.policyChanged === true || t.providerChanged || t.codeDrift,
|
|
70
|
+
);
|
|
71
|
+
sections.push(
|
|
72
|
+
anySignal
|
|
73
|
+
? "✔ no architectural change across the selected revisions (only policy, provider, or drift signals)"
|
|
74
|
+
: "✔ no change at all across the selected revisions",
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
sections.push(
|
|
78
|
+
`${changed} transition${changed === 1 ? "" : "s"} recorded an architectural change`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return sections.join("\n");
|
|
83
|
+
}
|
|
@@ -7,122 +7,12 @@
|
|
|
7
7
|
* actually holds. Counts end every section, so a reader is never left deciding
|
|
8
8
|
* whether an omission is content or silence.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The per-transition formatters live in `./snapshot-text.mjs`, beside the
|
|
11
|
+
* ones `evolution-text.mjs` renders from — one home for the way a transition
|
|
12
|
+
* becomes prose, so the two commands cannot disagree about it.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
* Neutralises control and terminal-escape sequences in a name or value before
|
|
16
|
-
* it is printed, so a crafted project/tag/edge name cannot inject escape
|
|
17
|
-
* sequences into a consumer's terminal (`SECURITY.md`). Real project names are
|
|
18
|
-
* ordinary characters and pass through untouched; only C0 control characters
|
|
19
|
-
* (which includes the ESC byte) and DEL become visible escapes.
|
|
20
|
-
*
|
|
21
|
-
* @param {string} text
|
|
22
|
-
* @returns {string}
|
|
23
|
-
*/
|
|
24
|
-
function sanitize(text) {
|
|
25
|
-
// eslint-disable-next-line no-control-regex
|
|
26
|
-
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
27
|
-
if (c === "\n") return "\\n";
|
|
28
|
-
if (c === "\t") return "\\t";
|
|
29
|
-
if (c === "\r") return "\\r";
|
|
30
|
-
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* One project as a line, same shape as `graph-text.mjs`.
|
|
36
|
-
*
|
|
37
|
-
* @param {{name: string, root: string, tags: string[]}} project
|
|
38
|
-
* @returns {string}
|
|
39
|
-
*/
|
|
40
|
-
function formatProject(project) {
|
|
41
|
-
const tags =
|
|
42
|
-
project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
|
|
43
|
-
return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* One edge as a line, same shape as `graph-text.mjs`.
|
|
48
|
-
*
|
|
49
|
-
* @param {{source: string, target: string, type: string}} edge
|
|
50
|
-
* @returns {string}
|
|
51
|
-
*/
|
|
52
|
-
function formatEdge(edge) {
|
|
53
|
-
return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* One metadata change as a line, same shape as `diff-text.mjs`.
|
|
58
|
-
*
|
|
59
|
-
* @param {{field: string, baseline: *, head: *}} change
|
|
60
|
-
* @returns {string}
|
|
61
|
-
*/
|
|
62
|
-
function formatChange(change) {
|
|
63
|
-
const formatValue = (v) => {
|
|
64
|
-
if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
|
|
65
|
-
if (v === null || v === undefined) return "(none)";
|
|
66
|
-
return sanitize(String(v));
|
|
67
|
-
};
|
|
68
|
-
return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* How the architecture actually changed between two snapshots: the added and
|
|
73
|
-
* removed projects and edges rendered as one line each. Changed projects
|
|
74
|
-
* render their changed fields beneath the project line, like `diff`.
|
|
75
|
-
*
|
|
76
|
-
* @param {object} changes The `computeDiff` payload.
|
|
77
|
-
* @returns {string[]}
|
|
78
|
-
*/
|
|
79
|
-
function formatChanges(changes) {
|
|
80
|
-
const lines = [];
|
|
81
|
-
if (changes.addedProjects.length > 0) {
|
|
82
|
-
const word = changes.addedProjects.length === 1 ? "project" : "projects";
|
|
83
|
-
lines.push(`+ ${changes.addedProjects.length} added ${word}`);
|
|
84
|
-
for (const project of changes.addedProjects) lines.push(formatProject(project));
|
|
85
|
-
}
|
|
86
|
-
if (changes.removedProjects.length > 0) {
|
|
87
|
-
const word = changes.removedProjects.length === 1 ? "project" : "projects";
|
|
88
|
-
lines.push(`- ${changes.removedProjects.length} removed ${word}`);
|
|
89
|
-
for (const project of changes.removedProjects) lines.push(formatProject(project));
|
|
90
|
-
}
|
|
91
|
-
if (changes.changedProjects.length > 0) {
|
|
92
|
-
const word = changes.changedProjects.length === 1 ? "project" : "projects";
|
|
93
|
-
lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
|
|
94
|
-
for (const project of changes.changedProjects) {
|
|
95
|
-
lines.push(` ${project.name}`);
|
|
96
|
-
for (const change of project.changes) lines.push(formatChange(change));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (changes.addedEdges.length > 0) {
|
|
100
|
-
const word = changes.addedEdges.length === 1 ? "edge" : "edges";
|
|
101
|
-
lines.push(`+ ${changes.addedEdges.length} added ${word}`);
|
|
102
|
-
for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
|
|
103
|
-
}
|
|
104
|
-
if (changes.removedEdges.length > 0) {
|
|
105
|
-
const word = changes.removedEdges.length === 1 ? "edge" : "edges";
|
|
106
|
-
lines.push(`- ${changes.removedEdges.length} removed ${word}`);
|
|
107
|
-
for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
|
|
108
|
-
}
|
|
109
|
-
return lines;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Classifies one transition into the short "kind" a reader skims for.
|
|
114
|
-
*
|
|
115
|
-
* @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
|
|
116
|
-
* providerChanged: boolean}} transition
|
|
117
|
-
* @returns {string}
|
|
118
|
-
*/
|
|
119
|
-
function transitionKind(transition) {
|
|
120
|
-
if (transition.architectureChanged) return "architecture";
|
|
121
|
-
if (transition.providerChanged) return "provider";
|
|
122
|
-
if (transition.policyChanged === true) return "policy";
|
|
123
|
-
if (transition.codeDrift) return "code drift";
|
|
124
|
-
return "unchanged";
|
|
125
|
-
}
|
|
15
|
+
import { formatChanges, transitionKind } from "./snapshot-text.mjs";
|
|
126
16
|
|
|
127
17
|
/**
|
|
128
18
|
* The whole history report.
|
package/src/report/sarif.mjs
CHANGED
|
@@ -956,6 +956,261 @@ export function buildSarifLog({
|
|
|
956
956
|
};
|
|
957
957
|
}
|
|
958
958
|
|
|
959
|
+
/**
|
|
960
|
+
* One introduced boundary-violation entry, at one of its head sites, as a
|
|
961
|
+
* SARIF result — the `delta` verb's rendering of the same rule catalogue
|
|
962
|
+
* `sarifResult` resolves into.
|
|
963
|
+
*
|
|
964
|
+
* `ruleId` is the entry's `messageId`, spelled exactly, so a delta upload and
|
|
965
|
+
* a `check` upload name the same rule for the same law — no new descriptors,
|
|
966
|
+
* and no second catalogue. The classifier's entry carries no rendered upstream
|
|
967
|
+
* message (`../commands/delta-classify.mjs` keeps identity and evidence, not
|
|
968
|
+
* prose), so the message is composed here from the facts the entry does carry:
|
|
969
|
+
* the classification, both sides' occurrence counts, the edge, and the
|
|
970
|
+
* constraint row — never empty, because GitHub rejects a result whose
|
|
971
|
+
* `message.text` is.
|
|
972
|
+
*
|
|
973
|
+
* One result per HEAD site: an introduced violation exists at head by
|
|
974
|
+
* construction (`headCount > 0`), and the head sites are the lines a reviewer
|
|
975
|
+
* of the change can act on — a base site names code the checkout under review
|
|
976
|
+
* may no longer contain.
|
|
977
|
+
*
|
|
978
|
+
* A waived-introduced entry is still a result (reported, not gating —
|
|
979
|
+
* `../commands/delta.mjs`'s fold), tagged with the same `accepted` vocabulary
|
|
980
|
+
* `sarifResult` uses for a waived violation, so one consumer query covers both
|
|
981
|
+
* verbs' uploads.
|
|
982
|
+
*
|
|
983
|
+
* @param {{messageId: string, sourceProject: string|null, target: string,
|
|
984
|
+
* targetIsSpecifier: boolean, constraint: object|null, baseCount: number,
|
|
985
|
+
* headCount: number, reason?: string, waived?: boolean,
|
|
986
|
+
* waivedBy?: {expiresAt?: string, reason?: string}}} entry One `introduced`
|
|
987
|
+
* entry from `classifyViolations` (`../commands/delta-classify.mjs`).
|
|
988
|
+
* @param {{file: string, line: number, column: number, specifier?: string,
|
|
989
|
+
* kind?: string}} site One of the entry's head sites — 1-based, as the
|
|
990
|
+
* analysis records carry them.
|
|
991
|
+
* @returns {object}
|
|
992
|
+
*/
|
|
993
|
+
export function sarifDeltaResult(entry, site) {
|
|
994
|
+
const target = entry.targetIsSpecifier
|
|
995
|
+
? `specifier ${JSON.stringify(entry.target)}`
|
|
996
|
+
: entry.target;
|
|
997
|
+
const text =
|
|
998
|
+
`Introduced by this change: ${entry.messageId} — ` +
|
|
999
|
+
`from ${entry.sourceProject ?? "(no project)"} to ${target} ` +
|
|
1000
|
+
`(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
|
|
1001
|
+
`${entry.headCount} at head)` +
|
|
1002
|
+
`${entry.reason ? ` — ${entry.reason}` : ""}. ` +
|
|
1003
|
+
`Constraint: ${formatConstraint(entry.constraint)}`;
|
|
1004
|
+
return {
|
|
1005
|
+
ruleId: entry.messageId,
|
|
1006
|
+
ruleIndex: MESSAGE_IDS.indexOf(entry.messageId),
|
|
1007
|
+
level: "error",
|
|
1008
|
+
message: { text },
|
|
1009
|
+
locations: [
|
|
1010
|
+
{
|
|
1011
|
+
physicalLocation: {
|
|
1012
|
+
artifactLocation: { uri: toUriReference(site.file) },
|
|
1013
|
+
region: { startLine: site.line, startColumn: site.column },
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
],
|
|
1017
|
+
properties: {
|
|
1018
|
+
delta: "introduced",
|
|
1019
|
+
baseCount: entry.baseCount,
|
|
1020
|
+
headCount: entry.headCount,
|
|
1021
|
+
sourceProject: entry.sourceProject,
|
|
1022
|
+
target: entry.target,
|
|
1023
|
+
// The same vocabulary `sarifResult` uses for a waived violation: the
|
|
1024
|
+
// result is still an error (an accepted violation is still a violation),
|
|
1025
|
+
// and the properties say why it does not gate.
|
|
1026
|
+
...(entry.waived === true
|
|
1027
|
+
? {
|
|
1028
|
+
accepted: true,
|
|
1029
|
+
acceptedUntil: entry.waivedBy?.expiresAt,
|
|
1030
|
+
acceptedReason: entry.waivedBy?.reason,
|
|
1031
|
+
}
|
|
1032
|
+
: {}),
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* The `delta` verb's SARIF log: the INTRODUCED buckets as results, everything
|
|
1039
|
+
* the classification could not answer as tool-execution notifications.
|
|
1040
|
+
*
|
|
1041
|
+
* Results are introduced entries only — the classified boundary violations
|
|
1042
|
+
* (`sarifDeltaResult`, waived ones included and tagged) and the introduced
|
|
1043
|
+
* custom-rule findings, the latter through the same `sarifCustomRuleResult`
|
|
1044
|
+
* and end-of-catalogue descriptors `check`'s log uses, so a custom finding
|
|
1045
|
+
* resolves in `sarifRules(customCatalogue)` here exactly as it does there.
|
|
1046
|
+
* Resolved and unchanged entries are deliberately NOT results: this log is
|
|
1047
|
+
* uploaded against the head checkout, and an annotation for a violation the
|
|
1048
|
+
* change resolved would mark code that no longer contains it.
|
|
1049
|
+
*
|
|
1050
|
+
* The notification lane is the load-bearing half. `delta` exits 3 on any
|
|
1051
|
+
* `unknown` entry (`../commands/delta.mjs`'s fold), and every one of those
|
|
1052
|
+
* rides here — a violation, unresolvable-record, or custom-rule item whose
|
|
1053
|
+
* identity could not be stated — so an exit-3 delta can never upload a log
|
|
1054
|
+
* byte-identical to a clean run's, the same guarantee `buildSarifLog` keeps
|
|
1055
|
+
* for `check`'s no-verdict lanes. Coverage notes (policy drift, dirty trees,
|
|
1056
|
+
* skipped or removed custom rules) and introduced unresolvable records ride
|
|
1057
|
+
* the same lane: none is a verdict, and dropping any of them is the silent
|
|
1058
|
+
* direction (`../../../../AGENTS.md`).
|
|
1059
|
+
*
|
|
1060
|
+
* `executionSuccessful` stays `true` on every status — the run completed and
|
|
1061
|
+
* classified; the findings are results, not tool errors — and `columnKind`
|
|
1062
|
+
* states the analyzers' real UTF-16 convention, both for the reasons the
|
|
1063
|
+
* module header gives.
|
|
1064
|
+
*
|
|
1065
|
+
* @param {{delta: {violations: {introduced: object[], resolved?: object[],
|
|
1066
|
+
* unchanged?: object[], unknown: object[]},
|
|
1067
|
+
* unresolvable: {introduced: object[], resolved?: object[],
|
|
1068
|
+
* unchanged?: object[], unknown: object[]},
|
|
1069
|
+
* customRules?: {findings: {introduced: object[], resolved?: object[],
|
|
1070
|
+
* unchanged?: object[], unknown: object[]}}},
|
|
1071
|
+
* coverage: {notes?: string[]},
|
|
1072
|
+
* customCatalogue?: {ruleId: string, rule: string, findingId: string,
|
|
1073
|
+
* message: string}[]}} run The `deltaCommand` result's `delta` and
|
|
1074
|
+
* `coverage`, plus the head-declared custom-rule catalogue
|
|
1075
|
+
* (`../commands/custom-rules.mjs`'s `customRulesForDelta`) — passed
|
|
1076
|
+
* separately because the envelope deliberately does not carry it.
|
|
1077
|
+
* @returns {object} A SARIF 2.1.0 log, ready to `JSON.stringify`.
|
|
1078
|
+
*/
|
|
1079
|
+
export function buildDeltaSarifLog({ delta, coverage, customCatalogue = [] }) {
|
|
1080
|
+
const customRuleIndex = new Map(
|
|
1081
|
+
customCatalogue.map((entry, index) => [entry.ruleId, CUSTOM_RULE_INDEX_BASE + index]),
|
|
1082
|
+
);
|
|
1083
|
+
const customIntroduced = delta.customRules?.findings.introduced ?? [];
|
|
1084
|
+
const results = [
|
|
1085
|
+
...delta.violations.introduced.flatMap((entry) =>
|
|
1086
|
+
entry.headSites.map((site) => sarifDeltaResult(entry, site)),
|
|
1087
|
+
),
|
|
1088
|
+
...customIntroduced.flatMap((entry) =>
|
|
1089
|
+
entry.headSites.map((site) => {
|
|
1090
|
+
const rendered = sarifCustomRuleResult(
|
|
1091
|
+
{
|
|
1092
|
+
id: entry.ruleId,
|
|
1093
|
+
message:
|
|
1094
|
+
`Introduced by this change: custom rule finding ${entry.ruleId} ` +
|
|
1095
|
+
`(${entry.baseCount} occurrence${entry.baseCount === 1 ? "" : "s"} at base, ` +
|
|
1096
|
+
`${entry.headCount} at head)` +
|
|
1097
|
+
`${entry.reason ? ` — ${entry.reason}` : ""}` +
|
|
1098
|
+
`${typeof entry.message === "string" && entry.message !== "" ? `: ${entry.message}` : ""}`,
|
|
1099
|
+
...(typeof site.file === "string" ? { sourceFile: site.file } : {}),
|
|
1100
|
+
...(typeof site.line === "number" ? { line: site.line } : {}),
|
|
1101
|
+
...(typeof site.column === "number" ? { column: site.column } : {}),
|
|
1102
|
+
...(entry.project === null ? {} : { project: entry.project }),
|
|
1103
|
+
},
|
|
1104
|
+
// `?? -1` for the same reason `buildSarifLog` gives: an introduced
|
|
1105
|
+
// finding whose id its own head catalogue does not declare cannot
|
|
1106
|
+
// arrive here, and a regression in that guarantee must be a visibly
|
|
1107
|
+
// broken index rather than an `undefined` GitHub ignores.
|
|
1108
|
+
customRuleIndex.get(entry.ruleId) ?? -1,
|
|
1109
|
+
);
|
|
1110
|
+
return {
|
|
1111
|
+
...rendered,
|
|
1112
|
+
properties: {
|
|
1113
|
+
...(rendered.properties ?? {}),
|
|
1114
|
+
delta: "introduced",
|
|
1115
|
+
baseCount: entry.baseCount,
|
|
1116
|
+
headCount: entry.headCount,
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
}),
|
|
1120
|
+
),
|
|
1121
|
+
];
|
|
1122
|
+
const toolExecutionNotifications = [
|
|
1123
|
+
...delta.violations.unknown.map((entry) => ({
|
|
1124
|
+
level: "warning",
|
|
1125
|
+
message: {
|
|
1126
|
+
text: `delta could not classify a violation — ${entry.reason}. The run reaches no verdict.`,
|
|
1127
|
+
},
|
|
1128
|
+
})),
|
|
1129
|
+
...delta.unresolvable.unknown.map((entry) => ({
|
|
1130
|
+
level: "warning",
|
|
1131
|
+
message: {
|
|
1132
|
+
text:
|
|
1133
|
+
`delta could not classify an unresolvable import site — ${entry.reason}. ` +
|
|
1134
|
+
`The run reaches no verdict.`,
|
|
1135
|
+
},
|
|
1136
|
+
})),
|
|
1137
|
+
...(delta.customRules?.findings.unknown ?? []).map((entry) => ({
|
|
1138
|
+
level: "warning",
|
|
1139
|
+
message: {
|
|
1140
|
+
text:
|
|
1141
|
+
`delta could not classify a custom-rule item` +
|
|
1142
|
+
`${typeof entry.rule === "string" ? ` (rule "${entry.rule}")` : ""} — ` +
|
|
1143
|
+
`${entry.reason}. The run reaches no verdict.`,
|
|
1144
|
+
},
|
|
1145
|
+
})),
|
|
1146
|
+
// Introduced unresolvable records: sites the change added whose target
|
|
1147
|
+
// analysis could not resolve. Never results — no rule reached a verdict
|
|
1148
|
+
// about them — but a change that adds them must not upload the log a
|
|
1149
|
+
// change that adds nothing would.
|
|
1150
|
+
...delta.unresolvable.introduced.map((entry) => ({
|
|
1151
|
+
level: "warning",
|
|
1152
|
+
message: {
|
|
1153
|
+
text:
|
|
1154
|
+
`This change introduces ${entry.headCount - entry.baseCount} unresolvable import ` +
|
|
1155
|
+
`site${entry.headCount - entry.baseCount === 1 ? "" : "s"} for specifier ` +
|
|
1156
|
+
`${JSON.stringify(entry.specifier)} (${entry.kind || "unknown kind"})` +
|
|
1157
|
+
`${entry.sourceProject === null ? "" : ` in ${entry.sourceProject}`} — ` +
|
|
1158
|
+
`no rule reached a verdict about ${entry.headCount - entry.baseCount === 1 ? "it" : "them"}`,
|
|
1159
|
+
},
|
|
1160
|
+
locations: (entry.headSites ?? [])
|
|
1161
|
+
.filter((site) => typeof site.file === "string")
|
|
1162
|
+
.map((site) => ({
|
|
1163
|
+
physicalLocation: {
|
|
1164
|
+
artifactLocation: { uri: toUriReference(site.file) },
|
|
1165
|
+
...(typeof site.line === "number"
|
|
1166
|
+
? {
|
|
1167
|
+
region: {
|
|
1168
|
+
startLine: site.line,
|
|
1169
|
+
...(typeof site.column === "number" ? { startColumn: site.column } : {}),
|
|
1170
|
+
},
|
|
1171
|
+
}
|
|
1172
|
+
: {}),
|
|
1173
|
+
},
|
|
1174
|
+
})),
|
|
1175
|
+
})),
|
|
1176
|
+
...(coverage.notes ?? []).map((note) => ({
|
|
1177
|
+
level: "warning",
|
|
1178
|
+
message: { text: `Coverage note: ${note}` },
|
|
1179
|
+
})),
|
|
1180
|
+
];
|
|
1181
|
+
return {
|
|
1182
|
+
$schema: SARIF_SCHEMA,
|
|
1183
|
+
version: SARIF_VERSION,
|
|
1184
|
+
runs: [
|
|
1185
|
+
{
|
|
1186
|
+
tool: { driver: { name: "archkeep", rules: sarifRules(customCatalogue) } },
|
|
1187
|
+
columnKind: "utf16CodeUnits",
|
|
1188
|
+
results,
|
|
1189
|
+
invocations: [
|
|
1190
|
+
{
|
|
1191
|
+
// True even on exit 1 or 3: the classification completed and said
|
|
1192
|
+
// what it could not answer — the same reasoning `buildSarifLog`
|
|
1193
|
+
// states for `check`.
|
|
1194
|
+
executionSuccessful: true,
|
|
1195
|
+
toolExecutionNotifications,
|
|
1196
|
+
},
|
|
1197
|
+
],
|
|
1198
|
+
},
|
|
1199
|
+
],
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* The delta SARIF log as the bytes to write — pretty-printed with a trailing
|
|
1205
|
+
* newline, the same presentation `formatSarif` gives `check`'s log.
|
|
1206
|
+
*
|
|
1207
|
+
* @param {Parameters<typeof buildDeltaSarifLog>[0]} run
|
|
1208
|
+
* @returns {string}
|
|
1209
|
+
*/
|
|
1210
|
+
export function formatDeltaSarif(run) {
|
|
1211
|
+
return `${JSON.stringify(buildDeltaSarifLog(run), null, 2)}\n`;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
959
1214
|
/**
|
|
960
1215
|
* The SARIF log as the bytes to write — pretty-printed with a trailing newline,
|
|
961
1216
|
* so a file that lands in a diff or a log stays readable.
|