@ecoma-io/archkeep 0.22.2 → 0.24.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.
Files changed (63) hide show
  1. package/package.json +40 -13
  2. package/src/analysis/jvm/packages.mjs +0 -17
  3. package/src/analysis/manifest-util.mjs +14 -5
  4. package/src/analysis/markdown.mjs +340 -0
  5. package/src/analysis/source-util.mjs +5 -4
  6. package/src/analysis/typescript.mjs +146 -0
  7. package/src/architecture-intent/judge.mjs +1 -1
  8. package/src/architecture-intent/model.mjs +3 -12
  9. package/src/commands/README.md +16 -7
  10. package/src/commands/change-intent.mjs +2 -11
  11. package/src/commands/check.mjs +234 -12
  12. package/src/commands/completeness.mjs +0 -32
  13. package/src/commands/context-command.mjs +13 -21
  14. package/src/commands/context.mjs +46 -47
  15. package/src/commands/coverage-verdict.mjs +12 -2
  16. package/src/commands/delta-snapshot.mjs +1 -5
  17. package/src/commands/diff.mjs +1 -1
  18. package/src/commands/discover.mjs +7 -3
  19. package/src/commands/evaluation-primitives.mjs +6 -2
  20. package/src/commands/explain.mjs +17 -20
  21. package/src/commands/graph.mjs +7 -0
  22. package/src/commands/health.mjs +4 -0
  23. package/src/commands/impact-reachability.mjs +104 -0
  24. package/src/commands/impact.mjs +9 -71
  25. package/src/commands/plan-context-command.mjs +5 -1
  26. package/src/commands/policy.mjs +4 -4
  27. package/src/commands/provenance.mjs +8 -2
  28. package/src/commands/scenario-evaluation.mjs +1 -1
  29. package/src/config.mjs +171 -17
  30. package/src/custom-rules/evidence.mjs +1 -1
  31. package/src/custom-rules/host.mjs +2 -2
  32. package/src/custom-rules/values.mjs +8 -3
  33. package/src/errors.mjs +24 -2
  34. package/src/eslint-config.mjs +2 -5
  35. package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
  36. package/src/governance/adr-registry.mjs +33 -17
  37. package/src/governance/decision-graph.mjs +1 -1
  38. package/src/governance/evolution-store.mjs +36 -18
  39. package/src/governance/fitness-registry.mjs +1 -14
  40. package/src/governance/profile-registry.mjs +20 -23
  41. package/src/governance/provenance-record.mjs +1 -11
  42. package/src/governance/reconcile-score.mjs +0 -3
  43. package/src/governance/row-schema.mjs +1 -14
  44. package/src/governance/verdict.mjs +168 -4
  45. package/src/intent/intent-manifest.json +16 -16
  46. package/src/lsp/diagnose.mjs +2 -2
  47. package/src/lsp/server.mjs +1 -1
  48. package/src/lsp/workspace-index.mjs +3 -3
  49. package/src/options.mjs +1 -1
  50. package/src/providers/model-gate.mjs +59 -0
  51. package/src/providers/moon.mjs +6 -6
  52. package/src/providers/native/model.mjs +2 -16
  53. package/src/report/README.md +13 -7
  54. package/src/report/evidence.mjs +11 -168
  55. package/src/report/json.mjs +10 -7
  56. package/src/report/sarif.mjs +29 -4
  57. package/src/report/text.mjs +39 -0
  58. package/src/rules/README.md +18 -9
  59. package/src/{commands → rules}/edge-constraints.mjs +18 -12
  60. package/src/rules/index.mjs +30 -0
  61. package/src/values.mjs +49 -0
  62. package/src/verdict.mjs +58 -7
  63. package/src/workspace.mjs +29 -0
@@ -49,7 +49,7 @@ import {
49
49
  isWholeFileFailure,
50
50
  unresolvableLiteralCount,
51
51
  } from "../analysis/source-util.mjs";
52
- import { EXIT, coverageIncompleteReasons } from "../verdict.mjs";
52
+ import { EXIT, coverageComplete, coverageIncompleteReasons } from "../verdict.mjs";
53
53
  import { buildDecision } from "../report/evidence.mjs";
54
54
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
55
55
  import { formatCoverageIncomplete } from "../report/text.mjs";
