@ecoma-io/archkeep 0.18.0 → 0.19.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 +1 -3
- package/cli.mjs +167 -5
- package/commands.mjs +2 -0
- package/package.json +1 -1
- package/src/analysis/kotlin.mjs +60 -0
- package/src/analysis/python.mjs +124 -73
- package/src/commands/diff.mjs +15 -7
- package/src/commands/discover.mjs +40 -0
- package/src/commands/explain.mjs +11 -0
- package/src/commands/impact-statement.mjs +308 -0
- package/src/commands/impact.mjs +9 -0
- package/src/commands/rules.mjs +3 -1
- package/src/commands/scenario-evaluation.mjs +515 -0
- package/src/commands/scenario.mjs +184 -0
- package/src/report/evidence.mjs +13 -0
package/README.md
CHANGED
|
@@ -220,12 +220,10 @@ Exit codes: 0 clean — and every selected file was analyzed; 1 findings;
|
|
|
220
220
|
([exit-codes.md](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/exit-codes.md) ·
|
|
221
221
|
[ci.md](https://github.com/ecoma-io/archkeep/blob/main/docs/usage/ci.md)).
|
|
222
222
|
|
|
223
|
-
Ten minutes end to end, most of it spent deciding what your tags mean:
|
|
224
|
-
[**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
|
|
225
223
|
Ten minutes end to end, most of it spent deciding what your tags mean:
|
|
226
224
|
[**Getting started →**](https://github.com/ecoma-io/archkeep/blob/main/docs/getting-started/installation.md). `graph`, `diff`,
|
|
227
225
|
`history`, `trajectory`, `evolution`, `drift`, `impact`, `explain`,
|
|
228
|
-
`context` and the rest of the
|
|
226
|
+
`context` and the rest of the 24-command surface are in the
|
|
229
227
|
[CLI reference](https://github.com/ecoma-io/archkeep/blob/main/docs/reference/cli.md).
|
|
230
228
|
|
|
231
229
|
## Documentation map
|
package/cli.mjs
CHANGED
|
@@ -93,6 +93,11 @@
|
|
|
93
93
|
*/
|
|
94
94
|
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
95
95
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
96
|
+
import { createRequire } from "node:module";
|
|
97
|
+
|
|
98
|
+
const require = createRequire(import.meta.url);
|
|
99
|
+
/*** @type {{name: string, version: string}} */
|
|
100
|
+
const { name: TOOL_NAME, version: TOOL_VERSION } = require("./package.json");
|
|
96
101
|
|
|
97
102
|
import { containmentViolation } from "./src/containment.mjs";
|
|
98
103
|
import { UsageError } from "./src/errors.mjs";
|
|
@@ -110,7 +115,7 @@ import { adrCommand } from "./src/commands/adr.mjs";
|
|
|
110
115
|
import { decisionsCommand } from "./src/commands/decisions.mjs";
|
|
111
116
|
import { diffCommand } from "./src/commands/diff.mjs";
|
|
112
117
|
import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
|
|
113
|
-
import { discoverCommand } from "./src/commands/discover.mjs";
|
|
118
|
+
import { discoverCommand, proposalToIntent } from "./src/commands/discover.mjs";
|
|
114
119
|
import { driftCommand } from "./src/commands/drift.mjs";
|
|
115
120
|
import { fitnessCommand } from "./src/commands/fitness.mjs";
|
|
116
121
|
import { reconcileCommand } from "./src/commands/reconcile.mjs";
|
|
@@ -124,6 +129,7 @@ import { reportCommand } from "./src/commands/report.mjs";
|
|
|
124
129
|
import { debtCommand } from "./src/commands/debt.mjs";
|
|
125
130
|
import { explainCommand } from "./src/commands/explain.mjs";
|
|
126
131
|
import { impactCommand } from "./src/commands/impact.mjs";
|
|
132
|
+
import { scenarioCommand } from "./src/commands/scenario.mjs";
|
|
127
133
|
import { provenanceCommand } from "./src/commands/provenance-command.mjs";
|
|
128
134
|
import {
|
|
129
135
|
rulesAddCommand,
|
|
@@ -1542,6 +1548,72 @@ async function runImpact(options, { cwd, env }) {
|
|
|
1542
1548
|
// Impact is descriptive: 0 when it completes, never 1.
|
|
1543
1549
|
return EXIT.ok;
|
|
1544
1550
|
}
|
|
1551
|
+
/**
|
|
1552
|
+
* `scenario`'s `run`: resolves the command context, reads the scenario file,
|
|
1553
|
+
* drives `scenarioCommand`, writes the report, and returns the exit code.
|
|
1554
|
+
*
|
|
1555
|
+
* The project name is the single positional argument. `--scenario-file` names
|
|
1556
|
+
* the JSON scenario description. `--config` is accepted, same as `check`,
|
|
1557
|
+
* because the constraint-impact analysis depends on which boundary law is in
|
|
1558
|
+
* effect.
|
|
1559
|
+
*
|
|
1560
|
+
* @param {{format: string, output: string|null, config: string|null, scenarioFile: string|null, paths: string[]}} options
|
|
1561
|
+
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
1562
|
+
* @returns {Promise<number>}
|
|
1563
|
+
*/
|
|
1564
|
+
async function runScenario(options, { cwd, env }) {
|
|
1565
|
+
if (options.paths.length !== 1) {
|
|
1566
|
+
env.err(
|
|
1567
|
+
`archkeep: scenario takes exactly one positional argument (the project name); ` +
|
|
1568
|
+
`got ${options.paths.length}`,
|
|
1569
|
+
);
|
|
1570
|
+
return EXIT.usage;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
if (!options.scenarioFile) {
|
|
1574
|
+
env.err("archkeep: scenario requires --scenario-file <file>");
|
|
1575
|
+
return EXIT.usage;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const projectName = options.paths[0];
|
|
1579
|
+
const scenarioFilePath = resolve(cwd, options.scenarioFile);
|
|
1580
|
+
|
|
1581
|
+
let scenarioJson;
|
|
1582
|
+
try {
|
|
1583
|
+
scenarioJson = readFileSync(scenarioFilePath, "utf-8");
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
env.err(`archkeep: cannot read scenario file "${options.scenarioFile}": ${error.message}`);
|
|
1586
|
+
return EXIT.usage;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
let result;
|
|
1590
|
+
try {
|
|
1591
|
+
const commandContext = resolveCommandContext(
|
|
1592
|
+
{ cwd },
|
|
1593
|
+
{ readGraph: env.readGraph, listFiles: env.listFiles },
|
|
1594
|
+
);
|
|
1595
|
+
|
|
1596
|
+
const { config } = await resolvePolicy(options, commandContext, cwd);
|
|
1597
|
+
|
|
1598
|
+
result = scenarioCommand(projectName, scenarioJson, commandContext, config);
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
const usageError = error instanceof UsageError;
|
|
1601
|
+
env.err(String(error?.message ?? error));
|
|
1602
|
+
return usageError ? EXIT.usage : EXIT.error;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
const report = options.format === "json" ? result.report.json : result.report.text;
|
|
1606
|
+
|
|
1607
|
+
if (options.output) {
|
|
1608
|
+
if (!writeOutputReport(options.output, report, env, cwd, options.config)) return EXIT.error;
|
|
1609
|
+
env.err(`archkeep: scenario for "${projectName}" complete → ${options.output}`);
|
|
1610
|
+
} else {
|
|
1611
|
+
env.out(report);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Scenario is descriptive: 0 when it completes, never 1.
|
|
1615
|
+
return EXIT.ok;
|
|
1616
|
+
}
|
|
1545
1617
|
|
|
1546
1618
|
/**
|
|
1547
1619
|
* `explain`'s `run`: resolves the command context, loads the boundary config,
|
|
@@ -2207,7 +2279,7 @@ async function runDebt(options, { cwd, env }) {
|
|
|
2207
2279
|
* graph. `--propose` is opt-in: a proposal is a suggestion, and a workspace
|
|
2208
2280
|
* that does not ask for one must not get one.
|
|
2209
2281
|
*
|
|
2210
|
-
* @param {{format: string, output: string|null, propose: boolean, paths: string[]}} options
|
|
2282
|
+
* @param {{format: string, output: string|null, propose: boolean, writeIntent: string|null, paths: string[]}} options
|
|
2211
2283
|
* @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
|
|
2212
2284
|
* @returns {Promise<number>}
|
|
2213
2285
|
*/
|
|
@@ -2217,6 +2289,11 @@ async function runDiscover(options, { cwd, env }) {
|
|
|
2217
2289
|
return EXIT.usage;
|
|
2218
2290
|
}
|
|
2219
2291
|
|
|
2292
|
+
if (options.writeIntent && !options.propose) {
|
|
2293
|
+
env.err("archkeep: --write-intent requires --propose");
|
|
2294
|
+
return EXIT.usage;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2220
2297
|
let result;
|
|
2221
2298
|
try {
|
|
2222
2299
|
const commandContext = resolveCommandContext(
|
|
@@ -2252,6 +2329,17 @@ async function runDiscover(options, { cwd, env }) {
|
|
|
2252
2329
|
env.out(report);
|
|
2253
2330
|
}
|
|
2254
2331
|
|
|
2332
|
+
if (options.writeIntent) {
|
|
2333
|
+
try {
|
|
2334
|
+
const intentJson = JSON.stringify(proposalToIntent(result.proposal), null, 2) + "\n";
|
|
2335
|
+
writeFileSync(options.writeIntent, intentJson, "utf-8");
|
|
2336
|
+
env.err(`archkeep: proposed architecture written to ${options.writeIntent}`);
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
env.err(`archkeep: failed to write intent file: ${error.message}`);
|
|
2339
|
+
return EXIT.error;
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2255
2343
|
// Discover is descriptive: 0 when it completes, never 1.
|
|
2256
2344
|
return result.status === "ok" ? EXIT.ok : EXIT.error;
|
|
2257
2345
|
}
|
|
@@ -2541,9 +2629,10 @@ const DELTA_FLAG_HELP = Object.freeze([
|
|
|
2541
2629
|
key: "capture",
|
|
2542
2630
|
arg: "",
|
|
2543
2631
|
describe: Object.freeze([
|
|
2544
|
-
"
|
|
2632
|
+
"Print an evidence snapshot of the current tree",
|
|
2545
2633
|
"(raw import records, graph, coverage, policy",
|
|
2546
|
-
"fingerprint) for a later delta run to compare against",
|
|
2634
|
+
"fingerprint) for a later delta run to compare against.",
|
|
2635
|
+
"Without --output, the snapshot goes to stdout.",
|
|
2547
2636
|
]),
|
|
2548
2637
|
}),
|
|
2549
2638
|
Object.freeze({
|
|
@@ -2703,6 +2792,17 @@ const DISCOVER_FLAG_HELP = Object.freeze([
|
|
|
2703
2792
|
"authoritative; nothing is ever written",
|
|
2704
2793
|
]),
|
|
2705
2794
|
}),
|
|
2795
|
+
Object.freeze({
|
|
2796
|
+
flag: "--write-intent",
|
|
2797
|
+
key: "writeIntent",
|
|
2798
|
+
arg: "<file>",
|
|
2799
|
+
describe: Object.freeze([
|
|
2800
|
+
"Write the proposed components and rules to a valid",
|
|
2801
|
+
"architecture-intent.json file. Only valid with --propose;",
|
|
2802
|
+
"the file is a candidate for drift/reconcile and must be",
|
|
2803
|
+
"reviewed before use",
|
|
2804
|
+
]),
|
|
2805
|
+
}),
|
|
2706
2806
|
Object.freeze({
|
|
2707
2807
|
flag: "--format",
|
|
2708
2808
|
key: "format",
|
|
@@ -3102,6 +3202,48 @@ const IMPACT_FLAG_HELP = Object.freeze([
|
|
|
3102
3202
|
]),
|
|
3103
3203
|
}),
|
|
3104
3204
|
]);
|
|
3205
|
+
/**
|
|
3206
|
+
* `scenario`'s flags: text or JSON envelope, optional file output, and
|
|
3207
|
+
* `--scenario-file` to specify the scenario input (required). The project
|
|
3208
|
+
* name is positional.
|
|
3209
|
+
*
|
|
3210
|
+
* @type {readonly FlagHelp[]}
|
|
3211
|
+
*/
|
|
3212
|
+
const SCENARIO_FLAG_HELP = Object.freeze([
|
|
3213
|
+
Object.freeze({
|
|
3214
|
+
flag: "--format",
|
|
3215
|
+
key: "format",
|
|
3216
|
+
arg: "text|json",
|
|
3217
|
+
describe: Object.freeze([
|
|
3218
|
+
"Terminal report (default) or the versioned JSON envelope",
|
|
3219
|
+
"docs/reference/json-output.md documents",
|
|
3220
|
+
]),
|
|
3221
|
+
}),
|
|
3222
|
+
Object.freeze({
|
|
3223
|
+
flag: "--output",
|
|
3224
|
+
key: "output",
|
|
3225
|
+
arg: "<file>",
|
|
3226
|
+
describe: Object.freeze(["Write the report to a file instead of stdout"]),
|
|
3227
|
+
}),
|
|
3228
|
+
Object.freeze({
|
|
3229
|
+
flag: "--config",
|
|
3230
|
+
key: "config",
|
|
3231
|
+
arg: "<file>",
|
|
3232
|
+
describe: Object.freeze([
|
|
3233
|
+
"Read the boundary law from here instead of",
|
|
3234
|
+
"<workspace root>/module-boundaries.config.mjs",
|
|
3235
|
+
]),
|
|
3236
|
+
}),
|
|
3237
|
+
Object.freeze({
|
|
3238
|
+
flag: "--scenario-file",
|
|
3239
|
+
key: "scenarioFile",
|
|
3240
|
+
arg: "<file>",
|
|
3241
|
+
describe: Object.freeze([
|
|
3242
|
+
"Path to the scenario JSON file describing the",
|
|
3243
|
+
"hypothetical changes to evaluate",
|
|
3244
|
+
]),
|
|
3245
|
+
}),
|
|
3246
|
+
]);
|
|
3105
3247
|
|
|
3106
3248
|
/**
|
|
3107
3249
|
* `explain`'s flags: text or JSON envelope, optional file output.
|
|
@@ -3384,7 +3526,7 @@ const COMMANDS = Object.freeze({
|
|
|
3384
3526
|
summary: "Report observed facts, and optionally propose candidate architecture",
|
|
3385
3527
|
flagHelp: DISCOVER_FLAG_HELP,
|
|
3386
3528
|
flags: Object.freeze(Object.fromEntries(DISCOVER_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3387
|
-
defaults: Object.freeze({ format: "text", output: null, propose: false }),
|
|
3529
|
+
defaults: Object.freeze({ format: "text", output: null, propose: false, writeIntent: null }),
|
|
3388
3530
|
formats: DESCRIBABLE_FORMATS,
|
|
3389
3531
|
booleans: Object.freeze(["propose"]),
|
|
3390
3532
|
run: runDiscover,
|
|
@@ -3507,6 +3649,16 @@ const COMMANDS = Object.freeze({
|
|
|
3507
3649
|
formats: DESCRIBABLE_FORMATS,
|
|
3508
3650
|
run: runImpact,
|
|
3509
3651
|
}),
|
|
3652
|
+
scenario: Object.freeze({
|
|
3653
|
+
name: "scenario",
|
|
3654
|
+
args: "<project>",
|
|
3655
|
+
summary: "Evaluate a hypothetical change against the current workspace",
|
|
3656
|
+
flagHelp: SCENARIO_FLAG_HELP,
|
|
3657
|
+
flags: Object.freeze(Object.fromEntries(SCENARIO_FLAG_HELP.map((f) => [f.flag, f.key]))),
|
|
3658
|
+
defaults: Object.freeze({ format: "text", output: null, config: null, scenarioFile: null }),
|
|
3659
|
+
formats: DESCRIBABLE_FORMATS,
|
|
3660
|
+
run: runScenario,
|
|
3661
|
+
}),
|
|
3510
3662
|
explain: Object.freeze({
|
|
3511
3663
|
name: "explain",
|
|
3512
3664
|
args: "<file:line:column>",
|
|
@@ -3621,10 +3773,15 @@ export async function runCli(argv, env) {
|
|
|
3621
3773
|
// one root-marker read and a clean run pays none.
|
|
3622
3774
|
const help = () => usage(optionsForUsage(cwd));
|
|
3623
3775
|
|
|
3776
|
+
// --help and --version are universal flags, handled before command dispatch.
|
|
3624
3777
|
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
3625
3778
|
env.out(help());
|
|
3626
3779
|
return EXIT.ok;
|
|
3627
3780
|
}
|
|
3781
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
3782
|
+
env.out(`${TOOL_NAME} ${TOOL_VERSION}`);
|
|
3783
|
+
return EXIT.ok;
|
|
3784
|
+
}
|
|
3628
3785
|
|
|
3629
3786
|
const [maybeCommand, ...maybeRest] = argv;
|
|
3630
3787
|
let commandName;
|
|
@@ -3635,6 +3792,11 @@ export async function runCli(argv, env) {
|
|
|
3635
3792
|
} else if (Object.hasOwn(COMMANDS, maybeCommand)) {
|
|
3636
3793
|
commandName = maybeCommand;
|
|
3637
3794
|
rest = maybeRest;
|
|
3795
|
+
// Subcommand --help: show the main help without "unknown option" error.
|
|
3796
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
3797
|
+
env.out(help());
|
|
3798
|
+
return EXIT.ok;
|
|
3799
|
+
}
|
|
3638
3800
|
} else if (
|
|
3639
3801
|
maybeCommand !== "" &&
|
|
3640
3802
|
existsSync(isAbsolute(maybeCommand) ? maybeCommand : join(cwd, maybeCommand))
|
package/commands.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecoma-io/archkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Architecture enforcement for polyglot repositories — dependency graphs and module boundaries for Go, Rust, Python, TypeScript, JavaScript, Vue, Java and Kotlin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|
package/src/analysis/kotlin.mjs
CHANGED
|
@@ -95,6 +95,58 @@ export function parseKotlinImportSites(kotlinText) {
|
|
|
95
95
|
*/
|
|
96
96
|
const importableNameOf = (name) => (name.endsWith(".*") ? name.slice(0, -2) : name);
|
|
97
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Kotlin malformation detection: an `import` keyword whose body never reaches
|
|
100
|
+
* its terminator before a `{` (on the same line) or EOF is a whole-file
|
|
101
|
+
* failure — without it, a file truncated inside an import would parse as
|
|
102
|
+
* importing nothing, with no failure record and a clean verdict over the hole
|
|
103
|
+
* (#419, the same pattern as `javaImportMalformations` adapted for Kotlin's
|
|
104
|
+
* newline-terminated imports).
|
|
105
|
+
*
|
|
106
|
+
* Kotlin imports are terminated by `\n`, `;`, `}`, or EOF — unlike Java where
|
|
107
|
+
* every import MUST end with `;`. So the scan includes `\n` as a valid
|
|
108
|
+
* terminator: a `{` is only a malformation when it arrives on the same line
|
|
109
|
+
* as the import head, before any newline does.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} kotlinText Raw file contents.
|
|
112
|
+
* @returns {string[]} Reasons, at most one per malformation kind.
|
|
113
|
+
*/
|
|
114
|
+
export function kotlinImportMalformations(kotlinText) {
|
|
115
|
+
const source = maskKotlinComments(kotlinText);
|
|
116
|
+
const KOTLIN_IMPORT_HEAD = /(?:^\uFEFF?|[\n;])[ \t]*(?:import[ \t]+)/gu;
|
|
117
|
+
/** @type {string[]} */
|
|
118
|
+
const reasons = [];
|
|
119
|
+
// `\n`, `;`, and `{` ascend with the text, so one shared cursor walks all
|
|
120
|
+
// three in a single pass — the same shape the Java scan uses, but with `\n`
|
|
121
|
+
// included because Kotlin's import syntax allows newline termination.
|
|
122
|
+
const terminators = [...source.matchAll(/[\n;{]/g)];
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
for (const m of source.matchAll(KOTLIN_IMPORT_HEAD)) {
|
|
125
|
+
const at = m.index + m[0].length;
|
|
126
|
+
while (cursor < terminators.length && terminators[cursor].index < at) cursor += 1;
|
|
127
|
+
const next = terminators[cursor];
|
|
128
|
+
// A `{` before any `\n` or `;` means the import and a body opener share a
|
|
129
|
+
// line, which is never valid Kotlin. No terminator at all (EOF) is also a
|
|
130
|
+
// truncation. A `\n` or `;` is a clean terminator.
|
|
131
|
+
if (next === undefined) {
|
|
132
|
+
const importOffset = m.index + m[0].indexOf("import");
|
|
133
|
+
reasons.push(
|
|
134
|
+
"an `import` never reaches its terminator — the file is truncated or malformed, " +
|
|
135
|
+
`so its imports cannot be read (line ${positionAt(kotlinText, importOffset).line})`,
|
|
136
|
+
);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
if (next[0] === "{") {
|
|
140
|
+
const importOffset = m.index + m[0].indexOf("import");
|
|
141
|
+
reasons.push(
|
|
142
|
+
"an `import` and a `{` share a line — the file is malformed, " +
|
|
143
|
+
`so its imports cannot be read (line ${positionAt(kotlinText, importOffset).line})`,
|
|
144
|
+
);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return reasons;
|
|
149
|
+
}
|
|
98
150
|
/**
|
|
99
151
|
* Analyzes one `.kt`/`.kts` file. Ambiguity resolves to null WITH a
|
|
100
152
|
* positioned failure naming every claimant, exactly as Java's does — the
|
|
@@ -113,6 +165,14 @@ export function analyzeKotlin({ sourceFile, text, workspace }) {
|
|
|
113
165
|
try {
|
|
114
166
|
const { byName: index } = jvmPackageIndex(workspace);
|
|
115
167
|
const owner = projectOwning(workspace.projects, sourceFile);
|
|
168
|
+
// A file truncated inside an import used to parse as importing nothing,
|
|
169
|
+
// with no failure beside the empty result — the clean verdict over it was
|
|
170
|
+
// the bug (#419). The whole-file shape is what turns the verdict loud:
|
|
171
|
+
// `check` counts the file toward `unchecked` and refuses to call the run
|
|
172
|
+
// complete, instead of reporting a hole as a clean file.
|
|
173
|
+
for (const reason of kotlinImportMalformations(text)) {
|
|
174
|
+
result.failures.push(fileFailure(sourceFile, reason));
|
|
175
|
+
}
|
|
116
176
|
for (const site of parseKotlinImportSites(text)) {
|
|
117
177
|
const { line, column } = positionAt(text, site.offset);
|
|
118
178
|
const resolved = resolveJvmSpecifier(site.importableName, { language: "kotlin" }, index);
|
package/src/analysis/python.mjs
CHANGED
|
@@ -1155,6 +1155,45 @@ export function parsePythonImportSites(pythonText) {
|
|
|
1155
1155
|
return sites.sort((a, b) => a.offset - b.offset);
|
|
1156
1156
|
}
|
|
1157
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* Python malformation detection: an `import` or `from` keyword whose name
|
|
1160
|
+
* never resolves before EOF, on a line that doesn't continue, is a
|
|
1161
|
+
* whole-file failure — without it, a file truncated inside an import
|
|
1162
|
+
* statement would parse as importing nothing, with no failure record and a
|
|
1163
|
+
* clean verdict over the hole (#419).
|
|
1164
|
+
*
|
|
1165
|
+
* @param {string} pythonText Raw file contents.
|
|
1166
|
+
* @returns {string[]} Reasons, at most one per malformation kind.
|
|
1167
|
+
*/
|
|
1168
|
+
export function pythonImportMalformations(pythonText) {
|
|
1169
|
+
/** @type {string[]} */
|
|
1170
|
+
const reasons = [];
|
|
1171
|
+
// A bare `import` or `from` at the end of the file, or on a line with
|
|
1172
|
+
// nothing after the keyword, is the common truncation pattern.
|
|
1173
|
+
const TRUNCATED_IMPORT = /^[ \t]*(?:import[ \t]*$|from[ \t]*$)/m;
|
|
1174
|
+
if (TRUNCATED_IMPORT.test(pythonText)) {
|
|
1175
|
+
const m = TRUNCATED_IMPORT.exec(pythonText);
|
|
1176
|
+
const lineStart = pythonText.lastIndexOf("\n", m.index) + 1;
|
|
1177
|
+
const lineNumber = pythonText.slice(0, lineStart).split("\n").length;
|
|
1178
|
+
reasons.push(
|
|
1179
|
+
"an `import` or `from` statement is truncated — the file is malformed, " +
|
|
1180
|
+
`so its imports cannot be read (line ${lineNumber})`,
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
// Also detect a `from ... import` that ends without naming what to import.
|
|
1184
|
+
const TRUNCATED_FROM_IMPORT = /^[ \t]*from[ \t]+[\w.]+[ \t]+import[ \t]*$/m;
|
|
1185
|
+
if (TRUNCATED_FROM_IMPORT.test(pythonText)) {
|
|
1186
|
+
const m = TRUNCATED_FROM_IMPORT.exec(pythonText);
|
|
1187
|
+
const lineStart = pythonText.lastIndexOf("\n", m.index) + 1;
|
|
1188
|
+
const lineNumber = pythonText.slice(0, lineStart).split("\n").length;
|
|
1189
|
+
reasons.push(
|
|
1190
|
+
"a `from ... import` statement has no imported names — the file is malformed, " +
|
|
1191
|
+
`so its imports cannot be read (line ${lineNumber})`,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
return reasons;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1158
1197
|
/**
|
|
1159
1198
|
* Analyzes one `.py` file.
|
|
1160
1199
|
*
|
|
@@ -1170,92 +1209,104 @@ export function analyzePython({ sourceFile, text, workspace }) {
|
|
|
1170
1209
|
? ownPackageOf(sourceFile, owner.root, directoriesOf.get(owner.name) ?? [])
|
|
1171
1210
|
: null;
|
|
1172
1211
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
fail(
|
|
1197
|
-
`dynamic import of '${site.specifier}' has a non-literal argument, ` +
|
|
1198
|
-
`so its target is not knowable statically`,
|
|
1199
|
-
);
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1212
|
+
// A file truncated inside an `import` or `from` statement used to parse as
|
|
1213
|
+
// importing nothing — the clean verdict over it was the bug (#419). The
|
|
1214
|
+
// whole-file failure is what turns the verdict loud: `check` counts the
|
|
1215
|
+
// file toward `unchecked` and refuses to call the run complete.
|
|
1216
|
+
const malformations = [...pythonImportMalformations(text)];
|
|
1217
|
+
for (const reason of malformations) {
|
|
1218
|
+
result.failures.push(fileFailure(sourceFile, reason));
|
|
1219
|
+
}
|
|
1220
|
+
// When the file is truncated inside an import statement, no imports at all
|
|
1221
|
+
// can be reliably extracted — a whole-file failure is the only honest answer.
|
|
1222
|
+
if (malformations.length === 0) {
|
|
1223
|
+
for (const site of parsePythonImportSites(text)) {
|
|
1224
|
+
const { line, column } = positionAt(text, site.offset);
|
|
1225
|
+
const record = {
|
|
1226
|
+
sourceFile,
|
|
1227
|
+
line,
|
|
1228
|
+
column,
|
|
1229
|
+
specifier: site.specifier,
|
|
1230
|
+
kind: site.kind,
|
|
1231
|
+
spelling: { path: false, relative: isRelativeImport(site.specifier), namesOnly: true },
|
|
1232
|
+
resolved: null,
|
|
1233
|
+
};
|
|
1234
|
+
result.imports.push(record);
|
|
1235
|
+
const fail = (reason) => result.failures.push({ sourceFile, line, column, reason });
|
|
1202
1236
|
|
|
1203
|
-
|
|
1204
|
-
if (site.specifier.startsWith(".")) {
|
|
1205
|
-
if (ownPackage === null) {
|
|
1237
|
+
if (site.continuation) {
|
|
1206
1238
|
fail(
|
|
1207
|
-
`
|
|
1239
|
+
`'${site.specifier}' looks like a \`from\`/\`import\` statement continued across a ` +
|
|
1240
|
+
`backslash-joined line, but does not parse as one once its continuation lines are ` +
|
|
1241
|
+
`joined — this reader cannot say what it imports`,
|
|
1208
1242
|
);
|
|
1209
1243
|
continue;
|
|
1210
1244
|
}
|
|
1211
|
-
|
|
1212
|
-
if (absolute === null) {
|
|
1245
|
+
if (!site.literal) {
|
|
1213
1246
|
fail(
|
|
1214
|
-
`
|
|
1215
|
-
`
|
|
1247
|
+
`dynamic import of '${site.specifier}' has a non-literal argument, ` +
|
|
1248
|
+
`so its target is not knowable statically`,
|
|
1216
1249
|
);
|
|
1217
1250
|
continue;
|
|
1218
1251
|
}
|
|
1219
|
-
}
|
|
1220
1252
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1253
|
+
let absolute = site.specifier;
|
|
1254
|
+
if (site.specifier.startsWith(".")) {
|
|
1255
|
+
if (ownPackage === null) {
|
|
1256
|
+
fail(
|
|
1257
|
+
`relative import '${site.specifier}' cannot be resolved: '${sourceFile}' is not on any import root`,
|
|
1258
|
+
);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
absolute = resolveRelativeModule(site.specifier, ownPackage);
|
|
1262
|
+
if (absolute === null) {
|
|
1263
|
+
fail(
|
|
1264
|
+
`relative import '${site.specifier}' climbs past the top-level package of '${sourceFile}', ` +
|
|
1265
|
+
`which leaves the project's import root — Python rejects it the same way`,
|
|
1266
|
+
);
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const resolution = resolveModuleName(absolute, byModule);
|
|
1272
|
+
if (resolution === null) {
|
|
1273
|
+
// Reaching no project is only evidence of a PyPI package when every
|
|
1274
|
+
// project's packages are where this reader can see them. Otherwise the
|
|
1275
|
+
// honest answer is that the name is unplaceable — asserting `external`
|
|
1276
|
+
// here is what let a first-party import cross a tag boundary silently.
|
|
1277
|
+
if (unmodelled.length > 0) {
|
|
1278
|
+
fail(
|
|
1279
|
+
`'${site.specifier}' reaches no project, and this reader cannot conclude it is ` +
|
|
1280
|
+
`external — ${unmodelled
|
|
1281
|
+
.map(
|
|
1282
|
+
(entry) =>
|
|
1283
|
+
`project '${entry.project}' may place its packages where this reader ` +
|
|
1284
|
+
`cannot look: ${entry.reason}`,
|
|
1285
|
+
)
|
|
1286
|
+
.join("; ")}. A first-party package put there would look exactly like this.`,
|
|
1287
|
+
);
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
record.resolved = {
|
|
1291
|
+
target: null,
|
|
1292
|
+
file: null,
|
|
1293
|
+
external: true,
|
|
1294
|
+
packageName: absolute.split(".")[0],
|
|
1295
|
+
};
|
|
1296
|
+
} else if (resolution.ambiguous) {
|
|
1228
1297
|
fail(
|
|
1229
|
-
`'${site.specifier}'
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
(entry) =>
|
|
1233
|
-
`project '${entry.project}' may place its packages where this reader ` +
|
|
1234
|
-
`cannot look: ${entry.reason}`,
|
|
1235
|
-
)
|
|
1236
|
-
.join("; ")}. A first-party package put there would look exactly like this.`,
|
|
1298
|
+
`'${site.specifier}' resolves through the namespace package '${resolution.prefix}', which ` +
|
|
1299
|
+
`${resolution.ambiguous.join(" and ")} both contribute to — Python picks by sys.path order, ` +
|
|
1300
|
+
`which no static reader can know`,
|
|
1237
1301
|
);
|
|
1238
|
-
|
|
1302
|
+
} else {
|
|
1303
|
+
record.resolved = {
|
|
1304
|
+
target: resolution.owner.project,
|
|
1305
|
+
file: resolution.owner.file,
|
|
1306
|
+
external: false,
|
|
1307
|
+
packageName: null,
|
|
1308
|
+
};
|
|
1239
1309
|
}
|
|
1240
|
-
record.resolved = {
|
|
1241
|
-
target: null,
|
|
1242
|
-
file: null,
|
|
1243
|
-
external: true,
|
|
1244
|
-
packageName: absolute.split(".")[0],
|
|
1245
|
-
};
|
|
1246
|
-
} else if (resolution.ambiguous) {
|
|
1247
|
-
fail(
|
|
1248
|
-
`'${site.specifier}' resolves through the namespace package '${resolution.prefix}', which ` +
|
|
1249
|
-
`${resolution.ambiguous.join(" and ")} both contribute to — Python picks by sys.path order, ` +
|
|
1250
|
-
`which no static reader can know`,
|
|
1251
|
-
);
|
|
1252
|
-
} else {
|
|
1253
|
-
record.resolved = {
|
|
1254
|
-
target: resolution.owner.project,
|
|
1255
|
-
file: resolution.owner.file,
|
|
1256
|
-
external: false,
|
|
1257
|
-
packageName: null,
|
|
1258
|
-
};
|
|
1259
1310
|
}
|
|
1260
1311
|
}
|
|
1261
1312
|
} catch (cause) {
|
package/src/commands/diff.mjs
CHANGED
|
@@ -114,6 +114,21 @@ export function parseBaseline(text, path) {
|
|
|
114
114
|
);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
if (!envelope.command) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`archkeep: the baseline snapshot at '${path}' has no 'command' field — it is not a ` +
|
|
120
|
+
`'graph' envelope. diff requires a graph snapshot (from 'graph --format json'). ` +
|
|
121
|
+
`For delta evidence snapshots use 'delta <snapshot>'.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (envelope.command !== "graph") {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`archkeep: the baseline snapshot at '${path}' is a '${envelope.command}' envelope, not a ` +
|
|
128
|
+
`'graph' envelope — diff requires a graph snapshot as its baseline`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
117
132
|
// A consumer that reads a schemaVersion it does not recognise should refuse
|
|
118
133
|
// to parse the rest (`docs/reference/json-output.md`). This tool IS that
|
|
119
134
|
// consumer when reading a baseline — a future schema version could change
|
|
@@ -127,13 +142,6 @@ export function parseBaseline(text, path) {
|
|
|
127
142
|
);
|
|
128
143
|
}
|
|
129
144
|
|
|
130
|
-
if (envelope.command !== "graph") {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`archkeep: the baseline snapshot at '${path}' is a '${envelope.command}' envelope, not a ` +
|
|
133
|
-
`'graph' envelope — diff requires a graph snapshot as its baseline`,
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
145
|
if (!envelope.coverage?.complete) {
|
|
138
146
|
throw new Error(
|
|
139
147
|
`archkeep: the baseline snapshot at '${path}' has incomplete coverage — every "removed" ` +
|