@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatters two snapshot-transition reports share — `history-text.mjs` and
|
|
3
|
+
* `evolution-text.mjs` render the same transition shape (a graph diff, notes,
|
|
4
|
+
* and a short kind label), and a second copy of these helpers is where the
|
|
5
|
+
* two renders would drift: one day "code drift" means one thing in `history`
|
|
6
|
+
* and another in `evolution`, and no gate compares rendered prose.
|
|
7
|
+
*
|
|
8
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
9
|
+
* wearing a formatter's name (`../README.md`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Neutralises control and terminal-escape sequences in a name or value before
|
|
14
|
+
* it is printed, so a crafted project/tag/edge name cannot inject escape
|
|
15
|
+
* sequences into a consumer's terminal (`SECURITY.md`). Real project names are
|
|
16
|
+
* ordinary characters and pass through untouched; only C0 control characters
|
|
17
|
+
* (which includes the ESC byte) and DEL become visible escapes.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text
|
|
20
|
+
* @returns {string}
|
|
21
|
+
*/
|
|
22
|
+
export function sanitize(text) {
|
|
23
|
+
// eslint-disable-next-line no-control-regex
|
|
24
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
25
|
+
if (c === "\n") return "\\n";
|
|
26
|
+
if (c === "\t") return "\\t";
|
|
27
|
+
if (c === "\r") return "\\r";
|
|
28
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One project as a line, same shape as `graph-text.mjs`.
|
|
34
|
+
*
|
|
35
|
+
* @param {{name: string, root: string, tags: string[]}} project
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function formatProject(project) {
|
|
39
|
+
const tags =
|
|
40
|
+
project.tags.length > 0 ? ` [${project.tags.map((t) => sanitize(t)).join(", ")}]` : "";
|
|
41
|
+
return ` ${sanitize(project.name)} ${sanitize(project.root)}${tags}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One edge as a line, same shape as `graph-text.mjs`.
|
|
46
|
+
*
|
|
47
|
+
* @param {{source: string, target: string, type: string}} edge
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatEdge(edge) {
|
|
51
|
+
return ` ${sanitize(edge.source)} → ${sanitize(edge.target)} (${sanitize(edge.type)})`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One metadata change as a line, same shape as `diff-text.mjs`.
|
|
56
|
+
*
|
|
57
|
+
* @param {{field: string, baseline: *, head: *}} change
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function formatChange(change) {
|
|
61
|
+
const formatValue = (v) => {
|
|
62
|
+
if (Array.isArray(v)) return v.length > 0 ? v.map((x) => sanitize(x)).join(", ") : "(none)";
|
|
63
|
+
if (v === null || v === undefined) return "(none)";
|
|
64
|
+
return sanitize(String(v));
|
|
65
|
+
};
|
|
66
|
+
return ` ${change.field} ${formatValue(change.baseline)} → ${formatValue(change.head)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How the architecture actually changed between two snapshots: the added and
|
|
71
|
+
* removed projects and edges rendered as one line each. Changed projects
|
|
72
|
+
* render their changed fields beneath the project line, like `diff`.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} changes The `computeDiff` payload.
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
export function formatChanges(changes) {
|
|
78
|
+
const lines = [];
|
|
79
|
+
if (changes.addedProjects.length > 0) {
|
|
80
|
+
const word = changes.addedProjects.length === 1 ? "project" : "projects";
|
|
81
|
+
lines.push(`+ ${changes.addedProjects.length} added ${word}`);
|
|
82
|
+
for (const project of changes.addedProjects) lines.push(formatProject(project));
|
|
83
|
+
}
|
|
84
|
+
if (changes.removedProjects.length > 0) {
|
|
85
|
+
const word = changes.removedProjects.length === 1 ? "project" : "projects";
|
|
86
|
+
lines.push(`- ${changes.removedProjects.length} removed ${word}`);
|
|
87
|
+
for (const project of changes.removedProjects) lines.push(formatProject(project));
|
|
88
|
+
}
|
|
89
|
+
if (changes.changedProjects.length > 0) {
|
|
90
|
+
const word = changes.changedProjects.length === 1 ? "project" : "projects";
|
|
91
|
+
lines.push(`~ ${changes.changedProjects.length} changed ${word}`);
|
|
92
|
+
for (const project of changes.changedProjects) {
|
|
93
|
+
lines.push(` ${project.name}`);
|
|
94
|
+
for (const change of project.changes) lines.push(formatChange(change));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (changes.addedEdges.length > 0) {
|
|
98
|
+
const word = changes.addedEdges.length === 1 ? "edge" : "edges";
|
|
99
|
+
lines.push(`+ ${changes.addedEdges.length} added ${word}`);
|
|
100
|
+
for (const edge of changes.addedEdges) lines.push(formatEdge(edge));
|
|
101
|
+
}
|
|
102
|
+
if (changes.removedEdges.length > 0) {
|
|
103
|
+
const word = changes.removedEdges.length === 1 ? "edge" : "edges";
|
|
104
|
+
lines.push(`- ${changes.removedEdges.length} removed ${word}`);
|
|
105
|
+
for (const edge of changes.removedEdges) lines.push(formatEdge(edge));
|
|
106
|
+
}
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Classifies one transition into the short "kind" a reader skims for.
|
|
112
|
+
*
|
|
113
|
+
* @param {{architectureChanged: boolean, codeDrift: boolean, policyChanged: boolean|null,
|
|
114
|
+
* providerChanged: boolean}} transition
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
export function transitionKind(transition) {
|
|
118
|
+
if (transition.architectureChanged) return "architecture";
|
|
119
|
+
if (transition.providerChanged) return "provider";
|
|
120
|
+
if (transition.policyChanged === true) return "policy";
|
|
121
|
+
if (transition.codeDrift) return "code drift";
|
|
122
|
+
return "unchanged";
|
|
123
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal report for the `trajectory` command: the aggregate signals
|
|
3
|
+
* across a snapshot history, with what each number is a claim about.
|
|
4
|
+
*
|
|
5
|
+
* Every line states counts, never judgments — the report has no "better", no
|
|
6
|
+
* score, no direction adjective. The header names the observation basis (one
|
|
7
|
+
* observation is one stored graph snapshot, not a commit or a day), an
|
|
8
|
+
* insufficient history says so instead of printing zeros, and the disclosures
|
|
9
|
+
* line prints even when every count is zero, so a reader can tell "nothing
|
|
10
|
+
* was incomparable" from "the report forgot to say".
|
|
11
|
+
*
|
|
12
|
+
* This module decides nothing. A formatter that filtered would be a rule
|
|
13
|
+
* wearing a formatter's name (`../README.md`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Neutralises control and terminal-escape sequences before anything reaches
|
|
18
|
+
* the terminal (`SECURITY.md`) — the same sanitation every other renderer
|
|
19
|
+
* applies. Only paths and snapshot filenames are printed here; project and
|
|
20
|
+
* edge identities stay aggregated precisely so nothing name-shaped needs to.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function sanitize(text) {
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
return String(text).replace(/[\x00-\x1F\x7F]/g, (c) => {
|
|
28
|
+
if (c === "\n") return "\\n";
|
|
29
|
+
if (c === "\t") return "\\t";
|
|
30
|
+
if (c === "\r") return "\\r";
|
|
31
|
+
return `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A delta with its sign, so +2 / -1 / 0 read as movement rather as bare
|
|
37
|
+
* magnitudes. Zero prints unsigned — it is the absence of movement, not a
|
|
38
|
+
* positive one.
|
|
39
|
+
*
|
|
40
|
+
* @param {number|null} value
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function signed(value) {
|
|
44
|
+
if (value === null) return "n/a";
|
|
45
|
+
if (value > 0) return `+${value}`;
|
|
46
|
+
return `${value}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A count that is either established or explicitly unavailable. `null`
|
|
51
|
+
* renders as `n/a` beside the reason the header already stated — never as a
|
|
52
|
+
* zero that would claim a measurement.
|
|
53
|
+
*
|
|
54
|
+
* @param {number|null} value
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function counted(value) {
|
|
58
|
+
return value === null ? "n/a" : `${value}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One structural axis as one line: endpoints, then events, then persistence.
|
|
63
|
+
*
|
|
64
|
+
* @param {{first: number, current: number, delta: number|null,
|
|
65
|
+
* addedEvents: number|null, removedEvents: number|null,
|
|
66
|
+
* changedEvents: number|null, introduced: number|null, resolved: number|null,
|
|
67
|
+
* persistent: number|null}} axis
|
|
68
|
+
* @param {boolean} withChanged Whether the axis carries a changed-event count
|
|
69
|
+
* (projects do; edges do not — a type flip is remove+add under the triple
|
|
70
|
+
* identity).
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function formatAxis(axis, withChanged) {
|
|
74
|
+
const parts = [
|
|
75
|
+
`first ${axis.first}`,
|
|
76
|
+
`current ${axis.current}`,
|
|
77
|
+
`delta ${signed(axis.delta)}`,
|
|
78
|
+
`added ${counted(axis.addedEvents)}`,
|
|
79
|
+
`removed ${counted(axis.removedEvents)}`,
|
|
80
|
+
];
|
|
81
|
+
if (withChanged) parts.push(`changed ${counted(axis.changedEvents)}`);
|
|
82
|
+
parts.push(
|
|
83
|
+
`introduced ${counted(axis.introduced)}`,
|
|
84
|
+
`resolved ${counted(axis.resolved)}`,
|
|
85
|
+
`persistent ${counted(axis.persistent)}`,
|
|
86
|
+
);
|
|
87
|
+
return parts.join(" · ");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The whole trajectory report.
|
|
92
|
+
*
|
|
93
|
+
* @param {{trajectory: {dir: string, observations: {count: number, basis: string,
|
|
94
|
+
* first: string|null, last: string|null, withProvenance: number,
|
|
95
|
+
* dirtyProvenance: number}, available: boolean, unavailableReason: string|null,
|
|
96
|
+
* transitions: {count: number, architecture: number, policy: number,
|
|
97
|
+
* provider: number, codeDrift: number, incomparable: number, unchanged: number},
|
|
98
|
+
* disclosures: {policyOneSided: number, provenanceOneSided: number, crossRepo: number},
|
|
99
|
+
* projects: object, edges: object}, coverage: object}} input
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function formatTrajectoryReport({ trajectory, coverage }) {
|
|
103
|
+
const sections = [];
|
|
104
|
+
|
|
105
|
+
const observations = trajectory.observations;
|
|
106
|
+
sections.push(`trajectory ${trajectory.dir}`);
|
|
107
|
+
sections.push(
|
|
108
|
+
`${observations.count} observation${observations.count === 1 ? "" : "s"} ` +
|
|
109
|
+
`(${observations.basis}), ${trajectory.transitions.count} transition${
|
|
110
|
+
trajectory.transitions.count === 1 ? "" : "s"
|
|
111
|
+
}`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (!trajectory.available) {
|
|
115
|
+
// Named, not implied: a one-snapshot history cannot show movement, and
|
|
116
|
+
// every derived number below stays n/a rather than reading as a zero.
|
|
117
|
+
sections.push(
|
|
118
|
+
`✖ ${trajectory.unavailableReason}: a trajectory needs at least two observations — ` +
|
|
119
|
+
"derived values are unavailable, not zero",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const t = trajectory.transitions;
|
|
124
|
+
sections.push(
|
|
125
|
+
`signals architecture ${t.architecture} · policy ${t.policy} · provider ${t.provider} · ` +
|
|
126
|
+
`code drift ${t.codeDrift} · incomparable ${t.incomparable} · unchanged ${t.unchanged}`,
|
|
127
|
+
);
|
|
128
|
+
sections.push(`projects ${formatAxis(trajectory.projects, true)}`);
|
|
129
|
+
sections.push(`edges ${formatAxis(trajectory.edges, false)}`);
|
|
130
|
+
|
|
131
|
+
const d = trajectory.disclosures;
|
|
132
|
+
sections.push(
|
|
133
|
+
`disclosures policy incomparable ${d.policyOneSided} · provenance incomparable ` +
|
|
134
|
+
`${d.provenanceOneSided} · cross-repo ${d.crossRepo} · ` +
|
|
135
|
+
`dirty captures ${observations.dirtyProvenance} · with provenance ${observations.withProvenance}`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const note of coverage.notes) {
|
|
139
|
+
sections.push(sanitize(note));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return sections.join("\n");
|
|
143
|
+
}
|
package/src/tsconfig-paths.mjs
CHANGED
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
* says (`tsconfigPathsFacts` there).
|
|
78
78
|
*/
|
|
79
79
|
import { posix } from "node:path";
|
|
80
|
+
import { stripTrailingSlashes } from "./path-util.mjs";
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* What a hygiene finding means — one entry per `messageId`, the arrangement
|
|
@@ -116,7 +117,7 @@ function probeDirectory(target, base, root) {
|
|
|
116
117
|
// candidates live in its parent.
|
|
117
118
|
const dir =
|
|
118
119
|
prefix === "" || prefix.endsWith("/")
|
|
119
|
-
? joined
|
|
120
|
+
? stripTrailingSlashes(joined) || "/"
|
|
120
121
|
: posix.dirname(joined);
|
|
121
122
|
if (dir === root) return "";
|
|
122
123
|
return dir.startsWith(`${root}/`) ? dir.slice(root.length + 1) : null;
|
|
@@ -144,7 +145,7 @@ function probeDirectory(target, base, root) {
|
|
|
144
145
|
* verdict; `malformed` is for `../cli.mjs` to refuse loudly, never to skip.
|
|
145
146
|
*/
|
|
146
147
|
export function judgeTsconfigPaths({ paths, base, workspaceRoot, tsConfig, directoryExists }) {
|
|
147
|
-
const root = workspaceRoot
|
|
148
|
+
const root = stripTrailingSlashes(workspaceRoot);
|
|
148
149
|
const findings = [];
|
|
149
150
|
const malformed = [];
|
|
150
151
|
let aliases = 0;
|