@ecoma-io/archkeep 0.21.0 → 0.22.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 (37) hide show
  1. package/cli.mjs +156 -66
  2. package/package.json +1 -1
  3. package/src/analysis/contract.md +32 -5
  4. package/src/analysis/source-util.mjs +107 -0
  5. package/src/analysis/typescript.mjs +86 -5
  6. package/src/commands/change.mjs +59 -28
  7. package/src/commands/check.mjs +65 -26
  8. package/src/commands/completeness.mjs +126 -19
  9. package/src/commands/context-command.mjs +13 -5
  10. package/src/commands/context.mjs +31 -4
  11. package/src/commands/coverage-verdict.mjs +184 -0
  12. package/src/commands/debt.mjs +18 -15
  13. package/src/commands/delta-classify.mjs +13 -18
  14. package/src/commands/delta.mjs +95 -33
  15. package/src/commands/diff.mjs +31 -24
  16. package/src/commands/discover.mjs +30 -10
  17. package/src/commands/drift.mjs +21 -21
  18. package/src/commands/edge-constraints.mjs +47 -1
  19. package/src/commands/evaluation-primitives.mjs +194 -2
  20. package/src/commands/evolution.mjs +27 -10
  21. package/src/commands/explain.mjs +14 -13
  22. package/src/commands/fitness.mjs +20 -19
  23. package/src/commands/graph.mjs +14 -5
  24. package/src/commands/health.mjs +12 -5
  25. package/src/commands/history.mjs +29 -15
  26. package/src/commands/impact.mjs +17 -18
  27. package/src/commands/plan-context-command.mjs +10 -5
  28. package/src/commands/reconcile.mjs +14 -17
  29. package/src/commands/scenario-evaluation.mjs +93 -16
  30. package/src/commands/scenario.mjs +28 -18
  31. package/src/commands/waivers.mjs +36 -28
  32. package/src/governance/evolution-event.mjs +62 -9
  33. package/src/intent/intent-manifest.json +83 -39
  34. package/src/report/json.mjs +32 -5
  35. package/src/report/text.mjs +82 -12
  36. package/src/verdict.mjs +78 -36
  37. package/src/workspace.mjs +126 -2
@@ -21,7 +21,11 @@
21
21
  * wearing a formatter's name, and it would disagree with the engine the first
22
22
  * time either changed (`README.md` beside this file).
23
23
  */
24
- import { isWholeFileFailure } from "../analysis/source-util.mjs";
24
+ import {
25
+ isDynamicSiteFailure,
26
+ isExternalSiteFailure,
27
+ isWholeFileFailure,
28
+ } from "../analysis/source-util.mjs";
25
29
 
26
30
  /** Two spaces of indent for a violation's detail lines, four for wrapped text. */
27
31
  const DETAIL = " ";
@@ -128,12 +132,19 @@ const formatFailure = (failure) =>
128
132
  * consequences and one heading for both hid that for as long as it existed.
129
133
  *
130
134
  * A SITE failure is a blind spot: the file was analyzed, and one specifier in
