Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<template><div>{{ slug }}</div></template>
<script>
import { slugify } from '../lib/format.js';
export default {
props: ['title'],
computed: { slug() { return slugify(this.title); } },
methods: { refresh() { this.$emit('refresh', slugify(this.title)); } },
};
</script>
<style scoped>div { color: red; }</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
import { onMount } from 'svelte';
import { fetchProducts, formatPrice, onlyInMarkup } from '../lib/format.js';
let items = [];
onMount(async () => { items = await fetchProducts(); });
function show(p) { return formatPrice(p.price); }
</script>
{#each items as p}<li>{show(p)} {onlyInMarkup(p)}</li>{/each}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template><span @click="onClick">{{ label }} {{ onlyInMarkup(label) }}</span></template>
<!-- <script>onlyInMarkup(1)</script> -->
<script setup>
import { computed } from 'vue';
import { formatPrice } from '../lib/format.js';
const props = defineProps({ value: Number });
const label = computed(() => formatPrice(props.value));
function onClick() { console.log(label.value); }
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<template><b>{{ n }}</b></template>
<script setup lang="ts">
import { onlyInMarkup } from '../lib/format.js';
const n: number = onlyInMarkup(1);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Helpers called only from single-file components: every call to them lives in a
// <script> block or an Astro frontmatter fence, never in a .js file.
export function formatPrice(n) { return '$' + n.toFixed(2); }
export function slugify(s) { return s.toLowerCase(); }
export function fetchProducts() { return []; }
// CONTROL: named only in markup, a <script lang="ts"> block and a commented-out
// <script>, none of which is JavaScript the front end reads. It must stay without callers.
export function onlyInMarkup(x) { return x; }
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// CONTROL: a plain .js module importing a component; unchanged by the component support.
import { createApp } from 'vue';
import Price from './components/Price.vue';
import { slugify } from './lib/format.js';
createApp(Price).mount('#' + slugify('App'));
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "single-file-component-scripts", "type": "module" }
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
import { fetchProducts, slugify } from '../lib/format.js';
const products = fetchProducts();
const slug = slugify('Home');
---
<h1>{slug}</h1>
<script>
import { formatPrice } from '../lib/format.js';
document.title = formatPrice(1);
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import_cause components/List.svelte:2:12 svelte not_staged
import_cause components/Price.vue:4:10 vue not_staged
import_cause main.js:2:10 vue not_staged
package_entry single-file-component-scripts . [] DEFAULT_INDEX index.js MISSING_FILE -> -
unresolved components/Legacy.vue:7:26 METHOD_CALL $emit member_absent
unresolved components/List.svelte:5:3 FUNCTION_CALL onMount callee_untyped
unresolved components/Price.vue:6:15 FUNCTION_CALL defineProps callee_untyped
unresolved components/Price.vue:7:15 FUNCTION_CALL computed callee_untyped
unresolved components/Price.vue:8:22 METHOD_CALL log no_target
unresolved lib/format.js:3:47 METHOD_CALL toFixed no_target
unresolved lib/format.js:4:37 METHOD_CALL toLowerCase no_target
unresolved main.js:5:1 FUNCTION_CALL createApp callee_untyped
unresolved main.js:5:1 METHOD_CALL mount receiver_untyped
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
components/Legacy.vue:6:31 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
components/Legacy.vue:7:26 METHOD_CALL this.$emit -> ambiguous_unknown -
components/Legacy.vue:7:48 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
components/List.svelte:5:3 FUNCTION_CALL onMount -> ambiguous_unknown -
components/List.svelte:5:3 FUNCTION_CALL onMount -> callback_registered components/List.svelte:5:11 <arrow>
components/List.svelte:5:39 FUNCTION_CALL fetchProducts -> known_edge lib/format.js:5:1 fetchProducts
components/List.svelte:6:29 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
components/Price.vue:6:15 FUNCTION_CALL defineProps -> ambiguous_unknown -
components/Price.vue:7:15 FUNCTION_CALL computed -> ambiguous_unknown -
components/Price.vue:7:15 FUNCTION_CALL computed -> callback_registered components/Price.vue:7:24 <arrow>
components/Price.vue:7:30 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
components/Price.vue:8:22 METHOD_CALL console.log -> ambient_terminal -
lib/format.js:3:47 METHOD_CALL n.toFixed -> ambient_terminal -
lib/format.js:4:37 METHOD_CALL s.toLowerCase -> ambient_terminal -
main.js:5:1 FUNCTION_CALL createApp -> ambiguous_unknown -
main.js:5:1 METHOD_CALL createApp(Price).mount -> ambiguous_unknown -
main.js:5:30 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
pages/index.astro:3:18 FUNCTION_CALL fetchProducts -> known_edge lib/format.js:5:1 fetchProducts
pages/index.astro:4:14 FUNCTION_CALL slugify -> known_edge lib/format.js:4:1 slugify
pages/index.astro:9:20 FUNCTION_CALL formatPrice -> known_edge lib/format.js:3:1 formatPrice
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
main.js:5:30 FUNCTION_CALL slugify EXACT lib/format.js:4:1
# defects: 0
13 changes: 13 additions & 0 deletions parser/src/constants/javascript-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ export const JS_DEFAULT_EXPORT_NAME = 'default';
*/
export const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const;

