@ecoma-io/archkeep 0.21.0 → 0.22.1
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/cli.mjs +156 -66
- package/gate-attestation.mjs +23 -0
- package/package.json +3 -1
- package/src/analysis/analyze.mjs +6 -0
- package/src/analysis/contract.md +32 -5
- package/src/analysis/csharp.mjs +18 -0
- package/src/analysis/go.mjs +18 -0
- package/src/analysis/java.mjs +15 -0
- package/src/analysis/kotlin.mjs +15 -0
- package/src/analysis/python.mjs +25 -3
- package/src/analysis/rust.mjs +18 -0
- package/src/analysis/source-util.mjs +113 -0
- package/src/analysis/typescript.mjs +86 -5
- package/src/canonical.mjs +43 -25
- package/src/commands/README.md +63 -12
- package/src/commands/change-intent.mjs +25 -1
- package/src/commands/change.mjs +90 -40
- package/src/commands/check.mjs +65 -26
- package/src/commands/completeness.mjs +126 -19
- package/src/commands/context-command.mjs +13 -5
- package/src/commands/context.mjs +31 -4
- package/src/commands/coverage-verdict.mjs +191 -0
- package/src/commands/debt.mjs +18 -15
- package/src/commands/delta-classify.mjs +13 -18
- package/src/commands/delta-snapshot.mjs +13 -5
- package/src/commands/delta.mjs +95 -33
- package/src/commands/diff.mjs +31 -24
- package/src/commands/discover.mjs +70 -29
- package/src/commands/drift.mjs +21 -21
- package/src/commands/edge-constraints.mjs +47 -1
- package/src/commands/evaluation-primitives.mjs +194 -2
- package/src/commands/evolution.mjs +27 -10
- package/src/commands/explain.mjs +14 -13
- package/src/commands/fitness.mjs +20 -19
- package/src/commands/graph.mjs +29 -11
- package/src/commands/health.mjs +12 -5
- package/src/commands/history.mjs +41 -26
- package/src/commands/impact.mjs +17 -18
- package/src/commands/plan-context-command.mjs +10 -5
- package/src/commands/reconcile.mjs +14 -17
- package/src/commands/scenario-evaluation.mjs +93 -16
- package/src/commands/scenario.mjs +28 -18
- package/src/commands/waivers.mjs +36 -28
- package/src/governance/evolution-event.mjs +96 -9
- package/src/intent/intent-manifest.json +83 -39
- package/src/lsp/diagnose.mjs +12 -3
- package/src/report/discover-text.mjs +31 -9
- package/src/report/graph-text.mjs +25 -5
- package/src/report/json.mjs +32 -5
- package/src/report/text.mjs +82 -12
- package/src/verdict.mjs +78 -36
- package/src/verify-gate-attestation.mjs +323 -0
- package/src/workspace.mjs +126 -2
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The external blocking-gate attestation — the machine-readable form of
|
|
3
|
+
* `../../../docs/doctrine/roadmap.md`'s second 1.0 condition: a workspace OUTSIDE
|
|
4
|
+
* this repository running `archkeep check` as a blocking gate.
|
|
5
|
+
*
|
|
6
|
+
* An attestation is a small JSON file an external consumer publishes. It
|
|
7
|
+
* carries no verdict authority and proves nothing by existing: it is a claim,
|
|
8
|
+
* structured tightly enough that a reviewer can check every named fact, and
|
|
9
|
+
* this module is what checks the structure so the reviewer never has to.
|
|
10
|
+
* `scripts/check-readiness.mjs` ingests validated attestations through
|
|
11
|
+
* `--attestations`; `scripts/verify-gate-attestation.mjs` is the CLI face of
|
|
12
|
+
* this module; the `./gate-attestation` package subpath is the installed face
|
|
13
|
+
* a consumer reaches from its own tree, so an attestation can be validated
|
|
14
|
+
* against the version that consumer actually installed rather than a clone of
|
|
15
|
+
* this repository.
|
|
16
|
+
*
|
|
17
|
+
* ## What the validator decides, and what it refuses to decide
|
|
18
|
+
*
|
|
19
|
+
* It validates shape and internal consistency only. Three of those decisions
|
|
20
|
+
* carry the whole design, each argued at its field below:
|
|
21
|
+
*
|
|
22
|
+
* - **A green-only run proves nothing** — the `proof` block demands both
|
|
23
|
+
* directions, because "we run Archkeep" without a demonstrated failure and
|
|
24
|
+
* recovery is indistinguishable from a command that exits 0 on everything.
|
|
25
|
+
* - **The red direction must be exit 1** — archkeep's own documented contract
|
|
26
|
+
* (`docs/reference/exit-codes.md`): findings are 1, a run that could not
|
|
27
|
+
* complete is 3, and a gate failing on 3 is a broken install, not a boundary
|
|
28
|
+
* verdict. Demanding exactly 1 keeps "it crashed" from counting as "it
|
|
29
|
+
* blocked".
|
|
30
|
+
* - **Unknown fields refuse** — an attestation proves exactly the fields
|
|
31
|
+
* below; a schema that silently accepts extra keys grows spellings nobody
|
|
32
|
+
* validates.
|
|
33
|
+
*
|
|
34
|
+
* What NO validator here can decide: whether the named commit exists, whether
|
|
35
|
+
* CI actually ran, whether the run URLs are real. Those facts live outside any
|
|
36
|
+
* file's reach, which is why readiness stays a report and the acceptance is a
|
|
37
|
+
* human's — the module's job ends at making the claim precise enough to check.
|
|
38
|
+
*
|
|
39
|
+
* Security posture: this module executes nothing from the attestation, spawns
|
|
40
|
+
* no child process, and treats every byte as untrusted data (`SECURITY.md`).
|
|
41
|
+
* A malformed file is a refusal naming every problem found, never a partial
|
|
42
|
+
* pass — an attestation with one bad field and nine good ones proves nothing,
|
|
43
|
+
* so reporting the ten good ones beside it would be the silent direction.
|
|
44
|
+
*/
|
|
45
|
+
import { readFileSync } from "node:fs";
|
|
46
|
+
|
|
47
|
+
/** Bumped when a field's meaning changes, never reused. */
|
|
48
|
+
export const GATE_ATTESTATION_SCHEMA_VERSION = 1;
|
|
49
|
+
|
|
50
|
+
/** The only package whose adoption this condition speaks about. */
|
|
51
|
+
export const ATTESTED_PACKAGE = "@ecoma-io/archkeep";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One external consumer's blocking-gate claim.
|
|
55
|
+
*
|
|
56
|
+
* @typedef {object} GateAttestation
|
|
57
|
+
* @property {1} schemaVersion This format's version; a different number is a
|
|
58
|
+
* different format and is refused rather than guessed at.
|
|
59
|
+
* @property {string} repository The consumer, `owner/name`.
|
|
60
|
+
* @property {string} commit The full 40-hex SHA of the consumer commit the
|
|
61
|
+
* gate ran at — the binding that makes the evidence stale-detectable.
|
|
62
|
+
* @property {{name: string, version: string}} tool Exactly
|
|
63
|
+
* `@ecoma-io/archkeep`, and the semver the gate ran.
|
|
64
|
+
* @property {{command: string, blocking: true}} gate The command their CI
|
|
65
|
+
* runs, and the claim that it blocks — `false` here is a report, not a gate.
|
|
66
|
+
* @property {{violationExitCode: 1, recoveryExitCode: 0}} proof Both
|
|
67
|
+
* directions demonstrated: a controlled violation failed the build with the
|
|
68
|
+
* findings exit code, and removing it restored green.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether the attested `gate.command` names an invocation that verifiably
|
|
73
|
+
* reaches the boundary verdict through one of this package's documented entry
|
|
74
|
+
* points.
|
|
75
|
+
*
|
|
76
|
+
* Two spellings count, and nothing else:
|
|
77
|
+
*
|
|
78
|
+
* 1. **The command names the check subcommand.** `/\bcheck\b/` matches
|
|
79
|
+
* `archkeep check`, `npx archkeep check`, `pnpm archkeep check`, and any
|
|
80
|
+
* npm-script alias whose own name carries `check` as a word
|
|
81
|
+
* (`archkeep:check`, `check-boundaries`) — every one of these is the check
|
|
82
|
+
* invocation on its face.
|
|
83
|
+
* 2. **The command is a package-manager script alias whose name is this
|
|
84
|
+
* tool's own.** A consumer whose CI step is `pnpm arch` (an npm script
|
|
85
|
+
* defined as `archkeep check`) writes exactly what its CI runs. The alias
|
|
86
|
+
* name must be the whole word `arch` or begin `archkeep` — the spellings
|
|
87
|
+
* this package's own name produces — and must arrive through the
|
|
88
|
+
* package-manager script form (`pnpm`, `npm run`, `yarn`). `npm test`,
|
|
89
|
+
* `pnpm build` and `pnpm archive` are refused: a rule that accepted every
|
|
90
|
+
* script name would be a gate that verifies nothing.
|
|
91
|
+
*
|
|
92
|
+
* What no string test can decide: whether the alias actually resolves to
|
|
93
|
+
* `archkeep check`. The attestation's `proof` block — exit 1 on a controlled
|
|
94
|
+
* violation, exit 0 after recovery — is what demonstrates the gate works; the
|
|
95
|
+
* command test exists to keep the field from naming something that could not
|
|
96
|
+
* be that gate at all.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} command The attested `gate.command`.
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
export function reachesCheckVerdict(command) {
|
|
102
|
+
return (
|
|
103
|
+
/\bcheck\b/u.test(command) ||
|
|
104
|
+
/^(?:pnpm\s|npm\s+run\s|yarn\s+)(?:arch\b|archkeep)/u.test(command)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validates one parsed attestation, returning the normalized record readiness
|
|
110
|
+
* ingests. Throws naming EVERY problem, because an attestation is accepted
|
|
111
|
+
* whole or not at all.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} record The parsed JSON document.
|
|
114
|
+
* @returns {{repository: string, commit: string, version: string, command: string}}
|
|
115
|
+
* @throws {Error} On the first round-trip where anything is wrong — with
|
|
116
|
+
* every wrong thing named.
|
|
117
|
+
*/
|
|
118
|
+
export function validateGateAttestation(record) {
|
|
119
|
+
if (record === null || typeof record !== "object" || Array.isArray(record)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"archkeep: a gate attestation must be a single JSON object — got " +
|
|
122
|
+
`${record === null ? "null" : Array.isArray(record) ? "an array" : typeof record}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @type {string[]} */
|
|
127
|
+
const problems = [];
|
|
128
|
+
const r = /** @type {Record<string, unknown>} */ (record);
|
|
129
|
+
|
|
130
|
+
if (r.schemaVersion !== GATE_ATTESTATION_SCHEMA_VERSION) {
|
|
131
|
+
problems.push(
|
|
132
|
+
`"schemaVersion" must be ${GATE_ATTESTATION_SCHEMA_VERSION}; got ` +
|
|
133
|
+
`${JSON.stringify(r.schemaVersion) ?? "undefined"} — a different number is a ` +
|
|
134
|
+
`different format, and reading one as the other would invent meanings`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
typeof r.repository !== "string" ||
|
|
140
|
+
!/^[A-Za-z0-9][A-Za-z0-9.-]*\/[A-Za-z0-9._-]+$/u.test(r.repository)
|
|
141
|
+
) {
|
|
142
|
+
problems.push(
|
|
143
|
+
`"repository" must be 'owner/name'; got ${JSON.stringify(r.repository) ?? "undefined"} — ` +
|
|
144
|
+
`the condition is about a workspace OUTSIDE this repository, and a name this ` +
|
|
145
|
+
`shape cannot hold cannot name one`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof r.commit !== "string" || !/^[0-9a-f]{40}$/u.test(r.commit)) {
|
|
150
|
+
problems.push(
|
|
151
|
+
`"commit" must be the full 40-hex SHA the gate ran at; got ` +
|
|
152
|
+
`${JSON.stringify(r.commit) ?? "undefined"} — a short or symbolic ref could name ` +
|
|
153
|
+
`a different commit tomorrow, which is how stale evidence goes unnoticed`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tool = r.tool;
|
|
158
|
+
if (tool === null || typeof tool !== "object" || Array.isArray(tool)) {
|
|
159
|
+
problems.push(`"tool" must be an object; got ${tool === null ? "null" : typeof tool}`);
|
|
160
|
+
} else {
|
|
161
|
+
const t = /** @type {Record<string, unknown>} */ (tool);
|
|
162
|
+
if (t.name !== ATTESTED_PACKAGE) {
|
|
163
|
+
problems.push(
|
|
164
|
+
`"tool.name" must be '${ATTESTED_PACKAGE}'; got ${JSON.stringify(t.name) ?? "undefined"} — ` +
|
|
165
|
+
`this condition speaks about this package and no other`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (typeof t.version !== "string" || !/^\d+\.\d+\.\d+$/u.test(t.version)) {
|
|
169
|
+
problems.push(
|
|
170
|
+
`"tool.version" must be a bare semver (major.minor.patch); got ` +
|
|
171
|
+
`${JSON.stringify(t.version) ?? "undefined"} — ranges and tags would make the ` +
|
|
172
|
+
`claim unverifiable against a registry document`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const gate = r.gate;
|
|
178
|
+
if (gate === null || typeof gate !== "object" || Array.isArray(gate)) {
|
|
179
|
+
problems.push(`"gate" must be an object; got ${gate === null ? "null" : typeof gate}`);
|
|
180
|
+
} else {
|
|
181
|
+
const g = /** @type {Record<string, unknown>} */ (gate);
|
|
182
|
+
if (g.blocking !== true) {
|
|
183
|
+
problems.push(
|
|
184
|
+
`"gate.blocking" must be true; got ${JSON.stringify(g.blocking) ?? "undefined"} — ` +
|
|
185
|
+
`a non-blocking run is a report about architecture, not a gate a build answers to`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (
|
|
189
|
+
typeof g.command !== "string" ||
|
|
190
|
+
!reachesCheckVerdict(g.command) ||
|
|
191
|
+
g.command.trim() === ""
|
|
192
|
+
) {
|
|
193
|
+
problems.push(
|
|
194
|
+
`"gate.command" must name the check invocation their CI runs; got ` +
|
|
195
|
+
`${JSON.stringify(g.command) ?? "undefined"} — the command must reach the boundary ` +
|
|
196
|
+
`verdict: either it names the check subcommand, or it is a package-manager script ` +
|
|
197
|
+
`alias whose name is this tool's own (pnpm arch, npm run arch, pnpm archkeep…)`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const proof = r.proof;
|
|
203
|
+
if (proof === null || typeof proof !== "object" || Array.isArray(proof)) {
|
|
204
|
+
problems.push(`"proof" must be an object; got ${proof === null ? "null" : typeof proof}`);
|
|
205
|
+
} else {
|
|
206
|
+
const p = /** @type {Record<string, unknown>} */ (proof);
|
|
207
|
+
if (p.violationExitCode !== 1) {
|
|
208
|
+
problems.push(
|
|
209
|
+
`"proof.violationExitCode" must be 1 — archkeep's documented findings exit; got ` +
|
|
210
|
+
`${JSON.stringify(p.violationExitCode) ?? "undefined"}. 0 would prove the gate ` +
|
|
211
|
+
`never blocks, and 3 proves it could not look, not that it judged`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (p.recoveryExitCode !== 0) {
|
|
215
|
+
problems.push(
|
|
216
|
+
`"proof.recoveryExitCode" must be 0 — removing the violation restored green; got ` +
|
|
217
|
+
`${JSON.stringify(p.recoveryExitCode) ?? "undefined"}. Without the recovery half, ` +
|
|
218
|
+
`a permanently red pipeline would satisfy this condition too`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const known = new Set(["schemaVersion", "repository", "commit", "tool", "gate", "proof"]);
|
|
224
|
+
const unknownKeys = Object.keys(r).filter((key) => !known.has(key));
|
|
225
|
+
if (unknownKeys.length > 0) {
|
|
226
|
+
problems.push(
|
|
227
|
+
`unknown field(s) ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — an ` +
|
|
228
|
+
`attestation proves exactly the fields this format defines; extra keys would be ` +
|
|
229
|
+
`claims nobody validates`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (problems.length > 0) {
|
|
234
|
+
throw new Error(`archkeep: the gate attestation is not valid:\n - ${problems.join("\n - ")}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const t = /** @type {{name: string, version: string}} */ (r.tool);
|
|
238
|
+
const g = /** @type {{command: string}} */ (r.gate);
|
|
239
|
+
return {
|
|
240
|
+
repository: /** @type {string} */ (r.repository),
|
|
241
|
+
commit: /** @type {string} */ (r.commit),
|
|
242
|
+
version: t.version,
|
|
243
|
+
command: g.command,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reduces validated attestations to what `scripts/check-readiness.mjs`'s
|
|
249
|
+
* `evaluate` reads as `externalAdopters`. When a registry document was
|
|
250
|
+
* supplied, the attested version must be one it published — an attestation
|
|
251
|
+
* about a version nobody can install describes a gate that cannot exist yet.
|
|
252
|
+
*
|
|
253
|
+
* The same adopter arriving twice (a recovery chain's red and green halves,
|
|
254
|
+
* or a re-run over the same file) collapses to one entry: readiness prints
|
|
255
|
+
* one row per repository, not one per attestation file.
|
|
256
|
+
*
|
|
257
|
+
* @param {{repository: string, commit: string, version: string, command: string}[]} attestations
|
|
258
|
+
* Already-validated records, in file order.
|
|
259
|
+
* @param {string[] | null} publishedVersions From `versionsFromRegistry`, or
|
|
260
|
+
* `null` when no registry document was supplied.
|
|
261
|
+
* @returns {string[]} Distinct `owner/name@version` entries, first-seen order,
|
|
262
|
+
* ready for `adoptionRow`.
|
|
263
|
+
* @throws {Error} When a registry document was supplied and an attested
|
|
264
|
+
* version is absent from it.
|
|
265
|
+
*/
|
|
266
|
+
export function verifiedAdopters(attestations, publishedVersions) {
|
|
267
|
+
if (publishedVersions !== null) {
|
|
268
|
+
for (const attestation of attestations) {
|
|
269
|
+
if (!publishedVersions.includes(attestation.version)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`archkeep: ${attestation.repository}'s attestation names ${ATTESTED_PACKAGE} ` +
|
|
272
|
+
`${attestation.version}, which the supplied registry document has never ` +
|
|
273
|
+
`published — a gate built from a version nobody can install proves nothing ` +
|
|
274
|
+
`about the package consumers get`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** @type {string[]} */
|
|
280
|
+
const entries = [];
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
for (const { repository, version } of attestations) {
|
|
283
|
+
const entry = `${repository}@${version}`;
|
|
284
|
+
if (!seen.has(entry)) {
|
|
285
|
+
seen.add(entry);
|
|
286
|
+
entries.push(entry);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return entries;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Reads and validates one attestation file, naming the path in every error.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} path
|
|
296
|
+
* @returns {{repository: string, commit: string, version: string, command: string}}
|
|
297
|
+
* @throws {Error}
|
|
298
|
+
*/
|
|
299
|
+
export function readGateAttestation(path) {
|
|
300
|
+
let text;
|
|
301
|
+
try {
|
|
302
|
+
text = readFileSync(path, "utf8");
|
|
303
|
+
} catch (cause) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`archkeep: cannot read the gate attestation at '${path}': ${cause?.message ?? cause}`,
|
|
306
|
+
{ cause },
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
let parsed;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(text);
|
|
312
|
+
} catch (cause) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`archkeep: the gate attestation at '${path}' is not valid JSON: ${cause?.message ?? cause}`,
|
|
315
|
+
{ cause },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
return validateGateAttestation(parsed);
|
|
320
|
+
} catch (cause) {
|
|
321
|
+
throw new Error(`archkeep: ${path}\n${cause?.message ?? cause}`, { cause });
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/workspace.mjs
CHANGED
|
@@ -598,12 +598,130 @@ export function selectFiles(files, paths, { root, cwd, tracked = files }) {
|
|
|
598
598
|
* @param {{ analyze?: typeof analyzeFile }} [io] Injectable analyzer.
|
|
599
599
|
* @returns {{ imports: object[], failures: object[], analyzed: number, analyzedFiles: string[] }}
|
|
600
600
|
*/
|
|
601
|
+
/**
|
|
602
|
+
* Extensions that cannot carry an import or a boundary crossing, so their
|
|
603
|
+
* silence in analysis is never a coverage gap (#601): documentation,
|
|
604
|
+
* structured data and configuration, binary assets, generated lockfiles.
|
|
605
|
+
* A file in one of these formats has no imports for ANY analyzer to read —
|
|
606
|
+
* listing it under "unsupported language" would name every README in every
|
|
607
|
+
* workspace, and a gap that always fires teaches a reader to skip the line
|
|
608
|
+
* it is written on (the argument `./commands/context.mjs`'s
|
|
609
|
+
* `unownedAnalyzableFiles` already states for the same reason). The set must
|
|
610
|
+
* never intersect `LANGUAGE_BY_EXTENSION` — a format that cannot carry an
|
|
611
|
+
* import cannot become an analyzed language; `workspace.test.mjs` holds that
|
|
612
|
+
* line, because an entry that crossed it would turn this exemption into the
|
|
613
|
+
* silent direction.
|
|
614
|
+
*/
|
|
615
|
+
const DATA_BY_EXTENSION = Object.freeze(
|
|
616
|
+
new Set([
|
|
617
|
+
// Documentation.
|
|
618
|
+
".md",
|
|
619
|
+
".txt",
|
|
620
|
+
".rst",
|
|
621
|
+
".adoc",
|
|
622
|
+
// Structured data and configuration.
|
|
623
|
+
".json",
|
|
624
|
+
".json5",
|
|
625
|
+
".jsonc",
|
|
626
|
+
".yaml",
|
|
627
|
+
".yml",
|
|
628
|
+
".toml",
|
|
629
|
+
".ini",
|
|
630
|
+
".cfg",
|
|
631
|
+
".conf",
|
|
632
|
+
".properties",
|
|
633
|
+
".xml",
|
|
634
|
+
// Binary assets.
|
|
635
|
+
".png",
|
|
636
|
+
".jpg",
|
|
637
|
+
".jpeg",
|
|
638
|
+
".gif",
|
|
639
|
+
".svg",
|
|
640
|
+
".webp",
|
|
641
|
+
".ico",
|
|
642
|
+
".bmp",
|
|
643
|
+
".woff",
|
|
644
|
+
".woff2",
|
|
645
|
+
".ttf",
|
|
646
|
+
".otf",
|
|
647
|
+
".eot",
|
|
648
|
+
".mp4",
|
|
649
|
+
".mp3",
|
|
650
|
+
".wav",
|
|
651
|
+
".pdf",
|
|
652
|
+
// Archives and generated artifacts.
|
|
653
|
+
".zip",
|
|
654
|
+
".gz",
|
|
655
|
+
".tgz",
|
|
656
|
+
".tar",
|
|
657
|
+
".br",
|
|
658
|
+
// Generated lockfiles.
|
|
659
|
+
".lock",
|
|
660
|
+
".sum",
|
|
661
|
+
]),
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Extension-less basenames that are workspace furniture, not source: legal
|
|
666
|
+
* notices and the two command files whose bodies are shell commands rather
|
|
667
|
+
* than imports. `languageOf` answers `null` for every dotless name already —
|
|
668
|
+
* this set is what keeps them out of the unsupported-language row without
|
|
669
|
+
* inventing a general dotfile list.
|
|
670
|
+
*/
|
|
671
|
+
const DOTLESS_FURNITURE = Object.freeze(
|
|
672
|
+
new Set([
|
|
673
|
+
"LICENSE",
|
|
674
|
+
"NOTICE",
|
|
675
|
+
"AUTHORS",
|
|
676
|
+
"CHANGELOG",
|
|
677
|
+
"CODEOWNERS",
|
|
678
|
+
"CONTRIBUTING",
|
|
679
|
+
"SECURITY",
|
|
680
|
+
"Makefile",
|
|
681
|
+
"Dockerfile",
|
|
682
|
+
]),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Whether a file no analyzer claims is nevertheless not a coverage gap —
|
|
687
|
+
* the predicate `analyzeWorkspace` applies before naming a skipped file
|
|
688
|
+
* under `unsupported-language` (#601). Three exemptions, each with the same
|
|
689
|
+
* shape of reason: the file was never part of what this tool could judge.
|
|
690
|
+
*
|
|
691
|
+
* @param {string} sourceFile Workspace-relative path.
|
|
692
|
+
* @returns {boolean}
|
|
693
|
+
*/
|
|
694
|
+
export function exemptFromUnsupportedLanguage(sourceFile) {
|
|
695
|
+
const base = sourceFile.slice(sourceFile.lastIndexOf("/") + 1);
|
|
696
|
+
// Dotfiles are editor and tool state (`.gitignore`, `.env`, `.npmrc`).
|
|
697
|
+
if (base.startsWith(".")) return true;
|
|
698
|
+
const dot = base.lastIndexOf(".");
|
|
699
|
+
if (dot > 0 && DATA_BY_EXTENSION.has(base.slice(dot))) return true;
|
|
700
|
+
if (DOTLESS_FURNITURE.has(base)) return true;
|
|
701
|
+
// The polyglot manifests the manifest track itself reads — a `go.mod` is
|
|
702
|
+
// claimed by the engine one layer down, not skipped by it.
|
|
703
|
+
return basenameMatches(base, POLYGLOT_MANIFEST_NAMES, posix.matchesGlob);
|
|
704
|
+
}
|
|
705
|
+
|
|
601
706
|
export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {}) {
|
|
602
707
|
const imports = [];
|
|
603
708
|
const failures = [];
|
|
604
709
|
const analyzedFiles = [];
|
|
710
|
+
// Files whose extension no analyzer claims AND whose silence is a coverage
|
|
711
|
+
// gap (#601): skipped before reading, so naming them here is the only
|
|
712
|
+
// record the run will ever carry that they existed — "not analyzed" must
|
|
713
|
+
// not be indistinguishable from "not present". READMEs, manifests and
|
|
714
|
+
// other formats that cannot carry an import are exempt
|
|
715
|
+
// (`exemptFromUnsupportedLanguage` above): they are not an unsupported
|
|
716
|
+
// language, they are workspace furniture the tool has always declined.
|
|
717
|
+
const unsupportedLanguageFiles = [];
|
|
605
718
|
for (const sourceFile of files) {
|
|
606
|
-
if (languageOf(sourceFile) === null)
|
|
719
|
+
if (languageOf(sourceFile) === null) {
|
|
720
|
+
if (!exemptFromUnsupportedLanguage(sourceFile)) {
|
|
721
|
+
unsupportedLanguageFiles.push(sourceFile);
|
|
722
|
+
}
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
607
725
|
const text = workspace.readFile(sourceFile);
|
|
608
726
|
if (text === null) {
|
|
609
727
|
failures.push(fileFailure(sourceFile, "could not be read"));
|
|
@@ -614,7 +732,13 @@ export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {
|
|
|
614
732
|
imports.push(...result.imports);
|
|
615
733
|
failures.push(...result.failures);
|
|
616
734
|
}
|
|
617
|
-
return {
|
|
735
|
+
return {
|
|
736
|
+
imports,
|
|
737
|
+
failures,
|
|
738
|
+
analyzed: analyzedFiles.length,
|
|
739
|
+
analyzedFiles,
|
|
740
|
+
unsupportedLanguageFiles,
|
|
741
|
+
};
|
|
618
742
|
}
|
|
619
743
|
|
|
620
744
|
/** The polyglot manifests `polyglotManifests` looks for. */
|