@intentius/chant 0.71.1 → 0.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import * as ts from "typescript";
3
3
  import { fold, foldResource, collectConsts, FoldError } from "./fold";
4
- import { briefNodeText, callExpressionMessage, findSubsetViolation } from "./subset";
4
+ import { briefNodeText, callExpressionMessage, findSubsetViolation, collectLocalCallables } from "./subset";
5
5
  import { evl001NonLiteralExpressionRule } from "../lint/rules/evl001-non-literal-expression";
6
6
  import { evl003DynamicPropertyAccessRule } from "../lint/rules/evl003-dynamic-property-access";
7
7
  import { evl004SpreadNonConstRule } from "../lint/rules/evl004-spread-non-const";
@@ -137,6 +137,38 @@ describe("subset equivalence — supported cases: fold succeeds AND EVL is clean
137
137
  }
138
138
  });
139
139
 
140
+ /**
141
+ * chant#2436 closed a divergence that used to live in the block below.
142
+ *
143
+ * `phase` is a registered authoring helper, but shadowed by the file's own
144
+ * arrow the local binding wins — which is what #1082 asserted, and still
145
+ * true. It used to win by making `fold()` reject, because `fold()` had no way
146
+ * to call a local function; a build folded the same file. Now both fold it, so
147
+ * the two agree rather than diverging by design.
148
+ */
149
+ describe("authoring-helper shadowing — now unified (chant#2436)", () => {
150
+ const source = `
151
+ const phase = (n) => ({ phase: n });
152
+ const good = new Thing({ x: phase("Apply") });
153
+ `;
154
+
155
+ test("fold calls the local arrow, and does not apply the registered meaning", () => {
156
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
157
+ const consts = collectConsts(sourceFile);
158
+ const folded = foldResource(consts.get("good") as ts.NewExpression, consts, []);
159
+
160
+ // chant's own `phase(name, steps)` yields `{ phase, steps }`; no `steps`
161
+ // here is what proves the shadowing binding was the one called.
162
+ expect((folded.props as { x: unknown }).x).toEqual({ phase: "Apply" });
163
+ });
164
+
165
+ test("and EVL001 is clean, as it always was", () => {
166
+ const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
167
+ const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
168
+ expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
169
+ });
170
+ });
171
+
140
172
  describe("subset equivalence — unsupported cases: fold rejects AND EVL flags the same rule id + position", () => {
141
173
  for (const c of UNSUPPORTED_CASES) {
142
174
  test(c.name, () => {
@@ -241,25 +273,6 @@ describe("documented divergences — NOT unified by design (see subset.ts module
241
273
  expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
242
274
  });
243
275
 
244
- test("authoring-helper shadowing: fold rejects a registered name bound to a local const; EVL001 does not (shape-only, no binding resolution)", () => {
245
- // chant #1082 — `phase` is registered, but here it's the file's own local
246
- // arrow function, so the local binding wins and fold() rejects. EVL has no
247
- // binding resolver (subset.ts module doc, point 1) and stays permissive —
248
- // the same direction as every other divergence here.
249
- const source = `
250
- const phase = (n) => ({ phase: n });
251
- const bad = new Thing({ x: phase("Apply") });
252
- `;
253
- const sourceFile = ts.createSourceFile("t.ts", source, ts.ScriptTarget.Latest, true);
254
- const consts = collectConsts(sourceFile);
255
- const badInit = consts.get("bad") as ts.NewExpression;
256
-
257
- expect(() => foldResource(badInit, consts, [])).toThrow(FoldError);
258
-
259
- const context: LintContext = { sourceFile, entities: [], filePath: "t.ts", lexicon: undefined };
260
- expect(evl001NonLiteralExpressionRule.check(context)).toHaveLength(0);
261
- });
262
-
263
276
  test("method call on an unresolved receiver: fold rejects; EVL001 accepts the shape (chant #1966, resolution-dependent)", () => {
264
277
  // `config.getName()` — chant #1966 widened `findSubsetViolation` to
265
278
  // accept a method call unconditionally (mirroring `.step` and a nested
@@ -550,3 +563,131 @@ describe("callExpressionMessage — one line regardless of the call's own argume
550
563
  expect(message).toBe("function call as a value is not foldable: GkeCluster(...)");
551
564
  });
552
565
  });
566
+
567
+ /**
568
+ * chant#2435 — `S-CallLocal`.
569
+ *
570
+ * The classifier rejected every call to a project-local function while the
571
+ * build folds all of them, which is the one direction `F-Direction` forbids:
572
+ * this module may accept what the fold rejects, never the reverse. In practice
573
+ * it meant `chant lint` reported EVL001 on a file `chant build --fold` folds
574
+ * cleanly.
575
+ *
576
+ * The admission is by NAME and the names come from the node's own source file,
577
+ * so nothing a caller does changes the answer.
578
+ */
579
+ describe("findSubsetViolation — project-local calls (chant#2435)", () => {
580
+ /** The initializer of `export const x = …` in `src`, with its file attached. */
581
+ const initializerOf = (src: string): ts.Expression => {
582
+ const sourceFile = ts.createSourceFile("m.ts", src, ts.ScriptTarget.Latest, true);
583
+ for (const statement of sourceFile.statements) {
584
+ if (!ts.isVariableStatement(statement)) continue;
585
+ for (const decl of statement.declarationList.declarations) {
586
+ if (ts.isIdentifier(decl.name) && decl.name.text === "x" && decl.initializer) return decl.initializer;
587
+ }
588
+ }
589
+ throw new Error("fixture has no `x`");
590
+ };
591
+ const accepts = (src: string) => findSubsetViolation(initializerOf(src)) === undefined;
592
+
593
+ describe("admitted, because a build folds them", () => {
594
+ test("a same-file function declaration, exported or not", () => {
595
+ expect(accepts("function twice(n: number) { return n * 2; }\nexport const x = twice(21);")).toBe(true);
596
+ expect(accepts("export function twice(n: number) { return n * 2; }\nexport const x = twice(21);")).toBe(true);
597
+ });
598
+
599
+ test("a same-file arrow const, through parentheses and `as`", () => {
600
+ expect(accepts('const label = (s: string) => ({ label: s });\nexport const x = label("k");')).toBe(true);
601
+ expect(accepts('const label = ((s: string) => s) as (s: string) => string;\nexport const x = label("k");')).toBe(true);
602
+ });
603
+
604
+ test("a name imported from a project specifier", () => {
605
+ expect(accepts('import { twice } from "./pure";\nexport const x = twice(21);')).toBe(true);
606
+ expect(accepts('import { twice } from "../lib/pure";\nexport const x = twice(21);')).toBe(true);
607
+ });
608
+
609
+ test("a default import from a project specifier", () => {
610
+ expect(accepts('import make from "./make";\nexport const x = make(1);')).toBe(true);
611
+ });
612
+ });
613
+
614
+ describe("still rejected, so the diagnostic keeps its value", () => {
615
+ test("a call to a name imported from a package", () => {
616
+ expect(accepts('import { v4 } from "uuid";\nexport const x = v4();')).toBe(false);
617
+ });
618
+
619
+ test("a call to a name the file never binds", () => {
620
+ expect(accepts("export const x = getId();")).toBe(false);
621
+ });
622
+
623
+ test("a local name that shadows nothing local — the specifier decides, not the name", () => {
624
+ expect(accepts('import { twice } from "some-package";\nexport const x = twice(1);')).toBe(false);
625
+ });
626
+
627
+ test("a type-only import, which is erased before anything can call it", () => {
628
+ expect(accepts('import type { T } from "./t";\nexport const x = T();')).toBe(false);
629
+ expect(accepts('import { type T } from "./t";\nexport const x = T();')).toBe(false);
630
+ });
631
+
632
+ test("a `let`-bound arrow, which `S-LocalFunction` does not admit", () => {
633
+ expect(accepts("let f = (n: number) => n;\nexport const x = f(1);")).toBe(false);
634
+ });
635
+
636
+ test("a function nested inside another, so not a top-level binding", () => {
637
+ expect(accepts("function outer() { function inner() { return 1; } return inner; }\nexport const x = inner();")).toBe(false);
638
+ });
639
+
640
+ test("an argument outside the subset, even when the callee is local", () => {
641
+ const violation = findSubsetViolation(
642
+ initializerOf("function twice(n: number) { return n; }\nexport const x = twice(getId());"),
643
+ );
644
+ expect(violation?.ruleId).toBe("EVL001");
645
+ // The rejection points at the argument, not at the admitted call.
646
+ expect(violation?.node.getText()).toBe("getId()");
647
+ });
648
+ });
649
+
650
+ describe("collectLocalCallables", () => {
651
+ test("collects each binding kind, and nothing else", () => {
652
+ const sourceFile = ts.createSourceFile(
653
+ "m.ts",
654
+ [
655
+ 'import { fromProject } from "./p";',
656
+ 'import fromProjectDefault from "./d";',
657
+ 'import { fromPackage } from "pkg";',
658
+ 'import type { TypeOnly } from "./t";',
659
+ "export function declared() { return 1; }",
660
+ "const arrowConst = () => 1;",
661
+ "let notConst = () => 1;",
662
+ "const notAFunction = 1;",
663
+ ].join("\n"),
664
+ ts.ScriptTarget.Latest,
665
+ true,
666
+ );
667
+
668
+ expect([...collectLocalCallables(sourceFile)].sort()).toEqual([
669
+ "arrowConst",
670
+ "declared",
671
+ "fromProject",
672
+ "fromProjectDefault",
673
+ ]);
674
+ });
675
+
676
+ test("is memoized per source file, so a large file is walked once", () => {
677
+ const sourceFile = ts.createSourceFile("m.ts", "function f() { return 1; }", ts.ScriptTarget.Latest, true);
678
+ expect(collectLocalCallables(sourceFile)).toBe(collectLocalCallables(sourceFile));
679
+ });
680
+ });
681
+
682
+ test("the local-callable lookup is not a new failure mode for a node with no file", () => {
683
+ // A synthetic node has no parent chain, so `getSourceFile()` throws. That
684
+ // is pre-existing: `violation()` renders the offending node with
685
+ // `getText()`, which throws for the same reason, so this shape was never
686
+ // supported. What is asserted here is narrower and is what chant#2435
687
+ // could have broken: the lookup added in front of that rejection swallows
688
+ // the throw instead of turning it into a different error.
689
+ const synthetic = ts.factory.createCallExpression(ts.factory.createIdentifier("twice"), undefined, []);
690
+ expect(() => collectLocalCallables(synthetic.getSourceFile())).toThrow();
691
+ expect(() => findSubsetViolation(synthetic)).toThrow(/Cannot read|undefined|getSourceFile|getText/i);
692
+ });
693
+ });
@@ -48,7 +48,16 @@ import { intrinsicCallFolds, intrinsicCallFoldsEagerly, type IntrinsicDef } from
48
48
  * expression, its operator, and (for keys) its literal-ness. It
49
49
  * deliberately does NOT resolve bindings or perform any evaluation, because
50
50
  * three things a full fold needs are inherently environment-dependent and
51
- * cannot be recovered from shape alone:
51
+ * cannot be recovered from shape alone.
52
+ *
53
+ * One thing it does read beyond the expression itself, since chant#2435: the
54
+ * enclosing file's top-level bindings, for `S-CallLocal`. A call to a
55
+ * project-local function is admitted by NAME (see
56
+ * {@link collectLocalCallables}), which is decidable from that file's syntax
57
+ * and needs no resolution. Before that, this module rejected all four shapes
58
+ * the build folds, which is the one direction `F-Direction` forbids, and it
59
+ * made `chant lint` report EVL001 on a file `chant build --fold` folds
60
+ * cleanly. The three genuinely environment-dependent cases are:
52
61
  *
53
62
  * 1. Identifier *resolution* — is a bare name actually a local `const`,
54
63
  * vs. an unbound name? That needs the file's `consts` map. Both
@@ -237,6 +246,111 @@ export function briefNodeText(node: ts.Node, maxLength = 60): string {
237
246
  return collapsed.length > maxLength ? `${collapsed.slice(0, maxLength - 3)}...` : collapsed;
238
247
  }
239
248
 
249
+ // ---------------------------------------------------------------------------
250
+ // Project-local callables (chant#2435, spec `S-CallLocal`)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Every name a file binds to something a project-local call can reach.
255
+ *
256
+ * Two bindings, both of which the specification's `S-LocalFunction` names and
257
+ * both decidable from this file's own syntax, which is all a shape classifier
258
+ * has:
259
+ *
260
+ * - a top-level `function f(...) {...}`, or a `const f = (...) => ...`
261
+ * (a function expression counts, and parentheses / `as` / `satisfies`
262
+ * wrappers are unwrapped), exported or not;
263
+ * - a name imported from a *project* specifier, one starting `./` or `../`.
264
+ * A bare specifier is a package, and a package's function is not
265
+ * project-local.
266
+ *
267
+ * Deliberately syntax-only, and deliberately not a judgment about the callee's
268
+ * body. Whether the body is admissible is `S-FnBody`'s question and the fold's
269
+ * to answer, exactly as it already is for a call this classifier admits by
270
+ * name (a registered authoring helper, an opted-in intrinsic).
271
+ *
272
+ * Mirrors `collectLocalFunctions` in ../discovery/fold-import.ts, which builds
273
+ * the real `FoldableFunction` for each of these during a build. This one only
274
+ * needs the names.
275
+ */
276
+ export function collectLocalCallables(sourceFile: ts.SourceFile): ReadonlySet<string> {
277
+ const cached = localCallableCache.get(sourceFile);
278
+ if (cached) return cached;
279
+
280
+ const names = new Set<string>();
281
+ const unwrap = (node: ts.Expression): ts.Expression => {
282
+ let current = node;
283
+ while (
284
+ ts.isParenthesizedExpression(current) ||
285
+ ts.isAsExpression(current) ||
286
+ ts.isSatisfiesExpression(current)
287
+ ) {
288
+ current = current.expression;
289
+ }
290
+ return current;
291
+ };
292
+
293
+ for (const statement of sourceFile.statements) {
294
+ if (ts.isFunctionDeclaration(statement)) {
295
+ if (statement.name && statement.body) names.add(statement.name.text);
296
+ continue;
297
+ }
298
+ if (ts.isImportDeclaration(statement)) {
299
+ const specifier = statement.moduleSpecifier;
300
+ if (!ts.isStringLiteral(specifier) || !/^\.\.?\//.test(specifier.text)) continue;
301
+ const clause = statement.importClause;
302
+ // `import type { T } from "./t"` is erased before anything runs, so `T`
303
+ // is not something a call can reach. Both spellings are skipped: the
304
+ // whole-clause one and the per-specifier `import { type T }`.
305
+ if (!clause || clause.isTypeOnly) continue;
306
+ if (clause.name) names.add(clause.name.text);
307
+ const bindings = clause.namedBindings;
308
+ if (bindings && ts.isNamedImports(bindings)) {
309
+ for (const element of bindings.elements) {
310
+ if (!element.isTypeOnly) names.add(element.name.text);
311
+ }
312
+ }
313
+ continue;
314
+ }
315
+ if (!ts.isVariableStatement(statement)) continue;
316
+ if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue;
317
+ for (const decl of statement.declarationList.declarations) {
318
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
319
+ const initializer = unwrap(decl.initializer);
320
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
321
+ names.add(decl.name.text);
322
+ }
323
+ }
324
+ }
325
+
326
+ localCallableCache.set(sourceFile, names);
327
+ return names;
328
+ }
329
+
330
+ /** Per-file memo: the classifier asks this once per call expression, and a big file has many. */
331
+ const localCallableCache = new WeakMap<ts.SourceFile, ReadonlySet<string>>();
332
+
333
+ /**
334
+ * The project-local callables in scope at `node`, or an empty set when the node
335
+ * has no file to read.
336
+ *
337
+ * The guard is not a claim that a file-less node is supported: the rejection
338
+ * this sits in front of renders the node with `getText()`, which needs the same
339
+ * parent chain. It is here so that looking the names up never becomes a *new*
340
+ * way for this function to fail, and so the answer for a node whose file cannot
341
+ * be read is the pre-#2435 one, that every call is a violation.
342
+ */
343
+ function localCallablesAt(node: ts.Node): ReadonlySet<string> {
344
+ try {
345
+ const sourceFile = node.getSourceFile();
346
+ return sourceFile ? collectLocalCallables(sourceFile) : EMPTY_NAMES;
347
+ } catch {
348
+ return EMPTY_NAMES;
349
+ }
350
+ }
351
+
352
+ const EMPTY_NAMES: ReadonlySet<string> = new Set<string>();
353
+
240
354
  // ---------------------------------------------------------------------------
241
355
  // Shared message builders — `fold()` and `findSubsetViolation` both call
242
356
  // these so the diagnostic text for the same violation kind is the same
@@ -547,6 +661,27 @@ export function findSubsetViolation(
547
661
  return undefined;
548
662
  }
549
663
 
664
+ // chant#2435 — a call to a project-local function. `fold-import.ts` binds
665
+ // every top-level function a file declares (and every one it imports from
666
+ // a project file) before folding it, so a build folds all four shapes the
667
+ // issue tabulates; this classifier rejected all four. That is the one
668
+ // direction `F-Direction` forbids, and it made `chant lint` report EVL001
669
+ // on a file `chant build --fold` folds cleanly.
670
+ //
671
+ // Name-only, like the authoring-helper case above and for the same reason:
672
+ // this module classifies shape and never resolves bindings. Whether the
673
+ // callee's body is admissible is `S-FnBody`'s question, which the fold
674
+ // answers. The set comes from the node's own source file, so every caller
675
+ // gets the corrected answer without passing anything — a caller holding a
676
+ // synthetic node with no file keeps the old, stricter one.
677
+ if (ts.isIdentifier(node.expression) && localCallablesAt(node).has(node.expression.text)) {
678
+ for (const arg of node.arguments) {
679
+ const v = findSubsetViolation(arg, intrinsics);
680
+ if (v) return v;
681
+ }
682
+ return undefined;
683
+ }
684
+
550
685
  return violation(node, callExpressionMessage(node));
551
686
  }
552
687
 
package/src/index.ts CHANGED
@@ -43,7 +43,7 @@ export * from "./fold/fold";
43
43
  // fold?" of without running a fold (subset.ts module doc, point 2c), and the
44
44
  // half of the fold subset a conformance adapter needs that `fold()` alone
45
45
  // does not expose. INTENTIUS/typescript-as-data#11.
46
- export { findSubsetViolation, checkObjectMember, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
46
+ export { findSubsetViolation, checkObjectMember, collectLocalCallables, type SubsetViolation, type SubsetRuleId } from "./fold/subset";
47
47
  // The version of the TypeScript-as-Data specification chant implements
48
48
  // (INTENTIUS/typescript-as-data#18, chant#2424). The specification's
49
49
  // conformance adapter reads this off the public entry; without it a suite