Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .changeset/ai-training-license.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@
"deslop-cli": patch
---

Update the license to MIT with additional restrictions: the software may not be used as training, fine-tuning, or evaluation data for machine-learning models or AI systems, nor sold or resold as a commercial product or service (e.g. a paid API, SaaS, or hosted/managed service) whose value derives substantially from the software, without prior written permission (contact founders@million.dev). Each version's additional restrictions expire on the second anniversary of its release, after which that version is available under the standard MIT License (an FSL-style grant of future license). Each published package now ships its own up-to-date `LICENSE` file so the terms travel with the tarball.
Update the license to MIT with additional restrictions: the software may not be used as training, fine-tuning, or evaluation data for machine-learning models or AI systems, nor sold or resold as a commercial product or service (e.g. a paid API, SaaS, or hosted/managed service) whose value derives substantially from the software, without prior written permission (contact founders@million.dev). Each published package now ships its own up-to-date `LICENSE` file so the terms travel with the tarball.

The `react-doctor` CLI also now prints a one-time notice (once per run) when it detects it is running inside an AI/ML training pipeline or agent sandbox, pointing to the license terms.
The `react-doctor` CLI and programmatic `diagnose()` API also now print a one-time notice (once per process) when they detect a high-confidence AI/ML pipeline environment, pointing to the license terms.
34 changes: 34 additions & 0 deletions packages/api/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Modified MIT License

Copyright (c) 2026 Million Software, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Our only modification is that the following uses require prior written
permission from the copyright holder. To request permission, contact
founders@million.dev.

1. Using the Software, its source code, or any derivative works thereof, in
whole or in part, as training, fine-tuning, or evaluation data, or as input
to any automated pipeline for training or improving any machine learning
model or AI system.

2. Selling the Software, or offering it to third parties as a paid, hosted, or
managed product or service (including any commercial API or SaaS offering)
whose value derives entirely or substantially from the Software.
114 changes: 58 additions & 56 deletions packages/api/src/diagnose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
DEFAULT_PROJECT_SCAN_CONCURRENCY,
DEFAULT_SHOW_WARNINGS,
DeadCode,
detectAiTrainingEnvironment,
Files,
Git,
layerOtlp,
Expand All @@ -22,6 +21,7 @@ import {
runInspect,
Score,
SupplyChain,
warnAiTrainingLicenseOnce,
type InspectOutput,
type ResolvedScanTarget,
} from "@react-doctor/core";
Expand All @@ -36,58 +36,58 @@ import type {
ScoreResult,
} from "@react-doctor/core";

// The CLI carries the richer warning (logger + telemetry); the library only
// has stdout, so it warns once per process via console.warn when a scan runs
// inside an AI/ML training environment (license requires written permission).
let didWarnAiTraining = false;
const warnIfAiTrainingEnvironment = (): void => {
if (didWarnAiTraining || detectAiTrainingEnvironment() === null) return;
didWarnAiTraining = true;
console.warn(
"[react-doctor] Use in an AI or ML pipeline requires written permission under the react-doctor license. Contact founders@million.dev to request access.",
);
warnAiTrainingLicenseOnce({
write: (message) => {
process.stderr.write(`[react-doctor] ${message}\n`);
},
});
};

