@jimhoyd/urlcode 0.5.0 → 0.5.5

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.
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ConfigError, assert } from './errors.js';
7
+
8
+ /** Shared GitHub release/cache/lockfile plumbing for extension-artifacts.ts and extension-bundles.ts. No opinion on what content is allowed or executable; that trust boundary stays local to each caller (#441). */
9
+
10
+
11
+ export const isRecord=(v ) =>v !== null && typeof v==='object' && !Array.isArray(v);
12
+ export const digestHex=(bytes ) =>createHash('sha256').update(bytes).digest('hex');
13
+ export function textField(value , what ) { assert(typeof value==='string' && value.length>0 && value.length<256,`Invalid ${what}`); return value; }
14
+ export function exactKeys(value , expected , what ) { const actual=Object.keys(value).sort(); assert(JSON.stringify(actual)===JSON.stringify([...expected].sort()),`${what} has unknown or missing fields`); }
15
+ const capitalize=(value ) =>value.charAt(0).toUpperCase()+value.slice(1);
16
+
17
+ /** Sorted, recursive listing of an extension cache directory; refuses links and special files. */
18
+ export async function listCachedFiles(root , itemLabel , prefix='') { const found =[]; for(const item of await readdir(join(root,prefix),{withFileTypes:true})) { const path=prefix?`${prefix}/${item.name}`:item.name; assert(item.isDirectory()||item.isFile(),`${capitalize(itemLabel)} cache contains a link or special file`); if(item.isDirectory()) found.push(...await listCachedFiles(root,itemLabel,path)); else found.push(path); } return found.sort(); }
19
+
20
+ /** Atomic write-then-rename for a JSON lockfile, refusing to clobber a concurrent writer. */
21
+ export async function writeLockAtomic(path , temporary , data ) { await writeFile(temporary,JSON.stringify(data,null,2)+'\n',{flag:'wx'}); try { await rename(temporary,path); } finally { await rm(temporary,{force:true}); } }
22
+
23
+
24
+
25
+
26
+
27
+ /** A transport that accepts only GitHub Release asset URLs and verifies every downloaded subject. */
28
+ export function createGithubTransport(config ) {
29
+ const {repository,workflow,tagPattern,exampleTag,maxAssetSize,itemLabel}=config, releaseLabel=`${itemLabel} release`;
30
+ const releaseUrl=(tagName ) =>`https://api.github.com/repos/${repository}/releases/tags/${encodeURIComponent(tagName)}`;
31
+ function downloadUrl(value ) { const url=new URL(value); assert(url.protocol==='https:'&&(url.hostname==='github.com'||url.hostname.endsWith('.githubusercontent.com')),`${capitalize(releaseLabel)} redirect left GitHub`); return url; }
32
+ async function download(value ) { let url=downloadUrl(value); for(let redirects=0;redirects<=3;redirects++) { const response=await fetch(url,{redirect:'manual'}); if(response.status>=300&&response.status<400) { const location=response.headers.get('location'); assert(location&&redirects<3,`${capitalize(releaseLabel)} asset has an invalid redirect`); url=downloadUrl(new URL(location,url).href); continue; } assert(response.ok&&response.body,`Could not download ${releaseLabel} asset`); const length=response.headers.get('content-length'); assert(length===null||(/^\d+$/.test(length)&&Number(length)<=maxAssetSize),`${capitalize(releaseLabel)} asset exceeds the size limit`); const chunks =[]; let size=0; for await(const chunk of response.body) { size+=chunk.byteLength; assert(size<=maxAssetSize,`${capitalize(releaseLabel)} asset exceeds the size limit`); chunks.push(chunk); } const body=new Uint8Array(size); let offset=0; for(const chunk of chunks) { body.set(chunk,offset); offset+=chunk.byteLength; } return body; } throw new ConfigError(`${capitalize(releaseLabel)} asset redirected too many times`); }
33
+ return {
34
+ async release(tagName) { assert(tagPattern.test(tagName),`Use an immutable ${releaseLabel} tag such as ${exampleTag}`); const response=await fetch(releaseUrl(tagName),{headers:{accept:'application/vnd.github+json'}}); assert(response.ok,`Could not fetch ${releaseLabel} ${tagName}`); const raw =await response.json(); assert(isRecord(raw)&&Array.isArray(raw.assets),`${capitalize(releaseLabel)} has no asset inventory`); const seen=new Set (); return raw.assets.map(item=>{ assert(isRecord(item)&&typeof item.name==='string'&&typeof item.browser_download_url==='string'&&!seen.has(item.name),`Invalid or duplicate ${releaseLabel} asset`); seen.add(item.name); const url=new URL(item.browser_download_url); assert(url.protocol==='https:'&&url.hostname==='github.com'&&url.pathname.startsWith(`/${repository}/releases/download/`),`${capitalize(releaseLabel)} asset is not a GitHub download`); return {name:item.name,url:url.href}; }); },
35
+ download,
36
+ async attest(path,release) { assert(tagPattern.test(release),`Invalid ${itemLabel} release tag`); await new Promise ((resolveVerify,reject)=>{ const child=spawn('gh',['attestation','verify',path,'--repo',repository,'--signer-workflow',workflow,'--source-ref',`refs/tags/${release}`,'--deny-self-hosted-runners'],{stdio:'ignore'}); child.on('error',()=>reject(new ConfigError(`GitHub CLI with attestation support is required to verify ${itemLabel}s`))); child.on('exit',code=>code===0?resolveVerify():reject(new ConfigError(`GitHub attestation verification refused the ${itemLabel}`))); }); },
37
+ };
38
+ }
39
+
40
+ /** Downloads one named release asset and has the transport attest it before returning its bytes. */
41
+ export async function verifiedReleaseAsset(assets , asset , release , transport , itemLabel , tempPrefix ) { const found=assets.filter(item=>item.name===asset); assert(found.length===1,`${capitalize(itemLabel)} release is missing or repeats ${asset}`); const bytes=await transport.download(found[0] .url); const temporary=join(tmpdir(),`${tempPrefix}-${process.pid}-${Math.random().toString(16).slice(2)}`); await writeFile(temporary,bytes,{flag:'wx'}); try { await transport.attest(temporary,release); return bytes; } finally { await rm(temporary,{force:true}); } }
@@ -0,0 +1,99 @@
1
+ import {buildContext,estimateTokens} from './context.js';
2
+ import {getCapabilities,normalizeCapabilityTarget} from './capabilities.js';
3
+
4
+ import {listRecipes} from './recipes.js';
5
+ import {describeArtifactCache} from './extension-artifacts.js';
6
+
7
+
8
+ /** The planner is deliberately a small, local projection. It never treats goal
9
+ * text as instructions, opens a host, or reads extension/project source. */
10
+ export const featurePlanMaxBytes=32768;
11
+ export const featurePlanMaxGoalLength=512;
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+ const stop=new Set(['a','an','and','the','with','for','to','of','in','on','that','my','i','want','need','from']);
21
+ function terms(goal ) {
22
+ const words=goal.toLowerCase().split(/[^a-z0-9.-]+/).filter(word=>word.length>1&&!stop.has(word));
23
+ return [...new Set(words)].slice(0,16);
24
+ }
25
+ const recipeTerms ={
26
+ 'contact-form':['contact','form','message','submit','submission','email'],
27
+ 'authenticated-json-api':['auth','authenticated','account','sign','signed','private','protected'],
28
+ 'store-crud':['store','persist','persisted','persistence','durable','database','crud','record','records','submission','submissions'],
29
+ };
30
+ const extensionReason ={
31
+ ui:'Presentation is an operator-installed extension; its registered authoring surfaces govern project-owned UI customization.',
32
+ auth:'Authentication is an operator-installed extension; its registration and revision pin, not project YAML, select the executable package and grants.',
33
+ store:'Durable state is an operator-installed extension; its data directory and revision pin remain operator-owned.',
34
+ forms:'Form workflow is available only through the already-registered forms extension contract; the project cannot select or install it.',
35
+ };
36
+ const outline ={
37
+ 'contact-form':{kind:'contact endpoint',note:'The bundled recipe establishes one POST JSON endpoint with bounded validation and a revision-pinned signal; it does not provide a browser form flow.'},
38
+ 'authenticated-json-api':{kind:'protected endpoint',note:'The bundled recipe protects a function route through the auth extension policy; the project declares the requirement but never loads the package.'},
39
+ 'store-crud':{kind:'durable collection',note:'The bundled recipe declares a collection and an extension mount; CRUD behavior belongs to the registered store extension, not a generated handler.'},
40
+ };
41
+ function selectedRecipes(goalTerms , recipes ) {
42
+ const mapped=recipes.filter(recipe=>{
43
+ const known=recipeTerms[recipe.name]??[];
44
+ return known.some(term=>goalTerms.includes(term));
45
+ });
46
+ return (mapped.length?mapped:recipes.filter(recipe=>recipe.tags.some(tag=>goalTerms.includes(tag.toLowerCase())))).slice(0,4);
47
+ }
48
+
49
+ /**
50
+ * Plans only from the current compiled project, package-owned catalogs, locked
51
+ * inert artifacts, and registrations passed by the already-opened operator
52
+ * session. It intentionally has no filesystem path, host-file, binding, or
53
+ * execution argument.
54
+ */
55
+ export async function planFeature(project ,goal ,options ={}) {
56
+ if(typeof goal!=='string'||goal.length<1||goal.length>featurePlanMaxGoalLength)throw new Error(`Feature goal must be 1 to ${featurePlanMaxGoalLength} characters`);
57
+ const goalTerms=terms(goal),target=normalizeCapabilityTarget(options.target??'self-hosted');
58
+ const context=await buildContext(project,{target,projectFlag:'.'}), recipes=selectedRecipes(goalTerms,await listRecipes());
59
+ const capabilities=[...new Set(recipes.flatMap(recipe=>recipe.capabilities??[]).filter((name) =>typeof name==='string'))].sort();
60
+ const catalog=getCapabilities(target), rows=new Map(catalog.capabilities.map(row=>[row.capability,row]));
61
+ const registrations=new Map((options.extensions??[]).map(extension=>[extension.name,extension]));
62
+ const wanted=new Set ();
63
+ for(const recipe of recipes) for(const service of recipe.services??[]) {
64
+ const match=/\b(ui|auth|store) extension\b/i.exec(service.name); if(match)wanted.add(match[1] .toLowerCase());
65
+ }
66
+ // These nouns request composition, not a project-controlled package choice.
67
+ if(goalTerms.some(term=>['form','contact','screen','page','ui'].includes(term)))wanted.add('ui');
68
+ // A forms capability is discoverable only from an operator registration. Do
69
+ // not turn a goal noun into an implied package choice or YAML declaration.
70
+ if(goalTerms.some(term=>['form','flow','workflow','multistep','multi-step','wizard'].includes(term))&&registrations.has('forms'))wanted.add('forms');
71
+ if(goalTerms.some(term=>['auth','authenticated','account','sign','signed','private','protected'].includes(term)))wanted.add('auth');
72
+ if(goalTerms.some(term=>['store','persist','persisted','persistence','durable','database','crud','record','records','submission','submissions'].includes(term)))wanted.add('store');
73
+ let artifacts =[];
74
+ try {artifacts=(await describeArtifactCache(project)).artifacts;} catch {/* no lock is an ordinary absence, never a reason to read elsewhere */}
75
+ const declared=new Set(context.project.extensions);
76
+ const required=[...wanted].sort().map(name=>{
77
+ const registration=registrations.get(name), artifact=artifacts.find(item=>item.name===name);
78
+ const supported=target!=='static'&&registration?.targets.includes(target==='self-hosted'?'node':target);
79
+ return {name,reason:extensionReason[name]??'This feature needs an operator-registered extension contract.',declared:declared.has(name),registered:Boolean(registration),target:registration?(supported?'supported':'refused'):'unknown',artifact:artifact?.status??'none'} ;
80
+ });
81
+ const unsupported =[];
82
+ if(goalTerms.some(term=>['flow','workflow','multistep','multi-step','wizard'].includes(term))&&!registrations.has('forms'))unsupported.push({requirement:'Declarative form flow',reason:'No already-registered forms extension contract is available, and no bundled core capability or recipe declares multi-step form state, transitions, or submission orchestration. Keep that application behavior focused, or define it as an extension boundary.'});
83
+ if(goalTerms.some(term=>['idempotent','idempotency'].includes(term)))unsupported.push({requirement:'Idempotent mutation',reason:'The core capability catalog has no idempotent mutation primitive. Require an installed extension contract that exposes it, or keep the idempotency key and mutation logic in application code.'});
84
+ for(const capability of capabilities){const row=rows.get(capability);if(row?.targets[target]?.support==='refused')unsupported.push({requirement:capability,reason:row.targets[target] .reason});}
85
+ for(const extension of required)if(extension.target==='refused')unsupported.push({requirement:`${extension.name} extension on ${target}`,reason:'The already-registered extension does not declare support for this target.'});
86
+ const applicationCode =[];
87
+ if(recipes.some(recipe=>recipe.name==='contact-form'))applicationCode.push({requirement:'Product-specific form presentation and submission rules',reason:'The contact recipe covers a bounded JSON endpoint and optional signal only; it does not generate browser UI or business workflow code.'});
88
+ if(!recipes.length)applicationCode.push({requirement:'Feature-specific behavior',reason:'No bundled declarative recipe matched the bounded goal terms. Check capability and extension contracts before writing focused application code.'});
89
+ const plan ={
90
+ format:1,goalTerms,target,project:{routes:context.project.routes,extensions:context.project.extensions},
91
+ applicable:{capabilities:capabilities.map(name=>{const decision=rows.get(name)?.targets[target];return {name,support:decision?.support??'unknown',reason:decision?.reason??'Not in this revision\'s capability catalog'};}),recipes:recipes.map(recipe=>({name:recipe.name,description:recipe.description,matched:(recipeTerms[recipe.name]??[]).filter(term=>goalTerms.includes(term)).slice(0,4)}))},
92
+ extensions:{required,ordering:{status:'operator-resolved',names:[...wanted].sort(),note:'Extension package selection, prerequisites, and canonical activation order are resolved by the operator-approved init/host composition. This read-only plan neither loads a bundle nor turns project YAML into an operator decision.'}},
93
+ outline:recipes.map(recipe=>outline[recipe.name]??{kind:recipe.name,note:'Use the bundled recipe metadata as the known contract.'}),applicationCode,unsupported,
94
+ next:['get_context','search_recipes','get_capability','get_extensions','get_extension_artifacts'].filter((name,index,all)=>all.indexOf(name)===index),
95
+ };
96
+ const estimatedTokens=estimateTokens(JSON.stringify(plan)); const result={...plan,estimatedTokens};
97
+ if(Buffer.byteLength(JSON.stringify(result))>featurePlanMaxBytes)throw new Error('Feature plan exceeds output limit');
98
+ return result;
99
+ }
package/dist/index.js CHANGED
@@ -21,8 +21,8 @@ export {buildTypeScriptProject} from './typescript-authoring.js';
21
21
 