@@ -96,7 +96,17 @@ export function coverageVerdict(commandContext, { acceptedFiles } = {}) {
96
96
  .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
97
97
  const blindSpots = blindSpotRows(analysis.failures);
98
98
  const blindSpotCount = unresolvableLiteralCount(analysis.failures);
99
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0 && analysis.analyzed > 0;
99
+ // The conjunction is `../verdict.mjs`'s `coverageComplete` the same
100
+ // predicate `verdictFor`'s decision face reads, so `check`'s envelope
101
+ // cannot carry a `coverage.complete` and a `decision.coverageComplete`
102
+ // that disagree about one run. The predicate is defined there, not here,
103
+ // because `verdictFor` needs it and this module already imports from that
104
+ // one: the reverse import would be a cycle.
105
+ const complete = coverageComplete({
106
+ unchecked: notAnalyzed.length,
107
+ blindSpotCount,
108
+ analyzed: analysis.analyzed,
109
+ });
100
110
  return {
101
111
  notAnalyzed,
102
112
  blindSpots,
@@ -48,6 +48,7 @@
48
48
  import { readFileSync } from "node:fs";
49
49
 
50
50
  import { canonicalJsonReplacer } from "../canonical.mjs";
51
+ import { isPlainObject } from "../values.mjs";
51
52
  import { buildDependencies, buildProjects } from "./graph.mjs";
52
53
 
53
54
  /** The only snapshot schemaVersion this module writes and reads. */
@@ -518,11 +519,6 @@ export function providerMismatch(baselineProvider, currentProvider) {
518
519
  );
519
520
  }
520
521
 
521
- /** Non-empty plain-object guard used across validation. */
522
- function isPlainObject(value) {
523
- return value !== null && typeof value === "object" && !Array.isArray(value);
524
- }
525
-
526
522
  /** Describes a value for error messages without dumping it. */
527
523
  function describe(value) {
528
524
  if (value === null) return "null";
@@ -41,7 +41,7 @@
41
41
  import { readFileSync } from "node:fs";
42
42
 
43
43
  import { buildDependencies, buildProjects, computePolicyFingerprint } from "./graph.mjs";
44
- import { computeRuleImpact } from "./edge-constraints.mjs";
44
+ import { computeRuleImpact } from "../rules/edge-constraints.mjs";
45
45
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
46
46
  import { SCHEMA_VERSION } from "../report/json.mjs";
47
47
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
@@ -14,9 +14,13 @@
14
14
  * - **`--propose`** — computes the candidate architecture over those same
15
15
  * observations (`src/governance/discovery-proposal.mjs`'s
16
16
  * `evaluateDiscovery`) and emits it with `proposed: true` and
17
- * `notAuthoritative: true` on every candidate. It never writes
18
- * `architecture-intent.json`, never mutates the workspace, and never hands
19
- * a candidate the authority of a decision.
17
+ * `notAuthoritative: true` on every candidate. This layer performs no
18
+ * write: the evaluator is pure, and the one route from a proposal to
19
+ * `architecture-intent.json` is the CLI's own `--write-intent <file>` flag
20
+ * (`cli.mjs`'s `runDiscover`, serialized through `proposalToIntent`
21
+ * below) — explicit, named by the operator, and refused when a file
22
+ * already stands at the target. The command never hands a candidate the
23
+ * authority of a decision.
20
24
  *
21
25
  * ## The empty-result invariant
22
26
  *
@@ -25,8 +25,12 @@ import {
25
25
  computeDomainCoverage,
26
26
  REQUIRED_DOMAINS,
27
27
  } from "./completeness.mjs";
28
- import { computeImpact } from "./impact.mjs";
29
- import { computeImpactConstraints } from "./edge-constraints.mjs";
28
+ // The reachability walk comes from `./impact-reachability.mjs`, not from
29
+ // `./impact.mjs`: that module drives `./impact-statement.mjs`, which drives
30
+ // this one, so importing the walk from there closed the engine's only import
31
+ // cycle (#644).
32
+ import { computeImpact } from "./impact-reachability.mjs";
33
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
30
34
  // ---------------------------------------------------------------------------
31
35
  // Decision resolution
32
36
  // ---------------------------------------------------------------------------
@@ -79,16 +79,13 @@
79
79
  * was: the field, and its rendered lines, exist only when the comparison was
80
80
  * requested.
81
81
  */
82
- import {
83
- blindSpotRows,
84
- isWholeFileFailure,
85
- unresolvableLiteralCount,
86
- } from "../analysis/source-util.mjs";
82
+ import { isWholeFileFailure } from "../analysis/source-util.mjs";
87
83
  import { UsageError } from "../errors.mjs";
88
84
  import { evaluate } from "../rules/index.mjs";
89
85
  import { findConstraintsFor } from "../rules/tags.mjs";
90
86
  import { findProjectForPath, createProjectRootMappings } from "../rules/specifiers.mjs";
91
87
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
88
+ import { coverageVerdict } from "./coverage-verdict.mjs";
92
89
  import { formatExplainReport } from "../report/explain-text.mjs";
93
90
  import { resolveProvenance } from "./provenance.mjs";
94
91
  import { readAdrContext } from "./adr.mjs";
@@ -369,18 +366,18 @@ export function explainCommand(site, commandContext, config, options = {}) {
369
366
  // Normalize backslash separators (Windows paths) to forward slashes.
370
367
  parsed.sourceFile = sep === "\\" ? normalizedFile.replaceAll("\\", "/") : normalizedFile;
371
368
 
372
- const notAnalyzed = commandContext.analysis.failures
373
- .filter(isWholeFileFailure)
374
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
375
-
376
- // An unresolvable site was seen but never judged (#595): the graph is
377
- // missing whatever edge that site would have drawn, and rules that judge
378
- // the whole graph (circularity, lazy loading) would answer over a gap. The
379
- // explanation still reports status no-verdict naming the site in
380
- // `coverage.blindSpots`, the same contract `graph`/`discover` run.
381
- const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
382
- const complete = notAnalyzed.length === 0 && blindSpotCount === 0;
383
- const status = complete ? "ok" : "no-verdict";
369
+ // The completeness verdict is the shared constructor's, not this file's —
370
+ // the same contract `graph`/`discover` run: an unresolvable site was seen
371
+ // but never judged (#595), the graph is missing whatever edge it would have
372
+ // drawn, and rules that judge the whole graph (circularity, lazy loading)
373
+ // would answer over a gap. The explanation still reports status
374
+ // no-verdict naming the site in `coverage.blindSpots`. The restatement
375
+ // this replaces carried two of the constructor's three axes; the third
376
+ // (`analyzed > 0`, #599) moves no byte here, because every lane below that
377
+ // builds an envelope already implies a read file — an import record or a
378
+ // positioned failure exists only for a file the run analyzed — so the axis
379
+ // is carried by composition, not changed by it.
380
+ const { notAnalyzed, blindSpots, complete, status, exitCode } = coverageVerdict(commandContext);
384
381
 
385
382
  // Find the import record at this site.
386
383
  const record = findSite(parsed, commandContext.analysis.imports);
@@ -432,7 +429,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
432
429
  analyzedFiles: commandContext.analysis.analyzed,
433
430
  imports: commandContext.analysis.imports.length,
434
431
  notAnalyzed,
435
- blindSpots: blindSpotRows(commandContext.analysis.failures),
432
+ blindSpots,
436
433
  notes: [],
437
434
  };
438
435
 
@@ -447,7 +444,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
447
444
  command: "explain",
448
445
  context,
449
446
  status,
450
- exitCode: complete ? 0 : 3,
447
+ exitCode,
451
448
  coverage,
452
449
  result,
453
450
  });
