@reqvire-org/ads 0.3.17 → 0.3.18

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.
Files changed (44) hide show
  1. package/dist/atomic-composition.mjs +1 -1
  2. package/dist/check.mjs +30 -3
  3. package/dist/investigations.mjs +34 -3
  4. package/dist/linaria/preserve-values.cjs +21 -2
  5. package/dist/mcp/behavior-pages.mjs +36 -0
  6. package/dist/mcp/context.mjs +3 -1
  7. package/dist/mcp/execution-results.mjs +146 -0
  8. package/dist/mcp/mutations.mjs +3 -3
  9. package/dist/mcp/result-pages.mjs +87 -0
  10. package/dist/mcp/server.mjs +43 -28
  11. package/dist/property-registrations.mjs +39 -0
  12. package/dist/scenario-trace.mjs +1 -1
  13. package/dist/scoped-tokens.mjs +6 -0
  14. package/dist/skills/ads-break-ui/SKILL.md +6 -4
  15. package/dist/skills/ads-break-ui/references/atomic-styles.md +48 -0
  16. package/dist/skills/ads-break-ui/references/captures.md +6 -1
  17. package/dist/skills/ads-break-ui/references/evidence.md +52 -5
  18. package/dist/skills/ads-break-ui/references/guarded-editing.md +8 -5
  19. package/dist/skills/ads-designer/SKILL.md +6 -4
  20. package/dist/skills/ads-designer/references/atomic-styles.md +48 -0
  21. package/dist/skills/ads-designer/references/captures.md +6 -1
  22. package/dist/skills/ads-designer/references/evidence.md +52 -5
  23. package/dist/skills/ads-designer/references/guarded-editing.md +8 -5
  24. package/dist/skills/ads-import/SKILL.md +6 -4
  25. package/dist/skills/ads-import/references/atomic-styles.md +48 -0
  26. package/dist/skills/ads-import/references/captures.md +6 -1
  27. package/dist/skills/ads-import/references/evidence.md +52 -5
  28. package/dist/skills/ads-import/references/guarded-editing.md +8 -5
  29. package/dist/skills/ads-migrate/SKILL.md +6 -4
  30. package/dist/skills/ads-migrate/references/atomic-styles.md +48 -0
  31. package/dist/skills/ads-migrate/references/captures.md +6 -1
  32. package/dist/skills/ads-migrate/references/evidence.md +52 -5
  33. package/dist/skills/ads-migrate/references/guarded-editing.md +8 -5
  34. package/dist/skills/ads-motion/SKILL.md +6 -4
  35. package/dist/skills/ads-motion/references/atomic-styles.md +48 -0
  36. package/dist/skills/ads-motion/references/captures.md +6 -1
  37. package/dist/skills/ads-motion/references/evidence.md +52 -5
  38. package/dist/skills/ads-motion/references/guarded-editing.md +8 -5
  39. package/docs/ADS.md +10 -0
  40. package/docs/ARCHITECTURE.md +27 -0
  41. package/docs/BEHAVIORS.md +14 -2
  42. package/docs/CHANGELOG.md +17 -0
  43. package/docs/MCP.md +60 -14
  44. package/package.json +2 -1
@@ -168,7 +168,7 @@ export function createAtomicCompositionReview(program, options) {
168
168
  if (parent.type === "atrule") conditions.unshift(`${parent.name} ${parent.params}`.trim().replace(/\s+/g, " "));
169
169
  }
170
170
  // Animation frames describe different instants, not combined classes.
171
- if (conditions.some(condition => /^(?:-\w+-)?keyframes\b/.test(condition))) return;
171
+ if (conditions.some(condition => /^(?:(?:-\w+-)?keyframes|property)\b/.test(condition))) return;
172
172
  result.push({ property: decl.prop.toLowerCase(), value: decl.value, important: !!decl.important, selectors: JSON.stringify(selectors), conditions: JSON.stringify(conditions),
173
173
  file: path.relative(options.root, source.fileName).replaceAll("\\", "/"), line: position.line + decl.source.start.line, column: decl.source.start.column + (decl.source.start.line === 1 ? position.character : 0) });
174
174
  });
package/dist/check.mjs CHANGED
@@ -24,6 +24,7 @@ import { createScenarioTrace } from "./scenario-trace.mjs";
24
24
  import { createScenarioSlots } from "./scenario-slots.mjs";
25
25
  import { createScenarioSelectors } from "./scenario-selectors.mjs";
26
26
  import { architectureEvidence, failure } from "./evidence.mjs";
27
+ import { propertyRegistration } from "./property-registrations.mjs";
27
28
 
28
29
  const scenarioLayoutGuidance = "Put demo-only sizing and framing in a public review/layout pattern. Keep required behavior in the runtime owner; add component props for deliberate product API needs.";
29
30
  const inlineStyleGuidance = `Own visual styles in component/pattern Linaria. ${scenarioLayoutGuidance} Reserve inlineStyleFiles for documented measured geometry, not demo layout.`;