/**
* Single-file components: markup files whose JavaScript lives in `<script>`
* blocks (Vue, Svelte, Astro) or, for Astro, in the `---` frontmatter fence.
*
* They were not walked at all, so every call inside them was absent, nothing
* recorded the absence, and `impact` on a helper they call said it was used
* only where it was declared. The analyzer reads only their JavaScript: the rest
* of the file is blanked to whitespace so every line and column stays the one on
* disk (see `sfcScriptText`). Kept apart from `JS_SOURCE_EXTENSIONS` because
* they have no `.flow` spelling and are never plain JavaScript.
*/
export const JS_COMPONENT_EXTENSIONS = ['.vue', '.svelte', '.astro'] as const;

/**
* Directories that contain JavaScript but are never the project under analysis.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { JsScopeNode, nodeKey } from '@/parsers/javascript/extractors/js-symbol-
import {
enclosingVariableDeclaration,
isDynamicImportCall,
isJsComponentFile,
isNodeBuiltinSpecifier,
isRelativeSpecifier,
isRequireCall,
pointOf,
} from '@/utils/javascript';
Expand Down Expand Up @@ -1187,6 +1189,14 @@ class JsModuleEdgeExtractor {
// none; a bundler-only ES module that spells `./lib` for `./lib/index.js` gets
// the CommonJS-mode answer as a fallback rather than nothing, since the file it
// means is not in doubt.
// `./Price.vue`: the compiler's resolver knows no component extension, so the
// file the relative path names is the answer when the analyzer walked it.
if (isRelativeSpecifier(specifier) && isJsComponentFile(path.basename(specifier))) {
const named = realPathOfResolved(path.resolve(path.dirname(this.options.absoluteFilePath), specifier));
if (this.options.projectModuleHashes.has(named)) {
return { filePath: this.options.toProjectRelative(named), outcome: JsImportResolutionOutcome.RESOLVED_PROJECT };
}
}
const requireLike = form === JsImportForm.REQUIRE_CALL || form === JsImportForm.CREATE_REQUIRE
|| (form === JsImportForm.JSDOC_IMPORT_TYPE && this.options.moduleSystem !== 'ESM');
const mode = requireLike ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext;
Expand Down
28 changes: 27 additions & 1 deletion parser/src/test/javascript-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
JAVASCRIPT_CSV_FILES, JS_SKIP_DIRECTORIES, JS_SOURCE_EXTENSIONS,
} from '@/constants/javascript-constants';
import {
isFlowDeclarationFileName, isJavaScriptSourceFile, jsExtensionOf, stripJsExtension,
isFlowDeclarationFileName, isJavaScriptSourceFile, jsExtensionOf, sfcScriptText, stripJsExtension,
} from '@/utils/javascript';
import { formatCompleteness } from '@/parsers/javascript/extractors/js-ir-completeness';
import * as JsEnums from '@/enums/javascript';
Expand Down Expand Up @@ -4855,6 +4855,12 @@ function extensionsAreDecidedInOnePlace(): number {
['a.json', '', false, false, 'a.json'],
['a.flow', '', false, false, 'a.flow'],
['README', '', false, false, 'README'],
// Single-file components: walked (their <script> blocks are read), stemmed
// like any module, and with no `.flow` declaration spelling.
['Price.vue', '.vue', true, false, 'Price'],
['List.svelte', '.svelte', true, false, 'List'],
['index.astro', '.astro', true, false, 'index'],
['a.vue.flow', '', false, false, 'a.vue.flow'],
];
for (const [name, extension, isSource, isFlowDeclaration, stem] of CASES) {
if (jsExtensionOf(name) !== extension) {
Expand Down Expand Up @@ -4889,6 +4895,26 @@ function extensionsAreDecidedInOnePlace(): number {
}
}

// ---- 1b. a component's JavaScript keeps the file's own lines and columns;
// markup, a commented-out <script> and a non-JavaScript block are blanked.
// Each case is [name, blanked, kept, blanked, ...]: the file is the parts
// joined, and the extractor must see the kept parts with the rest as spaces.
const COMPONENTS: ReadonlyArray<readonly [string, ...string[]]> = [
['a.vue', '<template>{{ f(1) }}</template>\n<script setup>', '\nf(2)\n', '</script>\n'],
['a.vue', '<!-- <script>f(1)</script> -->\n<script lang="ts">\nf(2)\n</script>\n'],
['a.svelte', '<script context="module">', 'g()', '</script>{g()}'],
['a.astro', '---\n', 'h()\n', '---\n<p>{h()}</p>\n'],
];
for (const [name, ...parts] of COMPONENTS) {
const text = parts.join('');
const want = parts.map((part, i) => (i % 2 === 1 ? part : part.replace(/[^\n]/g, ' '))).join('');
const got = sfcScriptText(name, text).text;
if (got !== want) {
failures += fail(`sfcScriptText(${name}, ${JSON.stringify(text)}) is ${JSON.stringify(got)}, `
+ `expected ${JSON.stringify(want)}`);
}
}

// ---- 2. source-level: nobody rolls their own.
const KNOWN = ['.js', '.jsx', '.mjs', '.cjs', '.flow'];
const files: string[] = [];
Expand Down
93 changes: 91 additions & 2 deletions parser/src/utils/javascript/javascript-module-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JS_SOURCE_EXTENSIONS } from '@/constants/javascript-constants';
import { JS_COMPONENT_EXTENSIONS, JS_SOURCE_EXTENSIONS } from '@/constants/javascript-constants';

/**
* What counts as a platform module, in one place.
Expand Down Expand Up @@ -129,7 +129,96 @@ export function jsExtensionOf(fileName: string): string {
// table, which is the argument for having written one: the function was in use
// at eight call sites and every one of them happened to pass a `.js` family
// name, so nothing else would ever have shown it.
return (JS_SOURCE_EXTENSIONS as readonly string[]).includes(extension) ? extension : '';
if ((JS_SOURCE_EXTENSIONS as readonly string[]).includes(extension)) {
return extension;
}
// A component has no `.flow` spelling: `a.vue.flow` is not a declaration file.
return base === name && (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(extension)
? extension
: '';
}

/** Is this a single-file component (`.vue`, `.svelte`, `.astro`), by name? */
export function isJsComponentFile(fileName: string): boolean {
return (JS_COMPONENT_EXTENSIONS as readonly string[]).includes(jsExtensionOf(fileName));
}