@@ -579,7 +576,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
579
576
  analyzedFiles: commandContext.analysis.analyzed,
580
577
  imports: commandContext.analysis.imports.length,
581
578
  notAnalyzed,
582
- blindSpots: blindSpotRows(commandContext.analysis.failures),
579
+ blindSpots,
583
580
  notes: [],
584
581
  };
585
582
 
@@ -180,6 +180,13 @@ export function computePolicyFingerprint(config) {
180
180
  suppressions: config.suppressions ?? [],
181
181
  ...(config.fitness === undefined ? {} : { fitness: config.fitness }),
182
182
  ...(config.customRules === undefined ? {} : { customRules: config.customRules }),
183
+ // The document track is law the same way the two blocks above are: it
184
+ // decides what this run judges, so a policy that adds or edits a
185
+ // `markdown` block must not share a fingerprint with one that does not —
186
+ // `diff`'s policy-changed warning reads this hash. Conditional, like the
187
+ // two above, so a policy declaring no block hashes exactly as it did
188
+ // before this key existed.
189
+ ...(config.markdown === undefined ? {} : { markdown: config.markdown }),
183
190
  };
184
191
  // Canonicalise: sort object keys at every depth so insertion order does not
185
192
  // affect the hash. Semantic equality, not construction order, is the claim —
