Skip to content
Closed
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
14 changes: 14 additions & 0 deletions packages/eslint-plugin-sam/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module.exports = {
root: true,
env: {
es2022: true,
node: true,
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# sam/no-local-record-guard

Flags known local `isRecord` / `isObject` guard definitions that duplicate SAM runtime-validation helpers.

The matcher is intentionally narrow: it targets local definitions whose body is the familiar `typeof value === 'object' && value !== null` shape, with an optional `!Array.isArray(value)` clause and a TypeScript type-predicate return.

This rule is advisory and suggestion-only. Replacement requires call-site review because local guard semantics may intentionally differ.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# sam/no-unsafe-json-parse-assertion

Flags TypeScript assertions that narrow the result of `JSON.parse(...)` directly to application shapes.

Allowed:

```ts
const parsed = JSON.parse(raw) as unknown;
```

Disallowed examples include `Record<string, unknown>`, `Partial<T>`, concrete object shapes, and nested typed assertions such as `JSON.parse(raw) as unknown as Payload`.

This rule is advisory and provides suggestions only. Runtime parsing/validation must be chosen by the owning code path.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# sam/no-unvalidated-request-json

Flags typed Hono-style `*.req.json<T>()` calls. The type argument is compile-time-only and does not validate request bodies at runtime.

Use route-level `jsonValidator(schema)` or an established parsing helper before consuming the request body.

This rule is advisory and provides suggestions only. It intentionally does not auto-fix because inserting validation changes route semantics and error behavior.
24 changes: 24 additions & 0 deletions packages/eslint-plugin-sam/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@simple-agent-manager/eslint-plugin-sam",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Unpublished SAM-specific ESLint rules for deterministic quality gates.",
"exports": {
".": "./src/index.js"
},
"scripts": {
"test": "vitest run",
"lint": "eslint 'src/**/*.js' 'tests/**/*.test.js'",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"eslint": "^9.0.0"
},
"devDependencies": {
"@typescript-eslint/parser": "catalog:",
"eslint": "^9.39.1",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
75 changes: 75 additions & 0 deletions packages/eslint-plugin-sam/rules.manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"schemaVersion": 1,
"plugin": "@simple-agent-manager/eslint-plugin-sam",
"stage": "advisory",
"gateOwner": "deterministic-runtime-boundary-quality",
"baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md",
"rules": [
{
"name": "sam/no-unvalidated-request-json",
"owner": "runtime-boundary-quality",
"matcherVersion": "2026-08-09.1",
"advisoryStage": "shadow",
"gateOwner": "quality-program",
"evidenceIncident": "Typed Hono request JSON masks unvalidated request bodies at runtime.",
"baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md",
"addedDate": "2026-08-09",
"reviewDate": "2026-09-09",
"falsePositiveSamples": [
"Untyped c.req.json() calls are intentionally excluded for this syntax rule.",
"Route handlers already using jsonValidator(schema) are handled by integration wiring, not this isolated syntax matcher."
],
"expiringExemptions": [
{
"scope": "existing debt",
"expiresOn": "2026-10-09",
"reason": "Advisory rollout while the deterministic ratchet establishes current baseline ownership."
}
]
},
{
"name": "sam/no-unsafe-json-parse-assertion",
"owner": "runtime-boundary-quality",
"matcherVersion": "2026-08-09.1",
"advisoryStage": "shadow",
"gateOwner": "quality-program",
"evidenceIncident": "Type assertions over JSON.parse were repeatedly mistaken for runtime validation.",
"baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md",
"addedDate": "2026-08-09",
"reviewDate": "2026-09-09",
"falsePositiveSamples": [
"JSON.parse(raw) as unknown is allowed as the neutral parse boundary.",
"Non-JSON.parse assertions are excluded even when their target type is structural."
],
"expiringExemptions": [
{
"scope": "Record<string, unknown> population",
"expiresOn": "2026-10-09",
"reason": "Kept advisory until discriminating validation-oriented matchers and baselines are integrated."
}
]
},
{
"name": "sam/no-local-record-guard",
"owner": "runtime-boundary-quality",
"matcherVersion": "2026-08-09.1",
"advisoryStage": "shadow",
"gateOwner": "quality-program",
"evidenceIncident": "Local record/object guards drift from established runtime-validation helpers.",
"baselineBacklogLink": "tasks/active/2026-08-09-deterministic-runtime-boundary-quality.md",
"addedDate": "2026-08-09",
"reviewDate": "2026-09-09",
"falsePositiveSamples": [
"Guards with different names are excluded.",
"Guards with extra semantic checks are excluded because automatic replacement is unsafe."
],
"expiringExemptions": [
{
"scope": "existing local guard definitions",
"expiresOn": "2026-10-09",
"reason": "Existing call sites need semantics review before shared-helper migration."
}
]
}
]
}
19 changes: 19 additions & 0 deletions packages/eslint-plugin-sam/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import noLocalRecordGuard from './rules/no-local-record-guard.js';
import noUnsafeJsonParseAssertion from './rules/no-unsafe-json-parse-assertion.js';
import noUnvalidatedRequestJson from './rules/no-unvalidated-request-json.js';

const rules = {
'no-local-record-guard': noLocalRecordGuard,
'no-unsafe-json-parse-assertion': noUnsafeJsonParseAssertion,
'no-unvalidated-request-json': noUnvalidatedRequestJson,
};

export default {
meta: {
name: '@simple-agent-manager/eslint-plugin-sam',
version: '0.0.0',
},
rules,
};

export { rules };
31 changes: 31 additions & 0 deletions packages/eslint-plugin-sam/src/rules/ast.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function unwrapChainExpression(node) {
return node?.type === 'ChainExpression' ? node.expression : node;
}

export function getPropertyName(node) {
if (!node) {
return undefined;
}

if (node.type === 'Identifier') {
return node.name;
}

if (node.type === 'PrivateIdentifier') {
return node.name;
}

if (node.type === 'Literal' && typeof node.value === 'string') {
return node.value;
}

return undefined;
}

export function getCallTypeArguments(node) {
return node.typeArguments ?? node.typeParameters;
}

export function isIdentifierNamed(node, name) {
return node?.type === 'Identifier' && node.name === name;
}
182 changes: 182 additions & 0 deletions packages/eslint-plugin-sam/src/rules/no-local-record-guard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { isIdentifierNamed } from './ast.js';

const docsUrl =
'https://github.com/raphaeltm/simple-agent-manager/blob/main/packages/eslint-plugin-sam/docs/rules/no-local-record-guard.md';

function isRecordGuardName(name) {
return name === 'isRecord' || name === 'isObject';
}

function getFunctionName(node) {
if (node.type === 'FunctionDeclaration') {
return node.id?.name;
}

if (
node.type === 'VariableDeclarator' &&
node.id.type === 'Identifier' &&
(node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression')
) {
return node.id.name;
}

return undefined;
}

function getFunctionNode(node) {
if (node.type === 'FunctionDeclaration') {
return node;
}

if (node.type === 'VariableDeclarator') {
return node.init;
}

return undefined;
}

function getReturnExpression(functionNode) {
if (!functionNode || functionNode.body.type !== 'BlockStatement') {

Check warning on line 39 in packages/eslint-plugin-sam/src/rules/no-local-record-guard.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ_niYbPO9VMtsP3FJcH&open=AZ_niYbPO9VMtsP3FJcH&pullRequest=1783
return functionNode?.body;
}

if (functionNode.body.body.length !== 1) {
return undefined;
}

const statement = functionNode.body.body[0];
return statement?.type === 'ReturnStatement' ? statement.argument : undefined;
}

function hasTypePredicateReturn(functionNode, parameterName) {
const returnType = functionNode.returnType?.typeAnnotation;
if (returnType?.type !== 'TSTypePredicate') {
return false;
}

return isIdentifierNamed(returnType.parameterName, parameterName);
}

function flattenLogicalAnd(node) {
if (node?.type === 'LogicalExpression' && node.operator === '&&') {
return [...flattenLogicalAnd(node.left), ...flattenLogicalAnd(node.right)];
}

return node ? [node] : [];
}

function isTypeofObjectCheck(node, parameterName) {
return (
node.type === 'BinaryExpression' &&
(node.operator === '===' || node.operator === '==') &&
node.left.type === 'UnaryExpression' &&
node.left.operator === 'typeof' &&
isIdentifierNamed(node.left.argument, parameterName) &&
node.right.type === 'Literal' &&
node.right.value === 'object'
);
}

function isNotNullCheck(node, parameterName) {
return (
node.type === 'BinaryExpression' &&
(node.operator === '!==' || node.operator === '!=') &&
isIdentifierNamed(node.left, parameterName) &&
node.right.type === 'Literal' &&
node.right.value === null
);
}

function isNotArrayCheck(node, parameterName) {
return (
node.type === 'UnaryExpression' &&
node.operator === '!' &&
node.argument.type === 'CallExpression' &&
node.argument.callee.type === 'MemberExpression' &&
node.argument.callee.object.type === 'Identifier' &&
node.argument.callee.object.name === 'Array' &&
node.argument.callee.property.type === 'Identifier' &&
node.argument.callee.property.name === 'isArray' &&
node.argument.arguments.length === 1 &&
isIdentifierNamed(node.argument.arguments[0], parameterName)
);
}

function isKnownLocalRecordGuard(functionNode) {
const parameter = functionNode?.params[0];
if (!parameter || parameter.type !== 'Identifier') {

Check warning on line 107 in packages/eslint-plugin-sam/src/rules/no-local-record-guard.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ_niYbPO9VMtsP3FJcI&open=AZ_niYbPO9VMtsP3FJcI&pullRequest=1783
return false;
}

if (!hasTypePredicateReturn(functionNode, parameter.name)) {
return false;
}

const clauses = flattenLogicalAnd(getReturnExpression(functionNode));
const hasObject = clauses.some((clause) => isTypeofObjectCheck(clause, parameter.name));
const hasNotNull = clauses.some((clause) => isNotNullCheck(clause, parameter.name));
const onlyKnownClauses = clauses.every(
(clause) =>
isTypeofObjectCheck(clause, parameter.name) ||
isNotNullCheck(clause, parameter.name) ||
isNotArrayCheck(clause, parameter.name),
);

return clauses.length >= 2 && hasObject && hasNotNull && onlyKnownClauses;
}

function checkNode(context, node) {
const name = getFunctionName(node);
if (!name || !isRecordGuardName(name)) {
return;
}

const functionNode = getFunctionNode(node);
if (!isKnownLocalRecordGuard(functionNode)) {
return;
}

context.report({
node,
messageId: 'localRecordGuard',
data: { name },
suggest: [
{
messageId: 'useSharedValidation',
fix: (fixer) => {
const sourceCode = context.sourceCode ?? context.getSourceCode();
return fixer.replaceText(node, sourceCode.getText(node));
},
},
],
});
}

export default {
meta: {
type: 'suggestion',
docs: {
description: 'Discourage local isRecord/isObject guard definitions that duplicate shared runtime validation.',
recommended: false,
url: docsUrl,
},
hasSuggestions: true,
messages: {
localRecordGuard:
'Local {{name}} guard definitions drift from shared runtime-validation helpers. Use the established helper instead.',
useSharedValidation:
'Replace this local guard with the shared runtime-validation helper after checking call-site semantics.',
},
schema: [],
},
create(context) {
return {
FunctionDeclaration(node) {
checkNode(context, node);
},
VariableDeclarator(node) {
checkNode(context, node);
},
};
},
};
Loading
Loading