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.
- package/dist/builtin-packs/core/fixtures/conventional-commit/passing/basic/case.json +1 -1
- package/dist/builtin-packs/core/fixtures/conventional-commit/violating/basic/case.json +1 -1
- package/dist/builtin-packs/typescript/fixtures/lint/passing/exported/case.json +8 -0
- package/dist/builtin-packs/typescript/pack.json +1 -1
- package/dist/cli/gate-scope.d.ts +1 -0
- package/dist/cli/gate-scope.js +11 -0
- package/dist/cli/git-hook.js +13 -8
- package/dist/cli/plan-launcher.d.ts +1 -0
- package/dist/cli/plan-launcher.js +53 -3
- package/dist/cli/task-advance.js +16 -16
- package/dist/features/adapters/claude-shaped.d.ts +1 -0
- package/dist/features/adapters/claude-shaped.js +29 -31
- package/dist/features/adapters/copilot.js +22 -29
- package/dist/features/audit/append.js +9 -4
- package/dist/features/audit/surfaces.js +15 -8
- package/dist/features/ci/protection.js +37 -13
- package/dist/features/deps/npm.js +39 -24
- package/dist/features/deps/registry.js +45 -34
- package/dist/features/deps/stable.js +52 -27
- package/dist/features/deps/upgrade.d.ts +1 -1
- package/dist/features/deps/upgrade.js +29 -19
- package/dist/features/engine/evaluate.js +6 -1
- package/dist/features/packs/enforcers/ci-guards.js +4 -1
- package/dist/features/packs/enforcers/code.js +2 -1
- package/dist/features/packs/enforcers/engine-version-guards.d.ts +6 -0
- package/dist/features/packs/enforcers/engine-version-guards.js +42 -0
- package/dist/features/packs/enforcers/external-bind.js +62 -28
- package/dist/features/packs/enforcers/external-sarif.js +30 -24
- package/dist/features/packs/enforcers/gate-guards.d.ts +0 -1
- package/dist/features/packs/enforcers/gate-guards.js +83 -136
- package/dist/features/packs/enforcers/health-measures.d.ts +2 -0
- package/dist/features/packs/enforcers/health-measures.js +10 -1
- package/dist/features/packs/enforcers/health-refs.d.ts +1 -2
- package/dist/features/packs/enforcers/health-refs.js +71 -56
- package/dist/features/packs/enforcers/secret-scan.d.ts +4 -20
- package/dist/features/packs/enforcers/secret-scan.js +61 -67
- package/dist/features/packs/enforcers/ts-checks.js +104 -58
- package/dist/features/packs/enforcers/ts-security-patterns.js +12 -8
- package/dist/features/packs/manifest-decl.d.ts +13 -0
- package/dist/features/packs/manifest-decl.js +121 -0
- package/dist/features/packs/manifest.d.ts +2 -12
- package/dist/features/packs/manifest.js +5 -90
- package/dist/features/packs/selftest.js +22 -5
- package/dist/features/remediation/adoption.js +40 -29
- package/dist/features/testing/scenarios.js +49 -27
- package/dist/features/testing/tap-parser.js +38 -26
- package/dist/features/workflow/phases.js +45 -42
- package/dist/features/workflow/review.js +17 -10
- package/dist/kernel/git.js +4 -1
- package/dist/kernel/pattern.js +120 -86
- package/dist/kernel/secret-paths.js +34 -20
- package/dist/kernel/shell-cmd.js +23 -6
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
2
|
-
// and JavaScript files.
|
|
3
|
-
// with a template or concatenated argument,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
for (const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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);
|