@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
|
@@ -195,7 +195,7 @@ export function buildDecisionImpact(root, constraintImpact, config) {
|
|
|
195
195
|
* not available.
|
|
196
196
|
* @returns {{evaluated: boolean, findings: object[], count: number}}
|
|
197
197
|
*/
|
|
198
|
-
|
|
198
|
+
function evaluateFindingsImpact(affectedProjects, availableFindings) {
|
|
199
199
|
if (!availableFindings || availableFindings.length === 0) {
|
|
200
200
|
return { evaluated: false, findings: [], count: 0 };
|
|
201
201
|
}
|
|
@@ -221,7 +221,7 @@ export function evaluateFindingsImpact(affectedProjects, availableFindings) {
|
|
|
221
221
|
* entry's associated project.
|
|
222
222
|
* @returns {{evaluated: boolean, debt: object[], count: number}}
|
|
223
223
|
*/
|
|
224
|
-
|
|
224
|
+
function evaluateDebtImpact(affectedProjects, availableDebt, resolveProject = null) {
|
|
225
225
|
if (!availableDebt || availableDebt.length === 0) {
|
|
226
226
|
return { evaluated: false, debt: [], count: 0 };
|
|
227
227
|
}
|
|
@@ -247,7 +247,7 @@ export function evaluateDebtImpact(affectedProjects, availableDebt, resolveProje
|
|
|
247
247
|
* @param {string} targetProject The target of the impact analysis.
|
|
248
248
|
* @returns {{boundaries: object[], evaluated: boolean}}
|
|
249
249
|
*/
|
|
250
|
-
|
|
250
|
+
function evaluateBoundaryImpact(graph, constraintImpact, targetProject) {
|
|
251
251
|
if (!constraintImpact || constraintImpact.length === 0) {
|
|
252
252
|
return { boundaries: [], evaluated: false };
|
|
253
253
|
}
|
|
@@ -339,7 +339,7 @@ export function decisionProvenanceCoverage(decisions) {
|
|
|
339
339
|
* @param {object} evaluation.evolutionAlignment Evolution alignment.
|
|
340
340
|
* @returns {object} Evidence gate values for buildEvidenceComplete.
|
|
341
341
|
*/
|
|
342
|
-
|
|
342
|
+
function deriveEvidenceGates(evaluation) {
|
|
343
343
|
const { completeness, constraintImpact, decisionImpact } = evaluation;
|
|
344
344
|
|
|
345
345
|
// domainCoverage: ratio of evaluated required domains
|
|
@@ -138,6 +138,7 @@ function shortSha(sha) {
|
|
|
138
138
|
* and a shallow clone whose cut-off sits below the requested revision.
|
|
139
139
|
*/
|
|
140
140
|
export function resolveRevision(root, rev, flag, { run = runProcess } = {}) {
|
|
141
|
+
// used by its own test
|
|
141
142
|
if (typeof rev !== "string" || rev.length === 0) {
|
|
142
143
|
throw new Error(`archkeep: ${flag} needs a revision — a commit, branch, tag, or HEAD~n.`);
|
|
143
144
|
}
|
|
@@ -190,6 +191,7 @@ export function resolveRevision(root, rev, flag, { run = runProcess } = {}) {
|
|
|
190
191
|
* head's ancestry, or a merge commit inside the range.
|
|
191
192
|
*/
|
|
192
193
|
export function selectLinearRange(root, { base, head }, { run = runProcess } = {}) {
|
|
194
|
+
// used by its own test
|
|
193
195
|
const baseSha = resolveRevision(root, base, "--base", { run });
|
|
194
196
|
const headSha = resolveRevision(root, head ?? "HEAD", "--head", { run });
|
|
195
197
|
|
package/src/commands/explain.mjs
CHANGED
|
@@ -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";
|
|
@@ -114,6 +111,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
|
114
111
|
* @throws {UsageError} when the site string is malformed.
|
|
115
112
|
*/
|
|
116
113
|
export function parseSite(site) {
|
|
114
|
+
// used by its own test
|
|
117
115
|
const lastColon = site.lastIndexOf(":");
|
|
118
116
|
if (lastColon === -1 || lastColon === 0) {
|
|
119
117
|
throw new UsageError(
|
|
@@ -160,6 +158,7 @@ export function parseSite(site) {
|
|
|
160
158
|
* @returns {object|null} The matching record, or `null`.
|
|
161
159
|
*/
|
|
162
160
|
export function findSite(parsed, imports) {
|
|
161
|
+
// used by its own test
|
|
163
162
|
return (
|
|
164
163
|
imports.find(
|
|
165
164
|
(site) =>
|
|
@@ -369,18 +368,18 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
369
368
|
// Normalize backslash separators (Windows paths) to forward slashes.
|
|
370
369
|
parsed.sourceFile = sep === "\\" ? normalizedFile.replaceAll("\\", "/") : normalizedFile;
|
|
371
370
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
371
|
+
// The completeness verdict is the shared constructor's, not this file's —
|
|
372
|
+
// the same contract `graph`/`discover` run: an unresolvable site was seen
|
|
373
|
+
// but never judged (#595), the graph is missing whatever edge it would have
|
|
374
|
+
// drawn, and rules that judge the whole graph (circularity, lazy loading)
|
|
375
|
+
// would answer over a gap. The explanation still reports — status
|
|
376
|
+
// no-verdict — naming the site in `coverage.blindSpots`. The restatement
|
|
377
|
+
// this replaces carried two of the constructor's three axes; the third
|
|
378
|
+
// (`analyzed > 0`, #599) moves no byte here, because every lane below that
|
|
379
|
+
// builds an envelope already implies a read file — an import record or a
|
|
380
|
+
// positioned failure exists only for a file the run analyzed — so the axis
|
|
381
|
+
// is carried by composition, not changed by it.
|
|
382
|
+
const { notAnalyzed, blindSpots, complete, status, exitCode } = coverageVerdict(commandContext);
|
|
384
383
|
|
|
385
384
|
// Find the import record at this site.
|
|
386
385
|
const record = findSite(parsed, commandContext.analysis.imports);
|
|
@@ -432,7 +431,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
432
431
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
433
432
|
imports: commandContext.analysis.imports.length,
|
|
434
433
|
notAnalyzed,
|
|
435
|
-
blindSpots
|
|
434
|
+
blindSpots,
|
|
436
435
|
notes: [],
|
|
437
436
|
};
|
|
438
437
|
|
|
@@ -447,7 +446,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
447
446
|
command: "explain",
|
|
448
447
|
context,
|
|
449
448
|
status,
|
|
450
|
-
exitCode
|
|
449
|
+
exitCode,
|
|
451
450
|
coverage,
|
|
452
451
|
result,
|
|
453
452
|
});
|
|
@@ -579,7 +578,7 @@ export function explainCommand(site, commandContext, config, options = {}) {
|
|
|
579
578
|
analyzedFiles: commandContext.analysis.analyzed,
|
|
580
579
|
imports: commandContext.analysis.imports.length,
|
|
581
580
|
notAnalyzed,
|
|
582
|
-
blindSpots
|
|
581
|
+
blindSpots,
|
|
583
582
|
notes: [],
|
|
584
583
|
};
|
|
585
584
|
|
package/src/commands/graph.mjs
CHANGED
|
@@ -151,24 +151,26 @@ export function buildDependencies(dependencies) {
|
|
|
151
151
|
* config comparison logic.
|
|
152
152
|
*
|
|
153
153
|
* The fingerprint is SHA-256 of the canonicalized JSON for `depConstraints`,
|
|
154
|
-
* `options`, `suppressions` and — when the policy declares them —
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
154
|
+
* `options`, `suppressions` and — when the policy declares them — the
|
|
155
|
+
* `fitness`, `customRules`, `coverage` and `markdown` blocks, plus the
|
|
156
|
+
* selected profile's NAME when the law was selected by name. Those are every
|
|
157
|
+
* field of a loaded policy that states law: the first three decide which
|
|
158
|
+
* violations `evaluate` produces, the fourth decides which fitness functions
|
|
159
|
+
* `check` folds into the same exit code
|
|
160
|
+
* (`../governance/fitness-registry.mjs`), and the fifth names the rule
|
|
159
161
|
* artifacts a workspace declared, each pinned to the bytes its `sha256` claims
|
|
160
162
|
* (`../config.mjs`'s `customRuleRowViolations`) — swap one row's hash or its
|
|
161
|
-
* `params` and the policy says something different.
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* together.
|
|
163
|
+
* `params` and the policy says something different. The three after those are
|
|
164
|
+
* argued at their keys below. A field that can fail a build and is not in the
|
|
165
|
+
* hash is a law that can be rewritten while `diff` reports the policy
|
|
166
|
+
* unchanged — the silent direction, and the reason the list here and
|
|
167
|
+
* `policyFrom`'s return shape (`../config.mjs`) are revisited together.
|
|
166
168
|
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* declares
|
|
170
|
-
*
|
|
171
|
-
*
|
|
169
|
+
* The conditional fields are included only when they are DECLARED, and the
|
|
170
|
+
* absent case contributes no key rather than an empty array. A policy that
|
|
171
|
+
* declares none therefore fingerprints exactly as it did before those fields
|
|
172
|
+
* were covered, so extending the hash did not move every existing snapshot's
|
|
173
|
+
* value — only those whose law it was failing to describe.
|
|
172
174
|
*
|
|
173
175
|
* @param {object} config The loaded boundary config.
|
|
174
176
|
* @returns {string} A hex-encoded SHA-256 fingerprint.
|
|
@@ -180,6 +182,33 @@ export function computePolicyFingerprint(config) {
|
|
|
180
182
|
suppressions: config.suppressions ?? [],
|
|
181
183
|
...(config.fitness === undefined ? {} : { fitness: config.fitness }),
|
|
182
184
|
...(config.customRules === undefined ? {} : { customRules: config.customRules }),
|
|
185
|
+
// The acceptance channel is law the same way the blocks above are: a
|
|
186
|
+
// `coverage.unowned` row withdraws an unclaimed file's whole-file failure
|
|
187
|
+
// as a recorded acceptance (`../config.mjs`'s `findCoverageViolations`
|
|
188
|
+
// owns the shape, `../commands/coverage-acceptance.mjs` matches it), so a
|
|
189
|
+
// policy that records or removes an acceptance must not share a
|
|
190
|
+
// fingerprint with one that does not — `diff`'s policy-changed warning
|
|
191
|
+
// reads this hash (#709: the one block `policyFrom` sets that the hash
|
|
192
|
+
// skipped). Conditional, like the three above, so a policy recording no
|
|
193
|
+
// acceptance hashes exactly as it did before this key existed.
|
|
194
|
+
...(config.coverage === undefined ? {} : { coverage: config.coverage }),
|
|
195
|
+
// The document track is law the same way the blocks above are: it
|
|
196
|
+
// decides what this run judges, so a policy that adds or edits a
|
|
197
|
+
// `markdown` block must not share a fingerprint with one that does not —
|
|
198
|
+
// `diff`'s policy-changed warning reads this hash. Conditional, like the
|
|
199
|
+
// three above, so a policy declaring no block hashes exactly as it did
|
|
200
|
+
// before this key existed.
|
|
201
|
+
...(config.markdown === undefined ? {} : { markdown: config.markdown }),
|
|
202
|
+
// The named selection is law identity the same way the blocks above are:
|
|
203
|
+
// `profilePolicy` (`../governance/profile-registry.mjs`) sets this key
|
|
204
|
+
// when the workspace selects a profile by name, and two profiles whose
|
|
205
|
+
// resolved blocks converge are still two different named laws. Only
|
|
206
|
+
// `check`'s report names the selection, so a hash blind to it would let a
|
|
207
|
+
// `delta` across a switch report the law unchanged with nothing anywhere
|
|
208
|
+
// saying it moved. Conditional, like the four above, so a policy
|
|
209
|
+
// selected by file or inline — no `profile` key at all — hashes exactly
|
|
210
|
+
// as it did before the selection was covered.
|
|
211
|
+
...(config.profile === undefined ? {} : { profile: config.profile }),
|
|
183
212
|
};
|
|
184
213
|
// Canonicalise: sort object keys at every depth so insertion order does not
|
|
185
214
|
// affect the hash. Semantic equality, not construction order, is the claim —
|
package/src/commands/health.mjs
CHANGED
|
@@ -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
|
package/src/commands/history.mjs
CHANGED
|
@@ -229,6 +229,7 @@ export function readSnapshots(dir, root) {
|
|
|
229
229
|
* @returns {string} First 8 hex characters.
|
|
230
230
|
*/
|
|
231
231
|
export function shortId(id) {
|
|
232
|
+
// used by its own test
|
|
232
233
|
return id.slice(0, 8);
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -248,6 +249,7 @@ export function shortId(id) {
|
|
|
248
249
|
* @returns {string} Zero-padded sequence, at least four digits.
|
|
249
250
|
*/
|
|
250
251
|
export function nextSequence(read) {
|
|
252
|
+
// used by its own test
|
|
251
253
|
let max = 0;
|
|
252
254
|
for (const file of read.files) {
|
|
253
255
|
const match = /^(\d+)-/.exec(file.name);
|
|
@@ -87,7 +87,7 @@ import { declaredFitnessNames, unresolvedDecisionRefRows } from "../governance/a
|
|
|
87
87
|
import { formatPlanContextReport } from "../report/plan-context-text.mjs";
|
|
88
88
|
|
|
89
89
|
/** How many dependents are listed before an explicit overflow note. */
|
|
90
|
-
export const DEPENDENT_CAP = 10;
|
|
90
|
+
export const DEPENDENT_CAP = 10; // used by its own test
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
93
|
* The projects a change touches: the target project, plus every project whose
|
|
@@ -100,6 +100,7 @@ export const DEPENDENT_CAP = 10;
|
|
|
100
100
|
* @returns {string[]} Distinct affected project names, sorted.
|
|
101
101
|
*/
|
|
102
102
|
export function collectAffectedProjects(commandContext, paths) {
|
|
103
|
+
// used by its own test
|
|
103
104
|
const affected = new Set();
|
|
104
105
|
for (const { file, project } of commandContext.owned ?? []) {
|
|
105
106
|
for (const p of paths) {
|
|
@@ -124,6 +125,7 @@ export function collectAffectedProjects(commandContext, paths) {
|
|
|
124
125
|
* @returns {object[]}
|
|
125
126
|
*/
|
|
126
127
|
export function collectImpact(projectName, affected, graph) {
|
|
128
|
+
// used by its own test
|
|
127
129
|
const targets = [...new Set([projectName, ...affected])].sort((a, b) =>
|
|
128
130
|
a < b ? -1 : a > b ? 1 : 0,
|
|
129
131
|
);
|
|
@@ -172,6 +174,7 @@ function scopedFiles(commandContext, affected) {
|
|
|
172
174
|
* @returns {{goWork: object|null, tsconfigPaths: object|null, failures: object[]}}
|
|
173
175
|
*/
|
|
174
176
|
export function collectDrift(commandContext) {
|
|
177
|
+
// used by its own test
|
|
175
178
|
const { root, tracked, workspace } = commandContext;
|
|
176
179
|
const failures = [];
|
|
177
180
|
|
|
@@ -501,10 +504,14 @@ export async function planContextCommand(
|
|
|
501
504
|
(a.messageId < b.messageId ? -1 : a.messageId > b.messageId ? 1 : 0),
|
|
502
505
|
);
|
|
503
506
|
|
|
507
|
+
// A run that analyzed nothing judged nothing (#599, #694): judging nothing
|
|
508
|
+
// is not finding nothing, so it defeats completeness the way a whole-file
|
|
509
|
+
// failure does.
|
|
504
510
|
// An unresolvable literal site is work the run saw but never judged
|
|
505
511
|
// (#595, narrowed): a plan over it would present edges the run does not
|
|
506
512
|
// hold, so it defeats completeness the way a whole-file failure does.
|
|
507
|
-
const complete =
|
|
513
|
+
const complete =
|
|
514
|
+
wholeTree.analyzed > 0 && notAnalyzed.length === 0 && unresolvableLiteralCount(failures) === 0;
|
|
508
515
|
const status = complete ? "ok" : "no-verdict";
|
|
509
516
|
const exitCode = complete ? 0 : 3;
|
|
510
517
|
|
package/src/commands/policy.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import { ARCHKEEP_MODEL_FILE } from "../providers/native/model.mjs";
|
|
|
26
26
|
* @param {object} options The resolved options from `resolveCommandContext`.
|
|
27
27
|
* @returns {boolean}
|
|
28
28
|
*/
|
|
29
|
-
|
|
29
|
+
function hasProfiles(options) {
|
|
30
30
|
return typeof options?.profiles === "string" && options.profiles !== "";
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -86,10 +86,13 @@ 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 `
|
|
91
|
-
* declares them — an absent key is the workspace's decision not to
|
|
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[], profile?: 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
|
+
* `profile` is present only when the law was selected by name, set by the
|
|
94
|
+
* profiles arm (`../governance/profile-registry.mjs`'s `profilePolicy`) so
|
|
95
|
+
* the selection the policy was resolved by travels with it.
|
|
93
96
|
* @throws {Error} when a named profile, a `--config` file, or an inline
|
|
94
97
|
* policy cannot be resolved or is malformed — every arm's existing failure
|
|
95
98
|
* 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
|
-
|
|
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 };
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
56
56
|
|
|
57
57
|
/** The supported scenario change types. */
|
|
58
|
-
export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]);
|
|
58
|
+
export const SCENARIO_CHANGE_TYPES = Object.freeze(["dependency_added", "dependency_removed"]); // used by its own test
|
|
59
59
|
|
|
60
60
|
// ---------------------------------------------------------------------------
|
|
61
61
|
// Input schema types
|
|
@@ -107,7 +107,7 @@ const OBSERVATION_BASIS = "graph_snapshots";
|
|
|
107
107
|
* constant in the envelope, so a consumer branches on a documented value
|
|
108
108
|
* rather than on prose.
|
|
109
109
|
*/
|
|
110
|
-
export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
110
|
+
export const INSUFFICIENT_HISTORY = "insufficient_history"; // used by its own test
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
113
|
* The derived-number block for one structural axis (projects keyed by name,
|
|
@@ -187,6 +187,7 @@ export const INSUFFICIENT_HISTORY = "insufficient_history";
|
|
|
187
187
|
* trends: TrajectoryTrends|null}}
|
|
188
188
|
*/
|
|
189
189
|
export function computeTrajectory(files) {
|
|
190
|
+
// used by its own test
|
|
190
191
|
const n = files.length;
|
|
191
192
|
const available = n >= 2;
|
|
192
193
|
|
package/src/config.mjs
CHANGED
|
@@ -639,6 +639,155 @@ function findCoverageViolations(value) {
|
|
|
639
639
|
return violations;
|
|
640
640
|
}
|
|
641
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
|
+
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
|
+
|
|
642
791
|
/**
|
|
643
792
|
* The grammar a custom rule's `name` is written in: lowercase letters and
|
|
644
793
|
* digits, single `-` separators, nothing else.
|
|
@@ -945,6 +1094,7 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
945
1094
|
fitness,
|
|
946
1095
|
customRules,
|
|
947
1096
|
coverage,
|
|
1097
|
+
markdown,
|
|
948
1098
|
} = module;
|
|
949
1099
|
// F05: the resolution half of the governance block (`row-schema.mjs`'s
|
|
950
1100
|
// `io.resolve`) was validator-only until now — no production caller passed
|
|
@@ -1000,6 +1150,19 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
1000
1150
|
violations.push(...findCoverageViolations(coverage));
|
|
1001
1151
|
}
|
|
1002
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
|
+
|
|
1003
1166
|
// Absent means "nothing is suppressed", which is the only default that fails
|
|
1004
1167
|
// toward reporting — unlike the eight options above, where a missing value
|
|
1005
1168
|
// would be a second copy of something ESLint also reads and this module has
|
|
@@ -1095,7 +1258,10 @@ export function findBoundaryConfigViolations(module, io = {}) {
|
|
|
1095
1258
|
* `coverage.exempt` is that provider's one channel for the same decision —
|
|
1096
1259
|
* and the refusal lives in `./commands/policy.mjs`'s `resolvePolicy` and
|
|
1097
1260
|
* `./providers/native/model.mjs`'s inline-policy check, because only they
|
|
1098
|
-
* 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.
|
|
1099
1265
|
*
|
|
1100
1266
|
* The name says `.json` and the list binds both file dialects: `loadModulePolicy`
|
|
1101
1267
|
* runs the same check over an ES module's exports, which is what makes a
|
|
@@ -1109,6 +1275,7 @@ const JSON_POLICY_KEYS = [
|
|
|
1109
1275
|
"fitness",
|
|
1110
1276
|
"customRules",
|
|
1111
1277
|
"coverage",
|
|
1278
|
+
"markdown",
|
|
1112
1279
|
];
|
|
1113
1280
|
|
|
1114
1281
|
/**
|
|
@@ -1185,7 +1352,7 @@ export function policyKeyViolations(parsed, { allowSchema }) {
|
|
|
1185
1352
|
* inline one.
|
|
1186
1353
|
* @param {string[]} [extraViolations] Violations the caller already found that
|
|
1187
1354
|
* `findBoundaryConfigViolations` does not check on its own.
|
|
1188
|
-
* @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 }}
|
|
1189
1356
|
* `fitness` and `customRules` are present only when the config declares
|
|
1190
1357
|
* them — a workspace without one carries no key, the same "absent is a
|
|
1191
1358
|
* decision" posture `cli.mjs`'s `check` uses for a missing
|
|
@@ -1209,6 +1376,7 @@ export function policyFrom(parsed, sourceLabel, extraViolations = []) {
|
|
|
1209
1376
|
...(parsed.fitness === undefined ? {} : { fitness: parsed.fitness }),
|
|
1210
1377
|
...(parsed.customRules === undefined ? {} : { customRules: parsed.customRules }),
|
|
1211
1378
|
...(parsed.coverage === undefined ? {} : { coverage: parsed.coverage }),
|
|
1379
|
+
...(parsed.markdown === undefined ? {} : { markdown: parsed.markdown }),
|
|
1212
1380
|
};
|
|
1213
1381
|
}
|
|
1214
1382
|
|
|
@@ -124,17 +124,17 @@ export const CUSTOM_RULE_TIMEOUT_MS = 10_000;
|
|
|
124
124
|
* bundle for a large workspace is smaller — and it is what stops a claimed
|
|
125
125
|
* length from being an allocation instruction.
|
|
126
126
|
*/
|
|
127
|
-
export const CUSTOM_RULE_MAX_VERDICT_BYTES = 8 * 1024 * 1024;
|
|
127
|
+
export const CUSTOM_RULE_MAX_VERDICT_BYTES = 8 * 1024 * 1024; // used by its own test
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
130
|
* The most linear memory a rule may hold at the two boundaries the worker
|
|
131
131
|
* measures. 256 MiB is far past what reading an evidence bundle needs and far
|
|
132
132
|
* short of what a workspace's CI runner can absorb without noticing.
|
|
133
133
|
*/
|
|
134
|
-
export const CUSTOM_RULE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
|
|
134
|
+
export const CUSTOM_RULE_MEMORY_LIMIT_BYTES = 256 * 1024 * 1024; // used by its own test
|
|
135
135
|
|
|
136
136
|
/** The four ABI symbols a rule module must export, and the kind each must be. */
|
|
137
|
-
|
|
137
|
+
const REQUIRED_EXPORTS = Object.freeze({
|
|
138
138
|
memory: "memory",
|
|
139
139
|
archkeep_alloc: "function",
|
|
140
140
|
archkeep_describe: "function",
|