// The production layer stack for the programmatic API. The only axis that
// varies across calls is `Config`: with no override we load from disk
// (`Config.layerNode`); with a per-project override the caller's already
// resolved config drives `Config.layerOf(...)`. The supply-chain gate reads
// `supplyChain.enabled` from that same effective config (default on), so the
// one config input decides both. Every other service is identical, so the
// stack is built once here rather than duplicated per variant.
const buildDiagnoseLayer = (
config: ReactDoctorConfig | null,
configOverrideTarget?: Pick<ResolvedScanTarget, "resolvedDirectory" | "configSourceDirectory">,
) => {
const configLayer =
configOverrideTarget === undefined
? Config.layerNode
: Config.layerOf({
config,
resolvedDirectory: configOverrideTarget.resolvedDirectory,
configSourceDirectory: configOverrideTarget.configSourceDirectory,
});
interface BuildDiagnoseLayerInput {
readonly config: ReactDoctorConfig | null;
readonly configSourceDirectory: string | null;
readonly resolvedDirectory: string;
readonly shouldRunDeadCode: boolean;
readonly shouldRunLint: boolean;
}

const buildDiagnoseLayer = (input: BuildDiagnoseLayerInput) => {
const configLayer = Config.layerOf({
config: input.config,
resolvedDirectory: input.resolvedDirectory,
configSourceDirectory: input.configSourceDirectory,
});
return Layer.mergeAll(
Project.layerNode,
configLayer,
DeadCode.layerNode,
input.shouldRunDeadCode ? DeadCode.layerNode : DeadCode.layerOf([]),
Files.layerNode,
Git.layerNode,
Linter.layerOxlint,
input.shouldRunLint ? Linter.layerOxlint : Linter.layerOf([]),
LintPartialFailures.layerLive,
Progress.layerNoop,
Reporter.layerNoop,
Score.layerHttp,
config?.supplyChain?.enabled !== false ? SupplyChain.layerNode : SupplyChain.layerOf([]),
input.config?.supplyChain?.enabled !== false ? SupplyChain.layerNode : SupplyChain.layerOf([]),
);
};

const shouldRunDeadCode = (
options: DiagnoseOptions,
effectiveConfig: ReactDoctorConfig | null,
): boolean => options.deadCode ?? effectiveConfig?.deadCode ?? true;

const shouldRunLint = (
options: DiagnoseOptions,
effectiveConfig: ReactDoctorConfig | null,
): boolean => options.lint ?? effectiveConfig?.lint ?? true;

const buildInspectProgram = (
scanTarget: ResolvedScanTarget,
options: DiagnoseOptions,
configOverride?: ReactDoctorConfig,
effectiveConfig: ReactDoctorConfig | null,
) => {
const effectiveConfig = configOverride ?? scanTarget.userConfig;
const includePaths = options.includePaths ?? [];

return runInspect({
Expand All @@ -99,7 +99,7 @@ const buildInspectProgram = (
warnings: options.warnings ?? effectiveConfig?.warnings ?? DEFAULT_SHOW_WARNINGS,
adoptExistingLintConfig: effectiveConfig?.adoptExistingLintConfig ?? true,
ignoredTags: new Set(effectiveConfig?.ignore?.tags ?? []),
runDeadCode: options.deadCode ?? effectiveConfig?.deadCode ?? true,
runDeadCode: shouldRunDeadCode(options, effectiveConfig),
isCi: false,
resolveLocalGithubViewerPermission: true,
});
Expand Down Expand Up @@ -133,15 +133,24 @@ const diagnoseDirectory = async (
directory: string,
options: DiagnoseOptions,
): Promise<DiagnoseResult> => {
warnIfAiTrainingEnvironment();
const startTime = globalThis.performance.now();
const scanTarget = await resolveScanTarget(directory);
const program = buildInspectProgram(scanTarget, options);
warnIfAiTrainingEnvironment();
const effectiveConfig = scanTarget.userConfig;
const program = buildInspectProgram(scanTarget, options, effectiveConfig);

const output: InspectOutput = await Effect.runPromise(
restoreLegacyThrow(
program.pipe(
Effect.provide(buildDiagnoseLayer(scanTarget.userConfig)),
Effect.provide(
buildDiagnoseLayer({
config: effectiveConfig,
configSourceDirectory: scanTarget.configSourceDirectory,
resolvedDirectory: scanTarget.resolvedDirectory,
shouldRunDeadCode: shouldRunDeadCode(options, effectiveConfig),
shouldRunLint: shouldRunLint(options, effectiveConfig),
}),
),
Effect.provide(layerOtlp),
),
),
Expand Down Expand Up @@ -173,35 +182,28 @@ const diagnoseProject = async (
try {
const scanTarget = await resolveScanTarget(projectDefinition.directory);
const { directory: _, config: projectConfig, ...perProjectOptions } = projectDefinition;
const projectOptions = { ...baseOptions, ...perProjectOptions };

// Config layers, least to most specific: on-disk `doctor.config.*` ←
// batch `config` ← per-project `config`. With no overrides the merge is
// the identity and the orchestrator loads from disk (`Config.layerNode`).
const didOverrideConfig = batchConfig !== undefined || projectConfig !== undefined;
const effectiveConfig = mergeReactDoctorConfigs(
mergeReactDoctorConfigs(scanTarget.userConfig, batchConfig),
projectConfig,
);

const program = buildInspectProgram(
scanTarget,
{ ...baseOptions, ...perProjectOptions },
effectiveConfig ?? undefined,
);
const program = buildInspectProgram(scanTarget, projectOptions, effectiveConfig);
// `plugins` is override-wins in the merge: when a caller layer supplies
// it, relative entries resolve against the scan root (caller configs
// have no file location); otherwise the on-disk config's directory.
const didOverridePlugins =
batchConfig?.plugins !== undefined || projectConfig?.plugins !== undefined;
const layer = buildDiagnoseLayer(
effectiveConfig,
didOverrideConfig
? {
resolvedDirectory: scanTarget.resolvedDirectory,
configSourceDirectory: didOverridePlugins ? null : scanTarget.configSourceDirectory,
}
: undefined,
);
const layer = buildDiagnoseLayer({
config: effectiveConfig,
configSourceDirectory:
didOverrideConfig && didOverridePlugins ? null : scanTarget.configSourceDirectory,
resolvedDirectory: scanTarget.resolvedDirectory,
shouldRunDeadCode: shouldRunDeadCode(projectOptions, effectiveConfig),
shouldRunLint: shouldRunLint(projectOptions, effectiveConfig),
});

const output: InspectOutput = await Effect.runPromise(
restoreLegacyThrow(program.pipe(Effect.provide(layer), Effect.provide(layerOtlp))),
Expand All @@ -224,9 +226,9 @@ const diagnoseProject = async (
const diagnoseProjectBatch = async (
input: DiagnoseProjectsInput,
): Promise<DiagnoseProjectsResult> => {
warnIfAiTrainingEnvironment();
const startTime = globalThis.performance.now();
const { projects, concurrency, config: batchConfig, ...baseOptions } = input;
if (projects.length > 0) warnIfAiTrainingEnvironment();

// `diagnoseProject` never rejects (failures come back as `ok: false`),
// so the pool always drains every project.
Expand Down
34 changes: 34 additions & 0 deletions packages/core/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
Modified MIT License

Copyright (c) 2026 Million Software, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

Our only modification is that the following uses require prior written
permission from the copyright holder. To request permission, contact
founders@million.dev.

1. Using the Software, its source code, or any derivative works thereof, in
whole or in part, as training, fine-tuning, or evaluation data, or as input
to any automated pipeline for training or improving any machine learning
model or AI system.

2. Selling the Software, or offering it to third parties as a paid, hosted, or
managed product or service (including any commercial API or SaaS offering)
whose value derives entirely or substantially from the Software.
76 changes: 31 additions & 45 deletions packages/core/src/utils/detect-ai-training-environment.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,62 @@
// Presence-based env vars that signal an active ML training pipeline, data
// collection job, RL simulation, or AI-agent sandbox. Auth/config vars (e.g.
// OPENAI_API_KEY, HF_TOKEN) are excluded — a stored credential doesn't mean
// the process is inside a training run. Active-run or hardware-binding vars do.
// Shared by the CLI warning and the programmatic API so the list lives once.
const AI_TRAINING_ENV_VARS: ReadonlyArray<readonly [string, string]> = [
// HuggingFace — dataset/cache paths only (active data pipeline, not just auth)
["HF_DATASETS_CACHE", "huggingface"],
["HF_HOME", "huggingface"],
["HUGGINGFACE_HUB_CACHE", "huggingface"],
["SPACE_ID", "huggingface-spaces"],
// GPU/accelerator binding — implies an active training or inference job
["CUDA_VISIBLE_DEVICES", "cuda"],
["NVIDIA_VISIBLE_DEVICES", "nvidia"],
["ROCR_VISIBLE_DEVICES", "rocm"],
["TPU_NAME", "google-tpu"],
// Experiment tracking — active run/sweep IDs, not just stored API keys
const AI_TRAINING_BY_ENVIRONMENT_VARIABLE: ReadonlyArray<readonly [string, string]> = [
["WANDB_RUN_ID", "wandb"],
["WANDB_SWEEP_ID", "wandb"],
["MLFLOW_RUN_ID", "mlflow"],
["MLFLOW_TRACKING_URI", "mlflow"],
["COMET_EXPERIMENT_KEY", "comet"],
["NEPTUNE_RUN_ID", "neptune"],
["CLEARML_TASK_ID", "clearml"],
["DVC_STAGE", "dvc"],
// Distributed ML/RL workers
["RAY_WORKER_PROCESS", "ray"],
["RAY_ADDRESS", "ray"],
// RL simulation environments
["MUJOCO_GL", "mujoco"],
["MUJOCO_PATH", "mujoco"],
["GYM_DISABLE_ENV_CHECKER", "gymnasium"],
// Managed cloud ML platforms — job/model dir or run IDs imply a training job
["SM_TRAINING_ENV", "sagemaker"],
["TRAINING_JOB_ARN", "sagemaker"],
["SAGEMAKER_BASE_DIR", "sagemaker"],
["AZUREML_RUN_ID", "azure-ml"],
["AZURE_ML_MODEL_DIR", "azure-ml"],
["CLOUD_ML_PROJECT_ID", "vertex-ai"],
["VERTEX_AI_LOG_LEVEL", "vertex-ai"],
["DET_MASTER", "determined-ai"],
["LIGHTNING_USER_ID", "lightning-ai"],
// ML pipeline orchestrators
["FLYTE_INTERNAL_EXECUTION_ID", "flyte"],
["ARGO_WORKFLOW_NAME", "argo-workflows"],
["KFP_POD_NAME", "kubeflow-pipelines"],
// Notebook environments — active session markers
["KAGGLE_KERNEL_RUN_TYPE", "kaggle"],
["COLAB_BACKEND_VERSION", "google-colab"],
["DATABRICKS_RUNTIME_VERSION", "databricks"],
// GPU cloud / sandboxed code-execution platforms used by AI agents
["DAYTONA_WS_ID", "daytona"],
["DAYTONA_WS_NAME", "daytona"],
["E2B_SANDBOX_ID", "e2b"],
["MODAL_FUNCTION_ID", "modal"],
["MODAL_TASK_ID", "modal"],
["RUNPOD_POD_ID", "runpod"],
["REPLICATE_USERNAME", "replicate"],
["VAST_CONTAINERLABEL", "vast-ai"],
// Container registries for ML artifacts
["HARBOR_URL", "harbor"],
["HARBOR_HOSTNAME", "harbor"],
// Coding-agent evaluation harnesses (SWE-bench and derivatives)
["SWE_BENCH_TASK", "swe-bench"],
["SWEBENCH_TASK", "swe-bench"],
["SWE_AGENT_MODEL", "swe-agent"],
];

export const detectAiTrainingEnvironment = (): string | null => {
for (const [envVar, label] of AI_TRAINING_ENV_VARS) {
// Truthy, not just defined: an empty value (e.g. `CUDA_VISIBLE_DEVICES=""`,
// which disables the GPU) shouldn't classify the run as a training pipeline.
if (process.env[envVar]) return label;
export const AI_TRAINING_LICENSE_NOTICE =
"Detected an AI or ML pipeline environment. This use may require written permission under the react-doctor license. Contact founders@million.dev to request access.";

export const AI_TRAINING_ENVIRONMENT_VARIABLES = AI_TRAINING_BY_ENVIRONMENT_VARIABLE.map(
([environmentVariable]) => environmentVariable,
);

interface WarnAiTrainingLicenseOnceOptions {
readonly environment?: NodeJS.ProcessEnv;
readonly write: (message: string) => void;
}

let didWarnAiTrainingLicense = false;

export const detectAiTrainingEnvironment = (
environment: NodeJS.ProcessEnv = process.env,
): string | null => {
for (const [environmentVariable, label] of AI_TRAINING_BY_ENVIRONMENT_VARIABLE) {
if (environment[environmentVariable]?.trim()) return label;
}
return null;
};

export const warnAiTrainingLicenseOnce = (
options: WarnAiTrainingLicenseOnceOptions,
): string | null => {
if (didWarnAiTrainingLicense) return null;
const detectedEnvironment = detectAiTrainingEnvironment(options.environment);
if (detectedEnvironment === null) return null;
didWarnAiTrainingLicense = true;
options.write(AI_TRAINING_LICENSE_NOTICE);
return detectedEnvironment;
};
Loading
Loading