@orkestrel/scaffold 0.0.53 → 0.0.55

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.
@@ -23,6 +23,8 @@ export type PolicyRule =
23
23
  | 'function'
24
24
  | 'mirror'
25
25
  | 'parser'
26
+ | 'portability'
27
+ | 'rules'
26
28
  | 'skill'
27
29
  | 'suppression'
28
30
  | 'type'
@@ -266,6 +268,61 @@ export const POLICY_SUPPRESSION_PATTERN = new RegExp(
266
268
  'u',
267
269
  )
268
270
 
271
+ /** The workspace-authored path population inspected for a name a Windows checkout cannot hold. */
272
+ export const POLICY_PORTABILITY_GLOB: readonly string[] = Object.freeze([
273
+ '{src,app,configs,tests,scripts,guides}/**/*',
274
+ '{.agents,.claude,.codex,.cursor,.github}/**/*',
275
+ '*',
276
+ '.*',
277
+ ])
278
+
279
+ /** The TypeScript source population parsed for host-specific line-ending handling. */
280
+ export const POLICY_PORTABILITY_SOURCE_GLOB = '{src,app,configs}/**/*.ts'
281
+
282
+ /** Every device name Windows reserves, whatever extension the segment carries. */
283
+ export const POLICY_RESERVED_NAMES: readonly string[] = Object.freeze([
284
+ 'aux',
285
+ 'com1',
286
+ 'com2',
287
+ 'com3',
288
+ 'com4',
289
+ 'com5',
290
+ 'com6',
291
+ 'com7',
292
+ 'com8',
293
+ 'com9',
294
+ 'con',
295
+ 'lpt1',
296
+ 'lpt2',
297
+ 'lpt3',
298
+ 'lpt4',
299
+ 'lpt5',
300
+ 'lpt6',
301
+ 'lpt7',
302
+ 'lpt8',
303
+ 'lpt9',
304
+ 'nul',
305
+ 'prn',
306
+ ])
307
+
308
+ /** Every character Windows refuses inside a path segment. */
309
+ export const POLICY_RESERVED_PATTERN = /[<>:"|?*]/u
310
+
311
+ /** A shell script named as a complete path token inside a manifest script. */
312
+ export const POLICY_SHELL_PATTERN = /\.sh\b/u
313
+
314
+ /** The directory whose direct Markdown files form the complete rule family. */
315
+ export const POLICY_RULE_ROOT = '.claude/rules'
316
+
317
+ /** The root instruction file whose rule map registers the rule family. */
318
+ export const POLICY_RULE_MAP_FILE = 'AGENTS.md'
319
+
320
+ /** The heading that opens the root instruction file's rule map table. */
321
+ export const POLICY_RULE_MAP_HEADING = '## Rule map'
322
+
323
+ /** The workspace manifest whose scripts run on every supported host. */
324
+ export const POLICY_MANIFEST_FILE = 'package.json'
325
+
269
326
  /**
270
327
  * Normalize platform separators for stable matching and diagnostics.
271
328
  *
@@ -736,10 +793,14 @@ export function inspectPolicySources(sources: readonly PolicySource[]): readonly
736
793
  * Read the parsed TypeScript source population beneath one workspace.
737
794
  *
738
795
  * @param root - The workspace root to read.
739
- * @returns Every non-ambient TypeScript source under the src and app axes, sorted by path.
796
+ * @param glob - The population to read. Default: the src and app placement population.
797
+ * @returns Every non-ambient TypeScript source the population names, sorted by path.
740
798
  */
741
- export function readPolicySources(root: string): readonly PolicySource[] {
742
- return globSync(POLICY_SOURCE_GLOB, { cwd: root })
799
+ export function readPolicySources(
800
+ root: string,
801
+ glob: string | readonly string[] = POLICY_SOURCE_GLOB,
802
+ ): readonly PolicySource[] {
803
+ return globSync(glob, { cwd: root })
743
804
  .map(normalizePolicyPath)
744
805
  .filter((path) => !POLICY_AMBIENT_SUFFIXES.some((suffix) => basename(path).endsWith(suffix)))
745
806
  .sort()
@@ -1561,11 +1622,323 @@ export function inspectSkillBridges(root: string): readonly PolicyViolation[] {
1561
1622
  return violations
1562
1623
  }
1563
1624
 
1625
+ /**
1626
+ * Read every rule path the root instruction file's rule map registers.
1627
+ *
1628
+ * @param content - The raw root instruction text.
1629
+ * @returns Each backticked first cell beneath the rule map heading, in table order.
1630
+ */
1631
+ export function readPolicyRuleMap(content: string): readonly string[] {
1632
+ const lines = content.replaceAll('\r\n', '\n').split('\n')
1633
+ const heading = lines.indexOf(POLICY_RULE_MAP_HEADING)
1634
+ if (heading === -1) return []
1635
+ const paths: string[] = []
1636
+ for (let index = heading + 1; index < lines.length; index += 1) {
1637
+ const line = lines[index]
1638
+ if (line === undefined || line.startsWith('## ')) break
1639
+ const cell = line.match(/^\|\s*`([^`]+)`\s*\|/u)?.[1]
1640
+ if (cell !== undefined) paths.push(normalizePolicyPath(cell))
1641
+ }
1642
+ return paths
1643
+ }
1644
+
1645
+ /**
1646
+ * Inspect the discovered rule family against the root instruction file's rule map.
1647
+ *
1648
+ * A workspace with no rule file has no rule map to keep, so the population is empty there.
1649
+ *
1650
+ * @param root - The workspace root to inspect.
1651
+ * @returns Every unregistered rule file, then every row resolving to no file.
1652
+ */
1653
+ export function inspectPolicyRuleMap(root: string): readonly PolicyViolation[] {
1654
+ const directory = resolvePolicyDirectory(root, POLICY_RULE_ROOT)
1655
+ if (directory === undefined) return []
1656
+ const rules = readdirSync(directory, { withFileTypes: true })
1657
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
1658
+ .map((entry) => `${POLICY_RULE_ROOT}/${entry.name}`)
1659
+ .sort()
1660
+ if (rules.length === 0) return []
1661
+ const content = isPolicyFile(root, POLICY_RULE_MAP_FILE)
1662
+ ? readFileSync(join(root, POLICY_RULE_MAP_FILE), 'utf8')
1663
+ : ''
1664
+ const registered = new Set(readPolicyRuleMap(content))
1665
+ const violations: PolicyViolation[] = []
1666
+ for (const rule of rules) {
1667
+ if (!registered.has(rule)) {
1668
+ violations.push(createPolicyViolation('rules', rule, 'the rule map names every rule file'))
1669
+ }
1670
+ }
1671
+ for (const path of registered) {
1672
+ if (!isPolicyFile(root, path)) {
1673
+ violations.push(
1674
+ createPolicyViolation('rules', path, 'every rule-map row resolves to a rule file'),
1675
+ )
1676
+ }
1677
+ }
1678
+ return violations
1679
+ }
1680
+
1681
+ /**
1682
+ * Inspect an explicit path population for a name a Windows checkout cannot hold.
1683
+ *
1684
+ * Each path is read through its own final segment, because the population lists every directory as
1685
+ * its own entry. A Windows host refuses the reserved characters and folds a case collision into one
1686
+ * file, so those two boundaries are proven from a path population rather than from written files.
1687
+ *
1688
+ * @param paths - The workspace-relative paths to inspect.
1689
+ * @returns Every unusable-name and case-collision violation in path order.
1690
+ */
1691
+ export function inspectPolicyFilenamePaths(paths: readonly string[]): readonly PolicyViolation[] {
1692
+ const violations: PolicyViolation[] = []
1693
+ const folded = new Map<string, string>()
1694
+ for (const candidate of paths) {
1695
+ const path = normalizePolicyPath(candidate)
1696
+ const segment = basename(path)
1697
+ const [stem = ''] = segment.split('.')
1698
+ if (POLICY_RESERVED_NAMES.includes(stem.toLowerCase())) {
1699
+ violations.push(
1700
+ createPolicyViolation(
1701
+ 'portability',
1702
+ path,
1703
+ 'path segments avoid the names Windows reserves',
1704
+ ),
1705
+ )
1706
+ }
1707
+ if (POLICY_RESERVED_PATTERN.test(segment)) {
1708
+ violations.push(
1709
+ createPolicyViolation(
1710
+ 'portability',
1711
+ path,
1712
+ 'path segments avoid the characters Windows refuses',
1713
+ ),
1714
+ )
1715
+ }
1716
+ if (segment.endsWith('.') || segment.endsWith(' ')) {
1717
+ violations.push(
1718
+ createPolicyViolation(
1719
+ 'portability',
1720
+ path,
1721
+ 'path segments end with neither a dot nor a space',
1722
+ ),
1723
+ )
1724
+ }
1725
+ const key = path.toLowerCase()
1726
+ const previous = folded.get(key)
1727
+ if (previous === undefined) {
1728
+ folded.set(key, path)
1729
+ } else if (previous !== path) {
1730
+ violations.push(
1731
+ createPolicyViolation('portability', path, `path differs from ${previous} by case alone`),
1732
+ )
1733
+ }
1734
+ }
1735
+ return violations
1736
+ }
1737
+
1738
+ /**
1739
+ * Read the workspace-authored path population, directories included.
1740
+ *
1741
+ * @param root - The workspace root to read.
1742
+ * @returns Every authored path, sorted by path.
1743
+ */
1744
+ export function readPolicyPaths(root: string): readonly string[] {
1745
+ return globSync(POLICY_PORTABILITY_GLOB, { cwd: root }).map(normalizePolicyPath).sort()
1746
+ }
1747
+
1748
+ /**
1749
+ * Inspect the workspace-authored path population for a name a Windows checkout cannot hold.
1750
+ *
1751
+ * @param root - The workspace root to inspect.
1752
+ * @returns Every unusable-name and case-collision violation in path order.
1753
+ */
1754
+ export function inspectPolicyFilenames(root: string): readonly PolicyViolation[] {
1755
+ return inspectPolicyFilenamePaths(readPolicyPaths(root))
1756
+ }
1757
+
1758
+ /**
1759
+ * Parse the manifest's script record without interpreting any other manifest field.
1760
+ *
1761
+ * @param content - The raw package.json text.
1762
+ * @returns Each script name and its command, in manifest order.
1763
+ */
1764
+ export function parsePolicyScripts(content: string): ReadonlyMap<string, string> {
1765
+ const scripts = new Map<string, string>()
1766
+ let manifest: unknown
1767
+ try {
1768
+ manifest = JSON.parse(content)
1769
+ } catch {
1770
+ return scripts
1771
+ }
1772
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest)) return scripts
1773
+ const record: unknown = Object.getOwnPropertyDescriptor(manifest, 'scripts')?.value
1774
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) return scripts
1775
+ for (const name of Object.getOwnPropertyNames(record)) {
1776
+ const command: unknown = Object.getOwnPropertyDescriptor(record, name)?.value
1777
+ if (typeof command === 'string') scripts.set(name, command)
1778
+ }
1779
+ return scripts
1780
+ }
1781
+
1782
+ /**
1783
+ * Inspect every manifest script for a shell file no Windows host runs.
1784
+ *
1785
+ * @param root - The workspace root to inspect.
1786
+ * @returns Every shell-script violation in manifest order.
1787
+ */
1788
+ export function inspectPolicyScripts(root: string): readonly PolicyViolation[] {
1789
+ if (!isPolicyFile(root, POLICY_MANIFEST_FILE)) return []
1790
+ const content = readFileSync(join(root, POLICY_MANIFEST_FILE), 'utf8')
1791
+ const violations: PolicyViolation[] = []
1792
+ for (const [name, command] of parsePolicyScripts(content)) {
1793
+ if (POLICY_SHELL_PATTERN.test(command)) {
1794
+ violations.push(
1795
+ createPolicyViolation(
1796
+ 'portability',
1797
+ POLICY_MANIFEST_FILE,
1798
+ `manifest scripts name no .sh file: ${name}`,
1799
+ ),
1800
+ )
1801
+ }
1802
+ }
1803
+ return violations
1804
+ }
1805
+
1806
+ /**
1807
+ * Whether a call trims a whole payload before splitting it on a line feed.
1808
+ *
1809
+ * @param node - The syntax node to inspect.
1810
+ * @returns True when the node is the split chain that leaves a carriage return on each line but
1811
+ * the last; false otherwise.
1812
+ */
1813
+ export function matchesPolicySplit(node: ts.Node): boolean {
1814
+ if (!ts.isCallExpression(node) || node.arguments.length !== 1) return false
1815
+ const split = node.expression
1816
+ if (!ts.isPropertyAccessExpression(split) || split.name.text !== 'split') return false
1817
+ const argument = node.arguments[0]
1818
+ if (argument === undefined) return false
1819
+ if (!ts.isStringLiteral(argument) && !ts.isNoSubstitutionTemplateLiteral(argument)) return false
1820
+ if (argument.text !== '\n') return false
1821
+ const trim = split.expression
1822
+ return (
1823
+ ts.isCallExpression(trim) &&
1824
+ trim.arguments.length === 0 &&
1825
+ ts.isPropertyAccessExpression(trim.expression) &&
1826
+ trim.expression.name.text === 'trim'
1827
+ )
1828
+ }
1829
+
1830
+ /**
1831
+ * Whether an expression reads the host line ending from a binding named os.
1832
+ *
1833
+ * @param node - The syntax node to inspect.
1834
+ * @returns True for an EOL member read on a binding named os; false otherwise.
1835
+ */
1836
+ export function matchesPolicyTerminator(node: ts.Node): boolean {
1837
+ return (
1838
+ ts.isPropertyAccessExpression(node) &&
1839
+ node.name.text === 'EOL' &&
1840
+ ts.isIdentifier(node.expression) &&
1841
+ node.expression.text === 'os'
1842
+ )
1843
+ }
1844
+
1845
+ /**
1846
+ * Whether an import declaration takes the EOL member from the host module.
1847
+ *
1848
+ * @param node - The syntax node to inspect.
1849
+ * @returns True for a named EOL import from node:os or os; false otherwise.
1850
+ */
1851
+ export function importsPolicyTerminator(node: ts.Node): boolean {
1852
+ if (!ts.isImportDeclaration(node)) return false
1853
+ const specifier = node.moduleSpecifier
1854
+ if (!ts.isStringLiteral(specifier)) return false
1855
+ if (specifier.text !== 'node:os' && specifier.text !== 'os') return false
1856
+ const bindings = node.importClause?.namedBindings
1857
+ if (bindings === undefined || !ts.isNamedImports(bindings)) return false
1858
+ return bindings.elements.some((element) => (element.propertyName ?? element.name).text === 'EOL')
1859
+ }
1860
+
1861
+ /**
1862
+ * Inspect one syntax node and its descendants for host-specific line-ending handling.
1863
+ *
1864
+ * @param path - The workspace-relative source path.
1865
+ * @param node - The syntax node to inspect.
1866
+ * @returns Every line-ending violation in source order.
1867
+ */
1868
+ export function inspectPolicyEndingNode(path: string, node: ts.Node): readonly PolicyViolation[] {
1869
+ const violations: PolicyViolation[] = []
1870
+ if (matchesPolicySplit(node)) {
1871
+ violations.push(
1872
+ createPolicyViolation(
1873
+ 'portability',
1874
+ path,
1875
+ 'sources split arrived text before trimming each line',
1876
+ node,
1877
+ ),
1878
+ )
1879
+ }
1880
+ if (matchesPolicyTerminator(node) || importsPolicyTerminator(node)) {
1881
+ violations.push(
1882
+ createPolicyViolation(
1883
+ 'portability',
1884
+ path,
1885
+ 'sources emit a line feed rather than the host line ending',
1886
+ node,
1887
+ ),
1888
+ )
1889
+ }
1890
+ ts.forEachChild(node, (child) => {
1891
+ violations.push(...inspectPolicyEndingNode(path, child))
1892
+ })
1893
+ return violations
1894
+ }
1895
+
1896
+ /**
1897
+ * Inspect one parsed source for host-specific line-ending handling.
1898
+ *
1899
+ * @param source - The path and TypeScript text to inspect.
1900
+ * @returns Every line-ending violation in source order.
1901
+ */
1902
+ export function inspectPolicyEndingSource(source: PolicySource): readonly PolicyViolation[] {
1903
+ const path = normalizePolicyPath(source.path)
1904
+ const syntax = ts.createSourceFile(path, source.content, ts.ScriptTarget.Latest, true)
1905
+ return inspectPolicyEndingNode(path, syntax)
1906
+ }
1907
+
1908
+ /**
1909
+ * Inspect every source axis the portability population covers for line-ending handling.
1910
+ *
1911
+ * @param root - The workspace root to inspect.
1912
+ * @returns Every line-ending violation in path and source order.
1913
+ */
1914
+ export function inspectPolicyEndings(root: string): readonly PolicyViolation[] {
1915
+ const violations: PolicyViolation[] = []
1916
+ for (const source of readPolicySources(root, POLICY_PORTABILITY_SOURCE_GLOB)) {
1917
+ violations.push(...inspectPolicyEndingSource(source))
1918
+ }
1919
+ return violations
1920
+ }
1921
+
1922
+ /**
1923
+ * Inspect every host portability rule across one workspace.
1924
+ *
1925
+ * @param root - The workspace root to inspect.
1926
+ * @returns Every rule-map, filename, manifest-script, and line-ending violation.
1927
+ */
1928
+ export function inspectPolicyPortability(root: string): readonly PolicyViolation[] {
1929
+ return [
1930
+ ...inspectPolicyRuleMap(root),
1931
+ ...inspectPolicyFilenames(root),
1932
+ ...inspectPolicyScripts(root),
1933
+ ...inspectPolicyEndings(root),
1934
+ ]
1935
+ }
1936
+
1564
1937
  /**
1565
1938
  * Inspect every policy rule across one workspace.
1566
1939
  *
1567
1940
  * @param root - The workspace root to inspect.
1568
- * @returns Every source, mirror, suppression, skill, and bridge violation.
1941
+ * @returns Every source, mirror, suppression, skill, bridge, and portability violation.
1569
1942
  */
1570
1943
  export function inspectPolicyWorkspace(root: string): readonly PolicyViolation[] {
1571
1944
  return [
@@ -1574,6 +1947,7 @@ export function inspectPolicyWorkspace(root: string): readonly PolicyViolation[]
1574
1947
  ...inspectPolicySuppressions(root),
1575
1948
  ...inspectSkillFamily(root),
1576
1949
  ...inspectSkillBridges(root),
1950
+ ...inspectPolicyPortability(root),
1577
1951
  ]
1578
1952
  }
1579
1953
 
@@ -2339,6 +2713,168 @@ export const SKILL_POLICY_EXCLUSION: PolicyControl = Object.freeze({
2339
2713
  files: [{ path: '.claude/skills/bridge/SKILL.md', content: SKILL_POLICY_TEXT }],
2340
2714
  })
2341
2715
 
2716
+ /**
2717
+ * Create root instruction text whose rule map names an explicit rule set.
2718
+ *
2719
+ * @param rules - The workspace-relative rule paths the map registers.
2720
+ * @returns Root instruction text carrying one rule map table.
2721
+ */
2722
+ export function createPolicyRuleMap(rules: readonly string[]): string {
2723
+ return (
2724
+ [
2725
+ '# Fixture instructions',
2726
+ '',
2727
+ POLICY_RULE_MAP_HEADING,
2728
+ '',
2729
+ '| Rule | Governs |',
2730
+ '| ---- | ------- |',
2731
+ ...rules.map((rule) => `| \`${rule}\` | Fixture rows |`),
2732
+ ].join('\n') + '\n'
2733
+ )
2734
+ }
2735
+
2736
+ /** Physical controls for every rule-map parity assertion the workspace route reaches. */
2737
+ export const RULES_POLICY_CONTROLS: readonly PolicyControl[] = Object.freeze([
2738
+ {
2739
+ label: 'rejects a rule file the rule map omits',
2740
+ membership: 'Markdown files directly beneath .claude/rules',
2741
+ rule: 'rules',
2742
+ message: 'the rule map names every rule file',
2743
+ files: [
2744
+ { path: POLICY_RULE_MAP_FILE, content: createPolicyRuleMap([]) },
2745
+ { path: `${POLICY_RULE_ROOT}/sample.md`, content: '# Sample\n' },
2746
+ ],
2747
+ },
2748
+ {
2749
+ label: 'rejects a rule-map row that resolves to nothing',
2750
+ membership: 'backticked first cells in the rule map table',
2751
+ rule: 'rules',
2752
+ message: 'every rule-map row resolves to a rule file',
2753
+ files: [
2754
+ {
2755
+ path: POLICY_RULE_MAP_FILE,
2756
+ content: createPolicyRuleMap([
2757
+ `${POLICY_RULE_ROOT}/sample.md`,
2758
+ `${POLICY_RULE_ROOT}/missing.md`,
2759
+ ]),
2760
+ },
2761
+ { path: `${POLICY_RULE_ROOT}/sample.md`, content: '# Sample\n' },
2762
+ ],
2763
+ },
2764
+ ])
2765
+
2766
+ /** Physical controls for every portability assertion the workspace route reaches. */
2767
+ export const PORTABILITY_POLICY_CONTROLS: readonly PolicyControl[] = Object.freeze([
2768
+ {
2769
+ label: 'rejects a reserved device name',
2770
+ membership: 'path segments in the workspace-authored path population',
2771
+ rule: 'portability',
2772
+ message: 'path segments avoid the names Windows reserves',
2773
+ files: [{ path: 'src/worker/con.ts', content: '' }],
2774
+ },
2775
+ {
2776
+ label: 'rejects a segment that ends with a dot',
2777
+ membership: 'path segments in the workspace-authored path population',
2778
+ rule: 'portability',
2779
+ message: 'path segments end with neither a dot nor a space',
2780
+ files: [{ path: 'src/worker/helpers.ts.', content: '' }],
2781
+ },
2782
+ {
2783
+ label: 'rejects a segment that ends with a space',
2784
+ membership: 'path segments in the workspace-authored path population',
2785
+ rule: 'portability',
2786
+ message: 'path segments end with neither a dot nor a space',
2787
+ files: [{ path: 'src/worker/helpers.ts ', content: '' }],
2788
+ },
2789
+ {
2790
+ label: 'rejects a shell script named by a manifest script',
2791
+ membership: 'string values beneath the manifest scripts record',
2792
+ rule: 'portability',
2793
+ message: 'manifest scripts name no .sh file: prepare',
2794
+ files: [
2795
+ {
2796
+ path: POLICY_MANIFEST_FILE,
2797
+ content: '{\n\t"scripts": {\n\t\t"prepare": "bash scripts/prepare.sh"\n\t}\n}\n',
2798
+ },
2799
+ ],
2800
+ },
2801
+ {
2802
+ label: 'rejects a payload trimmed before it is split',
2803
+ membership: 'split calls carrying a line-feed string literal in the parsed source axes',
2804
+ rule: 'portability',
2805
+ message: 'sources split arrived text before trimming each line',
2806
+ files: [
2807
+ {
2808
+ path: 'src/worker/helpers.ts',
2809
+ content:
2810
+ "export function readLines(text: string): readonly string[] {\n\treturn text.trim().split('\\n')\n}\n",
2811
+ },
2812
+ ],
2813
+ },
2814
+ {
2815
+ label: 'rejects a read of the host line ending',
2816
+ membership: 'EOL member reads on a binding named os in the parsed source axes',
2817
+ rule: 'portability',
2818
+ message: 'sources emit a line feed rather than the host line ending',
2819
+ files: [
2820
+ {
2821
+ path: 'configs/helpers.ts',
2822
+ content:
2823
+ "import * as os from 'node:os'\nexport function endLine(): string {\n\treturn os.EOL\n}\n",
2824
+ },
2825
+ ],
2826
+ },
2827
+ {
2828
+ label: 'rejects an EOL import from node:os',
2829
+ membership: 'named import specifiers from node:os in the parsed source axes',
2830
+ rule: 'portability',
2831
+ message: 'sources emit a line feed rather than the host line ending',
2832
+ files: [
2833
+ {
2834
+ path: 'configs/helpers.ts',
2835
+ content:
2836
+ "import { EOL } from 'node:os'\nexport function endLine(): string {\n\treturn EOL\n}\n",
2837
+ },
2838
+ ],
2839
+ },
2840
+ ])
2841
+
2842
+ /** A trimmed split outside the parsed source axes, proving the population boundary. */
2843
+ export const PORTABILITY_POLICY_EXCLUSION: PolicyControl = Object.freeze({
2844
+ label: 'excludes a script module from the parsed source axes',
2845
+ membership: 'TypeScript modules outside the src, app, and configs axes',
2846
+ rule: 'portability',
2847
+ files: [
2848
+ {
2849
+ path: 'scripts/read.ts',
2850
+ content:
2851
+ "export function readLines(text: string): readonly string[] {\n\treturn text.trim().split('\\n')\n}\n",
2852
+ },
2853
+ ],
2854
+ })
2855
+
2856
+ /** A locally declared line-ending constant, which the host line-ending rule leaves legal. */
2857
+ export const PORTABILITY_POLICY_LOCAL: PolicyControl = Object.freeze({
2858
+ label: 'accepts a locally declared EOL constant',
2859
+ membership: 'EOL identifiers that neither read a binding named os nor import from node:os',
2860
+ rule: 'portability',
2861
+ files: [{ path: 'src/worker/constants.ts', content: "export const EOL = '\\n'\n" }],
2862
+ })
2863
+
2864
+ /** A split on the host-independent line-ending pattern, which the split rule leaves legal. */
2865
+ export const PORTABILITY_POLICY_SPLIT: PolicyControl = Object.freeze({
2866
+ label: 'accepts a split on the line-ending pattern',
2867
+ membership: 'split calls whose argument is not a line-feed string literal',
2868
+ rule: 'portability',
2869
+ files: [
2870
+ {
2871
+ path: 'src/worker/helpers.ts',
2872
+ content:
2873
+ 'export function readLines(text: string): readonly string[] {\n\treturn text.trim().split(/\\r\\n|\\n/u)\n}\n',
2874
+ },
2875
+ ],
2876
+ })
2877
+
2342
2878
  /** A differently shaped workspace with app, browser, and worker environments but no core. */
