@anik-2/autosar 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anik-2/autosar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "AUTOSAR Classic pack for Hydron: ECUC read/edit-in-place/validate/diff on ARXML, config-to-code trace, generator and build runners",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,6 +16,7 @@
16
16
  "plugins/index.js",
17
17
  "python/pyproject.toml",
18
18
  "python/ecuc_engine/*.py",
19
+ "python/ecuc_engine/rules/*.py",
19
20
  "README.md"
20
21
  ],
21
22
  "scripts": {
package/plugins/index.js CHANGED
@@ -1,21 +1,21 @@
1
1
  // @bun
2
- import{isAbsolute as W,join as T,resolve as G}from"path";import{tool as p}from"@kilocode/plugin";import{homedir as j}from"os";import{join as h}from"path";var I=()=>process.env.HYDRON_TEST_HOME||j(),R=(t,n)=>process.env[t]||h(I(),n),D=()=>h(R("XDG_CONFIG_HOME",".config"),"hydron-cli"),L=()=>h(R("XDG_DATA_HOME",".local/share"),"hydron-cli"),M=()=>h(R("XDG_STATE_HOME",".local/state"),"hydron-cli");async function v(t){let n=Bun.file(t);if(!await n.exists())return;return n.json().catch(()=>{return})}async function N(){let t=process.env.KILO_API_URL?.trim();if(t)return t.replace(/\/+$/,"");let n=await v(h(D(),"server-config.json")),e=typeof n?.apiBaseUrl==="string"?n.apiBaseUrl:void 0;if(!e)throw Error("H2Loop backend URL unknown: set KILO_API_URL or sign in with `hydron auth login`.");return e.replace(/\/+$/,"")}function F(t){let n=t?.split(".")[1];if(!n)return"";try{let e=JSON.parse(Buffer.from(n.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"));return typeof e.sub==="string"?e.sub:""}catch{return""}}async function J(t){let n=F(t.access);if(n)return n;if(!t.refresh)return"";return new Bun.CryptoHasher("sha256").update(t.refresh).digest("hex").slice(0,12)}async function E(){let n=(await v(h(L(),"auth.json")))?.hydron;if(!n||n.type!=="oauth"||!n.access)throw Error("Not signed in to H2Loop. Run `hydron auth login` (or sign in from the TUI), then retry.");return n}async function x(){let t=await E();if(typeof t.expires==="number"&&t.expires>0&&Date.now()>=t.expires-30000)throw Error("The H2Loop session token has expired. Send any message in Hydron (the CLI refreshes it) or re-login with `hydron auth login`, then retry.");return t.access}async function A(t){if(t!==void 0&&String(t).trim())return String(t).trim();let n=await v(h(M(),"h2loop-project.json")),e=await E(),o=await J(e),i=n?.project??void 0;if(i?.id!==void 0&&typeof n?.account==="string"&&n.account===o)return String(i.id);throw Error("No H2Loop project selected for this account. Select one in Hydron (project picker) or pass workspace_id explicitly.")}async function $(t,n,e,o){let i=`${await N()}${t}?workspace_id=${encodeURIComponent(n)}`,a=await fetch(i,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${await x()}`},body:JSON.stringify(e),signal:o?AbortSignal.any([o,AbortSignal.timeout(120000)]):AbortSignal.timeout(120000)}),r=await a.text();if(a.status===401)throw Error("H2Loop rejected the session (401). Re-login with `hydron auth login`, then retry.");if(a.status===404&&r.includes("Not Found"))throw Error(`${t} is not available on ${await N()} (backend predates the AUTOSAR endpoints).`);if(!a.ok)throw Error(`H2Loop ${t} failed: HTTP ${a.status} ${r.slice(0,800)}`);try{return JSON.parse(r)}catch(d){throw Error(`H2Loop ${t} returned non-JSON (${String(d)}): ${r.slice(0,300)}`)}}import{existsSync as P}from"fs";import{dirname as q,join as S}from"path";import{fileURLToPath as X}from"url";var H="autosar-data==0.17.0";function B(){let t=q(X(import.meta.url)),n=[S(t,"..","python"),S(t,"..","..","python")].find((e)=>P(S(e,"ecuc_engine","__main__.py")));if(!n)throw Error(`ECUC engine not found next to ${t} (expected ../python/ecuc_engine)`);return n}async function V(t){try{return await Bun.spawn([...t,"-c","import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)"],{stdout:"ignore",stderr:"ignore",stdin:"ignore"}).exited===0}catch{return!1}}var _={};async function Y(){if(_.cmd!==void 0)return _.cmd;let t=process.env.AUTOSAR_PYTHON?.trim(),e=t?[[t]]:[["uv","run","--no-project","--quiet","--with",H,"--python",">=3.10","python"],["python3"],["python"],["py","-3"]];for(let o of e)if(await V(o))return _.cmd=o,o;return _.cmd=null,null}async function y(t,n,e){let o=await Y();if(!o)return{ok:!1,error:"no Python >= 3.10 found. Install uv (https://docs.astral.sh/uv/) or Python 3.10+, or set AUTOSAR_PYTHON to an interpreter path."};let i=B(),a=Bun.spawn([...o,"-m","ecuc_engine","--root",t,...n,"--json"],{cwd:t,env:{...process.env,PYTHONPATH:[i,process.env.PYTHONPATH].filter(Boolean).join(process.platform==="win32"?";":":")},stdout:"pipe",stderr:"pipe",stdin:"ignore",signal:e}),[r,d]=await Promise.all([new Response(a.stdout).text(),new Response(a.stderr).text()]);await a.exited;let u=r.trim().split(`
3
- `).filter(Boolean).at(-1);if(!u)return{ok:!1,error:`engine produced no output (exit ${a.exitCode}): ${d.trim().slice(-2000)}`};try{return JSON.parse(u)}catch(c){return{ok:!1,error:`engine output was not JSON (${String(c)}): ${u.slice(0,500)} ${d.slice(-500)}`}}}var s=p.schema,K=["# AUTOSAR pack active","For AUTOSAR/ECUC/ARXML work load the `autosar` skill. Never hand-write ARXML: use ecuc_read, ecuc_edit,","then ecuc_validate after every edit; arxml_diff to review. autosar_trace links config to C code.","Cite AUTOSAR path + DEFINITION-REF."].join(`
4
- `),b=" In your answer cite the AUTOSAR path and the DEFINITION-REF (and file:line) of every element you rely on.";function C(t,n=200){let e=t.slice(0,n).map((o)=>`${o.file}:${o.line} ${o.severity.toUpperCase()} ${o.rule} ${o.path} \u2014 ${o.message}${o.defRef?` [def ${o.defRef}]`:""}`);if(t.length>n)e.push(`\u2026 ${t.length-n} more`);return e}function w(t){let n=t.didYouMean?`
5
- Did you mean: ${t.didYouMean.join(", ")}`:"",e=t.schema?`
6
- Schema errors: ${JSON.stringify(t.schema)}`:"";return`ERROR: ${t.error}${n}${e}`}function g(t){let n=t.warnings??[];return n.length?n.map((e)=>`WARNING: ${e}`).join(`
2
+ import{isAbsolute as q_,join as h,resolve as H_}from"path";import{tool as P}from"@kilocode/plugin";import{homedir as x}from"os";import{join as X}from"path";var d=()=>process.env.HYDRON_TEST_HOME||x(),C=(q,H)=>process.env[q]||X(d(),H),c=()=>X(C("XDG_CONFIG_HOME",".config"),"hydron-cli"),l=()=>X(C("XDG_DATA_HOME",".local/share"),"hydron-cli"),n=()=>X(C("XDG_STATE_HOME",".local/state"),"hydron-cli");async function L(q){let H=Bun.file(q);if(!await H.exists())return;return H.json().catch(()=>{return})}async function O(){let q=process.env.KILO_API_URL?.trim();if(q)return q.replace(/\/+$/,"");let H=await L(X(c(),"server-config.json")),_=typeof H?.apiBaseUrl==="string"?H.apiBaseUrl:void 0;if(!_)throw Error("H2Loop backend URL unknown: set KILO_API_URL or sign in with `hydron auth login`.");return _.replace(/\/+$/,"")}function i(q){let H=q?.split(".")[1];if(!H)return"";try{let _=JSON.parse(Buffer.from(H.replace(/-/g,"+").replace(/_/g,"/"),"base64").toString("utf8"));return typeof _.sub==="string"?_.sub:""}catch{return""}}async function g(q){let H=i(q.access);if(H)return H;if(!q.refresh)return"";return new Bun.CryptoHasher("sha256").update(q.refresh).digest("hex").slice(0,12)}async function v(){let H=(await L(X(l(),"auth.json")))?.hydron;if(!H||H.type!=="oauth"||!H.access)throw Error("Not signed in to H2Loop. Run `hydron auth login` (or sign in from the TUI), then retry.");return H}async function u(){let q=await v();if(typeof q.expires==="number"&&q.expires>0&&Date.now()>=q.expires-30000)throw Error("The H2Loop session token has expired. Send any message in Hydron (the CLI refreshes it) or re-login with `hydron auth login`, then retry.");return q.access}async function f(q){let H=await fetch(`${await O()}/api/v1/workspaces`,{headers:{Authorization:`Bearer ${await u()}`},signal:q?AbortSignal.any([q,AbortSignal.timeout(30000)]):AbortSignal.timeout(30000)});if(H.status===401)throw Error("H2Loop rejected the session (401). Re-login with `hydron auth login`, then retry.");if(!H.ok)throw Error(`could not list H2Loop projects: HTTP ${H.status}`);let _=await H.json();return Array.isArray(_)?_:_?.items??[]}var k=()=>X(n(),"h2loop-project.json");async function S(q,H){let _=q===void 0||q===null?"":String(q).trim();if(/^\d+$/.test(_))return _;if(_){let $=await f(H),D=$.filter((F)=>F.name===_),B=D.length?D:$.filter((F)=>F.name?.toLowerCase()===_.toLowerCase());if(B.length===1)return String(B[0].id);if(B.length>1)throw Error(`workspace "${_}" is ambiguous: ${B.map((F)=>`${F.name} (id ${F.id})`).join(", ")}; pass the numeric id`);let W=$.filter((F)=>F.name?.toLowerCase().includes(_.toLowerCase())).slice(0,10);throw Error(`no H2Loop project named "${_}"`+(W.length?`; similar: ${W.map((F)=>`${F.name} (id ${F.id})`).join(", ")}`:`; you have ${$.length} project(s)`))}let J=await L(k());if(!J)throw Error("No H2Loop project selected (run `hydron projects select <id>`), or pass workspace_id (id or name).");let V=await v();if(J.project&&typeof J.project==="object"){let $=J.project;if(typeof J.account==="string"&&J.account!==await g(V))throw Error("The selected H2Loop project belongs to another account; select one again or pass workspace_id.");if($.id!==void 0)return String($.id)}let Q=J;if(Q.id===void 0)throw Error(`${k()} holds no project id; select a project again or pass workspace_id.`);if(!(await f(H)).some(($)=>String($.id)===String(Q.id)))throw Error(`The selected project ${Q.name??""} (id ${Q.id}) is not accessible to the signed-in account; select another or pass workspace_id.`);return String(Q.id)}async function T(q,H,_,J){let V=`${await O()}${q}?workspace_id=${encodeURIComponent(H)}`,Q=await fetch(V,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${await u()}`},body:JSON.stringify(_),signal:J?AbortSignal.any([J,AbortSignal.timeout(120000)]):AbortSignal.timeout(120000)}),Y=await Q.text();if(Q.status===401)throw Error("H2Loop rejected the session (401). Re-login with `hydron auth login`, then retry.");if(Q.status===404&&Y.includes("Not Found"))throw Error(`${q} is not available on ${await O()} (backend predates the AUTOSAR endpoints).`);if(!Q.ok)throw Error(`H2Loop ${q} failed: HTTP ${Q.status} ${Y.slice(0,800)}`);try{return JSON.parse(Y)}catch($){throw Error(`H2Loop ${q} returned non-JSON (${String($)}): ${Y.slice(0,300)}`)}}var A=/\.arxml\b/i,E="Blocked: .arxml files must not be edited by hand (it breaks DEFINITION-REF/DEST, UUIDs and the round trip). Use ecuc_edit with ops by AUTOSAR path (read paths and allowed params with ecuc_read schema=true), then ecuc_validate.";function z(q,H){if(process.env.AUTOSAR_ALLOW_ARXML_EDIT==="1"||!H)return;let _=typeof H.filePath==="string"?H.filePath:typeof H.file_path==="string"?H.file_path:"";if(["edit","write","multiedit"].includes(q)){let J=Array.isArray(H.edits)?H.edits.some((V)=>typeof V?.filePath==="string"&&A.test(V.filePath)):!1;if(A.test(_)||J)return`${E} (${q} on ${_||"an .arxml file"})`;return}if(q==="apply_patch"||q==="patch"){let J=String(H.patchText??H.patch??""),V=J.match(/^\*\*\* (?:Add|Update|Delete) File: (.+\.arxml)\s*$/im)??J.match(/^\+\+\+ (?:b\/)?(.+\.arxml)/im);return V?`${E} (${q} on ${V[1].trim()})`:void 0}if(q==="bash"){let J=String(H.command??"");if(!A.test(J))return;let V=/\b(?:sed|gsed)\b[^|;&]*\s-(?:[a-zA-Z]*i|-in-place)\b/.test(J)||/\bperl\b[^|;&]*\s-[a-zA-Z]*i/.test(J),Q=/(?:^|[^<>&0-9])>>?\s*['"]?[^\s'"|;&]*\.arxml\b/i.test(J)||/\btee\b[^|;&]*\.arxml\b/i.test(J);if(V||Q)return`${E} (bash: ${J.slice(0,120)})`}return}import{existsSync as o}from"fs";import{dirname as t,join as b}from"path";import{fileURLToPath as a}from"url";var s="autosar-data==0.17.0";function e(){let q=t(a(import.meta.url)),H=[b(q,"..","python"),b(q,"..","..","python")].find((_)=>o(b(_,"ecuc_engine","__main__.py")));if(!H)throw Error(`ECUC engine not found next to ${q} (expected ../python/ecuc_engine)`);return H}async function r(q){try{return await Bun.spawn([...q,"-c","import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)"],{stdout:"ignore",stderr:"ignore",stdin:"ignore"}).exited===0}catch{return!1}}var I={};async function __(){if(I.cmd!==void 0)return I.cmd;let q=process.env.AUTOSAR_PYTHON?.trim(),_=q?[[q]]:[["uv","run","--no-project","--quiet","--with",s,"--python",">=3.10","python"],["python3"],["python"],["py","-3"]];for(let J of _)if(await r(J))return I.cmd=J,J;return I.cmd=null,null}async function G(q,H,_){let J=await __();if(!J)return{ok:!1,error:"no Python >= 3.10 found. Install uv (https://docs.astral.sh/uv/) or Python 3.10+, or set AUTOSAR_PYTHON to an interpreter path."};let V=e(),Q=Bun.spawn([...J,"-m","ecuc_engine","--root",q,...H,"--json"],{cwd:q,env:{...process.env,PYTHONPATH:[V,process.env.PYTHONPATH].filter(Boolean).join(process.platform==="win32"?";":":")},stdout:"pipe",stderr:"pipe",stdin:"ignore",signal:_}),[Y,$]=await Promise.all([new Response(Q.stdout).text(),new Response(Q.stderr).text()]);await Q.exited;let D=Y.trim().split(`
3
+ `).filter(Boolean).at(-1);if(!D)return{ok:!1,error:`engine produced no output (exit ${Q.exitCode}): ${$.trim().slice(-2000)}`};try{return JSON.parse(D)}catch(B){return{ok:!1,error:`engine output was not JSON (${String(B)}): ${D.slice(0,500)} ${$.slice(-500)}`}}}var Z=P.schema,J_=["# AUTOSAR pack active","Load the `autosar` skill for ARXML/ECUC work. Configured values: use ecuc_read (accepts a SHORT-NAME) or","autosar_config_lookup, not grep/read. Change config only with ecuc_edit (hand edits of .arxml are blocked),","then ecuc_validate. Cite the value's @path (its AUTOSAR path) and its DEFINITION-REF (@def) separately."].join(`
4
+ `);function p(q){if(typeof q!=="string")return q;let H=q.trim();if(!H.startsWith("[")&&!H.startsWith("{")&&!H.startsWith('"'))return q;try{return p(JSON.parse(H))}catch{return q}}var M=(q)=>Z.preprocess(p,q),U=" In answers cite, for every element, its AUTOSAR path (the value's @path, e.g. /Pkg/Com/ComConfig/MySig), its"+" DEFINITION-REF (@def, e.g. /AUTOSAR/EcucDefs/Com/ComConfig/ComSignal) and file:line \u2014 never give the"+" DEFINITION-REF when the path is asked.";function w(q,H=200){let _=q.slice(0,H).map((J)=>`${J.file}:${J.line} ${J.severity.toUpperCase()} ${J.rule} ${J.path} \u2014 ${J.message}${J.defRef?` [def ${J.defRef}]`:""}`);if(q.length>H)_.push(`\u2026 ${q.length-H} more`);return _}function R(q){let H=q.didYouMean?`
5
+ Did you mean: ${q.didYouMean.join(", ")}`:"",_=q.schema?`
6
+ Schema errors: ${JSON.stringify(q.schema)}`:"";return`ERROR: ${q.error}${H}${_}`}function N(q){let H=q.warnings??[];return H.length?H.map((_)=>`WARNING: ${_}`).join(`
7
7
  `)+`
8
- `:""}function l(t,n){if(!n)return t;return W(n)?n:G(t,n)}async function z(t){let n=Bun.file(T(t,".hydron","autosar.json"));if(!await n.exists())return{};return n.json().catch((e)=>{throw Error(`.hydron/autosar.json is not valid JSON: ${String(e)}`)})}async function Q(t,n){let e=(await z(t))[n];if(typeof e==="string")return{command:e};if(e&&typeof e==="object"&&typeof e.command==="string")return e;return}function k(t,n=150){let e=t.split(`
9
- `);return e.length>n?[`\u2026 (${e.length-n} earlier lines omitted)`,...e.slice(-n)].join(`
10
- `):t}async function Z(t,n,e,o){let i=process.platform==="win32"?["cmd","/d","/s","/c",t]:["sh","-c",t],a=Date.now(),r=Bun.spawn(i,{cwd:n,stdout:"pipe",stderr:"pipe",stdin:"ignore",signal:AbortSignal.any([o,AbortSignal.timeout(e*1000)])}),[d,u]=await Promise.all([new Response(r.stdout).text(),new Response(r.stderr).text()]);return{code:await r.exited,out:d,err:u,secs:Math.round((Date.now()-a)/1000)}}var U=(t)=>{let n=t.notes??[],e=t.errors??[];return[...n.map((o)=>`NOTE: ${typeof o==="string"?o:JSON.stringify(o)}`),...e.map((o)=>`BACKEND ERROR: ${typeof o==="string"?o:JSON.stringify(o)}`)]},ee=async(t)=>{let n=(e)=>p({description:e==="generate"?"Run the project's AUTOSAR code generator (e.g. the vendor's configurator CLI) as configured in .hydron/autosar.json `generate` ({command, cwd?, timeoutSec?} or a string; `{module}` and `{args}` placeholders are substituted). Run it only after ecuc_validate reports no errors. Asks the user for permission. Returns the exit code and the output tail; follow with arxml_diff/autosar_build.":"Run the project's build command as configured in .hydron/autosar.json `build` ({command, cwd?, timeoutSec?} or a string; `{module}`/`{args}` substituted). Asks the user for permission. Returns the exit code and the output tail (compiler/linker errors). Use after autosar_generate.",args:{module:s.string().optional().describe("module name substituted for {module} in the command, if used"),args:s.string().optional().describe("extra arguments substituted for {args} (or appended if no placeholder)"),project_dir:s.string().optional().describe("project root (default: session directory)")},async execute(o,i){let a=l(i.directory,o.project_dir),r=await Q(a,e);if(!r)return`No \`${e}\` command configured. Add it to ${T(a,".hydron","autosar.json")}, e.g.
11
- {"${e}": {"command": "${e==="generate"?"<vendor-cli> -p Config/Ecu.dpa -g {module}":"make -C Build all"}", "cwd": ".", "timeoutSec": 1800}}`;let d=o.args??"",u=r.command.replaceAll("{module}",o.module??""),c=u.includes("{args}")?u.replaceAll("{args}",d):`${u}${d?` ${d}`:""}`,m=l(a,r.cwd);await i.ask({permission:`autosar_${e}`,patterns:[c],always:[r.command],metadata:{command:c,cwd:m}}),i.metadata({title:`${e}: ${c}`});let f=await Z(c,m,r.timeoutSec??3600,i.abort),O=f.code===0?"OK":`FAILED (exit ${f.code})`;return{title:`autosar_${e} ${O}`,output:[`$ ${c} (cwd ${m}, ${f.secs}s) -> ${O}`,f.out.trim()?`--- stdout (tail) ---
12
- ${k(f.out)}`:"",f.err.trim()?`--- stderr (tail) ---
13
- ${k(f.err,80)}`:""].filter(Boolean).join(`
14
- `),metadata:{exit:f.code}}}});return{"experimental.chat.system.transform":async(e,o)=>{o.system.push(K)},tool:{ecuc_read:p({description:'Read AUTOSAR ECUC configuration as compact ECUC JSON (keyed by def names: "<DefName>:<ShortName>" for containers, params by def short-name, references as "->/path"; every container carries @path). No path: lists modules and the param-def/config files found. A module/container path: its values to '+"`depth` levels (merged across split files). A param path (\u2026/Container/Param) or a def path "+"(/AUTOSAR/EcucDefs/\u2026): the value and/or the definition. schema=true adds the def summary (allowed "+"params with type/range/literals/multiplicity, allowed sub-containers) \u2014 read it before ecuc_edit. "+"Never read or edit ARXML by hand."+b,args:{path:s.string().optional().describe("AUTOSAR path of a module/container/parameter, or a def path"),depth:s.number().int().min(0).max(20).optional().describe("sub-container levels to expand (default 2)"),schema:s.boolean().optional().describe("include the param-def summary for the element"),project_dir:s.string().optional().describe("project root (default: session directory)")},async execute(e,o){let i=["read"];if(e.path)i.push(e.path);if(i.push("--depth",String(e.depth??2)),e.schema)i.push("--schema");let a=await y(l(o.directory,e.project_dir),i,o.abort);if(!a.ok)return g(a)+w(a);let{ok:r,engine:d,ms:u,warnings:c,...m}=a;return g(a)+JSON.stringify(m,null,1)}}),ecuc_edit:p({description:"Edit ECUC ARXML in place \u2014 the ONLY way to change configuration (never write ARXML yourself). Ops by "+"AUTOSAR path: {op:'set', path, param, value} (value scalar, '->/ref/path', or a list for multi-valued "+"params), {op:'add', path:<parent>, container:'DefName:ShortName', values:{\u2026ECUC JSON\u2026}}, {op:'add', "+"path, param, value} (append a value), {op:'remove', path[, param[, value]]}. The engine picks the value element type, DEFINITION-REF/DEST and VALUE-REF DEST from the param defs, rejects out-of-range/non-literal values, patches only the changed bytes in the file each element lives in (UUIDs, order, comments preserved), applies all ops or none, and checks ARXML schema validity. Returns the unified diff and the validation of touched files. ALWAYS run ecuc_validate after editing and fix what it reports."+b,args:{ops:s.array(s.object({op:s.enum(["set","add","remove"]),path:s.string().describe("AUTOSAR path of the target (or parent for add-container)"),param:s.string().optional().describe("parameter/reference def short-name"),value:s.any().optional().describe("value: number | boolean | string | '->/path' | list"),container:s.string().optional().describe("for add: 'DefName:ShortName' (or 'Name' if equal)"),values:s.record(s.string(),s.any()).optional().describe("for add: ECUC JSON of the new container")})).min(1),dry_run:s.boolean().optional().describe("compute and show the diff without writing"),force:s.boolean().optional().describe("write values that fail def checks (avoid; explain why if used)"),project_dir:s.string().optional().describe("project root (default: session directory)")},async execute(e,o){let i=["edit","--ops",JSON.stringify(e.ops)];if(e.dry_run)i.push("--dry-run");if(e.force)i.push("--force");let a=await y(l(o.directory,e.project_dir),i,o.abort);if(!a.ok){let c=a.changes??[];return g(a)+w(a)+(c.length?`
8
+ `:""}function K(q,H){if(!H)return q;return q_(H)?H:H_(q,H)}async function Q_(q){let H=Bun.file(h(q,".hydron","autosar.json"));if(!await H.exists())return{};return H.json().catch((_)=>{throw Error(`.hydron/autosar.json is not valid JSON: ${String(_)}`)})}async function V_(q,H){let _=(await Q_(q))[H];if(typeof _==="string")return{command:_};if(_&&typeof _==="object"&&typeof _.command==="string")return _;return}function m(q,H=150){let _=q.split(`
9
+ `);return _.length>H?[`\u2026 (${_.length-H} earlier lines omitted)`,..._.slice(-H)].join(`
10
+ `):q}async function Y_(q,H,_,J){let V=process.platform==="win32"?["cmd","/d","/s","/c",q]:["sh","-c",q],Q=Date.now(),Y=Bun.spawn(V,{cwd:H,stdout:"pipe",stderr:"pipe",stdin:"ignore",signal:AbortSignal.any([J,AbortSignal.timeout(_*1000)])}),[$,D]=await Promise.all([new Response(Y.stdout).text(),new Response(Y.stderr).text()]);return{code:await Y.exited,out:$,err:D,secs:Math.round((Date.now()-Q)/1000)}}var y=(q)=>{let H=q.notes??[],_=q.errors??[];return[...H.map((J)=>`NOTE: ${typeof J==="string"?J:JSON.stringify(J)}`),..._.map((J)=>`BACKEND ERROR: ${typeof J==="string"?J:JSON.stringify(J)}`)]};async function Z_(q,H){try{let _=await H(),J=typeof _==="string"?_:_.output;if(J&&J.trim())return _;return`${q}: no output (this is a bug in the AUTOSAR pack; retry or report it)`}catch(_){return`ERROR (${q}): ${_ instanceof Error?_.message:String(_)}`}}function $_(q){for(let[H,_]of Object.entries(q)){let J=_.execute;_.execute=(V,Q)=>Z_(H,()=>J(V,Q))}return q}var B_=async(q)=>{let H=(_)=>P({description:_==="generate"?"Run the project's AUTOSAR code generator (e.g. the vendor's configurator CLI) as configured in .hydron/autosar.json `generate` ({command, cwd?, timeoutSec?} or a string; `{module}` and `{args}` placeholders are substituted). Run it only after ecuc_validate reports no errors. Asks the user for permission. Returns the exit code and the output tail; follow with arxml_diff/autosar_build.":"Run the project's build command as configured in .hydron/autosar.json `build` ({command, cwd?, timeoutSec?} or a string; `{module}`/`{args}` substituted). Asks the user for permission. Returns the exit code and the output tail (compiler/linker errors). Use after autosar_generate.",args:{module:Z.string().optional().describe("module name substituted for {module} in the command, if used"),args:Z.string().optional().describe("extra arguments substituted for {args} (or appended if no placeholder)"),project_dir:Z.string().optional().describe("project root (default: session directory)")},async execute(J,V){let Q=K(V.directory,J.project_dir),Y=await V_(Q,_);if(!Y)return`No \`${_}\` command configured. Add it to ${h(Q,".hydron","autosar.json")}, e.g.
11
+ {"${_}": {"command": "${_==="generate"?"<vendor-cli> -p Config/Ecu.dpa -g {module}":"make -C Build all"}", "cwd": ".", "timeoutSec": 1800}}`;let $=J.args??"",D=Y.command.replaceAll("{module}",J.module??""),B=D.includes("{args}")?D.replaceAll("{args}",$):`${D}${$?` ${$}`:""}`,W=K(Q,Y.cwd);await V.ask({permission:`autosar_${_}`,patterns:[B],always:[Y.command],metadata:{command:B,cwd:W}}),V.metadata({title:`${_}: ${B}`});let F=await Y_(B,W,Y.timeoutSec??3600,V.abort),j=F.code===0?"OK":`FAILED (exit ${F.code})`;return{title:`autosar_${_} ${j}`,output:[`$ ${B} (cwd ${W}, ${F.secs}s) -> ${j}`,F.out.trim()?`--- stdout (tail) ---
12
+ ${m(F.out)}`:"",F.err.trim()?`--- stderr (tail) ---
13
+ ${m(F.err,80)}`:""].filter(Boolean).join(`
14
+ `),metadata:{exit:F.code}}}});return{"tool.execute.before":async(_,J)=>{let V=z(_.tool,J.args);if(V)throw Error(V)},"experimental.chat.system.transform":async(_,J)=>{J.system.push(J_)},tool:$_({ecuc_read:P({description:`THE way to answer any question about configured ECUC values (use it instead of grep/read on .arxml). Read AUTOSAR ECUC configuration as compact ECUC JSON (keyed by def names: "<DefName>:<ShortName>" for containers, params by def short-name, references as "->/path"; every container carries @path = its AUTOSAR path, and @def = its DEFINITION-REF). No path: lists modules and the param-def/config files found. path may be a full AUTOSAR path, a SHORT-NAME ('DoorLatch') or a partial path ('ComConfig/DoorLatch', 'DoorLatch/ComBitPosition'); ambiguous names return candidate paths. A module/container: its values to `+"`depth` levels (merged across split files). A param path (\u2026/Container/Param) or a def path "+"(/AUTOSAR/EcucDefs/\u2026): the value and/or the definition. schema=true adds the def summary (allowed "+"params with type/range/literals/multiplicity, allowed sub-containers) \u2014 read it before ecuc_edit. "+"Never read or edit ARXML by hand."+U,args:{path:Z.string().optional().describe("AUTOSAR path, SHORT-NAME or partial path of a module/container/parameter, or a def path"),depth:Z.number().int().min(0).max(20).optional().describe("sub-container levels to expand (default 2)"),schema:Z.boolean().optional().describe("include the param-def summary for the element"),project_dir:Z.string().optional().describe("project root (default: session directory)")},async execute(_,J){let V=["read"];if(_.path)V.push(_.path);if(V.push("--depth",String(_.depth??2)),_.schema)V.push("--schema");let Q=await G(K(J.directory,_.project_dir),V,J.abort);if(!Q.ok)return N(Q)+R(Q);let{ok:Y,engine:$,ms:D,warnings:B,...W}=Q;return N(Q)+JSON.stringify(W,null,1)}}),ecuc_edit:P({description:"Edit ECUC ARXML in place \u2014 the ONLY way to change configuration (edit/write/sed on .arxml are blocked). "+"Get exact paths and allowed params from ecuc_read (schema=true) first. Ops by AUTOSAR path: {op:'set', path, param, value} (value scalar, '->/ref/path', or a list for multi-valued "+"params), {op:'add', path:<parent>, container:'DefName:ShortName', values:{\u2026ECUC JSON\u2026}}, {op:'add', "+"path, param, value} (append a value), {op:'remove', path[, param[, value]]}. The engine picks the value element type, DEFINITION-REF/DEST and VALUE-REF DEST from the param defs, rejects out-of-range/non-literal values, patches only the changed bytes in the file each element lives in (UUIDs, order, comments preserved), applies all ops or none, and checks ARXML schema validity. Returns the unified diff and the validation of touched files. ALWAYS run ecuc_validate after editing and fix what it reports."+U,args:{ops:M(Z.array(M(Z.object({op:Z.enum(["set","add","remove"]),path:Z.string().describe("AUTOSAR path of the target (or parent for add-container)"),param:Z.string().optional().describe("parameter/reference def short-name"),value:Z.any().optional().describe("value: number | boolean | string | '->/path' | list"),container:Z.string().optional().describe("for add: 'DefName:ShortName' (or 'Name' if equal)"),values:M(Z.record(Z.string(),Z.any()).optional()).describe("for add: ECUC JSON of the new container")}))).min(1)).describe("list of ops (a JSON array; a JSON-encoded string is also accepted)"),dry_run:Z.boolean().optional().describe("compute and show the diff without writing"),force:Z.boolean().optional().describe("write values that fail def checks (avoid; explain why if used)"),project_dir:Z.string().optional().describe("project root (default: session directory)")},async execute(_,J){let V=["edit","--ops",JSON.stringify(_.ops)];if(_.dry_run)V.push("--dry-run");if(_.force)V.push("--force");let Q=await G(K(J.directory,_.project_dir),V,J.abort);if(!Q.ok){let B=Q.changes??[];return N(Q)+R(Q)+(B.length?`
15
15
  Rejected diff:
16
- ${c.map((m)=>m.diff).join(`
17
- `)}`:"")}let r=a.changes??[],d=a.validation,u=[`${a.written?"Written":"Dry run (nothing written)"}: ${a.applied} op(s), ${r.length} file(s) changed.`,...(a.notes??[]).map((c)=>`NOTE: ${c}`),...r.map((c)=>c.diff)];if(d)u.push(d.validated?`Validation of touched files: ${JSON.stringify(d.summary)}`:"Validation skipped: no param defs loaded.",...C(d.diagnostics,50));return{title:`ecuc_edit ${r.map((c)=>c.file).join(", ")}`,output:g(a)+u.join(`
18
- `)}}}),ecuc_validate:p({description:"Validate ECUC configuration against the param defs (project .hydron/autosar.json paramDefs, auto-discovered ECUC-MODULE-DEF files, or $AUTOSAR_PARAMDEFS): multiplicity, mandatory params/containers, integer/float ranges, enum literals, booleans, string length/regex, reference targets and destination types, dangling refs, duplicate paths, unknown definitions. Returns LSP-like diagnostics "+"file:line severity rule path \u2014 message [def DEFINITION-REF]. Run after EVERY ecuc_edit and before "+"autosar_generate. Without param defs it only warns."+b,args:{files:s.array(s.string()).optional().describe("only report diagnostics in these files"),module:s.string().optional().describe("only validate this module (e.g. Com)"),max:s.number().int().min(1).optional().describe("cap the diagnostics listed (default 200)"),project_dir:s.string().optional().describe("project root (default: session directory)")},async execute(e,o){let i=l(o.directory,e.project_dir),a=["validate"];for(let c of e.files??[])a.push("--file",l(i,c));if(e.module)a.push("--module",e.module);let r=await y(i,a,o.abort);if(!r.ok)return g(r)+w(r);let d=r.diagnostics,u=r.validated?`Validated ${r.modules.join(", ")||"(no modules)"} against ${r.defsLoaded} module def(s): ${d.length?JSON.stringify(r.summary):"no findings"}`:"NOT validated: no param definitions loaded.";return{title:`ecuc_validate ${JSON.stringify(r.summary)}`,output:g(r)+[u,...C(d,e.max??200)].join(`
19
- `)}}}),arxml_diff:p({description:"Semantic ARXML diff by AUTOSAR path between two files or directories (e.g. a git checkout of the old Config/ vs the current one, or Output/ before/after generation inputs). Ignores UUIDs, whitespace, comments, element order and file splits; ECUC values are compared per parameter. Returns added/removed/changed elements with the changed parameter values. Use it to review edits and PRs instead of a textual diff.",args:{a:s.string().describe("old file or directory"),b:s.string().describe("new file or directory"),project_dir:s.string().optional().describe("project root (default: session directory)")},async execute(e,o){let i=l(o.directory,e.project_dir),a=await y(i,["diff",l(i,e.a),l(i,e.b)],o.abort);if(!a.ok)return w(a);let{ok:r,engine:d,ms:u,...c}=a;return JSON.stringify(c,null,1)}}),autosar_trace:p({description:"Trace AUTOSAR configuration to code through the H2Loop code graph of the selected project: query with an AUTOSAR path, a SHORT-NAME, or a C symbol (reverse lookup). Returns the ARXML element (path, module, DEFINITION-REF, file:line, values), the C symbols it configures (generated macros/const tables via the symbolic-name rule or token/value matching, with method + score) and their callers in in-house code. Use for impact analysis ('what code changes if I change X?') and 'where does this symbol come from?'. Requires the project's code to be ingested."+b,args:{query:s.string().describe("AUTOSAR path, SHORT-NAME, or C symbol"),data_source_id:s.number().int().optional().describe("restrict to one code data source"),limit:s.number().int().min(1).max(100).optional().describe("max matches (default server-side)"),workspace_id:s.string().optional().describe("H2Loop project id (default: the selected project)")},async execute(e,o){let i=await A(e.workspace_id),a={query:e.query};if(e.data_source_id!==void 0)a.data_source_id=e.data_source_id;if(e.limit!==void 0)a.limit=e.limit;let r=await $("/api/v1/mcp/autosar/trace",i,a,o.abort),d=r.matches??[],u=[`${d.length} match(es) for "${e.query}" in workspace ${i}`+(r.data_sources_queried!==void 0?` (data sources queried: ${JSON.stringify(r.data_sources_queried)})`:""),...U(r),JSON.stringify(d,null,1)];if(!d.length)u.push("No match: try a SHORT-NAME, a full AUTOSAR path from ecuc_read, or the C symbol.");return{title:`autosar_trace ${e.query}`,output:u.join(`
20
- `)}}}),autosar_config_lookup:p({description:"Look up one ARXML element in the H2Loop code graph by AUTOSAR path: the element (module, DEFINITION-REF, file:line, values), its direct children, references out and in (who points at it), and linked C symbols. Use it to navigate config relations across the ingested repo (including files not in the local checkout); for local edits use ecuc_read/ecuc_edit."+b,args:{path:s.string().describe("AUTOSAR path, e.g. /Pkg/Com/ComConfig/MySignal"),data_source_id:s.number().int().optional().describe("restrict to one code data source"),workspace_id:s.string().optional().describe("H2Loop project id (default: the selected project)")},async execute(e,o){let i=await A(e.workspace_id),a={path:e.path};if(e.data_source_id!==void 0)a.data_source_id=e.data_source_id;let r=await $("/api/v1/mcp/autosar/config-lookup",i,a,o.abort),d=r.matches??[];return{title:`autosar_config_lookup ${e.path}`,output:[`${d.length} match(es) for ${e.path} in workspace ${i}`,...U(r),JSON.stringify(d,null,1)].join(`
21
- `)}}}),autosar_generate:n("generate"),autosar_build:n("build")}}},te=ee;export{te as Autosar};
16
+ ${B.map((W)=>W.diff).join(`
17
+ `)}`:"")}let Y=Q.changes??[],$=Q.validation,D=[`${Q.written?"Written":"Dry run (nothing written)"}: ${Q.applied} op(s), ${Y.length} file(s) changed.`,...(Q.notes??[]).map((B)=>`NOTE: ${B}`),...Y.map((B)=>B.diff)];if($)D.push($.validated?`Validation of touched files: ${JSON.stringify($.summary)}`:"Validation skipped: no param defs loaded.",...w($.diagnostics,50));return{title:`ecuc_edit ${Y.map((B)=>B.file).join(", ")}`,output:N(Q)+D.join(`
18
+ `)}}}),ecuc_validate:P({description:"Validate ECUC configuration against the param defs (project .hydron/autosar.json paramDefs, auto-discovered ECUC-MODULE-DEF files, or $AUTOSAR_PARAMDEFS): multiplicity, mandatory params/containers, integer/float ranges, enum literals, booleans, string length/regex, reference targets and destination types, dangling refs, duplicate paths, unknown definitions, plus semantic rule packs (H2L-*: Com signals inside their I-PDU and non-overlapping incl. Motorola bit order, unique handle IDs) that run even without defs. Returns LSP-like diagnostics "+"file:line severity rule path \u2014 message [def DEFINITION-REF]. Run after EVERY ecuc_edit and before "+"autosar_generate. Without param defs it only warns."+U,args:{files:M(Z.array(Z.string()).optional()).describe("only report diagnostics in these files"),module:Z.string().optional().describe("only validate this module (e.g. Com)"),max:Z.number().int().min(1).optional().describe("cap the diagnostics listed (default 200)"),project_dir:Z.string().optional().describe("project root (default: session directory)")},async execute(_,J){let V=K(J.directory,_.project_dir),Q=["validate"];for(let B of _.files??[])Q.push("--file",K(V,B));if(_.module)Q.push("--module",_.module);let Y=await G(V,Q,J.abort);if(!Y.ok)return N(Y)+R(Y);let $=Y.diagnostics,D=Y.validated?`Validated ${Y.modules.join(", ")||"(no modules)"} against ${Y.defsLoaded} module def(s): ${$.length?JSON.stringify(Y.summary):"no findings"}`:"NOT validated: no param definitions loaded.";return{title:`ecuc_validate ${JSON.stringify(Y.summary)}`,output:N(Y)+[D,...w($,_.max??200)].join(`
19
+ `)}}}),arxml_diff:P({description:"Semantic ARXML diff by AUTOSAR path between two files or directories (e.g. a git checkout of the old Config/ vs the current one, or Output/ before/after generation inputs). Ignores UUIDs, whitespace, comments, element order and file splits; ECUC values are compared per parameter. Returns added/removed/changed elements with the changed parameter values. Use it to review edits and PRs instead of a textual diff.",args:{a:Z.string().describe("old file or directory"),b:Z.string().describe("new file or directory"),project_dir:Z.string().optional().describe("project root (default: session directory)")},async execute(_,J){let V=K(J.directory,_.project_dir),Q=await G(V,["diff",K(V,_.a),K(V,_.b)],J.abort);if(!Q.ok)return R(Q);let{ok:Y,engine:$,ms:D,...B}=Q;return JSON.stringify(B,null,1)}}),autosar_trace:P({description:"Trace AUTOSAR configuration to code through the H2Loop code graph of the selected project: query with an AUTOSAR path, a SHORT-NAME, or a C symbol (reverse lookup). Returns the ARXML element (path, module, DEFINITION-REF, file:line, values), the C symbols it configures (generated macros/const tables via the symbolic-name rule or token/value matching, with method + score) and their callers in in-house code. Use for impact analysis ('what code changes if I change X?') and 'where does this symbol come from?'. Requires the project's code to be ingested."+U,args:{query:Z.string().describe("AUTOSAR path, SHORT-NAME, or C symbol"),data_source_id:Z.number().int().optional().describe("restrict to one code data source"),limit:Z.number().int().min(1).max(100).optional().describe("max matches (default server-side)"),workspace_id:Z.union([Z.string(),Z.number()]).optional().describe("H2Loop project id or name (default: the project selected with `hydron projects select`)")},async execute(_,J){let V=await S(_.workspace_id,J.abort),Q={query:_.query};if(_.data_source_id!==void 0)Q.data_source_id=_.data_source_id;if(_.limit!==void 0)Q.limit=_.limit;let Y=await T("/api/v1/mcp/autosar/trace",V,Q,J.abort),$=Y.matches??[],D=[`${$.length} match(es) for "${_.query}" in workspace ${V}`+(Y.data_sources_queried!==void 0?` (data sources queried: ${JSON.stringify(Y.data_sources_queried)})`:""),...y(Y),JSON.stringify($,null,1)];if(!$.length)D.push("No match: try a SHORT-NAME, a full AUTOSAR path from ecuc_read, or the C symbol.");return{title:`autosar_trace ${_.query}`,output:D.join(`
20
+ `)}}}),autosar_config_lookup:P({description:"Look up one ARXML element in the H2Loop code graph by AUTOSAR path: the element (module, DEFINITION-REF, file:line, values), its direct children, references out and in (who points at it), and linked C symbols. Use it to navigate config relations across the ingested repo (including files not in the local checkout); for local edits use ecuc_read/ecuc_edit."+U,args:{path:Z.string().describe("AUTOSAR path, e.g. /Pkg/Com/ComConfig/MySignal"),data_source_id:Z.number().int().optional().describe("restrict to one code data source"),workspace_id:Z.union([Z.string(),Z.number()]).optional().describe("H2Loop project id or name (default: the project selected with `hydron projects select`)")},async execute(_,J){let V=await S(_.workspace_id,J.abort),Q={path:_.path};if(_.data_source_id!==void 0)Q.data_source_id=_.data_source_id;let Y=await T("/api/v1/mcp/autosar/config-lookup",V,Q,J.abort),$=Y.matches??[];return{title:`autosar_config_lookup ${_.path}`,output:[`${$.length} match(es) for ${_.path} in workspace ${V}`,...y(Y),JSON.stringify($,null,1)].join(`
21
+ `)}}}),autosar_generate:H("generate"),autosar_build:H("build")})}},D_=B_;export{D_ as Autosar};
@@ -1,2 +1,2 @@
1
1
  """ECUC engine: load, read, edit, validate and diff AUTOSAR ECUC ARXML without regenerating files."""
2
- ENGINE_VERSION = "0.1.0"
2
+ ENGINE_VERSION = "0.1.1"
@@ -21,6 +21,11 @@ def _ops(arg: str):
21
21
  if arg == "-":
22
22
  arg = sys.stdin.read()
23
23
  ops = json.loads(arg)
24
+ for _ in range(3): # tolerate JSON that was encoded more than once ("[{...}]" as a string)
25
+ if isinstance(ops, str):
26
+ ops = json.loads(ops)
27
+ if isinstance(ops, dict) and isinstance(ops.get("ops"), (list, str)):
28
+ return _ops(json.dumps(ops["ops"]))
24
29
  return ops if isinstance(ops, list) else [ops]
25
30
 
26
31
 
@@ -64,6 +69,11 @@ def main(argv=None) -> int:
64
69
 
65
70
 
66
71
  def run(a) -> dict:
72
+ if a.cmd == "edit":
73
+ try:
74
+ a.ops = _ops(a.ops)
75
+ except (ValueError, OSError) as ex:
76
+ return {"error": f"ops is not valid JSON: {ex}"}
67
77
  if a.cmd == "diff":
68
78
  from .diff import diff
69
79
  for x in (a.a, a.b):
@@ -85,7 +95,7 @@ def run(a) -> dict:
85
95
  return read(project, a.path, depth=a.depth, schema=a.schema)
86
96
  if a.cmd == "edit":
87
97
  from .edit import edit
88
- res = edit(project, _ops(a.ops), dry_run=a.dry_run, force=a.force, run_validate=not a.no_validate)
98
+ res = edit(project, a.ops, dry_run=a.dry_run, force=a.force, run_validate=not a.no_validate)
89
99
  if project.warnings:
90
100
  res["warnings"] = project.warnings
91
101
  return res
@@ -78,6 +78,13 @@ def read(project: Project, path: Optional[str], depth: int = 2, schema: bool = F
78
78
  res["hint"] = "read a module or container by its AUTOSAR path, e.g. path=" + (
79
79
  res["modules"][0]["path"] if res["modules"] else "/Pkg/Module")
80
80
  return res
81
+ resolved = resolve(project, path)
82
+ if isinstance(resolved, dict):
83
+ res.update(resolved)
84
+ return res
85
+ if resolved != path:
86
+ res["resolved"] = f"{path} -> {resolved}"
87
+ path = resolved
81
88
  group = project.find(path)
82
89
  if group:
83
90
  res["ecuc"] = {key_of(group[0]): render(project, group, depth)}
@@ -116,3 +123,32 @@ def _walk(e: Elem):
116
123
  x = stack.pop()
117
124
  yield x
118
125
  stack.extend(x.children)
126
+
127
+
128
+ def resolve(project: Project, q: str):
129
+ """Exact path, def path, or param path pass through. Otherwise resolve a SHORT-NAME or partial
130
+ path ("ComConfig/DoorLatch", "DoorLatch", "DoorLatch/ComBitPosition") against value paths.
131
+ Returns the path, or {"error", "candidates"} when ambiguous / not found."""
132
+ q = q.strip()
133
+ if project.find(q) or project.defs.get(q):
134
+ return q
135
+ parent, _, last = q.rstrip("/").rpartition("/")
136
+ if parent.startswith("/") and project.find(parent):
137
+ return q
138
+ tail = q.strip("/")
139
+ paths = list(project.by_path)
140
+ hits = [p for p in paths if p == "/" + tail or p.endswith("/" + tail)]
141
+ param = None
142
+ if not hits and "/" in tail: # last segment may be a parameter
143
+ head, _, param = tail.rpartition("/")
144
+ hits = [p for p in paths if p.endswith("/" + head)]
145
+ hits = [p for p in hits if any(pv.name == param for e in project.find(p) for pv in e.params)]
146
+ if not hits:
147
+ low = tail.lower().rsplit("/", 1)[-1]
148
+ sugg = sorted(p for p in paths if low in p.rsplit("/", 1)[-1].lower())[:15]
149
+ return {"error": f"no ECUC element matches '{q}'", "didYouMean": sugg} if sugg else \
150
+ {"error": f"no ECUC element or definition at {q}; call ecuc_read with no path to list modules"}
151
+ if len(hits) > 1:
152
+ return {"error": f"'{q}' is ambiguous ({len(hits)} matches); pass one of these full paths",
153
+ "didYouMean": sorted(hits)[:25]}
154
+ return hits[0] + (f"/{param}" if param else "")
@@ -206,6 +206,7 @@ class Editor:
206
206
  # ---------- ops ----------
207
207
  def apply(self, ops: list, force: bool = False):
208
208
  self.force = force
209
+ check_ops(ops)
209
210
  for i, op in enumerate(ops):
210
211
  kind = op.get("op")
211
212
  try:
@@ -355,6 +356,35 @@ class Editor:
355
356
  os.replace(tmp, f)
356
357
 
357
358
 
359
+ OP_KEYS = {"op", "path", "param", "value", "container", "values"}
360
+
361
+
362
+ def check_ops(ops) -> None:
363
+ """Shape-check ops before touching anything: clear errors instead of tracebacks."""
364
+ if not isinstance(ops, list) or not ops:
365
+ raise EditError(f"ops must be a non-empty list of op objects, got {type(ops).__name__}")
366
+ for i, op in enumerate(ops):
367
+ if not isinstance(op, dict):
368
+ raise EditError(f"op {i} must be an object like {{\"op\": \"set\", \"path\": ..., \"param\": ..., \"value\": ...}}, "
369
+ f"got {type(op).__name__}: {str(op)[:120]}")
370
+ unknown = set(op) - OP_KEYS
371
+ if unknown:
372
+ raise EditError(f"op {i}: unknown key(s) {sorted(unknown)}; allowed: {sorted(OP_KEYS)}")
373
+ if op.get("op") not in ("set", "add", "remove"):
374
+ raise EditError(f"op {i}: 'op' must be set, add or remove (got {op.get('op')!r})")
375
+ if not isinstance(op.get("path"), str) or not op["path"].startswith("/"):
376
+ raise EditError(f"op {i}: 'path' must be an absolute AUTOSAR path string ('/Pkg/Module/...'), got {op.get('path')!r}")
377
+ for k in ("param", "container"):
378
+ if k in op and op[k] is not None and not isinstance(op[k], str):
379
+ raise EditError(f"op {i}: '{k}' must be a string, got {type(op[k]).__name__}")
380
+ if "values" in op and op["values"] is not None and not isinstance(op["values"], dict):
381
+ raise EditError(f"op {i}: 'values' must be an object of ECUC JSON, got {type(op['values']).__name__}")
382
+ if op["op"] == "set" and "value" not in op:
383
+ raise EditError(f"op {i}: set needs 'value'")
384
+ if op["op"] == "add" and not op.get("container") and not op.get("param"):
385
+ raise EditError(f"op {i}: add needs 'container' (\"DefName:ShortName\") or 'param' + 'value'")
386
+
387
+
358
388
  def schema_check(path: str, data: bytes) -> Optional[str]:
359
389
  """Strict schema load with autosar-data when available. Returns an error string or None."""
360
390
  try:
@@ -0,0 +1,82 @@
1
+ """Hand-written semantic rule packs (the "H" checks the param defs cannot express).
2
+
3
+ A rule is a function registered with @rule(...). It receives a RuleContext and yields
4
+ (elem, line, message, def_ref) tuples; the registry adds the rule id, severity and spec citation.
5
+ Rules are keyed by module-relative DEFINITION-REFs ("Com/ComConfig/ComSignal/ComBitPosition"), so
6
+ they work for /AUTOSAR/EcucDefs/... and vendor-relocated defs alike, and run with or without the
7
+ param defs loaded. To add a pack: create a module here with @rule functions and import it below.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable, Iterable, Optional
13
+
14
+ from ..model import Elem, ParamVal, Project
15
+
16
+
17
+ @dataclass
18
+ class Rule:
19
+ id: str
20
+ severity: str
21
+ cite: str
22
+ title: str
23
+ fn: Callable
24
+
25
+
26
+ RULES: list = []
27
+
28
+
29
+ def rule(id: str, severity: str, cite: str, title: str):
30
+ def deco(fn):
31
+ RULES.append(Rule(id, severity, cite, title, fn))
32
+ return fn
33
+ return deco
34
+
35
+
36
+ class RuleContext:
37
+ def __init__(self, project: Project):
38
+ self.p = project
39
+ self.defs = project.defs
40
+ self._by_rel: Optional[dict] = None
41
+
42
+ def rel(self, def_ref: Optional[str]) -> str:
43
+ return self.defs.rel(def_ref) if def_ref else ""
44
+
45
+ def containers(self, rel_def: str) -> list:
46
+ """All container values (first fragment per path) whose def is ``rel_def`` (module-relative)."""
47
+ if self._by_rel is None:
48
+ self._by_rel = {}
49
+ for path, group in self.p.by_path.items():
50
+ self._by_rel.setdefault(self.rel(group[0].def_ref), []).append(group[0])
51
+ return self._by_rel.get(rel_def, [])
52
+
53
+ def values(self, e: Elem, name: str) -> list:
54
+ return [pv for g in self.p.find(e.path) for pv in g.params if pv.name == name]
55
+
56
+ def value(self, e: Elem, name: str) -> Optional[ParamVal]:
57
+ v = self.values(e, name)
58
+ return v[0] if v else None
59
+
60
+ def text(self, e: Elem, name: str) -> Optional[str]:
61
+ v = self.value(e, name)
62
+ return v.value if v is not None else None
63
+
64
+ def target(self, pv: Optional[ParamVal]) -> Optional[Elem]:
65
+ if pv is None or not pv.value:
66
+ return None
67
+ hit = self.p.find(pv.value.strip())
68
+ return hit[0] if hit else None
69
+
70
+ def module_of(self, e: Elem) -> Elem:
71
+ while e.parent is not None:
72
+ e = e.parent
73
+ return e
74
+
75
+
76
+ def run_rules(project: Project) -> Iterable[dict]:
77
+ from . import com, handles # noqa: F401 (registers the packs)
78
+ ctx = RuleContext(project)
79
+ for r in RULES:
80
+ for e, line, msg, def_ref in r.fn(ctx):
81
+ yield {"file": project.rel(e.file), "line": line, "path": e.path, "rule": r.id,
82
+ "severity": r.severity, "message": f"{msg} [{r.cite}]", "defRef": def_ref}
@@ -0,0 +1,107 @@
1
+ """COM I-PDU packing rules: signals inside the I-PDU, no overlapping signals."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ TYPE_BITS = {"BOOLEAN": 1, "UINT8": 8, "SINT8": 8, "UINT16": 16, "SINT16": 16, "UINT32": 32, "SINT32": 32,
10
+ "UINT64": 64, "SINT64": 64, "FLOAT32": 32, "FLOAT64": 64}
11
+ CITE_BOUNDS = "ECUC_Com_00259 ComBitPosition (position within the I-PDU), ECUC_Com_00158 ComBitSize, ECUC_EcuC_00003 PduLength"
12
+ CITE_OVERLAP = "SWS_Com_CONSTR_00102 Signals Separated in I-PDU"
13
+
14
+
15
+ def _size(ctx, s) -> Optional[int]:
16
+ bs = parse_int(ctx.text(s, "ComBitSize") or "")
17
+ if bs is not None:
18
+ return bs
19
+ typ = (ctx.text(s, "ComSignalType") or "").strip()
20
+ if typ in ("UINT8_N", "UINT8_DYN"):
21
+ n = parse_int(ctx.text(s, "ComSignalLength") or "")
22
+ return n * 8 if n is not None else None
23
+ return TYPE_BITS.get(typ)
24
+
25
+
26
+ def bits(pos: int, size: int, endian: str) -> list:
27
+ """Occupied bit indices (byte*8 + bit). ComBitPosition is the LSB. BIG_ENDIAN (Motorola) walks
28
+ toward the MSB by going up within a byte, then to bit 0 of the previous byte."""
29
+ if endian == "BIG_ENDIAN":
30
+ out, b = [], pos
31
+ for _ in range(size):
32
+ out.append(b)
33
+ b = b + 1 if b % 8 != 7 else b - 15
34
+ return out
35
+ return list(range(pos, pos + size)) # LITTLE_ENDIAN and OPAQUE (bit0 of first byte, little-endian order)
36
+
37
+
38
+ def _ipdus(ctx):
39
+ """(ipdu, length_bytes|None, [(member, kind)]) for every ComIPdu."""
40
+ for ipdu in ctx.containers("Com/ComConfig/ComIPdu"):
41
+ pdu = ctx.target(ctx.value(ipdu, "ComPduIdRef"))
42
+ length = parse_int(ctx.text(pdu, "PduLength") or "") if pdu is not None else None
43
+ members = []
44
+ for ref in ctx.values(ipdu, "ComIPduSignalRef"):
45
+ s = ctx.target(ref)
46
+ if s is not None:
47
+ members.append(s)
48
+ for ref in ctx.values(ipdu, "ComIPduSignalGroupRef"):
49
+ grp = ctx.target(ref)
50
+ if grp is None:
51
+ continue
52
+ members.extend(c for g in ctx.p.find(grp.path) for c in g.children
53
+ if ctx.rel(c.def_ref).endswith("/ComGroupSignal"))
54
+ members.append(grp) # contributes only its update bit
55
+ yield ipdu, length, pdu, members
56
+
57
+
58
+ def _layout(ctx, s):
59
+ """[(label, bitset, line)] a signal/group occupies: its value bits and its update bit."""
60
+ out = []
61
+ pos = parse_int(ctx.text(s, "ComBitPosition") or "")
62
+ size = _size(ctx, s)
63
+ if pos is not None and size:
64
+ endian = (ctx.text(s, "ComSignalEndianness") or "LITTLE_ENDIAN").strip()
65
+ out.append((s.name, bits(pos, size, endian), ctx.value(s, "ComBitPosition").node.line, pos, size, endian))
66
+ ub = parse_int(ctx.text(s, "ComUpdateBitPosition") or "")
67
+ if ub is not None:
68
+ out.append((f"{s.name}.updateBit", [ub], ctx.value(s, "ComUpdateBitPosition").node.line, ub, 1, "LITTLE_ENDIAN"))
69
+ return out
70
+
71
+
72
+ @rule("H2L-COM-001", "error", CITE_BOUNDS, "signal bits lie inside its I-PDU (PduLength * 8)")
73
+ def signal_in_pdu(ctx):
74
+ for ipdu, length, pdu, members in _ipdus(ctx):
75
+ if length is None:
76
+ continue
77
+ limit = length * 8
78
+ for s in members:
79
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
80
+ lo, hi = min(bs), max(bs)
81
+ if lo < 0 or hi >= limit:
82
+ yield s, line, (f"{label}: ComBitPosition={pos} ComBitSize={size} ({endian}) occupies bits {lo}..{hi}, "
83
+ f"outside I-PDU {ipdu.name} ({pdu.path} PduLength={length} bytes = bits 0..{limit - 1})"), \
84
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
85
+
86
+
87
+ @rule("H2L-COM-002", "error", CITE_OVERLAP, "signals of one I-PDU do not overlap")
88
+ def no_overlap(ctx):
89
+ for ipdu, length, pdu, members in _ipdus(ctx):
90
+ owner: dict = {}
91
+ reported = set()
92
+ for s in members:
93
+ for label, bs, line, pos, size, endian in _layout(ctx, s):
94
+ for b in bs:
95
+ prev = owner.get(b)
96
+ if prev is None:
97
+ owner[b] = (label, s)
98
+ continue
99
+ if prev[0] == label:
100
+ continue
101
+ key = tuple(sorted((prev[0], label)))
102
+ if key in reported:
103
+ continue
104
+ reported.add(key)
105
+ yield s, line, (f"{label} overlaps {prev[0]} at bit {b} in I-PDU {ipdu.name} "
106
+ f"({label}: pos {pos} size {size} {endian})"), \
107
+ (ctx.value(s, "ComBitPosition") or ctx.value(s, "ComUpdateBitPosition")).def_ref
@@ -0,0 +1,42 @@
1
+ """Handle-ID uniqueness (TPS_ECUConfiguration 3.4.1.1 "Handle ID concept")."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from . import rule
7
+ from ..values import parse_int
8
+
9
+ # Handle-ID params by name when no defs say so; with defs, any SYMBOLIC-NAME-VALUE integer counts.
10
+ NAME = re.compile(r"(HandleId|^CanIfTxPduId|^CanIfRxPduId)$")
11
+
12
+
13
+ @rule("H2L-HID-001", "error",
14
+ "TPS_ECUConfiguration 3.4.1.1 Handle ID concept; ECUC_Com_00165/00175, ECUC_PduR_00322, ECUC_CanIf_00591/00597",
15
+ "handle IDs are unique per module and handle-ID parameter")
16
+ def unique_handle_ids(ctx):
17
+ seen: dict = {} # (module path, rel def) -> {value: [(elem, pv)]}
18
+ for path, group in ctx.p.by_path.items():
19
+ e = group[0]
20
+ if e.kind != "container":
21
+ continue
22
+ mod = ctx.module_of(e)
23
+ for g in group:
24
+ for pv in g.params:
25
+ if pv.is_ref or pv.value is None:
26
+ continue
27
+ d = ctx.defs.get(pv.def_ref)
28
+ is_handle = (d.kind == "INTEGER" and d.symbolic_name) if d is not None else False
29
+ if not is_handle and not NAME.search(pv.name):
30
+ continue
31
+ v = parse_int(pv.value)
32
+ if v is None:
33
+ continue
34
+ key = (mod.path, ctx.rel(pv.def_ref))
35
+ seen.setdefault(key, {}).setdefault(v, []).append((g, pv))
36
+ for (mod, rel), by_val in seen.items():
37
+ for v, hits in by_val.items():
38
+ if len(hits) < 2:
39
+ continue
40
+ names = ", ".join(sorted({h[0].name for h in hits}))
41
+ for g, pv in hits:
42
+ yield g, pv.node.line, f"{pv.name}={v} is used by {len(hits)} containers in {mod} ({names}); handle IDs must be unique", pv.def_ref
@@ -17,6 +17,7 @@ Rules:
17
17
  ECUC-REF-UNRESOLVED foreign reference target not in any loaded file (warning)
18
18
  ECUC-DUP-NAME two containers with the same AUTOSAR path
19
19
  ECUC-OBSOLETE value for an obsolete def (warning)
20
+ Hand-written semantic rules (ecuc_engine/rules, ids H2L-*) run afterwards, with or without defs.
20
21
  """
21
22
  from __future__ import annotations
22
23
 
@@ -205,6 +206,9 @@ def validate(project: Project, files: Optional[list] = None, module: Optional[st
205
206
  continue
206
207
  check_elem_group(m.path)
207
208
 
209
+ from .rules import run_rules
210
+ dg.items.extend(run_rules(project))
211
+
208
212
  items = dg.items
209
213
  if files:
210
214
  want = {project.rel(f) for f in files} | set(files)
@@ -215,6 +219,7 @@ def validate(project: Project, files: Optional[list] = None, module: Optional[st
215
219
  summary[i["severity"]] = summary.get(i["severity"], 0) + 1
216
220
  return {
217
221
  "validated": bool(defs),
222
+ "rules": "semantic rule packs ran (H2L-*)",
218
223
  "defsLoaded": len(defs.modules),
219
224
  "modules": sorted({m.name for m in project.modules}),
220
225
  "summary": summary,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ecuc-engine"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Load, read, edit in place, validate and diff AUTOSAR ECUC ARXML (Hydron AUTOSAR pack)"
5
5
  requires-python = ">=3.10"
6
6
  # The core is stdlib-only; autosar-data adds a strict schema check of every edited file.
@@ -15,7 +15,10 @@ You are working on an AUTOSAR Classic ECU project. The configuration is ECUC ARX
15
15
  in `Output/` is derived from it; in-house code in `Source/` calls the generated symbols.
16
16
 
17
17
  ## Hard rules
18
- 1. **Never hand-write or hand-edit ARXML** (no `write`/`edit`/`sed` on `.arxml`). Every change goes
18
+ 0. **Configured values are answered with `ecuc_read`** (it accepts a SHORT-NAME or partial path) or
19
+ `autosar_config_lookup` — not grep/read on ARXML. A value's AUTOSAR path is its `@path`; `@def` is its
20
+ DEFINITION-REF. Never give one when the other is asked.
21
+ 1. **Never hand-write or hand-edit ARXML** (no `write`/`edit`/`sed` on `.arxml`; the pack blocks them). Every change goes
19
22
  through `ecuc_edit`, which picks the value element, DEFINITION-REF/DEST and VALUE-REF DEST from
20
23
  the param defs and patches only the changed bytes. Reading raw ARXML for context is fine; prefer `ecuc_read`.
21
24
  2. **Validate after every edit**: `ecuc_validate` (scoped with `files`/`module` if large). Fix
@@ -59,5 +62,9 @@ ECUC-MULT-CONTAINER/-PARAM, ECUC-CHOICE, ECUC-RANGE, ECUC-ENUM, ECUC-TYPE, ECUC-
59
62
  ECUC-REF-DANGLING, ECUC-REF-DEST, ECUC-REF-UNRESOLVED (foreign target not loaded: add System/SWC
60
63
  ARXML to `input`), ECUC-UNKNOWN-DEF (often a release mismatch between config and param defs),
61
64
  ECUC-DUP-NAME, ECUC-NO-DEF (module defs not loaded → module not validated), ECUC-VALUE-TYPE,
62
- ECUC-DEST-MISMATCH, ECUC-OBSOLETE. These are mechanical checks from the defs; they do not cover
63
- SWS cross-module rules — reason about those explicitly.
65
+ ECUC-DEST-MISMATCH, ECUC-OBSOLETE. These are mechanical checks from the defs.
66
+ Semantic rule packs (run even without defs; each message carries its spec citation):
67
+ H2L-COM-001 signal/update bit outside its I-PDU (ComBitPosition/ComBitSize vs EcuC PduLength*8),
68
+ H2L-COM-002 overlapping signals in one I-PDU (Motorola numbering for BIG_ENDIAN; ComBitPosition = LSB),
69
+ H2L-HID-001 duplicate handle IDs per module. Other SWS cross-module rules are not checked yet — reason
70
+ about them explicitly.