@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 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 23-command surface are in 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
- "Write an evidence snapshot of the current tree",
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
@@ -54,4 +54,6 @@ export {
54
54
  rulesVerifyCommand,
55
55
  } from "./src/commands/rules.mjs";
56
56
 
57
+ export { parseScenarioInput, scenarioCommand } from "./src/commands/scenario.mjs";
58
+
57
59
  export { UsageError } from "./src/errors.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecoma-io/archkeep",
3
- "version": "0.18.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",
@@ -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);
@@ -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
- for (const site of parsePythonImportSites(text)) {
1174
- const { line, column } = positionAt(text, site.offset);
1175
- const record = {
1176
- sourceFile,
1177
- line,
1178
- column,
1179
- specifier: site.specifier,
1180
- kind: site.kind,
1181
- spelling: { path: false, relative: isRelativeImport(site.specifier), namesOnly: true },
1182
- resolved: null,
1183
- };
1184
- result.imports.push(record);
1185
- const fail = (reason) => result.failures.push({ sourceFile, line, column, reason });
1186
-
1187
- if (site.continuation) {
1188
- fail(
1189
- `'${site.specifier}' looks like a \`from\`/\`import\` statement continued across a ` +
1190
- `backslash-joined line, but does not parse as one once its continuation lines are ` +
1191
- `joined — this reader cannot say what it imports`,
1192
- );
1193
- continue;
1194
- }
1195
- if (!site.literal) {
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
- let absolute = site.specifier;
1204
- if (site.specifier.startsWith(".")) {
1205
- if (ownPackage === null) {
1237
+ if (site.continuation) {
1206
1238
  fail(
1207
- `relative import '${site.specifier}' cannot be resolved: '${sourceFile}' is not on any import root`,
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
- absolute = resolveRelativeModule(site.specifier, ownPackage);
1212
- if (absolute === null) {
1245
+ if (!site.literal) {
1213
1246
  fail(
1214
- `relative import '${site.specifier}' climbs past the top-level package of '${sourceFile}', ` +
1215
- `which leaves the project's import root — Python rejects it the same way`,
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
- const resolution = resolveModuleName(absolute, byModule);
1222
- if (resolution === null) {
1223
- // Reaching no project is only evidence of a PyPI package when every
1224
- // project's packages are where this reader can see them. Otherwise the
1225
- // honest answer is that the name is unplaceable asserting `external`
1226
- // here is what let a first-party import cross a tag boundary silently.
1227
- if (unmodelled.length > 0) {
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}' reaches no project, and this reader cannot conclude it is ` +
1230
- `external${unmodelled
1231
- .map(
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
- continue;
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) {
@@ -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" ` +