codictator 0.1.2 → 0.1.3

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.
Files changed (54) hide show
  1. package/dist/builtin-packs/core/fixtures/conventional-commit/passing/basic/case.json +1 -1
  2. package/dist/builtin-packs/core/fixtures/conventional-commit/violating/basic/case.json +1 -1
  3. package/dist/builtin-packs/typescript/fixtures/lint/passing/exported/case.json +8 -0
  4. package/dist/builtin-packs/typescript/pack.json +1 -1
  5. package/dist/cli/gate-scope.d.ts +1 -0
  6. package/dist/cli/gate-scope.js +11 -0
  7. package/dist/cli/git-hook.js +13 -8
  8. package/dist/cli/plan-launcher.d.ts +1 -0
  9. package/dist/cli/plan-launcher.js +53 -3
  10. package/dist/cli/task-advance.js +16 -16
  11. package/dist/features/adapters/claude-shaped.d.ts +1 -0
  12. package/dist/features/adapters/claude-shaped.js +29 -31
  13. package/dist/features/adapters/copilot.js +22 -29
  14. package/dist/features/audit/append.js +9 -4
  15. package/dist/features/audit/surfaces.js +15 -8
  16. package/dist/features/ci/protection.js +37 -13
  17. package/dist/features/deps/npm.js +39 -24
  18. package/dist/features/deps/registry.js +45 -34
  19. package/dist/features/deps/stable.js +52 -27
  20. package/dist/features/deps/upgrade.d.ts +1 -1
  21. package/dist/features/deps/upgrade.js +29 -19
  22. package/dist/features/engine/evaluate.js +6 -1
  23. package/dist/features/packs/enforcers/ci-guards.js +4 -1
  24. package/dist/features/packs/enforcers/code.js +2 -1
  25. package/dist/features/packs/enforcers/engine-version-guards.d.ts +6 -0
  26. package/dist/features/packs/enforcers/engine-version-guards.js +42 -0
  27. package/dist/features/packs/enforcers/external-bind.js +62 -28
  28. package/dist/features/packs/enforcers/external-sarif.js +30 -24
  29. package/dist/features/packs/enforcers/gate-guards.d.ts +0 -1
  30. package/dist/features/packs/enforcers/gate-guards.js +83 -136
  31. package/dist/features/packs/enforcers/health-measures.d.ts +2 -0
  32. package/dist/features/packs/enforcers/health-measures.js +10 -1
  33. package/dist/features/packs/enforcers/health-refs.d.ts +1 -2
  34. package/dist/features/packs/enforcers/health-refs.js +71 -56
  35. package/dist/features/packs/enforcers/secret-scan.d.ts +4 -20
  36. package/dist/features/packs/enforcers/secret-scan.js +61 -67
  37. package/dist/features/packs/enforcers/ts-checks.js +104 -58
  38. package/dist/features/packs/enforcers/ts-security-patterns.js +12 -8
  39. package/dist/features/packs/manifest-decl.d.ts +13 -0
  40. package/dist/features/packs/manifest-decl.js +121 -0
  41. package/dist/features/packs/manifest.d.ts +2 -12
  42. package/dist/features/packs/manifest.js +5 -90
  43. package/dist/features/packs/selftest.js +22 -5
  44. package/dist/features/remediation/adoption.js +40 -29
  45. package/dist/features/testing/scenarios.js +49 -27
  46. package/dist/features/testing/tap-parser.js +38 -26
  47. package/dist/features/workflow/phases.js +45 -42
  48. package/dist/features/workflow/review.js +17 -10
  49. package/dist/kernel/git.js +4 -1
  50. package/dist/kernel/pattern.js +120 -86
  51. package/dist/kernel/secret-paths.js +34 -20
  52. package/dist/kernel/shell-cmd.js +23 -6
  53. package/dist/tsconfig.build.tsbuildinfo +1 -1
  54. package/package.json +1 -1
@@ -5,6 +5,6 @@
5
5
  "trigger": "commit subject with no type prefix",
6
6
  "expectRules": [],
7
7
  "files": {
8
- "commit.txt": "subject: fix(core): drop dead export\n"
8
+ "sample.txt": "subject: fix(core): drop dead export\n"
9
9
  }
10
10
  }
@@ -5,6 +5,6 @@
5
5
  "trigger": "commit subject with no type prefix",
6
6
  "expectRules": ["core/conventional-commit"],
7
7
  "files": {
8
- "commit.txt": "subject: fixed stuff\n"
8
+ "sample.txt": "subject: fixed stuff\n"
9
9
  }
