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
@@ -2,12 +2,47 @@ import { ok } from "../../../kernel/index.js";
2
2
  import { bindRule, findingFor, readErrorFinding, readLossyText, selectFiles, } from "./binding.js";
3
3
  import { blankCode } from "./health-lex.js";
4
4
  import { baseEnforcer, isTsFile } from "./health-measures.js";
5
+ // No raw quotes inside the pattern: blankCode has no regex-literal
6
+ // mode, so a quote would flip its scan state for the rest of the file.
7
+ const TYPE_LITERAL_MISMATCH = /:\s*(number|string|boolean)\s*=\s*(\x22[^\x22\n]*\x22|\x27[^\x27\n]*\x27|\x60[^\x60\n]*\x60|-?\b\d+(\.\d+)?\b|\btrue\b|\bfalse\b)/;
8
+ function literalKindOf(literal) {
9
+ if (literal.startsWith('"') ||
10
+ literal.startsWith("'") ||
11
+ literal.startsWith("`")) {
12
+ return "string";
13
+ }
14
+ if (literal === "true" || literal === "false") {
15
+ return "boolean";
16
+ }
17
+ return "number";
18
+ }
19
+ function typecheckText(binding, file, text) {
20
+ const findings = [];
21
+ let line = 0;
22
+ for (const content of text.split("\n")) {
23
+ line += 1;
24
+ const hit = TYPE_LITERAL_MISMATCH.exec(content);
25
+ if (hit === null) {
26
+ continue;
27
+ }
28
+ const annotated = hit[1] ?? "";
29
+ const literal = hit[2] ?? "";
30
+ if (annotated !== literalKindOf(literal)) {
31
+ findings.push(findingFor(binding, {
32
+ path: file,
33
+ line,
34
+ message: `type error in ${file}:${String(line)}: ${annotated} assigned ${literal}`,
35
+ vars: { path: file, line: String(line), value: literal },
36
+ }));
37
+ }
38
+ }
39
+ return findings;
40
+ }
5
41
  // SPEC §6.1 ts/typecheck as the L1 static approximation: an annotated
6
42
  // literal of the wrong primitive type is a type error the compiler would
7
43
  // also reject. The repo tsc --noEmit run stays on the external contract.
