@dailephd/my-dev-kit-lab 0.9.1 → 0.9.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/CHANGELOG.md +14 -2
- package/README.md +8 -2
- package/dist/src/commands/runExperimentRunCommand.js +4 -3
- package/dist/src/experiments/plugins/agentSuccessRate/campaignCheckpointStore.js +1 -1
- package/dist/src/experiments/plugins/agentSuccessRate/campaignProgress.js +4 -2
- package/dist/src/report/experiments/writeAgentSuccessCampaignProgressReport.js +6 -2
- package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
- package/dist/src/securityValidation/packageChecks/parseNpmPackDryRun.js +34 -4
- package/dist/src/securityValidation/packageChecks/requiredPackageContents.js +106 -2
- package/dist/src/securityValidation/packageChecks/runPackageChecks.js +31 -16
- package/dist/src/securityValidation/staticScans/semgrep.js +68 -64
- package/dist/src/securityValidation/types.js +0 -4
- package/dist/src/securityValidation/validate/runSecurityValidation.js +1 -1
- package/docs/METRICS.md +1 -1
- package/examples/tutorial-browser/prepare.mjs +13 -13
- package/examples/tutorial-browser/server.mjs +13 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to my-dev-kit-lab are documented here.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.9.2] - 2026-10-13
|
|
8
|
+
|
|
9
|
+
### Security validation and cross-platform corrections
|
|
10
|
+
|
|
11
|
+
- Corrected external npm package validation to derive required entrypoints from validated package metadata while preserving the strict Lab self-package inventory. Missing declared entrypoints, invalid or incomplete metadata, failed or incomplete package evidence, and forbidden contents remain blockers.
|
|
12
|
+
- Eliminated nine inherited Lab-only false positives when validating the external Orchestrator package. This does not change the Orchestrator package or its independent security findings and release status.
|
|
13
|
+
- Corrected Semgrep failed and incomplete scan evidence so neither can produce a false `PASS`; unavailable optional scanners remain explicitly `SKIPPED`.
|
|
14
|
+
- Corrected campaign filesystem identity and path-safety behavior and cross-platform compatibility.
|
|
15
|
+
- Preserved security verdict strictness, severity mappings, mandatory checks, and existing command and report contracts.
|
|
16
|
+
|
|
17
|
+
This section records the intended v0.9.2 release contents. Registry publication status is established through npm evidence, not this changelog.
|
|
18
|
+
|
|
5
19
|
## [0.9.1] - 2026-10-12
|
|
6
20
|
|
|
7
21
|
### Normalized provider telemetry and resumable campaigns
|
|
@@ -11,8 +25,6 @@ All notable changes to my-dev-kit-lab are documented here.
|
|
|
11
25
|
- Added public campaign CLI options, Codex and Claude timeout presets, campaign progress summaries, and strong/partial/unavailable metric-specific evidence classifications.
|
|
12
26
|
- Preserved partial evidence without presenting it as complete scientific analysis; uncertain provider invocations are not replayed automatically. Real-provider use remains explicitly opted in and depends on the existing provider adapters.
|
|
13
27
|
|
|
14
|
-
## [Unreleased]
|
|
15
|
-
|
|
16
28
|
## [0.9.0] - 2026-10-10
|
|
17
29
|
|
|
18
30
|
### Agent success rate
|
package/README.md
CHANGED
|
@@ -6,11 +6,15 @@ my-dev-kit provides local repository indexing and graph-guided retrieval. my-dev
|
|
|
6
6
|
|
|
7
7
|
Whole-ecosystem workflows are centralized in [my-dev-kit/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md). Its [command-surface compatibility map](https://github.com/dailephd/my-dev-kit/blob/main/docs/ECOSYSTEM_DEVELOPMENT_WORKFLOWS.md#915-command-surface-compatibility-map) documents which Lab outputs can safely seed my-dev-kit investigation, Observer reference work, or Orchestrator evidence, and which look-alike artifacts are not direct inputs.
|
|
8
8
|
|
|
9
|
-
The
|
|
9
|
+
The current release-document and package-metadata version is v0.9.2. It corrects external npm package entrypoint validation, preserves strict Lab self-package validation, eliminates nine inherited false positives, corrects Semgrep failed/incomplete-scan evidence, and includes campaign filesystem identity, path-safety, and cross-platform corrections; security verdict strictness is unchanged. v0.9.1 (normalized provider telemetry and resumable agent-success-rate campaigns) is the previous published release. Publication is established separately through registry evidence. The v0.4.5 context-integrity evaluation remains intentionally frozen against the published `@dailephd/my-dev-kit@1.10.4` and `@dailephd/my-dev-kit-orchestrator@1.2.3` contracts; those versions are historical validation baselines, not a statement that they are the ecosystem's current releases. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for current state.
|
|
10
10
|
|
|
11
11
|
v0.9.0 adds the `agent-success-rate` plugin, the eighth registered experiment plugin, which evaluates implementation patches under the `raw-full-file` and `context-pack` treatments.
|
|
12
12
|
|
|
13
|
-
v0.9.1
|
|
13
|
+
v0.9.1 is the previous published package release.
|
|
14
|
+
|
|
15
|
+
The v0.9.2 release corrects external npm package entrypoint validation, preserves strict Lab self-package validation, eliminates nine inherited false positives, corrects Semgrep failed/incomplete-scan evidence, and includes campaign filesystem identity, path-safety, and cross-platform corrections. Security verdict strictness is unchanged. Readiness was accepted for the exact implementation SHA, and the release candidate passed its release-PR validation. The current release-document state is final; actual npm publication is established separately through registry evidence.
|
|
16
|
+
|
|
17
|
+
The original prompt/report/gallery feature milestone remains planned for v0.9.3. See [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for implementation-stage evidence and lifecycle state.
|
|
14
18
|
|
|
15
19
|
v0.4.6 adds a supported `my-dev-kit-lab` installed CLI router (`--help`, `--version`, `security validate`, `audit`, the `experiment` family, `report render`, `plots generate`, `gallery build`, `demo final`, and the historical direct final-demo invocation form), a writable lab workspace model kept separate from the installed package and the inspected target, and a permanent packed-tarball installation/execution acceptance gate (`npm run verify:packed-package`). See [docs/ROADMAP.md](docs/ROADMAP.md) and [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md) for status detail and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the runtime path model. The "Installed CLI" section below documents the shipped command surface; the source-checkout `npm run` workflow in Quickstart remains available for contributors.
|
|
16
20
|
|
|
@@ -284,6 +288,8 @@ See [docs/METRICS.md](docs/METRICS.md) for full metric definitions.
|
|
|
284
288
|
|
|
285
289
|
`npm run security:validate` (contributor/source-checkout) and `my-dev-kit-lab security validate` (installed CLI) both call the same standalone security-validation command owner. It checks local CLI/package boundaries and can inspect another local project with `--target <path>`. The generic audit command can reuse those results through `--types security`, but it does not replace the standalone validator or its reports.
|
|
286
290
|
|
|
291
|
+
**External npm package validation (v0.9.2 release documentation):** the external-target package-content correction keeps Lab self-validation's strict required inventory and checks external packages against declared entrypoints. Missing entrypoints, invalid or incomplete metadata, failed package evidence, and forbidden contents remain blockers. The nine inherited Lab-only Orchestrator false positives are eliminated. The prompt/report/gallery feature scope is preserved as planned v0.9.3 work. Earlier failed readiness attempts remain historical and are superseded by the accepted readiness result for the exact validated SHA; see [CURRENT_STATE.md](docs/CURRENT_STATE.md).
|
|
292
|
+
|
|
287
293
|
Android validation uses `--profile android`. Its default path is static and non-destructive: it starts zero Gradle processes, zero external tools, and zero network operations. Only confirmed `SecurityFinding` records can become audit issues; Android `CandidateEvidence` remains review-only evidence.
|
|
288
294
|
|
|
289
295
|
Optional local scanners are reported as `skipped` when unavailable, never as passed. This local availability rule does not make CodeQL coverage optional for release readiness: the exact candidate must pass the repository-controlled GitHub CodeQL workflow and have its Code Scanning alerts reviewed. The framework does not provide runtime isolation proof, device or APK/AAB analysis, signing verification, Play Console validation, automatic fixes, or manual pentesting. Manual pentest remains deferred until post-v1/version TBD.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, mkdir, readFile, readdir
|
|
1
|
+
import { lstat, mkdir, readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseAgentCommandTemplate } from "../agents/index.js";
|
|
4
4
|
import { parseAgentId } from "../agents/agentRegistry.js";
|
|
@@ -23,7 +23,7 @@ import { validateAgentSuccessRateConfig } from "../experiments/plugins/agentSucc
|
|
|
23
23
|
import { AGENT_SUCCESS_RATE_DEFAULT_AGENT_TIMEOUT_MS } from "../experiments/plugins/agentSuccessRate/realAgentExecution.js";
|
|
24
24
|
import { AGENT_SUCCESS_RATE_TREATMENT_IDS } from "../experiments/plugins/agentSuccessRate/metadata.js";
|
|
25
25
|
import { executeAgentSuccessCampaignV2, prepareAgentSuccessCampaign } from "../experiments/plugins/agentSuccessRate/campaignExecution.js";
|
|
26
|
-
import { readCampaignCheckpointV2, validateCampaignOutputPath } from "../experiments/plugins/agentSuccessRate/campaignCheckpointStore.js";
|
|
26
|
+
import { readCampaignCheckpointV2, resolveCampaignRoot, validateCampaignOutputPath } from "../experiments/plugins/agentSuccessRate/campaignCheckpointStore.js";
|
|
27
27
|
import { validateCampaignLimitAuthorization } from "../experiments/plugins/agentSuccessRate/campaignRecovery.js";
|
|
28
28
|
import { campaignIdentifier } from "../experiments/plugins/agentSuccessRate/campaignIdentity.js";
|
|
29
29
|
import { readCampaignProgress } from "../experiments/plugins/agentSuccessRate/campaignProgress.js";
|
|
@@ -263,7 +263,8 @@ async function runAgentCampaignCommand(args, context, outputRoot, inputs, runId,
|
|
|
263
263
|
const executingMetadata = JSON.parse(await readFile(path.join(context.packageRoot, "package.json"), "utf8"));
|
|
264
264
|
if (executingMetadata.name !== packageName || typeof executingMetadata.version !== "string" || !validPackageVersion.test(executingMetadata.version))
|
|
265
265
|
throw new Error();
|
|
266
|
-
|
|
266
|
+
await resolveCampaignRoot(sourceRoot);
|
|
267
|
+
if (!(await lstat(path.join(sourceRoot, "src"))).isDirectory())
|
|
267
268
|
throw new Error();
|
|
268
269
|
const metadata = JSON.parse(await readFile(path.join(sourceRoot, "package.json"), "utf8"));
|
|
269
270
|
if (metadata.name !== packageName || metadata.version !== executingMetadata.version)
|
|
@@ -46,7 +46,7 @@ async function plainAncestors(candidate) {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
/** Resolve filesystem aliases before inspecting ancestors (macOS exposes /var as /private/var). */
|
|
49
|
-
async function resolveCampaignRoot(candidate) {
|
|
49
|
+
export async function resolveCampaignRoot(candidate) {
|
|
50
50
|
const resolved = path.resolve(candidate);
|
|
51
51
|
let existing = resolved;
|
|
52
52
|
const remainder = [];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { lstat, readFile
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { isSameOrInside, resolvePhysicalPath } from "../../../evaluation/benchmarkSandbox/pathPolicy.js";
|
|
3
4
|
import { normalizeAgentDurationV091 as normalizedDuration, normalizeAgentTokenUsageV091 as normalizedTokens } from "../../../agents/normalizeAgentTelemetry.js";
|
|
4
5
|
import { validateAgentSuccessRateExecutionArtifact } from "./executionArtifact.js";
|
|
5
6
|
import { validateAgentSuccessRateAnalysisArtifact, validateAgentSuccessRateArtifactFamily } from "./analysisArtifact.js";
|
|
@@ -88,7 +89,8 @@ export async function readCampaignProgressScientificFamily(snapshot, boundary) {
|
|
|
88
89
|
const load = async (name) => {
|
|
89
90
|
const ref = cp.finalization.artifacts.find(a => a.path === name);
|
|
90
91
|
const file = path.join(boundary.outputRoot, name), stat = await lstat(file);
|
|
91
|
-
|
|
92
|
+
const [physicalRoot, physicalFile] = await Promise.all([resolvePhysicalPath(boundary.outputRoot), resolvePhysicalPath(file)]);
|
|
93
|
+
campaignAssert(stat.isFile() && !stat.isSymbolicLink() && stat.size === ref.bytes && isSameOrInside(physicalRoot, physicalFile) && path.dirname(physicalFile) === physicalRoot, "SCIENTIFIC_ARTIFACT_UNSAFE");
|
|
92
94
|
const bytes = await readFile(file);
|
|
93
95
|
campaignAssert(bytes.length === ref.bytes && campaignHash(bytes) === ref.sha256, "SCIENTIFIC_ARTIFACT_CHANGED");
|
|
94
96
|
return JSON.parse(bytes.toString("utf8"));
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { campaignAssert, campaignHash, canonicalCampaignJson } from "../../experiments/plugins/agentSuccessRate/campaignIdentity.js";
|
|
4
4
|
import { validateCampaignOutputPath } from "../../experiments/plugins/agentSuccessRate/campaignCheckpointStore.js";
|
|
5
5
|
import { readCampaignProgressScientificFamily } from "../../experiments/plugins/agentSuccessRate/campaignProgress.js";
|
|
6
|
+
import { resolvePhysicalPath } from "../../evaluation/benchmarkSandbox/pathPolicy.js";
|
|
6
7
|
import { agentSuccessRatePlugin, mapAgentSuccessRateToRun } from "../../experiments/plugins/agentSuccessRate/plugin.js";
|
|
7
8
|
import { writePluginExperimentReports } from "./writePluginExperimentReports.js";
|
|
8
9
|
import { buildPluginExperimentReport } from "./buildPluginExperimentReport.js";
|
|
@@ -13,7 +14,7 @@ import { renderAgentSuccessCampaignProgressText } from "./renderAgentSuccessCamp
|
|
|
13
14
|
import { renderAgentSuccessCampaignProgressHtml } from "./renderAgentSuccessCampaignProgressHtml.js";
|
|
14
15
|
async function plain(p, kind) {
|
|
15
16
|
const s = await lstat(p);
|
|
16
|
-
campaignAssert(!s.isSymbolicLink() && (kind === "file" ? s.isFile() : s.isDirectory()) && path.resolve(await realpath(p))
|
|
17
|
+
campaignAssert(!s.isSymbolicLink() && (kind === "file" ? s.isFile() : s.isDirectory()) && path.resolve(await realpath(p)) === path.resolve(p), "PROGRESS_UNSAFE_PATH");
|
|
17
18
|
}
|
|
18
19
|
async function writeExclusive(p, content) {
|
|
19
20
|
const h = await open(p, "wx");
|
|
@@ -68,7 +69,10 @@ async function owned(parent, name, owner) {
|
|
|
68
69
|
export async function writeAgentSuccessCampaignProgressReport(args) {
|
|
69
70
|
const model = buildAgentSuccessCampaignProgressReport(args.snapshot, args.boundary);
|
|
70
71
|
await validateCampaignOutputPath(args.boundary);
|
|
71
|
-
|
|
72
|
+
// The checkpoint store has already validated this output root. Use the same physical identity for
|
|
73
|
+
// publication: a legitimate OS alias (for example macOS /var -> /private/var) is not a redirect
|
|
74
|
+
// created inside the campaign tree, and comparing it to the lexical spelling rejects valid roots.
|
|
75
|
+
const root = await resolvePhysicalPath(args.boundary.outputRoot);
|
|
72
76
|
await plain(root, "directory");
|
|
73
77
|
const family = await owned(root, "campaign-progress-v1", "agent-success-rate-campaign-progress-v1");
|
|
74
78
|
const identity = await owned(family, model.campaignIdentity, `agent-success-rate-campaign-progress-v1:${model.campaignIdentity}`);
|
|
Binary file
|
|
@@ -13,6 +13,29 @@ export function parseNpmPackDryRun(stdout) {
|
|
|
13
13
|
if (!stdout.trim()) {
|
|
14
14
|
return { files: [], parseError: "npm pack --dry-run produced no output" };
|
|
15
15
|
}
|
|
16
|
+
if (/^[\[{]/.test(stdout.trim())) {
|
|
17
|
+
try {
|
|
18
|
+
const inventory = JSON.parse(stdout);
|
|
19
|
+
if (!Array.isArray(inventory) || inventory.length !== 1 ||
|
|
20
|
+
!inventory[0] || !Array.isArray(inventory[0].files) || !inventory[0].files.length) {
|
|
21
|
+
return { files: [], parseError: "npm pack JSON must contain exactly one nonempty package inventory" };
|
|
22
|
+
}
|
|
23
|
+
const files = [];
|
|
24
|
+
for (const file of inventory[0].files) {
|
|
25
|
+
if (!file || typeof file.path !== "string" || !safeInventoryPath(file.path)) {
|
|
26
|
+
return { files: [], parseError: "npm pack JSON contains a malformed or unsafe file path" };
|
|
27
|
+
}
|
|
28
|
+
// JSON paths are package-relative, unlike text tarball paths. Add the
|
|
29
|
+
// archive root exactly once so a real nested package/ directory cannot
|
|
30
|
+
// masquerade as a root entrypoint in the existing detectors.
|
|
31
|
+
files.push(`package/${file.path.replace(/\\/g, "/").replace(/^(\.\/)+/, "")}`);
|
|
32
|
+
}
|
|
33
|
+
return { files };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { files: [], parseError: "npm pack JSON is unparseable" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
16
39
|
const files = [];
|
|
17
40
|
let inContents = false;
|
|
18
41
|
let totalSize;
|
|
@@ -35,10 +58,8 @@ export function parseNpmPackDryRun(stdout) {
|
|
|
35
58
|
if (match?.[1]) {
|
|
36
59
|
files.push(match[1].trim());
|
|
37
60
|
}
|
|
38
|
-
else
|
|
39
|
-
|
|
40
|
-
files.push(line);
|
|
41
|
-
}
|
|
61
|
+
else
|
|
62
|
+
return { files: [], parseError: "Malformed npm tarball contents line" };
|
|
42
63
|
}
|
|
43
64
|
const totalMatch = line.match(/total files\s*:\s*(\d+)/i) ?? line.match(/unpacked size\s*:\s*([\d.]+\s*[kKmMgGbB]+)/i);
|
|
44
65
|
if (totalMatch?.[1]) {
|
|
@@ -52,5 +73,14 @@ export function parseNpmPackDryRun(stdout) {
|
|
|
52
73
|
parseError: "npm pack --dry-run: no files detected in tarball contents section",
|
|
53
74
|
};
|
|
54
75
|
}
|
|
76
|
+
if (files.some((file) => !safeInventoryPath(file))) {
|
|
77
|
+
return { files: [], parseError: "npm pack contains an unsafe file path" };
|
|
78
|
+
}
|
|
55
79
|
return { files, totalSize };
|
|
56
80
|
}
|
|
81
|
+
function safeInventoryPath(file) {
|
|
82
|
+
const normalized = file.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
83
|
+
return Boolean(normalized) && !normalized.startsWith("/") &&
|
|
84
|
+
!/[\x00-\x1f\x7f:]/.test(normalized) &&
|
|
85
|
+
normalized.split("/").every((part) => Boolean(part) && part !== "." && part !== "..");
|
|
86
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
export const REQUIRED_PACKAGE_CONTENTS = [
|
|
2
4
|
"CHANGELOG.md",
|
|
3
5
|
"LICENSE",
|
|
@@ -20,15 +22,117 @@ export const REQUIRED_PACKAGE_CONTENTS = [
|
|
|
20
22
|
"examples/token-savings-cases.json",
|
|
21
23
|
];
|
|
22
24
|
function normalizeTarballPath(file) {
|
|
23
|
-
const normalized = file.replace(/\\/g, "/");
|
|
25
|
+
const normalized = file.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
24
26
|
return normalized.startsWith("package/")
|
|
25
27
|
? normalized.slice("package/".length)
|
|
26
28
|
: normalized;
|
|
27
29
|
}
|
|
30
|
+
// Policy belongs beside the existing required-file detector. Identity is
|
|
31
|
+
// supplied by the existing target resolver, never inferred from package name.
|
|
32
|
+
export async function requiredContentsForTarget(target) {
|
|
33
|
+
const required = new Set(target.isSelf ? REQUIRED_PACKAGE_CONTENTS : ["package.json"]);
|
|
34
|
+
const findings = [];
|
|
35
|
+
function invalid(field, reason) {
|
|
36
|
+
findings.push({
|
|
37
|
+
id: `npm-pack-metadata-${findings.length}`,
|
|
38
|
+
title: `Package metadata cannot be validated: ${field}`,
|
|
39
|
+
severity: "blocker",
|
|
40
|
+
category: "package-content",
|
|
41
|
+
description: reason,
|
|
42
|
+
evidence: `package.json field: ${field}; ${reason}`,
|
|
43
|
+
affectedFiles: ["package.json"],
|
|
44
|
+
recommendation: "Provide readable package metadata with supported, safe package-relative entrypoint paths and rerun validation.",
|
|
45
|
+
releaseImpact: "Blocker: required package evidence is incomplete",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function entry(value, field, exportsPath = false) {
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
invalid(field, "Malformed entrypoint: expected a nonempty string.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const normalized = value.replace(/\\/g, "/");
|
|
54
|
+
const relative = normalized.replace(/^(\.\/)+/, "");
|
|
55
|
+
if (!relative || normalized !== normalized.trim() ||
|
|
56
|
+
/[\x00-\x1f\x7f:*?#%]/.test(normalized) || normalized.startsWith("/") ||
|
|
57
|
+
relative.split("/").some((part) => !part || part === ".." || part === "." || part === "node_modules") ||
|
|
58
|
+
(exportsPath && !normalized.startsWith("./"))) {
|
|
59
|
+
invalid(field, "Unsafe or unsupported entrypoint path (absolute, escaping, pattern, URL, or ambiguous path).");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
required.add(relative);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const metadata = JSON.parse(await fs.readFile(path.join(target.targetRoot, "package.json"), "utf8"));
|
|
66
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
67
|
+
invalid("package.json", "Malformed package metadata: expected an object.");
|
|
68
|
+
}
|
|
69
|
+
else if (!target.isSelf) {
|
|
70
|
+
const pkg = metadata;
|
|
71
|
+
if ("bin" in pkg) {
|
|
72
|
+
if (typeof pkg.bin === "string")
|
|
73
|
+
entry(pkg.bin, "bin");
|
|
74
|
+
else if (pkg.bin && typeof pkg.bin === "object" && !Array.isArray(pkg.bin)) {
|
|
75
|
+
for (const key of Object.keys(pkg.bin).sort()) {
|
|
76
|
+
if (!key || key === "." || key === ".." || /[\\/:\x00-\x1f\x7f]/.test(key)) {
|
|
77
|
+
invalid("bin", "Unsafe or unsupported executable name in bin map.");
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
entry(pkg.bin[key], `bin.${key}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else
|
|
84
|
+
invalid("bin", "Malformed bin: expected a string or object map.");
|
|
85
|
+
}
|
|
86
|
+
if ("main" in pkg) {
|
|
87
|
+
if (typeof pkg.main === "string" && pkg.main && !path.posix.extname(pkg.main.replace(/\\/g, "/"))) {
|
|
88
|
+
invalid("main", "Unsupported or ambiguous main: extensionless or directory resolution is not validated; declare an exact runtime file.");
|
|
89
|
+
}
|
|
90
|
+
else
|
|
91
|
+
entry(pkg.main, "main");
|
|
92
|
+
}
|
|
93
|
+
if ("exports" in pkg && pkg.exports !== null) {
|
|
94
|
+
if (typeof pkg.exports === "string")
|
|
95
|
+
entry(pkg.exports, "exports", true);
|
|
96
|
+
else if (pkg.exports && typeof pkg.exports === "object" && !Array.isArray(pkg.exports)) {
|
|
97
|
+
const exports = pkg.exports;
|
|
98
|
+
const keys = Object.keys(exports).sort();
|
|
99
|
+
if (!keys.length || keys.some((key) => key !== "." && (!key.startsWith("./") || /[\\:*?#%\x00-\x1f\x7f]/.test(key) ||
|
|
100
|
+
key.slice(2).split("/").some((part) => !part || part === "." || part === "..")))) {
|
|
101
|
+
invalid("exports", "Unsupported or ambiguous exports: conditional or pattern mappings require additional evidence.");
|
|
102
|
+
}
|
|
103
|
+
else
|
|
104
|
+
for (const key of keys) {
|
|
105
|
+
if (exports[key] === null)
|
|
106
|
+
continue; // Explicitly excluded subpath.
|
|
107
|
+
if (typeof exports[key] !== "string") {
|
|
108
|
+
invalid(`exports.${key}`, "Unsupported exports shape: conditional objects and fallback arrays are not validated.");
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
entry(exports[key], `exports.${key}`, true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else
|
|
115
|
+
invalid("exports", "Unsupported exports shape: expected an exact string or subpath map.");
|
|
116
|
+
}
|
|
117
|
+
// npm automatically ships existing root README and license files. Do not
|
|
118
|
+
// invent a documentation requirement for packages without those files.
|
|
119
|
+
for (const file of (await fs.readdir(target.targetRoot, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
120
|
+
if (file.isFile() && /^(readme|licen[cs]e)(\..*)?$/i.test(file.name))
|
|
121
|
+
required.add(file.name);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
invalid("package.json", "Package metadata is missing, unreadable, or unparseable.");
|
|
127
|
+
}
|
|
128
|
+
return { requiredFiles: [...required].sort(), findings };
|
|
129
|
+
}
|
|
28
130
|
export function detectMissingRequiredContents(options) {
|
|
29
131
|
const present = new Set(options.files.map(normalizeTarballPath));
|
|
30
132
|
const required = [...(options.requiredFiles ?? REQUIRED_PACKAGE_CONTENTS)]
|
|
31
|
-
.
|
|
133
|
+
// Requirements are package-relative declarations, not archive paths. A
|
|
134
|
+
// literal package/ directory in an entrypoint must not be stripped.
|
|
135
|
+
.map((file) => file.replace(/\\/g, "/").replace(/^(\.\/)+/, ""))
|
|
32
136
|
.sort((left, right) => left.localeCompare(right));
|
|
33
137
|
const missing = required.filter((file) => !present.has(file));
|
|
34
138
|
const findings = missing.map((file, index) => ({
|
|
@@ -3,7 +3,9 @@ import { runSecurityCommand, resolveNpmCommand } from "../commandRunner.js";
|
|
|
3
3
|
import { writeCheckResult } from "../artifacts.js";
|
|
4
4
|
import { parseNpmPackDryRun } from "./parseNpmPackDryRun.js";
|
|
5
5
|
import { detectForbiddenContents } from "./forbiddenPackageContents.js";
|
|
6
|
-
import { detectMissingRequiredContents } from "./requiredPackageContents.js";
|
|
6
|
+
import { detectMissingRequiredContents, requiredContentsForTarget } from "./requiredPackageContents.js";
|
|
7
|
+
import { resolveValidationTarget } from "../validate/resolveTarget.js";
|
|
8
|
+
import { discoverPackageRoot } from "../../runtime/packageRoot.js";
|
|
7
9
|
// Run npm pack --dry-run and inspect the resulting file list for forbidden contents.
|
|
8
10
|
// Does not publish anything.
|
|
9
11
|
export async function runPackageChecks(options) {
|
|
@@ -12,20 +14,23 @@ export async function runPackageChecks(options) {
|
|
|
12
14
|
const allFindings = [];
|
|
13
15
|
const checks = [];
|
|
14
16
|
const npm = resolveNpmCommand();
|
|
17
|
+
const target = options.target ?? resolveValidationTarget(path.resolve(cwd), discoverPackageRoot());
|
|
18
|
+
if (path.resolve(target.targetRoot) !== path.resolve(cwd)) {
|
|
19
|
+
throw new Error("Package check cwd does not match the resolved target root");
|
|
20
|
+
}
|
|
21
|
+
const policy = await requiredContentsForTarget(target);
|
|
15
22
|
// npm pack --dry-run to get the tarball file list
|
|
16
23
|
const startedAt = new Date().toISOString();
|
|
17
24
|
const cmd = await runSecurityCommand({
|
|
18
25
|
command: npm,
|
|
19
|
-
args: ["pack", "--dry-run"],
|
|
26
|
+
args: ["pack", "--dry-run", "--json", "--ignore-scripts", "--cache", path.resolve(rawOutputDir, "npm-cache")],
|
|
20
27
|
cwd,
|
|
21
28
|
timeoutMs: commandTimeoutMs,
|
|
22
29
|
});
|
|
23
30
|
const finishedAt = new Date().toISOString();
|
|
24
|
-
// npm
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const packOutput = [cmd.stdout, cmd.stderr].find((stream) => /tarball contents/i.test(stream)) ??
|
|
28
|
-
[cmd.stdout, cmd.stderr].filter(Boolean).join("\n");
|
|
31
|
+
// Supported npm versions emit JSON on stdout. Lifecycle scripts are disabled
|
|
32
|
+
// so inspection cannot build, install, or rewrite the target.
|
|
33
|
+
const packOutput = cmd.stdout;
|
|
29
34
|
const parsed = parseNpmPackDryRun(packOutput);
|
|
30
35
|
const { findings: contentFindings } = detectForbiddenContents({
|
|
31
36
|
files: parsed.files,
|
|
@@ -36,20 +41,28 @@ export async function runPackageChecks(options) {
|
|
|
36
41
|
const { findings: missingRequiredFindings } = detectMissingRequiredContents({
|
|
37
42
|
files: parsed.files,
|
|
38
43
|
checkId: "npm-pack",
|
|
44
|
+
requiredFiles: policy.requiredFiles,
|
|
39
45
|
});
|
|
40
|
-
const
|
|
46
|
+
const evidenceFindings = [];
|
|
47
|
+
if (cmd.exitCode !== 0 || cmd.timedOut || cmd.skipped || parsed.parseError || parsed.files.length === 0) {
|
|
48
|
+
evidenceFindings.push({
|
|
49
|
+
id: "npm-pack-evidence-invalid",
|
|
50
|
+
title: "npm package inventory could not be validated",
|
|
51
|
+
severity: "blocker",
|
|
52
|
+
category: "package-content",
|
|
53
|
+
description: "A successful npm pack operation and a nonempty, parseable inventory are required.",
|
|
54
|
+
evidence: `exitCode=${String(cmd.exitCode)}; timedOut=${cmd.timedOut}; skipped=${cmd.skipped}; ${parsed.parseError ?? ""}; ${cmd.stderr.slice(0, 500)}`,
|
|
55
|
+
recommendation: "Resolve the package command or inventory failure and rerun validation.",
|
|
56
|
+
releaseImpact: "Blocker: required package evidence is unavailable",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const packageFindings = [...policy.findings, ...evidenceFindings, ...contentFindings, ...missingRequiredFindings];
|
|
41
60
|
allFindings.push(...packageFindings);
|
|
42
61
|
const packCheck = {
|
|
43
62
|
id: "npm-pack-dry-run",
|
|
44
63
|
name: "npm pack --dry-run (tarball file list)",
|
|
45
64
|
category: "package-content",
|
|
46
|
-
status:
|
|
47
|
-
? "failed"
|
|
48
|
-
: parsed.parseError && parsed.files.length === 0
|
|
49
|
-
? "warning"
|
|
50
|
-
: packageFindings.length > 0
|
|
51
|
-
? "failed"
|
|
52
|
-
: "passed",
|
|
65
|
+
status: packageFindings.length > 0 ? "failed" : "passed",
|
|
53
66
|
severity: packageFindings.length > 0
|
|
54
67
|
? packageFindings.some((f) => f.severity === "blocker")
|
|
55
68
|
? "blocker"
|
|
@@ -60,7 +73,9 @@ export async function runPackageChecks(options) {
|
|
|
60
73
|
durationMs: cmd.durationMs,
|
|
61
74
|
findings: packageFindings,
|
|
62
75
|
skippedReason: undefined,
|
|
63
|
-
command: "npm pack --dry-run",
|
|
76
|
+
command: "npm pack --dry-run --json --ignore-scripts",
|
|
77
|
+
commandCwd: cwd,
|
|
78
|
+
exitCode: cmd.exitCode,
|
|
64
79
|
};
|
|
65
80
|
await writeCheckResult({
|
|
66
81
|
result: packCheck,
|
|
@@ -5,16 +5,35 @@ import { resolveCommand } from "../../core/resolveCommand.js";
|
|
|
5
5
|
import { skippedCheck } from "../cliAdversarial/runAdversarialCheck.js";
|
|
6
6
|
export function parseSemgrepJson(raw) {
|
|
7
7
|
if (!raw.trim()) {
|
|
8
|
-
return { findings: [], rawOutput: raw };
|
|
8
|
+
return { findings: [], parseError: "Semgrep produced no JSON scan evidence", rawOutput: raw };
|
|
9
9
|
}
|
|
10
10
|
try {
|
|
11
11
|
const parsed = JSON.parse(raw);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.results)) {
|
|
13
|
+
return { findings: [], parseError: "Semgrep JSON requires a results array", rawOutput: raw };
|
|
14
|
+
}
|
|
15
|
+
const findings = [];
|
|
16
|
+
let parseError = !Array.isArray(parsed.errors) || !isRecord(parsed.paths) ||
|
|
17
|
+
!Array.isArray(parsed.paths.scanned) || parsed.paths.scanned.some((file) => typeof file !== "string")
|
|
18
|
+
? "Semgrep JSON requires errors and paths.scanned arrays" : undefined;
|
|
19
|
+
for (const [index, r] of parsed.results.entries()) {
|
|
20
|
+
if (!isRecord(r) || typeof r.check_id !== "string" || !r.check_id ||
|
|
21
|
+
typeof r.path !== "string" || !r.path || !isRecord(r.start) ||
|
|
22
|
+
!Number.isInteger(r.start.line) || Number(r.start.line) < 1 || !isRecord(r.extra) ||
|
|
23
|
+
(r.extra.message !== undefined && typeof r.extra.message !== "string") ||
|
|
24
|
+
(r.extra.metadata !== undefined && !isRecord(r.extra.metadata))) {
|
|
25
|
+
parseError ??= `Malformed Semgrep result at index ${index}`;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
14
28
|
const item = r;
|
|
15
29
|
const checkId = typeof item["check_id"] === "string" ? item["check_id"] : "unknown";
|
|
16
30
|
const extra = item["extra"] ?? {};
|
|
17
31
|
const metadata = extra["metadata"] ?? {};
|
|
32
|
+
if ((extra.severity !== undefined && (typeof extra.severity !== "string" || !extra.severity.trim())) ||
|
|
33
|
+
(extra.severity === undefined && (typeof metadata.severity !== "string" || !metadata.severity.trim()))) {
|
|
34
|
+
parseError ??= `Missing or malformed Semgrep severity at index ${index}`;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
18
37
|
const severity = typeof extra["severity"] === "string"
|
|
19
38
|
? extra["severity"]
|
|
20
39
|
: typeof metadata["severity"] === "string"
|
|
@@ -24,9 +43,14 @@ export function parseSemgrepJson(raw) {
|
|
|
24
43
|
const filePath = typeof item["path"] === "string" ? item["path"] : "";
|
|
25
44
|
const startObj = item["start"] ?? {};
|
|
26
45
|
const line = typeof startObj["line"] === "number" ? startObj["line"] : 0;
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
46
|
+
findings.push({ ruleId: checkId, severity, message, path: filePath, line });
|
|
47
|
+
}
|
|
48
|
+
// Semgrep's CLI contract reports partial/fatal analysis in errors even
|
|
49
|
+
// when the process exits zero. Retain matches, but never call this clean.
|
|
50
|
+
const analysisError = Array.isArray(parsed.errors) && parsed.errors.length > 0
|
|
51
|
+
? `Semgrep reported ${parsed.errors.length} analysis error(s): ${JSON.stringify(parsed.errors).slice(0, 500)}`
|
|
52
|
+
: undefined;
|
|
53
|
+
return { findings, parseError, analysisError, rawOutput: raw };
|
|
30
54
|
}
|
|
31
55
|
catch (err) {
|
|
32
56
|
return {
|
|
@@ -36,6 +60,9 @@ export function parseSemgrepJson(raw) {
|
|
|
36
60
|
};
|
|
37
61
|
}
|
|
38
62
|
}
|
|
63
|
+
function isRecord(value) {
|
|
64
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
}
|
|
39
66
|
function semgrepSeverityToSecurity(semgrepSeverity) {
|
|
40
67
|
switch (semgrepSeverity.toUpperCase()) {
|
|
41
68
|
case "ERROR":
|
|
@@ -88,67 +115,25 @@ export async function runSemgrepCheck(options) {
|
|
|
88
115
|
"--quiet",
|
|
89
116
|
scanTarget,
|
|
90
117
|
];
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
args: scanArgs,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
118
|
+
let cmd;
|
|
119
|
+
try {
|
|
120
|
+
cmd = await runSecurityCommand({ command, args: scanArgs, cwd: targetRoot, timeoutMs });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
cmd = { command, args: scanArgs, cwd: targetRoot, exitCode: null, durationMs: 0,
|
|
124
|
+
stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut: false, skipped: false };
|
|
125
|
+
}
|
|
97
126
|
const finishedAt = new Date().toISOString();
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
status: "failed",
|
|
107
|
-
severity: "major",
|
|
108
|
-
startedAt,
|
|
109
|
-
finishedAt,
|
|
110
|
-
durationMs: cmd.durationMs,
|
|
111
|
-
findings: [
|
|
112
|
-
{
|
|
113
|
-
id: "semgrep-execution-error",
|
|
114
|
-
title: "Semgrep execution failed",
|
|
115
|
-
severity: "major",
|
|
116
|
-
category: "static-scan",
|
|
117
|
-
description: isTimedOut
|
|
118
|
-
? "Semgrep timed out."
|
|
119
|
-
: `Semgrep exited with code ${String(cmd.exitCode)}. stderr: ${cmd.stderr.slice(0, 500)}`,
|
|
120
|
-
recommendation: "Check semgrep installation and .semgrep.yml config.",
|
|
121
|
-
releaseImpact: "Review before release",
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
command: [command, ...scanArgs].join(" "),
|
|
125
|
-
};
|
|
127
|
+
// Distinguish positively established absence from a failed attempted scan.
|
|
128
|
+
// Network, permission, generic npm, or native spawn errors are not absence.
|
|
129
|
+
const unavailable = !cmd.timedOut && cmd.exitCode !== 0 && !cmd.stdout.trim() && ((cmd.skipped && cmd.exitCode === null && Boolean(cmd.skippedReason?.match(/\b(semgrep|scanner|cli)\b.*(unavailable|not installed|not found|not on PATH)/i))) ||
|
|
130
|
+
(useNpx && !cmd.skipped && cmd.exitCode === 1 && /could not determine executable to run/i.test(cmd.stderr)));
|
|
131
|
+
if (unavailable) {
|
|
132
|
+
return { ...skippedCheck({ id: "semgrep-scan", name: "Semgrep static analysis", category: "static-scan",
|
|
133
|
+
reason: cmd.skippedReason ?? "Semgrep CLI is unavailable; the npx fallback package provides no executable." }),
|
|
134
|
+
command: [command, ...scanArgs].join(" "), commandCwd: targetRoot, exitCode: cmd.exitCode };
|
|
126
135
|
}
|
|
127
136
|
const parsed = parseSemgrepJson(cmd.stdout);
|
|
128
|
-
if (parsed.parseError) {
|
|
129
|
-
return {
|
|
130
|
-
id: "semgrep-scan",
|
|
131
|
-
name: "Semgrep static analysis",
|
|
132
|
-
category: "static-scan",
|
|
133
|
-
status: "warning",
|
|
134
|
-
severity: "minor",
|
|
135
|
-
startedAt,
|
|
136
|
-
finishedAt,
|
|
137
|
-
durationMs: cmd.durationMs,
|
|
138
|
-
findings: [
|
|
139
|
-
{
|
|
140
|
-
id: "semgrep-parse-error",
|
|
141
|
-
title: "Could not parse Semgrep output",
|
|
142
|
-
severity: "minor",
|
|
143
|
-
category: "static-scan",
|
|
144
|
-
description: `JSON parse error: ${parsed.parseError}`,
|
|
145
|
-
recommendation: "Verify semgrep version produces valid JSON output.",
|
|
146
|
-
releaseImpact: "Review before release",
|
|
147
|
-
},
|
|
148
|
-
],
|
|
149
|
-
command: [command, ...scanArgs].join(" "),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
137
|
const securityFindings = parsed.findings.map((f) => ({
|
|
153
138
|
id: `semgrep-${f.ruleId.replace(/[^a-z0-9-]/gi, "-")}`,
|
|
154
139
|
title: f.ruleId,
|
|
@@ -160,6 +145,23 @@ export async function runSemgrepCheck(options) {
|
|
|
160
145
|
? "Should fix before release"
|
|
161
146
|
: "Review before release",
|
|
162
147
|
}));
|
|
148
|
+
const executionFailed = cmd.timedOut || cmd.skipped || (cmd.exitCode !== 0 && cmd.exitCode !== 1) ||
|
|
149
|
+
(cmd.exitCode === 1 && parsed.findings.length === 0);
|
|
150
|
+
const evidenceError = executionFailed
|
|
151
|
+
? `Semgrep execution failed: exitCode=${String(cmd.exitCode)}, timedOut=${cmd.timedOut}, skipped=${cmd.skipped}. ${cmd.stderr.slice(0, 500)}`
|
|
152
|
+
: parsed.parseError ?? parsed.analysisError;
|
|
153
|
+
if (evidenceError) {
|
|
154
|
+
securityFindings.push({
|
|
155
|
+
id: executionFailed ? "semgrep-execution-error" : parsed.parseError ? "semgrep-parse-error" : "semgrep-analysis-error",
|
|
156
|
+
title: "Semgrep scan evidence is incomplete or unsuccessful",
|
|
157
|
+
severity: "major",
|
|
158
|
+
category: "static-scan",
|
|
159
|
+
description: evidenceError,
|
|
160
|
+
evidence: `exitCode=${String(cmd.exitCode)}; ${parsed.parseError ?? parsed.analysisError ?? ""}`,
|
|
161
|
+
recommendation: "Resolve the scanner execution or analysis error and rerun with valid JSON scan evidence.",
|
|
162
|
+
releaseImpact: "Review before release",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
163
165
|
const hasBlockerOrMajor = securityFindings.some((f) => f.severity === "blocker" || f.severity === "major");
|
|
164
166
|
return {
|
|
165
167
|
id: "semgrep-scan",
|
|
@@ -176,5 +178,7 @@ export async function runSemgrepCheck(options) {
|
|
|
176
178
|
durationMs: cmd.durationMs,
|
|
177
179
|
findings: securityFindings,
|
|
178
180
|
command: [command, ...scanArgs].join(" "),
|
|
181
|
+
commandCwd: targetRoot,
|
|
182
|
+
exitCode: cmd.exitCode,
|
|
179
183
|
};
|
|
180
184
|
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Core types for the my-dev-kit-lab security-validation framework.
|
|
2
|
-
// These types describe the vocabulary for checks, findings, results, and verdicts
|
|
3
|
-
// used by dependency checks (Prompt 3), CLI adversarial tests (Prompts 4–5),
|
|
4
|
-
// static scans (Prompt 6), fuzz smoke tests (Prompt 7), and the release report (Prompt 8).
|
|
5
1
|
export const SECURITY_SEVERITIES = [
|
|
6
2
|
"blocker",
|
|
7
3
|
"major",
|
|
@@ -80,7 +80,7 @@ export async function runSecurityValidation(options) {
|
|
|
80
80
|
// missing package.json gracefully.
|
|
81
81
|
if (selectedChecks.has("package")) {
|
|
82
82
|
try {
|
|
83
|
-
const pkgOutput = await runPackageChecks({ cwd: target.targetRoot, config });
|
|
83
|
+
const pkgOutput = await runPackageChecks({ cwd: target.targetRoot, config, target });
|
|
84
84
|
collectMany(pkgOutput.checks);
|
|
85
85
|
}
|
|
86
86
|
catch (err) {
|
package/docs/METRICS.md
CHANGED
|
@@ -940,7 +940,7 @@ Cumulative versus final: durations and tokens accumulate over all provider attem
|
|
|
940
940
|
- Provider telemetry can be unavailable, and in deterministic-fixture mode both treatments receive the same reference patch, so those results measure the pipeline only.
|
|
941
941
|
|
|
942
942
|
|
|
943
|
-
## Campaign progress evidence (v0.9.1
|
|
943
|
+
## Campaign progress evidence (v0.9.1 released)
|
|
944
944
|
|
|
945
945
|
Token evidence levels are `provider-reported`, `cli-reported`, `parsed-from-output`, `unavailable`, and `estimated`. The original telemetry source is retained. Numeric availability is recorded per token field, so partial measurements remain partial and normalization never fills missing fields or invents totals; unattributed OpenTelemetry evidence is unavailable. Duration provenance is `lab-wall-clock` for valid observed elapsed time and `unavailable` for missing or invalid values; campaign comparison time is Lab-measured wall-clock duration, not provider-native time.
|
|
946
946
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
const sourceRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
-
const targetRoot = process.argv[2];
|
|
7
|
-
if (!targetRoot) {
|
|
8
|
-
console.error("prepare requires a target root argument");
|
|
9
|
-
process.exit(2);
|
|
10
|
-
}
|
|
11
|
-
fs.mkdirSync(path.join(targetRoot, "app"), { recursive: true });
|
|
12
|
-
fs.mkdirSync(path.join(targetRoot, "out"), { recursive: true });
|
|
13
|
-
for (const file of ["index.html", "server.mjs"]) fs.copyFileSync(path.join(sourceRoot, file), path.join(targetRoot, "app", file));
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const sourceRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const targetRoot = process.argv[2];
|
|
7
|
+
if (!targetRoot) {
|
|
8
|
+
console.error("prepare requires a target root argument");
|
|
9
|
+
process.exit(2);
|
|
10
|
+
}
|
|
11
|
+
fs.mkdirSync(path.join(targetRoot, "app"), { recursive: true });
|
|
12
|
+
fs.mkdirSync(path.join(targetRoot, "out"), { recursive: true });
|
|
13
|
+
for (const file of ["index.html", "server.mjs"]) fs.copyFileSync(path.join(sourceRoot, file), path.join(targetRoot, "app", file));
|
|
14
14
|
fs.writeFileSync(path.join(targetRoot, "out", "report.json"), JSON.stringify({ prepared: true, counts: { widgets: 3 } }, null, 2), "utf8");
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
6
|
-
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
-
const port = Number(process.env.TUTORIAL_FIXTURE_PORT || 0);
|
|
8
|
-
const server = http.createServer((req, res) => {
|
|
9
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
10
|
-
if (url.pathname === "/api/status") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, widgets: 3 })); return; }
|
|
11
|
-
const filePath = url.pathname === "/" ? path.join(root, "index.html") : path.join(root, url.pathname);
|
|
12
|
-
fs.readFile(filePath, (error, contents) => { if (error) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": filePath.endsWith(".html") ? "text/html" : "application/octet-stream" }); res.end(contents); });
|
|
13
|
-
});
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const port = Number(process.env.TUTORIAL_FIXTURE_PORT || 0);
|
|
8
|
+
const server = http.createServer((req, res) => {
|
|
9
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
10
|
+
if (url.pathname === "/api/status") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, widgets: 3 })); return; }
|
|
11
|
+
const filePath = url.pathname === "/" ? path.join(root, "index.html") : path.join(root, url.pathname);
|
|
12
|
+
fs.readFile(filePath, (error, contents) => { if (error) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": filePath.endsWith(".html") ? "text/html" : "application/octet-stream" }); res.end(contents); });
|
|
13
|
+
});
|
|
14
14
|
server.listen(port, "127.0.0.1", () => console.log("tutorial fixture listening on " + server.address().port));
|