/** One `<script>` block of a single-file component, as {@link sfcScriptText} read it. */
export interface SfcScriptBlock {
/** 1-based line of the block's opening tag. */
readonly line: number;
/** The `lang` attribute, lower-cased; '' when there is none. */
readonly lang: string;
/** Whether its body was kept as JavaScript. */
readonly kept: boolean;
}

/**
* The JavaScript of a single-file component, at the offsets it has on disk.
*
* Every character outside a JavaScript `<script>` body (and, for `.astro`, the
* leading `---` frontmatter fence) becomes a space; line breaks are kept. The
* text is the same length as the file, so every line and column the extractor
* records is the file's own, and a reader opening `Price.vue:6` sees the call.
*
* A block whose `lang` is not JavaScript (`ts`, `typescript`, `coffee`) is
* blanked like markup: the JavaScript front end cannot read it, and parsing it
* as JavaScript would invent a wrong tree rather than an honest absence. The
* blocks come back so the caller can say what was left out. `<script>` inside
* an HTML comment is markup too.
*/
export function sfcScriptText(
fileName: string,
text: string
): { text: string; blocks: SfcScriptBlock[] } {
const keep: Array<[number, number]> = [];
const blocks: SfcScriptBlock[] = [];
const lineAt = (offset: number): number => text.slice(0, offset).split('\n').length;
let from = 0;
if (jsExtensionOf(fileName) === '.astro') {
// The frontmatter: a `---` line before anything else but whitespace, up to the next `---` line.
const open = /^\s*---[ \t]*\r?\n/.exec(text);
if (open !== null) {
const bodyStart = open[0].length;
const close = /^---[ \t]*$/m.exec(text.slice(bodyStart));
if (close !== null) {
keep.push([bodyStart, bodyStart + close.index]);
from = bodyStart + close.index + close[0].length;
blocks.push({ line: lineAt(open.index), lang: '', kept: true });
}
}
}
const tag = /<!--[\s\S]*?-->|<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
tag.lastIndex = from;
for (let match = tag.exec(text); match !== null; match = tag.exec(text)) {
if (match[0].startsWith('<!--')) {
continue;
}
const attributes = match[1] ?? '';
const lang = (/\blang\s*=\s*["']?([\w-]+)/i.exec(attributes)?.[1] ?? '').toLowerCase();
const type = (/\btype\s*=\s*["']?([\w/+.-]+)/i.exec(attributes)?.[1] ?? '').toLowerCase();
const javascript = ['', 'js', 'javascript', 'jsx'].includes(lang)
&& ['', 'module', 'text/javascript', 'application/javascript'].includes(type);
const bodyStart = match.index + match[0].indexOf('>') + 1;
const bodyEnd = bodyStart + (match[2] ?? '').length;
if (javascript) {
keep.push([bodyStart, bodyEnd]);
}
blocks.push({ line: lineAt(match.index), lang: lang || type, kept: javascript });
}
const out: string[] = [];
let at = 0;
const blank = (end: number): void => {
out.push(text.slice(at, end).replace(/[^\r\n]/g, ' '));
at = end;
};
for (const [start, end] of keep) {
blank(start);
out.push(text.slice(start, end));
at = end;
}
blank(text.length);
return { text: out.join(''), blocks };
}

/**
Expand Down
16 changes: 15 additions & 1 deletion parser/src/workflows/javascript/javascript-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ import {
import { EntityUtils } from '@/utils/entity-utils';
import { isGeneratedOutputDirectory } from '@/utils/generated-output';
import { JsRelationWriter } from '@/workflows/javascript/js-relation-writer';
import { isJavaScriptSourceFile, stripJsExtension } from '@/utils/javascript';
import {
isJavaScriptSourceFile, isJsComponentFile, sfcScriptText, stripJsExtension,
} from '@/utils/javascript';
import { JsBlockRegistry } from '@/analysis-types/javascript/JsBlockRegistry';
import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry';
import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry';
Expand Down Expand Up @@ -428,6 +430,18 @@ export class JavaScriptProjectAnalyzer {
SkippedFileReason.READ_ERROR, String(error));
continue;
}
if (isJsComponentFile(path.basename(file))) {
// only the component's JavaScript, at its own lines; markup and non-JavaScript blocks are blanked
const script = sfcScriptText(file, sourceText);
if (script.blocks.length > 0 && !script.blocks.some((block) => block.kept)) {
this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash,
SkippedFileReason.EMPTY_CONTENT,
`single-file component with no JavaScript to read: ${script.blocks.map((block) =>
`<script lang=${block.lang}> at line ${block.line}`).join(', ')}`);
continue;
}
sourceText = script.text;
}
const governing = governingByFile.get(file)!;
let facts: JsFileFacts;
try {
Expand Down
3 changes: 2 additions & 1 deletion plugins/axiomcode/hooks/_where.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
BY_EXT = {'.py': ('python',), '.pyi': ('python',), '.java': ('java',), '.cs': ('csharp',),
'.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',),
'.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'),
'.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')}
'.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript'),
'.vue': ('javascript',), '.svelte': ('javascript',), '.astro': ('javascript',)}
# THE ONE TABLE OF WHAT A HOOK CALLS SOURCE. Each hook kept its own regex or tuple of extensions, and each drifted:
# the edit hooks had no `.cs`, so a C# edit got no graph line while a C# read did. Every hook asks these instead.
SOURCE_EXT = tuple(BY_EXT)
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
EXT = {
'java': ('.java', '.properties', '.xml', '.yml', '.yaml', '.gradle', '.kts', '.toml'),
'typescript': ('.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'),
'javascript': ('.js', '.jsx', '.mjs', '.cjs'),
'javascript': ('.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.astro'), # components: their <script> blocks
'python': ('.py', '.pyi'),
'csharp': ('.cs', '.csproj', '.props', '.targets', '.sln'),
}
Expand Down
4 changes: 3 additions & 1 deletion plugins/axiomcode/skills/axiomcode/scripts/ax_langs.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ def graphs(repo):
# graph's when there is one (the parser gives it to that front end), else TypeScript's, which reads JavaScript too
BY_EXT = {'.py': ('python',), '.pyi': ('python',), '.java': ('java',), '.cs': ('csharp',),
'.ts': ('typescript',), '.tsx': ('typescript',), '.mts': ('typescript',), '.cts': ('typescript',),
'.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'), '.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript')}
'.js': ('javascript', 'typescript'), '.jsx': ('javascript', 'typescript'), '.mjs': ('javascript', 'typescript'), '.cjs': ('javascript', 'typescript'),
# single-file components: only the JavaScript front end reads their <script> blocks
'.vue': ('javascript',), '.svelte': ('javascript',), '.astro': ('javascript',)}


def owners(repo, files):
Expand Down
Loading