obey-cli 0.1.0__py3-none-any.whl
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.
- obey_cli/__init__.py +3 -0
- obey_cli/__main__.py +3 -0
- obey_cli/_data/demo/README.md +12 -0
- obey_cli/_data/demo/obey.yaml +287 -0
- obey_cli/_data/demo/repo/.gitignore +8 -0
- obey_cli/_data/demo/repo/AGENTS.md +38 -0
- obey_cli/_data/demo/repo/README.md +36 -0
- obey_cli/_data/demo/repo/eslint.config.js +24 -0
- obey_cli/_data/demo/repo/index.html +21 -0
- obey_cli/_data/demo/repo/openapi.yaml +86 -0
- obey_cli/_data/demo/repo/package.json +26 -0
- obey_cli/_data/demo/repo/pnpm-lock.yaml +1439 -0
- obey_cli/_data/demo/repo/scripts/gen-types.mjs +109 -0
- obey_cli/_data/demo/repo/src/api/client.test.ts +60 -0
- obey_cli/_data/demo/repo/src/api/client.ts +71 -0
- obey_cli/_data/demo/repo/src/api/response-guards.test.ts +21 -0
- obey_cli/_data/demo/repo/src/api/response-guards.ts +38 -0
- obey_cli/_data/demo/repo/src/date-utils.test.ts +51 -0
- obey_cli/_data/demo/repo/src/date-utils.ts +60 -0
- obey_cli/_data/demo/repo/src/generated/api-types.ts +35 -0
- obey_cli/_data/demo/repo/src/logger.test.ts +24 -0
- obey_cli/_data/demo/repo/src/logger.ts +36 -0
- obey_cli/_data/demo/repo/src/main.ts +30 -0
- obey_cli/_data/demo/repo/src/render-table.test.ts +34 -0
- obey_cli/_data/demo/repo/src/render-table.ts +48 -0
- obey_cli/_data/demo/repo/src/shift-planner.test.ts +66 -0
- obey_cli/_data/demo/repo/src/shift-planner.ts +101 -0
- obey_cli/_data/demo/repo/tsconfig.json +20 -0
- obey_cli/_data/demo/repo/vitest.config.ts +8 -0
- obey_cli/_data/demo/runs/after.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/before.jsonl.gz +0 -0
- obey_cli/_data/demo/runs/meta.json +10 -0
- obey_cli/_data/schemas/obey-config-v1.json +116 -0
- obey_cli/_data/schemas/report-v1.json +55 -0
- obey_cli/_data/schemas/survey-record-v1.json +30 -0
- obey_cli/checkers/__init__.py +30 -0
- obey_cli/checkers/samples.py +95 -0
- obey_cli/cli.py +201 -0
- obey_cli/commands.py +676 -0
- obey_cli/config.py +90 -0
- obey_cli/discover.py +232 -0
- obey_cli/enforce/__init__.py +114 -0
- obey_cli/enforce/engine/__init__.py +0 -0
- obey_cli/enforce/engine/obey_engine.py +2005 -0
- obey_cli/enforce/merge.py +196 -0
- obey_cli/enforce/targets/__init__.py +354 -0
- obey_cli/enforce/templates/obey_hook.py +452 -0
- obey_cli/enforce/templates/pre_commit.py +116 -0
- obey_cli/extract.py +1342 -0
- obey_cli/lint.py +125 -0
- obey_cli/llm.py +129 -0
- obey_cli/parse_md.py +260 -0
- obey_cli/probes/__init__.py +0 -0
- obey_cli/probes/facts.py +162 -0
- obey_cli/probes/templates.py +326 -0
- obey_cli/py.typed +0 -0
- obey_cli/report/__init__.py +314 -0
- obey_cli/resources.py +35 -0
- obey_cli/rules.py +75 -0
- obey_cli/runner/__init__.py +0 -0
- obey_cli/runner/agents/__init__.py +22 -0
- obey_cli/runner/agents/base.py +96 -0
- obey_cli/runner/agents/claude_code.py +203 -0
- obey_cli/runner/agents/codex.py +152 -0
- obey_cli/runner/agents/copilot.py +142 -0
- obey_cli/runner/agents/gemini.py +125 -0
- obey_cli/runner/agents/opencode.py +156 -0
- obey_cli/runner/core.py +140 -0
- obey_cli/runner/egress_proxy.py +166 -0
- obey_cli/runner/gateway.py +529 -0
- obey_cli/runner/ledger.py +132 -0
- obey_cli/runner/local.py +176 -0
- obey_cli/runner/orchestrate.py +227 -0
- obey_cli/runner/sandbox/Dockerfile +41 -0
- obey_cli/runner/sandbox/entrypoint.sh +38 -0
- obey_cli/runner/sandbox.py +341 -0
- obey_cli/runner/sidecar.py +38 -0
- obey_cli/runner/workspace.py +187 -0
- obey_cli/score.py +150 -0
- obey_cli/study/__init__.py +0 -0
- obey_cli/study/analysis.py +277 -0
- obey_cli/study/design.py +98 -0
- obey_cli/study/experiment.py +198 -0
- obey_cli/study/report.py +519 -0
- obey_cli/study/survey.py +438 -0
- obey_cli-0.1.0.dist-info/METADATA +393 -0
- obey_cli-0.1.0.dist-info/RECORD +90 -0
- obey_cli-0.1.0.dist-info/WHEEL +4 -0
- obey_cli-0.1.0.dist-info/entry_points.txt +2 -0
- obey_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { NewShift, Person, Shift } from "./generated/api-types.js";
|
|
4
|
+
import { findConflicts, groupByDay, validateNewShift, weeklyHours } from "./shift-planner.js";
|
|
5
|
+
|
|
6
|
+
const WEEK = new Date("2026-03-02T00:00:00Z");
|
|
7
|
+
|
|
8
|
+
const ada: Person = { id: "p1", name: "Ada Example", role: "barista" };
|
|
9
|
+
const bo: Person = { id: "p2", name: "Bo Example", role: "baker", maxWeeklyHours: 10 };
|
|
10
|
+
|
|
11
|
+
function shift(id: string, personId: string, day: number, startHour: number, endHour: number): Shift {
|
|
12
|
+
const at = (hour: number): string => new Date(Date.UTC(2026, 2, day, hour)).toISOString();
|
|
13
|
+
return { id, personId, start: at(startHour), end: at(endHour) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("findConflicts", () => {
|
|
17
|
+
it("reports overlapping shifts for the same person only", () => {
|
|
18
|
+
const shifts = [
|
|
19
|
+
shift("a", "p1", 2, 9, 13),
|
|
20
|
+
shift("b", "p1", 2, 12, 16),
|
|
21
|
+
shift("c", "p2", 2, 12, 16),
|
|
22
|
+
shift("d", "p1", 3, 9, 13),
|
|
23
|
+
];
|
|
24
|
+
const conflicts = findConflicts(shifts);
|
|
25
|
+
expect(conflicts).toHaveLength(1);
|
|
26
|
+
expect(conflicts[0]?.personId).toBe("p1");
|
|
27
|
+
expect([conflicts[0]?.first.id, conflicts[0]?.second.id]).toEqual(["a", "b"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("weeklyHours and groupByDay", () => {
|
|
32
|
+
const shifts = [shift("a", "p1", 2, 9, 17), shift("b", "p1", 4, 9, 13), shift("c", "p1", 9, 9, 17)];
|
|
33
|
+
|
|
34
|
+
it("sums only the shifts that start in the given week", () => {
|
|
35
|
+
expect(weeklyHours(shifts, WEEK).get("p1")).toBe(12);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("groups shifts by day and keeps empty days", () => {
|
|
39
|
+
const days = groupByDay(shifts, WEEK);
|
|
40
|
+
expect([...days.keys()]).toHaveLength(7);
|
|
41
|
+
expect(days.get("2026-03-02")?.map((s) => s.id)).toEqual(["a"]);
|
|
42
|
+
expect(days.get("2026-03-03")).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("validateNewShift", () => {
|
|
47
|
+
const existing = [shift("a", "p1", 2, 9, 17)];
|
|
48
|
+
const candidate = (personId: string, day: number, startHour: number, endHour: number): NewShift => {
|
|
49
|
+
const { start, end } = shift("new", personId, day, startHour, endHour);
|
|
50
|
+
return { personId, start, end };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
it("accepts a shift that fits", () => {
|
|
54
|
+
expect(validateNewShift(candidate("p1", 3, 9, 17), existing, ada)).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("explains every rule that is broken", () => {
|
|
58
|
+
expect(validateNewShift(candidate("p9", 3, 9, 17), existing, undefined)).toEqual(["unknown person: p9"]);
|
|
59
|
+
expect(validateNewShift(candidate("p1", 3, 17, 9), existing, ada)).toEqual(["a shift must end after it starts"]);
|
|
60
|
+
expect(validateNewShift(candidate("p1", 2, 12, 20), existing, ada)).toEqual(["Ada Example already has a shift then"]);
|
|
61
|
+
expect(validateNewShift(candidate("p2", 3, 6, 20), [], bo)).toEqual([
|
|
62
|
+
"a shift cannot be longer than 12 hours",
|
|
63
|
+
"Bo Example would exceed 10 hours this week",
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import {
|
|
3
|
+
addDays,
|
|
4
|
+
hoursBetween,
|
|
5
|
+
parseIsoDateTime,
|
|
6
|
+
rangesOverlap,
|
|
7
|
+
startOfWeek,
|
|
8
|
+
toDayKey,
|
|
9
|
+
weekDays,
|
|
10
|
+
} from "./date-utils.js";
|
|
11
|
+
import type { NewShift, Person, Shift } from "./generated/api-types.js";
|
|
12
|
+
|
|
13
|
+
export const MAX_SHIFT_HOURS = 12;
|
|
14
|
+
export const DEFAULT_WEEKLY_LIMIT = 40;
|
|
15
|
+
|
|
16
|
+
export interface Conflict {
|
|
17
|
+
personId: string;
|
|
18
|
+
first: Shift;
|
|
19
|
+
second: Shift;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function byStart(a: Shift, b: Shift): number {
|
|
23
|
+
return parseIsoDateTime(a.start).getTime() - parseIsoDateTime(b.start).getTime();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pairs of shifts that overlap for the same person. */
|
|
27
|
+
export function findConflicts(shifts: readonly Shift[]): Conflict[] {
|
|
28
|
+
const byPerson = new Map<string, Shift[]>();
|
|
29
|
+
for (const shift of shifts) {
|
|
30
|
+
byPerson.set(shift.personId, [...(byPerson.get(shift.personId) ?? []), shift]);
|
|
31
|
+
}
|
|
32
|
+
const conflicts: Conflict[] = [];
|
|
33
|
+
for (const [personId, list] of byPerson) {
|
|
34
|
+
const sorted = [...list].sort(byStart);
|
|
35
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
36
|
+
for (let j = i + 1; j < sorted.length; j += 1) {
|
|
37
|
+
const first = sorted[i];
|
|
38
|
+
const second = sorted[j];
|
|
39
|
+
if (first && second && overlaps(first, second)) conflicts.push({ personId, first, second });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return conflicts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function overlaps(a: Pick<Shift, "start" | "end">, b: Pick<Shift, "start" | "end">): boolean {
|
|
47
|
+
return rangesOverlap(
|
|
48
|
+
parseIsoDateTime(a.start),
|
|
49
|
+
parseIsoDateTime(a.end),
|
|
50
|
+
parseIsoDateTime(b.start),
|
|
51
|
+
parseIsoDateTime(b.end),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Scheduled hours per person id for shifts that start in the week beginning at `weekStart`. */
|
|
56
|
+
export function weeklyHours(shifts: readonly Shift[], weekStart: Date): Map<string, number> {
|
|
57
|
+
const weekEnd = addDays(weekStart, 7);
|
|
58
|
+
const totals = new Map<string, number>();
|
|
59
|
+
for (const shift of shifts) {
|
|
60
|
+
const start = parseIsoDateTime(shift.start);
|
|
61
|
+
if (start < weekStart || start >= weekEnd) continue;
|
|
62
|
+
const hours = hoursBetween(start, parseIsoDateTime(shift.end));
|
|
63
|
+
totals.set(shift.personId, (totals.get(shift.personId) ?? 0) + hours);
|
|
64
|
+
}
|
|
65
|
+
return totals;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Shifts grouped by the UTC day they start on, one (possibly empty) entry for each day of the week. */
|
|
69
|
+
export function groupByDay(shifts: readonly Shift[], weekStart: Date): Map<string, Shift[]> {
|
|
70
|
+
const days = new Map<string, Shift[]>(
|
|
71
|
+
weekDays(weekStart).map((day): [string, Shift[]] => [toDayKey(day), []]),
|
|
72
|
+
);
|
|
73
|
+
for (const shift of [...shifts].sort(byStart)) {
|
|
74
|
+
days.get(toDayKey(parseIsoDateTime(shift.start)))?.push(shift);
|
|
75
|
+
}
|
|
76
|
+
return days;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Reasons a new shift cannot be added; an empty list means it is fine. */
|
|
80
|
+
export function validateNewShift(
|
|
81
|
+
candidate: NewShift,
|
|
82
|
+
existing: readonly Shift[],
|
|
83
|
+
person: Person | undefined,
|
|
84
|
+
): string[] {
|
|
85
|
+
if (!person) return [`unknown person: ${candidate.personId}`];
|
|
86
|
+
const start = parseIsoDateTime(candidate.start);
|
|
87
|
+
const end = parseIsoDateTime(candidate.end);
|
|
88
|
+
if (end <= start) return ["a shift must end after it starts"];
|
|
89
|
+
|
|
90
|
+
const errors: string[] = [];
|
|
91
|
+
const length = hoursBetween(start, end);
|
|
92
|
+
if (length > MAX_SHIFT_HOURS) errors.push(`a shift cannot be longer than ${MAX_SHIFT_HOURS} hours`);
|
|
93
|
+
|
|
94
|
+
const own = existing.filter((shift) => shift.personId === person.id);
|
|
95
|
+
if (own.some((shift) => overlaps(candidate, shift))) errors.push(`${person.name} already has a shift then`);
|
|
96
|
+
|
|
97
|
+
const planned = weeklyHours(own, startOfWeek(start)).get(person.id) ?? 0;
|
|
98
|
+
const limit = person.maxWeeklyHours ?? DEFAULT_WEEKLY_LIMIT;
|
|
99
|
+
if (planned + length > limit) errors.push(`${person.name} would exceed ${limit} hours this week`);
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"noFallthroughCasesInSwitch": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": [],
|
|
15
|
+
"rootDir": "src",
|
|
16
|
+
"outDir": "dist",
|
|
17
|
+
"sourceMap": true
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"]
|
|
20
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/B0yko/obey/schemas/obey-config-v1.json",
|
|
4
|
+
"title": "obey.yaml",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["version", "rules"],
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"$defs": {
|
|
9
|
+
"globs": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
10
|
+
"regex": {"type": "string", "minLength": 1},
|
|
11
|
+
"checker": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["type", "params"],
|
|
14
|
+
"additionalProperties": false,
|
|
15
|
+
"properties": {
|
|
16
|
+
"type": {"enum": ["command_forbidden", "command_required", "path_forbidden", "path_required_with",
|
|
17
|
+
"dependency_unchanged", "content_forbidden_added", "content_required_added",
|
|
18
|
+
"commit_message", "no_commit", "no_push", "file_naming", "final_message"]},
|
|
19
|
+
"params": {"type": "object"}
|
|
20
|
+
},
|
|
21
|
+
"allOf": [
|
|
22
|
+
{"if": {"properties": {"type": {"const": "command_forbidden"}}},
|
|
23
|
+
"then": {"properties": {"params": {"required": ["patterns"], "additionalProperties": false, "properties": {
|
|
24
|
+
"patterns": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/regex"}},
|
|
25
|
+
"preferred": {"type": "string"},
|
|
26
|
+
"preferred_patterns": {"type": "array", "items": {"$ref": "#/$defs/regex"}},
|
|
27
|
+
"manual_edit_paths": {"$ref": "#/$defs/globs"}}}}}},
|
|
28
|
+
{"if": {"properties": {"type": {"const": "command_required"}}},
|
|
29
|
+
"then": {"properties": {"params": {"required": ["pattern"], "additionalProperties": false, "properties": {
|
|
30
|
+
"pattern": {"$ref": "#/$defs/regex"}, "edit_globs": {"$ref": "#/$defs/globs"},
|
|
31
|
+
"require_success": {"type": "boolean"}}}}}},
|
|
32
|
+
{"if": {"properties": {"type": {"const": "path_forbidden"}}},
|
|
33
|
+
"then": {"properties": {"params": {"required": ["globs"], "additionalProperties": false, "properties": {
|
|
34
|
+
"globs": {"$ref": "#/$defs/globs", "minItems": 1}, "existing_only": {"type": "boolean"},
|
|
35
|
+
"allow_via": {"type": "array", "items": {"$ref": "#/$defs/regex"}}}}}}},
|
|
36
|
+
{"if": {"properties": {"type": {"const": "path_required_with"}}},
|
|
37
|
+
"then": {"properties": {"params": {"required": ["when", "require"], "additionalProperties": false,
|
|
38
|
+
"properties": {"when": {"$ref": "#/$defs/globs"}, "require": {"$ref": "#/$defs/globs"}}}}}},
|
|
39
|
+
{"if": {"properties": {"type": {"const": "dependency_unchanged"}}},
|
|
40
|
+
"then": {"properties": {"params": {"additionalProperties": false, "properties": {
|
|
41
|
+
"manifests": {"$ref": "#/$defs/globs"}, "allow": {"type": "array", "items": {"type": "string"}}}}}}},
|
|
42
|
+
{"if": {"properties": {"type": {"const": "content_forbidden_added"}}},
|
|
43
|
+
"then": {"properties": {"params": {"required": ["regex", "globs"], "additionalProperties": false,
|
|
44
|
+
"properties": {"regex": {"$ref": "#/$defs/regex"}, "globs": {"$ref": "#/$defs/globs"}}}}}},
|
|
45
|
+
{"if": {"properties": {"type": {"const": "content_required_added"}}},
|
|
46
|
+
"then": {"properties": {"params": {"required": ["regex", "globs"], "additionalProperties": false,
|
|
47
|
+
"properties": {"regex": {"$ref": "#/$defs/regex"}, "globs": {"$ref": "#/$defs/globs"},
|
|
48
|
+
"scope": {"const": "new_files"}, "first_lines": {"type": "integer", "minimum": 1}}}}}},
|
|
49
|
+
{"if": {"properties": {"type": {"const": "commit_message"}}},
|
|
50
|
+
"then": {"properties": {"params": {"required": ["regex"], "additionalProperties": false,
|
|
51
|
+
"properties": {"regex": {"$ref": "#/$defs/regex"}}}}}},
|
|
52
|
+
{"if": {"properties": {"type": {"enum": ["no_commit", "no_push"]}}},
|
|
53
|
+
"then": {"properties": {"params": {"maxProperties": 0}}}},
|
|
54
|
+
{"if": {"properties": {"type": {"const": "file_naming"}}},
|
|
55
|
+
"then": {"properties": {"params": {"required": ["globs", "pattern"], "additionalProperties": false,
|
|
56
|
+
"properties": {"globs": {"$ref": "#/$defs/globs"}, "pattern": {"$ref": "#/$defs/regex"}}}}}},
|
|
57
|
+
{"if": {"properties": {"type": {"const": "final_message"}}},
|
|
58
|
+
"then": {"properties": {"params": {"additionalProperties": false, "minProperties": 1, "maxProperties": 1,
|
|
59
|
+
"properties": {"required": {"$ref": "#/$defs/regex"}, "forbidden": {"$ref": "#/$defs/regex"}}}}}}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
"probe": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"required": ["id", "template", "instruction"],
|
|
65
|
+
"additionalProperties": false,
|
|
66
|
+
"properties": {
|
|
67
|
+
"id": {"type": "string"},
|
|
68
|
+
"template": {"type": "string"},
|
|
69
|
+
"instruction": {"type": "string", "minLength": 1},
|
|
70
|
+
"setup_patch": {"type": ["string", "null"]},
|
|
71
|
+
"domain": {"type": ["string", "null"]},
|
|
72
|
+
"control": {"type": "boolean"}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"properties": {
|
|
77
|
+
"version": {"const": 1},
|
|
78
|
+
"generated_by": {"type": "string"},
|
|
79
|
+
"sources": {"type": "array", "items": {"type": "object", "required": ["file", "sha256"],
|
|
80
|
+
"additionalProperties": false, "properties": {"file": {"type": "string"},
|
|
81
|
+
"sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, "kind": {"type": "string"},
|
|
82
|
+
"scope": {"$ref": "#/$defs/globs"}, "loaded_by": {"type": "array", "items": {"type": "string"}}}}},
|
|
83
|
+
"sandbox": {"type": "object", "additionalProperties": false, "properties": {
|
|
84
|
+
"image": {"type": ["string", "null"]},
|
|
85
|
+
"allow_hosts": {"type": "array", "items": {"type": "string"}},
|
|
86
|
+
"timeout_s": {"type": "integer", "minimum": 30},
|
|
87
|
+
"cpus": {"type": "string"}, "memory": {"type": "string"}}},
|
|
88
|
+
"agents": {"type": "array", "items": {"type": "object", "required": ["agent"], "additionalProperties": false,
|
|
89
|
+
"properties": {"agent": {"type": "string"}, "model": {"type": "string"}}}},
|
|
90
|
+
"rules": {"type": "array", "items": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"required": ["id", "text", "source", "status", "checker"],
|
|
93
|
+
"additionalProperties": false,
|
|
94
|
+
"properties": {
|
|
95
|
+
"id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
|
|
96
|
+
"text": {"type": "string", "minLength": 1},
|
|
97
|
+
"source": {"type": "object", "required": ["file", "lines"], "additionalProperties": false, "properties": {
|
|
98
|
+
"file": {"type": "string"}, "lines": {"type": "array", "items": {"type": "integer", "minimum": 1},
|
|
99
|
+
"minItems": 2, "maxItems": 2}}},
|
|
100
|
+
"scope": {"type": "object", "additionalProperties": false, "properties": {
|
|
101
|
+
"paths": {"$ref": "#/$defs/globs"}, "agents": {"type": ["array", "null"], "items": {"type": "string"}}}},
|
|
102
|
+
"checker": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/checker"}]},
|
|
103
|
+
"status": {"enum": ["active", "proposed", "uncheckable", "disabled"]},
|
|
104
|
+
"reason": {"type": ["string", "null"]},
|
|
105
|
+
"origin": {"enum": ["pattern", "llm", "user"]},
|
|
106
|
+
"probes": {"type": "array", "items": {"$ref": "#/$defs/probe"}}
|
|
107
|
+
},
|
|
108
|
+
"allOf": [
|
|
109
|
+
{"if": {"properties": {"status": {"enum": ["active", "proposed"]}}},
|
|
110
|
+
"then": {"properties": {"checker": {"$ref": "#/$defs/checker"}}}},
|
|
111
|
+
{"if": {"properties": {"status": {"enum": ["uncheckable", "disabled"]}}},
|
|
112
|
+
"then": {"required": ["reason"], "properties": {"reason": {"type": "string", "minLength": 1}}}}
|
|
113
|
+
]
|
|
114
|
+
}}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/B0yko/obey/schemas/report-v1.json",
|
|
4
|
+
"title": "obey run report",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema", "obey_version", "generated_at", "agents", "summary", "rules", "scores", "pooled", "suggestions",
|
|
7
|
+
"cost", "compare"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"$defs": {
|
|
10
|
+
"fraction": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
|
|
11
|
+
"pooled": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"required": ["obeyed", "exercised", "runs", "compliance", "ci_low", "ci_high", "blocked_runs", "evasions",
|
|
14
|
+
"evasion_rate"],
|
|
15
|
+
"properties": {
|
|
16
|
+
"obeyed": {"type": "integer", "minimum": 0}, "exercised": {"type": "integer", "minimum": 0},
|
|
17
|
+
"runs": {"type": "integer", "minimum": 0}, "compliance": {"$ref": "#/$defs/fraction"},
|
|
18
|
+
"ci_low": {"$ref": "#/$defs/fraction"}, "ci_high": {"$ref": "#/$defs/fraction"},
|
|
19
|
+
"blocked_runs": {"type": "integer", "minimum": 0}, "evasions": {"type": "integer", "minimum": 0},
|
|
20
|
+
"evasion_rate": {"$ref": "#/$defs/fraction"}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"properties": {
|
|
25
|
+
"schema": {"const": "obey-report-v1"},
|
|
26
|
+
"obey_version": {"type": "string"},
|
|
27
|
+
"generated_at": {"type": "string"},
|
|
28
|
+
"repository": {"type": "string"},
|
|
29
|
+
"hardware": {"type": ["string", "null"]},
|
|
30
|
+
"agents": {"type": "array", "items": {"type": "object", "required": ["agent"],
|
|
31
|
+
"properties": {"agent": {"type": "string"}, "version": {"type": ["string", "null"]},
|
|
32
|
+
"model": {"type": ["string", "null"]}}}},
|
|
33
|
+
"summary": {"type": "array", "items": {"type": "string"}},
|
|
34
|
+
"rules": {"type": "array", "items": {"type": "object", "required": ["id", "text", "source", "checker", "status"],
|
|
35
|
+
"properties": {"id": {"type": "string"}, "text": {"type": "string"}, "source": {"type": "object"},
|
|
36
|
+
"checker": {"type": "string"},
|
|
37
|
+
"status": {"enum": ["active", "proposed", "uncheckable", "disabled"]}}}},
|
|
38
|
+
"scores": {"type": "array", "items": {"type": "object",
|
|
39
|
+
"required": ["rule_id", "agent", "arm", "runs", "exercised", "obeyed", "violated", "compliance",
|
|
40
|
+
"ci_low", "ci_high", "exercised_share", "blocked_runs", "evasions", "evasion_rate",
|
|
41
|
+
"violation_example", "errors"],
|
|
42
|
+
"properties": {"arm": {"enum": ["rule", "baseline", "enforced"]},
|
|
43
|
+
"compliance": {"$ref": "#/$defs/fraction"}, "ci_low": {"$ref": "#/$defs/fraction"},
|
|
44
|
+
"ci_high": {"$ref": "#/$defs/fraction"},
|
|
45
|
+
"exercised_share": {"$ref": "#/$defs/fraction"},
|
|
46
|
+
"evasion_rate": {"$ref": "#/$defs/fraction"}}}},
|
|
47
|
+
"pooled": {"type": "object", "additionalProperties": {"type": "object",
|
|
48
|
+
"additionalProperties": {"$ref": "#/$defs/pooled"}}},
|
|
49
|
+
"suggestions": {"type": "array", "items": {"type": "object", "required": ["rule_id", "kind", "text"]}},
|
|
50
|
+
"cost": {"type": "object", "required": ["total_usd", "runs", "per_run_usd"]},
|
|
51
|
+
"compare": {"type": ["object", "null"], "required": ["rows", "before", "after"],
|
|
52
|
+
"properties": {"rows": {"type": "array"}, "before": {"$ref": "#/$defs/pooled"},
|
|
53
|
+
"after": {"$ref": "#/$defs/pooled"}}}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/B0yko/obey/schemas/survey-record-v1.json",
|
|
4
|
+
"title": "obey survey sample record",
|
|
5
|
+
"description": "One surveyed repository. URLs, SHAs, licence ids and derived booleans only; never file contents.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["repo", "url", "commit_sha", "path", "blob_sha", "license_spdx", "stars", "default_branch",
|
|
9
|
+
"size_slice", "tree_truncated", "has_claude_md", "has_gemini_md", "has_cursor_rules",
|
|
10
|
+
"has_copilot_instructions", "claude_settings_hooks", "gemini_settings_hooks", "enforcement"],
|
|
11
|
+
"properties": {
|
|
12
|
+
"repo": {"type": "string", "pattern": "^[^/]+/[^/]+$"},
|
|
13
|
+
"url": {"type": "string", "format": "uri"},
|
|
14
|
+
"commit_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"},
|
|
15
|
+
"path": {"const": "AGENTS.md"},
|
|
16
|
+
"blob_sha": {"type": "string"},
|
|
17
|
+
"license_spdx": {"type": ["string", "null"]},
|
|
18
|
+
"stars": {"type": "integer", "minimum": 0},
|
|
19
|
+
"default_branch": {"type": "string"},
|
|
20
|
+
"size_slice": {"type": "string"},
|
|
21
|
+
"tree_truncated": {"type": "boolean"},
|
|
22
|
+
"has_claude_md": {"type": "boolean"},
|
|
23
|
+
"has_gemini_md": {"type": "boolean"},
|
|
24
|
+
"has_cursor_rules": {"type": "boolean"},
|
|
25
|
+
"has_copilot_instructions": {"type": "boolean"},
|
|
26
|
+
"claude_settings_hooks": {"type": "boolean"},
|
|
27
|
+
"gemini_settings_hooks": {"type": "boolean"},
|
|
28
|
+
"enforcement": {"type": "object", "additionalProperties": {"type": "boolean"}}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Deterministic checkers: one verdict per rule per run, with evidence.
|
|
2
|
+
|
|
3
|
+
The logic lives in the stdlib-only rule engine (`obey_cli.enforce.engine.obey_engine`), which `obey enforce` also
|
|
4
|
+
vendors into generated hooks, so what a hook blocks is exactly what a checker measures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from obey_cli.enforce.engine import obey_engine as engine
|
|
14
|
+
|
|
15
|
+
TYPES = tuple(engine.CHECKS)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Verdict:
|
|
20
|
+
verdict: str # obeyed | violated | not_exercised
|
|
21
|
+
evidence: list[dict[str, Any]] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check(rule: Mapping[str, Any], run: Mapping[str, Any]) -> Verdict:
|
|
25
|
+
"""Verdict of `rule` (a checker {"type", "params"} or a rule holding one) on a normalised run record."""
|
|
26
|
+
result = engine.check(dict(rule), dict(run))
|
|
27
|
+
return Verdict(verdict=str(result["verdict"]), evidence=list(result.get("evidence") or []))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = ["TYPES", "Verdict", "check", "engine"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Decide labelled extraction samples with the engine (used by eval/run_eval.py to score extracted parameters).
|
|
2
|
+
|
|
3
|
+
A sample is flagged when the extracted checker reports a violation for a minimal run built from it; see
|
|
4
|
+
fixtures/extraction for the sample formats.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from obey_cli.enforce.engine import obey_engine as engine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _empty() -> dict[str, Any]:
|
|
16
|
+
return {"events": [], "diff": "", "base_files": [], "commits": [], "pushed_refs": [], "final_message": ""}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _modified(path: str, line: str = "x = 1") -> str:
|
|
20
|
+
return f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1,0 +1,1 @@\n+{line}\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _split(sample: str) -> tuple[str, str]:
|
|
24
|
+
path, _, line = sample.partition(": ")
|
|
25
|
+
return path.strip(), line
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_for(checker: dict[str, Any], sample: Any) -> dict[str, Any]:
|
|
29
|
+
kind = checker["type"]
|
|
30
|
+
run = _empty()
|
|
31
|
+
if kind in ("command_forbidden", "no_commit", "no_push"):
|
|
32
|
+
run["events"] = [{"seq": 0, "type": "shell", "command": str(sample), "exit_code": 0}]
|
|
33
|
+
elif kind == "command_required":
|
|
34
|
+
globs = checker["params"].get("edit_globs") or ["**"]
|
|
35
|
+
path = "src/app.py"
|
|
36
|
+
for candidate in ("src/app.py", "app/main.py", "src/index.ts", "main.go", "src/lib.rs", "docs/index.md"):
|
|
37
|
+
if engine.glob_match(globs, candidate):
|
|
38
|
+
path = candidate
|
|
39
|
+
break
|
|
40
|
+
else:
|
|
41
|
+
path = globs[0].replace("**/", "").replace("**", "x").replace("*", "x")
|
|
42
|
+
run["events"] = [
|
|
43
|
+
{"seq": 0, "type": "edit", "path": path},
|
|
44
|
+
{"seq": 1, "type": "shell", "command": str(sample), "exit_code": 0},
|
|
45
|
+
]
|
|
46
|
+
run["diff"] = _modified(path)
|
|
47
|
+
elif kind == "path_forbidden":
|
|
48
|
+
run["events"] = [{"seq": 0, "type": "write", "path": str(sample)}]
|
|
49
|
+
run["base_files"] = [str(sample)]
|
|
50
|
+
elif kind == "path_required_with":
|
|
51
|
+
files = sample if isinstance(sample, list) else [sample]
|
|
52
|
+
run["diff"] = "".join(_modified(str(f)) for f in files)
|
|
53
|
+
elif kind == "dependency_unchanged":
|
|
54
|
+
text = str(sample)
|
|
55
|
+
if ": +" in text:
|
|
56
|
+
path, _, name = text.partition(": +")
|
|
57
|
+
path = path.strip()
|
|
58
|
+
base = path.rsplit("/", 1)[-1]
|
|
59
|
+
if base == "package.json":
|
|
60
|
+
line = f' "{name.strip()}": "^1.0.0",'
|
|
61
|
+
elif base == "go.mod":
|
|
62
|
+
line = f"\t{name.strip()} v1.0.0"
|
|
63
|
+
elif base == "Cargo.toml":
|
|
64
|
+
line = f'{name.strip()} = "1.0"'
|
|
65
|
+
elif base.startswith("requirements"):
|
|
66
|
+
line = f"{name.strip()}>=1.0"
|
|
67
|
+
else:
|
|
68
|
+
line = f' "{name.strip()}>=1.0",'
|
|
69
|
+
run["diff"] = _modified(path, line)
|
|
70
|
+
else:
|
|
71
|
+
run["events"] = [{"seq": 0, "type": "shell", "command": text, "exit_code": 0}]
|
|
72
|
+
elif kind == "content_forbidden_added":
|
|
73
|
+
path, line = _split(str(sample))
|
|
74
|
+
run["diff"] = _modified(path, line)
|
|
75
|
+
elif kind == "content_required_added":
|
|
76
|
+
path, line = _split(str(sample))
|
|
77
|
+
run["diff"] = engine.new_file_diff(path, line + "\n")
|
|
78
|
+
elif kind == "commit_message":
|
|
79
|
+
run["commits"] = [{"sha": "0" * 40, "message": str(sample), "files": []}]
|
|
80
|
+
elif kind == "file_naming":
|
|
81
|
+
run["diff"] = engine.new_file_diff(str(sample), "x\n")
|
|
82
|
+
elif kind == "final_message":
|
|
83
|
+
run["final_message"] = str(sample)
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"unknown checker type {kind}")
|
|
86
|
+
return run
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def classify_sample(checker: dict[str, Any], sample: Any) -> bool:
|
|
90
|
+
"""True when the sample breaks the rule according to the extracted checker."""
|
|
91
|
+
try:
|
|
92
|
+
verdict = engine.check(checker, run_for(checker, sample))
|
|
93
|
+
except (KeyError, ValueError, TypeError, json.JSONDecodeError):
|
|
94
|
+
return False
|
|
95
|
+
return bool(verdict["verdict"] == "violated")
|