mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss/grind] deferred session-0002 (20260521T201327Z-3848)
This commit is contained in:
parent
159a779f31
commit
d99361cff6
18 changed files with 499 additions and 144 deletions
|
|
@ -3,6 +3,10 @@
|
|||
//! Fires when the surrounding source imports Django middleware base
|
||||
//! classes (`MiddlewareMixin`) or declares a callable middleware whose
|
||||
//! body defines `__call__(self, request)` / `process_request`.
|
||||
//!
|
||||
//! Notably does NOT fire just because the file contains `MIDDLEWARE = [`
|
||||
//! (typical of `settings.py`) — that needle stole every settings module
|
||||
//! into Middleware bindings (Phase 21 binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -17,24 +21,42 @@ fn callee_is_django_middleware(name: &str) -> bool {
|
|||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"process_request" | "process_response" | "process_view" | "process_exception" | "__call__"
|
||||
"process_request" | "process_response" | "process_view" | "process_exception"
|
||||
)
|
||||
}
|
||||
|
||||
fn source_imports_django_middleware(file_bytes: &[u8]) -> bool {
|
||||
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"django.utils.deprecation",
|
||||
b"MiddlewareMixin",
|
||||
b"def __call__(self, request",
|
||||
b"def process_request",
|
||||
b"django.middleware",
|
||||
b"MIDDLEWARE = [",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn looks_like_settings_module(file_bytes: &[u8]) -> bool {
|
||||
// Heuristic: settings.py declares MIDDLEWARE / INSTALLED_APPS / DATABASES at
|
||||
// module scope. A real middleware module declares none of these (it carries
|
||||
// a class with __call__ / process_*).
|
||||
let has_middleware_list = file_bytes
|
||||
.windows(b"MIDDLEWARE = [".len())
|
||||
.any(|w| w == b"MIDDLEWARE = [");
|
||||
let has_installed_apps = file_bytes
|
||||
.windows(b"INSTALLED_APPS".len())
|
||||
.any(|w| w == b"INSTALLED_APPS");
|
||||
let declares_middleware_class = file_bytes
|
||||
.windows(b"def __call__".len())
|
||||
.any(|w| w == b"def __call__")
|
||||
|| file_bytes
|
||||
.windows(b"def process_request".len())
|
||||
.any(|w| w == b"def process_request");
|
||||
(has_middleware_list || has_installed_apps) && !declares_middleware_class
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -50,8 +72,11 @@ impl FrameworkAdapter for MiddlewareDjangoAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if looks_like_settings_module(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_django_middleware);
|
||||
let matches_source = source_imports_django_middleware(file_bytes);
|
||||
let matches_source = source_has_middleware_shape(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -95,4 +120,20 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "middleware-django");
|
||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_settings_module() {
|
||||
let src: &[u8] = b"INSTALLED_APPS = ['django.contrib.auth']\nMIDDLEWARE = [\n 'django.middleware.security.SecurityMiddleware',\n]\nDATABASES = {}\n";
|
||||
let tree = parse_python(src);
|
||||
let summary = FuncSummary {
|
||||
name: "some_helper".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareDjangoAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none(),
|
||||
"settings.py-shaped module must not bind as middleware",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
//! Phase 21 (Track M.3) — Express middleware adapter (JS).
|
||||
//!
|
||||
//! Fires when the surrounding source imports Express and declares a
|
||||
//! middleware function — a `(req, res, next) => …` callable mounted
|
||||
//! via `app.use(...)` / `router.use(...)`.
|
||||
//! Fires when the surrounding source imports Express and the function
|
||||
//! under analysis is mounted via `app.use(<this_fn>)` /
|
||||
//! `router.use(<this_fn>)`. An anonymous-mount or callee-only signal
|
||||
//! (`app.use(...)` with a non-matching function name) is no longer
|
||||
//! enough on its own — that needle stole every Express setup file into
|
||||
//! Middleware bindings regardless of which function the analyser was
|
||||
//! looking at (Phase 21 binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -13,21 +17,36 @@ pub struct MiddlewareExpressAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "middleware-express";
|
||||
|
||||
fn callee_is_express(name: &str) -> bool {
|
||||
fn callee_is_express_mount(name: &str) -> bool {
|
||||
// `use` on Express's app/router registers middleware. Other Express
|
||||
// helpers like `json`/`urlencoded`/`static` are body-parser
|
||||
// factories that pair WITH `use` rather than identifying the
|
||||
// function itself as middleware, so they no longer count.
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "use" | "next" | "json" | "urlencoded" | "static")
|
||||
last == "use"
|
||||
}
|
||||
|
||||
fn source_imports_express(file_bytes: &[u8]) -> bool {
|
||||
// Phase 21 v1: require an explicit middleware-registration shape
|
||||
// (`app.use(` / `router.use(`), not the bare `require('express')`
|
||||
// import. Many non-middleware Express fixtures import the framework
|
||||
// but never declare middleware; gating on the registration shape
|
||||
// keeps the adapter focused on the function the brief targets.
|
||||
const NEEDLES: &[&[u8]] = &[b"app.use(", b"router.use(", b"express.Router()"];
|
||||
NEEDLES
|
||||
fn function_is_mounted_as_middleware(file_bytes: &[u8], name: &str) -> bool {
|
||||
if name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let needles: [Vec<u8>; 2] = [
|
||||
format!("app.use({name})").into_bytes(),
|
||||
format!("router.use({name})").into_bytes(),
|
||||
];
|
||||
needles
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == n.as_slice()))
|
||||
}
|
||||
|
||||
fn function_has_middleware_signature(summary: &FuncSummary) -> bool {
|
||||
// Express middleware contract: (req, res, next). Adapters cannot
|
||||
// rely on a generic mount-everything heuristic so the param shape
|
||||
// becomes the secondary signal when no explicit `app.use(<name>)`
|
||||
// line is present.
|
||||
let names: Vec<&str> = summary.param_names.iter().map(|s| s.as_str()).collect();
|
||||
matches!(names.as_slice(), ["req", "res", "next"])
|
||||
|| matches!(names.as_slice(), ["request", "response", "next"])
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareExpressAdapter {
|
||||
|
|
@ -45,22 +64,23 @@ impl FrameworkAdapter for MiddlewareExpressAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_express);
|
||||
let matches_source = source_imports_express(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let mounted_by_name = function_is_mounted_as_middleware(file_bytes, &summary.name);
|
||||
let has_mw_signature = function_has_middleware_signature(summary);
|
||||
let body_mounts = super::any_callee_matches(summary, callee_is_express_mount);
|
||||
let binds = mounted_by_name || has_mw_signature || body_mounts;
|
||||
if !binds {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,4 +114,26 @@ mod tests {
|
|||
assert_eq!(name, "audit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_unrelated_helper_in_express_setup() {
|
||||
// File mounts middleware `audit` but the analyser is asking
|
||||
// about an unrelated helper `loadConfig` in the same file.
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function audit(req, res, next) { next(); }\n\
|
||||
function loadConfig() { return { port: 3000 }; }\n\
|
||||
app.use(audit);\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = FuncSummary {
|
||||
name: "loadConfig".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareExpressAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none(),
|
||||
"unrelated helper in an Express setup file must not bind as middleware",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
//! Fires when the surrounding source declares a class with a `handle`
|
||||
//! method whose signature matches Laravel's middleware contract
|
||||
//! (`$request, Closure $next`).
|
||||
//!
|
||||
//! Notably does NOT fire just because the file imports
|
||||
//! `Illuminate\Http\Request` or mentions `$middleware` — every typical
|
||||
//! Laravel controller imports the request facade, and `$middleware`
|
||||
//! appears in routes / kernel files unrelated to middleware classes
|
||||
//! (Phase 21 binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -15,23 +21,26 @@ const ADAPTER_NAME: &str = "middleware-laravel";
|
|||
|
||||
fn callee_is_laravel_middleware(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "handle" | "terminate" | "next" | "withMiddleware")
|
||||
matches!(last, "terminate" | "withMiddleware")
|
||||
}
|
||||
|
||||
fn source_imports_laravel_middleware(file_bytes: &[u8]) -> bool {
|
||||
fn source_has_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Illuminate\\Http\\Request",
|
||||
b"Illuminate\\Foundation\\Http\\Middleware",
|
||||
b"function handle($request, Closure $next",
|
||||
b"function handle(Request $request, Closure $next",
|
||||
b"function handle($request, $next",
|
||||
b"app/Http/Middleware",
|
||||
b"$middleware",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_middleware_entry(name: &str) -> bool {
|
||||
matches!(name, "handle" | "terminate")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -47,22 +56,24 @@ impl FrameworkAdapter for MiddlewareLaravelAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
let matches_source = source_imports_laravel_middleware(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let has_shape = source_has_middleware_shape(file_bytes);
|
||||
let name_matches = name_is_middleware_entry(&summary.name);
|
||||
let body_mounts_middleware =
|
||||
super::any_callee_matches(summary, callee_is_laravel_middleware);
|
||||
let binds = (name_matches && has_shape) || body_mounts_middleware;
|
||||
if !binds {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,4 +102,20 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "middleware-laravel");
|
||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_laravel_controller_method() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Http\\Request;\nclass UserController {\n public function show(Request $request) { return $request->all(); }\n}\n";
|
||||
let tree = parse_php(src);
|
||||
let summary = FuncSummary {
|
||||
name: "show".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareLaravelAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none(),
|
||||
"controller method must not bind as middleware just because the file imports Request",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
//! Phase 21 (Track M.3) — Rack / Rails middleware adapter (Ruby).
|
||||
//!
|
||||
//! Fires when the surrounding source defines a Rack-shaped middleware
|
||||
//! (`def call(env)`) or registers a Rails before-action callback.
|
||||
//! (`def call(env)`) or wires one into the Rails middleware stack.
|
||||
//!
|
||||
//! Notably does NOT fire for Rails controller actions even when the file
|
||||
//! contains `before_action :name` / `after_action :name` callback
|
||||
//! registrations — those are class-level controller DSL hooks, not Rack
|
||||
//! middleware definitions. Older `before_action ` / `after_action ` /
|
||||
//! `around_action ` source needles were dropped because every typical
|
||||
//! Rails controller mentions them, which made the adapter bind every
|
||||
//! controller action as middleware (Phase 21 binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -14,28 +22,39 @@ const ADAPTER_NAME: &str = "middleware-rails";
|
|||
|
||||
fn callee_is_rails_middleware(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"call" | "before_action" | "around_action" | "after_action" | "use"
|
||||
)
|
||||
matches!(last, "call" | "use")
|
||||
}
|
||||
|
||||
fn source_imports_rails_middleware(file_bytes: &[u8]) -> bool {
|
||||
fn source_has_rack_middleware_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"def call(env)",
|
||||
b"def call (env",
|
||||
b"before_action ",
|
||||
b"after_action ",
|
||||
b"around_action ",
|
||||
b"Rails.application.config.middleware",
|
||||
b"Rack::Builder",
|
||||
b"@app = app",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn looks_like_rails_controller(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"< ApplicationController",
|
||||
b"<ApplicationController",
|
||||
b"< ActionController::Base",
|
||||
b"<ActionController::Base",
|
||||
b"< ActionController::API",
|
||||
b"<ActionController::API",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_rack_entry(name: &str) -> bool {
|
||||
name == "call"
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MiddlewareRailsAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -51,22 +70,27 @@ impl FrameworkAdapter for MiddlewareRailsAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_rails_middleware);
|
||||
let matches_source = source_imports_rails_middleware(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
if looks_like_rails_controller(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let has_middleware_shape = source_has_rack_middleware_shape(file_bytes);
|
||||
let name_matches = name_is_rack_entry(&summary.name);
|
||||
let body_mounts_middleware =
|
||||
super::any_callee_matches(summary, callee_is_rails_middleware);
|
||||
let binds = (name_matches && has_middleware_shape) || body_mounts_middleware;
|
||||
if !binds {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Middleware {
|
||||
name: summary.name.clone(),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,4 +119,20 @@ mod tests {
|
|||
assert_eq!(binding.adapter, "middleware-rails");
|
||||
assert!(matches!(binding.kind, EntryKind::Middleware { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_rails_controller_action() {
|
||||
let src: &[u8] = b"class UsersController < ApplicationController\n before_action :authenticate\n def index\n @users = User.all\n render :index\n end\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "index".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MiddlewareRailsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none(),
|
||||
"controller action must not bind as Rack middleware",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@
|
|||
//! Fires when the surrounding source extends `Illuminate\\Database\\Migrations\\Migration`
|
||||
//! and declares an `up()` / `down()` method whose body invokes
|
||||
//! `Schema::create` / `Schema::table` / `DB::statement`.
|
||||
//!
|
||||
//! Notably does NOT fire just because the file mentions `DB::statement`
|
||||
//! or the bare `Illuminate\\Database\\Schema` namespace — those tokens
|
||||
//! appear in plenty of model helpers, query objects, and database
|
||||
//! drivers that are not themselves migration classes (Phase 21
|
||||
//! binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -13,28 +19,26 @@ pub struct MigrationLaravelAdapter;
|
|||
|
||||
const ADAPTER_NAME: &str = "migration-laravel";
|
||||
|
||||
fn callee_is_laravel_migration(name: &str) -> bool {
|
||||
fn callee_is_laravel_migration_ddl(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"up" | "down" | "create" | "table" | "drop" | "statement" | "unprepared"
|
||||
)
|
||||
matches!(last, "create" | "table" | "drop" | "statement" | "unprepared")
|
||||
}
|
||||
|
||||
fn source_imports_laravel_migration(file_bytes: &[u8]) -> bool {
|
||||
fn source_has_migration_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"Illuminate\\Database\\Migrations\\Migration",
|
||||
b"Illuminate\\Database\\Schema",
|
||||
b"Schema::create",
|
||||
b"Schema::table",
|
||||
b"DB::statement",
|
||||
b"use Illuminate\\Database\\Schema",
|
||||
];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "up" | "down")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for MigrationLaravelAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
|
|
@ -50,20 +54,21 @@ impl FrameworkAdapter for MigrationLaravelAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_laravel_migration);
|
||||
let matches_source = source_imports_laravel_migration(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration { version: None },
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let has_shape = source_has_migration_shape(file_bytes);
|
||||
let name_matches = name_is_migration_entry(&summary.name);
|
||||
let body_runs_ddl = super::any_callee_matches(summary, callee_is_laravel_migration_ddl);
|
||||
let binds = (name_matches || body_runs_ddl) && has_shape;
|
||||
if !binds {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration { version: None },
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
//! Phase 21 (Track M.3) — Rails ActiveRecord migration adapter (Ruby).
|
||||
//!
|
||||
//! Fires when the surrounding source declares a class inheriting from
|
||||
//! `ActiveRecord::Migration[...]` or invokes the canonical migration
|
||||
//! DSL (`create_table`, `add_column`, `execute`).
|
||||
//! `ActiveRecord::Migration[...]` or carries the canonical migration
|
||||
//! marker the fixture uses (`# class Foo < ActiveRecord::Migration[…]`).
|
||||
//!
|
||||
//! Notably does NOT fire just because the file mentions `create_table` /
|
||||
//! `add_column` / `drop_table` — those tokens also appear in
|
||||
//! `db/schema.rb` snapshots, helper modules, and SQL ddl bodies that are
|
||||
//! not themselves migration classes (Phase 21 binding-stealing audit).
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::evidence::EntryKind;
|
||||
|
|
@ -17,9 +22,7 @@ fn callee_is_rails_migration(name: &str) -> bool {
|
|||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(
|
||||
last,
|
||||
"up" | "down"
|
||||
| "change"
|
||||
| "create_table"
|
||||
"create_table"
|
||||
| "add_column"
|
||||
| "remove_column"
|
||||
| "drop_table"
|
||||
|
|
@ -28,19 +31,17 @@ fn callee_is_rails_migration(name: &str) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
fn source_imports_rails_migration(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[
|
||||
b"ActiveRecord::Migration",
|
||||
b"< ActiveRecord::Migration",
|
||||
b"create_table ",
|
||||
b"add_column ",
|
||||
b"drop_table ",
|
||||
];
|
||||
fn source_has_migration_shape(file_bytes: &[u8]) -> bool {
|
||||
const NEEDLES: &[&[u8]] = &[b"ActiveRecord::Migration", b"< ActiveRecord::Migration"];
|
||||
NEEDLES
|
||||
.iter()
|
||||
.any(|n| file_bytes.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
fn name_is_migration_entry(name: &str) -> bool {
|
||||
matches!(name, "up" | "down" | "change")
|
||||
}
|
||||
|
||||
fn extract_version(file_bytes: &[u8]) -> Option<String> {
|
||||
let text = std::str::from_utf8(file_bytes).unwrap_or("");
|
||||
let needle = "ActiveRecord::Migration[";
|
||||
|
|
@ -68,22 +69,23 @@ impl FrameworkAdapter for MigrationRailsAdapter {
|
|||
_ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
let matches_call = super::any_callee_matches(summary, callee_is_rails_migration);
|
||||
let matches_source = source_imports_rails_migration(file_bytes);
|
||||
if matches_call || matches_source {
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
version: extract_version(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let has_shape = source_has_migration_shape(file_bytes);
|
||||
let name_matches = name_is_migration_entry(&summary.name);
|
||||
let body_runs_ddl = super::any_callee_matches(summary, callee_is_rails_migration);
|
||||
let binds = (name_matches || body_runs_ddl) && has_shape;
|
||||
if !binds {
|
||||
return None;
|
||||
}
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::Migration {
|
||||
version: extract_version(file_bytes),
|
||||
},
|
||||
route: None,
|
||||
request_params: Vec::new(),
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,4 +116,20 @@ mod tests {
|
|||
assert_eq!(version.as_deref(), Some("7.0"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_bind_schema_dump() {
|
||||
let src: &[u8] = b"ActiveRecord::Schema.define(version: 2024_01_01_000000) do\n create_table :users do |t|\n t.string :name\n end\nend\n";
|
||||
let tree = parse_ruby(src);
|
||||
let summary = FuncSummary {
|
||||
name: "define".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
MigrationRailsAdapter
|
||||
.detect(&summary, tree.root_node(), src)
|
||||
.is_none(),
|
||||
"db/schema.rb dump must not bind as migration",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue