vigiles 32.0.1 → 32.1.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/dist/adapters/claude-code/instruction-chain.js +36 -2
- package/dist/cli-flag-check.js +7 -1
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.js +32 -0
- package/dist/core/config-schema.d.ts +1 -0
- package/dist/core/config-schema.js +1 -0
- package/dist/core/instruction-baseline.d.ts +139 -0
- package/dist/core/instruction-baseline.js +224 -0
- package/dist/core/instruction-weight.d.ts +17 -7
- package/dist/core/instruction-weight.js +25 -9
- package/dist/core/rule-meta.js +8 -0
- package/dist/core/types.d.ts +8 -0
- package/dist/instruction-weight-ratchet.d.ts +53 -0
- package/dist/instruction-weight-ratchet.js +132 -0
- package/dist/local-files.js +1 -0
- package/dist/scan.d.ts +10 -0
- package/dist/scan.js +30 -4
- package/dist/vigilesrc.schema.json +36 -0
- package/package.json +1 -1
|
@@ -239,7 +239,17 @@ function excludesIn(text, parse) {
|
|
|
239
239
|
/** Does this rule file declare a `paths:` scope — i.e. load only on demand? */
|
|
240
240
|
function isPathScoped(text) {
|
|
241
241
|
const { data } = (0, frontmatter_read_js_1.readFrontmatter)(text);
|
|
242
|
-
|
|
242
|
+
if (data === null || !Object.hasOwn(data, PATH_SCOPE_KEY))
|
|
243
|
+
return false;
|
|
244
|
+
// A SCOPE IS A NON-EMPTY LIST OF GLOBS, not the presence of the key.
|
|
245
|
+
// `paths: []` names no file to load on and a scalar is not the documented
|
|
246
|
+
// shape; reading either as on-demand would let one frontmatter line drop a
|
|
247
|
+
// rule's whole size from the always-loaded total — an under-report, the
|
|
248
|
+
// direction that reads as "you are fine". So both count as always-loaded.
|
|
249
|
+
const value = data[PATH_SCOPE_KEY];
|
|
250
|
+
return (Array.isArray(value) &&
|
|
251
|
+
value.length > 0 &&
|
|
252
|
+
value.every((g) => typeof g === "string"));
|
|
243
253
|
}
|
|
244
254
|
/**
|
|
245
255
|
* `CLAUDE.md` → `CLAUDE.local.md`: the per-machine sibling, DERIVED so the two
|
|
@@ -452,6 +462,21 @@ function supersederOf(files, input, isExcluded) {
|
|
|
452
462
|
// keeps another.
|
|
453
463
|
return candidates.find((c) => files[c.path] !== undefined && !isExcluded(c.path));
|
|
454
464
|
}
|
|
465
|
+
/** One loaded file's `@import` tokens, reported only — never taken. */
|
|
466
|
+
function nameImportsOf(b, entry) {
|
|
467
|
+
const text = b.files[entry.path];
|
|
468
|
+
if (text === undefined)
|
|
469
|
+
return;
|
|
470
|
+
const named = importTokens(text)
|
|
471
|
+
.filter((token) => (0, instruction_chain_js_1.isRepoRootedImport)(token.slice(1)))
|
|
472
|
+
.map((token) => ({
|
|
473
|
+
path: (0, instruction_chain_js_1.resolveImportPath)(entry.path, token.slice(1)),
|
|
474
|
+
token,
|
|
475
|
+
from: entry.path,
|
|
476
|
+
}))
|
|
477
|
+
.filter((n) => !b.imports.some((m) => m.path === n.path && m.from === n.from));
|
|
478
|
+
b.imports.push(...named);
|
|
479
|
+
}
|
|
455
480
|
/** One loaded file's `@import` tokens: reported, and TAKEN when already present. */
|
|
456
481
|
function takeImportsOf(b, entry) {
|
|
457
482
|
const text = b.files[entry.path];
|
|
@@ -561,8 +586,17 @@ function claudeCodeInstructionChain(files, input) {
|
|
|
561
586
|
// assumed — `instruction-chain.test.ts` runs each against an `AGENTS.md` that
|
|
562
587
|
// got in as a ROOT file, since a role-keyed version of either would pass every
|
|
563
588
|
// `CLAUDE.md` case and fail exactly those two.
|
|
564
|
-
|
|
589
|
+
const firstHop = [...b.loaded];
|
|
590
|
+
for (const entry of firstHop)
|
|
565
591
|
takeImportsOf(b, entry);
|
|
592
|
+
// THE SECOND HOP IS NAMED, NEVER TAKEN. One level is what this reads (the
|
|
593
|
+
// corpus measurement is on `resolveImports`), but the vendor follows up to
|
|
594
|
+
// four, so an imported file's own tokens are real weight this number lacks.
|
|
595
|
+
// Recording them as imports — not loading them — lets the weight list them
|
|
596
|
+
// as unread instead of silently dropping them.
|
|
597
|
+
for (const entry of b.loaded.filter((e) => !firstHop.includes(e))) {
|
|
598
|
+
nameImportsOf(b, entry);
|
|
599
|
+
}
|
|
566
600
|
// THE SUBDIRECTORY PASS, AFTER THE IMPORTS AND NOT BEFORE THEM. Order is the
|
|
567
601
|
// whole of a fixed bug: this pass claims ANY slash path whose leaf is an
|
|
568
602
|
// instruction name, and `takeImportsOf` skips a path already `known`. Running
|
package/dist/cli-flag-check.js
CHANGED
|
@@ -78,7 +78,13 @@ exports.COMMAND_FLAGS = {
|
|
|
78
78
|
],
|
|
79
79
|
compile: [],
|
|
80
80
|
eject: ["--keep-spec"],
|
|
81
|
-
lint: [
|
|
81
|
+
lint: [
|
|
82
|
+
"--bundles=",
|
|
83
|
+
"--summary",
|
|
84
|
+
"--json",
|
|
85
|
+
"--json-out=",
|
|
86
|
+
"--update-baseline",
|
|
87
|
+
],
|
|
82
88
|
// handleRunScripts (free tier — no lock flags).
|
|
83
89
|
test: ["--min=", "--all", "--yes", "--no-interactive", "--no-skip"],
|
|
84
90
|
// handleRunScripts + resolveEvalLockEnv + the trials knob.
|
package/dist/cli-main.d.ts
CHANGED
|
@@ -77,6 +77,8 @@ interface LintReport {
|
|
|
77
77
|
hookBlockErrors: number;
|
|
78
78
|
hookMatcherIssues: number;
|
|
79
79
|
hookMatcherErrors: number;
|
|
80
|
+
instructionWeightIssues: number;
|
|
81
|
+
instructionWeightErrors: number;
|
|
80
82
|
docRefErrors: number;
|
|
81
83
|
symbolRefErrors: number;
|
|
82
84
|
mcpRefErrors: number;
|
package/dist/cli-main.js
CHANGED
|
@@ -34,6 +34,7 @@ const setup_plan_js_1 = require("./setup-plan.js");
|
|
|
34
34
|
const types_js_1 = require("./core/types.js");
|
|
35
35
|
const test_coverage_js_1 = require("./test-coverage.js");
|
|
36
36
|
const scan_js_1 = require("./scan.js");
|
|
37
|
+
const instruction_weight_ratchet_js_1 = require("./instruction-weight-ratchet.js");
|
|
37
38
|
const frame_js_1 = require("./core/frame.js");
|
|
38
39
|
const surface_discovery_js_1 = require("./core/surface-discovery.js");
|
|
39
40
|
const scan_trigger_suggest_js_1 = require("./scan-trigger-suggest.js");
|
|
@@ -978,6 +979,7 @@ function lintExitCode(report) {
|
|
|
978
979
|
report.delegationTrifectaErrors > 0 ||
|
|
979
980
|
report.hookBlockErrors > 0 ||
|
|
980
981
|
report.hookMatcherErrors > 0 ||
|
|
982
|
+
report.instructionWeightErrors > 0 ||
|
|
981
983
|
report.symbolRefErrors > 0 ||
|
|
982
984
|
report.mcpRefErrors > 0 ||
|
|
983
985
|
// `doc-refs` is opt-in and this counter is only non-zero when the user set
|
|
@@ -1675,6 +1677,22 @@ async function runLint(restArgs, flags, excludes, config) {
|
|
|
1675
1677
|
// typo, an uncompilable or unreachable MCP pattern, one too narrow for real
|
|
1676
1678
|
// server naming, or an undeclared MCP server).
|
|
1677
1679
|
const hookMatcher = overBundles(checkHookMatcher, run, lintRoots);
|
|
1680
|
+
// 7v. Instruction-weight ratchet — the always-loaded instruction weight may
|
|
1681
|
+
// not move off the committed baseline. One file for the whole run (bundles
|
|
1682
|
+
// are keys inside it), so it runs ONCE over `lintRoots`, not per bundle.
|
|
1683
|
+
const instructionWeight = (0, instruction_weight_ratchet_js_1.checkInstructionWeightRatchet)({
|
|
1684
|
+
root: frame.root,
|
|
1685
|
+
severity: (0, types_js_1.ruleSeverity)(config?.rules?.["instruction-weight"]),
|
|
1686
|
+
update: flags.includes("--update-baseline"),
|
|
1687
|
+
silent,
|
|
1688
|
+
measure: () => lintRoots.map((abs) => [
|
|
1689
|
+
frame.bundle(abs).at,
|
|
1690
|
+
(0, scan_js_1.measureInstructionWeight)(abs, adapter),
|
|
1691
|
+
]),
|
|
1692
|
+
annotate: (level, message) => {
|
|
1693
|
+
ghAnnotate(level, message);
|
|
1694
|
+
},
|
|
1695
|
+
});
|
|
1678
1696
|
// 8. Validate vigiles builder calls inside markdown code blocks — the
|
|
1679
1697
|
// `doc-refs` rule, DEFAULT OFF. Illustrative blocks opt out via
|
|
1680
1698
|
// `<!-- vigiles:ignore -->` (single block) or `<!-- vigiles:ignore-file -->`
|
|
@@ -1803,6 +1821,8 @@ async function runLint(restArgs, flags, excludes, config) {
|
|
|
1803
1821
|
hookBlockErrors: hookBlock.errors,
|
|
1804
1822
|
hookMatcherIssues: hookMatcher.issues,
|
|
1805
1823
|
hookMatcherErrors: hookMatcher.errors,
|
|
1824
|
+
instructionWeightIssues: instructionWeight.issues,
|
|
1825
|
+
instructionWeightErrors: instructionWeight.errors,
|
|
1806
1826
|
// Only the "error" tier gates. At "warn" the findings are printed and
|
|
1807
1827
|
// annotated, and the exit code is untouched — same contract as every other
|
|
1808
1828
|
// opt-in rule here.
|
|
@@ -6636,6 +6656,18 @@ async function main() {
|
|
|
6636
6656
|
if (specs.length > 0)
|
|
6637
6657
|
valid =
|
|
6638
6658
|
(await compile(specs, config, excludes, { harnessFlag })) && valid;
|
|
6659
|
+
// The always-loaded weight the author just produced, against the
|
|
6660
|
+
// committed baseline — shown at the moment of change. Report-only:
|
|
6661
|
+
// `lint` is the gate (rule `instruction-weight`).
|
|
6662
|
+
if (specs.length > 0) {
|
|
6663
|
+
const weightLine = (0, instruction_weight_ratchet_js_1.compileWeightLine)(process.cwd(), (0, scan_js_1.measureInstructionWeight)(process.cwd(), (0, adapter_registry_js_1.resolveHarnessSelection)({
|
|
6664
|
+
root: process.cwd(),
|
|
6665
|
+
flag: harnessFlag,
|
|
6666
|
+
configHarness: (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses),
|
|
6667
|
+
}).adapter));
|
|
6668
|
+
if (weightLine !== null)
|
|
6669
|
+
console.log(`\n${weightLine}`);
|
|
6670
|
+
}
|
|
6639
6671
|
valid =
|
|
6640
6672
|
(await installHooks(hooks, harnessFlag, (0, adapter_registry_js_1.declaredHarnessNames)(config.harnesses))) && valid;
|
|
6641
6673
|
// Keep an existing whole-harness registry in sync (cheap, opt-in) so the
|
|
@@ -131,6 +131,7 @@ export declare const vigilesConfigSchema: z.ZodObject<{
|
|
|
131
131
|
"hook-block-ineffective": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
|
|
132
132
|
"hook-matcher": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
|
|
133
133
|
"doc-refs": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
|
|
134
|
+
"instruction-weight": z.ZodDefault<z.ZodPipe<z.ZodUnion<readonly [z.ZodLiteral<"warn">, z.ZodLiteral<"error">, z.ZodLiteral<false>, z.ZodLiteral<"off">, z.ZodLiteral<0>, z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<true>]>, z.ZodTransform<false | "warn" | "error", boolean | 0 | 1 | 2 | "warn" | "error" | "off">>>;
|
|
134
135
|
}, z.core.$strict>>;
|
|
135
136
|
files: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
136
137
|
maxRules: z.ZodOptional<z.ZodNumber>;
|
|
@@ -163,6 +163,7 @@ const rulesSchema = zod_1.z
|
|
|
163
163
|
"hook-block-ineffective": severitySchema.default("warn"),
|
|
164
164
|
"hook-matcher": severitySchema.default("warn"),
|
|
165
165
|
"doc-refs": severitySchema.default(false),
|
|
166
|
+
"instruction-weight": severitySchema.default("error"),
|
|
166
167
|
})
|
|
167
168
|
.strict();
|
|
168
169
|
/** The rule names, for the "did you mean" on an unknown one. */
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instruction-weight RATCHET: the always-loaded weight may not grow past
|
|
3
|
+
* what the repository itself last recorded.
|
|
4
|
+
*
|
|
5
|
+
* WHY A RATCHET AND NOT THE BUDGET. `./instruction-weight.ts` explains why the
|
|
6
|
+
* harness's own threshold cannot gate: real repositories sit several times over
|
|
7
|
+
* it, and a rule that fails every repo on day one is switched off on day one.
|
|
8
|
+
* What CAN gate is the repository's own past. The baseline is whatever the repo
|
|
9
|
+
* weighed when it was last recorded, so a repo four times over budget starts
|
|
10
|
+
* green — and stays green only while it does not get heavier. The measured case
|
|
11
|
+
* this exists for: a root instruction file cut from ~4 100 lines to ~1 900, then
|
|
12
|
+
* regrown by ~1 000 lines over ten ordinary commits, with nothing in CI to say so.
|
|
13
|
+
*
|
|
14
|
+
* WHY THE SUM, AGAIN. The baseline records `committedTotal`, the same number the
|
|
15
|
+
* audit report scores: everything the harness loads without being asked that a
|
|
16
|
+
* teammate on the same commit also loads. A per-file baseline would read text
|
|
17
|
+
* moved into an always-loaded sibling as a shrink — the evasion the weight
|
|
18
|
+
* module was built to see through. The per-file sizes are recorded too, but only
|
|
19
|
+
* so a message can say WHICH file moved; the verdict is decided on the total.
|
|
20
|
+
*
|
|
21
|
+
* WHY A SHRINK IS A FINDING TOO. A baseline left above the tree is headroom, and
|
|
22
|
+
* headroom is exactly where the regrowth above landed: every character up to the
|
|
23
|
+
* old number would pass. So, like ESLint's unpruned suppressions and `betterer
|
|
24
|
+
* ci`, a tree LIGHTER than its baseline asks for the baseline to be lowered —
|
|
25
|
+
* one command, in the same change that made the cut. The baseline then always
|
|
26
|
+
* equals the tree on the default branch, and any growth at all is a diff a
|
|
27
|
+
* reviewer sees.
|
|
28
|
+
*
|
|
29
|
+
* WHO WRITES IT. Never `lint` on its own: a read never writes. The file is
|
|
30
|
+
* written only by the explicit `vigiles lint --update-baseline`, the same shape
|
|
31
|
+
* as `eslint --suppress-all` / `--prune-suppressions` and `jest -u`. Raising the
|
|
32
|
+
* baseline is allowed and is the point: growth is not forbidden, it is made
|
|
33
|
+
* visible as a reviewed diff of a committed file.
|
|
34
|
+
*
|
|
35
|
+
* Pure: parsing, comparison and formatting only. Reading and writing the file
|
|
36
|
+
* is the caller's (`src/instruction-weight-ratchet.ts`).
|
|
37
|
+
*/
|
|
38
|
+
import { z } from "zod";
|
|
39
|
+
import type { InstructionWeight } from "./instruction-weight.js";
|
|
40
|
+
/** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
|
|
41
|
+
export declare const INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
|
|
42
|
+
/** The command that writes the baseline — named once, quoted by every message. */
|
|
43
|
+
export declare const UPDATE_BASELINE_COMMAND = "vigiles lint --update-baseline";
|
|
44
|
+
/** Bumped only on an incompatible change to the on-disk shape. */
|
|
45
|
+
export declare const INSTRUCTION_BASELINE_VERSION = 1;
|
|
46
|
+
/**
|
|
47
|
+
* Which MEASUREMENT produced a recorded number — bumped whenever a release
|
|
48
|
+
* changes what counts (chain membership in any adapter, how a file is sized),
|
|
49
|
+
* even when the file shape stays the same.
|
|
50
|
+
*
|
|
51
|
+
* Without it, a vigiles fix that moves a file into or out of the chain moves
|
|
52
|
+
* `committedTotal` on every consumer with a committed baseline, and they see
|
|
53
|
+
* `grew`/`shrank` attributed to their own content. With it, they see one
|
|
54
|
+
* "re-record" verdict that names the cause. `version` cannot do this job: it
|
|
55
|
+
* describes the file, not the number in it.
|
|
56
|
+
*/
|
|
57
|
+
export declare const INSTRUCTION_WEIGHT_MEASURE = 1;
|
|
58
|
+
declare const entrySchema: z.ZodObject<{
|
|
59
|
+
unit: z.ZodEnum<{
|
|
60
|
+
chars: "chars";
|
|
61
|
+
bytes: "bytes";
|
|
62
|
+
}>;
|
|
63
|
+
measure: z.ZodNumber;
|
|
64
|
+
total: z.ZodNumber;
|
|
65
|
+
files: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
66
|
+
}, z.core.$strict>;
|
|
67
|
+
declare const baselineSchema: z.ZodObject<{
|
|
68
|
+
version: z.ZodLiteral<1>;
|
|
69
|
+
bundles: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
70
|
+
unit: z.ZodEnum<{
|
|
71
|
+
chars: "chars";
|
|
72
|
+
bytes: "bytes";
|
|
73
|
+
}>;
|
|
74
|
+
measure: z.ZodNumber;
|
|
75
|
+
total: z.ZodNumber;
|
|
76
|
+
files: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
77
|
+
}, z.core.$strict>>;
|
|
78
|
+
}, z.core.$strict>;
|
|
79
|
+
/** One bundle's recorded weight. */
|
|
80
|
+
export type BaselineEntry = z.infer<typeof entrySchema>;
|
|
81
|
+
/** The whole committed file. */
|
|
82
|
+
export type InstructionBaseline = z.infer<typeof baselineSchema>;
|
|
83
|
+
/** The file's contents, parsed once at the boundary. */
|
|
84
|
+
export type ParsedBaseline = {
|
|
85
|
+
readonly kind: "ok";
|
|
86
|
+
readonly baseline: InstructionBaseline;
|
|
87
|
+
} | {
|
|
88
|
+
readonly kind: "invalid";
|
|
89
|
+
readonly message: string;
|
|
90
|
+
};
|
|
91
|
+
export declare function parseInstructionBaseline(text: string): ParsedBaseline;
|
|
92
|
+
/** What a measured weight would be recorded as: the SCORED files only. */
|
|
93
|
+
export declare function entryFor(weight: InstructionWeight): BaselineEntry;
|
|
94
|
+
/** One file whose recorded size differs. `null` = absent on that side. */
|
|
95
|
+
export interface FileChange {
|
|
96
|
+
readonly path: string;
|
|
97
|
+
readonly from: number | null;
|
|
98
|
+
readonly to: number | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The comparison of a tree against its baseline. Each case is a different fact,
|
|
102
|
+
* so each is its own member — "no baseline" is not "equal", and a baseline in
|
|
103
|
+
* another unit is not a number to subtract.
|
|
104
|
+
*/
|
|
105
|
+
export type RatchetVerdict = {
|
|
106
|
+
readonly kind: "unrecorded";
|
|
107
|
+
/** What `--update-baseline` would write. */
|
|
108
|
+
readonly current: BaselineEntry;
|
|
109
|
+
} | {
|
|
110
|
+
readonly kind: "unit-changed";
|
|
111
|
+
readonly recorded: BaselineEntry["unit"];
|
|
112
|
+
readonly current: BaselineEntry["unit"];
|
|
113
|
+
} | {
|
|
114
|
+
readonly kind: "measure-changed";
|
|
115
|
+
readonly recorded: number;
|
|
116
|
+
readonly current: number;
|
|
117
|
+
} | {
|
|
118
|
+
readonly kind: "held";
|
|
119
|
+
readonly total: number;
|
|
120
|
+
readonly unit: BaselineEntry["unit"];
|
|
121
|
+
} | {
|
|
122
|
+
readonly kind: "grew" | "shrank";
|
|
123
|
+
readonly from: number;
|
|
124
|
+
readonly to: number;
|
|
125
|
+
readonly unit: BaselineEntry["unit"];
|
|
126
|
+
/** Every file whose size moved, largest movement first. */
|
|
127
|
+
readonly changes: readonly FileChange[];
|
|
128
|
+
};
|
|
129
|
+
export declare function compareToBaseline(weight: InstructionWeight, recorded: BaselineEntry | undefined): RatchetVerdict;
|
|
130
|
+
/** Replace the measured bundles' entries; `null` removes one. Others are kept. */
|
|
131
|
+
export declare function recordEntries(previous: InstructionBaseline | undefined, measured: readonly (readonly [string, BaselineEntry | null])[]): InstructionBaseline;
|
|
132
|
+
/** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
|
|
133
|
+
export declare function serializeBaseline(baseline: InstructionBaseline): string;
|
|
134
|
+
/** The one-paragraph message a verdict prints as, prefixed by its bundle. */
|
|
135
|
+
export declare function formatVerdict(verdict: RatchetVerdict, at: string): string;
|
|
136
|
+
/** The compile-time one-liner: weight now, against the baseline when there is one. */
|
|
137
|
+
export declare function formatWeightLine(verdict: RatchetVerdict): string;
|
|
138
|
+
export {};
|
|
139
|
+
//# sourceMappingURL=instruction-baseline.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.INSTRUCTION_WEIGHT_MEASURE = exports.INSTRUCTION_BASELINE_VERSION = exports.UPDATE_BASELINE_COMMAND = exports.INSTRUCTION_BASELINE_FILE = void 0;
|
|
4
|
+
exports.parseInstructionBaseline = parseInstructionBaseline;
|
|
5
|
+
exports.entryFor = entryFor;
|
|
6
|
+
exports.compareToBaseline = compareToBaseline;
|
|
7
|
+
exports.recordEntries = recordEntries;
|
|
8
|
+
exports.serializeBaseline = serializeBaseline;
|
|
9
|
+
exports.formatVerdict = formatVerdict;
|
|
10
|
+
exports.formatWeightLine = formatWeightLine;
|
|
11
|
+
/**
|
|
12
|
+
* The instruction-weight RATCHET: the always-loaded weight may not grow past
|
|
13
|
+
* what the repository itself last recorded.
|
|
14
|
+
*
|
|
15
|
+
* WHY A RATCHET AND NOT THE BUDGET. `./instruction-weight.ts` explains why the
|
|
16
|
+
* harness's own threshold cannot gate: real repositories sit several times over
|
|
17
|
+
* it, and a rule that fails every repo on day one is switched off on day one.
|
|
18
|
+
* What CAN gate is the repository's own past. The baseline is whatever the repo
|
|
19
|
+
* weighed when it was last recorded, so a repo four times over budget starts
|
|
20
|
+
* green — and stays green only while it does not get heavier. The measured case
|
|
21
|
+
* this exists for: a root instruction file cut from ~4 100 lines to ~1 900, then
|
|
22
|
+
* regrown by ~1 000 lines over ten ordinary commits, with nothing in CI to say so.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE SUM, AGAIN. The baseline records `committedTotal`, the same number the
|
|
25
|
+
* audit report scores: everything the harness loads without being asked that a
|
|
26
|
+
* teammate on the same commit also loads. A per-file baseline would read text
|
|
27
|
+
* moved into an always-loaded sibling as a shrink — the evasion the weight
|
|
28
|
+
* module was built to see through. The per-file sizes are recorded too, but only
|
|
29
|
+
* so a message can say WHICH file moved; the verdict is decided on the total.
|
|
30
|
+
*
|
|
31
|
+
* WHY A SHRINK IS A FINDING TOO. A baseline left above the tree is headroom, and
|
|
32
|
+
* headroom is exactly where the regrowth above landed: every character up to the
|
|
33
|
+
* old number would pass. So, like ESLint's unpruned suppressions and `betterer
|
|
34
|
+
* ci`, a tree LIGHTER than its baseline asks for the baseline to be lowered —
|
|
35
|
+
* one command, in the same change that made the cut. The baseline then always
|
|
36
|
+
* equals the tree on the default branch, and any growth at all is a diff a
|
|
37
|
+
* reviewer sees.
|
|
38
|
+
*
|
|
39
|
+
* WHO WRITES IT. Never `lint` on its own: a read never writes. The file is
|
|
40
|
+
* written only by the explicit `vigiles lint --update-baseline`, the same shape
|
|
41
|
+
* as `eslint --suppress-all` / `--prune-suppressions` and `jest -u`. Raising the
|
|
42
|
+
* baseline is allowed and is the point: growth is not forbidden, it is made
|
|
43
|
+
* visible as a reviewed diff of a committed file.
|
|
44
|
+
*
|
|
45
|
+
* Pure: parsing, comparison and formatting only. Reading and writing the file
|
|
46
|
+
* is the caller's (`src/instruction-weight-ratchet.ts`).
|
|
47
|
+
*/
|
|
48
|
+
const zod_1 = require("zod");
|
|
49
|
+
/** Where the baseline lives, relative to the directory `.vigilesrc.json` is read from. */
|
|
50
|
+
exports.INSTRUCTION_BASELINE_FILE = ".vigiles/instruction-weight.json";
|
|
51
|
+
/** The command that writes the baseline — named once, quoted by every message. */
|
|
52
|
+
exports.UPDATE_BASELINE_COMMAND = "vigiles lint --update-baseline";
|
|
53
|
+
/** Bumped only on an incompatible change to the on-disk shape. */
|
|
54
|
+
exports.INSTRUCTION_BASELINE_VERSION = 1;
|
|
55
|
+
/**
|
|
56
|
+
* Which MEASUREMENT produced a recorded number — bumped whenever a release
|
|
57
|
+
* changes what counts (chain membership in any adapter, how a file is sized),
|
|
58
|
+
* even when the file shape stays the same.
|
|
59
|
+
*
|
|
60
|
+
* Without it, a vigiles fix that moves a file into or out of the chain moves
|
|
61
|
+
* `committedTotal` on every consumer with a committed baseline, and they see
|
|
62
|
+
* `grew`/`shrank` attributed to their own content. With it, they see one
|
|
63
|
+
* "re-record" verdict that names the cause. `version` cannot do this job: it
|
|
64
|
+
* describes the file, not the number in it.
|
|
65
|
+
*/
|
|
66
|
+
exports.INSTRUCTION_WEIGHT_MEASURE = 1;
|
|
67
|
+
const entrySchema = zod_1.z
|
|
68
|
+
.object({
|
|
69
|
+
unit: zod_1.z.enum(["chars", "bytes"]),
|
|
70
|
+
measure: zod_1.z.number().int().nonnegative(),
|
|
71
|
+
total: zod_1.z.number().int().nonnegative(),
|
|
72
|
+
files: zod_1.z.record(zod_1.z.string(), zod_1.z.number().int().nonnegative()),
|
|
73
|
+
})
|
|
74
|
+
.strict()
|
|
75
|
+
.refine((e) => Object.values(e.files).reduce((a, b) => a + b, 0) === e.total, {
|
|
76
|
+
message: "`total` is not the sum of `files` — the file was edited by hand; re-record it",
|
|
77
|
+
});
|
|
78
|
+
const baselineSchema = zod_1.z
|
|
79
|
+
.object({
|
|
80
|
+
version: zod_1.z.literal(exports.INSTRUCTION_BASELINE_VERSION),
|
|
81
|
+
/** Keyed by bundle location from the config root; `.` is the root itself. */
|
|
82
|
+
bundles: zod_1.z.record(zod_1.z.string(), entrySchema),
|
|
83
|
+
})
|
|
84
|
+
.strict();
|
|
85
|
+
function parseInstructionBaseline(text) {
|
|
86
|
+
const json = (() => {
|
|
87
|
+
try {
|
|
88
|
+
return { ok: true, value: JSON.parse(text) };
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { ok: false };
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
if (!json.ok)
|
|
95
|
+
return { kind: "invalid", message: "not valid JSON" };
|
|
96
|
+
const parsed = baselineSchema.safeParse(json.value);
|
|
97
|
+
return parsed.success
|
|
98
|
+
? { kind: "ok", baseline: parsed.data }
|
|
99
|
+
: {
|
|
100
|
+
kind: "invalid",
|
|
101
|
+
message: parsed.error.issues
|
|
102
|
+
.map((i) => `${i.path.join(".") || "(top level)"}: ${i.message}`)
|
|
103
|
+
.join("; "),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** What a measured weight would be recorded as: the SCORED files only. */
|
|
107
|
+
function entryFor(weight) {
|
|
108
|
+
const scored = weight.files.filter((f) => f.scope === "repo");
|
|
109
|
+
return {
|
|
110
|
+
unit: weight.unit,
|
|
111
|
+
measure: exports.INSTRUCTION_WEIGHT_MEASURE,
|
|
112
|
+
total: weight.committedTotal,
|
|
113
|
+
files: Object.fromEntries([...scored]
|
|
114
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
115
|
+
.map((f) => [f.path, f.size])),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function changesBetween(before, after) {
|
|
119
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])];
|
|
120
|
+
const size = (m, p) => Object.hasOwn(m, p) ? m[p] : null;
|
|
121
|
+
return paths
|
|
122
|
+
.map((path) => ({ path, from: size(before, path), to: size(after, path) }))
|
|
123
|
+
.filter((c) => c.from !== c.to)
|
|
124
|
+
.sort((a, b) => Math.abs((b.to ?? 0) - (b.from ?? 0)) -
|
|
125
|
+
Math.abs((a.to ?? 0) - (a.from ?? 0)) || a.path.localeCompare(b.path));
|
|
126
|
+
}
|
|
127
|
+
function compareToBaseline(weight, recorded) {
|
|
128
|
+
const current = entryFor(weight);
|
|
129
|
+
if (recorded === undefined)
|
|
130
|
+
return { kind: "unrecorded", current };
|
|
131
|
+
if (recorded.unit !== current.unit) {
|
|
132
|
+
return {
|
|
133
|
+
kind: "unit-changed",
|
|
134
|
+
recorded: recorded.unit,
|
|
135
|
+
current: current.unit,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (recorded.measure !== current.measure) {
|
|
139
|
+
return {
|
|
140
|
+
kind: "measure-changed",
|
|
141
|
+
recorded: recorded.measure,
|
|
142
|
+
current: current.measure,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (current.total === recorded.total) {
|
|
146
|
+
return { kind: "held", total: current.total, unit: current.unit };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: current.total > recorded.total ? "grew" : "shrank",
|
|
150
|
+
from: recorded.total,
|
|
151
|
+
to: current.total,
|
|
152
|
+
unit: current.unit,
|
|
153
|
+
changes: changesBetween(recorded.files, current.files),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Replace the measured bundles' entries; `null` removes one. Others are kept. */
|
|
157
|
+
function recordEntries(previous, measured) {
|
|
158
|
+
const removed = new Set(measured.filter(([, e]) => e === null).map(([at]) => at));
|
|
159
|
+
const kept = Object.entries(previous?.bundles ?? {}).filter(([at]) => !removed.has(at));
|
|
160
|
+
const fresh = measured.flatMap(([at, e]) => e === null ? [] : [[at, e]]);
|
|
161
|
+
return {
|
|
162
|
+
version: exports.INSTRUCTION_BASELINE_VERSION,
|
|
163
|
+
bundles: Object.fromEntries([...new Map([...kept, ...fresh]).entries()].sort(([a], [b]) => a.localeCompare(b))),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Stable text: sorted keys, two-space indent, trailing newline, no timestamp. */
|
|
167
|
+
function serializeBaseline(baseline) {
|
|
168
|
+
return `${JSON.stringify(baseline, null, 2)}\n`;
|
|
169
|
+
}
|
|
170
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
171
|
+
const signed = (x) => (x > 0 ? `+${n(x)}` : n(x));
|
|
172
|
+
function describeChange(c) {
|
|
173
|
+
if (c.from === null)
|
|
174
|
+
return `${c.path} +${n(c.to ?? 0)} (new)`;
|
|
175
|
+
if (c.to === null)
|
|
176
|
+
return `${c.path} -${n(c.from)} (gone)`;
|
|
177
|
+
return `${c.path} ${signed(c.to - c.from)}`;
|
|
178
|
+
}
|
|
179
|
+
/** The one-paragraph message a verdict prints as, prefixed by its bundle. */
|
|
180
|
+
function formatVerdict(verdict, at) {
|
|
181
|
+
const where = at === "." ? "" : `${at}: `;
|
|
182
|
+
switch (verdict.kind) {
|
|
183
|
+
case "unrecorded":
|
|
184
|
+
return (`${where}no instruction-weight baseline recorded (${n(verdict.current.total)} ${verdict.current.unit} always loaded) — ` +
|
|
185
|
+
`run \`${exports.UPDATE_BASELINE_COMMAND}\` and commit ${exports.INSTRUCTION_BASELINE_FILE} to stop it growing unnoticed`);
|
|
186
|
+
case "unit-changed":
|
|
187
|
+
return (`${where}the instruction-weight baseline is in ${verdict.recorded} but this harness counts ${verdict.current} — ` +
|
|
188
|
+
`re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
|
|
189
|
+
case "measure-changed":
|
|
190
|
+
return (`${where}the instruction-weight baseline was recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — ` +
|
|
191
|
+
`what counts changed in vigiles, not in your files. Re-record it with \`${exports.UPDATE_BASELINE_COMMAND}\``);
|
|
192
|
+
case "held":
|
|
193
|
+
return `${where}always-loaded instructions held at ${n(verdict.total)} ${verdict.unit} (baseline)`;
|
|
194
|
+
case "grew":
|
|
195
|
+
case "shrank": {
|
|
196
|
+
const head = `${n(verdict.from)} → ${n(verdict.to)} ${verdict.unit} (${signed(verdict.to - verdict.from)})`;
|
|
197
|
+
const moved = verdict.changes.length > 0
|
|
198
|
+
? ` Changed: ${verdict.changes.map(describeChange).join(", ")}.`
|
|
199
|
+
: "";
|
|
200
|
+
return verdict.kind === "grew"
|
|
201
|
+
? `${where}always-loaded instructions grew ${head} over the recorded baseline.${moved} ` +
|
|
202
|
+
`If the growth is intended, record it with \`${exports.UPDATE_BASELINE_COMMAND}\` — the diff of ${exports.INSTRUCTION_BASELINE_FILE} is the review.`
|
|
203
|
+
: `${where}always-loaded instructions shrank ${head}; the baseline still allows the old weight.${moved} ` +
|
|
204
|
+
`Lock the cut in with \`${exports.UPDATE_BASELINE_COMMAND}\`, or the headroom can be regrown without a finding.`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** The compile-time one-liner: weight now, against the baseline when there is one. */
|
|
209
|
+
function formatWeightLine(verdict) {
|
|
210
|
+
switch (verdict.kind) {
|
|
211
|
+
case "unrecorded":
|
|
212
|
+
return `always-loaded: ${n(verdict.current.total)} ${verdict.current.unit} (no baseline — \`${exports.UPDATE_BASELINE_COMMAND}\`)`;
|
|
213
|
+
case "unit-changed":
|
|
214
|
+
return `always-loaded: baseline is in ${verdict.recorded}, this harness counts ${verdict.current}`;
|
|
215
|
+
case "measure-changed":
|
|
216
|
+
return `always-loaded: baseline recorded by measurement ${String(verdict.recorded)}, this vigiles uses ${String(verdict.current)} — re-record`;
|
|
217
|
+
case "held":
|
|
218
|
+
return `always-loaded: ${n(verdict.total)} ${verdict.unit} (baseline ${n(verdict.total)}, ±0)`;
|
|
219
|
+
case "grew":
|
|
220
|
+
case "shrank":
|
|
221
|
+
return `always-loaded: ${n(verdict.to)} ${verdict.unit} (baseline ${n(verdict.from)}, ${signed(verdict.to - verdict.from)})`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=instruction-baseline.js.map
|
|
@@ -28,12 +28,13 @@
|
|
|
28
28
|
* number carries a different severity per harness, so the harness must supply
|
|
29
29
|
* it — hence a port field, not a constant.
|
|
30
30
|
*
|
|
31
|
-
* NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
32
|
-
* roughly four times the Claude Code threshold. A rule that
|
|
33
|
-
* repo on day one is switched off on day one
|
|
34
|
-
* tracks confidence, and a check nobody
|
|
35
|
-
*
|
|
36
|
-
*
|
|
31
|
+
* THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
32
|
+
* against sit at roughly four times the Claude Code threshold. A rule that
|
|
33
|
+
* fails every real repo on day one is switched off on day one
|
|
34
|
+
* (`lint-rule-calibration`: severity tracks confidence, and a check nobody
|
|
35
|
+
* leaves on catches nothing). So `audit` reports the budget, and what `lint`
|
|
36
|
+
* gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
|
|
37
|
+
* on day one at any size, red the day it grows.
|
|
37
38
|
*/
|
|
38
39
|
import type { InstructionChain, InstructionRole, InstructionScope } from "./instruction-chain.js";
|
|
39
40
|
/** What the harness counts, and what it does when the count is exceeded. */
|
|
@@ -147,7 +148,16 @@ export interface InstructionWeight {
|
|
|
147
148
|
readonly to: readonly string[];
|
|
148
149
|
}[];
|
|
149
150
|
}
|
|
150
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
|
|
153
|
+
*
|
|
154
|
+
* Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
|
|
155
|
+
* same commit with one more character per line, and a number that differs
|
|
156
|
+
* between two clones of one commit cannot be committed and compared — a
|
|
157
|
+
* 1 000-line file read +1 000 on Windows with no content change. The harness
|
|
158
|
+
* may count the CR itself; reproducibility is worth more here than that
|
|
159
|
+
* fidelity.
|
|
160
|
+
*/
|
|
151
161
|
export declare function sizeIn(text: string, unit: "chars" | "bytes"): number;
|
|
152
162
|
/**
|
|
153
163
|
* Weigh a harness's LOADED chain against its own budget.
|
|
@@ -29,19 +29,30 @@
|
|
|
29
29
|
* number carries a different severity per harness, so the harness must supply
|
|
30
30
|
* it — hence a port field, not a constant.
|
|
31
31
|
*
|
|
32
|
-
* NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
33
|
-
* roughly four times the Claude Code threshold. A rule that
|
|
34
|
-
* repo on day one is switched off on day one
|
|
35
|
-
* tracks confidence, and a check nobody
|
|
36
|
-
*
|
|
37
|
-
*
|
|
32
|
+
* THE BUDGET IS NOT A GATE, AND THAT IS MEASURED. Both corpora this was built
|
|
33
|
+
* against sit at roughly four times the Claude Code threshold. A rule that
|
|
34
|
+
* fails every real repo on day one is switched off on day one
|
|
35
|
+
* (`lint-rule-calibration`: severity tracks confidence, and a check nobody
|
|
36
|
+
* leaves on catches nothing). So `audit` reports the budget, and what `lint`
|
|
37
|
+
* gates is the repo's OWN recorded weight (`./instruction-baseline.ts`): green
|
|
38
|
+
* on day one at any size, red the day it grows.
|
|
38
39
|
*/
|
|
39
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
41
|
exports.sizeIn = sizeIn;
|
|
41
42
|
exports.weighInstructions = weighInstructions;
|
|
42
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Size in the harness's own unit. Bytes and chars differ on any non-ASCII text.
|
|
45
|
+
*
|
|
46
|
+
* Line endings are counted as `\n`. A checkout with `core.autocrlf` holds the
|
|
47
|
+
* same commit with one more character per line, and a number that differs
|
|
48
|
+
* between two clones of one commit cannot be committed and compared — a
|
|
49
|
+
* 1 000-line file read +1 000 on Windows with no content change. The harness
|
|
50
|
+
* may count the CR itself; reproducibility is worth more here than that
|
|
51
|
+
* fidelity.
|
|
52
|
+
*/
|
|
43
53
|
function sizeIn(text, unit) {
|
|
44
|
-
|
|
54
|
+
const lf = text.replaceAll("\r\n", "\n");
|
|
55
|
+
return unit === "chars" ? lf.length : Buffer.byteLength(lf, "utf8");
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
58
|
* Weigh a harness's LOADED chain against its own budget.
|
|
@@ -107,10 +118,15 @@ function weighInstructions(chain, files, budget) {
|
|
|
107
118
|
committedTotal,
|
|
108
119
|
effectiveTotal: sum(weighed.filter((f) => f.notLoadedHere === undefined)),
|
|
109
120
|
overBy: committedTotal > budget.limit ? committedTotal - budget.limit : null,
|
|
121
|
+
// NOT "absent from the map": a second-hop target can be in the map (the
|
|
122
|
+
// import pass read it because its importer was also a candidate) and still
|
|
123
|
+
// not be in the chain, because the second hop is named, never taken. What
|
|
124
|
+
// makes an import unread is that the chain did not classify its target.
|
|
110
125
|
unreadImports: [
|
|
111
126
|
...new Set(chain.imports
|
|
112
127
|
.map((i) => i.path)
|
|
113
|
-
.filter((path) =>
|
|
128
|
+
.filter((path) => !chain.loaded.some((e) => e.path === path) &&
|
|
129
|
+
!chain.unloaded.some((e) => e.path === path))),
|
|
114
130
|
].sort(),
|
|
115
131
|
unweighedPatterns: [
|
|
116
132
|
...new Set(chain.patterns.map((p) => p.pattern)),
|
package/dist/core/rule-meta.js
CHANGED
|
@@ -210,6 +210,14 @@ exports.RULE_META = {
|
|
|
210
210
|
summary: "Two model-invocable skills aren't near-identical (wrong one fires).",
|
|
211
211
|
detector: "findDescriptionOverlaps",
|
|
212
212
|
},
|
|
213
|
+
"instruction-weight": {
|
|
214
|
+
id: "instruction-weight",
|
|
215
|
+
bucket: "external-decidable",
|
|
216
|
+
surface: ["instruction"],
|
|
217
|
+
defaultSeverity: "error",
|
|
218
|
+
summary: "The always-loaded instruction weight did not move off its committed baseline.",
|
|
219
|
+
detector: "compareToBaseline",
|
|
220
|
+
},
|
|
213
221
|
"skill-description-budget": {
|
|
214
222
|
id: "skill-description-budget",
|
|
215
223
|
bucket: "heuristic-behavioral",
|
package/dist/core/types.d.ts
CHANGED
|
@@ -122,6 +122,14 @@ export interface RulesConfig {
|
|
|
122
122
|
* exited 0 (#173). Default: "error", matching `compile`.
|
|
123
123
|
*/
|
|
124
124
|
"spec-refs"?: RuleSeverity;
|
|
125
|
+
/**
|
|
126
|
+
* The always-loaded instruction weight may not move away from the committed
|
|
127
|
+
* baseline in `.vigiles/instruction-weight.json` — a ratchet against the
|
|
128
|
+
* repo's own past, not a fixed budget. No baseline file → a one-line note,
|
|
129
|
+
* never a finding, so turning this on fails no repository on day one.
|
|
130
|
+
* `vigiles lint --update-baseline` records it. Default: "error".
|
|
131
|
+
*/
|
|
132
|
+
"instruction-weight"?: RuleSeverity;
|
|
125
133
|
/**
|
|
126
134
|
* Near-duplicate rules WITHIN one spec, by NCD similarity — spec bloat, two
|
|
127
135
|
* rules saying the same thing in different words.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type InstructionBaseline, type ParsedBaseline } from "./core/instruction-baseline.js";
|
|
2
|
+
import type { InstructionWeight } from "./core/instruction-weight.js";
|
|
3
|
+
import type { RuleSeverity } from "./core/types.js";
|
|
4
|
+
/** The file on disk, read once: absent, parsed, or refused. */
|
|
5
|
+
export type BaselineOnDisk = {
|
|
6
|
+
readonly kind: "absent";
|
|
7
|
+
} | ParsedBaseline;
|
|
8
|
+
export declare function readInstructionBaseline(root: string): BaselineOnDisk;
|
|
9
|
+
/** One bundle's measurement: where it is, and its weight (`null` = nothing to weigh). */
|
|
10
|
+
export type MeasuredBundle = readonly [
|
|
11
|
+
at: string,
|
|
12
|
+
weight: InstructionWeight | null
|
|
13
|
+
];
|
|
14
|
+
/** Write the baseline for every measured bundle; returns what was written. */
|
|
15
|
+
export declare function writeInstructionBaseline(root: string, onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[]): InstructionBaseline;
|
|
16
|
+
/**
|
|
17
|
+
* One line of lint output. `finding` counts toward the exit code; `ok` is the
|
|
18
|
+
* baseline holding; `note` is the opt-in nudge on a repo with no baseline.
|
|
19
|
+
*/
|
|
20
|
+
export interface RatchetLine {
|
|
21
|
+
readonly at: string;
|
|
22
|
+
readonly status: "finding" | "ok" | "note";
|
|
23
|
+
readonly text: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Pure: the lines a run prints, given what was measured and what is on disk.
|
|
27
|
+
*
|
|
28
|
+
* A bundle with no entry is a nudge while the repo has NO baseline file (it has
|
|
29
|
+
* not opted in, and a fresh repo must not fail on day one) — but a FINDING once
|
|
30
|
+
* the file exists: a bundle that appeared after the baseline was recorded is
|
|
31
|
+
* weight nobody signed off on.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ratchetLines(onDisk: BaselineOnDisk, measured: readonly MeasuredBundle[]): readonly RatchetLine[];
|
|
34
|
+
/** Run the ratchet for `lint`: print, annotate, count. */
|
|
35
|
+
export declare function checkInstructionWeightRatchet(opts: {
|
|
36
|
+
readonly root: string;
|
|
37
|
+
readonly severity: RuleSeverity;
|
|
38
|
+
readonly update: boolean;
|
|
39
|
+
readonly silent: boolean;
|
|
40
|
+
/** Called only when the rule is on or an update was asked for. */
|
|
41
|
+
readonly measure: () => readonly MeasuredBundle[];
|
|
42
|
+
readonly annotate: (level: "error" | "warning", message: string) => void;
|
|
43
|
+
}): {
|
|
44
|
+
issues: number;
|
|
45
|
+
errors: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The line `compile` prints after writing instruction files: the weight NOW,
|
|
49
|
+
* against the baseline, at the moment the author changed it. Report-only —
|
|
50
|
+
* `lint` stays the single gate. `null` when there is nothing to weigh.
|
|
51
|
+
*/
|
|
52
|
+
export declare function compileWeightLine(root: string, weight: InstructionWeight | null): string | null;
|
|
53
|
+
//# sourceMappingURL=instruction-weight-ratchet.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readInstructionBaseline = readInstructionBaseline;
|
|
4
|
+
exports.writeInstructionBaseline = writeInstructionBaseline;
|
|
5
|
+
exports.ratchetLines = ratchetLines;
|
|
6
|
+
exports.checkInstructionWeightRatchet = checkInstructionWeightRatchet;
|
|
7
|
+
exports.compileWeightLine = compileWeightLine;
|
|
8
|
+
/**
|
|
9
|
+
* `lint`'s side of the instruction-weight ratchet: measure each scored bundle,
|
|
10
|
+
* compare it with `.vigiles/instruction-weight.json`, and — only under the
|
|
11
|
+
* explicit `--update-baseline` — write that file.
|
|
12
|
+
*
|
|
13
|
+
* The decisions (what a verdict is, what it says) live in
|
|
14
|
+
* `core/instruction-baseline.ts`; this module owns the two effects the core
|
|
15
|
+
* must not have, reading and writing the committed file, plus turning verdicts
|
|
16
|
+
* into lint counters.
|
|
17
|
+
*
|
|
18
|
+
* WHY THE FILE IS COMMITTED, unlike `.vigiles/coverage.json`. Coverage records
|
|
19
|
+
* what tests ran on THIS machine, so a committed copy would credit a checkout
|
|
20
|
+
* where nothing ran. The baseline records a property of the COMMIT: it is built
|
|
21
|
+
* from `committedTotal`, which already leaves out every per-machine file
|
|
22
|
+
* (`CLAUDE.local.md`, a gitignored settings sibling), so two clones of one
|
|
23
|
+
* commit compute byte-identical files. That is what lets CI compare against it.
|
|
24
|
+
*/
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
26
|
+
const node_path_1 = require("node:path");
|
|
27
|
+
const instruction_baseline_js_1 = require("./core/instruction-baseline.js");
|
|
28
|
+
function readInstructionBaseline(root) {
|
|
29
|
+
const path = (0, node_path_1.join)(root, instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE);
|
|
30
|
+
return (0, node_fs_1.existsSync)(path)
|
|
31
|
+
? (0, instruction_baseline_js_1.parseInstructionBaseline)((0, node_fs_1.readFileSync)(path, "utf-8"))
|
|
32
|
+
: { kind: "absent" };
|
|
33
|
+
}
|
|
34
|
+
/** Write the baseline for every measured bundle; returns what was written. */
|
|
35
|
+
function writeInstructionBaseline(root, onDisk, measured) {
|
|
36
|
+
const next = (0, instruction_baseline_js_1.recordEntries)(onDisk.kind === "ok" ? onDisk.baseline : undefined, measured.map(([at, w]) => [at, w === null ? null : (0, instruction_baseline_js_1.entryFor)(w)]));
|
|
37
|
+
const path = (0, node_path_1.join)(root, instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE);
|
|
38
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
|
|
39
|
+
(0, node_fs_1.writeFileSync)(path, (0, instruction_baseline_js_1.serializeBaseline)(next));
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure: the lines a run prints, given what was measured and what is on disk.
|
|
44
|
+
*
|
|
45
|
+
* A bundle with no entry is a nudge while the repo has NO baseline file (it has
|
|
46
|
+
* not opted in, and a fresh repo must not fail on day one) — but a FINDING once
|
|
47
|
+
* the file exists: a bundle that appeared after the baseline was recorded is
|
|
48
|
+
* weight nobody signed off on.
|
|
49
|
+
*/
|
|
50
|
+
function ratchetLines(onDisk, measured) {
|
|
51
|
+
if (onDisk.kind === "invalid") {
|
|
52
|
+
return [
|
|
53
|
+
{
|
|
54
|
+
at: ".",
|
|
55
|
+
status: "finding",
|
|
56
|
+
text: `${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE} cannot be read (${onDisk.message}) — ` +
|
|
57
|
+
`re-record it with \`${instruction_baseline_js_1.UPDATE_BASELINE_COMMAND}\``,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
const recorded = onDisk.kind === "ok" ? onDisk.baseline.bundles : {};
|
|
62
|
+
return measured.flatMap(([at, weight]) => {
|
|
63
|
+
if (weight === null)
|
|
64
|
+
return [];
|
|
65
|
+
const verdict = (0, instruction_baseline_js_1.compareToBaseline)(weight, Object.hasOwn(recorded, at) ? recorded[at] : undefined);
|
|
66
|
+
const status = verdict.kind === "held"
|
|
67
|
+
? "ok"
|
|
68
|
+
: verdict.kind === "unrecorded" && onDisk.kind !== "ok"
|
|
69
|
+
? "note"
|
|
70
|
+
: "finding";
|
|
71
|
+
// An import named and not read is weight this number lacks — a second
|
|
72
|
+
// hop, or a path that does not resolve. Said on the line itself, because
|
|
73
|
+
// the gate holding is only as true as the sum it holds.
|
|
74
|
+
const unfollowed = weight.unreadImports.length > 0
|
|
75
|
+
? ` (${String(weight.unreadImports.length)} import(s) not followed: ${weight.unreadImports.join(", ")} — their size is not in this number)`
|
|
76
|
+
: "";
|
|
77
|
+
return [{ at, status, text: `${(0, instruction_baseline_js_1.formatVerdict)(verdict, at)}${unfollowed}` }];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Run the ratchet for `lint`: print, annotate, count. */
|
|
81
|
+
function checkInstructionWeightRatchet(opts) {
|
|
82
|
+
const { severity, update, silent } = opts;
|
|
83
|
+
if (!severity && !update)
|
|
84
|
+
return { issues: 0, errors: 0 };
|
|
85
|
+
const measured = opts.measure();
|
|
86
|
+
const before = readInstructionBaseline(opts.root);
|
|
87
|
+
const onDisk = update
|
|
88
|
+
? {
|
|
89
|
+
kind: "ok",
|
|
90
|
+
baseline: writeInstructionBaseline(opts.root, before, measured),
|
|
91
|
+
}
|
|
92
|
+
: before;
|
|
93
|
+
const lines = severity ? ratchetLines(onDisk, measured) : [];
|
|
94
|
+
const findings = lines.filter((l) => l.status === "finding");
|
|
95
|
+
if (!silent && (update || lines.length > 0)) {
|
|
96
|
+
console.log("\nInstruction-weight ratchet:\n");
|
|
97
|
+
if (update)
|
|
98
|
+
console.log(` ✓ recorded ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE}`);
|
|
99
|
+
const marks = {
|
|
100
|
+
finding: severity === "error" ? "✗" : "⚠",
|
|
101
|
+
ok: "✓",
|
|
102
|
+
note: "ℹ",
|
|
103
|
+
};
|
|
104
|
+
lines.forEach((l) => {
|
|
105
|
+
console.log(` ${marks[l.status]} ${l.text}`);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const level = severity === "error" ? "error" : "warning";
|
|
109
|
+
findings.forEach((l) => {
|
|
110
|
+
opts.annotate(level, l.text);
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
issues: findings.length,
|
|
114
|
+
errors: severity === "error" ? findings.length : 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The line `compile` prints after writing instruction files: the weight NOW,
|
|
119
|
+
* against the baseline, at the moment the author changed it. Report-only —
|
|
120
|
+
* `lint` stays the single gate. `null` when there is nothing to weigh.
|
|
121
|
+
*/
|
|
122
|
+
function compileWeightLine(root, weight) {
|
|
123
|
+
if (weight === null)
|
|
124
|
+
return null;
|
|
125
|
+
const onDisk = readInstructionBaseline(root);
|
|
126
|
+
if (onDisk.kind === "invalid") {
|
|
127
|
+
return `always-loaded: ${instruction_baseline_js_1.INSTRUCTION_BASELINE_FILE} cannot be read (${onDisk.message})`;
|
|
128
|
+
}
|
|
129
|
+
const recorded = onDisk.kind === "ok" ? onDisk.baseline.bundles : {};
|
|
130
|
+
return (0, instruction_baseline_js_1.formatWeightLine)((0, instruction_baseline_js_1.compareToBaseline)(weight, Object.hasOwn(recorded, ".") ? recorded["."] : undefined));
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=instruction-weight-ratchet.js.map
|
package/dist/local-files.js
CHANGED
|
@@ -107,6 +107,7 @@ exports.COMMITTED_PATHS = [
|
|
|
107
107
|
"schema.json", // YAML-LSP frontmatter schema (`vigiles init`)
|
|
108
108
|
"guards.json", // the declared guard set (`core/guards.ts`)
|
|
109
109
|
"action-gates.json", // declared action gates (`action-gate.ts`)
|
|
110
|
+
"instruction-weight.json", // the instruction-weight ratchet's baseline (`core/instruction-baseline.ts`)
|
|
110
111
|
];
|
|
111
112
|
/** The header comment on a `.vigiles/.gitignore` vigiles creates. */
|
|
112
113
|
/** The ignore file vigiles keeps inside `.vigiles/`. One spelling for every reader. */
|
package/dist/scan.d.ts
CHANGED
|
@@ -411,6 +411,16 @@ export interface ScanHarness {
|
|
|
411
411
|
readonly dialect: HarnessDialect;
|
|
412
412
|
readonly roots: readonly string[];
|
|
413
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The always-loaded instruction weight of ONE directory under ONE harness —
|
|
416
|
+
* the bounded read, the harness's chain, and the sum, in one place.
|
|
417
|
+
*
|
|
418
|
+
* `scanPlugin` reports it, `lint`'s instruction-weight ratchet gates on it and
|
|
419
|
+
* `compile` prints it, so all three must arrive at the same number by the same
|
|
420
|
+
* route; this function is that route. `null` when the harness publishes no
|
|
421
|
+
* budget (there is no unit to count in).
|
|
422
|
+
*/
|
|
423
|
+
export declare function measureInstructionWeight(dir: string, harness: Pick<ScanHarness, "layout" | "dialect">): InstructionWeight | null;
|
|
414
424
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
415
425
|
export declare function scanPlugin(dir: string, layout: PluginLayout, dialect: HarnessDialect, opts?: {
|
|
416
426
|
sharedDirs?: readonly string[];
|
package/dist/scan.js
CHANGED
|
@@ -27,6 +27,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
27
27
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
28
28
|
};
|
|
29
29
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.measureInstructionWeight = measureInstructionWeight;
|
|
30
31
|
exports.scanPlugin = scanPlugin;
|
|
31
32
|
exports.verifyLiveMcpTools = verifyLiveMcpTools;
|
|
32
33
|
exports.formatMcpContractReport = formatMcpContractReport;
|
|
@@ -153,6 +154,25 @@ function ownTestSignalOnDisk(dir) {
|
|
|
153
154
|
existsSync: node_fs_1.existsSync,
|
|
154
155
|
});
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* The always-loaded instruction weight of ONE directory under ONE harness —
|
|
159
|
+
* the bounded read, the harness's chain, and the sum, in one place.
|
|
160
|
+
*
|
|
161
|
+
* `scanPlugin` reports it, `lint`'s instruction-weight ratchet gates on it and
|
|
162
|
+
* `compile` prints it, so all three must arrive at the same number by the same
|
|
163
|
+
* route; this function is that route. `null` when the harness publishes no
|
|
164
|
+
* budget (there is no unit to count in).
|
|
165
|
+
*/
|
|
166
|
+
function measureInstructionWeight(dir, harness) {
|
|
167
|
+
// No `exclude` parameter, on purpose — see the note in `scanPlugin`.
|
|
168
|
+
return weighBoundedInstructions(harness, (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), harness.layout));
|
|
169
|
+
}
|
|
170
|
+
function weighBoundedInstructions(harness, instructionFiles) {
|
|
171
|
+
const budget = harness.dialect.instructionBudget;
|
|
172
|
+
return budget
|
|
173
|
+
? (0, instruction_weight_js_1.weighInstructions)(harness.layout.instructionChain(instructionFiles), instructionFiles, budget)
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
156
176
|
/** Scan a plugin/repo directory and report its surfaces + structural issues. */
|
|
157
177
|
function scanPlugin(dir, layout, dialect, opts = {}) {
|
|
158
178
|
const lay = layout;
|
|
@@ -195,7 +215,15 @@ function scanPlugin(dir, layout, dialect, opts = {}) {
|
|
|
195
215
|
// expanded an ADAPTER's globs by walking the whole tree; the bound and the
|
|
196
216
|
// classification are now separate jobs held by separate modules, and only the
|
|
197
217
|
// second is the adapter's. See `core/instruction-chain.ts`.
|
|
198
|
-
|
|
218
|
+
//
|
|
219
|
+
// 🔴 `exclude` DOES NOT REACH IT. `exclude` means "vigiles does not lint
|
|
220
|
+
// this"; the harness still loads the file, so hiding it from the weight
|
|
221
|
+
// under-reports — and since the weight is a committed baseline, one more
|
|
222
|
+
// `exclude` line would silently lower the ratchet. The harness's own
|
|
223
|
+
// `claudeMdExcludes` is the channel that removes a file, because it is also
|
|
224
|
+
// what stops the harness loading it. The browser twin never applied `exclude`
|
|
225
|
+
// here either, so this is also where the two engines agree.
|
|
226
|
+
const instructionFiles = (0, surface_discovery_fs_js_1.boundedInstructionFiles)((0, node_path_1.resolve)(dir), instructionHarness.layout);
|
|
199
227
|
const instructions = loaded.files[instructionFile] !== undefined
|
|
200
228
|
? {
|
|
201
229
|
file: instructionFile,
|
|
@@ -321,9 +349,7 @@ function scanPlugin(dir, layout, dialect, opts = {}) {
|
|
|
321
349
|
// unit (Claude Code counts 40 000 chars, Codex 32 768 bytes), so there is no
|
|
322
350
|
// meaningful sum across two; reporting the one that owns the file that
|
|
323
351
|
// exists is the only reading that is true of something.
|
|
324
|
-
instructionWeight: instructionHarness
|
|
325
|
-
? (0, instruction_weight_js_1.weighInstructions)(instructionHarness.layout.instructionChain(instructionFiles), instructionFiles, instructionHarness.dialect.instructionBudget)
|
|
326
|
-
: null,
|
|
352
|
+
instructionWeight: weighBoundedInstructions(instructionHarness, instructionFiles),
|
|
327
353
|
untested: coverage.untested.length,
|
|
328
354
|
untestedHarness: coverage.harness.untested.length,
|
|
329
355
|
unevaluated: coverage.evals.untested.length,
|
|
@@ -1519,6 +1519,42 @@
|
|
|
1519
1519
|
"const": true
|
|
1520
1520
|
}
|
|
1521
1521
|
]
|
|
1522
|
+
},
|
|
1523
|
+
"instruction-weight": {
|
|
1524
|
+
"anyOf": [
|
|
1525
|
+
{
|
|
1526
|
+
"type": "string",
|
|
1527
|
+
"const": "warn"
|
|
1528
|
+
},
|
|
1529
|
+
{
|
|
1530
|
+
"type": "string",
|
|
1531
|
+
"const": "error"
|
|
1532
|
+
},
|
|
1533
|
+
{
|
|
1534
|
+
"type": "boolean",
|
|
1535
|
+
"const": false
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
"type": "string",
|
|
1539
|
+
"const": "off"
|
|
1540
|
+
},
|
|
1541
|
+
{
|
|
1542
|
+
"type": "number",
|
|
1543
|
+
"const": 0
|
|
1544
|
+
},
|
|
1545
|
+
{
|
|
1546
|
+
"type": "number",
|
|
1547
|
+
"const": 1
|
|
1548
|
+
},
|
|
1549
|
+
{
|
|
1550
|
+
"type": "number",
|
|
1551
|
+
"const": 2
|
|
1552
|
+
},
|
|
1553
|
+
{
|
|
1554
|
+
"type": "boolean",
|
|
1555
|
+
"const": true
|
|
1556
|
+
}
|
|
1557
|
+
]
|
|
1522
1558
|
}
|
|
1523
1559
|
},
|
|
1524
1560
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vigiles",
|
|
3
|
-
"version": "32.0
|
|
3
|
+
"version": "32.1.0",
|
|
4
4
|
"description": "Audit, test and measure the harness your AI agent runs on — grade your CLAUDE.md / AGENTS.md, skills, subagents and hooks, run them against a scripted model, and measure whether they actually fire.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|