131
- * it is not statically knowable `import(url)` with a computed argument is
132
- * the honest example, and so is a literal package import that names no
133
- * declared project and cannot resolve (an uninstalled third-party dependency:
134
- * a workspace with packages is a normal state, and failing the run on it would
135
- * block merges over dependencies nobody crossed). Both are legitimately
136
- * permanent, and the rest of the file still got a verdict.
135
+ * it is not statically knowable. Three classes, and the section says which
136
+ * class did what instead of leaving the exit code to be discovered. An
137
+ * unresolvable literal referencing the workspace's own surface — path-like,
138
+ * `#` subpath, `paths` alias is a concrete question the resolver was asked
139
+ * about the governed graph and could not answer: it WITHHELD the verdict
140
+ * (exit 3 on a findings-free run, #595). An unresolvable bare-package
141
+ * specifier names no project the workspace declares — `external: true`, the
142
+ * resolvability question an installed dependency tree answers, which a
143
+ * workspace legitimately may not have (the native self-check's `git archive`
144
+ * copy is the measured case: 284 rows) — disclosed without withholding. A
145
+ * non-literal `import()`/`require()` argument is the language declaring the
146
+ * target computed at runtime — a declared limit, `dynamic: true`, disclosed
147
+ * without withholding.
137
148
  *
138
149
  * A WHOLE-FILE failure is a hole: nothing was read, parsed, or analyzed — or a
139
150
  * literal import that names a DECLARED project could not be resolved, so the
@@ -165,12 +176,42 @@ export function formatFailures(failures) {
165
176
  }
166
177
 
167
178
  if (blind.length > 0) {
179
+ const dyn = blind.filter(isDynamicSiteFailure).length;
180
+ const ext = blind.filter(isExternalSiteFailure).length;
181
+ const literal = blind.length - dyn - ext;
168
182
  sections.push(
169
183
  [
170
- `${blind.length} import${blind.length === 1 ? "" : "s"} could not be resolved. ` +
171
- `These are blind spots inside files that were analyzed, not verdicts — the run does not fail on them:`,
172
- ...blind.map(formatFailure),
173
- ].join("\n"),
184
+ `${blind.length} import${blind.length === 1 ? "" : "s"} could not be resolved ` +
185
+ `blind spots inside files that were analyzed:`,
186
+ // All permanent classes are disclosed; the verdict treats them
187
+ // differently, and the report says which class did what instead of
188
+ // leaving the exit code to be discovered (the same posture the
189
+ // whole-file section's heading holds).
190
+ ...(literal > 0
191
+ ? [
192
+ `${literal} unresolvable literal import${literal === 1 ? "" : "s"} withheld the run's verdict (#595):`,
193
+ ]
194
+ : []),
195
+ ...blind
196
+ .filter((failure) => !isDynamicSiteFailure(failure) && !isExternalSiteFailure(failure))
197
+ .map(formatFailure),
198
+ ...(ext > 0
199
+ ? [
200
+ `${ext} unresolvable package import${ext === 1 ? "" : "s"} name${ext === 1 ? "s" : ""} ` +
201
+ `no project the workspace declares — external, disclosed without withholding:`,
202
+ ]
203
+ : []),
204
+ ...blind.filter(isExternalSiteFailure).map(formatFailure),
205
+ ...(dyn > 0
206
+ ? [
207
+ `${dyn} non-literal import() argument${dyn === 1 ? "" : "s"} — a declared limit static analysis cannot answer; ` +
208
+ `the verdict stands over the statically judgeable surface:`,
209
+ ]
210
+ : []),
211
+ ...blind.filter(isDynamicSiteFailure).map(formatFailure),
212
+ ]
213
+ .filter(Boolean)
214
+ .join("\n"),
174
215
  );
175
216
  }
176
217
  return sections.join("\n\n");
@@ -751,6 +792,27 @@ export function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
751
792
  ].join("\n\n");
752
793
  }
753
794
 
