@ecoma-io/archkeep 0.18.1 → 0.20.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
@@ -115,7 +115,7 @@ import { adrCommand } from "./src/commands/adr.mjs";
115
115
  import { decisionsCommand } from "./src/commands/decisions.mjs";
116
116
  import { diffCommand } from "./src/commands/diff.mjs";
117
117
  import { captureDelta, deltaCommand } from "./src/commands/delta.mjs";
118
- import { discoverCommand } from "./src/commands/discover.mjs";
118
+ import { discoverCommand, proposalToIntent } from "./src/commands/discover.mjs";
119
119
  import { driftCommand } from "./src/commands/drift.mjs";
120
120
  import { fitnessCommand } from "./src/commands/fitness.mjs";
121
121
  import { reconcileCommand } from "./src/commands/reconcile.mjs";
@@ -129,6 +129,7 @@ import { reportCommand } from "./src/commands/report.mjs";
129
129
  import { debtCommand } from "./src/commands/debt.mjs";
130
130
  import { explainCommand } from "./src/commands/explain.mjs";
131
131
  import { impactCommand } from "./src/commands/impact.mjs";
132
+ import { scenarioCommand } from "./src/commands/scenario.mjs";
132
133
  import { provenanceCommand } from "./src/commands/provenance-command.mjs";
133
134
  import {
134
135
  rulesAddCommand,
@@ -1547,6 +1548,72 @@ async function runImpact(options, { cwd, env }) {
1547
1548
  // Impact is descriptive: 0 when it completes, never 1.
1548
1549
  return EXIT.ok;
1549
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
+ }
1550
1617
 
1551
1618
  /**
1552
1619
  * `explain`'s `run`: resolves the command context, loads the boundary config,
@@ -2212,7 +2279,7 @@ async function runDebt(options, { cwd, env }) {
2212
2279
  * graph. `--propose` is opt-in: a proposal is a suggestion, and a workspace
2213
2280
  * that does not ask for one must not get one.
2214
2281
  *
2215
- * @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
2216
2283
  * @param {{cwd: string, env: {out: Function, err: Function, readGraph?: Function, listFiles?: Function}}} runContext
2217
2284
  * @returns {Promise<number>}
2218
2285
  */
@@ -2222,6 +2289,11 @@ async function runDiscover(options, { cwd, env }) {
2222
2289
  return EXIT.usage;
2223
2290
  }
2224
2291
 
2292
+ if (options.writeIntent && !options.propose) {
2293
+ env.err("archkeep: --write-intent requires --propose");
2294
+ return EXIT.usage;
2295
+ }
2296
+
2225
2297
  let result;
2226
2298
  try {
2227
2299
  const commandContext = resolveCommandContext(
@@ -2257,6 +2329,17 @@ async function runDiscover(options, { cwd, env }) {
2257
2329
  env.out(report);
2258
2330
  }
2259
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
+
2260
2343
  // Discover is descriptive: 0 when it completes, never 1.
2261
2344
  return result.status === "ok" ? EXIT.ok : EXIT.error;
2262
2345
  }
@@ -2709,6 +2792,17 @@ const DISCOVER_FLAG_HELP = Object.freeze([
2709
2792
  "authoritative; nothing is ever written",
2710
2793
  ]),
2711
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
+ }),
2712
2806
  Object.freeze({
2713
2807
  flag: "--format",
2714
2808
  key: "format",
@@ -3108,6 +3202,48 @@ const IMPACT_FLAG_HELP = Object.freeze([
3108
3202
  ]),
3109
3203
  }),
3110
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
+ ]);
3111
3247
 
3112
3248
  /**
3113
3249
  * `explain`'s flags: text or JSON envelope, optional file output.
@@ -3390,7 +3526,7 @@ const COMMANDS = Object.freeze({
3390
3526
  summary: "Report observed facts, and optionally propose candidate architecture",
3391
3527
  flagHelp: DISCOVER_FLAG_HELP,
3392
3528
  flags: Object.freeze(Object.fromEntries(DISCOVER_FLAG_HELP.map((f) => [f.flag, f.key]))),
3393
- defaults: Object.freeze({ format: "text", output: null, propose: false }),
3529
+ defaults: Object.freeze({ format: "text", output: null, propose: false, writeIntent: null }),
3394
3530
  formats: DESCRIBABLE_FORMATS,
3395
3531
  booleans: Object.freeze(["propose"]),
3396
3532
  run: runDiscover,
@@ -3513,6 +3649,16 @@ const COMMANDS = Object.freeze({
3513
3649
  formats: DESCRIBABLE_FORMATS,
3514
3650
  run: runImpact,
3515
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
+ }),
3516
3662
  explain: Object.freeze({
3517
3663
  name: "explain",
3518
3664
  args: "<file:line:column>",
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.1",
3
+ "version": "0.20.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) {
@@ -72,6 +72,46 @@ export function buildObserved(commandContext) {
72
72
  return { projects, edges };
73
73
  }
74
74
 
75
+ /**
76
+ * Convert a discovery proposal into an `architecture-intent.json`-compatible
77
+ * object. The conversion preserves the proposal's structural intent:
78
+ *
79
+ * - **Components** (directory groupings of 2+ projects) → `boundaries` entries
80
+ * with `directory:` selectors.
81
+ * - **`noDependency` rules** → `forbidden` entries — cross-component
82
+ * dependencies that should not exist.
83
+ *
84
+ * Confidence markers and evidence are dropped: the user is expected to review
85
+ * the output before using it with `drift` or `reconcile`.
86
+ *
87
+ * @param {object} proposal The proposal from `evaluateDiscovery`.
88
+ * @returns {{version: string, boundaries: Array<{name: string, match: string[]}>, forbidden?: Array<{source: string, target: string}>}}
89
+ */
90
+ export function proposalToIntent(proposal) {
91
+ const boundaries = (proposal.components?.items ?? []).map((component) => ({
92
+ name: component.name,
93
+ match: [`directory:${component.commonDirectory}`],
94
+ }));
95
+
96
+ // `noDependency` rules map to `forbidden` intent rows. The rules array
97
+ // includes both `noDependency` and `boundary` kinds; only the former
98
+ // carries source/target project pairs.
99
+ const forbidden = (proposal.rules?.items ?? [])
100
+ .filter((rule) => rule.kind === "noDependency")
101
+ .map((rule) => ({
102
+ source: rule.source,
103
+ target: rule.target,
104
+ }));
105
+
106
+ return {
107
+ version: "1",
108
+ // Auto-generated header comment is not possible in strict JSON; the
109
+ // user is expected to review before using with drift/reconcile.
110
+ boundaries,
111
+ ...(forbidden.length > 0 ? { forbidden } : {}),
112
+ };
113
+ }
114
+
75
115
  /**
76
116
  * Runs the `discover` command: observes the workspace, optionally proposes the
77
117
  * candidate architecture over it, and returns the report.