@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.
- package/package.json +40 -13
- package/src/analysis/jvm/packages.mjs +0 -17
- package/src/analysis/manifest-util.mjs +14 -5
- package/src/analysis/markdown.mjs +340 -0
- package/src/analysis/source-util.mjs +5 -4
- package/src/analysis/typescript.mjs +146 -0
- package/src/architecture-intent/judge.mjs +1 -1
- package/src/architecture-intent/model.mjs +3 -12
- package/src/commands/README.md +16 -7
- package/src/commands/change-intent.mjs +2 -11
- package/src/commands/check.mjs +234 -12
- package/src/commands/completeness.mjs +0 -32
- package/src/commands/context-command.mjs +13 -21
- package/src/commands/context.mjs +46 -47
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/delta-snapshot.mjs +1 -5
- package/src/commands/diff.mjs +1 -1
- package/src/commands/discover.mjs +7 -3
- package/src/commands/evaluation-primitives.mjs +6 -2
- package/src/commands/explain.mjs +17 -20
- package/src/commands/graph.mjs +7 -0
- package/src/commands/health.mjs +4 -0
- package/src/commands/impact-reachability.mjs +104 -0
- package/src/commands/impact.mjs +9 -71
- package/src/commands/plan-context-command.mjs +5 -1
- package/src/commands/policy.mjs +4 -4
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/config.mjs +171 -17
- package/src/custom-rules/evidence.mjs +1 -1
- package/src/custom-rules/host.mjs +2 -2
- package/src/custom-rules/values.mjs +8 -3
- package/src/errors.mjs +24 -2
- package/src/eslint-config.mjs +2 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +0 -5
- package/src/governance/adr-registry.mjs +33 -17
- package/src/governance/decision-graph.mjs +1 -1
- package/src/governance/evolution-store.mjs +36 -18
- package/src/governance/fitness-registry.mjs +1 -14
- package/src/governance/profile-registry.mjs +20 -23
- package/src/governance/provenance-record.mjs +1 -11
- package/src/governance/reconcile-score.mjs +0 -3
- package/src/governance/row-schema.mjs +1 -14
- package/src/governance/verdict.mjs +168 -4
- package/src/intent/intent-manifest.json +16 -16
- package/src/lsp/diagnose.mjs +2 -2
- package/src/lsp/server.mjs +1 -1
- package/src/lsp/workspace-index.mjs +3 -3
- package/src/options.mjs +1 -1
- package/src/providers/model-gate.mjs +59 -0
- package/src/providers/moon.mjs +6 -6
- package/src/providers/native/model.mjs +2 -16
- package/src/report/README.md +13 -7
- package/src/report/evidence.mjs +11 -168
- package/src/report/json.mjs +10 -7
- package/src/report/sarif.mjs +29 -4
- package/src/report/text.mjs +39 -0
- package/src/rules/README.md +18 -9
- package/src/{commands → rules}/edge-constraints.mjs +18 -12
- package/src/rules/index.mjs +30 -0
- package/src/values.mjs +49 -0
- package/src/verdict.mjs +58 -7
- package/src/workspace.mjs +29 -0
package/src/config.mjs
CHANGED
|
@@ -125,6 +125,7 @@ import { pathToFileURL } from "node:url";
|
|
|
125
125
|
import { containmentViolation, pathEscapes } from "./containment.mjs";
|
|
126
126
|
|
|
127
127
|
import { loadEslintBoundaryConfig } from "./eslint-config.mjs";
|
|
128
|
+
import { describe, isPlainObject, isStringArray } from "./values.mjs";
|
|
128
129
|
import { findFitnessViolations } from "./governance/fitness-registry.mjs";
|
|
129
130
|
import { declaredFitnessNames, stripRuleFitnessPrefix } from "./governance/adr-registry.mjs";
|
|
130
131
|
import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./governance/row-schema.mjs";
|
|
@@ -258,18 +259,10 @@ const ROW_LIST_MATCHERS = {
|
|
|
258
259
|
|
|
259
260
|
const ROW_LIST_KEYS = Object.keys(ROW_LIST_MATCHERS);
|
|
260
261
|
|
|
261
|
-
/** @type {(value: unknown) => value is string[]} */
|
|
262
|
-
const isStringArray = (value) =>
|
|
263
|
-
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
264
|
-
|
|
265
262
|
/** @type {(value: unknown) => value is [string, string][]} */
|
|
266
263
|
const isTagPairArray = (value) =>
|
|
267
264
|
Array.isArray(value) && value.every((pair) => isStringArray(pair) && pair.length === 2);
|
|
268
265
|
|
|
269
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
270
|
-
const isPlainObject = (value) =>
|
|
271
|
-
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
272
|
-
|
|
273
266
|
/**
|
|
274
267
|
* What is wrong with the entries of one string list, each message naming the
|
|
275
268
|
* entry's own index so a long list points at the offender rather than at
|
|
@@ -646,6 +639,155 @@ function findCoverageViolations(value) {
|
|
|
646
639
|
return violations;
|
|
647
640
|
}
|
|
648
641
|
|
|
642
|
+
/**
|
|
643
|
+
* The one edge kind a markdown marker row can declare. The name is the claim
|
|
644
|
+
* the row makes about the graph: the captured symbol is resolved to the project
|
|
645
|
+
* that exports it, and the edge runs from the document's own project to that
|
|
646
|
+
* project. Kept as data rather than inlined at the check site so a second kind
|
|
647
|
+
* (`docs/reference/policy-schema.md`, "markdown") extends this list and nothing
|
|
648
|
+
* else — and so a row naming a kind this reader cannot draw is refused at load,
|
|
649
|
+
* where a law that would silently never run belongs.
|
|
650
|
+
*
|
|
651
|
+
* @type {Readonly<string[]>}
|
|
652
|
+
*/
|
|
653
|
+
export const MARKDOWN_EDGE_KINDS = Object.freeze(["resolvedExportOwner"]);
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* One markdown marker row's problems, prefixed with its index.
|
|
657
|
+
*
|
|
658
|
+
* `pattern` is a regular-expression SOURCE matched against one line of a
|
|
659
|
+
* matched document at a time — never a glob, and never a whole-document
|
|
660
|
+
* match — because the position a violation reports must be a real
|
|
661
|
+
* `file:line:column` a developer can open. Its FIRST capture group must name
|
|
662
|
+
* the exported symbol the marker claims; a pattern with no capture group can
|
|
663
|
+
* match every line it is aimed at and still resolve nothing, which is a law
|
|
664
|
+
* that reads as enforced while testing nothing — refused here for the reason
|
|
665
|
+
* every dead shape in this file is refused.
|
|
666
|
+
*
|
|
667
|
+
* @param {object} row
|
|
668
|
+
* @param {number} index
|
|
669
|
+
* @returns {string[]}
|
|
670
|
+
*/
|
|
671
|
+
function markdownMarkerRowViolations(row, index) {
|
|
672
|
+
const at = `markdown.markers[${index}]`;
|
|
673
|
+
if (!isPlainObject(row)) return [`${at}: must be an object, got ${describe(row)}`];
|
|
674
|
+
|
|
675
|
+
const violations = [];
|
|
676
|
+
if (typeof row.pattern !== "string" || row.pattern === "") {
|
|
677
|
+
violations.push(
|
|
678
|
+
`${at}.pattern: must be a non-empty regular-expression source matched against one line of ` +
|
|
679
|
+
`a matched document, got ${describe(row.pattern)}`,
|
|
680
|
+
);
|
|
681
|
+
} else {
|
|
682
|
+
let compiled = null;
|
|
683
|
+
try {
|
|
684
|
+
compiled = new RegExp(row.pattern, "u");
|
|
685
|
+
} catch (cause) {
|
|
686
|
+
violations.push(
|
|
687
|
+
`${at}.pattern: '${row.pattern}' is not a valid regular expression under the 'u' flag ` +
|
|
688
|
+
`(${cause?.message ?? cause})`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
if (compiled !== null) {
|
|
692
|
+
// The capture count, read off the standard `pattern|` trick: matching the
|
|
693
|
+
// empty string yields one element per capture group plus the whole match,
|
|
694
|
+
// so a pattern that cannot capture anything reports `length === 1` here.
|
|
695
|
+
// Compiled in its own try, because the appended `|` can fail on a source
|
|
696
|
+
// the pattern alone accepted; the compile violation above is already
|
|
697
|
+
// reported in that case, and a second one would name the same row twice.
|
|
698
|
+
let captures;
|
|
699
|
+
try {
|
|
700
|
+
captures = (new RegExp(`${row.pattern}|`, "u").exec("")?.length ?? 1) - 1;
|
|
701
|
+
} catch {
|
|
702
|
+
captures = 1; // already refused above by the pattern's own compile check
|
|
703
|
+
}
|
|
704
|
+
if (captures < 1) {
|
|
705
|
+
violations.push(
|
|
706
|
+
`${at}.pattern: '${row.pattern}' has no capture group — the first capture group must ` +
|
|
707
|
+
`name the exported symbol the marker claims, and a pattern that captures nothing ` +
|
|
708
|
+
`resolves nothing while reading as enforced`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (!MARKDOWN_EDGE_KINDS.includes(row.edge)) {
|
|
714
|
+
violations.push(
|
|
715
|
+
`${at}.edge: ${describe(row.edge)} is not an edge kind this reader can draw — expected ` +
|
|
716
|
+
`${MARKDOWN_EDGE_KINDS.map((kind) => `"${kind}"`).join(", ")} (the captured symbol is ` +
|
|
717
|
+
`resolved to the project exporting it, and the edge runs there from the document's own ` +
|
|
718
|
+
`project)`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
for (const key of Object.keys(row)) {
|
|
722
|
+
if (key !== "pattern" && key !== "edge") {
|
|
723
|
+
violations.push(`${at}.${key}: not a markdown marker field — expected 'pattern' and 'edge'`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return violations;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The markdown document track's problems, as messages; empty when it is
|
|
731
|
+
* well-formed. Pure, so a test drives it without a file on disk.
|
|
732
|
+
*
|
|
733
|
+
* `include` names the documents the track reads, by workspace-relative glob —
|
|
734
|
+
* the same glob machinery `boundarySuppressions` rows and `coverage.unowned`
|
|
735
|
+
* rows match with (`./rules/match.mjs`'s `safeMatchesGlob`), complexity-checked
|
|
736
|
+
* here at load for the reason a suppression's `path` is. An ABSOLUTE pattern is
|
|
737
|
+
* refused rather than left to match nothing forever: it can never match a
|
|
738
|
+
* workspace-relative document path, so declaring one is a law aimed outside
|
|
739
|
+
* the tree it governs (the family test is `ABSOLUTE_ARTIFACT_PATH`'s — the same
|
|
740
|
+
* "absolute in either path family" question, asked of a glob instead of a file).
|
|
741
|
+
*
|
|
742
|
+
* @param {unknown} value The parsed `markdown` value.
|
|
743
|
+
* @returns {string[]}
|
|
744
|
+
*/
|
|
745
|
+
function findMarkdownViolations(value) {
|
|
746
|
+
if (!isPlainObject(value)) {
|
|
747
|
+
return [`markdown: must be an object carrying 'include' and 'markers', got ${describe(value)}`];
|
|
748
|
+
}
|
|
749
|
+
const violations = [];
|
|
750
|
+
if (!Array.isArray(value.include) || value.include.length === 0) {
|
|
751
|
+
violations.push(
|
|
752
|
+
`markdown.include: must be a non-empty array of workspace-relative glob patterns naming ` +
|
|
753
|
+
`the documents the track reads, got ${describe(value.include)}`,
|
|
754
|
+
);
|
|
755
|
+
} else {
|
|
756
|
+
value.include.forEach((pattern, index) => {
|
|
757
|
+
const at = `markdown.include[${index}]`;
|
|
758
|
+
if (typeof pattern !== "string" || pattern === "") {
|
|
759
|
+
violations.push(
|
|
760
|
+
`${at}: must be a non-empty workspace-relative glob, got ${describe(pattern)}`,
|
|
761
|
+
);
|
|
762
|
+
} else if (ABSOLUTE_ARTIFACT_PATH.test(pattern)) {
|
|
763
|
+
violations.push(
|
|
764
|
+
`${at}: '${pattern}' is an absolute path — an include glob is matched against ` +
|
|
765
|
+
`workspace-relative document paths, so an absolute one can never match`,
|
|
766
|
+
);
|
|
767
|
+
} else {
|
|
768
|
+
const problem = globComplexityError(pattern);
|
|
769
|
+
if (problem) violations.push(`${at}: '${pattern}' ${problem}`);
|
|
770
|
+
}
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if (!Array.isArray(value.markers) || value.markers.length === 0) {
|
|
774
|
+
violations.push(
|
|
775
|
+
`markdown.markers: must be a non-empty array of {pattern, edge} rows, got ` +
|
|
776
|
+
`${describe(value.markers)}`,
|
|
777
|
+
);
|
|
778
|
+
} else {
|
|
779
|
+
value.markers.forEach((row, index) =>
|
|
780
|
+
violations.push(...markdownMarkerRowViolations(row, index)),
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
for (const key of Object.keys(value)) {
|
|
784
|
+
if (key !== "include" && key !== "markers") {
|
|
785
|
+
violations.push(`markdown.${key}: not a markdown field — expected 'include' and 'markers'`);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return violations;
|
|
789
|
+
}
|
|
790
|
+
|
|
649
791
|
/**
|
|
650
792
|
* The grammar a custom rule's `name` is written in: lowercase letters and
|
|
651
793
|
* digits, single `-` separators, nothing else.
|
|
@@ -934,13 +1076,6 @@ function findCustomRuleViolations(list, io) {
|
|
|
934
1076
|
return violations;
|
|
935
1077
|
}
|
|
936
1078
|
|
|
937
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
938
|
-
function describe(value) {
|
|
939
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
940
|
-
if (value === null) return "null";
|
|
941
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
1079
|
/**
|
|
945
1080
|
* Everything wrong with a loaded boundary config, as messages; empty when it
|
|
946
1081
|
* is well-formed. Pure, so a test drives it without a file on disk.
|
|
@@ -959,6 +1094,7 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
959
1094
|
fitness,
|
|
960
1095
|
customRules,
|
|
961
1096
|
coverage,
|
|
1097
|
+
markdown,
|
|
962
1098
|
} = module;
|
|
963
1099
|
// F05: the resolution half of the governance block (`row-schema.mjs`'s
|
|
964
1100
|
// `io.resolve`) was validator-only until now — no production caller passed
|
|
@@ -1014,6 +1150,19 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
1014
1150
|
violations.push(...findCoverageViolations(coverage));
|
|
1015
1151
|
}
|
|
1016
1152
|
|
|
1153
|
+
// The markdown document track — the seventh top-level law, declared here and
|
|
1154
|
+
// executed at graph level (`../analysis/markdown.mjs`): machine-readable
|
|
1155
|
+
// markers inside tracked markdown documents, each resolved to an edge from
|
|
1156
|
+
// the document's own project to the project that exports the named symbol.
|
|
1157
|
+
// Absent means "no document track" — the workspace decision this key exists
|
|
1158
|
+
// to state, and the state every config-absent run must stay byte-identical
|
|
1159
|
+
// to. Present and malformed is refused here, loudly, for the reason the two
|
|
1160
|
+
// blocks above state: a row this reader cannot understand is a document law
|
|
1161
|
+
// that would not run while the policy still says it does.
|
|
1162
|
+
if (markdown !== undefined) {
|
|
1163
|
+
violations.push(...findMarkdownViolations(markdown));
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1017
1166
|
// Absent means "nothing is suppressed", which is the only default that fails
|
|
1018
1167
|
// toward reporting — unlike the eight options above, where a missing value
|
|
1019
1168
|
// would be a second copy of something ESLint also reads and this module has
|
|
@@ -1109,7 +1258,10 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
1109
1258
|
* `coverage.exempt` is that provider's one channel for the same decision —
|
|
1110
1259
|
* and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
|
|
1111
1260
|
* `./providers/native/model.mjs`'s inline-policy check, because only they
|
|
1112
|
-
* know which provider is reading.
|
|
1261
|
+
* know which provider is reading. `markdown` is the seventh: the document
|
|
1262
|
+
* track (`findMarkdownViolations` above owns the shape), read into the graph
|
|
1263
|
+
* by `../analysis/markdown.mjs` on every provider, because documents belong
|
|
1264
|
+
* to projects under all three.
|
|
1113
1265
|
*
|
|
1114
1266
|
* The name says `.json` and the list binds both file dialects: `loadModulePolicy`
|
|
1115
1267
|
* runs the same check over an ES module's exports, which is what makes a
|
|
@@ -1123,6 +1275,7 @@ const JSON_POLICY_KEYS = [
|
|
|
1123
1275
|
"fitness",
|
|
1124
1276
|
"customRules",
|
|
1125
1277
|
"coverage",
|
|
1278
|
+
"markdown",
|
|
1126
1279
|
];
|
|
1127
1280
|
|
|
1128
1281
|
/**
|
|
@@ -1199,7 +1352,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
|
|
|
1199
1352
|
* inline one.
|
|
1200
1353
|
* @param {string[]} [extraViolations] Violations the caller already found that
|
|
1201
1354
|
* `findBoundaryConfigViolations` does not check on its own.
|
|
1202
|
-
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object }}
|
|
1355
|
+
* @returns {{ depConstraints: object[], options: object, suppressions: object[], fitness?: object[], customRules?: object[], coverage?: object, markdown?: object }}
|
|
1203
1356
|
* `fitness` and `customRules` are present only when the config declares
|
|
1204
1357
|
* them — a workspace without one carries no key, the same "absent is a
|
|
1205
1358
|
* decision" posture `cli.mjs`'s `check` uses for a missing
|
|
@@ -1223,6 +1376,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1223
1376
|
...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
|
|
1224
1377
|
...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
|
|
1225
1378
|
...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
|
|
1379
|
+
...(parsed.markdown === undefined ? {} : { markdown: parsed.markdown }),
|
|
1226
1380
|
};
|
|
1227
1381
|
}
|
|
1228
1382
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* from "the pipeline never collected any", so it answers `pass` for a reason
|
|
22
22
|
* nobody earned. A throw is a bug in the caller that composed the bundle, not
|
|
23
23
|
* a fact about the workspace, which is why it is an exception rather than a
|
|
24
|
-
* violation list — the same posture `../
|
|
24
|
+
* violation list — the same posture `../governance/verdict.mjs`'s `buildDecision`
|
|
25
25
|
* takes when a verdict and its counts disagree.
|
|
26
26
|
*
|
|
27
27
|
* ## Byte-determinism, and the two orders that are NOT normalized here
|
|
@@ -725,8 +725,8 @@ export async function evaluateCustomRule({
|
|
|
725
725
|
* What is wrong with a verdict, or `null`.
|
|
726
726
|
*
|
|
727
727
|
* The obligations are the four-state vocabulary's, not this host's invention
|
|
728
|
-
* (`../governance/verdict.mjs` states I1–I5 and
|
|
729
|
-
*
|
|
728
|
+
* (`../governance/verdict.mjs` states I1–I5 and enforces them for a command's
|
|
729
|
+
* own decision through `buildDecision`): `fail` names what
|
|
730
730
|
* failed, `pass` names nothing, `unknown` names why it could not tell,
|
|
731
731
|
* `not_applicable` names why it did not apply. A rule that breaks one of them
|
|
732
732
|
* has returned a shape, not a judgment — hence "hollow", and hence a refusal
|
|
@@ -16,11 +16,16 @@
|
|
|
16
16
|
*
|
|
17
17
|
* The rendering is fixed here and nowhere else: `an array of N`, `null`,
|
|
18
18
|
* `undefined`, and otherwise the value's type followed by its JSON.
|
|
19
|
+
*
|
|
20
|
+
* The plain-object guard is defined one floor down, in `../values.mjs` — the
|
|
21
|
+
* package-wide vocabulary every layer's validators share — and re-exported
|
|
22
|
+
* here so this layer's importers keep their spelling. It cannot be defined
|
|
23
|
+
* here: `./host.mjs` imports `../config.mjs` and `../governance/verdict.mjs`,
|
|
24
|
+
* so a guard defined in this directory would sit above the modules that need
|
|
25
|
+
* to reach it.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
export const isPlainObject = (value) =>
|
|
23
|
-
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
export { isPlainObject } from "../values.mjs";
|
|
24
29
|
|
|
25
30
|
/** @type {(value: unknown) => boolean} */
|
|
26
31
|
export const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
|
package/src/errors.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* `UsageError` covers exactly the refusals those regexes matched: a path
|
|
14
14
|
* outside the workspace or matching no tracked file (`./workspace.mjs`'s
|
|
15
|
-
* `selectFiles`), an unknown project name (`./commands/impact.mjs`'s
|
|
15
|
+
* `selectFiles`), an unknown project name (`./commands/impact-reachability.mjs`'s
|
|
16
16
|
* `computeImpact`, `./commands/context-command.mjs`'s
|
|
17
17
|
* `collectProjectContext`), and a malformed `file:line:column` site string
|
|
18
18
|
* (`./commands/explain.mjs`'s `parseSite`). They are one mistake in four
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
* (`../AGENTS.md`, check's four exit codes).
|
|
24
24
|
*
|
|
25
25
|
* One class, nothing else exported: a second class needs a catch site that
|
|
26
|
-
* treats two of these mistakes differently, and none does.
|
|
26
|
+
* treats two of these mistakes differently, and none does. Beside it sits the
|
|
27
|
+
* one error-SHAPE predicate the engine shares (`isEnoent` below) — a test of
|
|
28
|
+
* what a caught value looks like, not a decision about what one means, which
|
|
29
|
+
* is why it lives with the error primitives rather than at any catch site.
|
|
27
30
|
*/
|
|
28
31
|
export class UsageError extends Error {
|
|
29
32
|
/**
|
|
@@ -34,3 +37,22 @@ export class UsageError extends Error {
|
|
|
34
37
|
this.name = "UsageError";
|
|
35
38
|
}
|
|
36
39
|
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Is this caught value the filesystem's "no such file or directory"?
|
|
43
|
+
*
|
|
44
|
+
* Node's `fs` throws the raw error with `code: "ENOENT"` set directly;
|
|
45
|
+
* `./process.mjs`'s `runProcess` wraps its child's failure and carries the
|
|
46
|
+
* original on `cause` — so the shape arrives both ways, and every site that
|
|
47
|
+
* distinguishes "absent" from "could not read" was spelling the test by hand
|
|
48
|
+
* (`#652`). The two legs together are the one definition; a catch site adds
|
|
49
|
+
* its own meaning on top (absent store, absent registry, install Moon), which
|
|
50
|
+
* is exactly the logic this predicate does not own.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} error The caught value, of any shape.
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function isEnoent(error) {
|
|
56
|
+
const thrown = /** @type {{code?: unknown, cause?: {code?: unknown}}|null|undefined} */ (error);
|
|
57
|
+
return thrown?.code === "ENOENT" || thrown?.cause?.code === "ENOENT";
|
|
58
|
+
}
|
package/src/eslint-config.mjs
CHANGED
|
@@ -71,6 +71,8 @@
|
|
|
71
71
|
import { createRequire } from "node:module";
|
|
72
72
|
import { pathToFileURL } from "node:url";
|
|
73
73
|
|
|
74
|
+
import { isPlainObject } from "./values.mjs";
|
|
75
|
+
|
|
74
76
|
/** The severities ESLint itself recognises for a rule entry. */
|
|
75
77
|
const KNOWN_SEVERITIES = new Set(["off", "warn", "error", 0, 1, 2]);
|
|
76
78
|
const OFF_SEVERITIES = new Set(["off", 0]);
|
|
@@ -94,11 +96,6 @@ const OFF_SEVERITIES = new Set(["off", 0]);
|
|
|
94
96
|
// one — a stricter refusal than strictly necessary, not a silent guess.
|
|
95
97
|
const BARE_EXTENSION_GLOB = /^\*\*\/\*\.[A-Za-z0-9]+$/u;
|
|
96
98
|
|
|
97
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
98
|
-
function isPlainObject(value) {
|
|
99
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
99
|
/** A rule entry's severity — the bare value itself, or the `[severity, …]` pair's first element. */
|
|
103
100
|
function severityOf(value) {
|
|
104
101
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -58,11 +58,6 @@ export function commit(root, message) {
|
|
|
58
58
|
return git(root, "rev-parse", "HEAD").trim();
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Resolves the current HEAD of the fixture. */
|
|
62
|
-
export function headOf(root) {
|
|
63
|
-
return git(root, "rev-parse", "HEAD").trim();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
61
|
/**
|
|
67
62
|
* Opens a brand-new throwaway native git workspace (never the repository's own
|
|
68
63
|
* tree). `archkeep.json` declares two Go projects on two layers, exactly the
|
|
@@ -91,6 +91,8 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "
|
|
|
91
91
|
import { join } from "node:path";
|
|
92
92
|
|
|
93
93
|
import { containmentViolation } from "../containment.mjs";
|
|
94
|
+
import { isEnoent } from "../errors.mjs";
|
|
95
|
+
import { describe } from "../values.mjs";
|
|
94
96
|
|
|
95
97
|
/** The directory, relative to a workspace root, where ADR files live. */
|
|
96
98
|
export const ADR_DIR = "docs/adr";
|
|
@@ -137,13 +139,6 @@ const FRONTMATTER_KEYS = Object.freeze([
|
|
|
137
139
|
"updated",
|
|
138
140
|
]);
|
|
139
141
|
|
|
140
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
141
|
-
function describe(value) {
|
|
142
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
143
|
-
if (value === null) return "null";
|
|
144
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
142
|
/** The `---`-delimited frontmatter block's text, or null when the file has none. */
|
|
148
143
|
function frontmatterBlock(text) {
|
|
149
144
|
if (!text.startsWith("---")) return null;
|
|
@@ -494,12 +489,17 @@ export function validateLineage(records) {
|
|
|
494
489
|
* throws; the caller maps that to exit 3, never to an empty list.
|
|
495
490
|
*
|
|
496
491
|
* @param {string} root Absolute workspace root.
|
|
497
|
-
* @param {{
|
|
492
|
+
* @param {{existsSync?: (path: string) => boolean, readdirSync?: (path: string) => string[],
|
|
493
|
+
* readFileSync?: (path: string, encoding: "utf8") => string,
|
|
498
494
|
* lstatSync?: (path: string) => {isSymbolicLink: () => boolean}, realpathSync?: (path: string) => string,
|
|
499
495
|
* tracked?: string[]}} [io]
|
|
500
|
-
* Injectable filesystem seams, defaulting to
|
|
501
|
-
* module uses so the CLI stays event-loop-simple.
|
|
502
|
-
*
|
|
496
|
+
* Injectable filesystem seams, defaulting to `defaultAdrIo` (the sync
|
|
497
|
+
* `node:fs` calls this module uses) so the CLI stays event-loop-simple.
|
|
498
|
+
* Tests inject an in-memory tree.
|
|
499
|
+
* `existsSync` gates the containment probe below: an in-memory root does not
|
|
500
|
+
* exist on disk, and probing a nonexistent path's ancestry would walk up to
|
|
501
|
+
* a real parent and misread it as an escape.
|
|
502
|
+
* `tracked` is the `git ls-files` list (`../workspace.mjs`'s
|
|
503
503
|
* `listTrackedFiles`); when provided, a directory entry whose `docs/adr/<name>`
|
|
504
504
|
* path is not in it is excluded before it is ever validated — see this
|
|
505
505
|
* module's header for why, and `../architecture-intent/model.mjs`'s
|
|
@@ -512,10 +512,11 @@ export function validateLineage(records) {
|
|
|
512
512
|
* @throws {Error} on an unreadable registry.
|
|
513
513
|
*/
|
|
514
514
|
export function loadAdrRegistry(root, io = {}) {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
const
|
|
518
|
-
const
|
|
515
|
+
const dirExists = io.existsSync ?? defaultAdrIo.existsSync;
|
|
516
|
+
const readDir = io.readdirSync ?? defaultAdrIo.readdirSync;
|
|
517
|
+
const readFile = io.readFileSync ?? defaultAdrIo.readFileSync;
|
|
518
|
+
const lstat = io.lstatSync ?? defaultAdrIo.lstatSync;
|
|
519
|
+
const realpath = io.realpathSync ?? defaultAdrIo.realpathSync;
|
|
519
520
|
const dir = join(root, ADR_DIR);
|
|
520
521
|
|
|
521
522
|
/** @type {string[]} */
|
|
@@ -538,7 +539,7 @@ export function loadAdrRegistry(root, io = {}) {
|
|
|
538
539
|
// deliberately silent, for the reason this module's header states.
|
|
539
540
|
names = readDir(dir);
|
|
540
541
|
} catch (cause) {
|
|
541
|
-
if (cause
|
|
542
|
+
if (isEnoent(cause)) return { records: [], byId: new Map() };
|
|
542
543
|
throw new Error(`archkeep: cannot read ${ADR_DIR}: ${cause?.message ?? cause}`, { cause });
|
|
543
544
|
}
|
|
544
545
|
names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
@@ -581,7 +582,7 @@ export function loadAdrRegistry(root, io = {}) {
|
|
|
581
582
|
// drives is keyed by a fixture path that does not exist on disk, and
|
|
582
583
|
// probing a nonexistent root's ancestry would walk up to a real parent
|
|
583
584
|
// directory and misread it as an escape. Real roots only.
|
|
584
|
-
|
|
585
|
+
dirExists(root) &&
|
|
585
586
|
containmentViolation(root, filePath, { lstatSync: lstat, realpathSync: realpath }) !== null
|
|
586
587
|
) {
|
|
587
588
|
continue;
|
|
@@ -774,3 +775,18 @@ export function unresolvedDecisionRefRows(rows, byId, knownFitness) {
|
|
|
774
775
|
}
|
|
775
776
|
return unresolved;
|
|
776
777
|
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The default io: the sync `node:fs` calls `loadAdrRegistry` makes. This is
|
|
781
|
+
* the only place in this module the filesystem is named directly — every
|
|
782
|
+
* function reads through an injected `io` whose missing seams fall back here,
|
|
783
|
+
* so a test drives an in-memory tree without mocking the fs module and the
|
|
784
|
+
* module body never touches the disk on its own.
|
|
785
|
+
*/
|
|
786
|
+
const defaultAdrIo = Object.freeze({
|
|
787
|
+
existsSync,
|
|
788
|
+
lstatSync,
|
|
789
|
+
readdirSync,
|
|
790
|
+
readFileSync,
|
|
791
|
+
realpathSync,
|
|
792
|
+
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* ## Descriptive by contract (Wave 2 scope)
|
|
13
13
|
*
|
|
14
14
|
* This module NEVER gates. It decides nothing about whether a finding IS one
|
|
15
|
-
* (the rule that produced it owns that,
|
|
15
|
+
* (the rule that produced it owns that, `./verdict.mjs`), never
|
|
16
16
|
* changes `check`'s exit code, and never turns green or red on its own. It
|
|
17
17
|
* reports the graph the registry and the caller's row/finding facts describe
|
|
18
18
|
* — or, where a reference cannot resolve, names the gap. The `adr`/`report`
|
|
@@ -40,17 +40,18 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
43
|
+
lstatSync,
|
|
44
|
+
mkdirSync,
|
|
45
|
+
readdirSync,
|
|
46
|
+
readFileSync,
|
|
47
|
+
realpathSync,
|
|
48
|
+
renameSync,
|
|
49
|
+
writeFileSync,
|
|
50
50
|
} from "node:fs";
|
|
51
51
|
import { join, resolve } from "node:path";
|
|
52
52
|
|
|
53
53
|
import { containmentViolation } from "../containment.mjs";
|
|
54
|
+
import { isEnoent } from "../errors.mjs";
|
|
54
55
|
import {
|
|
55
56
|
eventDedupeKey,
|
|
56
57
|
eventId,
|
|
@@ -205,13 +206,13 @@ function validateEventRecord(parsed, path) {
|
|
|
205
206
|
export function writeEvent(dir, event, io = {}) {
|
|
206
207
|
validateEventForWrite(event);
|
|
207
208
|
|
|
208
|
-
const readDir = io.readdirSync ??
|
|
209
|
-
const readFile = io.readFileSync ??
|
|
210
|
-
const writeFile = io.writeFileSync ??
|
|
211
|
-
const rename = io.renameSync ??
|
|
212
|
-
const makeDir = io.mkdirSync ??
|
|
213
|
-
const lstat = io.lstatSync ??
|
|
214
|
-
const realpath = io.realpathSync ??
|
|
209
|
+
const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
|
|
210
|
+
const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
|
|
211
|
+
const writeFile = io.writeFileSync ?? defaultEvolutionIo.writeFileSync;
|
|
212
|
+
const rename = io.renameSync ?? defaultEvolutionIo.renameSync;
|
|
213
|
+
const makeDir = io.mkdirSync ?? defaultEvolutionIo.mkdirSync;
|
|
214
|
+
const lstat = io.lstatSync ?? defaultEvolutionIo.lstatSync;
|
|
215
|
+
const realpath = io.realpathSync ?? defaultEvolutionIo.realpathSync;
|
|
215
216
|
|
|
216
217
|
// Resolved once: the identical string feeds the containment check and the
|
|
217
218
|
// actual write (`../containment.mjs`, "One contract binds the WRITE call
|
|
@@ -222,7 +223,7 @@ export function writeEvent(dir, event, io = {}) {
|
|
|
222
223
|
try {
|
|
223
224
|
names = readDir(dirAbs);
|
|
224
225
|
} catch (cause) {
|
|
225
|
-
if (cause
|
|
226
|
+
if (isEnoent(cause)) {
|
|
226
227
|
// An absent optional store is an empty store: the caller's first event.
|
|
227
228
|
makeDir(dirAbs, { recursive: true });
|
|
228
229
|
names = readDir(dirAbs);
|
|
@@ -316,8 +317,8 @@ export function writeEvent(dir, event, io = {}) {
|
|
|
316
317
|
* @throws {Error} on the first unreadable or malformed event file.
|
|
317
318
|
*/
|
|
318
319
|
export function readEvents(dir, io = {}) {
|
|
319
|
-
const readDir = io.readdirSync ??
|
|
320
|
-
const readFile = io.readFileSync ??
|
|
320
|
+
const readDir = io.readdirSync ?? defaultEvolutionIo.readdirSync;
|
|
321
|
+
const readFile = io.readFileSync ?? defaultEvolutionIo.readFileSync;
|
|
321
322
|
|
|
322
323
|
const dirAbs = resolve(dir);
|
|
323
324
|
|
|
@@ -325,7 +326,7 @@ export function readEvents(dir, io = {}) {
|
|
|
325
326
|
try {
|
|
326
327
|
names = readDir(dirAbs);
|
|
327
328
|
} catch (cause) {
|
|
328
|
-
if (cause
|
|
329
|
+
if (isEnoent(cause)) return [];
|
|
329
330
|
throw new Error(
|
|
330
331
|
`archkeep: cannot read the event store '${dirAbs}': ${cause?.message ?? cause}`,
|
|
331
332
|
{ cause },
|
|
@@ -360,3 +361,20 @@ export function readEvents(dir, io = {}) {
|
|
|
360
361
|
}
|
|
361
362
|
return events;
|
|
362
363
|
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The default io: the sync `node:fs` calls `writeEvent` and `readEvents` make.
|
|
367
|
+
* This is the only place in this module the filesystem is named directly —
|
|
368
|
+
* every store operation reads and writes through an injected `io` whose
|
|
369
|
+
* missing seams fall back here, so a test drives an in-memory store without
|
|
370
|
+
* mocking the fs module and the module body never touches the disk on its own.
|
|
371
|
+
*/
|
|
372
|
+
const defaultEvolutionIo = Object.freeze({
|
|
373
|
+
lstatSync,
|
|
374
|
+
mkdirSync,
|
|
375
|
+
readdirSync,
|
|
376
|
+
readFileSync,
|
|
377
|
+
realpathSync,
|
|
378
|
+
renameSync,
|
|
379
|
+
writeFileSync,
|
|
380
|
+
});
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
import { isValidSelector, resolveMembers } from "../architecture-intent/selectors.mjs";
|
|
51
51
|
import { languageOf } from "../analysis/registry.mjs";
|
|
52
52
|
import { canonicalizeJson } from "../canonical.mjs";
|
|
53
|
+
import { describe, isPlainObject } from "../values.mjs";
|
|
53
54
|
import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "./row-schema.mjs";
|
|
54
55
|
import { fitnessVerdict, isVerdict } from "./verdict.mjs";
|
|
55
56
|
import {
|
|
@@ -62,9 +63,6 @@ import {
|
|
|
62
63
|
tagConformance,
|
|
63
64
|
} from "./fitness-rules.mjs";
|
|
64
65
|
|
|
65
|
-
/** The one `fitness` list key in the boundary config. */
|
|
66
|
-
export const FITNESS_KEY = "fitness";
|
|
67
|
-
|
|
68
66
|
/** The condition types the registry can evaluate. */
|
|
69
67
|
export const CONDITION_TYPES = Object.freeze([
|
|
70
68
|
"cycle-free",
|
|
@@ -87,17 +85,6 @@ const LAYER_DIRECTIONS = Object.freeze(["forbidden", "required"]);
|
|
|
87
85
|
/** The one `toDependents` a `tag-conformance` row may carry. */
|
|
88
86
|
const TAG_DEPENDENT_DIRECTIONS = Object.freeze(["only", "never"]);
|
|
89
87
|
|
|
90
|
-
/** @type {(value: unknown) => value is Record<string, unknown>} */
|
|
91
|
-
const isPlainObject = (value) =>
|
|
92
|
-
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
93
|
-
|
|
94
|
-
/** A value's type, for an error message that shows what was actually there. */
|
|
95
|
-
function describe(value) {
|
|
96
|
-
if (Array.isArray(value)) return `an array (${JSON.stringify(value)})`;
|
|
97
|
-
if (value === null) return "null";
|
|
98
|
-
return `${typeof value} (${JSON.stringify(value) ?? String(value)})`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
88
|
function unknownKeys(obj, allowed) {
|
|
102
89
|
return Object.keys(obj).filter((key) => !allowed.includes(key));
|
|
103
90
|
}
|