@cejel/cejel 0.6.0 → 0.6.2

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/README.md CHANGED
@@ -92,11 +92,12 @@ if (-not $expected -or $actual -ne $expected.ToLowerInvariant()) {
92
92
  ```
93
93
 
94
94
  > **Windows signing status:** `cejel-Windows-x86_64.exe` is intentionally unsigned in
95
- > 0.6.0 and may trigger Microsoft SmartScreen. The release build removes Node's inherited
95
+ > 0.6.2 and may trigger Microsoft SmartScreen. The release build removes Node's inherited
96
96
  > signature before SEA injection and fails unless Windows reports the result as `NotSigned`;
97
97
  > it does not ship an invalid signature. Before running it, verify `SHA256SUMS` and the
98
- > GitHub build-provenance attestation. Each binary also has an attached SPDX SBOM and an
99
- > own-platform verification receipt covering `--version`, `--help`, a real scan,
98
+ > GitHub build-provenance attestation. Each binary also has an attached SPDX SBOM (the packages
99
+ > bundled into it, listed from the build, and the Node.js runtime it embeds; from the release after
100
+ > 0.6.0 it is attested to the binary) and an own-platform verification receipt covering `--version`, `--help`, a real scan,
100
101
  > source/binary parity, and a network-denied scan. If your policy requires Authenticode,
101
102
  > use the npm package or the OCI image until a human-approved signing path is available.
102
103
 
@@ -115,7 +116,7 @@ docker run --rm --network=none -v "$PWD:/w" -w /w -v "$PWD/cejel:/cejel:ro" debi
115
116
  npx @cejel/cejel@latest .
116
117
  ```
117
118
 
118
- > **Distribution note:** Cejel `0.6.0` is the coordinated release version for npm,
119
+ > **Distribution note:** Cejel `0.6.2` is the coordinated release version for npm,
119
120
  > standalone binaries, Docker/OCI, GitHub Action, Homebrew, and MCP Registry. A GitLab CI
120
121
  > template and Code Quality export live in [`docs/gitlab-ci.md`](./docs/gitlab-ci.md).
121
122
 
@@ -148,11 +149,17 @@ The npm package is scoped as `@cejel/cejel`; its executable remains the short co
148
149
  **GitHub Action** — score every PR and publish the badge:
149
150
 
150
151
  ```yaml
152
+ - uses: actions/checkout@v4
153
+ with:
154
+ fetch-depth: 0 # full history: A2 and B2 read commit history
151
155
  - uses: BargLabs/cejel/action@v1
152
156
  with:
153
157
  min-score: "2.5" # optional: fail the build below this
154
158
  ```
155
159
 
160
+ `actions/checkout` fetches one commit by default. On a shallow clone, A2 and B2 see only that
161
+ commit, and the certificate says so under "History limitations".
162
+
156
163
  For an executed public example that binds a release artifact to the same commit named by its
157
164
  Cejel certificate, see [Certified releases with GitHub build provenance](./docs/certified-release-example.md).
158
165
 
@@ -166,7 +173,9 @@ node dist/index.js .
166
173
 
167
174
  Released binaries: `cejel-Darwin-arm64`, `cejel-Darwin-x86_64`, `cejel-Linux-aarch64`,
168
175
  `cejel-Linux-x86_64`, and `cejel-Windows-x86_64.exe`. The release also carries
169
- `SHA256SUMS`, a per-binary SPDX SBOM, and an own-platform verification receipt. Each binary
176
+ `SHA256SUMS`, a per-binary SPDX SBOM, and an own-platform verification receipt. The SBOM lists
177
+ the packages bundled into the binary plus its embedded Node.js runtime. The 0.6.0 SBOMs do not: they
178
+ list only the scanned directory and the binary. See [`docs/release-process.md`](docs/release-process.md). Each binary
170
179
  is executed against the source build and with networking denied before attachment. Releases
171
180
  from v0.1.6 also carry a Sigstore bundle containing GitHub's signed build-provenance
172
181
  attestation for the release set. Verify a downloaded binary with:
@@ -178,20 +187,21 @@ gh attestation verify ./cejel-Windows-x86_64.exe -R BargLabs/cejel
178
187
  This is cryptographically signed provenance. It is distinct from Apple Developer ID or
179
188
  Microsoft Authenticode code-signing.
180
189
 
181
- **Docker / OCI.** The current container release is `0.6.0`:
190
+ **Docker / OCI.** The current container release is `0.6.2`:
182
191
 
183
192
  ```bash
184
- docker run --rm -i -v "$PWD:/workspace:ro" ghcr.io/barglabs/cejel:0.6.0
193
+ docker run --rm -i -v "$PWD:/workspace:ro" ghcr.io/barglabs/cejel:0.6.2
185
194
  ```
186
195
 
187
196
  The image defaults to `cejel-mcp` over stdio. To use the CLI instead:
188
197
 
189
198
  ```bash
190
- docker run --rm -v "$PWD:/workspace:ro" --entrypoint cejel ghcr.io/barglabs/cejel:0.6.0 .
199
+ docker run --rm -v "$PWD:/workspace:ro" --entrypoint cejel ghcr.io/barglabs/cejel:0.6.2 .
191
200
  ```
192
201
 
193
- The OCI image carries an SBOM, maximum-mode build provenance, and a signed registry
194
- attestation.
202
+ The OCI image carries an SBOM of its base-image packages, maximum-mode build provenance, and a
203
+ signed registry attestation. From the release after 0.6.0 it also carries a second SPDX attestation
204
+ listing the packages Cejel bundles. The 0.6.0 image's SBOM does not list those.
195
205
 
196
206
  ## Leaderboard
197
207
 
@@ -425,11 +435,51 @@ a configurable `min-score` threshold. The scoring step makes no network calls an
425
435
  secrets.
426
436
 
427
437
  ```yaml
438
+ - uses: actions/checkout@v4
439
+ with:
440
+ fetch-depth: 0 # full history: A2 and B2 read commit history
428
441
  - uses: BargLabs/cejel/action@v1
429
442
  with:
430
443
  min-score: '2.5' # optional; omit to never fail the check
431
444
  ```
432
445
 
446
+ Check out with `fetch-depth: 0`: the default depth of 1 truncates the history A2 and B2 read, and
447
+ the certificate then lists a history limitation.
448
+
449
+ ## Use with Claude
450
+
451
+ Cejel ships as a Claude Code plugin in [`plugins/cejel/`](./plugins/cejel/). The plugin gives
452
+ Claude the `scan` tool below, started through `npx` and pinned to this release, plus a
453
+ `cejel-scan` skill. The skill tells Claude when a scan answers the question and how to read the
454
+ certificate. It also tells Claude to present a score as evidence for your own judgment, never as
455
+ an audit or a guarantee.
456
+
457
+ Install it from this repository's marketplace, inside Claude Code:
458
+
459
+ ```text
460
+ /plugin marketplace add BargLabs/cejel
461
+ /plugin install cejel@cejel
462
+ ```
463
+
464
+ From a shell, the same two steps are `claude plugin marketplace add BargLabs/cejel` and
465
+ `claude plugin install cejel@cejel`.
466
+
467
+ Scanning stays offline: the `scan` tool makes no network calls and writes no files. The first
468
+ time the server starts, `npx` fetches the pinned `@cejel/cejel` package from npm. When you name
469
+ a repository that isn't on your machine, Claude asks before cloning it with your `git`. Neither
470
+ fetch is part of the scan.
471
+
472
+ Example prompts that work on a public repository:
473
+
474
+ - "Clone https://github.com/BargLabs/cejel into a temporary folder and give me its Cejel trust
475
+ score."
476
+ - "Scan https://github.com/BargLabs/cejel with Cejel and explain the top findings and the criteria
477
+ they come from."
478
+ - "Run Cejel on https://github.com/BargLabs/cejel and save its trust badge to ./cejel-badge.svg."
479
+
480
+ Support: [GitHub issues](https://github.com/BargLabs/cejel/issues). Privacy policy:
481
+ <https://barglabs.ai/privacy>. Terms: <https://barglabs.ai/terms>.
482
+
433
483
  ## MCP server (for agents)
434
484
 
435
485
  The same package ships a second bin, `cejel-mcp` — a thin MCP (Model Context Protocol)
@@ -517,7 +567,7 @@ The OCI image is an alternative when Docker is the preferred execution boundary.
517
567
  the host path with the repository OpenClaw should allow Cejel to read:
518
568
 
519
569
  ```bash
520
- npx -y openclaw@latest mcp set cejel-oci '{"command":"docker","args":["run","--rm","-i","-v","/absolute/path/to/repo:/workspace:ro","ghcr.io/barglabs/cejel:0.6.0"]}'
570
+ npx -y openclaw@latest mcp set cejel-oci '{"command":"docker","args":["run","--rm","-i","-v","/absolute/path/to/repo:/workspace:ro","ghcr.io/barglabs/cejel:0.6.2"]}'
521
571
  npx -y openclaw@latest mcp doctor cejel-oci --probe
522
572
  ```
523
573
 
@@ -5,7 +5,7 @@ import {
5
5
  deriveProductIdentity,
6
6
  runCejelScan,
7
7
  serializeWitanReport
8
- } from "../chunk-2453M7C2.js";
8
+ } from "../chunk-KG62JCJZ.js";
9
9
  import {
10
10
  listCejelLlmPackFiles
11
11
  } from "../chunk-M6YYPJSN.js";
@@ -1597,6 +1597,8 @@ function detectSideEffectingToolWithoutAuthorityBoundary(file) {
1597
1597
  var CEJEL_LLM_ACTION_RULES = [
1598
1598
  {
1599
1599
  id: "LLM-VAL-001",
1600
+ tier: "semantic",
1601
+ tierEvidence: "python-lineage-rules.ts detectPythonInterproceduralModelOutput() emits this id from a 12-pass fixpoint of taint across local call arguments, returns and tainted-branch control dependence; the JS detectUnvalidatedConsequentialAction() tracks aliases",
1600
1602
  title: "Consequential structured action lacks validation",
1601
1603
  detectorConfidence: "high",
1602
1604
  evidenceContract: "A direct local JSON.parse of recognized model output supplies an object field to a named consequential dispatcher without an intervening fail-closed runtime parse or allowlist guard.",
@@ -1610,6 +1612,8 @@ var CEJEL_LLM_ACTION_RULES = [
1610
1612
  },
1611
1613
  {
1612
1614
  id: "LLM-AGY-001",
1615
+ tier: "semantic",
1616
+ tierEvidence: "boundedTaintedIdentifiers() propagates handler-parameter taint through assignments, destructuring and push, and helperCallSideEffects() follows it into one local helper call (bounded data flow; ambiguous with structural, so the higher tier)",
1613
1617
  title: "Side-effecting tool lacks an authority boundary",
1614
1618
  detectorConfidence: "high",
1615
1619
  evidenceContract: "A complete local tool declaration passes a recognized handler parameter or bounded alias to an import-resolved Node filesystem or child-process mutation API, directly or through one unique local helper, without a preceding fail-closed approval or operation-allowlist guard.",
@@ -2273,6 +2277,8 @@ function detectPythonSensitivePromptData(file) {
2273
2277
  var CEJEL_LLM_PYTHON_RULES = [
2274
2278
  {
2275
2279
  id: "LLM-IOH-001",
2280
+ tier: "semantic",
2281
+ tierEvidence: "python-lineage-rules.ts detectPythonInterproceduralModelOutput() emits this id from a 12-pass fixpoint of taint across local call arguments, returns and tainted-branch control dependence",
2276
2282
  title: "Python model output passed to a consequential sink",
2277
2283
  detectorConfidence: "high",
2278
2284
  evidenceContract: "An official OpenAI or Anthropic Python integration, a supported response-output shape or still-live direct alias in the visible function or class scope, and a direct eval, exec, os.system, or subprocess sink are observable in one non-test source file.",
@@ -2286,6 +2292,8 @@ var CEJEL_LLM_PYTHON_RULES = [
2286
2292
  },
2287
2293
  {
2288
2294
  id: "LLM-AGY-002",
2295
+ tier: "structural",
2296
+ tierEvidence: "detectPythonUnboundedAgentLoop() finds a while True loop, takes its indentation-delimited body and tests for guard presence; no data or control flow",
2289
2297
  title: "Unbounded Python agent loop",
2290
2298
  detectorConfidence: "medium",
2291
2299
  evidenceContract: "A literal while True loop has a complete local indentation-delimited body containing an official SDK call or an abstract model-base response call and no observable mandatory finite counter, deadline, cancellation, or budget exit guard; alternatively, one model-facing agent class declares both tools and an explicitly unset per-run tool-call limit.",
@@ -2298,6 +2306,8 @@ var CEJEL_LLM_PYTHON_RULES = [
2298
2306
  },
2299
2307
  {
2300
2308
  id: "LLM-DAT-001",
2309
+ tier: "structural",
2310
+ tierEvidence: "detectPythonSensitivePromptData() paren-matches the full argument extent of an import-resolved SDK model call across lines, then pattern-tests it for secret-named env reads; no flow",
2301
2311
  title: "Sensitive Python environment data passed to a model request",
2302
2312
  detectorConfidence: "high",
2303
2313
  evidenceContract: "A narrowly named os.environ or os.getenv secret-like value occurs inside the executable, syntactically matched arguments of an official SDK model call after comments and strings are excluded from call extraction.",
@@ -3936,6 +3946,8 @@ function hasModelJudgeSurface(files) {
3936
3946
  var CEJEL_LLM_EVALUATION_RULES = [
3937
3947
  {
3938
3948
  id: "LLM-PRV-001",
3949
+ tier: "semantic",
3950
+ tierEvidence: "latestAssignmentsForReferences() and hasLocalOrResolvedHelperInvocationBefore() resolve the latest in-scope assignment and the calling helper that owns the model invocation, following an aggregate to its emitter (data and call-graph flow within a file)",
3939
3951
  title: "Declared evaluation lacks reproducible system provenance",
3940
3952
  detectorConfidence: "high",
3941
3953
  evidenceContract: "A recognized direct or locally called-helper model invocation precedes a complete local evaluation result that is emitted or returned while lacking model or prompt/policy evaluation-configuration lineage. Recognized invocations include import-resolved SDK calls, a complete authenticated OpenAI-compatible REST request, and a complete local Flowise evaluation request. Supported results are aggregate-to-emitter paths, identifier-bound discrete results with model/case/outcome fields, and incrementally built per-case collections with an evaluation identity and resolved return.",
@@ -3951,6 +3963,8 @@ var CEJEL_LLM_EVALUATION_RULES = [
3951
3963
  },
3952
3964
  {
3953
3965
  id: "LLM-EVL-001",
3966
+ tier: "semantic",
3967
+ tierEvidence: "detectMissingDenominator() follows aggregate assignments and denominator aliases (denominatorAliasAssignments, latestAssignmentsForReferences) to the object that is emitted, within function scopes",
3954
3968
  title: "Evaluation result omits its eligible denominator or exclusions",
3955
3969
  detectorConfidence: "high",
3956
3970
  evidenceContract: "A complete local case-iteration, aggregate-calculation, and direct result-emission path is visible, while the emitted object lacks a denominator or raw case results.",
@@ -3964,6 +3978,8 @@ var CEJEL_LLM_EVALUATION_RULES = [
3964
3978
  },
3965
3979
  {
3966
3980
  id: "LLM-EVL-002",
3981
+ tier: "structural",
3982
+ tierEvidence: "detectSoleSelfJudge() counts producer and judge invocations in one file and resolves a model identifier to its literal binding (literalBindings); detectPythonConfiguredSelfJudge() reads class blocks and self.attribute aliasing; no flow beyond one file",
3967
3983
  title: "Evaluated system is its own sole judge",
3968
3984
  detectorConfidence: "high",
3969
3985
  evidenceContract: "Exactly one local producer and one explicit model-assisted judge resolve to the same literal model identity, a result is locally emitted, and no observable independent review signal participates in evaluation acceptance or gating.",
@@ -4751,6 +4767,8 @@ function hasUnsafeSinkSurface(file) {
4751
4767
  var CEJEL_LLM_V1_RULES = [
4752
4768
  {
4753
4769
  id: "LLM-IOH-001",
4770
+ tier: "semantic",
4771
+ tierEvidence: "detectUnsafeSink() keeps a brace-scope stack and propagates model-output taint through assignments, and javaScriptExecutableHelperParameterSinks() runs a taint fixpoint; the Python path (python-rules.ts) is an interprocedural fixpoint",
4754
4772
  title: "Model output passed to a consequential sink",
4755
4773
  detectorConfidence: "high",
4756
4774
  evidenceContract: "A supported model-output expression, or an identifier whose latest assignment in the visible lexical scope is directly from one, appears on the same line as a supported sink; process sinks additionally require a recognized local import or require binding. A local helper is also supported when one of its parameters is observably interpolated into an executable template or passed to dynamic evaluation. Imported model-facing member-tool handlers are supported when their input parameter or bounded alias reaches an import-resolved execution sink directly, through one unique helper, or by writing an executed local artifact.",
@@ -4768,6 +4786,8 @@ var CEJEL_LLM_V1_RULES = [
4768
4786
  },
4769
4787
  {
4770
4788
  id: "LLM-AGY-002",
4789
+ tier: "structural",
4790
+ tierEvidence: "detectUnboundedLoop() finds a literal while(true) or for(;;), brace-matches its whole body and tests for guard presence and a supported model call inside; no data or control flow",
4771
4791
  title: "Unbounded agent loop",
4772
4792
  detectorConfidence: "medium",
4773
4793
  evidenceContract: "A literal while(true) or for(;;) loop has a complete local brace-delimited body containing a recognized model call and no observable conditional counter, deadline, cancellation, budget, return, throw, or break guard.",
@@ -4781,6 +4801,8 @@ var CEJEL_LLM_V1_RULES = [
4781
4801
  },
4782
4802
  {
4783
4803
  id: "LLM-DAT-001",
4804
+ tier: "structural",
4805
+ tierEvidence: "detectSensitivePromptData() paren-matches the full argument extent of an import-resolved SDK model call across lines, then pattern-tests it for secret-named process.env reads; no flow",
4784
4806
  title: "Sensitive data passed to a model request",
4785
4807
  detectorConfidence: "high",
4786
4808
  evidenceContract: "A narrowly named secret-like process.env value occurs inside the executable, syntactically matched arguments of a recognized model call; comments and string-literal lookalikes are masked before call extraction.",
@@ -345,6 +345,10 @@ var WitanReportCommonSchema = external_exports.object({
345
345
  consumedSignals: external_exports.array(WitanConsumedSignalSummarySchema).optional(),
346
346
  archetype: WitanRepoArchetypeSchema.optional(),
347
347
  scanLimitations: external_exports.array(external_exports.string().min(1).max(1e3)).max(16).optional(),
348
+ // Additive-optional (report format 1.5, issue #428): disclosures about the commit history the
349
+ // scan read, such as a shallow clone. Declared, never scored: unlike scanLimitations it gates
350
+ // nothing (no score, abstention, badge or --min-score effect). Absent when history is complete.
351
+ historyLimitations: external_exports.array(external_exports.string().min(1).max(1e3)).max(4).optional(),
348
352
  contentReadSummary: WitanContentReadSummarySchema.optional(),
349
353
  // Additive-optional (report format 1.3): absent means a report produced before this field
350
354
  // existed, never "nothing withheld" — createWitanReport always sets it (possibly to []) on any
@@ -397,7 +401,7 @@ var WitanReportSchema = external_exports.preprocess((value) => {
397
401
  }, StrictWitanReportSchema);
398
402
  var WITAN_ATTESTATION_STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
399
403
  var WITAN_ATTESTATION_PREDICATE_TYPE = "https://cejel.dev/attestations/scan/v1";
400
- var WITAN_REPORT_FORMAT_VERSION = "1.4";
404
+ var WITAN_REPORT_FORMAT_VERSION = "1.5";
401
405
  var WitanAttestationOutcomeSchema = external_exports.discriminatedUnion("status", [
402
406
  external_exports.object({
403
407
  status: external_exports.literal("scored"),
@@ -502,9 +506,6 @@ function shortCategoryLabel(category) {
502
506
  function categoryLabel(category) {
503
507
  return category.replaceAll("_", " ");
504
508
  }
505
- function formatCoverageCounts(coverage) {
506
- return coverage.byCategory.map((entry) => `${shortCategoryLabel(entry.category)} ${entry.measured}/${entry.total}`).join(" \xB7 ");
507
- }
508
509
  function formatCoverageSummary(coverage) {
509
510
  const parts = coverage.byCategory.map(
510
511
  (entry) => `${categoryLabel(entry.category)} ${entry.measured}/${entry.total}`
@@ -1368,7 +1369,7 @@ function roundScore(score) {
1368
1369
  }
1369
1370
 
1370
1371
  // src/witan/certificate-presentation.ts
1371
- var CALLER_CONTEXT_PRODUCT_IDENTITY_NOTICE = "Product name and slug are caller context and are excluded from certificate byte-comparison claims.";
1372
+ var CALLER_CONTEXT_PRODUCT_IDENTITY_NOTICE = "The product name and its short ID were given by whoever ran the scan, or taken from package.json or the folder name. They are not evidence from the repository, and they are not covered when Cejel says two scans of the same revision give identical results.";
1372
1373
  var CERTIFICATE_SCOPE_NOTICE = "This certificate is a statement about the repository tree at the revision pinned above, not about the system that tree is part of. Evidence outside that tree \u2014 for example, a test suite kept in a separate repository, or CI configuration that changes into a different working directory before running its checks \u2014 is neither seen by this scan nor claimed to be absent.";
1373
1374
  var CERTIFICATE_METRIC_REGISTRY = {
1374
1375
  test_to_source_ratio: { displayLabel: "Test-to-source file ratio" },
@@ -1430,7 +1431,7 @@ var CERTIFICATE_GLOSSARY = [
1430
1431
  {
1431
1432
  key: "static-coverage",
1432
1433
  term: "Static coverage percentage",
1433
- definition: "A percentage read from a coverage report or threshold that the repository itself publishes. Cejel does not run the repository's tests. The scoring rubric defines how much credit the published percentage receives.",
1434
+ definition: "A percentage read from a coverage report or threshold that the repository itself publishes. Cejel does not run the repository's tests. The scoring rubric defines how much credit the published percentage receives. When no percentage above 0 is published, the rubric scores this metric as 0 of 100.",
1434
1435
  metricNames: ["coverage_percent"]
1435
1436
  },
1436
1437
  {
@@ -1670,6 +1671,9 @@ function isCoverageNotMeasured(criterion, metric2) {
1670
1671
  ...criterion.findings.map((finding) => finding.evidence)
1671
1672
  ].some((evidence) => evidence.kind === "coverage");
1672
1673
  }
1674
+ function isCoveragePercentageUnpublished(criterion, metric2) {
1675
+ return metric2.name === "coverage_percent" && metric2.value === 0 && !isCoverageNotMeasured(criterion, metric2);
1676
+ }
1673
1677
  function labelAlreadyStatesUnit(label, unit) {
1674
1678
  if (!unit) return false;
1675
1679
  const escapedUnit = unit.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -1733,7 +1737,10 @@ function formatEnumerableMetric(metric2) {
1733
1737
  }
1734
1738
  function formatCertificateMetricValue(criterion, metric2) {
1735
1739
  if (isCoverageNotMeasured(criterion, metric2)) {
1736
- return "no coverage report found \u2014 not measured";
1740
+ return "no coverage report or threshold found, so there is no percentage to read (Cejel does not run tests); the rubric scores this as 0 of 100";
1741
+ }
1742
+ if (isCoveragePercentageUnpublished(criterion, metric2)) {
1743
+ return "coverage configuration found, but no published coverage percentage above 0 (Cejel reads published reports and thresholds and does not run tests); the rubric scores this as 0 of 100";
1737
1744
  }
1738
1745
  if (metric2.name === "test_to_source_ratio" && metric2.max !== void 0) {
1739
1746
  const tests = formatMetricNumber(metric2.value);
@@ -1803,7 +1810,12 @@ function buildNextSteps(report, unmeasured, notApplicable, a1, coverageMetric) {
1803
1810
  }
1804
1811
  if (a1 && coverageMetric && isCoverageNotMeasured(a1, coverageMetric)) {
1805
1812
  steps.push(
1806
- "Obtain a coverage report or configured threshold for A1's static coverage metric; none was found, so it was not measured."
1813
+ "Obtain a coverage report or configured threshold for A1's static coverage metric; none was found, so no percentage was measured."
1814
+ );
1815
+ }
1816
+ if (a1 && coverageMetric && isCoveragePercentageUnpublished(a1, coverageMetric)) {
1817
+ steps.push(
1818
+ "Publish a coverage report or threshold for A1's static coverage metric; coverage configuration was found, but no published percentage above 0."
1807
1819
  );
1808
1820
  }
1809
1821
  if (!report.repo.headSha && report.criteria.some(
@@ -1825,10 +1837,10 @@ function buildNextSteps(report, unmeasured, notApplicable, a1, coverageMetric) {
1825
1837
  `Resolve the ${scanLimitationCount} scan limitation${scanLimitationCount === 1 ? "" : "s"} qualifying the readable evidence, then reproduce the scan.`
1826
1838
  );
1827
1839
  }
1828
- const skippedCount = report.contentReadSummary?.skipped ?? 0;
1829
- if (skippedCount > 0) {
1840
+ const actionableSkips = actionableSkipReasons(report.contentReadSummary);
1841
+ if (actionableSkips.count > 0) {
1830
1842
  steps.push(
1831
- `Make the ${skippedCount} skipped content entr${skippedCount === 1 ? "y" : "ies"} readable (see the itemized reasons in the certificate), then reproduce the scan.`
1843
+ `Resolve the ${actionableSkips.count} content entr${actionableSkips.count === 1 ? "y" : "ies"} skipped as ${joinWithOr(actionableSkips.reasons)} (see the itemized reasons in the certificate), then reproduce the scan.`
1832
1844
  );
1833
1845
  }
1834
1846
  if (steps.length === 0) {
@@ -1838,6 +1850,76 @@ function buildNextSteps(report, unmeasured, notApplicable, a1, coverageMetric) {
1838
1850
  }
1839
1851
  return steps;
1840
1852
  }
1853
+ function joinWithConjunction(items, conjunction) {
1854
+ if (items.length <= 1) return items.join("");
1855
+ return `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
1856
+ }
1857
+ function joinWithOr(items) {
1858
+ return joinWithConjunction(items, "or");
1859
+ }
1860
+ function joinWithAnd(items) {
1861
+ return joinWithConjunction(items, "and");
1862
+ }
1863
+ var ACTIONABLE_SKIP_REASONS = [
1864
+ { key: "unreadable", label: "unreadable" },
1865
+ { key: "tooLarge", label: "too large" },
1866
+ { key: "deniedPath", label: "a denied path" },
1867
+ { key: "nonRegularFile", label: "not a regular file" }
1868
+ ];
1869
+ function actionableSkipReasons(summary) {
1870
+ if (!summary) return { count: 0, reasons: [] };
1871
+ const present = ACTIONABLE_SKIP_REASONS.filter(({ key }) => summary.byReason[key] > 0);
1872
+ return {
1873
+ count: present.reduce((sum, { key }) => sum + summary.byReason[key], 0),
1874
+ reasons: present.map(({ label }) => label)
1875
+ };
1876
+ }
1877
+ function contentEntries(count, singular, plural) {
1878
+ return `${count} content entr${count === 1 ? `y ${singular}` : `ies ${plural}`}`;
1879
+ }
1880
+ function formatMeasuredDimensionCounts(coverage) {
1881
+ return coverage.byCategory.map((entry) => `${shortCategoryLabel(entry.category)} ${entry.measured} of ${entry.total}`).join(", ");
1882
+ }
1883
+ function notApplicableCount(report) {
1884
+ return report.criteria.filter((criterion) => criterion.status === "not_applicable").length;
1885
+ }
1886
+ function formatDimensionsMeasuredSentence(report) {
1887
+ const notApplicable = notApplicableCount(report);
1888
+ const counts = `Dimensions measured: ${formatMeasuredDimensionCounts(computeMeasuredCoverage(report))}.`;
1889
+ return notApplicable > 0 ? `${counts} ${notApplicable} do not apply to this repository.` : counts;
1890
+ }
1891
+ function formatLowConfidenceSentence(report) {
1892
+ const coverage = computeMeasuredCoverage(report);
1893
+ if (!coverage.lowConfidence) return void 0;
1894
+ const isLow = (counts) => counts.total > 0 && counts.measured / counts.total < LOW_CONFIDENCE_COVERAGE_THRESHOLD;
1895
+ const parts = [
1896
+ ...coverage.byCategory.filter(isLow).map((entry) => `${shortCategoryLabel(entry.category)} (${entry.measured} of ${entry.total})`),
1897
+ ...isLow(coverage.overall) ? [`overall (${coverage.overall.measured} of ${coverage.overall.total})`] : []
1898
+ ];
1899
+ const sentence = `Low confidence: fewer than half of the ${joinWithAnd(parts)} dimensions were measured.`;
1900
+ return notApplicableCount(report) > 0 ? `${sentence} Dimensions that do not apply count as not measured here.` : sentence;
1901
+ }
1902
+ var SCORED_VERDICT_BANDS = {
1903
+ conditional: { label: "Conditional", band: "2.5 up to 3.5" },
1904
+ at_risk: { label: "At risk", band: "1.5 up to 2.5" },
1905
+ unverified: { label: "Unverified", band: "below 1.5" }
1906
+ };
1907
+ var MAX_LOWEST_DIMENSIONS = 3;
1908
+ function buildVerdictExplanation(report) {
1909
+ if (report.verdict === "insufficient_source" || report.verdict === "verified") return void 0;
1910
+ if (report.overallScore === null) return void 0;
1911
+ const hasCriticalOrWarning = report.criteria.some(
1912
+ (criterion) => criterion.findings.some((finding) => finding.severity === "critical" || finding.severity === "warning")
1913
+ );
1914
+ if (hasCriticalOrWarning) return void 0;
1915
+ const { label, band } = SCORED_VERDICT_BANDS[report.verdict];
1916
+ const lowest = report.criteria.map((criterion, index) => ({ criterion, index })).filter(
1917
+ ({ criterion }) => isMeasuredCriterionStatus(criterion.status) && witanVerdictForScore(criterion.score) !== "verified"
1918
+ ).sort((a, b) => a.criterion.score - b.criterion.score || a.index - b.index).slice(0, MAX_LOWEST_DIMENSIONS).map(({ criterion }) => `${criterion.id} (${criterion.score.toFixed(1)})`);
1919
+ const verdictSentence = `The ${label} verdict comes from the overall score of ${report.overallScore.toFixed(1)} out of 4.0, which is in the ${label} band (${band}). No finding set it.`;
1920
+ if (lowest.length === 0) return verdictSentence;
1921
+ return lowest.length === 1 ? `${verdictSentence} The measured dimension scoring lowest is ${lowest[0]}.` : `${verdictSentence} The measured dimensions scoring lowest are ${joinWithAnd(lowest)}.`;
1922
+ }
1841
1923
  var WITHHELD_PATH_READ_FAILURE_REASONS = /* @__PURE__ */ new Set([
1842
1924
  "unreadable",
1843
1925
  "denied_path"
@@ -1876,6 +1958,13 @@ function buildWithheldPathsGapSentence(report) {
1876
1958
  const reportsOnWhatItRead = signals.length === 1 ? "that signal reports on what it read" : "these signals report on what they read";
1877
1959
  return `${count} ${entryWord} withheld (${reasonLabel}); ${signalList} would have read ${pronoun}. Under this rubric ${reportsOnWhatItRead}; the prospective v23 rubric abstains instead.`;
1878
1960
  }
1961
+ function describeEstablishedDimensions(report, measured, applicable) {
1962
+ const total = report.criteria.length;
1963
+ const counts = formatMeasuredDimensionCounts(computeMeasuredCoverage(report));
1964
+ const measuredSentence = `The report measured ${measured} of the ${total} rubric dimensions (${counts}).`;
1965
+ const notApplicable = total - applicable;
1966
+ return notApplicable > 0 ? `${measuredSentence} ${notApplicable} do not apply to this repository; ${measured} of the ${applicable} that apply were measured.` : `${measuredSentence} All ${total} apply to this repository.`;
1967
+ }
1879
1968
  function buildRelyingPartySummary(report) {
1880
1969
  const applicable = report.criteria.filter((criterion) => criterion.status !== "not_applicable");
1881
1970
  const measured = applicable.filter((criterion) => criterion.status !== "insufficient_data");
@@ -1886,7 +1975,12 @@ function buildRelyingPartySummary(report) {
1886
1975
  const gaps = [];
1887
1976
  if (a1 && coverageMetric && isCoverageNotMeasured(a1, coverageMetric)) {
1888
1977
  gaps.push(
1889
- "No coverage report or configured threshold was found, so static coverage was not measured; Cejel does not run the subject's tests."
1978
+ "No coverage report or configured threshold was found, so no static coverage percentage was measured and the rubric scored it as 0 of 100; Cejel does not run the subject's tests."
1979
+ );
1980
+ }
1981
+ if (a1 && coverageMetric && isCoveragePercentageUnpublished(a1, coverageMetric)) {
1982
+ gaps.push(
1983
+ "Coverage configuration was found, but no coverage report or threshold in this tree publishes a percentage above 0, so the rubric scored static coverage as 0 of 100; Cejel does not run the subject's tests."
1890
1984
  );
1891
1985
  }
1892
1986
  if (!report.repo.headSha && report.criteria.some(
@@ -1910,10 +2004,16 @@ function buildRelyingPartySummary(report) {
1910
2004
  const count = report.scanLimitations?.length ?? 0;
1911
2005
  gaps.push(`${count} scan limitation${count === 1 ? "" : "s"} qualifies the readable evidence.`);
1912
2006
  }
1913
- if ((report.contentReadSummary?.skipped ?? 0) > 0) {
1914
- const skipped = report.contentReadSummary?.skipped ?? 0;
2007
+ const actionableSkips = actionableSkipReasons(report.contentReadSummary).count;
2008
+ if (actionableSkips > 0) {
1915
2009
  gaps.push(
1916
- `${skipped} content entr${skipped === 1 ? "y was" : "ies were"} skipped for the reasons itemized in the certificate.`
2010
+ `${contentEntries(actionableSkips, "was", "were")} skipped for the reasons itemized in the certificate.`
2011
+ );
2012
+ }
2013
+ const excludedByExtension = report.contentReadSummary?.byReason.excludedByExtension ?? 0;
2014
+ if (excludedByExtension > 0) {
2015
+ gaps.push(
2016
+ `${contentEntries(excludedByExtension, "was", "were")} not read because ${excludedByExtension === 1 ? "its file type is" : "their file types are"} outside what Cejel reads. This is a fixed limit of the tool, not something the repository needs to fix.`
1917
2017
  );
1918
2018
  }
1919
2019
  const withheldPathsSentence = buildWithheldPathsGapSentence(report);
@@ -1926,7 +2026,7 @@ function buildRelyingPartySummary(report) {
1926
2026
  return {
1927
2027
  scope: CERTIFICATE_SCOPE_NOTICE,
1928
2028
  examined: `Cejel examined the repository evidence recorded for ${report.productDisplayName}${revision} under ${report.rubricVersion}.`,
1929
- established: `The report established measured results for ${measured.length} of ${applicable.length} applicable rubric dimensions and recorded ${findings} evidence-backed finding${findings === 1 ? "" : "s"}.`,
2029
+ established: `${describeEstablishedDimensions(report, measured.length, applicable.length)} It recorded ${findings} evidence-backed finding${findings === 1 ? "" : "s"}.`,
1930
2030
  notEstablished: gaps.join(" "),
1931
2031
  next: buildNextSteps(report, unmeasured, notApplicable, a1, coverageMetric).join(" ")
1932
2032
  };
@@ -2022,6 +2122,7 @@ function renderWitanHtmlReport(report, options = {}) {
2022
2122
  const coverage = computeMeasuredCoverage(report);
2023
2123
  const gitHistoryUnavailable = !report.repo.headSha;
2024
2124
  const relyingPartySummary = buildRelyingPartySummary(report);
2125
+ const lowConfidenceSentence = formatLowConfidenceSentence(report);
2025
2126
  return `<!doctype html>
2026
2127
  <html lang="en">
2027
2128
  <head>
@@ -2043,7 +2144,6 @@ function renderWitanHtmlReport(report, options = {}) {
2043
2144
  <p class="eyebrow">Trust Certificate</p>
2044
2145
  <h1>${escapeHtml(report.productDisplayName)}</h1>
2045
2146
  <dl class="meta">
2046
- <div><dt>Product identity</dt><dd>${escapeHtml(CALLER_CONTEXT_PRODUCT_IDENTITY_NOTICE)}</dd></div>
2047
2147
  ${options.generatedAt ? `<div><dt>Date</dt><dd>${escapeHtml(formatDate(options.generatedAt))}</dd></div>` : ""}
2048
2148
  <div><dt>Run</dt><dd>${escapeHtml(renderRepo(report))}</dd></div>
2049
2149
  <div><dt>CLI</dt><dd>${escapeHtml(options.cliVersion ? `Cejel ${options.cliVersion}` : "Not recorded")}</dd></div>
@@ -2052,6 +2152,7 @@ function renderWitanHtmlReport(report, options = {}) {
2052
2152
  ${// Gated on the rubric actually being prospective — a calibrated (v17) certificate's
2053
2153
  // HTML stays byte-identical to before --rubric-pin existed.
2054
2154
  isProspectiveRubricVersion(report.rubricVersion) ? `<div><dt>Calibration</dt><dd class="prospective-rubric-notice">${escapeHtml(PROSPECTIVE_RUBRIC_NOTICE)}</dd></div>` : ""}
2155
+ <div><dt>About the name</dt><dd>${escapeHtml(CALLER_CONTEXT_PRODUCT_IDENTITY_NOTICE)}</dd></div>
2055
2156
  ${contributingSources.length > 0 ? `<div><dt>Sources</dt><dd>Incorporates findings from: ${escapeHtml(contributingSources.join(", "))}<ul class="source-counts">${externalSourceSummaries.map((s) => `<li>${escapeHtml(formatSourceProvenanceLine(s))}</li>`).join("")}</ul></dd></div>` : ""}
2056
2157
  </dl>
2057
2158
  </section>
@@ -2065,7 +2166,8 @@ function renderWitanHtmlReport(report, options = {}) {
2065
2166
  <span>Code ${renderCategoryScore(report.codeTrustScore, coverage, "code_trust")}</span>
2066
2167
  <span>Process ${renderCategoryScore(report.processTrustScore, coverage, "process_trust")}</span>
2067
2168
  </div>
2068
- <div class="coverage-note">${escapeHtml(formatCoverageCounts(coverage))} measured${coverage.lowConfidence ? " \xB7 low confidence" : ""}</div>`}
2169
+ <div class="coverage-note">${escapeHtml(formatDimensionsMeasuredSentence(report))}</div>${lowConfidenceSentence ? `
2170
+ <div class="coverage-note">${escapeHtml(lowConfidenceSentence)}</div>` : ""}`}
2069
2171
  </aside>
2070
2172
  </div>
2071
2173
  </header>
@@ -2081,6 +2183,13 @@ function renderWitanHtmlReport(report, options = {}) {
2081
2183
  ).join("")}</ul>
2082
2184
  </section>` : ""}
2083
2185
 
2186
+ ${(report.historyLimitations?.length ?? 0) > 0 ? `<section class="scan-limitations-section evidence-section" aria-label="History limitations">
2187
+ <h2>History limitations</h2>
2188
+ <ul>${(report.historyLimitations ?? []).map(
2189
+ (limitation) => `<li class="scan-warning"><strong>History limitation</strong><span>${escapeHtml(limitation)}</span></li>`
2190
+ ).join("")}</ul>
2191
+ </section>` : ""}
2192
+
2084
2193
  ${renderContentReadSummary(report)}
2085
2194
 
2086
2195
  <section class="trust-grid" aria-label="Rubric criteria">
@@ -2128,9 +2237,12 @@ function collectFindingsFirstEntries(report) {
2128
2237
  function renderFindingsFirstSummary(report) {
2129
2238
  const entries = collectFindingsFirstEntries(report);
2130
2239
  const body = entries.length > 0 ? `<ul>${entries.map(({ criterion, finding }) => `<li>${renderFindingEvidence(criterion, finding)}</li>`).join("")}</ul>` : '<p class="findings-first-empty">No critical or warning findings were identified across any criterion in this scan.</p>';
2240
+ const verdictExplanation = buildVerdictExplanation(report);
2241
+ const explanation = verdictExplanation ? `
2242
+ <p class="verdict-explanation">${escapeHtml(verdictExplanation)}</p>` : "";
2131
2243
  return `<section class="findings-first-summary" aria-labelledby="findings-first-heading">
2132
2244
  <h2 id="findings-first-heading">Critical and warning findings</h2>
2133
- ${body}
2245
+ ${body}${explanation}
2134
2246
  </section>`;
2135
2247
  }
2136
2248
  function renderRelyingPartySummary(summary) {
@@ -2457,6 +2569,7 @@ dd { margin: 0; color: var(--muted); overflow-wrap: anywhere; }
2457
2569
  .findings-first-summary { margin-top: 20px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--surface); padding: 22px; }
2458
2570
  .findings-first-summary ul { margin: 0; padding-left: 18px; display: grid; gap: 10px; }
2459
2571
  .findings-first-empty { margin: 0; color: var(--muted); }
2572
+ .verdict-explanation { margin: 10px 0 0; color: var(--muted); }
2460
2573
  .relying-party-summary, .glossary { margin-top: 28px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--surface); padding: 22px; }
2461
2574
  .relying-party-summary dl { display: grid; gap: 14px; margin: 0; }
2462
2575
  .relying-party-summary dl div { display: grid; grid-template-columns: 190px minmax(0, 1fr); gap: 18px; }
@@ -9116,6 +9229,15 @@ function hasEnvPathInGitHistory(repoPath, scanLimitations) {
9116
9229
  // src/witan/public-scan.ts
9117
9230
  init_esm_shims();
9118
9231
 
9232
+ // src/witan/history-depth.ts
9233
+ init_esm_shims();
9234
+ var SHALLOW_HISTORY_LIMITATION = "This repository is a shallow clone (git rev-parse --is-shallow-repository reports true), so its commit history is truncated. A2 (credentials and .env files in earlier commits) and B2 (pull-request merge ratio over recent commits) read history and saw only the commits present; on a full clone their results may differ. Scores are computed exactly as for any other scan. Re-scan a full clone (for actions/checkout, set fetch-depth: 0).";
9235
+ function detectHistoryLimitations(repoPath) {
9236
+ const result = execGit(["rev-parse", "--is-shallow-repository"], { cwd: repoPath });
9237
+ if (!result.ok || result.stdout.trim() !== "true") return void 0;
9238
+ return [SHALLOW_HISTORY_LIMITATION];
9239
+ }
9240
+
9119
9241
  // src/witan/ingest.ts
9120
9242
  init_esm_shims();
9121
9243
  import { lstatSync as lstatSync3, opendirSync, readFileSync as readFileSync4, realpathSync as realpathSync3 } from "fs";
@@ -9653,12 +9775,14 @@ function scoreRepoWithPublicCejel(options) {
9653
9775
  ...options.rubricVersion ? { rubricVersion: options.rubricVersion } : {}
9654
9776
  });
9655
9777
  const inputSignals = resolvePublicIngestSignals(options);
9656
- const report = createWitanReport(
9778
+ const scored = createWitanReport(
9657
9779
  input,
9658
9780
  inputSignals.length > 0 ? inputSignals : void 0,
9659
9781
  void 0,
9660
9782
  options.toolVersion
9661
9783
  );
9784
+ const historyLimitations = detectHistoryLimitations(options.repoPath);
9785
+ const report = historyLimitations ? { ...scored, historyLimitations } : scored;
9662
9786
  if (report.verdict !== "insufficient_source" || !isWitanNoMeasurementAbstention(report)) {
9663
9787
  return report;
9664
9788
  }
@@ -9880,6 +10004,7 @@ function buildWitanCliSummary(report) {
9880
10004
  externalFindingCount: allExternalFindings.length,
9881
10005
  topExternalFindings: allExternalFindings.slice(0, EXTERNAL_FINDINGS_DISPLAY_LIMIT),
9882
10006
  scanLimitations: [...report.scanLimitations ?? []],
10007
+ ...report.historyLimitations?.length ? { historyLimitations: [...report.historyLimitations] } : {},
9883
10008
  ...report.contentReadSummary ? { contentReadSummary: report.contentReadSummary } : {}
9884
10009
  };
9885
10010
  if (report.verdict === "insufficient_source") {
@@ -9937,6 +10062,9 @@ export {
9937
10062
  formatCertificateMetricLabel,
9938
10063
  formatCertificateMetricAppliedWeightPercent,
9939
10064
  formatCertificateMetricValue,
10065
+ formatDimensionsMeasuredSentence,
10066
+ formatLowConfidenceSentence,
10067
+ buildVerdictExplanation,
9940
10068
  buildRelyingPartySummary,
9941
10069
  renderWitanHtmlReport,
9942
10070
  renderWitanBadgeEndpoint,