@ttsc/evidence 0.25.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +279 -0
  3. package/lib/index.d.ts +65 -0
  4. package/lib/index.js +87 -0
  5. package/lib/internal/loadPrismaModels.d.ts +1 -0
  6. package/lib/internal/loadPrismaModels.js +197 -0
  7. package/lib/internal/loadSwaggerOperations.d.ts +1 -0
  8. package/lib/internal/loadSwaggerOperations.js +156 -0
  9. package/lib/internal/normalizeSwaggerDocument.d.ts +1 -0
  10. package/lib/internal/normalizeSwaggerDocument.js +7 -0
  11. package/lib/structures/ITtscEvidenceDocumentedConfig.d.ts +32 -0
  12. package/lib/structures/ITtscEvidenceDocumentedConfig.js +2 -0
  13. package/lib/structures/ITtscEvidenceGraphClaim.d.ts +14 -0
  14. package/lib/structures/ITtscEvidenceGraphClaim.js +2 -0
  15. package/lib/structures/ITtscEvidenceGraphClaimBase.d.ts +102 -0
  16. package/lib/structures/ITtscEvidenceGraphClaimBase.js +2 -0
  17. package/lib/structures/ITtscEvidenceGraphConfig.d.ts +19 -0
  18. package/lib/structures/ITtscEvidenceGraphConfig.js +2 -0
  19. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.d.ts +40 -0
  20. package/lib/structures/ITtscEvidenceGraphMarkdownClaim.js +2 -0
  21. package/lib/structures/ITtscEvidenceGraphMarkdownReference.d.ts +76 -0
  22. package/lib/structures/ITtscEvidenceGraphMarkdownReference.js +2 -0
  23. package/lib/structures/ITtscEvidenceGraphPrismaClaim.d.ts +65 -0
  24. package/lib/structures/ITtscEvidenceGraphPrismaClaim.js +2 -0
  25. package/lib/structures/ITtscEvidenceGraphPrismaReference.d.ts +75 -0
  26. package/lib/structures/ITtscEvidenceGraphPrismaReference.js +2 -0
  27. package/lib/structures/ITtscEvidenceGraphReference.d.ts +14 -0
  28. package/lib/structures/ITtscEvidenceGraphReference.js +2 -0
  29. package/lib/structures/ITtscEvidenceGraphReferenceBase.d.ts +73 -0
  30. package/lib/structures/ITtscEvidenceGraphReferenceBase.js +2 -0
  31. package/lib/structures/ITtscEvidenceGraphSwaggerReference.d.ts +39 -0
  32. package/lib/structures/ITtscEvidenceGraphSwaggerReference.js +2 -0
  33. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.d.ts +54 -0
  34. package/lib/structures/ITtscEvidenceGraphTypeScriptClaim.js +2 -0
  35. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.d.ts +83 -0
  36. package/lib/structures/ITtscEvidenceGraphTypeScriptReference.js +2 -0
  37. package/lib/structures/index.d.ts +13 -0
  38. package/lib/structures/index.js +29 -0
  39. package/lib/typings/ITtscLintContributorRules.d.ts +34 -0
  40. package/lib/typings/ITtscLintContributorRules.js +2 -0
  41. package/lib/typings/ITtscLintRuleOptionsMap.d.ts +20 -0
  42. package/lib/typings/ITtscLintRuleOptionsMap.js +2 -0
  43. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.d.ts +32 -0
  44. package/lib/typings/TtscEvidenceGraphMarkdownSymbol.js +2 -0
  45. package/lib/typings/TtscEvidenceGraphPrismaSymbol.d.ts +38 -0
  46. package/lib/typings/TtscEvidenceGraphPrismaSymbol.js +2 -0
  47. package/lib/typings/TtscEvidenceGraphTypeScriptSymbol.d.ts +64 -0
  48. package/lib/typings/TtscEvidenceGraphTypeScriptSymbol.js +2 -0
  49. package/lib/typings/index.d.ts +5 -0
  50. package/lib/typings/index.js +21 -0
  51. package/native/base.go +298 -0
  52. package/native/config.go +798 -0
  53. package/native/declaration.go +240 -0
  54. package/native/documented.go +353 -0
  55. package/native/exports.go +484 -0
  56. package/native/glob.go +207 -0
  57. package/native/graph.go +1409 -0
  58. package/native/health.go +45 -0
  59. package/native/hints.go +261 -0
  60. package/native/imports.go +373 -0
  61. package/native/inputs.go +125 -0
  62. package/native/loader.go +424 -0
  63. package/native/markdown.go +423 -0
  64. package/native/model.go +296 -0
  65. package/native/prisma.go +719 -0
  66. package/native/prisma_cache.go +213 -0
  67. package/native/prisma_locator.go +417 -0
  68. package/native/singular.go +369 -0
  69. package/native/swagger.go +331 -0
  70. package/native/swagger_cache.go +274 -0
  71. package/native/todo.go +138 -0
  72. package/native/typescript.go +1199 -0
  73. package/native/typescript_cache.go +96 -0
  74. package/package.json +64 -0
