@event4u/agent-config 9.22.0 → 9.23.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hard-Gate linter: the skill-ecosystem roadmap family may not exceed its
|
|
4
|
+
* concurrency cap.
|
|
5
|
+
*
|
|
6
|
+
* ADR-215 § D2 states the cap is "mechanically enforced, not left to
|
|
7
|
+
* discipline", and gives the reason: a guideline a single maintainer intends to
|
|
8
|
+
* honour drifts under urgency. Until this file existed the record asserted an
|
|
9
|
+
* enforcement that did not exist and pointed at an implementation step that was
|
|
10
|
+
* never written — a claim-without-resolution in the one record governing the
|
|
11
|
+
* only surviving restraint mechanism. This gate makes the claim true.
|
|
12
|
+
*
|
|
13
|
+
* The cap counts roadmaps in the family that sit at the TOP LEVEL of
|
|
14
|
+
* `agents/roadmaps/` — i.e. active work. `archive/`, `later/`, `skipped/` and
|
|
15
|
+
* `stubs/` are by definition not concurrently open and are not counted.
|
|
16
|
+
*
|
|
17
|
+
* Widening the cap to cover more families (see ADR-216 § D3, which records the
|
|
18
|
+
* scope limitation honestly rather than pretending the cap manages total
|
|
19
|
+
* capacity) is a one-line change to FAMILY_PREFIX plus a new decision record —
|
|
20
|
+
* never a silent edit to CAP.
|
|
21
|
+
*
|
|
22
|
+
* CLI contract: exit 0 = at or under the cap, 1 = over the cap.
|
|
23
|
+
* `--quiet` suppresses the green line, matching the sibling roadmap gates.
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
30
|
+
|
|
31
|
+
/** Mirror the sibling gates: bare argv membership, computed at import. */
|
|
32
|
+
const QUIET = process.argv.slice(2).includes('--quiet');
|
|
33
|
+
|
|
34
|
+
const ROADMAP_DIR = path.join('agents', 'roadmaps');
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The capped family and its ceiling, per ADR-215 § D2 as amended by ADR-216 § D3.
|
|
38
|
+
* Both are named constants so a future widening is a visible one-line diff
|
|
39
|
+
* accompanied by a decision record, not an unnoticed threshold edit.
|
|
40
|
+
*/
|
|
41
|
+
const FAMILY_PREFIX = 'road-to-skill-ecosystem-';
|
|
42
|
+
const CAP = 2;
|
|
43
|
+
|
|
44
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
45
|
+
|
|
46
|
+
/** `Path.is_dir()` following symlinks, never throwing. */
|
|
47
|
+
function _isDir(p: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
return fs.statSync(p).isDirectory();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Walk up from CWD until a dir containing `agents/roadmaps` is found. */
|
|
56
|
+
function _repo_root(): string {
|
|
57
|
+
let cur = process.cwd();
|
|
58
|
+
const chain = [cur];
|
|
59
|
+
for (;;) {
|
|
60
|
+
const parent = path.dirname(cur);
|
|
61
|
+
if (parent === cur) break;
|
|
62
|
+
chain.push(parent);
|
|
63
|
+
cur = parent;
|
|
64
|
+
}
|
|
65
|
+
for (const candidate of chain) {
|
|
66
|
+
if (_isDir(path.join(candidate, ROADMAP_DIR))) {
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return process.cwd();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every `*.md` directly under `agents/roadmaps/` — top level only, sorted.
|
|
75
|
+
* Subdirectories are deliberately NOT walked: a roadmap in `later/` or
|
|
76
|
+
* `archive/` is not concurrently open, which is the whole point of the cap.
|
|
77
|
+
*/
|
|
78
|
+
export function active_roadmaps(root: string): string[] {
|
|
79
|
+
const base = path.join(root, ROADMAP_DIR);
|
|
80
|
+
if (!_isDir(base)) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
let entries: fs.Dirent[];
|
|
84
|
+
try {
|
|
85
|
+
entries = fs.readdirSync(base, { withFileTypes: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
return entries
|
|
90
|
+
.filter((e) => !e.isDirectory() && e.name.endsWith('.md'))
|
|
91
|
+
.map((e) => e.name)
|
|
92
|
+
.sort();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The subset of active roadmaps belonging to the capped family. */
|
|
96
|
+
export function family_members(root: string): string[] {
|
|
97
|
+
return active_roadmaps(root).filter((n) => n.startsWith(FAMILY_PREFIX));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function main(): number {
|
|
101
|
+
const root = _repo_root();
|
|
102
|
+
|
|
103
|
+
// Anti-vacuity: this gate reports an ABSENCE (no over-cap condition), so a
|
|
104
|
+
// dead scan root would print the same green line as a healthy tree. The
|
|
105
|
+
// scanned unit is every ACTIVE roadmap, not just the family — a repo whose
|
|
106
|
+
// top-level roadmap dir is empty or moved is a dead scope, not a pass.
|
|
107
|
+
// This is the invariant road-to-skill-ecosystem-gate-integrity generalises;
|
|
108
|
+
// the gate that enforces that roadmap's own cap had better honour it.
|
|
109
|
+
try {
|
|
110
|
+
assertScanned({
|
|
111
|
+
gate: 'lint_roadmap_family_cap',
|
|
112
|
+
scanned: active_roadmaps(root).length,
|
|
113
|
+
units: 'active roadmap file(s)',
|
|
114
|
+
roots: [ROADMAP_DIR],
|
|
115
|
+
});
|
|
116
|
+
} catch (exc) {
|
|
117
|
+
if (exc instanceof DeadScopeError) {
|
|
118
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
throw exc;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const members = family_members(root);
|
|
125
|
+
|
|
126
|
+
if (members.length <= CAP) {
|
|
127
|
+
if (!QUIET) {
|
|
128
|
+
process.stdout.write(
|
|
129
|
+
`✅ lint-roadmap-family-cap: ${members.length}/${CAP} slot(s) used ` +
|
|
130
|
+
`(scanned ${active_roadmaps(root).length} active roadmap file(s)).\n`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
process.stdout.write(
|
|
137
|
+
`❌ lint-roadmap-family-cap: ${members.length} concurrently-open ` +
|
|
138
|
+
`\`${FAMILY_PREFIX}*\` roadmap(s), cap is ${CAP}:\n`,
|
|
139
|
+
);
|
|
140
|
+
for (const name of members) {
|
|
141
|
+
process.stdout.write(` agents/roadmaps/${name}\n`);
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write('\n');
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
' The cap is a capacity mechanism, not an adoption gate — one maintainer\n',
|
|
146
|
+
);
|
|
147
|
+
process.stdout.write(
|
|
148
|
+
' cannot hold this many parallel workstreams (ADR-215 § D2, ADR-216 § D3).\n',
|
|
149
|
+
);
|
|
150
|
+
process.stdout.write(' To open another, free a slot first:\n');
|
|
151
|
+
process.stdout.write(
|
|
152
|
+
' • archive a finished one — git mv it into agents/roadmaps/archive/, or\n',
|
|
153
|
+
);
|
|
154
|
+
process.stdout.write(
|
|
155
|
+
' • park one — git mv it into agents/roadmaps/later/ with `status: later`\n',
|
|
156
|
+
);
|
|
157
|
+
process.stdout.write(' and a reachable resume condition, or\n');
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
' • widen the cap deliberately — edit CAP in this file AND record the\n',
|
|
160
|
+
);
|
|
161
|
+
process.stdout.write(
|
|
162
|
+
' decision. A silent threshold edit is the failure this gate exists to stop.\n',
|
|
163
|
+
);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _isCliEntry(): boolean {
|
|
168
|
+
if (process.argv[1] === undefined) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
172
|
+
if (import.meta.url === argvUrl) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
// A symlinked invocation (installed projection, or macOS /var → /private/var
|
|
176
|
+
// temp dirs) makes the raw URLs differ: compare realpaths so the entry guard
|
|
177
|
+
// still fires instead of silently no-opping.
|
|
178
|
+
try {
|
|
179
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
180
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
181
|
+
return here === argv;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
188
|
+
process.exitCode = main();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { CAP, FAMILY_PREFIX, QUIET };
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Settings A/B/C class linter — every template leaf carries exactly one class.
|
|
4
|
+
*
|
|
5
|
+
* Phase 1 of `road-to-zero-ceremony-settings`. The package is growing an
|
|
6
|
+
* agent-writable settings path (`settings set`); the A/B/C taxonomy in
|
|
7
|
+
* `docs/contracts/settings-classes.md` is the fence that keeps a spend ceiling,
|
|
8
|
+
* an allowlist, or a gate switch off that path. A key with no class is a key
|
|
9
|
+
* with no fence, so this gate refuses the build until every leaf has a row.
|
|
10
|
+
*
|
|
11
|
+
* Checks, in order:
|
|
12
|
+
* 1. every template leaf has exactly one row in the contract table;
|
|
13
|
+
* 2. every contract row names a key the template actually has;
|
|
14
|
+
* 3. every row's class is one of A | B | C;
|
|
15
|
+
* 4. every B row's template default is a conservative value
|
|
16
|
+
* (`false` / `""` / `0` / `[]` / `null` / `{}`) — the invariant that makes
|
|
17
|
+
* "absent" and "declined" the same outcome in a sparse settings file;
|
|
18
|
+
* 5. the contract's own Counts table matches the tallies computed here.
|
|
19
|
+
*
|
|
20
|
+
* **Landed at error, not advisory.** The authoring guideline's advisory-first
|
|
21
|
+
* rule exists so a gate cannot be wired to error over a corpus whose hits are
|
|
22
|
+
* unfixable. That does not apply here: the gate was run against the real
|
|
23
|
+
* template in the same change, all 140 leaves were classified, and the finding
|
|
24
|
+
* count reached zero before it was wired. There is therefore no
|
|
25
|
+
* `gate-violation-baselines.json` entry — zero is the standing requirement, not
|
|
26
|
+
* a floor a ratchet walks down to.
|
|
27
|
+
*
|
|
28
|
+
* **Gaming risk.** The cheap degenerate pass is class inflation in the *other*
|
|
29
|
+
* direction from the one people expect: marking every key `C` makes the gate
|
|
30
|
+
* green while making the writer useless, and nothing here can tell a
|
|
31
|
+
* conscientious C from a lazy one. Mitigation: the contract states eight
|
|
32
|
+
* explicit tests for C (the eighth, audit-and-observability, is the one a lazy
|
|
33
|
+
* blanket-C most often hides behind) and names `commands.suggestion.enabled` as the worked
|
|
34
|
+
* A-not-C counter-example, so a blanket-C diff reads as wrong to a reviewer;
|
|
35
|
+
* check 4 additionally makes the B class mechanically falsifiable rather than
|
|
36
|
+
* decorative. Residual: **the A/B/C judgement itself is not machine-checkable.**
|
|
37
|
+
* This gate proves the table is *complete and consistent with the template*; it
|
|
38
|
+
* cannot prove a row's class is *correct*. The C list was council-reviewed once
|
|
39
|
+
* (Phase 1 step 4) precisely because completeness is checkable and correctness
|
|
40
|
+
* is not.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as fs from 'node:fs';
|
|
44
|
+
import * as os from 'node:os';
|
|
45
|
+
import * as path from 'node:path';
|
|
46
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
|
+
|
|
48
|
+
import { load as parseYaml } from 'js-yaml';
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
getSettingsLeaf,
|
|
52
|
+
isConservativeDefault,
|
|
53
|
+
isSettingsClass,
|
|
54
|
+
parseDeclaredClassCounts,
|
|
55
|
+
parseSettingsClassRows,
|
|
56
|
+
SETTINGS_CLASSES,
|
|
57
|
+
type SettingsClass,
|
|
58
|
+
type SettingsClassRow,
|
|
59
|
+
settingsLeafPaths,
|
|
60
|
+
} from '../shared/settingsClasses.js';
|
|
61
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
62
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
63
|
+
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
64
|
+
|
|
65
|
+
const GATE = 'lint_settings_classes';
|
|
66
|
+
|
|
67
|
+
/** Mirrors the sibling gates: a bare argv membership check, computed at import. */
|
|
68
|
+
const QUIET = process.argv.slice(2).includes('--quiet');
|
|
69
|
+
|
|
70
|
+
const _HERE = path.resolve(fileURLToPath(import.meta.url));
|
|
71
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
72
|
+
|
|
73
|
+
const TEMPLATE_RELATIVE = 'src/config/agent-settings.template.yml';
|
|
74
|
+
const CONTRACT_RELATIVE = 'docs/contracts/settings-classes.md';
|
|
75
|
+
|
|
76
|
+
type Yaml = string | number | boolean | null | Yaml[] | { [k: string]: Yaml };
|
|
77
|
+
|
|
78
|
+
function _rootOverride(argv: readonly string[]): string | null {
|
|
79
|
+
const i = argv.indexOf('--root');
|
|
80
|
+
const value = i === -1 ? undefined : argv[i + 1];
|
|
81
|
+
return value === undefined ? null : path.resolve(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _readFile(p: string): string | null {
|
|
85
|
+
try {
|
|
86
|
+
return fs.readFileSync(p, 'utf-8');
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Prove, against the real CLI, that this gate still rejects what it must.
|
|
94
|
+
*
|
|
95
|
+
* The accept case seeds a two-key template with a matching contract; each
|
|
96
|
+
* reject case removes exactly one property of that fixture, so a pass proves
|
|
97
|
+
* the specific check and not merely that the fixture is broken.
|
|
98
|
+
*/
|
|
99
|
+
export function selfTest(): number {
|
|
100
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lsc-selftest-'));
|
|
101
|
+
const seed = (name: string, template: string | null, contract: string | null): string => {
|
|
102
|
+
const root = path.join(tmp, name);
|
|
103
|
+
fs.mkdirSync(path.join(root, 'src', 'config'), { recursive: true });
|
|
104
|
+
fs.mkdirSync(path.join(root, 'docs', 'contracts'), { recursive: true });
|
|
105
|
+
if (template !== null) {
|
|
106
|
+
fs.writeFileSync(path.join(root, TEMPLATE_RELATIVE), template, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
if (contract !== null) {
|
|
109
|
+
fs.writeFileSync(path.join(root, CONTRACT_RELATIVE), contract, 'utf-8');
|
|
110
|
+
}
|
|
111
|
+
return root;
|
|
112
|
+
};
|
|
113
|
+
const run = (cwd: string): number => runGateCli(REPO_ROOT, `src/scripts/${GATE}.ts`, ['--quiet', '--root', cwd], REPO_ROOT);
|
|
114
|
+
|
|
115
|
+
const template = 'alpha:\n one: false\nbeta: "keep"\n';
|
|
116
|
+
const counts = (a: number, b: number, c: number, total: number): string =>
|
|
117
|
+
`\n## Counts\n\n| Class | Keys |\n|---|---|\n| A — preference | ${String(a)} |\n` +
|
|
118
|
+
`| B — consent | ${String(b)} |\n| C — guarded | ${String(c)} |\n` +
|
|
119
|
+
`| **Total** | **${String(total)}** |\n`;
|
|
120
|
+
const table = (rows: string): string => `# Fixture\n${counts(0, 1, 1, 2)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n${rows}`;
|
|
121
|
+
|
|
122
|
+
const clean = table('| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n');
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return runSelfTest({
|
|
126
|
+
gate: GATE,
|
|
127
|
+
minCases: 6,
|
|
128
|
+
minRejectCases: 5,
|
|
129
|
+
cases: [
|
|
130
|
+
{
|
|
131
|
+
name: 'a template whose every leaf is classified passes',
|
|
132
|
+
expect: 'accept',
|
|
133
|
+
run: () => run(seed('clean', template, clean)),
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'an unclassified template leaf is rejected',
|
|
137
|
+
expect: 'reject',
|
|
138
|
+
run: () =>
|
|
139
|
+
run(
|
|
140
|
+
seed(
|
|
141
|
+
'unclassified',
|
|
142
|
+
template,
|
|
143
|
+
table('| `beta` | C | `"keep"` | fixture |\n') + counts(0, 0, 1, 1),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'a contract row naming a key the template lost is rejected',
|
|
149
|
+
expect: 'reject',
|
|
150
|
+
run: () =>
|
|
151
|
+
run(
|
|
152
|
+
seed(
|
|
153
|
+
'stale-row',
|
|
154
|
+
template,
|
|
155
|
+
table(
|
|
156
|
+
'| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n' +
|
|
157
|
+
'| `gamma.gone` | A | `1` | fixture |\n',
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'a class outside A|B|C is rejected',
|
|
164
|
+
expect: 'reject',
|
|
165
|
+
run: () =>
|
|
166
|
+
run(
|
|
167
|
+
seed(
|
|
168
|
+
'bad-class',
|
|
169
|
+
template,
|
|
170
|
+
table('| `alpha.one` | D | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n'),
|
|
171
|
+
),
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'a B key whose default is permissive is rejected',
|
|
176
|
+
expect: 'reject',
|
|
177
|
+
run: () =>
|
|
178
|
+
run(
|
|
179
|
+
seed(
|
|
180
|
+
'permissive-b',
|
|
181
|
+
'alpha:\n one: true\nbeta: "keep"\n',
|
|
182
|
+
clean,
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'a Counts table that disagrees with the rows is rejected',
|
|
188
|
+
expect: 'reject',
|
|
189
|
+
run: () =>
|
|
190
|
+
run(
|
|
191
|
+
seed(
|
|
192
|
+
'bad-counts',
|
|
193
|
+
template,
|
|
194
|
+
`# Fixture\n${counts(9, 9, 9, 27)}\n## The table\n\n| Key | Class | Default | Why |\n|---|---|---|---|\n` +
|
|
195
|
+
'| `alpha.one` | B | `false` | fixture |\n| `beta` | C | `"keep"` | fixture |\n',
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'a missing template — a dead scan root — is rejected, not passed as "nothing to check"',
|
|
201
|
+
expect: 'reject',
|
|
202
|
+
run: () => run(seed('dead-root', null, clean)),
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
});
|
|
206
|
+
} finally {
|
|
207
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function main(): number {
|
|
212
|
+
if (process.argv.slice(2).includes('--self-test')) {
|
|
213
|
+
return selfTest();
|
|
214
|
+
}
|
|
215
|
+
const root = _rootOverride(process.argv.slice(2)) ?? REPO_ROOT;
|
|
216
|
+
const templatePath = path.join(root, TEMPLATE_RELATIVE);
|
|
217
|
+
const contractPath = path.join(root, CONTRACT_RELATIVE);
|
|
218
|
+
|
|
219
|
+
const templateText = _readFile(templatePath);
|
|
220
|
+
if (templateText === null) {
|
|
221
|
+
process.stderr.write(`❌ ${GATE}: settings template not found at ${TEMPLATE_RELATIVE} — the scan root is dead.\n`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
const contractText = _readFile(contractPath);
|
|
225
|
+
if (contractText === null) {
|
|
226
|
+
process.stderr.write(`❌ ${GATE}: class contract not found at ${CONTRACT_RELATIVE} — every key is unclassified.\n`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let parsed: Yaml;
|
|
231
|
+
try {
|
|
232
|
+
parsed = parseYaml(templateText) as Yaml;
|
|
233
|
+
} catch (e) {
|
|
234
|
+
process.stderr.write(`❌ ${GATE}: ${TEMPLATE_RELATIVE} did not parse: ${String(e)}\n`);
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
238
|
+
process.stderr.write(`❌ ${GATE}: ${TEMPLATE_RELATIVE} did not parse to a map.\n`);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const leaves = settingsLeafPaths(parsed);
|
|
243
|
+
try {
|
|
244
|
+
// Publishes the number it just asserted, so the coverage guard reads the
|
|
245
|
+
// judged denominator rather than an enumerated one.
|
|
246
|
+
reportScanned({ gate: GATE, scanned: leaves.length, units: 'settings leaf key(s)', roots: [TEMPLATE_RELATIVE] });
|
|
247
|
+
} catch (e) {
|
|
248
|
+
if (e instanceof DeadScopeError) {
|
|
249
|
+
process.stderr.write(`❌ ${e.message}\n`);
|
|
250
|
+
return 1;
|
|
251
|
+
}
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const rows = parseSettingsClassRows(contractText);
|
|
256
|
+
const rowByKey = new Map<string, SettingsClassRow>();
|
|
257
|
+
const findings: string[] = [];
|
|
258
|
+
|
|
259
|
+
// The ledger plans one target per template leaf plus one per contract row,
|
|
260
|
+
// so a stale row is accounted work rather than an invisible extra.
|
|
261
|
+
const ledger = new GateLedger(GATE);
|
|
262
|
+
ledger.plan(leaves.map((k) => `key:${k}`));
|
|
263
|
+
ledger.plan(rows.map((r, i) => `row:${String(i)}:${r.key}`));
|
|
264
|
+
|
|
265
|
+
const leafSet = new Set(leaves);
|
|
266
|
+
for (const [i, row] of rows.entries()) {
|
|
267
|
+
const target = `row:${String(i)}:${row.key}`;
|
|
268
|
+
const duplicate = rowByKey.get(row.key);
|
|
269
|
+
if (duplicate !== undefined) {
|
|
270
|
+
const finding =
|
|
271
|
+
`${CONTRACT_RELATIVE}:${String(row.line)} duplicate row for \`${row.key}\` ` +
|
|
272
|
+
`(first at line ${String(duplicate.line)}) — one key, one class.`;
|
|
273
|
+
findings.push(finding);
|
|
274
|
+
ledger.fail(target, finding);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
rowByKey.set(row.key, row);
|
|
278
|
+
if (!isSettingsClass(row.cls)) {
|
|
279
|
+
const finding =
|
|
280
|
+
`${CONTRACT_RELATIVE}:${String(row.line)} \`${row.key}\` has class '${row.cls}' — ` +
|
|
281
|
+
`allowed: ${SETTINGS_CLASSES.join(' | ')}.`;
|
|
282
|
+
findings.push(finding);
|
|
283
|
+
ledger.fail(target, finding);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (!leafSet.has(row.key)) {
|
|
287
|
+
// Resolved HERE rather than in a later pass. A stale row that
|
|
288
|
+
// `complete`s and then produces a finding downstream is counted as
|
|
289
|
+
// satisfied by the ledger while the gate exits 1 — a completeness
|
|
290
|
+
// report that covers less than it appears to, which is the exact
|
|
291
|
+
// shape the ledger exists to make impossible.
|
|
292
|
+
const finding =
|
|
293
|
+
`${CONTRACT_RELATIVE}:${String(row.line)} \`${row.key}\` is not a leaf in ` +
|
|
294
|
+
`${TEMPLATE_RELATIVE} — the row is stale; delete it or restore the key.`;
|
|
295
|
+
findings.push(finding);
|
|
296
|
+
ledger.fail(target, finding);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
ledger.complete(target);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
const tally: Record<SettingsClass, number> = { A: 0, B: 0, C: 0 };
|
|
304
|
+
for (const leaf of leaves) {
|
|
305
|
+
const target = `key:${leaf}`;
|
|
306
|
+
const row = rowByKey.get(leaf);
|
|
307
|
+
if (row === undefined) {
|
|
308
|
+
const finding =
|
|
309
|
+
`${TEMPLATE_RELATIVE} \`${leaf}\` has no class — add a row to ${CONTRACT_RELATIVE}. ` +
|
|
310
|
+
'An unclassified key reaches the settings writer with no fence.';
|
|
311
|
+
findings.push(finding);
|
|
312
|
+
ledger.fail(target, finding);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (!isSettingsClass(row.cls)) {
|
|
316
|
+
// Already reported against the row; do not double-count the key.
|
|
317
|
+
ledger.outOfScope(target, 'declared_exemption');
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const cls: SettingsClass = row.cls;
|
|
321
|
+
tally[cls] += 1;
|
|
322
|
+
if (cls === 'B' && !isConservativeDefault(getSettingsLeaf(parsed, leaf))) {
|
|
323
|
+
const finding =
|
|
324
|
+
`${CONTRACT_RELATIVE}:${String(row.line)} \`${leaf}\` is class B but its template default is ` +
|
|
325
|
+
'permissive — a B key must default to the conservative value, or "never asked" and ' +
|
|
326
|
+
'"answered yes" become the same state in a sparse file.';
|
|
327
|
+
findings.push(finding);
|
|
328
|
+
ledger.fail(target, finding);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
ledger.complete(target);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const declared = parseDeclaredClassCounts(contractText);
|
|
335
|
+
const expected: Array<[string, number | null, number]> = [
|
|
336
|
+
['A', declared.A, tally.A],
|
|
337
|
+
['B', declared.B, tally.B],
|
|
338
|
+
['C', declared.C, tally.C],
|
|
339
|
+
['Total', declared.total, leaves.length],
|
|
340
|
+
];
|
|
341
|
+
for (const [label, stated, actual] of expected) {
|
|
342
|
+
if (stated === null) {
|
|
343
|
+
findings.push(
|
|
344
|
+
`${CONTRACT_RELATIVE} the Counts table has no '${label}' row — a published count with no ` +
|
|
345
|
+
'row is a count nothing can check.',
|
|
346
|
+
);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (stated !== actual) {
|
|
350
|
+
findings.push(
|
|
351
|
+
`${CONTRACT_RELATIVE} Counts says ${label} = ${String(stated)}, the table holds ` +
|
|
352
|
+
`${String(actual)} — a derived number beside a mechanism that can compute it.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
ledger.report();
|
|
358
|
+
|
|
359
|
+
if (findings.length > 0) {
|
|
360
|
+
process.stderr.write('\n');
|
|
361
|
+
for (const f of findings) {
|
|
362
|
+
process.stderr.write(`❌ ${f}\n`);
|
|
363
|
+
}
|
|
364
|
+
process.stderr.write(`\n❌ ${GATE}: ${String(findings.length)} finding(s)\n`);
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
if (!QUIET) {
|
|
368
|
+
process.stdout.write(
|
|
369
|
+
`✅ ${GATE}: ${String(leaves.length)} settings key(s) classified — ` +
|
|
370
|
+
`A=${String(tally.A)} B=${String(tally.B)} C=${String(tally.C)}\n`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function _isCliEntry(): boolean {
|
|
377
|
+
if (process.argv[1] === undefined) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
381
|
+
if (import.meta.url === argvUrl) {
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
386
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
387
|
+
return here === argv;
|
|
388
|
+
} catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
394
|
+
process.exit(main());
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export { CONTRACT_RELATIVE, GATE, REPO_ROOT, TEMPLATE_RELATIVE, main };
|