22
22
  export {importBulkProject} from './bulk.js';
23
23
 
24
- export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks} from './tooling.js';
25
-
24
+ export {inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks, planFeature, featurePlanMaxBytes, featurePlanMaxGoalLength} from './tooling.js';
25
+
26
26
  export {buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION} from './manifest.js';
27
27
 
28
28
  export {serveMcp} from './mcp.js';
package/dist/mcp.js CHANGED
@@ -2,7 +2,7 @@ import {realpath} from 'node:fs/promises';
2
2
 
3
3
  import {once} from 'node:events';
4
4
  import {Ajv} from 'ajv';
5
- import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext} from './tooling.js';
5
+ import {inspectProject,validateProject,explainRoute,getCapabilities,getCapability,getSchemaFragment,previewImport,previewExport,listRecipes,showRecipe,searchRecipes,searchExamples,describeExtensions,buildContext,buildTaskContext,planFeature,reviewProject} from './tooling.js';
6
6
  import {loadOperatorHost} from './operator-host.js';
7
7
  import {buildManifest} from './manifest.js';
8
8
 
@@ -36,6 +36,8 @@ const definitions=[
36
36
  {name:'get_extension_artifacts',description:'Validate and list the project\'s locked declarative extension artifacts and their allowlisted files. Artifacts are inert data and do not activate extension code.',properties:{}},
37
37
  {name:'get_extension_artifact',description:'Read one bounded JSON or Markdown file from a verified cached declarative extension artifact. The artifact name and member path must exist in the project lock/cache.',properties:{name:{type:'string',maxLength:64},path:{type:'string',maxLength:128}},required:['name','path']},
38
38
  {name:'get_context',description:'Emit the compact project context an authoring agent needs: versions, project summary, constraints, target support and exact commands, derived from the compiled project. Pass `task: "redirects"` for a bounded, redirect-focused call instead (supported/gap shapes, exact YAML, this project\'s redirects). Optional token budget drops sections in a fixed order.',properties:{target:text,task:{enum:['redirects']},budget:{type:'integer',minimum:1}}},
39
+ {name:'plan_feature',description:'Plan a bounded feature from the compiled project, current capability catalog, local recipes, locked inert artifacts and already-loaded operator registrations. Returns contracts and next calls, never generated application code, binding values, remote content or mutations.',properties:{goal:{type:'string',minLength:1,maxLength:512},target:{enum:['self-hosted','cloudflare','aws','vercel','static']}},required:['goal']},
40
+ {name:'review_project',description:'Opt-in, read-only static review of the project\'s own function/middleware source for avoidable plumbing: native-alternative/extension-alternative/gap/manual-review. Already-loaded operator registrations (--host-file) sharpen extension-alternative findings with registered/revision-pinned state; without a host file that state stays conservative ("declared, setup unconfirmed"). No execution, no secrets, no network.',properties:{target:text}},
39
41
  ];