795
+ /**
796
+ * The could-not-look section: the reasons a run that found no violation is
797
+ * still not a pass, spelled the same way the JSON envelope words them
798
+ * (`../verdict.mjs`'s `coverageIncompleteReasons`, joined into
799
+ * `decision.reason`).
800
+ *
801
+ * "✔ no boundary violations" states only the boundary half of the verdict —
802
+ * on a run whose exit is 3, the reader needs the next lines to say WHY the
803
+ * run failed despite it, or the checkmark reads as a clean tree (P1-04's
804
+ * repro: an exit-3 run printed the checkmark and nothing else). Empty exactly
805
+ * when the run reached a verdict on everything it looked at.
806
+ *
807
+ * @param {string[]} coverageIncomplete The verdict's reason clauses, in
808
+ * `verdictFor`'s pinned order.
809
+ * @returns {string} Empty exactly when there is nothing to disclose.
810
+ */
811
+ export function formatCoverageIncomplete(coverageIncomplete) {
812
+ if (coverageIncomplete.length === 0) return "";
813
+ return coverageIncomplete.map((reason) => `⚠ ${reason}`).join("\n");
814
+ }
815
+
754
816
  /**
755
817
  * The whole report, violations first.
756
818
  *
@@ -766,7 +828,7 @@ export function formatAcceptedViolations(waived, unresolvedDecisionRefs) {
766
828
  * summary line above only says "no boundary violations" when there is nothing
767
829
  * a waiver is covering either.
768
830
  *
769
- * @param {{violations: object[], failures: object[], analyzed: number, projects: number, imports: number, goWork?: object|null, tsconfigPaths?: object|null, declaredEdges?: object|null, intent?: object|null, fitness?: object|null, fitnessOverall?: {verdict: string}|null, customRules?: {decisions: object[], overall: {verdict: string}}|null, coverageGaps?: object[], notes?: string[], policy?: {profile: string|null, source: string, fingerprint: string}|null, unresolvedDecisionRefs?: Set<string>}} run
831
+ * @param {{violations: object[], failures: object[], analyzed: number, projects: number, imports: number, goWork?: object|null, tsconfigPaths?: object|null, declaredEdges?: object|null, intent?: object|null, fitness?: object|null, fitnessOverall?: {verdict: string}|null, customRules?: {decisions: object[], overall: {verdict: string}}|null, coverageGaps?: object[], notes?: string[], policy?: {profile: string|null, source: string, fingerprint: string}|null, unresolvedDecisionRefs?: Set<string>, coverageIncomplete?: string[]}} run
770
832
  * @returns {string}
771
833
  */