@@ -70,6 +71,30 @@ export function checkProject(directory, { trackingRoot } = {}) {
70
71
  inspectControllers(options, program, report, behaviors.contracts);
71
72
  // The foundation remains a fixed import-only entry, not an extra styling layer.
72
73
  const foundationEntry = path.join(dsRoot, config.stylesEntry);
74
+ const registeredProperties=new Map();
75
+ function checkRegistrations(file,css,offset={line:1}){
76
+ css.walkAtRules(/^property$/i,rule=>{
77
+ const line=offset.line+(rule.source?.start.line??1)-1;
78
+ try{
79
+ const registration=propertyRegistration(rule),relative=scoped.inventory.relative(path.relative(root,file));
80
+ const owner=scoped.inventory.owner(relative),foundation=isInside(path.join(dsRoot,config.tokenDir),file);
81
+ if(foundation){
82
+ let defined=false;css.walkDecls(registration.name,()=>{defined=true;});
83
+ if(!isSharedToken(registration.name)||!declaredTokens.has(registration.name)||!defined||["base.css","fonts.css"].includes(path.basename(file)))throw new Error("Shared @property registrations belong beside their real token definitions in foundation value sheets, not base.css or fonts.css.");
84
+ }else{
85
+ if(!owner||!/^(?:components|patterns)\//.test(owner))throw new Error("@property belongs to its runtime component/pattern or real token value sheet, never application, scenario, controller or shared reset styling.");
86
+ const violation=checkComponentVariable(registration.name,file,options);if(violation)throw new Error(violation.message);
87
+ }
88
+ for(const descriptor of rule.nodes??[])if(descriptor.type==="decl")for(const ref of references(descriptor.value)){
89
+ const tokenOwner=scopedTokenOwner(options,ref.name);
90
+ if(tokenOwner&&(tokenOwner.unit!==owner||!declaredTokens.has(ref.name))||!tokenOwner&&!/^(?:--ds-|--ux-|--showcase-)/.test(ref.name)&&(!isSharedToken(ref.name)||!declaredTokens.has(ref.name)&&!ref.fallback))throw new Error(`Invalid or cross-owner token reference ${ref.name} in registration metadata.`);
91
+ }
92
+ const previous=registeredProperties.get(registration.name);
93
+ if(previous&&(previous.owner!==owner||previous.signature!==registration.signature))throw new Error(`Conflicting @property registration for ${registration.name}; ${path.relative(root,previous.file)} already registers it. Keep one owner and identical descriptors across that owner's modules.`);
94
+ registeredProperties.set(registration.name,{...registration,owner,file});
95
+ }catch(error){report(file,"property-registration",`${error.message} See ads://guides/atomic-styles.`,line,rule.source?.start.column??1);diagnostics.at(-1).guidanceUri="ads://guides/atomic-styles";}
96
+ });
97
+ }
73
98
  try {
74
99
  const css = postcss.parse(readFileSync(foundationEntry, "utf8"), { from: foundationEntry });
75
100
  const imports = css.nodes.filter((node) => node.type !== "comment");
@@ -81,6 +106,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
81
106
  let css;
82
107
  try { css = postcss.parse(text, { from: file }); }
83
108
  catch (error) { report(file, "css-syntax", error.reason ?? error.message, offset.line + (error.line ?? 1) - 1); return; }
109
+ checkRegistrations(file,css,offset);
84
110
  if (!foundation && (isDevelopmentFile(file, options) || !isInside(path.join(dsRoot, "components"), file) && !isInside(path.join(dsRoot, "patterns"), file) && file !== foundationEntry)) report(file, "style-ownership", "Visual CSS belongs to the local ADS's runtime components or patterns.");
85
111
  if (isDevelopmentFile(file, options) && css.nodes.some((node) => node.type !== "comment" && !(node.type === "atrule" && node.name === "import"))) report(file, "showcase-style", `Scenarios and fixtures define no styles. ${scenarioLayoutGuidance}`, offset.line);
86
112
  css.walkAtRules("import", (rule) => {
@@ -108,12 +134,13 @@ export function checkProject(directory, { trackingRoot } = {}) {
108
134
  if (rule.name === "font-face" && path.basename(file) !== "fonts.css") report(file, "foundation-role", "Font faces belong in the canonical fonts.css foundation sheet.", rule.source.start.line);
109
135
  });
110
136
  css.walkDecls((decl) => {
137
+ const propertyDescriptor=decl.parent.type==="atrule"&&decl.parent.name.toLowerCase()==="property";
111
138
  if (foundation && decl.prop.startsWith("--")) {
112
139
  if (path.basename(file) === "base.css") report(file, "foundation-role", "Shared token definitions belong in value sheets, not base.css.", decl.source.start.line, decl.source.start.column);
113
140
  return; // Definition names, values and aliases are validated by inspectTokens.
114
141
  }
115
142
  const fontDescriptor = foundation && path.basename(file) === "fonts.css" && decl.parent.type === "atrule" && decl.parent.name === "font-face";
116
- if (foundation && !fontDescriptor && (path.basename(file) !== "base.css" || decl.parent.type !== "rule")) report(file, "foundation-role", "Token sheets contain shared definitions; native defaults belong in base.css, font descriptors in fonts.css, and UI styling in Linaria owners.", decl.source.start.line, decl.source.start.column);
143
+ if (foundation && !fontDescriptor && !propertyDescriptor && (path.basename(file) !== "base.css" || decl.parent.type !== "rule")) report(file, "foundation-role", "Token sheets contain shared definitions; native defaults belong in base.css, font descriptors in fonts.css, and UI styling in Linaria owners.", decl.source.start.line, decl.source.start.column);
117
144
  if (foundation && path.basename(file) === "base.css" && decl.parent.type === "rule") {
118
145
  const violation = foundationDeclaration(decl.parent, decl);
119
146
  if (violation) report(file, "foundation-role", violation, decl.source.start.line, decl.source.start.column);
@@ -131,7 +158,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
131
158
  if (!local && !isSharedToken(ref.name)) report(file, "token-name", `${ref.name} does not follow ADS's fixed token naming contract.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
132
159
  else if (!local && !declaredTokens.has(ref.name) && !ref.fallback) report(file, "token-reference", `Undefined shared token ${ref.name}.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
133
160
  }
134
- if (fontDescriptor) return; // Font metadata defines source assets, not token-backed UI styles.
161
+ if (fontDescriptor || propertyDescriptor) return; // Font metadata defines source assets, not token-backed UI styles.
135
162
  const violations = new Set();
136
163
  valueParser(decl.value).walk((node) => {
137
164
  if (node.type === "function" && node.value === "url") return false;
@@ -146,7 +173,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
146
173
  });
147
174
  }
148
175
  for (const file of files) {
149
- if (scopedFiles.has(file)) continue; // Closed definition-only contract checked by inspectTokens.
176
+ if (scopedFiles.has(file)) { try{checkRegistrations(file,postcss.parse(readFileSync(file,"utf8"),{from:file}));}catch{/* Token inspection reports malformed sheets. */} continue; } // Closed definition-only contract checked by inspectTokens.
150
177
  if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) { checkCss(file, readFileSync(file, "utf8"), undefined, true); continue; }
151
178
  if (file.endsWith(".html")) { inspectHtml(readFileSync(file, "utf8"), (rule, message, line) => report(file, rule, message, line)); continue; }
152
179
  if (file.endsWith(".css")) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
+ import { compactAcceptance, compactRow, jsonBytes, resultBudgets, resultPage, resultRecord } from "./mcp/result-pages.mjs";
4
5
 
5
6
  export const INVESTIGATIONS_URI = "ads://project/investigations";
6
7
  const indexFile = root => path.join(root,".ds/investigations.json");
@@ -32,7 +33,7 @@ export function saveInvestigations(root, entries, registered) {
32
33
  }
33
34
 
34
35
  export function investigationIndex(root) {
35
- return {retention:"latest-per-check",entries:Object.values(saved(root)).map(({unit,check,status,completedAt,inputRevision,resourceUri,failure})=>({unit,check,status,completedAt,inputRevision,resourceUri,...(failure?{failure}:{})}))};
36
+ return {retention:"latest-per-check",entries:Object.values(saved(root)).map(({unit,check,status,runId,target,completedAt,inputRevision,resourceUri,failure})=>({unit,check,status,runId,target,completedAt,inputRevision,resourceUri,...(failure?{failure}:{})}))};
36
37
  }
37
38
 
38
39
  export function readInvestigation(root,{unit,check}) {
@@ -41,6 +42,35 @@ export function readInvestigation(root,{unit,check}) {
41
42
  return {...bundle,artifacts:(bundle.artifacts??[]).map(artifact=>({...artifact,available:!!artifactPath(root,artifact.path)}))};
42
43
  }
43
44
 
45
+ export const investigationSections=["summary","details","requests","browserErrors","artifacts"];
46
+ export function investigationPage(root,{unit,check,runId,section="summary",cursor,limit,recordIndex,offset,chars}={}) {
47
+ if(check===undefined){
48
+ if(runId!==undefined||section!=="summary"||recordIndex!==undefined||offset!==undefined||chars!==undefined)throw new Error("Select an exact unit/check before reading investigation details.");
49
+ const entries=investigationIndex(root).entries.filter(entry=>!unit||entry.unit===unit);
50
+ return resultPage(entries,{cursor,limit,identity:["investigations",unit,entries],key:"entries",header:{retention:"latest-per-check"}});
51
+ }
52
+ if(!unit||!investigationSections.includes(section))throw new Error("Select an exact unit/check and a known investigation section.");
53
+ const bundle=readInvestigation(root,{unit,check});
54
+ if(runId!==undefined&&bundle.runId!==runId)throw new Error("Investigation was superseded by a different run. Do not mix it with the earlier result.");
55
+ const details={source:bundle.source,failingStep:bundle.failingStep,viewport:bundle.viewport,viewportSource:bundle.viewportSource,failure:bundle.failure};
56
+ if(section!=="summary"){
57
+ const rows=(section==="details"?[details]:bundle[section]??[]).map((row,recordIndex)=>({recordIndex,...(row&&typeof row==="object"?row:{value:row})})),header={unit,check,runId:bundle.runId,section};
58
+ if(recordIndex!==undefined){if(cursor!==undefined||!runId)throw new Error("Read investigation record ranges with the exact runId and without a cursor.");return resultRecord(rows,{recordIndex,offset,chars,header});}
59
+ if(offset!==undefined||chars!==undefined)throw new Error("Select a recordIndex before reading text ranges.");
60
+ return resultPage(rows,{cursor,limit,identity:["investigation",unit,check,bundle.runId,bundle.inputRevision,section,rows],header});
61
+ }
62
+ if(recordIndex!==undefined||offset!==undefined||chars!==undefined)throw new Error("Select an investigation detail section before reading record ranges.");
63
+ if(cursor!==undefined)throw new Error("The investigation summary has no continuation; select a detail section.");
64
+ const result={unit,check,runId:bundle.runId,status:bundle.status,target:bundle.target,inputRevision:bundle.inputRevision,inputsCurrentAtCompletion:bundle.inputsCurrentAtCompletion,
65
+ framework:bundle.framework,profile:bundle.profile,mode:bundle.mode,scenario:bundle.scenario,observedPreviewRevision:bundle.observedPreviewRevision,
66
+ details:compactRow(details,1536),
67
+ acceptance:compactAcceptance(bundle.acceptance),counts:Object.fromEntries(investigationSections.slice(2).map(key=>[key,bundle[key]?.length??0])),resourceUri:bundle.resourceUri,byteLimit:resultBudgets.summary};
68
+ if(jsonBytes(result)>resultBudgets.summary){delete result.resourceUri;result.resourceUriOmitted=true;result.details=compactRow(result.details,512);}
69
+ if(jsonBytes(result)>resultBudgets.summary){result.acceptance={status:bundle.acceptance?.status,detailTruncated:true};delete result.scenario;delete result.observedPreviewRevision;result.detailTruncated=true;}
70
+ if(jsonBytes(result)>resultBudgets.summary)throw new Error("Investigation identity exceeds the summary budget.");
71
+ return result;
72
+ }
73
+
44
74
  export function investigationArtifacts(root,report,attachments=[]) {
45
75
  return attachments.slice(0,5).flatMap(attachment=>{
46
76
  if(typeof attachment?.path!=="string")return [];
@@ -57,9 +87,10 @@ export function investigationArtifacts(root,report,attachments=[]) {
57
87
  /** Only files explicitly registered by the latest bundles are resource-readable. */
58
88
  export function readInvestigationResource(root,uri) {
59
89
  if(uri.startsWith("ads://investigations/")) {
60
- const parts=uri.slice("ads://investigations/".length).split("/").map(decodeURIComponent);
90
+ const url=new URL(uri),parts=url.pathname.slice(1).split("/").map(decodeURIComponent);
61
91
  if(parts.length!==2)throw new Error("Invalid investigation URI.");
62
- return {uri,mimeType:"application/json",text:JSON.stringify(readInvestigation(root,{unit:parts[0],check:parts[1]}))};
92
+ if([...url.searchParams.keys()].some(key=>!["section","runId","cursor","limit","recordIndex","offset","chars"].includes(key)))throw new Error("Unknown investigation resource query.");
93
+ return {uri,mimeType:"application/json",text:JSON.stringify(investigationPage(root,{unit:parts[0],check:parts[1],section:url.searchParams.get("section")??"summary",runId:url.searchParams.get("runId")??undefined,cursor:url.searchParams.get("cursor")??undefined,...Object.fromEntries(["limit","recordIndex","offset","chars"].filter(key=>url.searchParams.has(key)).map(key=>[key,Number(url.searchParams.get(key))]))}))};
63
94
  }
64
95
  const artifact=Object.values(saved(root)).flatMap(bundle=>bundle.artifacts??[]).find(artifact=>artifact.resourceUri===uri);
65
96
  const file=artifact&&artifactPath(root,artifact.path);
@@ -8,6 +8,23 @@ module.exports = function preserveCustomProperties(Processor) {
8
8
  return class PreservedCustomProperties extends Processor {
9
9
  extractRules(valueCache, cssText, loc) {
10
10
  const source = postcss.parse(cssText);
11
+ const registrations = [];
12
+ const layerOrder=[];
13
+ // Moving a registration must not change which named layer was first seen.
14
+ source.walkAtRules("layer",rule=>{
15
+ if(!rule.nodes||!rule.params.trim())return;
16
+ let statement=postcss.atRule({name:"layer",params:rule.params});
17
+ for(let parent=rule.parent;parent&&parent.type!=="root";parent=parent.parent){if(parent.type!=="atrule")return;const wrapper=parent.clone({nodes:[]});wrapper.raws.semicolon=true;wrapper.append(statement);statement=wrapper;}
18
+ layerOrder.push(statement.toString()+(statement.nodes?"":";"));
19
+ });
20
+ source.walkAtRules(/^property$/i, rule => {
21
+ let extracted=rule.clone();
22
+ for(let parent=rule.parent;parent&&parent.type!=="root";parent=parent.parent){
23
+ if(parent.type!=="atrule")throw new Error("@property is document-global. Put it at the Linaria template's top level or inside conditional at-rules, not inside an element selector.");
24
+ const wrapper=parent.clone({nodes:[]});wrapper.append(extracted);extracted=wrapper;
25
+ }
26
+ registrations.push(extracted.toString());rule.remove();
27
+ });
11
28
  const values = new Map();
12
29
  source.walkDecls(declaration => {
13
30
  if (!declaration.prop.startsWith("--")) return;
@@ -17,9 +34,9 @@ module.exports = function preserveCustomProperties(Processor) {
17
34
  declaration.value = marker;
18
35
  delete declaration.raws.value;
19
36
  });
20
- if (!values.size) return super.extractRules(valueCache, cssText, loc);
37
+ if (!values.size && !registrations.length) return super.extractRules(valueCache, cssText, loc);
21
38
  const rules = super.extractRules(valueCache, source.toString(), loc);
22
- return Object.fromEntries(Object.entries(rules).map(([key, rule]) => {
39
+ const restoredRules=Object.fromEntries(Object.entries(rules).map(([key, rule]) => {
23
40
  const output = postcss.parse(rule.cssText);
24
41
  output.walkDecls(declaration => {
25
42
  if (declaration.prop.startsWith("--") && values.has(declaration.value)) declaration.value = values.get(declaration.value);
@@ -27,6 +44,8 @@ module.exports = function preserveCustomProperties(Processor) {
27
44
  const restored = output.toString();
28
45
  return [rule.atom ? restored : key, { ...rule, cssText: restored }];
29
46
  }));
47
+ const globalRule=cssText=>({cssText,start:loc?.start??null,className:this.className,displayName:this.displayName,atom:true});
48
+ return {...Object.fromEntries(registrations.length?layerOrder.map(css=>[css,globalRule(css)]):[]),...restoredRules,...Object.fromEntries(registrations.map(css=>[css,globalRule(css)]))};
30
49
  }
31
50
  };
32
51
  };
@@ -0,0 +1,36 @@
1
+ import { behaviorStatus } from "../verification.mjs";
2
+ import { compactRow, resultPage } from "./result-pages.mjs";
3
+
4
+ export const behaviorSections = ["owners", "checks", "guarantees", "subjects", "demonstrations", "profiles"];
5
+ const checkResult = (unit, id, result) => {
6
+ const {status,current,revision,runId,profile,case:caseId,durationMs,completedAt,failure}=result;
7
+ return {status,current,revision,runId,profile,case:caseId,durationMs,completedAt,...(failure?{failure}:{}),
8
+ ...(runId?{investigation:{unit,check:id,runId}}:{})};
9
+ };
10
+
11
+ /** Discovery never embeds stored runner reports or investigation bodies. */
12
+ export function behaviorPage(options, {unit,api,query="",section=unit?"checks":"owners",cursor,limit} = {}) {
13
+ if(!behaviorSections.includes(section)||section!=="owners"&&!unit)throw new Error("Select an exact unit before paging behavioral details.");
14
+ const inventory=behaviorStatus(options);
15
+ const matches=inventory.contracts.filter(contract=>(!unit||contract.unit===unit)&&(!api||Object.values(contract.subjects).some(subject=>subject.api===api))&&JSON.stringify([contract.unit,contract.subjects,contract.guarantees.map(({id,description})=>({id,description}))]).toLowerCase().includes(query.toLowerCase()));
16
+ let rows;
17
+ if(section==="owners") rows=matches.map(contract=>({unit:contract.unit,file:contract.file,
18
+ apis:Object.values(contract.subjects).filter(subject=>subject.api).map(subject=>subject.api).slice(0,10),
19
+ apiCount:Object.values(contract.subjects).filter(subject=>subject.api).length,
20
+ checkCount:Object.keys(contract.checks).length,guaranteeCount:contract.guarantees.length,
21
+ guarantees:contract.guarantees.slice(0,3).map(({id,result})=>({id,result})),guaranteesTruncated:contract.guarantees.length>3,
22
+ sections:{checks:Object.keys(contract.checks).length,guarantees:contract.guarantees.length,subjects:Object.keys(contract.subjects).length,demonstrations:Object.keys(contract.demonstrations).length},
23
+ next:{tool:"ads_behaviors",arguments:{unit:contract.unit,section:"checks"}}}));
24
+ else {
25
+ const contract=matches[0];
26
+ if(!contract)throw new Error("No behavior contract matches this unit/filter.");
27
+ if(section==="checks")rows=Object.entries(contract.checks).map(([id,{result,...check}])=>({id,...check,result:checkResult(contract.unit,id,result)}));
28
+ if(section==="guarantees")rows=contract.guarantees;
29
+ if(section==="subjects")rows=Object.entries(contract.subjects).map(([id,subject])=>({id,...subject}));
30
+ if(section==="demonstrations")rows=Object.entries(contract.demonstrations).map(([subject,scenarios])=>({subject,scenarios}));
31
+ if(section==="profiles")rows=[...new Set(Object.values(contract.checks).map(check=>check.profile))].map(id=>({id,...options.config.mcp.behaviorProfiles[id]}));
32
+ }
33
+ const revision=matches.map(contract=>[contract.unit,contract.file,Object.entries(contract.checks).map(([id,check])=>[id,check.result.revision,check.result.status,check.result.runId])]);
34
+ return resultPage(rows,{cursor,limit,identity:["behaviors",section,unit,api,query,revision,rows],key:section==="owners"?"contracts":"items",
35
+ header:{section,...(unit?{unit,sourceFile:matches[0]?.file}:{}),diagnosticCount:inventory.diagnostics.length,diagnostics:inventory.diagnostics.slice(0,2).map(row=>compactRow(row,384)),resourceUri:"ads://project/behaviors"}});
36
+ }
@@ -5,7 +5,7 @@ import { projectGuideFiles } from "../project-documents.mjs";
5
5
 
6
6
  export const GUIDE_URI = "ads://project/guide";
7
7
  // Put the task triggers and entry path first: some hosts shorten server guidance.
8
- export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For requested visual evidence, use ads_capture or screenshot-enabled patch dry runs; read ads://guides/captures for named targets, image resources and the current/candidate lifecycle. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. For a failed check or rejected patch, inspect its structured failure boundary and acceptance scope; ads_investigate reads retained per-check evidence without rerunning tests (ads://guides/evidence). Preserve valid APIs when source tracing is inconclusive. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
8
+ export const serverInstructions = "Use ADS for UI work: components, patterns, controllers, hooks, tokens, themes, branding, layout, interaction, accessibility, motion and showcase review. Start with ads_status; if tools are not exposed, read ads://project/guide then ads://project/status. Use the returned projectRoot and commands. When ads_status.mutations.enabled, use ads_units and guarded ads_apply_patch for one owner; use ads_apply_transaction only for coupled owner patches. Find existing public UI with ads_catalog; discover its previews with ads_scenarios and open ads_show_scenario. Starting ADS also serves its built-in showcase: use ads_status.showcaseUrl for browser review instead of starting another runtime/compiler or project browser app. Read one relevant ADS skill before editing. Inspect the real preview, edit its owned source, check the build and review again. Scope source/guides to the project, its instruction ancestry and user-selected import/reference sources; broad searches of /mnt or home are not ADS discovery. Domain-model/requirements work is a separate project workflow. For requested visual evidence, use ads_capture or screenshot-enabled patch dry runs; read ads://guides/captures for named targets, image resources and the current/candidate lifecycle. For behavior work, read ads://guides/behaviors and use ads_behaviors/ads_verify: scenario exercises are demonstrations, and registered guarantees need current passing execution evidence. Execution replies are bounded summaries. Read only needed sections with ads_results(resultId, section, status, cursor); follow nextCursor without rerunning. For a failure, inspect its boundary and acceptance, then ads_investigate with the outcome's exact unit/check/runId; detail sections and artifacts load separately (ads://guides/evidence). Preserve valid APIs when source tracing is inconclusive. Use the configured project framework and declared dependencies; MCP hosts supply no ad hoc APIs. MCP source checks do not replace browser tests; direct edits still need project checks. Reading HTML is not rendering, and reading a skill is not native activation. A running watcher is not proof of current source: inspect ads_status.build or ads://project/build after edits. Notifications may not reach the agent. Only explicitly enabled mutation tools edit project-owned ADS units after validation. Checks and compiler commands come from validated project configuration, never an ad hoc command argument.";
9
9
 
10
10
  function manager(root, pkg) {
11
11
  if (typeof pkg.packageManager === "string") return pkg.packageManager.split("@")[0];
@@ -58,6 +58,8 @@ Use this server for UI components/patterns, tokens/themes/branding, layout, inte
58
58
  Inspection connects a public export to its owning source, available guides, token values and discovered preview IDs. Bounded tools keep discovery focused; explicit resource reads supply detail. Source metadata does not prove layout, interaction or motion. A scenario renders the real public implementation and its fixture context in either review surface.
59
59
  5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements; a real selected-owner re-export can remove its redundant export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; use ads_apply_transaction for coupled owners instead of bypassing rejection with direct writes. ads_prepare_patch creates a local owner draft/diff; ads_prepare_token_samples reconciles explicit project bindings without guessing visual meaning. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
60
60
  6. ${route.validation} Registered commands: ${context.commands.map((command) => command.args?.join(" ") ?? `choose the project package manager for ${command.script}`).join("; ") || "none discovered; inspect the project's build setup"}. Run them from ${context.projectRoot}.
61
+ Execution replies from ads_verify and guarded edits contain counts, acceptance and resultId, not full reports. Read only needed outcomes/diagnostics/captures with ads_results, following nextCursor with identical filters. A truncated row has recordIndex for bounded text ranges. For one failure, use ads_investigate with its unit/check/runId; read request/error/artifact sections only when needed. Only the latest verification and mutation results, and latest investigation per check, are available. Notifications do not establish freshness.
62
+
61
63
  7. After edits, inspect ads_status.build or ads://project/build explicitly. Running/unreported/old output is not a current successful preview. Fix diagnostics in owned source, wait for rebuilding and invoke the preview tool again. Notifications may not start an agent turn.
62
64
 
63
65
  Project guide resources: ${context.guides.map((guide) => `${guide.file}: ${guide.uri}`).join("; ") || "none discovered"}.
@@ -0,0 +1,146 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { compactAcceptance, compactRow, jsonBytes, resultBudgets, resultPage, resultRecord } from "./result-pages.mjs";
5
+
6
+ export const RESULTS_URI = "ads://project/results";
7
+ export const resultSections = ["outcomes", "checks", "diagnostics", "advisories", "warnings", "changes", "captures", "acceptance", "investigation", "selection"];
8
+ const kinds = ["verification", "mutation"];
9
+ const maxStoredBytes = 8 * 1024 * 1024;
10
+ const uuid = value => typeof value === "string" && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
11
+ const resultUri = id => `ads://results/${id}`;
12
+ const count = items => {
13
+ const statuses = {};
14
+ for (const item of items) { const status = item.status ?? (item.skipped ? "skipped" : item.passed === true ? "passed" : item.passed === false ? "failed" : "unknown"); statuses[status] = (statuses[status] ?? 0) + 1; }
15
+ return { total: items.length, ...statuses };
16
+ };
17
+
18
+ function outcome(item) {
19
+ const { current, investigation, ...detail } = item;
20
+ return { ...detail, inputsCurrentAtCompletion:current,
21
+ investigation: { unit:item.unit, check:item.check, runId:item.runId, resourceUri:investigation?.resourceUri } };
22
+ }
23
+
24
+ function snapshot(kind, raw) {
25
+ const outcomes = raw.results ?? raw.behavior?.results ?? (raw.checks ?? []).flatMap(check => check.results ?? []);
26
+ const sections = {
27
+ outcomes: outcomes.map(outcome),
28
+ checks: (raw.checks ?? []).map(({ results, ...check }) => ({ ...check, ...(results ? { outcomeCount: results.length } : {}) })),
29
+ diagnostics: raw.diagnostics ?? [], advisories: raw.advisories ?? [], warnings: raw.warnings ?? [], changes: raw.changes ?? [],
30
+ captures: raw.screenshots?.captures ?? [], selection: [{unit:raw.unit,units:raw.units,transaction:raw.transaction}],
31
+ acceptance: raw.acceptance ? [raw.acceptance] : [], investigation: raw.investigation ? [raw.investigation] : [],
32
+ };
33
+ const resultId = randomUUID();
34
+ return { schemaVersion: 1, resultId, kind, createdAt: new Date().toISOString(), ok: !!raw.ok,
35
+ ...(raw.applied !== undefined ? { applied: raw.applied } : {}), ...(raw.unit ? { unit: raw.unit } : {}),
36
+ ...(raw.units ? { units: raw.units } : {}), ...(raw.transaction ? { transaction: true } : {}),
37
+ acceptance: raw.acceptance, investigation: raw.investigation,
38
+ sections: Object.fromEntries(Object.entries(sections).map(([key, rows]) => [key, rows.map((row, recordIndex) => ({ recordIndex, ...(row && typeof row === "object" ? row : {message:row}) }))])),
39
+ counts: { outcomes: count(outcomes), checks: count(sections.checks), diagnostics: sections.diagnostics.length,
40
+ advisories: sections.advisories.length, warnings: sections.warnings.length, changes: sections.changes.length, captures: sections.captures.length },
41
+ resourceUri: resultUri(resultId) };
42
+ }
43
+
44
+ function summary(value, storageWarning) {
45
+ const { ok, resultId, kind, applied, unit, units, transaction, createdAt, counts, resourceUri } = value;
46
+ const failedOutcomes = value.sections.outcomes.filter(item => item.status !== "passed");
47
+ const failureRows = failedOutcomes.length ? failedOutcomes : value.sections.diagnostics;
48
+ const section = failedOutcomes.length ? "outcomes" : value.sections.diagnostics.length ? "diagnostics" : value.sections.captures.length ? "captures" : value.sections.outcomes.length ? "outcomes" : "checks";
49
+ const result = { ok, resultId, kind, ...(applied !== undefined ? { applied } : {}), ...(unit ? { unit } : {}),
50
+ ...(units ? { units } : {}), ...(transaction ? { transaction } : {}), createdAt, counts, freshness:"recorded-at-completion",
51
+ acceptance: compactAcceptance(value.acceptance), failures: value.failurePreview ?? failureRows.slice(0, 3).map(item => compactRow({recordIndex:item.recordIndex,unit:item.unit,check:item.check,status:item.status,rule:item.rule,file:item.file,failure:item.failure,message:item.message},640)),
52
+ failureCount: value.failureCount ?? failureRows.length, resourceUri,
53
+ next: value.detailsUnavailable ? null : { tool: "ads_results", arguments: { resultId, section, ...(section === "outcomes" && failedOutcomes.length ? { status: "not_passed" } : {}) } },
54
+ ...((storageWarning ?? value.storageWarning) ? { storageWarning: storageWarning ?? value.storageWarning } : {}), byteLimit: resultBudgets.summary };
55
+ if (jsonBytes(result) > resultBudgets.summary) {
56
+ result.failures = failureRows.slice(0, 3).map(item => compactRow(item, 384));
57
+ result.detailTruncated = true;
58
+ }
59
+ if (jsonBytes(result) > resultBudgets.summary) {
60
+ result.failures = []; result.detailTruncated = true;
61
+ result.acceptance = { scope: value.acceptance?.scope, status: value.acceptance?.status, detailTruncated: true };
62
+ }
63
+ if (jsonBytes(result) > resultBudgets.summary) {
64
+ delete result.unit; delete result.units;
65
+ result.acceptance = { status: value.acceptance?.status, detailTruncated: true };
66
+ }
67
+ return result;
68
+ }
69
+
70
+ /** One immutable, replaceable snapshot per execution kind. No result history. */
71
+ export function createExecutionResults(root) {
72
+ root = path.resolve(root);
73
+ const folder = path.join(root, ".ds/tool-results"), memory = new Map();
74
+ function file(kind) { return path.join(folder, `${kind}.json`); }
75
+ function read(kind) {
76
+ const temporary = memory.get(kind);
77
+ if (temporary?.memoryOnly) return temporary.value;
78
+ const target = file(kind);
79
+ try {
80
+ if (realpathSync(target) !== target || !lstatSync(target).isFile() || lstatSync(target).nlink !== 1 || lstatSync(target).size > maxStoredBytes) return undefined;
81
+ const value = JSON.parse(readFileSync(target, "utf8"));
82
+ return value.schemaVersion === 1 && value.kind === kind && uuid(value.resultId) && resultSections.every(section => Array.isArray(value.sections?.[section])) ? value : undefined;
83
+ } catch { return undefined; }
84
+ }
85
+ function find(resultId) {
86
+ if (!uuid(resultId)) throw new Error("Use an exact resultId returned by an execution summary.");
87
+ const value = kinds.map(read).find(value => value?.resultId === resultId);
88
+ if (!value) throw new Error("Result is unavailable or superseded. Only the latest completed result of each kind is retained; discover its ID with ads_status.");
89
+ return value;
90
+ }
91
+ return {
92
+ save(kind, raw) {
93
+ if (!kinds.includes(kind)) throw new Error("Unknown execution kind.");
94
+ const value = snapshot(kind, raw);
95
+ let content = JSON.stringify(value);
96
+ if (Buffer.byteLength(content) > maxStoredBytes) {
97
+ const preview=summary(value);
98
+ value.failurePreview=preview.failures;value.failureCount=preview.failureCount;
99
+ value.sections=Object.fromEntries(resultSections.map(section=>[section,[]]));
100
+ value.acceptance=preview.acceptance;delete value.investigation;
101
+ value.detailsUnavailable=true;
102
+ value.storageWarning="Result details exceeded the 8 MiB retention limit. Only this summary is retained; execution and applied state above are unchanged.";
103
+ content=JSON.stringify(value);
104
+ }
105
+ let storageWarning;
106
+ try {
107
+ let ancestor=folder;
108
+ while(true){try{lstatSync(ancestor);break;}catch(error){if(error.code!=="ENOENT")throw error;ancestor=path.dirname(ancestor);}}
109
+ if(realpathSync(ancestor)!==ancestor)throw new Error("Result output cannot resolve through a symlink.");
110
+ mkdirSync(folder, { recursive: true });
111
+ if (realpathSync(folder) !== folder) throw new Error("Result output cannot resolve through a symlink.");
112
+ const staged = `${file(kind)}.${randomUUID()}.tmp`;
113
+ try { writeFileSync(staged, content); renameSync(staged, file(kind)); } finally { rmSync(staged, { force: true }); }
114
+ memory.set(kind, { value, memoryOnly: false });
115
+ } catch {
116
+ memory.set(kind, { value: JSON.parse(content), memoryOnly: true });
117
+ storageWarning = "Result details are available in this server's memory only; storage failed. Execution and applied state above are unchanged.";
118
+ }
119
+ return summary(value, storageWarning);
120
+ },
121
+ latest(kind) { const value = read(kind); return value ? summary(value, memory.get(kind)?.memoryOnly ? "Result details are in server memory only." : undefined) : null; },
122
+ index() { return { retention: "latest-per-kind", results: kinds.map(kind => this.latest(kind)).filter(Boolean).map(({resultId,kind,ok,applied,createdAt,counts,resourceUri})=>({resultId,kind,ok,applied,createdAt,counts,resourceUri})) }; },
123
+ fingerprint() { return kinds.map(kind=>{if(memory.get(kind)?.memoryOnly)return memory.get(kind).value.resultId;try{const stat=lstatSync(file(kind));return `${kind}:${stat.mtimeMs}:${stat.size}`;}catch{return `${kind}:missing`;}}).join("\n"); },
124
+ page({ resultId, section = "outcomes", status = "all", cursor, limit, recordIndex, offset, chars } = {}) {
125
+ const value = find(resultId);
126
+ if(value.detailsUnavailable)throw new Error("This result exceeded the 8 MiB retention limit; only its summary is available.");
127
+ if (!resultSections.includes(section) || !["all", "passed", "not_passed"].includes(status) || status !== "all" && section !== "outcomes") throw new Error("Select a registered result section; status filters apply only to outcomes.");
128
+ if (recordIndex !== undefined) {
129
+ if (cursor !== undefined || status !== "all") throw new Error("Read record ranges without a cursor or status filter.");
130
+ return resultRecord(value.sections[section],{recordIndex,offset,chars,header:{resultId,section}});
131
+ }
132
+ if(offset!==undefined||chars!==undefined)throw new Error("Select a recordIndex before reading text ranges.");
133
+ const rows = value.sections[section].filter(row => status === "all" || (status === "passed" ? row.status === "passed" : row.status !== "passed"));
134
+ return resultPage(rows, { cursor, limit, identity: [resultId, section, status], header: { resultId, kind: value.kind, section, status, freshness:"recorded-at-completion" } });
135
+ },
136
+ readResource(uri) {
137
+ if (uri === RESULTS_URI) return { uri, mimeType: "application/json", text: JSON.stringify(this.index()) };
138
+ const url = new URL(uri);
139
+ if (url.protocol !== "ads:" || url.hostname !== "results" || [...url.searchParams.keys()].some(key => !["section", "status", "cursor", "limit", "recordIndex", "offset", "chars"].includes(key))) throw new Error("Invalid execution result resource URI.");
140
+ const id = url.pathname.slice(1);
141
+ const value = url.search ? this.page({ resultId: id, section: url.searchParams.get("section") ?? "outcomes", status: url.searchParams.get("status") ?? "all", cursor: url.searchParams.get("cursor") ?? undefined,
142
+ ...Object.fromEntries(["limit","recordIndex","offset","chars"].filter(key=>url.searchParams.has(key)).map(key=>[key,Number(url.searchParams.get(key))])) }) : summary(find(id));
143
+ return { uri, mimeType: "application/json", text: JSON.stringify(value) };
144
+ },
145
+ };
146
+ }
@@ -279,7 +279,7 @@ export function createMutationController(directory, { captures = createCaptureCo
279
279
  const expected = snapshot(candidateOptions);
280
280
  candidateRevision=digest(Buffer.from(expected));
281
281
  const result = checkProject(candidate, { trackingRoot: root });
282
- if (!result.ok) return lastResult = mutationEvidence({ ok: false, applied: false, diagnostics: result.diagnostics.slice(0, 50), total: result.diagnostics.length, checks: verified },candidateRevision);
282
+ if (!result.ok) return lastResult = mutationEvidence({ ok: false, applied: false, diagnostics: result.diagnostics, total: result.diagnostics.length, checks: verified },candidateRevision);
283
283
  verified.push({ name: "ads check", passed: true });
284
284
  const lintConfig = generateLintConfig(candidate).file;
285
285
  const require = createRequire(path.join(candidate, "package.json"));
@@ -305,7 +305,7 @@ export function createMutationController(directory, { captures = createCaptureCo
305
305
  let evidence;
306
306
  try { evidence = await verification.run({ units: behaviorUnits, persist: false }, signal); }
307
307
  finally { await verification.close(); }
308
- if (!evidence.ok) return lastResult = mutationEvidence({ ok: false, applied: false, diagnostics: [...evidence.results.filter(item => item.status !== "passed").map(item => ({ rule: "behavior-check", failure:item.failure, message: `${item.unit}#${item.check}: ${item.status}. ${item.message}` })),...evidence.diagnostics].slice(0, 50), checks: verified, behavior: evidence },candidateRevision);
308
+ if (!evidence.ok) return lastResult = mutationEvidence({ ok: false, applied: false, diagnostics: [...evidence.results.filter(item => item.status !== "passed").map(item => ({ rule: "behavior-check", failure:item.failure, message: `${item.unit}#${item.check}: ${item.status}. ${item.message}` })),...evidence.diagnostics], checks: verified, behavior: evidence },candidateRevision);
309
309
  verified.push({ name: "registered behavior checks", passed: true, results: evidence.results });
310
310
  }
311
311
  let screenshots;
@@ -353,7 +353,7 @@ export function createMutationController(directory, { captures = createCaptureCo
353
353
  const warnings = [];
354
354
  if (!dryRun && (assets || configuration)) { try { await generateBrandAssets(root); } catch (error) { warnings.push({ rule: "mutation-assets", message: `Source was applied; generated brand output needs recovery: ${error.message}` }); } }
355
355
  if (!dryRun && captures.store.fingerprint()) { try { captures.store.promote(previewSourceRevision(loadConfig(root)), inspectScenarios(loadConfig(root)).scenarios); } catch (error) { warnings.push({ rule: "mutation-capture", message: `Source was applied; capture promotion needs recovery: ${error.message}` }); } }
356
- return lastResult = mutationEvidence({ ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories.slice(0, 50), advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
356
+ return lastResult = mutationEvidence({ ok: true, ...(patches ? {units,transaction:true} : {unit}), applied: !dryRun, changes: changes.map(change => ({ path: change.path, operation: change.content === null ? "delete" : change.expectedDigest === null ? "create" : "update", digest: change.content === null ? null : digest(Buffer.from(change.content)) })), checks: verified, ...(result.advisories?.length ? { advisories: result.advisories, advisoryCount: result.advisories.length } : {}), ...(warnings.length ? { warnings } : {}),
357
357
  ...(screenshots ? { screenshots } : {}),
358
358
  preview: "Source validation passed. Inspect the managed preview build and rendered scenario after applying; validation is not visual verification." },candidateRevision);
359
359
  } catch (error) {
@@ -0,0 +1,87 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const resultBudgets = { summary: 4096, page: 8192, row: 2048, defaultLimit: 10, maxLimit: 20 };
4
+ export const jsonBytes = value => Buffer.byteLength(JSON.stringify(value));
5
+
6
+ /** Projection is only for delivery; validation always uses the complete result. */
7
+ export function compactValue(input, { string = 500, array = 8, keys = 24, depth = 5 } = {}) {
8
+ let truncated = false;
9
+ function visit(value, level) {
10
+ if (typeof value === "string") {
11
+ if (value.length <= string) return value;
12
+ truncated = true; return `${value.slice(0, string)}…`;
13
+ }
14
+ if (value === null || typeof value === "number" || typeof value === "boolean" || value === undefined) return value;
15
+ if (level >= depth) { truncated = true; return "[omitted]"; }
16
+ if (Array.isArray(value)) { if (value.length > array) truncated = true; return value.slice(0, array).map(item => visit(item, level + 1)); }
17
+ const entries = Object.entries(value);
18
+ if (entries.length > keys) truncated = true;
19
+ return Object.fromEntries(entries.slice(0, keys).map(([key, item]) => [key, visit(item, level + 1)]));
20
+ }
21
+ const value = visit(input, 0);
22
+ return { value, truncated };
23
+ }
24
+
25
+ export function compactRow(input, bytes = resultBudgets.row) {
26
+ if (input === null || typeof input !== "object" || Array.isArray(input)) input = { value: input };
27
+ for (const limits of [{}, { string: 160, array: 3, keys: 18 }, { string: 60, array: 1, keys: 12, depth: 3 }]) {
28
+ const { value, truncated } = compactValue(input, limits);
29
+ const row = { ...value, ...(truncated ? { detailTruncated: true } : {}) };
30
+ if (jsonBytes(row) <= bytes) return row;
31
+ }
32
+ const row = Object.fromEntries(["recordIndex", "id", "unit", "check", "status", "passed", "rule", "file", "path", "name"].filter(key => input[key] !== undefined).slice(0, 4).map(key => [key, typeof input[key] === "string" ? input[key].slice(0, 20) : input[key]]));
33
+ const result = { ...row, detailTruncated: true };
34
+ return jsonBytes(result) <= bytes ? result : { detailTruncated: true };
35
+ }
36
+
37
+ export function compactAcceptance(value) {
38
+ if (!value) return undefined;
39
+ const establishedCount=value.establishedCount??value.established?.length??0,unverifiedCount=value.unverifiedCount??value.unverified?.length??0;
40
+ return { scope: value.scope, status: value.status, establishedCount, unverifiedCount,
41
+ established: (value.established ?? []).slice(0, 3).map(item => String(item).slice(0, 100)),
42
+ unverified: (value.unverified ?? []).slice(0, 12).map(item => String(item).slice(0, 100)),
43
+ ...(value.detailTruncated || establishedCount > 3 || unverifiedCount > 12 ? { detailTruncated: true } : {}) };
44
+ }
45
+
46
+ /** The cursor binds the immutable result/query, not just an array offset. */
47
+ export function resultPage(items, { cursor, limit = resultBudgets.defaultLimit, identity, header = {}, key = "items" } = {}) {
48
+ if (!Number.isInteger(limit) || limit < 1 || limit > resultBudgets.maxLimit) throw new Error("Use a page limit from 1 to 20.");
49
+ const stamp = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 24);
50
+ let offset = 0;
51
+ if (cursor !== undefined) {
52
+ if (typeof cursor !== "string" || cursor.length > 300) throw new Error("Invalid result cursor.");
53
+ const match = /^([a-f0-9]{24}):(\d+)$/.exec(cursor);
54
+ offset = match && match[1] === stamp ? Number(match[2]) : -1;
55
+ if (!Number.isSafeInteger(offset) || offset <= 0 || offset >= items.length) throw new Error("Invalid or stale result cursor; restart this listing.");
56
+ }
57
+ const selected = [];
58
+ const envelope = () => ({ ...header, total: items.length, returned: selected.length, byteLimit: resultBudgets.page, [key]: selected,
59
+ ...(offset + selected.length < items.length ? { nextCursor: `${stamp}:${offset + selected.length}` } : {}) });
60
+ for (const item of items.slice(offset, offset + limit)) {
61
+ selected.push(compactRow(item));
62
+ if (jsonBytes(envelope()) > resultBudgets.page) { selected.pop(); break; }
63
+ }
64
+ if (offset < items.length && !selected.length) throw new Error("The page envelope exceeds the response budget.");
65
+ const result = envelope();
66
+ if (jsonBytes(result) > resultBudgets.page) throw new Error("The page exceeds its response budget.");
67
+ return result;
68
+ }
69
+
70
+ export function compactProgress(value) {
71
+ if (!value) return undefined;
72
+ const { stage, profile, elapsedMs, timeoutMs, total, completed, pending, workers } = value;
73
+ return { stage, profile, elapsedMs, timeoutMs, total, completed, pending, workers, activeCount: value.active?.length ?? 0,
74
+ active: (value.active ?? []).slice(0, 3).map(item => compactRow(item, 512)) };
75
+ }
76
+
77
+ /** Exact JSON ranges complement lossy row previews without unbounded replies. */
78
+ export function resultRecord(rows, { recordIndex, offset = 0, chars = 2000, header = {} } = {}) {
79
+ if (!Number.isInteger(recordIndex) || recordIndex < 0 || recordIndex >= rows.length || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(chars) || chars < 1 || chars > 2000) throw new Error("Read a valid recordIndex with an offset and 1–2000 characters.");
80
+ const source=JSON.stringify(rows[recordIndex]);
81
+ if(offset>source.length)throw new Error("Offset exceeds the selected record.");
82
+ let end=Math.min(source.length,offset+chars);
83
+ const result=()=>({...header,recordIndex,offset,totalChars:source.length,text:source.slice(offset,end),nextOffset:end<source.length?end:null,byteLimit:resultBudgets.page});
84
+ while(jsonBytes(result())>resultBudgets.page&&end>offset)end=offset+Math.floor((end-offset)*0.8);
85
+ if(jsonBytes(result())>resultBudgets.page||end===offset&&offset<source.length)throw new Error("Record identity exceeds the response budget.");
86
+ return result();
87
+ }