40
42
  // Only the operator's own --host-file exposes registered extension contracts; no tool argument can name one.
41
43
  const hostDefinition={name:'get_extensions',description:'List operator-registered extension contracts, schemas, hooks, and supported project-owned customization surfaces with fast checks; use these before generating replacement framework code. Activates nothing.',properties:{}};
@@ -85,6 +87,8 @@ export async function serveMcp(options ) {
85
87
  case 'get_context':return typeof args.task==='string'
86
88
  ?buildTaskContext(project,args.task,{...(typeof args.budget==='number'?{budget:args.budget}:{})})
87
89
  :buildContext(project,{projectFlag:'.',...(typeof args.target==='string'?{target:args.target}:{}),...(typeof args.budget==='number'?{budget:args.budget}:{})});
90
+ case 'plan_feature':return planFeature(project,args.goal ,{...(typeof args.target==='string'?{target:args.target}:{}),extensions:host.extensions});
91
+ case 'review_project':return reviewProject(project,{...base,...(typeof args.target==='string'?{target:args.target}:{}),extensions:host.extensions});
88
92
  default:if(authoring)return callAuthoringTool(project,name,args,options.origin);throw new Error('Unknown tool');
89
93
  }
90
94
  };
@@ -98,7 +102,7 @@ export async function serveMcp(options ) {
98
102
  if(message.method==='initialize') {
99
103
  if(initialized){await error(id,-32600,'Already initialized');return;}
100
104
  if(typeof params.protocolVersion!=='string'||!object(params.capabilities)||!object(params.clientInfo)||typeof params.clientInfo.name!=='string'||typeof params.clientInfo.version!=='string'){await error(id,-32602,'Invalid initialize params');return;}
101
- initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.0'}}});return;
105
+ initialized=true;await send({jsonrpc:'2.0',id,result:{protocolVersion,capabilities:{tools:{}},serverInfo:{name:'urlcode',version:'0.5.5'}}});return;
102
106
  }
103
107
  if(message.method==='ping'){await send({jsonrpc:'2.0',id,result:{}});return;}
104
108
  if(!ready){await error(id,-32002,'Initialize first');return;}
package/dist/review.js ADDED
@@ -0,0 +1,206 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {relative, sep} from 'node:path';
3
+ import {functionFile} from './config.js';
4
+ import {routeFunctions, MODULE_BYTE_LIMIT} from './function-sources.js';
5
+ import {prepare} from './tooling.js';
6
+
7
+ import {effectivePolicies} from './policies.js';
8
+
9
+
10
+ // Read-only static review; see docs/TOOLING.md#project-review.
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+ export const reviewModuleByteLimit = MODULE_BYTE_LIMIT;
28
+ export const reviewExcerptLimit = 240;
29
+
30
+
31
+ function locate(source , at ) {
32
+ const start = Math.max(0, at - 40), end = Math.min(source.length, at + 200);
33
+ return {line: source.slice(0, at).split('\n').length, excerpt: source.slice(start, end).replace(/\s+/g, ' ').trim().slice(0, reviewExcerptLimit)};
34
+ }
35
+ const bodyHints = [/typeof\s+\w+\s*(!==|===)/, /\brequired\b/i, /\bmissing\b/i, /\binvalid\b/i, /throw\s+new\s+(Error|TypeError)/];
36
+ function detectBodyValidation(source ) {
37
+ const parse = /JSON\.parse\s*\(/.exec(source);
38
+ return parse && bodyHints.filter(re => re.test(source)).length >= 2 ? locate(source, parse.index) : undefined;
39
+ }
40
+ const cookieHints = [/randomUUID\s*\(/, /randomBytes\s*\(/, /\bsession\b/i, /\btoken\b/i, /expires=/i, /httponly/i];
41
+ function detectCookieSession(source ) {
42
+ const cookie = /set-cookie/i.exec(source);
43
+ return cookie && cookieHints.filter(re => re.test(source)).length >= 2 ? locate(source, cookie.index) : undefined;
44
+ }
45
+ function detectGlobalState(source ) {
46
+ const decl = /^(?:export\s+)?(?:let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\[\s*\]|\{\s*\}|new\s+Map\s*\(\s*\)|new\s+Set\s*\(\s*\)|0)\s*;?\s*$/m.exec(source);
47
+ if (!decl) return undefined;
48
+ const name = decl[1] , mutated = new RegExp(`\\b${name}\\s*(?:\\+\\+|--|\\+=|-=|\\.push\\s*\\(|\\.set\\s*\\(|\\.add\\s*\\(|\\.delete\\s*\\(|\\[[^\\]]*\\]\\s*=)`);
49
+ return mutated.test(source.slice(decl.index + decl[0].length)) ? locate(source, decl.index) : undefined;
50
+ }
51
+ function detectEgress(source ) {
52
+ const call = /\bfetch\s*\(|\bhttps?\.request\s*\(|\bhttps?\.get\s*\(/.exec(source);
53
+ return call ? locate(source, call.index) : undefined;
54
+ }
55
+ const methodCompare = /\brequest\s*\.\s*method\s*(?:===|==)\s*(['"])[A-Z]+\1/g;
56
+ function detectMethodDispatch(source ) {
57
+ const compares = [...source.matchAll(methodCompare)];
58
+ if (compares.length >= 2) return locate(source, compares[0] .index );
59
+ const dispatch = /switch\s*\(\s*request\s*\.\s*method\s*\)/.exec(source);
60
+ if (!dispatch) return undefined;
61
+ const tail = source.slice(dispatch.index, dispatch.index + 2000);
62
+ const cases = [...tail.matchAll(/case\s+(['"])[A-Z]+\1\s*:/g)];
63
+ return cases.length >= 2 ? locate(source, dispatch.index) : undefined;
64
+ }
65
+ const rateLimitHints = [/\b(?:count|counts|hits|attempts|requests)\w*\s*(?:\+\+|\+=\s*1)/i, /Date\.now\s*\(\)/, /\bwindow\b/i, /\bquota\b/i, /retry-after/i, /too many requests/i];
66
+ function detectRateLimit(source ) {
67
+ const anchor = /\b429\b/.exec(source) ?? /retry-after/i.exec(source);
68
+ return anchor && rateLimitHints.filter(re => re.test(source)).length >= 2 ? locate(source, anchor.index) : undefined;
69
+ }
70
+ const securityHeaderNames = ['x-frame-options', 'content-security-policy', 'strict-transport-security', 'x-content-type-options', 'referrer-policy', 'permissions-policy', 'x-xss-protection'];
71
+ function detectSecurityHeaders(source ) {
72
+ const found = securityHeaderNames.filter(name => new RegExp(name, 'i').test(source));
73
+ if (found.length < 2) return undefined;
74
+ const first = new RegExp(found[0] , 'i').exec(source) ;
75
+ return locate(source, first.index);
76
+ }
77
+
78
+ const emptyCategory = () => ({'native-alternative': 0, 'extension-alternative': 0, gap: 0, 'manual-review': 0});
79
+ /** Whether an operator extension is actually registered (not merely declared in YAML), from the caller-supplied registrations
80
+ * (InspectOptions.extensions) that a --host-file/MCP host already loaded; review.ts never loads or executes a host file itself.
81
+ * `undefined` when the caller supplied no registrations at all, meaning registration state is genuinely unconfirmed. */
82
+ function extensionStatus(name , extensions , projectSha256 ) {
83
+ if (extensions === undefined) return undefined;
84
+ const registration = extensions.find(item => item.name === name);
85
+ return registration ? {registered: true, revisionPinned: registration.projectSha256 === projectSha256} : {registered: false};
86
+ }
87
+
88
+ export async function reviewProject(project , options = {}) {
89
+ const {loaded, projectSha256, routes} = await prepare(project, options);
90
+ const declaredExtensions = new Set(Object.keys(loaded.document.extensions ?? {}));
91
+
92
+ const modules = new Map ();
93
+ for (const route of routes) {
94
+ const declared = loaded.routes[route.pattern];
95
+ if (!declared) continue;
96
+ const hasSchema = declared.request?.body?.schema !== undefined;
97
+ const effective = effectivePolicies(loaded.document, declared);
98
+ const hasThrottle = Boolean(effective.throttle), hasSecurity = Boolean(effective.security);
99
+ for (const definition of routeFunctions(declared)) {
100
+ let absolute ;
101
+ try { absolute = await functionFile(loaded.root, definition.source); } catch { continue; }
102
+ let info = modules.get(absolute);
103
+ if (!info) { info = {source: '/' + relative(loaded.root, absolute).split(sep).join('/'), routes: new Set(), routesMissingSchema: new Set(), routesWithThrottle: new Set(), routesWithSecurity: new Set()}; modules.set(absolute, info); }
104
+ info.routes.add(route.pattern);
105
+ if (!hasSchema) info.routesMissingSchema.add(route.pattern);
106
+ if (hasThrottle) info.routesWithThrottle.add(route.pattern);
107
+ if (hasSecurity) info.routesWithSecurity.add(route.pattern);
108
+ }
109
+ }
110
+ const observations = [], summary = emptyCategory();
111
+ for (const [absolute, info] of [...modules.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
112
+ let source ;
113
+ try { const text = await readFile(absolute, 'utf8'); source = text.length > reviewModuleByteLimit ? text.slice(0, reviewModuleByteLimit) : text; } catch { continue; }
114
+ const routesList = [...info.routes].sort();
115
+ const push = (match , rest ) => {
116
+ const observation = {...rest, source: info.source, line: match.line, excerpt: match.excerpt};
117
+ observations.push(observation); summary[observation.category]++;
118
+ };
119
+
120
+ const bodyValidation = detectBodyValidation(source);
121
+ if (bodyValidation && info.routesMissingSchema.size) push(bodyValidation, {
122
+ category: 'native-alternative', signal: 'manual-body-validation', routes: [...info.routesMissingSchema].sort(), confidence: 'medium',
123
+ reason: 'JSON.parse plus hand checks; no request.body.schema.', capability: 'request.body',
124
+ note: 'request.body.schema validates this; see get_capability("request.body").',
125
+ });
126
+
127
+ const cookieSession = detectCookieSession(source);
128
+ if (cookieSession) {
129
+ const authDeclared = declaredExtensions.has('auth');
130
+ const authStatus = authDeclared ? extensionStatus('auth', options.extensions, projectSha256) : undefined;
131
+ push(cookieSession, {
132
+ category: authDeclared ? 'extension-alternative' : 'manual-review', signal: 'manual-cookie-session', routes: routesList, confidence: 'medium',
133
+ reason: 'Hand-built Set-Cookie with session values (id, token, expiry, HttpOnly).',
134
+ ...(authDeclared ? {extension: 'auth'} : {}),
135
+ ...(authStatus?.registered ? {registered: true, revisionPinned: authStatus.revisionPinned} : {}),
136
+ note: authDeclared
137
+ ? authStatus?.registered
138
+ ? authStatus.revisionPinned
139
+ ? 'auth is registered and revision-pinned to this project; hand-built cookies still need a human decision.'
140
+ : 'auth is registered but not revision-pinned to this project\'s current revision; hand-built cookies still need a human decision.'
141
+ : 'auth owns sessions once registered; hand-built cookies still need a human decision.'
142
+ : 'No session extension declared; needs human review (rotation, invalidation).',
143
+ });
144
+ }
145
+
146
+ const globalState = detectGlobalState(source);
147
+ if (globalState) {
148
+ const storeDeclared = declaredExtensions.has('store');
149
+ const storeStatus = storeDeclared ? extensionStatus('store', options.extensions, projectSha256) : undefined;
150
+ push(globalState, {
151
+ category: storeDeclared ? 'extension-alternative' : 'gap', signal: 'global-mutable-state', routes: routesList, confidence: 'medium',
152
+ reason: 'Module-scope let/var starts empty, later mutated: local state.',
153
+ ...(storeDeclared ? {extension: 'store'} : {}),
154
+ ...(storeStatus?.registered ? {registered: true, revisionPinned: storeStatus.revisionPinned} : {}),
155
+ note: storeDeclared
156
+ ? storeStatus?.registered
157
+ ? storeStatus.revisionPinned
158
+ ? 'store is registered and revision-pinned to this project; resets on restart, not shared across multiple instances.'
159
+ : 'store is registered but not revision-pinned to this project\'s current revision; resets on restart, not shared across multiple instances.'
160
+ : 'store can own this once registered; resets on restart, not shared across multiple instances.'
161
+ : 'Resets on restart, not shared across multiple instances; no alternative yet: a real gap.',
162
+ });
163
+ }
164
+
165
+ const egress = detectEgress(source);
166
+ if (egress) push(egress, {
167
+ category: 'manual-review', signal: 'outbound-network-call', routes: routesList, confidence: 'low',
168
+ reason: 'Direct outbound call (fetch/http(s).request/get) from app code.', capability: 'proxy',
169
+ note: 'proxy/signals centralizes egress but equivalence isn\'t verifiable; review by hand.',
170
+ });
171
+
172
+ const methodDispatch = detectMethodDispatch(source);
173
+ if (methodDispatch) push(methodDispatch, {
174
+ category: 'native-alternative', signal: 'method-dispatch', routes: routesList, confidence: 'medium',
175
+ reason: 'Hand-written request.method branching/switch dispatches per-method logic in code.', capability: 'methods',
176
+ note: 'Native routing already dispatches by method; declare one route per method instead of branching on request.method. See get_capability("methods").',
177
+ });
178
+
179
+ const rateLimit = detectRateLimit(source);
180
+ if (rateLimit) {
181
+ const declaredRoutes = [...info.routesWithThrottle].sort(), duplicate = declaredRoutes.length > 0;
182
+ push(rateLimit, {
183
+ category: duplicate ? 'manual-review' : 'native-alternative', signal: 'manual-rate-limit',
184
+ routes: duplicate ? declaredRoutes : routesList, confidence: 'medium', capability: 'policies.throttle',
185
+ reason: 'Hand-rolled request counting with a 429/Retry-After response: a rate-limit pattern.',
186
+ note: duplicate
187
+ ? 'policies.throttle is already declared for these routes; hand-rolled counting duplicates the host-enforced quota and needs a human decision to remove one.'
188
+ : 'policies.throttle is not declared for these routes; see get_capability("policies.throttle") for quota/window enforcement without application code.',
189
+ });
190
+ }
191
+
192
+ const securityHeaders = detectSecurityHeaders(source);
193
+ if (securityHeaders) {
194
+ const declaredRoutes = [...info.routesWithSecurity].sort(), duplicate = declaredRoutes.length > 0;
195
+ push(securityHeaders, {
196
+ category: duplicate ? 'manual-review' : 'native-alternative', signal: 'manual-security-headers',
197
+ routes: duplicate ? declaredRoutes : routesList, confidence: 'medium', capability: 'policies.security',
198
+ reason: 'Hand-set security response headers (two or more of X-Frame-Options, CSP, HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy).',
199
+ note: duplicate
200
+ ? 'policies.security is already declared for these routes; hand-set headers duplicate the host-enforced profile and need a human decision to remove one.'
201
+ : 'policies.security is not declared for these routes; see get_capability("policies.security") for header configuration without application code.',
202
+ });
203
+ }
204
+ }
205
+ return {format: 1, projectSha256, routeCount: routes.length, moduleCount: modules.size, observations, summary};
206
+ }
package/dist/router.js CHANGED
@@ -140,8 +140,21 @@ export async function compileRoutes(loaded , bindings
140
140
  }
141
141
  assert(names.every(name => route.parameters.some(p => p.in === 'path' && p.name === name)), 'Every path placeholder requires an input declaration');
142
142
  for (const [alias, ref] of Object.entries(config.env || {})) {
143
- if (ref.env) assert(permissions.projectSha256 === projectSha256 && permissions.routes?.[pattern]?.env?.includes(ref.env), 'Environment binding denied by operator policy');
144
- const value = own(ref, 'value') ? ref.value : bindings[ref.env ];
143
+ // `env` always requires an operator grant for that route/name. A missing grant is not
144
+ // fatal when a `default` is declared: the binding just degrades to its literal default
145
+ // and never reads the host (issue #258). With no `default`, a missing grant still fails
146
+ // route compilation, as before. `value`-only bindings are plain literals and never
147
+ // consult the grant or the host environment.
148
+ let value ;
149
+ if (ref.env) {
150
+ const granted = permissions.projectSha256 === projectSha256 && permissions.routes?.[pattern]?.env?.includes(ref.env);
151
+ assert(granted || ref.default !== undefined, 'Environment binding denied by operator policy');
152
+ const hostValue = bindings[ref.env];
153
+ const hostSet = ref.default !== undefined ? typeof hostValue === 'string' && hostValue.length > 0 : hostValue !== undefined;
154
+ value = granted ? (hostSet ? hostValue : ref.default) : ref.default;
155
+ } else {
156
+ value = ref.value;
157
+ }
145
158
  assert(typeof value === 'string', 'Missing required environment binding');
146
159
  route.env[alias] = value;
147
160
  }
package/dist/tooling.js CHANGED
@@ -23,7 +23,11 @@ export {getSchemaFragment,schemaPathNames} from './schema-query.js';
23
23
  export {listRecipes,showRecipe,searchRecipes,listExamples,searchExamples};
24
24
  export {buildContext,renderContext,estimateTokens,documentationTokens,buildTaskContext,renderTaskContext,contextTasks} from './context.js';
25
25
 
26
+ export {planFeature,featurePlanMaxBytes,featurePlanMaxGoalLength} from './feature-plan.js';
27
+
26
28
 
29
+ export {reviewProject} from './review.js';
30
+
27
31
  /** `extensions` are operator registrations from a host file; explain reports whether each requirement has a provider. Nothing is activated. */
28
32
 
29
33
  function routesOf(table ) {return [...table.exact.values(),...[...table.byLength.values()].flat(),...table.mounts];}
@@ -8,5 +8,7 @@ export interface InitOptions {
8
8
  /** `default` (function, middleware, redirect) or `page`: urlcode.yaml, public/index.html, a README and fixtures only. */
9
9
  template?: 'default' | 'page' | 'redirects' | undefined;
10
10
  }
11
+ /** Adds what the starter needs to an existing package.json and changes nothing else; a conflicting script is refused, never overwritten. */
12
+ export declare function mergePackageJson(text: string, scripts: Record<string, string>, dependency: string, version: string): string;
11
13
  export declare function initProject(destination: string, { manifest, template }?: InitOptions): Promise<string>;
12
14
  export declare function addRedirect(project: string, destination: string, alias?: string | undefined): Promise<string>;
@@ -76,6 +76,9 @@ export interface RouteExplanation {
76
76
  env: string;
77
77
  } | {
78
78
  literal: true;
79
+ } | {
80
+ env: string;
81
+ default: string;
79
82
  }>;
80
83
  secrets: Record<string, {
81
84
  secret: string;
@@ -1,3 +1,4 @@
1
+ import { type ReleaseAsset } from './extension-transport.ts';
1
2
  /** Offline, declarative extension bundles. These are deliberately not Node packages. */
2
3
  export declare const ARTIFACT_REPOSITORY = "jimhoyd-com/urlcode";
3
4
  export declare const ARTIFACT_WORKFLOW = "jimhoyd-com/urlcode/.github/workflows/extension-artifacts.yml";
@@ -47,10 +48,7 @@ export declare function extractArtifact(bytes: Uint8Array, entry: ArtifactEntry,
47
48
  export declare function readLock(project: string): Promise<ExtensionLock>;
48
49
  export declare function writeLock(project: string, lock: ExtensionLock): Promise<void>;
49
50
  export declare function cachePath(project: string, sha256: string): string;
50
- export interface ReleaseAsset {
51
- name: string;
52
- url: string;
53
- }
51
+ export type { ReleaseAsset };
54
52
  export interface ArtifactTransport {
55
53
  release(tag: string): Promise<ReleaseAsset[]>;
56
54
  download(url: string): Promise<Uint8Array>;
@@ -0,0 +1,31 @@
1
+ /** Shared GitHub release/cache/lockfile plumbing for extension-artifacts.ts and extension-bundles.ts. No opinion on what content is allowed or executable; that trust boundary stays local to each caller (#441). */
2
+ export type UnknownRecord = Record<string, unknown>;
3
+ export declare const isRecord: (v: unknown) => v is UnknownRecord;
4
+ export declare const digestHex: (bytes: Uint8Array) => string;
5
+ export declare function textField(value: unknown, what: string): string;
6
+ export declare function exactKeys(value: UnknownRecord, expected: readonly string[], what: string): void;
7
+ /** Sorted, recursive listing of an extension cache directory; refuses links and special files. */
8
+ export declare function listCachedFiles(root: string, itemLabel: string, prefix?: string): Promise<string[]>;
9
+ /** Atomic write-then-rename for a JSON lockfile, refusing to clobber a concurrent writer. */
10
+ export declare function writeLockAtomic(path: string, temporary: string, data: unknown): Promise<void>;
11
+ export interface ReleaseAsset {
12
+ name: string;
13
+ url: string;
14
+ }
15
+ export interface GithubTransport {
16
+ release(tag: string): Promise<ReleaseAsset[]>;
17
+ download(url: string): Promise<Uint8Array>;
18
+ attest(path: string, release: string): Promise<void>;
19
+ }
20
+ export interface GithubTransportConfig {
21
+ repository: string;
22
+ workflow: string;
23
+ tagPattern: RegExp;
24
+ exampleTag: string;
25
+ maxAssetSize: number;
26
+ itemLabel: string;
27
+ }
28
+ /** A transport that accepts only GitHub Release asset URLs and verifies every downloaded subject. */
29
+ export declare function createGithubTransport(config: GithubTransportConfig): GithubTransport;
30
+ /** Downloads one named release asset and has the transport attest it before returning its bytes. */
31
+ export declare function verifiedReleaseAsset(assets: ReleaseAsset[], asset: string, release: string, transport: GithubTransport, itemLabel: string, tempPrefix: string): Promise<Uint8Array>;
@@ -0,0 +1,67 @@
1
+ import type { CapabilityName, CapabilityTarget } from './capabilities.ts';
2
+ import type { RuntimeExtension } from './extensions.ts';
3
+ /** The planner is deliberately a small, local projection. It never treats goal
4
+ * text as instructions, opens a host, or reads extension/project source. */
5
+ export declare const featurePlanMaxBytes = 32768;
6
+ export declare const featurePlanMaxGoalLength = 512;
7
+ export interface FeaturePlanOptions {
8
+ target?: string;
9
+ extensions?: readonly RuntimeExtension[] | undefined;
10
+ }
11
+ export interface FeaturePlan {
12
+ format: 1;
13
+ goalTerms: string[];
14
+ target: CapabilityTarget;
15
+ project: {
16
+ routes: number;
17
+ extensions: string[];
18
+ };
19
+ applicable: {
20
+ capabilities: {
21
+ name: CapabilityName;
22
+ support: string;
23
+ reason: string;
24
+ }[];
25
+ recipes: {
26
+ name: string;
27
+ description: string;
28
+ matched: string[];
29
+ }[];
30
+ };
31
+ extensions: {
32
+ required: {
33
+ name: string;
34
+ reason: string;
35
+ declared: boolean;
36
+ registered: boolean;
37
+ target: string;
38
+ artifact: 'none' | 'cached' | 'missing' | 'invalid';
39
+ }[];
40
+ ordering: {
41
+ status: 'operator-resolved';
42
+ names: string[];
43
+ note: string;
44
+ };
45
+ };
46
+ outline: {
47
+ kind: string;
48
+ note: string;
49
+ }[];
50
+ applicationCode: {
51
+ requirement: string;
52
+ reason: string;
53
+ }[];
54
+ unsupported: {
55
+ requirement: string;
56
+ reason: string;
57
+ }[];
58
+ next: string[];
59
+ estimatedTokens: number;
60
+ }
61
+ /**
62
+ * Plans only from the current compiled project, package-owned catalogs, locked
63
+ * inert artifacts, and registrations passed by the already-opened operator
64
+ * session. It intentionally has no filesystem path, host-file, binding, or
65
+ * execution argument.
66
+ */
67
+ export declare function planFeature(project: string, goal: string, options?: FeaturePlanOptions): Promise<FeaturePlan>;
@@ -18,8 +18,8 @@ export { buildTypeScriptProject } from './typescript-authoring.ts';
18
18
  export type { TypeScriptBuildReport } from './typescript-authoring.ts';
19
19
  export { importBulkProject } from './bulk.ts';
20
20
  export type { BulkFormat, BulkFilePlan, BulkImportReport } from './bulk.ts';
21
- export { inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks } from './tooling.ts';
22
- export type { InspectOptions, RouteExplanation, RouteMiss, ExplainedHandler, ExplainedCache, ExplainedExtensionRequirement, ExtensionProvider, TargetSupport, CapabilityEntry, CapabilityUsage, SchemaFragment, ExtensionInspection, ContextOptions, ProjectContext, ContextSection, ContextTask, TaskContext, TaskShape } from './tooling.ts';
21
+ export { inspectProject, validateProject, explainRoute, explainProject, previewImport, previewExport, getCapability, getSchemaFragment, schemaPathNames, inspectExtensions, describeExtensions, buildContext, renderContext, estimateTokens, documentationTokens, buildTaskContext, renderTaskContext, contextTasks, planFeature, featurePlanMaxBytes, featurePlanMaxGoalLength } from './tooling.ts';
22
+ export type { InspectOptions, RouteExplanation, RouteMiss, ExplainedHandler, ExplainedCache, ExplainedExtensionRequirement, ExtensionProvider, TargetSupport, CapabilityEntry, CapabilityUsage, SchemaFragment, ExtensionInspection, ContextOptions, ProjectContext, ContextSection, ContextTask, TaskContext, TaskShape, FeaturePlan, FeaturePlanOptions } from './tooling.ts';
23
23
  export { buildManifest, renderManifest, MANIFEST_SCHEMA_VERSION } from './manifest.ts';
24
24
  export type { Manifest, ManifestRoute, ManifestModule, RecipeProvenance } from './manifest.ts';
25
25
  export { serveMcp } from './mcp.ts';