@compr/opscontext-mcp 2.1.3 ā 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents.d.ts +32 -1
- package/dist/agents.js +376 -88
- package/dist/cli.js +14 -1
- package/dist/config.js +1 -0
- package/dist/rubric.d.ts +45 -0
- package/dist/rubric.js +9 -0
- package/package.json +2 -2
package/dist/agents.d.ts
CHANGED
|
@@ -102,9 +102,32 @@ export interface ScoreCheck {
|
|
|
102
102
|
category: string;
|
|
103
103
|
points: number;
|
|
104
104
|
maxPoints: number;
|
|
105
|
-
status: "pass" | "partial" | "fail";
|
|
105
|
+
status: "pass" | "partial" | "fail" | "unknown";
|
|
106
106
|
detail: string;
|
|
107
|
+
/**
|
|
108
|
+
* A failure here is disqualifying: it caps the overall grade regardless of the other points.
|
|
109
|
+
* Reserved for actual secret exposure ā see [SECURITY-IS-DISQUALIFYING].
|
|
110
|
+
*/
|
|
111
|
+
disqualifying?: boolean;
|
|
107
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* š LOCKED [ABSENCE-IS-NOT-A-VERDICT] ā 2026-08-13
|
|
115
|
+
* ā NEVER emit "pass" or "fail" for a condition the check could not actually determine,
|
|
116
|
+
* and NEVER write a detail string that hides WHICH locations were inspected.
|
|
117
|
+
* WHY: three live bugs, all the same shape ā the scorer wrote *absence of evidence* down as
|
|
118
|
+
* a *verdict*, and the verdict pointed at the wrong fix.
|
|
119
|
+
* 1. `copilot-instructions.md`/`SKILLS.md` looked only in .github/ and reported files that
|
|
120
|
+
* existed at the repo root as "Missing" (fixed ffa5914, [DOC-PATH-DUAL]).
|
|
121
|
+
* 2. `Git hooks` reported "No hooks ā consider auto-push" for a hook that WAS installed but
|
|
122
|
+
* whose symlink target had been deleted. existsSync() follows symlinks, so dangling and
|
|
123
|
+
* absent are indistinguishable. CE's own Drive backup drifted while the row said
|
|
124
|
+
* "consider auto-push" ā advice for a problem the repo did not have.
|
|
125
|
+
* 3. `Secrets exposure` awarded a full 6/6 PASS with the detail "No .env or not a git repo" ā
|
|
126
|
+
* full marks for a state it openly could not distinguish.
|
|
127
|
+
* FIX: a check that cannot determine its condition emits status "unknown" (0 points, rendered ā,
|
|
128
|
+
* excluded from the remediation list ā it is a gap in the CHECK, not in the project). Every
|
|
129
|
+
* pass/fail detail must name what was inspected. Absence is a measurement, not a decision.
|
|
130
|
+
*/
|
|
108
131
|
export interface ProjectScore {
|
|
109
132
|
project: string;
|
|
110
133
|
path: string;
|
|
@@ -114,6 +137,14 @@ export interface ProjectScore {
|
|
|
114
137
|
grade: string;
|
|
115
138
|
checks: ScoreCheck[];
|
|
116
139
|
}
|
|
140
|
+
export interface CanaryResult {
|
|
141
|
+
ok: boolean;
|
|
142
|
+
/** Human-readable deviations from the pinned expectation. Empty when ok. */
|
|
143
|
+
deviations: string[];
|
|
144
|
+
/** True when the canary itself could not be built ā an unknown, not a failure. */
|
|
145
|
+
inconclusive: boolean;
|
|
146
|
+
}
|
|
147
|
+
export declare function runScoreCanary(): CanaryResult;
|
|
117
148
|
/**
|
|
118
149
|
* Score a project's AI-readiness (0-100%).
|
|
119
150
|
* Checks how well-prepared a project is for AI coding agents.
|
package/dist/agents.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execSync } from "child_process";
|
|
2
|
-
import { readFileSync, existsSync, readdirSync, lstatSync } from "fs";
|
|
2
|
+
import { readFileSync, existsSync, readdirSync, statSync, lstatSync, readlinkSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs";
|
|
3
3
|
import { resolve, join, dirname } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
4
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { RUBRIC } from "./rubric.js";
|
|
5
7
|
// Read version from package.json at module load
|
|
6
8
|
const __agents_dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
let AGENTS_VERSION = "1.23.0";
|
|
@@ -14,16 +16,34 @@ catch { /* fallback */ }
|
|
|
14
16
|
// Helpers
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
16
18
|
function exec(cmd, cwd) {
|
|
19
|
+
return execChecked(cmd, cwd).output;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* exec() that distinguishes "ran, produced nothing" from "did not run".
|
|
23
|
+
*
|
|
24
|
+
* š LOCKED [EXEC-FAILURE-IS-NOT-EMPTY] ā 2026-08-13
|
|
25
|
+
* ā NEVER let a caller treat exec()'s "" as a factual answer for a check that can FAIL SAFE.
|
|
26
|
+
* WHY: exec() returns "" for every failure mode ā command not found, not a git repo, timeout,
|
|
27
|
+
* permission denied ā indistinguishable from a successful empty result. The `Secrets exposure`
|
|
28
|
+
* check read `git ls-files .env` ā "" as ".env is not tracked" and awarded a full 6/6 PASS.
|
|
29
|
+
* A security check that hands out full marks precisely when it cannot run is worse than one
|
|
30
|
+
* that fails: it manufactures reassurance. Found by [SCORE-CANARY] on its first run, against
|
|
31
|
+
* a fixture with a .git directory that is not a real repository.
|
|
32
|
+
* FIX: security- and correctness-relevant callers use execChecked() and emit "unknown" when
|
|
33
|
+
* ok === false. Absence of output is a measurement, not a decision.
|
|
34
|
+
*/
|
|
35
|
+
function execChecked(cmd, cwd) {
|
|
17
36
|
try {
|
|
18
|
-
|
|
37
|
+
const output = execSync(cmd, {
|
|
19
38
|
cwd,
|
|
20
39
|
encoding: "utf-8",
|
|
21
40
|
timeout: 10_000,
|
|
22
41
|
stdio: ["pipe", "pipe", "pipe"],
|
|
23
42
|
}).trim();
|
|
43
|
+
return { ok: true, output };
|
|
24
44
|
}
|
|
25
45
|
catch {
|
|
26
|
-
return "";
|
|
46
|
+
return { ok: false, output: "" };
|
|
27
47
|
}
|
|
28
48
|
}
|
|
29
49
|
/**
|
|
@@ -37,6 +57,66 @@ function isSymlink(filePath) {
|
|
|
37
57
|
return false;
|
|
38
58
|
}
|
|
39
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Status icon for a score check. "unknown" is ā ā the check could not determine its
|
|
62
|
+
* condition, which is a gap in the audit, not a verdict on the project.
|
|
63
|
+
* See [ABSENCE-IS-NOT-A-VERDICT].
|
|
64
|
+
*/
|
|
65
|
+
function statusIcon(status) {
|
|
66
|
+
if (status === "pass")
|
|
67
|
+
return "ā
";
|
|
68
|
+
if (status === "partial")
|
|
69
|
+
return "š”";
|
|
70
|
+
if (status === "unknown")
|
|
71
|
+
return "ā";
|
|
72
|
+
return "ā";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Topics an agent doc must cover to be useful. Matched case-insensitively anywhere in the
|
|
76
|
+
* document, so a project's own heading vocabulary still counts ā this rewards covering the
|
|
77
|
+
* subject, not adopting our wording. See [SCORE-CONTENT-NOT-LENGTH].
|
|
78
|
+
*/
|
|
79
|
+
const AGENT_DOC_TOPICS = [
|
|
80
|
+
{ label: "architecture", patterns: /\b(architecture|structure|layout|stack|components?|modules?)\b/i },
|
|
81
|
+
{ label: "commands", patterns: /\b(commands?|scripts?|npm run|yarn |pnpm |make |getting started|setup|install)\b/i },
|
|
82
|
+
{ label: "rules", patterns: /\b(rules?|conventions?|guidelines?|standards?|policy|policies|do not|never|always)\b/i },
|
|
83
|
+
{ label: "key files", patterns: /\b(key files?|important files?|entry ?point|file (map|tree)|directory)\b/i },
|
|
84
|
+
];
|
|
85
|
+
/** Labels of the topics this document covers. */
|
|
86
|
+
function matchDocTopics(content, topics) {
|
|
87
|
+
return topics.filter(t => t.patterns.test(content)).map(t => t.label);
|
|
88
|
+
}
|
|
89
|
+
/** Symlink target for diagnostics, or "?" if unreadable. Never throws. */
|
|
90
|
+
function readlinkSafe(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
return readlinkSync(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return "?";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Resolve an agent doc that may live in `.github/` or at the repo root.
|
|
100
|
+
* Returns the first location that exists (`.github/` wins), or null.
|
|
101
|
+
*
|
|
102
|
+
* š LOCKED [DOC-PATH-DUAL] ā 2026-08-07
|
|
103
|
+
* ā NEVER collapse a caller back to a single hardcoded join(p, ".github", file).
|
|
104
|
+
* WHY: the scorer read `.github/` only, while `contextengine init` writes SKILLS.md
|
|
105
|
+
* at the repo root and the generated pre-commit hook accepts both. Every project
|
|
106
|
+
* keeping these at root scored 0/10 + 0/3 for files that existed, so SCORE.md
|
|
107
|
+
* rows had to be hand-corrected after every run (caught 2026-08-07 on this repo).
|
|
108
|
+
* FIX: score whichever location exists ā `.github/` first (Copilot's official read
|
|
109
|
+
* path), repo root second. Presence is what earns the points, not the directory.
|
|
110
|
+
*/
|
|
111
|
+
function resolveDocPath(projectPath, file) {
|
|
112
|
+
const inGithub = join(projectPath, ".github", file);
|
|
113
|
+
if (existsSync(inGithub))
|
|
114
|
+
return { path: inGithub, rel: `.github/${file}` };
|
|
115
|
+
const atRoot = join(projectPath, file);
|
|
116
|
+
if (existsSync(atRoot))
|
|
117
|
+
return { path: atRoot, rel: file };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
40
120
|
/**
|
|
41
121
|
* Check if an ESLint config is actually backed by installed packages.
|
|
42
122
|
* Returns true if at least `eslint` itself is installed in node_modules.
|
|
@@ -909,6 +989,91 @@ export function formatPortMap(ports, conflicts) {
|
|
|
909
989
|
}
|
|
910
990
|
return lines.join("\n");
|
|
911
991
|
}
|
|
992
|
+
/**
|
|
993
|
+
* š LOCKED [SCORE-CANARY] ā 2026-08-13
|
|
994
|
+
* ā NEVER weaken this to "test one known bug", and never let a deviation be a warning only.
|
|
995
|
+
* WHY: every guard in this file was written AFTER the failure it prevents ā the doc-path bug, the
|
|
996
|
+
* dangling-hook bug, the shrinking-denominator bug. A wall of named guards is a museum of past
|
|
997
|
+
* mistakes: valuable, but it does nothing about the next one. The canary is the only check here
|
|
998
|
+
* that can catch a failure nobody predicted, because it does not test for a specific defect ā
|
|
999
|
+
* it demands that EVERY health signal still reads exactly as pinned before the scorer is
|
|
1000
|
+
* allowed to write a single file.
|
|
1001
|
+
* FIX: build a synthetic project whose every awkward case is represented (doc at repo root, doc in
|
|
1002
|
+
* .github/, dangling hook symlink, .env with no git, no package manager), score it, and compare
|
|
1003
|
+
* against the pinned expectation. Any drift blocks the run. If the fixture cannot be built at
|
|
1004
|
+
* all, that is `inconclusive` ā an unknown, per [ABSENCE-IS-NOT-A-VERDICT] ā and must not be
|
|
1005
|
+
* silently treated as a pass.
|
|
1006
|
+
*
|
|
1007
|
+
* Runs in production on every scoring run, not just in CI. Costs a few milliseconds.
|
|
1008
|
+
*/
|
|
1009
|
+
/**
|
|
1010
|
+
* True while the canary fixture is being scored. The fixture deliberately leaves 9 points
|
|
1011
|
+
* unassessable (no package manager, no .gitignore), so its invariant log line is EXPECTED ā
|
|
1012
|
+
* printing it on every run would train the reader to ignore the warning that matters.
|
|
1013
|
+
*/
|
|
1014
|
+
let canaryRunning = false;
|
|
1015
|
+
export function runScoreCanary() {
|
|
1016
|
+
let dir = null;
|
|
1017
|
+
canaryRunning = true;
|
|
1018
|
+
try {
|
|
1019
|
+
dir = mkdtempSync(join(tmpdir(), "ce-canary-"));
|
|
1020
|
+
// Deliberately awkward, all legal, each one a past or plausible failure:
|
|
1021
|
+
mkdirSync(join(dir, ".github"), { recursive: true });
|
|
1022
|
+
// doc in .github/ ā covers all required topics, so it must score full marks on CONTENT.
|
|
1023
|
+
// Deliberately short: length must not be what earns it. See [SCORE-CONTENT-NOT-LENGTH].
|
|
1024
|
+
writeFileSync(join(dir, ".github", "copilot-instructions.md"), [
|
|
1025
|
+
"# Canary", "", "## Architecture", "Two modules and a CLI entry point.", "",
|
|
1026
|
+
"## Commands", "`npm run build`, `npm test`.", "",
|
|
1027
|
+
"## Rules", "Never commit secrets.", "",
|
|
1028
|
+
"## Key files", "`src/index.ts` is the entry point.", "",
|
|
1029
|
+
...Array.from({ length: 20 }, (_, i) => `filler ${i + 1}`),
|
|
1030
|
+
].join("\n"));
|
|
1031
|
+
// doc at repo ROOT ā the [DOC-PATH-DUAL] case, should score the full 3
|
|
1032
|
+
writeFileSync(join(dir, "SKILLS.md"), Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join("\n"));
|
|
1033
|
+
// hook installed but BROKEN ā the 2026-06-10 case, must read as fail-broken not fail-absent
|
|
1034
|
+
mkdirSync(join(dir, ".git", "hooks"), { recursive: true });
|
|
1035
|
+
symlinkSync(join(dir, "hooks", "post-commit"), join(dir, ".git", "hooks", "post-commit"));
|
|
1036
|
+
// .env with no git repo ā unverifiable, must be `unknown` and must NOT collect points
|
|
1037
|
+
writeFileSync(join(dir, ".env"), "SECRET=canary\n");
|
|
1038
|
+
const score = scoreProject({ name: "ce-canary", path: dir });
|
|
1039
|
+
const by = (n) => score.checks.find(c => c.name === n);
|
|
1040
|
+
const deviations = [];
|
|
1041
|
+
const expect = (label, actual, wanted) => {
|
|
1042
|
+
if (actual !== wanted)
|
|
1043
|
+
deviations.push(`${label}: expected ${JSON.stringify(wanted)}, got ${JSON.stringify(actual)}`);
|
|
1044
|
+
};
|
|
1045
|
+
expect("copilot-instructions.md points", by("copilot-instructions.md")?.points, 6);
|
|
1046
|
+
expect("copilot scored on topics not length", by("copilot-instructions.md")?.detail.includes("covers"), true);
|
|
1047
|
+
expect("SKILLS.md points (repo root)", by("SKILLS.md")?.points, 3);
|
|
1048
|
+
// No .git worktree here, so freshness is unmeasurable ā it must be unknown, never a pass.
|
|
1049
|
+
expect("Doc freshness status", by("Doc freshness")?.status, "unknown");
|
|
1050
|
+
expect("Doc freshness points", by("Doc freshness")?.points, 0);
|
|
1051
|
+
expect("Git hooks status", by("Git hooks")?.status, "fail");
|
|
1052
|
+
expect("Git hooks names the break", by("Git hooks")?.detail.includes("BROKEN"), true);
|
|
1053
|
+
expect("Secrets exposure status", by("Secrets exposure")?.status, "unknown");
|
|
1054
|
+
expect("Secrets exposure points", by("Secrets exposure")?.points, 0);
|
|
1055
|
+
expect("denominator pinned to 100", score.maxScore, 100);
|
|
1056
|
+
expect("maxPoints sum to 100", score.checks.reduce((s, c) => s + c.maxPoints, 0), 100);
|
|
1057
|
+
expect("percentage matches score/100", score.percentage, Math.round(score.score));
|
|
1058
|
+
return { ok: deviations.length === 0, deviations, inconclusive: false };
|
|
1059
|
+
}
|
|
1060
|
+
catch (err) {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
inconclusive: true,
|
|
1064
|
+
deviations: [`canary fixture could not be built: ${err instanceof Error ? err.message : String(err)}`],
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
canaryRunning = false;
|
|
1069
|
+
if (dir) {
|
|
1070
|
+
try {
|
|
1071
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1072
|
+
}
|
|
1073
|
+
catch { /* temp dir cleanup is best-effort */ }
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
912
1077
|
/**
|
|
913
1078
|
* Score a project's AI-readiness (0-100%).
|
|
914
1079
|
* Checks how well-prepared a project is for AI coding agents.
|
|
@@ -917,101 +1082,157 @@ export function scoreProject(dir) {
|
|
|
917
1082
|
const checks = [];
|
|
918
1083
|
const p = dir.path;
|
|
919
1084
|
// --- Documentation (30 points max) ---
|
|
920
|
-
// copilot-instructions.md (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1085
|
+
// copilot-instructions.md (6 pts) ā scored on CONTENT, not length.
|
|
1086
|
+
// š LOCKED [SCORE-CONTENT-NOT-LENGTH] ā 2026-08-14
|
|
1087
|
+
// ā NEVER score an agent doc on line count alone.
|
|
1088
|
+
// WHY: this was 10 points ā a tenth of the entire score ā awarded for ">50 lines". Fifty lines
|
|
1089
|
+
// of anything earned full marks, so the largest single check in the rubric was also the
|
|
1090
|
+
// easiest to satisfy without doing the work. Length is a proxy for effort; it measures
|
|
1091
|
+
// typing, not usefulness to an agent.
|
|
1092
|
+
// FIX: score the sections an agent actually needs ā architecture, commands, rules/conventions,
|
|
1093
|
+
// key files. Length survives only as a floor to reject stubs. Same approach the
|
|
1094
|
+
// .env.example check already used (counting real variables, not lines).
|
|
1095
|
+
const copilot = resolveDocPath(p, "copilot-instructions.md");
|
|
1096
|
+
if (copilot) {
|
|
1097
|
+
const copilotIsSymlink = isSymlink(copilot.path);
|
|
1098
|
+
const content = readFileSync(copilot.path, "utf-8");
|
|
925
1099
|
const lines = content.split("\n").length;
|
|
1100
|
+
const found = matchDocTopics(content, AGENT_DOC_TOPICS);
|
|
1101
|
+
const at = `${copilot.rel}, ${lines} lines`;
|
|
926
1102
|
if (copilotIsSymlink) {
|
|
927
|
-
checks.push({ name: "copilot-instructions.md", category: "Documentation", points:
|
|
1103
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 2, maxPoints: 6, status: "partial", detail: `ā Symlink (${at}) ā should be a real file with project-specific context` });
|
|
1104
|
+
}
|
|
1105
|
+
else if (found.length >= AGENT_DOC_TOPICS.length) {
|
|
1106
|
+
// Content decides, and it decides FIRST. A short doc that covers everything an agent needs
|
|
1107
|
+
// beats a long one that covers nothing ā that inversion is the whole point of this rework,
|
|
1108
|
+
// so the stub floor below must never be allowed to override a complete document.
|
|
1109
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `${at} ā covers ${found.join(", ")}` });
|
|
928
1110
|
}
|
|
929
|
-
else if (lines
|
|
930
|
-
checks.push({ name: "copilot-instructions.md", category: "Documentation", points:
|
|
1111
|
+
else if (lines <= RUBRIC.copilotPartial) {
|
|
1112
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 1, maxPoints: 6, status: "partial", detail: `${at} ā a stub; add architecture, commands, rules, key files` });
|
|
931
1113
|
}
|
|
932
|
-
else if (
|
|
933
|
-
|
|
1114
|
+
else if (found.length > 0) {
|
|
1115
|
+
const missing = AGENT_DOC_TOPICS.filter(t => !found.includes(t.label)).map(t => t.label);
|
|
1116
|
+
const pts = found.length >= AGENT_DOC_TOPICS.length - 1 ? 4 : 2;
|
|
1117
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: pts, maxPoints: 6, status: "partial", detail: `${at} ā has ${found.join(", ")}; missing ${missing.join(", ")}` });
|
|
934
1118
|
}
|
|
935
1119
|
else {
|
|
936
|
-
checks.push({ name: "copilot-instructions.md", category: "Documentation", points:
|
|
1120
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 1, maxPoints: 6, status: "partial", detail: `${at} ā none of ${AGENT_DOC_TOPICS.map(t => t.label).join("/")} found; length without structure` });
|
|
937
1121
|
}
|
|
938
1122
|
}
|
|
939
1123
|
else {
|
|
940
|
-
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints:
|
|
1124
|
+
checks.push({ name: "copilot-instructions.md", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "Missing from .github/ and repo root ā AI agents lack project context" });
|
|
1125
|
+
}
|
|
1126
|
+
// Doc freshness (4 pts) ā is the agent doc keeping up with the code?
|
|
1127
|
+
// š LOCKED [SCORE-DOC-FRESHNESS] ā 2026-08-14
|
|
1128
|
+
// ā NEVER treat a doc's existence as evidence that it is current.
|
|
1129
|
+
// WHY: a 500-line copilot-instructions.md last touched a year ago scored identically to one
|
|
1130
|
+
// updated yesterday. Stale agent docs are worse than missing ones ā an agent trusts them.
|
|
1131
|
+
// FIX: count commits that touched the repo since the doc was last modified, excluding the doc
|
|
1132
|
+
// itself. The same signal firewall.ts already computes for its staleness nudges.
|
|
1133
|
+
// [EXEC-FAILURE-IS-NOT-EMPTY]: if git cannot answer, this is "unknown", never a pass.
|
|
1134
|
+
if (copilot && existsSync(join(p, ".git"))) {
|
|
1135
|
+
const since = new Date(statSync(copilot.path).mtimeMs).toISOString();
|
|
1136
|
+
// NEVER pipe this through `| wc -l`: a shell pipeline reports the LAST command's exit status,
|
|
1137
|
+
// so a failed `git log` still exits 0 with output "0" ā which reads as "0 commits since the
|
|
1138
|
+
// doc changed ā fully current". Caught by [SCORE-CANARY]. Count in JS where failure is visible.
|
|
1139
|
+
const { ok, output } = execChecked(`git --no-pager log --oneline --since="${since}" -- . ":!${copilot.rel}"`, p);
|
|
1140
|
+
const drift = ok ? (output ? output.split("\n").length : 0) : NaN;
|
|
1141
|
+
if (!ok || Number.isNaN(drift)) {
|
|
1142
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "unknown", detail: "ā git log failed here ā cannot tell whether the agent doc is current" });
|
|
1143
|
+
}
|
|
1144
|
+
else if (drift === 0) {
|
|
1145
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: "Agent doc is the most recent change ā fully current" });
|
|
1146
|
+
}
|
|
1147
|
+
else if (drift <= RUBRIC.freshnessGood) {
|
|
1148
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: `${drift} commit(s) since the agent doc was updated` });
|
|
1149
|
+
}
|
|
1150
|
+
else if (drift <= RUBRIC.freshnessStale) {
|
|
1151
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 2, maxPoints: 4, status: "partial", detail: `${drift} commits since the agent doc was updated ā drifting` });
|
|
1152
|
+
}
|
|
1153
|
+
else {
|
|
1154
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: `${drift} commits since the agent doc was updated ā agents are reading stale context` });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
else if (copilot) {
|
|
1158
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "unknown", detail: "ā Not a git repo ā cannot measure doc drift" });
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
checks.push({ name: "Doc freshness", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: "No agent doc to keep fresh" });
|
|
941
1162
|
}
|
|
942
1163
|
// README.md (8 pts)
|
|
943
1164
|
const readmePath = join(p, "README.md");
|
|
944
1165
|
if (existsSync(readmePath)) {
|
|
945
1166
|
const content = readFileSync(readmePath, "utf-8");
|
|
946
1167
|
const readmeLines = content.split("\n").length;
|
|
947
|
-
if (readmeLines >
|
|
948
|
-
checks.push({ name: "README.md", category: "Documentation", points:
|
|
1168
|
+
if (readmeLines > RUBRIC.readmeFull) {
|
|
1169
|
+
checks.push({ name: "README.md", category: "Documentation", points: 6, maxPoints: 6, status: "pass", detail: `${readmeLines} lines` });
|
|
949
1170
|
}
|
|
950
1171
|
else {
|
|
951
|
-
checks.push({ name: "README.md", category: "Documentation", points:
|
|
1172
|
+
checks.push({ name: "README.md", category: "Documentation", points: 3, maxPoints: 6, status: "partial", detail: `${readmeLines} lines ā sparse` });
|
|
952
1173
|
}
|
|
953
1174
|
}
|
|
954
1175
|
else {
|
|
955
|
-
checks.push({ name: "README.md", category: "Documentation", points: 0, maxPoints:
|
|
1176
|
+
checks.push({ name: "README.md", category: "Documentation", points: 0, maxPoints: 6, status: "fail", detail: "Missing" });
|
|
956
1177
|
}
|
|
957
1178
|
// CLAUDE.md / .cursorrules / AGENTS.md (6 pts)
|
|
958
1179
|
const altPatterns = ["CLAUDE.md", ".cursorrules", ".cursor/rules", "AGENTS.md"];
|
|
959
1180
|
const foundAlt = altPatterns.filter(pat => existsSync(join(p, pat)));
|
|
960
1181
|
const realAlt = foundAlt.filter(pat => !isSymlink(join(p, pat)));
|
|
961
1182
|
const symlinkAlt = foundAlt.filter(pat => isSymlink(join(p, pat)));
|
|
962
|
-
if (realAlt.length >=
|
|
963
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points:
|
|
1183
|
+
if (realAlt.length >= RUBRIC.multiAgentFull) {
|
|
1184
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 4, maxPoints: 4, status: "pass", detail: `Found: ${realAlt.join(", ")}` });
|
|
964
1185
|
}
|
|
965
1186
|
else if (realAlt.length === 1 && symlinkAlt.length >= 1) {
|
|
966
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points:
|
|
1187
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 3, maxPoints: 4, status: "partial", detail: `${realAlt[0]} + ${symlinkAlt.length} symlink(s) ā symlinks count as partial` });
|
|
967
1188
|
}
|
|
968
|
-
else if (foundAlt.length >=
|
|
969
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points:
|
|
1189
|
+
else if (foundAlt.length >= RUBRIC.multiAgentFull && realAlt.length === 0) {
|
|
1190
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 1, maxPoints: 4, status: "partial", detail: `${foundAlt.join(", ")} ā all symlinks, create real per-agent files` });
|
|
970
1191
|
}
|
|
971
1192
|
else if (foundAlt.length === 1) {
|
|
972
1193
|
const pts = isSymlink(join(p, foundAlt[0])) ? 1 : 3;
|
|
973
1194
|
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: pts, maxPoints: 6, status: "partial", detail: `Found: ${foundAlt[0]}${isSymlink(join(p, foundAlt[0])) ? " (symlink)" : ""} only` });
|
|
974
1195
|
}
|
|
975
1196
|
else {
|
|
976
|
-
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints:
|
|
1197
|
+
checks.push({ name: "Multi-agent patterns", category: "Documentation", points: 0, maxPoints: 4, status: "fail", detail: "No CLAUDE.md, .cursorrules, or AGENTS.md" });
|
|
977
1198
|
}
|
|
978
|
-
//
|
|
979
|
-
const
|
|
980
|
-
if (
|
|
981
|
-
const skillsContent = readFileSync(
|
|
1199
|
+
// SKILLS.md (3 pts) ā .github/ or repo root, see resolveDocPath LOCK
|
|
1200
|
+
const skills = resolveDocPath(p, "SKILLS.md");
|
|
1201
|
+
if (skills) {
|
|
1202
|
+
const skillsContent = readFileSync(skills.path, "utf-8");
|
|
982
1203
|
const skillsLines = skillsContent.split("\n").length;
|
|
983
|
-
if (skillsLines >
|
|
984
|
-
checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines` });
|
|
1204
|
+
if (skillsLines > RUBRIC.skillsFull && !isSymlink(skills.path)) {
|
|
1205
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 3, maxPoints: 3, status: "pass", detail: `${skillsLines} lines (${skills.rel})` });
|
|
985
1206
|
}
|
|
986
1207
|
else {
|
|
987
|
-
checks.push({ name: "SKILLS.md", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `${skillsLines} lines${isSymlink(
|
|
1208
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 1, maxPoints: 3, status: "partial", detail: `${skillsLines} lines (${skills.rel})${isSymlink(skills.path) ? " ā symlink" : ""} ā add real skill descriptions` });
|
|
988
1209
|
}
|
|
989
1210
|
}
|
|
990
1211
|
else {
|
|
991
|
-
checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing ā agents can't discover capabilities" });
|
|
1212
|
+
checks.push({ name: "SKILLS.md", category: "Documentation", points: 0, maxPoints: 3, status: "fail", detail: "Missing from .github/ and repo root ā agents can't discover capabilities" });
|
|
992
1213
|
}
|
|
993
1214
|
// .env.example (3 pts) ā validates actual content, not just existence
|
|
994
1215
|
const envExamplePath = join(p, ".env.example");
|
|
995
1216
|
if (existsSync(envExamplePath)) {
|
|
996
1217
|
const envContent = readFileSync(envExamplePath, "utf-8");
|
|
997
1218
|
const envVarLines = envContent.split("\n").filter(l => /^[A-Z_]+=/.test(l.trim())).length;
|
|
998
|
-
if (envVarLines >=
|
|
999
|
-
checks.push({ name: ".env.example", category: "Documentation", points:
|
|
1219
|
+
if (envVarLines >= RUBRIC.envExampleVars) {
|
|
1220
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 2, maxPoints: 2, status: "pass", detail: `${envVarLines} env vars documented` });
|
|
1000
1221
|
}
|
|
1001
1222
|
else {
|
|
1002
|
-
checks.push({ name: ".env.example", category: "Documentation", points: 1, maxPoints:
|
|
1223
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 1, maxPoints: 2, status: "partial", detail: `Only ${envVarLines} env var(s) ā add all required vars` });
|
|
1003
1224
|
}
|
|
1004
1225
|
}
|
|
1005
1226
|
else {
|
|
1006
|
-
checks.push({ name: ".env.example", category: "Documentation", points: 0, maxPoints:
|
|
1227
|
+
checks.push({ name: ".env.example", category: "Documentation", points: 0, maxPoints: 2, status: "fail", detail: "Missing ā agents can't set up env" });
|
|
1007
1228
|
}
|
|
1008
1229
|
// --- Infrastructure (30 points max) ---
|
|
1009
1230
|
// Git repo (5 pts)
|
|
1010
1231
|
if (existsSync(join(p, ".git"))) {
|
|
1011
|
-
checks.push({ name: "Git repository", category: "Infrastructure", points:
|
|
1232
|
+
checks.push({ name: "Git repository", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Initialized" });
|
|
1012
1233
|
}
|
|
1013
1234
|
else {
|
|
1014
|
-
checks.push({ name: "Git repository", category: "Infrastructure", points: 0, maxPoints:
|
|
1235
|
+
checks.push({ name: "Git repository", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "Not a git repo" });
|
|
1015
1236
|
}
|
|
1016
1237
|
// .gitignore (3 pts) ā validates essential patterns, not just existence
|
|
1017
1238
|
const gitignoreScorePath = join(p, ".gitignore");
|
|
@@ -1019,28 +1240,35 @@ export function scoreProject(dir) {
|
|
|
1019
1240
|
const giContent = readFileSync(gitignoreScorePath, "utf-8");
|
|
1020
1241
|
const essentialPatterns = [".env", "node_modules", "dist", "vendor", ".DS_Store", "*.log"];
|
|
1021
1242
|
const foundPatterns = essentialPatterns.filter(pat => giContent.includes(pat));
|
|
1022
|
-
if (foundPatterns.length >=
|
|
1023
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points:
|
|
1243
|
+
if (foundPatterns.length >= RUBRIC.gitignoreFull) {
|
|
1244
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 2, maxPoints: 2, status: "pass", detail: `${foundPatterns.length} essential patterns` });
|
|
1024
1245
|
}
|
|
1025
|
-
else if (foundPatterns.length >=
|
|
1026
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points:
|
|
1246
|
+
else if (foundPatterns.length >= RUBRIC.gitignorePartial) {
|
|
1247
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 2, status: "partial", detail: `Only ${foundPatterns.length} essential pattern(s) ā add .env, node_modules, dist` });
|
|
1027
1248
|
}
|
|
1028
1249
|
else {
|
|
1029
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints:
|
|
1250
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 1, maxPoints: 2, status: "partial", detail: "Exists but missing essential patterns (.env, node_modules)" });
|
|
1030
1251
|
}
|
|
1031
1252
|
}
|
|
1032
1253
|
else {
|
|
1033
|
-
checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints:
|
|
1034
|
-
}
|
|
1035
|
-
// Git hooks (5 pts)
|
|
1036
|
-
const
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1254
|
+
checks.push({ name: ".gitignore", category: "Infrastructure", points: 0, maxPoints: 2, status: "fail", detail: "Missing" });
|
|
1255
|
+
}
|
|
1256
|
+
// Git hooks (5 pts) ā see [ABSENCE-IS-NOT-A-VERDICT]: "installed but broken" is not "not installed"
|
|
1257
|
+
const repoHook = join(p, "hooks", "post-commit");
|
|
1258
|
+
const gitHook = join(p, ".git", "hooks", "post-commit");
|
|
1259
|
+
// existsSync() follows symlinks ā a dangling link reads as absent. Check the link itself first.
|
|
1260
|
+
const dangling = [repoHook, gitHook].filter(h => isSymlink(h) && !existsSync(h));
|
|
1261
|
+
const live = [repoHook, gitHook].filter(h => existsSync(h));
|
|
1262
|
+
if (live.length > 0) {
|
|
1263
|
+
const where = live.map(h => h === gitHook ? ".git/hooks/post-commit" : "hooks/post-commit").join(" + ");
|
|
1264
|
+
checks.push({ name: "Git hooks", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: `post-commit hook configured (${where})` });
|
|
1265
|
+
}
|
|
1266
|
+
else if (dangling.length > 0) {
|
|
1267
|
+
const broken = dangling.map(h => `${h === gitHook ? ".git/hooks" : "hooks"}/post-commit ā ${readlinkSafe(h)}`).join(", ");
|
|
1268
|
+
checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: `ā Hook installed but BROKEN ā dangling symlink: ${broken}. Auto-push is silently dead; restore the target, don't re-install` });
|
|
1041
1269
|
}
|
|
1042
1270
|
else {
|
|
1043
|
-
checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints:
|
|
1271
|
+
checks.push({ name: "Git hooks", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "No post-commit hook at hooks/ or .git/hooks/ ā consider auto-push" });
|
|
1044
1272
|
}
|
|
1045
1273
|
// Docker / containerization (5 pts)
|
|
1046
1274
|
// Context-aware: only award points if Docker is actually used for deployment.
|
|
@@ -1081,21 +1309,21 @@ export function scoreProject(dir) {
|
|
|
1081
1309
|
existsSync(join(p, "fly.toml")) ||
|
|
1082
1310
|
existsSync(join(p, "railway.json"));
|
|
1083
1311
|
if (usesDockerForReal && hasDockerfile && hasCompose) {
|
|
1084
|
-
checks.push({ name: "Docker", category: "Infrastructure", points:
|
|
1312
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Dockerfile + compose (active deployment)" });
|
|
1085
1313
|
}
|
|
1086
1314
|
else if (usesDockerForReal && (hasDockerfile || hasCompose)) {
|
|
1087
|
-
checks.push({ name: "Docker", category: "Infrastructure", points:
|
|
1315
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 2, maxPoints: 4, status: "partial", detail: hasDockerfile ? "Dockerfile only" : "Compose only" });
|
|
1088
1316
|
}
|
|
1089
1317
|
else if ((hasDockerfile || hasCompose) && !usesDockerForReal) {
|
|
1090
1318
|
// Files exist but look like stubs/placeholders ā minimal credit
|
|
1091
|
-
checks.push({ name: "Docker", category: "Infrastructure", points: 1, maxPoints:
|
|
1319
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 1, maxPoints: 4, status: "partial", detail: "Docker files exist but appear to be placeholders ā not used in deployment" });
|
|
1092
1320
|
}
|
|
1093
1321
|
else if (hasAltDeploy) {
|
|
1094
1322
|
// Project uses a different deploy platform ā Docker is N/A, award full points
|
|
1095
|
-
checks.push({ name: "Containerization", category: "Infrastructure", points:
|
|
1323
|
+
checks.push({ name: "Containerization", category: "Infrastructure", points: 4, maxPoints: 4, status: "pass", detail: "Uses managed platform (Vercel/Netlify/Render/Fly)" });
|
|
1096
1324
|
}
|
|
1097
1325
|
else {
|
|
1098
|
-
checks.push({ name: "Docker", category: "Infrastructure", points: 0, maxPoints:
|
|
1326
|
+
checks.push({ name: "Docker", category: "Infrastructure", points: 0, maxPoints: 4, status: "fail", detail: "Not containerized" });
|
|
1099
1327
|
}
|
|
1100
1328
|
// CI config (5 pts) ā validates workflows have real actions, not empty stubs
|
|
1101
1329
|
const ciPaths = [".github/workflows", ".gitlab-ci.yml", "Jenkinsfile", ".circleci", ".travis.yml"];
|
|
@@ -1138,14 +1366,14 @@ export function scoreProject(dir) {
|
|
|
1138
1366
|
const deployContent = readFileSync(deployFile, "utf-8");
|
|
1139
1367
|
const deployLines = deployContent.split("\n").filter(l => l.trim() && !l.trim().startsWith("#")).length;
|
|
1140
1368
|
if (deployLines >= 3) {
|
|
1141
|
-
checks.push({ name: "Deploy script", category: "Infrastructure", points:
|
|
1369
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 3, maxPoints: 3, status: "pass", detail: `${foundDeploy[0]} (${deployLines} effective lines)` });
|
|
1142
1370
|
}
|
|
1143
1371
|
else {
|
|
1144
|
-
checks.push({ name: "Deploy script", category: "Infrastructure", points: 1, maxPoints:
|
|
1372
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 1, maxPoints: 3, status: "partial", detail: `${foundDeploy[0]} ā only ${deployLines} effective lines, looks like a placeholder` });
|
|
1145
1373
|
}
|
|
1146
1374
|
}
|
|
1147
1375
|
else {
|
|
1148
|
-
checks.push({ name: "Deploy script", category: "Infrastructure", points: 0, maxPoints:
|
|
1376
|
+
checks.push({ name: "Deploy script", category: "Infrastructure", points: 0, maxPoints: 3, status: "fail", detail: "No deploy automation" });
|
|
1149
1377
|
}
|
|
1150
1378
|
// PM2 / process manager (3 pts)
|
|
1151
1379
|
if (existsSync(join(p, "ecosystem.config.js")) || existsSync(join(p, "ecosystem.config.cjs"))) {
|
|
@@ -1165,10 +1393,10 @@ export function scoreProject(dir) {
|
|
|
1165
1393
|
if (testIsSymlink) {
|
|
1166
1394
|
checks.push({ name: "Tests", category: "Code Quality", points: 3, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ is a symlink (${testFileCount} test files) ā should be real test directory` });
|
|
1167
1395
|
}
|
|
1168
|
-
else if (testFileCount >=
|
|
1396
|
+
else if (testFileCount >= RUBRIC.testsFull) {
|
|
1169
1397
|
checks.push({ name: "Tests", category: "Code Quality", points: 8, maxPoints: 8, status: "pass", detail: `${foundTests[0]}/ ā ${testFileCount} test files` });
|
|
1170
1398
|
}
|
|
1171
|
-
else if (testFileCount >
|
|
1399
|
+
else if (testFileCount > RUBRIC.testsPartial) {
|
|
1172
1400
|
checks.push({ name: "Tests", category: "Code Quality", points: 5, maxPoints: 8, status: "partial", detail: `${foundTests[0]}/ ā only ${testFileCount} test files` });
|
|
1173
1401
|
}
|
|
1174
1402
|
else {
|
|
@@ -1195,7 +1423,7 @@ export function scoreProject(dir) {
|
|
|
1195
1423
|
const tsconfigContent = readFileSync(tsconfigPath, "utf-8").trim();
|
|
1196
1424
|
const tsconfigIsSymlink = isSymlink(tsconfigPath);
|
|
1197
1425
|
// Detect minimal/reference-only tsconfigs (just project references with no real config)
|
|
1198
|
-
const isSubstantive = tsconfigContent.length >
|
|
1426
|
+
const isSubstantive = tsconfigContent.length > RUBRIC.tsconfigSubstantive && (tsconfigContent.includes('"compilerOptions"') || tsconfigContent.includes('"extends"'));
|
|
1199
1427
|
if (tsconfigIsSymlink) {
|
|
1200
1428
|
checks.push({ name: "TypeScript", category: "Code Quality", points: 2, maxPoints: 5, status: "partial", detail: "tsconfig.json is a symlink ā create root config" });
|
|
1201
1429
|
}
|
|
@@ -1253,53 +1481,93 @@ export function scoreProject(dir) {
|
|
|
1253
1481
|
if (existsSync(gitignorePath)) {
|
|
1254
1482
|
const gitignore = readFileSync(gitignorePath, "utf-8");
|
|
1255
1483
|
if (gitignore.includes(".env")) {
|
|
1256
|
-
checks.push({ name: ".env in .gitignore", category: "Security", points:
|
|
1484
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: ".env is gitignored" });
|
|
1257
1485
|
}
|
|
1258
1486
|
else {
|
|
1259
|
-
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints:
|
|
1487
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 10, status: "fail", disqualifying: true, detail: ".env NOT in .gitignore ā secrets at risk! Caps this project's grade at C" });
|
|
1260
1488
|
}
|
|
1261
1489
|
}
|
|
1262
1490
|
else {
|
|
1263
|
-
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints:
|
|
1491
|
+
checks.push({ name: ".env in .gitignore", category: "Security", points: 0, maxPoints: 10, status: "fail", detail: "No .gitignore at all" });
|
|
1264
1492
|
}
|
|
1265
1493
|
// No secrets in tracked files (6 pts)
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1494
|
+
// [ABSENCE-IS-NOT-A-VERDICT]: "no .env" and "can't run git here" are different states.
|
|
1495
|
+
// The second is unverifiable and must NOT collect a 6/6 pass.
|
|
1496
|
+
const hasEnv = existsSync(join(p, ".env"));
|
|
1497
|
+
const hasGit = existsSync(join(p, ".git"));
|
|
1498
|
+
if (hasEnv && hasGit) {
|
|
1499
|
+
// [EXEC-FAILURE-IS-NOT-EMPTY]: a failed `git ls-files` must never read as "not tracked".
|
|
1500
|
+
const { ok, output: tracked } = execChecked("git ls-files .env", p);
|
|
1501
|
+
if (!ok) {
|
|
1502
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "unknown", detail: "ā .env present but `git ls-files` failed here ā cannot verify whether it is tracked" });
|
|
1503
|
+
}
|
|
1504
|
+
else if (tracked === ".env") {
|
|
1505
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "fail", disqualifying: true, detail: ".env is tracked by git! Caps this project's grade at C" });
|
|
1270
1506
|
}
|
|
1271
1507
|
else {
|
|
1272
|
-
checks.push({ name: "Secrets exposure", category: "Security", points:
|
|
1508
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: ".env present and not tracked by git" });
|
|
1273
1509
|
}
|
|
1274
1510
|
}
|
|
1511
|
+
else if (!hasEnv) {
|
|
1512
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 10, maxPoints: 10, status: "pass", detail: "No .env at repo root ā nothing to leak" });
|
|
1513
|
+
}
|
|
1275
1514
|
else {
|
|
1276
|
-
checks.push({ name: "Secrets exposure", category: "Security", points:
|
|
1515
|
+
checks.push({ name: "Secrets exposure", category: "Security", points: 0, maxPoints: 10, status: "unknown", detail: "ā .env exists but this is not a git repo ā cannot verify whether it is tracked" });
|
|
1277
1516
|
}
|
|
1278
1517
|
// Lockfile present (3 pts)
|
|
1279
1518
|
const lockfiles = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "composer.lock"];
|
|
1280
1519
|
const foundLock = lockfiles.filter(l => existsSync(join(p, l)));
|
|
1281
1520
|
if (foundLock.length > 0) {
|
|
1282
|
-
checks.push({ name: "Lockfile", category: "Security", points:
|
|
1521
|
+
checks.push({ name: "Lockfile", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: foundLock.join(", ") });
|
|
1283
1522
|
}
|
|
1284
1523
|
else if (existsSync(pkgPath) || existsSync(join(p, "composer.json"))) {
|
|
1285
|
-
checks.push({ name: "Lockfile", category: "Security", points: 0, maxPoints:
|
|
1524
|
+
checks.push({ name: "Lockfile", category: "Security", points: 0, maxPoints: 5, status: "fail", detail: "No lockfile ā deps not pinned" });
|
|
1286
1525
|
}
|
|
1287
1526
|
// node_modules in .gitignore (3 pts)
|
|
1288
1527
|
if (existsSync(gitignorePath)) {
|
|
1289
1528
|
const gitignore = readFileSync(gitignorePath, "utf-8");
|
|
1290
1529
|
if (gitignore.includes("node_modules") || gitignore.includes("vendor")) {
|
|
1291
|
-
checks.push({ name: "Deps gitignored", category: "Security", points:
|
|
1530
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: "node_modules/vendor gitignored" });
|
|
1292
1531
|
}
|
|
1293
1532
|
else if (!existsSync(join(p, "package.json")) && !existsSync(join(p, "composer.json"))) {
|
|
1294
|
-
checks.push({ name: "Deps gitignored", category: "Security", points:
|
|
1533
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 5, maxPoints: 5, status: "pass", detail: "N/A ā no package manager" });
|
|
1295
1534
|
}
|
|
1296
1535
|
else {
|
|
1297
|
-
checks.push({ name: "Deps gitignored", category: "Security", points: 0, maxPoints:
|
|
1536
|
+
checks.push({ name: "Deps gitignored", category: "Security", points: 0, maxPoints: 5, status: "fail", detail: "node_modules/vendor not in .gitignore" });
|
|
1298
1537
|
}
|
|
1299
1538
|
}
|
|
1300
1539
|
// --- Calculate totals ---
|
|
1540
|
+
// š LOCKED [SCORE-ARITHMETIC-INVARIANT] ā 2026-08-13
|
|
1541
|
+
// ā NEVER compute the percentage against a summed maxScore without checking it equals 100 first.
|
|
1542
|
+
// WHY: several checks only push a result inside an `if` (e.g. "Deps gitignored" is skipped
|
|
1543
|
+
// entirely when a project has no .gitignore). A skipped check silently SHRANK the
|
|
1544
|
+
// denominator, so a project got a percentage out of 97 while every report ā and every
|
|
1545
|
+
// cross-project comparison ā presented it as if it were out of 100. Nothing was wrong on
|
|
1546
|
+
// screen; the number was just quietly measuring something else. A missing check is an
|
|
1547
|
+
// unknown, not a smaller exam.
|
|
1548
|
+
// FIX: the denominator is pinned to EXPECTED_MAX_SCORE. Any shortfall becomes a visible
|
|
1549
|
+
// "Scoring completeness" unknown row worth the missing points, and is logged to stderr
|
|
1550
|
+
// (never stdout ā MCP protocol stream). Exact, free, unarguable, runs every time.
|
|
1551
|
+
const EXPECTED_MAX_SCORE = 100;
|
|
1301
1552
|
const totalScore = checks.reduce((sum, c) => sum + c.points, 0);
|
|
1302
|
-
const
|
|
1553
|
+
const emittedMax = checks.reduce((sum, c) => sum + c.maxPoints, 0);
|
|
1554
|
+
if (emittedMax !== EXPECTED_MAX_SCORE) {
|
|
1555
|
+
const gap = EXPECTED_MAX_SCORE - emittedMax;
|
|
1556
|
+
if (!canaryRunning) {
|
|
1557
|
+
console.error(`[contextengine] ā scoring invariant: ${dir.name} emitted ${emittedMax}/${EXPECTED_MAX_SCORE} max points (${gap > 0 ? "missing" : "excess"} ${Math.abs(gap)}) ā see the "Could Not Be Verified" section`);
|
|
1558
|
+
}
|
|
1559
|
+
if (gap > 0) {
|
|
1560
|
+
checks.push({
|
|
1561
|
+
name: "Scoring completeness",
|
|
1562
|
+
category: "Meta",
|
|
1563
|
+
points: 0,
|
|
1564
|
+
maxPoints: gap,
|
|
1565
|
+
status: "unknown",
|
|
1566
|
+
detail: `ā ${gap} point(s) never assessed ā a check did not run for this project. Scored against the full ${EXPECTED_MAX_SCORE}, so this is a gap in the audit, not a penalty you can fix`,
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
const maxScore = Math.max(EXPECTED_MAX_SCORE, emittedMax);
|
|
1303
1571
|
const percentage = Math.round((totalScore / maxScore) * 100);
|
|
1304
1572
|
let grade;
|
|
1305
1573
|
if (percentage >= 90)
|
|
@@ -1314,6 +1582,21 @@ export function scoreProject(dir) {
|
|
|
1314
1582
|
grade = "D";
|
|
1315
1583
|
else
|
|
1316
1584
|
grade = "F";
|
|
1585
|
+
// š LOCKED [SECURITY-IS-DISQUALIFYING] ā 2026-08-14
|
|
1586
|
+
// ā NEVER let exposed secrets be averaged away into a good grade.
|
|
1587
|
+
// WHY: security was 20 of 100, so a project could commit its .env and still score 80% on the
|
|
1588
|
+
// strength of good docs. Weighting alone cannot express "this one thing is not a rounding
|
|
1589
|
+
// error" ā at any weight below ~50 the arithmetic still lets other categories outvote it.
|
|
1590
|
+
// FIX: a failed secret-exposure check CAPS the grade at C no matter the point total. The
|
|
1591
|
+
// percentage stays honest (it still reports what was earned); only the grade is capped, and
|
|
1592
|
+
// the report says why. Reserved for actual secret exposure ā a missing lockfile is hygiene,
|
|
1593
|
+
// not a disqualification, and must never set this flag.
|
|
1594
|
+
const disqualified = checks.filter(c => c.disqualifying && c.status === "fail");
|
|
1595
|
+
const GRADE_CAP = "C";
|
|
1596
|
+
const gradeOrder = ["F", "D", "C", "B", "A", "A+"];
|
|
1597
|
+
if (disqualified.length > 0 && gradeOrder.indexOf(grade) > gradeOrder.indexOf(GRADE_CAP)) {
|
|
1598
|
+
grade = GRADE_CAP;
|
|
1599
|
+
}
|
|
1317
1600
|
return {
|
|
1318
1601
|
project: dir.name,
|
|
1319
1602
|
path: dir.path,
|
|
@@ -1372,7 +1655,7 @@ export function formatScoreReport(scores) {
|
|
|
1372
1655
|
lines.push("| Check | Category | Score | Status | Detail |");
|
|
1373
1656
|
lines.push("|-------|----------|-------|--------|--------|");
|
|
1374
1657
|
for (const c of s.checks) {
|
|
1375
|
-
const icon = c.status
|
|
1658
|
+
const icon = statusIcon(c.status);
|
|
1376
1659
|
lines.push(`| ${c.name} | ${c.category} | ${c.points}/${c.maxPoints} | ${icon} | ${c.detail} |`);
|
|
1377
1660
|
}
|
|
1378
1661
|
lines.push("");
|
|
@@ -1414,7 +1697,7 @@ export function generateProjectScoreMD(score) {
|
|
|
1414
1697
|
lines.push("| Check | Category | Score | Max | Status | Detail |");
|
|
1415
1698
|
lines.push("|---|---|---|---|---|---|");
|
|
1416
1699
|
for (const c of score.checks) {
|
|
1417
|
-
const icon = c.status
|
|
1700
|
+
const icon = statusIcon(c.status);
|
|
1418
1701
|
lines.push(`| ${c.name} | ${c.category} | ${c.points} | ${c.maxPoints} | ${icon} | ${c.detail} |`);
|
|
1419
1702
|
}
|
|
1420
1703
|
// Failures and improvements
|
|
@@ -1429,6 +1712,16 @@ export function generateProjectScoreMD(score) {
|
|
|
1429
1712
|
lines.push(`- š” **${p.name}**: ${p.detail}`);
|
|
1430
1713
|
}
|
|
1431
1714
|
}
|
|
1715
|
+
// Unverifiable ā kept OUT of "Improvements Needed" on purpose. These are checks that could not
|
|
1716
|
+
// reach a verdict; listing them as project failures is what [ABSENCE-IS-NOT-A-VERDICT] forbids.
|
|
1717
|
+
const unknowns = score.checks.filter(c => c.status === "unknown");
|
|
1718
|
+
if (unknowns.length > 0) {
|
|
1719
|
+
lines.push("\n## Could Not Be Verified\n");
|
|
1720
|
+
lines.push("_Gaps in the audit, not defects in the project ā the scorer could not determine these._\n");
|
|
1721
|
+
for (const u of unknowns) {
|
|
1722
|
+
lines.push(`- ā **${u.name}**: ${u.detail}`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1432
1725
|
lines.push(`\n---\n*Generated by [ContextEngine](https://www.npmjs.com/package/@compr/contextengine-mcp) on ${date}*\n`);
|
|
1433
1726
|
return lines.join("\n");
|
|
1434
1727
|
}
|
|
@@ -1449,13 +1742,8 @@ export function generateScoreHTML(scores) {
|
|
|
1449
1742
|
return "#f97316";
|
|
1450
1743
|
return "#ef4444";
|
|
1451
1744
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
return "ā
";
|
|
1455
|
-
if (status === "partial")
|
|
1456
|
-
return "š”";
|
|
1457
|
-
return "ā";
|
|
1458
|
-
}
|
|
1745
|
+
// statusIcon is module-level ā see [ABSENCE-IS-NOT-A-VERDICT]. A local copy here previously
|
|
1746
|
+
// rendered "unknown" as ā, which is the exact conflation this work removed.
|
|
1459
1747
|
function categoryByScore(checks) {
|
|
1460
1748
|
const m = new Map();
|
|
1461
1749
|
for (const c of checks) {
|
package/dist/cli.js
CHANGED
|
@@ -522,7 +522,7 @@ import { ingestSources } from "./ingest.js";
|
|
|
522
522
|
import { searchChunks } from "./search.js";
|
|
523
523
|
import { collectProjectOps, collectSystemOps } from "./collectors.js";
|
|
524
524
|
import { scanCodeDir } from "./code-chunker.js";
|
|
525
|
-
import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
|
|
525
|
+
import { listProjects, runComplianceAudit, formatProjectList, formatPlan, scoreProject, runScoreCanary, formatScoreReport, generateScoreHTML, generateProjectScoreMD, } from "./agents.js";
|
|
526
526
|
import { listLearnings, learningsToChunks, learningsStats, formatLearnings, saveLearning, deleteLearning, importLearningsFromFile, autoImportFromSources, LEARNING_CATEGORIES, } from "./learnings.js";
|
|
527
527
|
import { saveSession, loadSession, listSessions, deleteSession, formatSession, formatSessionList, } from "./sessions.js";
|
|
528
528
|
import { activate, deactivate, getActivationStatus, gateCheck, } from "./activation.js";
|
|
@@ -703,6 +703,19 @@ async function cliScore(project, html = false, save = true) {
|
|
|
703
703
|
console.error(gate);
|
|
704
704
|
process.exit(1);
|
|
705
705
|
}
|
|
706
|
+
// [SCORE-CANARY] ā every health signal must read exactly as pinned before we are allowed to
|
|
707
|
+
// write a single SCORE.md. A drifting scorer that silently rewrites 37 reports is the failure
|
|
708
|
+
// this blocks; it does not test for one known bug, it refuses to proceed on ANY deviation.
|
|
709
|
+
const canary = runScoreCanary();
|
|
710
|
+
if (!canary.ok) {
|
|
711
|
+
console.error("\nšØ Scoring canary FAILED ā refusing to write any SCORE.md.\n");
|
|
712
|
+
for (const d of canary.deviations)
|
|
713
|
+
console.error(` ⢠${d}`);
|
|
714
|
+
console.error(canary.inconclusive
|
|
715
|
+
? "\nThe canary fixture could not be built, so the scorer is unverified. This is an unknown, not a pass.\n"
|
|
716
|
+
: "\nThe scorer no longer behaves as pinned. Fix the deviation or update the pin deliberately.\n");
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
706
719
|
const projectDirs = loadProjectDirs();
|
|
707
720
|
let scores;
|
|
708
721
|
if (project) {
|
package/dist/config.js
CHANGED
|
@@ -7,6 +7,7 @@ const DEFAULT_PATTERNS = [
|
|
|
7
7
|
".github/copilot-instructions.md",
|
|
8
8
|
".github/instructions/copilot-instructions.md",
|
|
9
9
|
".github/SKILLS.md",
|
|
10
|
+
"SKILLS.md", // `contextengine init` writes SKILLS.md at the repo root ā index both
|
|
10
11
|
// Claude Code
|
|
11
12
|
"CLAUDE.md",
|
|
12
13
|
// Cursor
|
package/dist/rubric.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoring thresholds ā the numbers that decide what earns what.
|
|
3
|
+
*
|
|
4
|
+
* š LOCKED [RUBRIC-SINGLE-TABLE] ā 2026-08-14
|
|
5
|
+
* ā NEVER inline a scoring threshold back into src/agents.ts.
|
|
6
|
+
* WHY: these values are what makes a score gameable ā knowing ">50 lines earns the full 10"
|
|
7
|
+
* lets anyone pad a file to a number instead of writing the content. They were scattered
|
|
8
|
+
* as bare literals across ~10 call sites in agents.ts, so they shipped readable in
|
|
9
|
+
* dist/agents.js and could not be obscured without rewriting the whole scorer.
|
|
10
|
+
* FIX: one table, one module. `scripts/obfuscate-rubric.mjs` rewrites dist/rubric.js at build
|
|
11
|
+
* time into an encoded blob, so the published package carries no readable thresholds.
|
|
12
|
+
* Adding a literal back into agents.ts silently re-exposes it ā the obfuscator only
|
|
13
|
+
* touches this file.
|
|
14
|
+
*
|
|
15
|
+
* NOTE ON STRENGTH: this stops casual reading, not determined analysis. The surrounding
|
|
16
|
+
* comparison logic still ships, and 2.1.3 remains on npm forever as a plaintext reference.
|
|
17
|
+
* It is a speed bump for score-gaming, deliberately, and must never be described as more.
|
|
18
|
+
* The real protections are the activation gate and BSL-1.1. See CLAUDE.md rules 1-3.
|
|
19
|
+
*/
|
|
20
|
+
export interface Rubric {
|
|
21
|
+
/** copilot-instructions.md line counts for the 10 / 6 point tiers */
|
|
22
|
+
copilotFull: number;
|
|
23
|
+
copilotPartial: number;
|
|
24
|
+
/** README.md line count for the full 8 points */
|
|
25
|
+
readmeFull: number;
|
|
26
|
+
/** SKILLS.md line count for the full 3 points */
|
|
27
|
+
skillsFull: number;
|
|
28
|
+
/** .env.example ā documented variables needed for full marks */
|
|
29
|
+
envExampleVars: number;
|
|
30
|
+
/** .gitignore ā essential patterns matched for full / partial credit */
|
|
31
|
+
gitignoreFull: number;
|
|
32
|
+
gitignorePartial: number;
|
|
33
|
+
/** Test files present for the 8 / 5 point tiers */
|
|
34
|
+
testsFull: number;
|
|
35
|
+
testsPartial: number;
|
|
36
|
+
/** tsconfig.json byte length below which it is treated as a stub */
|
|
37
|
+
tsconfigSubstantive: number;
|
|
38
|
+
/** Real (non-symlink) agent-pattern files for full credit */
|
|
39
|
+
multiAgentFull: number;
|
|
40
|
+
/** Commits since the agent doc was updated: at or below = current, above `freshnessStale` = stale */
|
|
41
|
+
freshnessGood: number;
|
|
42
|
+
freshnessStale: number;
|
|
43
|
+
}
|
|
44
|
+
export declare const RUBRIC: Rubric;
|
|
45
|
+
//# sourceMappingURL=rubric.d.ts.map
|
package/dist/rubric.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*__RUBRIC_ENCODED__*/
|
|
2
|
+
const _k = "ce-rubric-v1";
|
|
3
|
+
const _d = (b) => {
|
|
4
|
+
const raw = Buffer.from(b, "base64").toString("binary");
|
|
5
|
+
let s = "";
|
|
6
|
+
for (let i = 0; i < raw.length; i++) s += String.fromCharCode(raw.charCodeAt(i) ^ _k.charCodeAt(i % _k.length));
|
|
7
|
+
return JSON.parse(s);
|
|
8
|
+
};
|
|
9
|
+
export const RUBRIC = Object.freeze(_d("GEdOHQULHgYXawNdD0cXR0VOUAoMXR9dDBF9EwcWGwgPD0wAVkkPABADFgQGawNdD0cXQUVOUBoIRBpdECNYHhlASFhTAVRUDRNoChQPAgUGexdDEEcXQVlAFQAXRBFfDBdINAAOHktZHloTBAxZGxIMHRsGfRdDFwxMHldYQ0VBWRNCFxZrBxkOUFNWAVRFBhZZASUDAB0KTBoTWVUBUAEREQYNSx9WMBBPAQEDHB0KWxMTWVAdXlcPBwUXRDdWBgtZNAAOHktZH1oTBRdIAR0MFxoQahleB0cXQ0VOUA8RSAVZDQBeASYWEwUGD0wFUxg="));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compr/opscontext-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "OpsContext for AI Agents ā read-only fleet visibility (PM2/nginx/Docker/git/cron) + tamper-evident audit log + policy-as-code hooks. The ops + compliance layer Claude Code can't grow natively.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test": "vitest run",
|
|
18
18
|
"test:watch": "vitest",
|
|
19
19
|
"lint": "eslint src/",
|
|
20
|
-
"prepublishOnly": "node scripts/check-npm-token-expiry.mjs && npm run build",
|
|
20
|
+
"prepublishOnly": "node scripts/check-npm-token-expiry.mjs && npm run build && node scripts/obfuscate-rubric.mjs",
|
|
21
21
|
"check-token": "node scripts/check-npm-token-expiry.mjs"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|