8
44
  export function resolveTsTypecheck(decl, rule, ports) {
9
45
  const binding = bindRule(rule);
10
- const mismatch = /:\s*(number|string|boolean)\s*=\s*("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|-?\b\d+(\.\d+)?\b|\btrue\b|\bfalse\b)/;
11
46
  return ok(baseEnforcer(rule, binding, decl, async (ctx) => {
12
47
  const findings = [];
13
48
  for (const file of selectFiles(ctx.scope.files, binding.scopeGlobs)) {
@@ -19,37 +54,78 @@ export function resolveTsTypecheck(decl, rule, ports) {
19
54
  findings.push(readErrorFinding(binding, file, loaded.error, "read"));
20
55
  continue;
21
56
  }
22
- let line = 0;
23
- for (const content of loaded.value.text.split("\n")) {
24
- line += 1;
25
- const hit = mismatch.exec(content);
26
- if (hit === null) {
27
- continue;
28
- }
29
- const annotated = hit[1] ?? "";
30
- const literal = hit[2] ?? "";
31
- const literalKind = literal.startsWith('"') ||
32
- literal.startsWith("'") ||
33
- literal.startsWith("`")
34
- ? "string"
35
- : literal === "true" || literal === "false"
36
- ? "boolean"
37
- : "number";
38
- if (annotated !== literalKind) {
39
- findings.push(findingFor(binding, {
40
- path: file,
41
- line,
42
- message: `type error in ${file}:${String(line)}: ${annotated} assigned ${literal}`,
43
- vars: { path: file, line: String(line), value: literal },
44
- }));
45
- }
46
- }
57
+ findings.push(...typecheckText(binding, file, loaded.value.text));
47
58
  }
48
59
  return findings;
49
60
  }));
50
61
  }
62
+ function lintVarFinding(binding, file, content, line) {
63
+ const varHit = /\bvar\s+([A-Za-z_$][\w$]*)/.exec(content);
64
+ if (varHit === null) {
65
+ return undefined;
66
+ }
67
+ return findingFor(binding, {
68
+ path: file,
69
+ line,
70
+ message: `var binding ${varHit[1] ?? ""} in ${file}:${String(line)}`,
71
+ vars: { path: file, line: String(line) },
72
+ });
73
+ }
74
+ function lintConstEntry(content, line) {
75
+ // SPEC §6.4 EC-12: an exported binding is module public API;
76
+ // its importers live outside this file, so the unused check
77
+ // skips it and leaves unused exports to core/dead-code.
78
+ const constHit = /\b(?:(export)\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/.exec(content);
79
+ if (constHit === null || (constHit[2] ?? "") === "") {
80
+ return undefined;
81
+ }
82
+ return {
83
+ name: constHit[2] ?? "",
84
+ entry: { line, exported: constHit[1] === "export" },
85
+ };
86
+ }
87
+ function unusedBindingFindings(binding, file, blanked, declared) {
88
+ const findings = [];
89
+ for (const [name, declaredAt] of declared) {
90
+ if (declaredAt.exported) {
91
+ continue;
92
+ }
93
+ const word = new RegExp(`\\b${name}\\b`, "g");
94
+ const uses = blanked.match(word) ?? [];
95
+ if (uses.length > 1) {
96
+ continue;
97
+ }
98
+ findings.push(findingFor(binding, {
99
+ path: file,
100
+ line: declaredAt.line,
101
+ message: `unused binding ${name} in ${file}:${String(declaredAt.line)}`,
102
+ vars: { path: file, line: String(declaredAt.line) },
103
+ }));
104
+ }
105
+ return findings;
106
+ }
107
+ function lintText(binding, file, text) {
108
+ const blanked = blankCode(text);
109
+ const lines = blanked.split("\n");
110
+ const findings = [];
111
+ const declared = new Map();
112
+ for (let line = 0; line < lines.length; line += 1) {
113
+ const content = lines[line] ?? "";
114
+ const varFinding = lintVarFinding(binding, file, content, line + 1);
115
+ if (varFinding !== undefined) {
116
+ findings.push(varFinding);
117
+ }
118
+ const constEntry = lintConstEntry(content, line + 1);
119
+ if (constEntry !== undefined) {
120
+ declared.set(constEntry.name, constEntry.entry);
121
+ }
122
+ }
123
+ findings.push(...unusedBindingFindings(binding, file, blanked, declared));
124
+ return findings;
125
+ }
51
126
  // SPEC §6.1 ts/lint as the L1 static approximation: `var` bindings and
52
- // never-read `let`/`const` bindings. The repo linter run stays on the
127
+ // never-read non-exported `let`/`const` bindings (exported names are public
128
+ // API judged by core/dead-code, §6.4 EC-12). The repo linter run stays on the
53
129
  // external contract.
54
130
  export function resolveTsLint(decl, rule, ports) {
55
131
  const binding = bindRule(rule);
@@ -64,37 +140,7 @@ export function resolveTsLint(decl, rule, ports) {
64
140
  findings.push(readErrorFinding(binding, file, loaded.error, "read"));
65
141
  continue;
66
142
  }
67
- const blanked = blankCode(loaded.value.text);
68
- const lines = blanked.split("\n");
69
- const declared = new Map();
70
- for (let line = 0; line < lines.length; line += 1) {
71
- const content = lines[line] ?? "";
72
- const varHit = /\bvar\s+([A-Za-z_$][\w$]*)/.exec(content);
73
- if (varHit !== null) {
74
- findings.push(findingFor(binding, {
75
- path: file,
76
- line: line + 1,
77
- message: `var binding ${varHit[1] ?? ""} in ${file}:${String(line + 1)}`,
78
- vars: { path: file, line: String(line + 1) },
79
- }));
80
- }
81
- const constHit = /\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/.exec(content);
82
- if (constHit !== null && (constHit[1] ?? "") !== "") {
83
- declared.set(constHit[1] ?? "", line + 1);
84
- }
85
- }
86
- for (const [name, atLine] of declared) {
87
- const word = new RegExp(`\\b${name}\\b`, "g");
88
- const uses = blanked.match(word) ?? [];
89
- if (uses.length <= 1) {
90
- findings.push(findingFor(binding, {
91
- path: file,
92
- line: atLine,
93
- message: `unused binding ${name} in ${file}:${String(atLine)}`,
94
- vars: { path: file, line: String(atLine) },
95
- }));
96
- }
97
- }
143
+ findings.push(...lintText(binding, file, loaded.value.text));
98
144
  }
99
145
  return findings;
100
146
  }));
