@ecoma-io/archkeep 0.23.0 → 0.24.1
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 +1 -1
- package/src/analysis/csharp.mjs +3 -1
- package/src/analysis/dotnet/csproj.mjs +5 -1
- package/src/analysis/dotnet/namespaces.mjs +1 -0
- package/src/analysis/go.mjs +6 -0
- package/src/analysis/java.mjs +2 -0
- package/src/analysis/jvm/gradle.mjs +3 -1
- package/src/analysis/jvm/maven.mjs +6 -1
- package/src/analysis/jvm/packages.mjs +1 -0
- package/src/analysis/jvm/resolve.mjs +4 -2
- package/src/analysis/kotlin.mjs +2 -0
- package/src/analysis/markdown.mjs +341 -0
- package/src/analysis/python.mjs +8 -0
- package/src/analysis/rust.mjs +5 -1
- package/src/analysis/source-util.mjs +6 -5
- package/src/analysis/typescript.mjs +148 -0
- package/src/architecture-intent/model.mjs +13 -8
- package/src/architecture-intent/selectors.mjs +2 -1
- package/src/commands/change-intent.mjs +10 -9
- package/src/commands/change.mjs +17 -1
- package/src/commands/check.mjs +177 -17
- package/src/commands/completeness.mjs +7 -6
- package/src/commands/context-command.mjs +12 -20
- package/src/commands/coverage-acceptance.mjs +46 -0
- package/src/commands/coverage-verdict.mjs +12 -2
- package/src/commands/custom-rules.mjs +1 -0
- package/src/commands/delta-classify.mjs +3 -0
- package/src/commands/delta-snapshot.mjs +3 -6
- package/src/commands/delta.mjs +35 -21
- package/src/commands/drift.mjs +1 -1
- package/src/commands/evaluation-primitives.mjs +4 -4
- package/src/commands/evolution.mjs +2 -0
- package/src/commands/explain.mjs +19 -20
- package/src/commands/graph.mjs +44 -15
- package/src/commands/health.mjs +4 -0
- package/src/commands/history.mjs +2 -0
- package/src/commands/plan-context-command.mjs +9 -2
- package/src/commands/policy.mjs +8 -5
- package/src/commands/provenance.mjs +8 -2
- package/src/commands/scenario-evaluation.mjs +1 -1
- package/src/commands/trajectory.mjs +2 -1
- package/src/config.mjs +170 -2
- package/src/custom-rules/host.mjs +3 -3
- package/src/errors.mjs +23 -1
- package/src/eslint-config.mjs +3 -5
- package/src/fixtures/evolution-lifecycle/workspace.mjs +15 -4
- package/src/go-work.mjs +1 -1
- package/src/governance/adr-registry.mjs +6 -2
- package/src/governance/debt-ledger.mjs +1 -1
- package/src/governance/decision-fitness.mjs +2 -0
- package/src/governance/decision-graph.mjs +1 -0
- package/src/governance/discovery-proposal.mjs +8 -2
- package/src/governance/evolution-event.mjs +42 -0
- package/src/governance/evolution-store.mjs +3 -2
- package/src/governance/fitness-registry.mjs +2 -3
- package/src/governance/preset-fingerprints.json +14 -14
- package/src/governance/profile-registry.mjs +22 -3
- package/src/governance/provenance-record.mjs +4 -1
- package/src/governance/reconcile-score.mjs +4 -0
- package/src/governance/row-schema.mjs +1 -3
- package/src/governance/verdict.mjs +1 -0
- package/src/governance/waiver.mjs +1 -0
- package/src/intent/intent-manifest.json +6 -6
- package/src/intent/mask-non-code.mjs +1 -0
- package/src/lsp/diagnostics.mjs +3 -2
- package/src/lsp/protocol.mjs +2 -1
- package/src/lsp/server.mjs +3 -0
- package/src/lsp/workspace-index.mjs +3 -1
- package/src/providers/moon.mjs +5 -5
- package/src/providers/native/differential.fixtures.mjs +29 -11
- package/src/providers/native/index.mjs +2 -1
- package/src/providers/native/model.mjs +4 -0
- package/src/report/envelope-shape.mjs +2 -0
- package/src/report/sarif.mjs +21 -8
- package/src/report/snapshot-text.mjs +3 -3
- package/src/report/text.mjs +10 -2
- package/src/rules/index.mjs +30 -0
- package/src/rules/match.mjs +7 -5
- package/src/rules/specifiers.mjs +2 -0
- package/src/rules/tags.mjs +3 -2
- package/src/rules/topology.mjs +6 -1
- package/src/verdict.mjs +33 -2
- package/src/workspace.mjs +1 -0
package/src/analysis/python.mjs
CHANGED
|
@@ -227,17 +227,20 @@ import {
|
|
|
227
227
|
|
|
228
228
|
/** PEP 503 name normalization: case-insensitive, runs of `-_.` collapse to `-`. */
|
|
229
229
|
export function normalizePackageName(name) {
|
|
230
|
+
// used by its own test
|
|
230
231
|
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/** The package name a PEP 508 requirement string refers to, or null. */
|
|
234
235
|
export function parseRequirementName(requirement) {
|
|
236
|
+
// used by its own test
|
|
235
237
|
const match = requirement.trim().match(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/);
|
|
236
238
|
return match ? normalizePackageName(match[0]) : null;
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
/** Every dependency name a pyproject manifest declares, deduped. */
|
|
240
242
|
export function collectDeclaredDependencies(manifest) {
|
|
243
|
+
// used by its own test
|
|
241
244
|
const names = new Set();
|
|
242
245
|
const groups = [
|
|
243
246
|
manifest.project?.dependencies ?? [],
|
|
@@ -630,6 +633,7 @@ const READ_BUILD_BACKENDS = new Set([
|
|
|
630
633
|
* @returns {{ directories: string[], unmodelled: string[] }}
|
|
631
634
|
*/
|
|
632
635
|
export function pythonPackageLayout(manifestText) {
|
|
636
|
+
// used by its own test
|
|
633
637
|
if (manifestText === null) return { directories: [], unmodelled: [] };
|
|
634
638
|
const manifest = parseManifest(manifestText);
|
|
635
639
|
if (manifest === null) {
|
|
@@ -730,6 +734,7 @@ function ownPackageOf(file, projectRoot, directories) {
|
|
|
730
734
|
* @returns {Map<string, { file: string|null, namespace: boolean }>}
|
|
731
735
|
*/
|
|
732
736
|
export function pythonModuleIndex(projectRoot, files, directories = []) {
|
|
737
|
+
// used by its own test
|
|
733
738
|
const index = new Map();
|
|
734
739
|
for (const file of files) {
|
|
735
740
|
if (!file.endsWith(".py")) continue;
|
|
@@ -755,6 +760,7 @@ export function pythonModuleIndex(projectRoot, files, directories = []) {
|
|
|
755
760
|
* @returns {string[]}
|
|
756
761
|
*/
|
|
757
762
|
export function pythonImportRoots(projectRoot, files, directories = []) {
|
|
763
|
+
// used by its own test
|
|
758
764
|
return [...pythonModuleIndex(projectRoot, files, directories).keys()]
|
|
759
765
|
.filter((name) => !name.includes("."))
|
|
760
766
|
.sort();
|
|
@@ -1059,6 +1065,7 @@ function joinContinuedStatement(physicalLines, lineOffsets, index) {
|
|
|
1059
1065
|
* continuation?: boolean }[]}
|
|
1060
1066
|
*/
|
|
1061
1067
|
export function parsePythonImportSites(pythonText) {
|
|
1068
|
+
// used by its own test
|
|
1062
1069
|
const sites = [];
|
|
1063
1070
|
// Byte tolerance (`contract.md`): the lines a CRLF file splits into still
|
|
1064
1071
|
// carry their `\r`, and a BOM-prefixed file's first line starts with
|
|
@@ -1166,6 +1173,7 @@ export function parsePythonImportSites(pythonText) {
|
|
|
1166
1173
|
* @returns {string[]} Reasons, at most one per malformation kind.
|
|
1167
1174
|
*/
|
|
1168
1175
|
export function pythonImportMalformations(pythonText) {
|
|
1176
|
+
// used by its own test
|
|
1169
1177
|
/** @type {string[]} */
|
|
1170
1178
|
const reasons = [];
|
|
1171
1179
|
// A bare `import` or `from` at the end of the file, or on a line with
|
package/src/analysis/rust.mjs
CHANGED
|
@@ -238,6 +238,7 @@ export function resolveRustDependencies(projects, filesOf, readFile) {
|
|
|
238
238
|
|
|
239
239
|
/** Cargo's identifier spelling of a crate name: `-` and `.` become `_`. */
|
|
240
240
|
export function crateIdentifier(name) {
|
|
241
|
+
// used by its own test
|
|
241
242
|
return name.replace(/[-.]/g, "_");
|
|
242
243
|
}
|
|
243
244
|
|
|
@@ -250,6 +251,7 @@ export function crateIdentifier(name) {
|
|
|
250
251
|
* @returns {string|null}
|
|
251
252
|
*/
|
|
252
253
|
export function crateImportName(manifest) {
|
|
254
|
+
// used by its own test
|
|
253
255
|
const declared = manifest?.lib?.name ?? manifest?.package?.name;
|
|
254
256
|
return typeof declared === "string" && declared !== "" ? crateIdentifier(declared) : null;
|
|
255
257
|
}
|
|
@@ -471,7 +473,7 @@ function isOwnProjectPath(root, owner, byCrate) {
|
|
|
471
473
|
* @param {string} path The `use` path, from the first non-space to the `;`.
|
|
472
474
|
* @returns {{text: string, offset: number}[] | null}
|
|
473
475
|
*/
|
|
474
|
-
|
|
476
|
+
function braceGroupArms(path) {
|
|
475
477
|
const open = path.indexOf("{");
|
|
476
478
|
if (open === -1 || path.slice(0, open).trim() !== "") return null;
|
|
477
479
|
/** @type {{text: string, offset: number}[]} */
|
|
@@ -520,6 +522,7 @@ export function braceGroupArms(path) {
|
|
|
520
522
|
* with a brace group and names none.
|
|
521
523
|
*/
|
|
522
524
|
export function useRootSegment(path) {
|
|
525
|
+
// used by its own test
|
|
523
526
|
const match = /^\s*(?:::\s*)?([A-Za-z_]\w*)/.exec(path);
|
|
524
527
|
return match ? match[1] : null;
|
|
525
528
|
}
|
|
@@ -560,6 +563,7 @@ export function useRootSegment(path) {
|
|
|
560
563
|
* produces from reading as a clean file.
|
|
561
564
|
*/
|
|
562
565
|
export function parseRustUseSites(rustText, knownCrates = new Set(), options = {}) {
|
|
566
|
+
// used by its own test
|
|
563
567
|
const { returnMetrics = false } = options;
|
|
564
568
|
/** The offset of the first `use` opener no `;` terminates, `null` before one is seen. */
|
|
565
569
|
let unterminatedUseAt = null;
|
|
@@ -153,13 +153,14 @@ function ownershipIndexOf(projects) {
|
|
|
153
153
|
/**
|
|
154
154
|
* Root comparisons `projectOwning` has performed since the module loaded.
|
|
155
155
|
*
|
|
156
|
-
*
|
|
157
|
-
* deterministic operations instead of milliseconds —
|
|
158
|
-
* repository does not trust in a test (cf. #359, #369).
|
|
159
|
-
* lookup makes is counted: one per binary-search step,
|
|
156
|
+
* A test-support export: nothing in production reads it. It exists so the
|
|
157
|
+
* complexity test counts deterministic operations instead of milliseconds —
|
|
158
|
+
* the wall-clock this repository does not trust in a test (cf. #359, #369).
|
|
159
|
+
* Every comparison the lookup makes is counted: one per binary-search step,
|
|
160
|
+
* one per equality probe.
|
|
160
161
|
*/
|
|
161
162
|
let rootComparisons = 0;
|
|
162
|
-
export const ownershipRootComparisons = () => rootComparisons;
|
|
163
|
+
export const ownershipRootComparisons = () => rootComparisons; // used by its own test
|
|
163
164
|
|
|
164
165
|
/**
|
|
165
166
|
* The first index in `roots` (sorted ascending) whose value is at or after
|
|
@@ -318,6 +318,7 @@ const SCRIPT_KIND_BY_LANG = Object.freeze({
|
|
|
318
318
|
* @returns {{ path: boolean, relative: boolean, namesOnly: boolean }}
|
|
319
319
|
*/
|
|
320
320
|
export function specifierSpelling(specifier) {
|
|
321
|
+
// used by its own test
|
|
321
322
|
const relative =
|
|
322
323
|
specifier === "." ||
|
|
323
324
|
specifier === ".." ||
|
|
@@ -340,6 +341,7 @@ export function specifierSpelling(specifier) {
|
|
|
340
341
|
* @returns {string|null}
|
|
341
342
|
*/
|
|
342
343
|
export function packageNameOf(specifier) {
|
|
344
|
+
// used by its own test
|
|
343
345
|
if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return null;
|
|
344
346
|
const segments = specifier.split("/");
|
|
345
347
|
if (specifier.startsWith("@"))
|
|
@@ -1121,3 +1123,149 @@ export function analyzeTypeScript({ sourceFile, text, workspace, lang }) {
|
|
|
1121
1123
|
}
|
|
1122
1124
|
return result;
|
|
1123
1125
|
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* The names one TypeScript-language file EXPORTS, split by how the export was
|
|
1129
|
+
* written — the two facts `../analysis/markdown.mjs`'s resolution needs when a
|
|
1130
|
+
* document marker names a symbol and the engine must answer "which project
|
|
1131
|
+
* publishes this".
|
|
1132
|
+
*
|
|
1133
|
+
* The split is load-bearing rather than bookkeeping. A name a file DECLARES
|
|
1134
|
+
* here (`export const Button`, `export { Button }` over a local binding,
|
|
1135
|
+
* `export default`) is a symbol whose home this file's project is: a marker
|
|
1136
|
+
* naming it resolves to that project even when five other projects re-export
|
|
1137
|
+
* it, because the re-exporters are downstream of the declaration, not
|
|
1138
|
+
* alternative homes for it. A name a file RE-EXPORTS from another module
|
|
1139
|
+
* (`export { Button } from "@scope/ui-button"`, `export * as ui from …`) is a
|
|
1140
|
+
* name this module passes through, and the project that declares it owns the
|
|
1141
|
+
* resolution. Resolution prefers the declared tier for exactly this reason —
|
|
1142
|
+
* an umbrella barrel that re-exports a whole library must not turn every one
|
|
1143
|
+
* of its symbols into an ambiguous claim.
|
|
1144
|
+
*
|
|
1145
|
+
* Only top-level statements are read, and `export * from "…"` is deliberately
|
|
1146
|
+
* absent from both tiers: a star names no symbol, and enumerating one would
|
|
1147
|
+
* mean resolving the starred module — a module-resolution walk this function's
|
|
1148
|
+
* caller never needs, because a star's targets are themselves scanned as the
|
|
1149
|
+
* files they are. A project whose public surface is star-re-exported from
|
|
1150
|
+
* another project's files resolves those markers through the declaring files'
|
|
1151
|
+
* own projects, which is the honest answer at project grain.
|
|
1152
|
+
*
|
|
1153
|
+
* Never throws: a malformed file yields whatever TypeScript could parse plus a
|
|
1154
|
+
* failure per syntax error, the same posture `analyzeTypeScript` above holds —
|
|
1155
|
+
* one unreadable file must not blank the index every marker resolves against.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {{ sourceFile: string, text: string, workspace: object, lang?: string }} request
|
|
1158
|
+
* The same request shape `analyzeTypeScript` takes; `lang` is a Vue block's
|
|
1159
|
+
* `<script lang>` and is omitted for a real file.
|
|
1160
|
+
* @returns {{ declared: string[], reexported: string[], failures: object[] }}
|
|
1161
|
+
*/
|
|
1162
|
+
export function exportedNamesOf({ sourceFile, text, workspace, lang }) {
|
|
1163
|
+
/** @type {string[]} */
|
|
1164
|
+
const declared = [];
|
|
1165
|
+
/** @type {string[]} */
|
|
1166
|
+
const reexported = [];
|
|
1167
|
+
/** @type {object[]} */
|
|
1168
|
+
const failures = [];
|
|
1169
|
+
try {
|
|
1170
|
+
const parsed = ts.createSourceFile(
|
|
1171
|
+
`${workspace.root}/${sourceFile}`,
|
|
1172
|
+
text,
|
|
1173
|
+
ts.ScriptTarget.Latest,
|
|
1174
|
+
false,
|
|
1175
|
+
scriptKindFor(sourceFile, lang),
|
|
1176
|
+
);
|
|
1177
|
+
failures.push(...parseFailures(parsed, sourceFile));
|
|
1178
|
+
|
|
1179
|
+
const hasModifier = (node, kind) =>
|
|
1180
|
+
(node.modifiers ?? []).some((modifier) => modifier.kind === kind);
|
|
1181
|
+
|
|
1182
|
+
for (const statement of parsed.statements) {
|
|
1183
|
+
// `export { a, b as c }` with no `from` — a local binding list. The
|
|
1184
|
+
// EXPORTED name is the alias side: plain `a` exports `a`, `b as c`
|
|
1185
|
+
// exports `c`. Element name text is read defensively so a
|
|
1186
|
+
// string-literal alias (`export { a as "x y" }`) is carried as written
|
|
1187
|
+
// rather than undefined.
|
|
1188
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause) {
|
|
1189
|
+
const clause = statement.exportClause;
|
|
1190
|
+
if (ts.isNamedExports(clause)) {
|
|
1191
|
+
const target = statement.moduleSpecifier ? reexported : declared;
|
|
1192
|
+
for (const element of clause.elements) {
|
|
1193
|
+
target.push(element.name?.text ?? "");
|
|
1194
|
+
}
|
|
1195
|
+
} else if (clause.name) {
|
|
1196
|
+
// `export * as ns from "…"` — a re-export wearing a new name.
|
|
1197
|
+
reexported.push(clause.name.text);
|
|
1198
|
+
}
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
if (ts.isExportAssignment(statement)) {
|
|
1202
|
+
// `export default <expression>` and `export = <identifier>` — the
|
|
1203
|
+
// module's own default/exports binding, declared here whatever it
|
|
1204
|
+
// wraps. The `export =` form carries the identifier it aliases; a
|
|
1205
|
+
// default is the name every consumer writes, not the expression's.
|
|
1206
|
+
declared.push(
|
|
1207
|
+
statement.isExportEquals && ts.isIdentifier(statement.expression)
|
|
1208
|
+
? statement.expression.text
|
|
1209
|
+
: "default",
|
|
1210
|
+
);
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const exported = hasModifier(statement, ts.SyntaxKind.ExportKeyword);
|
|
1214
|
+
if (!exported) continue;
|
|
1215
|
+
if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) {
|
|
1216
|
+
declared.push("default");
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
// The one-name statements — function, class, enum, namespace, type,
|
|
1220
|
+
// interface — all carry the exported identifier as `.name`. Enumerated
|
|
1221
|
+
// kind by kind so the type checker's statement union narrows to the
|
|
1222
|
+
// members that actually have one; a future statement kind with a name
|
|
1223
|
+
// is a new arm here, which is the point.
|
|
1224
|
+
const named =
|
|
1225
|
+
ts.isFunctionDeclaration(statement) ||
|
|
1226
|
+
ts.isClassDeclaration(statement) ||
|
|
1227
|
+
ts.isEnumDeclaration(statement) ||
|
|
1228
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
1229
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
1230
|
+
ts.isModuleDeclaration(statement);
|
|
1231
|
+
if (named) {
|
|
1232
|
+
const name = statement.name?.text ?? "";
|
|
1233
|
+
if (name !== "") {
|
|
1234
|
+
declared.push(name);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (ts.isVariableStatement(statement)) {
|
|
1239
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1240
|
+
bindingNames(declaration.name, declared);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
} catch (cause) {
|
|
1245
|
+
failures.push(fileFailure(sourceFile, `export scan failed: ${cause?.message ?? cause}`));
|
|
1246
|
+
}
|
|
1247
|
+
return { declared, reexported, failures };
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Every identifier a binding pattern introduces, in source order — `a`,
|
|
1252
|
+
* `{ a, b: c }`'s `a` and `c`, `[x, ...rest]`'s `x` and `rest`. Computed
|
|
1253
|
+
* properties (`{ [key]: value }`) introduce nothing nameable and are skipped,
|
|
1254
|
+
* the same call a minifier would make: a name no source text carries is a name
|
|
1255
|
+
* no marker can claim.
|
|
1256
|
+
*
|
|
1257
|
+
* @param {ts.Node} node A binding name or pattern.
|
|
1258
|
+
* @param {string[]} out Accumulator, mutated in place.
|
|
1259
|
+
*/
|
|
1260
|
+
function bindingNames(node, out) {
|
|
1261
|
+
if (ts.isIdentifier(node) || ts.isStringLiteral(node)) {
|
|
1262
|
+
out.push(node.text);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
if (ts.isObjectBindingPattern(node) || ts.isArrayBindingPattern(node)) {
|
|
1266
|
+
for (const element of node.elements) {
|
|
1267
|
+
if (ts.isOmittedExpression(element)) continue;
|
|
1268
|
+
bindingNames(element.name ?? element, out);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
@@ -41,6 +41,7 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
|
41
41
|
import { resolve } from "node:path";
|
|
42
42
|
|
|
43
43
|
import { containmentViolation } from "../containment.mjs";
|
|
44
|
+
import { isEnoent } from "../errors.mjs";
|
|
44
45
|
|
|
45
46
|
import { isValidSelector, splitSelector } from "./selectors.mjs";
|
|
46
47
|
import { describe, isPlainObject } from "../values.mjs";
|
|
@@ -50,7 +51,7 @@ import { GOVERNANCE_ROW_KEYS, rowSchemaViolations } from "../governance/row-sche
|
|
|
50
51
|
export const INTENT_FILE = "architecture-intent.json";
|
|
51
52
|
|
|
52
53
|
/** The one supported `version`. A different value is a load error. */
|
|
53
|
-
export const INTENT_VERSION = "1";
|
|
54
|
+
export const INTENT_VERSION = "1"; // used by its own test
|
|
54
55
|
|
|
55
56
|
/**
|
|
56
57
|
* The only keys a valid intent file may carry at the top level.
|
|
@@ -65,6 +66,7 @@ export const INTENT_VERSION = "1";
|
|
|
65
66
|
* engine judges them.
|
|
66
67
|
*/
|
|
67
68
|
export const TOP_LEVEL_KEYS = Object.freeze([
|
|
69
|
+
// used by its own test
|
|
68
70
|
"version",
|
|
69
71
|
"boundaries",
|
|
70
72
|
"allowed",
|
|
@@ -75,17 +77,17 @@ export const TOP_LEVEL_KEYS = Object.freeze([
|
|
|
75
77
|
]);
|
|
76
78
|
|
|
77
79
|
/** The sub-keys a `projects` section may carry. */
|
|
78
|
-
|
|
80
|
+
const PROJECT_SECTION_KEYS = Object.freeze(["required", "forbidden"]);
|
|
79
81
|
/** The sub-keys a `dependencies` section may carry. */
|
|
80
|
-
|
|
82
|
+
const DEPENDENCY_SECTION_KEYS = Object.freeze(["allowed", "forbidden"]);
|
|
81
83
|
/** The keys a `projects.required[]` row may carry. */
|
|
82
|
-
|
|
84
|
+
const REQUIRED_PROJECT_KEYS = Object.freeze(["name", "tags", "decisionRef"]);
|
|
83
85
|
/** The keys a `projects.forbidden[]` row may carry. */
|
|
84
|
-
|
|
86
|
+
const FORBIDDEN_PROJECT_KEYS = Object.freeze(["name", "decisionRef"]);
|
|
85
87
|
/** The keys a `dependencies.allowed[]` / `dependencies.forbidden[]` row may carry. */
|
|
86
|
-
|
|
88
|
+
const DEPENDENCY_ROW_KEYS = Object.freeze(["source", "target", "decisionRef"]);
|
|
87
89
|
/** The keys a `forbiddenTags[]` row may carry. */
|
|
88
|
-
|
|
90
|
+
const TAG_ROW_KEYS = Object.freeze(["from", "to", "decisionRef"]);
|
|
89
91
|
|
|
90
92
|
/**
|
|
91
93
|
* The shared governance-block check for any intent row (Contract 2): when the
|
|
@@ -156,6 +158,7 @@ function isSingleProjectSelector(selector) {
|
|
|
156
158
|
* resolve against.
|
|
157
159
|
*/
|
|
158
160
|
export function boundaryNames(intent) {
|
|
161
|
+
// used by its own test
|
|
159
162
|
return (intent.boundaries ?? []).map((b) => b.name);
|
|
160
163
|
}
|
|
161
164
|
|
|
@@ -167,6 +170,7 @@ export function boundaryNames(intent) {
|
|
|
167
170
|
* @returns {string[]}
|
|
168
171
|
*/
|
|
169
172
|
export function findIntentViolations(raw) {
|
|
173
|
+
// used by its own test
|
|
170
174
|
const violations = [];
|
|
171
175
|
|
|
172
176
|
if (!isPlainObject(raw)) {
|
|
@@ -602,6 +606,7 @@ export function findIntentViolations(raw) {
|
|
|
602
606
|
* @returns {object} The normalized model: `{version, boundaries: {name, match, members?}[], allowed, forbidden, projects?, dependencies?, forbiddenTags?}`.
|
|
603
607
|
*/
|
|
604
608
|
export function normalizeIntent(intent) {
|
|
609
|
+
// used by its own test
|
|
605
610
|
return {
|
|
606
611
|
version: intent.version,
|
|
607
612
|
boundaries: intent.boundaries.map((b) => ({ name: b.name, match: [...b.match] })),
|
|
@@ -665,7 +670,7 @@ export async function loadIntent(root, { read = readFileFromDisk, tracked } = {}
|
|
|
665
670
|
// neighbours on the identical tree are both loud: an escaping symlink
|
|
666
671
|
// throws at the containment check above, and EACCES throws below. Only
|
|
667
672
|
// this one was silent (`../../../../AGENTS.md`).
|
|
668
|
-
if (cause
|
|
673
|
+
if (isEnoent(cause)) {
|
|
669
674
|
if (tracked === undefined) return undefined;
|
|
670
675
|
throw new Error(
|
|
671
676
|
`${INTENT_FILE}: is tracked but could not be read: ${cause?.message ?? cause} — ` +
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
/** The three selector labels. `unlabeled` is a bare project name (equals `name:`). */
|
|
33
|
-
|
|
33
|
+
const SELECTOR_LABELS = Object.freeze(["name", "tag", "directory"]);
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Split a selector into `{exclude, label, value}` — `!` prefix removed, `*`
|
|
@@ -71,6 +71,7 @@ export function isValidSelector(value) {
|
|
|
71
71
|
* @returns {string[]}
|
|
72
72
|
*/
|
|
73
73
|
export function selectProjects(selector, nodes) {
|
|
74
|
+
// used by its own test
|
|
74
75
|
const { label, value } = splitSelector(selector);
|
|
75
76
|
|
|
76
77
|
let found;
|
|
@@ -44,10 +44,10 @@ import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
|
44
44
|
import { describe, isPlainObject } from "../values.mjs";
|
|
45
45
|
|
|
46
46
|
/** The only `version` this module accepts. A different value is a load error. */
|
|
47
|
-
|
|
47
|
+
const CHANGE_INTENT_VERSION = "1";
|
|
48
48
|
|
|
49
49
|
/** The only keys a valid change-intent file may carry at the top level. */
|
|
50
|
-
|
|
50
|
+
const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
|
|
51
51
|
"version",
|
|
52
52
|
"base",
|
|
53
53
|
"summary",
|
|
@@ -57,16 +57,16 @@ export const CHANGE_INTENT_TOP_LEVEL_KEYS = Object.freeze([
|
|
|
57
57
|
]);
|
|
58
58
|
|
|
59
59
|
/** The keys the `base` section may carry. */
|
|
60
|
-
|
|
60
|
+
const CHANGE_INTENT_BASE_KEYS = Object.freeze(["commit"]);
|
|
61
61
|
|
|
62
62
|
/** The sub-keys the `projects` section may carry. */
|
|
63
|
-
|
|
63
|
+
const CHANGE_INTENT_PROJECT_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
64
64
|
/** The sub-keys the `edges` section may carry. */
|
|
65
|
-
|
|
65
|
+
const CHANGE_INTENT_EDGE_SECTION_KEYS = Object.freeze(["add", "remove"]);
|
|
66
66
|
/** The keys an edge row may carry. */
|
|
67
|
-
|
|
67
|
+
const CHANGE_INTENT_EDGE_ROW_KEYS = Object.freeze(["from", "to"]);
|
|
68
68
|
/** The keys the `constraints` section may carry. */
|
|
69
|
-
|
|
69
|
+
const CHANGE_INTENT_CONSTRAINT_KEYS = Object.freeze(["noNewViolations", "noNewCycles"]);
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* The constraints the reconciliation judges, in the fixed order their verdict
|
|
@@ -204,7 +204,7 @@ function sectionListViolations(section, spec) {
|
|
|
204
204
|
* @param {unknown} raw The parsed JSON value.
|
|
205
205
|
* @returns {string[]}
|
|
206
206
|
*/
|
|
207
|
-
|
|
207
|
+
function findChangeIntentViolations(raw) {
|
|
208
208
|
const violations = [];
|
|
209
209
|
if (!isPlainObject(raw)) {
|
|
210
210
|
return [`top level: must be an object, got ${describe(raw)}`];
|
|
@@ -351,7 +351,7 @@ export function findChangeIntentViolations(raw) {
|
|
|
351
351
|
* with absent raw sections already normalized to empty arrays.
|
|
352
352
|
* @returns {string[]} Messages; empty when the intent is not a catch-all.
|
|
353
353
|
*/
|
|
354
|
-
|
|
354
|
+
function findChangeIntentBreadthViolations(intent) {
|
|
355
355
|
const declaredRows =
|
|
356
356
|
intent.projects.add.length +
|
|
357
357
|
intent.projects.remove.length +
|
|
@@ -389,6 +389,7 @@ export function findChangeIntentBreadthViolations(intent) {
|
|
|
389
389
|
* silently would reconcile against an expectation nobody wrote.
|
|
390
390
|
*/
|
|
391
391
|
export function parseChangeIntent(text, path) {
|
|
392
|
+
// used by its own test
|
|
392
393
|
let parsed;
|
|
393
394
|
try {
|
|
394
395
|
parsed = JSON.parse(text);
|
package/src/commands/change.mjs
CHANGED
|
@@ -112,6 +112,7 @@ import { compareSnapshotMetadata } from "./snapshot-meta.mjs";
|
|
|
112
112
|
import { resolveProvenance } from "./provenance.mjs";
|
|
113
113
|
import { referenceTime } from "../governance/clock.mjs";
|
|
114
114
|
import {
|
|
115
|
+
assertReproducibleEventIdentity,
|
|
115
116
|
classifyEvolution,
|
|
116
117
|
declarationDigest,
|
|
117
118
|
escapeIdentityField,
|
|
@@ -170,6 +171,7 @@ function cmpFacts(a, b) {
|
|
|
170
171
|
* @returns {string}
|
|
171
172
|
*/
|
|
172
173
|
export function violationFindingId(entry) {
|
|
174
|
+
// used by its own test
|
|
173
175
|
const sourceProject =
|
|
174
176
|
entry.sourceProject == null ? "-" : escapeIdentityField(entry.sourceProject);
|
|
175
177
|
return `${escapeIdentityField(entry.messageId)}:${sourceProject}:${escapeIdentityField(entry.target)}`;
|
|
@@ -250,6 +252,7 @@ function observedFrom(structural, meta) {
|
|
|
250
252
|
* @returns {{matched: object[], unexpected: object[], missingExpected: object[]}}
|
|
251
253
|
*/
|
|
252
254
|
export function reconcileMaterialDelta(intent, delta) {
|
|
255
|
+
// used by its own test
|
|
253
256
|
const addedProjectNames = new Set(intent.projects.add);
|
|
254
257
|
const removedProjectNames = new Set(intent.projects.remove);
|
|
255
258
|
// The declared row's identity is `./change-intent.mjs`'s `edgePairKey` — the
|
|
@@ -358,7 +361,7 @@ export function reconcileMaterialDelta(intent, delta) {
|
|
|
358
361
|
* @param {string[]} unprovenReasons Why the base identity could not be proven.
|
|
359
362
|
* @returns {"matched"|"undeclared"|"unfulfilled"|"unproven"}
|
|
360
363
|
*/
|
|
361
|
-
|
|
364
|
+
function reconciliationVerdict(lists, unprovenReasons) {
|
|
362
365
|
if (unprovenReasons.length > 0) return "unproven";
|
|
363
366
|
if (lists.unexpected.length > 0) return "undeclared";
|
|
364
367
|
if (lists.missingExpected.length > 0) return "unfulfilled";
|
|
@@ -389,6 +392,7 @@ export function reconciliationVerdict(lists, unprovenReasons) {
|
|
|
389
392
|
* @returns {"accepted"|"rejected"|"no-verdict"}
|
|
390
393
|
*/
|
|
391
394
|
export function reconcileDisposition(verdict, constraints = []) {
|
|
395
|
+
// used by its own test
|
|
392
396
|
if (verdict === "unproven") return "no-verdict";
|
|
393
397
|
if (verdict === "undeclared" || verdict === "unfulfilled") return "rejected";
|
|
394
398
|
if (constraints.some((row) => row.verdict === "unknown")) return "no-verdict";
|
|
@@ -898,6 +902,18 @@ export async function changeCommand(
|
|
|
898
902
|
/** @type {{dir: string, id: string, duplicate: boolean}|null} */
|
|
899
903
|
let eventWritten = null;
|
|
900
904
|
if (eventOut !== undefined && eventOut !== null && eventOut !== "") {
|
|
905
|
+
// F-delta-event-id on the reconcile event: the event's identity names
|
|
906
|
+
// base and head revisions, so a commitless head or a dirty tree collapses
|
|
907
|
+
// distinct evidence states onto one event id — a later transition is
|
|
908
|
+
// silently lost or aliased, the silent direction. `delta` holds the same
|
|
909
|
+
// gate; the shared law and its frozen wording live in
|
|
910
|
+
// `../governance/evolution-event.mjs`.
|
|
911
|
+
assertReproducibleEventIdentity({
|
|
912
|
+
label: "change",
|
|
913
|
+
headCommit,
|
|
914
|
+
baseDirty: baseline.provenance?.dirty === true,
|
|
915
|
+
headDirty: headProvenance?.dirty === true,
|
|
916
|
+
});
|
|
901
917
|
// The store's io.root is the workspace root: the write must be provable
|
|
902
918
|
// to stay inside the workspace (the same containment `--output` obeys).
|
|
903
919
|
// A write failure throws — exit 3 upstream, the could-not-look lane —
|