2343
2879
  export const GENERIC_POLICY_SOURCES: readonly PolicySource[] = Object.freeze([
2344
2880
  {
@@ -4,7 +4,7 @@ let _orkestrel_template = require("@orkestrel/template");
4
4
  let _orkestrel_emitter = require("@orkestrel/emitter");
5
5
  var package_default = {
6
6
  name: "@orkestrel/scaffold",
7
- version: "0.0.53",
7
+ version: "0.0.55",
8
8
  description: "Scaffold workspaces with five commands: new, audit, repair, catalog, and overwrite.",
9
9
  keywords: [
10
10
  "audit",
@@ -78,7 +78,7 @@ var package_default = {
78
78
  "test:distribution": "vitest run --config vite.config.ts --no-cache --reporter=dot --project distribution",
79
79
  "test:probe": "vitest run --config vite.config.ts --no-cache --reporter=verbose --project probe",
80
80
  "test:bench": "vitest bench --config vite.config.ts --no-cache --project probe",
81
- "build": "npm run clean && npm run build:src && npm run build:host",
81
+ "build": "npm run clean && npm run build:src && npm run build:host && npm run build:inventory",
82
82
  "build:src": "npm run build:src:core && npm run build:src:server && npm run build:src:bin",
83
83
  "build:src:core": "vite build --config configs/src/vite.core.config.ts && npm run copy dist/src/core/index.d.ts dist/src/core/index.d.cts",
84
84
  "build:src:server": "vite build --config configs/src/vite.server.config.ts && npm run copy dist/src/server/index.d.ts dist/src/server/index.d.cts",
@@ -89,23 +89,23 @@ var package_default = {
89
89
  "prepublishOnly": "npm run format:check && npm run lint:check && npm run check && npm run build && npm test && npm run test:distribution -- --mode release"
90
90
  },
91
91
  dependencies: {
92
- "@orkestrel/console": "^0.0.10",
92
+ "@orkestrel/console": "^0.0.11",
93
93
  "@orkestrel/contract": "^0.0.13",
94
94
  "@orkestrel/emitter": "^0.0.8",
95
- "@orkestrel/markdown": "^0.0.11",
95
+ "@orkestrel/markdown": "^0.0.12",
96
96
  "@orkestrel/process": "^0.0.6",
97
97
  "@orkestrel/template": "^0.0.5"
98
98
  },
99
99
  devDependencies: {
100
100
  "@microsoft/api-extractor": "^7.59.0",
101
- "@orkestrel/guide": "^0.0.13",
102
- "@orkestrel/html": "^0.0.6",
103
- "@orkestrel/probe": "^0.0.6",
101
+ "@orkestrel/guide": "^0.0.14",
102
+ "@orkestrel/html": "^0.0.7",
103
+ "@orkestrel/probe": "^0.0.9",
104
104
  "@orkestrel/test": "^0.0.11",
105
- "@types/node": "^26.2.0",
105
+ "@types/node": "^26.4.0",
106
106
  "@vitest/browser-playwright": "^4.1.11",
107
- "oxfmt": "^0.64.0",
108
- "oxlint": "^1.79.0",
107
+ "oxfmt": "^0.65.0",
108
+ "oxlint": "^1.80.0",
109
109
  "playwright": "^1.62.1",
110
110
  "typescript": "^6.0.3",
111
111
  "vite": "^8.2.2",