@@ -0,0 +1,719 @@
1
+ package evidence
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "io/fs"
9
+ "os"
10
+ "os/exec"
11
+ "path/filepath"
12
+ "sort"
13
+ "strings"
14
+ "time"
15
+ )
16
+
17
+ const prismaBridgeTimeout = 60 * time.Second
18
+ const prismaBridgeOutputLimit = 64 * 1024 * 1024
19
+ const prismaBridgeErrorLimit = 64 * 1024
20
+
21
+ // prismaSetID names the one schema set this graph parses.
22
+ //
23
+ // A project declares one Prisma schema. Prisma itself enforces that — two
24
+ // `datasource` blocks in one parse are its own error — so splitting the
25
+ // configured files into several independent parses would invent a topology the
26
+ // tool being wrapped does not have. Every configured Prisma glob, claim and
27
+ // reference alike, therefore contributes to a single ordered set, and the
28
+ // identity round-trips through the bridge so a contract drift is caught rather
29
+ // than silently reassigned.
30
+ const prismaSetID = "schema"
31
+
32
+ const prismaBridgeScript = `
33
+ const path = require("node:path");
34
+ const { createRequire } = require("node:module");
35
+
36
+ const root = process.argv[1];
37
+ const projectRequire = createRequire(path.join(root, "package.json"));
38
+ const manifest = projectRequire.resolve("@ttsc/evidence/package.json");
39
+ const pluginRequire = createRequire(manifest);
40
+ const loader = pluginRequire(
41
+ path.join(path.dirname(manifest), "lib", "internal", "loadPrismaModels.js"),
42
+ );
43
+
44
+ let input = "";
45
+ process.stdin.setEncoding("utf8");
46
+ process.stdin.on("data", (chunk) => { input += chunk; });
47
+ process.stdin.on("end", async () => {
48
+ try {
49
+ const result = await loader.loadPrismaModels(JSON.parse(input));
50
+ process.stdout.write(JSON.stringify(result));
51
+ } catch (error) {
52
+ process.stderr.write(error instanceof Error ? error.message : String(error));
53
+ process.exitCode = 1;
54
+ }
55
+ });
56
+ `
57
+
58
+ type prismaNormalizationRequest struct {
59
+ Root string `json:"root"`
60
+ Sets []prismaSetRequest `json:"sets"`
61
+ }
62
+
63
+ type prismaSetRequest struct {
64
+ ID string `json:"id"`
65
+ Files []string `json:"files"`
66
+ }
67
+
68
+ type prismaNormalizationResult struct {
69
+ Documents []prismaSetInventory `json:"documents"`
70
+ Problems []prismaSetProblem `json:"problems"`
71
+ }
72
+
73
+ // prismaSetInventory is one parsed schema set.
74
+ //
75
+ // Digest is the composition the loader itself computed from the bytes it read,
76
+ // and it is what the result is remembered under. Hashing there rather than
77
+ // trusting the digest this process took beforehand is what makes the key exact:
78
+ // the loader runs in another process and opens the files again, so a write
79
+ // landing between the two reads would otherwise bind one schema's models to
80
+ // another schema's bytes.
81
+ type prismaSetInventory struct {
82
+ ID string `json:"id"`
83
+ Models []prismaModel `json:"models"`
84
+ Digest string `json:"digest"`
85
+ }
86
+
87
+ // prismaSetProblem is a set the parser refused. Digest carries the same meaning
88
+ // as on an inventory, so a schema that cannot be parsed is remembered as a
89
+ // failure rather than re-parsed on every later cycle.
90
+ type prismaSetProblem struct {
91
+ ID string `json:"id"`
92
+ Message string `json:"message"`
93
+ Digest string `json:"digest"`
94
+ }
95
+
96
+ type prismaModel struct {
97
+ Name string `json:"name"`
98
+ Documentation string `json:"documentation"`
99
+ Fields []prismaField `json:"fields"`
100
+ }
101
+
102
+ type prismaField struct {
103
+ Name string `json:"name"`
104
+ Symbol string `json:"symbol"`
105
+ Documentation string `json:"documentation"`
106
+ }
107
+
108
+ // loadPrismaInventories materializes one inventory per configured schema file.
109
+ //
110
+ // The population comes from Prisma's own parser and the locations come from a
111
+ // native scan, and the split is load-bearing rather than convenient. The parser
112
+ // answers what exists — including which fields are relations, which no amount
113
+ // of reading text recovers cheaply, because a back-reference carries no
114
+ // attribute at all — but its payload has no line, column, or even file for
115
+ // anything it returns. The scan answers only where a name is written, and it
116
+ // can neither add a unit nor remove one, so a scan that misses costs a precise
117
+ // location and never a silently smaller coverage denominator.
118
+ func loadPrismaInventories(
119
+ root string,
120
+ config graphConfig,
121
+ ) (map[string]*artifactInventory, []string) {
122
+ // A graph that names no Prisma glob never pays for one. The walk below is
123
+ // cheap on such a project — every directory fails the descendant test and is
124
+ // skipped at once — but "cheap" is still a full listing of the project root
125
+ // on every cycle of every consumer who does not use this artifact kind.
126
+ if !configuresPrisma(config) {
127
+ return map[string]*artifactInventory{}, nil
128
+ }
129
+ addresses, failedBases, problems := configuredPrismaAddressesWithHealth(config)
130
+ inventories := map[string]*artifactInventory{}
131
+ for _, base := range failedBases {
132
+ recordPopulationFailure(inventories, artifactPrisma, base)
133
+ }
134
+ for _, address := range addresses {
135
+ inventories[address.Key] = &artifactInventory{
136
+ Path: address.Display,
137
+ Type: artifactPrisma,
138
+ }
139
+ }
140
+ // The set is composed of physical files, deduplicated across bases. Prisma
141
+ // parses one schema at a time and a file listed twice is a duplicate
142
+ // declaration to its own parser, so two populations that reach one file
143
+ // through different roots must still contribute it once.
144
+ sources := distinctPrismaSources(addresses)
145
+ if len(sources) == 0 {
146
+ return inventories, problems
147
+ }
148
+
149
+ // Parsing costs a Node process, and the process start dominates it — a
150
+ // ten-model schema and a two-hundred-model one pay nearly the same. A
151
+ // resident host repeats this every cycle, so an unchanged schema would
152
+ // otherwise be re-parsed on every TypeScript keystroke that rebuilds.
153
+ digest := prismaContentDigest(root, sources)
154
+ if outcome, hit := prismaSchemas.lookup(digest); hit {
155
+ return inventories, append(
156
+ problems,
157
+ prismaUnitsFromOutcome(root, sources, inventories, outcome)...,
158
+ )
159
+ }
160
+
161
+ result, err := normalizePrismaSet(root, sources)
162
+ if err != nil {
163
+ message := "Evidence graph could not run its Prisma schema loader: " + err.Error() + ". Prisma references require Node.js and a resolvable @prisma/prisma-schema-wasm."
164
+ return inventories, append(problems, failPrismaSet(inventories, sources, message))
165
+ }
166
+
167
+ outcome, problem := prismaOutcomeOf(result)
168
+ if problem != "" {
169
+ return inventories, append(problems, failPrismaSet(inventories, sources, problem))
170
+ }
171
+ problems = append(
172
+ problems,
173
+ prismaUnitsFromOutcome(root, sources, inventories, outcome)...,
174
+ )
175
+ rememberPrismaSchema(outcome.digest, outcome)
176
+ return inventories, problems
177
+ }
178
+
179
+ // prismaOutcomeOf reduces one bridge answer to the single set that was asked
180
+ // for, and reports a contract drift rather than guessing past it.
181
+ //
182
+ // Every branch here describes a bridge that answered something other than what
183
+ // it was asked. None of them can be repaired by editing a schema, so each names
184
+ // the installation instead — and none may fall through to "no models", which is
185
+ // a schema whose every obligation is vacuously satisfied.
186
+ func prismaOutcomeOf(result prismaNormalizationResult) (prismaSetOutcome, string) {
187
+ drift := "Reinstall @ttsc/evidence; the native and JavaScript bridge contracts disagree."
188
+ if len(result.Documents)+len(result.Problems) != 1 {
189
+ return prismaSetOutcome{}, "Evidence graph Prisma loader answered " +
190
+ decimal(len(result.Documents)+len(result.Problems)) +
191
+ " schema sets for one request. " + drift
192
+ }
193
+ for _, document := range result.Documents {
194
+ if document.ID != prismaSetID {
195
+ return prismaSetOutcome{}, "Evidence graph Prisma loader returned an unconfigured schema set '" + document.ID + "'. " + drift
196
+ }
197
+ return prismaSetOutcome{Models: document.Models, digest: document.Digest}, ""
198
+ }
199
+ problem := result.Problems[0]
200
+ if problem.ID != prismaSetID {
201
+ return prismaSetOutcome{}, "Evidence graph Prisma loader rejected an unconfigured schema set '" + problem.ID + "'. " + drift
202
+ }
203
+ return prismaSetOutcome{
204
+ Rejected: true,
205
+ Problem: problem.Message,
206
+ digest: problem.Digest,
207
+ }, ""
208
+ }
209
+
210
+ // configuredPrismaAddresses lists the schema files every configured Prisma glob
211
+ // selects, claim and reference alike, once per population base.
212
+ //
213
+ // Order is the digest's, so it must not depend on filesystem enumeration.
214
+ func configuredPrismaAddresses(
215
+ config graphConfig,
216
+ ) ([]artifactAddress, []string) {
217
+ addresses, _, problems := configuredPrismaAddressesWithHealth(config)
218
+ return addresses, problems
219
+ }
220
+
221
+ func configuredPrismaAddressesWithHealth(
222
+ config graphConfig,
223
+ ) ([]artifactAddress, []populationBase, []string) {
224
+ addresses := []artifactAddress{}
225
+ failedBases := []populationBase{}
226
+ problems := []string{}
227
+ for _, base := range configuredBases(config, artifactPrisma) {
228
+ if problem := unreadableBaseProblem(base, artifactPrisma); problem != "" {
229
+ problems = append(problems, problem)
230
+ failedBases = append(failedBases, base)
231
+ continue
232
+ }
233
+ baseFailed := false
234
+ err := filepath.WalkDir(base.Absolute, func(current string, entry fs.DirEntry, walkErr error) error {
235
+ if walkErr != nil {
236
+ relative, ok := relativeProjectPath(base.Absolute, current)
237
+ relevant := ok &&
238
+ (matchesConfiguredPrismaFile(config, base, relative) ||
239
+ couldContainConfiguredPrisma(config, base, relative))
240
+ if !relevant {
241
+ if entry != nil && entry.IsDir() {
242
+ return filepath.SkipDir
243
+ }
244
+ return nil
245
+ }
246
+ baseFailed = true
247
+ problems = append(problems, "Evidence graph could not inspect '"+current+"': "+walkErr.Error()+". Fix filesystem access so configured Prisma sources can be indexed.")
248
+ if entry != nil && entry.IsDir() {
249
+ return filepath.SkipDir
250
+ }
251
+ return nil
252
+ }
253
+ if entry.IsDir() {
254
+ if current == base.Absolute {
255
+ return nil
256
+ }
257
+ relative, ok := relativeProjectPath(base.Absolute, current)
258
+ if !ok || !couldContainConfiguredPrisma(config, base, relative) {
259
+ return filepath.SkipDir
260
+ }
261
+ return nil
262
+ }
263
+ relative, ok := relativeProjectPath(base.Absolute, current)
264
+ if !ok || !matchesConfiguredPrismaFile(config, base, relative) {
265
+ return nil
266
+ }
267
+ addresses = append(addresses, base.addressOf(relative))
268
+ return nil
269
+ })
270
+ if err != nil {
271
+ baseFailed = true
272
+ problems = append(problems, "Evidence graph could not walk Prisma root '"+populationRootLabel(base)+"': "+err.Error()+".")
273
+ }
274
+ if baseFailed {
275
+ failedBases = append(failedBases, base)
276
+ }
277
+ }
278
+ sort.Slice(addresses, func(left int, right int) bool {
279
+ return addresses[left].Key < addresses[right].Key
280
+ })
281
+ return addresses, failedBases, problems
282
+ }
283
+
284
+ // distinctPrismaSources reduces the configured addresses to the physical files
285
+ // the parser is handed, in one stable order.
286
+ func distinctPrismaSources(addresses []artifactAddress) []string {
287
+ seen := map[string]bool{}
288
+ sources := []string{}
289
+ for _, address := range addresses {
290
+ if seen[address.Display] {
291
+ continue
292
+ }
293
+ seen[address.Display] = true
294
+ sources = append(sources, address.Display)
295
+ }
296
+ sort.Strings(sources)
297
+ return sources
298
+ }
299
+
300
+ // prismaInventoriesByDisplay indexes the inventories that share one physical
301
+ // file.
302
+ //
303
+ // Two populations reaching one schema through different roots own separate
304
+ // inventories of it, because each answers a different set of globs — while the
305
+ // file itself is parsed once and its models are located once. Everything derived
306
+ // from those bytes therefore has to reach every inventory of the file rather
307
+ // than one of them.
308
+ func prismaInventoriesByDisplay(
309
+ inventories map[string]*artifactInventory,
310
+ ) map[string][]*artifactInventory {
311
+ indexed := map[string][]*artifactInventory{}
312
+ for _, inventory := range inventories {
313
+ if inventory == nil {
314
+ continue
315
+ }
316
+ indexed[inventory.Path] = append(indexed[inventory.Path], inventory)
317
+ }
318
+ return indexed
319
+ }
320
+
321
+ // failPrismaSet records one whole-set failure against every file of the set.
322
+ //
323
+ // The symbol is `*` rather than `model`, and the difference is load-bearing. A
324
+ // reference reads an inventory problem only when it selects that problem's
325
+ // symbol (`graph.go:169-174`), so a set that failed to parse would look
326
+ // problem-free to a reference selecting only columns or relations — which then
327
+ // reports that its globs "materialized no selected evidence units", sending the
328
+ // author to widen a selector when the schema is what could not be read. A
329
+ // failure that belongs to the whole set belongs to every selector over it.
330
+ func failPrismaSet(
331
+ inventories map[string]*artifactInventory,
332
+ sources []string,
333
+ message string,
334
+ ) string {
335
+ indexed := prismaInventoriesByDisplay(inventories)
336
+ for _, source := range sources {
337
+ for _, inventory := range indexed[source] {
338
+ inventory.Problems = append(inventory.Problems, inventoryProblem{
339
+ Symbol: "*",
340
+ Message: message,
341
+ })
342
+ inventory.LoadFailed = true
343
+ }
344
+ }
345
+ return message
346
+ }
347
+
348
+ // configuresPrisma reports whether any claim or reference selects this artifact
349
+ // kind at all.
350
+ func configuresPrisma(config graphConfig) bool {
351
+ for _, claim := range config.Claims {
352
+ if claim.Type == artifactPrisma {
353
+ return true
354
+ }
355
+ for _, reference := range claim.References {
356
+ if reference.Type == artifactPrisma {
357
+ return true
358
+ }
359
+ }
360
+ }
361
+ return false
362
+ }
363
+
364
+ func matchesConfiguredPrismaFile(
365
+ config graphConfig,
366
+ base populationBase,
367
+ path string,
368
+ ) bool {
369
+ for _, claim := range config.Claims {
370
+ if claim.Type == artifactPrisma &&
371
+ claim.Base.Absolute == base.Absolute &&
372
+ claim.Files.matches(path) {
373
+ return true
374
+ }
375
+ for _, reference := range claim.References {
376
+ if reference.Type == artifactPrisma &&
377
+ reference.Base.Absolute == base.Absolute &&
378
+ reference.Files.matches(path) {
379
+ return true
380
+ }
381
+ }
382
+ }
383
+ return false
384
+ }
385
+
386
+ func couldContainConfiguredPrisma(
387
+ config graphConfig,
388
+ base populationBase,
389
+ directory string,
390
+ ) bool {
391
+ for _, claim := range config.Claims {
392
+ if claim.Type == artifactPrisma &&
393
+ claim.Base.Absolute == base.Absolute &&
394
+ claim.Files.couldMatchDescendant(directory) {
395
+ return true
396
+ }
397
+ for _, reference := range claim.References {
398
+ if reference.Type == artifactPrisma &&
399
+ reference.Base.Absolute == base.Absolute &&
400
+ reference.Files.couldMatchDescendant(directory) {
401
+ return true
402
+ }
403
+ }
404
+ }
405
+ return false
406
+ }
407
+
408
+ func normalizePrismaSet(
409
+ root string,
410
+ sources []string,
411
+ ) (prismaNormalizationResult, error) {
412
+ request, err := json.Marshal(prismaNormalizationRequest{
413
+ Root: root,
414
+ Sets: []prismaSetRequest{{ID: prismaSetID, Files: sources}},
415
+ })
416
+ if err != nil {
417
+ return prismaNormalizationResult{}, err
418
+ }
419
+ node := os.Getenv("TTSC_NODE_BINARY")
420
+ if node == "" {
421
+ node, err = exec.LookPath("node")
422
+ if err != nil {
423
+ return prismaNormalizationResult{}, errors.New("Node.js executable was not found")
424
+ }
425
+ }
426
+ ctx, cancel := context.WithTimeout(context.Background(), prismaBridgeTimeout)
427
+ defer cancel()
428
+ command := exec.CommandContext(ctx, node, "-e", prismaBridgeScript, root)
429
+ command.Dir = root
430
+ command.Stdin = bytes.NewReader(request)
431
+ stdout := &limitedBuffer{Limit: prismaBridgeOutputLimit}
432
+ stderr := &limitedBuffer{Limit: prismaBridgeErrorLimit}
433
+ command.Stdout = stdout
434
+ command.Stderr = stderr
435
+ if err := command.Run(); err != nil {
436
+ if ctx.Err() == context.DeadlineExceeded {
437
+ return prismaNormalizationResult{}, errors.New("Prisma schema loader exceeded its 60 second timeout")
438
+ }
439
+ detail := strings.TrimSpace(stderr.String())
440
+ if detail == "" {
441
+ detail = err.Error()
442
+ }
443
+ return prismaNormalizationResult{}, errors.New(detail)
444
+ }
445
+ var result prismaNormalizationResult
446
+ if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
447
+ return prismaNormalizationResult{}, errors.New("Prisma schema loader returned invalid JSON: " + err.Error())
448
+ }
449
+ return result, nil
450
+ }
451
+
452
+ // prismaModelUnits turns one parsed model into its unit and its members'.
453
+ //
454
+ // A relation is a field, so a two-sided relation is two units, one owned by
455
+ // each model — including a self-relation, where both sides belong to the same
456
+ // model and share one relation name. Collapsing them by that shared name would
457
+ // give one unit two parents, which the hierarchy has no way to express.
458
+ func prismaModelUnits(model prismaModel) []*evidenceUnit {
459
+ modelID := "prisma:" + model.Name
460
+ // A schema author marking a model internal has made the same declaration a
461
+ // TypeScript author makes with the same tag, and a model that is out of the
462
+ // surface takes its fields with it.
463
+ modelHidden := commentHidingTag(model.Documentation)
464
+ units := []*evidenceUnit{{
465
+ ID: modelID,
466
+ Target: modelID,
467
+ Identity: []string{model.Name},
468
+ Type: artifactPrisma,
469
+ Symbol: "model",
470
+ Readable: "Prisma model '" + model.Name + "'",
471
+ Hidden: modelHidden,
472
+ }}
473
+ seen := map[string]bool{}
474
+ for _, field := range model.Fields {
475
+ if field.Symbol != "column" && field.Symbol != "relation" {
476
+ continue
477
+ }
478
+ target := modelID + "." + field.Name
479
+ if seen[target] {
480
+ continue
481
+ }
482
+ seen[target] = true
483
+ fieldHidden := modelHidden
484
+ if fieldHidden == "" {
485
+ fieldHidden = commentHidingTag(field.Documentation)
486
+ }
487
+ units = append(units, &evidenceUnit{
488
+ ID: target,
489
+ ParentID: modelID,
490
+ Target: target,
491
+ Identity: []string{model.Name, field.Name},
492
+ Type: artifactPrisma,
493
+ Symbol: field.Symbol,
494
+ Readable: "Prisma " + field.Symbol + " '" + model.Name + "." + field.Name + "'",
495
+ Hidden: fieldHidden,
496
+ })
497
+ }
498
+ return units
499
+ }
500
+
501
+ // prismaDeclarationsFromComments turns the scanned comment runs into
502
+ // declarations, and reports every citation that cannot become one.
503
+ //
504
+ // Silence is the failure this rule exists to remove, so a tag is never simply
505
+ // skipped. Which forms may host was settled by measurement rather than by
506
+ // preference: `///` and `/* */` both reach the parser's `documentation` and
507
+ // both are emitted into the generated client and into prisma-markdown's ERD,
508
+ // so both host a citation here. `//` is discarded by Prisma itself and is the
509
+ // only form that cannot. A top-level unattached `///` run is the one
510
+ // exclusion-only carrier.
511
+ // Every other unusable placement — documenting nothing,
512
+ // documenting something this graph does not address, or burying the tag behind
513
+ // an extra slash — names the move that fixes it.
514
+ func prismaDeclarationsFromComments(
515
+ comments []prismaCommentRun,
516
+ hosts map[string]*evidenceUnit,
517
+ inventories map[string][]*artifactInventory,
518
+ ) []string {
519
+ problems := []string{}
520
+ sequence := 0
521
+ for _, run := range comments {
522
+ location := run.Path + ":" + decimal(run.Line)
523
+ if run.Form != prismaDocComment {
524
+ if prismaCommentCarriesTag(run.Body) {
525
+ problems = append(
526
+ problems,
527
+ "Evidence tag at "+location+" sits in a '//' line comment, which Prisma discards rather than attaching to the declaration below it. Write the citation on a '///' or '/* */' documentation comment directly above the model, column, or relation it grounds.",
528
+ )
529
+ }
530
+ continue
531
+ }
532
+ if run.FileLevel {
533
+ hosted := inventories[run.Path]
534
+ if len(hosted) == 0 {
535
+ continue
536
+ }
537
+ for _, offset := range prismaBuriedTagLines(run.Body) {
538
+ problems = append(
539
+ problems,
540
+ "Evidence tag at "+run.Path+":"+decimal(run.Line+offset)+" is buried behind an extra slash. Prisma reads a fourth slash as content, so the tag no longer opens its documentation line and nothing resolves it. Write exactly three slashes.",
541
+ )
542
+ }
543
+ for _, parsed := range parseCommentDeclarations(run.Body, true) {
544
+ sequence++
545
+ line := run.Line + parsed.LineOffset
546
+ if parsed.Tag == tagEvidence {
547
+ problems = append(
548
+ problems,
549
+ "@evidence at "+run.Path+":"+decimal(line)+" is on a file-level Prisma exclusion carrier. Move ownership evidence directly above the selected model, column, or relation it grounds; only @evidenceExclude may be unattached at file level.",
550
+ )
551
+ continue
552
+ }
553
+ declaration := &evidenceDeclaration{
554
+ ID: "prisma:" + run.Path + ":" + decimal(line) + ":" + decimal(sequence),
555
+ HostID: "prisma:" + run.Path + ":file",
556
+ Type: artifactPrisma,
557
+ Tag: parsed.Tag,
558
+ Target: parsed.Target,
559
+ Reason: parsed.Reason,
560
+ ExclusionCarrier: true,
561
+ Path: run.Path,
562
+ Line: line,
563
+ Sequence: sequence,
564
+ }
565
+ for _, inventory := range hosted {
566
+ inventory.Declarations = append(inventory.Declarations, declaration)
567
+ }
568
+ }
569
+ continue
570
+ }
571
+ if run.Key == "" {
572
+ if prismaCommentCarriesTag(run.Body) {
573
+ problems = append(
574
+ problems,
575
+ "Evidence tag at "+location+" documents no declaration. Prisma attaches a '///' comment to the declaration that immediately follows it, so a blank line before a top-level block, a block attribute, or a closing brace leaves the comment documenting nothing. Move the citation directly above the model, column, or relation it grounds.",
576
+ )
577
+ }
578
+ continue
579
+ }
580
+ host := hosts[run.Key]
581
+ if host == nil {
582
+ if prismaCommentCarriesTag(run.Body) {
583
+ problems = append(
584
+ problems,
585
+ "Evidence tag at "+location+" documents '"+run.Key+"', which is not a model, column, or relation. A model or a view and their members host an evidence citation; an enum, a composite type, and a datasource or generator setting do not.",
586
+ )
587
+ }
588
+ continue
589
+ }
590
+ hosted := inventories[run.Path]
591
+ if len(hosted) == 0 {
592
+ continue
593
+ }
594
+ for _, offset := range prismaBuriedTagLines(run.Body) {
595
+ problems = append(
596
+ problems,
597
+ "Evidence tag at "+run.Path+":"+decimal(run.Line+offset)+" is buried behind an extra slash. Prisma reads a fourth slash as content, so the tag no longer opens its documentation line and nothing resolves it. Write exactly three slashes.",
598
+ )
599
+ }
600
+ for _, parsed := range parseCommentDeclarations(run.Body, true) {
601
+ sequence++
602
+ line := run.Line + parsed.LineOffset
603
+ // One declaration, one identity, shared by every inventory of the
604
+ // file it is written in. Two populations that reached this schema
605
+ // through different roots each need to see it, and giving each its
606
+ // own copy would make one comment two declarations in the map
607
+ // `evaluateEvidenceGraph` keys by ID.
608
+ declaration := &evidenceDeclaration{
609
+ ID: "prisma:" + run.Path + ":" + decimal(line) + ":" + decimal(sequence),
610
+ HostID: host.ID,
611
+ SemanticHostIDs: []string{host.ID},
612
+ Type: artifactPrisma,
613
+ Tag: parsed.Tag,
614
+ Target: parsed.Target,
615
+ Reason: parsed.Reason,
616
+ Hosts: prismaHostSymbols(host),
617
+ Path: run.Path,
618
+ Line: line,
619
+ Sequence: sequence,
620
+ }
621
+ for _, inventory := range hosted {
622
+ inventory.Declarations = append(inventory.Declarations, declaration)
623
+ }
624
+ }
625
+ }
626
+ return problems
627
+ }
628
+
629
+ // prismaHostSymbols reports the host kinds a declaration on this unit satisfies.
630
+ //
631
+ // A unit withdrawn from the surface by its own documentation tag hosts nothing:
632
+ // it is not a selected claim host, and with no host kind it is not an exclusion
633
+ // carrier either. The declaration is still recorded, so the citation on it is
634
+ // reported rather than silently discarded.
635
+ func prismaHostSymbols(host *evidenceUnit) symbolSet {
636
+ if host == nil || host.Hidden != "" {
637
+ return nil
638
+ }
639
+ return symbolSet{host.Symbol: true}
640
+ }
641
+
642
+ // prismaCommentCarriesTag reports whether a comment body opens a citation on
643
+ // any of its lines.
644
+ func prismaCommentCarriesTag(body string) bool {
645
+ for _, line := range strings.Split(body, "\n") {
646
+ trimmed := strings.TrimSpace(line)
647
+ if _, _, found := declarationLine(trimmed); found {
648
+ return true
649
+ }
650
+ if prismaBuriedTag(trimmed) {
651
+ return true
652
+ }
653
+ }
654
+ return false
655
+ }
656
+
657
+ // prismaBuriedTagLines lists the offsets of lines whose citation is buried
658
+ // behind extra slashes.
659
+ //
660
+ // A fourth slash is content, not syntax: Prisma's `(!"///") ~ "//"` lookahead
661
+ // makes `//// @evidence ...` a doc comment whose text begins `/ @evidence ...`.
662
+ // The tag therefore no longer opens its line, so nothing parses it — and
663
+ // nothing reported it either, which made a one-keystroke slip the quietest
664
+ // failure in this artifact kind. The comment is real, the schema keeps it, and
665
+ // an author reading the file sees a citation that does nothing.
666
+ func prismaBuriedTagLines(body string) []int {
667
+ offsets := []int{}
668
+ for offset, line := range strings.Split(body, "\n") {
669
+ trimmed := strings.TrimSpace(line)
670
+ if _, _, found := declarationLine(trimmed); found {
671
+ continue
672
+ }
673
+ if prismaBuriedTag(trimmed) {
674
+ offsets = append(offsets, offset)
675
+ }
676
+ }
677
+ return offsets
678
+ }
679
+
680
+ // prismaBuriedTag reports whether a line would open a citation once its leading
681
+ // comment punctuation is removed.
682
+ //
683
+ // Two shapes bury a tag, and both are one keystroke from a citation that works.
684
+ // A fourth slash makes `//// @evidence` a doc comment whose text begins with a
685
+ // slash, and a JSDoc-style block hands Prisma its own asterisks as content:
686
+ // measured, `/** @evidence x */` reaches the parser's documentation as
687
+ // `* @evidence x`, and the multi-line form keeps a leading asterisk on every
688
+ // line of it. In both the tag no longer opens its line, so nothing parses it —
689
+ // and until this stripped asterisks too, nothing reported it either. A schema
690
+ // author arriving from JSDoc writes the second shape by habit.
691
+ //
692
+ // Only leading punctuation is stripped, so prose that merely mentions the tag
693
+ // somewhere in a sentence is untouched. Over-reporting an ordinary comment
694
+ // would teach an author to stop reading these diagnostics, which costs more
695
+ // than the case being caught.
696
+ func prismaBuriedTag(trimmed string) bool {
697
+ stripped := strings.TrimSpace(strings.TrimLeft(trimmed, "/"))
698
+ if stripped == trimmed {
699
+ return false
700
+ }
701
+ _, _, found := declarationLine(stripped)
702
+ return found
703
+ }
704
+
705
+ // prismaNormalizationFailure words a rejected schema identically whether the
706
+ // rejection arrived from the parser this cycle or from memory.
707
+ //
708
+ // The parser's own report is preserved rather than summarized, because it is
709
+ // the only place a location survives: a successful parse carries no positions
710
+ // at all, while a rejection names the file and line the author has to open.
711
+ func prismaNormalizationFailure(message string) string {
712
+ reason := strings.TrimSpace(message)
713
+ if reason == "" {
714
+ reason = "the parser reported no reason"
715
+ }
716
+ return "Evidence graph could not parse the configured Prisma schema: " +
717
+ reason +
718
+ ". Fix the schema so Prisma can read it."
719
+ }