10
10
  }
@@ -0,0 +1,8 @@
1
+ {
2
+ "expectation": "passing",
3
+ "expectRules": [],
4
+ "files": { "sample.ts": "export const MS_PER_DAY = 86_400_000;\n" },
5
+ "rule": "ts/lint",
6
+ "section": "§6.1",
7
+ "trigger": "exported binding used cross-file, not in its own file"
8
+ }
@@ -31,7 +31,7 @@
31
31
  "params": {},
32
32
  "ratchet": false,
33
33
  "measureVersion": 1,
34
- "fixtures": { "violating": ["basic"], "passing": ["basic"] }
34
+ "fixtures": { "violating": ["basic"], "passing": ["basic", "exported"] }
35
35
  },
36
36
  {
37
37
  "id": "ts/no-phantom-import",
@@ -7,4 +7,5 @@ export interface GateScope {
7
7
  readonly note: string;
8
8
  readonly proposedContent?: string;
9
9
  }
10
+ export declare function addedLines(diff: string): string;
10
11
  export declare function evaluateGateScope(ports: CommandPorts, packIds: ReadonlyArray<string>, scope: GateScope, now: string, configHash: string): Promise<Result<Array<Finding>>>;
@@ -51,6 +51,17 @@ function mergeGateFindings(first, second) {
51
51
  }
52
52
  return merged.sort(compareGateFindings);
53
53
  }
54
+ // BUG-0022 + SPEC §4.7.1: the L3 gate judges added lines only, so a commit
55
+ // deleting a flagged literal passes while staging one still blocks. A diff
56
+ // carries its removed lines as `-` rows; scanning the raw diff would match
57
+ // those and make the removal uncommittable.
58
+ export function addedLines(diff) {
59
+ return diff
60
+ .split("\n")
61
+ .filter((line) => line.startsWith("+") && !line.startsWith("+++"))
62
+ .map((line) => line.slice(1))
63
+ .join("\n");
64
+ }
54
65
  // BUG-0014 + SPEC §4.3.2: the gate evaluation over a diff scope. The
55
66
  // pre-tool leg keeps today's fail-closed local verdict byte for byte; the
56
67
  // review leg adds the diff-sound review subset over the same files, so the
@@ -4,9 +4,10 @@
4
4
  // the change with the gate rule subset `verify` loads over staged scopes —
5
5
  // new scope plumbing, no new detection logic. commit-msg appends
6
6
  // the Codictator-L3 trailer §4.14.2 checks at L4.
7
+ import path from "node:path";
7
8
  import { ENGINE_VERSION, HOOK_MARKER } from "../features/config/index.js";
8
9
  import { err, makeError, ok, readHostText, writeHostText, } from "../kernel/index.js";
9
- import { evaluateGateScope } from "./gate-scope.js";
10
+ import { addedLines, evaluateGateScope } from "./gate-scope.js";
10
11
  import { printFinding } from "./inspect.js";
11
12
  import { commandEnv, fail, loadRepoConfig, MIN_NODE_MAJOR, needRoot, nodeMajor, nodeTooOldMessage, printHuman, printJson, reportError, usageError, } from "./shared.js";
12
13
  const GIT_TIMEOUT_MS = 30000;
