@ecoma-io/archkeep 0.23.0 → 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.
@@ -12,6 +12,7 @@
12
12
  import { statSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { foldMarkdownTrack } from "../analysis/markdown.mjs";
15
16
  import {
16
17
  blindSpotRows,
17
18
  fileFailure,
@@ -31,7 +32,7 @@ import {
31
32
  import { partitionUnownedCoverage } from "./coverage-acceptance.mjs";
32
33
  import { readAdrContext } from "./adr.mjs";
33
34
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
34
- import { declaredEdgeViolationsForCheck } from "../rules/edge-constraints.mjs";
35
+ import { declaredEdgeViolationsForCheck, judgeEdge } from "../rules/edge-constraints.mjs";
35
36
  import { customRulesForCheck, declaresCustomRules } from "./custom-rules.mjs";
36
37
  import { driftForCheck } from "./drift.mjs";
37
38
  import { fitnessForCheck } from "./fitness.mjs";
@@ -40,14 +41,16 @@ import { resolvePolicy } from "./policy.mjs";
40
41
  import { resolveProvenance } from "./provenance.mjs";
41
42
  import { INTENT_FILE, loadIntent } from "../architecture-intent/model.mjs";
42
43
  import { compareGoWork, parseGoWorkUse } from "../go-work.mjs";
44
+ import { mergeDeclaredEdges } from "../providers/native/graph.mjs";
43
45
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
44
46
  import { formatSarif } from "../report/sarif.mjs";
45
47
  import { formatReport } from "../report/text.mjs";
46
48
  import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
47
- import { evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
49
+ import { applySuppressionTable, evaluateRun, exemptResolvedFile } from "../rules/index.mjs";
50
+ import { buildReachability } from "../rules/reachability.mjs";
48
51
  import { orphanedNotDependOnTags, unmatchedConstraintRows } from "../rules/tags.mjs";
49
52
  import { judgeTsconfigPaths } from "../tsconfig-paths.mjs";
50
- import { verdictFor } from "../verdict.mjs";
53
+ import { coverageComplete, verdictFor } from "../verdict.mjs";
51
54
  import { listTrackedFiles, listUntrackedFiles } from "../workspace.mjs";
52
55
 
53
56
  /**
@@ -453,6 +456,41 @@ export async function check(
453
456
  }
454
457
  }
455
458
 
459
+ // The markdown document track, keyed by presence like every fold above it:
460
+ // a policy that declares no `markdown` block reaches nothing here and hears
461
+ // nothing anywhere — no edges, no failures, no envelope key — so a
462
+ // config-absent run is byte-identical to one this fold never existed for
463
+ // (`../../../../AGENTS.md`, "a change to what is reported on an unchanged
464
+ // workspace is a breaking change"). A policy that DOES declare one has its
465
+ // documents read (`../analysis/markdown.mjs`'s `foldMarkdownTrack`), the
466
+ // resolved pairings folded into the graph the way the declared manifest
467
+ // track's edges already are (`mergeDeclaredEdges`, the same dedup key), and
468
+ // every whole-file failure the read earned pushed into the same `failures`
469
+ // funnel the analysis uses — an unresolvable marker is an `unchecked` file,
470
+ // never a clean one.
471
+ //
472
+ // The fold runs BEFORE the walk below on purpose: a document pairing is a
473
+ // project-to-project dependency claim, and from here on this run's graph
474
+ // carries it exactly as it carries a csproj's `ProjectReference` — the tag
475
+ // rows, the circularity walk and the reachability behind them all read one
476
+ // graph, so a pairing cannot be legal here and violating in `context`.
477
+ // What it does not join is the twelve non-tag import-site rules: those need
478
+ // a specifier, a file and a resolution, and a document marker has none of
479
+ // them — it is judged as the EDGE it drew, the same 3-of-15 limit
480
+ // `declaredEdgeViolationsForCheck` documents for `implicit` edges.
481
+ let markdownTrack = null;
482
+ if (config !== null && config.markdown !== undefined) {
483
+ markdownTrack = foldMarkdownTrack({
484
+ tracked,
485
+ owned: commandContext.owned,
486
+ readFile: (file) => workspace.readFile(file),
487
+ workspace,
488
+ markdown: config.markdown,
489
+ });
490
+ mergeDeclaredEdges(graph, markdownTrack.edges);
491
+ failures.push(...markdownTrack.failures);
492
+ }
493
+
456
494
  // Both faces of one walk: the run's verdict, and the raw superset it was
457
495
  // picked from — every candidate up to each site's surviving group, including
458
496
  // the verdicts the suppression table removed to get there. `evaluate` alone
@@ -462,8 +500,66 @@ export async function check(
462
500
  // the gate's waiver-expiry judgement below and the engine's are the same
463
501
  // judgement, not two reads of the clock a boundary instant could split.
464
502
  const now = referenceTime();
465
- const { violations: judged, rawViolations } = evaluateRun(imports, graph, { ...config, now });
466
- const violations = sortViolations(judged);
503
+ const { violations: judged, rawViolations: judgedRaw } = evaluateRun(imports, graph, {
504
+ ...config,
505
+ now,
506
+ });
507
+
508
+ // The document pairings' own verdicts, judged by the machinery that already
509
+ // existed: `judgeEdge` — the same function `declaredEdgeViolationsForCheck`
510
+ // runs `implicit` edges through, so a doc pairing and a declared manifest
511
+ // edge can never disagree about the same boundary — over the graph the fold
512
+ // above already joined, with reachability built once for the whole claim
513
+ // list. The same empty-table exit `declaredEdgeViolationsForCheck` takes: a
514
+ // workspace declaring no `depConstraints` has opted out of tag enforcement
515
+ // entirely, and folding `projectWithoutTagsCannotHaveDependencies` onto
516
+ // every marker anyway would flag an opted-out workspace for a reason its
517
+ // import sites are never flagged for. Each verdict is then reshaped into the
518
+ // exact `Violation` record `violationOf` builds — position and specifier
519
+ // from the MARKER (the line a reader edits is the document's), project and
520
+ // constraint from the edge — so suppression, waiver annotation, sorting and
521
+ // the SARIF face all treat it as the ordinary violation it is.
522
+ /** @type {object[]} */
523
+ const markdownRaw = [];
524
+ if (config !== null && markdownTrack !== null && config.depConstraints.length > 0) {
525
+ const reachability = buildReachability(graph);
526
+ for (const claim of markdownTrack.claims) {
527
+ for (const verdict of judgeEdge(
528
+ { source: claim.source, target: claim.target },
529
+ graph.nodes,
530
+ graph.dependencies,
531
+ config.depConstraints,
532
+ reachability,
533
+ )) {
534
+ markdownRaw.push({
535
+ sourceFile: claim.file,
536
+ line: claim.line,
537
+ column: claim.column,
538
+ specifier: claim.name,
539
+ kind: claim.type,
540
+ messageId: verdict.messageId,
541
+ message: verdict.message,
542
+ sourceProject: verdict.source,
543
+ targetProject: verdict.target,
544
+ constraint: verdict.constraint,
545
+ data: verdict.data,
546
+ });
547
+ }
548
+ }
549
+ }
550
+ // The same table, the same behaviour: a suppression row whose glob covers a
551
+ // document removes that document's verdict (or waives it, with the same
552
+ // expiry evidence an import-site waiver carries), and the RAW records join
553
+ // `rawViolations` below so a row covering only document verdicts still
554
+ // counts as alive — a table that is doing its job must not read as dead
555
+ // because the violations it removes live in a different fold.
556
+ const markdownViolations =
557
+ config !== null && markdownTrack !== null
558
+ ? applySuppressionTable(config.suppressions, markdownRaw, now)
559
+ : [];
560
+
561
+ const rawViolations = [...judgedRaw, ...markdownRaw];
562
+ const violations = sortViolations([...judged, ...markdownViolations]);
467
563
  // An ACTIVE waiver keeps the violation it accepts in the findings list,
468
564
  // marked `waivedBy` — the run is still non-zero (waiving does not flip
469
565
  // exit 1 → 0), and this count is the additive "accepted violations" number
@@ -704,7 +800,10 @@ export async function check(
704
800
  config !== null &&
705
801
  options.paths.length === 0 &&
706
802
  failures.length === 0 &&
707
- (config.suppressions.length > 0 || config.depConstraints.length > 0 || coverageRows.length > 0)
803
+ (config.suppressions.length > 0 ||
804
+ config.depConstraints.length > 0 ||
805
+ coverageRows.length > 0 ||
806
+ config.markdown !== undefined)
708
807
  ) {
709
808
  const deadRows = [];
710
809
  // The third dead table: a `coverage.unowned` row matching no unowned file
@@ -755,6 +854,34 @@ export async function check(
755
854
  );
756
855
  }
757
856
  }
857
+ // The document track's two dead tables, measured against what the fold
858
+ // actually selected (`markdownTrack.includeCounts`/`rowMatches` — the
859
+ // same counts the fold's own selection is built from, not a re-derivation
860
+ // that could disagree): an include glob matching no tracked document, and
861
+ // a marker row matching no line in any included document, both govern
862
+ // nothing while reading as enforced — every pairing the workspace meant
863
+ // to declare goes unjudged and the run stays green. A whole-file failure
864
+ // above already disabled this gate, so reaching here with a row that
865
+ // matched nothing means the tree genuinely has no such line.
866
+ if (markdownTrack !== null && config.markdown !== undefined) {
867
+ markdownTrack.includeCounts.forEach((count, index) => {
868
+ if (count > 0) return;
869
+ deadRows.push(
870
+ `markdown.include[${index}]: '${config.markdown.include[index]}' matches no tracked ` +
871
+ `document — the document track reads nothing, so every marker row below it governs ` +
872
+ `nothing while reading as enforced. Either the path was never right, or the ` +
873
+ `documents are not tracked`,
874
+ );
875
+ });
876
+ markdownTrack.rowMatches.forEach((count, index) => {
877
+ if (count > 0) return;
878
+ deadRows.push(
879
+ `markdown.markers[${index}]: the pattern matches no line in any included document — ` +
880
+ `this row extracts no pairing, so it enforces nothing while reading as enforced. ` +
881
+ `Either the documents do not carry the marker, or the pattern was never right`,
882
+ );
883
+ });
884
+ }
758
885
  if (deadRows.length > 0) {
759
886
  throw new Error(
760
887
  `archkeep: ${policySource ?? "the boundary config"} describes a workspace that does not ` +
@@ -977,8 +1104,14 @@ export async function check(
977
1104
  // Complete means the run judged everything in scope: no
978
1105
  // whole-file failure (unchecked), no unresolvable site (#595),
979
1106
  // and at least one file analyzed (#599 — a run that judged
980
- // nothing has no verdict to claim).
981
- complete: unchecked === 0 && blindSpotCount === 0 && analyzed > 0,
1107
+ // nothing has no verdict to claim). Read from the one predicate
1108
+ // `verdictFor`'s decision face reads, so this field and the
1109
+ // envelope's `decision.coverageComplete` are one derivation —
1110
+ // the counts are this command's (the go.work/tsconfig failures
1111
+ // pushed above and the accepted files withdrawn above make the
1112
+ // universe wider than `commandContext.analysis`), the law is
1113
+ // not.
1114
+ complete: coverageComplete({ unchecked, blindSpotCount, analyzed }),
982
1115
  projects: Object.keys(graph.nodes).length,
983
1116
  analyzedFiles: analyzed,
984
1117
  imports: imports.length,
@@ -1029,6 +1162,31 @@ export async function check(
1029
1162
  judged: declaredEdges.judged,
1030
1163
  findings: declaredEdges.findings,
1031
1164
  },
1165
+ // The document track is a policy DECLARATION, so it takes the
1166
+ // same omitted-key-not-null discipline the intent/fitness/
1167
+ // customRules blocks below state: a workspace whose policy
1168
+ // declares no `markdown` block gets no key at all, and its
1169
+ // envelope is byte-identical to the one it got before this
1170
+ // section existed. The findings themselves are NOT restated
1171
+ // here — they are members of `violations` above (sorted with,
1172
+ // suppressible by, and counted into the verdict exactly like
1173
+ // every import-site violation), so an array here would be one
1174
+ // fact counted in two places; the block states what the track
1175
+ // DID: how many documents the globs selected, how many markers
1176
+ // they carried, where each resolution went.
1177
+ ...(markdownTrack === null
1178
+ ? {}
1179
+ : {
1180
+ markdown: {
1181
+ checked: true,
1182
+ documents: markdownTrack.documents,
1183
+ judged: markdownTrack.judged,
1184
+ resolved: markdownTrack.resolved,
1185
+ ...(markdownTrack.selfPaired > 0
1186
+ ? { selfPaired: markdownTrack.selfPaired }
1187
+ : {}),
1188
+ },
1189
+ }),
1032
1190
  // Intent is a governance DECLARATION, absent when the workspace
1033
1191
  // chose not to make one: the key is omitted, never written as
1034
1192
  // null — the design contract `docs/reference/json-output.md` will
@@ -32,16 +32,12 @@
32
32
  * than explaining constraints from a graph whose edges silently under-represent
33
33
  * the real architecture.
34
34
  */
35
- import {
36
- blindSpotRows,
37
- isWholeFileFailure,
38
- unresolvableLiteralCount,
39
- } from "../analysis/source-util.mjs";
40
35
  import { UsageError } from "../errors.mjs";
41
36
  import { judgeEdge } from "../rules/edge-constraints.mjs";
42
37
  import { findConstraintsFor } from "../rules/tags.mjs";
43
38
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
44
39
  import { formatContextReport } from "../report/context-text.mjs";
40
+ import { coverageVerdict } from "./coverage-verdict.mjs";
45
41
  import { resolveProvenance } from "./provenance.mjs";
46
42
  import { readAdrContext } from "./adr.mjs";
47
43
  import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/adr-registry.mjs";
@@ -150,27 +146,23 @@ export function contextCommand(projectName, commandContext, config) {
150
146
  );
151
147
  }
152
148
 
153
- const notAnalyzed = commandContext.analysis.failures
154
- .filter(isWholeFileFailure)
155
- .map(({ sourceFile, reason }) => ({ file: sourceFile, reason }));
156
-
157
- // The same completeness `check` claims (#595, #599): unjudged sites and
158
- // a zero-analyzed run defeat it here exactly as they do there, so a
159
- // context report cannot look complete over a tree the run could not
160
- // fully read.
161
- const blindSpotCount = unresolvableLiteralCount(commandContext.analysis.failures);
162
- const complete =
163
- notAnalyzed.length === 0 && blindSpotCount === 0 && commandContext.analysis.analyzed > 0;
164
- const status = complete ? "ok" : "no-verdict";
165
- const exitCode = complete ? 0 : 3;
149
+ // The completeness verdict is the shared constructor's, not this file's —
150
+ // the same law `graph`/`discover` run (#595, #599): unjudged sites and a
151
+ // zero-analyzed run defeat it here exactly as they do there, so a context
152
+ // report cannot look complete over a tree the run could not fully read
153
+ // while the faces beside it refuse the same tree. The restatement this
154
+ // replaces carried all three axes, so composing it moves no byte on any
155
+ // input.
156
+ const verdict = coverageVerdict(commandContext);
157
+ const { complete, status, exitCode } = verdict;
166
158
 
167
159
  const coverage = {
168
160
  complete,
169
161
  projects: Object.keys(graph.nodes).length,
170
162
  analyzedFiles: commandContext.analysis.analyzed,
171
163
  imports: commandContext.analysis.imports.length,
172
- notAnalyzed,
173
- blindSpots: blindSpotRows(commandContext.analysis.failures),
164
+ notAnalyzed: verdict.notAnalyzed,
165
+ blindSpots: verdict.blindSpots,
174
166
  notes: [
175
167
  "per-edge violations cover only depConstraints (3 of 15 violation types). " +
176
168
  "A dependency with no violations here may still violate npm-ban, circular-dependency, " +
@@ -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";
@@ -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
@@ -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 };