@@ -112,10 +112,14 @@ export function healthCommand(commandContext, io = {}) {
112
112
  const edges = buildDependencies(graph.dependencies);
113
113
 
114
114
  // The run's coverage facts, the same shape every command's envelope carries.
115
+ // A run that analyzed nothing judged nothing (#599, #694): judging nothing
116
+ // is not finding nothing, so it defeats completeness the way a whole-file
117
+ // failure does.
115
118
  // An unresolvable site is a fact the run saw but never judged (#595) —
116
119
  // metrics measured over it would read precision the run does not have,
117
120
  // so it defeats file completeness the way a whole-file failure does.
118
121
  const fileComplete =
122
+ analysis.analyzed > 0 &&
119
123
  analysis.failures.filter(isWholeFileFailure).length === 0 &&
120
124
  unresolvableLiteralCount(analysis.failures) === 0;
121
125
  // The graph is complete only when the files are AND the graph actually sees
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Reverse reachability over the project graph — the impact *computation*.
3
+ *
4
+ * Given a project name, `computeImpact` lists every project that transitively
5
+ * depends on it — the set a developer needs to consider before changing that
6
+ * project. It is descriptive: it never exits anything, because a description
7
+ * of what depends on a project is never a finding.
8
+ *
9
+ * The result separates **direct** dependents (projects whose edges point
10
+ * straight at the target) from **transitive** ones (reachable only through
11
+ * another project), and the union of both is `dependents`. An empty
12
+ * `dependents` list is a claim — "nothing depends on this project" — not a
13
+ * shrug, and the reports built on it are worded that way so a reader never
14
+ * mistakes it for silence.
15
+ *
16
+ * This is a plain module rather than part of `./impact.mjs` on purpose: the
17
+ * canonical evaluator (`./evaluation-primitives.mjs`) needs the same
18
+ * reachability walk the `impact` command reports, and `./impact.mjs` drives
19
+ * `./impact-statement.mjs`, which drives that evaluator. Holding the walk in
20
+ * `./impact.mjs` made `evaluation-primitives → impact → impact-statement →
21
+ * evaluation-primitives` the engine's only import cycle (#644) — three
22
+ * modules forced to load together, with no dependency order left to reason
23
+ * about. Both sides import the walk from here instead:
24
+ * `impact → impact-reachability`, `evaluation-primitives →
25
+ * impact-reachability`, and the trio's remaining direction stays one-way.
26
+ * `./impact.mjs` re-exports `computeImpact` so its existing importers keep
27
+ * resolving.
28
+ *
29
+ * Like `./edge-constraints.mjs`, this is a shared computation layer: it holds
30
+ * no argv, prints nothing, and decides no exit code (`./README.md`).
31
+ */
32
+ import { UsageError } from "../errors.mjs";
33
+
34
+ /**
35
+ * Computes the impact set: every project that transitively depends on
36
+ * `projectName`.
37
+ *
38
+ * Builds a reverse adjacency map from the graph's `dependencies`, then walks
39
+ * it breadth-first starting from the target project. The walk does NOT include
40
+ * the target project itself in the dependent set — a project does not depend
41
+ * on itself — but the returned `dependents` array is the union of `direct`
42
+ * and `transitive`, and the report header names the target separately.
43
+ *
44
+ * @param {string} projectName The project whose impact is being queried.
45
+ * @param {object} graph The project graph: `{nodes, dependencies}`.
46
+ * @returns {{project: string, direct: string[], transitive: string[], dependents: string[]}}
47
+ * @throws {UsageError} when `projectName` is not in the graph.
48
+ */
49
+ export function computeImpact(projectName, graph) {
50
+ const nodes = graph.nodes;
51
+ const deps = graph.dependencies;
52
+
53
+ if (!Object.hasOwn(nodes, projectName)) {
54
+ throw new UsageError(
55
+ `archkeep: no project named '${projectName}' in the graph — ` +
56
+ `available projects: ${Object.keys(nodes)
57
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
58
+ .join(", ")}`,
59
+ );
60
+ }
61
+
62
+ // Build reverse adjacency: target → [sources that depend on it]
63
+ const reverseAdj = Object.create(null);
64
+ for (const source of Object.keys(deps)) {
65
+ if (!Object.hasOwn(deps, source)) continue;
66
+ const targets = deps[source];
67
+ for (const edge of targets) {
68
+ if (!Object.hasOwn(reverseAdj, edge.target)) {
69
+ reverseAdj[edge.target] = [];
70
+ }
71
+ reverseAdj[edge.target].push(source);
72
+ }
73
+ }
74
+
75
+ // Direct dependents: projects whose edges point straight at the target.
76
+ // Deduplicate (multiple edges between same pair are possible) and sort.
77
+ const directSet = new Set(reverseAdj[projectName] ?? []);
78
+ const direct = [...directSet].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
79
+
80
+ // BFS through reverse edges to find transitive dependents.
81
+ const visited = new Set(directSet);
82
+ const queue = [...directSet];
83
+ while (queue.length > 0) {
84
+ const current = queue.shift();
85
+ const parents = reverseAdj[current];
86
+ if (parents === undefined) continue;
87
+ for (const parent of parents) {
88
+ if (!visited.has(parent)) {
89
+ visited.add(parent);
90
+ queue.push(parent);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Transitive dependents: reachable through another project, but not direct.
96
+ const transitive = [...visited]
97
+ .filter((name) => !directSet.has(name))
98
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
99
+
100
+ // All dependents: direct + transitive, sorted.
101
+ const dependents = [...visited].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
102
+
103
+ return { project: projectName, direct, transitive, dependents };
104
+ }
@@ -35,85 +35,23 @@
35
35
  * under project roots, `impact` refuses loudly rather than returning a result
36
36
  * whose dependents silently under-represent the real architecture.
37
37
  */
38
- import { UsageError } from "../errors.mjs";
39
- import { computeImpactConstraints } from "./edge-constraints.mjs";
38
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
40
39
  import { coverageRefusal, coverageVerdict } from "./coverage-verdict.mjs";
41
40
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
42
41
  import { formatImpactReport } from "../report/impact-text.mjs";
43
42
  import { resolveProvenance } from "./provenance.mjs";
44
43
  import { composeImpactStatement } from "./impact-statement.mjs";
44
+ import { computeImpact } from "./impact-reachability.mjs";
45
45
 
46
46
  /**
47
- * Computes the impact set: every project that transitively depends on
48
- * `projectName`.
49
- *
50
- * Builds a reverse adjacency map from the graph's `dependencies`, then walks
51
- * it breadth-first starting from the target project. The walk does NOT include
52
- * the target project itself in the dependent set — a project does not depend
53
- * on itself — but the returned `dependents` array is the union of `direct`
54
- * and `transitive`, and the report header names the target separately.
55
- *
56
- * @param {string} projectName The project whose impact is being queried.
57
- * @param {object} graph The project graph: `{nodes, dependencies}`.
58
- * @returns {{project: string, direct: string[], transitive: string[], dependents: string[]}}
59
- * @throws {UsageError} when `projectName` is not in the graph.
47
+ * The reachability walk this command reports, shared with the canonical
48
+ * evaluator through `./impact-reachability.mjs` — held there, not here,
49
+ * because this module also drives `./impact-statement.mjs`, which drives that
50
+ * evaluator, and keeping the walk here is what closed the engine's only
51
+ * import cycle (#644). Re-exported so every importer of this module keeps
52
+ * resolving `computeImpact` from where it always has.
60
53
  */
61
- export function computeImpact(projectName, graph) {
62
- const nodes = graph.nodes;
63
- const deps = graph.dependencies;
64
-
65
- if (!Object.hasOwn(nodes, projectName)) {
66
- throw new UsageError(
67
- `archkeep: no project named '${projectName}' in the graph — ` +
68
- `available projects: ${Object.keys(nodes)
69
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
70
- .join(", ")}`,
71
- );
72
- }
73
-
74
- // Build reverse adjacency: target → [sources that depend on it]
75
- const reverseAdj = Object.create(null);
76
- for (const source of Object.keys(deps)) {
77
- if (!Object.hasOwn(deps, source)) continue;
78
- const targets = deps[source];
79
- for (const edge of targets) {
80
- if (!Object.hasOwn(reverseAdj, edge.target)) {
81
- reverseAdj[edge.target] = [];
82
- }
83
- reverseAdj[edge.target].push(source);
84
- }
85
- }
86
-
87
- // Direct dependents: projects whose edges point straight at the target.
88
- // Deduplicate (multiple edges between same pair are possible) and sort.
89
- const directSet = new Set(reverseAdj[projectName] ?? []);
90
- const direct = [...directSet].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
91
-
92
- // BFS through reverse edges to find transitive dependents.
93
- const visited = new Set(directSet);
94
- const queue = [...directSet];
95
- while (queue.length > 0) {
96
- const current = queue.shift();
97
- const parents = reverseAdj[current];
98
- if (parents === undefined) continue;
99
- for (const parent of parents) {
100
- if (!visited.has(parent)) {
101
- visited.add(parent);
102
- queue.push(parent);
103
- }
104
- }
105
- }
106
-
107
- // Transitive dependents: reachable through another project, but not direct.
108
- const transitive = [...visited]
109
- .filter((name) => !directSet.has(name))
110
- .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
111
-
112
- // All dependents: direct + transitive, sorted.
113
- const dependents = [...visited].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
114
-
115
- return { project: projectName, direct, transitive, dependents };
116
- }
54
+ export { computeImpact };
117
55
 
118
56
  /**
119
57
  * Runs the `impact` command: resolves the command context, checks the
@@ -501,10 +501,14 @@ export async function planContextCommand(
501
501
  (a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0),
502
502
  );
503
503
 
504
+ // A run that analyzed nothing judged nothing (#599, #694): judging nothing
505
+ // is not finding nothing, so it defeats completeness the way a whole-file
506
+ // failure does.
504
507
  // An unresolvable literal site is work the run saw but never judged
505
508
  // (#595, narrowed): a plan over it would present edges the run does not
506
509
  // hold, so it defeats completeness the way a whole-file failure does.
507
- const complete = notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
510
+ const complete =
511
+ wholeTree.analyzed > 0 && notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
508
512
  const status = complete ? "ok" : "no-verdict";
509
513
  const exitCode = complete ? 0 : 3;
510
514
 
@@ -86,10 +86,10 @@ export function hasProfiles(options) {
86
86
  * @param {string} cwd The process's working directory a relative `--config`
87
87
  * resolves against — kept separate from the workspace root for the reason
88
88
  * above.
89
- * @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, notes?: string[]}|null, profile: string|null, source: string|null}>}
90
- * `fitness` and `customRules` are present only when the resolved policy
91
- * declares them — an absent key is the workspace's decision not to declare
92
- * that law, never an empty one (`../config.mjs`'s `policyFrom`).
89
+ * @returns {Promise<{config: {depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: {include: string[], markers: {pattern: string, edge: string}[]}, notes?: string[]}|null, profile: string|null, source: string|null}>}
90
+ * `fitness`, `customRules` and `markdown` are present only when the resolved
91
+ * policy declares them — an absent key is the workspace's decision not to
92
+ * declare that law, never an empty one (`../config.mjs`'s `policyFrom`).
93
93
  * @throws {Error} when a named profile, a `--config` file, or an inline
94
94
  * policy cannot be resolved or is malformed — every arm's existing failure
95
95
  * mode, unchanged by the extraction.
@@ -114,8 +114,14 @@ export function resolveProvenance(root) {
114
114
 
115
115
  // Dirty: any uncommitted change to tracked files means the working tree
116
116
  // does not match the commit. A baseline from a dirty tree is not a
117
- // reproducible claim about that commit.
118
- const status = runProcess("git", ["status", "--porcelain"], root).trim();
117
+ // reproducible claim about that commit. `--untracked-files=no` is what
118
+ // makes the code agree with that sentence: bare `--porcelain` includes
119
+ // untracked paths, and an untracked file is not an uncommitted change to a
120
+ // tracked file — the analysis reads `git ls-files`-tracked files only, so a
121
+ // tree whose only dirt is an editor swap, a scratch file, or an unignored
122
+ // build output has an unchanged analyzed input and must produce an
123
+ // unchanged envelope (#683).
124
+ const status = runProcess("git", ["status", "--porcelain", "--untracked-files=no"], root).trim();
119
125
  const dirty = status.length > 0;
120
126
 
121
127
  return { commit, remote, dirty };
@@ -32,7 +32,7 @@
32
32
  * @module
33
33
  */
34
34
  import { computeImpact } from "./impact.mjs";
35
- import { computeImpactConstraints } from "./edge-constraints.mjs";
35
+ import { computeImpactConstraints } from "../rules/edge-constraints.mjs";
36
36
  import {
37
37
  buildDecisionImpact,
38
38
  buildEvolutionAlignment,