@@ -53,7 +54,7 @@ async function evaluateGitChange(ports, req, hook, packIds, files, content, now,
53
54
  const evaluated = await evaluateGateScope(ports, packIds, {
54
55
  files: [...files],
55
56
  note: hook,
56
- ...(content === "" ? {} : { proposedContent: content }),
57
+ ...(content === "" ? {} : { proposedContent: addedLines(content) }),
57
58
  }, now, configHash);
58
59
  if (!evaluated.ok) {
59
60
  return reportError(evaluated.error, req.json);
@@ -77,16 +78,20 @@ async function runPreCommit(req, ports, packIds, now, configHash) {
77
78
  }
78
79
  return evaluateGitChange(ports, req, "pre-commit", packIds, staged.value, diff.value, now, configHash);
79
80
  }
80
- // SPEC §4.14.4: git hands commit-msg an absolute COMMIT_EDITMSG path, which
81
- // in a linked worktree lives under the main repo's .git/worktrees/<name>/
82
- // dir, outside the worktree root. The confined Fs port rejects absolute
83
- // paths (CDX-E701), so the hook uses kernel-owned host I/O; an unreadable
84
- // or unwritable message file blocks fail-closed.
81
+ // SPEC §4.14.4: git hands commit-msg the COMMIT_EDITMSG path, absolute in a
82
+ // linked worktree (under the main repo's .git/worktrees/<name>/ dir, outside
83
+ // the worktree root) but relative to the repo root (`.git/COMMIT_EDITMSG`)
84
+ // in the main worktree. The confined Fs port rejects absolute paths
85
+ // (CDX-E701), so the hook uses kernel-owned host I/O — which itself
86
+ // requires absolute paths — resolving a relative handoff against the
87
+ // invocation cwd first; an unreadable or unwritable message file blocks
88
+ // fail-closed.
85
89
  async function runCommitMsg(req, configHash) {
86
90
  if (req.args.length !== 2 || req.args[1] === undefined) {
87
91
  return usageError("usage: codictator git commit-msg <file>");
88
92
  }
89
- const file = req.args[1];
93
+ const raw = req.args[1];
94
+ const file = path.isAbsolute(raw) ? raw : path.resolve(req.cwd, raw);
90
95
  const trailer = `Codictator-L3: ${configHash.slice(0, 12)}`;
91
96
  const read = await readHostText(file);
92
97
  if (!read.ok) {
@@ -1 +1,2 @@
1
1
  export declare const LAUNCHER_TEXT: string;
2
+ export type LauncherText = typeof LAUNCHER_TEXT;
@@ -20,7 +20,11 @@ export const LAUNCHER_TEXT = [
20
20
  "// any newer syntax could load, and only node: built-ins are imported. It",
21
21
  "// sets CODICTATOR_HOOK=1 in the engine process, then resolves the engine at",
22
22
  "// <root>/node_modules/codictator/dist/cli/main.js else",
23
- "// <root>/.codictator/tool/node_modules/codictator/dist/cli/main.js. A",
23
+ "// <root>/.codictator/tool/node_modules/codictator/dist/cli/main.js. The",
24
+ "// launcher reads the resolved engine's manifest version and passes it",
25
+ "// as main's third argument, so hook validation sees the installed",
26
+ "// version (SPEC §4.2.1 EC-11); an unreadable manifest falls back to",
27
+ "// the two-argument call. A",
24
28
  "// missing engine answers the harness's embedded static CDX-E003 block",
25
29
  "// reply; outside hook it prints CDX-E003 and exits 6. Git hooks arrive",
26
30
  "// as a `git` first word and route to the engine like hook words do, so",
@@ -28,7 +32,7 @@ export const LAUNCHER_TEXT = [
28
32
  "// Version arbitration",
29
33
  "// between the root and tool-dir engines (SPEC §4.19 EC-6) belongs to the",
30
34
  "// phase 9 CLI: until then the first candidate that exists wins.",
31
- launcherImport("import { existsSync }", "fs"),
35
+ launcherImport("import { existsSync, readFileSync }", "fs"),
32
36
  launcherImport("import path", "path"),
33
37
  launcherImport("import { fileURLToPath, pathToFileURL }", "url"),
34
38
  "",
@@ -141,6 +145,48 @@ export const LAUNCHER_TEXT = [
141
145
  "\treturn null;",
142
146
  "}",
143
147
  "",
148
+ "// SPEC §4.2.1 EC-11: the engine entry validates config.codictator",
149
+ "// against the installed engine version, so the launcher extracts it",
150
+ "// from the resolved engine's own manifest text and passes it as",
151
+ "// main's third argument. A version-field scan keeps JSON parsing in",
152
+ "// the kernel per §10.3 item 6 (and a regex literal would confuse the",
153
+ "// blankCode lexer the ts/lint gate runs on); any unreadable or",
154
+ "// version-less manifest answers null and the caller falls back to",
155
+ "// the two-argument call, which keeps the pinned placeholder —",
156
+ "// fail-closed downstream, never an allow.",
157
+ "function installedEngineVersion(enginePath) {",
158
+ "\ttry {",
159
+ "\t\tconst manifestPath = path.join(",
160
+ "\t\t\tpath.dirname(path.dirname(path.dirname(enginePath))),",
161
+ `\t\t\t"package.json",`,
162
+ "\t\t);",
163
+ `\t\tconst text = readFileSync(manifestPath, "utf8");`,
164
+ '\t\tconst marker = "\\"version\\":";',
165
+ "\t\tconst at = text.indexOf(marker);",
166
+ "\t\tif (at === -1) {",
167
+ "\t\t\treturn null;",
168
+ "\t\t}",
169
+ "\t\tlet index = at + marker.length;",
170
+ '\t\twhile (text[index] === " " || text[index] === "\\t" || text[index] === "\\n" || text[index] === "\\r") {',
171
+ "\t\t\tindex += 1;",
172
+ "\t\t}",
173
+ '\t\tif (text[index] !== "\\"") {',
174
+ "\t\t\treturn null;",
175
+ "\t\t}",
176
+ '\t\tconst end = text.indexOf("\\"", index + 1);',
177
+ "\t\tif (end === -1) {",
178
+ "\t\t\treturn null;",
179
+ "\t\t}",
180
+ "\t\tconst version = text.slice(index + 1, end);",
181
+ '\t\tif (version === "") {',
182
+ "\t\t\treturn null;",
183
+ "\t\t}",
184
+ "\t\treturn version;",
185
+ "\t} catch (ignored) {",
186
+ "\t\treturn null;",
187
+ "\t}",
188
+ "}",
189
+ "",
144
190
  "function reportMissing(harness) {",
145
191
  "\tconst missing = staticReply(",
146
192
  "\t\tharness,",
@@ -175,7 +221,11 @@ export const LAUNCHER_TEXT = [
175
221
  `\t\t\t\tif (engine === null || typeof engine.main !== "function") {`,
176
222
  `\t\t\t\t\tthrow new Error("engine entry has no main");`,
177
223
  "\t\t\t\t}",
178
- "\t\t\t\treturn engine.main(args, process.cwd());",
224
+ "\t\t\t\tconst version = installedEngineVersion(enginePath);",
225
+ "\t\t\t\tif (version === null) {",
226
+ "\t\t\t\t\treturn engine.main(args, process.cwd());",
227
+ "\t\t\t\t}",
228
+ "\t\t\t\treturn engine.main(args, process.cwd(), version);",
179
229
  "\t\t\t},",
180
230
  "\t\t\t(error) => {",
181
231
  "\t\t\t\tthrow error;",
@@ -87,31 +87,31 @@ async function taskMutate(req, root, sub) {
87
87
  }
88
88
  return taskClose(req, root, id);
89
89
  }
90
- // SPEC §4.19.2 + §4.12: task lifecycle reads and writes; only ack is
91
- // human-only, and only it clears an escalation.
92
- export async function handleTask(req) {
93
- const root = await needTaskRoot(req);
94
- if (typeof root !== "string") {
95
- return root;
96
- }
97
- const sub = req.args[0] ?? "";
98
- if (sub === "new") {
99
- return taskNew(req, root);
100
- }
101
- if (sub === "show") {
102
- return taskShow(req, root);
103
- }
90
+ const SINGLE_SUBCOMMANDS = {
91
+ new: (req, root) => taskNew(req, root),
92
+ show: (req, root) => taskShow(req, root),
93
+ checkpoint: (req, root) => taskCheckpoint(req, root),
94
+ };
95
+ async function runTaskSub(req, root, sub) {
104
96
  if (sub === "phase" && req.args[1] === "next") {
105
97
  return taskPhaseNext(req, root);
106
98
  }
107
- if (sub === "checkpoint") {
108
- return taskCheckpoint(req, root);
99
+ const single = SINGLE_SUBCOMMANDS[sub];
100
+ if (single !== undefined) {
101
+ return single(req, root);
109
102
  }
110
103
  if (sub === "ack" || sub === "amend" || sub === "close") {
111
104
  return taskMutate(req, root, sub);
112
105
  }
113
106
  return usageError("usage: codictator task <new|show|amend|phase next|checkpoint|close|ack> ...");
114
107
  }
108
+ export async function handleTask(req) {
109
+ const root = await needTaskRoot(req);
110
+ if (typeof root !== "string") {
111
+ return root;
112
+ }
113
+ return runTaskSub(req, root, req.args[0] ?? "");
114
+ }
115
115
  async function taskShow(req, root) {
116
116
  const ids = await listTaskIds(root);
117
117
  if (req.args.length === 1) {
@@ -1,3 +1,4 @@
1
1
  import type { Result } from "../../kernel/index.ts";
2
2
  import type { HookEvent, ToolKind } from "./contract.ts";
3
+ export declare function readString(record: Record<string, unknown>, keys: ReadonlyArray<string>): string | undefined;
3
4
  export declare function parseClaudeShapedEvent(harness: string, event: string, raw: unknown, table: Readonly<Record<string, ToolKind>>): Result<HookEvent>;
@@ -4,10 +4,9 @@
4
4
  // leaks (§9.2).
5
5
  import { err, makeError, ok } from "../../kernel/index.js";
6
6
  import { normalizeToolName } from "./contract.js";
7
- function isRecord(value) {
8
- return typeof value === "object" && value !== null && !Array.isArray(value);
9
- }
10
- function readString(record, keys) {
7
+ // SPEC §4.5.3: first non-empty string under any of the keys. Shared with
8
+ // the sibling adapters, so the field readers never drift apart.
9
+ export function readString(record, keys) {
11
10
  for (const key of keys) {
12
11
  const value = record[key];
13
12
  if (typeof value === "string" && value.length > 0) {
@@ -16,6 +15,9 @@ function readString(record, keys) {
16
15
  }
17
16
  return undefined;
18
17
  }
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
19
21
  function readToolInput(raw) {
20
22
  for (const key of ["tool_input", "toolInput", "input", "args"]) {
21
23
  const value = raw[key];
@@ -42,34 +44,30 @@ function collectPaths(input) {
42
44
  }
43
45
  return [...found].sort();
44
46
  }
47
+ // SPEC §4.5.3: event names map to triggers through one table, so adding a
48
+ // harness alias is a row, not a branch.
49
+ const CLAUDE_EVENT_TABLE = {
50
+ PreToolUse: "pre-tool",
51
+ "tool.pre.write": "pre-tool",
52
+ "tool.pre.shell": "pre-tool",
53
+ "tool.pre.read": "pre-tool",
54
+ "tool.pre.search": "pre-tool",
55
+ "tool.pre.other": "pre-tool",
56
+ PostToolUse: "post-tool",
57
+ "tool.post.write": "post-tool",
58
+ Stop: "post-tool",
59
+ stop: "post-tool",
60
+ SessionStart: "startup",
61
+ "session.start": "startup",
62
+ SubagentStart: "startup",
63
+ "subagent.start": "startup",
64
+ UserPromptSubmit: "prompt",
65
+ "prompt.submit": "prompt",
66
+ PostCompact: "compact",
67
+ "compact.post": "compact",
68
+ };
45
69
  function mapClaudeEvent(event) {
46
- switch (event) {
47
- case "PreToolUse":
48
- case "tool.pre.write":
49
- case "tool.pre.shell":
50
- case "tool.pre.read":
51
- case "tool.pre.search":
52
- case "tool.pre.other":
53
- return "pre-tool";
54
- case "PostToolUse":
55
- case "tool.post.write":
56
- case "Stop":
57
- case "stop":
58
- return "post-tool";
59
- case "SessionStart":
60
- case "session.start":
61
- case "SubagentStart":
62
- case "subagent.start":
63
- return "startup";
64
- case "UserPromptSubmit":
65
- case "prompt.submit":
66
- return "prompt";
67
- case "PostCompact":
68
- case "compact.post":
69
- return "compact";
70
- default:
71
- return undefined;
72
- }
70
+ return CLAUDE_EVENT_TABLE[event];
73
71
  }
74
72
  function blockError(harness, detail) {
75
73
  return makeError("CDX-E303", `unparseable ${harness} hook payload: ${detail}`, "Check the harness hook configuration, then retry the tool call.");
@@ -1,4 +1,9 @@
1
+ // SPEC §4.5.4: the GitHub Copilot adapter (CLI, cloud agent, VS Code
2
+ // agent). Parse, reply and staticBlock are pure; hookIoVerified stays false
3
+ // until a first-party capture lands (§4.5.7), and the hooks-file schema
4
+ // below follows the SPEC row verbatim for the same reason.
1
5
  import { err, makeError, ok, stableStringify } from "../../kernel/index.js";
6
+ import { readString } from "./claude-shaped.js";
2
7
  import { normalizeToolName } from "./contract.js";
3
8
  import { COPILOT_CAPABILITIES } from "./copilot-capabilities.js";
4
9
  // SPEC §4.5.4 with §4.5.5: hook entries carry timeoutSec; the runtime budget
@@ -31,40 +36,28 @@ const COPILOT_TOOL_TABLE = {
31
36
  export function normalizeCopilotToolName(name) {
32
37
  return normalizeToolName(COPILOT_TOOL_TABLE, name);
33
38
  }
39
+ // SPEC §4.5.4: event names map to triggers through one table, so adding a
40
+ // harness alias is a row, not a branch.
41
+ const COPILOT_EVENT_TABLE = {
42
+ preToolUse: "pre-tool",
43
+ "tool.pre.write": "pre-tool",
44
+ "tool.pre.shell": "pre-tool",
45
+ "tool.pre.read": "pre-tool",
46
+ "tool.pre.search": "pre-tool",
47
+ "tool.pre.other": "pre-tool",
48
+ postToolUse: "post-tool",
49
+ "tool.post.write": "post-tool",
50
+ sessionStart: "startup",
51
+ "session.start": "startup",
52
+ userPromptSubmitted: "prompt",
53
+ "prompt.submit": "prompt",
54
+ };
34
55
  function mapCopilotEvent(event) {
35
- switch (event) {
36
- case "preToolUse":
37
- case "tool.pre.write":
38
- case "tool.pre.shell":
39
- case "tool.pre.read":
40
- case "tool.pre.search":
41
- case "tool.pre.other":
42
- return "pre-tool";
43
- case "postToolUse":
44
- case "tool.post.write":
45
- return "post-tool";
46
- case "sessionStart":
47
- case "session.start":
48
- return "startup";
49
- case "userPromptSubmitted":
50
- case "prompt.submit":
51
- return "prompt";
52
- default:
53
- return undefined;
54
- }
56
+ return COPILOT_EVENT_TABLE[event];
55
57
  }
56
58
  function isRecord(value) {
57
59
  return typeof value === "object" && value !== null && !Array.isArray(value);
58
60
  }
59
- function readString(record, keys) {
60
- for (const key of keys) {
61
- const value = record[key];
62
- if (typeof value === "string" && value.length > 0) {
63
- return value;
64
- }
65
- }
66
- return undefined;
67
- }
68
61
  function blockError(detail) {
69
62
  return makeError("CDX-E303", `unparseable copilot hook payload: ${detail}`, "Check the harness hook configuration, then retry the tool call.");
70
63
  }
@@ -87,18 +87,23 @@ function fitLine(event, now, id) {
87
87
  return err(e801("append event", "a single event without a message exceeds 64 KB"));
88
88
  }
89
89
  const empty = stableLineStringify(withOptionals(base, event, ""));
90
- let allowance = MAX_LINE_BYTES - byteLength(empty) - 2;
90
+ const allowance = MAX_LINE_BYTES - byteLength(empty) - 2;
91
+ return truncateToFit(base, event, message, allowance);
92
+ }
93
+ // Halve the budget until the marked message fits, else fail closed.
94
+ function truncateToFit(base, event, message, allowance) {
95
+ let budget = allowance;
91
96
  for (let round = 0; round < 32; round += 1) {
92
- if (allowance < 0) {
97
+ if (budget < 0) {
93
98
  return err(e801("append event", "the message cannot be truncated to fit 64 KB"));
94
99
  }
95
- const kept = truncateUtf8(message, allowance);
100
+ const kept = truncateUtf8(message, budget);
96
101
  const omitted = byteLength(message) - byteLength(kept);
97
102
  const candidate = stableLineStringify(withOptionals(base, event, `${kept}[truncated ${omitted} bytes]`));
98
103
  if (byteLength(candidate) <= MAX_LINE_BYTES) {
99
104
  return ok(candidate);
100
105
  }
101
- allowance = Math.floor(allowance / 2);
106
+ budget = Math.floor(budget / 2);
102
107
  }
103
108
  return err(e801("append event", "the message cannot be truncated to fit 64 KB"));
104
109
  }
@@ -27,6 +27,20 @@ function helpersLine(helpers) {
27
27
  });
28
28
  return `helpers: ${parts.join(", ")}`;
29
29
  }
30
+ // SPEC §4.18.3: trailing status rows with their fallbacks, so the screen
31
+ // builder stays a straight-line assembly.
32
+ function tailStatusLines(input) {
33
+ return [
34
+ `task: ${input.task ?? "none active"}`,
35
+ `waivers: ${input.waivers ?? "none in force"}`,
36
+ `ratchet: ${input.ratchet ?? "no baseline history"}`,
37
+ `deps: ${input.deps ?? "freshness unknown"}`,
38
+ `integrity: ${input.integrity ?? "unverified"}`,
39
+ `escalations: ${input.escalations ?? "none open"}`,
40
+ `remediation: ${input.remediation ?? "no plan"}`,
41
+ `events: ${input.events} observed, ${input.corrupt} corrupt${input.gap ? "; gap in coverage, log was deleted mid-session" : ""}`,
42
+ ];
43
+ }
30
44
  // SPEC §4.18.3: one screen with engine, packs, harness states, helper
31
45
  // tools, task, waivers, ratchet, deps, integrity, escalations and
32
46
  // remediation progress.
@@ -43,14 +57,7 @@ export function buildStatusText(input) {
43
57
  lines.push(`- ${harness.harness}: ${harness.state} (${harness.layers})`);
44
58
  }
45
59
  lines.push(helpersLine(input.helpers));
46
- lines.push(`task: ${input.task ?? "none active"}`);
47
- lines.push(`waivers: ${input.waivers ?? "none in force"}`);
48
- lines.push(`ratchet: ${input.ratchet ?? "no baseline history"}`);
49
- lines.push(`deps: ${input.deps ?? "freshness unknown"}`);
50
- lines.push(`integrity: ${input.integrity ?? "unverified"}`);
51
- lines.push(`escalations: ${input.escalations ?? "none open"}`);
52
- lines.push(`remediation: ${input.remediation ?? "no plan"}`);
53
- lines.push(`events: ${input.events} observed, ${input.corrupt} corrupt${input.gap ? "; gap in coverage, log was deleted mid-session" : ""}`);
60
+ lines.push(...tailStatusLines(input));
54
61
  return lines.join("\n");
55
62
  }
56
63
  // SPEC §4.18.3: the same status as data for the extension (--json).
@@ -13,25 +13,46 @@ function block(ruleId, message, remediation) {
13
13
  }
14
14
  // SPEC §4.15.2: reported not required → block; unreadable → advise (PRD
15
15
  // §11.1). The same read feeds core/branch-protection below.
16
- export function evaluateProtection(read) {
17
- if (read === undefined || !read.readable) {
18
- return [
19
- advise("core/ci-required-check", "cannot confirm the verify check is required: branch protection is unreadable", "Grant the workflow read access to rulesets/branch protection, or confirm the required check by hand (EC-1, EC-6)."),
20
- advise("core/branch-protection", "cannot confirm force-push, deletion and squash settings: branch protection is unreadable", "Grant the workflow read access to the repo settings and rulesets, or confirm them by hand (§4.15.2)."),
21
- ];
22
- }
23
- const findings = [];
16
+ function unreadableFindings() {
17
+ return [
18
+ advise("core/ci-required-check", "cannot confirm the verify check is required: branch protection is unreadable", "Grant the workflow read access to rulesets/branch protection, or confirm the required check by hand (EC-1, EC-6)."),
19
+ advise("core/branch-protection", "cannot confirm force-push, deletion and squash settings: branch protection is unreadable", "Grant the workflow read access to the repo settings and rulesets, or confirm them by hand (§4.15.2)."),
20
+ ];
21
+ }
22
+ function requiredCheckFinding(read) {
24
23
  if (read.verifyRequired !== true) {
25
- findings.push(block("core/ci-required-check", "the verify check is not a required check on the default branch", "Add verify to the required checks of the branch ruleset (§4.15 EC-7)."));
24
+ return block("core/ci-required-check", "the verify check is not a required check on the default branch", "Add verify to the required checks of the branch ruleset (§4.15 EC-7).");
26
25
  }
26
+ return undefined;
27
+ }
28
+ function squashFinding(read) {
27
29
  const squashExcluded = read.allowSquashMerge === false ||
28
30
  (read.rulesetMergeMethods !== undefined &&
29
31
  !read.rulesetMergeMethods.includes("squash"));
30
32
  if (!squashExcluded) {
31
- findings.push(block("core/branch-protection", "squash merging is allowed and no ruleset excludes it", "Set allow_squash_merge to false and exclude squash from the ruleset allowed_merge_methods (§4.9.3, EC-11, S33)."));
33
+ return block("core/branch-protection", "squash merging is allowed and no ruleset excludes it", "Set allow_squash_merge to false and exclude squash from the ruleset allowed_merge_methods (§4.9.3, EC-11, S33).");
32
34
  }
35
+ return undefined;
36
+ }
37
+ function hardeningFinding(read) {
33
38
  if (read.forcePushBlocked !== true || read.deletionBlocked !== true) {
34
- findings.push(block("core/branch-protection", "force-push or deletion is not blocked on the default branch", "Block force-pushes and deletions on the default branch in settings and rulesets (§4.15.2)."));
39
+ return block("core/branch-protection", "force-push or deletion is not blocked on the default branch", "Block force-pushes and deletions on the default branch in settings and rulesets (§4.15.2).");
40
+ }
41
+ return undefined;
42
+ }
43
+ export function evaluateProtection(read) {
44
+ if (read === undefined || !read.readable) {
45
+ return unreadableFindings();
46
+ }
47
+ const findings = [];
48
+ for (const maybe of [
49
+ requiredCheckFinding(read),
50
+ squashFinding(read),
51
+ hardeningFinding(read),
52
+ ]) {
53
+ if (maybe !== undefined) {
54
+ findings.push(maybe);
55
+ }
35
56
  }
36
57
  return findings;
37
58
  }
@@ -51,10 +72,13 @@ export function resolveBaseRef(input) {
51
72
  input.before !== zero) {
52
73
  return ok(input.before);
53
74
  }
75
+ return ok(baseFromDefault(input));
76
+ }
77
+ function baseFromDefault(input) {
54
78
  if (input.eventName === "push") {
55
- return ok(`merge-base(${input.defaultBranch ?? "main"},HEAD)`);
79
+ return `merge-base(${input.defaultBranch ?? "main"},HEAD)`;
56
80
  }
57
- return ok(input.pullBaseSha ?? `merge-base(${input.defaultBranch ?? "main"},HEAD)`);
81
+ return (input.pullBaseSha ?? `merge-base(${input.defaultBranch ?? "main"},HEAD)`);
58
82
  }
59
83
  function usesRef(uses) {
60
84
  const at = uses.indexOf("@");
@@ -17,10 +17,45 @@ function stripWorkspacePrefix(range) {
17
17
  }
18
18
  return undefined;
19
19
  }
20
+ function classifyWorkspaceRange(workspace) {
21
+ return EXACT_PIN.test(workspace) ? "workspace-exact" : "workspace-range";
22
+ }
23
+ function classifyAliasRange(range) {
24
+ const at = range.lastIndexOf("@");
25
+ const version = at < 4 ? "" : range.slice(at + 1);
26
+ return EXACT_PIN.test(version) ? "alias-exact" : "alias-range";
27
+ }
28
+ function isGitRange(range) {
29
+ return (range.startsWith("git+") ||
30
+ range.startsWith("git://") ||
31
+ range.startsWith("github:"));
32
+ }
33
+ function classifyGitRange(range) {
34
+ const hash = range.split("#")[1] ?? "";
35
+ return FULL_SHA.test(hash) ? "git-sha" : "git-unpinned";
36
+ }
37
+ function isTagName(range) {
38
+ return (range === "*" ||
39
+ range === "latest" ||
40
+ range === "" ||
41
+ /^[a-zA-Z][0-9A-Za-z.-]*$/.test(range));
42
+ }
43
+ function classifyBareRange(range) {
44
+ if (isGitRange(range)) {
45
+ return classifyGitRange(range);
46
+ }
47
+ if (range.startsWith("http://") || range.startsWith("https://")) {
48
+ return "tarball-url";
49
+ }
50
+ if (EXACT_PIN.test(range)) {
51
+ return "exact";
52
+ }
53
+ return isTagName(range) ? "tag" : "range";
54
+ }
20
55
  export function classifyNpmRange(range) {
21
56
  const workspace = stripWorkspacePrefix(range);
22
57
  if (workspace !== undefined) {
23
- return EXACT_PIN.test(workspace) ? "workspace-exact" : "workspace-range";
58
+ return classifyWorkspaceRange(workspace);
24
59
  }
25
60
  if (range.startsWith("file:")) {
26
61
  return "file";
@@ -28,30 +63,10 @@ export function classifyNpmRange(range) {
28
63
  if (range.startsWith("link:")) {
29
64
  return "link";
30
65
  }
31
- if (range.startsWith("npm:")) {
32
- const at = range.lastIndexOf("@");
33
- const version = at < 4 ? "" : range.slice(at + 1);
34
- return EXACT_PIN.test(version) ? "alias-exact" : "alias-range";
35
- }
36
- if (range.startsWith("git+") ||
37
- range.startsWith("git://") ||
38
- range.startsWith("github:")) {
39
- const hash = range.split("#")[1] ?? "";
40
- return FULL_SHA.test(hash) ? "git-sha" : "git-unpinned";
41
- }
42
- if (range.startsWith("http://") || range.startsWith("https://")) {
43
- return "tarball-url";
44
- }
45
- if (EXACT_PIN.test(range)) {
46
- return "exact";
47
- }
48
- if (range === "*" ||
49
- range === "latest" ||
50
- range === "" ||
51
- /^[a-zA-Z][0-9A-Za-z.-]*$/.test(range)) {
52
- return "tag";
66
+ if (range.startsWith(`${NPM_ECOSYSTEM}:`)) {
67
+ return classifyAliasRange(range);
53
68
  }
54
- return "range";
69
+ return classifyBareRange(range);
55
70
  }
56
71
  // SPEC §4.8.5: ranges, tags, *, latest and unpinned git/alias/workspace
57
72
  // forms block core/dep-exact-pin. In-repo file:/link: forms, full-SHA git