@contractspec/module.workspace 4.0.3 → 4.1.0

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,12 @@
1
+ export type DocBlockDiagnosticSeverity = 'error' | 'warning';
2
+ export interface DocBlockDiagnostic {
3
+ ruleId: string;
4
+ message: string;
5
+ severity: DocBlockDiagnosticSeverity;
6
+ file: string;
7
+ line?: number;
8
+ column?: number;
9
+ context?: Record<string, unknown>;
10
+ }
11
+ export declare function isAllowedDocOwnerModule(filePath: string, sourceModule: string): boolean;
12
+ export declare function formatDocBlockDiagnostics(diagnostics: readonly DocBlockDiagnostic[]): string;
@@ -0,0 +1,10 @@
1
+ import type { DocBlockManifestEntry } from '@contractspec/lib.contracts-spec/docs';
2
+ export interface ModuleDocAnalysis {
3
+ entries: DocBlockManifestEntry[];
4
+ docRefs: string[];
5
+ docRefExports: Array<{
6
+ exportName: string;
7
+ refs: string[];
8
+ }>;
9
+ }
10
+ export declare function extractModuleDocData(sourceText: string, filePath: string, sourceModule: string): ModuleDocAnalysis;
@@ -0,0 +1,4 @@
1
+ export * from './diagnostics';
2
+ export * from './evaluator';
3
+ export * from './list-source-files';
4
+ export * from './manifest';
@@ -0,0 +1,2 @@
1
+ export declare function listSourceFiles(srcRoot: string): string[];
2
+ export declare function toSourceModule(srcRoot: string, filePath: string): string;
@@ -0,0 +1,14 @@
1
+ import type { PackageDocManifest } from '@contractspec/lib.contracts-spec/docs';
2
+ import { type DocBlockDiagnostic } from './diagnostics';
3
+ export interface PackageDocAnalysisResult {
4
+ manifest: PackageDocManifest;
5
+ diagnostics: DocBlockDiagnostic[];
6
+ }
7
+ export declare function analyzePackageDocBlocks(options: {
8
+ packageName: string;
9
+ srcRoot: string;
10
+ }): PackageDocAnalysisResult;
11
+ export declare function buildPackageDocManifest(options: {
12
+ packageName: string;
13
+ srcRoot: string;
14
+ }): PackageDocManifest;
@@ -0,0 +1,6 @@
1
+ import ts from 'typescript';
2
+ export type JsonValue = string | number | boolean | null | JsonValue[] | {
3
+ [key: string]: JsonValue;
4
+ };
5
+ export declare function evaluateExpression(expression: ts.Expression, locals: Map<string, JsonValue>, filePath: string): JsonValue | undefined;
6
+ export declare function collectLocalValues(sourceFile: ts.SourceFile, filePath: string): Map<string, JsonValue>;
@@ -0,0 +1 @@
1
+ export {};
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export * from './deps/index';
5
5
  export * from './diff/index';
6
+ export * from './docblocks';
6
7
  export * from './example-scan';
7
8
  export * from './feature-scan';
8
9
  export * from './grouping';
package/dist/index.js CHANGED
@@ -918,6 +918,337 @@ function compareStructuralHints(diffs, a, b) {
918
918
  label: "definition section presence"
919
919
  });
920
920
  }