772
834
  export function formatReport({
@@ -786,6 +848,7 @@ export function formatReport({
786
848
  notes = [],
787
849
  policy = null,
788
850
  unresolvedDecisionRefs,
851
+ coverageIncomplete = [],
789
852
  }) {
790
853
  const inspected =
791
854
  `${imports} import${imports === 1 ? "" : "s"} in ${analyzed} file${analyzed === 1 ? "" : "s"} ` +
@@ -829,6 +892,13 @@ export function formatReport({
829
892
  }
830
893
  }
831
894
 
895
+ // The could-not-look reasons render BEFORE the per-feature sections, right
896
+ // after the verdict summary they qualify: a reader who just read "✔ no
897
+ // boundary violations" needs the next lines to be the reason the exit is
898
+ // still 3, not go.work bookkeeping.
899
+ const coverageIncompleteSection = formatCoverageIncomplete(coverageIncomplete);
900
+ if (coverageIncompleteSection !== "") sections.push(coverageIncompleteSection);
901
+
832
902
  const goWorkSection = formatGoWork(goWork);
833
903
  if (goWorkSection !== "") sections.push(goWorkSection);
834
904
 
package/src/verdict.mjs CHANGED
@@ -4,10 +4,10 @@
4
4
  *
5
5
  * Both sit here rather than in `../cli.mjs` because two callers need them and
6
6
  * only one of the two is the CLI shell: `./commands/check.mjs` words its own
7
- * `--format json` envelope from `verdictFor`, and `../cli.mjs`'s `runCheck`
8
- * takes the process's exit code from the same call. `../cli.mjs` re-exports
9
- * `EXIT` under its own name, so every importer that already reads it from
10
- * there keeps working.
7
+ * `--format json` envelope's `status` and `exitCode` from `verdictFor`, and
8
+ * `../cli.mjs`'s `runCheck` takes the process's exit code from the same call.
9
+ * `../cli.mjs` re-exports `EXIT` under its own name, so every importer that
10
+ * already reads it from there keeps working.
11
11
  */
12
12
 
13
13
  import { buildDecision } from "./report/evidence.mjs";
@@ -18,6 +18,29 @@ export const EXIT = Object.freeze({
18
18
  usage: 2,
19
19
  error: 3,
20
20
  });
21
+ /**
22
+ * The coverage clauses of a no-verdict reason, spelled once — the strings
23
+ * `verdictFor` joins into `decision.reason` and `check`'s text report renders
24
+ * beside its headline, so the two faces cannot disagree about WHY a run
25
+ * failed to reach a verdict. The clauses cover only the three coverage axes
26
+ * (whole-file failures, unresolved sites, zero analysis); the intent,
27
+ * fitness and custom-rule clauses stay local to `verdictFor` because they
28
+ * name verdict surfaces the coverage counts cannot see.
29
+ *
30
+ * @param {{unchecked: number, blindSpots: number, analyzed: number}} counts
31
+ * @returns {string[]} One clause per failed coverage axis, in pinned order.
32
+ */
33
+ export function coverageIncompleteReasons({ unchecked, blindSpots, analyzed }) {
34
+ return [
35
+ unchecked > 0
36
+ ? `${unchecked} file${unchecked === 1 ? "" : "s"} could not be analyzed — coverage incomplete`
37
+ : null,
38
+ blindSpots > 0
39
+ ? `${blindSpots} import site${blindSpots === 1 ? "" : "s"} could not be resolved — coverage incomplete`
40
+ : null,
41
+ analyzed === 0 ? "no file in scope could be analyzed — coverage incomplete" : null,
42
+ ].filter(Boolean);
43
+ }
21
44
  /**
22
45
  * The one place that turns a run's counts into the verdict every format
23
46
  * agrees on. `runCheck` uses it for the process's exit code; `check` uses the
@@ -41,8 +64,12 @@ export const EXIT = Object.freeze({
41
64
  * with no findings), which makes a regression in this mapping a loud error
42
65
  * rather than a silent one.
43
66
  *
44
- * @param {{violations: number, declaredEdgeFindings: number, goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number, intentUnresolved: number, intentUnresolvedDecisionRefs?: number, unchecked: number, fitnessFail?: number, fitnessUnknown?: number, customRuleFail?: number, customRuleUnknown?: number}} counts
45
- * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3, decision: object}}
67
+ * @param {{violations: number, declaredEdgeFindings: number, goWorkDrift: number, tsconfigPathsDead: number, intentFindings: number, intentUnresolved: number, intentUnresolvedDecisionRefs?: number, unchecked: number, analyzed: number, blindSpots: number, fitnessFail?: number, fitnessUnknown?: number, customRuleFail?: number, customRuleUnknown?: number}} counts
68
+ * @returns {{status: "ok"|"findings"|"no-verdict", exitCode: 0|1|3, reasons: string[], decision: object}}
69
+ * `reasons` is the coverage clause list behind this verdict — the
70
+ * could-not-look clauses on the findings and no-verdict lanes, empty on the
71
+ * clean one. `decision.reason` joins it with the intent/fitness/custom
72
+ * clauses where the lane is no-verdict.
46
73
  */
47
74
  export function verdictFor({
48
75
  violations,
@@ -53,11 +80,14 @@ export function verdictFor({
53
80
  intentUnresolved,
54
81
  intentUnresolvedDecisionRefs = 0,
55
82
  unchecked,
83
+ analyzed,
84
+ blindSpots,
56
85
  fitnessFail = 0,
57
86
  fitnessUnknown = 0,
58
87
  customRuleFail = 0,
59
88
  customRuleUnknown = 0,
60
89
  }) {
90
+ const coverageReasons = coverageIncompleteReasons({ unchecked, blindSpots, analyzed });
61
91
  if (
62
92
  violations > 0 ||
63
93
  declaredEdgeFindings > 0 ||
@@ -74,9 +104,10 @@ export function verdictFor({
74
104
  return {
75
105
  status: "findings",
76
106
  exitCode: EXIT.violations,
107
+ reasons: coverageReasons,
77
108
  decision: buildDecision({
78
109
  status: "findings",
79
- coverageComplete: unchecked === 0,
110
+ coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
80
111
  findings:
81
112
  violations +
82
113
  declaredEdgeFindings +
@@ -90,52 +121,63 @@ export function verdictFor({
90
121
  }
91
122
  if (
92
123
  unchecked > 0 ||
124
+ // An unresolvable site was seen but never judged (#595): named in
125
+ // coverage.blindSpots, and echoed here so the exit says it too — a
126
+ // pass over a site the run could not read claims a verdict it does
127
+ // not hold.
128
+ blindSpots > 0 ||
129
+ // A run that analyzed nothing judged nothing (#599) — a scope that
130
+ // selected no project-owned file, or in-scope files no analyzer
131
+ // claims. Judging nothing is not finding nothing.
132
+ analyzed === 0 ||
93
133
  intentUnresolved > 0 ||
94
134
  intentUnresolvedDecisionRefs > 0 ||
95
135
  fitnessUnknown > 0 ||
96
136
  customRuleUnknown > 0
97
137
  ) {
138
+ // The list is built before the return so `decision.reason` joins the very
139
+ // array the envelope's `reasons` carries — one list, two renderings, and
140
+ // neither can drift from the other.
141
+ const reasons = [
142
+ ...coverageReasons,
143
+ // The could-not-look condition, named so a reader knows WHICH half of
144
+ // the run did not reach a verdict (I3). When read-only coverage and
145
+ // intent both failed, name both — a reason naming only the file count
146
+ // would hide the unresolved intent boundary from a reader acting on
147
+ // the reason alone (it stays visible in result.intent.unresolved, and
148
+ // status is still no-verdict, so nothing is silent). Each clause below
149
+ // is independent of the others — none is gated on a sibling clause
150
+ // being zero — so a tree that fails on several axes at once names
151
+ // every one of them, not only the first the array happens to hit.
152
+ intentUnresolved > 0
153
+ ? `${intentUnresolved} architecture-intent boundary or row${intentUnresolved === 1 ? "" : "s"} could not be established`
154
+ : null,
155
+ intentUnresolvedDecisionRefs > 0
156
+ ? `${intentUnresolvedDecisionRefs} intent row${intentUnresolvedDecisionRefs === 1 ? "" : "s"} ${intentUnresolvedDecisionRefs === 1 ? "cites" : "cite"} a decisionRef that does not resolve`
157
+ : null,
158
+ fitnessUnknown > 0
159
+ ? `${fitnessUnknown} fitness functions${fitnessUnknown === 1 ? "" : "s"} could not be determined`
160
+ : null,
161
+ customRuleUnknown > 0
162
+ ? `${customRuleUnknown} custom rule${customRuleUnknown === 1 ? "" : "s"} could not be judged`
163
+ : null,
164
+ ].filter(Boolean);
98
165
  return {
99
166
  status: "no-verdict",
100
167
  exitCode: EXIT.error,
168
+ reasons,
101
169
  decision: buildDecision({
102
170
  status: "no-verdict",
103
- coverageComplete: unchecked === 0,
171
+ coverageComplete: unchecked === 0 && blindSpots === 0 && analyzed > 0,
104
172
  findings: 0,
105
- // The could-not-look condition, named so a reader knows WHICH half of
106
- // the run did not reach a verdict (I3). When read-only coverage and
107
- // intent both failed, name both — a reason naming only the file count
108
- // would hide the unresolved intent boundary from a reader acting on
109
- // the reason alone (it stays visible in result.intent.unresolved, and
110
- // status is still no-verdict, so nothing is silent). Each clause below
111
- // is independent of the others — none is gated on a sibling clause
112
- // being zero — so a tree that fails on several axes at once names
113
- // every one of them, not just the first the array happens to check.
114
- reason: [
115
- unchecked > 0
116
- ? `${unchecked} file${unchecked === 1 ? "" : "s"} could not be analyzed — coverage incomplete`
117
- : null,
118
- intentUnresolved > 0
119
- ? `${intentUnresolved} architecture-intent boundary or row${intentUnresolved === 1 ? "" : "s"} could not be established`
120
- : null,
121
- intentUnresolvedDecisionRefs > 0
122
- ? `${intentUnresolvedDecisionRefs} intent row${intentUnresolvedDecisionRefs === 1 ? "" : "s"} ${intentUnresolvedDecisionRefs === 1 ? "cites" : "cite"} a decisionRef that does not resolve`
123
- : null,
124
- fitnessUnknown > 0
125
- ? `${fitnessUnknown} fitness function${fitnessUnknown === 1 ? "" : "s"} could not be determined`
126
- : null,
127
- customRuleUnknown > 0
128
- ? `${customRuleUnknown} custom rule${customRuleUnknown === 1 ? "" : "s"} could not be judged`
129
- : null,
130
- ]
131
- .filter(Boolean)
132
- .join("; "),
173
+ reason: reasons.join("; "),
133
174
  }),
134
175
  };
135
176
  }
136
177
  return {
137
178
  status: "ok",
138
179
  exitCode: EXIT.ok,
180
+ reasons: [],
139
181
  decision: buildDecision({
140
182
  status: "ok",
141
183
  coverageComplete: true,
package/src/workspace.mjs CHANGED
@@ -598,12 +598,130 @@ export function selectFiles(files, paths, { root, cwd, tracked = files }) {
598
598
  * @param {{ analyze?: typeof analyzeFile }} [io] Injectable analyzer.
599
599
  * @returns {{ imports: object[], failures: object[], analyzed: number, analyzedFiles: string[] }}
600
600
  */
601
+ /**
602
+ * Extensions that cannot carry an import or a boundary crossing, so their
603
+ * silence in analysis is never a coverage gap (#601): documentation,
604
+ * structured data and configuration, binary assets, generated lockfiles.
605
+ * A file in one of these formats has no imports for ANY analyzer to read —
606
+ * listing it under "unsupported language" would name every README in every
607
+ * workspace, and a gap that always fires teaches a reader to skip the line
608
+ * it is written on (the argument `./commands/context.mjs`'s
609
+ * `unownedAnalyzableFiles` already states for the same reason). The set must
610
+ * never intersect `LANGUAGE_BY_EXTENSION` — a format that cannot carry an
611
+ * import cannot become an analyzed language; `workspace.test.mjs` holds that
612
+ * line, because an entry that crossed it would turn this exemption into the
613
+ * silent direction.
614
+ */
615
+ const DATA_BY_EXTENSION = Object.freeze(
616
+ new Set([
617
+ // Documentation.
618
+ ".md",
619
+ ".txt",
620
+ ".rst",
621
+ ".adoc",
622
+ // Structured data and configuration.
623
+ ".json",
624
+ ".json5",
625
+ ".jsonc",
626
+ ".yaml",
627
+ ".yml",
628
+ ".toml",
629
+ ".ini",
630
+ ".cfg",
631
+ ".conf",
632
+ ".properties",
633
+ ".xml",
634
+ // Binary assets.
635
+ ".png",
636
+ ".jpg",
637
+ ".jpeg",
638
+ ".gif",
639
+ ".svg",
640
+ ".webp",
641
+ ".ico",
642
+ ".bmp",
643
+ ".woff",
644
+ ".woff2",
645
+ ".ttf",
646
+ ".otf",
647
+ ".eot",
648
+ ".mp4",
649
+ ".mp3",
650
+ ".wav",
651
+ ".pdf",
652
+ // Archives and generated artifacts.
653
+ ".zip",
654
+ ".gz",
655
+ ".tgz",
656
+ ".tar",
657
+ ".br",
658
+ // Generated lockfiles.
659
+ ".lock",
660
+ ".sum",
661
+ ]),
662
+ );
663
+
664
+ /**
665
+ * Extension-less basenames that are workspace furniture, not source: legal
666
+ * notices and the two command files whose bodies are shell commands rather
667
+ * than imports. `languageOf` answers `null` for every dotless name already —
668
+ * this set is what keeps them out of the unsupported-language row without
669
+ * inventing a general dotfile list.
670
+ */
671
+ const DOTLESS_FURNITURE = Object.freeze(
672
+ new Set([
673
+ "LICENSE",
674
+ "NOTICE",
675
+ "AUTHORS",
676
+ "CHANGELOG",
677
+ "CODEOWNERS",
678
+ "CONTRIBUTING",
679
+ "SECURITY",
680
+ "Makefile",
681
+ "Dockerfile",
682
+ ]),
683
+ );
684
+
685
+ /**
686
+ * Whether a file no analyzer claims is nevertheless not a coverage gap —
687
+ * the predicate `analyzeWorkspace` applies before naming a skipped file
688
+ * under `unsupported-language` (#601). Three exemptions, each with the same
689
+ * shape of reason: the file was never part of what this tool could judge.
690
+ *
691
+ * @param {string} sourceFile Workspace-relative path.
692
+ * @returns {boolean}
693
+ */
694
+ export function exemptFromUnsupportedLanguage(sourceFile) {
695
+ const base = sourceFile.slice(sourceFile.lastIndexOf("/") + 1);
696
+ // Dotfiles are editor and tool state (`.gitignore`, `.env`, `.npmrc`).
697
+ if (base.startsWith(".")) return true;
698
+ const dot = base.lastIndexOf(".");
699
+ if (dot > 0 && DATA_BY_EXTENSION.has(base.slice(dot))) return true;
700
+ if (DOTLESS_FURNITURE.has(base)) return true;
701
+ // The polyglot manifests the manifest track itself reads — a `go.mod` is
702
+ // claimed by the engine one layer down, not skipped by it.
703
+ return basenameMatches(base, POLYGLOT_MANIFEST_NAMES, posix.matchesGlob);
704
+ }
705
+
601
706
  export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {}) {
602
707
  const imports = [];
603
708
  const failures = [];
604
709
  const analyzedFiles = [];
710
+ // Files whose extension no analyzer claims AND whose silence is a coverage
711
+ // gap (#601): skipped before reading, so naming them here is the only
712
+ // record the run will ever carry that they existed — "not analyzed" must
713
+ // not be indistinguishable from "not present". READMEs, manifests and
714
+ // other formats that cannot carry an import are exempt
715
+ // (`exemptFromUnsupportedLanguage` above): they are not an unsupported
716
+ // language, they are workspace furniture the tool has always declined.
717
+ const unsupportedLanguageFiles = [];
605
718
  for (const sourceFile of files) {
606
- if (languageOf(sourceFile) === null) continue;
719
+ if (languageOf(sourceFile) === null) {
720
+ if (!exemptFromUnsupportedLanguage(sourceFile)) {
721
+ unsupportedLanguageFiles.push(sourceFile);
722
+ }
723
+ continue;
724
+ }
607
725
  const text = workspace.readFile(sourceFile);
608
726
  if (text === null) {
609
727
  failures.push(fileFailure(sourceFile, "could not be read"));
@@ -614,7 +732,13 @@ export function analyzeWorkspace(workspace, files, { analyze = analyzeFile } = {
614
732
  imports.push(...result.imports);
615
733
  failures.push(...result.failures);
616
734
  }
617
- return { imports, failures, analyzed: analyzedFiles.length, analyzedFiles };
735
+ return {
736
+ imports,
737
+ failures,
738
+ analyzed: analyzedFiles.length,
739
+ analyzedFiles,
740
+ unsupportedLanguageFiles,
741
+ };
618
742
  }
619
743
 
620
744
  /** The polyglot manifests `polyglotManifests` looks for. */