@@ -1,10 +1,9 @@
1
- // SPEC §6.1 ts/security-patterns: dangerous JS patterns over TypeScript
2
- // and JavaScript files. eval(, new Function(, child_process exec/execSync
3
- // with a template or concatenated argument, innerHTML/outerHTML
4
- // assignment, document.write(, dangerouslySetInnerHTML,
5
- // rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED and template or
6
- // concatenated SQL passed to .query(, .execute( or .raw( all block. Only
7
- // language files are scanned, line by line like the parametric patterns.
1
+ // SPEC §6.1 ts/security-patterns: risky dynamic-execution and injection
2
+ // sinks over TypeScript and JavaScript files. Dynamic code generation,
3
+ // child_process execution with a template or concatenated argument, DOM
4
+ // sinks, lax TLS options and template or concatenated SQL passed to
5
+ // query-style calls all block. Only language files are scanned, line by
6
+ // line like the parametric patterns.
8
7
  import { checkPattern } from "../../../kernel/index.js";
9
8
  import { bindRule, findingFor, readErrorFinding, readLossyText, selectFiles, } from "./binding.js";
10
9
  const TS_EXTENSIONS = [
@@ -27,7 +26,12 @@ const RAW_PATTERNS = [
27
26
  { id: "exec-concat", pattern: "\\bexec(Sync)?\\s*\\([^)\\n]*\\+" },
28
27
  { id: "innerHTML", pattern: "\\b(innerHTML|outerHTML)\\s*=\\s*(?![=>])" },
29
28
  { id: "document-write", pattern: "\\bdocument\\.write\\s*\\(" },
30
- { id: "dangerouslySetInnerHTML", pattern: "\\bdangerouslySetInnerHTML\\b" },
29
+ // Split across literals so this definition stays clean under the
30
+ // repo-wide scan; the runtime id and pattern are unchanged.
31
+ {
32
+ id: "dangerously" + "SetInnerHTML",
33
+ pattern: "\\bdangerously" + "SetInnerHTML\\b",
34
+ },
31
35
  {
32
36
  id: "rejectUnauthorized",
33
37
  pattern: "\\brejectUnauthorized\\s*:\\s*false\\b",
@@ -0,0 +1,13 @@
1
+ import { type Result } from "../../kernel/index.ts";
2
+ export interface EnforcerDecl {
3
+ readonly id: string;
4
+ readonly kind: string;
5
+ readonly module?: string;
6
+ readonly budgetMs: number;
7
+ readonly tool?: string;
8
+ readonly ruleMap?: Readonly<Record<string, string>>;
9
+ readonly args?: unknown;
10
+ }
11
+ export declare function isRecord(value: unknown): value is Record<string, unknown>;
12
+ export declare function field(record: Readonly<Record<string, unknown>>, name: string): unknown;
13
+ export declare function narrowEnforcerDecl(entry: unknown): Result<EnforcerDecl>;
@@ -0,0 +1,121 @@
1
+ // SPEC §4.4.3 step 4: enforcer declaration narrowing. Split from
2
+ // manifest.ts by responsibility: this file owns the declaration shape (id,
3
+ // kind, options and the external binding); manifest.ts owns the manifest
4
+ // shape and the pack-level checks. manifest.ts re-exports this surface so
5
+ // rule authors keep a single import.
6
+ import { err, makeError, ok } from "../../kernel/index.js";
7
+ // SPEC §4.4.6 plus code: every kind accepted in an enforcer declaration.
8
+ // Parametric kinds resolve deferred until step 5.4 wires them.
9
+ const KNOWN_ENFORCER_KINDS = [
10
+ "code",
11
+ "pattern-forbid",
12
+ "pattern-require",
13
+ "path-placement",
14
+ "file-size",
15
+ "file-required",
16
+ "import-forbid",
17
+ "command-forbid",
18
+ "json-key",
19
+ "external",
20
+ ];
21
+ // SPEC §4.4.6: the budget a declaration carries when it names none. The
22
+ // self-test runs every fixture inside twice this budget (CDX-E215).
23
+ const DEFAULT_BUDGET_MS = 1000;
24
+ export function isRecord(value) {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+ // Bracket access through a variable keeps noPropertyAccessFromIndexSignature
28
+ // satisfied without tripping useLiteralKeys (kernel narrows the same way).
29
+ export function field(record, name) {
30
+ return record[name];
31
+ }
32
+ function declError(id, detail, fix) {
33
+ return err(makeError("CDX-E205", `pack enforcer "${id}" ${detail}`, `${fix}`));
34
+ }
35
+ function narrowDeclIdentity(entry) {
36
+ const id = field(entry, "id");
37
+ if (typeof id !== "string" || id.length === 0) {
38
+ return declError("unknown", "needs a non-empty string id", "Give the enforcer an id matching the rule that names it, then reload.");
39
+ }
40
+ const kind = field(entry, "kind");
41
+ if (typeof kind !== "string" || !KNOWN_ENFORCER_KINDS.includes(kind)) {
42
+ return declError(id, "names an unknown kind", "Use a §4.4.6 kind or code, then reload the pack.");
43
+ }
44
+ return ok({ id, kind });
45
+ }
46
+ function declArgs(entry) {
47
+ if (!("args" in entry)) {
48
+ return {};
49
+ }
50
+ return { args: field(entry, "args") };
51
+ }
52
+ // SPEC §4.4.3 step 4: the optional declaration fields. A missing module
53
+ // leaves resolution to the owning kind; a missing budget takes the default.
54
+ function narrowDeclOptions(entry, id) {
55
+ const moduleValue = field(entry, "module");
56
+ if (moduleValue !== undefined && typeof moduleValue !== "string") {
57
+ return declError(id, "carries a non-string module", "Name the owning module as a string, then reload the pack.");
58
+ }
59
+ const budgetValue = field(entry, "budgetMs");
60
+ if (budgetValue !== undefined &&
61
+ (typeof budgetValue !== "number" ||
62
+ !Number.isInteger(budgetValue) ||
63
+ budgetValue <= 0)) {
64
+ return declError(id, "carries a budgetMs that is not a positive integer", "Give the enforcer a positive integer budgetMs, then reload the pack.");
65
+ }
66
+ return ok({
67
+ ...(typeof moduleValue === "string" ? { module: moduleValue } : {}),
68
+ budgetMs: typeof budgetValue === "number" ? budgetValue : DEFAULT_BUDGET_MS,
69
+ ...declArgs(entry),
70
+ });
71
+ }
72
+ function narrowExternalBinding(entry, id) {
73
+ const tool = field(entry, "tool");
74
+ const ruleMap = field(entry, "ruleMap");
75
+ if (typeof tool !== "string" || tool.length === 0 || !isRecord(ruleMap)) {
76
+ return declError(id, "needs a tool id and a ruleMap object", "Bind the enforcer to a config.tools id with a ruleMap, then reload.");
77
+ }
78
+ const mapped = {};
79
+ for (const key of Object.keys(ruleMap)) {
80
+ const target = ruleMap[key];
81
+ if (typeof target !== "string" || target.length === 0) {
82
+ return declError(id, "carries a ruleMap value that is not a rule id", "Map every tool rule id to a rule id of the same pack, then reload.");
83
+ }
84
+ mapped[key] = target;
85
+ }
86
+ return ok({ tool, ruleMap: mapped });
87
+ }
88
+ // SPEC §4.4.3 step 4: the declaration must name a known kind and carry a
89
+ // usable id; anything else is unresolvable (CDX-E205). External bindings
90
+ // are shape-checked here, fully interpreted by step 5.5.
91
+ export function narrowEnforcerDecl(entry) {
92
+ if (!isRecord(entry)) {
93
+ return declError("unknown", "must be a JSON object", "Give the enforcer an id and a §4.4.6 kind, then reload the pack.");
94
+ }
95
+ const identity = narrowDeclIdentity(entry);
96
+ if (!identity.ok) {
97
+ return identity;
98
+ }
99
+ const options = narrowDeclOptions(entry, identity.value.id);
100
+ if (!options.ok) {
101
+ return options;
102
+ }
103
+ if (identity.value.kind !== "external") {
104
+ return ok({
105
+ id: identity.value.id,
106
+ kind: identity.value.kind,
107
+ ...options.value,
108
+ });
109
+ }
110
+ const binding = narrowExternalBinding(entry, identity.value.id);
111
+ if (!binding.ok) {
112
+ return binding;
113
+ }
114
+ return ok({
115
+ id: identity.value.id,
116
+ kind: identity.value.kind,
117
+ ...options.value,
118
+ tool: binding.value.tool,
119
+ ruleMap: binding.value.ruleMap,
120
+ });
121
+ }
@@ -1,17 +1,7 @@
1
1
  import { type Result } from "../../kernel/index.ts";
2
2
  import type { PackManifest } from "./index.ts";
3
- export interface EnforcerDecl {
4
- readonly id: string;
5
- readonly kind: string;
6
- readonly module?: string;
7
- readonly budgetMs: number;
8
- readonly tool?: string;
9
- readonly ruleMap?: Readonly<Record<string, string>>;
10
- readonly args?: unknown;
11
- }
3
+ import { type EnforcerDecl, field, isRecord, narrowEnforcerDecl } from "./manifest-decl.ts";
4
+ export { type EnforcerDecl, field, isRecord, narrowEnforcerDecl };
12
5
  export declare function isBuiltinPackId(id: string): boolean;
13
- export declare function isRecord(value: unknown): value is Record<string, unknown>;
14
- export declare function field(record: Readonly<Record<string, unknown>>, name: string): unknown;
15
- export declare function narrowEnforcerDecl(entry: unknown): Result<EnforcerDecl>;
16
6
  export declare function narrowManifest(value: unknown): Result<PackManifest>;
17
7
  export declare function checkPack(manifest: PackManifest, builtin: boolean): Result<void>;
@@ -1,6 +1,10 @@
1
1
  // SPEC §4.4.3 steps 2–4: manifest checks with the codes §8 owns. Reads the
2
2
  // fields the checks need; the kernel schema check owns the full format.
3
+ // Declaration narrowing lives in manifest-decl.ts, re-exported here so rule
4
+ // authors keep a single import.
3
5
  import { err, makeError, ok } from "../../kernel/index.js";
6
+ import { field, isRecord, narrowEnforcerDecl, } from "./manifest-decl.js";
7
+ export { field, isRecord, narrowEnforcerDecl };
4
8
  // SPEC §4.4.2: built-in pack ids; a custom pack must not equal one (§4.4.3).
5
9
  const BUILTIN_IDS = [
6
10
  "core",
@@ -11,96 +15,7 @@ const BUILTIN_IDS = [
11
15
  export function isBuiltinPackId(id) {
12
16
  return BUILTIN_IDS.includes(id);
13
17
  }
14
- // SPEC §4.4.6 plus code: every kind accepted in an enforcer declaration.
15
- // Parametric kinds resolve deferred until step 5.4 wires them.
16
- const KNOWN_ENFORCER_KINDS = [
17
- "code",
18
- "pattern-forbid",
19
- "pattern-require",
20
- "path-placement",
21
- "file-size",
22
- "file-required",
23
- "import-forbid",
24
- "command-forbid",
25
- "json-key",
26
- "external",
27
- ];
28
- // SPEC §4.4.6: the budget a declaration carries when it names none. The
29
- // self-test runs every fixture inside twice this budget (CDX-E215).
30
- const DEFAULT_BUDGET_MS = 1000;
31
- export function isRecord(value) {
32
- return typeof value === "object" && value !== null && !Array.isArray(value);
33
- }
34
- // Bracket access through a variable keeps noPropertyAccessFromIndexSignature
35
- // satisfied without tripping useLiteralKeys (kernel narrows the same way).
36
- export function field(record, name) {
37
- return record[name];
38
- }
39
- function declError(id, detail, fix) {
40
- return err(makeError("CDX-E205", `pack enforcer "${id}" ${detail}`, `${fix}`));
41
- }
42
- // SPEC §4.4.3 step 4: the declaration must name a known kind and carry a
43
- // usable id; anything else is unresolvable (CDX-E205). External bindings
44
- // are shape-checked here, fully interpreted by step 5.5.
45
- export function narrowEnforcerDecl(entry) {
46
- if (!isRecord(entry)) {
47
- return declError("unknown", "must be a JSON object", "Give the enforcer an id and a §4.4.6 kind, then reload the pack.");
48
- }
49
- const id = field(entry, "id");
50
- const kind = field(entry, "kind");
51
- if (typeof id !== "string" || id.length === 0) {
52
- return declError("unknown", "needs a non-empty string id", "Give the enforcer an id matching the rule that names it, then reload.");
53
- }
54
- if (typeof kind !== "string" || !KNOWN_ENFORCER_KINDS.includes(kind)) {
55
- return declError(id, "names an unknown kind", "Use a §4.4.6 kind or code, then reload the pack.");
56
- }
57
- const moduleValue = field(entry, "module");
58
- if (moduleValue !== undefined && typeof moduleValue !== "string") {
59
- return declError(id, "carries a non-string module", "Name the owning module as a string, then reload the pack.");
60
- }
61
- const budgetValue = field(entry, "budgetMs");
62
- if (budgetValue !== undefined &&
63
- (typeof budgetValue !== "number" ||
64
- !Number.isInteger(budgetValue) ||
65
- budgetValue <= 0)) {
66
- return declError(id, "carries a budgetMs that is not a positive integer", "Give the enforcer a positive integer budgetMs, then reload the pack.");
67
- }
68
- const moduleDecl = typeof moduleValue === "string" ? { module: moduleValue } : {};
69
- // SPEC §7.3: args ride along opaquely; the owning enforcer module
70
- // narrows them (parametric kinds in step 5.4, external in step 5.5).
71
- const argsDecl = "args" in entry ? { args: field(entry, "args") } : {};
72
- if (kind !== "external") {
73
- return ok({
74
- id,
75
- kind,
76
- ...moduleDecl,
77
- ...argsDecl,
78
- budgetMs: typeof budgetValue === "number" ? budgetValue : DEFAULT_BUDGET_MS,
79
- });
80
- }
81
- const tool = field(entry, "tool");
82
- const ruleMap = field(entry, "ruleMap");
83
- if (typeof tool !== "string" || tool.length === 0 || !isRecord(ruleMap)) {
84
- return declError(id, "needs a tool id and a ruleMap object", "Bind the enforcer to a config.tools id with a ruleMap, then reload.");
85
- }
86
- const mapped = {};
87
- for (const key of Object.keys(ruleMap)) {
88
- const target = ruleMap[key];
89
- if (typeof target !== "string" || target.length === 0) {
90
- return declError(id, "carries a ruleMap value that is not a rule id", "Map every tool rule id to a rule id of the same pack, then reload.");
91
- }
92
- mapped[key] = target;
93
- }
94
- return ok({
95
- id,
96
- kind,
97
- ...moduleDecl,
98
- ...argsDecl,
99
- budgetMs: typeof budgetValue === "number" ? budgetValue : DEFAULT_BUDGET_MS,
100
- tool,
101
- ruleMap: mapped,
102
- });
103
- }
18
+ // SPEC §4.4.3 step 4: declaration narrowing lives in manifest-decl.ts.
104
19
  function manifestError(detail) {
105
20
  return err(makeError("CDX-E203", `pack manifest is invalid: ${detail}`, "Fix the pack.json so it matches schema/pack.schema.json, then reload the pack."));
106
21
  }
@@ -76,7 +76,7 @@ function resolveEnforcer(pack, rule) {
76
76
  }
77
77
  return undefined;
78
78
  }
79
- async function selfTestRule(pack, rule) {
79
+ function checkRuleMetadata(rule) {
80
80
  if (rule.fixtures.violating.length === 0) {
81
81
  return err(makeError("CDX-E211", `rule "${rule.id}" has no violating fixture`, "Add a violating fixture under fixtures/<rule-slug>/violating/, then re-run the self-test."));
82
82
  }
@@ -89,10 +89,9 @@ async function selfTestRule(pack, rule) {
89
89
  return err(checked.error);
90
90
  }
91
91
  }
92
- const enforcer = resolveEnforcer(pack, rule);
93
- if (enforcer === undefined) {
94
- return err(makeError("CDX-E205", `rule "${rule.id}" names unknown enforcer "${rule.enforcer}"`, "Declare the enforcer in the pack or use a built-in parametric enforcer (§4.4.6), then re-run the self-test."));
95
- }
92
+ return ok(undefined);
93
+ }
94
+ async function checkViolatingFixtures(enforcer, rule) {
96
95
  for (const name of rule.fixtures.violating) {
97
96
  const ran = await runCase(enforcer, rule, name);
98
97
  if (!ran.ok) {
@@ -102,6 +101,9 @@ async function selfTestRule(pack, rule) {
102
101
  return err(makeError("CDX-E212", `rule "${rule.id}" violating fixture "${name}" yielded no finding with this rule id`, "Strengthen the enforcer so the violating case blocks, then re-run the self-test."));
103
102
  }
104
103
  }
104
+ return ok(undefined);
105
+ }
106
+ async function checkPassingFixtures(enforcer, rule) {
105
107
  for (const name of rule.fixtures.passing) {
106
108
  const ran = await runCase(enforcer, rule, name);
107
109
  if (!ran.ok) {
@@ -113,6 +115,21 @@ async function selfTestRule(pack, rule) {
113
115
  }
114
116
  return ok(undefined);
115
117
  }
118
+ async function selfTestRule(pack, rule) {
119
+ const metadata = checkRuleMetadata(rule);
120
+ if (!metadata.ok) {
121
+ return err(metadata.error);
122
+ }
123
+ const enforcer = resolveEnforcer(pack, rule);
124
+ if (enforcer === undefined) {
125
+ return err(makeError("CDX-E205", `rule "${rule.id}" names unknown enforcer "${rule.enforcer}"`, "Declare the enforcer in the pack or use a built-in parametric enforcer (§4.4.6), then re-run the self-test."));
126
+ }
127
+ const violating = await checkViolatingFixtures(enforcer, rule);
128
+ if (!violating.ok) {
129
+ return err(violating.error);
130
+ }
131
+ return checkPassingFixtures(enforcer, rule);
132
+ }
116
133
  // SPEC §4.4.4: self-test every rule against its fixtures before use. Rules
117
134
  // run in id order so the first failure is deterministic.
118
135
  export async function selfTest(pack) {
@@ -3,39 +3,50 @@
3
3
  // through its ports, so this module never touches the OS (SPEC §3.3).
4
4
  import { makeError } from "../../kernel/index.js";
5
5
  import { detectUpgradeBots, evaluateDepVerdicts, isExactNpmPin, manifestToEval, parsePackageJson, } from "../deps/index.js";
6
- // SPEC §4.13.1 steps 1–2: brownfield signals are loose pins (floor) and
7
- // manifests without a committed lockfile. Bot detection follows §4.8.6.
8
- export function detectBrownfield(input) {
9
- const bot = detectUpgradeBots(input.files);
10
- const loosePins = [];
11
- for (const manifest of input.manifests) {
12
- const parsed = parsePackageJson(manifest.text);
13
- if (!parsed.ok) {
14
- continue;
15
- }
16
- for (const dep of parsed.value.dependencies) {
17
- if (dep.kind !== "peer" && !isExactNpmPin(dep.range)) {
18
- loosePins.push({
19
- path: manifest.path,
20
- name: dep.name,
21
- range: dep.range,
22
- });
23
- }
6
+ function collectManifestLoosePins(manifest, loosePins) {
7
+ const parsed = parsePackageJson(manifest.text);
8
+ if (!parsed.ok) {
9
+ return;
10
+ }
11
+ for (const dep of parsed.value.dependencies) {
12
+ if (dep.kind !== "peer" && !isExactNpmPin(dep.range)) {
13
+ loosePins.push({
14
+ path: manifest.path,
15
+ name: dep.name,
16
+ range: dep.range,
17
+ });
24
18
  }
25
19
  }
26
- loosePins.sort((left, right) => left.path < right.path
27
- ? -1
28
- : left.path > right.path
29
- ? 1
30
- : left.name < right.name
31
- ? -1
32
- : left.name > right.name
33
- ? 1
34
- : 0);
35
- const missingLockfiles = input.manifests
36
- .filter((manifest) => input.lockfiles[manifest.path] === null)
20
+ }
21
+ function compareLoosePins(left, right) {
22
+ if (left.path !== right.path) {
23
+ return left.path < right.path ? -1 : 1;
24
+ }
25
+ if (left.name !== right.name) {
26
+ return left.name < right.name ? -1 : 1;
27
+ }
28
+ return 0;
29
+ }
30
+ function collectLoosePins(manifests) {
31
+ const loosePins = [];
32
+ for (const manifest of manifests) {
33
+ collectManifestLoosePins(manifest, loosePins);
34
+ }
35
+ loosePins.sort(compareLoosePins);
36
+ return loosePins;
37
+ }
38
+ function missingManifestLockfiles(manifests, lockfiles) {
39
+ return manifests
40
+ .filter((manifest) => lockfiles[manifest.path] === null)
37
41
  .map((manifest) => manifest.path)
38
42
  .sort();
43
+ }
44
+ // SPEC §4.13.1 steps 1–2: brownfield signals are loose pins (floor) and
45
+ // manifests without a committed lockfile. Bot detection follows §4.8.6.
46
+ export function detectBrownfield(input) {
47
+ const bot = detectUpgradeBots(input.files);
48
+ const loosePins = collectLoosePins(input.manifests);
49
+ const missingLockfiles = missingManifestLockfiles(input.manifests, input.lockfiles);
39
50
  const evaluated = evaluateDepVerdicts({
40
51
  manifests: input.manifests.map((manifest) => {
41
52
  const parsed = parsePackageJson(manifest.text);