921
+ // src/analysis/docblocks/diagnostics.ts
922
+ import path from "path";
923
+ var OWNER_SUFFIX_PATTERNS = [
924
+ ".feature",
925
+ ".command",
926
+ ".query",
927
+ ".operation",
928
+ ".event",
929
+ ".form",
930
+ ".view",
931
+ ".presentation",
932
+ ".capability",
933
+ ".dataView",
934
+ ".data-view",
935
+ ".service"
936
+ ];
937
+ function isAllowedDocOwnerModule(filePath, sourceModule) {
938
+ const normalizedFilePath = filePath.replaceAll("\\", "/");
939
+ const normalizedSourceModule = sourceModule.replaceAll("\\", "/");
940
+ const baseName = path.posix.basename(normalizedSourceModule);
941
+ if (baseName === "index" || baseName === "spec" || baseName === "feature" || baseName === "extension") {
942
+ return true;
943
+ }
944
+ if (OWNER_SUFFIX_PATTERNS.some((suffix) => baseName.endsWith(suffix))) {
945
+ return true;
946
+ }
947
+ if (/\/(commands|queries|events|forms|views|presentations|capabilities|services)\//.test(normalizedFilePath)) {
948
+ return true;
949
+ }
950
+ return !/\/docs\//.test(normalizedFilePath);
951
+ }
952
+ function formatDocBlockDiagnostics(diagnostics) {
953
+ return diagnostics.map((diagnostic) => {
954
+ const location = diagnostic.line && diagnostic.column ? `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}` : diagnostic.file;
955
+ return `${diagnostic.ruleId}: ${diagnostic.message} (${location})`;
956
+ }).join(`
957
+ `);
958
+ }
959
+ // src/analysis/docblocks/evaluator.ts
960
+ import ts2 from "typescript";
961
+
962
+ // src/analysis/docblocks/static-values.ts
963
+ import ts from "typescript";
964
+ function evaluateExpression(expression, locals, filePath) {
965
+ if (ts.isParenthesizedExpression(expression)) {
966
+ return evaluateExpression(expression.expression, locals, filePath);
967
+ }
968
+ if (ts.isSatisfiesExpression(expression) || ts.isAsExpression(expression)) {
969
+ return evaluateExpression(expression.expression, locals, filePath);
970
+ }
971
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
972
+ return expression.text;
973
+ }
974
+ if (ts.isNumericLiteral(expression)) {
975
+ return Number(expression.text);
976
+ }
977
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) {
978
+ return true;
979
+ }
980
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) {
981
+ return false;
982
+ }
983
+ if (expression.kind === ts.SyntaxKind.NullKeyword) {
984
+ return null;
985
+ }
986
+ if (ts.isArrayLiteralExpression(expression)) {
987
+ const values = [];
988
+ for (const element of expression.elements) {
989
+ const value = evaluateExpression(element, locals, filePath);
990
+ if (value === undefined) {
991
+ return;
992
+ }
993
+ values.push(value);
994
+ }
995
+ return values;
996
+ }
997
+ if (ts.isObjectLiteralExpression(expression)) {
998
+ const value = {};
999
+ for (const property of expression.properties) {
1000
+ if (!ts.isPropertyAssignment(property)) {
1001
+ return;
1002
+ }
1003
+ const name = property.name.getText().replace(/^["']|["']$/g, "");
1004
+ const propertyValue = evaluateExpression(property.initializer, locals, filePath);
1005
+ if (propertyValue === undefined) {
1006
+ return;
1007
+ }
1008
+ value[name] = propertyValue;
1009
+ }
1010
+ return value;
1011
+ }
1012
+ if (ts.isIdentifier(expression)) {
1013
+ return locals.get(expression.text);
1014
+ }
1015
+ if (ts.isPropertyAccessExpression(expression)) {
1016
+ const parent = evaluateExpression(expression.expression, locals, filePath);
1017
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)) {
1018
+ return;
1019
+ }
1020
+ return parent[expression.name.text];
1021
+ }
1022
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) {
1023
+ if ((expression.expression.text === "docId" || expression.expression.text === "docRef") && expression.arguments.length === 1) {
1024
+ return evaluateExpression(expression.arguments[0], locals, filePath);
1025
+ }
1026
+ }
1027
+ return;
1028
+ }
1029
+ function collectLocalValues(sourceFile, filePath) {
1030
+ const locals = new Map;
1031
+ for (const statement of sourceFile.statements) {
1032
+ if (!ts.isVariableStatement(statement)) {
1033
+ continue;
1034
+ }
1035
+ for (const declaration of statement.declarationList.declarations) {
1036
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
1037
+ continue;
1038
+ }
1039
+ const value = evaluateExpression(declaration.initializer, locals, filePath);
1040
+ if (value !== undefined) {
1041
+ locals.set(declaration.name.text, value);
1042
+ }
1043
+ }
1044
+ }
1045
+ return locals;
1046
+ }
1047
+
1048
+ // src/analysis/docblocks/evaluator.ts
1049
+ function isDocBlockValue(value) {
1050
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1051
+ return false;
1052
+ }
1053
+ const candidate = value;
1054
+ return typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.body === "string";
1055
+ }
1056
+ function extractDocRefsFromExpression(expression, locals, filePath) {
1057
+ let candidate = expression;
1058
+ while (ts2.isParenthesizedExpression(candidate) || ts2.isSatisfiesExpression(candidate) || ts2.isAsExpression(candidate)) {
1059
+ candidate = candidate.expression;
1060
+ }
1061
+ if (!ts2.isObjectLiteralExpression(candidate)) {
1062
+ return [];
1063
+ }
1064
+ const metaProperty = candidate.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText() === "meta");
1065
+ if (!metaProperty || !ts2.isPropertyAssignment(metaProperty)) {
1066
+ return [];
1067
+ }
1068
+ let metaInitializer = metaProperty.initializer;
1069
+ while (ts2.isParenthesizedExpression(metaInitializer) || ts2.isSatisfiesExpression(metaInitializer) || ts2.isAsExpression(metaInitializer)) {
1070
+ metaInitializer = metaInitializer.expression;
1071
+ }
1072
+ if (!ts2.isObjectLiteralExpression(metaInitializer)) {
1073
+ return [];
1074
+ }
1075
+ const docIdProperty = metaInitializer.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText() === "docId");
1076
+ if (!docIdProperty || !ts2.isPropertyAssignment(docIdProperty)) {
1077
+ return [];
1078
+ }
1079
+ const refs = evaluateExpression(docIdProperty.initializer, locals, filePath);
1080
+ if (!Array.isArray(refs) || !refs.every((entry) => typeof entry === "string")) {
1081
+ throw new Error(`Non-static DocBlock reference in ${filePath} at ${docIdProperty.initializer.getStart()}`);
1082
+ }
1083
+ return refs;
1084
+ }
1085
+ function extractModuleDocData(sourceText, filePath, sourceModule) {
1086
+ const sourceFile = ts2.createSourceFile(filePath, sourceText, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1087
+ const locals = collectLocalValues(sourceFile, filePath);
1088
+ const entries = [];
1089
+ const docRefs = new Set;
1090
+ const docRefExports = [];
1091
+ for (const statement of sourceFile.statements) {
1092
+ if (!ts2.isVariableStatement(statement)) {
1093
+ continue;
1094
+ }
1095
+ const isExported = statement.modifiers?.some((modifier) => modifier.kind === ts2.SyntaxKind.ExportKeyword);
1096
+ if (!isExported) {
1097
+ continue;
1098
+ }
1099
+ for (const declaration of statement.declarationList.declarations) {
1100
+ if (!ts2.isIdentifier(declaration.name) || !declaration.initializer) {
1101
+ continue;
1102
+ }
1103
+ const value = evaluateExpression(declaration.initializer, locals, filePath);
1104
+ const declarationRefs = extractDocRefsFromExpression(declaration.initializer, locals, filePath);
1105
+ for (const ref of declarationRefs) {
1106
+ docRefs.add(ref);
1107
+ }
1108
+ if (declarationRefs.length > 0) {
1109
+ docRefExports.push({
1110
+ exportName: declaration.name.text,
1111
+ refs: declarationRefs
1112
+ });
1113
+ }
1114
+ if (value === undefined) {
1115
+ if (declaration.name.text.includes("DocBlock") || declaration.initializer.getText(sourceFile).includes("satisfies DocBlock")) {
1116
+ throw new Error(`Non-static DocBlock source in ${filePath} at ${declaration.initializer.getStart(sourceFile)}`);
1117
+ }
1118
+ continue;
1119
+ }
1120
+ if (isDocBlockValue(value)) {
1121
+ entries.push({
1122
+ id: value.id,
1123
+ exportName: declaration.name.text,
1124
+ sourceModule,
1125
+ block: value
1126
+ });
1127
+ }
1128
+ if (Array.isArray(value) && value.every((entry) => isDocBlockValue(entry))) {
1129
+ for (const block of value) {
1130
+ entries.push({
1131
+ id: block.id,
1132
+ exportName: declaration.name.text,
1133
+ sourceModule,
1134
+ block
1135
+ });
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+ return { entries, docRefs: [...docRefs], docRefExports };
1141
+ }
1142
+ // src/analysis/docblocks/list-source-files.ts
1143
+ import fs from "fs";
1144
+ import path2 from "path";
1145
+ function shouldIgnoreSourceFile(filePath) {
1146
+ return !/\.[cm]?[jt]sx?$/.test(filePath) || filePath.endsWith(".d.ts") || filePath.endsWith(".test.ts") || filePath.endsWith(".generated.ts") || filePath.includes(`${path2.sep}dist${path2.sep}`);
1147
+ }
1148
+ function listSourceFiles(srcRoot) {
1149
+ return fs.readdirSync(srcRoot, { withFileTypes: true }).flatMap((entry) => {
1150
+ const absolutePath = path2.join(srcRoot, entry.name);
1151
+ if (entry.isDirectory()) {
1152
+ return listSourceFiles(absolutePath);
1153
+ }
1154
+ return shouldIgnoreSourceFile(absolutePath) ? [] : [absolutePath];
1155
+ }).sort((left, right) => left.localeCompare(right));
1156
+ }
1157
+ function toSourceModule(srcRoot, filePath) {
1158
+ return path2.relative(srcRoot, filePath).replace(/\\/g, "/").replace(/\.[cm]?[jt]sx?$/, "");
1159
+ }
1160
+ // src/analysis/docblocks/manifest.ts
1161
+ import fs2 from "fs";
1162
+ function createDiagnostic(ruleId, message, file, context) {
1163
+ return {
1164
+ ruleId,
1165
+ message,
1166
+ severity: "error",
1167
+ file,
1168
+ context
1169
+ };
1170
+ }
1171
+ function analyzePackageDocBlocks(options) {
1172
+ const { packageName, srcRoot } = options;
1173
+ const diagnostics = [];
1174
+ const entries = [];
1175
+ const seenIds = new Map;
1176
+ const seenRoutes = new Map;
1177
+ const moduleRecords = new Map;
1178
+ for (const filePath of listSourceFiles(srcRoot)) {
1179
+ if (filePath.endsWith(".docblock.ts")) {
1180
+ diagnostics.push(createDiagnostic("docblock-standalone-source", "Standalone DocBlock sources are not allowed.", filePath));
1181
+ }
1182
+ if (filePath.includes("/docs/tech/") || filePath.includes("\\docs\\tech\\")) {
1183
+ diagnostics.push(createDiagnostic("docblock-tech-folder", "docs/tech source files are not allowed.", filePath));
1184
+ }
1185
+ const sourceModule = toSourceModule(srcRoot, filePath);
1186
+ try {
1187
+ const moduleData = extractModuleDocData(fs2.readFileSync(filePath, "utf8"), filePath, sourceModule);
1188
+ moduleRecords.set(sourceModule, {
1189
+ entries: moduleData.entries,
1190
+ docRefExports: moduleData.docRefExports,
1191
+ filePath,
1192
+ sourceModule
1193
+ });
1194
+ if (moduleData.entries.length > 0 && !isAllowedDocOwnerModule(filePath, sourceModule)) {
1195
+ diagnostics.push(createDiagnostic("docblock-orphan-owner", "DocBlocks must be exported from the owner module, not a docs-only helper file.", filePath, { sourceModule }));
1196
+ }
1197
+ for (const entry of moduleData.entries) {
1198
+ const sourceRef = `${entry.sourceModule}:${entry.exportName}`;
1199
+ const priorId = seenIds.get(entry.id);
1200
+ if (priorId) {
1201
+ diagnostics.push(createDiagnostic("docblock-duplicate-id", `Duplicate DocBlock id ${entry.id} in ${sourceRef} and ${priorId}.`, filePath, { docId: entry.id, prior: priorId, sourceRef }));
1202
+ } else {
1203
+ seenIds.set(entry.id, sourceRef);
1204
+ }
1205
+ if (entry.block.route) {
1206
+ const priorRoute = seenRoutes.get(entry.block.route);
1207
+ if (priorRoute) {
1208
+ diagnostics.push(createDiagnostic("docblock-duplicate-route", `Duplicate DocBlock route ${entry.block.route} in ${sourceRef} and ${priorRoute}.`, filePath, { route: entry.block.route, prior: priorRoute, sourceRef }));
1209
+ } else {
1210
+ seenRoutes.set(entry.block.route, sourceRef);
1211
+ }
1212
+ }
1213
+ entries.push(entry);
1214
+ }
1215
+ } catch (error) {
1216
+ diagnostics.push(createDiagnostic("docblock-non-static-source", error instanceof Error ? error.message : `Non-static DocBlock source in ${filePath}.`, filePath));
1217
+ }
1218
+ }
1219
+ for (const record of moduleRecords.values()) {
1220
+ const localIds = new Set(record.entries.map((entry) => entry.id));
1221
+ for (const docRefExport of record.docRefExports) {
1222
+ for (const ref of docRefExport.refs) {
1223
+ if (localIds.has(ref)) {
1224
+ continue;
1225
+ }
1226
+ if (seenIds.has(ref)) {
1227
+ diagnostics.push(createDiagnostic("docblock-cross-file-reference", `${docRefExport.exportName} references DocBlock ${ref}, but the DocBlock is not exported from the same module.`, record.filePath, { docId: ref, exportName: docRefExport.exportName }));
1228
+ continue;
1229
+ }
1230
+ diagnostics.push(createDiagnostic("docblock-missing-ref", `${docRefExport.exportName} references missing DocBlock ${ref}.`, record.filePath, { docId: ref, exportName: docRefExport.exportName }));
1231
+ }
1232
+ }
1233
+ }
1234
+ entries.sort((left, right) => left.id.localeCompare(right.id));
1235
+ return {
1236
+ manifest: {
1237
+ packageName,
1238
+ generatedAt: new Date().toISOString(),
1239
+ blocks: entries
1240
+ },
1241
+ diagnostics
1242
+ };
1243
+ }
1244
+ function buildPackageDocManifest(options) {
1245
+ const analysis = analyzePackageDocBlocks(options);
1246
+ const failures = analysis.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1247
+ if (failures.length > 0) {
1248
+ throw new Error(formatDocBlockDiagnostics(failures));
1249
+ }
1250
+ return analysis.manifest;
1251
+ }
921
1252
  // src/analysis/example-scan.ts
922
1253
  function isExampleFile(filePath) {
923
1254
  return filePath.includes("/example.") || filePath.endsWith(".example.ts");
@@ -1694,8 +2025,8 @@ function sortFields(fields) {
1694
2025
  // src/analysis/snapshot/snapshot.ts
1695
2026
  function generateSnapshot(specs, options = {}) {
1696
2027
  const snapshots = [];
1697
- for (const { path, content } of specs) {
1698
- const scanned = scanSpecSource(content, path);
2028
+ for (const { path: path3, content } of specs) {
2029
+ const scanned = scanSpecSource(content, path3);
1699
2030
  if (options.types && !options.types.includes(scanned.specType)) {
1700
2031
  continue;
1701
2032
  }
@@ -2657,7 +2988,7 @@ async function formatFilesBatch(files, config, options, logger) {
2657
2988
  return formatFiles(files, config, options, logger);
2658
2989
  }
2659
2990
  // src/formatters/spec-markdown.ts
2660
- import * as path from "path";
2991
+ import * as path3 from "path";
2661
2992
  function specToMarkdown(spec, format, optionsOrDepth = 0) {
2662
2993
  const options = typeof optionsOrDepth === "number" ? { depth: optionsOrDepth } : optionsOrDepth;
2663
2994
  const depth = options.depth ?? 0;
@@ -2782,7 +3113,7 @@ function formatFullMode(spec, lines, indent, rootPath) {
2782
3113
  lines.push(`${indent}- **Tags**: ${spec.meta.tags.join(", ")}`);
2783
3114
  }
2784
3115
  if (spec.filePath) {
2785
- const displayPath = rootPath ? path.relative(rootPath, spec.filePath) : spec.filePath;
3116
+ const displayPath = rootPath ? path3.relative(rootPath, spec.filePath) : spec.filePath;
2786
3117
  lines.push(`${indent}- **File**: \`${displayPath}\``);
2787
3118
  }
2788
3119
  lines.push("");
@@ -4191,6 +4522,7 @@ export const ${runnerName} = new WorkflowRunner({
4191
4522
  }
4192
4523
  export {
4193
4524
  validateSpecStructure,
4525
+ toSourceModule,
4194
4526
  toPascalCase,
4195
4527
  toKebabCase,
4196
4528
  toDot,
@@ -4207,9 +4539,11 @@ export {
4207
4539
  parseImportedSpecNames,
4208
4540
  normalizeValue,
4209
4541
  loadSpecFromSource,
4542
+ listSourceFiles,
4210
4543
  isFeatureFile,
4211
4544
  isExampleFile,
4212
4545
  isBreakingChange,
4546
+ isAllowedDocOwnerModule,
4213
4547
  inferSpecTypeFromFilePath,
4214
4548
  inferSpecTypeFromCodeBlock,
4215
4549
  groupSpecsToArray,
@@ -4239,12 +4573,14 @@ export {
4239
4573
  generateAppBlueprintSpec,
4240
4574
  formatFilesBatch,
4241
4575
  formatFiles,
4576
+ formatDocBlockDiagnostics,
4242
4577
  findMissingDependencies,
4243
4578
  findMatchingRule,
4244
4579
  filterSpecs,
4245
4580
  filterFeatures,
4246
4581
  extractTestTarget,
4247
4582
  extractTestCoverage,
4583
+ extractModuleDocData,
4248
4584
  escapeString,
4249
4585
  detectFormatter,
4250
4586
  detectCycles,
@@ -4261,11 +4597,13 @@ export {
4261
4597
  buildTestPrompt,
4262
4598
  buildReverseEdges,
4263
4599
  buildPresentationSpecPrompt,
4600
+ buildPackageDocManifest,
4264
4601
  buildOperationSpecPrompt,
4265
4602
  buildHandlerPrompt,
4266
4603
  buildFormPrompt,
4267
4604
  buildEventSpecPrompt,
4268
4605
  buildComponentPrompt,
4606
+ analyzePackageDocBlocks,
4269
4607
  addExampleContext,
4270
4608
  addContractNode,
4271
4609
  SpecGroupingStrategies,
@@ -917,6 +917,337 @@ function compareStructuralHints(diffs, a, b) {
917
917
  label: "definition section presence"
918
918
  });
919
919
  }
920
+ // src/analysis/docblocks/diagnostics.ts
921
+ import path from "node:path";
922
+ var OWNER_SUFFIX_PATTERNS = [
923
+ ".feature",
924
+ ".command",
925
+ ".query",
926
+ ".operation",
927
+ ".event",
928
+ ".form",
929
+ ".view",
930
+ ".presentation",
931
+ ".capability",
932
+ ".dataView",
933
+ ".data-view",
934
+ ".service"
935
+ ];
936
+ function isAllowedDocOwnerModule(filePath, sourceModule) {
937
+ const normalizedFilePath = filePath.replaceAll("\\", "/");
938
+ const normalizedSourceModule = sourceModule.replaceAll("\\", "/");
939
+ const baseName = path.posix.basename(normalizedSourceModule);
940
+ if (baseName === "index" || baseName === "spec" || baseName === "feature" || baseName === "extension") {
941
+ return true;
942
+ }
943
+ if (OWNER_SUFFIX_PATTERNS.some((suffix) => baseName.endsWith(suffix))) {
944
+ return true;
945
+ }
946
+ if (/\/(commands|queries|events|forms|views|presentations|capabilities|services)\//.test(normalizedFilePath)) {
947
+ return true;
948
+ }
949
+ return !/\/docs\//.test(normalizedFilePath);
950
+ }
951
+ function formatDocBlockDiagnostics(diagnostics) {
952
+ return diagnostics.map((diagnostic) => {
953
+ const location = diagnostic.line && diagnostic.column ? `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}` : diagnostic.file;
954
+ return `${diagnostic.ruleId}: ${diagnostic.message} (${location})`;
955
+ }).join(`
956
+ `);
957
+ }
958
+ // src/analysis/docblocks/evaluator.ts
959
+ import ts2 from "typescript";
960
+
961
+ // src/analysis/docblocks/static-values.ts
962
+ import ts from "typescript";
963
+ function evaluateExpression(expression, locals, filePath) {
964
+ if (ts.isParenthesizedExpression(expression)) {
965
+ return evaluateExpression(expression.expression, locals, filePath);
966
+ }
967
+ if (ts.isSatisfiesExpression(expression) || ts.isAsExpression(expression)) {
968
+ return evaluateExpression(expression.expression, locals, filePath);
969
+ }
970
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
971
+ return expression.text;
972
+ }
973
+ if (ts.isNumericLiteral(expression)) {
974
+ return Number(expression.text);
975
+ }
976
+ if (expression.kind === ts.SyntaxKind.TrueKeyword) {
977
+ return true;
978
+ }
979
+ if (expression.kind === ts.SyntaxKind.FalseKeyword) {
980
+ return false;
981
+ }
982
+ if (expression.kind === ts.SyntaxKind.NullKeyword) {
983
+ return null;
984
+ }
985
+ if (ts.isArrayLiteralExpression(expression)) {
986
+ const values = [];
987
+ for (const element of expression.elements) {
988
+ const value = evaluateExpression(element, locals, filePath);
989
+ if (value === undefined) {
990
+ return;
991
+ }
992
+ values.push(value);
993
+ }
994
+ return values;
995
+ }
996
+ if (ts.isObjectLiteralExpression(expression)) {
997
+ const value = {};
998
+ for (const property of expression.properties) {
999
+ if (!ts.isPropertyAssignment(property)) {
1000
+ return;
1001
+ }
1002
+ const name = property.name.getText().replace(/^["']|["']$/g, "");
1003
+ const propertyValue = evaluateExpression(property.initializer, locals, filePath);
1004
+ if (propertyValue === undefined) {
1005
+ return;
1006
+ }
1007
+ value[name] = propertyValue;
1008
+ }
1009
+ return value;
1010
+ }
1011
+ if (ts.isIdentifier(expression)) {
1012
+ return locals.get(expression.text);
1013
+ }
1014
+ if (ts.isPropertyAccessExpression(expression)) {
1015
+ const parent = evaluateExpression(expression.expression, locals, filePath);
1016
+ if (!parent || typeof parent !== "object" || Array.isArray(parent)) {
1017
+ return;
1018
+ }
1019
+ return parent[expression.name.text];
1020
+ }
1021
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) {
1022
+ if ((expression.expression.text === "docId" || expression.expression.text === "docRef") && expression.arguments.length === 1) {
1023
+ return evaluateExpression(expression.arguments[0], locals, filePath);
1024
+ }
1025
+ }
1026
+ return;
1027
+ }
1028
+ function collectLocalValues(sourceFile, filePath) {
1029
+ const locals = new Map;
1030
+ for (const statement of sourceFile.statements) {
1031
+ if (!ts.isVariableStatement(statement)) {
1032
+ continue;
1033
+ }
1034
+ for (const declaration of statement.declarationList.declarations) {
1035
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
1036
+ continue;
1037
+ }
1038
+ const value = evaluateExpression(declaration.initializer, locals, filePath);
1039
+ if (value !== undefined) {
1040
+ locals.set(declaration.name.text, value);
1041
+ }
1042
+ }
1043
+ }
1044
+ return locals;
1045
+ }
1046
+
1047
+ // src/analysis/docblocks/evaluator.ts
1048
+ function isDocBlockValue(value) {
1049
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1050
+ return false;
1051
+ }
1052
+ const candidate = value;
1053
+ return typeof candidate.id === "string" && typeof candidate.title === "string" && typeof candidate.body === "string";
1054
+ }
1055
+ function extractDocRefsFromExpression(expression, locals, filePath) {
1056
+ let candidate = expression;
1057
+ while (ts2.isParenthesizedExpression(candidate) || ts2.isSatisfiesExpression(candidate) || ts2.isAsExpression(candidate)) {
1058
+ candidate = candidate.expression;
1059
+ }
1060
+ if (!ts2.isObjectLiteralExpression(candidate)) {
1061
+ return [];
1062
+ }
1063
+ const metaProperty = candidate.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText() === "meta");
1064
+ if (!metaProperty || !ts2.isPropertyAssignment(metaProperty)) {
1065
+ return [];
1066
+ }
1067
+ let metaInitializer = metaProperty.initializer;
1068
+ while (ts2.isParenthesizedExpression(metaInitializer) || ts2.isSatisfiesExpression(metaInitializer) || ts2.isAsExpression(metaInitializer)) {
1069
+ metaInitializer = metaInitializer.expression;
1070
+ }
1071
+ if (!ts2.isObjectLiteralExpression(metaInitializer)) {
1072
+ return [];
1073
+ }
1074
+ const docIdProperty = metaInitializer.properties.find((property) => ts2.isPropertyAssignment(property) && property.name.getText() === "docId");
1075
+ if (!docIdProperty || !ts2.isPropertyAssignment(docIdProperty)) {
1076
+ return [];
1077
+ }
1078
+ const refs = evaluateExpression(docIdProperty.initializer, locals, filePath);
1079
+ if (!Array.isArray(refs) || !refs.every((entry) => typeof entry === "string")) {
1080
+ throw new Error(`Non-static DocBlock reference in ${filePath} at ${docIdProperty.initializer.getStart()}`);
1081
+ }
1082
+ return refs;
1083
+ }
1084
+ function extractModuleDocData(sourceText, filePath, sourceModule) {
1085
+ const sourceFile = ts2.createSourceFile(filePath, sourceText, ts2.ScriptTarget.Latest, true, ts2.ScriptKind.TS);
1086
+ const locals = collectLocalValues(sourceFile, filePath);
1087
+ const entries = [];
1088
+ const docRefs = new Set;
1089
+ const docRefExports = [];
1090
+ for (const statement of sourceFile.statements) {
1091
+ if (!ts2.isVariableStatement(statement)) {
1092
+ continue;
1093
+ }
1094
+ const isExported = statement.modifiers?.some((modifier) => modifier.kind === ts2.SyntaxKind.ExportKeyword);
1095
+ if (!isExported) {
1096
+ continue;
1097
+ }
1098
+ for (const declaration of statement.declarationList.declarations) {
1099
+ if (!ts2.isIdentifier(declaration.name) || !declaration.initializer) {
1100
+ continue;
1101
+ }
1102
+ const value = evaluateExpression(declaration.initializer, locals, filePath);
1103
+ const declarationRefs = extractDocRefsFromExpression(declaration.initializer, locals, filePath);
1104
+ for (const ref of declarationRefs) {
1105
+ docRefs.add(ref);
1106
+ }
1107
+ if (declarationRefs.length > 0) {
1108
+ docRefExports.push({
1109
+ exportName: declaration.name.text,
1110
+ refs: declarationRefs
1111
+ });
1112
+ }
1113
+ if (value === undefined) {
1114
+ if (declaration.name.text.includes("DocBlock") || declaration.initializer.getText(sourceFile).includes("satisfies DocBlock")) {
1115
+ throw new Error(`Non-static DocBlock source in ${filePath} at ${declaration.initializer.getStart(sourceFile)}`);
1116
+ }
1117
+ continue;
1118
+ }
1119
+ if (isDocBlockValue(value)) {
1120
+ entries.push({
1121
+ id: value.id,
1122
+ exportName: declaration.name.text,
1123
+ sourceModule,
1124
+ block: value
1125
+ });
1126
+ }
1127
+ if (Array.isArray(value) && value.every((entry) => isDocBlockValue(entry))) {
1128
+ for (const block of value) {
1129
+ entries.push({
1130
+ id: block.id,
1131
+ exportName: declaration.name.text,
1132
+ sourceModule,
1133
+ block
1134
+ });
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+ return { entries, docRefs: [...docRefs], docRefExports };
1140
+ }
1141
+ // src/analysis/docblocks/list-source-files.ts
1142
+ import fs from "node:fs";
1143
+ import path2 from "node:path";
1144
+ function shouldIgnoreSourceFile(filePath) {
1145
+ return !/\.[cm]?[jt]sx?$/.test(filePath) || filePath.endsWith(".d.ts") || filePath.endsWith(".test.ts") || filePath.endsWith(".generated.ts") || filePath.includes(`${path2.sep}dist${path2.sep}`);
1146
+ }
1147
+ function listSourceFiles(srcRoot) {
1148
+ return fs.readdirSync(srcRoot, { withFileTypes: true }).flatMap((entry) => {
1149
+ const absolutePath = path2.join(srcRoot, entry.name);
1150
+ if (entry.isDirectory()) {
1151
+ return listSourceFiles(absolutePath);
1152
+ }
1153
+ return shouldIgnoreSourceFile(absolutePath) ? [] : [absolutePath];
1154
+ }).sort((left, right) => left.localeCompare(right));
1155
+ }
1156
+ function toSourceModule(srcRoot, filePath) {
1157
+ return path2.relative(srcRoot, filePath).replace(/\\/g, "/").replace(/\.[cm]?[jt]sx?$/, "");
1158
+ }
1159
+ // src/analysis/docblocks/manifest.ts
1160
+ import fs2 from "node:fs";
1161
+ function createDiagnostic(ruleId, message, file, context) {
1162
+ return {
1163
+ ruleId,
1164
+ message,
1165
+ severity: "error",
1166
+ file,
1167
+ context
1168
+ };
1169
+ }
1170
+ function analyzePackageDocBlocks(options) {
1171
+ const { packageName, srcRoot } = options;
1172
+ const diagnostics = [];
1173
+ const entries = [];
1174
+ const seenIds = new Map;
1175
+ const seenRoutes = new Map;
1176
+ const moduleRecords = new Map;
1177
+ for (const filePath of listSourceFiles(srcRoot)) {
1178
+ if (filePath.endsWith(".docblock.ts")) {
1179
+ diagnostics.push(createDiagnostic("docblock-standalone-source", "Standalone DocBlock sources are not allowed.", filePath));
1180
+ }
1181
+ if (filePath.includes("/docs/tech/") || filePath.includes("\\docs\\tech\\")) {
1182
+ diagnostics.push(createDiagnostic("docblock-tech-folder", "docs/tech source files are not allowed.", filePath));
1183
+ }
1184
+ const sourceModule = toSourceModule(srcRoot, filePath);
1185
+ try {
1186
+ const moduleData = extractModuleDocData(fs2.readFileSync(filePath, "utf8"), filePath, sourceModule);
1187
+ moduleRecords.set(sourceModule, {
1188
+ entries: moduleData.entries,
1189
+ docRefExports: moduleData.docRefExports,
1190
+ filePath,
1191
+ sourceModule
1192
+ });
1193
+ if (moduleData.entries.length > 0 && !isAllowedDocOwnerModule(filePath, sourceModule)) {
1194
+ diagnostics.push(createDiagnostic("docblock-orphan-owner", "DocBlocks must be exported from the owner module, not a docs-only helper file.", filePath, { sourceModule }));
1195
+ }
1196
+ for (const entry of moduleData.entries) {
1197
+ const sourceRef = `${entry.sourceModule}:${entry.exportName}`;
1198
+ const priorId = seenIds.get(entry.id);
1199
+ if (priorId) {
1200
+ diagnostics.push(createDiagnostic("docblock-duplicate-id", `Duplicate DocBlock id ${entry.id} in ${sourceRef} and ${priorId}.`, filePath, { docId: entry.id, prior: priorId, sourceRef }));
1201
+ } else {
1202
+ seenIds.set(entry.id, sourceRef);
1203
+ }
1204
+ if (entry.block.route) {
1205
+ const priorRoute = seenRoutes.get(entry.block.route);
1206
+ if (priorRoute) {
1207
+ diagnostics.push(createDiagnostic("docblock-duplicate-route", `Duplicate DocBlock route ${entry.block.route} in ${sourceRef} and ${priorRoute}.`, filePath, { route: entry.block.route, prior: priorRoute, sourceRef }));
1208
+ } else {
1209
+ seenRoutes.set(entry.block.route, sourceRef);
1210
+ }
1211
+ }
1212
+ entries.push(entry);
1213
+ }
1214
+ } catch (error) {
1215
+ diagnostics.push(createDiagnostic("docblock-non-static-source", error instanceof Error ? error.message : `Non-static DocBlock source in ${filePath}.`, filePath));
1216
+ }
1217
+ }
1218
+ for (const record of moduleRecords.values()) {
1219
+ const localIds = new Set(record.entries.map((entry) => entry.id));
1220
+ for (const docRefExport of record.docRefExports) {
1221
+ for (const ref of docRefExport.refs) {
1222
+ if (localIds.has(ref)) {
1223
+ continue;
1224
+ }
1225
+ if (seenIds.has(ref)) {
1226
+ diagnostics.push(createDiagnostic("docblock-cross-file-reference", `${docRefExport.exportName} references DocBlock ${ref}, but the DocBlock is not exported from the same module.`, record.filePath, { docId: ref, exportName: docRefExport.exportName }));
1227
+ continue;
1228
+ }
1229
+ diagnostics.push(createDiagnostic("docblock-missing-ref", `${docRefExport.exportName} references missing DocBlock ${ref}.`, record.filePath, { docId: ref, exportName: docRefExport.exportName }));
1230
+ }
1231
+ }
1232
+ }
1233
+ entries.sort((left, right) => left.id.localeCompare(right.id));
1234
+ return {
1235
+ manifest: {
1236
+ packageName,
1237
+ generatedAt: new Date().toISOString(),
1238
+ blocks: entries
1239
+ },
1240
+ diagnostics
1241
+ };
1242
+ }
1243
+ function buildPackageDocManifest(options) {
1244
+ const analysis = analyzePackageDocBlocks(options);
1245
+ const failures = analysis.diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1246
+ if (failures.length > 0) {
1247
+ throw new Error(formatDocBlockDiagnostics(failures));
1248
+ }
1249
+ return analysis.manifest;
1250
+ }
920
1251
  // src/analysis/example-scan.ts
921
1252
  function isExampleFile(filePath) {
922
1253
  return filePath.includes("/example.") || filePath.endsWith(".example.ts");
@@ -1693,8 +2024,8 @@ function sortFields(fields) {
1693
2024
  // src/analysis/snapshot/snapshot.ts
1694
2025
  function generateSnapshot(specs, options = {}) {
1695
2026
  const snapshots = [];
1696
- for (const { path, content } of specs) {
1697
- const scanned = scanSpecSource(content, path);
2027
+ for (const { path: path3, content } of specs) {
2028
+ const scanned = scanSpecSource(content, path3);
1698
2029
  if (options.types && !options.types.includes(scanned.specType)) {
1699
2030
  continue;
1700
2031
  }
@@ -2656,7 +2987,7 @@ async function formatFilesBatch(files, config, options, logger) {
2656
2987
  return formatFiles(files, config, options, logger);
2657
2988
  }
2658
2989
  // src/formatters/spec-markdown.ts
2659
- import * as path from "node:path";
2990
+ import * as path3 from "node:path";
2660
2991
  function specToMarkdown(spec, format, optionsOrDepth = 0) {
2661
2992
  const options = typeof optionsOrDepth === "number" ? { depth: optionsOrDepth } : optionsOrDepth;
2662
2993
  const depth = options.depth ?? 0;
@@ -2781,7 +3112,7 @@ function formatFullMode(spec, lines, indent, rootPath) {
2781
3112
  lines.push(`${indent}- **Tags**: ${spec.meta.tags.join(", ")}`);
2782
3113
  }
2783
3114
  if (spec.filePath) {
2784
- const displayPath = rootPath ? path.relative(rootPath, spec.filePath) : spec.filePath;
3115
+ const displayPath = rootPath ? path3.relative(rootPath, spec.filePath) : spec.filePath;
2785
3116
  lines.push(`${indent}- **File**: \`${displayPath}\``);
2786
3117
  }
2787
3118
  lines.push("");
@@ -4190,6 +4521,7 @@ export const ${runnerName} = new WorkflowRunner({
4190
4521
  }
4191
4522
  export {
4192
4523
  validateSpecStructure,
4524
+ toSourceModule,
4193
4525
  toPascalCase,
4194
4526
  toKebabCase,
4195
4527
  toDot,
@@ -4206,9 +4538,11 @@ export {
4206
4538
  parseImportedSpecNames,
4207
4539
  normalizeValue,
4208
4540
  loadSpecFromSource,
4541
+ listSourceFiles,
4209
4542
  isFeatureFile,
4210
4543
  isExampleFile,
4211
4544
  isBreakingChange,
4545
+ isAllowedDocOwnerModule,
4212
4546
  inferSpecTypeFromFilePath,
4213
4547
  inferSpecTypeFromCodeBlock,
4214
4548
  groupSpecsToArray,
@@ -4238,12 +4572,14 @@ export {
4238
4572
  generateAppBlueprintSpec,
4239
4573
  formatFilesBatch,
4240
4574
  formatFiles,
4575
+ formatDocBlockDiagnostics,
4241
4576
  findMissingDependencies,
4242
4577
  findMatchingRule,
4243
4578
  filterSpecs,
4244
4579
  filterFeatures,
4245
4580
  extractTestTarget,
4246
4581
  extractTestCoverage,
4582
+ extractModuleDocData,
4247
4583
  escapeString,
4248
4584
  detectFormatter,
4249
4585
  detectCycles,
@@ -4260,11 +4596,13 @@ export {
4260
4596
  buildTestPrompt,
4261
4597
  buildReverseEdges,
4262
4598
  buildPresentationSpecPrompt,
4599
+ buildPackageDocManifest,
4263
4600
  buildOperationSpecPrompt,
4264
4601
  buildHandlerPrompt,
4265
4602
  buildFormPrompt,
4266
4603
  buildEventSpecPrompt,
4267
4604
  buildComponentPrompt,
4605
+ analyzePackageDocBlocks,
4268
4606
  addExampleContext,
4269
4607
  addContractNode,
4270
4608
  SpecGroupingStrategies,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractspec/module.workspace",
3
- "version": "4.0.3",
3
+ "version": "4.1.0",
4
4
  "description": "Workspace discovery and management module",
5
5
  "keywords": [
6
6
  "contractspec",
@@ -31,17 +31,17 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@contractspec/lib.contracts-spec": "4.1.2",
35
- "@contractspec/lib.schema": "3.7.8",
34
+ "@contractspec/lib.contracts-spec": "5.0.0",
35
+ "@contractspec/lib.schema": "3.7.10",
36
+ "typescript": "^5.9.3",
36
37
  "compare-versions": "^6.1.1",
37
38
  "ts-morph": "^27.0.2",
38
39
  "zod": "^4.3.5",
39
- "@contractspec/lib.contracts-integrations": "3.8.2"
40
+ "@contractspec/lib.contracts-integrations": "3.8.4"
40
41
  },
41
42
  "devDependencies": {
42
- "@contractspec/tool.typescript": "3.7.8",
43
- "typescript": "^5.9.3",
44
- "@contractspec/tool.bun": "3.7.8"
43
+ "@contractspec/tool.typescript": "3.7.9",
44
+ "@contractspec/tool.bun": "3.7.9"
45
45
  },
46
46
  "exports": {
47
47
  ".": {