agentic-scorecard 0.2.0 → 0.3.1
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/AGENT_PROMPT.md +72 -51
- package/README.md +62 -33
- package/benchmark/v0.3/adapter-contract.md +44 -0
- package/benchmark/v0.3/adapters/known-agent-harnesses.yaml +177 -0
- package/benchmark/v0.3/adapters/known-python-tooling.yaml +18 -0
- package/benchmark/v0.3/adapters/known-security-tools.yaml +6 -0
- package/benchmark/v0.3/agent-evidence-schema.json +67 -0
- package/benchmark/v0.3/attestation-schema.json +26 -0
- package/benchmark/v0.3/benchmark.yaml +127 -0
- package/benchmark/v0.3/controls/context.yaml +79 -0
- package/benchmark/v0.3/controls/environment.yaml +93 -0
- package/benchmark/v0.3/controls/governance.yaml +97 -0
- package/benchmark/v0.3/controls/learning.yaml +80 -0
- package/benchmark/v0.3/controls/observability.yaml +91 -0
- package/benchmark/v0.3/controls/resilience.yaml +107 -0
- package/benchmark/v0.3/controls/security.yaml +129 -0
- package/benchmark/v0.3/controls/specification.yaml +83 -0
- package/benchmark/v0.3/controls/testing.yaml +85 -0
- package/benchmark/v0.3/controls/tooling.yaml +69 -0
- package/benchmark/v0.3/report-schema.json +89 -0
- package/benchmark/v0.3/scoring-policy.md +100 -0
- package/dist/cli.js +508 -53
- package/package.json +1 -1
- package/templates/agent-evidence.yaml +3 -3
- package/templates/attestations.yaml +1 -1
- package/templates/baseline.csv +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { appendFile, mkdir, readFile as
|
|
5
|
-
import { dirname as dirname2, join as
|
|
4
|
+
import { appendFile, mkdir, readFile as readFile4, writeFile } from "fs/promises";
|
|
5
|
+
import { dirname as dirname2, join as join3, resolve as resolve4 } from "path";
|
|
6
6
|
import { Command } from "commander";
|
|
7
7
|
import { stringify } from "yaml";
|
|
8
8
|
|
|
@@ -30,6 +30,7 @@ var dimensionIds = [
|
|
|
30
30
|
var DimensionIdSchema = z.enum(dimensionIds);
|
|
31
31
|
var LevelSchema = z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3), z.literal(4)]);
|
|
32
32
|
var EvidenceScopeSchema = z.enum(["repository", "platform", "organization", "outcome"]);
|
|
33
|
+
var ManualEvidenceScopeSchema = z.enum(["platform", "organization", "outcome"]);
|
|
33
34
|
var AssessmentScopeSchema = z.enum(["tracked", "workspace"]);
|
|
34
35
|
var PathAnySchema = z.object({
|
|
35
36
|
type: z.literal("path_any"),
|
|
@@ -61,7 +62,9 @@ var ContentTermsSchema = z.object({
|
|
|
61
62
|
files: z.array(z.string().min(1)).min(1),
|
|
62
63
|
terms: z.array(z.string().min(1)).min(1),
|
|
63
64
|
min_terms: z.number().int().positive(),
|
|
64
|
-
required_any_terms: z.array(z.string().min(1)).min(1).optional()
|
|
65
|
+
required_any_terms: z.array(z.string().min(1)).min(1).optional(),
|
|
66
|
+
max_span_lines: z.number().int().positive().max(200).optional(),
|
|
67
|
+
max_files_per_pattern: z.number().int().positive().max(250).optional()
|
|
65
68
|
});
|
|
66
69
|
var MaxBytesSchema = z.object({
|
|
67
70
|
type: z.literal("max_bytes"),
|
|
@@ -71,7 +74,7 @@ var MaxBytesSchema = z.object({
|
|
|
71
74
|
});
|
|
72
75
|
var ManualSchema = z.object({
|
|
73
76
|
type: z.literal("manual"),
|
|
74
|
-
scope:
|
|
77
|
+
scope: ManualEvidenceScopeSchema.default("organization"),
|
|
75
78
|
prompt: z.string().min(1)
|
|
76
79
|
});
|
|
77
80
|
var EvidenceCheckSchema = z.discriminatedUnion("type", [
|
|
@@ -94,7 +97,8 @@ var RawControlSchema = z.object({
|
|
|
94
97
|
references: z.array(z.string().min(1)).default([]),
|
|
95
98
|
allow_attestation: z.boolean().default(false),
|
|
96
99
|
allow_not_applicable: z.boolean().default(false),
|
|
97
|
-
allow_agent_evidence: z.boolean().default(false)
|
|
100
|
+
allow_agent_evidence: z.boolean().default(false),
|
|
101
|
+
agent_evidence_scopes: z.array(EvidenceScopeSchema).default([])
|
|
98
102
|
});
|
|
99
103
|
var LegacyRawControlSchema = RawControlSchema.extend({
|
|
100
104
|
allow_attestation: z.boolean().default(true)
|
|
@@ -107,6 +111,32 @@ var LegacyControlFileSchema = z.object({
|
|
|
107
111
|
dimension: DimensionIdSchema,
|
|
108
112
|
controls: z.array(LegacyRawControlSchema).min(1)
|
|
109
113
|
});
|
|
114
|
+
var DetectorAdapterExtensionSchema = z.object({
|
|
115
|
+
control_id: z.string().regex(/^ADRB-[A-Z]{3}-\d{3}$/),
|
|
116
|
+
evidence_index: z.number().int().nonnegative(),
|
|
117
|
+
patterns: z.array(z.string().min(1)).min(1).optional(),
|
|
118
|
+
files: z.array(z.string().min(1)).min(1).optional(),
|
|
119
|
+
terms: z.array(z.string().min(1)).min(1).optional(),
|
|
120
|
+
required_any_terms: z.array(z.string().min(1)).min(1).optional()
|
|
121
|
+
}).strict().superRefine((extension, context) => {
|
|
122
|
+
const extensionKinds = [
|
|
123
|
+
extension.patterns,
|
|
124
|
+
extension.files,
|
|
125
|
+
extension.terms,
|
|
126
|
+
extension.required_any_terms
|
|
127
|
+
].filter(Boolean).length;
|
|
128
|
+
if (extensionKinds !== 1) {
|
|
129
|
+
context.addIssue({
|
|
130
|
+
code: z.ZodIssueCode.custom,
|
|
131
|
+
message: "A detector extension must declare exactly one of patterns, files, terms, or required_any_terms"
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
var DetectorAdapterSchema = z.object({
|
|
136
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
137
|
+
benchmark_version: z.string().regex(/^\d+\.\d+\.\d+$/),
|
|
138
|
+
extensions: z.array(DetectorAdapterExtensionSchema).min(1)
|
|
139
|
+
}).strict();
|
|
110
140
|
var DimensionSchema = z.object({
|
|
111
141
|
id: DimensionIdSchema,
|
|
112
142
|
title: z.string().min(1),
|
|
@@ -186,10 +216,47 @@ var AgentEvidenceFileSchema = z.object({
|
|
|
186
216
|
}).strict(),
|
|
187
217
|
claims: z.record(z.string().regex(/^ADRB-[A-Z]{3}-\d{3}$/), AgentEvidenceClaimSchema)
|
|
188
218
|
}).strict();
|
|
219
|
+
var AgentEvidenceClaimV03Schema = z.object({
|
|
220
|
+
status: z.enum(["met", "not_met", "unknown"]),
|
|
221
|
+
scope: EvidenceScopeSchema,
|
|
222
|
+
summary: z.string().min(1),
|
|
223
|
+
references: z.array(z.string().min(1)).min(1),
|
|
224
|
+
collected_at: z.string().datetime(),
|
|
225
|
+
expires_at: z.string().datetime(),
|
|
226
|
+
error: z.string().min(1).nullable().default(null)
|
|
227
|
+
}).strict().superRefine((claim, context) => {
|
|
228
|
+
if (claim.error && claim.status !== "unknown") {
|
|
229
|
+
context.addIssue({
|
|
230
|
+
code: z.ZodIssueCode.custom,
|
|
231
|
+
message: "A claim with an error must have unknown status",
|
|
232
|
+
path: ["status"]
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (claim.scope === "repository" && claim.references.some((reference) => !reference.startsWith("repo:"))) {
|
|
236
|
+
context.addIssue({
|
|
237
|
+
code: z.ZodIssueCode.custom,
|
|
238
|
+
message: "Repository claims must use repo:<tracked-path>[#Lx-Ly] references",
|
|
239
|
+
path: ["references"]
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
var AgentEvidenceFileV03Schema = z.object({
|
|
244
|
+
schema_version: z.literal("0.3.0"),
|
|
245
|
+
benchmark_version: z.literal("0.3.0"),
|
|
246
|
+
target: z.object({
|
|
247
|
+
repository: z.string().min(1),
|
|
248
|
+
git_head: z.string().min(1)
|
|
249
|
+
}).strict(),
|
|
250
|
+
collector: z.object({
|
|
251
|
+
name: z.string().min(1),
|
|
252
|
+
version: z.string().min(1)
|
|
253
|
+
}).strict(),
|
|
254
|
+
claims: z.record(z.string().regex(/^ADRB-[A-Z]{3}-\d{3}$/), AgentEvidenceClaimV03Schema)
|
|
255
|
+
}).strict();
|
|
189
256
|
|
|
190
257
|
// src/load.ts
|
|
191
258
|
var packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
192
|
-
var defaultBenchmarkRoot = join(packageRoot, "benchmark", "v0.
|
|
259
|
+
var defaultBenchmarkRoot = join(packageRoot, "benchmark", "v0.3");
|
|
193
260
|
async function readYaml(path) {
|
|
194
261
|
return parse(await readFile(path, "utf8"));
|
|
195
262
|
}
|
|
@@ -201,12 +268,85 @@ async function loadBenchmark(root = defaultBenchmarkRoot) {
|
|
|
201
268
|
const file = benchmark.version === "0.1.0" ? LegacyControlFileSchema.parse(await readYaml(path)) : ControlFileSchema.parse(await readYaml(path));
|
|
202
269
|
controls.push(...file.controls.map((control) => ({ ...control, dimension: file.dimension })));
|
|
203
270
|
}
|
|
271
|
+
const adapterPaths = await fg("adapters/*.yaml", {
|
|
272
|
+
cwd: root,
|
|
273
|
+
absolute: true,
|
|
274
|
+
onlyFiles: true
|
|
275
|
+
});
|
|
276
|
+
for (const path of adapterPaths.sort()) {
|
|
277
|
+
const adapter = DetectorAdapterSchema.parse(await readYaml(path));
|
|
278
|
+
applyDetectorAdapter(benchmark, controls, adapter);
|
|
279
|
+
}
|
|
204
280
|
validateCatalog(benchmark, controls);
|
|
205
281
|
return { benchmark, controls };
|
|
206
282
|
}
|
|
207
|
-
|
|
283
|
+
function applyDetectorAdapter(benchmark, controls, adapter) {
|
|
284
|
+
if (adapter.benchmark_version !== benchmark.version) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Detector adapter ${adapter.id} targets ${adapter.benchmark_version}, not ${benchmark.version}`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
for (const extension of adapter.extensions) {
|
|
290
|
+
const control = controls.find(({ id }) => id === extension.control_id);
|
|
291
|
+
if (!control) {
|
|
292
|
+
throw new Error(`Detector adapter ${adapter.id} references unknown ${extension.control_id}`);
|
|
293
|
+
}
|
|
294
|
+
const check = control.evidence[extension.evidence_index];
|
|
295
|
+
if (!check) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Detector adapter ${adapter.id} references missing evidence index ${extension.evidence_index} on ${control.id}`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (extension.patterns) {
|
|
301
|
+
if (!("patterns" in check)) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`Detector adapter ${adapter.id} cannot add patterns to ${control.id} evidence ${extension.evidence_index}`
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
check.patterns = [.../* @__PURE__ */ new Set([...check.patterns, ...extension.patterns])];
|
|
307
|
+
}
|
|
308
|
+
if (extension.files) {
|
|
309
|
+
if (!("files" in check)) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Detector adapter ${adapter.id} cannot add files to ${control.id} evidence ${extension.evidence_index}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
check.files = [.../* @__PURE__ */ new Set([...check.files, ...extension.files])];
|
|
315
|
+
}
|
|
316
|
+
if (extension.terms) {
|
|
317
|
+
if (check.type !== "content_terms") {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`Detector adapter ${adapter.id} cannot add terms to ${control.id} evidence ${extension.evidence_index}`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
check.terms = [.../* @__PURE__ */ new Set([...check.terms, ...extension.terms])];
|
|
323
|
+
}
|
|
324
|
+
if (extension.required_any_terms) {
|
|
325
|
+
if (check.type !== "content_terms") {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Detector adapter ${adapter.id} cannot add required terms to ${control.id} evidence ${extension.evidence_index}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
check.required_any_terms = [
|
|
331
|
+
.../* @__PURE__ */ new Set([...check.required_any_terms ?? [], ...extension.required_any_terms])
|
|
332
|
+
];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function loadAttestations(path, benchmarkVersion, options = {}) {
|
|
208
337
|
try {
|
|
209
338
|
const rawFile = await readYaml(path);
|
|
339
|
+
const artifactVersion = versionField(rawFile, "benchmark_version");
|
|
340
|
+
if (artifactVersion && handleVersionMismatch(
|
|
341
|
+
"Attestation",
|
|
342
|
+
path,
|
|
343
|
+
artifactVersion,
|
|
344
|
+
benchmarkVersion,
|
|
345
|
+
"init --force",
|
|
346
|
+
options
|
|
347
|
+
)) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
210
350
|
const file = benchmarkVersion === "0.1.0" ? LegacyAttestationFileSchema.parse(rawFile) : AttestationFileSchema.parse(rawFile);
|
|
211
351
|
if (file.benchmark_version !== benchmarkVersion) {
|
|
212
352
|
throw new Error(
|
|
@@ -219,26 +359,80 @@ async function loadAttestations(path, benchmarkVersion) {
|
|
|
219
359
|
throw error;
|
|
220
360
|
}
|
|
221
361
|
}
|
|
222
|
-
async function loadAgentEvidence(path) {
|
|
362
|
+
async function loadAgentEvidence(path, benchmarkVersion, options = {}) {
|
|
223
363
|
try {
|
|
224
|
-
|
|
364
|
+
const rawFile = await readYaml(path);
|
|
365
|
+
const artifactVersion = versionField(rawFile, "benchmark_version");
|
|
366
|
+
const schemaVersion = versionField(rawFile, "schema_version");
|
|
367
|
+
const mismatchedVersion = [artifactVersion, schemaVersion].find(
|
|
368
|
+
(version) => version && version !== benchmarkVersion
|
|
369
|
+
);
|
|
370
|
+
if (mismatchedVersion && handleVersionMismatch(
|
|
371
|
+
"Agent evidence",
|
|
372
|
+
path,
|
|
373
|
+
mismatchedVersion,
|
|
374
|
+
benchmarkVersion,
|
|
375
|
+
"init-evidence --force",
|
|
376
|
+
options
|
|
377
|
+
)) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const file = (() => {
|
|
381
|
+
if (benchmarkVersion === "0.2.0") return AgentEvidenceFileSchema.parse(rawFile);
|
|
382
|
+
if (benchmarkVersion === "0.3.0") return AgentEvidenceFileV03Schema.parse(rawFile);
|
|
383
|
+
throw new Error(`Agent evidence bundles are unsupported for benchmark ${benchmarkVersion}`);
|
|
384
|
+
})();
|
|
385
|
+
if (file.benchmark_version !== benchmarkVersion) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Agent evidence benchmark version ${file.benchmark_version} does not match ${benchmarkVersion}`
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return file;
|
|
225
391
|
} catch (error) {
|
|
226
392
|
if (error.code === "ENOENT") return null;
|
|
227
393
|
throw error;
|
|
228
394
|
}
|
|
229
395
|
}
|
|
396
|
+
function versionField(value, field) {
|
|
397
|
+
if (!value || typeof value !== "object" || !(field in value)) return null;
|
|
398
|
+
const version = value[field];
|
|
399
|
+
return typeof version === "string" ? version : null;
|
|
400
|
+
}
|
|
401
|
+
function handleVersionMismatch(label, path, artifactVersion, benchmarkVersion, regenerateCommand, options) {
|
|
402
|
+
if (artifactVersion === benchmarkVersion) return false;
|
|
403
|
+
const mismatch = `${label} file ${path} targets ADRB v${artifactVersion}, not v${benchmarkVersion}`;
|
|
404
|
+
if (!options.ignoreVersionMismatch) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`${mismatch}. Regenerate it with \`agentic-scorecard ${regenerateCommand}\` or pass a v${benchmarkVersion} file.`
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
options.onWarning?.(
|
|
410
|
+
`Ignored auto-loaded ${label.toLowerCase()} file ${path} because it targets ADRB v${artifactVersion}, not v${benchmarkVersion}. Regenerate it with \`agentic-scorecard ${regenerateCommand}\` before relying on its claims.`
|
|
411
|
+
);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
230
414
|
function validateCatalog(benchmark, controls) {
|
|
231
415
|
const ids = /* @__PURE__ */ new Set();
|
|
232
416
|
for (const control of controls) {
|
|
233
417
|
if (ids.has(control.id)) throw new Error(`Duplicate control id: ${control.id}`);
|
|
234
418
|
ids.add(control.id);
|
|
235
|
-
if (
|
|
236
|
-
throw new Error(
|
|
419
|
+
if (["0.2.0", "0.3.0"].includes(benchmark.version) && control.evidence.some(({ type }) => type === "content_any" || type === "content_all")) {
|
|
420
|
+
throw new Error(
|
|
421
|
+
`${control.id} uses a legacy broad content collector in benchmark ${benchmark.version}`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const manualScopes = control.evidence.filter(({ type }) => type === "manual").map(({ scope }) => scope);
|
|
425
|
+
const allowedAgentScopes = control.agent_evidence_scopes.length > 0 ? control.agent_evidence_scopes : manualScopes;
|
|
426
|
+
if (control.allow_agent_evidence && allowedAgentScopes.length === 0) {
|
|
427
|
+
throw new Error(`${control.id} allows agent evidence without an eligible evidence scope`);
|
|
237
428
|
}
|
|
238
|
-
if (control.allow_agent_evidence &&
|
|
239
|
-
throw new Error(`${control.id}
|
|
429
|
+
if (!control.allow_agent_evidence && control.agent_evidence_scopes.length > 0) {
|
|
430
|
+
throw new Error(`${control.id} declares agent evidence scopes but does not allow it`);
|
|
240
431
|
}
|
|
241
|
-
if (benchmark.version === "0.2.0" && control.
|
|
432
|
+
if (benchmark.version === "0.2.0" && control.agent_evidence_scopes.some((scope) => scope === "repository")) {
|
|
433
|
+
throw new Error(`${control.id} changes immutable v0.2 repository evidence semantics`);
|
|
434
|
+
}
|
|
435
|
+
if (["0.2.0", "0.3.0"].includes(benchmark.version) && control.allow_attestation && !control.evidence.some(({ type }) => type === "manual")) {
|
|
242
436
|
throw new Error(`${control.id} allows attestation for repository-detected evidence`);
|
|
243
437
|
}
|
|
244
438
|
for (const check of control.evidence) {
|
|
@@ -326,6 +520,12 @@ function appendControlDetails(lines, heading, controls) {
|
|
|
326
520
|
}
|
|
327
521
|
function toMarkdown(report) {
|
|
328
522
|
const target = report.profiles.find(({ id }) => id === report.target.profile);
|
|
523
|
+
const targetDependencies = target?.evidence_dependencies;
|
|
524
|
+
const dependencyCount = (targetDependencies?.agent_collected ?? 0) + (targetDependencies?.attested ?? 0);
|
|
525
|
+
const targetProvenance = target?.passed && dependencyCount > 0 ? ` (depends on ${[
|
|
526
|
+
targetDependencies?.agent_collected ? `${targetDependencies.agent_collected} agent-collected` : null,
|
|
527
|
+
targetDependencies?.attested ? `${targetDependencies.attested} human-attested` : null
|
|
528
|
+
].filter(Boolean).join(" and ")} required ${dependencyCount === 1 ? "control" : "controls"})` : "";
|
|
329
529
|
const established = report.controls.filter(
|
|
330
530
|
({ status }) => status === "met" || status === "not_applicable"
|
|
331
531
|
);
|
|
@@ -337,6 +537,10 @@ function toMarkdown(report) {
|
|
|
337
537
|
(control) => ["platform", "organization"].includes(controlScope(control))
|
|
338
538
|
);
|
|
339
539
|
const outcomeControls = unresolved.filter((control) => controlScope(control) === "outcome");
|
|
540
|
+
const repositoryOnlyBaseline = !report.controls.some(
|
|
541
|
+
({ agent_evidence: agentEvidence, attestation }) => agentEvidence !== null || attestation !== null
|
|
542
|
+
);
|
|
543
|
+
const resolvedEvidence = report.evidence_summary.resolved !== void 0 && report.evidence_summary.total !== void 0 ? `; ${report.evidence_summary.resolved}/${report.evidence_summary.total} controls resolved` : "";
|
|
340
544
|
const lines = [
|
|
341
545
|
"# Agentic Development Readiness Assessment",
|
|
342
546
|
"",
|
|
@@ -346,11 +550,24 @@ function toMarkdown(report) {
|
|
|
346
550
|
`- Git commit: ${report.target.git_head ? `\`${report.target.git_head}\`` : "unavailable"}`,
|
|
347
551
|
`- Working tree dirty: ${report.target.working_tree_dirty === null ? "unknown" : String(report.target.working_tree_dirty)}`,
|
|
348
552
|
`- Assessed: ${report.assessed_at}`,
|
|
349
|
-
|
|
553
|
+
...repositoryOnlyBaseline ? [
|
|
554
|
+
"- Assessment mode: **repository-only baseline** \u2014 platform, organization, and outcome evidence has not been established"
|
|
555
|
+
] : ["- Assessment mode: **evidence-assisted assessment**"],
|
|
556
|
+
...report.score.repository ? [
|
|
557
|
+
`- Repository-detected progress: **${report.score.repository.achieved}/${report.score.repository.ceiling} (${report.score.repository.percentage}%)** of the maturity levels the offline repository collector can establish`
|
|
558
|
+
] : [],
|
|
559
|
+
`- Normative readiness score: **${report.score.total}/${report.score.maximum} (${report.score.percentage}%)**`,
|
|
350
560
|
`- Highest readiness profile: **${report.readiness.highest_profile ?? "none"}**`,
|
|
351
|
-
`- Target \`${report.target.profile}\`: **${report.readiness.target_passed ?
|
|
352
|
-
`-
|
|
561
|
+
`- Target \`${report.target.profile}\`: **${report.readiness.target_passed ? `PASS${targetProvenance}` : "FAIL"}**`,
|
|
562
|
+
`- Established evidence: ${report.evidence_summary.repository_detected} repository-detected, ${report.evidence_summary.agent_collected} agent-collected, ${report.evidence_summary.attested} human-attested; ${report.evidence_summary.unmet} unmet, ${report.evidence_summary.unknown} unknown${resolvedEvidence}`,
|
|
563
|
+
...report.warnings && report.warnings.length > 0 ? [`- Warnings: **${report.warnings.length} \u2014 review before using this assessment**`] : [],
|
|
353
564
|
"",
|
|
565
|
+
...report.warnings && report.warnings.length > 0 ? [
|
|
566
|
+
"## Warnings",
|
|
567
|
+
"",
|
|
568
|
+
...report.warnings.map((warning) => `- WARNING: ${safeText(warning)}`),
|
|
569
|
+
""
|
|
570
|
+
] : [],
|
|
354
571
|
"## Dimensions",
|
|
355
572
|
"",
|
|
356
573
|
"| Dimension | Score | Controls met |",
|
|
@@ -453,10 +670,11 @@ async function git(repo, args) {
|
|
|
453
670
|
async function createRepositoryContext(repository, scope, excludedPaths = []) {
|
|
454
671
|
const requestedRoot = resolve2(repository);
|
|
455
672
|
const root = await realpath(requestedRoot);
|
|
456
|
-
const [headOutput, remoteOutput, statusOutput] = await Promise.all([
|
|
673
|
+
const [headOutput, remoteOutput, statusOutput, trackedStatusOutput] = await Promise.all([
|
|
457
674
|
git(root, ["rev-parse", "HEAD"]),
|
|
458
675
|
git(root, ["config", "--get", "remote.origin.url"]),
|
|
459
|
-
git(root, ["status", "--porcelain"])
|
|
676
|
+
git(root, ["status", "--porcelain"]),
|
|
677
|
+
git(root, ["status", "--porcelain", "--untracked-files=no"])
|
|
460
678
|
]);
|
|
461
679
|
let includedPaths = null;
|
|
462
680
|
if (scope === "tracked") {
|
|
@@ -474,7 +692,8 @@ async function createRepositoryContext(repository, scope, excludedPaths = []) {
|
|
|
474
692
|
scope,
|
|
475
693
|
git_head: headOutput?.trim() || null,
|
|
476
694
|
git_remote: sanitizeRemote(remoteOutput?.trim() || null),
|
|
477
|
-
working_tree_dirty: statusOutput === null ? null : statusOutput.length > 0
|
|
695
|
+
working_tree_dirty: statusOutput === null ? null : statusOutput.length > 0,
|
|
696
|
+
tracked_tree_dirty: trackedStatusOutput === null ? null : trackedStatusOutput.length > 0
|
|
478
697
|
},
|
|
479
698
|
includedPaths,
|
|
480
699
|
excludedPaths: new Set(
|
|
@@ -492,6 +711,10 @@ function repositoryEvidenceTarget(metadata) {
|
|
|
492
711
|
};
|
|
493
712
|
}
|
|
494
713
|
|
|
714
|
+
// src/score.ts
|
|
715
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
716
|
+
import { join as join2 } from "path";
|
|
717
|
+
|
|
495
718
|
// src/evidence.ts
|
|
496
719
|
import { lstat, readFile as readFile2, realpath as realpath2, stat } from "fs/promises";
|
|
497
720
|
import { resolve as resolve3, sep as sep2 } from "path";
|
|
@@ -553,9 +776,9 @@ async function evaluatePathAll(context, check) {
|
|
|
553
776
|
found
|
|
554
777
|
);
|
|
555
778
|
}
|
|
556
|
-
async function readSearchableFiles(context, patterns) {
|
|
779
|
+
async function readSearchableFiles(context, patterns, maxFilesPerPattern) {
|
|
557
780
|
const root = context.metadata.root;
|
|
558
|
-
const paths = (await matches(context, patterns)).slice(0, maxContentFiles);
|
|
781
|
+
const paths = maxFilesPerPattern ? await prioritizedMatches(context, patterns, maxFilesPerPattern) : (await matches(context, patterns)).slice(0, maxContentFiles);
|
|
559
782
|
const files = [];
|
|
560
783
|
let totalBytes = 0;
|
|
561
784
|
for (const path of paths) {
|
|
@@ -577,6 +800,26 @@ async function readSearchableFiles(context, patterns) {
|
|
|
577
800
|
}
|
|
578
801
|
return files;
|
|
579
802
|
}
|
|
803
|
+
async function prioritizedMatches(context, patterns, maxFilesPerPattern) {
|
|
804
|
+
const selected = [];
|
|
805
|
+
const seen = /* @__PURE__ */ new Set();
|
|
806
|
+
const groups = await Promise.all(
|
|
807
|
+
patterns.map(
|
|
808
|
+
async (pattern) => (await matches(context, [pattern])).slice(0, maxFilesPerPattern)
|
|
809
|
+
)
|
|
810
|
+
);
|
|
811
|
+
for (let candidateIndex = 0; candidateIndex < maxFilesPerPattern; candidateIndex += 1) {
|
|
812
|
+
for (const group of groups) {
|
|
813
|
+
const path = group[candidateIndex];
|
|
814
|
+
if (!path) continue;
|
|
815
|
+
if (seen.has(path)) continue;
|
|
816
|
+
seen.add(path);
|
|
817
|
+
selected.push(path);
|
|
818
|
+
if (selected.length === maxContentFiles) return selected;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return selected;
|
|
822
|
+
}
|
|
580
823
|
function isGeneratedAssessment(text) {
|
|
581
824
|
const normalized = text.trimStart();
|
|
582
825
|
if (normalized.startsWith("# agentic development readiness assessment")) return true;
|
|
@@ -609,29 +852,72 @@ async function evaluateLegacyContent(context, check) {
|
|
|
609
852
|
);
|
|
610
853
|
}
|
|
611
854
|
async function evaluateContentTerms(context, check) {
|
|
612
|
-
const files = await readSearchableFiles(context, check.files);
|
|
855
|
+
const files = await readSearchableFiles(context, check.files, check.max_files_per_pattern);
|
|
613
856
|
const matchesByFile = files.map(({ path, text }) => ({
|
|
614
857
|
path,
|
|
615
|
-
|
|
616
|
-
|
|
858
|
+
...strongestContentMatch(
|
|
859
|
+
text,
|
|
860
|
+
check.terms,
|
|
861
|
+
check.required_any_terms ?? [],
|
|
862
|
+
check.min_terms,
|
|
863
|
+
check.max_span_lines
|
|
864
|
+
)
|
|
617
865
|
}));
|
|
618
|
-
const qualifying = matchesByFile.filter(
|
|
619
|
-
({ matched, requiredMatched }) => matched >= check.min_terms && (check.required_any_terms === void 0 || requiredMatched > 0)
|
|
620
|
-
);
|
|
866
|
+
const qualifying = matchesByFile.filter(({ qualifies }) => qualifies);
|
|
621
867
|
const strongest = matchesByFile.reduce((maximum, file) => Math.max(maximum, file.matched), 0);
|
|
622
868
|
const strongestRequired = matchesByFile.reduce(
|
|
623
869
|
(maximum, file) => Math.max(maximum, file.requiredMatched),
|
|
624
870
|
0
|
|
625
871
|
);
|
|
626
872
|
const requiredSummary = check.required_any_terms ? `; strongest required match ${strongestRequired}/${check.required_any_terms.length}` : "";
|
|
873
|
+
const proximitySummary = check.max_span_lines ? ` within ${check.max_span_lines}-line window(s)` : "";
|
|
627
874
|
return result(
|
|
628
875
|
check.type,
|
|
629
876
|
check.scope,
|
|
630
877
|
qualifying.length > 0 ? "met" : "not_met",
|
|
631
|
-
`${qualifying.length} qualifying file(s); strongest co-located match ${strongest}/${check.terms.length} term(s)${requiredSummary} across ${files.length} candidate file(s); threshold ${check.min_terms}`,
|
|
878
|
+
`${qualifying.length} qualifying file(s); strongest co-located match ${strongest}/${check.terms.length} term(s)${requiredSummary}${proximitySummary} across ${files.length} candidate file(s); threshold ${check.min_terms}`,
|
|
632
879
|
qualifying.map(({ path }) => path)
|
|
633
880
|
);
|
|
634
881
|
}
|
|
882
|
+
function strongestContentMatch(text, terms, requiredTerms, minTerms, maxSpanLines) {
|
|
883
|
+
if (!maxSpanLines) {
|
|
884
|
+
const matched = terms.filter((term) => containsTerm(text, term)).length;
|
|
885
|
+
const requiredMatched = requiredTerms.filter((term) => containsTerm(text, term)).length;
|
|
886
|
+
return {
|
|
887
|
+
matched,
|
|
888
|
+
requiredMatched,
|
|
889
|
+
qualifies: matched >= minTerms && (requiredTerms.length === 0 || requiredMatched > 0)
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const termCounts = terms.map(() => 0);
|
|
893
|
+
const requiredCounts = requiredTerms.map(() => 0);
|
|
894
|
+
const lines = text.split(/\r?\n/);
|
|
895
|
+
let strongest = 0;
|
|
896
|
+
let strongestRequired = 0;
|
|
897
|
+
let qualifies = false;
|
|
898
|
+
const update = (line, direction) => {
|
|
899
|
+
terms.forEach((term, index) => {
|
|
900
|
+
if (containsTerm(line, term)) termCounts[index] = (termCounts[index] ?? 0) + direction;
|
|
901
|
+
});
|
|
902
|
+
requiredTerms.forEach((term, index) => {
|
|
903
|
+
if (containsTerm(line, term)) {
|
|
904
|
+
requiredCounts[index] = (requiredCounts[index] ?? 0) + direction;
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
};
|
|
908
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
909
|
+
update(lines[index] ?? "", 1);
|
|
910
|
+
if (index >= maxSpanLines) update(lines[index - maxSpanLines] ?? "", -1);
|
|
911
|
+
const matched = termCounts.filter((count) => count > 0).length;
|
|
912
|
+
const requiredMatched = requiredCounts.filter((count) => count > 0).length;
|
|
913
|
+
strongest = Math.max(strongest, matched);
|
|
914
|
+
strongestRequired = Math.max(strongestRequired, requiredMatched);
|
|
915
|
+
if (matched >= minTerms && (requiredTerms.length === 0 || requiredMatched > 0)) {
|
|
916
|
+
qualifies = true;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { matched: strongest, requiredMatched: strongestRequired, qualifies };
|
|
920
|
+
}
|
|
635
921
|
function containsTerm(text, term) {
|
|
636
922
|
const pattern = term.trim().split(/\s+/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s_-]+");
|
|
637
923
|
return new RegExp(`(^|[^a-z0-9])${pattern}(?=$|[^a-z0-9])`, "i").test(text);
|
|
@@ -753,6 +1039,46 @@ function dimensionScore(controls, dimension) {
|
|
|
753
1039
|
}
|
|
754
1040
|
return score;
|
|
755
1041
|
}
|
|
1042
|
+
function repositoryScore(catalog, results, dimensions) {
|
|
1043
|
+
let achieved = 0;
|
|
1044
|
+
let ceiling = 0;
|
|
1045
|
+
const resultsById = new Map(results.map((result2) => [result2.id, result2]));
|
|
1046
|
+
for (const { id: dimension } of dimensions) {
|
|
1047
|
+
let dimensionAchieved = 0;
|
|
1048
|
+
let dimensionCeiling = 0;
|
|
1049
|
+
let achievedOpen = true;
|
|
1050
|
+
let ceilingOpen = true;
|
|
1051
|
+
for (const level of [1, 2, 3, 4]) {
|
|
1052
|
+
const controlsAtLevel = catalog.filter(
|
|
1053
|
+
(control) => control.dimension === dimension && control.level === level
|
|
1054
|
+
);
|
|
1055
|
+
const repositoryDetectable = controlsAtLevel.every(
|
|
1056
|
+
(control) => control.evidence.every((evidence) => evidence.scope === "repository")
|
|
1057
|
+
);
|
|
1058
|
+
if (ceilingOpen && repositoryDetectable) {
|
|
1059
|
+
dimensionCeiling = level;
|
|
1060
|
+
} else {
|
|
1061
|
+
ceilingOpen = false;
|
|
1062
|
+
}
|
|
1063
|
+
const repositoryEstablished = controlsAtLevel.every((control) => {
|
|
1064
|
+
const result2 = resultsById.get(control.id);
|
|
1065
|
+
return result2?.status === "met" && result2.confidence === "repository-detected";
|
|
1066
|
+
});
|
|
1067
|
+
if (achievedOpen && repositoryDetectable && repositoryEstablished) {
|
|
1068
|
+
dimensionAchieved = level;
|
|
1069
|
+
} else {
|
|
1070
|
+
achievedOpen = false;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
achieved += dimensionAchieved;
|
|
1074
|
+
ceiling += dimensionCeiling;
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
achieved,
|
|
1078
|
+
ceiling,
|
|
1079
|
+
percentage: ceiling === 0 ? 0 : Math.round(achieved / ceiling * 100)
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
756
1082
|
function assessProfiles(benchmark, dimensions, controls) {
|
|
757
1083
|
return benchmark.readiness_profiles.map((profile) => {
|
|
758
1084
|
const blockers = benchmark.dimensions.flatMap(({ id }) => {
|
|
@@ -770,7 +1096,23 @@ function assessProfiles(benchmark, dimensions, controls) {
|
|
|
770
1096
|
}
|
|
771
1097
|
];
|
|
772
1098
|
});
|
|
773
|
-
|
|
1099
|
+
const requiredControls = controls.filter(
|
|
1100
|
+
(control) => control.level <= profile.floors[control.dimension] && controlPasses(control)
|
|
1101
|
+
);
|
|
1102
|
+
return {
|
|
1103
|
+
id: profile.id,
|
|
1104
|
+
title: profile.title,
|
|
1105
|
+
passed: blockers.length === 0,
|
|
1106
|
+
blockers,
|
|
1107
|
+
...benchmark.version === "0.3.0" ? {
|
|
1108
|
+
evidence_dependencies: {
|
|
1109
|
+
agent_collected: requiredControls.filter(
|
|
1110
|
+
({ confidence }) => confidence === "agent-collected"
|
|
1111
|
+
).length,
|
|
1112
|
+
attested: requiredControls.filter(({ confidence }) => confidence === "attested").length
|
|
1113
|
+
}
|
|
1114
|
+
} : {}
|
|
1115
|
+
};
|
|
774
1116
|
});
|
|
775
1117
|
}
|
|
776
1118
|
async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
@@ -783,7 +1125,7 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
783
1125
|
const scope = options.scope ?? "tracked";
|
|
784
1126
|
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
785
1127
|
const context = await createRepositoryContext(repo, scope, options.excludedPaths);
|
|
786
|
-
validateAgentEvidence(benchmark, catalog, context, options.agentEvidence ?? null, now);
|
|
1128
|
+
await validateAgentEvidence(benchmark, catalog, context, options.agentEvidence ?? null, now);
|
|
787
1129
|
const controls = await Promise.all(
|
|
788
1130
|
catalog.map(
|
|
789
1131
|
async (control) => evaluateControl(
|
|
@@ -795,6 +1137,27 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
795
1137
|
)
|
|
796
1138
|
)
|
|
797
1139
|
);
|
|
1140
|
+
const warnings = [...options.warnings ?? []];
|
|
1141
|
+
if (benchmark.version === "0.3.0") {
|
|
1142
|
+
if (scope === "tracked" && context.metadata.tracked_tree_dirty) {
|
|
1143
|
+
warnings.push(
|
|
1144
|
+
"Tracked assessment includes uncommitted tracked-file contents, so the result is not reproducible from git_head alone. Use a clean worktree before comparing scores or collecting agent evidence."
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
const hasActiveSupplementalEvidence = controls.some(
|
|
1148
|
+
({ agent_evidence: agentEvidence, attestation }) => agentEvidence !== null || attestation !== null
|
|
1149
|
+
);
|
|
1150
|
+
const unresolvedExternalOrOutcome = controls.some(
|
|
1151
|
+
({ evidence, status }) => (status === "unknown" || status === "not_met") && evidence.some(
|
|
1152
|
+
({ scope: evidenceScope }) => ["platform", "organization", "outcome"].includes(evidenceScope)
|
|
1153
|
+
)
|
|
1154
|
+
);
|
|
1155
|
+
if (!hasActiveSupplementalEvidence && unresolvedExternalOrOutcome) {
|
|
1156
|
+
warnings.push(
|
|
1157
|
+
"Repository-only baseline: no active agent-collected or human-attested evidence was supplied. Platform, organization, and outcome evidence remains unresolved until authorized evidence is collected with init-evidence or supplied by accountable owners."
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
798
1161
|
const dimensions = benchmark.dimensions.map(({ id, title }) => {
|
|
799
1162
|
const dimensionControls = controls.filter((control) => control.dimension === id);
|
|
800
1163
|
return {
|
|
@@ -807,10 +1170,11 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
807
1170
|
});
|
|
808
1171
|
const profiles = assessProfiles(benchmark, dimensions, controls);
|
|
809
1172
|
const total = dimensions.reduce((sum, { score }) => sum + score, 0);
|
|
1173
|
+
const repository = benchmark.version === "0.3.0" ? repositoryScore(catalog, controls, benchmark.dimensions) : void 0;
|
|
810
1174
|
const highestProfile = [...profiles].reverse().find(({ passed }) => passed)?.id ?? null;
|
|
811
1175
|
const targetPassed = profiles.find(({ id }) => id === profileId)?.passed ?? false;
|
|
812
1176
|
return {
|
|
813
|
-
schema_version: "0.2.0",
|
|
1177
|
+
schema_version: benchmark.version === "0.3.0" ? "0.3.0" : "0.2.0",
|
|
814
1178
|
benchmark: { id: benchmark.id, version: benchmark.version },
|
|
815
1179
|
target: {
|
|
816
1180
|
repository: repo,
|
|
@@ -821,7 +1185,13 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
821
1185
|
working_tree_dirty: context.metadata.working_tree_dirty
|
|
822
1186
|
},
|
|
823
1187
|
assessed_at: now.toISOString(),
|
|
824
|
-
|
|
1188
|
+
...benchmark.version === "0.3.0" ? { warnings } : {},
|
|
1189
|
+
score: {
|
|
1190
|
+
total,
|
|
1191
|
+
maximum: 40,
|
|
1192
|
+
percentage: Math.round(total / 40 * 100),
|
|
1193
|
+
...repository ? { repository } : {}
|
|
1194
|
+
},
|
|
825
1195
|
evidence_summary: {
|
|
826
1196
|
repository_detected: controls.filter(
|
|
827
1197
|
({ confidence, status }) => confidence === "repository-detected" && status === "met"
|
|
@@ -833,7 +1203,11 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
833
1203
|
({ confidence, status }) => confidence === "attested" && status === "met"
|
|
834
1204
|
).length,
|
|
835
1205
|
unmet: controls.filter(({ status }) => status === "not_met").length,
|
|
836
|
-
unknown: controls.filter(({ status }) => status === "unknown").length
|
|
1206
|
+
unknown: controls.filter(({ status }) => status === "unknown").length,
|
|
1207
|
+
...benchmark.version === "0.3.0" ? {
|
|
1208
|
+
resolved: controls.filter(({ status }) => status !== "unknown").length,
|
|
1209
|
+
total: controls.length
|
|
1210
|
+
} : {}
|
|
837
1211
|
},
|
|
838
1212
|
dimensions,
|
|
839
1213
|
controls,
|
|
@@ -842,12 +1216,16 @@ async function assess(repo, benchmark, catalog, profileId, options = {}) {
|
|
|
842
1216
|
limitations: [
|
|
843
1217
|
scope === "tracked" ? "Tracked mode considers only Git-tracked paths, using current working-tree contents; uncommitted edits to tracked files can affect the result." : "Workspace mode includes untracked local files and is provisional; do not compare it directly with tracked-mode reports.",
|
|
844
1218
|
"Repository-detected evidence proves a qualifying artifact match, not consistent practice or external enforcement.",
|
|
1219
|
+
...benchmark.version === "0.3.0" ? [
|
|
1220
|
+
"Repository-detected progress uses only deterministic offline evidence and its attainable ceiling; it is explanatory and does not replace the normative score or readiness floors.",
|
|
1221
|
+
"Agent-collected repository evidence is semantic, target-bound, and source-backed but is not independently verified or relabelled as repository-detected."
|
|
1222
|
+
] : [],
|
|
845
1223
|
"Agent-collected evidence and human attestations are reported separately and are not independently verified.",
|
|
846
1224
|
"This assessment does not grant production access, deployment authority, or certification."
|
|
847
1225
|
]
|
|
848
1226
|
};
|
|
849
1227
|
}
|
|
850
|
-
function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
1228
|
+
async function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
851
1229
|
if (!evidence) return;
|
|
852
1230
|
if (evidence.benchmark_version !== benchmark.version) {
|
|
853
1231
|
throw new Error(
|
|
@@ -865,6 +1243,9 @@ function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
|
865
1243
|
`Agent evidence commit ${evidence.target.git_head ?? "unavailable"} does not match ${expectedTarget.git_head ?? "an unavailable Git commit"}`
|
|
866
1244
|
);
|
|
867
1245
|
}
|
|
1246
|
+
if (benchmark.version === "0.3.0" && context.metadata.tracked_tree_dirty) {
|
|
1247
|
+
throw new Error("ADRB v0.3 agent evidence requires tracked files to match the bound commit");
|
|
1248
|
+
}
|
|
868
1249
|
const controls = new Map(catalog.map((control) => [control.id, control]));
|
|
869
1250
|
if (Object.keys(evidence.claims).length > 0 && (evidence.collector.name.startsWith("TODO") || evidence.collector.version.startsWith("TODO"))) {
|
|
870
1251
|
throw new Error("Agent evidence with claims must identify the collector name and version");
|
|
@@ -875,10 +1256,39 @@ function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
|
875
1256
|
if (!control.allow_agent_evidence) {
|
|
876
1257
|
throw new Error(`${controlId} does not permit agent-collected evidence`);
|
|
877
1258
|
}
|
|
878
|
-
const
|
|
1259
|
+
const manualScopes = control.evidence.filter(({ type }) => type === "manual").map(({ scope: evidenceScope }) => evidenceScope);
|
|
1260
|
+
const allowedScopes = control.agent_evidence_scopes.length > 0 ? control.agent_evidence_scopes : manualScopes;
|
|
879
1261
|
if (!allowedScopes.includes(claim.scope)) {
|
|
880
1262
|
throw new Error(`${controlId} does not accept ${claim.scope} evidence`);
|
|
881
1263
|
}
|
|
1264
|
+
if (claim.scope === "repository") {
|
|
1265
|
+
if (context.includedPaths === null) {
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`${controlId} uses repository-scoped agent evidence, which requires --scope tracked`
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
for (const reference of claim.references) {
|
|
1271
|
+
const parsedReference = repositoryReference(reference);
|
|
1272
|
+
if (!parsedReference || !context.includedPaths.has(parsedReference.path) || context.excludedPaths.has(parsedReference.path)) {
|
|
1273
|
+
throw new Error(`${controlId} references an unavailable tracked path: ${reference}`);
|
|
1274
|
+
}
|
|
1275
|
+
if (parsedReference.lines) {
|
|
1276
|
+
const contents = await readFile3(
|
|
1277
|
+
join2(context.metadata.root, parsedReference.path),
|
|
1278
|
+
"utf8"
|
|
1279
|
+
);
|
|
1280
|
+
const lineCount = countLines(contents);
|
|
1281
|
+
if (parsedReference.lines.start < 1 || parsedReference.lines.end < parsedReference.lines.start) {
|
|
1282
|
+
throw new Error(`${controlId} references an invalid line range: ${reference}`);
|
|
1283
|
+
}
|
|
1284
|
+
if (parsedReference.lines.end > lineCount) {
|
|
1285
|
+
throw new Error(
|
|
1286
|
+
`${controlId} references lines beyond ${parsedReference.path}'s ${lineCount} lines: ${reference}`
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
882
1292
|
if (new Date(claim.collected_at) > new Date(claim.expires_at)) {
|
|
883
1293
|
throw new Error(`${controlId} expires before it was collected`);
|
|
884
1294
|
}
|
|
@@ -890,10 +1300,30 @@ function validateAgentEvidence(benchmark, catalog, context, evidence, now) {
|
|
|
890
1300
|
}
|
|
891
1301
|
}
|
|
892
1302
|
}
|
|
1303
|
+
function repositoryReference(reference) {
|
|
1304
|
+
if (!reference.startsWith("repo:")) return null;
|
|
1305
|
+
const withoutPrefix = reference.slice("repo:".length);
|
|
1306
|
+
const lineMatch = withoutPrefix.match(/#L(\d+)(?:-L?(\d+))?$/);
|
|
1307
|
+
const path = lineMatch ? withoutPrefix.slice(0, lineMatch.index) : withoutPrefix;
|
|
1308
|
+
if (path.length === 0 || path.startsWith("/") || path.includes("#") || path.includes("\\") || path.split("/").some((part) => part === ".." || part === ".")) {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
const start = lineMatch ? Number(lineMatch[1]) : null;
|
|
1312
|
+
const end = lineMatch ? Number(lineMatch[2] ?? lineMatch[1]) : null;
|
|
1313
|
+
return {
|
|
1314
|
+
path,
|
|
1315
|
+
lines: start === null || end === null ? null : { start, end }
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
function countLines(contents) {
|
|
1319
|
+
if (contents.length === 0) return 0;
|
|
1320
|
+
const lines = contents.split("\n").length;
|
|
1321
|
+
return contents.endsWith("\n") ? lines - 1 : lines;
|
|
1322
|
+
}
|
|
893
1323
|
|
|
894
1324
|
// src/cli.ts
|
|
895
1325
|
var program = new Command();
|
|
896
|
-
program.name("agentic-scorecard").description("Evidence-backed readiness assessment for agentic software development harnesses").version("0.
|
|
1326
|
+
program.name("agentic-scorecard").description("Evidence-backed readiness assessment for agentic software development harnesses").version("0.3.1");
|
|
897
1327
|
program.command("validate").description("Validate the bundled benchmark catalog").action(async () => {
|
|
898
1328
|
const { benchmark, controls } = await loadBenchmark();
|
|
899
1329
|
process.stdout.write(
|
|
@@ -920,10 +1350,10 @@ ${control.evidence.map((check) => `- ${stringify(check).trim().replaceAll("\n",
|
|
|
920
1350
|
});
|
|
921
1351
|
program.command("init").argument("[repository]", "repository to initialize", ".").option("--force", "replace an existing attestation file", false).description("Create a manual-attestation template").action(async (repository, options) => {
|
|
922
1352
|
const repo = resolve4(repository);
|
|
923
|
-
const path =
|
|
1353
|
+
const path = join3(repo, ".agentic", "attestations.yaml");
|
|
924
1354
|
if (!options.force) {
|
|
925
1355
|
try {
|
|
926
|
-
await
|
|
1356
|
+
await readFile4(path, "utf8");
|
|
927
1357
|
throw new Error(`${path} already exists; use --force to replace it`);
|
|
928
1358
|
} catch (error) {
|
|
929
1359
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -960,12 +1390,12 @@ ${stringify({ benchmark_version: benchmark.version, attestations })}`,
|
|
|
960
1390
|
process.stdout.write(`Created ${path}
|
|
961
1391
|
`);
|
|
962
1392
|
});
|
|
963
|
-
program.command("init-evidence").argument("[repository]", "repository to prepare external evidence for", ".").option("--output <path>", "agent evidence bundle path").option("--request-output <path>", "human-readable evidence request path").option("--force", "replace an existing agent evidence bundle", false).description("Create a target-bound template for agent-collected
|
|
1393
|
+
program.command("init-evidence").argument("[repository]", "repository to prepare external evidence for", ".").option("--output <path>", "agent evidence bundle path").option("--request-output <path>", "human-readable evidence request path").option("--force", "replace an existing agent evidence bundle", false).description("Create a target-bound template for unresolved agent-collected evidence").action(
|
|
964
1394
|
async (repository, options) => {
|
|
965
1395
|
const repo = resolve4(repository);
|
|
966
|
-
const path = resolve4(options.output ??
|
|
1396
|
+
const path = resolve4(options.output ?? join3(repo, ".agentic", "agent-evidence.yaml"));
|
|
967
1397
|
const requestPath = resolve4(
|
|
968
|
-
options.requestOutput ??
|
|
1398
|
+
options.requestOutput ?? join3(repo, ".agentic", "evidence-request.md")
|
|
969
1399
|
);
|
|
970
1400
|
if (path === requestPath) {
|
|
971
1401
|
throw new Error("Agent evidence bundle and request paths must be different");
|
|
@@ -973,7 +1403,7 @@ program.command("init-evidence").argument("[repository]", "repository to prepare
|
|
|
973
1403
|
if (!options.force) {
|
|
974
1404
|
for (const candidate of [path, requestPath]) {
|
|
975
1405
|
try {
|
|
976
|
-
await
|
|
1406
|
+
await readFile4(candidate, "utf8");
|
|
977
1407
|
throw new Error(`${candidate} already exists; use --force to replace it`);
|
|
978
1408
|
} catch (error) {
|
|
979
1409
|
if (error.code !== "ENOENT") throw error;
|
|
@@ -994,9 +1424,22 @@ program.command("init-evidence").argument("[repository]", "repository to prepare
|
|
|
994
1424
|
"init-evidence requires a Git commit so the bundle can be target-bound. Commit the assessed state and try again."
|
|
995
1425
|
);
|
|
996
1426
|
}
|
|
997
|
-
|
|
1427
|
+
if (context.metadata.tracked_tree_dirty) {
|
|
1428
|
+
throw new Error(
|
|
1429
|
+
"init-evidence requires tracked files to match HEAD so every claim binds to the exact assessed commit."
|
|
1430
|
+
);
|
|
1431
|
+
}
|
|
1432
|
+
const baseline = await assess(repo, benchmark, controls, "read-only-analysis", {
|
|
1433
|
+
scope: "tracked"
|
|
1434
|
+
});
|
|
1435
|
+
const unresolved = new Set(
|
|
1436
|
+
baseline.controls.filter(({ status }) => status !== "met" && status !== "not_applicable").map(({ id }) => id)
|
|
1437
|
+
);
|
|
1438
|
+
const eligibleControls = controls.filter(
|
|
1439
|
+
({ allow_agent_evidence: allowed, id }) => allowed && unresolved.has(id)
|
|
1440
|
+
);
|
|
998
1441
|
const bundle = {
|
|
999
|
-
schema_version:
|
|
1442
|
+
schema_version: benchmark.version,
|
|
1000
1443
|
benchmark_version: benchmark.version,
|
|
1001
1444
|
target: repositoryEvidenceTarget(context.metadata),
|
|
1002
1445
|
collector: { name: "TODO: agent or adapter name", version: "TODO" },
|
|
@@ -1013,22 +1456,23 @@ ${stringify(bundle)}`,
|
|
|
1013
1456
|
await writeFile(
|
|
1014
1457
|
requestPath,
|
|
1015
1458
|
[
|
|
1016
|
-
"# ADRB v0.
|
|
1459
|
+
"# ADRB v0.3 evidence request",
|
|
1017
1460
|
"",
|
|
1018
1461
|
`- Repository: ${bundle.target.repository}`,
|
|
1019
1462
|
`- Git commit: ${bundle.target.git_head ?? "unavailable"}`,
|
|
1020
1463
|
`- Benchmark: ${benchmark.version}`,
|
|
1021
1464
|
"",
|
|
1022
|
-
"Obtain authorization before accessing connected systems. Use read-only, least-privileged tools. Add only attempted claims to the bundle;
|
|
1465
|
+
"Repository claims may cite only tracked paths from the bound commit and remain agent-collected, not repository-detected. Obtain authorization before accessing connected systems. Use read-only, least-privileged tools. Add only attempted claims to the bundle; partial or inconclusive evidence remains `unknown`. Never paste source excerpts, secrets, prompts, personal data, or raw sensitive content.",
|
|
1023
1466
|
"",
|
|
1024
1467
|
...eligibleControls.flatMap((control) => {
|
|
1025
1468
|
const manualCheck = control.evidence.find(
|
|
1026
1469
|
(check) => check.type === "manual"
|
|
1027
1470
|
);
|
|
1471
|
+
const scopes = control.agent_evidence_scopes.length > 0 ? control.agent_evidence_scopes : [manualCheck?.scope ?? "organization"];
|
|
1028
1472
|
return [
|
|
1029
1473
|
`## ${control.id} \u2014 ${control.title}`,
|
|
1030
1474
|
"",
|
|
1031
|
-
`- Scope: ${
|
|
1475
|
+
`- Scope: ${scopes.join(", ")}`,
|
|
1032
1476
|
`- Request: ${manualCheck?.prompt ?? control.outcome}`,
|
|
1033
1477
|
`- Risk: ${control.risk}`,
|
|
1034
1478
|
""
|
|
@@ -1042,7 +1486,7 @@ Created ${requestPath}
|
|
|
1042
1486
|
`);
|
|
1043
1487
|
}
|
|
1044
1488
|
);
|
|
1045
|
-
program.command("assess").argument("[repository]", "repository to assess", ".").option("--profile <profile>", "target autonomy profile", "pr-creation").option("--format <format>", "json or markdown", "markdown").option("--output <path>", "write the report to a file").option("--attestations <path>", "manual attestation file").option("--agent-evidence <path>", "agent-collected external evidence bundle").option("--scope <scope>", "tracked or workspace", "tracked").option("--enforce", "exit non-zero when the target profile fails", false).option("--github-output", "append summary values to $GITHUB_OUTPUT", false).description("Assess a repository using local, read-only evidence collection").action(
|
|
1489
|
+
program.command("assess").argument("[repository]", "repository to assess", ".").option("--profile <profile>", "target autonomy profile", "pr-creation").option("--format <format>", "json or markdown", "markdown").option("--output <path>", "write the report to a file").option("--attestations <path>", "manual attestation file").option("--agent-evidence <path>", "agent-collected repository or external evidence bundle").option("--scope <scope>", "tracked or workspace", "tracked").option("--enforce", "exit non-zero when the target profile fails", false).option("--github-output", "append summary values to $GITHUB_OUTPUT", false).description("Assess a repository using local, read-only evidence collection").action(
|
|
1046
1490
|
async (repository, options) => {
|
|
1047
1491
|
if (!["json", "markdown"].includes(options.format)) {
|
|
1048
1492
|
throw new Error("--format must be json or markdown");
|
|
@@ -1052,19 +1496,27 @@ program.command("assess").argument("[repository]", "repository to assess", ".").
|
|
|
1052
1496
|
}
|
|
1053
1497
|
const repo = resolve4(repository);
|
|
1054
1498
|
const { benchmark, controls } = await loadBenchmark();
|
|
1499
|
+
const warnings = [];
|
|
1055
1500
|
const attestationPath = resolve4(
|
|
1056
|
-
options.attestations ??
|
|
1501
|
+
options.attestations ?? join3(repo, ".agentic", "attestations.yaml")
|
|
1057
1502
|
);
|
|
1058
|
-
const attestations = await loadAttestations(attestationPath, benchmark.version
|
|
1503
|
+
const attestations = await loadAttestations(attestationPath, benchmark.version, {
|
|
1504
|
+
ignoreVersionMismatch: options.attestations === void 0,
|
|
1505
|
+
onWarning: (warning) => warnings.push(warning)
|
|
1506
|
+
});
|
|
1059
1507
|
const agentEvidencePath = resolve4(
|
|
1060
|
-
options.agentEvidence ??
|
|
1508
|
+
options.agentEvidence ?? join3(repo, ".agentic", "agent-evidence.yaml")
|
|
1061
1509
|
);
|
|
1062
|
-
const agentEvidence = await loadAgentEvidence(agentEvidencePath
|
|
1510
|
+
const agentEvidence = await loadAgentEvidence(agentEvidencePath, benchmark.version, {
|
|
1511
|
+
ignoreVersionMismatch: options.agentEvidence === void 0,
|
|
1512
|
+
onWarning: (warning) => warnings.push(warning)
|
|
1513
|
+
});
|
|
1063
1514
|
const reportPath = options.output ? resolve4(options.output) : null;
|
|
1064
1515
|
const report = await assess(repo, benchmark, controls, options.profile, {
|
|
1065
1516
|
scope: options.scope,
|
|
1066
1517
|
attestations,
|
|
1067
1518
|
agentEvidence,
|
|
1519
|
+
warnings,
|
|
1068
1520
|
excludedPaths: [attestationPath, agentEvidencePath, ...reportPath ? [reportPath] : []]
|
|
1069
1521
|
});
|
|
1070
1522
|
const output = options.format === "json" ? `${JSON.stringify(report, null, 2)}
|
|
@@ -1084,6 +1536,9 @@ program.command("assess").argument("[repository]", "repository to assess", ".").
|
|
|
1084
1536
|
githubOutput,
|
|
1085
1537
|
`score=${report.score.total}
|
|
1086
1538
|
percentage=${report.score.percentage}
|
|
1539
|
+
repository_score=${report.score.repository?.achieved ?? ""}
|
|
1540
|
+
repository_ceiling=${report.score.repository?.ceiling ?? ""}
|
|
1541
|
+
repository_percentage=${report.score.repository?.percentage ?? ""}
|
|
1087
1542
|
highest_profile=${report.readiness.highest_profile ?? "none"}
|
|
1088
1543
|
target_passed=${String(report.readiness.target_passed)}
|
|
1089
1544
|
report_path=${reportPath ?? ""}
|